summaryrefslogtreecommitdiffhomepage
path: root/packages/cache-warming/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 16:36:10 +0900
committerAdam Malczewski <[email protected]>2026-06-12 16:36:10 +0900
commit6689eb51b467d8e370f31495840d88661f978168 (patch)
treed4234bfde98754dec48d2eff8594461780e609d4 /packages/cache-warming/src
parentb3d270803f95db2467e20bb742aa42faf6867f91 (diff)
downloaddispatch-6689eb51b467d8e370f31495840d88661f978168.tar.gz
dispatch-6689eb51b467d8e370f31495840d88661f978168.zip
feat(cache-warming): lifecycle CR-4 — default-off, fresh nextWarmAt, conversation close (+CR-1 table, CR-2 scope)
CR-4a: warming defaults OFF (opt-in per conversation); re-enabling restores the persisted interval. CR-4b: re-arm BEFORE surface notify so post-warm updates carry the FUTURE nextWarmAt; turnSettled/turnStarted now also push (fresh schedule after seal, null while generating). CR-4c: POST /conversations/:id/close — per-turn AbortController wired to the kernel runTurn signal (partial persist + normal seal, done.reason "aborted"), new conversationClosed hook, cache-warming disables sync + persists OFF. Disconnect/chat.unsubscribe semantics unchanged. CR-4d: no change needed — initial surface echo already at HEAD (stale up2 boot on the FE probe). CR-1: loaded-extensions emits a single custom rendererId:"table" field (TablePayload exported; Name|Version|Trust|Activation, all trust tiers). CR-2: SurfaceCatalogEntry.scope?: "global"|"conversation" on both surfaces. Contracts: ui-contract 0.1.0→0.2.0, transport-contract 0.8.0→0.9.0 (additive). 907 tests pass (+13); live-verified against bin/up (warms @5s with future nextWarmAt; mid-turn close → abortedTurn:true + done.reason aborted). Courier: frontend-cache-warming-lifecycle-handoff.md.
Diffstat (limited to 'packages/cache-warming/src')
-rw-r--r--packages/cache-warming/src/extension.ts8
-rw-r--r--packages/cache-warming/src/pure.test.ts8
-rw-r--r--packages/cache-warming/src/pure.ts11
-rw-r--r--packages/cache-warming/src/warmer.test.ts186
-rw-r--r--packages/cache-warming/src/warmer.ts56
5 files changed, 239 insertions, 30 deletions
diff --git a/packages/cache-warming/src/extension.ts b/packages/cache-warming/src/extension.ts
index 19f5130..920177f 100644
--- a/packages/cache-warming/src/extension.ts
+++ b/packages/cache-warming/src/extension.ts
@@ -1,6 +1,7 @@
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import {
cacheWarmHandle,
+ conversationClosed,
turnSettled,
turnStarted,
warmCompleted,
@@ -81,6 +82,12 @@ export function activate(host: HostAPI): void {
warmer.onWarmCompleted(payload);
});
+ host.on(conversationClosed, (payload) => {
+ // Sync part (cancel + disable) runs before the first await inside;
+ // only the settings persist is deferred.
+ void warmer.onConversationClosed(payload.conversationId);
+ });
+
function getSpec(context?: SurfaceContext): SurfaceSpec {
const convId = context?.conversationId;
if (convId === undefined) {
@@ -124,6 +131,7 @@ export function activate(host: HostAPI): void {
id: "cache-warming",
region: "side",
title: "Cache Warming",
+ scope: "conversation",
},
getSpec,
invoke,
diff --git a/packages/cache-warming/src/pure.test.ts b/packages/cache-warming/src/pure.test.ts
index a503798..357fcb9 100644
--- a/packages/cache-warming/src/pure.test.ts
+++ b/packages/cache-warming/src/pure.test.ts
@@ -120,14 +120,14 @@ describe("parseSettings/serializeSettings round-trip", () => {
expect(parsed).toEqual(original);
});
- it("returns defaults for null input", () => {
+ it("returns defaults for null input (warming OFF by default — CR-4a)", () => {
const parsed = parseSettings(null);
- expect(parsed).toEqual({ enabled: true, intervalMs: 240_000 });
+ expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 });
});
- it("returns defaults for malformed JSON", () => {
+ it("returns defaults for malformed JSON (warming OFF by default — CR-4a)", () => {
const parsed = parseSettings("not-json{{{");
- expect(parsed).toEqual({ enabled: true, intervalMs: 240_000 });
+ expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 });
});
it("clamps non-positive interval to MIN_INTERVAL_MS", () => {
diff --git a/packages/cache-warming/src/pure.ts b/packages/cache-warming/src/pure.ts
index c4cbe8a..2a2fd1b 100644
--- a/packages/cache-warming/src/pure.ts
+++ b/packages/cache-warming/src/pure.ts
@@ -86,16 +86,19 @@ const SETTINGS_KEY = "settings";
/**
* Parse settings from a raw storage string.
* Returns defaults if null or malformed.
+ *
+ * Warming defaults to OFF (CR-4a): a new conversation never schedules warms
+ * until the user explicitly opts in via the toggle.
*/
export function parseSettings(raw: string | null): ConversationSettings {
- if (raw === null) return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS };
+ if (raw === null) return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS };
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) {
- return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS };
+ return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS };
}
const obj = parsed as Record<string, unknown>;
- const enabled = typeof obj.enabled === "boolean" ? obj.enabled : true;
+ const enabled = typeof obj.enabled === "boolean" ? obj.enabled : false;
const rawInterval = obj.intervalMs;
let intervalMs = DEFAULT_INTERVAL_MS;
if (typeof rawInterval === "number" && Number.isFinite(rawInterval)) {
@@ -104,7 +107,7 @@ export function parseSettings(raw: string | null): ConversationSettings {
}
return { enabled, intervalMs };
} catch {
- return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS };
+ return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS };
}
}
diff --git a/packages/cache-warming/src/warmer.test.ts b/packages/cache-warming/src/warmer.test.ts
index a389ccb..98fa634 100644
--- a/packages/cache-warming/src/warmer.test.ts
+++ b/packages/cache-warming/src/warmer.test.ts
@@ -102,6 +102,7 @@ describe("CacheWarmer", () => {
};
const warmer = createCacheWarmer(deps);
+ await warmer.setEnabled("conv-1", true);
warmer.onTurnSettled("conv-1", {});
deps.timers.flush();
@@ -109,6 +110,24 @@ describe("CacheWarmer", () => {
expect(warmCalls).toContain("conv-1");
});
+ it("warming is OFF by default — a new conversation never arms on turnSettled (CR-4a)", async () => {
+ const deps = makeDeps();
+ const warmCalls: string[] = [];
+ deps.warm = async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ };
+ const warmer = createCacheWarmer(deps);
+
+ expect(warmer.getState("conv-1").enabled).toBe(false);
+ warmer.onTurnSettled("conv-1", {});
+ expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
+ deps.timers.flush();
+
+ await new Promise((r) => setTimeout(r, 10));
+ expect(warmCalls).toHaveLength(0);
+ });
+
it("cancels the timer on turnStarted (no warm while generating)", () => {
const deps = makeDeps();
const warmCalls: string[] = [];
@@ -152,6 +171,7 @@ describe("CacheWarmer", () => {
const warmer = createCacheWarmer(deps);
// Enable and settle to arm the timer
+ await warmer.setEnabled("conv-1", true);
warmer.onTurnSettled("conv-1", {});
// Set interval to 30 seconds (30000ms)
@@ -198,16 +218,30 @@ describe("CacheWarmer", () => {
const deps = makeDeps();
const warmer = createCacheWarmer(deps);
- // Default is enabled
+ // Default is DISABLED (opt-in per conversation)
+ expect(warmer.getState("conv-1").enabled).toBe(false);
+
+ // Toggle on
+ await warmer.setEnabled("conv-1", true);
expect(warmer.getState("conv-1").enabled).toBe(true);
// Toggle off
await warmer.setEnabled("conv-1", false);
expect(warmer.getState("conv-1").enabled).toBe(false);
+ });
- // Toggle on
- await warmer.setEnabled("conv-1", true);
- expect(warmer.getState("conv-1").enabled).toBe(true);
+ it("re-enabling restores the PERSISTED interval into runtime state", async () => {
+ const deps = makeDeps();
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setIntervalMs("conv-1", 30_000);
+ await warmer.setEnabled("conv-1", false);
+
+ // Fresh warmer over the same storage (simulates restart)
+ const warmer2 = createCacheWarmer(deps);
+ expect(warmer2.getState("conv-1").intervalMs).toBe(240_000); // runtime default
+ await warmer2.setEnabled("conv-1", true);
+ expect(warmer2.getState("conv-1").intervalMs).toBe(30_000); // persisted restored
});
it("onSurfaceChange is called when settings change", async () => {
@@ -226,7 +260,7 @@ describe("CacheWarmer", () => {
expect(changeCount).toBe(2);
});
- it("warmCompleted updates lastPct/lastExpectedPct/lastWarmAt and re-arms (nextWarmAt set), pushes onSurfaceChange", () => {
+ it("warmCompleted updates lastPct/lastExpectedPct/lastWarmAt and re-arms (nextWarmAt set), pushes onSurfaceChange", async () => {
let changeCount = 0;
let nowMs = 5000;
const deps = makeDeps({
@@ -237,12 +271,14 @@ describe("CacheWarmer", () => {
});
const warmer = createCacheWarmer(deps);
+ await warmer.setEnabled("conv-1", true);
warmer.onTurnSettled("conv-1", {});
const stateBefore = warmer.getState("conv-1");
expect(stateBefore.lastPct).toBeNull();
expect(stateBefore.lastExpectedPct).toBeNull();
expect(stateBefore.lastWarmAt).toBeNull();
+ const countBefore = changeCount;
nowMs = 6000;
warmer.onWarmCompleted({
conversationId: "conv-1",
@@ -254,10 +290,72 @@ describe("CacheWarmer", () => {
expect(state.lastExpectedPct).toBe(70);
expect(state.lastWarmAt).toBe(6000);
expect(state.nextWarmAt).not.toBeNull();
- expect(changeCount).toBe(1);
+ expect(changeCount).toBe(countBefore + 1);
+ });
+
+ it("the post-warm surface notify observes the NEW future nextWarmAt, not the consumed one (CR-4b)", async () => {
+ let nowMs = 5000;
+ const observed: (number | null)[] = [];
+ const deps = makeDeps({ now: () => nowMs });
+ const warmer = createCacheWarmer(deps);
+ deps.onSurfaceChange = () => {
+ observed.push(warmer.getState("conv-1").nextWarmAt);
+ };
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+ observed.length = 0;
+
+ nowMs = 9000;
+ warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT });
+
+ // Exactly one notify, and AT NOTIFY TIME the state already carried the
+ // re-armed, future fire time (lastWarmAt + interval) — never the past one.
+ expect(observed).toEqual([9000 + 240_000]);
+ });
+
+ it("the post-warm surface notify carries nextWarmAt: null when warming was disabled mid-flight", async () => {
+ let nowMs = 5000;
+ const observed: (number | null)[] = [];
+ const deps = makeDeps({ now: () => nowMs });
+ const warmer = createCacheWarmer(deps);
+ deps.onSurfaceChange = () => {
+ observed.push(warmer.getState("conv-1").nextWarmAt);
+ };
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+ await warmer.setEnabled("conv-1", false);
+ observed.length = 0;
+
+ nowMs = 9000;
+ warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT });
+
+ // Not re-armed (disabled) — the notify must NOT carry a stale past value.
+ expect(observed).toEqual([null]);
});
- it("a warm that completes while the conversation is active is dropped (no update, no re-arm)", () => {
+ it("onTurnSettled pushes a surface notify carrying the fresh schedule (CR-4b post-seal path)", async () => {
+ let nowMs = 5000;
+ const observed: (number | null)[] = [];
+ const deps = makeDeps({ now: () => nowMs });
+ const warmer = createCacheWarmer(deps);
+ deps.onSurfaceChange = () => {
+ observed.push(warmer.getState("conv-1").nextWarmAt);
+ };
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnStarted("conv-1");
+ observed.length = 0;
+
+ nowMs = 12_000;
+ warmer.onTurnSettled("conv-1", {});
+
+ // At notify time the new schedule is already armed.
+ expect(observed).toEqual([12_000 + 240_000]);
+ });
+
+ it("a warm that completes while the conversation is active is dropped (no update, no re-arm)", async () => {
let changeCount = 0;
const deps = makeDeps({
onSurfaceChange: () => {
@@ -267,9 +365,11 @@ describe("CacheWarmer", () => {
});
const warmer = createCacheWarmer(deps);
+ await warmer.setEnabled("conv-1", true);
warmer.onTurnSettled("conv-1", {});
warmer.onTurnStarted("conv-1");
+ const countBefore = changeCount;
warmer.onWarmCompleted({
conversationId: "conv-1",
usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 800, cacheWriteTokens: 0 },
@@ -279,7 +379,7 @@ describe("CacheWarmer", () => {
expect(state.lastPct).toBeNull();
expect(state.lastWarmAt).toBeNull();
expect(state.nextWarmAt).toBeNull();
- expect(changeCount).toBe(0);
+ expect(changeCount).toBe(countBefore);
});
it("nextWarmAt is set when armed and null when disabled or active", async () => {
@@ -290,7 +390,8 @@ describe("CacheWarmer", () => {
// Before any event — not armed
expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
- // After turnSettled — armed with nextWarmAt
+ // After enable + turnSettled — armed with nextWarmAt
+ await warmer.setEnabled("conv-1", true);
nowMs = 2000;
warmer.onTurnSettled("conv-1", {});
const stateArmed = warmer.getState("conv-1");
@@ -301,12 +402,13 @@ describe("CacheWarmer", () => {
expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
// After disabling — null
+ await warmer.setEnabled("conv-2", true);
warmer.onTurnSettled("conv-2", {});
await warmer.setEnabled("conv-2", false);
expect(warmer.getState("conv-2").nextWarmAt).toBeNull();
});
- it("a manual warm (warmCompleted for a conversation) resets the timer + refreshes the surface", () => {
+ it("a manual warm (warmCompleted for a conversation) resets the timer + refreshes the surface", async () => {
let changeCount = 0;
let nowMs = 5000;
const deps = makeDeps({
@@ -317,12 +419,14 @@ describe("CacheWarmer", () => {
});
const warmer = createCacheWarmer(deps);
- // Settle to arm the timer
+ // Enable + settle to arm the timer
+ await warmer.setEnabled("conv-1", true);
warmer.onTurnSettled("conv-1", {});
const armed = warmer.getState("conv-1");
expect(armed.nextWarmAt).toBe(5000 + 240_000);
// Simulate a manual warm completing at t=8000
+ const countBefore = changeCount;
nowMs = 8000;
warmer.onWarmCompleted({
conversationId: "conv-1",
@@ -335,7 +439,7 @@ describe("CacheWarmer", () => {
expect(after.lastWarmAt).toBe(8000);
// Timer should be re-armed with new nextWarmAt
expect(after.nextWarmAt).toBe(8000 + 240_000);
- expect(changeCount).toBe(1);
+ expect(changeCount).toBe(countBefore + 1);
});
it("stores lastPct from the warmCompleted event", () => {
@@ -367,11 +471,12 @@ describe("CacheWarmer", () => {
expect(state.lastExpectedPct).toBe(70);
});
- it("re-arms timer after warmCompleted", () => {
+ it("re-arms timer after warmCompleted", async () => {
let nowMs = 1000;
const deps = makeDeps({ now: () => nowMs });
const warmer = createCacheWarmer(deps);
+ await warmer.setEnabled("conv-1", true);
warmer.onTurnSettled("conv-1", {});
const firstNextWarmAt = warmer.getState("conv-1").nextWarmAt;
@@ -387,6 +492,61 @@ describe("CacheWarmer", () => {
expect(after.nextWarmAt).toBe(5000 + 240_000);
});
+ it("onConversationClosed cancels the schedule, disables warming, persists OFF, and notifies (CR-4c)", async () => {
+ let changeCount = 0;
+ const deps = makeDeps({
+ onSurfaceChange: () => {
+ changeCount++;
+ },
+ now: () => 5000,
+ });
+ const warmCalls: string[] = [];
+ deps.warm = async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ };
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnSettled("conv-1", {});
+ expect(warmer.getState("conv-1").nextWarmAt).not.toBeNull();
+
+ const countBefore = changeCount;
+ await warmer.onConversationClosed("conv-1");
+
+ const state = warmer.getState("conv-1");
+ expect(state.enabled).toBe(false);
+ expect(state.nextWarmAt).toBeNull();
+ expect(changeCount).toBe(countBefore + 1);
+
+ // The pending timer is cancelled — flushing fires nothing.
+ deps.timers.flush();
+ await new Promise((r) => setTimeout(r, 10));
+ expect(warmCalls).toHaveLength(0);
+
+ // Persisted OFF: a fresh warmer over the same storage stays disabled on enable-read.
+ const raw = await deps.storage.get("settings:conv-1");
+ expect(raw).not.toBeNull();
+ expect(JSON.parse(raw as string).enabled).toBe(false);
+ });
+
+ it("a turnSettled racing a close does not re-arm (enabled flipped synchronously)", async () => {
+ const deps = makeDeps({ now: () => 5000 });
+ const warmer = createCacheWarmer(deps);
+
+ await warmer.setEnabled("conv-1", true);
+ warmer.onTurnStarted("conv-1");
+
+ // Close while "generating" — do NOT await: the sync part must suffice.
+ const closed = warmer.onConversationClosed("conv-1");
+ // The turn settles immediately after the close was issued.
+ warmer.onTurnSettled("conv-1", {});
+
+ expect(warmer.getState("conv-1").enabled).toBe(false);
+ expect(warmer.getState("conv-1").nextWarmAt).toBeNull();
+ await closed;
+ });
+
it("the per-conversation spec includes a cache-retention stat", () => {
const deps = makeDeps({ now: () => 5000 });
const warmer = createCacheWarmer(deps);
diff --git a/packages/cache-warming/src/warmer.ts b/packages/cache-warming/src/warmer.ts
index d77bfe0..6ad5c33 100644
--- a/packages/cache-warming/src/warmer.ts
+++ b/packages/cache-warming/src/warmer.ts
@@ -30,9 +30,17 @@ export interface CacheWarmer {
/** Handle a turnSettled event — mark idle, store context, arm timer if enabled. */
readonly onTurnSettled: (conversationId: string, ctx: ConversationContext) => void;
- /** Handle a warmCompleted event — process warm result, update surface, re-arm timer. */
+ /** Handle a warmCompleted event — process warm result, re-arm timer, update surface. */
readonly onWarmCompleted: (payload: WarmCompletedPayload) => void;
+ /**
+ * Handle an explicit "conversation closed" (tab close ≠ disconnect): stop the
+ * schedule and persist warming OFF for the conversation. The enabled flip is
+ * applied to in-memory state synchronously (so a turnSettled racing this close
+ * can never re-arm); only the settings persist is awaited.
+ */
+ readonly onConversationClosed: (conversationId: string) => Promise<void>;
+
/** Get the current state for a conversation (for surface rendering). */
readonly getState: (conversationId: string) => ConversationState;
@@ -63,8 +71,10 @@ export interface CacheWarmerDeps {
readonly onSurfaceChange: () => void;
}
+// Warming is OPT-IN per conversation (CR-4a): default OFF, no warm scheduled
+// until the user enables it.
const DEFAULT_STATE: ConversationState = {
- enabled: true,
+ enabled: false,
intervalMs: DEFAULT_INTERVAL_MS,
active: false,
lastPct: null,
@@ -171,6 +181,9 @@ export function createCacheWarmer(deps: CacheWarmerDeps): CacheWarmer {
deps.logger.debug("cache-warming: turn started", { conversationId });
mergeState(conversationId, { active: true });
cancelTimer(conversationId);
+ // Push the cleared schedule (nextWarmAt: null) so subscribers see
+ // "no warm scheduled" while the turn is generating.
+ deps.onSurfaceChange();
},
onTurnSettled(conversationId, ctx) {
@@ -182,6 +195,9 @@ export function createCacheWarmer(deps: CacheWarmerDeps): CacheWarmer {
if (state.enabled) {
armTimer(conversationId);
}
+ // Push the post-seal reschedule so subscribers get the NEW (future)
+ // nextWarmAt instead of a stale pre-turn one (CR-4b).
+ deps.onSurfaceChange();
},
onWarmCompleted(payload) {
@@ -204,29 +220,51 @@ export function createCacheWarmer(deps: CacheWarmerDeps): CacheWarmer {
lastPct: pct,
lastExpectedPct: expectedPct,
lastWarmAt: nowMs,
+ // The just-fired schedule is consumed; cleared here so a non-re-armed
+ // path never reports a stale (past) nextWarmAt.
+ nextWarmAt: null,
});
+
+ // Re-arm the automatic timer if enabled and not active — BEFORE the
+ // surface notify, so the pushed update carries the NEW future nextWarmAt
+ // instead of the fire time of the warm that just completed (CR-4b).
+ const updated = getState(conversationId);
+ if (updated.enabled && !updated.active) {
+ armTimer(conversationId);
+ }
+
deps.onSurfaceChange();
deps.logger.debug("cache-warming: warm complete", {
conversationId,
pct,
expectedPct,
});
-
- // Re-arm the automatic timer if enabled and not active
- const updated = getState(conversationId);
- if (updated.enabled && !updated.active) {
- armTimer(conversationId);
- }
},
getState,
getContext,
+ async onConversationClosed(conversationId) {
+ deps.logger.debug("cache-warming: conversation closed", { conversationId });
+ // Synchronous part FIRST: stop the schedule + flip enabled in memory so
+ // any racing turnSettled sees enabled=false and never re-arms.
+ cancelTimer(conversationId);
+ const updated = mergeState(conversationId, { enabled: false });
+ deps.onSurfaceChange();
+ // Persist OFF so a reopened conversation stays opt-in.
+ await persistSettings(conversationId, {
+ enabled: false,
+ intervalMs: updated.intervalMs,
+ });
+ },
+
async setEnabled(conversationId, enabled) {
const settings = await loadSettings(conversationId);
const updated = { ...settings, enabled };
await persistSettings(conversationId, updated);
- mergeState(conversationId, { enabled });
+ // Merge the FULL settings (not just `enabled`) so re-enabling restores
+ // the persisted interval into runtime state.
+ mergeState(conversationId, updated);
if (enabled) {
const state = getState(conversationId);