Skip to content

fix: auto-rotation never fired when memory was enabled - #304

Merged
saucam merged 2 commits into
mainfrom
fix/rotation-never-fires-with-memory
Aug 25, 2026
Merged

fix: auto-rotation never fired when memory was enabled#304
saucam merged 2 commits into
mainfrom
fix/rotation-never-fires-with-memory

Conversation

@saucam

@saucam saucam commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

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

#turnsSinceLastRotation is the only input to decideRotation's min-turns guard, and it's process-local (never read back from the store). But it was incremented inside the if (!store) branch of Session#recordUsageFromTurn — only when memory was off.

With memory on it stayed pinned at 0, so every check returned below_min_turns. Hoisted above the branch.

2. The min-turns guard preempted the hard ceiling

if (numTurns < minTurnsBeforeRotate) return { reason: "below_min_turns" };  // ← first
if (occupancy >= hardRotatePct)      return { reason: "hard_threshold"  };  // ← unreachable

So the stuck counter didn't 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'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]:

  • sat at 999,627 tokens across 52 primary calls above the 970k ceiling
  • rotated zero times, logged no rotation decision
  • 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. 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-turnshard 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 + typecheck clean, 2372 pass / 0 fail.

🤖 Generated with Claude Code

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>
@saucam
saucam merged commit bf733f1 into main Aug 25, 2026
4 checks passed
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