Code Mode functions.wait can report completion/termination while child processes keep running

Open 💬 4 comments Opened Aug 12, 2026 by pch007
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What version of Codex CLI is running?

codex-cli 0.147.0

What platform is your computer?

linux amd64

What issue are you seeing?

In Code Mode, functions.exec / functions.wait can report a cell as terminal while the command's child process is still alive.

We have observed this class three times. Two concrete shapes:

  1. A long-running local child, e.g. a cell that ultimately runs:
python3 -c 'import time; time.sleep(300)'

functions.exec yields quickly, then functions.wait reports a terminal/completed result before the child exits. A process-table check immediately after the reported completion shows the child PID still alive and consuming resources.

  1. A nested command that starts an SSH child. functions.exec starts a cell that runs an SSH command; an immediate functions.wait({ terminate: true }) reports Script terminated, but the remote command continues and completes its side effects about 25 seconds later.

Expected behavior

For local children, functions.wait should not report a terminal completed / terminated outcome until the process group it owns has actually exited, or it should return an explicit warning that descendants are still alive.

For SSH/remote descendants, terminate: true should either close the local SSH process strongly enough that the remote command receives HUP/TERM, or the result should explicitly say that remote descendants are outside the termination guarantee.

Actual behavior

The Code Mode wrapper reports a terminal status while work is still running. An orchestrator that trusts the terminal status can launch duplicate expensive validators or proceed under the false assumption that a mutating command was stopped.

Why this appears to be upstream Code Mode behavior

On our side, containment around ended Codex sessions is green: we snapshot/kill the local process tree on disconnect/abort and verified no init-reparented CPU-burning local cell shells after managed session teardown. The misleading part is the Code Mode wait/terminate status itself.

The Code Mode host appears to be the component emitting the premature terminal result; Oivo only observes the begin/end tool messages around it.

Related issues

This is related to, but narrower than, #34115 (unified exec drops canonical process identity / hides a live background wait). This report is specifically about Code Mode functions.wait / terminate returning a terminal status that contradicts process liveness.

#35108 also mentions nested functions.exec / wait-agent behavior, but the failure mode here is not repeated parent polling; it is a terminal status while child work is still alive.

View original on GitHub ↗

4 Comments

github-actions[bot] contributor · 16 days ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #38069

Powered by Codex Action

yxlphobe-pixel · 16 days ago

I reproduced the local-process variant independently on current main at b28aa476f4 on macOS 15.5 arm64, so this behavior is not Linux-specific.

I used a uniquely tagged 30-second process inside a Code Mode cell:

yield_control();

const result = await tools.exec_command({
  cmd: "python3 -c 'import time; time.sleep(30)' CODEX_ISSUE_38093_REPRO",
  yield_time_ms: 250,
});

text(JSON.stringify(result));

The outer cell yielded first. The nested exec_command then correctly returned a live session_id after about 250 ms, and a subsequent functions.wait for the outer cell returned Script completed. A process-table check about 19 seconds later still showed the uniquely tagged Python process running.

I also reproduced the termination variant with a uniquely tagged 90-second local process. I kept the outer cell live after the nested exec_command returned, then called:

functions.wait({ cell_id, terminate: true })

It returned Script terminated, but an immediate process-table check still showed the inner Python process alive. I terminated that exact tagged process explicitly afterward and verified that no reproduction process remained.

I did not independently test the SSH/remote-descendant variant, so the evidence above is specifically for locally managed unified-exec sessions.

The lifecycle boundary appears to be:

  • ExecCommandToolOutput::code_mode_result preserves a live unified-exec process_id as session_id;
  • once that nested tool promise resolves, JavaScript can finish and the Code Mode runtime can produce a terminal RuntimeResponse::Result;
  • functions.wait({ terminate: true }) terminates the outer Code Mode cell;
  • the nested invocation carries ToolCallSource::CodeMode { cell_id, ... }, but ExecCommandHandler currently discards that source, and ProcessEntry does not retain the owning Code Mode cell ID;
  • therefore, when a cell completes or is terminated, Core has no cell-to-live-session ownership index with which to inspect, await, or terminate those nested unified-exec sessions.

There is already a confirmed per-session primitive, UnifiedExecProcessManager::terminate_process(process_id); the missing design decision appears to be when a live nested session becomes detached intentionally versus remaining owned by its Code Mode cell.

I also checked the relevant paths on current origin/main at ca4d532b2a. The Code Mode terminal-response handling, session_id serialization, and Code Mode regression-test file are unchanged relative to the reproduced revision. The intervening unified-exec changes are unrelated skill-attribution plumbing.

Existing Code Mode end-to-end tests verify Script completed / Script terminated headers and verify that a completed nested command omits session_id, but I did not find coverage asserting the status or cleanup of a nested session that is still live when the outer cell reaches a terminal state.

A focused regression could use a tagged long-running local command with a short nested yield and assert the intended invariant for both paths:

  1. normal outer-cell completion either waits for owned nested sessions, or explicitly reports that live nested sessions remain and preserves their IDs as non-terminal work; and
  2. outer-cell termination terminates and confirms exit of any nested sessions the cell still owns before reporting Terminated.

If this diagnosis and scope match the intended semantics, would a Codex maintainer be willing to invite me to implement the focused fix and regression coverage?

jdcodes1 · 9 days ago

Partial code mapping on main @ 1f41cc5d92: the wrapper's terminal strings come straight from the runtime response (RuntimeResponse::Terminated → "Script terminated", core/src/tools/code_mode/mod.rs#L288), and the underlying kill semantics in exec-server are process-group based with a grace period (terminate_process_tree → group TERM → timeout → group KILL, exec-server/src/connection.rs#L138-L160). That gives the status a well-defined but narrower meaning than it advertises: "the tracked process group is terminated."

Both your shapes are the classic escapes from that guarantee:

  • a child that leaves the group (setsid, double-fork, daemonizing wrappers — and shells commonly spawn grandchildren that survive a group kill on some paths) stays alive while the group is gone → "completed/terminated" with a live PID;
  • an SSH child killed locally (especially KILL after the grace period, no clean channel teardown) cannot propagate anything to the remote — remote descendants are structurally outside any local termination guarantee.

Given the escapes can't be fully closed, the fix that matches your expected behavior is honest status semantics: report what was actually verified — e.g. terminated (process group); N known descendant(s) not tracked when a post-kill process-table sweep (children-of-session walk) finds survivors, and document that terminate: true guarantees local-group termination only, never remote effects. An orchestrator can act correctly on that; it can't on today's unqualified "terminated".

yxlphobe-pixel · 9 days ago

Thanks for the additional process-group analysis. I think that describes a separate failure mode from the local reproduction above.

In the normal-completion reproduction, no termination was requested at all: the outer Code Mode cell returned Script completed while the nested unified-exec session—and its ordinary local Python process—was still running. Process-group termination semantics are therefore not reached on that path.

The terminate: true reproduction has a similar ownership boundary:

  • Code Mode creates the nested invocation with ToolCallSource::CodeMode { cell_id, ... };
  • the nested exec_command returns a live session_id;
  • ExecCommandHandler currently destructures ToolInvocation with .., discarding that source;
  • ProcessEntry retains the process/session ID but no owning Code Mode cell_id;
  • terminating the outer cell therefore provides no mapping with which Core can call and await UnifiedExecProcessManager::terminate_process(process_id) for its still-live nested session.

I rechecked the relevant files on current main, and this ownership gap is still present. The recent changes in exec_command.rs and process_manager.rs concern skill/plugin attribution rather than cell-to-session lifecycle ownership.

So I agree that qualified process-group semantics may be necessary for daemonized, detached, or remote descendants. However, that would not address the reproduced local case, because Core never reaches the per-session termination primitive for the nested session in the first place.

I believe the focused first layer remains:

  1. retain the owning Code Mode cell_id for live nested unified-exec sessions;
  2. define when a returned session becomes intentionally detached from the cell;
  3. on outer-cell termination, terminate and await sessions still owned by that cell; and
  4. on normal completion, avoid reporting an unqualified terminal result while owned nested sessions remain live, or explicitly surface those sessions as continuing work.

Process-group escape and remote-descendant guarantees can then be handled as a separate status-semantics layer.