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:
@@ -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`
|
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.
|
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
|
Either way you get a seam across the transcript — **AGENT RESTARTED — TURN CUT OFF** — with a **Retry**
|
||||||
`chat_session_events` — so a reload afterwards shows the same explanation rather than a conversation that
|
button beside it that sends the same prompt again.
|
||||||
trails off mid-tool-call.
|
|
||||||
|
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
|
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
|
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
|
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.
|
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
|
**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.
|
`pm2` logs that pinned the cause; typecheck and the full 365-test suite are clean.
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,12 @@ export type ServerMessage =
|
|||||||
| {
|
| {
|
||||||
type: 'stopped';
|
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.
|
// Ack for a client 'disconnect': the session was torn down server-side.
|
||||||
type: 'disconnected';
|
type: 'disconnected';
|
||||||
|
|||||||
@@ -622,10 +622,7 @@ async function endTurnIfAgentIsGone(
|
|||||||
const session = sessionManager.getSession(sessionId);
|
const session = sessionManager.getSession(sessionId);
|
||||||
if (session) session.isGenerating = false;
|
if (session) session.isGenerating = false;
|
||||||
|
|
||||||
const event: ServerMessage = {
|
const event: ServerMessage = { type: 'cut-off' };
|
||||||
type: 'error',
|
|
||||||
message: 'The agent restarted while this turn was running, so it was cut off. The conversation is intact.',
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
const seq = await appendChatEvent(sessionId, event);
|
const seq = await appendChatEvent(sessionId, event);
|
||||||
sendToClient(ws, event, seq);
|
sendToClient(ws, event, seq);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
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 { toast } from '@/components/ui/sonner';
|
||||||
import type { Tone } from '@/components/Data';
|
import type { Tone } from '@/components/Data';
|
||||||
import { toneText } from '@/components/Data';
|
import { toneText } from '@/components/Data';
|
||||||
@@ -126,9 +126,10 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
|
|||||||
type MessageBubbleProps = {
|
type MessageBubbleProps = {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
onAnswer?: (text: string) => void;
|
onAnswer?: (text: string) => void;
|
||||||
|
onRetry?: (prompt: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
export const MessageBubble = ({ message, onAnswer, onRetry }: MessageBubbleProps) => {
|
||||||
switch (message.role) {
|
switch (message.role) {
|
||||||
case 'user': {
|
case 'user': {
|
||||||
const rawText = typeof message.text === 'string' ? message.text : '';
|
const rawText = typeof message.text === 'string' ? message.text : '';
|
||||||
@@ -225,6 +226,32 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
|||||||
</div>
|
</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':
|
case 'error':
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start group">
|
<div className="flex justify-start group">
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="py-1.5">
|
<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>
|
||||||
</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
|
// 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".
|
// in red destructive chrome (which is what it looked like) reads as "something went wrong".
|
||||||
| { role: 'interrupted' }
|
| { 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
|
// 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
|
// 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
|
// 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: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string }
|
||||||
| { type: 'error'; message: string; errorCode?: string }
|
| { type: 'error'; message: string; errorCode?: string }
|
||||||
| { type: 'stopped' }
|
| { type: 'stopped' }
|
||||||
|
/** The agent process went away mid-turn. Recoverable — see the `cutoff` message role. */
|
||||||
|
| { type: 'cut-off' }
|
||||||
| { type: 'disconnected' }
|
| { type: 'disconnected' }
|
||||||
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
|
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
|
||||||
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: 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);
|
setIsGenerating(false);
|
||||||
break;
|
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':
|
case 'disconnected':
|
||||||
// Session torn down server-side (turn killed, in-memory session dropped). Commit any partial
|
// 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.
|
// stream and settle to idle; the WS stays open so a new prompt starts a fresh session.
|
||||||
|
|||||||
Reference in New Issue
Block a user