Skip to content

fix(sessions): drive turn status off Claude Code's real lifecycle hooks (v0.324.0) - #593

Merged
vikasprogrammer merged 1 commit into
mainfrom
feat/turn-lifecycle
Aug 8, 2026
Merged

fix(sessions): drive turn status off Claude Code's real lifecycle hooks (v0.324.0)#593
vikasprogrammer merged 1 commit into
mainfrom
feat/turn-lifecycle

Conversation

@vikasprogrammer

Copy link
Copy Markdown
Owner

Reported: finished/stopped sessions still show the LoaderCircle spinner.

Root cause

term_sessions.busy_since — the flag the console spins on — was a one-way latch. It was cleared in exactly one place: the resident branch of markTurnIdle. A member's own interactive session returns before that branch (!r.headless), so its flag was never cleared.

On the live instapods DB: 72 of 520 rows carried a stale busy_since, 66 of them done/stopped/crashed — a finished run whose pane lingers reads live and working, i.e. a spinner on a session that ended hours ago.

The deeper problem: status was inferred (a latched flag + a tmux poll) instead of driven by the runtime's own lifecycle events. We listened to PreToolUse, Notification and Stop. Per the hooks reference there were three more that matter, and all three were missing:

Event Why it matters
UserPromptSubmit The turn-START signal we simply didn't have. busy_since was stamped only when the server delivered a message — a human typing straight into an attached TUI ran whole turns the console couldn't see.
StopFailure A turn killed by an API error (rate_limit, overloaded, …). Claude fires no Stop then, so the turn never ended server-side: the run kept reading "working", the automations pile-up guard kept holding its slot, and an unattended run parked as a zombie until a 24h reaper found it — the shape behind the recurring weekly-limit zombie sessions.
SessionEnd The run is over, with claude's own reason. Only prompt_input_exit / logout / bypass_permissions_disabled are terminal — clear, resume, compact are mid-run, so a /clear must not look like a finished session.

What changed

  • terminal/lifecycle-hook.shPOST /api/session-eventTerminalManager.recordLifecycle handles the three events above. Stop keeps its own script (it carries the unattended-teardown decision, and codex-launch.sh wires that one too).
  • markTurnIdle clears busy_since for every lane — the lane only decides what happens to the pane, never whether the flag is honest.
  • Every terminal status transition NULLs the flag.
  • Defence in depth — a hook that never fires must not strand a spinner. isWorking now requires all five: flag set; row not stopped/crashed (done still counts — report flips the row mid-turn); runtime still alive; no turn-END recorded after the start (last_activity > busy_since, which heals rows latched by older builds with no beacon needed); turn younger than the 2h wedged-turn ceiling.
  • One-time migration clears the already-latched rows (terminal ones + any turn past the ceiling). A genuinely in-flight turn is untouched, so it's safe on a live box.

Verification

Against a VACUUM INTO copy of the live instapods DB: 72 stale flags → 5, and all 5 confirmed genuinely mid-turn by capturing their live tmux panes (e.g. ses_aacdc040029c2643 showed Bootstrapping… (8m 23s)).

scripts/turn-lifecycle-test.cjs — 32 assertions, wired into npm run test:governance: the interactive-lane clear, the turn-start signal, StopFailure teardown, each SessionEnd reason, all five isWorking clauses, ignore-unknown-events. Full suite green.

Contract documented in docs/session-lifecycle-hooks.md.

Deploy note: server restart required. Hook settings are written at launch, so the new events reach a session only when it next launches — which is exactly why isWorking self-heals rather than waiting on a beacon.

🤖 Generated with Claude Code

https://claude.ai/code/session_015ZUfffxY4hKv7M6wMCcaTz

…ks (v0.324.0)

Finished sessions showed the "working" spinner. `term_sessions.busy_since` — the
flag the console spins on — was a ONE-WAY LATCH: it was cleared in exactly one
place, the `resident` branch of markTurnIdle, and a member's own interactive
session returns before that branch (`!r.headless`), so its flag was never
cleared. Live instapods carried a stale busy_since on 72 of 520 rows, 66 of them
done/stopped/crashed — a finished run whose pane lingered read live AND working.

The deeper problem was that status was INFERRED (a latched flag + a tmux poll)
rather than driven by the runtime's own lifecycle events. Agent OS listened to
two of them (PreToolUse, Notification) plus Stop. It now listens to the rest, via
terminal/lifecycle-hook.sh → POST /api/session-event → recordLifecycle:

- UserPromptSubmit — the turn-START signal, which we simply did not have.
  busy_since was stamped only when the SERVER delivered a message, so a human
  typing straight into an attached TUI ran whole turns the console couldn't see.
- StopFailure — a turn killed by an API error (rate_limit, overloaded, …).
  Claude fires NO Stop in that case, so the turn never ended server-side: the run
  kept reading "working", the pile-up guard kept holding its slot, and an
  unattended run parked as a zombie until a 24h reaper found it (the shape behind
  the recurring weekly-limit zombie sessions). Now ends the turn exactly as Stop
  does, including the unattended teardown, and audits session.turn.failed with
  the error_type.
- SessionEnd — the run is over, with claude's own `reason`. Only
  prompt_input_exit / logout / bypass_permissions_disabled are terminal; clear,
  resume and compact are mid-run, so a `/clear` no longer looks like a finish.

Stop keeps its own script: it carries the unattended teardown decision and
codex-launch.sh wires that one too.

Defence in depth, because a hook that never fires must not strand a spinner —
isWorking now requires all five of: busy_since set; the row not stopped/crashed
(`done` still counts, since `report` flips the row mid-turn); the runtime still
alive; no turn-END recorded after the start (last_activity > busy_since, which
heals rows latched by older builds with no beacon needed); and the turn younger
than the 2h wedged-turn ceiling. Every terminal status transition NULLs the flag,
and a one-time migration clears the already-latched rows (terminal ones, plus any
turn past the ceiling) — a genuinely in-flight turn is untouched.

Verified against a VACUUM copy of the live instapods DB: 72 stale flags → 5, and
all 5 were confirmed genuinely mid-turn by capturing their live tmux panes.
New scripts/turn-lifecycle-test.cjs (32 assertions) is in test:governance;
contract documented in docs/session-lifecycle-hooks.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ZUfffxY4hKv7M6wMCcaTz
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