Skip to content

[Bug] exec_command intermittently returns empty tool results with implausible wall_time (zero-output completion class) #2472

Description

@lidge-jun

Summary

During a live Codex Desktop session on 2026-08-24 (~14:00-14:40 KST, session 01a03224-c0f5-7a90-87ae-6c17ac691045), exec_command tool calls repeatedly surfaced empty tool results even though the calling agent emitted non-empty result text (text(r.output || JSON.stringify(r)) cannot produce empty output). Identical commands succeeded minutes later. The local shell itself is healthy — post-incident probes of plain echo, tty mode, stderr-only + exit codes all pass.

This looks like the same zero-output completion class as #2423 (OpenRouter Ox Alpha returns HTTP 200 but Codex App receives an empty completion): the turn's tool-result content is lost upstream/in-transit and Codex renders it as an empty result instead of a typed failure.

Evidence

  1. Empty-but-successful results: multiple consecutive exec_command calls returned no visible content; rerunning the exact same commands immediately after returned normal output. One probe used text(r.output || JSON.stringify(result)) — the fallback guarantees non-empty text, yet nothing rendered, so the loss is downstream of the agent (stream/transport), not the shell.

  2. Implausible wall_time_seconds telemetry on results that did arrive:

    • echo err-only >&2; exit 3 → exit 3, correct stderr, but wall_time_seconds: 0.000002875 (a real process spawn takes >= 5ms).
    • sleep 1; echo done → reported 0.85s vs JS-measured 1067ms (plausible), so timing is only broken on some terminal paths (observed on stderr-only terminals).
    • Several empty-output results also carried microsecond-scale wall_time_seconds, consistent with a response object assembled without the command ever running through the normal terminal path.
  3. Running bridge predates the zero-output mitigation: the serving process (PID 922, bun src/cli/index.ts start --port 10100) started 2026-08-23 22:59:12 KST, before:

    So the live process does not include fix(combos): fail over zero-output stream failures, recording each terminal once #2449's zero-output failover/preflight; a silent zero-output stream surfaces to the client as empty success instead of failing over or erroring.

Suspected root cause

Two distinct defects:

  1. Transport/upstream (primary): a provider turn completes with zero decoded content (HTTP 200, no frames or frame-loss) and the bridge treats it as a clean terminal. The client sees an empty tool result with no error. Same family as OpenRouter Ox Alpha returns HTTP 200 but Codex App receives an empty completion without retry #2423 and the silent/heartbeat-only stall class addressed by 994e5ba (T04) — but here the stream terminates cleanly-with-zero-output rather than hanging past the watchdog.
  2. Telemetry (secondary): wall_time_seconds is captured at the wrong point for at least the stderr-only terminal path (and appears on empty results), producing microsecond values that make incidents harder to triage.

Suggested next steps

Environment

  • OpenCodex dev @ 438b9cc (running process older than HEAD)
  • macOS arm64, Codex Desktop app-server + code-mode host
  • Provider route in use during incident: ocx/xai default with stealth/ox-alpha model

Activity

  1. lidge-jun commented on Aug 24, 2026

    @lidge-jun
    OwnerAuthor

    Correction / root-cause narrowing: Cursor-only local-shell ↔ host-shell alternation

    Reporter follow-up: the empty-result incidents happen only on the Cursor adapter path, specifically while Cursor's server-side agent alternates between its native local shell (shellArgs / shellStreamArgs, executed by the bridge locally) and the Codex host shell bridge (mcpArgs with OCX_RESPONSES_TOOL_PROVIDER, routed through the Responses bridge to Codex Desktop's exec_command). Sessions that stick to one channel do not show the symptom.

    Mechanism (source-verified)

    Both channels funnel into ONE shared event state in LiveCursorTransport.handleServerMessage:

    • Native exec: replies are written straight onto the Connect stream — live-transport.ts:1456-1457 (handleCursorNativeExec → writeConnectFrame).
    • Bridge-owned exec: planMcpArgsHandling (live-transport.ts:243) converts the frame into synthetic tool events keyed by args.toolCallId (fallback exec_<id>) and pushes them into the single Responses event pipeline.

    The failure surface when the agent flips channels mid-turn:

    1. Silent duplicate-callId drop — mapSyntheticMcpExecToToolEvents (protobuf-events.ts:1044) returns [] whenever state.completedToolCalls.has(callId). A replayed/re-delivered exec id (which happens when Cursor retries or re-announces a call after a native-exec interleave) produces ZERO events: no function_call_output reaches Codex, and if that was the turn's only payload, Codex renders an empty tool result with success status. This matches the observed "empty-but-successful" results exactly.
    2. Two flush paths, one turn lifetime — native exec bytes bypass push() and hit the wire immediately, while bridge tool events wait for the drained-finalizer grace timer (fcc3f5c closed one mixed-terminal race: clean END_STREAM with an open sibling). An interleaved sequence (native shell completes → bridge tool pending → turnEnded/grace finalization arms) can still finalize while the host-shell result is in flight; the result then has no live turn to attach to and is dropped.
    3. Amplified by stale runtime — the serving process started 2026-08-23 22:59 KST, so fix(tools): scope wait integer coercion to the bare wait tool #2448/fix(combos): fail over zero-output stream failures, recording each terminal once #2449 (zero-output failover, wait coercion scoping) were NOT live during the incident window; a dropped-output turn could not fail over and surfaced as silent emptiness.

    Suggested fix direction

    • Make the completed-callId branch of mapSyntheticMcpExecToToolEvents emit a typed diagnostic (or idempotent function_call_output) instead of returning []; silence here is indistinguishable from upstream frame loss.
    • Serialize native-exec direct writes and bridge tool-event finalization through one owner so a pending host-shell result can never outlive its turn (extend fcc3f5c's guard to cover native-exec interleaves, not just clean END_STREAM siblings).
    • After deploying current HEAD, add a regression test that drives a mixed sequence — shellStreamArgs → mcpArgs(shell bridge) → duplicate mcpArgs — and asserts every call yields exactly one non-empty function_call_output.

    The wall_time_seconds microsecond values remain a separate telemetry defect worth its own ticket.

  2. lidge-jun commented on Aug 24, 2026

    @lidge-jun
    OwnerAuthor

    리뷰 · 우선순위 62 / 80

    설명: 이 이슈는 실행 명령이 가끔 빈 도구 결과를 돌려준다는 것이다. 작성자는 주인 lidge-jun 이다. 오늘 열다섯 시 한 분에 열렸다. 도구 라벨이 붙어 있다. 본문은 처음엔 빈 완료 종류라고 적었다. 이어서 단 커서 어댑터에서만, 커서 제 껍데기와 코덱스 호스트 껍데기를 한 턴에서 섞을 때만 난다고 고쳤다. 한 길만 쓰는 일은 안 난다. 지금 CURRENT dev HEAD 는 c44e43f00 이다. 이번 시간에 origin/dev 는 438b9cc77 에서 여기로 왔다. 새 머지는 2469 와 2453 이다. package.json 은 2.27.0 이다. src/config.ts 는 3238줄이다. src/runtime 폴더는 지금 HEAD 에 없다. combo-stream-preflight.ts 는 171줄이고 HEAD 에 있다. gui/src/combo-workspace-data.ts 는 589줄이다. src/providers/default-aliases.ts 와 model-presets.ts 는 아직 없다. src/codex/history-provider.ts 는 1557줄이다. src/server/responses/fetch-helpers.ts 는 137줄이다. src/codex/history-manifest.ts 는 112줄이다.

    하려는 말은 이렇다. 커서가 자기 길 shellArgs / shellStreamArgs 로 직접 실행하는 길과, mcpArgs 에 OCX_RESPONSES_TOOL_PROVIDER 를 실어 코덱스 데스크톱 exec_command 로 넘기는 길이 있다. 두 길이 한 사건 상태에 모인다. 직접 실행은 답을 바로 커넥트 장에 쓴다. 다리 실행은 합성 도구 사건으로 바꿔 응답 파이프에 넣는다. 같은 호출 아이디가 다시 오면 completedToolCalls 에 이미 있어서 빈 배열을 돌려준다. 그때 함수 호출 결과가 코덱스에 안 간다. 그 턴의 알맹이가 그 결과뿐이면 코덱스는 성공인데 빈 칸으로 그린다. 살아 있는 세션에서 같은 명령을 바로 다시 치면 정상이었다.

    지금 HEAD 를 열었다. 작성자가 적은 줄 번호가 그대로다. src/adapters/cursor/live-transport.ts 243줄 planMcpArgsHandling 은 mcpArgs 이고 제공자 칸이 다리이면 합성 사건으로 바꾼다. 1456줄 handleCursorNativeExec 는 답을 기다린 뒤 1457줄에서 바로 writeConnectFrame 으로 쓴다. 그 길은 push 를 거치지 않는다. 1445줄 다리 사건은 push 로 들어간다. src/adapters/cursor/protobuf-events.ts 1044줄 mapSyntheticMcpExecToToolEvents 는 1055줄에서 끝난 호출 아이디면 빈 배열을 돌려준다. 글도 에러도 없다. 1102줄 recordToolCall 도 같은 집합이면 빈 배열이다. 2449 의 빈 에스에스 넘김은 이미 HEAD 에 있다. 그건 콤보 스트림 구멍이다. 이 이슈의 커서 섞임 길은 아니다. 2423 은 오픈라우터 빈 완료다. 제공자와 길이 다르다. 같은 이슈로 묶지 말 것.

    본문의 wall_time_seconds 는 이 저장소 src 안에 칸이 없다. 코덱스 데스크톱 호스트가 붙인 시간으로 보는 편이 맞다. 작성자도 따로 티켓이 맞다고 적었다. 이 번호에 섞지 말 것. 사고 당시 살아 있는 프로세스는 어제 밤 열 시 쉰아홉 분에 켜져서 2448 2449 가 없었다. 지금 HEAD 로 다시 켜도 1055줄 침묵 드롭은 남는다. 다시 켜는 것만으로 이 이슈를 닫지 말 것.

    사용자 길이로는 커서 한 턴에서 도구 결과가 빈 성공으로 사라져서 62. 고치는 PR 이 오기 전에는 열어 둔다. 2423 과 2426 과 2463 과 2464 과 2465 를 이 이슈로 닫지 말 것. 2451 은 이번 시간 2453 착지로 따로 닫는다. 2471 은 이미 닫힌 2426 복사본이다. 다시 열지 말 것. 카탈로그 팁은 Ox Alpha x-preview-f-free + deepseek-v4-flash-vision-exp. Cursor 정적 카탈로그는 opus-4-8-fast / opus-5-fast. 2334 CursorCredentialRouter 는 여전히 src/providers/cursor-pool.ts 모듈+테스트만 있고 어댑터에 연결되지 않았다. 2332 H2 는 discovery 전용. 2320 overflow + 2342 는 이미 dev. 2188 사이드카는 이미 dev. 2361 __omit__ 는 이미 HEAD 다. src/lib/bounded-body.ts 128줄은 이미 죽은 신호에서 취소 없이 던진다. 2453 이 착지해서 wait 허용 목록은 이제 밑줄 yield_time_ms 이다. types.ts/config.ts 가르기는 이 구멍을 바꾸지 않는다. 닫고 다시 밑지 말 것. 이 파일은 live-transport.ts 와 protobuf-events.ts 다.

    src/adapters/cursor/protobuf-events.ts 라인 1055 - 끝난 호출 아이디면 빈 배열이다. 진단도 결과도 없다
    src/adapters/cursor/protobuf-events.ts 라인 1102 - recordToolCall 도 같은 집합이면 빈 배열이다
    src/adapters/cursor/live-transport.ts 라인 1456 - 직접 실행은 push 를 건너뛰고 커넥트 장에 바로 쓴다
    src/adapters/cursor/live-transport.ts 라인 1445 - 다리 사건은 push 로 들어간다. 두 플러시 주인이 다르다
    src/adapters/cursor/live-transport.ts 라인 243 - planMcpArgsHandling 이 다리 mcpArgs 를 합성 사건으로 바꾼다
    src/server/responses/combo-stream-preflight.ts - 2449 빈 에스에스 넘김은 이미 HEAD 다. 이 커서 섞임 길이 아니다
    이슈 2423 - 오픈라우터 빈 완료다. 이 번호와 같은 구멍이 아니다. 닫지 말 것
    wall_time_seconds - 이 저장소 src 에 칸이 없다. 이 번호에 섞지 말 것

    메인테이너의 판단이 필요한 지점

    • 끝난 호출 아이디 가지에서 침묵할지. 침묵하지 말 것. 같은 결과이거나 짧은 진단이어야 한다
    • 직접 실행 쓰기와 다리 끝내기를 한 주인으로 묶을지. 묶는 편이 맞다. 호스트 껍데기 결과가 턴보다 늦게 오면 버려진다
    • 2449 로 이미 고쳤다고 볼지. 보지 말 것. 콤보 에스에스 구멍이다
    • 2423 과 한 이슈로 묶을지. 묶지 말 것. 제공자가 다르다
    • 시간 칸을 이 번호에 넣을지. 넣지 말 것. 호스트 쪽 칸이다
    • 지금 프로세스를 다시 켠 것만으로 닫을지. 닫지 말 것

    너의 추천
    열어 둔다. 지금 닫지 말 것. 고치는 PR 은 끝난 호출 아이디에서 빈 배열 대신 같은 결과나 짧은 진단을 내게 한다. 직접 실행 쓰기와 다리 끝내기는 한 주인으로 묶는다. 시험은 껍데기 장 다음에 다리 mcpArgs 다음에 같은 mcpArgs 를 다시 넣어, 호출마다 비지 않은 함수 호출 결과가 하나인지 잠근다. 2423 과 2426 과 2463 과 2464 과 2465 는 닫지 않는다. 2471 은 다시 열지 말 것. 호출 길을 넓히지 말 것. 라벨은 그대로 둔다. 프리뷰 배포가 아니다.

    이 댓글은 grok-bot이 작성했습니다

  3. lidge-jun commented on Aug 24, 2026

    @lidge-jun
    OwnerAuthor

    Investigated this for the v2.32.1 hotfix train. Short version: not reproduced on current dev, the issue stays open, and I want to record what was ruled out so the next attempt does not repeat it.

    The bridge-version theory is settled. Your point 3 was right — the serving process predated the mitigation. 88b7cc057 ("fix(combos): fail over zero-output stream failures") is now an ancestor of dev, and the regression you asked for exists and passes: tests/combo-stream-preflight.test.ts includes "converts a zero-output failed terminal into a retryable HTTP failure" (4 pass / 0 fail). So a restart alone changes the conditions the incident was observed under.

    A wrong lead, written down so nobody re-walks it. I built a regression around Cursor's native-exec/host-shell call-id dedupe, on the theory that a deduped duplicate could leave a turn ending done with nothing in it. That reproduces a real-looking empty turn, but it is the wrong side of the boundary and I discarded it. Two facts kill it:

    • planMcpArgsHandling deliberately ends turn 1 as done and cancels the Cursor run without a result — live-transport.ts:220-225 says the real result arrives on the next /v1/responses request as structured history. Your report is about output lost after the agent already produced non-empty text, which is downstream of that.
    • The empty-argument route only goes silent under allowEmptyArgs: false. The live bridge passes allowEmptyArgs: true (live-transport.ts:258), where malformed shell arguments produce an explicit error instead.

    The dedupe itself is correct and should stay: without it, each repeated tool_call_start becomes another Responses function_call output item, i.e. a duplicate execution request.

    What is genuinely open. There is no turn-wide semantic-output ledger. finalizeTurnEvents reports an error for a call left open at turn end, but says nothing for a turn that closed with zero output, and the bridge emits response.completed with an empty snapshot. The empty-completion-guard would catch that — but it is opt-in and defaults to false. So the protocol gap you describe is real if a reachable producer exists; I could not construct one on current dev.

    What would actually settle it. A reproduction at the function_call_output / next-request boundary: capture a request whose history carries a completed host tool result with non-empty text, and show where that text is dropped. The microsecond wall_time_seconds you measured is a strong hint — a result assembled without the command running the normal terminal path — and it is worth splitting into its own telemetry issue regardless of how this one lands, since it makes every future triage harder.

    Deliberately not doing two things here: not closing this on the strength of a fix that landed for a related symptom, and not turning every empty Cursor finalization into a typed error inside a bugfix-only release — that is a behavior change with retry and billing consequences that deserves its own unit.

  4. lidge-jun commented on Aug 25, 2026

    @lidge-jun
    OwnerAuthor

    Addressed on dev across #2597 and #2598, with one part explicitly out of scope.

    The earlier read on this issue leaned toward 'not an OpenCodex defect'. The envelope fields it named (wall_time_seconds, exec_command) really are host-owned — neither appears under src/ — but that was never evidence the proxy could not PRODUCE an empty successful turn. It can: a Cursor turnEnded with no text and no committed tool call finalizes to a bare done, a successful terminal carrying no content. #2598 pins that rather than assuming it.

    #2597 makes it visible. The empty-completion guard already handled this failure mode well, but it is opt-in, so by default the turn passed through untouched and the client recorded a silent success — the empty result with no trace. observeEmptyCompletion changes nothing about the stream and records the occurrence, naming the knob that enables recovery.

    I did not make the retry default-on: re-sending a turn that may already have had billable side effects is not an honest default, so the default is observability and emptyCompletionRetry: true remains the opt-in for recovery.

    Still not fixed, and not ours: the implausible wall_time_seconds reading. That belongs to the Codex host. If you still see empty results after this, the warning line now tells us whether OpenCodex was the producer — which is the piece that was missing when this was first investigated.

  5. added a commit that references this issue on Aug 26, 2026
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

    toolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions