Files
PaperClipAI/packages/adapters/codex-local/src/server/parse.test.ts
T
DottaandPaperclip 665408c6d0 fix(codex): classify mid-turn harness crashes structurally as retriable infra (#10210)
## Thinking Path

> - Paperclip is the open source control plane people use to manage AI
agents and their work
> - Heartbeat execution relies on adapters distinguishing agent failures
from failures in the harness running beneath the agent
> - Codex MCP transport crashes can kill the CLI after the JSONL
protocol has started but before it emits a protocol-terminal event
> - Those interrupted streams were left unclassified, so the control
plane terminalized the heartbeat as `heartbeat_failed` / `agent_failure`
with no continuation
> - Agent-level failure is already expressible through the JSONL
protocol via an `error` event, `turn.failed`, or `turn.completed`, so an
interrupted nonzero exit can be classified structurally without
inspecting unstable error strings
> - This pull request reports that shape as `codex_harness_crash` in the
`transient_upstream` family and routes it through Paperclip's existing
bounded retry and recovery-continuation paths
> - The benefit is that transient Codex harness failures recover safely
without misclassifying quoted agent output or depending on
transport-specific wording

## Linked Issues or Issue Description

- **What happened:** Codex MCP transport failures, including rmcp worker
death, could terminate the CLI mid-turn after protocol output began but
before any terminal JSONL event. The run then became an unclassified
terminal heartbeat failure with `continuationCount: 0`; this occurred in
3 of 44 L3 Codex-lane trials during the associated benchmark
investigation.
- **Expected behavior:** a nonzero Codex exit after the protocol starts
but before an `error`, `turn.failed`, or `turn.completed` event should
be treated as a harness/infrastructure crash and enter the existing
bounded retry policy.
- **Why structural classification:** transport error strings vary, and
stdout may quote agent output that merely discusses network failures.
The protocol boundary identifies whether the agent itself produced a
terminal result without regex matching.
- **Recovery behavior:** `codex_harness_crash` maps to `errorFamily:
transient_upstream`, using the existing `same_session` →
`safer_invocation` → `fresh_session` ladder plus the
recovery-continuation transient-infrastructure path.
- Supersedes the regex-based approach in #10150, which is closed.

## What Changed

- Added protocol-state tracking that identifies a nonzero exit after
protocol start and before any protocol-terminal event as
`codex_harness_crash`.
- Propagated the structural classification as `transient_upstream`
through the Codex adapter.
- Added parse unit coverage, including a faithful crash-shaped stream,
without matching stderr transport strings.
- Added adapter execution coverage using a fake Codex process that emits
a protocol prefix and then dies with the observed rmcp stderr line.
- Added heartbeat bounded-retry coverage, including the `errorCode`-only
fallback, and recovery-continuation classification coverage.

## Verification

- `parse.test.ts` — 16 passed.
- `codex-local-execute.test.ts` — 16 passed.
- `heartbeat-retry-scheduling.test.ts` — 30 passed.
- `service.pause-durability.test.ts` — 6 passed.
- Server and Codex adapter TypeScript checks passed.
- The branch commit is unchanged from the tested and pushed `88f5464d40`
handoff.

## Risks

- Low risk: the classification requires a nonzero exit after protocol
start and before any protocol-terminal event, so normal agent-declared
failures and completed turns keep their existing behavior.
- The change intentionally broadens recovery for structurally
interrupted Codex runs; bounded retry limits still prevent indefinite
continuation loops.
- No schema, migration, public API, UI, lockfile, or workflow changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent. The exact runtime model ID and
context-window size were not exposed by the execution environment;
capabilities used for the implementation included repository analysis,
reasoning, code editing, and terminal-based test execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
— the pre-existing, already-pushed branch name was explicitly prescribed
for this replacement PR
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-24 16:40:47 -05:00

250 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
classifyCodexAuthRefreshFailure,
extractCodexRetryNotBefore,
isCodexHarnessCrash,
isCodexProviderQuotaError,
isCodexTransientUpstreamError,
isCodexUnknownSessionError,
parseCodexJsonl,
} from "./parse.js";
describe("parseCodexJsonl", () => {
it("captures session id, assistant summary, usage, and error message", () => {
const stdout = [
JSON.stringify({ type: "thread.started", thread_id: "thread_123" }),
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "Recovered response" },
}),
JSON.stringify({
type: "turn.completed",
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 },
}),
JSON.stringify({ type: "turn.failed", error: { message: "resume failed" } }),
].join("\n");
expect(parseCodexJsonl(stdout)).toEqual({
sessionId: "thread_123",
summary: "Recovered response",
usage: {
inputTokens: 10,
cachedInputTokens: 2,
outputTokens: 4,
},
usageBasis: "per_run",
errorMessage: "resume failed",
sawProtocolEvent: true,
sawProtocolTerminalEvent: true,
});
});
it("uses the last agent message as the summary when commentary updates precede the final answer", () => {
const stdout = [
JSON.stringify({ type: "thread.started", thread_id: "thread_123" }),
JSON.stringify({
type: "item.completed",
item: { type: "reasoning", text: "Checking the heartbeat procedure" },
}),
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "I’m checking out the issue and reading the docs now." },
}),
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "Fixed the issue and verified the targeted tests pass." },
}),
JSON.stringify({
type: "turn.completed",
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 },
}),
].join("\n");
expect(parseCodexJsonl(stdout)).toEqual({
sessionId: "thread_123",
summary: "Fixed the issue and verified the targeted tests pass.",
usage: {
inputTokens: 10,
cachedInputTokens: 2,
outputTokens: 4,
},
usageBasis: "per_run",
errorMessage: null,
sawProtocolEvent: true,
sawProtocolTerminalEvent: true,
});
});
});
describe("isCodexHarnessCrash", () => {
const crashedMidTurnStream = [
JSON.stringify({ type: "thread.started", thread_id: "thread_123" }),
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "Checking out the issue now." },
}),
JSON.stringify({ type: "item.started", item: { type: "command_execution" } }),
].join("\n");
it("classifies a nonzero exit with no protocol-terminal event as a harness crash", () => {
const parsed = parseCodexJsonl(crashedMidTurnStream);
expect(parsed.sawProtocolEvent).toBe(true);
expect(parsed.sawProtocolTerminalEvent).toBe(false);
expect(isCodexHarnessCrash({ exitCode: 1, ...parsed })).toBe(true);
});
it("does not classify runs whose turn reached a protocol-terminal event", () => {
const failedInProtocol = parseCodexJsonl(
[
JSON.stringify({ type: "thread.started", thread_id: "thread_123" }),
JSON.stringify({ type: "turn.failed", error: { message: "the model rejected the request" } }),
].join("\n"),
);
expect(isCodexHarnessCrash({ exitCode: 1, ...failedInProtocol })).toBe(false);
const completedThenFailedExit = parseCodexJsonl(
[
JSON.stringify({ type: "thread.started", thread_id: "thread_123" }),
JSON.stringify({
type: "turn.completed",
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 },
}),
].join("\n"),
);
expect(isCodexHarnessCrash({ exitCode: 1, ...completedThenFailedExit })).toBe(false);
});
it("does not classify successful exits or streams that never spoke the protocol", () => {
expect(isCodexHarnessCrash({ exitCode: 0, ...parseCodexJsonl(crashedMidTurnStream) })).toBe(false);
expect(isCodexHarnessCrash({ exitCode: null, ...parseCodexJsonl(crashedMidTurnStream) })).toBe(false);
const neverStarted = parseCodexJsonl("error: unexpected argument '--bogus-flag'\n");
expect(neverStarted.sawProtocolEvent).toBe(false);
expect(isCodexHarnessCrash({ exitCode: 2, ...neverStarted })).toBe(false);
});
it("stays structural: agent output discussing network errors does not affect classification", () => {
const parsed = parseCodexJsonl(
[
JSON.stringify({ type: "thread.started", thread_id: "thread_123" }),
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "The deploy failed with connection reset by peer; investigating." },
}),
JSON.stringify({ type: "turn.failed", error: { message: "agent gave up" } }),
].join("\n"),
);
expect(isCodexHarnessCrash({ exitCode: 1, ...parsed })).toBe(false);
expect(
isCodexTransientUpstreamError({
stdout: "connection reset by peer while running the deploy",
errorMessage: "agent gave up",
}),
).toBe(false);
});
});
describe("classifyCodexAuthRefreshFailure", () => {
it("classifies explicit refresh-token failure messages", () => {
expect(classifyCodexAuthRefreshFailure({ errorMessage: "provider error: refresh_token_reused" })).toBe(
"refresh_token_reused",
);
expect(classifyCodexAuthRefreshFailure({ stderr: "OAuth failed: refresh token has expired" })).toBe(
"refresh_token_expired",
);
expect(classifyCodexAuthRefreshFailure({ stdout: "OAuth failed: invalid_grant" })).toBe(
"refresh_token_invalidated",
);
expect(classifyCodexAuthRefreshFailure({ errorMessage: "credential refresh returned 401 Unauthorized" })).toBe(
"refresh_token_invalidated",
);
});
it("does not classify bare 401 or quota messages as auth-refresh failures", () => {
expect(classifyCodexAuthRefreshFailure({ errorMessage: "chatgpt wham api returned 401" })).toBeNull();
expect(classifyCodexAuthRefreshFailure({ errorMessage: "You've hit your usage limit for GPT-5." })).toBeNull();
});
});
describe("isCodexUnknownSessionError", () => {
it("detects the current missing-rollout thread error", () => {
expect(
isCodexUnknownSessionError(
"",
"Error: thread/resume: thread/resume failed: no rollout found for thread id d448e715-7607-4bcc-91fc-7a3c0c5a9632",
),
).toBe(true);
});
it("still detects existing stale-session wordings", () => {
expect(isCodexUnknownSessionError("unknown thread id", "")).toBe(true);
expect(isCodexUnknownSessionError("", "state db missing rollout path for thread abc")).toBe(true);
expect(isCodexUnknownSessionError("", "state db returned stale rollout path for thread abc")).toBe(true);
});
it("does not classify unrelated Codex failures as stale sessions", () => {
expect(isCodexUnknownSessionError("", "model overloaded")).toBe(false);
});
});
describe("isCodexTransientUpstreamError", () => {
it("classifies the remote-compaction high-demand failure as transient upstream", () => {
expect(
isCodexTransientUpstreamError({
errorMessage:
"Error running remote compact task: We're currently experiencing high demand, which may cause temporary errors.",
}),
).toBe(true);
expect(
isCodexTransientUpstreamError({
stderr: "We're currently experiencing high demand, which may cause temporary errors.",
}),
).toBe(true);
});
it("classifies usage-limit windows as provider quota and extracts the retry time", () => {
const errorMessage = "You've hit your usage limit for GPT-5.3-Codex-Spark. Switch to another model now, or try again at 11:31 PM.";
const now = new Date(2026, 3, 22, 22, 29, 2);
expect(isCodexProviderQuotaError({ errorMessage })).toBe(true);
expect(isCodexTransientUpstreamError({ errorMessage })).toBe(false);
expect(extractCodexRetryNotBefore({ errorMessage }, now)?.getTime()).toBe(
new Date(2026, 3, 22, 23, 31, 0, 0).getTime(),
);
});
it("classifies model-capacity messages as provider quota without reset metadata", () => {
const errorMessage = "The requested model is at capacity. Please try again later.";
expect(isCodexProviderQuotaError({ errorMessage })).toBe(true);
expect(isCodexTransientUpstreamError({ errorMessage })).toBe(false);
expect(extractCodexRetryNotBefore({ errorMessage })).toBeNull();
});
it("parses explicit timezone hints on usage-limit retry windows", () => {
const errorMessage = "You've hit your usage limit for GPT-5.3-Codex-Spark. Switch to another model now, or try again at 11:31 PM (America/Chicago).";
const now = new Date("2026-04-23T03:29:02.000Z");
expect(extractCodexRetryNotBefore({ errorMessage }, now)?.toISOString()).toBe(
"2026-04-23T04:31:00.000Z",
);
});
it("does not classify deterministic compaction errors as transient", () => {
expect(
isCodexTransientUpstreamError({
errorMessage: [
"Error running remote compact task: {",
' "error": {',
' "message": "Unknown parameter: \'prompt_cache_retention\'.",',
' "type": "invalid_request_error",',
' "param": "prompt_cache_retention",',
' "code": "unknown_parameter"',
" }",
"}",
].join("\n"),
}),
).toBe(false);
});
});