-
Notifications
You must be signed in to change notification settings - Fork 3
codex_structured_turn_completion_hang
Cross-repo: symptom in mcp-agent-builder-go (Pulse), defect and fix in
multi-llm-provider-go (pkg/adapters/codexcli, pkg/adapters/internal/procshutdown).
- Pulse reviewer and Fixer stages running on
codex-cliwould emit two opening lines and then appear frozen indefinitely. A/pulse-fixerstage sat for 65+ minutes. - The parent agent narrated it accurately: "The durable result was recorded; only the
HTTP response remained stuck." Its
call_generic_agentbridge call never returned. - Strongly provider-skewed:
claude-code77 ok / 2 timeout againstcodex-cli2 / 13. This read for weeks as "codex is unstable." - The work itself was not lost. Findings, attempts, and dispositions were already committed to SQLite before the hang.
Teardown was reachable from exactly one place in
codexcli_structured_adapter.go:
case "turn.completed":
go procshutdown.GracefulAfterNaturalExit(cmd, scannerDone, 3*time.Second, c.logger)If that event never arrived on stdout there was no fallback, no deadline, and no
diagnostic. The scan loop ended (or blocked), and the bare cmd.Wait() below it blocked
on a live process that nothing would ever signal.
Three separate gaps compounded:
- Single completion signal. stdout was the only source of truth for "the turn ended."
-
Unbounded reap.
cmd.Wait()had no deadline, so the failure mode was an infinite hang rather than a bounded, logged error. -
Silent scan failures.
scanner.Err()was never consulted, making an oversized line (>1 MB) or a stream error indistinguishable from a clean EOF.
The contract was complicit. docs/coding_sdk_structured_contract.md §9 specified teardown
"once a terminal-state event ... is observed on stdout", and left reaping to "the
adapter's main goroutine" with no bound. The adapter implemented §9 faithfully; §9 was
incomplete.
Child stage agent for module eval_health, social-media workflow, 2026-08-01:
| Observation | Value |
|---|---|
| Rollout | ~/.codex/sessions/2026/08/01/rollout-2026-08-01T15-42-51-019fbccf-*.jsonl |
session_meta.cwd |
/var/folders/.../T/mlp-cli-session-749a97315667d5e2 |
| Last rollout write |
event_msg/task_complete at 10:20:02Z, full report attached |
| Anything written after | none, for 65 minutes |
| Process (pid 56905) | alive 1h12m at 3.43s total CPU, 0% at sampling |
| Teardown budget once a terminal event is seen | 3 + 10 + 10 + 5 = ~28s to SIGKILL |
task_complete is codex's own end-of-turn marker. Combined with 3.4s of CPU across 72
minutes, this rules out "the turn was merely slow": the work had finished and the process
was idle. And had the event reached us, procshutdown would have reaped it inside a minute.
The child was reaching tools normally throughout — custom_tool_call_output entries show
api-bridge calls returning in 25ms.
Both were wrong, and both are worth recording because they are easy to reach again:
-
"CLI stage agents have no MCP bridge" (because
runGoalAdvisorStageAgentsetsconfig.UseCodeExecutionMode = false). Wrong:mcpagent/agent/agent.goforce-enables code-exec for every CLI provider in two places (~1728 "Pre-set ... for CLI provider", ~1981 "safety net"), so that assignment is inert. The stale comment onrequiresCodeExecutionForProvider— "Phase agents normally disable code-exec mode" — is what misled; the adjacentisCliProviderForPromptcomment is correct. -
"
ServerNames = NoServersleaves the agent unable to act." Wrong:NO_SERVERSsuppresses external MCP servers only. Workflow custom tools and the api-bridge remain fully available.
pkg/adapters/internal/procshutdown/procshutdown.go
- New
Reap(cmd, grace, logger): callscmd.Wait(), escalating through the existing SIGTERM ladder if the process outlives its output stream. Placed here rather than in the codex adapter because all four structured adapters share the bare-Waitpattern.
pkg/adapters/codexcli/codexcli_structured_adapter.go
- Teardown moved behind a
sync.Onceso it is reachable from more than one signal. -
Rollout fallback: a watcher polls
codexTurnCompletionTrackerfor nativetask_completeand triggers the same teardown. This tracker already existed and the interactive adapter has always used it; the structured adapter simply never did. Requires a working dir — the rollout is matched bysession_meta.cwd. -
scanner.Err()is checked and forces teardown instead of being swallowed as EOF. -
cmd.Wait()replaced withprocshutdown.Reap(cmd, codexReapGrace, ...), 45s — above the full 28s ladder so an in-flight teardown always wins and this stays a last resort. - If stdout stalls before the final
agent_message, the answer is recovered viareadCodexTranscriptFinalAssistantTextrather than failing a turn whose work is durable.
None of this can cut a running turn short: the watcher fires only on task_complete, the
scanner check only on a real error, and the reap grace only starts after the output stream
has already ended.
docs/coding_sdk_structured_contract.md — §9 and certification row #22 updated to require
an independent completion signal and a bounded reap.
-
TestReapKillsProcessThatOutlivesItsOutputStreamreproduces the defect with a SIGTERM-ignoring process. Confirmed honest: revertingReapto a barecmd.Wait()makes it fail on timeout; restored, it passes. -
TestReapReturnsPromptlyForExitedProcessguards against regressing the normal path into a fixed delay. - Full
./pkg/adapters/...suite green;agent_gobuilds.
Not yet proven end-to-end. The confirming evidence is a codex stage agent completing and returning through the bridge on a rebuilt server. The tmux session exited mid-investigation, so the live reproduction was lost before the fix could be tried against it.
-
claudecode,picli, andcursorclistructured adapters still use a barecmd.Wait(). AdoptingReapis one line each. None has been observed hanging. -
Foreground stage agents have no stall detection at all. The scheduler has a 10-minute
idle detector; the chat path used by
/pulse-fixerdoes not. That is why this ran 65 minutes instead of failing, and it is independent of codex. - The upstream reason
turn.completednever reached stdout is still unknown. Candidates: thecodex-code-mode-hostgrandchild holding the inherited stdout pipe open (no EOF), or a JSON line above the 1 MB scanner cap. The fix does not depend on which.
Codex rollouts live at ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl, selected by
session_meta.cwd. event_msg/task_complete is the definitive turn-over marker — its
timestamp against the process's elapsed time, plus elapsed vs CPU time, distinguishes
"hung" from "slow" in one step.
Auto-synced from docs/ on main. Edit there, not here.