let an idle browser go without taking the agent with it

Officer's hour-long idle timer was doing two unrelated jobs: collecting its own in-memory binding, which
is its business, and terminating the agent, which is the sidecar's. It could not do the first without
the second, because `unsub` was a closure reachable only through `kill`.

So a browser that went away killed a live agent an hour later — including one the sidecar had
deliberately protected. The sidecar already refuses to collect a session that is mid-turn or holding
background tasks: `task:started` disarms its idle GC, and `armIdle` re-checks and re-arms rather than
firing once. Officer had no view of any of that. A laptop running out of battery overnight took a
`run_in_background` job with it for no reason.

`detach` now sits beside `kill` on both streaming handles, and `_sidecarUnsub` — declared and called for
a long time, never once assigned — is populated at all three sites. `releaseSession` unsubscribes and
forgets the record without killing; the idle timer points at it. `deleteSession` is unchanged, so an
explicit disconnect still ends the session.

The third assignment site was not in the plan: `adoptOrphanedSession` sets `_claudeKill` but nothing
else, so an adopted session that later idled out would have dropped its record while the listener stayed
subscribed — a leak of one per adopt-then-leave.

No double subscription: releasing unsubscribes first, so a returning browser either adopts with a fresh
listener or starts a first turn with none behind it.

Step 1 of docs/chat-session-lifetime.md. Step 2 (a list verb, so running sessions can be found after a
restart) is still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 00:32:10 +01:00
co-authored by Claude Opus 5
parent 5286e9f9ec
commit be266da9e2
5 changed files with 109 additions and 34 deletions
+9 -1
View File
@@ -344,7 +344,9 @@ async function handleClaudeCodeChat(
try {
if (!session._claudeKill) {
// First turn of this session: open the persistent session + a SESSION-scoped event subscription
// (survives turn-end so background task:notifications keep flowing). handle.kill tears both down.
// (survives turn-end so background task:notifications keep flowing). `kill` tears both down for an
// explicit disconnect; `detach` drops only the listener, which is what the idle GC uses so an
// absent browser stops taking a live agent with it.
const handle = await sendClaudeCodeStreaming({
userId,
email,
@@ -359,6 +361,7 @@ async function handleClaudeCodeChat(
});
session.piProcess = sessionId as any;
session._claudeKill = handle.kill;
session._sidecarUnsub = handle.detach;
} else {
// Session already live: push this turn onto the existing persistent session (no new subscription).
await sidecar.spawnClaudeStreaming({
@@ -450,6 +453,7 @@ async function handleOpenCodeChat(
// Store the abort handle so handleStop can end the turn (OpenCode is aborted via this handle).
session.piProcess = sessionId as any;
session._claudeKill = handle.kill;
session._sidecarUnsub = handle.detach;
} catch (err) {
logger.error('Failed to start OpenCode streaming', { sessionId, error: String(err) });
sendToClient(ws, { type: 'error', message: 'Failed to start OpenCode' });
@@ -583,6 +587,10 @@ function adoptOrphanedSession(ws: ServerWebSocket<WSData>, sessionId: string, mo
else sidecar.killOpenCode(sessionId);
unsub();
};
// An adopted session can idle out and be released like any other, and releasing detaches through this
// field alone. Leaving it unset would drop the record while the listener stayed subscribed — a leak
// that grows by one every time a browser adopts a session and then goes away.
session._sidecarUnsub = unsub;
logger.info('Adopted orphaned chat session after restart', { sessionId, model });
return session;