Skip to content

Commit 328fb4d

Browse files
Merge pull request #1315 from corbitsdev/cl-9902-keep-compaction-growth-latch-after-under-threshold-folds
fix(compaction): keep growth latch after under-threshold folds
2 parents 2d71ae7 + 28131a3 commit 328fb4d

4 files changed

Lines changed: 97 additions & 34 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,12 @@ The agent maintains an optional **`manage_tasks`** list (create/update via the h
203203

204204
When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. The compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with a harness-inserted assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The governor covers five cases:
205205

206-
- **Threshold at a tool pause** — Once over threshold, the follow-up `infer` after a tool batch is swapped for a `compact` cycle, and inference resumes via a host continuation message. After a compact that remains over the high watermark, the governor latches: it does not re-arm on growth alone until usage climbs a wide resume gap (~20% of the window, a full warning band) past the post-compact measurement, or until a summarizing fold gets usage back at or under the threshold. A consecutive-compact cap holds across tool-call occupancy as a second rail.
206+
- **Threshold at a tool pause** — Once over threshold, the follow-up `infer` after a tool batch is swapped for a `compact` cycle, and inference resumes via a host continuation message. After every compact (threshold, operator, overflow), the governor snapshots post-compact usage (`tokensAtLastCompact`) and keeps that growth latch. It does not re-arm the proactive path on growth alone; re-arm requires a wide resume gap (~20% of the window, a full warning band) past that snapshot. Usage back at or under the 60% threshold restores the consecutive-compact cap and the overflow-recovery budget; it does not drop the growth latch. A fold that landed well under (snapshot + 20% already past the 60% trigger) still re-arms at the next 60% crossing because the gap predicate is already satisfied. A fold that only just got under (typical live ~45% after a thin-tail fold) does not sawtooth on the next 60% crossing; it waits until post-compact + 20%. A consecutive-compact cap holds across tool-call occupancy as a second rail.
207207
- **Idle (end-of-turn)** — An interactive turn can end with a reply and then sit idle with no tool batch to intercept; the governor requests a continuation at that pause and compacts when it arrives. An operator message that races the continuation still compacts first, then re-enters inference to answer it.
208208
- **Idle recompress past the provider TTL** — Cache expiry does not compact. Stored turns stay. When settings set `anthropicCachePrompt`, and the saved Anthropic-protocol cache write is at least 5 minutes old, the pre-inference context transform stubs old tool-result bodies on the outgoing prompt only. `turns.jsonl` is not rewritten. The setting defaults off.
209209

210210
- **Operator `/compact`** — A first-class slash command folds now without waiting for the occupancy governor. Optional trailing instructions go to the summarizer (empty uses the default structured fold) and are stored on the compact record so later auto-folds still see them. Director rebuild (`/model`, interrupt, resume) restores those instructions from the latest compact record's parameters. An in-flight turn uses the same pair-safe compact-then-continue hop as threshold compact; an idle compact shows the fold and does not start a new turn.
211-
- **Overflow recovery** — A `context_overflow` inference error would otherwise become a terminal error reply; the governor compacts and retries instead, bounded so a history the compactor cannot shrink does not loop forever. The overflow compact itself always fires, but the recovery counts against the same latch: only an under-threshold fold restores the budget, and the proactive path stays subject to the latched re-arm rule.
211+
- **Overflow recovery** — A `context_overflow` inference error would otherwise become a terminal error reply; the governor compacts and retries instead, bounded so a history the compactor cannot shrink does not loop forever. The overflow compact itself always fires, then snapshots like any other compact. Only an under-threshold fold restores the overflow-recovery budget; that restore does not drop the growth latch. The proactive path re-arms only when usage has grown the resume gap past the post-compact snapshot.
212212

213213
The compaction control flow is shaped by a reactor invariant: a `compact` action runs in its own cycle (it cannot be paired with `infer`), and **the reactor delivers no event after a compact cycle**. A director that simply emitted `compact` in place of the follow-up `infer` would leave the loop idle forever — the cause of an earlier stall. Instead the governor pairs `compact` with an emit action (`custom.compaction.continue`), and the host answers that emission by delivering a content-less inbound message (`buildCompactionContinuationMessage()`) through the serial operation queue, generation-guarded like every other deliver. A hop superseded because interrupt already bumped generation and enqueued a rebuild is re-queued onto the replacement agent rather than consumed on the outgoing liveAgent. That message adds no turn (`createInboundTurn` returns `null` for empty content) but re-enters the loop, where the director issues the follow-up `infer` against the freshly truncated history. Each emission is answered at most once (a replayed duplicate of an already-answered emission is ignored), and an unsolicited continuation with neither resume flag set answers `wait` rather than burning a billable inference. The legacy `requestContinuation` closure survives only on the sub-agent path, which delivers directly with no host emit hop; idle arming keeps the two channels exclusive (closure fires and the arming flag stays false, or no closure and the flag reports the arming) so a caller honoring both cannot double-deliver.
214214

‎src/agent/compaction.test.ts‎

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -593,25 +593,58 @@ describe("compaction governor", () => {
593593
expect(governor.usingEstimate).toBe(false);
594594
});
595595

596-
test("clears the latch once usage drops under the high watermark", () => {
596+
test("a deep post-compact fold still re-arms at the next 60% crossing", () => {
597597
const governor = createCompactionGovernor(() => undefined);
598598
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
599599
expect(
600600
governor.interceptActions(toolDone(), inferAction, capabilities),
601601
).not.toBeNull();
602602

603-
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
603+
// Post-compact measurement is a deep fold, far under the watermark.
604+
// hasWideResumeGap is already satisfied at the next 60% crossing
605+
// (1000 + 0.2*window << threshold), so that crossing still arms.
606+
governor.noteInferenceDone(inferenceDone(1000), tenTurns);
604607
expect(
605608
governor.interceptActions(toolDone(), inferAction, capabilities),
606609
).toBeNull();
607610

608-
governor.noteInferenceDone(inferenceDone(1000), tenTurns);
611+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
612+
const actions = governor.interceptActions(
613+
toolDone(),
614+
inferAction,
615+
capabilities,
616+
);
617+
expect(actions).not.toBeNull();
618+
expect(actions?.some((a) => a.type === "compact")).toBe(true);
619+
});
620+
621+
test("a marginal post-compact fold does not re-arm at the next 60% crossing", () => {
622+
const governor = createCompactionGovernor(() => undefined);
623+
// ~45% of a 128k window; under the 60% high watermark, but close enough
624+
// that overThreshold - postCompact < wideDelta.
625+
const marginalInput = Math.floor(compactionThresholdFor("m") * 0.75);
626+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
627+
expect(
628+
governor.interceptActions(toolDone(), inferAction, capabilities),
629+
).not.toBeNull();
630+
631+
governor.noteInferenceDone(inferenceDone(marginalInput), tenTurns);
609632
expect(
610633
governor.interceptActions(toolDone(), inferAction, capabilities),
611634
).toBeNull();
612635

613-
// Next crossing of high arms immediately — no growth delta required.
636+
// Next 60% crossing must not re-arm — the growth latch survived the
637+
// under-threshold fold.
614638
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
639+
expect(
640+
governor.interceptActions(toolDone(), inferAction, capabilities),
641+
).toBeNull();
642+
643+
// Re-arm only once usage has climbed a wide resume gap past the snapshot.
644+
governor.noteInferenceDone(
645+
inferenceDone(marginalInput + wideDelta),
646+
tenTurns,
647+
);
615648
const actions = governor.interceptActions(
616649
toolDone(),
617650
inferAction,
@@ -638,6 +671,31 @@ describe("compaction governor", () => {
638671
expect(actions?.some((a) => a.type === "compact")).toBe(true);
639672
});
640673

674+
test("overflow still compacts while an under-threshold latch blocks the proactive path", () => {
675+
const governor = createCompactionGovernor(() => undefined);
676+
const marginalInput = Math.floor(compactionThresholdFor("m") * 0.75);
677+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
678+
expect(
679+
governor.interceptActions(toolDone(), inferAction, capabilities),
680+
).not.toBeNull();
681+
682+
// Post-compact measurement is a marginal under-threshold fold. The
683+
// growth latch must survive it so the next 60% crossing stays inert.
684+
governor.noteInferenceDone(inferenceDone(marginalInput), tenTurns);
685+
expect(
686+
governor.interceptActions(toolDone(), inferAction, capabilities),
687+
).toBeNull();
688+
689+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
690+
expect(
691+
governor.interceptActions(toolDone(), inferAction, capabilities),
692+
).toBeNull();
693+
694+
const actions = governor.interceptOverflow(overflowError(), capabilities);
695+
expect(actions).not.toBeNull();
696+
expect(actions?.some((a) => a.type === "compact")).toBe(true);
697+
});
698+
641699
test("spacer-echo terminal does not arm idle compact", () => {
642700
let continuations = 0;
643701
const governor = createCompactionGovernor(() => continuations++);

‎src/agent/compaction.ts‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,12 @@ export function createCompactionGovernor(
169169
let usingEstimate = false;
170170
let lastModel: string | undefined;
171171
let turnCount = 0;
172-
// Latch after a compact that remained over the high watermark: snapshot the
173-
// post-compact infer's usage, then do not re-arm on growth alone — only a
174-
// wide resume gap past the snapshot (hasWideResumeGap) or usage back at or
175-
// under the threshold clears it. Every compact sets this latch (threshold,
176-
// operator, and overflow alike); only fold evidence moves past it.
172+
// Growth latch: snapshot the first inference.done after a compact, then do
173+
// not re-arm on growth alone — only a wide resume gap past the snapshot
174+
// (hasWideResumeGap) re-arms the proactive path. Every compact sets this
175+
// latch (threshold, operator, and overflow alike). Under-threshold folds
176+
// keep the snapshot; they restore consecutive/overflow/non-converged rails
177+
// but do not drop the latch.
177178
let tokensAtLastCompact: number | undefined;
178179
let awaitingPostCompactMeasurement = false;
179180
// Set when a fold's post-compact measurement is still at or above the
@@ -279,12 +280,13 @@ export function createCompactionGovernor(
279280
consecutiveThresholdCompacts = MAX_CONSECUTIVE_THRESHOLD_COMPACTS;
280281
}
281282
}
282-
// Fold evidence: usage back at or under the threshold clears the latch
283-
// and restores both rails (consecutive threshold compacts, overflow
284-
// recoveries). Nothing else resets them — neither tool-call occupancy nor
285-
// a still-over measurement — or compact→infer→compact would loop forever.
283+
// Fold evidence: usage back at or under the threshold restores both rails
284+
// (consecutive threshold compacts, overflow recoveries) and clears
285+
// foldNonConverged. The growth latch stays — only a wide resume gap
286+
// re-arms the proactive path. Nothing else resets the rails — neither
287+
// tool-call occupancy nor a still-over measurement — or
288+
// compact→infer→compact would loop forever.
286289
if (isAtOrUnderCompactThreshold(contextTokens, lastModel)) {
287-
tokensAtLastCompact = undefined;
288290
consecutiveThresholdCompacts = 0;
289291
overflowRecoveries = 0;
290292
foldNonConverged = false;

‎src/provider/context-window.ts‎

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -161,13 +161,14 @@ export const COMPACTION_WINDOW_FRACTION = 0.6;
161161
// keep 80 in warning and start danger at 81.
162162
export const CONTEXT_METER_DANGER_FRACTION = 0.8;
163163

164-
// Wide resume gap after a compact that stayed over the high watermark: the
165-
// governor does not re-arm on growth alone until usage climbs this fraction
166-
// of the window past the post-compact measurement. Anchored to the meter
167-
// bands — danger (0.8) minus threshold (0.6) — so a still-over session must
168-
// climb a full warning band before another fold instead of looping a compact
169-
// on every small growth step. Re-arming sooner requires fold evidence: usage
170-
// back at or under the threshold (isAtOrUnderCompactThreshold).
164+
// Wide resume gap after a compact: the governor does not re-arm on growth
165+
// alone until usage climbs this fraction of the window past the post-compact
166+
// measurement. Anchored to the meter bands — danger (0.8) minus threshold
167+
// (0.6) — so a session that folded only marginally under the high watermark
168+
// (or stayed over it) must climb a full warning band before another fold
169+
// instead of looping a compact on every small growth step. Under-threshold
170+
// usage restores consecutive/overflow/non-converged rails; it does not skip
171+
// this gap.
171172
export const COMPACTION_WIDE_RESUME_FRACTION = 0.2;
172173

173174
export type ContextMeterBand = "quiet" | "warning" | "danger";
@@ -191,8 +192,8 @@ export function compactionThresholdFor(model: string | undefined): number {
191192
return Math.floor(window * COMPACTION_WINDOW_FRACTION);
192193
}
193194

194-
/** Tokens of growth past the last post-compact measurement before a
195-
* still-over session may re-arm. */
195+
/** Tokens of growth past the last post-compact measurement before the
196+
* latched proactive path may re-arm. */
196197
export function compactionWideResumeDeltaFor(
197198
model: string | undefined,
198199
): number {
@@ -201,22 +202,24 @@ export function compactionWideResumeDeltaFor(
201202
return Math.floor(window * COMPACTION_WIDE_RESUME_FRACTION);
202203
}
203204

204-
/** Low-watermark clear for the post-compact latch: usage back at or under the
205-
* compaction threshold is fold evidence — the summarizing fold got under — so
206-
* the governor re-arms on the next crossing with no gap required. */
205+
/** Fold evidence for restoring consecutive/overflow/non-converged rails:
206+
* usage back at or under the compaction threshold means the summarizing fold
207+
* got under. It does not drop the growth latch — re-arming still requires
208+
* hasWideResumeGap past the post-compact snapshot. */
207209
export function isAtOrUnderCompactThreshold(
208210
contextTokens: number,
209211
model: string | undefined,
210212
): boolean {
211213
return contextTokens <= compactionThresholdFor(model);
212214
}
213215

214-
/** Shared re-arm rule for a session latched above the threshold after a
215-
* compact (automatic, operator, or overflow recovery): growth alone never
216-
* re-arms — only a wide resume gap past the post-compact measurement does.
217-
* The proactive threshold path routes through this single predicate; the
218-
* overflow path shares the reset rule (under-threshold folds restore the
219-
* recovery budget) but fires on overflow errors regardless of this latch. */
216+
/** Shared re-arm rule for a session latched after a compact (automatic,
217+
* operator, or overflow recovery), whether the fold landed under or over
218+
* the threshold: growth alone never re-arms — only a wide resume gap past
219+
* the post-compact measurement does. The proactive threshold path routes
220+
* through this single predicate; the overflow path shares the reset rule
221+
* (under-threshold folds restore the recovery budget) but fires on overflow
222+
* errors regardless of this latch. */
220223
export function hasWideResumeGap(
221224
postCompactTokens: number,
222225
contextTokens: number,

0 commit comments

Comments
 (0)