fix(engine): retry Codex WebSocket transport drops as transient

pi-ai's openai-codex-responses provider surfaces ChatGPT-plan WebSocket
drops as bare "WebSocket error" / "WebSocket closed <code>". The
underlying ErrorEvent.error is dropped by extractWebSocketError (it only
inspects event.message), so the cause is gone by the time we see it.
The engine then propagated these as fatal failures instead of retrying
a transient network blip.

- Add WebSocket patterns to TRANSIENT_ERROR_PATTERNS so the executor's
  existing transient retry path picks them up.
- Tag the model id onto the thrown error and emit a structured warn in
  promptSessionAndCheck so future drops can be triaged by transport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-04 23:57:40 -07:00
parent f7afe31220
commit e5fc71b6eb
4 changed files with 39 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Treat pi-ai Codex WebSocket transport drops (`WebSocket error`, `WebSocket closed …`, `WebSocket stream closed before response.completed`) as transient errors so the engine retries them instead of marking the task failed. Tag the model id onto the thrown error and emit a structured warn so future drops can be triaged by which provider/model is unstable.

View File

@@ -92,6 +92,19 @@ describe("Transient Error Detector", () => {
expect(isTransientError("OPERATION WAS ABORTED")).toBe(true);
});
it("matches pi-ai Codex WebSocket transport drops", () => {
// Bare "WebSocket error" — pi-ai falls back to this when the ErrorEvent
// has no `message`. The diagnostic patch tags the model id onto it.
expect(isTransientError("WebSocket error")).toBe(true);
expect(isTransientError("WebSocket error (model=openai/gpt-5-codex)")).toBe(true);
// "WebSocket closed <code> <reason>" from extractWebSocketCloseError.
expect(isTransientError("WebSocket closed 1006")).toBe(true);
expect(isTransientError("WebSocket closed 1011 internal error")).toBe(true);
expect(isTransientError("WebSocket closed")).toBe(true);
// Half-open stream that ended before response.completed.
expect(isTransientError("WebSocket stream closed before response.completed")).toBe(true);
});
it("matches OpenAI/Codex structured server_error payloads", () => {
const message = `Codex error: {"type":"error","error":{"type":"server_error","code":"server_error","message":"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID 9349dabf-bcb7-4c36-aa40-f645dd04a472 in your message.","param":null},"sequence_number":2}`;
expect(isTransientError(message)).toBe(true);

View File

@@ -177,6 +177,18 @@ async function promptSessionAndCheck(session: AgentSession, prompt: string, opti
clearSessionStateError(session);
return;
}
// pi-ai's openai-codex-responses provider (Codex via ChatGPT-plan WebSocket)
// surfaces transport drops as bare "WebSocket error" / "WebSocket closed".
// The underlying ErrorEvent's `event.error` (cause/code) is dropped by
// pi-ai's `extractWebSocketError` (it only inspects `event.message`), so
// by the time we see the string the cause is gone. Tag the message with
// the model identity so retry/transient classification can at least tell
// which transport is unstable, and emit a structured warn for triage.
if (/^WebSocket (error|closed)\b/i.test(stateError) || /WebSocket stream closed before response\.completed/i.test(stateError)) {
const modelDesc = describeModel(session);
piLog.warn(`pi state error — Codex WebSocket transport drop (model=${modelDesc}): ${stateError}`);
throw new Error(`${stateError} (model=${modelDesc})`);
}
throw new Error(stateError);
}
}

View File

@@ -64,6 +64,15 @@ export const TRANSIENT_ERROR_PATTERNS: RegExp[] = [
/"type":"server_error"/i,
/"code":"server_error"/i,
/An error occurred while processing your request\./i,
// pi-ai openai-codex-responses WebSocket transport errors. The provider holds
// a long-lived WebSocket to the Codex backend; transient drops surface as
// bare "WebSocket error" / "WebSocket closed <code> <reason>" / a half-open
// stream that ended before `response.completed`. All three are network-layer
// hiccups, not task defects — retry them.
/WebSocket error\b/i,
/WebSocket closed\b/i,
/WebSocket stream closed before response\.completed/i,
];
/**