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
+24 -9
View File
@@ -20,9 +20,9 @@ until morning. Officer would have killed it about an hour after the browser sock
- `pendingTasks: Set<string>` (`:174`) holds background tasks started but not yet notified.
`task:started` adds the id and **clears the idle timer outright** (`:406-411`); `task:notification`
removes it and re-arms only if nothing else is outstanding (`:413-414`).
- Its own comment states the intent: *"never GC a session that's mid-turn or still has background tasks
- Its own comment states the intent: _"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`."*
`task_notification`."_
So **the sidecar already detects long-running background work with no declaration from the user.** The
"mark a session permanent" feature is not needed for this case.
@@ -38,7 +38,7 @@ There is also `stallTimer` for a turn that claims to be generating but has emitt
- Armed by the socket **closing**, not by it being unresponsive: `close(ws)``detachWs` +
`setIdleTimeout` (`websocket.ts:151-163`). Unresponsiveness matters only upstream — Bun closes a WS
idle for 60s and there is a per-connection heartbeat, so a dead laptop loses the socket about a minute
in, and *that* starts the hour.
in, and _that_ starts the hour.
- Expiry runs `deleteSession` (`session-manager.ts:74-102`), which calls `_sidecarUnsub()` then
`_claudeKill()``sidecar.killClaude(sessionId)`.
- `attachWs` clears the timer (`session-manager.ts:104-116`), so reconnecting cancels it cleanly.
@@ -49,7 +49,7 @@ There is also `stallTimer` for a turn that claims to be generating but has emitt
2. It does not survive `pm2 restart officer` — a `setTimeout` on an in-memory record, in the process
designed to bounce. If the browser never returns, nothing re-arms it.
3. On a flat battery the sidecar's 30 minutes normally fires first anyway, so officer's hour is mostly
redundant *except* in the one case where it does damage — a session with background work, which the
redundant _except_ in the one case where it does damage — a session with background work, which the
sidecar keeps and officer kills.
### What is already fine
@@ -59,7 +59,7 @@ agent keeps generating and keeps committing to `chat_session_events`. Only offic
dies, and `adoptOrphanedSession` (`websocket.ts:559`) rebuilds it — including the session-scoped
subscription, without which a reconnected client replays and then goes silent for the rest of the turn.
## Step 1 — officer's timer releases instead of kills
## Step 1 — officer's timer releases instead of kills — **DONE**
Officer's idle timer is doing two unrelated jobs: garbage-collecting its own binding (its business) and
terminating the agent (the sidecar's). Split them and leave the agent's lifetime to the process that
@@ -71,10 +71,15 @@ the sidecar's heartbeat in a second place, which is how these two drifted apart
**The blocker.** `src/servers/channels/send-claude-code.ts:65-69` welds them together:
```ts
return { kill: () => { sidecar.killClaude(params.sessionKey); unsub(); } }
return {
kill: () => {
sidecar.killClaude(params.sessionKey);
unsub();
},
};
```
`unsub` is a closure reachable only *through* `kill`, so officer cannot let go without killing.
`unsub` is a closure reachable only _through_ `kill`, so officer cannot let go without killing.
**The seam already exists.** `_sidecarUnsub` is declared (`types.ts:310`) and called separately in
`deleteSession` (`session-manager.ts:83-84`) — and **nothing ever assigns it**. Populating it is the fix.
@@ -96,9 +101,19 @@ Make sure a released-then-readopted session cannot land in that state.
Consequence to accept: after an hour a returning browser goes through the adopt path rather than finding
a live record. That path already runs on every officer restart.
**As built.** `detach` added beside `kill` on both streaming handles; `_sidecarUnsub` populated at all
three sites (Claude first turn, OpenCode, and `adoptOrphanedSession` — that last one was not in the
original list and would have leaked a listener per adopt-then-idle); `forget` factored out of
`deleteSession`; `releaseSession` added; the idle timer points at it. `deleteSession` still kills, so an
explicit disconnect is unchanged.
The double-subscription trap does not arise: `releaseSession` unsubscribes and drops the record, so a
returning browser either adopts (fresh single subscription) or starts a first turn with no stale
listener behind it. `unsub` is a `Set.delete`, so `deleteSession` calling it twice is harmless.
## Step 2 — enumerate live sessions
After an officer restart a live session is invisible until a browser reconnects to it *by id*; adoption
After an officer restart a live session is invisible until a browser reconnects to it _by id_; adoption
is on-demand only. **There is no list verb in the sidecar protocol** (checked).
Add `claude:list` returning each live `sessionKey` with `isGenerating` and `pendingTasks.size`, so
@@ -108,7 +123,7 @@ officer can answer "what is running right now" and surface it.
it cannot be serialised. The durable part — the output — is already in `chat_session_events`, which is
what makes replay work.
**Hard limit:** if the *agent sidecar* restarts, a mid-turn is lost regardless. Officer restarts are
**Hard limit:** if the _agent sidecar_ restarts, a mid-turn is lost regardless. Officer restarts are
survivable; `officer-agent` restarts are not.
## Deliberately not decided