Skip to content

Commit 4487f8f

Browse files
committed
test(compaction): characterize ttl fire in the hysteresis gap
The TTL arm only defers while threshold arming is pending. After a compact that stays over the high watermark, growth hysteresis clears pending, so idle past the provider TTL still folds. Comments claimed otherwise; keep the behavior and document it. cacheTtlMsFor returns undefined only for missing, empty, or local models; unknown strings get the default window.
1 parent b6d0faf commit 4487f8f

3 files changed

Lines changed: 42 additions & 10 deletions

File tree

‎src/agent/compaction.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,35 @@ describe("provider-aware idle recompress (CL-8745)", () => {
921921
).toBeNull();
922922
});
923923

924+
test("fires cache-ttl-recompress in the hysteresis gap (over-threshold, no growth)", () => {
925+
// Characterization, not a bug: after a threshold compact, a post-compact
926+
// infer at the same usage clears `pending` via growth hysteresis, so the
927+
// threshold path no longer owns the session. Idle past the provider TTL
928+
// still folds — same window- and cap-bounded path as under-threshold.
929+
let nowMs = 70_000_000;
930+
const governor = createCompactionGovernor(
931+
() => undefined,
932+
"",
933+
[],
934+
() => nowMs,
935+
);
936+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
937+
expect(
938+
governor.interceptActions(toolDone(), inferAction, capabilities),
939+
).not.toBeNull();
940+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
941+
942+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
943+
expect(
944+
governor.interceptActions(toolDone(), inferAction, capabilities),
945+
).toBeNull();
946+
947+
nowMs += 11 * MINUTE_MS;
948+
expect(
949+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
950+
).toEqual(ttlCompact);
951+
});
952+
924953
test("does not fold under an outstanding tool batch, fires once it settles", () => {
925954
let continuations = 0;
926955
let nowMs = 40_000_000;

‎src/agent/compaction.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,17 +290,19 @@ export function createCompactionGovernor(
290290
// a cache play. Provider KV caches expire on their own schedule
291291
// (provider/cache-ttl.ts); compressing after that expiry makes the next
292292
// turn a cheaper write and later reads compound on the shrunk context. This
293-
// fires under threshold — the threshold path above owns over-threshold —
294-
// on any live re-entry once `now - lastCacheWrite >= ttl`. Guards, in
293+
// fires on any live re-entry once `now - lastCacheWrite >= ttl`, including
294+
// over-threshold sessions whose threshold path has disarmed (`pending` is
295+
// false via growth hysteresis — the threshold path owns only armed
296+
// over-threshold; the fold is still window- and cap-bounded). Guards, in
295297
// order: threshold arming defers (pending), the fresh-tail floor (turns at
296298
// or under it are all kept, so a fold would shrink nothing), the
297299
// consecutive-compact cap (existing death-spiral bound, shared with the
298-
// threshold path), providers with no TTL (unknown model, local inference),
299-
// no observed cache write yet, the TTL window itself, and one fire per
300-
// window (a fresh fold rewrites the prefix; the summary call bypasses this
301-
// governor so lastCacheWriteAt cannot observe it — lastCompactAt covers
302-
// that). Never fires with a tool batch outstanding: the stall ping that
303-
// triggers this can arrive mid-work.
300+
// threshold path), providers with no TTL (undefined/empty model, local
301+
// inference), no observed cache write yet, the TTL window itself, and one
302+
// fire per window (a fresh fold rewrites the prefix; the summary call
303+
// bypasses this governor so lastCacheWriteAt cannot observe it —
304+
// lastCompactAt covers that). Never fires with a tool batch outstanding:
305+
// the stall ping that triggers this can arrive mid-work.
304306
function isTtlRecompressDue(nowMs: number): boolean {
305307
if (pending) return false;
306308
if (turnCount <= MIN_TURNS_TO_COMPACT) return false;

‎src/provider/cache-ttl.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,9 @@ function canonicalSegment(model: string): string {
8080

8181
/**
8282
* Milliseconds of provider-cache idle after which a recompress is allowed,
83-
* or `undefined` when the model is unknown or the provider has no remote
84-
* cache (local inference). Never throws; unknown strings get the default.
83+
* or `undefined` when the model is undefined/empty or the provider has no
84+
* remote cache (local inference). Never throws; unknown strings get the
85+
* default 10-minute window.
8586
*/
8687
export function cacheTtlMsFor(model: string | undefined): number | undefined {
8788
if (model === undefined) return undefined;

0 commit comments

Comments
 (0)