summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 16:36:10 +0900
committerAdam Malczewski <[email protected]>2026-06-12 16:36:10 +0900
commit6689eb51b467d8e370f31495840d88661f978168 (patch)
treed4234bfde98754dec48d2eff8594461780e609d4 /packages/session-orchestrator/src
parentb3d270803f95db2467e20bb742aa42faf6867f91 (diff)
downloaddispatch-6689eb51b467d8e370f31495840d88661f978168.tar.gz
dispatch-6689eb51b467d8e370f31495840d88661f978168.zip
feat(cache-warming): lifecycle CR-4 — default-off, fresh nextWarmAt, conversation close (+CR-1 table, CR-2 scope)
CR-4a: warming defaults OFF (opt-in per conversation); re-enabling restores the persisted interval. CR-4b: re-arm BEFORE surface notify so post-warm updates carry the FUTURE nextWarmAt; turnSettled/turnStarted now also push (fresh schedule after seal, null while generating). CR-4c: POST /conversations/:id/close — per-turn AbortController wired to the kernel runTurn signal (partial persist + normal seal, done.reason "aborted"), new conversationClosed hook, cache-warming disables sync + persists OFF. Disconnect/chat.unsubscribe semantics unchanged. CR-4d: no change needed — initial surface echo already at HEAD (stale up2 boot on the FE probe). CR-1: loaded-extensions emits a single custom rendererId:"table" field (TablePayload exported; Name|Version|Trust|Activation, all trust tiers). CR-2: SurfaceCatalogEntry.scope?: "global"|"conversation" on both surfaces. Contracts: ui-contract 0.1.0→0.2.0, transport-contract 0.8.0→0.9.0 (additive). 907 tests pass (+13); live-verified against bin/up (warms @5s with future nextWarmAt; mid-turn close → abortedTurn:true + done.reason aborted). Courier: frontend-cache-warming-lifecycle-handoff.md.
Diffstat (limited to 'packages/session-orchestrator/src')
-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
4 files changed, 134 insertions, 1 deletions
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,