re-adopt orphaned chat sessions on reconnect

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 10:36:02 +00:00
co-authored by Claude Opus 5
parent 75666e5e94
commit 3770d7c647
3 changed files with 71 additions and 7 deletions
+5
View File
@@ -82,6 +82,11 @@ export type ClientMessage =
type: 'resume-cursor'; type: 'resume-cursor';
sessionId: string; sessionId: string;
cursor: number; 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 // The `Task` tool's id, when this piece of output came from a subagent rather than the agent you are
+49 -6
View File
@@ -531,19 +531,62 @@ async function handleDisconnect(ws: ServerWebSocket<WSData>): Promise<void> {
sendToClient(ws, { type: 'disconnected' }); 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<WSData>, 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 // 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 // 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 // task:notification. attachWs cancels the pending idle-GC.
// GC'd, we still replay history from Postgres (new turns will respawn the session).
async function handleResumeCursor( async function handleResumeCursor(
ws: ServerWebSocket<WSData>, ws: ServerWebSocket<WSData>,
msg: { sessionId: string; cursor: number }, msg: { sessionId: string; cursor: number; model?: string; cwd?: string },
): Promise<void> { ): Promise<void> {
const { sessionId, cursor } = msg; const { sessionId, cursor } = msg;
if (sessionManager.getSession(sessionId)) { if (!sessionManager.getSession(sessionId)) {
sessionManager.attachWs(sessionId, ws); adoptOrphanedSession(ws, sessionId, msg.model || DEFAULT_MODEL, msg.cwd ?? '');
wsToSessionMap.set(ws as any, sessionId);
} }
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
try { try {
const events = await getChatEventsSince(sessionId, cursor ?? 0); const events = await getChatEventsSince(sessionId, cursor ?? 0);
for (const { id, event } of events) { for (const { id, event } of events) {
+17 -1
View File
@@ -84,6 +84,10 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
const streamingRef = useRef(''); const streamingRef = useRef('');
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
const sessionIdRef = useRef<string | null>(initialSessionId ?? null); const sessionIdRef = useRef<string | null>(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<string | null>(null);
const cwdRef = useRef<string | null>(null);
const saveTimerRef = useRef<number | null>(null); const saveTimerRef = useRef<number | null>(null);
const toolCallsInTurnRef = useRef(false); const toolCallsInTurnRef = useRef(false);
const onTurnCompleteRef = useRef(onTurnComplete); const onTurnCompleteRef = useRef(onTurnComplete);
@@ -216,6 +220,8 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
switch (msg.type) { switch (msg.type) {
case 'session:init': case 'session:init':
sessionIdRef.current = msg.sessionId; sessionIdRef.current = msg.sessionId;
modelRef.current = msg.model;
cwdRef.current = msg.cwd;
setSessionId(msg.sessionId); setSessionId(msg.sessionId);
setModel(msg.model); setModel(msg.model);
setCwd(msg.cwd); 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. // 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. // 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 onOpen = useCallback(() => {
const sid = sessionIdRef.current; 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 }); const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage, onOpen });