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/session-orchestrator/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/session-orchestrator/src')
| -rw-r--r-- | packages/session-orchestrator/src/extension.ts | 1 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 94 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 38 |
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, |
