`codex exec review` reports a failed Git command but never ends the turn, so the process hangs forever
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.4k
- PR merge metrics
- PR metrics pending
Description
What version of Codex CLI is running?
Reproduced on 0.152.0 (current at time of writing) and 0.151.0.
What subscription do you have?
ChatGPT Pro, authenticated via OAuth.
Which model were you using?
gpt-5.6-sol, model_reasoning_effort="xhigh". Not material: the failure happens while resolving the
review target, before any model request is made. The reproduction below hangs before the model is contacted.
What platform is your computer?
Linux 4.4.302+ x86_64, Debian 13 (trixie) container, Node v26.8.1. Non-interactive, stdin closed,
supervising process continuously draining stdout and stderr.
What terminal emulator and version are you using (if applicable)?
None. Codex runs headless and emits JSONL.
Codex doctor report
Not included — it carries account and path detail from a private deployment. Available on request; the
reproduction below is self-contained and does not depend on our environment.
What issue are you seeing?
When an internal Git command exits non-zero while Codex is resolving review --base, Codex emits a
correct, well-formed JSONL error describing the failure — and then never ends the turn. The process
stays alive indefinitely. There is no terminal event, so a non-interactive caller has nothing to wait
on and no exit code to react to. Only an external watchdog stops it.
To be clear about what is and is not broken: the diagnosis is right. Codex identifies the failing
command and its exit status accurately. The sole defect is that it does not then finish the turn.
What steps can reproduce the bug?
Any Git working tree, Linux, authenticated Codex. Nothing about our setup is required — no custom Git
wrapper, no large diff, no proprietary repository:
fail_git_dir="$(mktemp -d)"
mkdir "$fail_git_dir/bin"
cat >"$fail_git_dir/bin/git" <<'EOF'
#!/bin/sh
echo "intentional Git failure" >&2
exit 64
EOF
chmod +x "$fail_git_dir/bin/git"
PATH="$fail_git_dir/bin:$PATH" \
timeout 120s \
codex exec review --base main --json < /dev/null
Actual result
Complete stdout — all three lines, then silence until timeout kills it after the 120s cap
(121s wall clock, exit 124):
{"type":"thread.started","thread_id":"01a05b6e-666d-7ac2-8cdf-45fff5ed658d"}
{"type":"turn.started"}
{"type":"error","message":"git command `git -c safe.bareRepository=explicit -c core.hooksPath=/dev/null rev-parse --is-inside-work-tree` failed with status exit status: 64: intentional Git failure"}
A turn is announced, an error is reported, and the turn is then never closed.
Measurements
Same image, same command, same repository; only git differs. The control is what makes this a hang
rather than slowness:
| Arm | codex | git | Result |
|---|---|---|---|
| control | 0.152.0 | real | exit 0 in 67s, turn.completed present |
| repro above | 0.152.0 | fails | exit 124 in 121s wall time (120s cap), no terminal event |
| longer cap | 0.152.0 | fails | exit 124 at 720s cap, no terminal event |
| longer cap | 0.151.0 | fails | exit 124 at 720s cap, no terminal event |
In a separate long-running observation of the same three-event state, the process remained alive for
90 minutes until an outer watchdog intervened: ~2s of CPU consumed over that period, no child
processes, no zombies, and two ESTABLISHED TLS connections. Socket state alone cannot distinguish an
idle keep-alive from an in-flight request; the useful signature here is no CPU and no children while
waiting for an event that has no producer, which is what the source shows below.
What is the expected behavior?
The same three lines, followed by a fourth — a terminal turn.failed JSONL event (internally a TurnCompleted notification with TurnStatus::Failed) — and a prompt non-zero exit.
Additional information
Source analysis
The lifecycle gap is visible end to end. Links pinned to rust-v0.152.0:
review/startenqueuesOp::Reviewand returns a syntheticInProgressturn
— so a client is correctly told a turn has begun.review_prompt
resolves a base review viamerge_base_with_head, whose first action is
ensure_git_repository,
which runs the failing
rev-parse --is-inside-work-tree.
The error propagates by?.session::handlers::review
catches it, sends onlyEventMsg::Error, and returns. No task is spawned, so nothing exists
that could later produce a terminal turn event.- The exec JSONL processor treats
ServerNotification::Error
as non-terminal: it recordslast_critical_errorand returnsCodexStatus::Running. - The exec loop
setserror_seenbut keeps waiting. Its only exits areCodexStatus::InitiateShutdown— produced
exclusively inside theTurnCompletedmatch arms (Completed/Failed/Interrupted) — or
the event stream closing.
So exec blocks on client.next_event() for a TurnCompleted that, by construction, can never be
sent. The wait is in exec's event loop; it is not an HTTP stall.
Scope
This is specific to the inline Review path. Normal turn/start waits until core has spawned a task;
shell and compact spawn tasks; thread_rollback, memory-mode settings, realtime and shutdown
errors are non-turn RPC/session operations or carry their own terminal response. We did not find a
second turn-producing operation with this shape.
Suggested fix
The failed-turn lifecycle already exists and already does the right thing — the review error path just
does not reach it.
handle_turn_complete
derives status from the recorded error:
let (status, error, last_agent_message) = match turn_summary.last_error {
Some(error) => (TurnStatus::Failed, Some(error), None),
None => (TurnStatus::Completed, None, turn_summary.last_agent_message),
};
The review Err arm already emits the Error, and the app server's
handle_error_notification
records it as last_error. Following it with the existing TurnComplete terminal event would
therefore yield TurnStatus::Failed carrying the correct error, with no new error-mapping logic.
Two notes for whoever picks this up:
TurnAbortedis not the right event. The task code uses it as an explicit terminal event for
forced aborts,
but the app server
maps it toTurnStatus::Interrupted
with no error attached. Using it here would misreport a failure as a cancellation. The established
failed-task pattern is
Error
followed by
TurnComplete.- No directly callable helper exists for the pre-task case.
on_task_finishedcontains the
correct lifecycle (timing, flush, terminal event) but
early-returns when there is no active task,
which is exactly this situation;handle_task_abortrequires aRunningTaskand emits
Interrupted. The fix likely means extracting the failed-completion path fromon_task_finishedso
it can be used when a turn fails before any task is spawned.
A regression test could inject a non-zero Git result and assert that codex exec review emits the
error and exits non-zero within a short deadline.
More broadly: every accepted turn should produce exactly one terminal outcome. Enforcing that at the
producer is safer than making exec treat any error as terminal, which would break retryable errors.
How we hit this, in the interest of full disclosure
Our own sandboxing wrapper allow-lists the Git config options Codex may pass. Codex 0.147.0 began
adding safe.bareRepository=explicit; our allowlist did not include it and returned exit 64. Stock
Git accepts the option — that rejection was our bug, and widening our allowlist fixed our
integration.
We are reporting the behavior on the other side of that failure: given any non-zero Git exit, Codex
reports it and then hangs indefinitely. The reproduction above uses a trivially failing git and does
not involve our wrapper.
Ruled out
Investigated and eliminated before the error event was captured:
- PTY EOF deadlock — the hung process had no shell child or zombie, and PTY descriptor ownership
did not change at the version boundary. encrypted_function_argsstripping — guarded byif !is_openai; this run used the built-in
OpenAI/ChatGPT provider.- Large diff / ~10K-token tool output / Responses Lite tool namespacing — red herrings. The
failingrev-parseruns while constructing the review prompt, before anygit diffor model call. - Parent-side pipe backpressure — the supervisor drained stdout and stderr continuously, and the
final JSONL error arrived immediately.
No credentials, proprietary diff contents, repository names, or private paths appear in this report.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with codex-rs/core/src/session/handlers.rs review, then trace the terminal-event handling in codex-rs/app-server/src/bespoke_event_handling.rs and codex-rs/exec/src/lib.rs. Run the failing-git reproduction with codex exec review --base main --json. Done means the error is followed by a failed terminal turn event and the process exits non-zero promptly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- git, rust
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100