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
+60 -24
View File
@@ -1,5 +1,5 @@
import type { UserSession } from "./types";
import { logger } from "./logger";
import type { UserSession } from './types';
import { logger } from './logger';
class SessionManager {
private sessions = new Map<string, UserSession>();
@@ -26,13 +26,13 @@ class SessionManager {
ws: null,
lastActivity: Date.now(),
idleTimer: null,
streamBuffer: "",
streamBuffer: '',
isGenerating: false,
systemContextSent: false,
messages: [],
meta: {
id: sessionId,
title: "",
title: '',
model,
cwd,
groupSlug: groupSlug || null,
@@ -66,19 +66,36 @@ class SessionManager {
getUserSessions(email: string): UserSession[] {
const sessionIds = this.userSessions.get(email) || [];
return sessionIds
.map((id) => this.sessions.get(id))
.filter((s): s is UserSession => s !== undefined);
return sessionIds.map((id) => this.sessions.get(id)).filter((s): s is UserSession => s !== undefined);
}
deleteSession(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
/** Drop this process's record of a session. Shared by `deleteSession` and `releaseSession`. */
private forget(session: UserSession): void {
if (session.idleTimer) {
clearTimeout(session.idleTimer);
}
this.sessions.delete(session.sessionId);
const userSessionIds = this.userSessions.get(session.email);
if (userSessionIds) {
const filtered = userSessionIds.filter((id) => id !== session.sessionId);
if (filtered.length > 0) {
this.userSessions.set(session.email, filtered);
} else {
this.userSessions.delete(session.email);
}
}
}
/**
* End the session for good: kill the agent upstream, drop the subscription, forget the record. This is
* what an explicit "disconnect" means — the user said stop.
*/
deleteSession(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
// Clean up sidecar subscriptions
if (session._sidecarUnsub) {
session._sidecarUnsub();
@@ -87,19 +104,35 @@ class SessionManager {
session._claudeKill();
}
this.sessions.delete(sessionId);
this.forget(session);
}
const userSessionIds = this.userSessions.get(session.email);
if (userSessionIds) {
const filtered = userSessionIds.filter(
(id) => id !== sessionId
);
if (filtered.length > 0) {
this.userSessions.set(session.email, filtered);
} else {
this.userSessions.delete(session.email);
}
/**
* Let go WITHOUT killing: unsubscribe, forget the record, leave the agent running.
*
* This is what the idle GC should always have done. Officer's 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. The sidecar already refuses to collect a session that is mid-turn or holding background
* tasks (`claude-manager.ts` → `pendingTasks`, and an `armIdle` that re-checks rather than firing
* once); officer knew none of that and killed anyway. A `run_in_background` job outliving the browser
* — a laptop that ran out of battery — died an hour later for no reason.
*
* Nothing is stranded by forgetting the record. The sidecar keeps committing to `chat_session_events`,
* and a returning browser goes through `adoptOrphanedSession`, which rebuilds the record and a fresh
* subscription. That is the same path every `pm2 restart officer` already takes.
*/
releaseSession(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
// Only the listener goes. `_claudeKill` is deliberately NOT called — and must not be left behind
// either: the record is being dropped, so the next turn starts from `adoptOrphanedSession`, which
// installs its own kill and subscription.
if (session._sidecarUnsub) {
session._sidecarUnsub();
}
this.forget(session);
}
attachWs(sessionId: string, ws: any): void {
@@ -132,8 +165,11 @@ class SessionManager {
}
session.idleTimer = setTimeout(() => {
logger.info('Session idle timeout reached, cleaning up', { sessionId, timeoutMs });
this.deleteSession(sessionId);
logger.info('Session idle timeout reached, releasing binding (agent left running)', {
sessionId,
timeoutMs,
});
this.releaseSession(sessionId);
}, timeoutMs);
}
+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;
+12
View File
@@ -45,7 +45,18 @@ type ClaudeCodeStreamingParams = {
};
type ClaudeCodeStreamingHandle = {
/** End the agent's session upstream and stop listening. For an explicit disconnect. */
kill: () => void;
/**
* Stop listening and leave the agent running.
*
* These are separate because officer's idle GC and a user's "disconnect" want different things, and
* for a long time they could not have them: `unsub` was a closure reachable only through `kill`, so
* letting go of a session necessarily killed it. That is why an idle browser took a live agent down
* with it — including one the sidecar had deliberately protected because background work was still
* in flight.
*/
detach: () => void;
};
export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise<ClaudeCodeStreamingHandle> {
@@ -67,5 +78,6 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
sidecar.killClaude(params.sessionKey);
unsub();
},
detach: unsub,
};
}
+4
View File
@@ -25,7 +25,10 @@ type OpenCodeStreamingParams = {
};
type OpenCodeStreamingHandle = {
/** End the agent's session upstream and stop listening. For an explicit disconnect. */
kill: () => void;
/** Stop listening and leave the agent running — see the same pair in `send-claude-code.ts`. */
detach: () => void;
};
export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise<OpenCodeStreamingHandle> {
@@ -67,5 +70,6 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr
sidecar.killOpenCode(params.sessionKey);
unsub();
},
detach: unsub,
};
}