[Hooks] interrupt_agent does not dispatch SubagentStop for interrupted child
What version of Codex CLI is running?
codex-cli 0.147.0
What platform is your computer?
macOS 26.5, arm64
What issue are you seeing?
When a running subagent is interrupted through the native interrupt_agent tool, the child stops executing but Codex does not dispatch the documented SubagentStop hook for that child.
This leaves exact-ID lifecycle observers with a SubagentStart and no corresponding terminal event, so they must incorrectly retain the interrupted child as running indefinitely.
In one captured occurrence:
- three subagents each produced a
SubagentStarthook event; - two siblings that completed normally each produced
SubagentStop; interrupt_agentwas called for the remaining running child and returnedprevious_status: running;- that child's rollout immediately recorded a terminal
turn_abortedevent with reasoninterrupted; - the child was no longer running;
- no
SubagentStopevent was dispatched for that child's exactagent_id.
The hook configuration was active and functioning in the same parent turn, as shown by the two normal SubagentStop events. No transcript files or private paths are attached to this public report.
What steps can reproduce the bug?
- Configure trusted command hooks for both
SubagentStartandSubagentStop. Have each append the event name andagent_idto an event log. - Start a parent Codex session and spawn a subagent that remains running long enough to interrupt.
- Confirm that
SubagentStartwas recorded for the child. - Call the native
interrupt_agenttool for that running child. - Confirm that the child is no longer live and that its turn ended as interrupted.
- Inspect the hook event log.
Actual result: there is no SubagentStop event for the interrupted child's agent_id.
Control: allow another child in the same session to complete normally; its matching SubagentStop is dispatched.
What is the expected behavior?
Every subagent identity announced by SubagentStart should receive an exact terminal lifecycle event when it completes, fails, or is interrupted. With the current documented hook surface, that should be SubagentStop carrying the same agent_id.
Interruption should therefore dispatch SubagentStop exactly once after the child becomes terminal. If interruption is intentionally distinct from stop, Codex needs another documented exact-ID terminal hook; currently there is no such hook.
Why this matters
External lifecycle consumers cannot repair this safely from the other documented hooks:
Stopdoes not identify the child agent;SessionEnddoes not run for subagents;- the observed
interrupt_agenttool input/output identifies the logical target but does not return the child's exact provideragent_id; - parsing rollout transcripts is not a stable lifecycle interface.
Consequently, any observer that avoids timing or task-name heuristics can remain stuck in Running even though the interrupted child has stopped.
Related issues
- #37301 reports a parent repeatedly waiting after a child was interrupted; it may share the same missing terminal-notification boundary.
- #33700 tracks completed/interrupted descendants remaining persisted as open.
- #33097 is related to hook dispatch but appears distinct: in this reproduction
SubagentStartand normal-completionSubagentStophooks worked, and only the interruption path omitted the terminal event.
1 Comment
Mechanism on
main@ 1f41cc5d92:SubagentStop(likeStop) is dispatched byrun_turn_stop_hooks, which is called from exactly one place — the natural-completion branch of the sampling loop, gated on!needs_follow_up(core/src/session/turn.rs#L482-L490). A turn ended by interruption takes theTurnAbortederror path out ofrun_turnand never reaches that call — so aninterrupt_agent-stopped child recordsturn_abortedin its rollout but no terminal hook fires. Your capture (two natural siblings emitSubagentStop, the interrupted one doesn't) matches the branch structure precisely; this is also skip-path (2) enumerated in the #38213 analysis of missingStopdispatch.Fix shape: move stop-hook dispatch to turn teardown so it runs for every terminal outcome, with the completion kind (
completed/interrupted/error) passed in the hook payload — lifecycle observers need the terminal event most in exactly the abnormal cases that currently skip it. If blocking-continuation semantics (should_block) only make sense for natural completion, the abort-path dispatch can be fire-and-forget notification-only, which sidesteps that design question.