openai / openai/codex

`codex exec review` reports a failed Git command but never ends the turn, so the process hangs forever

Open
#41,984 9 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

app-server bug CLI code-review exec
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:

  1. review/start enqueues Op::Review and returns a synthetic InProgress turn
    — so a client is correctly told a turn has begun.
  2. review_prompt
    resolves a base review via merge_base_with_head, whose first action is
    ensure_git_repository,
    which runs the failing
    rev-parse --is-inside-work-tree.
    The error propagates by ?.
  3. session::handlers::review
    catches it, sends only EventMsg::Error, and returns. No task is spawned, so nothing exists
    that could later produce a terminal turn event.
  4. The exec JSONL processor treats
    ServerNotification::Error
    as non-terminal: it records last_critical_error and returns CodexStatus::Running.
  5. The exec loop
    sets error_seen but keeps waiting. Its only exits are CodexStatus::InitiateShutdown — produced
    exclusively inside the TurnCompleted match 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:

  • TurnAborted is not the right event. The task code uses it as an explicit terminal event for
    forced aborts,
    but the app server
    maps it to TurnStatus::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_finished contains the
    correct lifecycle (timing, flush, terminal event) but
    early-returns when there is no active task,
    which is exactly this situation; handle_task_abort requires a RunningTask and emits
    Interrupted. The fix likely means extracting the failed-completion path from on_task_finished so
    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_args stripping — guarded by if !is_openai; this run used the built-in
    OpenAI/ChatGPT provider.
  • Large diff / ~10K-token tool output / Responses Lite tool namespacing — red herrings. The
    failing rev-parse runs while constructing the review prompt, before any git diff or 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.