mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-02 00:35:27 +08:00
fix(coding-agent): skip late cache warming refreshes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user