end a turn whose agent has gone
restarting officer-agent takes every persistent session with it and nothing downstream notices: the browser's socket is healthy, officer's subscription is a bus filter, and there is simply never another event. the spinner ran forever and a refresh didn't help, because the transcript has no ending to read. keyed off the agent *registering*, not disconnecting — a disconnect fires on every `pm2 restart officer`, when the turn is fine. a registration socket dies with its process, so an agent appearing on it is a new one. covers the sitting tab; the reconnect path covers the rest, with the client now sending its belief that a turn is in flight and officer checking it against the agent over a new claude:is-generating. the check fails toward alive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import { ensureGeneralChatSessionsCwd } from './claude-sessions';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { join } from 'path';
|
||||
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
|
||||
import { getUserSettings, getEmailAccounts, getChatEventsSince } from 'officerdb';
|
||||
import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent } from 'officerdb';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -579,11 +579,12 @@ function adoptOrphanedSession(ws: ServerWebSocket<WSData>, sessionId: string, mo
|
||||
// task:notification. attachWs cancels the pending idle-GC.
|
||||
async function handleResumeCursor(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { sessionId: string; cursor: number; model?: string; cwd?: string },
|
||||
msg: { sessionId: string; cursor: number; model?: string; cwd?: string; generating?: boolean },
|
||||
): Promise<void> {
|
||||
const { sessionId, cursor } = msg;
|
||||
const model = msg.model || DEFAULT_MODEL;
|
||||
if (!sessionManager.getSession(sessionId)) {
|
||||
adoptOrphanedSession(ws, sessionId, msg.model || DEFAULT_MODEL, msg.cwd ?? '');
|
||||
adoptOrphanedSession(ws, sessionId, model, msg.cwd ?? '');
|
||||
}
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
@@ -595,8 +596,58 @@ async function handleResumeCursor(
|
||||
} catch (err) {
|
||||
logger.error('Failed to replay chat events on resume-cursor', { sessionId, error: String(err) });
|
||||
}
|
||||
if (msg.generating) await endTurnIfAgentIsGone(ws, sessionId, model);
|
||||
}
|
||||
|
||||
/**
|
||||
* The client came back still believing a turn is running. Check whether it is, and if it isn't, say so.
|
||||
*
|
||||
* A restart of the agent sidecar takes its persistent sessions with it, and nothing downstream notices:
|
||||
* the turn simply stops emitting. The browser's socket is fine, the conversation looks alive, and the
|
||||
* spinner runs forever — a refresh doesn't help either, because there is no ending in the transcript to
|
||||
* read. This is the one moment we can catch it, so the answer is written durably: a reload after this
|
||||
* shows the same explanation rather than a conversation that trails off mid-tool-call.
|
||||
*
|
||||
* Only the claude harness is asked. OpenCode runs a turn per invocation and has no equivalent question,
|
||||
* so its sessions are left alone rather than guessed at.
|
||||
*/
|
||||
async function endTurnIfAgentIsGone(
|
||||
ws: ServerWebSocket<WSData> | null,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
): Promise<void> {
|
||||
if (!isClaudeModel(model)) return;
|
||||
if (await sidecar.isClaudeGenerating(sessionId)) return;
|
||||
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (session) session.isGenerating = false;
|
||||
|
||||
const event: ServerMessage = {
|
||||
type: 'error',
|
||||
message: 'The agent restarted while this turn was running, so it was cut off. The conversation is intact.',
|
||||
};
|
||||
try {
|
||||
const seq = await appendChatEvent(sessionId, event);
|
||||
sendToClient(ws, event, seq);
|
||||
} catch (err) {
|
||||
// Still tell this client — an un-replayable explanation beats a spinner that never stops.
|
||||
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
|
||||
sendToClient(ws, event);
|
||||
}
|
||||
logger.info('Ended a turn whose agent had gone', { sessionId });
|
||||
}
|
||||
|
||||
// The other half of the same problem: the agent restarts while the browser sits there with a healthy
|
||||
// socket, so nothing ever reconnects and nothing ever asks. A fresh agent process means every turn we
|
||||
// still believe is running belongs to a process that no longer exists. On a fresh officer this loop is
|
||||
// empty — it has no sessions yet — which is exactly right, because that case is the reconnect's to catch.
|
||||
sidecar.onClaudeSidecarStarted(() => {
|
||||
for (const session of sessionManager.getAllSessions()) {
|
||||
if (!session.isGenerating) continue;
|
||||
void endTurnIfAgentIsGone(session.ws as ServerWebSocket<WSData> | null, session.sessionId, session.model);
|
||||
}
|
||||
});
|
||||
|
||||
export const chatWebsocket = {
|
||||
open,
|
||||
message,
|
||||
|
||||
Reference in New Issue
Block a user