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:
@@ -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
|
||||
|
||||
@@ -531,19 +531,62 @@ async function handleDisconnect(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
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
|
||||
// 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<WSData>,
|
||||
msg: { sessionId: string; cursor: number },
|
||||
msg: { sessionId: string; cursor: number; model?: string; cwd?: string },
|
||||
): Promise<void> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user