Code Mode functions.wait can report completion/termination while child processes keep running
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:
- 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.
- A nested command that starts an SSH child.
functions.execstarts a cell that runs an SSH command; an immediatefunctions.wait({ terminate: true })reportsScript 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.
4 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
I reproduced the local-process variant independently on current
mainatb28aa476f4on macOS 15.5 arm64, so this behavior is not Linux-specific.I used a uniquely tagged 30-second process inside a Code Mode cell:
The outer cell yielded first. The nested
exec_commandthen correctly returned a livesession_idafter about 250 ms, and a subsequentfunctions.waitfor the outer cell returnedScript 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_commandreturned, then called: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_resultpreserves a live unified-execprocess_idassession_id;RuntimeResponse::Result;functions.wait({ terminate: true })terminates the outer Code Mode cell;ToolCallSource::CodeMode { cell_id, ... }, butExecCommandHandlercurrently discards that source, andProcessEntrydoes not retain the owning Code Mode cell ID;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/mainatca4d532b2a. The Code Mode terminal-response handling,session_idserialization, 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 terminatedheaders and verify that a completed nested command omitssession_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:
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?
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:
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;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 trackedwhen a post-kill process-table sweep (children-of-session walk) finds survivors, and document thatterminate: trueguarantees local-group termination only, never remote effects. An orchestrator can act correctly on that; it can't on today's unqualified "terminated".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 completedwhile 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: truereproduction has a similar ownership boundary:ToolCallSource::CodeMode { cell_id, ... };exec_commandreturns a livesession_id;ExecCommandHandlercurrently destructuresToolInvocationwith.., discarding that source;ProcessEntryretains the process/session ID but no owning Code Modecell_id;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 inexec_command.rsandprocess_manager.rsconcern 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:
cell_idfor live nested unified-exec sessions;Process-group escape and remote-descendant guarantees can then be handled as a separate status-semantics layer.