Skip to content

Let a Codex chat stop a sub-agent from the background-tasks strip - #27026

Merged
brennanb2025 merged 4 commits into
mainfrom
brennanb2025/codex-subagent-stop
Oct 10, 2026
Merged

brennanb2025 merged 4 commits into
mainfrom
brennanb2025/codex-subagent-stop

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​594 $\color{#cf222e}{\Huge{\mathbf{−}}}$​18 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​576
Prod 11 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​273 $\color{#cf222e}{\Huge{\mathbf{−}}}$​78 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​195

ELI5

When a Codex chat hands part of its work to a helper agent (a sub-agent), native chat lists that helper in the background-tasks strip above the message box. Since #26780, a command Codex leaves running there has a Stop button, but a helper agent still had none. The only way to stop one was to ask Codex, or to close the chat. This PR gives each Codex helper its own Stop on its row. The Stop ends what that helper is doing, including the helpers it started and the commands it is running. It goes through the same Stop the strip already uses for commands.

What Changed

Before: in a Codex chat's background-tasks strip, each running sub-agent had no Stop. The chat's own Stop doesn't stop them either: it ends only the main conversation's turn, and the helpers keep working (checked in Codex's source, below). Anything waiting for background work to finish, such as a /clear queued behind it, waited until every helper finished on its own. The queued command said "Wait for background tasks to finish".

After:

  • Each running Codex sub-agent row has a Stop button, on desktop and phone.
  • Pressing it ends the helper's current run, the runs of any helpers it started (at any depth), and the commands those helpers have running. The helper's row leaves the strip when Codex reports that its turn ended and its commands exited. The button stays greyed out from the press until then.
  • If the helper later starts new work (for example, the main agent sends it more), the row offers Stop again for that new run instead of staying greyed out.
  • A queued /clear now says "Stop background tasks before using this command.", and the strip gives you the buttons to do it.
  • What the main Codex reply does afterwards is up to Codex. After you stop a helper, the main reply may keep waiting for it ("Coordinating with another agent") until you press the chat's own Stop, and Codex may later start the stopped helper again. Its row then comes back with a working Stop.
  • This works on every Codex version Orca supports, with nothing to detect. One exception: ending a helper's commands needs Codex 0.140 or newer, the same requirement as the command Stop from Let a Codex chat stop a command it left running in the background #26780. On an older Codex, the helper's run ends but a command it started keeps running, and it shows as its own row until it exits.

When a Stop doesn't go to plan, the user sees one of these, all existing lines with no new text:

  • The helper's run had already ended by the time Stop reached Codex: nothing is shown.
  • Codex refuses the stop for any other reason, or a stopped helper's command is still listed afterwards: "The background task wasn't stopped." (toast on desktop, error line on the phone). The row keeps its Stop.
  • Codex doesn't answer within 10 seconds, or the connection to it drops: the existing "Orca couldn't confirm what happened. Check the chat." on desktop, and nothing on the phone, as for any unconfirmed Stop. The row's own state settles it.

Mechanism:

  • How a sub-agent is stopped.
    • A Codex sub-agent runs as its own thread on the chat's Codex app-server.
    • The app-server's turn/interrupt {threadId, turnId} accepts any loaded thread, sub-agent threads included.
    • The Stop carries only the row's id, which is the sub-agent's thread id. When the host handles it, the Codex adapter looks up which turn that sub-agent is running (Orca already tracks this from the sub-agent's own turn/started and turn/completed events). It also finds every sub-agent spawned under it, at any depth.
    • It sends turn/interrupt to each one's running turn, through the same interruptCodexTurn Orca uses for the main turn, so one place reads Codex's refusals.
    • Codex answers when the turn ends, aborted or not. The sub-agent's own turn/completed then removes its row.
  • Commands. Interrupting a turn doesn't end the processes it started: Codex keeps them as background terminals. So the Stop then terminates every process those sub-agents hold, through Let a Codex chat stop a command it left running in the background #26780's thread/backgroundTerminals/terminate, and confirms with list. This runs only where the app-server proved it has background terminals.
  • One route, one outcome. It all goes through the background-Stop route Let a Codex chat stop a command it left running in the background #26780 built. The strip's row Stop goes to the host. The host resolves the row's id from its child records and calls the Codex adapter's stopBackgroundTasks. A small new module (codex-background-task-stops.ts) composes the sub-agent interrupts and the terminal stops, and folds the outcome as before:
    • anything stopped means stopped;
    • anything shown still running means "wasn't stopped";
    • a request with no answer, with nothing shown running, means unconfirmed.
  • Row flags. Codex sub-agent rows now say stoppable: true, and a Codex session always reports supportsTaskStop: true. Neither client shows a "Stop all" button, because both draw it only when per-row Stop is off.
  • Reading Codex's answers.
    • -32600 ("no active turn to interrupt", "expected active turn id … but found …", "thread not found") means that run is no longer running, so the Stop is quiet. Orca already reads it this way for the main turn.
    • Any other refusal means the helper still runs.
  • A race found in a live run against real Codex 0.159. A Stop that reaches Codex just as the helper's turn ends is not refused. Codex holds it unanswered until the helper's next turn ends, and doesn't stop that next turn. A timeout on a run Orca has already seen end is therefore quiet. Lost contact still reads as unconfirmed.
  • Client "Stopping" hold, desktop and phone. The hold that keeps a confirmed Stop's button greyed out now remembers which run of the row it stopped, using the row's run marker (invocation) from the host's records. A Codex helper keeps one row id (its thread id) across all its runs. Without this, a helper that started a new run before its row left would show a greyed-out Stop for that new run. Both clients use one shared rule (structuredSessionConfirmedStopsStillListed).
  • Older clients. A client from before child views reads the old row shape, where a Codex sub-agent's id is codex-agent:<thread>. A Stop naming that id matches no host record, so those old rows keep stoppable: false rather than show a button that does nothing. No wire or protocol change: the same fields, filled in differently.
  • SSH. The Codex app-server runs on the host that owns the workspace, under that host's Orca server, so the interrupt and the terminal stops go to that host's own app-server. Losing contact is never counted as stopped.

Why

The common pattern stops a Codex sub-agent the same way:

  • turn/interrupt on the sub-agent's own thread and running turn;
  • the same for that sub-agent's own sub-agents;
  • terminating the commands those turns started.

This PR follows that for the per-row Stop. Reusing the existing per-row Stop route means no new task kind, flag, capability probe or wire change. The clients needed only the run-keyed hold above.

Alternatives considered:

  • Interrupt only the named helper. Rejected, because the first review round found the result confusing. The helper's commands and its own helpers kept running, and its row stayed in the strip with nothing left to press.
  • Keep the hold until the row reads settled, instead of keying it by run. Rejected: if the restart and the abort reach the client in one update, the row never reads settled, and the hold would stay on the new run.
  • Codex's startup interrupt (an empty turn id). Rejected: it is meant for the window before a thread's first turn, and it answers before anything has stopped.

Differences from the common pattern

  • The chat's main Stop doesn't stop sub-agents. Temporary: the common pattern ends sub-agents on the main Stop too. This PR adds only the per-row Stop and leaves the main Stop unchanged. Follow-up: have the main Stop end live sub-agents through this same per-sub-agent stop.
  • A run the helper starts after the Stop is handled is not chased. Intended: the Stop ends the run the host sees when it handles the Stop (that can be a run started after the user pressed). A run that starts while the interrupt is in flight gets its own Stop on the row.
  • On a Codex without background terminals (older than 0.140), a stopped helper's commands keep running. Temporary, by Codex version: that Codex has no way to stop a single process, the same limit as the command Stop in Let a Codex chat stop a command it left running in the background #26780. The command shows as its own row until it exits.
  • The race above makes a Stop wait up to 10 seconds before answering quietly. Temporary: during that wait, the chat's other actions (sends, the main Stop) queue behind it. Follow-up, shared with the main Stop: settle when Orca sees the run end instead of waiting for Codex's answer.
  • After a helper is stopped, the main Codex reply may keep waiting for it, and Codex may restart it. Intended: this matches Codex's own behaviour. Codex does not tell the main agent when someone else stops its sub-agent: it treats an interrupted sub-agent as not finished, and sends the parent no notice for an ordinary interruption. Codex's own per-sub-agent stop leaves the same gap.
  • A confirmed Stop whose turn/completed never arrives keeps that run's button greyed out until the strip remounts. Intended: the same rule Let a Codex chat stop a command it left running in the background #26780 accepted. The hold is derived from the confirmed Stop plus that run still being listed, with no timer.

Linked Issue

None (internal follow-up named in #26780: "Codex subagent Stop").

Visual Proof

Real signed-in Codex 0.159.0, a hidden Orca test app on macOS, with the phone app in the iOS Simulator paired to that test app. The prompt asks Codex to start one helper that runs sleep 240.

Desktop. Before: the helper's row in the background-tasks strip, with its own Stop. After pressing it, the row is gone, the card reads "Ran 1 subagent · stopped" and the sleep process has ended. The main reply keeps waiting for the helper, which is Codex's own behaviour (see "Differences" below).

Desktop: helper row with its Stop Desktop: after Stop, row gone, card stopped

Phone (iOS Simulator). The same row with its Stop, then right after tapping it.

Phone: helper row with its Stop Phone: after Stop

Testing

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

  • Desktop QA, real signed-in Codex 0.159.0, hidden Orca rig on macOS (two runs):

    • a Codex chat spawned a helper running sleep, and the strip listed it with its own Stop;
    • pressing the row's Stop removed the row in under 1 second, and the helper's sleep process was gone within about 2 seconds;
    • the chat's sub-agent card read "stopped", and no error toast appeared;
    • the greyed-out Stopping state was too brief to capture;
    • both runs also showed the main reply waiting on the stopped helper, described above.
  • Phone QA, iOS Simulator (Orca Mobile dev build paired with the hidden rig, real Codex 0.159.0, head 84fa886): the phone strip listed the helper with its own Stop. Tapping it removed the row within about 1.6 seconds, the card read "stopped", the helper's sleep process ended, and no error line appeared. The main reply kept waiting as on desktop, and the phone's main Stop ended it.

  • Live mechanism check, real signed-in Codex 0.159.0 app-server on macOS (no Orca UI):

    • a turn spawned one sub-agent running sleep 90;
    • turn/interrupt on the sub-agent's thread and turn was answered in 135 ms, and the sub-agent's turn/completed arrived with status interrupted;
    • a wrong turn id was refused with -32600 "no active turn to interrupt";
    • the same check found the end-of-turn race above.
  • Adapter, against a fake app-server (codex-subagent-interrupts.test.ts):

    • Stop interrupts the sub-agent's turn and the interrupted turn ends the row;
    • nested sub-agents are interrupted too;
    • a sub-agent's running command is terminated and its row leaves;
    • -32600 refusals stay quiet, any other refusal reports still running, and a timeout or lost contact throws;
    • the end-of-turn race stays quiet;
    • an unknown task or a stale session sends nothing;
    • an app-server that timed out isn't asked again.
  • Host routing, end to end (structured-foreground-command-strip.test.ts): through the real host, a sub-agent row's Stop reaches turn/interrupt; a sub-agent with a running command has both stopped, and the row leaves; the old-client row stays unstoppable.

  • Clients (NativeChatStructuredSessionStatus.test.tsx, MobileNativeChatBackgroundTasks.test.tsx): a confirmed Stop holds its button, and the helper's next run (same row id, new run) offers Stop again. I checked that both tests fail with the old hold.

  • Local checks: targeted vitest (all Codex tests plus every host, shared and client test touching background Stop), mobile vitest for the strip, tsc node / web / cli, and orca-ci-checks.

  • Platforms: macOS only. The change is platform-independent JSON-RPC to the app-server.

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Security: no new process control. Codex ends its own turns and processes.
  • Cross-platform: no platform-specific code.
  • SSH: covered above.
  • Mobile: the phone strip's hold is keyed by run, like desktop.
  • Backwards compatibility: no wire change. Clients from before child views keep Codex sub-agent rows unstoppable, since their row id reaches no host record. Newer clients that predate this PR get a working Stop.
  • Performance: a Stop sends one request per running sub-agent run, plus the terminal stops, each bounded at 10 seconds.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

A Codex sub-agent showed in the background-tasks strip with no Stop, and
the chat's own Stop interrupts only the main turn, so a helper ran on until
it finished. Codex's app-server takes `turn/interrupt` for any loaded thread,
sub-agent threads included, so a sub-agent row's Stop now interrupts the
turn Orca saw that sub-agent run, through the same background Stop route
Codex commands use.

Codex's -32600 refusal (no active turn, another turn, thread not found)
means the run the row showed is over, so the Stop is quiet; any other
refusal says the task wasn't stopped; a timeout or lost contact is
unconfirmed. A live run showed Codex holds an interrupt that lands as the
turn ends until the sub-agent's next turn ends, so a timeout on a run the
session has since seen end is quiet too. Older clients' `codex-agent:` rows
name no host record and stay unstoppable.
…d Stopping per run

A sub-agent Stop interrupted only that helper's turn, so the commands it was
running (Codex keeps a turn's processes when the turn is interrupted) and the
helpers it had spawned kept going, and its row stayed in the strip. The Stop
now interrupts the helper's run and every descendant's run, then terminates
the processes those threads hold where the app-server has background
terminals. The interrupt goes through interruptCodexTurn, and a small
codex-background-task-stops module composes the sub-agent and terminal stops,
so the terminals module is terminals only again.

A Codex helper keeps one row id (its thread id) across runs, so a confirmed
Stop's held button carried over to the helper's next run. Both clients now
hold it for the run it was pressed on, read from the row's invocation.
… after a timeout

Adds direct tests of the per-run Stop hold (including an older host's roster),
matches the desktop hold comment to mobile's, and reuses the one 10 s bound.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review summary

The problem. In a Codex chat, the background-tasks strip above the message box listed each helper agent (sub-agent) Codex started, but a helper had no Stop. Codex's own Stop on the chat ends only the main reply, and the helpers keep going. The only way to stop one was to ask Codex or close the chat.

What changes for the user. Each running Codex helper row in the strip, on desktop and phone, now has its own Stop. Pressing it ends what that helper is doing: its current turn, any helpers it started itself, and the commands it left running (on Codex versions that support stopping background commands). The row leaves the strip within about a second. If Codex had already finished, nothing is shown. If Codex refuses, the existing "The background task wasn't stopped." line appears. If Codex doesn't answer, the existing "couldn't confirm" line appears.

One behaviour to know about: after you stop a helper, the main Codex reply may keep waiting for it ("Coordinating with another agent") until you press the chat's own Stop, and Codex may later start the helper again. This is how Codex itself works: it treats an interrupted helper as not finished and doesn't tell the main agent. Codex's own per-helper stop leaves the same gap, so this PR keeps it as an intended difference.

Fixed during review

  • A helper Stop used to end only the helper's own turn. Its running command (for example sleep 240) and any helpers it had started kept going, and the row stayed listed. Now the whole helper subtree and its commands are stopped together.
  • After one Stop, the helper's next run showed a Stop button that stayed greyed out, on desktop and phone. The greyed "Stopping" state was keyed by the helper's id, which Codex reuses for every run. It is now tied to the run that was pressed.
  • The interrupt now reuses the existing Codex turn-interrupt helper, so the "turn not running" rule lives in one place. The stop logic moved into its own small module. A wrong code comment was corrected, and the PR description's claims were corrected (which runs a Stop reaches, which older clients keep the rows unstoppable, how this compares with the common pattern).
  • Tests added: a helper Stop leaves sibling helpers and the main chat's own processes alone, a timeout skips the command stops, and direct tests for the per-run hold rule.

Deferred (P3)

  • A Stop that lands exactly as the helper's turn ends can wait out the 10-second limit before resolving quietly. Meanwhile it holds the chat's other actions, and the wait grows with the number of nested helpers. Settling as soon as Orca sees the turn end would fix this, together with the same wait on the main Stop.
  • A grandchild helper is found only through spawn notices Orca saw. One whose notice was lost before a resume survives its ancestor's Stop, but it keeps its own Stop.
  • Clients built in a roughly one-day window (09-09 to 09-10) no longer show Codex helper rows.
  • Whether a Codex submit failure (-32603) really means "wasn't stopped" is unverified.

Verified

  • Three review rounds plus a readiness checklist found no P0 or P1. The Codex mechanism was read in Codex's own source: interrupting a helper's thread works, the main Stop doesn't reach helpers, and an interrupted helper never notifies its parent.
  • CI on 84fa886: 16 checks pass, 16 skipped, none failing. An earlier cross-version wire-compatibility failure on ba34fb3 was the same 7 unrelated tests failing on other PRs that day, and it passed on this head.
  • Desktop QA with real signed-in Codex 0.159.0 in a hidden Orca test app, two runs: Stop removed the row in under 1 s, the helper's sleep process ended within about 2 s, and no error appeared. Phone QA in the iOS Simulator, paired with that test app: the same result within about 1.6 s. Screenshots are in the PR description.

Not verified

  • Windows, Linux, SSH remote hosts and WSL. The change is the same app-server request on whichever host runs Codex.
  • Codex versions older than 0.159 live, including terminal-stop-less versions where a helper's commands survive.
  • The greyed "Stopping" state on screen: the row left too fast to capture it.
  • Live behaviour of nested helpers. This is covered only by tests and source reading.

…bagent-stop

# Conflicts:
#	src/main/codex/codex-background-terminals.ts
@brennanb2025
brennanb2025 marked this pull request as ready for review October 10, 2026 01:53
@brennanb2025
brennanb2025 merged commit cbc565d into main Oct 10, 2026
34 checks passed
@brennanb2025
brennanb2025 deleted the brennanb2025/codex-subagent-stop branch October 10, 2026 01:55
brennanb2025 added a commit that referenced this pull request Oct 15, 2026
…7026)

* Let a Codex chat stop a sub-agent from the background-tasks strip

A Codex sub-agent showed in the background-tasks strip with no Stop, and
the chat's own Stop interrupts only the main turn, so a helper ran on until
it finished. Codex's app-server takes `turn/interrupt` for any loaded thread,
sub-agent threads included, so a sub-agent row's Stop now interrupts the
turn Orca saw that sub-agent run, through the same background Stop route
Codex commands use.

Codex's -32600 refusal (no active turn, another turn, thread not found)
means the run the row showed is over, so the Stop is quiet; any other
refusal says the task wasn't stopped; a timeout or lost contact is
unconfirmed. A live run showed Codex holds an interrupt that lands as the
turn ends until the sub-agent's next turn ends, so a timeout on a run the
session has since seen end is quiet too. Older clients' `codex-agent:` rows
name no host record and stay unstoppable.

* Stop a Codex sub-agent's own sub-agents and commands with it, and hold Stopping per run

A sub-agent Stop interrupted only that helper's turn, so the commands it was
running (Codex keeps a turn's processes when the turn is interrupted) and the
helpers it had spawned kept going, and its row stayed in the strip. The Stop
now interrupts the helper's run and every descendant's run, then terminates
the processes those threads hold where the app-server has background
terminals. The interrupt goes through interruptCodexTurn, and a small
codex-background-task-stops module composes the sub-agent and terminal stops,
so the terminals module is terminals only again.

A Codex helper keeps one row id (its thread id) across runs, so a confirmed
Stop's held button carried over to the helper's next run. Both clients now
hold it for the run it was pressed on, read from the row's invocation.

* Test that a sub-agent Stop leaves siblings alone and skips terminates after a timeout

Adds direct tests of the per-run Stop hold (including an older host's roster),
matches the desktop hold comment to mobile's, and reuses the one 10 s bound.

(cherry picked from commit cbc565d)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant