diff --git a/packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts b/packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts index 57f840621d..8f7d90e73f 100644 --- a/packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts @@ -301,6 +301,78 @@ describe("FN-5866 reliability interactions: post-done continuation no wedge", () manager.stop(); }); + it("self-heals Codex transcript-desync post-done wedges without swallowing generic 400s", async () => { + const codexEnvelope = "Codex error: " + JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + message: "No tool call found for function call output with call_id call_2KewW55MyBgwZoNtMubFNpUb.", + param: "input", + }, + status: 400, + }); + const symmetricLogEvidence = "No function call found for function call output with call_id call_2KewW55MyBgwZoNtMubFNpUb."; + const envelopeWedged = makeTask({ + id: "FN-6594-CODEX-ENVELOPE-WEDGED", + column: "in-review", + status: "failed", + error: codexEnvelope, + steps: [{ name: "Implement", status: "done" as const }], + log: [{ timestamp: new Date(Date.now() - 60_000).toISOString(), action: "Task marked done by agent" } as any], + }); + const logEvidenceWedged = makeTask({ + id: "FN-6594-CODEX-LOG-WEDGED", + column: "in-review", + status: "failed", + error: "Session failed while replaying transcript", + steps: [{ name: "Implement", status: "done" as const }], + log: [ + { timestamp: new Date(Date.now() - 60_000).toISOString(), action: "Task marked done by agent" } as any, + { + timestamp: new Date(Date.now() - 30_000).toISOString(), + action: "executor post-done continuation failed", + outcome: symmetricLogEvidence, + } as any, + ], + }); + const badInputWedged = makeTask({ + id: "FN-6594-BAD-INPUT-WEDGED", + column: "in-review", + status: "failed", + error: "400 invalid_request_error: invalid temperature", + steps: [{ name: "Implement", status: "done" as const }], + log: [ + { timestamp: new Date(Date.now() - 60_000).toISOString(), action: "Task marked done by agent" } as any, + { timestamp: new Date(Date.now() - 30_000).toISOString(), action: "quota exceeded" } as any, + ], + }); + const store = createSelfHealingStore([envelopeWedged, logEvidenceWedged, badInputWedged]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" }); + + expect(await manager.recoverPostDoneNonContinuableWedge()).toBe(2); + + for (const recovered of [envelopeWedged, logEvidenceWedged]) { + expect(recovered.column).toBe("in-review"); + expect(recovered.status).toBeUndefined(); + expect(recovered.error).toBeUndefined(); + expect(recovered.completionHandoffLimboRecoveryCount).toBe(1); + expect( + (recovered.log ?? []).some((entry: any) => entry.action.includes("Auto-recovered completed-task non-continuable wedge")), + ).toBe(true); + expect( + ((store as any).__audits as any[]).some( + (event: any) => event.mutationType === "task:auto-recover-post-done-noncontinuable-wedge" && event.target === recovered.id, + ), + ).toBe(true); + } + + expect(badInputWedged.status).toBe("failed"); + expect(badInputWedged.error).toBe("400 invalid_request_error: invalid temperature"); + expect(badInputWedged.completionHandoffLimboRecoveryCount).toBeUndefined(); + expect(((store as any).__audits as any[]).some((event: any) => event.target === badInputWedged.id)).toBe(false); + manager.stop(); + }); + it("falls through to terminal failure after the non-continuable fresh-session retry budget is exhausted", async () => { const task = makeTask({ id: "FN-5866-INCOMPLETE-EXHAUSTED", diff --git a/packages/engine/src/__tests__/transient-error-detector.test.ts b/packages/engine/src/__tests__/transient-error-detector.test.ts index cc8a6328ad..80de2ab1d9 100644 --- a/packages/engine/src/__tests__/transient-error-detector.test.ts +++ b/packages/engine/src/__tests__/transient-error-detector.test.ts @@ -316,13 +316,35 @@ describe("Transient Error Detector", () => { .toBe(true); }); - it("returns false for unrelated and provider role-validation errors", () => { + it("returns true for Codex transcript-desync errors", () => { + expect( + isNonContinuableSessionError( + "No tool call found for function call output with call_id call_2KewW55MyBgwZoNtMubFNpUb.", + ), + ).toBe(true); + expect( + isNonContinuableSessionError( + 'Codex error: {"type":"error","error":{"type":"invalid_request_error","message":"No tool call found for function call output with call_id call_2KewW55MyBgwZoNtMubFNpUb.","param":"input"},"status":400}', + ), + ).toBe(true); + expect( + isNonContinuableSessionError( + "No function call found for function call output with call_id call_2KewW55MyBgwZoNtMubFNpUb.", + ), + ).toBe(true); + }); + + it("returns false for unrelated, operator-actionable, and ordinary bad-input errors", () => { expect(isNonContinuableSessionError("socket hang up")).toBe(false); expect( isNonContinuableSessionError( "developer is not one of ['system', 'assistant', 'user', 'tool', 'function'] - 'messages.[0].role'", ), ).toBe(false); + expect(isNonContinuableSessionError("invalid api key")).toBe(false); + expect(isNonContinuableSessionError("quota exceeded")).toBe(false); + expect(isNonContinuableSessionError("billing issue: quota exceeded")).toBe(false); + expect(isNonContinuableSessionError("400 invalid_request_error: invalid temperature")).toBe(false); }); }); diff --git a/packages/engine/src/transient-error-detector.ts b/packages/engine/src/transient-error-detector.ts index 1c86a25c1c..3362fe041a 100644 --- a/packages/engine/src/transient-error-detector.ts +++ b/packages/engine/src/transient-error-detector.ts @@ -193,6 +193,11 @@ export function extractMissingModulePath(errorMessage: string): string | null { const UNSUPPORTED_MESSAGE_ROLE_PATTERN = /\bmessages\.\[\d+\]\.role\b[\s\S]*\bis not one of\b|\bis not one of\b[\s\S]*\bmessages\.\[\d+\]\.role\b/i; const NON_CONTINUABLE_SESSION_PATTERN = /cannot continue from message role\s*[:=-]?\s*(?:['"`]?)(assistant|tool|function|system|user)(?:['"`]?)\b/i; +/* +FNXC:Reliability-ErrorClassification 2026-06-17-14:48: +FN-6594 treats Codex transcript-desync on post-done session re-entry as non-continuable when a `function_call_output` is replayed without its `function_call`, or the symmetric function-call/output pair is missing. Anchor on the original `No tool call found for function call output with call_id ...` symptom so executor fresh-session retry and self-healing post-done wedge recovery engage without swallowing generic 400/auth/quota errors. +*/ +const CODEX_TRANSCRIPT_DESYNC_NON_CONTINUABLE_PATTERN = /\bno\s+(?:tool\s+call|function\s+call)\s+found\s+for\s+function\s+call\s+output\b/i; const MODEL_AUTH_TIER_INCOMPATIBILITY_PATTERNS: RegExp[] = [ // Codex ChatGPT-account auth-tier incompatibility: the model is valid, but // unavailable for the current auth tier. @@ -228,7 +233,7 @@ export function isNonContinuableSessionError(errorMessage: string): boolean { if (!errorMessage || typeof errorMessage !== "string") { return false; } - return NON_CONTINUABLE_SESSION_PATTERN.test(errorMessage); + return NON_CONTINUABLE_SESSION_PATTERN.test(errorMessage) || CODEX_TRANSCRIPT_DESYNC_NON_CONTINUABLE_PATTERN.test(errorMessage); } const OPERATOR_ACTIONABLE_AGENT_ERROR_PATTERNS: RegExp[] = [