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_HOME and repository fixture
  • No global hook installation or user configuration changes

Reproduction

  1. Configure trusted lifecycle hooks for SessionStart, UserPromptSubmit, Stop, PostCompact, and SessionEnd.
  2. Start app-server.
  3. Start a temporary persisted thread and a no-tools turn.
  4. Wait for the server to emit turn/completed.
  5. 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

  • SessionStart and UserPromptSubmit are dispatched.
  • The turn reaches turn/completed.
  • No hook/started notification for Stop appears within 10 seconds.
  • When app-server is then shut down, SessionEnd is 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.

View original on GitHub ↗

2 Comments

jdcodes1 · 9 days ago

Narrowing from main @ 1f41cc5d92: the Stop dispatch is wired for app-server regular turns — tasks/regular.rs drives run_turn, which calls run_turn_stop_hooks when 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:

  1. run_turn_stop_hooks early-returns for any SessionSource::SubAgent other 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.
  2. The hook only fires on the !needs_follow_up completion branch — turns ending via error/abort/compaction-restart paths bypass it.
  3. Review/compact task types never reach it.

A discriminating check for your fixture: run with RUST_LOG=codex_hooks=trace,codex_core=debug and look for whether a Stop request is built at all. If nothing appears, it's (1)/(2) — worth logging the session_source your 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.

argszero · 1 day ago

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 on 1f41cc5d92, 08-18).

Dispatch site (unchanged, moved slightly)

run_turn_stop_hooks still fires only in the !needs_follow_up branch of the sampling loop — now core/src/session/turn.rs:504. The SubAgent silent skip is now hook_runtime.rs:359.

Additional skip paths not in your enumeration

  1. Pre-loop SessionStart block: run_pending_session_start_hooks at turn.rs:264 — if a SessionStart hook's outcome carries should_stop (via run_context_injecting_hookrecord_additional_contexts, hook_runtime.rs:711–737), run_turn returns Ok(None) before the sampling loop ever runs, so Stop never fires.
  2. Post-compaction SessionStart block: same check at turn.rs:495 inside the should_roll_over branch (auto-compact → run_pending_session_start_hooksOk(None)).
  3. Input-phase error: emit_turn_error_lifecycle + Ok(None) at turn.rs:188.
  4. Prewarm cancellation: tasks/regular.rs:67 returns Ok(None) before run_turn is 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/completed

tasks/regular.rs treats run_turn's Ok(None) as a normal completion (regular.rs:77–90) — the task returns, turn/completed fires, 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 the RUST_LOG trace.

Two commits since your 1f41cc5d92 check (both 2026-08-25)

  • #40511 "Add hooks for interrupted turns" (cbfd999db7) — adds an Interrupt hook 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.
  • #40587 "Scope stop hooks for memory consolidation" (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=trace check 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 contains shouldStop: true, the turn is exiting at turn.rs:264/495 and 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.