Skip to content

Commit bf8b418

Browse files
committed
fix(serve): close session-binding races in scheduled-task create/reuse
R4-1: re-validate a caller-provided session under the cron write lock; archive/delete tears the session out of the live map before its cron hook runs, so a session that left the map between validation and commit is now rejected with 409 session_not_live instead of binding a 201-returned task to an archived/deleted session. R4-2: the in-lock duplicate-binding check now covers just-minted sessions too (boundSessionId, not only providedSessionId) and runs before the cap check; the alreadyBound branch no longer rolls the session back, since a committed owner task means a concurrent reuse-create won the race and owns the session. R4-3 (narrowed, not closed): DELETE re-reads the cron file right before closeSession and skips teardown when a surviving task references the session; the residual re-read-to-close window needs session-scoped serialization shared with the bind path (follow-up). R4-4: keepalive bind writes also bail when any committed task already references the just-minted session, mirroring the route's in-lock check. R4-5/R4-6: add the missing discriminating tests (mint-site naming, sessionOwnedByTask validation); both mutation-verified.
1 parent 713029b commit bf8b418

5 files changed

Lines changed: 293 additions & 18 deletions

File tree

packages/cli/src/serve/routes/scheduled-tasks.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,83 @@ describe('scheduled-tasks routes', () => {
861861
expect(h.bridge.closed).toEqual([]);
862862
});
863863

864+
it('rejects a reuse create whose session is archived between validation and commit', async () => {
865+
// The session passes the pre-lock validation (live, idle) but a
866+
// concurrent archive/delete removes it from the live map before the cron
867+
// write commits. The archive hook (disableTasksForSessions) only sees
868+
// tasks already on disk, so it no-ops for this task — the under-write-lock
869+
// re-validation is the only guard. Deleting it turns the 409 into a 201
870+
// bound to an archived session.
871+
h.bridge.liveSessions.set(CALLER_SESSION_ID, {
872+
sessionId: CALLER_SESSION_ID,
873+
workspaceCwd: h.workspace,
874+
hasActivePrompt: false,
875+
});
876+
let summaryCalls = 0;
877+
const originalGetSessionSummary = h.bridge.getSessionSummary.bind(h.bridge);
878+
h.bridge.getSessionSummary = (sessionId: string) => {
879+
summaryCalls += 1;
880+
if (summaryCalls > 1) {
881+
// Simulate the archive/delete landing after the first (pre-lock)
882+
// validation: archiving removes a session from the live map first.
883+
throw new SessionNotFoundError(sessionId);
884+
}
885+
return originalGetSessionSummary(sessionId);
886+
};
887+
const res = await create({
888+
cron: '0 9 * * *',
889+
prompt: 'p',
890+
sessionId: CALLER_SESSION_ID,
891+
});
892+
expect(res.status).toBe(409);
893+
expect(res.body.code).toBe('session_not_live');
894+
expect(await readCronTasks(h.workspace)).toEqual([]);
895+
// Caller-provided session — never torn down by this route.
896+
expect(h.bridge.closed).toEqual([]);
897+
expect(h.bridge.named).toEqual([]);
898+
});
899+
900+
it('does not double-bind a just-minted session a concurrent reuse-create committed', async () => {
901+
// Mint-vs-reuse race: a mint registers its session in the live map
902+
// (doSpawn) BEFORE its cron write commits, so a concurrent reuse-create
903+
// for that session passes every validation and commits first. The in-lock
904+
// duplicate check must cover minted sessions too — deleting the check
905+
// turns this create into a second 201 bound to the same session — and the
906+
// rejected create must NOT tear down the session the winner now owns.
907+
h.bridge.spawnOrAttach = async () => {
908+
const sessionId = 'sess-contested-mint';
909+
h.bridge.spawned.push(sessionId);
910+
// Simulate the concurrent reuse-create committing while this mint's
911+
// write is still pending.
912+
await updateCronTasks(h.workspace, (tasks) => [
913+
...tasks,
914+
{
915+
id: 'reuse-task',
916+
cron: '0 10 * * *',
917+
prompt: 'q',
918+
recurring: true,
919+
createdAt: 1_700_000_000_000,
920+
lastFiredAt: 1_700_000_000_000,
921+
enabled: true,
922+
sessionId,
923+
sessionOwnedByTask: false,
924+
},
925+
]);
926+
return { sessionId };
927+
};
928+
const res = await create({ cron: '0 9 * * *', prompt: 'p' });
929+
expect(res.status).toBe(409);
930+
expect(res.body.code).toBe('session_already_bound');
931+
// Exactly one task on disk — the reuse winner — still bound.
932+
const tasks = await readCronTasks(h.workspace);
933+
expect(tasks).toHaveLength(1);
934+
expect(tasks[0]?.id).toBe('reuse-task');
935+
expect(tasks[0]?.sessionId).toBe('sess-contested-mint');
936+
// The loser must not kill the session the winner committed to.
937+
expect(h.bridge.closed).toEqual([]);
938+
expect(h.cleanupSession).not.toHaveBeenCalled();
939+
});
940+
864941
it('rejects an invalid sessionId field with 400 invalid_session_id', async () => {
865942
for (const bad of [
866943
123,
@@ -1322,6 +1399,49 @@ describe('scheduled-tasks routes', () => {
13221399
expect(h.bridge.closed).toEqual(['sess-legacy']);
13231400
});
13241401

1402+
it("does not close a deleted task's session when another committed task references it", async () => {
1403+
// DELETE captures the bound session under the lock, but a concurrent
1404+
// reuse-create can commit a binding to it right after the removal lands
1405+
// (its in-lock duplicate check legitimately passes once the old task is
1406+
// gone). The pre-close re-read must notice the surviving reference and
1407+
// skip the teardown — closing on the stale capture would kill the
1408+
// surviving task's live session. Deleting the recheck puts 'sess-shared'
1409+
// back in `closed`.
1410+
await updateCronTasks(h.workspace, (tasks) => [
1411+
...tasks,
1412+
{
1413+
id: 'deleted-task',
1414+
cron: '0 9 * * *',
1415+
prompt: 'p',
1416+
recurring: true,
1417+
createdAt: 1_700_000_000_000,
1418+
lastFiredAt: 1_700_000_000_000,
1419+
enabled: true,
1420+
sessionId: 'sess-shared',
1421+
sessionOwnedByTask: true,
1422+
},
1423+
{
1424+
// The race winner: committed while DELETE's close was still pending.
1425+
id: 'surviving-task',
1426+
cron: '0 10 * * *',
1427+
prompt: 'q',
1428+
recurring: true,
1429+
createdAt: 1_700_000_000_000,
1430+
lastFiredAt: 1_700_000_000_000,
1431+
enabled: true,
1432+
sessionId: 'sess-shared',
1433+
sessionOwnedByTask: false,
1434+
},
1435+
]);
1436+
const del = await request(h.app).delete('/scheduled-tasks/deleted-task');
1437+
expect(del.status).toBe(200);
1438+
expect(del.body).toEqual({ deleted: true, id: 'deleted-task' });
1439+
expect(h.bridge.closed).toEqual([]); // surviving task keeps its session
1440+
const tasks = await readCronTasks(h.workspace);
1441+
expect(tasks).toHaveLength(1);
1442+
expect(tasks[0]?.id).toBe('surviving-task');
1443+
});
1444+
13251445
it('returns 500 (not 404) when the persisted-session probe hits a filesystem failure', async () => {
13261446
// The probe helpers rethrow non-ENOENT errors (EACCES/EIO/ESTALE). Such a
13271447
// failure is transient I/O, not "genuinely gone" — it must surface as a

packages/cli/src/serve/routes/scheduled-tasks.ts

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -875,30 +875,59 @@ function registerScheduledTaskCrudRoutes(
875875

876876
let overCap = false;
877877
let alreadyBound = false;
878+
let sessionGoneUnderLock = false;
878879
let rollbackBefore: DurableCronTask[] | undefined;
879880
let rollbackAfter: DurableCronTask[] | undefined;
880881
try {
881882
await runWithScheduledTaskTarget(target, () =>
882883
updateCronTasks(
883884
workspaceCwd,
884885
(tasks) => {
886+
// Same-lock duplicate-binding check in BOTH binding modes: the
887+
// pre-check read above is best-effort, and a concurrent create
888+
// may have bound the same session since. For a caller-provided
889+
// session that's another reuse-create; for a just-minted one
890+
// it's a reuse-create that committed while this request's mint
891+
// was still in flight (the mint registers the session in the
892+
// live map before THIS write commits, so the reuse path's
893+
// validation can pass against it). Runs before the cap check so
894+
// an over-cap loser never tears down a session another
895+
// committed task already references.
896+
if (
897+
boundSessionId !== undefined &&
898+
tasks.some((t) => t.sessionId === boundSessionId)
899+
) {
900+
alreadyBound = true;
901+
return tasks;
902+
}
903+
// Re-validate a caller-provided session UNDER the write lock:
904+
// archiving/deleting tears the session out of the live map
905+
// BEFORE its cron hook (disable/removeTasksForSessions) runs,
906+
// and that hook only sees tasks already on disk — so a session
907+
// that left the live map between the pre-lock validation and
908+
// this cycle is being archived/deleted and its hook skipped
909+
// this (not yet written) task. Committing anyway would bind a
910+
// 201-returned task to an archived or gone session. Cron write
911+
// cycles are serialized, so a hook that runs after THIS cycle
912+
// sees the new task and disables/removes it correctly.
913+
if (providedSessionId !== undefined && bridge) {
914+
try {
915+
bridge.getSessionSummary(providedSessionId);
916+
} catch (err) {
917+
if (err instanceof SessionNotFoundError) {
918+
sessionGoneUnderLock = true;
919+
return tasks; // no write
920+
}
921+
throw err;
922+
}
923+
}
885924
// Cap check under the write lock so two concurrent creates can't both
886925
// slip past a stale count. Returning the input unchanged is a no-op
887926
// (no write), which the flag below turns into a 409.
888927
if (tasks.length >= MAX_SCHEDULED_TASKS) {
889928
overCap = true;
890929
return tasks;
891930
}
892-
// Same-lock duplicate-binding check for a caller-provided
893-
// session: the pre-check read above is best-effort, and a
894-
// concurrent create may have bound the same session since.
895-
if (
896-
providedSessionId !== undefined &&
897-
tasks.some((t) => t.sessionId === providedSessionId)
898-
) {
899-
alreadyBound = true;
900-
return tasks;
901-
}
902931
rollbackBefore = tasks;
903932
rollbackAfter = [...tasks, task];
904933
return rollbackAfter;
@@ -942,8 +971,23 @@ function registerScheduledTaskCrudRoutes(
942971
});
943972
return;
944973
}
974+
if (sessionGoneUnderLock) {
975+
// Reuse mode only — a caller-provided session is never torn down
976+
// here, so there is nothing to roll back. Retryable: the session's
977+
// archive/delete completed between validation and commit.
978+
res.status(409).json({
979+
error:
980+
'The requested session was archived or deleted while the task was being created; retry with a live session',
981+
code: 'session_not_live',
982+
});
983+
return;
984+
}
945985
if (alreadyBound) {
946-
await rollbackSession();
986+
// NO rollbackSession here: the in-lock check fires only when a
987+
// COMMITTED task already references the bound session. For a
988+
// just-minted session that means a concurrent reuse-create won the
989+
// race and owns it — tearing it down would kill that task's session.
990+
// (For a caller-provided session rollbackSession is a no-op anyway.)
947991
res.status(409).json({
948992
error:
949993
'The requested session is already bound to another scheduled task',
@@ -1352,12 +1396,35 @@ function registerScheduledTaskCrudRoutes(
13521396
// task, may be the user's live working session, and must survive the
13531397
// task's deletion (same invariant the create path's rollback honors).
13541398
if (boundSessionId && sessionOwnedByTask && bridge) {
1399+
// Re-read just before teardown: between the removal commit above and
1400+
// this close, a concurrent reuse-create can bind THIS session (its
1401+
// in-lock duplicate check legitimately passes once the old task is
1402+
// gone) — from that task's perspective the session IS caller-provided
1403+
// and must survive. Closing on the stale capture would tear down the
1404+
// surviving task's live session mid-use. Best-effort: a rebind that
1405+
// commits between this re-read and the close still slips through;
1406+
// fully closing that window needs session-scoped serialization shared
1407+
// with the bind path (tracked as follow-up). A read failure falls
1408+
// back to the pre-recheck behavior (close).
1409+
let claimedBySurvivingTask = false;
13551410
try {
1356-
await runWithScheduledTaskTarget(target, () =>
1357-
bridge.closeSession(boundSessionId!),
1411+
const currentTasks = await runWithScheduledTaskTarget(target, () =>
1412+
readCronTasks(workspaceCwd),
13581413
);
1359-
} catch (error) {
1360-
if (sendActivityGateError(res, error)) return;
1414+
claimedBySurvivingTask = currentTasks.some(
1415+
(t) => t.sessionId === boundSessionId,
1416+
);
1417+
} catch {
1418+
// Read failure → keep the historical behavior (close the session).
1419+
}
1420+
if (!claimedBySurvivingTask) {
1421+
try {
1422+
await runWithScheduledTaskTarget(target, () =>
1423+
bridge.closeSession(boundSessionId!),
1424+
);
1425+
} catch (error) {
1426+
if (sendActivityGateError(res, error)) return;
1427+
}
13611428
}
13621429
}
13631430
if (boundSessionId) {

packages/cli/src/serve/scheduled-task-keepalive.test.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -661,8 +661,12 @@ describe('scheduled-task keepalive', () => {
661661
});
662662

663663
it('binds an unbound task to a dedicated session and writes sessionId to disk', async () => {
664+
// Named fixture + exact-name assertion discriminate the MINT-site naming
665+
// payload (`task.name ?? task.prompt`): mutating it to `task.prompt`
666+
// yields '⏰ check build' and fails this test. The already-bound rename
667+
// branch is covered separately below.
664668
await updateCronTasks(workspace, () => [
665-
task({ id: 'unbound-1', prompt: 'check build' }),
669+
task({ id: 'unbound-1', prompt: 'check build', name: 'Digest' }),
666670
]);
667671
const spawns: unknown[] = [];
668672
const names: Array<[string, { displayName?: string }]> = [];
@@ -693,7 +697,7 @@ describe('scheduled-task keepalive', () => {
693697
});
694698
expect(names).toHaveLength(1);
695699
expect(names[0]![0]).toBe('new-sess-1');
696-
expect(names[0]![1].displayName).toContain('⏰');
700+
expect(names[0]![1].displayName).toBe('⏰ Digest');
697701
const tasks = await readCronTasks(workspace);
698702
expect(tasks[0]!.sessionId).toBe('new-sess-1');
699703
// The keepalive minted this session, so the task records ownership —
@@ -883,6 +887,59 @@ describe('scheduled-task keepalive', () => {
883887
removeSpy.mockRestore();
884888
});
885889

890+
it('rolls back when the just-minted session is already committed to another task', async () => {
891+
// The scheduled-tasks reuse path can bind a session as soon as the spawn
892+
// registers it in the live map — BEFORE this bind write commits. The
893+
// in-lock check must notice the committed reference and leave the task
894+
// unbound (the orphan spawn rolls back), not double-bind the session.
895+
const closed: string[] = [];
896+
const removeSpy = vi
897+
.spyOn(SessionService.prototype, 'removeSession')
898+
.mockResolvedValue(true);
899+
const raceBridge = {
900+
...bridge,
901+
spawnOrAttach: async () => {
902+
// Simulate a concurrent caller-provided binding committing while our
903+
// spawn is in flight.
904+
await updateCronTasks(workspace, (list) => [
905+
...list,
906+
task({
907+
id: 'caller-task',
908+
sessionId: 'contested-sess',
909+
sessionOwnedByTask: false,
910+
}),
911+
]);
912+
return { sessionId: 'contested-sess' };
913+
},
914+
closeSession: async (id: string) => {
915+
closed.push(id);
916+
},
917+
markSessionCatalogChanged: vi.fn(),
918+
updateSessionMetadata: () => {},
919+
};
920+
await updateCronTasks(workspace, () => [
921+
task({ id: 'tool-task', prompt: 'contested' }),
922+
]);
923+
const ka = startScheduledTaskKeepalive({
924+
bridge: raceBridge,
925+
boundWorkspace: workspace,
926+
intervalMs: 60_000,
927+
});
928+
await ka.tick();
929+
ka.stop();
930+
// The orphaned mint is rolled back...
931+
expect(closed).toContain('contested-sess');
932+
expect(raceBridge.markSessionCatalogChanged).toHaveBeenCalledTimes(1);
933+
// ...and the session stays bound to exactly ONE task: the caller's.
934+
const tasks = await readCronTasks(workspace);
935+
expect(tasks).toHaveLength(2);
936+
const toolTask = tasks.find((t) => t.id === 'tool-task');
937+
const callerTask = tasks.find((t) => t.id === 'caller-task');
938+
expect(toolTask?.sessionId).toBeUndefined(); // still unbound
939+
expect(callerTask?.sessionId).toBe('contested-sess');
940+
removeSpy.mockRestore();
941+
});
942+
886943
it('a hung spawnOrAttach does not stall subsequent ticks', async () => {
887944
// spawnOrAttach is not abortable — if it hangs, the keepalive must time
888945
// out and move on so later ticks can still heartbeat/revive other

packages/cli/src/serve/scheduled-task-keepalive.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,10 +205,20 @@ async function bindAndNameSessions(
205205
// read and this write-lock acquisition — only attach when the task is
206206
// still unbound and enabled. Otherwise return unchanged so the
207207
// orphan spawn is rolled back below.
208+
//
209+
// Also bail when ANY committed task already references the
210+
// just-minted session: the scheduled-tasks reuse path can bind a
211+
// session the moment the spawn above registers it in the live map,
212+
// BEFORE this write commits — without this check the session would be
213+
// bound to two tasks (same transcript, conflicting ⏰ renames), and a
214+
// later delete of THIS task would close the session out from under
215+
// the surviving one. The orphan rollback below then tears the
216+
// unclaimed session back down.
208217
if (
209218
!list.some(
210219
(t) => t.id === task.id && !t.sessionId && t.enabled !== false,
211-
)
220+
) ||
221+
list.some((t) => t.sessionId === sessionId)
212222
) {
213223
return list;
214224
}

0 commit comments

Comments
 (0)