Repository navigation
[Bug] exec_command intermittently returns empty tool results with implausible wall_time (zero-output completion class) #2472
Description
Activity
- addedtoolstool_calls, MCP, web-search / sidecar toolstool_calls, MCP, web-search / sidecar tools
on Aug 24, 2026 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 (mcpArgswithOCX_RESPONSES_TOOL_PROVIDER, routed through the Responses bridge to Codex Desktop'sexec_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 byargs.toolCallId(fallbackexec_<id>) and pushes them into the single Responses event pipeline.
The failure surface when the agent flips channels mid-turn:
- Silent duplicate-callId drop —
mapSyntheticMcpExecToToolEvents(protobuf-events.ts:1044) returns[]wheneverstate.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: nofunction_call_outputreaches 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. - 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. - 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
mapSyntheticMcpExecToToolEventsemit a typed diagnostic (or idempotentfunction_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)→ duplicatemcpArgs— and asserts every call yields exactly one non-emptyfunction_call_output.
The
wall_time_secondsmicrosecond values remain a separate telemetry defect worth its own ticket.- Native exec: replies are written straight onto the Connect stream —
리뷰 · 우선순위 62 / 80
설명: 이 이슈는 실행 명령이 가끔 빈 도구 결과를 돌려준다는 것이다. 작성자는 주인 lidge-jun 이다. 오늘 열다섯 시 한 분에 열렸다. 도구 라벨이 붙어 있다. 본문은 처음엔 빈 완료 종류라고 적었다. 이어서 단 커서 어댑터에서만, 커서 제 껍데기와 코덱스 호스트 껍데기를 한 턴에서 섞을 때만 난다고 고쳤다. 한 길만 쓰는 일은 안 난다. 지금 CURRENT
devHEAD 는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이 작성했습니다
- added a commit that references this issue
on Aug 24, 2026 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 ofdev, and the regression you asked for exists and passes:tests/combo-stream-preflight.test.tsincludes "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
donewith 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:planMcpArgsHandlingdeliberately ends turn 1 asdoneand cancels the Cursor run without a result — live-transport.ts:220-225 says the real result arrives on the next/v1/responsesrequest 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 passesallowEmptyArgs: 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_startbecomes another Responsesfunction_calloutput item, i.e. a duplicate execution request.What is genuinely open. There is no turn-wide semantic-output ledger.
finalizeTurnEventsreports an error for a call left open at turn end, but says nothing for a turn that closed with zero output, and the bridge emitsresponse.completedwith an empty snapshot. Theempty-completion-guardwould 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 currentdev.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 microsecondwall_time_secondsyou 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.
Addressed on
devacross #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 undersrc/— but that was never evidence the proxy could not PRODUCE an empty successful turn. It can: a CursorturnEndedwith no text and no committed tool call finalizes to a baredone, 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.
observeEmptyCompletionchanges 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: trueremains the opt-in for recovery.Still not fixed, and not ours: the implausible
wall_time_secondsreading. 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.- added 4 commits that reference this issue
on Sep 14, 2026 - added 4 commits that reference this issue
on Sep 17, 2026
Summary
During a live Codex Desktop session on 2026-08-24 (~14:00-14:40 KST, session 01a03224-c0f5-7a90-87ae-6c17ac691045),
exec_commandtool 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
Empty-but-successful results: multiple consecutive
exec_commandcalls returned no visible content; rerunning the exact same commands immediately after returned normal output. One probe usedtext(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.Implausible
wall_time_secondstelemetry on results that did arrive:echo err-only >&2; exit 3→ exit 3, correct stderr, butwall_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).wall_time_seconds, consistent with a response object assembled without the command ever running through the normal terminal path.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:
wall_time_secondsis 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
turnEndedwith zero decoded output must yield a typed error (or trigger combo failover), never a silent empty tool result.Environment