chat: don't idle-GC a session while background tasks are still running

Phase 1 gap surfaced by a long overnight job: armIdle fired 30 min after the
last turn regardless of in-flight background work, so a silent run_in_background
job outliving the timeout got its persistent SDK session aborted — killing the
harness that delivers its task_notification (and any detached watcher's hook).

Fix = task-lifecycle heartbeat: track task:started → task:notification per
session; suppress/re-arm the idle timer while any task is pending. task:started
also clears a pending idle timer. So long run_in_background jobs keep their own
session alive and their completion is delivered; idle-GC resumes only once all
tasks finish and the session is truly idle. Pairs with the Activity path-tail
(35973a5) for the pure-setsid case.

Not deployed (no restart — long job still running); lands with the Activity
batch on next restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 09:34:59 +00:00
co-authored by Claude Opus 4.8
parent 35973a5505
commit 621f93b884
+20 -2
View File
@@ -142,6 +142,7 @@ type PersistentSession = {
abort: AbortController;
emit: (event: ChatEvent) => void;
isGenerating: boolean;
pendingTasks: Set<string>; // background tasks started but not yet notified; suppress idle-GC while non-empty
idleTimer?: ReturnType<typeof setTimeout>;
};
@@ -182,6 +183,12 @@ function makeInputQueue() {
function armIdle(session: PersistentSession): void {
if (session.idleTimer) clearTimeout(session.idleTimer);
session.idleTimer = setTimeout(() => {
// Heartbeat: never GC a session that's mid-turn or still has background tasks running — a long
// silent run_in_background job would otherwise be killed along with its pending task_notification.
if (session.isGenerating || session.pendingTasks.size > 0) {
armIdle(session);
return;
}
killClaudeSession(session.sessionKey);
}, IDLE_TIMEOUT_MS);
}
@@ -199,6 +206,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
abort,
emit: onEvent,
isGenerating: false,
pendingTasks: new Set<string>(),
};
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
@@ -241,9 +249,19 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
void (async () => {
const state = createParseState();
const emit = (event: ChatEvent) => {
if (event.type === 'result' || event.type === 'error' || event.type === 'stopped') {
if (event.type === 'task:started') {
// Work is running — hold off idle-GC until it finishes.
session.pendingTasks.add(event.taskId);
if (session.idleTimer) {
clearTimeout(session.idleTimer);
session.idleTimer = undefined;
}
} else if (event.type === 'task:notification') {
session.pendingTasks.delete(event.taskId);
if (!session.isGenerating && session.pendingTasks.size === 0) armIdle(session);
} else if (event.type === 'result' || event.type === 'error' || event.type === 'stopped') {
session.isGenerating = false;
armIdle(session);
if (session.pendingTasks.size === 0) armIdle(session);
}
session.emit(event);
};