Skip to content

Commit 8230a3a

Browse files
authored
fix(web): keep consuming recovered daemon retries (#5221)
1 parent 0a65b85 commit 8230a3a

2 files changed

Lines changed: 70 additions & 30 deletions

File tree

apps/web/src/providers/daemon.ts

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,35 +1138,13 @@ async function consumeDaemonRun({
11381138
const data = event.data as SseErrorPayload;
11391139
const structuredError = daemonSseError(data);
11401140
pendingStructuredError = structuredError;
1141-
// The daemon emits this error frame from the child-close handler
1142-
// BEFORE `finishWithRetryDecision()` runs, so a transient failure it
1143-
// can recover via a same-run retry is reported here first and only
1144-
// resolved later. `run.resumable` is also computed at that same
1145-
// finalize step. Read the run status ONCE to classify, and let the
1146-
// SSE `end` frame (always emitted on terminal) resolve in-flight
1147-
// runs — this has no timeout, so even a slow retry is handled:
1148-
// - failed / canceled -> surface the error now, with the
1149-
// finalized `resumable` bit (set just before status flips to
1150-
// failed, so a `failed` read already has it);
1151-
// - status unreachable -> surface the structured error (safe
1152-
// default; never drop a real failure);
1153-
// - succeeded (recovered) or still running/queued (retry in
1154-
// flight) -> do NOT surface; keep consuming so the stream's
1155-
// `end` frame resolves it (succeeded -> onDone; failed ->
1156-
// the failure path below, carrying `end`'s resumable bit).
1157-
const status = await fetchChatRunStatus(runId).catch(() => null);
1158-
if (status && (status.status === 'failed' || status.status === 'canceled')) {
1159-
onRunStatus?.('failed');
1160-
handlers.onError(
1161-
markErrorResumable(structuredError, status.resumable === true),
1162-
);
1163-
return;
1164-
}
1165-
if (!status) {
1166-
onRunStatus?.('failed');
1167-
handlers.onError(structuredError);
1168-
return;
1169-
}
1141+
// Error frames can be emitted for a failed first attempt before the
1142+
// same run's retry has completed. Do not classify the run from a
1143+
// point-in-time status probe here: that can catch a transient
1144+
// failed state, surface a stale error, and disconnect before the
1145+
// later successful retry frames arrive. Cache the structured error
1146+
// and let the terminal `end` event or the post-stream status
1147+
// fallback below decide whether it should be surfaced.
11701148
continue;
11711149
}
11721150

@@ -1184,7 +1162,30 @@ async function consumeDaemonRun({
11841162
}
11851163
}
11861164
}
1187-
reconnects = sawStreamProgress ? 0 : reconnects + 1;
1165+
let shouldResetReconnects = sawStreamProgress;
1166+
if (pendingStructuredError && endStatus === null) {
1167+
const status = await fetchChatRunStatus(runId).catch(() => null);
1168+
if (status && isChatRunStatus(status.status) && status.status !== 'queued' && status.status !== 'running') {
1169+
endStatus = status.status;
1170+
exitCode = status.exitCode ?? null;
1171+
exitSignal = status.signal ?? null;
1172+
serverDeclaredSuccess = status.status === 'succeeded';
1173+
if (status.resumable === true) endResumable = true;
1174+
onRunStatus?.(endStatus);
1175+
break;
1176+
}
1177+
if (!status) {
1178+
onRunStatus?.('failed');
1179+
handlers.onError(pendingStructuredError);
1180+
return;
1181+
}
1182+
// The connection closed after an error frame but before a terminal
1183+
// frame. If the run is still active, retry the SSE stream, but count
1184+
// this as a reconnect attempt instead of letting the error frame reset
1185+
// the budget forever.
1186+
shouldResetReconnects = false;
1187+
}
1188+
reconnects = shouldResetReconnects ? 0 : reconnects + 1;
11881189
}
11891190

11901191
if (endStatus === null) {

apps/web/tests/providers/sse.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,45 @@ describe('streamViaDaemon', () => {
105105
expect(handlers.onDone).toHaveBeenCalledTimes(1);
106106
});
107107

108+
it('keeps consuming when a same-run retry succeeds after the transient error status briefly reads failed', async () => {
109+
// Regression for #5110: the daemon can emit an empty-output error for a
110+
// failed first attempt, then recover the SAME run through the retry path.
111+
// If the status probe observes the transient failed state and returns
112+
// immediately, the browser never sees the later successful write/text/end
113+
// frames and the chat keeps showing the stale empty-output failure.
114+
const handlers = createDaemonHandlers();
115+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
116+
const url = String(input);
117+
if (url === '/api/runs') return jsonResponse({ runId: 'run-1' });
118+
if (url === '/api/runs/run-1/events') {
119+
return sseResponse(
120+
'event: error\ndata: {"code":"AGENT_EXECUTION_FAILED","message":"Agent completed without producing any output.","retryable":true}\n\n' +
121+
'event: agent\ndata: {"type":"tool_use","id":"call_1","name":"write","input":{"filePath":"index.html"}}\n\n' +
122+
'event: agent\ndata: {"type":"tool_result","toolUseId":"call_1","content":"Wrote file successfully.","isError":false}\n\n' +
123+
'event: agent\ndata: {"type":"text_delta","delta":"Landing page saved to `index.html`."}\n\n' +
124+
'event: end\ndata: {"code":0,"status":"succeeded"}\n\n',
125+
);
126+
}
127+
if (url === '/api/runs/run-1') {
128+
return jsonResponse({ id: 'run-1', status: 'failed' });
129+
}
130+
throw new Error(`unexpected fetch ${url}`);
131+
});
132+
vi.stubGlobal('fetch', fetchMock);
133+
134+
await streamViaDaemon({
135+
agentId: 'opencode',
136+
history: [{ id: '1', role: 'user', content: 'make a landing page' }],
137+
systemPrompt: '',
138+
signal: new AbortController().signal,
139+
handlers,
140+
});
141+
142+
expect(handlers.onError).not.toHaveBeenCalled();
143+
expect(handlers.onDelta).toHaveBeenCalledWith('Landing page saved to `index.html`.');
144+
expect(handlers.onDone).toHaveBeenCalledWith('Landing page saved to `index.html`.');
145+
});
146+
108147
it('prefers a structured daemon error over the lifecycle exit fallback when the run later fails', async () => {
109148
const handlers = createDaemonHandlers();
110149
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {

0 commit comments

Comments
 (0)