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`
|
||||
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.
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user