fix: post-compaction stuck "working" status - #154
Merged
Conversation
Sessions that auto-compacted on server-restart-resume got stuck at
"working" / spinning forever. Root cause: deriveStatus() for PostCompact
unconditionally returned { status: "working" }, but on resume there is
no active user turn to eventually fire Stop → idle, so the status never
cleared.
Fix: track the pre-compact status on AgentState and restore it on
PostCompact. When there is no baseline to restore (cold-start auto-
compact), fall back to "ready" instead of "working" so the UI reflects
an alive-but-idle agent.
State machine details:
- Save prev.status to preCompactStatus when entering "compacting" (skip
when prev is "unknown" or already "compacting")
- On PostCompact, restore preCompactStatus; coerce tool_running /
needs_input / error to "working" since those transient conditions
don't survive the JSONL collapse
- Clear preCompactStatus on any non-compacting exit (SessionEnd during
compact, duplicate PostCompact, user-cancel via Stop) so the baseline
can't leak across cycles
- Always clear currentTool/toolDetail on PostCompact defensively,
independent of deriveStatus
Tests: 6 new cases covering the resume auto-compact regression, mid-turn
restore, tool_running / needs_input coercion, and cross-cycle leak.
All 35 hook tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
aterrylu
marked this pull request as ready for review
April 20, 2026 00:05
nox-0x
approved these changes
Apr 20, 2026
nox-0x
left a comment
Collaborator
There was a problem hiding this comment.
Clean fix. The preCompactStatus tracking is correct and the invariant (only set while status === "compacting") holds across all exit paths. Stale coercion for tool_running/needs_input/error is the right call — those states dont survive JSONL compaction. 6 new tests cover the surface well. LGTM.
aterrylu
added a commit
that referenced
this pull request
Jul 11, 2026
…t hooks (ADR-053) (#278) Claude Code agents got stuck showing the "compacting" spinner forever after a compaction; the prior fix (#154) didn't hold. Root cause is a delivery-order race, not a missing handler. CC fires the compaction hooks — PreCompact, the summarizer's SubagentStop, SessionStart(source=compact), PostCompact — within ~90ms as async fire-and-forget curls, so arrival order at the server is non-deterministic. deriveStatus mapped SessionStart(source=compact) → "compacting", and #154's save/restore assumed SessionStart(compact) lands before PostCompact. When it lands last it re-enters "compacting" after PostCompact already resolved, and a manual /compact (or resume auto-compact) has no trailing Stop to self-heal. A deterministic probe through the real router: 2 of 6 racing-trio orderings stranded the agent forever. Make compaction order-independent in routes/hooks.ts: - SessionStart(source=compact) and PostCompact are idempotent "resolve" signals — whichever arrives first restores the saved baseline, the second no-ops. Order can't strand the agent. - PreCompact enters "compacting" only from an actively-working state (a fail-safe allowlist), so a /compact at rest and a duplicate PreCompact are no-ops (the latter can't overwrite the baseline with the spinner state). - The summarizer's SubagentStart/Stop are ignored while compacting. - restoredStatus coerces a "compacting" baseline back to "working". Adds a compaction order-independence test suite replaying all 6 orderings (idle→idle, mid-turn→working), both resume orders → ready, plus duplicate- PreCompact, /compact-from-ready, and PreCompact-last self-heal cases — closing #154's single-order blindspot. Probe after fix: 0/6 stranded. Documented in ADR-053. Claude-Session: https://claude.ai/code/session_01KqDTRj7iwz9GWfgye84MDJ Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.
Problem
Sessions that auto-compact on server-restart-resume get stuck at "working" / spinning forever in the dashboard. Terry hit this today after deploying a new server version: sessions restarted, several auto-compacted on resume, and even after compaction completed cleanly, the UI showed them as still working indefinitely.
Root cause
deriveStatus()forPostCompactunconditionally returned{ status: "working" }:That assumed compaction only happens mid-turn (user-triggered
/compactwhile agent is working), where the turn continues andStopeventually fires → idle. But on auto-compaction during session resume, there is no active user turn — so no subsequentStopevent fires, leaving the status stuck at "working" forever.The existing sticky-idle guard (#149) didn't catch it: before PostCompact,
prev.statusis"compacting"or"unknown"(fresh restart) — neither is sticky, so the bad transition passes through.sequenceDiagram participant Server participant CC as Claude Code participant Hooks as hooks.ts Note over Server,Hooks: BEFORE (buggy) Server->>CC: spawn --resume <id> CC->>Hooks: SessionStart source=compact Hooks-->>Hooks: status = compacting CC->>CC: compact JSONL CC->>Hooks: PostCompact Hooks-->>Hooks: status = working ❌ Note over Hooks: No turn → no Stop → stuck spinning Note over Server,Hooks: AFTER (fix) Server->>CC: spawn --resume <id> CC->>Hooks: SessionStart source=compact Hooks-->>Hooks: status = compacting, preCompactStatus = undefined CC->>CC: compact JSONL CC->>Hooks: PostCompact Hooks-->>Hooks: no baseline → fall back to "ready" ✓Solution
Track the pre-compact status on
AgentState. Restore it onPostCompact. Fall back to"ready"when there's no baseline.State machine changes:
prev.status→preCompactStatuson entering"compacting"(skip when prev is"unknown"or already"compacting")PostCompact, restorepreCompactStatus— coercetool_running/needs_input/errorto"working"since those transient conditions don't survive the JSONL collapsepreCompactStatusis only set whilestatus === "compacting". Cleared on any non-compacting exit (SessionEnd mid-compact, duplicate PostCompact, user-cancel via Stop) so the baseline can't leak across cyclescurrentTool/toolDetailon PostCompact defensively, independent ofderiveStatusWhy
"ready"fallback instead of"idle"or keeping"working":"idle"would trip the sticky guard and block subsequent tool events from transitioning → breaks mid-turn flows"working"is the original bug"ready"is non-sticky and represents "alive, awaiting input" accurately/compactwhile working/compact/compactduring permission prompt/compactwhile idleChanges
packages/server/src/routes/hooks.ts— addpreCompactStatusfield, save/restore logic in handler, staleness coercion, defensive tool-clear (+49/-7)packages/server/src/__tests__/hooks.test.ts— 6 new tests covering regression + invariants (+67/-6)Testing
biome checkcleantsc --buildclean/polishrun: 3 review agents — all findings addressed (invariant tightening, staleness coercion, explicit CLEAR_TOOL, stronger test assertions)Test plan
/compact(session continues working after compact)Risks
Low. All changes are internal to
hooks.tsstate derivation. Sticky-idle guard from #149 is untouched and remains the primary safety net. Rollback is trivial (single commit).Related
🤖 Generated with Claude Code