From 3770d7c647dcd971ad673f18758c87b8cba63f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 10:36:02 +0000 Subject: [PATCH] re-adopt orphaned chat sessions on reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restarting officer under a live turn left the browser connected but permanently silent. the sidecars are pm2 peers, so the agent kept generating and kept committing to chat_session_events — what died was officer's binding to it. on `resume-cursor` the server only re-attached the socket when an in-memory session still existed, so after a restart there was no session and, critically, no session-scoped subscription relaying sidecar events to the client. the client got its durable replay and then nothing, which reads exactly like the agent stopping. adopt the session instead: recreate the record and re-open the subscription without spawning anything. `_claudeKill` has to be set as part of that — handleChat treats its absence as "first turn" and would open a second subscription, doubling every message. the client now echoes the model and cwd from its session:init back in the handshake, since after a restart it is the only party that still remembers them. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/types.ts | 5 ++ src/servers/api/chat/websocket.ts | 55 +++++++++++++++++-- .../officerdev/src/hooks/useChat.ts | 18 +++++- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index ec6507e0..8f312db5 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -82,6 +82,11 @@ export type ClientMessage = type: 'resume-cursor'; sessionId: string; cursor: number; + // Echoed back from the `session:init` this client already received, so a session orphaned by an + // officer restart can be re-adopted with the harness and working directory it actually had. The + // client is the only party that still remembers them — officer's copy died with the process. + model?: string; + cwd?: string; }; // The `Task` tool's id, when this piece of output came from a subagent rather than the agent you are diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 4de6139a..cc67178b 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -531,19 +531,62 @@ async function handleDisconnect(ws: ServerWebSocket): Promise { sendToClient(ws, { type: 'disconnected' }); } +/** + * Re-adopt a session whose in-memory record died with the process that held it. + * + * The sidecars are PM2 peers, so `pm2 restart officer` does not touch a running turn: the agent keeps + * generating and keeps committing to chat_session_events. What the restart destroys is purely this + * process's binding to it — the session record and, critically, the session-scoped subscription that + * relays the sidecar's events to the browser. Re-creating the record is not enough on its own; without + * the subscription the client reconnects, receives its replay, and then goes silent for the rest of the + * turn, which is indistinguishable from the agent having died. + * + * Nothing is spawned here. The subscription is a local event-bus filter, so adopting a session that is + * NOT in fact still live upstream costs a listener that never fires and a record the idle GC collects. + */ +function adoptOrphanedSession(ws: ServerWebSocket, sessionId: string, model: string, cwd: string): UserSession { + const { email, userId } = ws.data; + const session = sessionManager.getOrCreate(sessionId, email, cwd, model, null); + session.userId = userId; + session.piProcess = sessionId as any; + + const onMessage = createMessageHandler(sessionId, model); + const unsubClaude = sidecar.onClaudeMessage((key, msg, seq) => { + if (key === sessionId) onMessage(msg, seq); + }); + const unsubOpenCode = sidecar.onOpenCodeMessage((key, msg, seq) => { + if (key === sessionId) onMessage(msg, seq); + }); + const unsub = () => { + unsubClaude(); + unsubOpenCode(); + }; + + // Must be set, and not only for teardown: handleChat treats an absent `_claudeKill` as "first turn of + // this session" and opens a *second* subscription, which would then deliver every message twice. + session._claudeKill = () => { + if (isClaudeModel(model)) sidecar.killClaude(sessionId); + else sidecar.killOpenCode(sessionId); + unsub(); + }; + + logger.info('Adopted orphaned chat session after restart', { sessionId, model }); + return session; +} + // Reconnect: re-bind this socket to the (possibly still-live) session and replay every durable event // queued since the client's cursor — so a brief disconnect never loses turn output or a background -// task:notification. attachWs cancels the pending idle-GC. If the in-memory session was already -// GC'd, we still replay history from Postgres (new turns will respawn the session). +// task:notification. attachWs cancels the pending idle-GC. async function handleResumeCursor( ws: ServerWebSocket, - msg: { sessionId: string; cursor: number }, + msg: { sessionId: string; cursor: number; model?: string; cwd?: string }, ): Promise { const { sessionId, cursor } = msg; - if (sessionManager.getSession(sessionId)) { - sessionManager.attachWs(sessionId, ws); - wsToSessionMap.set(ws as any, sessionId); + if (!sessionManager.getSession(sessionId)) { + adoptOrphanedSession(ws, sessionId, msg.model || DEFAULT_MODEL, msg.cwd ?? ''); } + sessionManager.attachWs(sessionId, ws); + wsToSessionMap.set(ws as any, sessionId); try { const events = await getChatEventsSince(sessionId, cursor ?? 0); for (const { id, event } of events) { diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 5fa398b8..7d4551d8 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -84,6 +84,10 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, const streamingRef = useRef(''); const rafRef = useRef(null); const sessionIdRef = useRef(initialSessionId ?? null); + // Mirrors of the session:init fields, for the reconnect handshake — `onOpen` is stable by design and + // cannot close over the state. + const modelRef = useRef(null); + const cwdRef = useRef(null); const saveTimerRef = useRef(null); const toolCallsInTurnRef = useRef(false); const onTurnCompleteRef = useRef(onTurnComplete); @@ -216,6 +220,8 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, switch (msg.type) { case 'session:init': sessionIdRef.current = msg.sessionId; + modelRef.current = msg.model; + cwdRef.current = msg.cwd; setSessionId(msg.sessionId); setModel(msg.model); setCwd(msg.cwd); @@ -367,9 +373,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, // On every (re)connect, if a session is already established, re-bind + replay via resume-cursor. // The first connect (no session yet) no-ops; the first turn establishes the session via session:init. + // `model`/`cwd` come back from the session:init this client already holds. After an officer restart the + // server has no memory of either, and it needs both to re-adopt the session rather than leave it + // orphaned — so the client, which is now the only party that remembers, hands them back. const onOpen = useCallback(() => { const sid = sessionIdRef.current; - if (sid) sendRef.current({ type: 'resume-cursor', sessionId: sid, cursor: cursorRef.current }); + if (!sid) return; + sendRef.current({ + type: 'resume-cursor', + sessionId: sid, + cursor: cursorRef.current, + ...(modelRef.current ? { model: modelRef.current } : {}), + ...(cwdRef.current ? { cwd: cwdRef.current } : {}), + }); }, []); const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage, onOpen });