summaryrefslogtreecommitdiffhomepage
path: root/packages/cache-warming
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-11 12:23:06 +0900
committerAdam Malczewski <[email protected]>2026-06-11 12:23:06 +0900
commitc2b4c05d91fa88b8d02c055a0e15c22abd8e21f3 (patch)
tree3f7c2feddbe697a79abd952bb80ed0e01dac0a7a /packages/cache-warming
parentf6b45507210e04e9884256b0132900640de4334b (diff)
downloaddispatch-c2b4c05d91fa88b8d02c055a0e15c22abd8e21f3.tar.gz
dispatch-c2b4c05d91fa88b8d02c055a0e15c22abd8e21f3.zip
feat(cache-warming): per-conversation prompt-cache warming + warm() service
Backend-driven warming targeting whatever provider a conversation uses (incl. the external Claude provider-anthropic). Core engine + on/off + last-cache-% done; interval-as-view-control pending a ui-contract NumberField (surface-system gap). Mechanism: - kernel: expose HostAPI.emit (typed bus event emit; counterpart of on) - session-orchestrator: turnStarted/turnSettled event hooks (conversationId/cwd/model); warm() service (cacheWarmHandle) reusing the real-turn assembly (byte-identical prefix, provider-agnostic), refuses mid-turn, never persists/emits, returns Usage - cache-warming (new ext): per-conversation timers (arm on settle, cancel on start, in-flight invalidation), calls warm(), pct=round(clamp(cacheRead/input,0,1)*100), persists {enabled,intervalMs} (default on/240s), registers a controls surface - host-bin: register cache-warming; transport-http: HostAPI stub +emit (fan-out) Honors old-code invariants. 760 vitest + 109 bun = 869 tests; tsc -b EXIT 0; biome clean.
Diffstat (limited to 'packages/cache-warming')
-rw-r--r--packages/cache-warming/package.json14
-rw-r--r--packages/cache-warming/src/extension.ts130
-rw-r--r--packages/cache-warming/src/index.ts19
-rw-r--r--packages/cache-warming/src/pure.test.ts109
-rw-r--r--packages/cache-warming/src/pure.ts95
-rw-r--r--packages/cache-warming/src/warmer.test.ts207
-rw-r--r--packages/cache-warming/src/warmer.ts248
-rw-r--r--packages/cache-warming/tsconfig.json11
8 files changed, 833 insertions, 0 deletions
diff --git a/packages/cache-warming/package.json b/packages/cache-warming/package.json
new file mode 100644
index 0000000..eaf3fda
--- /dev/null
+++ b/packages/cache-warming/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@dispatch/cache-warming",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/surface-registry": "workspace:*",
+ "@dispatch/ui-contract": "workspace:*"
+ }
+}
diff --git a/packages/cache-warming/src/extension.ts b/packages/cache-warming/src/extension.ts
new file mode 100644
index 0000000..16515a8
--- /dev/null
+++ b/packages/cache-warming/src/extension.ts
@@ -0,0 +1,130 @@
+import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
+import { cacheWarmHandle, turnSettled, turnStarted } from "@dispatch/session-orchestrator";
+import type { SurfaceProvider } from "@dispatch/surface-registry";
+import { surfaceRegistryHandle } from "@dispatch/surface-registry";
+import type { SurfaceSpec } from "@dispatch/ui-contract";
+import { createCacheWarmer } from "./warmer.js";
+
+export const manifest: Manifest = {
+ id: "cache-warming",
+ name: "Cache Warming",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ dependsOn: ["session-orchestrator", "surface-registry"],
+ capabilities: {},
+ contributes: {
+ services: ["cache-warming/surface"],
+ },
+};
+
+function buildSurfaceSpec(
+ _conversationId: string | undefined,
+ enabled: boolean,
+ lastPct: number | null,
+): SurfaceSpec {
+ const pctDisplay = lastPct === null ? "—" : `${lastPct}%`;
+ return {
+ id: "cache-warming",
+ region: "side",
+ title: "Cache Warming",
+ fields: [
+ {
+ kind: "toggle",
+ label: "Enabled",
+ value: enabled,
+ action: { actionId: "cache-warming/toggle" },
+ },
+ {
+ kind: "stat",
+ label: "Last Cache %",
+ value: pctDisplay,
+ },
+ ],
+ };
+}
+
+export function activate(host: HostAPI): void {
+ const warmService = host.getService(cacheWarmHandle);
+ const registry = host.getService(surfaceRegistryHandle);
+ const storage = host.storage("cache-warming");
+
+ let currentConversationId: string | undefined;
+
+ // Timer wrapper: setTimeout/clearTimeout return Timeout in Node types,
+ // but our TimerDeps uses number ids. Map between them.
+ const timeoutMap = new Map<number, ReturnType<typeof setTimeout>>();
+ let nextTimerId = 1;
+
+ const warmer = createCacheWarmer({
+ warm: warmService.warm,
+ storage,
+ logger: host.logger,
+ timers: {
+ setTimer(fn, ms) {
+ const id = nextTimerId++;
+ timeoutMap.set(id, setTimeout(fn, ms));
+ return id;
+ },
+ clearTimer(id) {
+ const timeout = timeoutMap.get(id);
+ if (timeout !== undefined) {
+ clearTimeout(timeout);
+ timeoutMap.delete(id);
+ }
+ },
+ },
+ onSurfaceChange: () => {
+ // Surface subscribers will re-fetch on next getSpec()
+ },
+ });
+
+ host.on(turnStarted, (payload) => {
+ currentConversationId = payload.conversationId;
+ warmer.onTurnStarted(payload.conversationId);
+ });
+
+ host.on(turnSettled, (payload) => {
+ currentConversationId = payload.conversationId;
+ warmer.onTurnSettled(payload.conversationId, {
+ ...(payload.cwd !== undefined ? { cwd: payload.cwd } : {}),
+ ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}),
+ });
+ });
+
+ const provider: SurfaceProvider = {
+ catalogEntry: {
+ id: "cache-warming",
+ region: "side",
+ title: "Cache Warming",
+ },
+ getSpec() {
+ const convId = currentConversationId;
+ const state =
+ convId !== undefined ? warmer.getState(convId) : { enabled: true, lastPct: null };
+ return buildSurfaceSpec(convId, state.enabled, state.lastPct);
+ },
+ async invoke(actionId, payload) {
+ const pl = payload as Record<string, unknown> | undefined;
+ const convId =
+ (typeof pl?.conversationId === "string" ? pl.conversationId : undefined) ??
+ currentConversationId;
+ if (convId === undefined) return;
+
+ if (actionId === "cache-warming/toggle") {
+ const current = warmer.getState(convId);
+ await warmer.setEnabled(convId, !current.enabled);
+ }
+ },
+ };
+
+ registry.register(provider);
+
+ host.logger.info("cache-warming: registered");
+}
+
+export const extension: Extension = {
+ manifest,
+ activate,
+};
diff --git a/packages/cache-warming/src/index.ts b/packages/cache-warming/src/index.ts
new file mode 100644
index 0000000..8670dc5
--- /dev/null
+++ b/packages/cache-warming/src/index.ts
@@ -0,0 +1,19 @@
+export { extension, manifest } from "./extension.js";
+export {
+ type ConversationSettings,
+ type ConversationState,
+ computeCachePct,
+ DEFAULT_INTERVAL_MS,
+ isTokenCurrent,
+ MIN_INTERVAL_MS,
+ parseSettings,
+ serializeSettings,
+ settingsKey,
+ shouldWarm,
+} from "./pure.js";
+export {
+ type CacheWarmer,
+ type CacheWarmerDeps,
+ createCacheWarmer,
+ type TimerDeps,
+} from "./warmer.js";
diff --git a/packages/cache-warming/src/pure.test.ts b/packages/cache-warming/src/pure.test.ts
new file mode 100644
index 0000000..820260b
--- /dev/null
+++ b/packages/cache-warming/src/pure.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from "vitest";
+import type { ConversationState } from "./pure.js";
+import {
+ computeCachePct,
+ isTokenCurrent,
+ parseSettings,
+ serializeSettings,
+ shouldWarm,
+} from "./pure.js";
+
+describe("computeCachePct", () => {
+ it("cacheRead/input rounded and clamped to 0..100", () => {
+ expect(computeCachePct(1000, 800)).toBe(80);
+ expect(computeCachePct(1000, 1200)).toBe(100);
+ expect(computeCachePct(1000, -100)).toBe(0);
+ expect(computeCachePct(1000, 0)).toBe(0);
+ expect(computeCachePct(1000, 333)).toBe(33);
+ });
+
+ it("zero input tokens → 0", () => {
+ expect(computeCachePct(0, 500)).toBe(0);
+ expect(computeCachePct(-1, 500)).toBe(0);
+ });
+});
+
+describe("shouldWarm", () => {
+ it("returns true when enabled, idle, and token matches", () => {
+ const state: ConversationState = {
+ enabled: true,
+ intervalMs: 240_000,
+ active: false,
+ lastPct: null,
+ token: 5,
+ };
+ expect(shouldWarm(state, 5)).toBe(true);
+ });
+
+ it("returns false when disabled", () => {
+ const state: ConversationState = {
+ enabled: false,
+ intervalMs: 240_000,
+ active: false,
+ lastPct: null,
+ token: 5,
+ };
+ expect(shouldWarm(state, 5)).toBe(false);
+ });
+
+ it("returns false when active", () => {
+ const state: ConversationState = {
+ enabled: true,
+ intervalMs: 240_000,
+ active: true,
+ lastPct: null,
+ token: 5,
+ };
+ expect(shouldWarm(state, 5)).toBe(false);
+ });
+
+ it("returns false when token is superseded", () => {
+ const state: ConversationState = {
+ enabled: true,
+ intervalMs: 240_000,
+ active: false,
+ lastPct: null,
+ token: 5,
+ };
+ expect(shouldWarm(state, 6)).toBe(false);
+ });
+});
+
+describe("isTokenCurrent", () => {
+ it("returns true when tokens match", () => {
+ expect(isTokenCurrent(5, 5)).toBe(true);
+ });
+
+ it("returns false when tokens differ", () => {
+ expect(isTokenCurrent(5, 6)).toBe(false);
+ });
+});
+
+describe("parseSettings/serializeSettings round-trip", () => {
+ it("round-trips enabled + intervalMs", () => {
+ const original = { enabled: false, intervalMs: 120_000 };
+ const serialized = serializeSettings(original);
+ const parsed = parseSettings(serialized);
+ expect(parsed).toEqual(original);
+ });
+
+ it("returns defaults for null input", () => {
+ const parsed = parseSettings(null);
+ expect(parsed).toEqual({ enabled: true, intervalMs: 240_000 });
+ });
+
+ it("returns defaults for malformed JSON", () => {
+ const parsed = parseSettings("not-json{{{");
+ expect(parsed).toEqual({ enabled: true, intervalMs: 240_000 });
+ });
+
+ it("clamps non-positive interval to MIN_INTERVAL_MS", () => {
+ const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: -500 }));
+ expect(parsed.intervalMs).toBe(1000);
+ });
+
+ it("uses default for NaN interval", () => {
+ const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: Number.NaN }));
+ expect(parsed.intervalMs).toBe(240_000);
+ });
+});
diff --git a/packages/cache-warming/src/pure.ts b/packages/cache-warming/src/pure.ts
new file mode 100644
index 0000000..2b00dab
--- /dev/null
+++ b/packages/cache-warming/src/pure.ts
@@ -0,0 +1,95 @@
+/**
+ * Pure core for cache-warming — zero I/O, zero ambient state.
+ * Every function is input → output; testable without mocks.
+ */
+
+// --- Types ---
+
+/** Persisted per-conversation settings (storage-facing). */
+export interface ConversationSettings {
+ readonly enabled: boolean;
+ readonly intervalMs: number;
+}
+
+/** Full per-conversation runtime state (in-memory, not persisted). */
+export interface ConversationState extends ConversationSettings {
+ readonly active: boolean;
+ readonly lastPct: number | null;
+ readonly token: number;
+}
+
+/** Context stored per-conversation from the latest lifecycle event. */
+export interface ConversationContext {
+ readonly cwd?: string;
+ readonly modelName?: string;
+}
+
+export const DEFAULT_INTERVAL_MS = 240_000;
+export const MIN_INTERVAL_MS = 1000;
+
+// --- Pure functions ---
+
+/**
+ * Compute cache-hit percentage from token counts.
+ * Returns an integer in [0, 100]. inputTokens ≤ 0 → 0.
+ */
+export function computeCachePct(inputTokens: number, cacheReadTokens: number): number {
+ if (inputTokens <= 0) return 0;
+ const ratio = cacheReadTokens / inputTokens;
+ const clamped = Math.max(0, Math.min(1, ratio));
+ return Math.round(clamped * 100);
+}
+
+/**
+ * Decide whether a conversation should be warmed right now.
+ * Requires: enabled, idle (not active), and the token is current (not superseded).
+ */
+export function shouldWarm(state: ConversationState, currentToken: number): boolean {
+ return state.enabled && !state.active && state.token === currentToken;
+}
+
+/**
+ * Check whether a token is still current (not superseded by a newer cancel/fire).
+ */
+export function isTokenCurrent(current: number, expected: number): boolean {
+ return current === expected;
+}
+
+const SETTINGS_KEY = "settings";
+
+/**
+ * Parse settings from a raw storage string.
+ * Returns defaults if null or malformed.
+ */
+export function parseSettings(raw: string | null): ConversationSettings {
+ if (raw === null) return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS };
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (typeof parsed !== "object" || parsed === null) {
+ return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS };
+ }
+ const obj = parsed as Record<string, unknown>;
+ const enabled = typeof obj.enabled === "boolean" ? obj.enabled : true;
+ const rawInterval = obj.intervalMs;
+ let intervalMs = DEFAULT_INTERVAL_MS;
+ if (typeof rawInterval === "number" && Number.isFinite(rawInterval)) {
+ intervalMs =
+ rawInterval <= 0 ? MIN_INTERVAL_MS : Math.max(MIN_INTERVAL_MS, Math.round(rawInterval));
+ }
+ return { enabled, intervalMs };
+ } catch {
+ return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS };
+ }
+}
+
+/**
+ * Serialize settings for storage.
+ */
+export function serializeSettings(settings: ConversationSettings): string {
+ return JSON.stringify(settings);
+}
+
+/** The storage key for a conversation's settings. */
+export function settingsKey(conversationId: string): string {
+ return `${SETTINGS_KEY}:${conversationId}`;
+}
diff --git a/packages/cache-warming/src/warmer.test.ts b/packages/cache-warming/src/warmer.test.ts
new file mode 100644
index 0000000..9b9ba93
--- /dev/null
+++ b/packages/cache-warming/src/warmer.test.ts
@@ -0,0 +1,207 @@
+import type { Logger, Span } from "@dispatch/kernel";
+import type { WarmResult } from "@dispatch/session-orchestrator";
+import { describe, expect, it } from "vitest";
+import { createCacheWarmer, type TimerDeps } from "./warmer.js";
+
+function memStorage(): StorageNamespace {
+ const map = new Map<string, string>();
+ return {
+ get: async (k) => map.get(k) ?? null,
+ set: async (k, v) => {
+ map.set(k, v);
+ },
+ delete: async (k) => {
+ map.delete(k);
+ },
+ has: async (k) => map.has(k),
+ keys: async (prefix) =>
+ [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))),
+ };
+}
+
+function makeSpan(): Span {
+ const span: Span = {
+ id: "span",
+ log: makeLogger(),
+ setAttributes: () => {},
+ addLink: () => {},
+ child: () => makeSpan(),
+ end: () => {},
+ };
+ return span;
+}
+
+function makeLogger(): Logger {
+ return {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => makeLogger(),
+ span: () => makeSpan(),
+ };
+}
+
+function fakeTimers(): TimerDeps & { flush: () => void } {
+ let nextId = 1;
+ const pending = new Map<number, () => void>();
+ return {
+ setTimer(fn, _ms) {
+ const id = nextId++;
+ pending.set(id, fn);
+ return id;
+ },
+ clearTimer(id) {
+ pending.delete(id);
+ },
+ flush() {
+ const fns = [...pending.values()];
+ pending.clear();
+ for (const fn of fns) fn();
+ },
+ };
+}
+
+const WARM_RESULT: WarmResult = {
+ inputTokens: 1000,
+ outputTokens: 10,
+ cacheReadTokens: 800,
+ cacheWriteTokens: 0,
+};
+
+import type { StorageNamespace } from "@dispatch/kernel";
+
+describe("CacheWarmer", () => {
+ it("arms a timer on turnSettled and warms when it fires (enabled)", async () => {
+ const timers = fakeTimers();
+ const warmCalls: string[] = [];
+ const warmer = createCacheWarmer({
+ warm: async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ },
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ warmer.onTurnSettled("conv-1", {});
+ timers.flush();
+
+ await new Promise((r) => setTimeout(r, 10));
+ expect(warmCalls).toContain("conv-1");
+ });
+
+ it("cancels the timer on turnStarted (no warm while generating)", () => {
+ const timers = fakeTimers();
+ const warmCalls: string[] = [];
+ const warmer = createCacheWarmer({
+ warm: async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ },
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ warmer.onTurnSettled("conv-1", {});
+ warmer.onTurnStarted("conv-1");
+ timers.flush();
+
+ expect(warmCalls).toHaveLength(0);
+ });
+
+ it("in-flight warm result is dropped when superseded (token mismatch)", async () => {
+ const timers = fakeTimers();
+ let resolveWarm: (v: WarmResult) => void = () => {};
+ const warmPromise = new Promise<WarmResult>((r) => {
+ resolveWarm = r;
+ });
+ const warmer = createCacheWarmer({
+ warm: () => warmPromise,
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ warmer.onTurnSettled("conv-1", {});
+ timers.flush();
+
+ warmer.onTurnStarted("conv-1");
+ warmer.onTurnSettled("conv-1", {});
+
+ resolveWarm?.(WARM_RESULT);
+ await new Promise((r) => setTimeout(r, 10));
+
+ const state = warmer.getState("conv-1");
+ expect(state.lastPct).toBeNull();
+ });
+
+ it("disabled conversation does not warm", async () => {
+ const timers = fakeTimers();
+ const warmCalls: string[] = [];
+ const warmer = createCacheWarmer({
+ warm: async (convId) => {
+ warmCalls.push(convId);
+ return WARM_RESULT;
+ },
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ await warmer.setEnabled("conv-1", false);
+ warmer.onTurnSettled("conv-1", {});
+ timers.flush();
+
+ await new Promise((r) => setTimeout(r, 10));
+ expect(warmCalls).toHaveLength(0);
+ });
+
+ it("stores lastPct from the warm result", async () => {
+ const timers = fakeTimers();
+ const warmer = createCacheWarmer({
+ warm: async () => WARM_RESULT,
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ warmer.onTurnSettled("conv-1", {});
+ timers.flush();
+
+ await new Promise((r) => setTimeout(r, 10));
+ const state = warmer.getState("conv-1");
+ expect(state.lastPct).toBe(80);
+ });
+
+ it("re-arms timer after warm completes", async () => {
+ const timers = fakeTimers();
+ let warmCount = 0;
+ const warmer = createCacheWarmer({
+ warm: async () => {
+ warmCount++;
+ return WARM_RESULT;
+ },
+ storage: memStorage(),
+ logger: makeLogger(),
+ timers,
+ onSurfaceChange: () => {},
+ });
+
+ warmer.onTurnSettled("conv-1", {});
+ timers.flush();
+ await new Promise((r) => setTimeout(r, 10));
+
+ timers.flush();
+ await new Promise((r) => setTimeout(r, 10));
+
+ expect(warmCount).toBe(2);
+ });
+});
diff --git a/packages/cache-warming/src/warmer.ts b/packages/cache-warming/src/warmer.ts
new file mode 100644
index 0000000..31dd41e
--- /dev/null
+++ b/packages/cache-warming/src/warmer.ts
@@ -0,0 +1,248 @@
+import type { Logger, StorageNamespace } from "@dispatch/kernel";
+import type { WarmService } from "@dispatch/session-orchestrator";
+import {
+ type ConversationContext,
+ type ConversationSettings,
+ type ConversationState,
+ computeCachePct,
+ DEFAULT_INTERVAL_MS,
+ isTokenCurrent,
+ MIN_INTERVAL_MS,
+ parseSettings,
+ serializeSettings,
+ settingsKey,
+ shouldWarm,
+} from "./pure.js";
+
+// --- Timer abstraction (injectable for tests) ---
+
+export interface TimerDeps {
+ readonly setTimer: (fn: () => void, ms: number) => number;
+ readonly clearTimer: (id: number) => void;
+}
+
+// --- Warmer interface ---
+
+export interface CacheWarmer {
+ /** Handle a turnStarted event — mark conversation active, cancel pending warm. */
+ readonly onTurnStarted: (conversationId: string) => void;
+
+ /** Handle a turnSettled event — mark idle, store context, arm timer if enabled. */
+ readonly onTurnSettled: (conversationId: string, ctx: ConversationContext) => void;
+
+ /** Get the current state for a conversation (for surface rendering). */
+ readonly getState: (conversationId: string) => ConversationState;
+
+ /** Get the stored context for a conversation. */
+ readonly getContext: (conversationId: string) => ConversationContext;
+
+ /** Toggle enabled for a conversation. Returns updated settings. */
+ readonly setEnabled: (conversationId: string, enabled: boolean) => Promise<ConversationSettings>;
+
+ /** Set the refresh interval for a conversation. Returns updated settings. */
+ readonly setIntervalMs: (
+ conversationId: string,
+ intervalMs: number,
+ ) => Promise<ConversationSettings>;
+
+ /** Dispose all timers (for cleanup). */
+ readonly dispose: () => void;
+}
+
+export interface CacheWarmerDeps {
+ readonly warm: WarmService["warm"];
+ readonly storage: StorageNamespace;
+ readonly logger: Logger;
+ readonly timers: TimerDeps;
+ /** Called when surface subscribers should re-fetch the spec. */
+ readonly onSurfaceChange: () => void;
+}
+
+const DEFAULT_STATE: ConversationState = {
+ enabled: true,
+ intervalMs: DEFAULT_INTERVAL_MS,
+ active: false,
+ lastPct: null,
+ token: 0,
+};
+
+export function createCacheWarmer(deps: CacheWarmerDeps): CacheWarmer {
+ // Per-conversation runtime state (not persisted — reconstructed from storage + events)
+ const states = new Map<string, ConversationState>();
+ // Per-conversation context from latest lifecycle event
+ const contexts = new Map<string, ConversationContext>();
+ // Per-conversation pending timer id
+ const timers = new Map<string, number>();
+ // Monotonic token per conversation for in-flight invalidation
+ let nextToken = 1;
+
+ function getState(conversationId: string): ConversationState {
+ return states.get(conversationId) ?? DEFAULT_STATE;
+ }
+
+ function getContext(conversationId: string): ConversationContext {
+ return contexts.get(conversationId) ?? {};
+ }
+
+ function setState(conversationId: string, state: ConversationState): void {
+ states.set(conversationId, state);
+ }
+
+ function cancelTimer(conversationId: string): void {
+ const existing = timers.get(conversationId);
+ if (existing !== undefined) {
+ deps.timers.clearTimer(existing);
+ timers.delete(conversationId);
+ }
+ }
+
+ function armTimer(conversationId: string): void {
+ cancelTimer(conversationId);
+ const state = getState(conversationId);
+ if (!state.enabled || state.active) return;
+
+ const token = nextToken++;
+ setState(conversationId, { ...state, token });
+
+ const timerId = deps.timers.setTimer(() => {
+ timers.delete(conversationId);
+ void fireWarm(conversationId, token);
+ }, state.intervalMs);
+
+ timers.set(conversationId, timerId);
+ }
+
+ async function fireWarm(conversationId: string, token: number): Promise<void> {
+ const state = getState(conversationId);
+ if (!shouldWarm(state, token)) {
+ deps.logger.debug("cache-warming: skip warm (superseded or disabled)", {
+ conversationId,
+ });
+ return;
+ }
+
+ const ctx = getContext(conversationId);
+ deps.logger.debug("cache-warming: firing warm", { conversationId });
+
+ const result = await deps.warm(conversationId, {
+ ...(ctx.cwd !== undefined ? { cwd: ctx.cwd } : {}),
+ ...(ctx.modelName !== undefined ? { modelName: ctx.modelName } : {}),
+ });
+
+ // Re-check token after async warm — result may be stale
+ const currentState = getState(conversationId);
+ if (!isTokenCurrent(currentState.token, token)) {
+ deps.logger.debug("cache-warming: discarding stale warm result", {
+ conversationId,
+ });
+ return;
+ }
+
+ if ("error" in result) {
+ deps.logger.debug("cache-warming: warm returned error (normal)", {
+ conversationId,
+ error: result.error,
+ });
+ } else {
+ const pct = computeCachePct(result.inputTokens, result.cacheReadTokens);
+ setState(conversationId, { ...currentState, lastPct: pct });
+ deps.onSurfaceChange();
+ deps.logger.debug("cache-warming: warm complete", {
+ conversationId,
+ pct,
+ });
+ }
+
+ // Re-arm for next cycle
+ armTimer(conversationId);
+ }
+
+ async function loadSettings(conversationId: string): Promise<ConversationSettings> {
+ const raw = await deps.storage.get(settingsKey(conversationId));
+ return parseSettings(raw);
+ }
+
+ async function persistSettings(
+ conversationId: string,
+ settings: ConversationSettings,
+ ): Promise<void> {
+ await deps.storage.set(settingsKey(conversationId), serializeSettings(settings));
+ }
+
+ function mergeState(
+ conversationId: string,
+ partial: Partial<ConversationState>,
+ ): ConversationState {
+ const current = getState(conversationId);
+ const updated = { ...current, ...partial };
+ setState(conversationId, updated);
+ return updated;
+ }
+
+ return {
+ onTurnStarted(conversationId) {
+ deps.logger.debug("cache-warming: turn started", { conversationId });
+ mergeState(conversationId, { active: true });
+ cancelTimer(conversationId);
+ },
+
+ onTurnSettled(conversationId, ctx) {
+ deps.logger.debug("cache-warming: turn settled", { conversationId });
+ contexts.set(conversationId, ctx);
+ mergeState(conversationId, { active: false });
+
+ const state = getState(conversationId);
+ if (state.enabled) {
+ armTimer(conversationId);
+ }
+ },
+
+ getState,
+ getContext,
+
+ async setEnabled(conversationId, enabled) {
+ const settings = await loadSettings(conversationId);
+ const updated = { ...settings, enabled };
+ await persistSettings(conversationId, updated);
+ mergeState(conversationId, { enabled });
+
+ if (enabled) {
+ const state = getState(conversationId);
+ if (!state.active) {
+ armTimer(conversationId);
+ }
+ } else {
+ cancelTimer(conversationId);
+ }
+
+ deps.onSurfaceChange();
+ return updated;
+ },
+
+ async setIntervalMs(conversationId, intervalMs) {
+ const clamped =
+ !Number.isFinite(intervalMs) || intervalMs <= 0
+ ? MIN_INTERVAL_MS
+ : Math.max(MIN_INTERVAL_MS, Math.round(intervalMs));
+ const settings = await loadSettings(conversationId);
+ const updated = { ...settings, intervalMs: clamped };
+ await persistSettings(conversationId, updated);
+ mergeState(conversationId, { intervalMs: clamped });
+
+ // Re-arm with new interval if currently armed
+ const state = getState(conversationId);
+ if (state.enabled && !state.active && timers.has(conversationId)) {
+ armTimer(conversationId);
+ }
+
+ deps.onSurfaceChange();
+ return updated;
+ },
+
+ dispose() {
+ for (const [conversationId] of timers) {
+ cancelTimer(conversationId);
+ }
+ },
+ };
+}
diff --git a/packages/cache-warming/tsconfig.json b/packages/cache-warming/tsconfig.json
new file mode 100644
index 0000000..6557731
--- /dev/null
+++ b/packages/cache-warming/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../kernel" },
+ { "path": "../session-orchestrator" },
+ { "path": "../surface-registry" },
+ { "path": "../ui-contract" }
+ ]
+}