From 621f93b884df95cd328e08919dc5b023ecf92efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 27 Jul 2026 09:34:59 +0000 Subject: [PATCH] chat: don't idle-GC a session while background tasks are still running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/sidecar/claude/claude-manager.ts | 22 ++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 65edf57a..155c2dfa 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -142,6 +142,7 @@ type PersistentSession = { abort: AbortController; emit: (event: ChatEvent) => void; isGenerating: boolean; + pendingTasks: Set; // background tasks started but not yet notified; suppress idle-GC while non-empty idleTimer?: ReturnType; }; @@ -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(), }; // 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); };