fix(compaction): enforce breaker and per-turn cap on blocking compaction routes - #529
Conversation
…ion routes applyBlockingCompaction() bypassed the circuit breaker and per-turn caps because precomputed results skip session_before_compact in core, letting the hard-limit, idle, and turn_end zero-yield triggers loop unbounded (each iteration paying a real summarization request). - Gate applyBlockingCompaction entry on breaker.isTripped and cap.shouldRejectByCap - Wire cap.incrementIneffective into the session_compact accepted branch so ineffective attempts count toward the soft cap (was dead code) - Add blocking-compaction-route-guards regression tests (red before, green after) QA evidence: local-ignore/qa-evidence/20260730-issue-527-blocking-route-guards/ closes code-yeongyu#527
|
Closing: live debugging on a real stuck session (Node inspector attached to the running TUI process) showed the actual user-facing infinite-compaction symptom comes from a different loop — the turn-retry ↔ pre-prompt compaction cycle (~60s breaker-cooldown cadence) plus a compaction lifecycle leak that leaves the UI spinner open permanently. The applyBlockingCompaction guard bypass fixed here is real but was never exercised by the live session (zero hits on that path while the loop was active). I'll open a separate PR for the actual root cause. The cap/breaker bypass documented in #527 remains a valid latent issue; happy to resubmit this fix separately if maintainers want it. |
|
Reopening: this fix is for issue #527, which turned out to be a different defect from the one that bricked the live session (that one is #531, fixed by #532). Why this still matters, and more than before: once #532 lands, prompts over the soft threshold proceed during a breaker cooldown instead of dying at admission. That means sessions near the hard limit will now actually reach the Scope recap (unchanged from the original PR):
The earlier close was only because this PR did not address the live incident — not because the defect was invalid. |
What changed
applyBlockingCompaction()now enforces the circuit breaker and the per-turn soft cap at its entry, and thesession_compactaccepted branch wires the previously deadcap.incrementIneffectivecounter.Why
The per-turn cap (soft 3 / hard 10) and circuit breaker (3 failures → 60s cooldown) were only consulted in the
session_before_compacthook. Every blocking trigger —before_agent_starthard-limit,agent_endidle,turn_endzero-yield recovery, degradation-monitor recovery — generates a summary viaapplyBlockingCompaction()and applies it precomputed, which skipssession_before_compactin_executeCompaction(if (!compactionResult)branch). Both guards were dead on those routes, so compaction could repeat without bound, each iteration paying a real LLM summarization request. Additionally,incrementIneffectivewas never called fromsrc/, so ineffective attempts never counted toward the soft cap and theturn_endzero-yield route retriggered on every tool turn.Reproduced three unbounded loops with deterministic faux-provider tests (details in #527):
Observed behavior after fix
FAILURE_TRIP_THRESHOLD(3) summarization calls.QA / evidence
Full receipts:
local-ignore/qa-evidence/20260730-issue-527-blocking-route-guards/.test/compaction/blocking-compaction-route-guards.test.ts: red before fix (8/12/5 calls), green after (3/3 pass).npx vitest --run test/compaction/ test/compaction*.test.ts test/trigger-compact-extension.test.ts: 47 files, 362 tests pass.test/suite/regressions/: 336 tests pass after workspace build (the only initial failures were unbuilt-distmodule errors in a fresh clone; green afternpm run build).npx biome checkclean on changed files; rootnpx tsgo --noEmitexit 0.compaction-remote-qa.mjs --self-test: 5/6, identical on pristinemain(the one FAIL is a pre-existing payload-replay assertion, A/B verified viagit stash).compaction-wave1.mjsfails identically onmain(fake model never called in this environment) — not caused by this change.Residual risk
closes #527
Summary by cubic
Prevents unbounded blocking compaction loops by enforcing the circuit breaker and per-turn cap on all blocking routes and counting ineffective compactions toward the cap. This bounds retries and avoids wasted LLM summarization calls (addresses #527).
applyBlockingCompaction()on the circuit breaker and per-turn soft cap; reject when tripped or capped.cap.incrementIneffectivein thesession_compactaccepted handler to stop zero-yield retriggers within the same turn.Written for commit 047e900. Summary will update on new commits.