retry a turn the agent restart cut off

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 02:00:16 +00:00
co-authored by Claude Opus 5
parent 876b39b301
commit 66fca28927
7 changed files with 83 additions and 10 deletions
+25 -3
View File
@@ -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/<email>/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.
+6
View File
@@ -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';
+1 -4
View File
@@ -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);
@@ -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) => {
</div>
);
// 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 (
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
<PlugZap className="h-3 w-3" />
Agent restarted turn cut off
</span>
{message.prompt && onRetry && (
<button
type="button"
onClick={() => onRetry(message.prompt)}
className="flex cursor-pointer items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] font-medium tracking-wide uppercase transition-colors hover:bg-muted hover:text-foreground"
>
<RotateCcw className="h-3 w-3" />
Retry
</button>
)}
<div className="h-px flex-1 bg-border" />
</div>
);
case 'error':
return (
<div className="flex justify-start group">
@@ -84,7 +84,11 @@ export const MessageList = ({ manager }: MessageListProps) => {
}}
>
<div className="py-1.5">
<MessageBubble message={msg} onAnswer={(text) => sendPrompt(text)} />
<MessageBubble
message={msg}
onAnswer={(text) => sendPrompt(text)}
onRetry={(prompt) => sendPrompt(prompt)}
/>
</div>
</div>
);
@@ -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 };
@@ -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.