fix: launch scheduled tasks through desktop sidecar

Scheduled task Run Now was still constructing a source checkout Bun command, so packaged desktop builds could try to execute src/entrypoints/cli.tsx from a missing or wrong root. Resolve the same launcher used by the desktop runtime first, and keep the source checkout fallback only for development.

Constraint: Desktop Release tasks must run through the configured sidecar and app root instead of assuming source files exist on disk

Rejected: Retain import.meta.dir path math with broader fallback | still bypasses packaged desktop launcher and repeats the issue root cause

Confidence: high

Scope-risk: narrow

Directive: Do not change scheduled task execution back to direct source CLI invocation without repeating desktop Run Now E2E

Tested: bun test src/server/__tests__/cron-scheduler-launcher.test.ts src/server/__tests__/cron-scheduler.test.ts

Tested: bun run check:server

Tested: bun run check:native

Tested: Desktop UI E2E via scheduled task page create task and Run Now with fake sidecar

Not-tested: Full packaged macOS .app click test
This commit is contained in:
程序员阿江(Relakkes) 2026-05-07 18:32:38 +08:00
parent 8b07c16f45
commit 165d914d5a
2 changed files with 302 additions and 18 deletions

View File

@ -0,0 +1,201 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import {
buildCronCliArgs,
CronScheduler,
resolveCronProjectRoot,
} from '../services/cronScheduler.js'
import { CronService } from '../services/cronService.js'
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalPath = process.env.PATH
const originalClaudeCliPath = process.env.CLAUDE_CLI_PATH
const originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT
const isWindows = process.platform === 'win32'
const unixOnly = isWindows ? it.skip : it
async function createTmpDir(): Promise<string> {
const dir = path.join(
os.tmpdir(),
`claude-cron-launcher-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
)
await fs.mkdir(dir, { recursive: true })
return dir
}
async function cleanupTmpDir(dir: string): Promise<void> {
await fs.rm(dir, { recursive: true, force: true }).catch(() => {})
}
async function createSourceRoot(root: string): Promise<void> {
await fs.mkdir(path.join(root, 'src', 'entrypoints'), { recursive: true })
await fs.writeFile(path.join(root, 'preload.ts'), '', 'utf-8')
await fs.writeFile(
path.join(root, 'src', 'entrypoints', 'cli.tsx'),
'',
'utf-8',
)
}
function restoreEnv(): void {
if (originalConfigDir) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
if (originalPath) {
process.env.PATH = originalPath
} else {
delete process.env.PATH
}
if (originalClaudeCliPath) {
process.env.CLAUDE_CLI_PATH = originalClaudeCliPath
} else {
delete process.env.CLAUDE_CLI_PATH
}
if (originalClaudeAppRoot) {
process.env.CLAUDE_APP_ROOT = originalClaudeAppRoot
} else {
delete process.env.CLAUDE_APP_ROOT
}
}
describe('cron scheduler launcher resolution', () => {
let tmpDir: string
beforeEach(async () => {
tmpDir = await createTmpDir()
process.env.CLAUDE_CONFIG_DIR = path.join(tmpDir, 'config')
})
afterEach(async () => {
restoreEnv()
await cleanupTmpDir(tmpDir)
})
it('uses the bundled sidecar launcher when one is configured', () => {
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
const appRoot = path.join(tmpDir, 'app-root')
const args = buildCronCliArgs(['--print'], {
cliPath: sidecarPath,
appRoot,
execPath: path.join(tmpDir, 'bun'),
cwd: path.join(tmpDir, 'missing-cwd'),
moduleDir: path.join(tmpDir, 'missing-module'),
env: {},
})
expect(args).toEqual([
sidecarPath,
'cli',
'--app-root',
appRoot,
'--print',
])
})
it('prefers an explicit CC_HAHA_ROOT when it points at a source checkout', async () => {
const sourceRoot = path.join(tmpDir, 'source')
await createSourceRoot(sourceRoot)
expect(
resolveCronProjectRoot({
cwd: path.join(tmpDir, 'other'),
moduleDir: path.join(tmpDir, 'broken', 'src', 'server', 'services'),
env: { CC_HAHA_ROOT: sourceRoot },
}),
).toBe(sourceRoot)
})
it('falls back to the nearest source checkout from cwd before module dir', async () => {
const sourceRoot = path.join(tmpDir, 'source')
const nestedCwd = path.join(sourceRoot, 'nested', 'workdir')
await createSourceRoot(sourceRoot)
await fs.mkdir(nestedCwd, { recursive: true })
expect(
resolveCronProjectRoot({
cwd: nestedCwd,
moduleDir: path.join(tmpDir, 'wrong', 'src', 'server', 'services'),
env: {},
}),
).toBe(sourceRoot)
})
unixOnly('executeTask launches the configured desktop sidecar instead of source bun', async () => {
const binDir = path.join(tmpDir, 'bin')
const appRoot = path.join(tmpDir, 'app-root')
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
const sidecarArgsPath = path.join(tmpDir, 'sidecar.args')
const bunArgsPath = path.join(tmpDir, 'bun.args')
await fs.mkdir(binDir, { recursive: true })
await fs.mkdir(appRoot, { recursive: true })
await fs.writeFile(
path.join(binDir, 'bun'),
[
'#!/bin/sh',
`printf '%s\\n' "$@" > "${bunArgsPath}"`,
'echo "error: Module not found \\"B:\\\\src\\\\entrypoints\\\\cli.tsx\\"" >&2',
'exit 1',
'',
].join('\n'),
'utf-8',
)
await fs.chmod(path.join(binDir, 'bun'), 0o755)
await fs.writeFile(
sidecarPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" > "${sidecarArgsPath}"`,
'/bin/cat >/dev/null',
'printf \'%s\\n\' \'{"type":"result","result":"sidecar ok"}\'',
'exit 0',
'',
].join('\n'),
'utf-8',
)
await fs.chmod(sidecarPath, 0o755)
process.env.PATH = binDir
process.env.CLAUDE_CLI_PATH = sidecarPath
process.env.CLAUDE_APP_ROOT = appRoot
const cronService = new CronService()
const scheduler = new CronScheduler(cronService)
const task = await cronService.createTask({
cron: '* * * * *',
prompt: 'cron sidecar test',
name: 'Sidecar Task',
recurring: true,
folderPath: tmpDir,
})
const run = await scheduler.executeTask(task)
expect(run.status).toBe('completed')
expect(run.output).toBe('sidecar ok')
const sidecarArgs = (await fs.readFile(sidecarArgsPath, 'utf-8'))
.trim()
.split('\n')
expect(sidecarArgs.slice(0, 4)).toEqual([
'cli',
'--app-root',
appRoot,
'--print',
])
expect(sidecarArgs).not.toContain(path.join('src', 'entrypoints', 'cli.tsx'))
const bunWasCalled = await fs
.stat(bunArgsPath)
.then(() => true)
.catch(() => false)
expect(bunWasCalled).toBe(false)
})
})

View File

@ -15,6 +15,10 @@ import * as crypto from 'crypto'
import { CronService, type CronTask } from './cronService.js'
import { SessionService } from './sessionService.js'
import { sendTaskNotification } from './notificationService.js'
import {
buildClaudeCliArgs,
resolveClaudeCliLauncher,
} from '../../utils/desktopBundledCli.js'
// ─── Types ─────────────────────────────────────────────────────────────────────
@ -242,6 +246,87 @@ function trimRuns(data: RunsFile): void {
const TASK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
type CronCliResolutionOptions = {
cliPath?: string | null
execPath?: string
appRoot?: string
cwd?: string
moduleDir?: string
env?: NodeJS.ProcessEnv
}
function isSourceProjectRoot(root: string): boolean {
return (
existsSync(path.join(root, 'preload.ts')) &&
existsSync(path.join(root, 'src', 'entrypoints', 'cli.tsx'))
)
}
function findSourceProjectRoot(startDir: string): string | null {
let current = path.resolve(startDir)
while (true) {
if (isSourceProjectRoot(current)) {
return current
}
const parent = path.dirname(current)
if (parent === current) {
return null
}
current = parent
}
}
export function resolveCronProjectRoot(
options: CronCliResolutionOptions = {},
): string {
const env = options.env ?? process.env
const explicitRoot = env.CC_HAHA_ROOT?.trim()
if (explicitRoot && isSourceProjectRoot(path.resolve(explicitRoot))) {
return path.resolve(explicitRoot)
}
const cwdRoot = findSourceProjectRoot(options.cwd ?? process.cwd())
if (cwdRoot) {
return cwdRoot
}
const moduleRoot = findSourceProjectRoot(options.moduleDir ?? import.meta.dir)
if (moduleRoot) {
return moduleRoot
}
return path.resolve(options.moduleDir ?? import.meta.dir, '../../..')
}
export function buildCronCliArgs(
baseArgs: string[],
options: CronCliResolutionOptions = {},
): string[] {
const launcher = resolveClaudeCliLauncher({
cliPath: options.cliPath ?? process.env.CLAUDE_CLI_PATH,
execPath: options.execPath ?? process.execPath,
})
if (launcher) {
return buildClaudeCliArgs(
launcher,
baseArgs,
options.appRoot ?? process.env.CLAUDE_APP_ROOT,
)
}
const projectRoot = resolveCronProjectRoot(options)
return [
'bun',
'--preload',
path.join(projectRoot, 'preload.ts'),
path.join(projectRoot, 'src', 'entrypoints', 'cli.tsx'),
...baseArgs,
]
}
export class CronScheduler {
private intervalId: Timer | null = null
private runningTasks = new Map<
@ -396,11 +481,6 @@ export class CronScheduler {
// Persist the "running" state
await appendRun(run)
// Resolve paths relative to project root
const projectRoot = path.resolve(import.meta.dir, '../../..')
const cliPath = path.join(projectRoot, 'src/entrypoints/cli.tsx')
const preloadPath = path.join(projectRoot, 'preload.ts')
const inputPayload = JSON.stringify({
type: 'user',
message: {
@ -411,25 +491,28 @@ export class CronScheduler {
session_id: sessionId || '',
}) + '\n'
const cliArgs = buildCronCliArgs([
'--print',
'--verbose',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
...(sessionId ? ['--session-id', sessionId] : []),
])
const proc = Bun.spawn(
[
'bun',
'--preload',
preloadPath,
cliPath,
'--print',
'--verbose',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
...(sessionId ? ['--session-id', sessionId] : []),
],
cliArgs,
{
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
cwd: workDir,
env: {
...process.env,
CALLER_DIR: workDir,
PWD: workDir,
},
},
)