From 66fca28927e452406a4dd8b1736e1c254d44096d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 02:00:16 +0000 Subject: [PATCH] retry a turn the agent restart cut off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the cut-off notice is its own event now rather than an error: nothing is broken and nothing is lost but the turn, so the row says what happened and offers the one action that fixes it. the conversation is already durable — the claude session id is written through to disk and passed back as resume: — so retry just resends the prompt on a session the fresh agent picks up with full context. read back out of the transcript, so a second window on the same session can offer it too. Co-Authored-By: Claude Opus 5 --- docs/chat-ui-walkthrough.md | 28 +++++++++++++++-- src/servers/api/chat/types.ts | 6 ++++ src/servers/api/chat/websocket.ts | 5 +-- .../apps/Chat/components/MessageBubble.tsx | 31 +++++++++++++++++-- .../src/apps/Chat/components/MessageList.tsx | 6 +++- .../officerdev/src/apps/Chat/types.ts | 5 +++ .../officerdev/src/hooks/useChat.ts | 12 +++++++ 7 files changed, 83 insertions(+), 10 deletions(-) diff --git a/docs/chat-ui-walkthrough.md b/docs/chat-ui-walkthrough.md index d4e52d1f..9d2c4325 100644 --- a/docs/chat-ui-walkthrough.md +++ b/docs/chat-ui-walkthrough.md @@ -550,15 +550,37 @@ Two paths, because the tab can be in two states: from its own memory, which died with the process, so it asks the agent over a new `claude:is-generating` command. The agent is the only party that knows. -Either way you get a line saying the agent restarted and the turn was cut off, and it's written to -`chat_session_events` — so a reload afterwards shows the same explanation rather than a conversation that -trails off mid-tool-call. +Either way you get a seam across the transcript — **AGENT RESTARTED — TURN CUT OFF** — with a **Retry** +button beside it that sends the same prompt again. + +The retry is the whole reason this is worth having, and it works because most of what looked like the +hard problem is already solved. `sessionKey → claude session_id` is written through to +`data//sidecar/claude-state.json` on every change — not just at shutdown, so it survives a +`SIGKILL` — and `createSession` passes it back as `resume:`. **A restarted agent costs you the turn, not +the conversation:** the next prompt picks the thread up from the transcript on disk with full context. +Retry just spares you scrolling up to copy what you'd said. + +It's deliberately a seam and not a red error bubble. Nothing is broken and nothing is lost but the turn, +so the row's job is to say what happened and offer the one action that fixes it. The prompt is read back +out of the transcript rather than remembered separately, because this can land in a second window on the +same session — one that never sent it. + +Making the turn _itself_ survive is the part that stays unsolved, and deliberately so. The Agent SDK +spawns `claude` as a child with piped stdio; re-adopting it after the sidecar dies would mean the CLI +becoming a detached grandchild talking over a socket, i.e. not using the SDK's process management at all. +That is a large, risky rewrite that buys exactly one turn — and Retry buys most of it for thirty lines. The liveness check **fails toward alive**: a timeout, or no answer, is read as "still running". Telling you a turn died while it is quietly typing would be a worse lie than a spinner that stays up a bit longer. Only a registered agent answering "no", or no agent at all, counts as dead. OpenCode sessions are left alone — that harness runs a turn per invocation and has no equivalent question. +The notice is also appended to `chat_session_events`, but **don't count on it surviving a refresh**. That +table is keyed by officer's own session id, while a session reopened from history is addressed by +Claude's transcript uuid; the two converge once a conversation has been resumed at least once, and don't +before that. The live case is the one that matters here and it is unaffected. Untangling those two ids is +a separate job. + **Not verified:** the browser, and the restart itself. The mechanism is reasoned from the code plus the `pm2` logs that pinned the cause; typecheck and the full 365-test suite are clean. diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index efeae242..948d7942 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -156,6 +156,12 @@ export type ServerMessage = | { type: 'stopped'; } + | { + // The agent process went away mid-turn (almost always `pm2 restart officer-agent`). Its own type + // rather than an `error` because it is recoverable and the recovery is one click: the conversation + // is on disk and the next turn resumes it, so the UI offers to send the same prompt again. + type: 'cut-off'; + } | { // Ack for a client 'disconnect': the session was torn down server-side. type: 'disconnected'; diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index a99d61da..7fd52e9e 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -622,10 +622,7 @@ async function endTurnIfAgentIsGone( 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.', - }; + const event: ServerMessage = { type: 'cut-off' }; try { const seq = await appendChatEvent(sessionId, event); sendToClient(ws, event, seq); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx index 05a6c7c2..dc3c642f 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx @@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; -import { Volume2, Loader2, Square, Clock, Check, X, CircleSlash, Eraser } from 'lucide-react'; +import { Volume2, Loader2, Square, Clock, Check, X, CircleSlash, Eraser, PlugZap, RotateCcw } from 'lucide-react'; import { toast } from '@/components/ui/sonner'; import type { Tone } from '@/components/Data'; import { toneText } from '@/components/Data'; @@ -126,9 +126,10 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => { type MessageBubbleProps = { message: ChatMessage; onAnswer?: (text: string) => void; + onRetry?: (prompt: string) => void; }; -export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => { +export const MessageBubble = ({ message, onAnswer, onRetry }: MessageBubbleProps) => { switch (message.role) { case 'user': { const rawText = typeof message.text === 'string' ? message.text : ''; @@ -225,6 +226,32 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => { ); + // The agent process went away mid-turn. Styled as a seam rather than a failure bubble: nothing is + // broken and nothing is lost except the turn itself, so the row's job is to say what happened and + // offer the one action that fixes it. The prompt goes back through the normal send path, which lands + // on a session the fresh agent resumes from the transcript on disk. + case 'cutoff': + return ( +
+
+ + + Agent restarted — turn cut off + + {message.prompt && onRetry && ( + + )} +
+
+ ); + case 'error': return (
diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx index e1b9b2ea..9c132819 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx @@ -84,7 +84,11 @@ export const MessageList = ({ manager }: MessageListProps) => { }} >
- sendPrompt(text)} /> + sendPrompt(text)} + onRetry={(prompt) => sendPrompt(prompt)} + />
); diff --git a/src/workspaces/officerdev/src/apps/Chat/types.ts b/src/workspaces/officerdev/src/apps/Chat/types.ts index d2a36d04..af242bb3 100644 --- a/src/workspaces/officerdev/src/apps/Chat/types.ts +++ b/src/workspaces/officerdev/src/apps/Chat/types.ts @@ -41,6 +41,9 @@ export type ChatMessage = // You pressed stop. Deliberately not an `error` — the turn did what you told it to, and dressing that // in red destructive chrome (which is what it looked like) reads as "something went wrong". | { role: 'interrupted' } + // The agent process went away mid-turn. Carries the prompt that was lost with it, so the row can offer + // to send it again — the conversation itself is on disk and the next turn resumes it. + | { role: 'cutoff'; prompt: string } // Where a `/clear` fell inside a resumed conversation. The server splices the parts of a chain into // one transcript (see `loadChainTranscript`), and this is the seam — deliberately visible, because // your history runs straight through it and the agent's context does not. `sessionId` is the part @@ -83,6 +86,8 @@ export type ServerMessage = | { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string } | { type: 'error'; message: string; errorCode?: string } | { type: 'stopped' } + /** The agent process went away mid-turn. Recoverable — see the `cutoff` message role. */ + | { type: 'cut-off' } | { type: 'disconnected' } | { type: 'task:started'; taskId: string; description: string; taskType?: string } | { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string }; diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 76b449d1..eb741a14 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -341,6 +341,18 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, setIsGenerating(false); break; + case 'cut-off': + // The prompt is taken from the transcript rather than remembered separately: this can arrive in a + // tab that never sent it (a second window on the same session), and the transcript is the one + // place both of them agree on. + commitStreaming(); + setMessages((prev) => { + const lastUser = [...prev].reverse().find((m) => m.role === 'user'); + return [...prev, { role: 'cutoff', prompt: lastUser?.role === 'user' ? lastUser.text : '' }]; + }); + setIsGenerating(false); + break; + case 'disconnected': // Session torn down server-side (turn killed, in-memory session dropped). Commit any partial // stream and settle to idle; the WS stays open so a new prompt starts a fresh session.