Skip to content

fix(ai): Codex todo_write turn still ends prompt_failed on request_timeout error event after complete function call #6301

Closed

Description

@probepark

Codex turns still end prompt_failed (-32603, phase post_start, retryability: terminal) when the relay sends a request_timeout error event about 900 s after the model emitted a complete todo_write function call. The salvage paths from #6256, #6274, #6284 and #6291 are all present in the build that fails, and the turn is still persisted as stopReason: "error".

Observed shape (production session, dev 03c88bb12 source)

Persisted assistant message (openai-codex / gpt-6.1-sol, SSE transport, openaiWebsockets: "off"):

content: [ {type:"thinking", thinking:"", thinkingSignature:<present>},
           {type:"toolCall", name:"todo_write", arguments:{ops:[{op:"init", list:[...complete...]}]}} ]
stopReason: "error"
duration: 902222
usage: all zero
errorMessage: "Codex error event: stream error: stream disconnected before completion: stream closed before response.completed (code=request_timeout)"

Next entries: toolResult todo_write isError=true -> todo-write-error-reminder -> sdk_prompt_terminal_failed (provenance: agent_failed, assistantStopReason: error).

Why the existing salvage does not catch it (code reading only, not reproduced)

processCodexResponseStream -> handleCodexStreamEvent throws createCodexProviderStreamError(rawEvent) for the error event. trySalvageCodexFinalizedToolCalls then gets an error for which isCodexTransientStreamClose should be true (code=request_timeout + "stream disconnected before completion"). The tool-call arguments are a complete JSON object. So one of the other guards must be refusing:

  • runtime.toolArgumentCorrelationFailed (argument events whose item_id does not match the active item)
  • argumentsComplete !== true or missing sourceItemId / sourceCallId on the block
  • runtime.currentBlock / currentItem in a state not covered (for example a new item opened after the function call)

Which one it is cannot be determined from the persisted session, because the raw event sequence is not recorded and there is no debug log on the refusal path.

Proposal

  1. Add a replay test in packages/ai/test/openai-codex-stream.test.ts that feeds the production order over SSE: reasoning item (empty summary, encrypted content) -> function_call todo_write with argument deltas -> (variants: with/without function_call_arguments.done, with/without output_item.done, with an extra item opened after the call) -> {type:"error", error:{code:"request_timeout", message:"stream error: stream disconnected before completion: stream closed before response.completed"}}. Find which variant ends error on current dev.
  2. Fix the guard that refuses that variant, and keep existing fail-closed cases (partial arguments, non-empty text, non-transient codes) as error.
  3. If every variant already salvages on dev, report that and add a GJC_OPENAI_CODE_DEBUG log on the refusal branch with the guard inputs, so the next field occurrence shows which guard refused.

Timeouts, retry counts and model/preset defaults stay unchanged.

Activity

  1. added
    bugSomething isn't working
    P1High priority
    on Oct 3, 2026
  2. probepark commented on Oct 3, 2026

    @probepark
    CollaboratorAuthor

    Triage: A (handing to a coder) · bug · P1

    Evidence: a production session on a runner whose session host runs dev 03c88bb12 (includes #6256, #6274, #6284 and #6291) persisted [thinking(signed, empty), toolCall todo_write(complete args)] with stopReason: "error", duration 902 s and a request_timeout error event. There were 7 such turns between 21:45Z and 23:10Z. All of them ended prompt_failed with zero commits.

    Root-cause candidates (from reading the dev source; not reproduced yet):

    • packages/ai/src/providers/openai-codex-responses.ts:1294 trySalvageCodexFinalizedToolCalls guards canSalvageCompleteArguments (L1341-1353): toolArgumentCorrelationFailed, argumentsComplete/sourceItemId/sourceCallId on the block (set in createOutputBlockForItem L1647-1677 and handleToolCallArgumentsDelta L1848-1870), and the currentBlock/currentItem state check.
    • isActiveToolArgumentEvent L1875: an item_id mismatch here sets toolArgumentCorrelationFailed, and after that every salvage path is refused.

    Next step: a replay test over SSE that finds the variant that still ends in error on dev, then a fix for that guard only. No timeout, retry or preset changes. If no variant fails on dev, add a debug log on the refusal branch so the next field occurrence shows which guard refused.

  3. probepark commented on Oct 4, 2026

    @probepark
    CollaboratorAuthor

    Two more production turns on home2 show the same shape after #6291 was installed. One of them also ran after #6302 was installed.

    session turn start (UTC) build at start duration persisted content function_call item id time usage
    27c9bf31 2026-10-04 00:52:15 33a29331e7 (has #6256, #6274, #6284, #6291; lacks #6302) 901.9 s thinking("" , signed rs_…), toolCall todo_write (complete args, 316 B) 00:52:21 (6 s into the turn) all zero
    2b31881c 2026-10-04 02:43:19 61f2e17243 (also has #6302, 2fc2e2b08a) 901.8 s thinking("" , signed rs_…), toolCall todo_write (complete args, 509 B) 02:43:27 (8 s into the turn) all zero

    Both turns:

    • stopReason: "error", errorMessage: "Codex error event: stream error: stream disconnected before completion: stream closed before response.completed (code=request_timeout)", SSE transport (openai-codex-responses).
    • No message item and no second reasoning item were persisted. The function call was complete about 6 to 8 s after the turn started. The relay then sent nothing for about 895 s and ended with request_timeout.
    • The todo_write toolResult is isError: true with the same Codex error text, followed by a todo-write-error-reminder. The SDK reconciliation record is prompt_failed, phase: post_start.
    • The gjc log for these pids has no salvage refused line. On these builds the refusal goes through logCodexDebug, which needs CODEX_DEBUG. That is why the refusal fields were not captured.

    Conclusion: by persisted shape this is salvageable, because the content is a signed reasoning item plus a complete function call. The salvage did not fire, and this is a salvage miss, not a server-side timeout on an unfinished call. The reason for the refusal needs the logger.warn output from #6308, which merged at 2026-10-04 07:57Z (39f68549). The next measurement on home2 will capture the refusal fields from a build that includes 39f68549.

    Volume on the mac runner side: 40 of the 74 NO_COMMITS runs since 2026-10-03 05:35Z start with [Think] Failed: todo_write and prompt_failed/post_start. Source: pod-side copies in /tmp/mac-agent-out-*.txt.

  4. probepark commented on Oct 4, 2026

    @probepark
    CollaboratorAuthor

    Recurrence count after #6302 (merged 2026-10-04 02:06Z as 61f2e172).

    Metric: agent runs that ended NO_COMMITS with todo_write failed and prompt_failed / phase: post_start, counted across all runners.

    window (UTC) runs runner with most hits
    10-03 20:06 to 10-04 02:06 (before merge) 18 17 on one runner
    10-04 02:31 to 08:31 (after merge and install) 26 25 on the same runner

    Status: the root cause is still pending. The refusal fields will come from the first failing turn on a build that includes #6308.

  5. probepark commented on Oct 5, 2026

    @probepark
    CollaboratorAuthor

    #6308(39f68549) 이후 바이너리로 [codex] codex stream close salvage refused warn 을 측정했다. home2 에서 7건이 나왔고 work 와 studio 는 0건이다. 측정 구간은 각 맥 ~/.gjc/logs/gjc.2026-10-05.log 의 처음부터 2026-10-05T01:48Z 까지다.

    pid 시각 (KST) argumentsComplete currentItem/Block finalizedToolCallIds content recentEvents (마지막 16개) 짝지은 sdk_prompt_terminal_failed
    94984 02:10:13 [false] function_call/toolCall 0 thinking,toolCall args.delta x15 → error 02:10:13.108 request_timeout
    27132 03:12:45 [false] function_call/toolCall 0 thinking,toolCall args.delta x15 → error 03:12:45.184 request_timeout
    36980 03:47:47 [false] function_call/toolCall 0 thinking,toolCall args.delta x15 → error 03:47:47.993 request_timeout
    11332 04:12:16 [false] function_call/toolCall 0 thinking,toolCall args.delta x15 → error 04:12:16.099 request_timeout
    82763 04:24:24 [false] function_call/toolCall 0 thinking,toolCall args.delta x15 → error 04:24:24.216 request_timeout
    66572 04:29:54 [false] function_call/toolCall 0 thinking,toolCall args.delta x15 → error 04:29:55.020 request_timeout
    78063 06:27:24 [false] function_call/toolCall 0 thinking,toolCall args.delta x15 → error 06:27:24.577 request_timeout

    7건 모두 toolArgumentCorrelationFailed=false, sourceItemId/sourceCallId 는 있다.

    판정:

    • 거부 이유는 7건 모두 같다. 함수 호출 인자를 스트리밍하는 도중(delta 1~20자)에 서버가 request_timeout error 이벤트로 스트림을 닫았다. 인자가 완성되지 않았으므로 trySalvageCodexFinalizedToolCalls 의 거부는 맞다. salvage 조건을 풀면 안 된다.
    • 7건 모두 프로세스 첫 로그부터 실패까지 약 15분이다(01:55→02:10, 02:57→03:12, 03:32→03:47, 03:57→04:12, 04:09→04:24, 04:14→04:29, 06:12→06:27). 서버 쪽 요청 시간 상한에 걸린 것으로 보인다. 이 판정은 로그 시각에서 추론한 것이고 서버 상한 값은 확인하지 못했다.
    • 남은 결함은 복구 경로다. recoverCodexStreamError → tryRetryCodexProviderError 는 context.output.content.length > 0 이면 재시도하지 않는다. 그래서 실행되지 않은 미완성 tool call 하나 때문에 턴이 prompt_failed 로 끝난다.

    후속: 미완성 인자 + request_timeout 일 때 부분 출력을 버리고 1회 재시도하는 수정을 별도 PR 로 낸다.

  6. probepark commented on Oct 6, 2026

    @probepark
    CollaboratorAuthor

    New data from one linux host (tank) on 2026-10-06, gjc/0.18.7 built from dev d66256dd1. This answers the open question in the proposal: which guard refuses.

    • 29 [codex] codex stream close salvage refused lines today. Every sampled one has argumentsComplete:[false], toolArgumentCorrelationFailed:false, currentItemType:"function_call", finalizedToolCallIds:0, errorCode:"request_timeout".
    • recentEvents is a run of response.function_call_arguments.delta with deltaLength: 1 (one byte per event) for the same fc_… item. The stream is never idle, so the 120 s idle watchdog (GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS) never fires. The turn ends only when the relay closes it with request_timeout, about 900 s later. The arguments are truncated, so refusing salvage is correct here.
    • Paired timeline, session f1a64daf-a115-4336-8c5e-c824bd019b70:
      • 13:32:38Z prompt
      • 13:43:18Z ACP acp_prompt_watchdog_expired (silenceMs 640001, lastFrameType:"agent_start"). No frame reached ACP while the call streamed.
      • 13:47:40Z salvage refused (request_timeout)
      • 13:48:11Z first assistant message (gpt-6.1-sol, todo_write) after the retry. The retry worked right away.
    • Of today's 52 ACP watchdog expiries on this host, 32 have lastFrameType:"agent_start". Each agent_start expiry is followed by a salvage refused about 4 m 20 s later. The ACP client gives up first. Our runner then records the run as "no commits" while the session is still working.

    Suggestion, in addition to the proposal: add a total-duration or progress bound for a single function-call argument stream, so a 1-byte trickle cannot hold a turn for 900 s. Also emit an ACP frame (tool-call start/progress) while arguments stream, so agent_start is not the last frame for 10+ minutes. Timeouts and retry counts on our side are unchanged.

  7. Yeachan-Heo commented on Oct 7, 2026

    @Yeachan-Heo
    Owner

    Resolved on dev by PR #6348, merged as ba4c77657208. It adds a bounded one-shot retry for transient request_timeout when Codex tool-call arguments are still incomplete, rather than ending the turn as prompt_failed with an unexecuted partial call.

    The post-merge reproduction in the latest report confirms argumentsComplete: false and shows the retry followed by a successful todo_write assistant response. CI on exact PR head 433b01197f45d99039c5ced08b267a61def20b11 passed, including both affected AI test jobs. No additional patch is needed.

    —
    [repo owner's gaebal-gajae (clawdbot) 🦞]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High prioritybugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions