summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--frontend-cache-warming-lifecycle-handoff.md94
-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
-rw-r--r--packages/session-orchestrator/src/extension.ts1
-rw-r--r--packages/session-orchestrator/src/index.ts2
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts94
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts38
-rw-r--r--packages/surface-loaded-extensions/src/extension.ts1
-rw-r--r--packages/surface-loaded-extensions/src/index.ts2
-rw-r--r--packages/surface-loaded-extensions/src/spec.test.ts84
-rw-r--r--packages/surface-loaded-extensions/src/spec.ts46
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/transport-contract/src/index.ts22
-rw-r--r--packages/transport-http/src/app.test.ts46
-rw-r--r--packages/transport-http/src/app.ts9
-rw-r--r--packages/transport-http/src/extension.ts1
-rw-r--r--packages/transport-http/src/server.bun.test.ts3
-rw-r--r--packages/transport-ws/src/server.bun.test.ts6
-rw-r--r--packages/ui-contract/package.json2
-rw-r--r--packages/ui-contract/src/index.ts8
-rw-r--r--tasks.md27
24 files changed, 676 insertions, 81 deletions
diff --git a/frontend-cache-warming-lifecycle-handoff.md b/frontend-cache-warming-lifecycle-handoff.md
new file mode 100644
index 0000000..0722e25
--- /dev/null
+++ b/frontend-cache-warming-lifecycle-handoff.md
@@ -0,0 +1,94 @@
+# FE handoff — CR-4 cache-warming lifecycle SHIPPED (+ CR-1 table, CR-2 scope)
+
+> **Courier doc** (backend → `../dispatch-web`, via the user). Response to your
+> `backend-handoff-cache-warming.md` (CR-4) and the open asks CR-1 / CR-2 in
+> `backend-handoff.md`. Everything below is live on `bin/up` and verified with a
+> headless probe (same flow as your `scripts/probe-cache-warming.ts` — re-run it to
+> confirm; default-off means Phase C's toggle-enable branch now executes).
+>
+> **Contract bumps to re-pin:** `@dispatch/ui-contract` **0.1.0 → 0.2.0**,
+> `@dispatch/transport-contract` **0.8.0 → 0.9.0**. `wire` unchanged (0.6.0).
+
+## CR-4a — warming now defaults OFF ✅
+A new conversation starts `enabled: false`, `nextWarmAt: null` — no warm is scheduled
+until the user opts in via the toggle. Interval default is still 240s. Bonus fix:
+re-enabling restores the conversation's PERSISTED interval (not the 240s default).
+One caveat (pre-existing behavior, now fail-safe): opt-in is not yet re-hydrated
+across a backend RESTART — after a restart a conversation reads disabled until
+toggled again. Flag it if that matters to you and we'll add boot hydration.
+
+## CR-4b — post-warm updates now carry the FUTURE `nextWarmAt` ✅
+Root cause was notify-before-reschedule in the warmer. Fixed; additionally:
+- after every automatic warm, the pushed `cache-warming-timer` payload is
+ `{ nextWarmAt: <future>, lastWarmAt: <just now> }` (probe: 2 warms @5s, both FUTURE);
+- after `turn-sealed` the surface now pushes the fresh post-turn schedule (this was
+ the "still past after a real chat turn" case in your probe);
+- on `turn-start` the surface pushes `nextWarmAt: null` (nothing scheduled while
+ generating — render as your "waiting…" state);
+- if a warm completes with warming since-disabled, the update carries
+ `nextWarmAt: null`, never a stale past timestamp.
+Your countdown can stay authoritative off `nextWarmAt`; the cosmetic past-value guard
+should now be dead code.
+
+## CR-4c — `POST /conversations/:id/close` ✅ (the tab-close affordance)
+New endpoint (no request body), `[email protected]`:
+
+```ts
+interface CloseConversationResponse {
+ conversationId: string;
+ abortedTurn: boolean; // true iff an in-flight turn existed and was aborted
+}
+```
+
+Semantics — exactly the asymmetry the user wanted:
+- **Aborts any in-flight turn.** The kernel stops at the next event boundary; the
+ partial turn is PERSISTED and the turn SEALS normally — watchers receive
+ `done` (with `reason: "aborted"`) then `turn-sealed`, so your stream-derived
+ `generating` flag clears with no special-casing. Live-verified.
+- **Stops + disables cache-warming** for the conversation (persisted OFF — reopening
+ the conversation later does not resume warming), and pushes a surface update
+ (`enabled: false`, `nextWarmAt: null`) to subscribers.
+- **Idempotent**: closing an idle/unknown conversation is a 200 with
+ `abortedTurn: false`.
+- Browser/socket disconnect and `chat.unsubscribe` are UNCHANGED — they still never
+ touch the turn or the warming schedule (your "keep running when the window closes"
+ half is regression-tested).
+Wire this into `store.closeTab()`; `fetch`/`sendBeacon` both fine (CORS already
+allows POST).
+
+## CR-4d — initial `surface` echo ✅ (no backend change was needed)
+HEAD already echoes `conversationId` on the initial `surface` reply (shipped in the
+per-conversation-scoping commit; unit-tested). We live-probed BOTH stacks today —
+:24205 and your :25205 — and the echo is present. Your probe most likely ran against
+a `bin/up2` instance booted before that commit (up2 freezes code at boot). Re-run
+`bin/up2` and your probe; if you still see a missing echo, send us the raw frame.
+
+## CR-1 — Loaded Extensions table ✅
+The surface now emits the "Loaded" count stat plus ONE custom field:
+
+```ts
+{ kind: "custom", rendererId: "table", payload: { columns, rows } }
+// columns: ["Name", "Version", "Trust", "Activation"]
+// rows: one per loaded extension (ALL trust tiers), cell-for-cell aligned
+```
+
+Typed payload is exported as `TablePayload` (+ `TABLE_RENDERER_ID`) from
+`@dispatch/surface-loaded-extensions` if you want to narrow instead of duck-typing.
+Note: `Version` cells all read `0.0.0` — manifests are genuinely unversioned today
+(the optional data-quality item from your handoff; not done).
+
+## CR-2 — catalog `scope` flag ✅ (`[email protected]`)
+`SurfaceCatalogEntry` gains `scope?: "global" | "conversation"`. Emitted today:
+`loaded-extensions` → `"global"`, `cache-warming` → `"conversation"`. Treat ABSENT as
+conversation-scoped (conservative — your current always-send-conversationId policy
+remains correct for both). You can now skip re-subscribing `scope: "global"` surfaces
+on conversation switch.
+
+## Suggested FE follow-ups (from your own queue)
+- Re-pin + re-mirror `.dispatch/{ui-contract,transport-contract}.reference.md`.
+- Wire `POST /conversations/:id/close` into the tab-close path.
+- Extend `probe-cache-warming.ts`: assert default-off, post-warm FUTURE `nextWarmAt`,
+ and (new) close → `abortedTurn` + `done.reason === "aborted"`.
+- The "waiting…" guard for a past `nextWarmAt` can stay as a belt-and-braces guard
+ but should never trigger now; `nextWarmAt: null` while generating is the real state
+ to render.
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);
diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts
index 4175b0c..781164a 100644
--- a/packages/session-orchestrator/src/extension.ts
+++ b/packages/session-orchestrator/src/extension.ts
@@ -25,6 +25,7 @@ export const manifest: Manifest = {
"session-orchestrator/turn-started",
"session-orchestrator/turn-settled",
"session-orchestrator/warm-completed",
+ "session-orchestrator/conversation-closed",
],
},
};
diff --git a/packages/session-orchestrator/src/index.ts b/packages/session-orchestrator/src/index.ts
index 8bc99d2..b99c15e 100644
--- a/packages/session-orchestrator/src/index.ts
+++ b/packages/session-orchestrator/src/index.ts
@@ -1,6 +1,8 @@
export { extension, manifest } from "./extension.js";
export {
+ type ConversationClosedPayload,
cacheWarmHandle,
+ conversationClosed,
createSessionOrchestrator,
createWarmService,
type SessionOrchestrator,
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index c3fbbc8..799fef5 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -2162,3 +2162,97 @@ describe("user-message event", () => {
expect(tm.steps[0]?.usage.outputTokens).toBe(5);
});
});
+
+describe("closeConversation (CR-4c)", () => {
+ it("aborts an in-flight turn: done.reason 'aborted', partial messages persisted, turn seals", async () => {
+ const store = createInMemoryStore();
+ let releaseStream: (() => void) | undefined;
+ const barrier = new Promise<void>((resolve) => {
+ releaseStream = resolve;
+ });
+ const provider: ProviderContract = {
+ id: "fake",
+ stream() {
+ return (async function* () {
+ yield { type: "text-delta", delta: "Hello" } as ProviderEvent;
+ await barrier;
+ yield { type: "text-delta", delta: " world" } as ProviderEvent;
+ yield { type: "finish", reason: "stop" } as ProviderEvent;
+ })();
+ },
+ };
+
+ const emittedHooks: string[] = [];
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ emit: (hook) => {
+ emittedHooks.push(hook.id);
+ },
+ });
+
+ const events: AgentEvent[] = [];
+ let resolveSealed: (() => void) | undefined;
+ const sealed = new Promise<void>((resolve) => {
+ resolveSealed = resolve;
+ });
+ let resolveFirstDelta: (() => void) | undefined;
+ const firstDelta = new Promise<void>((resolve) => {
+ resolveFirstDelta = resolve;
+ });
+ orchestrator.subscribe("conv-close", (e) => {
+ events.push(e);
+ if (e.type === "text-delta") resolveFirstDelta?.();
+ if (e.type === "turn-sealed") resolveSealed?.();
+ });
+
+ orchestrator.startTurn({ conversationId: "conv-close", text: "Hi" });
+ await firstDelta;
+
+ const result = orchestrator.closeConversation("conv-close");
+ expect(result.abortedTurn).toBe(true);
+ expect(emittedHooks).toContain("session-orchestrator/conversation-closed");
+
+ releaseStream?.();
+ await sealed;
+
+ const done = events.find((e): e is Extract<AgentEvent, { type: "done" }> => e.type === "done");
+ expect(done?.reason).toBe("aborted");
+ expect(orchestrator.isActive("conv-close")).toBe(false);
+
+ // Durability: the partial turn persisted normally (user msg + partial reply).
+ const persisted = store.data.get("conv-close") ?? [];
+ expect(persisted.length).toBeGreaterThanOrEqual(1);
+ expect(persisted[0]?.role).toBe("user");
+ });
+
+ it("is idempotent on an idle/unknown conversation: abortedTurn false, hook still emitted", () => {
+ const store = createInMemoryStore();
+ const emitted: Array<{ hook: string; payload: unknown }> = [];
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => createFakeProvider([]),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ emit: (hook, payload) => {
+ emitted.push({ hook: hook.id, payload });
+ },
+ });
+
+ const result = orchestrator.closeConversation("conv-never-seen");
+ expect(result.abortedTurn).toBe(false);
+ expect(emitted).toEqual([
+ {
+ hook: "session-orchestrator/conversation-closed",
+ payload: { conversationId: "conv-never-seen" },
+ },
+ ]);
+
+ // Closing again is still safe.
+ expect(orchestrator.closeConversation("conv-never-seen").abortedTurn).toBe(false);
+ });
+});
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index d55114b..b0a1083 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -36,6 +36,8 @@ export type TurnEventListener = (event: AgentEvent) => void;
interface ActiveTurn {
buffer: AgentEvent[];
turnId: string;
+ /** Aborts this turn's kernel runTurn (closeConversation). */
+ controller: AbortController;
}
// --- Lifecycle event hooks ---
@@ -55,6 +57,19 @@ export const turnStarted: EventHookDescriptor<TurnLifecyclePayload> =
export const turnSettled: EventHookDescriptor<TurnLifecyclePayload> =
defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-settled");
+/** Payload for the conversationClosed bus event. */
+export interface ConversationClosedPayload {
+ readonly conversationId: string;
+}
+
+/**
+ * Fired when a client EXPLICITLY closes a conversation (tab close — NOT a mere
+ * disconnect). Consumers stop per-conversation background work (e.g. cache-warming
+ * disables its schedule). Emitted by `SessionOrchestrator.closeConversation`.
+ */
+export const conversationClosed: EventHookDescriptor<ConversationClosedPayload> =
+ defineEventHook<ConversationClosedPayload>("session-orchestrator/conversation-closed");
+
/** Payload for the warmCompleted bus event. */
export interface WarmCompletedPayload {
readonly conversationId: string;
@@ -89,6 +104,15 @@ export interface SessionOrchestrator {
startTurn(input: StartTurnInput): StartTurnResult;
subscribe(conversationId: string, listener: TurnEventListener): () => void;
isActive(conversationId: string): boolean;
+ /**
+ * Explicitly close a conversation (the user closed its tab — distinct from a
+ * socket disconnect, which never touches the turn): aborts any in-flight turn
+ * (the kernel finishes with `finishReason: "aborted"`, partial messages are
+ * persisted and the turn seals normally) and emits the `conversationClosed`
+ * hook so per-conversation background work (cache-warming) stops.
+ * Idempotent — closing an idle/unknown conversation just emits the hook.
+ */
+ closeConversation(conversationId: string): { readonly abortedTurn: boolean };
handleMessage(input: {
conversationId: string;
text: string;
@@ -159,7 +183,8 @@ export function createSessionOrchestrator(
cwd: string | undefined,
): void {
const turnId = generateTurnId();
- activeTurns.set(conversationId, { buffer: [], turnId });
+ const controller = new AbortController();
+ activeTurns.set(conversationId, { buffer: [], turnId, controller });
activeConversations.add(conversationId);
emitToHub(conversationId, { type: "user-message", conversationId, turnId, text });
@@ -233,6 +258,7 @@ export function createSessionOrchestrator(
emit: emitAndAccumulate,
conversationId,
turnId,
+ signal: controller.signal,
...(modelOverride !== undefined
? { providerOpts: { model: modelOverride } satisfies ProviderStreamOptions }
: {}),
@@ -310,6 +336,16 @@ export function createSessionOrchestrator(
return activeTurns.has(conversationId);
},
+ closeConversation(conversationId) {
+ const turn = activeTurns.get(conversationId);
+ const abortedTurn = turn !== undefined;
+ if (turn !== undefined) {
+ turn.controller.abort();
+ }
+ deps.emit?.(conversationClosed, { conversationId });
+ return { abortedTurn };
+ },
+
async handleMessage({ conversationId, text, onEvent, modelName, cwd }) {
const turnInput: StartTurnInput = {
conversationId,
diff --git a/packages/surface-loaded-extensions/src/extension.ts b/packages/surface-loaded-extensions/src/extension.ts
index abef4b6..20abdec 100644
--- a/packages/surface-loaded-extensions/src/extension.ts
+++ b/packages/surface-loaded-extensions/src/extension.ts
@@ -27,6 +27,7 @@ export function createLoadedExtensionsExtension(): Extension {
id: "loaded-extensions",
region: "side",
title: "Loaded Extensions",
+ scope: "global",
},
getSpec() {
return buildLoadedExtensionsSpec(host.getExtensions());
diff --git a/packages/surface-loaded-extensions/src/index.ts b/packages/surface-loaded-extensions/src/index.ts
index bc10dc5..ae11e02 100644
--- a/packages/surface-loaded-extensions/src/index.ts
+++ b/packages/surface-loaded-extensions/src/index.ts
@@ -1,2 +1,2 @@
export { createLoadedExtensionsExtension, manifest } from "./extension.js";
-export { buildLoadedExtensionsSpec } from "./spec.js";
+export { buildLoadedExtensionsSpec, TABLE_RENDERER_ID, type TablePayload } from "./spec.js";
diff --git a/packages/surface-loaded-extensions/src/spec.test.ts b/packages/surface-loaded-extensions/src/spec.test.ts
index 9c1aa6a..bc31b9e 100644
--- a/packages/surface-loaded-extensions/src/spec.test.ts
+++ b/packages/surface-loaded-extensions/src/spec.test.ts
@@ -1,63 +1,78 @@
import type { Manifest } from "@dispatch/kernel";
-import type { StatField } from "@dispatch/ui-contract";
+import type { CustomField, StatField } from "@dispatch/ui-contract";
import { describe, expect, it } from "vitest";
-import { buildLoadedExtensionsSpec } from "./spec.js";
+import { buildLoadedExtensionsSpec, TABLE_RENDERER_ID, type TablePayload } from "./spec.js";
-function fakeManifest(id: string, name: string, version: string): Manifest {
+function fakeManifest(
+ id: string,
+ name: string,
+ version: string,
+ extra: Partial<Manifest> = {},
+): Manifest {
return {
id,
name,
version,
apiVersion: "^0.1.0",
trust: "bundled",
+ ...extra,
};
}
+function tablePayload(field: unknown): TablePayload {
+ const custom = field as CustomField;
+ expect(custom.kind).toBe("custom");
+ expect(custom.rendererId).toBe(TABLE_RENDERER_ID);
+ return custom.payload as TablePayload;
+}
+
describe("buildLoadedExtensionsSpec", () => {
- it("returns a count stat of '0' and no extension stats for empty manifests", () => {
+ it("returns a count stat of '0' and an empty table for empty manifests", () => {
const spec = buildLoadedExtensionsSpec([]);
expect(spec.id).toBe("loaded-extensions");
expect(spec.region).toBe("side");
expect(spec.title).toBe("Loaded Extensions");
- expect(spec.fields).toHaveLength(1);
+ expect(spec.fields).toHaveLength(2);
expect(spec.fields[0]).toEqual({
kind: "stat",
label: "Loaded",
value: "0",
});
+ expect(tablePayload(spec.fields[1]).rows).toEqual([]);
});
- it("returns a count stat plus one stat per manifest in order", () => {
+ it("returns a count stat plus ONE table field with a row per manifest (CR-1)", () => {
const manifests = [
fakeManifest("alpha", "Alpha", "1.0.0"),
- fakeManifest("beta", "Beta", "2.3.1"),
- fakeManifest("gamma", "Gamma", "0.5.0"),
+ fakeManifest("beta", "Beta", "2.3.1", { trust: "external", activation: "lazy" }),
+ fakeManifest("gamma", "Gamma", "0.5.0", { trust: "local" }),
];
const spec = buildLoadedExtensionsSpec(manifests);
- expect(spec.fields).toHaveLength(4);
+ expect(spec.fields).toHaveLength(2);
expect(spec.fields[0]).toEqual({
kind: "stat",
label: "Loaded",
value: "3",
});
- expect(spec.fields[1]).toEqual({
- kind: "stat",
- label: "Alpha",
- value: "1.0.0",
- });
- expect(spec.fields[2]).toEqual({
- kind: "stat",
- label: "Beta",
- value: "2.3.1",
- });
- expect(spec.fields[3]).toEqual({
- kind: "stat",
- label: "Gamma",
- value: "0.5.0",
- });
+
+ const payload = tablePayload(spec.fields[1]);
+ expect(payload.columns).toEqual(["Name", "Version", "Trust", "Activation"]);
+ expect(payload.rows).toEqual([
+ ["Alpha", "1.0.0", "bundled", "eager"],
+ ["Beta", "2.3.1", "external", "lazy"],
+ ["Gamma", "0.5.0", "local", "eager"],
+ ]);
+ });
+
+ it("every row aligns cell-for-cell to the columns", () => {
+ const spec = buildLoadedExtensionsSpec([fakeManifest("a", "A", "1.0.0")]);
+ const payload = tablePayload(spec.fields[1]);
+ for (const row of payload.rows) {
+ expect(row).toHaveLength(payload.columns.length);
+ }
});
it("preserves input order of manifests", () => {
@@ -68,8 +83,8 @@ describe("buildLoadedExtensionsSpec", () => {
const spec = buildLoadedExtensionsSpec(manifests);
- expect((spec.fields[1] as StatField).label).toBe("Z Last");
- expect((spec.fields[2] as StatField).label).toBe("A First");
+ const payload = tablePayload(spec.fields[1]);
+ expect(payload.rows.map((r) => r[0])).toEqual(["Z Last", "A First"]);
});
it("sets the surface id, region, and title correctly", () => {
@@ -80,15 +95,14 @@ describe("buildLoadedExtensionsSpec", () => {
expect(spec.title).toBe("Loaded Extensions");
});
- it("uses manifest.name as label and manifest.version as value", () => {
- const manifests = [fakeManifest("my-ext", "My Extension", "3.2.1")];
-
- const spec = buildLoadedExtensionsSpec(manifests);
+ it("defaults a missing activation to 'eager' (the declared manifest default)", () => {
+ const spec = buildLoadedExtensionsSpec([fakeManifest("my-ext", "My Extension", "3.2.1")]);
+ const payload = tablePayload(spec.fields[1]);
+ expect(payload.rows[0]).toEqual(["My Extension", "3.2.1", "bundled", "eager"]);
+ });
- expect(spec.fields[1]).toEqual({
- kind: "stat",
- label: "My Extension",
- value: "3.2.1",
- });
+ it("the count stat remains a plain stat (graceful-skip clients still see it)", () => {
+ const spec = buildLoadedExtensionsSpec([fakeManifest("a", "A", "1.0.0")]);
+ expect((spec.fields[0] as StatField).kind).toBe("stat");
});
});
diff --git a/packages/surface-loaded-extensions/src/spec.ts b/packages/surface-loaded-extensions/src/spec.ts
index bd3dd56..72e8d41 100644
--- a/packages/surface-loaded-extensions/src/spec.ts
+++ b/packages/surface-loaded-extensions/src/spec.ts
@@ -1,25 +1,51 @@
import type { Manifest } from "@dispatch/kernel";
-import type { StatField, SurfaceSpec } from "@dispatch/ui-contract";
+import type { CustomField, StatField, SurfaceSpec } from "@dispatch/ui-contract";
+
+/**
+ * The typed payload of the `rendererId: "table"` custom field (CR-1). Exported
+ * so a client renderer narrows `CustomField.payload` via this symbol instead of
+ * a blind `unknown`. Each row aligns cell-for-cell to `columns`.
+ */
+export interface TablePayload {
+ readonly columns: readonly string[];
+ readonly rows: ReadonlyArray<ReadonlyArray<string | number | boolean>>;
+}
+
+/** The renderer id clients dispatch on for the extensions table. */
+export const TABLE_RENDERER_ID = "table";
/**
* Pure core — builds the SurfaceSpec for the loaded-extensions surface.
* Zero I/O, zero ambient state. Decision logic only: input → output.
+ *
+ * Emits a "Loaded" count stat plus ONE `custom`/"table" field enumerating EVERY
+ * loaded extension (all trust tiers) as real columns (CR-1). A client without a
+ * "table" renderer gracefully skips the field and still sees the count.
*/
export function buildLoadedExtensionsSpec(manifests: readonly Manifest[]): SurfaceSpec {
- const fields: StatField[] = [{ kind: "stat", label: "Loaded", value: String(manifests.length) }];
+ const count: StatField = { kind: "stat", label: "Loaded", value: String(manifests.length) };
+
+ const payload: TablePayload = {
+ columns: ["Name", "Version", "Trust", "Activation"],
+ rows: manifests.map((manifest) => [
+ manifest.name,
+ manifest.version,
+ manifest.trust,
+ // Activation is optional in the manifest; "eager" is the declared default.
+ manifest.activation ?? "eager",
+ ]),
+ };
- for (const manifest of manifests) {
- fields.push({
- kind: "stat",
- label: manifest.name,
- value: manifest.version,
- });
- }
+ const table: CustomField = {
+ kind: "custom",
+ rendererId: TABLE_RENDERER_ID,
+ payload,
+ };
return {
id: "loaded-extensions",
region: "side",
title: "Loaded Extensions",
- fields,
+ fields: [count, table],
};
}
diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json
index 6a7a946..2e9d586 100644
--- a/packages/transport-contract/package.json
+++ b/packages/transport-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@dispatch/transport-contract",
- "version": "0.8.0",
+ "version": "0.9.0",
"type": "module",
"private": true,
"main": "dist/index.js",
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index 8283cea..b000147 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -167,6 +167,28 @@ export interface SetCwdRequest {
readonly cwd: string;
}
+// ─── Conversation close (explicit tab close) ──────────────────────────────────
+
+/**
+ * Response of `POST /conversations/:id/close` (no request body).
+ *
+ * The EXPLICIT "the user closed this conversation's tab" affordance — distinct
+ * from a socket disconnect or `chat.unsubscribe`, which deliberately never touch
+ * the turn or the warming schedule. Closing:
+ * 1. aborts any in-flight turn (the kernel stops at the next event boundary,
+ * partial messages are persisted, and the turn SEALS normally with
+ * `finishReason: "aborted"` — watchers see `done` + `turn-sealed`), and
+ * 2. stops + disables cache-warming for the conversation (persisted OFF, so a
+ * reopened conversation stays opt-in).
+ * Idempotent: closing an idle or unknown conversation succeeds with
+ * `abortedTurn: false`.
+ */
+export interface CloseConversationResponse {
+ readonly conversationId: string;
+ /** True when an in-flight turn existed and was aborted by this close. */
+ readonly abortedTurn: boolean;
+}
+
// ─── Per-conversation LSP status ──────────────────────────────────────────────
/** The connection state of a single language server for a workspace. */
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 32e9689..c26a868 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -115,6 +115,9 @@ function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator {
isActive() {
return false;
},
+ closeConversation() {
+ return { abortedTurn: false };
+ },
async handleMessage(input) {
for (const event of events) {
input.onEvent(event);
@@ -142,6 +145,9 @@ function createCapturingOrchestrator(): SessionOrchestrator & {
isActive() {
return false;
},
+ closeConversation() {
+ return { abortedTurn: false };
+ },
async handleMessage(input) {
state.received = input;
},
@@ -159,6 +165,9 @@ function createThrowingOrchestrator(error: Error): SessionOrchestrator {
isActive() {
return false;
},
+ closeConversation() {
+ return { abortedTurn: false };
+ },
async handleMessage() {
throw error;
},
@@ -1088,6 +1097,43 @@ describe("throughput recording + GET /metrics/throughput", () => {
});
});
+describe("POST /conversations/:id/close", () => {
+ it("closes via the orchestrator and returns CloseConversationResponse", async () => {
+ const closeCalls: string[] = [];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ closeConversation(conversationId) {
+ closeCalls.push(conversationId);
+ return { abortedTurn: true };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-9/close", { method: "POST" });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ conversationId: "conv-9", abortedTurn: true });
+ expect(closeCalls).toEqual(["conv-9"]);
+ });
+
+ it("reports abortedTurn false for an idle conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-idle/close", { method: "POST" });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ conversationId: "conv-idle", abortedTurn: false });
+ });
+});
+
describe("GET /conversations/:id/cwd", () => {
it("returns null when unset", async () => {
const app = createApp({
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 7778bad..11d2850 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -1,5 +1,6 @@
import type { AgentEvent, Logger } from "@dispatch/kernel";
import type {
+ CloseConversationResponse,
ConversationHistoryResponse,
ConversationMetricsResponse,
CwdResponse,
@@ -319,6 +320,14 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ app.post("/conversations/:id/close", (c) => {
+ const conversationId = c.req.param("id");
+ const { abortedTurn } = opts.orchestrator.closeConversation(conversationId);
+ log.info("conversations: closed", { conversationId, abortedTurn });
+ const body: CloseConversationResponse = { conversationId, abortedTurn };
+ return c.json(body, 200);
+ });
+
app.get("/conversations/:id/cwd", async (c) => {
const conversationId = c.req.param("id");
try {
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index 6c988a5..33b9990 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -28,6 +28,7 @@ export const manifest: Manifest = {
"/chat",
"/chat/warm",
"/conversations/:id",
+ "/conversations/:id/close",
"/conversations/:id/cwd",
"/conversations/:id/lsp",
"/health",
diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts
index 8a719c0..a465243 100644
--- a/packages/transport-http/src/server.bun.test.ts
+++ b/packages/transport-http/src/server.bun.test.ts
@@ -64,6 +64,9 @@ function fakeOrchestrator(): SessionOrchestrator {
isActive() {
return false;
},
+ closeConversation() {
+ return { abortedTurn: false };
+ },
async handleMessage() {},
};
}
diff --git a/packages/transport-ws/src/server.bun.test.ts b/packages/transport-ws/src/server.bun.test.ts
index 0f5ce72..43d008a 100644
--- a/packages/transport-ws/src/server.bun.test.ts
+++ b/packages/transport-ws/src/server.bun.test.ts
@@ -147,6 +147,9 @@ function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & {
isActive(conversationId) {
return listeners.has(conversationId);
},
+ closeConversation() {
+ return { abortedTurn: false };
+ },
async handleMessage(_input) {
// Not used by the new transport-ws, but kept for interface compat.
},
@@ -187,6 +190,9 @@ function fakeOrchestratorWithBroadcast(): SessionOrchestrator & {
isActive(conversationId) {
return listeners.has(conversationId);
},
+ closeConversation() {
+ return { abortedTurn: false };
+ },
async handleMessage(_input) {},
};
}
diff --git a/packages/ui-contract/package.json b/packages/ui-contract/package.json
index e7dd93f..3fef8a5 100644
--- a/packages/ui-contract/package.json
+++ b/packages/ui-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@dispatch/ui-contract",
- "version": "0.1.0",
+ "version": "0.2.0",
"type": "module",
"private": true,
"main": "dist/index.js",
diff --git a/packages/ui-contract/src/index.ts b/packages/ui-contract/src/index.ts
index a7943aa..16d13c8 100644
--- a/packages/ui-contract/src/index.ts
+++ b/packages/ui-contract/src/index.ts
@@ -139,6 +139,14 @@ export interface SurfaceCatalogEntry {
readonly id: string;
readonly region: Region;
readonly title: string;
+ /**
+ * Whether the surface's spec/values differ per conversation ("conversation")
+ * or are app-wide ("global"). A client may skip re-subscribing GLOBAL surfaces
+ * on a conversation switch (they ignore `conversationId`). Optional + additive:
+ * when absent, a client should assume conversation-scoped (the conservative
+ * "always send the focused conversationId" policy still works for both).
+ */
+ readonly scope?: "global" | "conversation";
}
/** The surface catalog: the list of available surfaces a client can choose to show. */
diff --git a/tasks.md b/tasks.md
index f7e9330..f77577a 100644
--- a/tasks.md
+++ b/tasks.md
@@ -297,8 +297,31 @@ outward stream/buffer. FE courier: `frontend-cr3-user-message-handoff.md`.
(user-message now precedes turn-start).
- **LIVE-VERIFIED vs flash:** a watcher that never sent receives `user-message` (correct text) as its
FIRST `chat.delta`, before `turn-sealed`, then the streaming reply. `RESULT: OK`.
-- **Process note:** implemented directly by the orchestrator (user directive: "do implementations
- yourself going forward") rather than via a summoned owner-agent.
+- **Process note:** implemented directly by the orchestrator as a one-off (user-approved at the
+ time). SUPERSEDED — the user has since confirmed the ORCHESTRATOR.md model governs: the
+ orchestrator summons owner-agents and does not write feature code itself.
+
+## Cache warming — FE CR-4 lifecycle + CR-1 extensions table + CR-2 catalog scope (DONE)
+FE courier in: `../dispatch-web/backend-handoff-cache-warming.md` (+ CR-1/CR-2 from their living
+`backend-handoff.md`). Courier out: `frontend-cache-warming-lifecycle-handoff.md`. Full report:
+`reports/cr4-cache-warming-lifecycle.md`.
+- **CR-4a:** warming defaults OFF (opt-in per conversation) — `parseSettings` + `DEFAULT_STATE`;
+ re-enabling now restores the persisted interval. Known gap (pre-existing, fail-safe): no boot
+ hydration of persisted opt-in across server restarts.
+- **CR-4b:** post-warm surface updates now carry the FUTURE `nextWarmAt` (re-arm BEFORE notify);
+ `turnSettled`/`turnStarted` also push (fresh schedule after seal / `null` while generating).
+- **CR-4c:** new `POST /conversations/:id/close` (tab close ≠ disconnect): aborts the in-flight
+ turn via a per-turn `AbortController` → kernel `runTurn` `signal` (partial persist + normal seal,
+ `done.reason:"aborted"`), and emits new typed hook `conversationClosed` → cache-warming disables
+ sync + persists OFF. Disconnect/`chat.unsubscribe` semantics unchanged.
+- **CR-4d:** no change — initial `surface` echo already at HEAD (FE probed a stale up2 boot).
+- **CR-1:** loaded-extensions emits count stat + ONE `custom`/`rendererId:"table"` field
+ (`TablePayload` exported); columns Name|Version|Trust|Activation, all trust tiers.
+- **CR-2:** `SurfaceCatalogEntry.scope?: "global"|"conversation"` (`ui-contract` `0.1.0→0.2.0`);
+ set on both surfaces. `transport-contract` `0.8.0→0.9.0` (additive `CloseConversationResponse`).
+- 907 tests pass (+13 new); typecheck + biome clean. **LIVE-VERIFIED vs `bin/up`:** default-off,
+ 2 automatic warms @5s each pushing future `nextWarmAt`, mid-turn close → `abortedTurn:true` +
+ `done.reason:"aborted"` + warming disabled, catalog scopes + table field present, echo present.
## Open items
- **Context window LIMIT (next, sibling of context size):** expose the selected model's max