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:
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user