fix(sessions): drive turn status off Claude Code's real lifecycle hooks (v0.324.0) - #593
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reported: finished/stopped sessions still show the
LoaderCirclespinner.Root cause
term_sessions.busy_since— the flag the console spins on — was a one-way latch. It was cleared in exactly one place: theresidentbranch ofmarkTurnIdle. 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 themdone/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,NotificationandStop. Per the hooks reference there were three more that matter, and all three were missing:UserPromptSubmitbusy_sincewas stamped only when the server delivered a message — a human typing straight into an attached TUI ran whole turns the console couldn't see.StopFailurerate_limit,overloaded, …). Claude fires noStopthen, 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.SessionEndreason. Onlyprompt_input_exit/logout/bypass_permissions_disabledare terminal —clear,resume,compactare mid-run, so a/clearmust not look like a finished session.What changed
terminal/lifecycle-hook.sh→POST /api/session-event→TerminalManager.recordLifecyclehandles the three events above.Stopkeeps its own script (it carries the unattended-teardown decision, andcodex-launch.shwires that one too).markTurnIdleclearsbusy_sincefor every lane — the lane only decides what happens to the pane, never whether the flag is honest.isWorkingnow requires all five: flag set; row notstopped/crashed(donestill counts —reportflips 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.Verification
Against a
VACUUM INTOcopy 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_aacdc040029c2643showedBootstrapping… (8m 23s)).scripts/turn-lifecycle-test.cjs— 32 assertions, wired intonpm run test:governance: the interactive-lane clear, the turn-start signal,StopFailureteardown, eachSessionEndreason, all fiveisWorkingclauses, 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
isWorkingself-heals rather than waiting on a beacon.🤖 Generated with Claude Code
https://claude.ai/code/session_015ZUfffxY4hKv7M6wMCcaTz