diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 16:36:10 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 16:36:10 +0900 |
| commit | 6689eb51b467d8e370f31495840d88661f978168 (patch) | |
| tree | d4234bfde98754dec48d2eff8594461780e609d4 /packages/cache-warming/src | |
| parent | b3d270803f95db2467e20bb742aa42faf6867f91 (diff) | |
| download | dispatch-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.ts | 8 | ||||
| -rw-r--r-- | packages/cache-warming/src/pure.test.ts | 8 | ||||
| -rw-r--r-- | packages/cache-warming/src/pure.ts | 11 | ||||
| -rw-r--r-- | packages/cache-warming/src/warmer.test.ts | 186 | ||||
| -rw-r--r-- | packages/cache-warming/src/warmer.ts | 56 |
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); |
