fix: auto-rotation never fired when memory was enabled - #304
Merged
Conversation
Two defects that together disabled context auto-rotation — soft threshold AND the hard safety net — for every session running with the memory engine on, which is the common configuration. 1. The turn counter never advanced. `#turnsSinceLastRotation` is the only input to decideRotation's min-turns guard and is process-local (never read back from the store), but it was incremented inside the `if (!store)` branch of Session#recordUsageFromTurn — i.e. only when memory was OFF. With memory on it stayed pinned at 0, so every check returned `below_min_turns`. Hoisted above the branch so both paths count. 2. The min-turns guard preempted the hard ceiling. decideRotation evaluated `numTurns < minTurnsBeforeRotate` BEFORE `occupancy >= hardRotatePct`, so the stuck counter did not merely delay rotation, it suppressed the ceiling too — the one check documented to fire regardless of the `enabled` toggle. Reordered so the ceiling is evaluated first. It is the last defence before the backend rejects the prompt outright; no other guard may gate it. A session that reaches 97% within its first few turns (one large paste, a wide repo scan) needs the net more than a long-running one, not less. This also means a stuck counter can no longer suppress it silently. Observed live: a session pinned to opus[1m] sat at 999,627 tokens across 52 primary calls above the 970k ceiling, rotated zero times, logged no rotation decision, and failed with `prompt is too long: 1000076 tokens > 1000000 maximum` on four separate occasions. Enabling autoRotate would not have helped — both thresholds sit downstream of the guard that never released. Two existing tests encoded the old ordering and are rewritten to state the new intent rather than flipped: the min-turns case now exercises an elevated but sub-ceiling occupancy (its actual job — suppressing churn after a rotation), and the former "hard threshold still respects min-turns" becomes an explicit preemption test. Added a case reproducing the exact failure shape (counter pinned at 0, occupancy 999,627) plus a structural guard asserting the increment stays outside the store branch — a behavioural test would need a full daemon + store + provider harness for a single integer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jalbrethsen-highflame
approved these changes
Aug 25, 2026
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.
Context auto-rotation — soft threshold and the hard safety net alike — was silently disabled for every session running with the memory engine on, i.e. the common configuration. Two defects compounded.
1. The turn counter never advanced
#turnsSinceLastRotationis the only input todecideRotation's min-turns guard, and it's process-local (never read back from the store). But it was incremented inside theif (!store)branch ofSession#recordUsageFromTurn— only when memory was off.With memory on it stayed pinned at
0, so every check returnedbelow_min_turns. Hoisted above the branch.2. The min-turns guard preempted the hard ceiling
So the stuck counter didn't merely delay rotation — it suppressed the ceiling too, the one check documented to fire "regardless of the
enabledtoggle."Reordered so the ceiling is evaluated first. It's the last defence before the backend rejects the prompt outright, so no other guard may gate it. A session reaching 97% within its first few turns — one large paste, a wide repo scan — needs the net more than a long-running one, not less. It also means a stuck counter can never suppress it silently again.
Observed
A session pinned to
opus[1m]:prompt is too long: 1000076 tokens > 1000000 maximumon four separate occasionsEnabling
autoRotatewould not have helped — both thresholds sit downstream of the guard that never released. That's why the symptom looked like "compaction is reactive": the only thing left to catch it was the SDK's post-error compact.Test changes, called out explicitly
Two existing tests encoded the old ordering. I rewrote them to state the new intent rather than just flipping expectations:
below min-turns guard → …now exercises an elevated but sub-ceiling occupancy (91%), which is the guard's actual job: suppressing churn while a fresh seed prompt earns its keep.hard threshold still respects min-turns→hard threshold PREEMPTS min-turns. A deliberate behavioural inversion, not a test fix.Added: a case reproducing the exact failure shape (counter pinned at
0, occupancy 999,627,enabled: false) and a structural guard keeping the increment outside the store branch. The latter is admittedly crude — a behavioural test would need a full daemon + store + provider harness for one integer — but it fails loudly on the single edit that would reintroduce this.Verification
lint+typecheckclean, 2372 pass / 0 fail.🤖 Generated with Claude Code