Skip to content

fix: post-compaction stuck "working" status - #154

Merged
aterrylu merged 1 commit into
mainfrom
terry/compaction-stuck-status
Apr 20, 2026
Merged

fix: post-compaction stuck "working" status#154
aterrylu merged 1 commit into
mainfrom
terry/compaction-stuck-status

Conversation

@aterrylu

Copy link
Copy Markdown
Owner

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() for PostCompact unconditionally returned { status: "working" }:

case "PostCompact":
  return { status: "working", ...CLEAR_TOOL };

That assumed compaction only happens mid-turn (user-triggered /compact while agent is working), where the turn continues and Stop eventually fires → idle. But on auto-compaction during session resume, there is no active user turn — so no subsequent Stop event fires, leaving the status stuck at "working" forever.

The existing sticky-idle guard (#149) didn't catch it: before PostCompact, prev.status is "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" ✓
Loading

Solution

Track the pre-compact status on AgentState. Restore it on PostCompact. Fall back to "ready" when there's no baseline.

State machine changes:

  • Save prev.statuspreCompactStatus on 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
  • Invariant: preCompactStatus is only set while status === "compacting". Cleared on any non-compacting exit (SessionEnd mid-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

Why "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
Scenario Before After
Resume auto-compact (Terry's bug) stuck "working" → "ready" ✓
Mid-turn /compact while working "working" (lucky) "working" (restored) ✓
Mid-tool /compact "working" with stale tool "working", tool cleared ✓
/compact during permission prompt restored "needs_input" (stale prompt) coerced to "working" ✓
/compact while idle "idle" (sticky guard) "idle" (unchanged) ✓
Mid-compact cancel (Stop) baseline leaks to next cycle baseline cleared ✓

Changes

  • packages/server/src/routes/hooks.ts — add preCompactStatus field, 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

  • 35/35 hook tests pass (6 new)
  • Full server test suite: 252/252 pass
  • biome check clean
  • tsc --build clean
  • /polish run: 3 review agents — all findings addressed (invariant tightening, staleness coercion, explicit CLEAR_TOOL, stronger test assertions)

Test plan

  • Deploy to dashboard
  • Verify no regression on mid-turn /compact (session continues working after compact)
  • Simulate resume auto-compact: force a session near context limit, restart server, verify status shows "ready" (not spinning) after compaction

Risks

Low. All changes are internal to hooks.ts state derivation. Sticky-idle guard from #149 is untouched and remains the primary safety net. Rollback is trivial (single commit).

Related

🤖 Generated with Claude Code

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
aterrylu marked this pull request as ready for review April 20, 2026 00:05

@nox-0x nox-0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
aterrylu merged commit 70d6e7c into main Apr 20, 2026
1 check passed
@aterrylu
aterrylu deleted the terry/compaction-stuck-status branch April 20, 2026 03:48
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>
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.

2 participants