Skip to content
Merged
761 changes: 761 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts

Large diffs are not rendered by default.

451 changes: 409 additions & 42 deletions packages/cli/src/acp-integration/session/Session.ts

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -2663,4 +2663,17 @@ export default {
'Failed to refresh extension content. Run /reload-plugins to apply updates.',
'Extension reload did not complete. Run /reload-plugins to try again.':
'Extension reload did not complete. Run /reload-plugins to try again.',
'Precondition check': 'Precondition check',
'Precondition not met — this scheduled run was skipped.':
'Precondition not met — this scheduled run was skipped.',
'The precondition check was cancelled — this scheduled run was skipped.':
'The precondition check was cancelled — this scheduled run was skipped.',
'The precondition check was interrupted — this scheduled run was skipped.':
'The precondition check was interrupted — this scheduled run was skipped.',
'The precondition check failed — this scheduled run was skipped.':
'The precondition check failed — this scheduled run was skipped.',
'Running this scheduled task in a new session: {{link}}':
'Running this scheduled task in a new session: {{link}}',
'This scheduled run could not be started: {{error}}':
'This scheduled run could not be started: {{error}}',
};
13 changes: 13 additions & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -2253,4 +2253,17 @@ export default {
'擴充內容重新整理失敗。執行 /reload-plugins 來套用更新。',
'Extension reload did not complete. Run /reload-plugins to try again.':
'擴充重新載入未完成。執行 /reload-plugins 重試。',
'Precondition check': '前置條件檢查',
'Precondition not met — this scheduled run was skipped.':
'前置條件不滿足 —— 已略過本次定時執行。',
'The precondition check was cancelled — this scheduled run was skipped.':
'前置條件檢查已取消 —— 已略過本次定時執行。',
'The precondition check was interrupted — this scheduled run was skipped.':
'前置條件檢查被中斷 —— 已略過本次定時執行。',
'The precondition check failed — this scheduled run was skipped.':
'前置條件檢查失敗 —— 已略過本次定時執行。',
'Running this scheduled task in a new session: {{link}}':
'正在新會話中執行該定時任務:{{link}}',
'This scheduled run could not be started: {{error}}':
'本次定時執行無法啟動:{{error}}',
};
13 changes: 13 additions & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -2455,4 +2455,17 @@ export default {
'扩展内容刷新失败。运行 /reload-plugins 来应用更新。',
'Extension reload did not complete. Run /reload-plugins to try again.':
'扩展重新加载未完成。运行 /reload-plugins 重试。',
'Precondition check': '前置条件检查',
'Precondition not met — this scheduled run was skipped.':
'前置条件不满足 —— 已跳过本次定时运行。',
'The precondition check was cancelled — this scheduled run was skipped.':
'前置条件检查已取消 —— 已跳过本次定时运行。',
'The precondition check was interrupted — this scheduled run was skipped.':
'前置条件检查被中断 —— 已跳过本次定时运行。',
'The precondition check failed — this scheduled run was skipped.':
'前置条件检查失败 —— 已跳过本次定时运行。',
'Running this scheduled task in a new session: {{link}}':
'正在新会话中运行该定时任务:{{link}}',
'This scheduled run could not be started: {{error}}':
'本次定时运行无法启动:{{error}}',
};
40 changes: 40 additions & 0 deletions packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3493,6 +3493,46 @@ describe('runNonInteractive', () => {
expect(predicate({ prompt: 'regular cron job' } as CronJob)).toBe(false);
});

it('skips a guarded scheduled task instead of firing it unconditionally', async () => {
// Only the ACP/daemon session evaluates a precondition. Running the prompt
// here would fire a guarded task with its guard ignored — the exact outcome
// the precondition exists to prevent. Fail closed, and still release the
// headless hold-open so the run does not hang.
setupMetricsMock();
const scheduler = new CronScheduler();
mockConfig.isCronEnabled = vi.fn().mockReturnValue(true);
mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler);
vi.spyOn(scheduler, 'start').mockImplementation((onFire) => {
onFire({
id: 'guarded-1',
prompt: 'nightly report',
condition: 'only when the release flag is set',
} as CronJob);
});
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents([
{ type: GeminiEventType.Content, value: 'ok' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
},
]),
);

await runNonInteractive(mockConfig, mockSettings, 'test', 'p-cron-guarded');

// The user's own prompt ran; the guarded cron prompt never reached the model.
const cronCalls = mockGeminiClient.sendMessageStream.mock.calls.filter(
(call) => call[3]?.type === SendMessageType.Cron,
);
expect(cronCalls).toHaveLength(0);
expect(
mockGeminiClient.sendMessageStream.mock.calls.some((call) =>
JSON.stringify(call[0]).includes('nightly report'),
),
).toBe(false);
});

describe('--json-schema structured output', () => {
// Helper: walk an emitted event and extract the first tool_use_id when
// it represents a tool_result block. Returns undefined for any other
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1826,6 +1826,23 @@ export async function runNonInteractive(
checkCronDone();
return;
}
// A guarded task's prompt must not run until its precondition
// has been evaluated, and only the ACP/daemon session can do
// that (it owns the sub-session dispatch the verdict gates).
// Running it here would fire it unconditionally — the exact
// outcome the precondition exists to prevent — so fail closed.
// Reachable only for a task whose `condition` was hand-written
// onto an unbound entry in the tasks file; every writer binds
// one, and a bound task fires only in its own session.
if (job.condition) {
debugLogger.warn(
'Skipping a guarded scheduled task: preconditions are not ' +
'evaluated in headless mode',
{ jobId: job.id },
);
checkCronDone();
return;
}
const label = job.prompt.slice(0, 40);
localQueue.push({
displayText: `${job.cronExpr === '@wakeup' ? 'Loop' : 'Cron'}: ${label}`,
Expand Down
254 changes: 254 additions & 0 deletions packages/cli/src/serve/routes/scheduled-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,97 @@ describe('scheduled-tasks routes', () => {
expect(res.body.code).toBe('invalid_run_mode');
});

it('creates a guarded isolated task, persisting the trimmed condition', async () => {
const res = await create({
cron: '0 9 * * *',
prompt: 'p',
runMode: 'isolated',
condition: ' anything new on main? ',
});
expect(res.status).toBe(201);
expect(res.body.condition).toBe('anything new on main?');
const list = await request(h.app).get('/scheduled-tasks');
expect(list.body.tasks[0].condition).toBe('anything new on main?');
});

it('reports no condition when one is omitted or blank', async () => {
const res = await create({
cron: '0 9 * * *',
prompt: 'p',
runMode: 'isolated',
condition: ' ',
});
expect(res.status).toBe(201);
// Whitespace-only trims to "no precondition" rather than persisting an
// empty string, which readCronTasks would reject on the next load.
expect(res.body.condition).toBeNull();
});

it('rejects a condition on a shared task, without minting a session', async () => {
const res = await create({
cron: '0 9 * * *',
prompt: 'p',
condition: 'anything new?',
});
expect(res.status).toBe(400);
expect(res.body.code).toBe('condition_requires_isolated');
// Validation must precede the spawn, or a rejected create leaves an
// orphaned "⏰ …" session behind with no owning task.
expect(h.bridge.spawned).toHaveLength(0);
});

it('rejects a condition when no bridge can bind the task a session', async () => {
// A minimal embedding has no session bridge, so POST creates the task
// UNBOUND and it fires through the shared durable owner. The check would
// then run in an unrelated session. Refuse rather than relocate it.
const app = express();
app.use(express.json());
registerScheduledTasksRoutes(app, {
boundWorkspace: h.workspace,
mutate: () => (_req, _res, next) => next(),
safeBody,
// no bridge
});
const res = await request(app).post('/scheduled-tasks').send({
cron: '0 9 * * *',
prompt: 'p',
runMode: 'isolated',
condition: 'anything new?',
});
expect(res.status).toBe(400);
expect(res.body.code).toBe('condition_requires_bound_session');

// An isolated task without a condition is still allowed (pre-existing
// behaviour: it dispatches from whichever session fires it).
const ok = await request(app)
.post('/scheduled-tasks')
.send({ cron: '0 9 * * *', prompt: 'p', runMode: 'isolated' });
expect(ok.status).toBe(201);
expect(ok.body.sessionId).toBeNull();
});

it('rejects an over-long condition on create', async () => {
const res = await create({
cron: '0 9 * * *',
prompt: 'p',
runMode: 'isolated',
condition: 'x'.repeat(10_001),
});
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_condition');
});

it('rejects a non-string condition on create', async () => {
const res = await create({
cron: '0 9 * * *',
prompt: 'p',
runMode: 'isolated',
condition: 42,
});
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_condition');
});

it('binds a created task to a freshly minted session', async () => {
const res = await create({ cron: '0 9 * * *', prompt: 'p' });
expect(res.status).toBe(201);
Expand Down Expand Up @@ -351,6 +442,169 @@ describe('scheduled-tasks routes', () => {
expect(bad.body.code).toBe('invalid_run_mode');
});

it('adds, replaces and clears a condition via PATCH', async () => {
const created = await create({
cron: '0 9 * * *',
prompt: 'p',
runMode: 'isolated',
});
const id = created.body.id as string;
expect(created.body.condition).toBeNull();

const added = await request(h.app)
.patch(`/scheduled-tasks/${id}`)
.send({ condition: 'anything new?' });
expect(added.status).toBe(200);
expect(added.body.condition).toBe('anything new?');

const replaced = await request(h.app)
.patch(`/scheduled-tasks/${id}`)
.send({ condition: 'anything new on main?' });
expect(replaced.status).toBe(200);
expect(replaced.body.condition).toBe('anything new on main?');

// `condition: null` clears it — the field is deleted, not stored as '',
// which readCronTasks would reject on the next load.
const cleared = await request(h.app)
.patch(`/scheduled-tasks/${id}`)
.send({ condition: null });
expect(cleared.status).toBe(200);
expect(cleared.body.condition).toBeNull();
const list = await request(h.app).get('/scheduled-tasks');
expect(list.body.tasks[0].condition).toBeNull();
});

it('accepts a lone `condition: null` as a real patch, not an empty one', async () => {
const created = await create({
cron: '0 9 * * *',
prompt: 'p',
runMode: 'isolated',
condition: 'anything new?',
});
const id = created.body.id as string;
const cleared = await request(h.app)
.patch(`/scheduled-tasks/${id}`)
.send({ condition: null });
expect(cleared.status).toBe(200);
expect(cleared.body.condition).toBeNull();
});

it('rejects a PATCH that would strand a condition on a shared task', async () => {
const guarded = await create({
cron: '0 9 * * *',
prompt: 'p',
runMode: 'isolated',
condition: 'anything new?',
});
const id = guarded.body.id as string;

// Switching to shared without dropping the condition would leave a task
// the user believes is guarded firing unconditionally. Reject, rather than
// silently discarding what they typed.
const stranded = await request(h.app)
.patch(`/scheduled-tasks/${id}`)
.send({ runMode: 'shared' });
expect(stranded.status).toBe(400);
expect(stranded.body.code).toBe('condition_requires_isolated');
// Nothing was written.
const list = await request(h.app).get('/scheduled-tasks');
expect(list.body.tasks[0].runMode).toBe('isolated');
expect(list.body.tasks[0].condition).toBe('anything new?');

// Sent together, the switch is accepted — this is what the Web Shell does.
const together = await request(h.app)
.patch(`/scheduled-tasks/${id}`)
.send({ runMode: 'shared', condition: null });
expect(together.status).toBe(200);
expect(together.body.runMode).toBe('shared');
expect(together.body.condition).toBeNull();
});

it('rejects adding a condition to a shared task via PATCH', async () => {
const created = await create({ cron: '0 9 * * *', prompt: 'p' });
const id = created.body.id as string;
const res = await request(h.app)
.patch(`/scheduled-tasks/${id}`)
.send({ condition: 'anything new?' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('condition_requires_isolated');
});

it('rejects a condition on an UNBOUND task via PATCH', async () => {
// `cron_create` mints unbound tasks; they fire through the shared
// per-project durable owner, so their check would land in whichever session
// holds that lock — someone else's conversation, not a decision log. PATCH
// is the only way such a task could acquire a condition.
const file = getCronFilePath(h.workspace);
await fsp.mkdir(path.dirname(file), { recursive: true });
await fsp.writeFile(
file,
JSON.stringify([
{
id: 'unbound',
cron: '0 9 * * *',
prompt: 'p',
recurring: true,
createdAt: 1_700_000_000_000,
lastFiredAt: null,
},
]),
'utf8',
);

const res = await request(h.app)
.patch('/scheduled-tasks/unbound')
.send({ runMode: 'isolated', condition: 'anything new?' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('condition_requires_bound_session');

// Nothing was written: the task keeps its unbound, unguarded shape.
const list = await request(h.app).get('/scheduled-tasks');
expect(list.body.tasks[0].sessionId).toBeNull();
expect(list.body.tasks[0].condition).toBeNull();
expect(list.body.tasks[0].runMode).toBe('shared');
});

it('still edits a hand-stranded task when the patch touches neither field', async () => {
// No writer produces `condition` on a shared task — only a hand-edited
// tasks file can. Such a task must stay editable: rejecting its
// enable/disable toggle would brick it behind an error about a field the
// request never mentioned.
const file = getCronFilePath(h.workspace);
await fsp.mkdir(path.dirname(file), { recursive: true });
await fsp.writeFile(
file,
JSON.stringify([
{
id: 'stranded',
cron: '0 9 * * *',
prompt: 'p',
recurring: true,
createdAt: 1_700_000_000_000,
lastFiredAt: null,
condition: 'anything new?', // no runMode → 'shared'
},
]),
'utf8',
);

const toggled = await request(h.app)
.patch('/scheduled-tasks/stranded')
.send({ enabled: false });
expect(toggled.status).toBe(200);
expect(toggled.body.enabled).toBe(false);
// The stranded condition is inert: the fire path only reads it on the
// isolated branch, and the view hides it.
expect(toggled.body.condition).toBeNull();

// But a patch that DOES touch the pairing is still rejected.
const guard = await request(h.app)
.patch('/scheduled-tasks/stranded')
.send({ runMode: 'shared' });
expect(guard.status).toBe(400);
expect(guard.body.code).toBe('condition_requires_isolated');
});

it('clears the name when patched to an empty string', async () => {
const created = await create({
name: 'Named',
Expand Down
Loading
Loading