Repository navigation
Let a Codex chat stop a sub-agent from the background-tasks strip - #27026
Conversation
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.
Review summaryThe 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
Deferred (P3)
Verified
Not verified
|
…bagent-stop # Conflicts: # src/main/codex/codex-background-terminals.ts
…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)
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
/clearqueued behind it, waited until every helper finished on its own. The queued command said "Wait for background tasks to finish".After:
/clearnow says "Stop background tasks before using this command.", and the strip gives you the buttons to do it.When a Stop doesn't go to plan, the user sees one of these, all existing lines with no new text:
Mechanism:
turn/interrupt {threadId, turnId}accepts any loaded thread, sub-agent threads included.turn/startedandturn/completedevents). It also finds every sub-agent spawned under it, at any depth.turn/interruptto each one's running turn, through the sameinterruptCodexTurnOrca uses for the main turn, so one place reads Codex's refusals.turn/completedthen removes its row.thread/backgroundTerminals/terminate, and confirms withlist. This runs only where the app-server proved it has background terminals.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:stoppable: true, and a Codex session always reportssupportsTaskStop: true. Neither client shows a "Stop all" button, because both draw it only when per-row Stop is off.-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.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).codex-agent:<thread>. A Stop naming that id matches no host record, so those old rows keepstoppable: falserather than show a button that does nothing. No wire or protocol change: the same fields, filled in differently.Why
The common pattern stops a Codex sub-agent the same way:
turn/interrupton the sub-agent's own thread and running turn;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:
Differences from the common pattern
turn/completednever 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
sleepprocess has ended. The main reply keeps waiting for the helper, which is Codex's own behaviour (see "Differences" below).Phone (iOS Simulator). The same row with its Stop, then right after tapping it.
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):
sleep, and the strip listed it with its own Stop;sleepprocess was gone within about 2 seconds;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
sleepprocess 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):
sleep 90;turn/interrupton the sub-agent's thread and turn was answered in 135 ms, and the sub-agent'sturn/completedarrived with statusinterrupted;-32600 "no active turn to interrupt";Adapter, against a fake app-server (
codex-subagent-interrupts.test.ts):-32600refusals stay quiet, any other refusal reports still running, and a timeout or lost contact throws;Host routing, end to end (
structured-foreground-command-strip.test.ts): through the real host, a sub-agent row's Stop reachesturn/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,
tscnode / web / cli, andorca-ci-checks.Platforms: macOS only. The change is platform-independent JSON-RPC to the app-server.
Review
Agent skill upstream boundary
Notes
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)