App-server does not dispatch Stop hook after turn/completed
Open 💬 2 comments Opened Aug 12, 2026 by Weicheng0917-creator
Summary
When a normal app-server turn reaches turn/completed, the configured Stop hook is not dispatched.
Environment
- Codex CLI:
0.147.0-alpha.6.5 - OS: macOS
- Transport: app-server over stdio
- Isolated temporary
CODEX_HOMEand repository fixture - No global hook installation or user configuration changes
Reproduction
- Configure trusted lifecycle hooks for
SessionStart,UserPromptSubmit,Stop,PostCompact, andSessionEnd. - Start app-server.
- Start a temporary persisted thread and a no-tools turn.
- Wait for the server to emit
turn/completed. - Observe hook lifecycle notifications for up to 10 seconds after completion.
Expected behavior
After turn/completed, app-server dispatches the Stop hook and emits the corresponding hook lifecycle notifications.
Actual behavior
SessionStartandUserPromptSubmitare dispatched.- The turn reaches
turn/completed. - No
hook/startednotification forStopappears within 10 seconds. - When app-server is then shut down,
SessionEndis dispatched.
This reproduces consistently in an isolated canary without modifying real user configuration.
Related issue
#22858 discusses interrupted turns. This report concerns a normally completed turn.
2 Comments
Narrowing from
main@ 1f41cc5d92: the Stop dispatch is wired for app-server regular turns —tasks/regular.rsdrivesrun_turn, which callsrun_turn_stop_hookswhen the sampling loop ends without follow-up (core/src/session/turn.rs#L482-L490). So the bug is in one of the paths that skip it, which are enumerable:run_turn_stop_hooksearly-returns for anySessionSource::SubAgentother than thread-spawn (hook_runtime.rs#L347-L349) — if the app-server client's session source classifies as internal, Stop is silently skipped by design.!needs_follow_upcompletion branch — turns ending via error/abort/compaction-restart paths bypass it.A discriminating check for your fixture: run with
RUST_LOG=codex_hooks=trace,codex_core=debugand look for whether a Stop request is built at all. If nothing appears, it's (1)/(2) — worth logging thesession_sourceyour client ends up with. If the engine runs the hook but you see no lifecycle notification, the gap is in app-server's hook-event forwarding instead, which is a different (and smaller) fix. Either way the enumeration above should shortcut the bisection.Verified your enumeration against current main (
10d5a603ae, 2026-08-26) — it holds, with updated line numbers, plus a few additions and two recent commits you may not have seen (your check was on1f41cc5d92, 08-18).Dispatch site (unchanged, moved slightly)
run_turn_stop_hooksstill fires only in the!needs_follow_upbranch of the sampling loop — nowcore/src/session/turn.rs:504. The SubAgent silent skip is nowhook_runtime.rs:359.Additional skip paths not in your enumeration
run_pending_session_start_hooksatturn.rs:264— if a SessionStart hook's outcome carriesshould_stop(viarun_context_injecting_hook→record_additional_contexts, hook_runtime.rs:711–737),run_turnreturnsOk(None)before the sampling loop ever runs, so Stop never fires.turn.rs:495inside theshould_roll_overbranch (auto-compact →run_pending_session_start_hooks→Ok(None)).emit_turn_error_lifecycle+Ok(None)atturn.rs:188.tasks/regular.rs:67returnsOk(None)beforerun_turnis even entered.Your fixture configures a SessionStart hook — if that hook ever returns
shouldStop(even transiently, e.g. a default block-on-first-event policy), the turn exits via (1) and matches your symptom exactly.Consistency check: why you still see
turn/completedtasks/regular.rstreatsrun_turn'sOk(None)as a normal completion (regular.rs:77–90) — the task returns,turn/completedfires, and nothing in that path indicates a Stop hook was skipped. So all of the above skip paths produce your exact symptom signature (completion event, no Stop). That's also why the gap is invisible without theRUST_LOGtrace.Two commits since your 1f41cc5d92 check (both 2026-08-25)
cbfd999db7) — adds anInterrupthook event that fires for an active top-level turn before its interrupted-abort event. This covers the aborted-path gap, but as a new event, not Stop.7c6eb0eef1) — memory-consolidation-internal sessions now error on Stop-hook block; not relevant to a VSCode-sourced turn.Neither changes the normal-completion path, so your fixture's gap should still reproduce on current main.
Discriminator (sharpened)
Your
RUST_LOG=codex_hooks=tracecheck is the right call. To distinguish my added paths (1)/(2) from your (2): grep the trace for the SessionStart handler's JSON response — if it ever containsshouldStop: true, the turn is exiting atturn.rs:264/495and Stop is skipped by design (that's the bug you'd want fixed: a blocked SessionStart should still emit a terminal Stop/notification). If the SessionStart response is clean and Stop is still absent, then either the sampling path errored (your path 2) or the hook runs but app-server never forwards the lifecycle event to the client — your "smaller fix" candidate, which would show up in the trace as a built Stop request with no client-visible notification.Happy to dig into whichever branch the trace points at once you've run it.