🧯 #3451 · the guard that blocked the only exit

A sixth finding in the compaction subsystem of #3321, in the one hook #3427 deliberately kept alive.
observed live at 100% context spawn log: 839 lines, 0 completion records 6 of 8 new tests fail on old code 7562 hook tests

What happened

The session filled its context window. Claude Code printed its own recovery instruction, then ran automatic compaction, and lifecycle/pre-compact-guard vetoed it.

Context limit reached · /compact or /clear to continue
Compaction blocked, 1 background agent(s) still running (ckleg2).
Wait for completion or set CLAUDE_CODE_ALLOW_COMPACT_DURING_AGENTS=1.
That leaves /clear as the only exit, which discards the session state the guard exists to protect. The hook is most destructive exactly where it matters least. It is also newly more reachable: #3427 armed autoCompactWindow one day earlier, so automatic compaction now runs at a chosen fill level rather than only at the model limit.

Three defects

#DefectEvidence
1Cannot tell auto from manual. The handler signature was (_input, ctx), so the payload was never readtrigger ('auto' | 'manual') has been typed and binary-verified in types.ts:187 since 2.1.227. The field was there all along.
2The remedy is unreachable from where it prints. Now fixed by repeat-to-confirm, belowThe env var is read from the hook process env, inherited fixed at CC launch. Acting on that sentence needs a restart, which loses the same context.
3"Active" was recency, not liveness, and undedupedThe spawn log has no completion records at all, so a finished agent blocks for the full 5 minutes. One agent is logged twice by design.

Defect 3 is the load-bearing one

.claude/logs/subagent-spawns.jsonl is a spawn-intent log. Two writers, no closer. The SubagentStop hooks write to entirely different files, so nothing ever marks an agent finished:

$ node -e '...tally sources...'   { start: 694, pretool: 145 }   zero terminal entries
$ wc -l .claude/logs/subagent-spawns.jsonl  839

the two writers
  subagent-validator            source: 'start'
  pretool/task/spawn-intent-logger  source: 'pretool'

So now - spawn.timestamp < 5min answers "was one started recently", never "is one running". The fix prefers input.background_tasks, CC's authoritative live list since 2.1.145, which subagent-stop/watchdog already consumes for this same class of phantom-agent bug (#1882).

An EMPTY background_tasks is trusted as "nothing running." Treating empty as "no signal" and falling back to the timestamp window would reinstate the defect in the exact case that matters, so presence of the array, not its length, selects the source.

The fix nobody should apply instead

The obvious repair for defect 3 is to write completion records into the spawn log. That trades one bug for another:

watchdog.getActiveSpawns()  reads the last 20 lines and treats
                            EVERY entry as a live spawn

append {source:'stop'} ──▶ watchdog reports completed agents as hung

Hence background_tasks from the payload, with the timestamp window surviving only as the pre-2.1.145 fallback.

The escape hatch that actually works from inside

Auto passing through does not help the case that started this: the operator typed /compact by hand, at the ceiling, and got stopped. Defect 2 said the printed remedy was unreachable. So the manual path now confirms by repetition.

/compact          ──▶ BLOCKED
  2 agents running (test-generator, cover).
  Run /compact again within 60s to compact anyway.

/compact (again)  ──▶ ALLOWED
  marker consumed, proceeding with 2 agents live

The marker is one session-keyed file, .claude/state/precompact-confirm-<sessionId>.json, following the existing nudge-outcome-state.ts convention. Writes are best-effort by design: a failed write costs one extra /compact, it must never change the verdict or throw out of the hook. Auto deliberately does not consume the marker, since it never blocks and so never needs a confirm; a test pins that, or an auto pass could silently spend a confirm a manual attempt was holding.

Control, run against the code each test replaces

× never blocks automatic compaction, even with active agents
× allows when background_tasks is empty despite a recent spawn entry
× blocks from background_tasks when CC reports a live agent
× counts one agent once when the spawn log holds duplicate entries
× blocks the first manual compact and leaves a confirm marker
× allows a repeat compact inside the confirm window

  6 of 8 new tests fail against the code they replace

The other two confirm tests (re-block after the window expires, auto does not consume the marker) pass vacuously against the prior code, which blocked unconditionally and never unlinked. They are recorded as regression guards, not controls. Counting them as controls would be the exact green-test-beside-an-inert-control failure this repo keeps finding in its own work.

The pre-existing five passed before and still pass, and they are why this survived: every one builds an input with no trigger and no background_tasks, so all five only ever exercised the fallback path. Behaviour under a real CC payload was never asserted.

Verification

One claim not made. That background_tasks is delivered on the PreCompact payload is confirmed for Stop and SubagentStop, and assumed here. The code is written so it does not matter: if CC omits the field, Array.isArray is false and the hook uses the deduped timestamp fallback, which is strictly better than today. The auto-compaction fix, which is the one that unblocks a stuck session, does not depend on it at all.