FN-6594: classify Codex transcript desync as non-continuable

Recognize Codex transcript replay desync failures so completed tasks can recover instead of wedging.

- Add Codex transcript-desync matching to non-continuable session error detection.
- Cover canonical and symmetric missing function-call-output messages while excluding auth, quota, and ordinary bad-input errors.
- Add self-healing coverage for post-done wedges with Codex envelope and log evidence.

Files changed:
 .../post-done-continuation-no-wedge.test.ts        | 72 ++++++++++++++++++++++
 .../src/__tests__/transient-error-detector.test.ts | 24 +++++++-
 packages/engine/src/transient-error-detector.ts    |  7 ++-
 3 files changed, 101 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-6594

Fusion-Task-Lineage: 517f39c8-9bbb-47d2-a4a4-495eaf8c3fca
This commit is contained in:
gsxdsm
2026-06-17 15:11:55 -07:00
parent e064becba5
commit e48df026c6
3 changed files with 101 additions and 2 deletions

View File

@@ -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",

View File

@@ -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);
});
});

View File

@@ -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[] = [