fix(coding-agent): skip late cache warming refreshes

This commit is contained in:
Armin Ronacher
2026-09-20 15:53:22 +02:00
parent 19451accde
commit 3390bd9363
3 changed files with 60 additions and 2 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed idle prompt-cache warming rebuilding expired caches when its timer or an extension decision is delayed.
## [0.86.1] - 2026-09-20
### New Features
+17 -1
View File
@@ -141,7 +141,10 @@ export interface CacheWarmRequest {
interface ActiveRun extends CacheWarmRequest {
/** False once the session's model or messages no longer match the request. */
isCurrent: () => boolean;
ttlMs: number;
delayMs: number;
/** Latest safe time to send this refresh, leaving half the original expiry margin. */
refreshDeadlineAt: number;
startedAt: number;
controller: AbortController;
phase: "streaming" | "idle";
@@ -227,7 +230,9 @@ export class CacheWarmer {
this.run = {
...request,
isCurrent,
ttlMs,
delayMs,
refreshDeadlineAt: 0,
startedAt: Date.now(),
controller: new AbortController(),
phase: "streaming",
@@ -279,6 +284,10 @@ export class CacheWarmer {
private schedule(run: ActiveRun): void {
run.extensionOverride = false;
run.nextWarmAt = Date.now() + run.delayMs;
// A timer can run late after sleep or event-loop blockage. Keep half of
// the planned pre-expiry margin for that delay and request dispatch; a
// late refresh is likely a full-price cache write, not a cache warm.
run.refreshDeadlineAt = run.nextWarmAt + Math.floor((run.ttlMs - run.delayMs) / 2);
const deadline = run.startedAt + (run.phase === "idle" ? MAX_IDLE_WARMING_AGE_MS : MAX_WARMING_AGE_MS);
if (run.nextWarmAt > deadline || Date.now() >= deadline) {
this.stop(run.phase === "idle" ? "30-minute idle safety limit reached" : "one-hour safety limit reached");
@@ -291,6 +300,7 @@ export class CacheWarmer {
private async refresh(run: ActiveRun): Promise<void> {
run.timer = undefined;
if (!this.validateRun(run)) return;
if (this.refreshDeadlineMissed(run)) return;
const decision = this.evaluate(run);
const { warmCost, missCost, continuationProbability } = decision;
let action = decision.action;
@@ -305,7 +315,7 @@ export class CacheWarmer {
} catch {
// Extension failures fall back to pi's own decision.
}
if (!this.validateRun(run)) return;
if (!this.validateRun(run) || this.refreshDeadlineMissed(run)) return;
const extensionOverride = action !== decision.action;
if (action === "stop") {
const reason = extensionOverride
@@ -344,6 +354,12 @@ export class CacheWarmer {
if (this.run === run) this.schedule(run);
}
private refreshDeadlineMissed(run: ActiveRun): boolean {
if (Date.now() <= run.refreshDeadlineAt) return false;
this.stop("cache refresh deadline missed");
return true;
}
private validateRun(run: ActiveRun): boolean {
if (this.run !== run) return false;
const reason = this.getModeStopReason(run) ?? (!run.isCurrent() ? "conversation context changed" : undefined);
@@ -84,7 +84,7 @@ function branchWithPrompt(promptTokens: number): SessionEntry[] {
function fakeRuntime(
options: {
result?: (model: Model<Api>) => Promise<AssistantMessage>;
decide?: (event: CacheWarmingDecisionEvent) => CacheWarmingAction;
decide?: (event: CacheWarmingDecisionEvent) => CacheWarmingAction | Promise<CacheWarmingAction>;
mode?: CacheWarmingMode;
branch?: SessionEntry[];
} = {},
@@ -179,6 +179,44 @@ describe("cache warming", () => {
warmer.cancel();
});
it("does not issue refreshes after their safe deadline", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const { warmer, calls } = fakeRuntime();
warmer.start(request(), current);
// A five-minute cache is scheduled for 4m30s and retains 15 seconds of
// the 30-second expiry margin. Simulate a timer delayed by sleep.
vi.setSystemTime(285_001);
vi.clearAllTimers();
const internal = warmer as unknown as { run: object | undefined; refresh: (run: object) => Promise<void> };
if (!internal.run) throw new Error("expected an active cache-warming run");
await internal.refresh(internal.run);
expect(calls).toHaveLength(0);
expect(warmer.status).toMatchObject({ state: "inactive", reason: "cache refresh deadline missed" });
});
it("rechecks the deadline after an extension decision", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const { warmer, calls } = fakeRuntime({
decide: async () => {
await Promise.resolve();
vi.setSystemTime(285_001);
return "warm" as const;
},
});
warmer.start(request(), current);
vi.clearAllTimers();
const internal = warmer as unknown as { run: object | undefined; refresh: (run: object) => Promise<void> };
if (!internal.run) throw new Error("expected an active cache-warming run");
await internal.refresh(internal.run);
expect(calls).toHaveLength(0);
expect(warmer.status).toMatchObject({ state: "inactive", reason: "cache refresh deadline missed" });
});
it("applies economic decisions and extension overrides", async () => {
vi.useFakeTimers();
const unprofitable = fakeRuntime({ branch: branchWithPrompt(5_000) });