From eedf865399a0cddebc9ce82c03edbe013bd18da6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:54:44 +0800 Subject: [PATCH 01/33] fix: persist steering at admission Generated-by: Maka --- .../canonical-session-projection.test.ts | 1 + .../src/__tests__/goal-root-authority.test.ts | 2 + .../src/__tests__/message-coordinator.test.ts | 81 +++++++++++++++++++ .../__tests__/root-turn-coordinator.test.ts | 4 + .../src/server/execution-composition.ts | 2 + .../src/server/message-coordinator.ts | 16 ++++ .../src/server/root-turn-coordinator.ts | 10 +++ packages/runtime/src/ai-sdk-backend.ts | 2 +- packages/runtime/src/runtime-kernel.ts | 28 +++++++ packages/runtime/src/session-manager.ts | 12 +++ 10 files changed, 157 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index ab624029d6..27587456ae 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -531,6 +531,7 @@ function createMessages( throw new Error('unexpected root start'); }, prepareMessage: async (input) => ({ kind: 'ready', content: input.content }), + commitSteeringAdmission: async () => {}, claimStop: async () => { throw new Error('unexpected root stop'); }, diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index f5f74ebaa0..874fc987e9 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -562,6 +562,8 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro startFromMessage: (input, lease) => requireCoordinator(coordinator).startFromMessage(input, lease), prepareMessage: (input) => requireCoordinator(coordinator).prepareMessage(input), + commitSteeringAdmission: (input) => + requireCoordinator(coordinator).commitSteeringAdmission(input), claimStop: (input, commitQueueFence, lease) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, lease), }; diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 934227bd5a..42c84f38b4 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -332,6 +332,58 @@ test('full snapshot preflight rejection leaves queue, receipt, residency, and pu await fixture.coordinator.close(); }); +test('persists a steering message before admitting it to the active Turn queue', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + + const accepted = await submit( + fixture, + 'durable-steering', + 'persist before queueing', + 'current_turn', + ); + + assert.equal(accepted.ok && accepted.result.disposition, 'steering'); + assert.deepEqual(fixture.steeringAdmissions, [ + { + sessionId: ROOT.sessionId, + turnId: ROOT.turnId, + runId: ROOT.runId, + messageId: 'durable-steering', + content: { text: 'persist before queueing' }, + }, + ]); + assert.equal(fixture.coordinator.projection(ROOT.sessionId).steering.length, 1); + + await fixture.coordinator.handlers['queue.retract']( + { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-durable' }, + operationContext(), + ); + fixture.coordinator.abandonRootReservation(ROOT); + await fixture.coordinator.close(); +}); + +test('does not expose steering when its durable admission fails', async () => { + const changedSessions: string[] = []; + const fixture = createFixture((sessionId) => changedSessions.push(sessionId)); + fixture.coordinator.reserveRootTurn(ROOT); + const delay = fixture.delaySteeringAdmission(new Error('durable admission failed')); + + const submission = submit(fixture, 'failed-steering', 'must not become visible', 'current_turn'); + await delay.started.promise; + assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).steering, []); + assert.deepEqual(changedSessions, []); + delay.release.resolve(undefined); + + await assert.rejects(submission, /durable admission failed/); + assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).steering, []); + assert.equal(fixture.liveResidencies(), 0); + assert.deepEqual(changedSessions, []); + + fixture.coordinator.abandonRootReservation(ROOT); + await fixture.coordinator.close(); +}); + test('queue admission rejects content that cannot form a durable follow-up Turn', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); @@ -2106,6 +2158,20 @@ function createFixture( | undefined; const receipts = new Map(); const events: RuntimeEvent[] = []; + const steeringAdmissions: Array<{ + sessionId: string; + turnId: string; + runId: string; + messageId: string; + content: MessageContent; + }> = []; + let steeringAdmissionDelay: + | { + readonly started: ReturnType>; + readonly release: ReturnType>; + readonly error?: Error; + } + | undefined; const operationReceipts = new Map(); const receiptDelays = new Map< string, @@ -2161,6 +2227,15 @@ function createFixture( return { turnId }; }, prepareMessage: (input) => prepareMessage(input), + commitSteeringAdmission: async (input) => { + steeringAdmissions.push(structuredClone(input)); + const delay = steeringAdmissionDelay; + if (!delay) return; + steeringAdmissionDelay = undefined; + delay.started.resolve(undefined); + await delay.release.promise; + if (delay.error) throw delay.error; + }, claimStop: async (_input, commitQueueFence) => { commitQueueFence(); return { @@ -2230,6 +2305,12 @@ function createFixture( startCalls: () => startCalls, events, receipts, + steeringAdmissions, + delaySteeringAdmission: (error?: Error) => { + const delay = { started: deferred(), release: deferred(), error }; + steeringAdmissionDelay = delay; + return delay; + }, stopClaimed, resolveTerminal: terminal.resolve, liveResidencies: () => liveResidencies, diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index aad3fdb039..67148dc04f 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -2162,6 +2162,8 @@ test('hosted linked child roots share admission, message, terminal, and stop aut startFromMessage: (input, admission) => requireCoordinator(coordinator).startFromMessage(input, admission), prepareMessage: (input) => requireCoordinator(coordinator).prepareMessage(input), + commitSteeringAdmission: (input) => + requireCoordinator(coordinator).commitSteeringAdmission(input), claimStop: (input, commitQueueFence, admission) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, admission), }; @@ -4781,6 +4783,8 @@ async function createFailureFixture(options: { startFromMessage: (input, admission) => requireCoordinator(coordinator).startFromMessage(input, admission), prepareMessage: (input) => requireCoordinator(coordinator).prepareMessage(input), + commitSteeringAdmission: (input) => + requireCoordinator(coordinator).commitSteeringAdmission(input), claimStop: (input, commitQueueFence, admission) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, admission), }; diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 153468a1e2..7a6a348f7b 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -453,6 +453,8 @@ export async function createExecutionRuntimeHostComposition( startFromMessage: (input, admission) => requireRootCoordinator(rootCoordinator).startFromMessage(input, admission), prepareMessage: (input) => requireRootCoordinator(rootCoordinator).prepareMessage(input), + commitSteeringAdmission: (input) => + requireRootCoordinator(rootCoordinator).commitSteeringAdmission(input), claimStop: (input, commitQueueFence, admission) => requireRootCoordinator(rootCoordinator).claimStop(input, commitQueueFence, admission), }; diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 8d8ac47e9b..45282eaf12 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -143,6 +143,13 @@ export interface HostMessageRootPort { | { readonly kind: 'ready'; readonly content: MessageContent } | { readonly kind: 'rejected'; readonly error: string } >; + commitSteeringAdmission(input: { + readonly sessionId: string; + readonly turnId: string; + readonly runId: string; + readonly messageId: string; + readonly content: MessageContent; + }): Promise; claimStop( input: Omit, commitQueueFence: () => QueueFenceResult, @@ -734,6 +741,15 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { continue; } const result = { disposition, queueRevision: candidateRevision + 1 } as const; + if (disposition === 'steering') { + await this.#root.commitSteeringAdmission({ + sessionId: input.sessionId, + turnId: rootState.turnId, + runId: rootState.runId, + messageId: input.messageId, + content: payload.content, + }); + } const residency = this.#acquireResidency(); const entry: LiveEntry = { entryId, diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index c05a8a019d..790d8d4690 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1085,6 +1085,16 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { }); } + commitSteeringAdmission(input: { + readonly sessionId: string; + readonly turnId: string; + readonly runId: string; + readonly messageId: string; + readonly content: MessageContent; + }): Promise { + return this.runCommand(() => this.manager.commitSteeringAdmission(input)); + } + prepareMessage( input: HostMessagePreparationInput, ): Promise< diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 48bb09af68..8d23cb274c 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -4514,7 +4514,7 @@ export class AiSdkBackend implements AgentBackend { } // Materialize provider content before publishing the durable event. // After consumption there must be no fallible gap before ack/injection. - const eventId = this.newId(); + const eventId = lease.messageId; const providerContent = await this.appendImageParts( scope.imageBudget, buildSteeringEnvelope(formatTextWithInlineRefs(lease.content.text, lease.content)), diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 968b54db95..4498229b0a 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -28,6 +28,7 @@ import { isSessionInlineRun } from '@maka/core/agent-run'; import type { ActiveInteractionRequestEvent, CompleteEvent, + MessageContent, QueueEnqueueOutcome, QueueUpdateEvent, SessionEvent, @@ -168,6 +169,13 @@ export interface RuntimeKernelLike { respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise; listActiveInteractions?(sessionId: string): ActiveInteractionRequestEvent[]; respondToUserQuestion?(sessionId: string, response: UserQuestionResponse): Promise; + commitSteeringAdmission?(input: { + sessionId: string; + turnId: string; + runId: string; + messageId: string; + content: MessageContent; + }): Promise; /** Queue a user message for mid-turn injection at the next step boundary. */ steer(sessionId: string, text: string): QueueEnqueueOutcome; /** Queue a user message to open the turn after the current one finishes. */ @@ -2564,6 +2572,26 @@ export class RuntimeKernel implements RuntimeKernelLike { state.activeTurnId = undefined; } + async commitSteeringAdmission(input: { + sessionId: string; + turnId: string; + runId: string; + messageId: string; + content: MessageContent; + }): Promise { + if (!this.hasActiveRun(input.sessionId, input.runId, input.turnId)) { + throw new Error('Steering admission no longer matches the active root Turn'); + } + await this.deps.store.appendMessage(input.sessionId, { + type: 'user', + id: input.messageId, + turnId: input.turnId, + ts: this.deps.now(), + ...structuredClone(input.content), + steeringEventId: input.messageId, + }); + } + hasActiveRuns(sessionId: string): boolean { return this.backendGenerationsFor(sessionId).some((active) => active.activeRuns.size > 0); } diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 0707b96998..a07bf3f205 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -4813,6 +4813,18 @@ export class SessionManager { } /** Queue a user message for mid-turn injection at the next step boundary. */ + commitSteeringAdmission(input: { + sessionId: string; + turnId: string; + runId: string; + messageId: string; + content: MessageContent; + }): Promise { + const commit = this.runtimeKernel.commitSteeringAdmission; + if (!commit) throw new Error('Runtime steering admission authority is unavailable'); + return commit.call(this.runtimeKernel, input); + } + steer(sessionId: string, text: string): QueueEnqueueOutcome { return this.runtimeKernel.steer(sessionId, text); } From 82995c4b377def24823f9f79caf9dacf3691a880 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 21:59:14 +0800 Subject: [PATCH 02/33] refactor(desktop): unify steering admission Generated-by: Maka --- apps/desktop/e2e/streaming-remount.spec.ts | 4 ++- ...me-host-session-execution-ipc-main.test.ts | 23 ++------------- .../workbar-services-adapter.test.ts | 7 ++++- ...runtime-host-session-execution-ipc-main.ts | 28 ++----------------- apps/desktop/src/preload/bridge-contract.d.ts | 2 -- apps/desktop/src/preload/preload.ts | 4 --- .../desktop/create-workbar-services.ts | 5 +++- 7 files changed, 17 insertions(+), 56 deletions(-) diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index ca47298563..8547e104f5 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -231,7 +231,9 @@ test('returning to a live conversation settles output accumulated while away', a unsubscribe(); resolve(); }); - void window.maka.sessions.steer(sessionId, steering).catch((error) => { + void window.maka.sessions + .enqueue(sessionId, 'current_turn', { text: steering }) + .catch((error) => { window.clearTimeout(timeout); unsubscribe(); reject(error); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index a59a82cf65..073cda9e56 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -907,16 +907,11 @@ test("routes per-entry queue mutations to the Runtime Host", async () => { ); }); -test("binds steer and stop to Host-owned queue and active Turn identities", async () => { - const submits: unknown[] = []; +test("binds stop to the Host-owned active Turn identity", async () => { const interrupts: unknown[] = []; const stopLifecycle: string[] = []; let sequence = 0; const client = executionClient({ - submitMessage: async (input) => { - submits.push(input); - return { disposition: "steering", queueRevision: 2 }; - }, interruptTurn: async (input) => { stopLifecycle.push("interrupt"); interrupts.push(input); @@ -952,12 +947,6 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn ipc, ); - assert.deepEqual( - await ipc.invoke("sessions:steer", "session-1", " Continue "), - { - kind: "queued", - }, - ); await ipc.invoke("sessions:stop", "session-1", { source: "stop_button", expectedTurnId: "turn-unrelated", @@ -969,18 +958,10 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn }); assert.deepEqual(stopLifecycle, ["teardown", "interrupt"]); - assert.deepEqual(submits, [ - { - sessionId: "session-1", - messageId: "id-1", - content: { text: "Continue" }, - placement: "current_turn", - }, - ]); assert.deepEqual(interrupts, [ { sessionId: "session-1", - interruptId: "id-2", + interruptId: "id-1", turnId: "turn-1", runId: "run-1", }, diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 010e5e2a4f..737c00e317 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -197,7 +197,7 @@ describe('createDesktopWorkbarServices', () => { 'sessions.abandonSessionCopy', 'sessions.send', 'sessions.stop', - 'sessions.steer', + 'sessions.enqueue', 'sessions.setPermissionMode', 'sessions.regenerateTurn', 'sessions.respondToSandboxBoundary', @@ -213,6 +213,11 @@ describe('createDesktopWorkbarServices', () => { 's', 'a', ]); + assert.deepEqual(calls.find((call) => call.name === 'sessions.enqueue')?.args, [ + 'fork', + 'current_turn', + { text: 'more' }, + ]); assert.deepEqual(calls.find((call) => call.name === 'inspector.trace')?.args, [ 's', 'cursor-1', diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 3be6608f6a..9d0dfcdb95 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -278,8 +278,8 @@ export function registerRuntimeHostSessionExecutionIpc( try { startResult = await deps.client.startTurn(startInput); } catch (error) { - // The renderer routes text at a session it sees as running to - // `sessions:steer`, but its view can lag the Host: another window, a + // The renderer routes text at a session it sees as running to the + // current-turn message queue, but its view can lag the Host: another window, a // Bot, or a Goal continuation may have opened the root Turn first, and // that race surfaced here as a session_busy send failure that dropped // the user's message (#1954). `turn.message.submit` resolves the race @@ -347,19 +347,6 @@ export function registerRuntimeHostSessionExecutionIpc( }, ); - ipcMain.handle( - "sessions:steer", - async (_event, sessionId: string, text: unknown) => { - const content = steeringContent(text); - await deps.client.submitMessage({ - sessionId, - messageId: newId(), - content: { text: content }, - placement: "current_turn", - }); - return { kind: "queued" as const }; - }, - ); ipcMain.handle( "sessions:enqueue", async (event, sessionId: string, placement: unknown, value: unknown) => { @@ -801,17 +788,6 @@ function requiredSequence(value: unknown, label: string): number { return value as number; } -function steeringContent(value: unknown): string { - if ( - typeof value !== "string" || - value.trim().length === 0 || - value.length > 128_000 - ) { - throw new Error("Invalid steering text"); - } - return value.trim(); -} - function isTerminalStatus(status: string): boolean { return ( status === "completed" || status === "failed" || status === "cancelled" diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d9615ba464..1fc7744104 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -48,7 +48,6 @@ import type { SessionCommand, SessionEvent, ShellRunUpdate, - QueueEnqueueOutcome, } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; @@ -731,7 +730,6 @@ export interface MakaBridge { sessionId: string, input?: { source?: 'stop_button'; expectedTurnId?: string }, ): Promise; - steer(sessionId: string, text: string): Promise; enqueue( sessionId: string, placement: 'current_turn' | 'next_turn', diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e909783bcd..c74cf42998 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -112,7 +112,6 @@ import type { SessionCommand, SessionEvent, ShellRunUpdate, - QueueEnqueueOutcome, } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; @@ -1545,9 +1544,6 @@ const makaBridge = { ): Promise { return invokeSessionRuntimeHost('sessions:stop', sessionId, input); }, - steer(sessionId: string, text: string): Promise { - return invokeSessionRuntimeHost('sessions:steer', sessionId, text); - }, async enqueue( sessionId: string, placement: 'current_turn' | 'next_turn', diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 86c9d0ef00..832d5ccf5a 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -121,7 +121,10 @@ export function createDesktopWorkbarServices( bridge.sessions.abandonSessionCopy(sourceSessionId, copyId), send: (sessionId, command) => bridge.sessions.send(sessionId, command), stop: (sessionId) => bridge.sessions.stop(sessionId), - steer: (sessionId, text) => bridge.sessions.steer(sessionId, text), + steer: async (sessionId, text) => { + await bridge.sessions.enqueue(sessionId, 'current_turn', { text }); + return { kind: 'queued' }; + }, setPermissionMode: (sessionId, mode) => bridge.sessions.setPermissionMode(sessionId, mode), regenerateTurn: (sessionId, input) => From 1138a5a51345c08f46966f08f48b9d4633d8977d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 22:09:39 +0800 Subject: [PATCH 03/33] refactor(cli): remove message fallback ownership Generated-by: Maka --- .../cli/src/__tests__/pi-transcript.test.ts | 2 - .../cli/src/__tests__/pi-tui-runner.test.ts | 442 ------------------ packages/cli/src/pi-transcript.ts | 27 +- packages/cli/src/pi-tui-runner.ts | 194 +------- .../cli/src/runtime-host-session-driver.ts | 16 +- packages/cli/src/session-driver.ts | 1 - 6 files changed, 22 insertions(+), 660 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b57e43775c..53f450611d 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -373,7 +373,6 @@ describe('Maka Pi TUI transcript', () => { ); state.entries.push({ kind: 'notice', level: 'error', text: 'Turn failed: provider_error' }); state.steering = ['Keep going']; - state.pendingFallback = [{ text: 'Try again', enqueue: 'steer' }]; assert.equal( hydrateToolsWithStoredMessages(state, 'turn-1', [ @@ -405,7 +404,6 @@ describe('Maka Pi TUI transcript', () => { assert.deepEqual(tool?.input, { path: 'README.md' }); assert.deepEqual(tool?.result, { kind: 'text', text: 'README contents' }); assert.deepEqual(state.steering, ['Keep going']); - assert.deepEqual(state.pendingFallback, [{ text: 'Try again', enqueue: 'steer' }]); assert.equal(state.entries.at(-1)?.kind, 'notice'); }); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 84d559537f..ee7134593a 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -2277,199 +2277,6 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('a fallback enqueue during a long turn is never dropped and flushes into the next turn', async () => { - const terminal = new FakeTerminal(); - // Every enqueue reports `fallback` — the runtime never has a live owner. - const driver = new FallbackSteeringDriver(); - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start the work'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - - terminal.input('second thought'); - terminal.input('\r'); // steer → fallback → CLI-held pending - terminal.input('and afterwards'); - terminal.input('\x1b\r'); // Alt+Enter → fallback → CLI-held pending - await waitFor(() => { - const screen = plainTerminalOutput(terminal.screenOutput()); - return ( - screen.includes('Steering: second thought') && screen.includes('Queued: and afterwards') - ); - }); - - // The old bounded poll gave up after ~2s of busy (about 20 attempts at the - // 100ms retry cadence) and silently dropped the text. Waiting for the - // driver to observe the retries crossing that budget — instead of guessing - // elapsed time — proves the CLI is still retrying under any scheduler load. - await waitForUpTo(() => driver.steerAttempts > 22 && driver.queueAttempts > 22, 30_000); - const screen = plainTerminalOutput(terminal.screenOutput()); - assert.equal(screen.includes('Steering: second thought'), true); - assert.equal(screen.includes('Queued: and afterwards'), true); - assert.deepEqual(driver.prompts, ['start the work']); - - // The turn boundary flushes the undelivered texts into the next turn. - driver.endTurn(); - await waitFor(() => driver.prompts.length === 2); - assert.equal(driver.prompts[1], 'second thought\n\nand afterwards'); - - await waitForUpTo(() => driver.parked, 1_000); - driver.endTurn(); - await waitFor(() => terminal.progressStates.at(-1) === false); - terminal.input('/exit'); - terminal.input('\r'); - await run; - }); - - test('a fallback steer retries the same enqueue and lands once the owner appears', async () => { - const terminal = new FakeTerminal(); - const driver = new FallbackSteeringDriver(); - driver.steerFallbacks = 2; // the owner appears after ~200ms of retries - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start the work'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - - terminal.input('late owner'); - terminal.input('\r'); // steer → fallback, retried until it lands - await waitForUpTo(() => driver.steered.includes('late owner'), 1_000); - // Landed as a steer of the RUNNING turn — no fresh turn was opened. - assert.deepEqual(driver.prompts, ['start the work']); - await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('Steering: late owner'), - ); - - driver.endTurn(); - await waitFor(() => terminal.progressStates.at(-1) === false); - // Nothing left to flush: the text was delivered mid-turn, not re-queued. - assert.deepEqual(driver.prompts, ['start the work']); - terminal.input('/exit'); - terminal.input('\r'); - await run; - }); - - test('a turn boundary waits for an unresolved enqueue before deciding whether to flush it', async () => { - const terminal = new FakeTerminal(); - const driver = new DeferredAdmissionDriver(); - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - await waitForUpTo(() => driver.parked, 1_000); - terminal.input('late admission'); - terminal.input('\r'); - await waitFor(() => driver.steerCalls === 1); - - driver.endTurn(); - await waitFor(() => driver.completedTurns === 1); - assert.deepEqual(driver.prompts, ['start']); - driver.releaseAdmission({ kind: 'fallback' }); - await waitForUpTo(() => driver.prompts.length === 2, 1_000); - assert.equal(driver.prompts[1], 'late admission'); - - await waitForUpTo(() => driver.parked, 1_000); - driver.endTurn(); - await waitFor(() => terminal.progressStates.at(-1) === false); - terminal.input('/exit'); - terminal.input('\r'); - await run; - }); - - test('a queued retry settling at the turn boundary is not also flushed as a new turn', async () => { - const terminal = new FakeTerminal(); - const driver = new DeferredRetryDriver(); - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - terminal.input('lands on retry'); - terminal.input('\r'); - await waitForUpTo(() => driver.steerCalls === 2, 1_000); - - driver.endTurn(); - driver.releaseRetry(); - await waitFor(() => terminal.progressStates.at(-1) === false); - assert.deepEqual(driver.prompts, ['start']); - assert.deepEqual(driver.delivered, ['lands on retry']); - - terminal.input('/exit'); - terminal.input('\r'); - await run; - }); - - test('interrupt refills CLI-held fallback text into the editor', async () => { - const terminal = new FakeTerminal(); - const driver = new FallbackSteeringDriver(); - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start the work'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - - terminal.input('rescue me'); - terminal.input('\r'); // steer → fallback → CLI-held pending - await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('Steering: rescue me'), - ); - - terminal.input('\x1b'); - terminal.input('\x1b'); // interrupt - await waitFor(() => terminal.progressStates.at(-1) === false); - // The CLI-held text comes back for re-editing; the pending bar clears. - await waitFor(() => { - const screen = plainTerminalOutput(terminal.screenOutput()); - return screen.includes('rescue me') && !screen.includes('Steering: rescue me'); - }); - - terminal.input('\x03'); // clear the refilled draft - terminal.input('/exit'); - terminal.input('\r'); - await run; - }); - test('input during the interrupt convergence window stays in the editor and opens no turn', async () => { const terminal = new FakeTerminal(); const driver = new SlowStopDriver(); // stop() returns but the turn keeps running @@ -2516,49 +2323,6 @@ describe('Maka Pi TUI runner', () => { assert.deepEqual(driver.prompts, ['start the work']); }); - test('an aborted turn never auto-opens the flush turn; undelivered text becomes a draft', async () => { - const terminal = new FakeTerminal(); - const driver = new FallbackSteeringDriver(); // enqueues always fall back - const run = runMakaPiTui({ - title: 'Maka', - driver, - cwd: '/repo', - model: 'm', - connectionSlug: 'c', - permissionMode: 'bypass', - terminal, - }); - - terminal.input('start the work'); - terminal.input('\r'); - await waitFor(() => terminal.progressStates.at(-1) === true); - - terminal.input('next thing'); - terminal.input('\x1b\r'); // Alt+Enter → fallback → CLI-held pending - await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('Queued: next thing'), - ); - - // The turn ends as ABORTED on its own (not via the CLI interrupt path): - // the boundary flush must not open a turn the user just stopped. - driver.abortNextTurn = true; - driver.endTurn(); - await waitFor(() => terminal.progressStates.at(-1) === false); - // The undelivered text is an editable draft, not a queued line. - await waitFor(() => { - const screen = plainTerminalOutput(terminal.screenOutput()); - return screen.includes('next thing') && !screen.includes('Queued: next thing'); - }); - - terminal.input('\x03'); // clear the preserved draft - terminal.input('/exit'); - terminal.input('\r'); - await run; - // Anchored after close: a wrongly-opened flush turn would have landed in - // prompts by the time the TUI has fully shut down. - assert.deepEqual(driver.prompts, ['start the work']); - }); - test('exits on the second Ctrl-C during a control command', async () => { const terminal = new FakeTerminal(); const driver = new DeferredControlDriver(); @@ -6809,212 +6573,6 @@ class SteeringTurnDriver implements MakaSessionDriver { * first N calls (configurable, default forever) while the turn parks until * `endTurn()` — the begin-window shape behind review finding N2. */ -class FallbackSteeringDriver implements MakaSessionDriver { - readonly prompts: string[] = []; - readonly steered: string[] = []; - readonly queuedMessages: string[] = []; - stopCalls = 0; - completedTurns = 0; - /** Enqueue calls that report `fallback` before the owner "appears". */ - steerFallbacks = Number.POSITIVE_INFINITY; - queueFallbacks = Number.POSITIVE_INFINITY; - /** Total enqueue attempts, including rejected ones — the observable retry count. */ - steerAttempts = 0; - queueAttempts = 0; - private steering: string[] = []; - private followup: string[] = []; - private pendingEvents: SessionEvent[] = []; - private wakeTurn: (() => void) | null = null; - private turnOpen = false; - private turnEnded = false; - private eventSeq = 0; - - get parked(): boolean { - return this.turnOpen && !this.turnEnded; - } - - async listSessions(): Promise { - return []; - } - - preparePrompt( - prompt: string, - options: MakaPreparePromptOptions = {}, - ): Promise { - this.prompts.push(options.modelText ?? prompt); - const turnId = options.turnId ?? `turn-${this.prompts.length}`; - return Promise.resolve({ - sessionId: this.getSessionId(), - turnId, - events: this.promptEvents(turnId), - }); - } - - async *compactSession(): AsyncIterable {} - - // Same single-path contract as the runtime: queue contents reach the CLI - // only through `queue_update` events on the turn stream. - private emitQueueUpdate(): void { - this.eventSeq += 1; - this.pendingEvents.push({ - type: 'queue_update', - id: `queue-update-${this.eventSeq}`, - turnId: `turn-${this.prompts.length}`, - ts: this.eventSeq, - steering: [...this.steering], - followup: [...this.followup], - }); - this.wakeTurn?.(); - this.wakeTurn = null; - } - - async *promptEvents(turnId: string): AsyncIterable { - this.turnOpen = true; - this.turnEnded = false; - for (;;) { - while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!; - if (this.turnEnded) break; - await new Promise((resolve) => { - this.wakeTurn = resolve; - }); - } - this.turnOpen = false; - if (this.abortNextTurn) { - this.abortNextTurn = false; - yield { - type: 'abort', - id: `abort-${this.prompts.length}`, - turnId, - ts: 1, - reason: 'user_stop', - }; - yield { - type: 'complete', - id: `complete-${this.prompts.length}`, - turnId, - ts: 2, - stopReason: 'user_stop', - }; - this.completedTurns += 1; - return; - } - yield { - type: 'complete', - id: `complete-${this.prompts.length}`, - turnId, - ts: 1, - stopReason: 'end_turn', - }; - this.completedTurns += 1; - } - - /** Next endTurn() finishes the turn as aborted instead of end_turn. */ - abortNextTurn = false; - - async steer(text: string): Promise { - this.steerAttempts += 1; - if (this.steerFallbacks > 0) { - this.steerFallbacks -= 1; - return { kind: 'fallback' }; - } - this.steered.push(text); - this.steering.push(text); - this.emitQueueUpdate(); - return { kind: 'queued' }; - } - - async queueMessage(text: string): Promise { - this.queueAttempts += 1; - if (this.queueFallbacks > 0) { - this.queueFallbacks -= 1; - return { kind: 'fallback' }; - } - this.queuedMessages.push(text); - this.followup.push(text); - this.emitQueueUpdate(); - return { kind: 'queued' }; - } - - async takePendingFollowup(): Promise { - if (this.followup.length === 0) return null; - const joined = this.followup.join('\n\n'); - this.followup = []; - return joined; - } - - async retractQueued(): Promise { - const joined = [...this.steering, ...this.followup].join('\n\n'); - this.steering = []; - this.followup = []; - this.emitQueueUpdate(); - return joined; - } - - endTurn(): void { - this.turnEnded = true; - this.wakeTurn?.(); - this.wakeTurn = null; - } - - async stop(): Promise { - this.stopCalls += 1; - this.steering = []; - this.followup = []; - this.endTurn(); - } - - async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} - async renameSession(): Promise {} - async setModel(): Promise {} - async setPermissionMode(): Promise {} - async setThinkingLevel(): Promise {} - async switchSession(sessionId: string): Promise { - return switchResult(fakeSessionSummary(sessionId)); - } - async listRewindTargets(): Promise { - return []; - } - async rewindToTurn(): Promise { - throw new Error('rewind not supported in this fake'); - } - startNewSession(): void {} - getSessionId(): string { - return 'session-1'; - } -} - -class DeferredAdmissionDriver extends FallbackSteeringDriver { - steerCalls = 0; - readonly #admission = deferred(); - - override async steer(_text: string): Promise { - this.steerCalls += 1; - return this.#admission.promise; - } - - releaseAdmission(outcome: QueueEnqueueOutcome): void { - this.#admission.resolve(outcome); - } -} - -class DeferredRetryDriver extends FallbackSteeringDriver { - steerCalls = 0; - readonly delivered: string[] = []; - readonly #retry = deferred(); - - override async steer(text: string): Promise { - this.steerCalls += 1; - if (this.steerCalls === 1) return { kind: 'fallback' }; - await this.#retry.promise; - this.delivered.push(text); - return { kind: 'queued' }; - } - - releaseRetry(): void { - this.#retry.resolve(); - } -} - class SlowStopDriver implements MakaSessionDriver { stopCalls = 0; readonly prompts: string[] = []; diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 35de5bbd80..5a96690285 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -112,7 +112,6 @@ export interface MakaPiTranscriptState { * into the next turn at the turn boundary, so the text is never dropped. * Rendered in the pending bar alongside the mirror. */ - pendingFallback: Array<{ text: string; enqueue: 'steer' | 'queue' }>; /** Current non-durable provider retry progress for the activity strip. */ providerRetry?: ProviderRetryEvent; } @@ -217,7 +216,6 @@ export function createMakaPiTranscriptState(): MakaPiTranscriptState { usage: { costUsd: 0, cacheHitInput: 0, cacheMissInput: 0 }, steering: [], followup: [], - pendingFallback: [], }; } @@ -344,7 +342,6 @@ export function replaceTranscriptWithStoredMessages( // Queues are per-active-run; a switched/reset session has none pending. state.steering = []; state.followup = []; - state.pendingFallback = []; for (const msg of messages) { if (msg.type === 'token_usage') accumulateUsage(state.usage, msg); } @@ -1467,33 +1464,15 @@ export function renderMakaPiPendingQueue( width: number, platform: NodeJS.Platform = process.platform, ): string[] { - if ( - state.steering.length === 0 && - state.followup.length === 0 && - state.pendingFallback.length === 0 - ) { - return []; - } + if (state.steering.length === 0 && state.followup.length === 0) return []; const safeWidth = Math.max(1, width); - const steering = [ - ...state.steering, - ...state.pendingFallback - .filter((entry) => entry.enqueue === 'steer') - .map((entry) => entry.text), - ]; - const followup = [ - ...state.followup, - ...state.pendingFallback - .filter((entry) => entry.enqueue === 'queue') - .map((entry) => entry.text), - ]; const lines: string[] = []; - for (const text of steering) { + for (const text of state.steering) { lines.push( fitLine(`${ansi.accent('Steering:')} ${ansi.dim(firstLinePreview(text))}`, safeWidth), ); } - for (const text of followup) { + for (const text of state.followup) { lines.push(fitLine(`${ansi.dim('Queued:')} ${ansi.dim(firstLinePreview(text))}`, safeWidth)); } lines.push( diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 61d335b547..409ea52492 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -46,7 +46,7 @@ import { slashCommandsForSurface, type SlashCommandIdForSurface, } from '@maka/core/slash-command-catalog'; -import { type QueueEnqueueOutcome, type ShellRunUpdate } from '@maka/core/events'; +import type { ShellRunUpdate } from '@maka/core/events'; import { latestAssistantModelId, type SessionSummary, @@ -719,7 +719,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { shellRunHydration.dispose(); shellRunElapsedTicker.dispose(); stopTurnElapsedTicker(); - stopFallbackRetry(); setTaskbarProgress(false); // Drop the busy / attention title marker so the tab is not handed back to // the shell still marked busy when the session exits. @@ -839,8 +838,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { void (async () => { await settlePendingEnqueues(); const retracted = (await input.driver.retractQueued?.()) ?? ''; - const fallback = await takePendingFallbackSettled(); - refillEditorFromQueues([fallback, retracted].filter(Boolean).join('\n\n')); + refillEditorFromQueues(retracted); requestRender(); await input.driver.stop(); })().catch((error) => { @@ -891,104 +889,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }); }; - // Fallback handoff owner. A `fallback` outcome while the turn is running - // means the runtime has no live steering owner YET (the begin window) or - // just lost it; the runtime keeps no record of the text, so the CLI owns - // delivery: retry the SAME enqueue until the owner appears, and flush any - // remainder into the next turn at the turn boundary. Never a bounded wait — - // a normal turn outlives any fixed budget and the text must not vanish. - const FALLBACK_RETRY_MS = 100; - let fallbackRetryTimer: ReturnType | null = null; - let fallbackRetryInFlight = false; - let fallbackRetryTask: Promise | null = null; - let fallbackRetryGeneration = 0; - - const stopFallbackRetry = () => { - fallbackRetryGeneration += 1; - if (fallbackRetryTimer !== null) clearTimeout(fallbackRetryTimer); - fallbackRetryTimer = null; - }; - - const scheduleFallbackRetry = () => { - if (fallbackRetryTimer !== null || fallbackRetryInFlight) return; - fallbackRetryTimer = setTimeout(() => { - fallbackRetryTimer = null; - const task = retryPendingFallback(); - fallbackRetryTask = task; - void task.finally(() => { - if (fallbackRetryTask === task) fallbackRetryTask = null; - }); - }, FALLBACK_RETRY_MS); - }; - - const retryPendingFallback = async () => { - if (closed || !turnRunning || state.pendingFallback.length === 0) { - stopFallbackRetry(); - return; - } - const generation = fallbackRetryGeneration; - const attempted = [...state.pendingFallback]; - fallbackRetryInFlight = true; - const remaining: typeof state.pendingFallback = []; - let failed = false; - try { - for (const entry of attempted) { - const enqueue = entry.enqueue === 'steer' ? input.driver.steer : input.driver.queueMessage; - let outcome: QueueEnqueueOutcome | undefined; - try { - outcome = enqueue ? await enqueue.call(input.driver, entry.text) : undefined; - } catch (error) { - failed = true; - reportError(error); - } - if (outcome?.kind !== 'queued') remaining.push(entry); - } - } finally { - fallbackRetryInFlight = false; - } - if (generation !== fallbackRetryGeneration) return; - const attemptedEntries = new Set(attempted); - const appended = state.pendingFallback.filter((entry) => !attemptedEntries.has(entry)); - const changed = remaining.length !== attempted.length; - state.pendingFallback = [...remaining, ...appended]; - if (remaining.length === 0) stopFallbackRetry(); - else if (!failed) scheduleFallbackRetry(); - if (!changed) return; - // The queue mirror updates only from `queue_update` events (single path); - // this render just drops the delivered entries from the fallback list. - requestRender(); - }; - - const deferFallback = (text: string, enqueue: 'steer' | 'queue') => { - state.pendingFallback.push({ text, enqueue }); - scheduleFallbackRetry(); - requestRender(); - }; - - /** Drain the CLI-held fallback texts (delivery order), stopping the retry loop. */ - const takePendingFallbackEntries = (): Array<{ text: string; enqueue: 'steer' | 'queue' }> => { - stopFallbackRetry(); - const entries = state.pendingFallback; - state.pendingFallback = []; - return entries; - }; - - const takePendingFallbackEntriesSettled = async (): Promise< - Array<{ text: string; enqueue: 'steer' | 'queue' }> - > => { - if (fallbackRetryTimer !== null) { - clearTimeout(fallbackRetryTimer); - fallbackRetryTimer = null; - } - await fallbackRetryTask; - return takePendingFallbackEntries(); - }; - - const takePendingFallbackSettled = async (): Promise => - (await takePendingFallbackEntriesSettled()).map((entry) => entry.text).join('\n\n'); - - // Enter during a turn steers it (inject at the next step boundary); the - // runtime falls back to a fresh turn if the run already ended. + // Enter during a turn submits one Host-owned message. Runtime Host resolves + // the turn-boundary race atomically, so the TUI never owns a fallback copy. const steerRunningTurn = (text: string) => { if (!text.trim()) { requestRender(); @@ -997,18 +899,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { editor.addToHistory(text); const enqueue = input.driver.steer; if (!enqueue) { - deferFallback(text, 'steer'); + refillEditorFromQueues(text); return; } const task = enqueue .call(input.driver, text) - .then((outcome) => { - if (outcome.kind === 'fallback') { - if (turnRunning || busy) deferFallback(text, 'steer'); - else submitPrompt(text); - return; - } - // Queued: the runtime's `queue_update` event refreshes the mirror. + .then(() => { + // The runtime's `queue_update` event refreshes the pending mirror. requestRender(); }) .catch((error) => { @@ -1038,18 +935,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { editor.addToHistory(text); const enqueue = input.driver.queueMessage; if (!enqueue) { - deferFallback(text, 'queue'); + refillEditorFromQueues(text); return; } const task = enqueue .call(input.driver, text) - .then((outcome) => { - if (outcome.kind === 'fallback') { - if (turnRunning || busy) deferFallback(text, 'queue'); - else submitPrompt(text); - return; - } - // Queued: the runtime's `queue_update` event refreshes the mirror. + .then(() => { + // The runtime's `queue_update` event refreshes the pending mirror. requestRender(); }) .catch((error) => { @@ -1059,14 +951,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { trackEnqueue(task); }; - // Alt+↑: take back every queued message (both queues plus CLI-held fallback - // texts), joined and prepended to the current draft for re-editing. + // Alt+↑: take back the Host-owned pending queue for re-editing. const retractQueuedMessages = () => { void (async () => { await settlePendingEnqueues(); const retracted = (await input.driver.retractQueued?.()) ?? ''; - const fallback = await takePendingFallbackSettled(); - refillEditorFromQueues([fallback, retracted].filter(Boolean).join('\n\n')); + refillEditorFromQueues(retracted); requestRender(); })().catch(reportError); }; @@ -1327,8 +1217,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (superseded()) { // Orphaned by a mid-turn detach (#3380): the Session this turn ran // on is no longer adopted. Skip every continuation that belongs to - // it — queue flushes would steer the NEW Session, fallback texts - // would refill the editor with abandoned-session context, and a + // it — accepted messages are already owned by the Host, and a // failure notice would misreport the still-running Host Turn. Only // release the slot and hand the freshly attached Turn its start; // startPendingAttachedTurn no-ops until applySwitchResult has @@ -1341,70 +1230,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return outcome; } - // Turn boundary flush: CLI-held fallback texts that never reached the - // runtime (the enqueue retry never found a live owner) are delivered - // FIRST, then queued followups (alt+Enter) — both open the next turn - // before any goal auto-continuation. Consumed here outside the turn - // stream, so clear the local mirror explicitly. + // Runtime Host owns the accepted-message transition across Turn + // boundaries. The TUI only waits for its admission calls to settle. await settlePendingEnqueues(); - const fallbackEntries = await takePendingFallbackEntriesSettled(); - const followup = await input.driver.takePendingFollowup?.(); if (outcome.kind === 'completed' && pendingAttachedTurn) { const attached = pendingAttachedTurn; pendingAttachedTurn = undefined; - const undelivered: string[] = []; - for (const entry of fallbackEntries) { - const enqueue = - entry.enqueue === 'steer' ? input.driver.steer : input.driver.queueMessage; - try { - if (!enqueue || (await enqueue.call(input.driver, entry.text)).kind === 'fallback') { - undelivered.push(entry.text); - } - } catch { - undelivered.push(entry.text); - } - } - if (followup) { - try { - if ( - !input.driver.queueMessage || - (await input.driver.queueMessage(followup)).kind === 'fallback' - ) { - undelivered.push(followup); - } - } catch { - undelivered.push(followup); - } - } busy = false; activity.finish(); startAttachedTurn?.(attached); - if (undelivered.length > 0) refillEditorFromQueues(undelivered.join('\n\n')); return outcome; } - const fallbackText = fallbackEntries.map((entry) => entry.text).join('\n\n'); - const nextPrompt = [fallbackText, followup ?? ''].filter(Boolean).join('\n\n'); - if (nextPrompt) { - state.steering = []; - state.followup = []; - if (outcome.kind !== 'completed') { - // The turn was aborted or errored: auto-opening a turn would defeat - // the interrupt (or hammer a failure). Keep the undelivered text as - // an editable draft instead, merged ahead of any current draft. - refillEditorFromQueues(nextPrompt); - } else { - // Install the next local activity before resolving the previous one. - // A Goal admission woken by the old activity therefore observes the - // user follow-up as busy instead of racing it for the session. - void runAgentTurn({ - kind: 'external', - prompt: nextPrompt, - sessionId: input.driver.getSessionId(), - }); - activity.finish(); - return outcome; - } - } busy = false; activity.finish(); diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 6b7cf0f2a8..14d34279a0 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -359,12 +359,6 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return this.#enqueue(text, 'next_turn'); } - async takePendingFollowup(): Promise { - // Runtime Host owns the terminal transition and starts the queued follow-up - // atomically. Returning its text here would make the TUI submit it twice. - return null; - } - async retractQueued(): Promise { if (!this.#sessionId) return ''; const result = await this.#request('queue.retract', { @@ -855,9 +849,8 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { text: string, placement: 'current_turn' | 'next_turn', ): Promise { - const sessionId = this.#sessionId; - if (!sessionId) return { kind: 'fallback' }; - const result = await this.#request('turn.message.submit', { + const sessionId = this.#requireSession('submit a message'); + await this.#request('turn.message.submit', { originHostEpoch: this.#connection.hostEpoch, sessionId, messageId: this.#newId(), @@ -865,9 +858,8 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { placement, }); // A root Turn can settle between the local projection check and Host - // admission. The Host has already started the message in that case, so it - // must not be submitted again. Treat it as accepted; the subscription owns - // projection of the successor Turn. + // admission. The Host atomically starts the message in that case; the + // subscription owns projection of the successor Turn. return { kind: 'queued' }; } diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 42e179efab..ce58bcd253 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -99,7 +99,6 @@ export interface MakaSessionDriver { resumeLatest?(): AsyncIterable; steer?(text: string): Promise; queueMessage?(text: string): Promise; - takePendingFollowup?(): Promise; retractQueued?(): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion?(response: UserQuestionResponse): Promise; From 8bc31f5166ca4076da8f79fa8227e2032e00b519 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 22:14:35 +0800 Subject: [PATCH 04/33] refactor: project steering from durable transcript Generated-by: Maka --- .../runtime-host-session-observer.test.ts | 17 +++--- .../src/main/runtime-host-session-observer.ts | 3 - .../src/adapter/session-projector.ts | 61 ------------------- 3 files changed, 10 insertions(+), 71 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index b022782d46..65f709e7cc 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -2267,7 +2267,7 @@ test("rehydrates pending interactions and publishes answer acknowledgements", as await observer.close(); }); -test("projects Host queue revisions and newly delivered steering messages", async () => { +test("projects Host queue revisions without synthesizing transcript messages", async () => { const events = new AsyncFrameQueue(); const observer = new RuntimeHostSessionObserver({ client: { @@ -2325,11 +2325,11 @@ test("projects Host queue revisions and newly delivered steering messages", asyn }, }), }); - await waitFor(() => target.events.length === 3); + await waitFor(() => target.events.length === 2); assert.deepEqual( target.events.map((event) => event.type), - ["queue_update", "steering_message", "queue_update"], + ["queue_update", "queue_update"], ); assert.deepEqual(target.events[0], { type: "queue_update", @@ -2343,12 +2343,15 @@ test("projects Host queue revisions and newly delivered steering messages", asyn followupEntries: [], }); assert.deepEqual(target.events[1], { - type: "steering_message", - id: "host-queue:host-1:2:entry-1", + type: "queue_update", + id: "host-queue:host-1:2", turnId: "turn-1", - messageId: "message-steer", ts: 90, - content: { text: "Change direction" }, + queueRevision: 2, + steering: ["Change direction"], + followup: [], + steeringEntries: [{ ...queued, state: "in_flight" }], + followupEntries: [], }); await observer.close(); }); diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 815b960a5a..96025493b5 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -1061,9 +1061,6 @@ export class RuntimeHostSessionObserver { change: DesktopTranscriptReplicaChange, ): void { if (state.replica !== replica || state.closing) return; - state.projector?.noteTranscriptMessageIds( - change.durableUpserts.map((entry) => entry.message.id), - ); this.#sendTranscriptChange(state, replica, change); if (!change.hasNewer && change.durableUpserts.length > 0) { this.#markTranscriptRead(state, replica); diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 09760af34a..d908733507 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -26,7 +26,6 @@ import type { SessionAssistantDelta, SessionAssistantStreamIdentity, SessionMessageQueueProjection, - SteeringMessageSnapshot, SubscriptionFrame, LiveTurnSnapshot, TurnSnapshot, @@ -42,7 +41,6 @@ interface AssistantAccumulator { } export interface RuntimeHostSessionProjectionSeed { - readonly durableInFlightMessageIds: readonly string[]; readonly activeAssistantMessages: readonly Extract[]; } @@ -50,13 +48,7 @@ export function createRuntimeHostSessionProjectionSeed( transcript: readonly StoredMessage[], snapshot: SessionContinuitySnapshot, ): RuntimeHostSessionProjectionSeed { - const inFlightMessageIds = new Set( - rootQueueInFlight(snapshot.queue).map((entry) => entry.messageId), - ); return { - durableInFlightMessageIds: transcript - .filter((message) => inFlightMessageIds.has(message.id)) - .map((message) => message.id), activeAssistantMessages: snapshot.rootTurn === null ? [] @@ -83,7 +75,6 @@ export interface RuntimeHostProjectionUpdate { export class RuntimeHostSessionProjector { #snapshot: SessionContinuitySnapshot; readonly #now: () => number; - readonly #transcriptIds: Set; readonly #accumulators = new Map(); constructor( @@ -94,7 +85,6 @@ export class RuntimeHostSessionProjector { ) { this.#snapshot = structuredClone(snapshot); this.#now = now; - this.#transcriptIds = new Set(seed.durableInFlightMessageIds); const root = snapshot.rootTurn; if (!root) return; for (const message of seed.activeAssistantMessages) { @@ -165,32 +155,12 @@ export class RuntimeHostSessionProjector { for (const interaction of this.#snapshot.interactions.pending) { events.push(...projectRuntimeHostInteractionRequest(interaction, this.#now())); } - for (const entry of rootQueueInFlight(this.#snapshot.queue)) { - if (this.#transcriptIds.has(entry.messageId)) continue; - events.push({ - type: 'steering_message', - id: `host-queue:${this.#snapshot.queue.hostEpoch}:${this.#snapshot.queue.queueRevision}:${entry.entryId}`, - turnId: root.turnId, - messageId: entry.messageId, - ts: this.#now(), - content: structuredClone(entry.content), - }); - } if (queueHasEntries(this.#snapshot.queue)) { events.push(projectQueueUpdate(this.#snapshot.queue, root.turnId, this.#now())); } return events; } - noteTranscriptMessageIds(messageIds: readonly string[]): void { - const inFlight = new Set( - rootQueueInFlight(this.#snapshot.queue).map((entry) => entry.messageId), - ); - for (const messageId of messageIds) { - if (inFlight.has(messageId)) this.#transcriptIds.add(messageId); - } - } - seedTerminal(turn: RuntimeHostTerminalTurn): SessionEvent[] { return this.#terminalEvents(turn, true); } @@ -347,26 +317,12 @@ export class RuntimeHostSessionProjector { const previousSnapshot = this.#snapshot; const next = frame.snapshot; this.#snapshot = structuredClone(next); - const nextInFlight = new Set(rootQueueInFlight(next.queue).map((entry) => entry.messageId)); - for (const messageId of this.#transcriptIds) { - if (!nextInFlight.has(messageId)) this.#transcriptIds.delete(messageId); - } const resolvedInteractions = removedPendingInteractions(previousSnapshot, next); for (const interaction of newlyPendingInteractions(previousSnapshot, next)) { events.push(...projectRuntimeHostInteractionRequest(interaction, this.#now())); } const root = next.rootTurn; if (root && queueChanged(previousSnapshot.queue, next.queue)) { - for (const entry of newlyInFlight(previousSnapshot.queue, next.queue)) { - events.push({ - type: 'steering_message', - id: `host-queue:${next.queue.hostEpoch}:${next.queue.queueRevision}:${entry.entryId}`, - turnId: root.turnId, - messageId: entry.messageId, - ts: this.#now(), - content: structuredClone(entry.content), - }); - } events.push(projectQueueUpdate(next.queue, root.turnId, this.#now())); } const previousRoot = previousSnapshot.rootTurn; @@ -586,23 +542,6 @@ function queueChanged( return previous.hostEpoch !== next.hostEpoch || previous.queueRevision !== next.queueRevision; } -function newlyInFlight( - previous: SessionMessageQueueProjection, - next: SessionMessageQueueProjection, -): Extract[] { - const previousIds = new Set(rootQueueInFlight(previous).map((entry) => entry.entryId)); - return rootQueueInFlight(next).filter((entry) => !previousIds.has(entry.entryId)); -} - -function rootQueueInFlight( - queue: SessionMessageQueueProjection, -): Extract[] { - return queue.steering.filter( - (entry): entry is Extract => - entry.state === 'in_flight', - ); -} - function queueHasEntries(queue: SessionMessageQueueProjection): boolean { return queue.steering.length > 0 || queue.followup.length > 0; } From a2c835e7891c81ab4ce81642a7a13952298e1d5f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 22:22:08 +0800 Subject: [PATCH 05/33] fix(cli): render steering only as conversation Generated-by: Maka --- .../cli/src/__tests__/pi-transcript.test.ts | 2 - .../cli/src/__tests__/pi-tui-runner.test.ts | 41 ++++++++----------- packages/cli/src/pi-transcript.ts | 34 +++------------ packages/cli/src/pi-tui-runner.ts | 1 - 4 files changed, 24 insertions(+), 54 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 53f450611d..16792e56c1 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -372,7 +372,6 @@ describe('Maka Pi TUI transcript', () => { event({ type: 'tool_start', toolUseId: 'tool-1', toolName: 'Read', args: {} }), ); state.entries.push({ kind: 'notice', level: 'error', text: 'Turn failed: provider_error' }); - state.steering = ['Keep going']; assert.equal( hydrateToolsWithStoredMessages(state, 'turn-1', [ @@ -403,7 +402,6 @@ describe('Maka Pi TUI transcript', () => { ); assert.deepEqual(tool?.input, { path: 'README.md' }); assert.deepEqual(tool?.result, { kind: 'text', text: 'README contents' }); - assert.deepEqual(state.steering, ['Keep going']); assert.equal(state.entries.at(-1)?.kind, 'notice'); }); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index ee7134593a..b369ebf7d4 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -1939,7 +1939,7 @@ describe('Maka Pi TUI runner', () => { await run; }); - test('Enter during a turn steers the running turn and shows a pending Steering line', async () => { + test('Enter during a turn submits steering without creating a pending queue row', async () => { const terminal = new FakeTerminal(); const driver = new SteeringTurnDriver(); const run = runMakaPiTui({ @@ -1958,10 +1958,13 @@ describe('Maka Pi TUI runner', () => { terminal.input('also handle Y'); terminal.input('\r'); - await waitFor(() => + await waitFor(() => driver.steered.length === 1); + await delay(0); + assert.deepEqual(driver.steered, ['also handle Y']); + assert.equal( plainTerminalOutput(terminal.screenOutput()).includes('Steering: also handle Y'), + false, ); - assert.deepEqual(driver.steered, ['also handle Y']); terminal.input('\x1b'); terminal.input('\x1b'); @@ -2082,9 +2085,9 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => terminal.progressStates.at(-1) === true); terminal.input('reword this later'); - terminal.input('\r'); // steer + terminal.input('\x1b\r'); // Alt+Enter queues a follow-up await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('Steering: reword this later'), + plainTerminalOutput(terminal.screenOutput()).includes('Queued: reword this later'), ); terminal.input('\x1b[1;3A'); // Alt+Up @@ -2092,9 +2095,7 @@ describe('Maka Pi TUI runner', () => { // The pending bar is cleared and the text is back in the editor. await waitFor(() => { const screen = plainTerminalOutput(terminal.screenOutput()); - return ( - !screen.includes('Steering: reword this later') && screen.includes('reword this later') - ); + return !screen.includes('Queued: reword this later') && screen.includes('reword this later'); }); terminal.input('\x1b'); @@ -2129,14 +2130,12 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => terminal.progressStates.at(-1) === true); terminal.input('reword this later'); - terminal.input('\r'); // steer — queued synchronously in the driver + terminal.input('\x1b\r'); // follow-up queued synchronously in the driver terminal.input('\x1b[1;3A'); // Alt+Up in the same tick, mirror still empty await waitFor(() => driver.retractCalls === 1); await waitFor(() => { const screen = plainTerminalOutput(terminal.screenOutput()); - return ( - screen.includes('reword this later') && !screen.includes('Steering: reword this later') - ); + return screen.includes('reword this later') && !screen.includes('Queued: reword this later'); }); terminal.input('\x1b'); @@ -2166,9 +2165,9 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => terminal.progressStates.at(-1) === true); terminal.input('unfinished idea'); - terminal.input('\r'); // steer + terminal.input('\x1b\r'); // queue a follow-up await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('Steering: unfinished idea'), + plainTerminalOutput(terminal.screenOutput()).includes('Queued: unfinished idea'), ); terminal.input('\x1b'); @@ -2178,7 +2177,7 @@ describe('Maka Pi TUI runner', () => { // Queue cleared from the pending bar; text preserved in the editor. await waitFor(() => { const screen = plainTerminalOutput(terminal.screenOutput()); - return !screen.includes('Steering: unfinished idea') && screen.includes('unfinished idea'); + return !screen.includes('Queued: unfinished idea') && screen.includes('unfinished idea'); }); terminal.input('\x03'); // clear the refilled draft @@ -2206,9 +2205,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('already consumed'); terminal.input('\r'); // steer - await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('Steering: already consumed'), - ); + await waitFor(() => driver.steered.includes('already consumed')); terminal.input('still queued'); terminal.input('\x1b\r'); // Alt+Enter queues a followup @@ -2216,8 +2213,8 @@ describe('Maka Pi TUI runner', () => { plainTerminalOutput(terminal.screenOutput()).includes('Queued: still queued'), ); - // The turn consumes the steering message at a step boundary; the CLI - // mirror has not seen a queue_update yet and still shows it. + // The turn consumes the steering message at a step boundary; only the + // future-turn follow-up remains retractable. driver.consumeSteering(); terminal.input('\x1b'); @@ -5539,9 +5536,7 @@ describe('Maka Pi TUI runner', () => { // text for the running turn, exactly like any other steered message. terminal.input('/skill:review'); terminal.input('\r'); - await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('Steering: /skill:review'), - ); + await waitFor(() => driver.steered.includes('/skill:review')); assert.deepEqual(driver.steered, ['/skill:review']); terminal.input('\x1b'); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 5a96690285..3f764eac1b 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -98,20 +98,8 @@ export interface MakaPiTranscriptState { renderGeometry: MakaPiRenderGeometry; /** Aggregated token usage for statusline display; reset on session switch. */ usage: MakaPiUsageSummary; - /** - * Read-only mirror of the runtime's authoritative pending queues, driven by - * `queue_update` events and enqueue results. Rendered as the pending bar above - * the editor; never the source of truth (the runtime owns that). - */ - steering: string[]; + /** Host-owned follow-ups that have not started their Turn yet. */ followup: string[]; - /** - * Messages whose enqueue hit the no-live-owner fallback while a turn was - * running (the begin window). CLI-owned, NOT a runtime mirror: the runner - * retries the original enqueue until it lands and flushes any remainder - * into the next turn at the turn boundary, so the text is never dropped. - * Rendered in the pending bar alongside the mirror. - */ /** Current non-durable provider retry progress for the activity strip. */ providerRetry?: ProviderRetryEvent; } @@ -214,7 +202,6 @@ export function createMakaPiTranscriptState(): MakaPiTranscriptState { expandAllThinking: false, renderGeometry: { entryFirstLine: undefined, viewportTop: 0 }, usage: { costUsd: 0, cacheHitInput: 0, cacheMissInput: 0 }, - steering: [], followup: [], }; } @@ -340,7 +327,6 @@ export function replaceTranscriptWithStoredMessages( state.renderGeometry.entryFirstLine = undefined; state.usage = { costUsd: 0, cacheHitInput: 0, cacheMissInput: 0 }; // Queues are per-active-run; a switched/reset session has none pending. - state.steering = []; state.followup = []; for (const msg of messages) { if (msg.type === 'token_usage') accumulateUsage(state.usage, msg); @@ -711,8 +697,8 @@ export function applyMakaSessionEventToTranscript( break; case 'queue_update': - // Authoritative snapshot from the runtime; mirror it for the pending bar. - state.steering = [...event.steering]; + // Steering is already a durable transcript message; only future-turn + // follow-ups belong in the pending bar. state.followup = [...event.followup]; break; @@ -1453,25 +1439,17 @@ function formatElapsedDuration(elapsedMs: number): string { } /** - * Pending-queue bar shown above the editor while messages are queued. Each - * steering message reads `Steering: ` (injected into the running turn at - * the next step boundary); each followup reads `Queued: ` (opens the next - * turn). A trailing hint reminds the user that alt+↑ takes them back to edit. - * Renders nothing when both queues are empty. + * Pending follow-ups shown above the editor before their Turn starts. A + * trailing hint reminds the user that alt+↑ takes them back to edit. */ export function renderMakaPiPendingQueue( state: MakaPiTranscriptState, width: number, platform: NodeJS.Platform = process.platform, ): string[] { - if (state.steering.length === 0 && state.followup.length === 0) return []; + if (state.followup.length === 0) return []; const safeWidth = Math.max(1, width); const lines: string[] = []; - for (const text of state.steering) { - lines.push( - fitLine(`${ansi.accent('Steering:')} ${ansi.dim(firstLinePreview(text))}`, safeWidth), - ); - } for (const text of state.followup) { lines.push(fitLine(`${ansi.dim('Queued:')} ${ansi.dim(firstLinePreview(text))}`, safeWidth)); } diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 409ea52492..77bc3d8aba 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -801,7 +801,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // lag a step-boundary consumption and would resurrect an already-consumed // steering message for a double execution. Clears the local mirror. const refillEditorFromQueues = (joined: string) => { - state.steering = []; state.followup = []; if (!joined) return; const draft = editor.getText(); From db176b1c6c8d007e18f767a2e4b9769619d2eb79 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 22:32:18 +0800 Subject: [PATCH 06/33] fix: make sent steering non-retractable Generated-by: Maka --- .../src/__tests__/message-coordinator.test.ts | 35 +++++++++++++---- .../src/server/message-coordinator.ts | 39 +++++++++++-------- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 42c84f38b4..a4449ba8a2 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -328,7 +328,7 @@ test('full snapshot preflight rejection leaves queue, receipt, residency, and pu { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-capacity' }, operationContext(), ); - fixture.coordinator.abandonRootReservation(ROOT); + completeActiveRoot(fixture); await fixture.coordinator.close(); }); @@ -359,7 +359,7 @@ test('persists a steering message before admitting it to the active Turn queue', { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-durable' }, operationContext(), ); - fixture.coordinator.abandonRootReservation(ROOT); + completeActiveRoot(fixture); await fixture.coordinator.close(); }); @@ -408,7 +408,7 @@ test('queue admission rejects content that cannot form a durable follow-up Turn' operationContext(), ); assert.equal(retracted.ok, true); - fixture.coordinator.abandonRootReservation(ROOT); + completeActiveRoot(fixture); await fixture.coordinator.close(); }); @@ -531,15 +531,16 @@ test('entry retract removes one queued entry, replays its receipt, and rejects s }, operationContext(), ); - assert.equal(steering.ok, true); - assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).steering, []); - assert.equal(fixture.liveResidencies(), 1); + assert.equal(steering.ok, false); + if (!steering.ok) assert.equal(steering.error.code, 'operation_conflict'); + assert.equal(fixture.coordinator.projection(ROOT.sessionId).steering.length, 1); + assert.equal(fixture.liveResidencies(), 2); await fixture.coordinator.handlers['queue.retract']( { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-entry' }, operationContext(), ); - fixture.coordinator.abandonRootReservation(ROOT); + completeActiveRoot(fixture); await fixture.coordinator.close(); }); @@ -1041,6 +1042,8 @@ test('submit mutation is visible before its receipt and concurrent retries share { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-submit-cut' }, operationContext(), ); + const cleanupLeases = owner.pull(); + owner.ack(cleanupLeases.map((lease) => lease.id)); owner.release(); const batch = fixture.coordinator.beginTerminalTransition(ROOT); fixture.coordinator.completeIdle(batch); @@ -1108,6 +1111,8 @@ test('retract mutation is visible while its receipt waits and preserves its exac { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-retract-cut' }, operationContext(), ); + const remaining = owner.pull(); + owner.ack(remaining.map((lease) => lease.id)); owner.release(); const batch = fixture.coordinator.beginTerminalTransition(ROOT); fixture.coordinator.completeIdle(batch); @@ -1789,6 +1794,8 @@ test('submit retries use keyed receipts and durable proof while old-Epoch rich c operationContext(), ); assert.equal(retracted.ok, true); + const cleanupLeases = owner.pull(); + owner.ack(cleanupLeases.map((lease) => lease.id)); owner.release(); const batch = fixture.coordinator.beginTerminalTransition(ROOT); fixture.coordinator.completeIdle(batch); @@ -2127,8 +2134,10 @@ test('canonical retry omits redundant display text and empty ordered refs', asyn ); assert.equal(retracted.ok, true); if (retracted.ok) { - assert.deepEqual(retracted.result.retracted[0]?.content, { text: 'same' }); + assert.deepEqual(retracted.result.retracted, []); } + const remaining = owner.pull(); + owner.ack(remaining.map((lease) => lease.id)); owner.release(); const batch = fixture.coordinator.beginTerminalTransition(ROOT); fixture.coordinator.completeIdle(batch); @@ -2362,6 +2371,16 @@ function memoryReceiptStore( }; } +function completeActiveRoot(fixture: ReturnType): void { + const owner = fixture.coordinator.bindRun(ROOT); + const leases = owner.pull(); + owner.ack(leases.map((lease) => lease.id)); + owner.release(); + const batch = fixture.coordinator.beginTerminalTransition(ROOT); + assert.equal(batch.sources.length, 0); + fixture.coordinator.completeIdle(batch); +} + function submit( fixture: ReturnType, messageId: string, diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 45282eaf12..5011866e6a 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -799,18 +799,18 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { if ( !retractionResultFits( state, - state.revision + (queuedEntryCount(state) > 0 ? 1 : 0), + state.revision + (state.followup.length > 0 ? 1 : 0), MESSAGE_OPERATION_RESULT_MAX_BYTES, ) ) { return failure('session_busy', 'Retract result exceeds protocol capacity'); } - const queued = [...state.steering, ...state.followup]; + const queued = [...state.followup]; const result = { queueRevision: state.revision + (queued.length > 0 ? 1 : 0), retracted: queued.map(retractedSnapshot), }; - const retracted = this.#retractQueued(state); + const retracted = this.#retractFollowups(state); if (retracted.length > 0) this.#mutated(state); if (!isDeepStrictEqual(result, { queueRevision: state.revision, retracted })) { throw new RuntimeMessageAuthorityInvariantError( @@ -981,8 +981,11 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } const queued = findQueuedEntry(state, input.entryId); if (!queued) { - if ([...state.inFlight.values()].some((entry) => entry.entryId === input.entryId)) { - return failure('operation_conflict', 'Message entry is already being delivered'); + if ( + state.steering.some((entry) => entry.entryId === input.entryId) || + [...state.inFlight.values()].some((entry) => entry.entryId === input.entryId) + ) { + return failure('operation_conflict', 'Sent steering messages cannot be retracted'); } return failure('not_found', 'Message queue entry does not exist'); } @@ -1577,7 +1580,8 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { ); } state.phase = 'closed'; - const retracted = this.#retractQueued(state); + const retracted = this.#retractFollowups(state); + this.#discardQueuedSteering(state); state.generation += 1; this.#mutated(state); const result = { queueRevision: state.revision, retracted }; @@ -1585,14 +1589,19 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return result; } - #retractQueued(state: SessionState): RetractedMessageSnapshot[] { - const entries = [...state.steering, ...state.followup]; - state.steering = []; + #retractFollowups(state: SessionState): RetractedMessageSnapshot[] { + const entries = state.followup; state.followup = []; for (const entry of entries) this.#releaseEntry(entry); return entries.map(retractedSnapshot); } + #discardQueuedSteering(state: SessionState): void { + const entries = state.steering; + state.steering = []; + for (const entry of entries) this.#releaseEntry(entry); + } + #commitTransition(state: SessionState): void { const transition = state.transition; if (!transition) throw new RuntimeMessageAuthorityInvariantError('Missing terminal transition'); @@ -1735,13 +1744,9 @@ function findQueuedEntry( state: SessionState, entryId: string, ): { readonly entry: LiveEntry; remove(): void } | undefined { - for (const queue of [state.steering, state.followup]) { - const index = queue.findIndex((entry) => entry.entryId === entryId); - const entry = index === -1 ? undefined : queue[index]; - if (!entry) continue; - return { entry, remove: () => queue.splice(index, 1) }; - } - return undefined; + const index = state.followup.findIndex((entry) => entry.entryId === entryId); + const entry = index === -1 ? undefined : state.followup[index]; + return entry ? { entry, remove: () => state.followup.splice(index, 1) } : undefined; } function relocateInlineReferences( @@ -1934,7 +1939,7 @@ function retractionResultFits( queueRevision: number, maxBytes: number, ): boolean { - const retracted = [...state.steering, ...state.followup].map(retractedSnapshot); + const retracted = state.followup.map(retractedSnapshot); return fitsEncodedByteLimit({ queueRevision, retracted }, maxBytes); } From ec78f1321f2e36652f14ddafdd60b1646a6809e2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 22:40:42 +0800 Subject: [PATCH 07/33] fix: preserve steering across terminal ownership Generated-by: Maka --- .../__tests__/execution-host-message.test.ts | 23 +++++++++++ .../src/server/root-turn-coordinator.ts | 16 ++++++++ packages/runtime/src/agent-run.ts | 41 ++++++++++--------- packages/runtime/src/runtime-kernel.ts | 28 +++++++++++++ packages/runtime/src/session-manager.ts | 10 +++++ 5 files changed, 99 insertions(+), 19 deletions(-) diff --git a/packages/runtime-host/src/__tests__/execution-host-message.test.ts b/packages/runtime-host/src/__tests__/execution-host-message.test.ts index 8c75c21e5a..1a32c9872c 100644 --- a/packages/runtime-host/src/__tests__/execution-host-message.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-message.test.ts @@ -311,6 +311,15 @@ test('interrupt atomically retracts queued followup, stops the exact run, and is content: { text: FAKE_ASK_USER_QUESTION_PROMPT }, }), ); + const steeringId = randomUUID(); + const steeringContent = { text: 'sent before explicit stop' }; + await second.request('turn.message.submit', { + originHostEpoch: host.hostEpoch, + sessionId: fixture.sessionId, + messageId: steeringId, + content: steeringContent, + placement: 'current_turn', + }); const followupId = randomUUID(); const followupContent = { text: 'must be withdrawn', @@ -359,6 +368,20 @@ test('interrupt atomically retracts queued followup, stops the exact run, and is await second.close(); await fixture.stopHost(host); + const ledger = await fixture.readTurn(turnId); + assert.deepEqual( + ledger.userMessages + .filter((message) => message.id === steeringId) + .map((message) => ({ + text: message.text, + steeringEventId: message.steeringEventId, + })), + [{ text: steeringContent.text, steeringEventId: steeringId }], + ); + assert.equal( + ledger.runtimeEvents.some((event) => event.refs?.providerEventId === steeringId), + false, + ); const chain = await fixture.readAdmissionChain(); assert.equal(chain.length, 1); assert.equal(chain[0]?.turnId, turnId); diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 790d8d4690..59e37f4169 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -156,6 +156,7 @@ interface ActiveRootTurn { residency: RuntimeHostResidency; stopRequested: boolean; messageTransitionCommitted: boolean; + initialUserMessagesMaterialized: boolean; } export type TurnStartOutcome = OperationOutcome<'turn.start'>; @@ -1919,6 +1920,19 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { if (unavailableReason) { return completedStart(operationUnavailable(unavailableReason)); } + const initialUserMessagesMaterialized = admission.sourceMessages.some( + (source) => source.disposition === 'steering', + ); + if (initialUserMessagesMaterialized) { + await this.manager.materializeRootSourceMessages({ + sessionId: input.sessionId, + turnId: input.turnId, + messages: admission.sourceMessages.map((source) => ({ + messageId: source.messageId, + content: source.content, + })), + }); + } const { runId } = admission; const existingRun = await this.readRunIfPresent(input.sessionId, runId); if (replacing && existingRun) { @@ -2011,6 +2025,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { residency, stopRequested: false, messageTransitionCommitted: false, + initialUserMessagesMaterialized, }; if (replacing && this.#executions.get(input.sessionId) !== replacing) { residency.release(); @@ -2108,6 +2123,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { { runId: active.runId, userMessageId: active.userMessageId ?? undefined, + recordInitialUserMessage: !active.initialUserMessagesMaterialized, durability: 'required', onRunStarted: async (startedRunId) => { if (startedRunId !== active.runId) { diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 0a43b14069..2930fd3f73 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -161,6 +161,7 @@ export interface AgentRunInput { commitContinuationStart?: (startedAt: number) => Promise<{ startEventId: string; created: true }>; hooks: AgentRunHooks; recordSessionMessages?: boolean; + recordInitialUserMessage?: boolean; invocationId?: string; /** Pre-resolved snapshot used by continuations; normal turns derive it from header + input. */ effectiveOrchestration?: EffectiveOrchestration; @@ -631,25 +632,27 @@ export class AgentRun { const userMessageId = this.input.userMessageId ?? this.input.newId(); const userMessageTs = this.input.now(); initialRuntimeEventId = userMessageId; - const userMsg: UserMessage = { - type: 'user', - id: userMessageId, - turnId: this.turnId, - ts: userMessageTs, - text: this.input.userInput.text, - ...(this.input.userInput.displayText !== undefined - ? { displayText: this.input.userInput.displayText } - : {}), - ...(this.input.userInput.attachments - ? { attachments: this.input.userInput.attachments } - : {}), - ...(this.input.userInput.quotes ? { quotes: this.input.userInput.quotes } : {}), - ...(this.input.userInput.inlineReferences - ? { inlineReferences: this.input.userInput.inlineReferences } - : {}), - ...(this.input.userInput.origin ? { origin: this.input.userInput.origin } : {}), - }; - await this.input.store.appendMessage(this.sessionId, userMsg); + if (this.input.recordInitialUserMessage !== false) { + const userMsg: UserMessage = { + type: 'user', + id: userMessageId, + turnId: this.turnId, + ts: userMessageTs, + text: this.input.userInput.text, + ...(this.input.userInput.displayText !== undefined + ? { displayText: this.input.userInput.displayText } + : {}), + ...(this.input.userInput.attachments + ? { attachments: this.input.userInput.attachments } + : {}), + ...(this.input.userInput.quotes ? { quotes: this.input.userInput.quotes } : {}), + ...(this.input.userInput.inlineReferences + ? { inlineReferences: this.input.userInput.inlineReferences } + : {}), + ...(this.input.userInput.origin ? { origin: this.input.userInput.origin } : {}), + }; + await this.input.store.appendMessage(this.sessionId, userMsg); + } await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage); this.lastTs = userMessageTs; } else { diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 4498229b0a..ce1a3e310c 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -176,6 +176,11 @@ export interface RuntimeKernelLike { messageId: string; content: MessageContent; }): Promise; + materializeRootSourceMessages?(input: { + sessionId: string; + turnId: string; + messages: readonly { messageId: string; content: MessageContent }[]; + }): Promise; /** Queue a user message for mid-turn injection at the next step boundary. */ steer(sessionId: string, text: string): QueueEnqueueOutcome; /** Queue a user message to open the turn after the current one finishes. */ @@ -224,6 +229,7 @@ export class RuntimeContextCompactError extends Error { export interface TurnStartOptions { runId?: string; userMessageId?: string; + recordInitialUserMessage?: boolean; durability?: AgentRunDurability; /** * Resolve turn admission after this Session has registered a pending start @@ -715,6 +721,7 @@ export class RuntimeKernel implements RuntimeKernelLike { userInput: input, runId: options.runId, userMessageId: options.userMessageId, + recordInitialUserMessage: options.recordInitialUserMessage, durability: options.durability, store: this.deps.store, runStore: this.deps.runStore, @@ -2592,6 +2599,27 @@ export class RuntimeKernel implements RuntimeKernelLike { }); } + async materializeRootSourceMessages(input: { + sessionId: string; + turnId: string; + messages: readonly { messageId: string; content: MessageContent }[]; + }): Promise { + const existingIds = new Set( + (await this.deps.store.readMessages(input.sessionId)).map((message) => message.id), + ); + for (const message of input.messages) { + if (existingIds.has(message.messageId)) continue; + await this.deps.store.appendMessage(input.sessionId, { + type: 'user', + id: message.messageId, + turnId: input.turnId, + ts: this.deps.now(), + ...structuredClone(message.content), + }); + existingIds.add(message.messageId); + } + } + hasActiveRuns(sessionId: string): boolean { return this.backendGenerationsFor(sessionId).some((active) => active.activeRuns.size > 0); } diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index a07bf3f205..61b94f7f75 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -4825,6 +4825,16 @@ export class SessionManager { return commit.call(this.runtimeKernel, input); } + materializeRootSourceMessages(input: { + sessionId: string; + turnId: string; + messages: readonly { messageId: string; content: MessageContent }[]; + }): Promise { + const materialize = this.runtimeKernel.materializeRootSourceMessages; + if (!materialize) throw new Error('Runtime root message materialization is unavailable'); + return materialize.call(this.runtimeKernel, input); + } + steer(sessionId: string, text: string): QueueEnqueueOutcome { return this.runtimeKernel.steer(sessionId, text); } From 5b160f9ee853055f28a4054bbdffd78c34baf953 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 22:45:04 +0800 Subject: [PATCH 08/33] test(cli): align queue fake with steering semantics Generated-by: Maka --- .../cli/src/__tests__/pi-tui-runner.test.ts | 18 +++--------------- packages/cli/src/pi-tui-layout.ts | 2 +- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index b369ebf7d4..d44cc2518b 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -1969,7 +1969,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('\x1b'); terminal.input('\x1b'); await waitFor(() => terminal.progressStates.at(-1) === false); - // Interrupt refills the editor with the cleared queue; clear it before /exit. + // Sent steering stays in the transcript and is not restored to the editor. terminal.input('\x03'); terminal.input('/exit'); terminal.input('\r'); @@ -6511,17 +6511,9 @@ class SteeringTurnDriver implements MakaSessionDriver { return { kind: 'queued' }; } - async takePendingFollowup(): Promise { - if (this.followup.length === 0) return null; - const joined = this.followup.join('\n\n'); - this.followup = []; - return joined; - } - async retractQueued(): Promise { this.retractCalls += 1; - const joined = [...this.steering, ...this.followup].join('\n\n'); - this.steering = []; + const joined = this.followup.join('\n\n'); this.followup = []; this.emitQueueUpdate(); return joined; @@ -6563,11 +6555,7 @@ class SteeringTurnDriver implements MakaSessionDriver { } } -/** - * A driver whose enqueues hit the no-live-owner `fallback` outcome for the - * first N calls (configurable, default forever) while the turn parks until - * `endTurn()` — the begin-window shape behind review finding N2. - */ +/** A driver whose active turn parks until stop releases it. */ class SlowStopDriver implements MakaSessionDriver { stopCalls = 0; readonly prompts: string[] = []; diff --git a/packages/cli/src/pi-tui-layout.ts b/packages/cli/src/pi-tui-layout.ts index 3d31fbe689..b25fb1cb88 100644 --- a/packages/cli/src/pi-tui-layout.ts +++ b/packages/cli/src/pi-tui-layout.ts @@ -109,7 +109,7 @@ export class MakaActivityStripComponent implements Component { } } -/** The pending-queue bar (Steering:/Queued:) rendered just above the editor. */ +/** The pending follow-up queue rendered just above the editor. */ export class MakaPendingQueueComponent implements Component { constructor(private readonly state: MakaPiTranscriptState) {} From f8678aed3b673e655b2d81e1cc10d59ea15b18cb Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 22:55:31 +0800 Subject: [PATCH 09/33] fix: preserve embedded steering event identity Generated-by: Maka --- packages/core/src/backend-types.ts | 2 ++ packages/runtime-host/src/server/message-coordinator.ts | 1 + packages/runtime/src/ai-sdk-backend.ts | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 8990812e8b..abd3f44256 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -152,6 +152,8 @@ export interface SteeringLease { messageId: string; /** Ephemeral delivery lease identity used only for ack/nack settlement. */ id: string; + /** Runtime event identity fixed when admission already materialized the message. */ + eventId?: string; content: MessageContent; /** Digest of the canonical user submission before host-side preparation. */ submittedContentDigest?: `sha256:${string}`; diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 5011866e6a..49e9ba28d7 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -1496,6 +1496,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return { id: leaseId, messageId: entry.messageId, + eventId: entry.messageId, content: normalizeMessageContent(entry.modelContent), submittedContentDigest: messageContentDigest(entry.content), }; diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 8d23cb274c..f048cd605d 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -4514,7 +4514,7 @@ export class AiSdkBackend implements AgentBackend { } // Materialize provider content before publishing the durable event. // After consumption there must be no fallible gap before ack/injection. - const eventId = lease.messageId; + const eventId = lease.eventId ?? this.newId(); const providerContent = await this.appendImageParts( scope.imageBudget, buildSteeringEnvelope(formatTextWithInlineRefs(lease.content.text, lease.content)), From 38c58d2eb66004b54fdd8cf9fd7401139e02259a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 23:03:41 +0800 Subject: [PATCH 10/33] test: settle durable steering projection cleanup Generated-by: Maka --- .../__tests__/canonical-session-projection.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index 27587456ae..e56a191eae 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -145,11 +145,14 @@ test('projects the canonical root lifecycle and the attachment queue from real S terminalEventId: terminal.id, }); - await messages.handlers['queue.retract']( - { originHostEpoch: 'epoch-1', sessionId: session.id, retractId: 'cleanup' }, - operationContext(), - ); - messages.abandonRootReservation({ sessionId: session.id, turnId: 'turn-1', runId: 'run-1' }); + const identity = { sessionId: session.id, turnId: 'turn-1', runId: 'run-1' }; + const owner = messages.bindRun(identity); + const leases = owner.pull(); + owner.ack(leases.map((lease) => lease.id)); + owner.release(); + const batch = messages.beginTerminalTransition(identity); + assert.equal(batch.sources.length, 0); + messages.completeIdle(batch); await messages.close(); }); }); From eb16eb2bc0bceedd21f67a44114a31cca20a450d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 00:28:41 +0800 Subject: [PATCH 11/33] fix(runtime-host): recover persisted steering admission Generated-by: Maka --- .../canonical-session-projection.test.ts | 1 + .../src/__tests__/goal-root-authority.test.ts | 1 + .../src/__tests__/message-coordinator.test.ts | 62 +++++++++++++++++ .../__tests__/root-turn-coordinator.test.ts | 2 + .../src/server/execution-composition.ts | 17 +++++ .../src/server/message-coordinator.ts | 67 ++++++++++++++++++- 6 files changed, 147 insertions(+), 3 deletions(-) diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index e56a191eae..cc6d39b4d5 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -545,6 +545,7 @@ function createMessages( durableProof: { readRootTurnSourceMessageReceipt: (requestedSessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(requestedSessionId, messageId), + readSteeringAdmission: async () => undefined, readImmutableSteeringMessageProof: (requestedSessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(requestedSessionId, messageId), }, diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index 874fc987e9..48425481c0 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -575,6 +575,7 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro durableProof: { readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), + readSteeringAdmission: async () => undefined, readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index a4449ba8a2..ea1d50d91c 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -384,6 +384,52 @@ test('does not expose steering when its durable admission fails', async () => { await fixture.coordinator.close(); }); +test('re-admits a durable steering message after admission persistence loses its queue commit', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + const delay = fixture.delaySteeringAdmission(new Error('host stopped after persistence')); + + const interrupted = submit( + fixture, + 'recoverable-steering', + 'recover after restart', + 'current_turn', + ); + await delay.started.promise; + delay.release.resolve(undefined); + await assert.rejects(interrupted, /host stopped after persistence/); + + const restarted = fixture.restart('epoch-2'); + restarted.reserveRootTurn(ROOT); + const recovered = await restarted.handlers['turn.message.submit']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + messageId: 'recoverable-steering', + content: { text: 'recover after restart' }, + placement: 'current_turn', + }, + operationContext(), + ); + + assert.deepEqual(recovered, { + ok: true, + result: { disposition: 'steering', queueRevision: 1 }, + }); + const owner = restarted.bindRun(ROOT); + const leases = owner.pull(); + assert.deepEqual( + leases.map((lease) => ({ messageId: lease.messageId, content: lease.content })), + [{ messageId: 'recoverable-steering', content: { text: 'recover after restart' } }], + ); + assert.equal(fixture.steeringAdmissions.length, 1); + owner.ack([leases[0]!.id]); + owner.release(); + const batch = restarted.beginTerminalTransition(ROOT); + restarted.completeIdle(batch); + await restarted.close(); +}); + test('queue admission rejects content that cannot form a durable follow-up Turn', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); @@ -2174,6 +2220,10 @@ function createFixture( messageId: string; content: MessageContent; }> = []; + const durableSteeringAdmissions = new Map< + string, + { sessionId: string; turnId: string; messageId: string; content: MessageContent } + >(); let steeringAdmissionDelay: | { readonly started: ReturnType>; @@ -2238,6 +2288,12 @@ function createFixture( prepareMessage: (input) => prepareMessage(input), commitSteeringAdmission: async (input) => { steeringAdmissions.push(structuredClone(input)); + durableSteeringAdmissions.set(input.messageId, { + sessionId: input.sessionId, + turnId: input.turnId, + messageId: input.messageId, + content: structuredClone(input.content), + }); const delay = steeringAdmissionDelay; if (!delay) return; steeringAdmissionDelay = undefined; @@ -2258,6 +2314,8 @@ function createFixture( root, durableProof: { readRootTurnSourceMessageReceipt: async (_sessionId, messageId) => receipts.get(messageId), + readSteeringAdmission: async (_sessionId, messageId) => + durableSteeringAdmissions.get(messageId), readImmutableSteeringMessageProof: async (_sessionId, messageId) => { const event = events.find( (candidate) => @@ -2305,6 +2363,10 @@ function createFixture( coordinator = new HostMessageCoordinator(options); return { coordinator, + restart: (hostEpoch: string) => { + coordinator = new HostMessageCoordinator({ ...options, hostEpoch }); + return coordinator; + }, setRootState: (state: HostMessageRootState) => { rootState = state; }, diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 67148dc04f..93a7ce2e27 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -2175,6 +2175,7 @@ test('hosted linked child roots share admission, message, terminal, and stop aut durableProof: { readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), + readSteeringAdmission: async () => undefined, readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, @@ -4801,6 +4802,7 @@ async function createFailureFixture(options: { durableProof: { readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), + readSteeringAdmission: async () => undefined, readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 7a6a348f7b..983b221930 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -23,6 +23,7 @@ import { NO_REAL_CONNECTION_CODE, } from '@maka/core/connection-error-copy'; import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; +import { normalizeMessageContent } from '@maka/core/events'; import { generalizedErrorMessage } from '@maka/core/redaction'; import { emptyPlanSessionState } from '@maka/core/plan'; import type { PermissionMode } from '@maka/core/permission'; @@ -464,6 +465,22 @@ export async function createExecutionRuntimeHostComposition( durableProof: { readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), + readSteeringAdmission: async (sessionId, messageId) => { + const message = (await stores.sessionStore.readMessages(sessionId)).find( + (candidate) => + candidate.type === 'user' && + candidate.id === messageId && + candidate.steeringEventId === messageId, + ); + return message?.type === 'user' + ? { + sessionId, + turnId: message.turnId, + messageId, + content: normalizeMessageContent(message), + } + : undefined; + }, readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 49e9ba28d7..3e082b4e0e 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -157,12 +157,23 @@ export interface HostMessageRootPort { ): Promise; } -/** Existing durable facts used only to prove an earlier Host Epoch's submit disposition. */ +/** Durable facts used to prove or recover an earlier Host Epoch's submit disposition. */ +export interface DurableSteeringAdmission { + readonly sessionId: string; + readonly turnId: string; + readonly messageId: string; + readonly content: MessageContent; +} + export interface HostMessageDurableProofReader { readRootTurnSourceMessageReceipt( sessionId: string, messageId: string, ): Promise; + readSteeringAdmission( + sessionId: string, + messageId: string, + ): Promise; readImmutableSteeringMessageProof( sessionId: string, messageId: string, @@ -582,12 +593,27 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { : failure('operation_conflict', 'Message identity has a different payload'); } } + const durableAdmission = isCurrentEpoch + ? undefined + : await this.#durableProof.readSteeringAdmission(input.sessionId, input.messageId); + if (this.#failStopped) { + return failure('host_draining', 'Runtime Host message authority has failed'); + } + if ( + durableAdmission && + (input.placement !== 'current_turn' || + durableAdmission.sessionId !== input.sessionId || + durableAdmission.messageId !== input.messageId || + !messageContentsEqual(durableAdmission.content, payload.content)) + ) { + return failure('operation_conflict', 'Durable steering admission has a different payload'); + } const durableProof = await this.#queryDurableSubmitProof(input, payload); if (this.#failStopped) { return failure('host_draining', 'Runtime Host message authority has failed'); } if (durableProof) return durableProof; - if (!isCurrentEpoch) { + if (!isCurrentEpoch && !durableAdmission) { return failure( 'outcome_unknown', 'Message disposition cannot be proven in this Host Epoch', @@ -614,6 +640,15 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { if (this.#failStopped) { return failure('host_draining', 'Runtime Host message authority has failed'); } + if ( + durableAdmission && + (rootState.kind !== 'active' || rootState.turnId !== durableAdmission.turnId) + ) { + return failure( + 'outcome_unknown', + 'Durable steering admission no longer has its active Turn owner', + ); + } if (rootState.kind === 'idle') { const existingState = this.#sessions.get(input.sessionId); if (existingState && hasLiveMessageState(existingState)) { @@ -660,6 +695,32 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { 'Root state does not match message reservation', ); } + if (durableAdmission) { + const existing = allLiveEntries(state).find( + (entry) => entry.messageId === durableAdmission.messageId, + ); + if (existing) { + if (existing.disposition !== 'steering') { + throw new RuntimeMessageAuthorityInvariantError( + 'Durable steering admission collided with a non-steering entry', + ); + } + const result = { disposition: 'steering', queueRevision: state.revision } as const; + try { + await this.#commitReceipt( + 'submit', + input.sessionId, + input.messageId, + payload, + result, + ); + } catch (error) { + this.#failStop(); + throw error; + } + return success(result); + } + } if (allLiveEntries(state).length >= MESSAGE_QUEUE_MAX_ENTRIES) { return failure('session_busy', 'Message queue capacity is full'); } @@ -741,7 +802,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { continue; } const result = { disposition, queueRevision: candidateRevision + 1 } as const; - if (disposition === 'steering') { + if (disposition === 'steering' && !durableAdmission) { await this.#root.commitSteeringAdmission({ sessionId: input.sessionId, turnId: rootState.turnId, From 57c538e93d7b8b884533b345e5c1e2eaa7ef125f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 00:29:02 +0800 Subject: [PATCH 12/33] fix(cli): refresh live transcript on durable advance Generated-by: Maka --- .../cli/src/__tests__/pi-tui-runner.test.ts | 39 ++++++++++++++-- .../runtime-host-session-driver.test.ts | 46 ++++++++++++++++++- packages/cli/src/pi-tui-runner.ts | 23 ++++++++-- .../cli/src/runtime-host-session-channel.ts | 5 ++ 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index d44cc2518b..37cb08c2b9 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -52,6 +52,7 @@ import type { MakaSessionRewindResult, MakaSessionSwitchOptions, MakaSessionSwitchResult, + MakaTranscriptReplacementReason, RewindTarget, SessionResumeAvailability, } from '../session-driver.js'; @@ -1959,7 +1960,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('also handle Y'); terminal.input('\r'); await waitFor(() => driver.steered.length === 1); - await delay(0); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('also handle Y')); assert.deepEqual(driver.steered, ['also handle Y']); assert.equal( plainTerminalOutput(terminal.screenOutput()).includes('Steering: also handle Y'), @@ -2222,10 +2223,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => terminal.progressStates.at(-1) === false); // Only the followup that was still queued comes back into the editor; the // consumed steering text must not be resurrected from the stale mirror. - await waitFor(() => { - const screen = plainTerminalOutput(terminal.screenOutput()); - return screen.includes('still queued') && !screen.includes('already consumed'); - }); + await waitFor(() => editorInputText(terminal) === 'still queued'); terminal.input('\x03'); // clear the refilled draft terminal.input('/exit'); @@ -6435,6 +6433,14 @@ class SteeringTurnDriver implements MakaSessionDriver { readonly steered: string[] = []; readonly queuedMessages: string[] = []; readonly turnOrchestrations: Array = []; + readonly transcriptListeners = new Set< + ( + sessionId: string, + turnId: string, + messages: StoredMessage[], + reason: MakaTranscriptReplacementReason, + ) => void + >(); retractCalls = 0; rewindTargets: RewindTarget[] = []; private steering: string[] = []; @@ -6497,10 +6503,33 @@ class SteeringTurnDriver implements MakaSessionDriver { yield { type: 'complete', id: 'event-complete', turnId, ts: 2, stopReason: 'user_stop' }; } + subscribeTranscriptReplacements( + listener: ( + sessionId: string, + turnId: string, + messages: StoredMessage[], + reason: MakaTranscriptReplacementReason, + ) => void, + ): () => void { + this.transcriptListeners.add(listener); + return () => this.transcriptListeners.delete(listener); + } + async steer(text: string): Promise { this.steered.push(text); this.steering.push(text); this.emitQueueUpdate(); + const message: Extract = { + type: 'user', + id: `steering-${this.steered.length}`, + turnId: 'turn-1', + ts: this.steered.length, + text, + steeringEventId: `steering-${this.steered.length}`, + }; + for (const listener of this.transcriptListeners) { + listener(this.getSessionId(), 'turn-1', [message], 'reconcile'); + } return { kind: 'queued' }; } diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 1b9dc7abb1..bdc92590a1 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -44,7 +44,11 @@ import { createRuntimeHostMakaSessionDriver, type RuntimeHostMakaSessionDriverInput, } from '../runtime-host-session-driver.js'; -import { SkillInvocationBlockedError, type MakaAttachedSessionTurn } from '../session-driver.js'; +import { + SkillInvocationBlockedError, + type MakaAttachedSessionTurn, + type MakaTranscriptReplacementReason, +} from '../session-driver.js'; import { WAIT_BUDGET_MS } from './tui-terminal-mock.js'; describe('Runtime Host Maka Session driver', () => { @@ -551,6 +555,46 @@ describe('Runtime Host Maka Session driver', () => { }); }); + test('publishes a durable transcript advance while the active turn is still running', async () => { + const attached = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const durableMessages = [userMessage('turn-1', 'Steer now')]; + const refresh = new FakeSubscription( + continuitySnapshot(), + Promise.resolve(durableMessages), + 'subscription-2', + ); + const connection = new FakeConnection([attached, refresh]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + const replacement = deferred<{ + messages: readonly StoredMessage[]; + reason: MakaTranscriptReplacementReason; + }>(); + driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => + replacement.resolve({ messages, reason }), + ); + + attached.push({ + kind: 'subscription.transcript_advanced', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + throughSequence: 0, + }); + + await waitFor(() => connection.openedSubscriptions === 2); + assert.deepEqual(await replacement.promise, { + messages: durableMessages, + reason: 'reconcile', + }); + }); + test('delivers completed thinking while a later step remains active', async () => { const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); const connection = new FakeConnection([subscription]); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 77bc3d8aba..79787cb9c5 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -281,11 +281,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const tui = new TuiMainScreen(terminal); const state = createMakaPiTranscriptState(); let transcriptLastUsedModel: string | undefined; - const rememberTranscriptModel = (messages: readonly StoredMessage[]): void => { + let transcriptMessageIds = new Set(); + const rememberTranscript = (messages: readonly StoredMessage[]): void => { transcriptLastUsedModel = latestAssistantModelId(messages); + transcriptMessageIds = new Set(messages.map((message) => message.id)); }; const replaceTranscript = (messages: readonly StoredMessage[]): void => { - rememberTranscriptModel(messages); + rememberTranscript(messages); replaceTranscriptWithStoredMessages(state, messages); }; let cwd = input.cwd; @@ -540,8 +542,21 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); return; } - rememberTranscriptModel(messages); - if (hydrateToolsWithStoredMessages(state, turnId, messages)) { + const newSteeringMessages = messages.filter( + (message): message is Extract => + message.type === 'user' && + message.turnId === turnId && + message.steeringEventId !== undefined && + !transcriptMessageIds.has(message.id), + ); + rememberTranscript(messages); + for (const message of newSteeringMessages) { + appendUserPrompt(state, message.displayText ?? message.text); + } + if ( + newSteeringMessages.length > 0 || + hydrateToolsWithStoredMessages(state, turnId, messages) + ) { shellRunElapsedTicker.sync(); requestRender(); } diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 01714f225f..d07666d5b9 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -572,6 +572,11 @@ export class RuntimeHostSessionChannel { } #accept(frame: SubscriptionFrame): void { + if (frame.kind === 'subscription.transcript_advanced') { + const turnId = this.snapshot.rootTurn?.turnId; + if (turnId) this.#onTranscriptSettlement(turnId); + return; + } if (frame.kind === 'subscription.session_domain_changed') { if (frame.domain === 'runtime_resource') { for (const resource of frame.resources) { From df0b251e1c16dea2af3ec1d62fcd24815048040b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 02:52:59 +0800 Subject: [PATCH 13/33] fix(runtime-host): recover durable steering automatically Generated-by: Maka --- .../canonical-session-projection.test.ts | 1 + .../src/__tests__/goal-root-authority.test.ts | 1 + .../src/__tests__/message-coordinator.test.ts | 57 +++++++++---- .../__tests__/root-turn-coordinator.test.ts | 2 + .../src/server/execution-composition.ts | 15 ++++ .../src/server/message-coordinator.ts | 80 +++++++++++++++++++ .../src/server/root-turn-coordinator.ts | 1 + 7 files changed, 143 insertions(+), 14 deletions(-) diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index cc6d39b4d5..924edf1a00 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -546,6 +546,7 @@ function createMessages( readRootTurnSourceMessageReceipt: (requestedSessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(requestedSessionId, messageId), readSteeringAdmission: async () => undefined, + listSteeringAdmissions: async () => [], readImmutableSteeringMessageProof: (requestedSessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(requestedSessionId, messageId), }, diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index 48425481c0..aa3d08a8d6 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -576,6 +576,7 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), readSteeringAdmission: async () => undefined, + listSteeringAdmissions: async () => [], readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index ea1d50d91c..5f70761eea 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -401,21 +401,15 @@ test('re-admits a durable steering message after admission persistence loses its const restarted = fixture.restart('epoch-2'); restarted.reserveRootTurn(ROOT); - const recovered = await restarted.handlers['turn.message.submit']( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - messageId: 'recoverable-steering', - content: { text: 'recover after restart' }, - placement: 'current_turn', - }, - operationContext(), - ); + await restarted.recoverRootTurn(ROOT); - assert.deepEqual(recovered, { - ok: true, - result: { disposition: 'steering', queueRevision: 1 }, - }); + assert.deepEqual( + restarted.projection(ROOT.sessionId).steering.map((entry) => ({ + messageId: entry.messageId, + content: entry.content, + })), + [{ messageId: 'recoverable-steering', content: { text: 'recover after restart' } }], + ); const owner = restarted.bindRun(ROOT); const leases = owner.pull(); assert.deepEqual( @@ -894,6 +888,37 @@ test('entry promote moves a follow-up into the steering queue', async () => { assert.equal(fixture.liveResidencies(), 0); }); +test('entry promote durably admits the message before making it non-retractable', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'promoted-followup', 'send this now', 'next_turn'); + const delay = fixture.delaySteeringAdmission(new Error('durable promotion failed')); + + const promotion = fixture.coordinator.handlers['queue.entry.promote']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: 'id-1', + promoteId: 'promote-durable', + }, + operationContext(), + ); + await delay.started.promise; + assert.deepEqual( + fixture.coordinator.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), + ['promoted-followup'], + ); + assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).steering, []); + delay.release.resolve(undefined); + await assert.rejects(promotion, /durable promotion failed/); + + assert.deepEqual( + fixture.coordinator.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), + ['promoted-followup'], + ); + assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).steering, []); +}); + test('entry promote requires an active Turn', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); @@ -2316,6 +2341,10 @@ function createFixture( readRootTurnSourceMessageReceipt: async (_sessionId, messageId) => receipts.get(messageId), readSteeringAdmission: async (_sessionId, messageId) => durableSteeringAdmissions.get(messageId), + listSteeringAdmissions: async (sessionId, turnId) => + [...durableSteeringAdmissions.values()].filter( + (admission) => admission.sessionId === sessionId && admission.turnId === turnId, + ), readImmutableSteeringMessageProof: async (_sessionId, messageId) => { const event = events.find( (candidate) => diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 93a7ce2e27..f9bc838a82 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -2176,6 +2176,7 @@ test('hosted linked child roots share admission, message, terminal, and stop aut readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), readSteeringAdmission: async () => undefined, + listSteeringAdmissions: async () => [], readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, @@ -4803,6 +4804,7 @@ async function createFailureFixture(options: { readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), readSteeringAdmission: async () => undefined, + listSteeringAdmissions: async () => [], readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 983b221930..3cb5f67973 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -481,6 +481,21 @@ export async function createExecutionRuntimeHostComposition( } : undefined; }, + listSteeringAdmissions: async (sessionId, turnId) => + (await stores.sessionStore.readMessages(sessionId)).flatMap((message) => + message.type === 'user' && + message.turnId === turnId && + message.steeringEventId === message.id + ? [ + { + sessionId, + turnId, + messageId: message.id, + content: normalizeMessageContent(message), + }, + ] + : [], + ), readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 3e082b4e0e..7eb5236549 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -174,6 +174,10 @@ export interface HostMessageDurableProofReader { sessionId: string, messageId: string, ): Promise; + listSteeringAdmissions( + sessionId: string, + turnId: string, + ): Promise; readImmutableSteeringMessageProof( sessionId: string, messageId: string, @@ -421,6 +425,75 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { state.phase = 'open'; } + async recoverRootTurn(identity: RuntimeMessageRunIdentity): Promise { + const state = this.#requireState(identity.sessionId); + if (!state.reservedRoot || !sameRun(state.reservedRoot, identity) || state.run) { + throw new RuntimeMessageAuthorityInvariantError( + 'Steering recovery requires the exact reserved root Turn', + ); + } + const admissions = await this.#durableProof.listSteeringAdmissions( + identity.sessionId, + identity.turnId, + ); + let changed = false; + const liveMessageIds = new Set(allLiveEntries(state).map((entry) => entry.messageId)); + for (const admission of admissions) { + if ( + admission.sessionId !== identity.sessionId || + admission.turnId !== identity.turnId || + liveMessageIds.has(admission.messageId) + ) { + continue; + } + const consumed = await this.#durableProof.readImmutableSteeringMessageProof( + identity.sessionId, + admission.messageId, + ); + if (consumed) continue; + if (allLiveEntries(state).length >= MESSAGE_QUEUE_MAX_ENTRIES) { + throw new RuntimeMessageAuthorityInvariantError( + 'Durable steering recovery exceeds message queue capacity', + ); + } + const initiatingConnectionId = `recovery-${this.#hostEpoch}`; + const prepared = await this.#root.prepareMessage({ + sessionId: identity.sessionId, + turnId: identity.turnId, + content: admission.content, + placement: 'current_turn', + initiatingConnectionId, + }); + if (prepared.kind === 'rejected') { + throw new RuntimeMessageAuthorityInvariantError( + `Durable steering recovery could not prepare ${admission.messageId}: ${prepared.error}`, + ); + } + const entryId = this.#createId(); + if (!isEntityId(entryId)) { + throw new RuntimeMessageAuthorityInvariantError( + 'Recovered message entry identity is not encodable', + ); + } + const residency = this.#acquireResidency(); + state.steering.push({ + entryId, + messageId: admission.messageId, + content: normalizeMessageContent(admission.content), + modelContent: normalizeMessageContent(prepared.content), + initiatingConnectionId, + placement: 'current_turn', + disposition: 'steering', + generation: state.generation, + residency, + state: 'queued', + }); + liveMessageIds.add(admission.messageId); + changed = true; + } + if (changed) this.#mutated(state); + } + abandonRootReservation(identity: RuntimeMessageRunIdentity): void { const state = this.#requireState(identity.sessionId); if (!state.reservedRoot || !sameRun(state.reservedRoot, identity) || state.run) { @@ -1103,6 +1176,13 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } return failure('not_found', 'Message queue entry does not exist'); } + await this.#root.commitSteeringAdmission({ + sessionId: input.sessionId, + turnId: rootState.turnId, + runId: rootState.runId, + messageId: entry.messageId, + content: entry.content, + }); state.followup.splice(index, 1); state.steering.push({ ...entry, placement: 'current_turn', disposition: 'steering' }); this.#mutated(state); diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 59e37f4169..a029710352 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1990,6 +1990,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { try { this.messages.reserveRootTurn(messageIdentity); messageReserved = true; + await this.messages.recoverRootTurn(messageIdentity); await this.continuity.holdTerminalPublication( input.sessionId, input.turnId, From 1c1c362c310c6b11488724b71c741ce01dbdf4f9 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 04:37:40 +0800 Subject: [PATCH 14/33] fix(runtime-host): persist steering admission lifecycle Persist a Host-owned pending steering admission before exposing it to the active Run. Recover unresolved admissions after interrupted Runs are terminalized, and settle them when provider consumption, successor root admission, or an intentional stop makes replay unnecessary. Add a production startup regression for the crash between durable admission and the in-memory queue commit. Generated-by: Maka --- .../canonical-session-projection.test.ts | 1 - .../__tests__/execution-composition.test.ts | 104 ++++++++ .../src/__tests__/goal-root-authority.test.ts | 1 - .../src/__tests__/message-coordinator.test.ts | 132 +++++----- .../__tests__/root-turn-coordinator.test.ts | 2 - .../src/server/execution-composition.ts | 20 +- .../src/server/message-coordinator.ts | 238 +++++++++++++----- .../src/server/root-turn-coordinator.ts | 90 ++++++- packages/runtime/src/runtime-kernel.ts | 46 +++- packages/runtime/src/session-manager.ts | 16 ++ .../sqlite-core-execution-store.test.ts | 29 +++ packages/storage/src/execution-stores.ts | 6 + packages/storage/src/message-receipt-store.ts | 174 +++++++++++++ .../src/sqlite-core-execution-schema.ts | 18 +- 14 files changed, 713 insertions(+), 164 deletions(-) diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index 924edf1a00..cc6d39b4d5 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -546,7 +546,6 @@ function createMessages( readRootTurnSourceMessageReceipt: (requestedSessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(requestedSessionId, messageId), readSteeringAdmission: async () => undefined, - listSteeringAdmissions: async () => [], readImmutableSteeringMessageProof: (requestedSessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(requestedSessionId, messageId), }, diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 67180218e0..b8bc2a4b0d 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -259,6 +259,110 @@ test('production recovery preserves legacy Automation history and closes an orph }); }); +test('production startup recovers steering admitted before an interrupted Run lost its queue', async () => { + await withCompositionRoot(async ({ root, owner }) => { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const session = await stores.sessionStore.create({ + cwd: root, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const admitted = await stores.agentRunStore.admitRootTurn({ + sessionId: session.id, + turnId: 'interrupted-turn', + proposedRunId: 'interrupted-run', + proposedUserMessageId: 'initial-message', + execution: { kind: 'external_message' }, + previousRootTurnId: null, + normalizedInput: { text: 'initial request' }, + sourceMessages: [], + admittedAt: 10, + }); + assert.equal(admitted.kind, 'admitted'); + await stores.sessionStore.appendMessage(session.id, { + type: 'user', + id: 'initial-message', + turnId: 'interrupted-turn', + ts: 10, + text: 'initial request', + }); + await stores.agentRunStore.createRun({ + runId: 'interrupted-run', + invocationId: 'interrupted-run', + sessionId: session.id, + turnId: 'interrupted-turn', + status: 'created', + backendKind: 'fake', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + cwd: root, + permissionMode: 'ask', + createdAt: 10, + updatedAt: 10, + }); + await stores.agentRunStore.appendEvent(session.id, 'interrupted-run', { + type: 'run_started', + id: 'interrupted-run-started', + sessionId: session.id, + turnId: 'interrupted-turn', + runId: 'interrupted-run', + ts: 11, + }); + await stores.agentRunStore.updateRun(session.id, 'interrupted-run', { + status: 'running', + updatedAt: 11, + }); + await stores.messageReceiptStore.commitPendingSteering({ + sessionId: session.id, + turnId: 'interrupted-turn', + runId: 'interrupted-run', + messageId: 'admitted-steering', + content: { text: 'durable steering' }, + modelContent: { text: 'durable steering' }, + initiatingConnectionId: 'crashed-client', + admittedAt: 12, + }); + await stores.sessionStore.appendMessage(session.id, { + type: 'user', + id: 'admitted-steering', + turnId: 'interrupted-turn', + ts: 12, + text: 'durable steering', + steeringEventId: 'admitted-steering', + }); + + const composition = await createExecutionRuntimeHostComposition( + compositionContext(owner), + {}, + { primaryBackendFactory: (context) => new FakeBackend(context) }, + ); + try { + await composition.recover(); + const admissions = await stores.agentRunStore.listRootTurnAdmissionsForRecovery(session.id); + assert.equal(admissions.length, 2); + const [source] = admissions[1]?.sourceMessages ?? []; + assert.equal(source?.messageId, 'admitted-steering'); + assert.deepEqual(source?.content, { text: 'durable steering' }); + assert.equal(source?.placement, 'current_turn'); + assert.equal(source?.disposition, 'steering'); + assert.equal((await stores.messageReceiptStore.listPendingSteering()).length, 0); + assert.equal( + (await stores.agentRunStore.readRun(session.id, 'interrupted-run')).status, + 'failed', + ); + assert.deepEqual( + (await stores.sessionStore.readMessages(session.id)) + .filter((message) => message.type === 'user') + .map((message) => message.id), + ['initial-message', 'admitted-steering'], + ); + } finally { + await composition.close(); + } + }); +}); + test('composition drain preserves usage admission until active Runtime work settles', async () => { await withCompositionRoot(async ({ owner }) => { const composition = await createExecutionRuntimeHostComposition(compositionContext(owner)); diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index aa3d08a8d6..48425481c0 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -576,7 +576,6 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), readSteeringAdmission: async () => undefined, - listSteeringAdmissions: async () => [], readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 5f70761eea..0d281754dd 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -344,15 +344,16 @@ test('persists a steering message before admitting it to the active Turn queue', ); assert.equal(accepted.ok && accepted.result.disposition, 'steering'); - assert.deepEqual(fixture.steeringAdmissions, [ - { - sessionId: ROOT.sessionId, - turnId: ROOT.turnId, - runId: ROOT.runId, - messageId: 'durable-steering', - content: { text: 'persist before queueing' }, - }, - ]); + assert.equal(fixture.steeringAdmissions.length, 1); + const [{ admittedAt, ...admission }] = fixture.steeringAdmissions; + assert.equal(typeof admittedAt, 'number'); + assert.deepEqual(admission, { + sessionId: ROOT.sessionId, + turnId: ROOT.turnId, + runId: ROOT.runId, + messageId: 'durable-steering', + content: { text: 'persist before queueing' }, + }); assert.equal(fixture.coordinator.projection(ROOT.sessionId).steering.length, 1); await fixture.coordinator.handlers['queue.retract']( @@ -363,6 +364,25 @@ test('persists a steering message before admitting it to the active Turn queue', await fixture.coordinator.close(); }); +test('terminal transition settles steering after durable provider consumption', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + const owner = fixture.coordinator.bindRun(ROOT); + assert.equal((await submit(fixture, 'consumed-steering', 'consume me', 'current_turn')).ok, true); + assert.equal(fixture.pendingSteeringCount(), 1); + + const [lease] = owner.pull(); + assert.ok(lease); + fixture.events.push(steeringEvent('consumed-steering', { text: 'consume me' })); + owner.ack([lease.id]); + owner.release(); + await fixture.coordinator.prepareTerminalTransition(ROOT); + + assert.equal(fixture.pendingSteeringCount(), 0); + fixture.coordinator.completeIdle(fixture.coordinator.beginTerminalTransition(ROOT)); + await fixture.coordinator.close(); +}); + test('does not expose steering when its durable admission fails', async () => { const changedSessions: string[] = []; const fixture = createFixture((sessionId) => changedSessions.push(sessionId)); @@ -384,46 +404,6 @@ test('does not expose steering when its durable admission fails', async () => { await fixture.coordinator.close(); }); -test('re-admits a durable steering message after admission persistence loses its queue commit', async () => { - const fixture = createFixture(); - fixture.coordinator.reserveRootTurn(ROOT); - const delay = fixture.delaySteeringAdmission(new Error('host stopped after persistence')); - - const interrupted = submit( - fixture, - 'recoverable-steering', - 'recover after restart', - 'current_turn', - ); - await delay.started.promise; - delay.release.resolve(undefined); - await assert.rejects(interrupted, /host stopped after persistence/); - - const restarted = fixture.restart('epoch-2'); - restarted.reserveRootTurn(ROOT); - await restarted.recoverRootTurn(ROOT); - - assert.deepEqual( - restarted.projection(ROOT.sessionId).steering.map((entry) => ({ - messageId: entry.messageId, - content: entry.content, - })), - [{ messageId: 'recoverable-steering', content: { text: 'recover after restart' } }], - ); - const owner = restarted.bindRun(ROOT); - const leases = owner.pull(); - assert.deepEqual( - leases.map((lease) => ({ messageId: lease.messageId, content: lease.content })), - [{ messageId: 'recoverable-steering', content: { text: 'recover after restart' } }], - ); - assert.equal(fixture.steeringAdmissions.length, 1); - owner.ack([leases[0]!.id]); - owner.release(); - const batch = restarted.beginTerminalTransition(ROOT); - restarted.completeIdle(batch); - await restarted.close(); -}); - test('queue admission rejects content that cannot form a durable follow-up Turn', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); @@ -1709,8 +1689,11 @@ test('administrative drain preserves accepted entries until the terminal stop fe fixture.coordinator.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), ['follow-drain'], ); + assert.equal(fixture.pendingSteeringCount(), 1); owner.release(); + await fixture.coordinator.prepareTerminalTransition(ROOT); + assert.equal(fixture.pendingSteeringCount(), 0); const batch = fixture.coordinator.beginTerminalTransition(ROOT); assert.deepEqual(batch.sources, []); assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).steering, []); @@ -2244,6 +2227,7 @@ function createFixture( runId: string; messageId: string; content: MessageContent; + admittedAt: number; }> = []; const durableSteeringAdmissions = new Map< string, @@ -2334,6 +2318,20 @@ function createFixture( }; }, }; + const receiptStore = memoryReceiptStore( + operationReceipts, + async (operation, operationId) => { + const delay = receiptDelays.get(`${operation}:${operationId}`); + if (!delay) return; + receiptDelays.delete(`${operation}:${operationId}`); + delay.started.resolve(undefined); + await delay.release.promise; + if (delay.error) throw delay.error; + }, + () => { + receiptReads += 1; + }, + ); const options: HostMessageCoordinatorOptions = { hostEpoch: 'epoch-1', root, @@ -2341,10 +2339,6 @@ function createFixture( readRootTurnSourceMessageReceipt: async (_sessionId, messageId) => receipts.get(messageId), readSteeringAdmission: async (_sessionId, messageId) => durableSteeringAdmissions.get(messageId), - listSteeringAdmissions: async (sessionId, turnId) => - [...durableSteeringAdmissions.values()].filter( - (admission) => admission.sessionId === sessionId && admission.turnId === turnId, - ), readImmutableSteeringMessageProof: async (_sessionId, messageId) => { const event = events.find( (candidate) => @@ -2356,20 +2350,7 @@ function createFixture( return event ? { event } : undefined; }, }, - receipts: memoryReceiptStore( - operationReceipts, - async (operation, operationId) => { - const delay = receiptDelays.get(`${operation}:${operationId}`); - if (!delay) return; - receiptDelays.delete(`${operation}:${operationId}`); - delay.started.resolve(undefined); - await delay.release.promise; - if (delay.error) throw delay.error; - }, - () => { - receiptReads += 1; - }, - ), + receipts: receiptStore, sessionAdmission: new SessionAdmissionGate(), acquireResidency: () => { liveResidencies += 1; @@ -2406,6 +2387,7 @@ function createFixture( events, receipts, steeringAdmissions, + pendingSteeringCount: () => receiptStore.pendingSteeringCount(), delaySteeringAdmission: (error?: Error) => { const delay = { started: deferred(), release: deferred(), error }; steeringAdmissionDelay = delay; @@ -2441,9 +2423,10 @@ function memoryReceiptStore( receipts: Map, beforeCommit?: (operation: string, operationId: string) => Promise, onRead?: () => void, -): MessageReceiptStore { +): MessageReceiptStore & { pendingSteeringCount(): number } { const key = (hostEpoch: string, operation: string, sessionId: string, operationId: string) => `${hostEpoch}:${operation}:${sessionId}:${operationId}`; + const pending = new Map[0]>(); return { beginHostEpoch: async () => undefined, read: async (hostEpoch, operation, sessionId, operationId) => { @@ -2459,6 +2442,19 @@ function memoryReceiptStore( receipts.set(receiptKey, snapshot); return snapshot; }, + commitPendingSteering: async (admission) => { + const admissionKey = `${admission.sessionId}:${admission.messageId}`; + const existing = pending.get(admissionKey); + if (existing) return existing; + const snapshot = structuredClone(admission); + pending.set(admissionKey, snapshot); + return snapshot; + }, + listPendingSteering: async () => [...pending.values()], + settlePendingSteering: async (sessionId, messageIds) => { + for (const messageId of messageIds) pending.delete(`${sessionId}:${messageId}`); + }, + pendingSteeringCount: () => pending.size, }; } diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index f9bc838a82..93a7ce2e27 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -2176,7 +2176,6 @@ test('hosted linked child roots share admission, message, terminal, and stop aut readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), readSteeringAdmission: async () => undefined, - listSteeringAdmissions: async () => [], readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, @@ -4804,7 +4803,6 @@ async function createFailureFixture(options: { readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), readSteeringAdmission: async () => undefined, - listSteeringAdmissions: async () => [], readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 3cb5f67973..68d6ebade0 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -453,6 +453,10 @@ export async function createExecutionRuntimeHostComposition( requireRootCoordinator(rootCoordinator).claimStopFence(input, commitQueueFence, admission), startFromMessage: (input, admission) => requireRootCoordinator(rootCoordinator).startFromMessage(input, admission), + startRecoveredSteering: (input, admission) => + requireRootCoordinator(rootCoordinator).startRecoveredSteering(input, admission), + materializeSteeringAdmissions: (admissions) => + requireRootCoordinator(rootCoordinator).materializeSteeringAdmissions(admissions), prepareMessage: (input) => requireRootCoordinator(rootCoordinator).prepareMessage(input), commitSteeringAdmission: (input) => requireRootCoordinator(rootCoordinator).commitSteeringAdmission(input), @@ -481,21 +485,6 @@ export async function createExecutionRuntimeHostComposition( } : undefined; }, - listSteeringAdmissions: async (sessionId, turnId) => - (await stores.sessionStore.readMessages(sessionId)).flatMap((message) => - message.type === 'user' && - message.turnId === turnId && - message.steeringEventId === message.id - ? [ - { - sessionId, - turnId, - messageId: message.id, - content: normalizeMessageContent(message), - }, - ] - : [], - ), readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), }, @@ -1503,6 +1492,7 @@ export async function createExecutionRuntimeHostComposition( ), ); await coordinator.recover(); + await messages.recoverPendingAfterHostRestart(); rootRecoveryCompleted = true; }, }, diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 7eb5236549..cbdd8b13c3 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -38,6 +38,7 @@ import { type ImmutableSteeringMessageProof, type MessageReceiptOperation, type MessageReceiptStore, + type PendingSteeringAdmission, type RootTurnSourceMessage, type RootTurnSourceMessageReceipt, } from '@maka/storage/execution-stores'; @@ -106,6 +107,14 @@ export interface HostMessageStartInput { readonly initiatingConnectionId: string; } +export interface HostMessageRecoveryBatch { + readonly sessionId: string; + readonly content: MessageContent; + readonly submittedContent: MessageContent; + readonly sources: readonly RootTurnSourceMessage[]; + readonly initiatingConnectionId: string; +} + export interface HostMessagePreparationInput { readonly sessionId: string; readonly turnId: string; @@ -137,6 +146,11 @@ export interface HostMessageRootPort { input: HostMessageStartInput, admission: SessionAdmissionLease, ): Promise<{ readonly turnId: string } | { readonly error: string }>; + startRecoveredSteering?( + input: HostMessageRecoveryBatch, + admission: SessionAdmissionLease, + ): Promise<{ readonly turnId: string } | { readonly error: string }>; + materializeSteeringAdmissions?(admissions: readonly PendingSteeringAdmission[]): Promise; prepareMessage( input: HostMessagePreparationInput, ): Promise< @@ -149,6 +163,7 @@ export interface HostMessageRootPort { readonly runId: string; readonly messageId: string; readonly content: MessageContent; + readonly admittedAt: number; }): Promise; claimStop( input: Omit, @@ -174,10 +189,6 @@ export interface HostMessageDurableProofReader { sessionId: string, messageId: string, ): Promise; - listSteeringAdmissions( - sessionId: string, - turnId: string, - ): Promise; readImmutableSteeringMessageProof( sessionId: string, messageId: string, @@ -281,6 +292,7 @@ interface SessionState { reservedRoot?: RuntimeMessageRunIdentity; run?: BoundRun; transition?: TerminalTransition; + steeringDiscardPreparedFor?: RuntimeMessageRunIdentity; stopFence?: { readonly identity: RuntimeMessageRunIdentity; readonly result: QueueFenceResult; @@ -425,73 +437,74 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { state.phase = 'open'; } - async recoverRootTurn(identity: RuntimeMessageRunIdentity): Promise { - const state = this.#requireState(identity.sessionId); - if (!state.reservedRoot || !sameRun(state.reservedRoot, identity) || state.run) { - throw new RuntimeMessageAuthorityInvariantError( - 'Steering recovery requires the exact reserved root Turn', - ); - } - const admissions = await this.#durableProof.listSteeringAdmissions( - identity.sessionId, - identity.turnId, - ); - let changed = false; - const liveMessageIds = new Set(allLiveEntries(state).map((entry) => entry.messageId)); - for (const admission of admissions) { - if ( - admission.sessionId !== identity.sessionId || - admission.turnId !== identity.turnId || - liveMessageIds.has(admission.messageId) - ) { - continue; - } - const consumed = await this.#durableProof.readImmutableSteeringMessageProof( - identity.sessionId, - admission.messageId, - ); - if (consumed) continue; - if (allLiveEntries(state).length >= MESSAGE_QUEUE_MAX_ENTRIES) { - throw new RuntimeMessageAuthorityInvariantError( - 'Durable steering recovery exceeds message queue capacity', - ); - } - const initiatingConnectionId = `recovery-${this.#hostEpoch}`; - const prepared = await this.#root.prepareMessage({ - sessionId: identity.sessionId, - turnId: identity.turnId, - content: admission.content, - placement: 'current_turn', - initiatingConnectionId, - }); - if (prepared.kind === 'rejected') { - throw new RuntimeMessageAuthorityInvariantError( - `Durable steering recovery could not prepare ${admission.messageId}: ${prepared.error}`, + async recoverPendingAfterHostRestart(): Promise { + const bySession = new Map(); + for (const admission of await this.#receipts.listPendingSteering()) { + const admissions = bySession.get(admission.sessionId); + if (admissions) admissions.push(admission); + else bySession.set(admission.sessionId, [admission]); + } + for (const [sessionId, durable] of bySession) { + await this.#sessionAdmission.run(sessionId, async (admissionLease) => { + const pending: PendingSteeringAdmission[] = []; + const settled: string[] = []; + for (const candidate of durable) { + const source = await this.#durableProof.readRootTurnSourceMessageReceipt( + sessionId, + candidate.messageId, + ); + const consumed = source + ? undefined + : await this.#durableProof.readImmutableSteeringMessageProof( + sessionId, + candidate.messageId, + ); + if (source || consumed) settled.push(candidate.messageId); + else pending.push(candidate); + } + if (settled.length > 0) { + await this.#receipts.settlePendingSteering(sessionId, settled); + } + if (pending.length === 0) return; + const header = await this.#root.readSessionHeader(sessionId); + if (!header || header.isArchived || header.unavailableReason) { + throw new RuntimeMessageAuthorityInvariantError( + 'Pending steering recovery found an unavailable Session', + ); + } + if ((await this.#root.readRootState(sessionId)).kind !== 'idle') { + throw new RuntimeMessageAuthorityInvariantError( + 'Pending steering recovery requires an idle root after interrupted Run recovery', + ); + } + if (!this.#root.materializeSteeringAdmissions || !this.#root.startRecoveredSteering) { + throw new RuntimeMessageAuthorityInvariantError( + 'Pending steering recovery authority is unavailable', + ); + } + await this.#root.materializeSteeringAdmissions(pending); + const sources = pending.map(pendingSteeringSource); + const started = await this.#root.startRecoveredSteering( + { + sessionId, + content: aggregateMessageContent(pending.map((entry) => entry.modelContent)), + submittedContent: aggregateMessageContent(pending.map((entry) => entry.content)), + sources, + initiatingConnectionId: pending[0]!.initiatingConnectionId, + }, + admissionLease, ); - } - const entryId = this.#createId(); - if (!isEntityId(entryId)) { - throw new RuntimeMessageAuthorityInvariantError( - 'Recovered message entry identity is not encodable', + if ('error' in started) { + throw new RuntimeMessageAuthorityInvariantError( + `Unable to recover pending steering: ${started.error}`, + ); + } + await this.#receipts.settlePendingSteering( + sessionId, + pending.map((entry) => entry.messageId), ); - } - const residency = this.#acquireResidency(); - state.steering.push({ - entryId, - messageId: admission.messageId, - content: normalizeMessageContent(admission.content), - modelContent: normalizeMessageContent(prepared.content), - initiatingConnectionId, - placement: 'current_turn', - disposition: 'steering', - generation: state.generation, - residency, - state: 'queued', }); - liveMessageIds.add(admission.messageId); - changed = true; } - if (changed) this.#mutated(state); } abandonRootReservation(identity: RuntimeMessageRunIdentity): void { @@ -511,6 +524,22 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { this.#maybeReclaim(identity.sessionId, state); } + async prepareTerminalTransition(identity: RuntimeMessageRunIdentity): Promise { + const consumed: string[] = []; + for (const pending of await this.#receipts.listPendingSteering()) { + if (pending.sessionId !== identity.sessionId) continue; + const proof = await this.#durableProof.readImmutableSteeringMessageProof( + identity.sessionId, + pending.messageId, + ); + if (proof) consumed.push(pending.messageId); + } + if (consumed.length > 0) { + await this.#receipts.settlePendingSteering(identity.sessionId, consumed); + } + if (this.#draining) await this.prepareStopFence(identity); + } + beginTerminalTransition(identity: RuntimeMessageRunIdentity): RootFollowupBatch { const state = this.#requireState(identity.sessionId); const run = state.run; @@ -565,6 +594,13 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { }; } + async settleAdmittedRootSources(batch: RootFollowupBatch): Promise { + await this.#receipts.settlePendingSteering( + batch.sessionId, + batch.sources.map((source) => source.messageId), + ); + } + commitNextRoot(batch: RootFollowupBatch, identity: RuntimeMessageRunIdentity): void { const state = this.#requireTransition(batch); if (identity.sessionId !== batch.sessionId) { @@ -594,6 +630,27 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { this.#draining = true; } + async prepareStopFence(identity: RuntimeMessageRunIdentity): Promise { + const state = this.#sessions.get(identity.sessionId); + // A root handoff can durably replace or release this identity before a concurrent + // administrative Stop reaches the Session lane. The authoritative fence + // commit still rejects a genuine mismatch if the Stop disposition needs it. + if (!state?.reservedRoot || !sameRun(state.reservedRoot, identity)) return; + if (state.steeringDiscardPreparedFor) { + if (!sameRun(state.steeringDiscardPreparedFor, identity)) { + throw new RuntimeMessageAuthorityInvariantError( + 'Steering discard preparation belongs to another root Turn', + ); + } + return; + } + await this.#receipts.settlePendingSteering( + identity.sessionId, + [...state.steering, ...state.inFlight.values()].map((entry) => entry.messageId), + ); + state.steeringDiscardPreparedFor = { ...identity }; + } + commitStopFence(identity: RuntimeMessageRunIdentity): QueueFenceResult { return this.#commitQueueFence(identity); } @@ -876,12 +933,23 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } const result = { disposition, queueRevision: candidateRevision + 1 } as const; if (disposition === 'steering' && !durableAdmission) { - await this.#root.commitSteeringAdmission({ + const pending = await this.#receipts.commitPendingSteering({ sessionId: input.sessionId, turnId: rootState.turnId, runId: rootState.runId, messageId: input.messageId, content: payload.content, + modelContent: prepared.content, + initiatingConnectionId, + admittedAt: Date.now(), + }); + await this.#root.commitSteeringAdmission({ + sessionId: pending.sessionId, + turnId: pending.turnId, + runId: pending.runId, + messageId: pending.messageId, + content: pending.content, + admittedAt: pending.admittedAt, }); } const residency = this.#acquireResidency(); @@ -1176,12 +1244,23 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } return failure('not_found', 'Message queue entry does not exist'); } - await this.#root.commitSteeringAdmission({ + const pending = await this.#receipts.commitPendingSteering({ sessionId: input.sessionId, turnId: rootState.turnId, runId: rootState.runId, messageId: entry.messageId, content: entry.content, + modelContent: entry.modelContent, + initiatingConnectionId: entry.initiatingConnectionId, + admittedAt: Date.now(), + }); + await this.#root.commitSteeringAdmission({ + sessionId: pending.sessionId, + turnId: pending.turnId, + runId: pending.runId, + messageId: pending.messageId, + content: pending.content, + admittedAt: pending.admittedAt, }); state.followup.splice(index, 1); state.steering.push({ ...entry, placement: 'current_turn', disposition: 'steering' }); @@ -1407,6 +1486,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { deferred.resolve(result); return { kind: 'receipt' as const, result: deferred.promise }; } + await this.prepareStopFence(rootState); let fence: QueueFenceResult | undefined; const stopFence = await this.#root.claimStopFence( { sessionId: input.sessionId, turnId: input.turnId, runId: input.runId }, @@ -1716,6 +1796,15 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { 'Stop fence does not match the reserved root Turn', ); } + if ( + !this.#failStopped && + (state.steering.length !== 0 || state.inFlight.size !== 0) && + (!state.steeringDiscardPreparedFor || !sameRun(state.steeringDiscardPreparedFor, identity)) + ) { + throw new RuntimeMessageAuthorityInvariantError( + 'Stop fence cannot discard steering before durable settlement', + ); + } if (!interruptResultFits(this.#project(state), identity)) { throw new RuntimeMessageAuthorityInvariantError( 'Stop fence interrupt result exceeds protocol capacity', @@ -1756,6 +1845,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { state.followup.splice(0, transition.entries.length); state.transition = undefined; state.reservedRoot = undefined; + state.steeringDiscardPreparedFor = undefined; state.stopFence = undefined; } @@ -2002,6 +2092,16 @@ function sourceFromEntry(entry: LiveEntry): RootFollowupSource { }; } +function pendingSteeringSource(entry: PendingSteeringAdmission): RootTurnSourceMessage { + return { + messageId: entry.messageId, + content: normalizeMessageContent(entry.modelContent), + submittedContentDigest: messageContentDigest(entry.content), + placement: 'current_turn', + disposition: 'steering', + }; +} + function queuedSnapshot(entry: LiveEntry): QueuedMessageSnapshot { return { entryId: entry.entryId, diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index a029710352..4d5ad3481a 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -63,6 +63,7 @@ import { isSessionNotFoundError, normalizeRootTurnAdmissionPayload, type ExecutionStoresWriter, + type PendingSteeringAdmission, type RootTurnAdmission, } from '@maka/storage/execution-stores'; import type { @@ -79,6 +80,7 @@ import type { HostInteractionCoordinator } from './interaction-coordinator.js'; import { type HostMessageRootState, type HostMessagePreparationInput, + type HostMessageRecoveryBatch, type HostMessageSessionHeader, type HostMessageStartInput, type HostMessageStopClaim, @@ -872,14 +874,15 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { } = {}, ): Promise { return this.runCommand(async () => { - const declared = await this.sessionAdmission.run(identity.sessionId, (lease) => - this.declareStopFence( + const declared = await this.sessionAdmission.run(identity.sessionId, async (lease) => { + await this.messages.prepareStopFence(identity); + return this.declareStopFence( identity, () => this.messages.commitStopFence(identity), lease, input, - ), - ); + ); + }); await declared?.deliverStop(); await declared?.active.startSettled.promise; const disposition = await this.sessionAdmission.run(identity.sessionId, (lease) => @@ -904,7 +907,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { } = {}, ): Promise { return this.runCommand(async () => { - const declared = await this.sessionAdmission.run(sessionId, (lease) => { + const declared = await this.sessionAdmission.run(sessionId, async (lease) => { const active = this.#executions.get(sessionId); if (!active) return undefined; const identity = { @@ -912,6 +915,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { turnId: active.turnId, runId: active.runId, }; + await this.messages.prepareStopFence(identity); return this.declareStopFence( identity, () => this.messages.commitStopFence(identity), @@ -1086,12 +1090,85 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { }); } + startRecoveredSteering( + input: HostMessageRecoveryBatch, + admissionLease: SessionAdmissionLease, + ): Promise<{ readonly turnId: string } | { readonly error: string }> { + return this.runCommand(async () => { + if (this.#executions.has(input.sessionId)) { + throw new RuntimeMessageAuthorityInvariantError( + 'Pending steering recovery attempted to replace a live root Turn', + ); + } + const reservation = this.reserveRootTurn(input.sessionId); + if (!reservation) return { error: 'Another root Turn is being admitted' }; + try { + const header = await this.stores.sessionStore.readHeaderSnapshot(input.sessionId); + const unavailableReason = runtimeHostExternalTurnUnavailableReason(header); + if (unavailableReason) return { error: unavailableReason }; + await this.clientCapabilities?.bindConfirmedFollowup( + input.sessionId, + input.initiatingConnectionId, + ); + if (!this.beginRootAdmission(reservation)) { + return { error: 'Root Turn reservation is no longer current' }; + } + await this.prepareFreshAgentGraphEpoch(header); + const turnId = randomUUID(); + const admitted = await this.rootAdmissionOwner.admitRootTurn({ + sessionId: input.sessionId, + turnId, + proposedRunId: randomUUID(), + proposedUserMessageId: randomUUID(), + execution: { + kind: 'external_message', + inputDigest: messageContentDigest(input.submittedContent), + }, + normalizedInput: input.content, + sourceMessages: input.sources, + admittedAt: Date.now(), + }); + if (admitted.kind !== 'admitted') { + throw new RuntimeMessageAuthorityInvariantError( + 'Recovered steering root Turn identity already existed', + ); + } + const disposition = await this.prepareAdmittedTurn( + { + sessionId: input.sessionId, + turnId, + content: admitted.admission.normalizedInput, + }, + admitted.admission, + this.acquireRecoveryResidency, + admissionLease, + undefined, + undefined, + reservation, + ); + if (disposition.kind !== 'await_start') { + throw new RuntimeMessageAuthorityInvariantError( + 'Recovered steering root Turn did not reserve execution', + ); + } + return { turnId }; + } finally { + this.releaseRootReservation(reservation); + } + }); + } + + materializeSteeringAdmissions(admissions: readonly PendingSteeringAdmission[]): Promise { + return this.runCommand(() => this.manager.materializeSteeringAdmissions(admissions)); + } + commitSteeringAdmission(input: { readonly sessionId: string; readonly turnId: string; readonly runId: string; readonly messageId: string; readonly content: MessageContent; + readonly admittedAt: number; }): Promise { return this.runCommand(() => this.manager.commitSteeringAdmission(input)); } @@ -1990,7 +2067,6 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { try { this.messages.reserveRootTurn(messageIdentity); messageReserved = true; - await this.messages.recoverRootTurn(messageIdentity); await this.continuity.holdTerminalPublication( input.sessionId, input.turnId, @@ -2280,6 +2356,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { runId: active.runId, }; await this.interactions.assertTerminalFence(identity, lease); + await this.messages.prepareTerminalTransition(identity); const batch = this.messages.beginTerminalTransition(identity); await this.continuity.publishTerminalProjection( sessionId, @@ -2363,6 +2440,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { 'Fresh follow-up root Turn did not reserve execution', ); } + await this.messages.settleAdmittedRootSources(batch); } private async deliverRuntimeStopIntent( diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index ce1a3e310c..f1a5460b0c 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -175,7 +175,18 @@ export interface RuntimeKernelLike { runId: string; messageId: string; content: MessageContent; + admittedAt?: number; }): Promise; + materializeSteeringAdmissions?( + admissions: readonly { + sessionId: string; + turnId: string; + runId: string; + messageId: string; + content: MessageContent; + admittedAt: number; + }[], + ): Promise; materializeRootSourceMessages?(input: { sessionId: string; turnId: string; @@ -2585,6 +2596,7 @@ export class RuntimeKernel implements RuntimeKernelLike { runId: string; messageId: string; content: MessageContent; + admittedAt?: number; }): Promise { if (!this.hasActiveRun(input.sessionId, input.runId, input.turnId)) { throw new Error('Steering admission no longer matches the active root Turn'); @@ -2593,12 +2605,44 @@ export class RuntimeKernel implements RuntimeKernelLike { type: 'user', id: input.messageId, turnId: input.turnId, - ts: this.deps.now(), + ts: input.admittedAt ?? this.deps.now(), ...structuredClone(input.content), steeringEventId: input.messageId, }); } + async materializeSteeringAdmissions( + admissions: readonly { + sessionId: string; + turnId: string; + runId: string; + messageId: string; + content: MessageContent; + admittedAt: number; + }[], + ): Promise { + const idsBySession = new Map>(); + for (const admission of admissions) { + let existingIds = idsBySession.get(admission.sessionId); + if (!existingIds) { + existingIds = new Set( + (await this.deps.store.readMessages(admission.sessionId)).map((message) => message.id), + ); + idsBySession.set(admission.sessionId, existingIds); + } + if (existingIds.has(admission.messageId)) continue; + await this.deps.store.appendMessage(admission.sessionId, { + type: 'user', + id: admission.messageId, + turnId: admission.turnId, + ts: admission.admittedAt, + ...structuredClone(admission.content), + steeringEventId: admission.messageId, + }); + existingIds.add(admission.messageId); + } + } + async materializeRootSourceMessages(input: { sessionId: string; turnId: string; diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 61b94f7f75..7aabfb9bca 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -4819,12 +4819,28 @@ export class SessionManager { runId: string; messageId: string; content: MessageContent; + admittedAt?: number; }): Promise { const commit = this.runtimeKernel.commitSteeringAdmission; if (!commit) throw new Error('Runtime steering admission authority is unavailable'); return commit.call(this.runtimeKernel, input); } + materializeSteeringAdmissions( + admissions: readonly { + sessionId: string; + turnId: string; + runId: string; + messageId: string; + content: MessageContent; + admittedAt: number; + }[], + ): Promise { + const materialize = this.runtimeKernel.materializeSteeringAdmissions; + if (!materialize) throw new Error('Runtime steering materialization is unavailable'); + return materialize.call(this.runtimeKernel, admissions); + } + materializeRootSourceMessages(input: { sessionId: string; turnId: string; diff --git a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts index 5f0e590fdc..52dabc63cc 100644 --- a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts @@ -392,6 +392,35 @@ describe('SQLite core execution stores', () => { }); }); + test('persists pending steering across Host Epochs until it is settled', async () => { + await withRoot(async (root) => { + const admission = { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content: { text: 'submitted' }, + modelContent: { text: 'prepared' }, + initiatingConnectionId: 'connection-1', + admittedAt: 123, + } as const; + const store = createSqliteMessageReceiptStore(root); + await store.beginHostEpoch('epoch-1'); + assert.deepEqual(await store.commitPendingSteering(admission), admission); + store.close(); + + const reopened = createSqliteMessageReceiptStore(root); + try { + await reopened.beginHostEpoch('epoch-2'); + assert.deepEqual(await reopened.listPendingSteering(), [admission]); + await reopened.settlePendingSteering('session-1', ['message-1']); + assert.deepEqual(await reopened.listPendingSteering(), []); + } finally { + reopened.close(); + } + }); + }); + test('persists interaction request and outcome', async () => { await withRoot(async (root) => { const capability = trackControlDirectory( diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index ea8e49bcda..5b07c697e2 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -118,6 +118,7 @@ export type { MessageOperationReceipt, MessageReceiptOperation, MessageReceiptStore, + PendingSteeringAdmission, } from './message-receipt-store.js'; export type { ProbeSessionRemovalResult, @@ -555,6 +556,11 @@ async function createExecutionStoresForWrite messageReceiptStore.commit(hostEpoch, operation, sessionId, operationId, receipt), ), + commitPendingSteering: (admission) => + run(() => messageReceiptStore.commitPendingSteering(admission)), + listPendingSteering: () => run(() => messageReceiptStore.listPendingSteering()), + settlePendingSteering: (sessionId, messageIds) => + run(() => messageReceiptStore.settlePendingSteering(sessionId, messageIds)), }, }; freezeExecutionStoresFacade(stores); diff --git a/packages/storage/src/message-receipt-store.ts b/packages/storage/src/message-receipt-store.ts index 82bcd27277..f6cc88a4fa 100644 --- a/packages/storage/src/message-receipt-store.ts +++ b/packages/storage/src/message-receipt-store.ts @@ -20,6 +20,11 @@ import { resolve } from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import type { DatabaseSync } from 'node:sqlite'; +import { + messageContentsEqual, + normalizeMessageContent, + type MessageContent, +} from '@maka/core/events'; import { acquireOperationalStateDatabase, type OperationalStateDatabaseLease, @@ -43,6 +48,17 @@ export interface MessageOperationReceipt { readonly result: unknown; } +export interface PendingSteeringAdmission { + readonly sessionId: string; + readonly turnId: string; + readonly runId: string; + readonly messageId: string; + readonly content: MessageContent; + readonly modelContent: MessageContent; + readonly initiatingConnectionId: string; + readonly admittedAt: number; +} + export interface MessageReceiptStore { beginHostEpoch(hostEpoch: string): Promise; read( @@ -58,6 +74,9 @@ export interface MessageReceiptStore { operationId: string, receipt: MessageOperationReceipt, ): Promise; + commitPendingSteering(admission: PendingSteeringAdmission): Promise; + listPendingSteering(): Promise; + settlePendingSteering(sessionId: string, messageIds: readonly string[]): Promise; } interface StoredMessageOperationReceipt { @@ -174,6 +193,69 @@ class SqliteMessageReceiptStore implements ClosableMessageReceiptStore { }); } + async commitPendingSteering( + admission: PendingSteeringAdmission, + ): Promise { + const stored = normalizePendingSteeringAdmission(admission); + return this.#lease.transaction('write', () => { + const inserted = this.#lease.database + .prepare(` + INSERT OR IGNORE INTO core_pending_steering_admissions( + session_id, turn_id, run_id, message_id, content_json, model_content_json, + initiating_connection_id, admitted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + stored.sessionId, + stored.turnId, + stored.runId, + stored.messageId, + JSON.stringify(stored.content), + JSON.stringify(stored.modelContent), + stored.initiatingConnectionId, + stored.admittedAt, + ); + if (inserted.changes !== 0) return stored; + const existing = readPendingSteeringAdmission( + this.#lease.database, + stored.sessionId, + stored.messageId, + ); + if (!existing || !samePendingSteeringAdmission(existing, stored)) { + throw new Error('Pending steering admission identity conflict'); + } + return existing; + }); + } + + async listPendingSteering(): Promise { + return this.#lease.database + .prepare(` + SELECT session_id, turn_id, run_id, message_id, content_json, model_content_json, + initiating_connection_id, admitted_at + FROM core_pending_steering_admissions + ORDER BY sequence + `) + .all() + .map(decodePendingSteeringAdmissionRow); + } + + async settlePendingSteering(sessionId: string, messageIds: readonly string[]): Promise { + assertSafeId(sessionId, 'Invalid Session identity'); + const uniqueMessageIds = [...new Set(messageIds)]; + if (uniqueMessageIds.length === 0) return; + for (const messageId of uniqueMessageIds) { + assertSafeId(messageId, 'Invalid Message identity'); + } + this.#lease.transaction('write', () => { + const statement = this.#lease.database.prepare(` + DELETE FROM core_pending_steering_admissions + WHERE session_id = ? AND message_id = ? + `); + for (const messageId of uniqueMessageIds) statement.run(sessionId, messageId); + }); + } + close(): void { this.#lease.close(); } @@ -291,6 +373,98 @@ function decodeStoredReceipt( return record as unknown as StoredMessageOperationReceipt; } +interface PendingSteeringAdmissionRow { + readonly session_id?: unknown; + readonly turn_id?: unknown; + readonly run_id?: unknown; + readonly message_id?: unknown; + readonly content_json?: unknown; + readonly model_content_json?: unknown; + readonly initiating_connection_id?: unknown; + readonly admitted_at?: unknown; +} + +function normalizePendingSteeringAdmission( + admission: PendingSteeringAdmission, +): PendingSteeringAdmission { + assertSafeId(admission.sessionId, 'Invalid Session identity'); + assertSafeId(admission.turnId, 'Invalid Turn identity'); + assertSafeId(admission.runId, 'Invalid Run identity'); + assertSafeId(admission.messageId, 'Invalid Message identity'); + assertSafeId(admission.initiatingConnectionId, 'Invalid Connection identity'); + if (!Number.isSafeInteger(admission.admittedAt) || admission.admittedAt < 0) { + throw new Error('Invalid steering admission timestamp'); + } + const normalized = Object.freeze({ + ...admission, + content: normalizeMessageContent(admission.content), + modelContent: normalizeMessageContent(admission.modelContent), + }); + if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > RECEIPT_MAX_BYTES) { + throw new Error('Pending steering admission exceeds size limit'); + } + return normalized; +} + +function decodePendingSteeringAdmissionRow( + row: PendingSteeringAdmissionRow, +): PendingSteeringAdmission { + if ( + typeof row.session_id !== 'string' || + typeof row.turn_id !== 'string' || + typeof row.run_id !== 'string' || + typeof row.message_id !== 'string' || + typeof row.content_json !== 'string' || + typeof row.model_content_json !== 'string' || + typeof row.initiating_connection_id !== 'string' || + typeof row.admitted_at !== 'number' + ) { + throw new Error('Invalid SQLite pending steering admission'); + } + return normalizePendingSteeringAdmission({ + sessionId: row.session_id, + turnId: row.turn_id, + runId: row.run_id, + messageId: row.message_id, + content: JSON.parse(row.content_json), + modelContent: JSON.parse(row.model_content_json), + initiatingConnectionId: row.initiating_connection_id, + admittedAt: row.admitted_at, + }); +} + +function readPendingSteeringAdmission( + db: DatabaseSync, + sessionId: string, + messageId: string, +): PendingSteeringAdmission | undefined { + const row = db + .prepare(` + SELECT session_id, turn_id, run_id, message_id, content_json, model_content_json, + initiating_connection_id, admitted_at + FROM core_pending_steering_admissions + WHERE session_id = ? AND message_id = ? + `) + .get(sessionId, messageId) as PendingSteeringAdmissionRow | undefined; + return row ? decodePendingSteeringAdmissionRow(row) : undefined; +} + +function samePendingSteeringAdmission( + left: PendingSteeringAdmission, + right: PendingSteeringAdmission, +): boolean { + return ( + left.sessionId === right.sessionId && + left.turnId === right.turnId && + left.runId === right.runId && + left.messageId === right.messageId && + left.initiatingConnectionId === right.initiatingConnectionId && + left.admittedAt === right.admittedAt && + messageContentsEqual(left.content, right.content) && + messageContentsEqual(left.modelContent, right.modelContent) + ); +} + function assertSafeId(value: string, message: string): void { if (!SAFE_ID_PATTERN.test(value)) throw new Error(message); } diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index 2e394e61b4..f02161a3e1 100644 --- a/packages/storage/src/sqlite-core-execution-schema.ts +++ b/packages/storage/src/sqlite-core-execution-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_CORE_EXECUTION_SCHEMA_VERSION = 4; +export const SQLITE_CORE_EXECUTION_SCHEMA_VERSION = 5; export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { db.exec(` @@ -132,6 +132,22 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS core_pending_steering_admissions ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + run_id TEXT NOT NULL, + message_id TEXT NOT NULL, + content_json TEXT NOT NULL, + model_content_json TEXT NOT NULL, + initiating_connection_id TEXT NOT NULL, + admitted_at INTEGER NOT NULL CHECK (admitted_at >= 0), + UNIQUE (session_id, message_id) + ); + + CREATE INDEX IF NOT EXISTS core_pending_steering_session_order + ON core_pending_steering_admissions(session_id, sequence); + CREATE TABLE IF NOT EXISTS core_shell_runs ( session_id TEXT NOT NULL, shell_run_id TEXT NOT NULL, From 171c5d421c23491bc492b2ddbc33b89854fd5e53 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 05:02:39 +0800 Subject: [PATCH 15/33] fix(desktop): make Host own message admission Route ordinary messages through the Runtime Host admission API, stop creating renderer-owned empty live turns, and keep transcript publication within the active subscription. Preserve visible live tails across sends, return sparse transcripts to latest before submission, and remove redundant live/durable filtering and unconfirmed-arm bookkeeping. Generated-by: Codex --- .../app-shell-busy-race-settlement.test.ts | 71 +++++++-- .../app-shell-first-send-cleanup.test.ts | 145 +++--------------- .../app-shell-session-ui-state.test.ts | 57 +------ .../__tests__/app-shell-turn-actions.test.ts | 1 - .../follow-up-submit-routing.test.ts | 34 +--- ...me-host-session-execution-ipc-main.test.ts | 59 ++----- ...ettled-session-transient-reconcile.test.ts | 25 +-- ...runtime-host-session-execution-ipc-main.ts | 32 +--- .../src/renderer/app-shell-chat-actions.ts | 113 ++------------ .../desktop/src/renderer/app-shell-effects.ts | 16 -- .../renderer/app-shell-revision-actions.ts | 4 - .../src/renderer/app-shell-session-events.ts | 13 +- .../renderer/app-shell-session-ui-state.ts | 17 +- .../src/renderer/app-shell-turn-actions.ts | 3 - apps/desktop/src/renderer/app-shell.tsx | 22 +-- .../tools/side-chat/use-quote-companion.ts | 4 - .../src/renderer/follow-up-submit-routing.ts | 41 +++-- .../renderer/settled-session-transients.ts | 19 +-- .../use-app-shell-session-workspace.ts | 1 - .../__tests__/live-turn-projection.test.ts | 37 ----- packages/ui/src/chat-view.tsx | 17 +- packages/ui/src/live-turn-projection.ts | 46 +----- 22 files changed, 172 insertions(+), 605 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 7b0edd27b9..e04e544ca2 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -102,7 +102,6 @@ function createActionsDeps() { setMessages: () => undefined, transcriptRangeRef: { current: undefined }, setNavSelection: () => undefined, - setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, @@ -119,10 +118,21 @@ function createActionsDeps() { const EMPTY_SKILL_INVOCATION = { loaded: [], failed: [], receipts: [] }; describe('busy-raced send settlement', () => { - it('a steered send on an existing session disarms its turn and shows no optimistic message', async () => { + it('keeps the visible live tail while the Host admits a new message', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); - const messageState = createMessageState(); + const visibleTail: LiveTurnProjection = { + turnId: 'existing-turn', + phase: 'streamed', + steps: [ + { + stepId: 'assistant-tail', + text: { text: 'still visible', truncated: false, complete: false }, + tools: [], + }, + ], + }; + turnState.setLiveTurnBySession(() => ({ 'session-a': visibleTail })); const restoreWindow = installWindow({ sessions: { send: async (_sessionId: string, command: { turnId: string }) => ({ @@ -136,12 +146,44 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const deps = { ...createActionsDeps(), activeIdRef, setLiveTurnBySession: turnState.setLiveTurnBySession, + }; + const actions = createAppShellChatActions(deps); + + assert.equal(await actions.send('also check the tests'), true); + assert.deepEqual(turnState.liveTurnBySession['session-a'], visibleTail); + } finally { + restoreWindow(); + } + }); + + it('a steered send on an existing session shows no optimistic message', async () => { + const activeIdRef = { current: 'session-a' as string | undefined }; + const turnState = createTurnState(); + const messageState = createMessageState(); + const restoreWindow = installWindow({ + sessions: { + send: async (_sessionId: string, command: { turnId: string }) => ({ + ok: true, + steered: true, + turnId: command.turnId, + attachments: [], + inlineReferences: [], + skillInvocation: EMPTY_SKILL_INVOCATION, + }), + }, + }); + try { + const deps = { + ...createActionsDeps(), + activeIdRef, setMessages: messageState.setMessages, - }); + setLiveTurnBySession: turnState.setLiveTurnBySession, + }; + const actions = createAppShellChatActions(deps); assert.equal(await actions.send('also check the tests'), true); assert.equal(turnState.liveTurnBySession['session-a'], undefined); assert.deepEqual(messageState.messages, []); @@ -150,7 +192,7 @@ describe('busy-raced send settlement', () => { } }); - it('rebinds the unconfirmed arm onto a Host-chosen turn id', async () => { + it('does not invent an empty live turn while the Host admits a message', async () => { const activeIdRef = { current: 'session-a' as string | undefined }; const turnState = createTurnState(); const messageState = createMessageState(); @@ -166,16 +208,15 @@ describe('busy-raced send settlement', () => { }, }); try { - const actions = createAppShellChatActions({ + const deps = { ...createActionsDeps(), activeIdRef, - setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, - }); + setLiveTurnBySession: turnState.setLiveTurnBySession, + }; + const actions = createAppShellChatActions(deps); assert.equal(await actions.send('also check the tests'), true); - const live = turnState.liveTurnBySession['session-a']; - assert.equal(live?.turnId, 'host-turn'); - assert.equal(live?.unconfirmed, true); + assert.equal(turnState.liveTurnBySession['session-a'], undefined); const optimistic = messageState.messages.filter((message) => message.type === 'user'); assert.equal(optimistic.length, 1); assert.equal(optimistic[0]?.turnId, 'host-turn'); @@ -210,14 +251,12 @@ describe('busy-raced send settlement', () => { const actions = createAppShellChatActions({ ...createActionsDeps(), activeIdRef, - setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, }); assert.equal(await actions.send('also check the tests'), true); const live = turnState.liveTurnBySession['session-a']; assert.equal(live?.turnId, 'host-turn'); assert.equal(live?.phase, 'streamed'); - assert.equal(live?.unconfirmed, undefined); } finally { restoreWindow(); } @@ -255,7 +294,6 @@ describe('busy-raced send settlement', () => { if (sessionId !== undefined) activated.push(sessionId); activeIdRef.current = sessionId; }, - setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, }); assert.equal(await actions.send('also check the tests'), true); @@ -293,11 +331,10 @@ describe('busy-raced send settlement', () => { setActiveId: (sessionId: string | undefined) => { activeIdRef.current = sessionId; }, - setLiveTurnBySession: turnState.setLiveTurnBySession, setMessages: messageState.setMessages, }); assert.equal(await actions.send('also check the tests'), true); - assert.equal(turnState.liveTurnBySession['session-new']?.turnId, 'host-turn'); + assert.equal(turnState.liveTurnBySession['session-new'], undefined); const optimistic = messageState.messages.filter((message) => message.type === 'user'); assert.equal(optimistic.length, 1); assert.equal(optimistic[0]?.turnId, 'host-turn'); diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 967d777dc9..032f36146b 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -35,12 +35,8 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { SessionSummary } from '@maka/core/session'; -import type { LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -import { createAppShellSessionUiStateController } from '../../renderer/app-shell-session-ui-state.js'; -import { settledSessionTransientIds } from '../../renderer/settled-session-transients.js'; function installWindow(maka: unknown): () => void { const target = globalThis as unknown as { window?: unknown }; @@ -64,23 +60,6 @@ function installWindow(maka: unknown): () => void { }; } -/** - * The live-turn arm as a real map rather than a black-hole stub: a send that - * never lands must leave nothing behind, and that cannot be asserted against a - * no-op setter. - */ -function createTurnState() { - const liveTurnBySession: Record = {}; - return { - liveTurnBySession, - setLiveTurnBySession(updater: (c: Record) => Record) { - const next = updater({ ...liveTurnBySession }); - for (const key of Object.keys(liveTurnBySession)) delete liveTurnBySession[key]; - Object.assign(liveTurnBySession, next); - }, - }; -} - function createActionsDeps() { return { uiLocale: 'en' as const, @@ -103,7 +82,6 @@ function createActionsDeps() { setMessages: () => undefined, transcriptRangeRef: { current: undefined }, setNavSelection: () => undefined, - setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, showModelSetupToast: () => undefined, toastApi: { error: () => undefined, info: () => undefined }, @@ -491,28 +469,6 @@ describe('composer send failure feedback', () => { assert.deepEqual(setupToasts, [], 'a stale surface must not be navigated to 设置 · 模型'); }); - // A send that never reaches the runtime must take its arm with it. A leftover - // arm still carries its `unconfirmed` claim, which would make - // `settledSessionTransientIds` protect a turn that does not exist — leaving a - // Stop button nothing can clear. - it('leaves no arm behind when the send never lands', async () => { - const turnState = createTurnState(); - const restoreWindow = installWindow(readinessFailure()); - - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: 'session-a' }, - setLiveTurnBySession: turnState.setLiveTurnBySession, - }); - assert.equal(await actions.send('hello'), false); - } finally { - restoreWindow(); - } - - assert.deepEqual(turnState.liveTurnBySession, {}, 'the arm must be disarmed'); - }); - it('still answers the surface that is actually waiting', async () => { const setupToasts: string[] = []; const restoreWindow = installWindow(readinessFailure()); @@ -533,91 +489,30 @@ describe('composer send failure feedback', () => { }); }); -/** - * The bug this guards, as the sequence that actually produced it: send arms the - * turn, a session list that was already in flight lands still carrying the - * pre-send status, and the settle reconcile runs against it. - * - * Nothing in that list is wrong — the runtime writes `status: 'running'` only at - * the end of `AgentRun.begin` and announces it to nobody until `onRunStarted`. - * The list simply predates the answer. Reading it as a settle used to drop the - * arm, so the first content event rebuilt the projection as `'streamed'` and the - * prominent "正在处理…" silently became the calm "继续中…". - * - * Asserted through the real `send`, the real state controller, and the real - * settle rule, because the defect lived in how those three compose — each one is - * individually correct. - */ -describe('a send in flight versus a stale session list', () => { - const sessionId = 'session-a'; - - function sendingWindow() { - return { - sessions: { - send: async () => ({ - ok: true, - attachments: [], - skillInvocation: { loaded: [], failed: [] }, - }), +describe('transcript refresh ownership', () => { + it('waits for durable transcript data without republishing the subscription snapshot', async () => { + let publications = 0; + const controller = { + ready: async () => undefined, + waitForDurableMessage: async () => true, + store: { + hasDurableMessage: () => true, + snapshot: () => ({ sessionId: 'session-a', messages: [] }), + }, + } as unknown as DesktopTranscriptRangeController; + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + transcriptRangeRef: { current: controller }, + setMessages: () => { + publications += 1; }, - }; - } - - // The list as it reads before the runtime's `running` write — identical to how - // it reads after the turn is over, which is exactly why the status alone - // cannot settle anything. - const preSendList = [{ id: sessionId, status: 'active', statusUpdatedAt: 100 }] as SessionSummary[]; - - async function armViaSend(controller: ReturnType) { - const restoreWindow = installWindow(sendingWindow()); - try { - const actions = createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef: { current: sessionId }, - setLiveTurnBySession: controller.setLiveTurnBySession, - }); - assert.equal(await actions.send('hello'), true); - } finally { - restoreWindow(); - } - const armed = controller.getState().liveTurnBySession[sessionId]; - assert.equal(armed?.unconfirmed, true, 'the send must arm an unconfirmed turn'); - return armed!.turnId; - } - - function settle(controller: ReturnType) { - return settledSessionTransientIds({ - activeId: sessionId, - sessions: preSendList, - liveTurnBySession: controller.getState().liveTurnBySession, }); - } - - it('keeps the armed turn, and settles it once the authority names that turn', async () => { - const controller = createAppShellSessionUiStateController(); - const turnId = await armViaSend(controller); - assert.deepEqual(settle(controller), [], 'a list older than the answer must not settle the turn'); assert.equal( - controller.getState().liveTurnBySession[sessionId]?.phase, - 'waiting', - 'the first-token wait must survive the stale refresh', + await actions.refreshMessages('session-a', { requiredAssistantMessageId: 'assistant-a' }), + true, ); - - // `sessions:changed` naming this turn — what `onRunStarted` now emits once - // the run has begun. This is the same controller entry point the shell - // wires that subscription to. - controller.confirmLiveTurn(sessionId, turnId); - - assert.deepEqual(settle(controller), [sessionId], 'an answered turn settles under the plain status rules'); - }); - - it('ignores an answer about a turn other than the one in flight', async () => { - const controller = createAppShellSessionUiStateController(); - await armViaSend(controller); - - controller.confirmLiveTurn(sessionId, 'turn-from-another-client'); - - assert.deepEqual(settle(controller), [], 'only this send\'s own turn may release its claim'); + assert.equal(publications, 0, 'the transcript subscription is the only messages publisher'); }); }); diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index 5771c5243c..b619cd6851 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -22,7 +22,7 @@ import { describe, it } from 'node:test'; import type { SandboxBoundaryRequestEvent } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; import type { SessionSummary } from '@maka/core/session'; -import { armLiveTurn, confirmLiveTurn } from '@maka/ui'; +import { armLiveTurn } from '@maka/ui'; import { settledSessionTransientIds } from '../../renderer/settled-session-transients.js'; import { normalizeSessionSummaryForDisplay } from '../../renderer/session-status-presentation.js'; import { @@ -53,10 +53,8 @@ function healthSnapshot(sessionId: string): SessionEventStreamSnapshot { return { sessionId, status: 'connected', subscribedAt: 1, checkedAt: 1 }; } -/** An arm the authority has already answered about — what every projection - * looks like once its turn has produced a single event. */ -function answeredArm(turnId: string) { - return confirmLiveTurn(armLiveTurn(turnId), turnId)!; +function liveTurn(turnId: string) { + return armLiveTurn(turnId); } function seededState(): AppShellSessionUiState { @@ -97,8 +95,8 @@ describe('app shell session UI state controller', () => { { id: 'background', status: 'active' }, { id: 'active', status: 'active' }, ] as SessionSummary[]; - const background = { ...answeredArm('turn-background'), terminal: true as const }; - const active = { ...answeredArm('turn-active'), terminal: true as const }; + const background = { ...liveTurn('turn-background'), terminal: true as const }; + const active = { ...liveTurn('turn-active'), terminal: true as const }; assert.deepEqual(settledSessionTransientIds({ activeId: 'active', @@ -107,34 +105,6 @@ describe('app shell session UI state controller', () => { }), ['background']); }); - // The runtime writes `status: 'running'` only at the end of `AgentRun.begin`, - // so a list refreshed between the send and that write reports the pre-send - // status — which is the same status a FINISHED turn leaves behind. Retiring - // the arm on it is what made the first-token wait disappear until the first - // content event rebuilt the projection as 'streamed'. - it('keeps an armed turn while its send is still awaiting the authority', () => { - const sessions = [{ id: 'sending', status: 'active' }] as SessionSummary[]; - - assert.deepEqual(settledSessionTransientIds({ - activeId: 'sending', - sessions, - liveTurnBySession: { sending: armLiveTurn('turn-1') }, - }), []); - }); - - // The same pre-send status also has to stop protecting the arm once the send - // has been answered, or a turn that ended while its stream wasn't followed - // would leave the Stop affordance up forever. - it('settles an armed turn once the authority has answered its send', () => { - const sessions = [{ id: 'sending', status: 'active' }] as SessionSummary[]; - - assert.deepEqual(settledSessionTransientIds({ - activeId: 'sending', - sessions, - liveTurnBySession: { sending: answeredArm('turn-1') }, - }), ['sending']); - }); - // The live runs outrank the persisted status in BOTH directions. A status // that has not caught up yet, or one a crash left behind, must not decide // this while the runtime still reports the turn as running. @@ -146,7 +116,7 @@ describe('app shell session UI state controller', () => { assert.deepEqual(settledSessionTransientIds({ activeId: 'running', sessions, - liveTurnBySession: { running: answeredArm('turn-live') }, + liveTurnBySession: { running: liveTurn('turn-live') }, }), []); }); @@ -158,23 +128,10 @@ describe('app shell session UI state controller', () => { assert.deepEqual(settledSessionTransientIds({ activeId: 'other', sessions, - liveTurnBySession: { ended: answeredArm('turn-over') }, + liveTurnBySession: { ended: liveTurn('turn-over') }, }), ['ended']); }); - // A backgrounded session's arm is protected by the same bit — the guard must - // not be an active-session special case, since a send can be backgrounded the - // instant it is made. - it('protects an unconfirmed arm in a backgrounded session too', () => { - const sessions = [{ id: 'background', status: 'active' }] as SessionSummary[]; - - assert.deepEqual(settledSessionTransientIds({ - activeId: 'other', - sessions, - liveTurnBySession: { background: armLiveTurn('turn-1') }, - }), []); - }); - it('clears one session from every per-session UI map without touching other sessions', () => { const next = clearAppShellSessionUiStateForSession(seededState(), 'drop'); diff --git a/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts index dca60bfefa..551b604a9f 100644 --- a/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts @@ -50,7 +50,6 @@ test('preserves a Branch copy identity after an ambiguous failure and completes opened.push(sessionId); }, pendingKeyOf: (sessionId, turnId, actionId) => `${sessionId}:${turnId}:${actionId}`, - refreshMessages: async () => true, refreshSessions: async () => [], setMessages: () => undefined, toastApi: { info() {}, success() {}, error() {} }, diff --git a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts index b829b09021..c9c8e4361f 100644 --- a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts +++ b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts @@ -20,54 +20,28 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { - hasActiveTurnAtSubmit, mergeWorkspaceReferences, resolveFollowUpModeAtSubmit, } from '../../renderer/follow-up-submit-routing.js'; describe('follow-up submit routing', () => { - it('uses the synchronous turn arm before React publishes streaming state', () => { - assert.equal( - hasActiveTurnAtSubmit({ - liveTurn: { turnId: 'turn-1' }, - runningTurnIds: [], - }), - true, - ); - }); - - it('ignores a terminal projection whose only running id is the same turn', () => { - assert.equal( - hasActiveTurnAtSubmit({ - liveTurn: { turnId: 'turn-1', terminal: true }, - runningTurnIds: ['turn-1'], - }), - false, - ); - }); - it('routes burst input through the selected follow-up lane', () => { assert.equal( - resolveFollowUpModeAtSubmit({ - hasActiveTurn: true, - }), + resolveFollowUpModeAtSubmit({}), 'queue', ); assert.equal( resolveFollowUpModeAtSubmit({ requestedMode: 'steer', - hasActiveTurn: true, }), 'steer', ); }); - it('starts a normal turn only when no active-turn witness exists', () => { + it('lets the Host admit an ordinary existing-session message without a liveness guess', () => { assert.equal( - resolveFollowUpModeAtSubmit({ - hasActiveTurn: false, - }), - undefined, + resolveFollowUpModeAtSubmit({}), + 'queue', ); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 073cda9e56..443e38940d 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -329,7 +329,7 @@ test('returns structured Side Conversation setup failures across IPC', async () }); test("sends canonical content and uploads owned Attachment bytes through the Host", async () => { - const starts: unknown[] = []; + const submits: unknown[] = []; const uploads: unknown[] = []; const changes: unknown[] = []; const attachment: AttachmentRef = { @@ -349,18 +349,9 @@ test("sends canonical content and uploads owned Attachment bytes through the Hos uploads.push(input); return attachment; }, - startTurn: async (input) => { - starts.push(input); - return { - kind: "started", - turn: { - sessionId: input.sessionId, - turnId: input.turnId, - runId: "run-1", - status: "running", - }, - skillInvocation: { loaded: [], failed: [], receipts: [] }, - }; + submitMessage: async (input) => { + submits.push(input); + return { disposition: "turn_started", turnId: "turn-1" }; }, }); const ipc = ipcHarness(); @@ -393,10 +384,10 @@ test("sends canonical content and uploads owned Attachment bytes through the Hos }); assert.equal((uploads[0] as { content: Uint8Array }).content.byteLength, 5); - assert.deepEqual(starts, [ + assert.deepEqual(submits, [ { sessionId: "session-1", - turnId: "turn-1", + messageId: "turn-1", content: { text: "Read @notes.txt", attachments: [attachment], @@ -409,6 +400,7 @@ test("sends canonical content and uploads owned Attachment bytes through the Hos }, ], }, + placement: "current_turn", }, ]); assert.deepEqual(result, { @@ -441,7 +433,7 @@ test("uploads a selected workspace file as a Host-owned Session Artifact", async ]); assert.ok(approved); const uploads: Array<{ content: Uint8Array }> = []; - const starts: unknown[] = []; + const submits: unknown[] = []; const attachment: AttachmentRef = { kind: "other", name: "notes.txt", @@ -462,18 +454,9 @@ test("uploads a selected workspace file as a Host-owned Session Artifact", async uploads.push(input); return attachment; }, - startTurn: async (input) => { - starts.push(input); - return { - kind: "started", - turn: { - sessionId: input.sessionId, - turnId: input.turnId, - runId: "run-1", - status: "running", - }, - skillInvocation: { loaded: [], failed: [], receipts: [] }, - }; + submitMessage: async (input) => { + submits.push(input); + return { disposition: "turn_started", turnId: "turn-1" }; }, }), observer: unusedObserver(), @@ -498,7 +481,7 @@ test("uploads a selected workspace file as a Host-owned Session Artifact", async "hello", ); assert.deepEqual( - (starts[0] as { content: { attachments: AttachmentRef[] } }).content + (submits[0] as { content: { attachments: AttachmentRef[] } }).content .attachments, [attachment], ); @@ -568,7 +551,7 @@ test("forwards explicit Skill invocation to the Host-owned Turn admission", asyn }); }); -test("queues a mid-turn send as steering when the Host reports the session busy", async () => { +test("submits ordinary text through the Host admission authority without starting first", async () => { const submits: unknown[] = []; const changes: unknown[] = []; const ipc = ipcHarness(); @@ -577,11 +560,7 @@ test("queues a mid-turn send as steering when the Host reports the session busy" client: executionClient({ getSession: async () => session(), startTurn: async () => { - throw new RuntimeHostOperationError( - "turn.start", - "session_busy", - "Session already has an active root Turn", - ); + throw new Error("ordinary text must not call turn.start"); }, submitMessage: async (input) => { submits.push(input); @@ -609,7 +588,7 @@ test("queues a mid-turn send as steering when the Host reports the session busy" assert.deepEqual(submits, [ { sessionId: "session-1", - messageId: "id-1", + messageId: "turn-1", content: { text: "also check the tests", inlineReferences: [] }, placement: "current_turn", }, @@ -627,7 +606,7 @@ test("queues a mid-turn send as steering when the Host reports the session busy" ]); }); -test("starts the turn from the queued message when the busy race resolves idle", async () => { +test("starts ordinary text when the Host admission authority finds the session idle", async () => { const changes: unknown[] = []; const ipc = ipcHarness(); registerExecutionIpc( @@ -635,11 +614,7 @@ test("starts the turn from the queued message when the busy race resolves idle", client: executionClient({ getSession: async () => session(), startTurn: async () => { - throw new RuntimeHostOperationError( - "turn.start", - "session_busy", - "Session already has an active root Turn", - ); + throw new Error("ordinary text must not call turn.start"); }, submitMessage: async () => ({ disposition: "turn_started", diff --git a/apps/desktop/src/main/__tests__/settled-session-transient-reconcile.test.ts b/apps/desktop/src/main/__tests__/settled-session-transient-reconcile.test.ts index ea1bbc67c8..3f1c681026 100644 --- a/apps/desktop/src/main/__tests__/settled-session-transient-reconcile.test.ts +++ b/apps/desktop/src/main/__tests__/settled-session-transient-reconcile.test.ts @@ -24,34 +24,12 @@ import { armLiveTurn } from '@maka/ui'; import { createAppShellSessionUiStateController } from '../../renderer/app-shell-session-ui-state.js'; import { reconcileSettledSessionTransients } from '../../renderer/settled-session-transients.js'; -test('does not let a session snapshot retire a turn confirmed while its list request was in flight', () => { - const sessionId = 'session-a'; - const turnId = 'turn-a'; - const controller = createAppShellSessionUiStateController(); - controller.setLiveTurnBySession(() => ({ - [sessionId]: armLiveTurn(turnId), - })); - const observedLiveTurnBySession = controller.liveTurnBySessionRef.current; - - controller.confirmLiveTurn(sessionId, turnId); - reconcileSettledSessionTransients({ - activeId: sessionId, - sessions: [settledSession(sessionId)], - observedLiveTurnBySession, - clearTurnTransientStateIfCurrent: controller.clearTurnTransientStateIfCurrent, - }); - - assert.equal(controller.getState().liveTurnBySession[sessionId]?.turnId, turnId); - assert.equal(controller.getState().liveTurnBySession[sessionId]?.unconfirmed, undefined); -}); - test('does not let an older session snapshot clear a replacement live turn', () => { const sessionId = 'session-a'; const controller = createAppShellSessionUiStateController(); controller.setLiveTurnBySession(() => ({ [sessionId]: armLiveTurn('turn-a'), })); - controller.confirmLiveTurn(sessionId, 'turn-a'); const observedLiveTurnBySession = controller.liveTurnBySessionRef.current; controller.setLiveTurnBySession(() => ({ @@ -67,13 +45,12 @@ test('does not let an older session snapshot clear a replacement live turn', () assert.equal(controller.getState().liveTurnBySession[sessionId]?.turnId, 'turn-b'); }); -test('clears a confirmed live turn when the accepted authority snapshot says it settled', () => { +test('clears a live turn when the accepted authority snapshot says it settled', () => { const sessionId = 'session-a'; const controller = createAppShellSessionUiStateController(); controller.setLiveTurnBySession(() => ({ [sessionId]: armLiveTurn('turn-a'), })); - controller.confirmLiveTurn(sessionId, 'turn-a'); const observedLiveTurnBySession = controller.liveTurnBySessionRef.current; reconcileSettledSessionTransients({ diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 9d0dfcdb95..df69bffdc9 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -20,7 +20,6 @@ import { randomUUID } from "node:crypto"; import type { IpcMainInvokeEvent } from "electron"; import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; -import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { type SessionChangedEvent, @@ -274,32 +273,14 @@ export function registerRuntimeHostSessionExecutionIpc( ? { turnOrchestration: command.turnOrchestration } : {}), }; - let startResult; - try { - startResult = await deps.client.startTurn(startInput); - } catch (error) { - // The renderer routes text at a session it sees as running to the - // current-turn message queue, but its view can lag the Host: another window, a - // Bot, or a Goal continuation may have opened the root Turn first, and - // that race surfaced here as a session_busy send failure that dropped - // the user's message (#1954). `turn.message.submit` resolves the race - // on the Host: an active session queues the text as steering, an idle - // one starts the Turn. Skill and orchestration sends keep the error — - // their turn semantics cannot be expressed as a queued message — and - // the Desktop composer carries Skills as canonical /skill: tokens in - // the text, not as skillIds. - if ( - !(error instanceof RuntimeHostOperationError) || - error.code !== "session_busy" || - (command.skillIds?.length ?? 0) > 0 || - command.turnOrchestration || - new RegExp(SKILL_INVOCATION_TOKEN_SOURCE).test(command.text) - ) { - throw error; - } + const isControlInput = + (command.skillIds?.length ?? 0) > 0 || + command.turnOrchestration !== undefined || + new RegExp(SKILL_INVOCATION_TOKEN_SOURCE).test(command.text); + if (!isControlInput) { const submitted = await deps.client.submitMessage({ sessionId, - messageId: newId(), + messageId: turnId, content: startInput.content, placement: "current_turn", }); @@ -328,6 +309,7 @@ export function registerRuntimeHostSessionExecutionIpc( skillInvocation: emptySkillInvocation, }; } + const startResult = await deps.client.startTurn(startInput); if (startResult.kind === "blocked") { return { ok: false as const, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 3c74ddfd47..086ef43eea 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -31,10 +31,8 @@ import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; import type { UserQuestionResponse } from '@maka/core/user-question'; import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; import { - armLiveTurn, dequeueInteractionByRequestId, type InteractionQueues, - type LiveTurnProjection, type NavSelection, } from '@maka/ui'; import { messageRefreshErrorMessage } from './app-shell-copy.js'; @@ -65,6 +63,7 @@ import { noRealConnectionSetupDescription, } from './model-connection-errors.js'; import type { RefreshMessagesOptions } from './session-message-settlement.js'; +import { returnToLatestBeforeSubmit } from './follow-up-submit-routing.js'; export type { RefreshMessagesOptions }; @@ -76,9 +75,6 @@ type ComposerImportOwner = { type RefBox = { current: T }; type BooleanRecordUpdater = (updater: (current: Record) => Record) => void; -type LiveTurnRecordUpdater = ( - updater: (current: Record) => Record, -) => void; type MessageListUpdater = (next: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[])) => void; type MessageLoadErrorUpdater = (updater: (current: Record) => Record) => void; type InteractionQueueUpdater = (updater: (current: InteractionQueues) => InteractionQueues) => void; @@ -146,9 +142,6 @@ export function createAppShellChatActions(deps: { setMessages: MessageListUpdater; transcriptRangeRef: RefBox; setNavSelection: (selection: NavSelection) => void; - /** #646: arm the "正在处理…" indicator locally at send() — the model-wait - * window opens before any SessionEvent arrives (turn_started is not one). */ - setLiveTurnBySession: LiveTurnRecordUpdater; setInteractionBySession: InteractionQueueUpdater; onInteractionChanged?: (sessionId: string) => void; /** A boundary decision settled: the session's execution boundary may have moved. */ @@ -194,7 +187,6 @@ export function createAppShellChatActions(deps: { setMessages, transcriptRangeRef, setNavSelection, - setLiveTurnBySession, setInteractionBySession, onInteractionChanged, onExecutionBoundaryChanged, @@ -265,69 +257,14 @@ export function createAppShellChatActions(deps: { setMessages((current) => current.filter((message) => message.id !== `optimistic-user-${turnId}`)); } - // #646: open the turn's model-wait window for a session. Armed the moment - // send() commits (before the IPC round-trip) so the "正在处理…" indicator - // covers the connect-to-first-token gap that has no SessionEvent of its own; - // disarmed if the send never reaches the runtime (the catch below). Always - // (re)set to `'waiting'`: a fresh send is a new first-token wait, so it must - // overwrite any `'streamed'` left by a prior turn whose terminal event was - // missed — otherwise the new turn's head would never show the indicator. - // - // The arm carries `unconfirmed` until the authority names this turn back. The - // runtime writes `status: 'running'` only at the END of `AgentRun.begin`, so - // every session list refreshed in between still reports the pre-send status — - // which is the same status a finished turn leaves behind. Without that bit, - // the stale value retires the arm the send just created - // (settled-session-transients.ts). - function armTurnActive(sessionId: string, turnId: string): void { - setLiveTurnBySession((current) => { - const active = current[sessionId]; - if (active?.turnId === turnId && active.phase === 'waiting') return current; - return { ...current, [sessionId]: armLiveTurn(turnId) }; - }); - } - - function disarmTurnActive(sessionId: string, turnId: string): void { - setLiveTurnBySession((current) => { - if (current[sessionId]?.turnId !== turnId) return current; - const next = { ...current }; - delete next[sessionId]; - return next; - }); - } - - // Rename only the exact unconfirmed arm this send created. Host events can - // beat the IPC response (main emits the sessions-changed nudge before it - // returns), and an authoritative projection that already arrived for the - // Host-chosen turn must not be replaced with a fresh waiting arm. - function rebindTurnActive(sessionId: string, fromTurnId: string, toTurnId: string): void { - setLiveTurnBySession((current) => { - const active = current[sessionId]; - if (!active || active.turnId !== fromTurnId || !active.unconfirmed || active.phase !== 'waiting') { - return current; - } - return { ...current, [sessionId]: armLiveTurn(toTurnId) }; - }); - } - - // One interpretation of a successful sessions:send for both the new-chat and - // existing-session branches: a busy-raced send can come back `steered` (this - // send owns no turn — the steering_message event renders the text) or under - // a Host-chosen turnId. Returns the turn the send owns, if any. - function settleSendBookkeeping( - sessionId: string, + // The Host alone decides whether admission starts a turn or steers the + // active one. The renderer uses that answer for the optimistic user message; + // it never creates a second, empty live-turn claim while awaiting it. + function admittedTurnId( requestedTurnId: string, sendResult: { steered?: true; turnId?: string }, ): string | undefined { - if (sendResult.steered) { - disarmTurnActive(sessionId, requestedTurnId); - return undefined; - } - const startedTurnId = sendResult.turnId ?? requestedTurnId; - if (startedTurnId !== requestedTurnId) { - rebindTurnActive(sessionId, requestedTurnId, startedTurnId); - } - return startedTurnId; + return sendResult.steered ? undefined : sendResult.turnId ?? requestedTurnId; } async function send( @@ -400,7 +337,6 @@ export function createAppShellChatActions(deps: { if (newChatPermissionChoice) clearNewChatPermissionChoice(); optimisticSessionId = session.id; optimisticTurnId = turnId; - armTurnActive(session.id, turnId); const attachmentItems = pending && pending.length > 0 ? toComposerIngestItems(pending) @@ -433,12 +369,11 @@ export function createAppShellChatActions(deps: { session.id, ); } - disarmTurnActive(session.id, turnId); await discardUnsentSession(); return false; } unsentSessionId = undefined; - const settledTurnId = settleSendBookkeeping(session.id, turnId, sendResult); + const settledTurnId = admittedTurnId(turnId, sendResult); if (settledTurnId !== undefined) optimisticTurnId = settledTurnId; options.onSessionResolved?.(session.id); if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) { @@ -471,26 +406,9 @@ export function createAppShellChatActions(deps: { return true; } const sessionId = initialSessionId; - const transcript = transcriptRangeRef.current; - if (transcript) { - let hasNewer = false; - try { - const range = transcript.store.range(); - hasNewer = range.sessionId === sessionId && range.hasNewer; - } catch { - // An unopened transcript is not a sparse historical view. - } - if (hasNewer) { - await transcript.loadLatest(); - if (activeIdRef.current !== sessionId || transcriptRangeRef.current !== transcript) { - return false; - } - setMessages([...transcript.store.snapshot().messages]); - } - } + if (!(await returnToLatestBeforeSubmit({ sessionId, activeIdRef, transcriptRangeRef }))) return false; optimisticSessionId = sessionId; optimisticTurnId = turnId; - armTurnActive(sessionId, turnId); const attachmentItems = pending && pending.length > 0 ? toComposerIngestItems(pending) @@ -523,10 +441,9 @@ export function createAppShellChatActions(deps: { sessionId, ); } - disarmTurnActive(sessionId, turnId); return false; } - const startedTurnId = settleSendBookkeeping(sessionId, turnId, sendResult); + const startedTurnId = admittedTurnId(turnId, sendResult); options.onSessionResolved?.(sessionId); if (startedTurnId === undefined) return true; optimisticTurnId = startedTurnId; @@ -555,11 +472,6 @@ export function createAppShellChatActions(deps: { if (optimisticSessionId && optimisticTurnId) { removeOptimisticUserMessage(optimisticSessionId, optimisticTurnId); } - // The turn never reached the runtime — close the model-wait window so the - // "正在处理…" indicator doesn't hang after a failed send. Nothing else has - // to be undone: the arm was the only claim the send made, and no - // subscribeChanges event would reconcile a turn that never started. - if (optimisticSessionId && optimisticTurnId) disarmTurnActive(optimisticSessionId, optimisticTurnId); // Which surface is allowed to hear about this failure. The id alone is // not it: `selectNavigation` never clears `activeId` (nav-selection.ts), // so a user who left for 扩展 → 技能 mid-flight still "is" session A by @@ -679,18 +591,13 @@ export function createAppShellChatActions(deps: { if (activeIdRef.current !== sessionId || transcriptRangeRef.current !== controller) { return false; } - const range = controller.store; - const snapshot = range.snapshot(); - if (snapshot.sessionId !== sessionId) return false; - const next = [...snapshot.messages]; - setMessages(next); setMessageLoadErrorBySession((current) => { if (!current[sessionId]) return current; const updated = { ...current }; delete updated[sessionId]; return updated; }); - return requiredMessageId === undefined || range.hasDurableMessage(requiredMessageId); + return requiredMessageId === undefined || controller.store.hasDurableMessage(requiredMessageId); } catch (error) { if (activeIdRef.current === sessionId) { const message = messageRefreshErrorMessage(error, uiLocale); diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index ed37f4cafb..ef17662d76 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -180,8 +180,6 @@ export function useAppShellBootstrapSubscriptions(options: { applyE2eFixture: () => Promise; bootstrapSessions: () => Promise; clearPendingTurnActionsForSession: (sessionId: string) => void; - /** Releases a send's pending claim once the authority names that turn. */ - confirmLiveTurn: (sessionId: string, turnId: string) => void; clearSessionRendererState: (sessionId: string) => void; createSession: () => Promise | void; handleConnectionEvent: (event: ConnectionEvent) => void; @@ -195,7 +193,6 @@ export function useAppShellBootstrapSubscriptions(options: { projectPickerRequestRef: RefBox; refreshConnections: () => Promise; refreshMemoryActive: (failureContext?: 'load') => Promise; - refreshMessages: (sessionId: string) => Promise; refreshScheduledTasks: (options?: { shouldShowError?: () => boolean }) => Promise; refreshProjects: () => Promise; refreshShellSettings: () => Promise; @@ -255,12 +252,6 @@ export function useAppShellBootstrapSubscriptions(options: { }); const handleSessionChange = useEffectEvent( (event: SessionChangedEvent) => { - // The authority has spoken about a specific turn — whether it started, - // failed to start, or ended. That confirms the send's arm, and the - // session's status becomes readable as an answer about it again. - if (event.sessionId && event.turnId) { - options.confirmLiveTurn(event.sessionId, event.turnId); - } void options.refreshSessions(); if (event.reason === 'created' || event.reason === 'migrated') { void options.refreshProjects(); @@ -281,10 +272,6 @@ export function useAppShellBootstrapSubscriptions(options: { ) { options.clearPendingTurnActionsForSession(event.sessionId); } - const changedSessionId = event.sessionId; - if (event.reason === 'message-appended' && changedSessionId && changedSessionId === options.activeIdRef.current) { - void options.refreshMessages(changedSessionId); - } if (event.reason === 'rebound') { const copy = getDesktopConversationCopy(options.uiLocale).actions; options.toastApi.info(copy.modelReboundTitle, copy.modelReboundDescription(event.modelId)); @@ -638,7 +625,6 @@ export function useSessionEventHealthPolling(options: { activeSession: SessionSummary | undefined; activeStreamingLive: boolean; hasInFlightLiveTools: boolean; - refreshMessages: (sessionId: string) => Promise; refreshSessions: () => Promise; sessionEventHealthBySessionRef: RefBox>; setSessionEventHealthBySession: SessionEventHealthUpdater; @@ -649,7 +635,6 @@ export function useSessionEventHealthPolling(options: { activeSession, activeStreamingLive, hasInFlightLiveTools, - refreshMessages, refreshSessions, sessionEventHealthBySessionRef, setSessionEventHealthBySession, @@ -672,7 +657,6 @@ export function useSessionEventHealthPolling(options: { })); if (result.shouldRefresh) { void refreshSessions(); - void refreshMessages(activeId); } }; // #1979: a stream nobody expects has nothing to observe — `evaluate` can only diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts index 7bd256fb07..4c13c05444 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts @@ -93,7 +93,6 @@ export function createAppShellRevisionActions(deps: { messages: readonly StoredMessage[]; hasPendingAttachments: () => boolean; openSessionInChat: (sessionId: string, turnId?: string) => void; - refreshMessages: (sessionId: string) => Promise; refreshSessions: () => Promise; setMessages: MessageListUpdater; commitRevisionDraft: (draft: TurnRevisionDraft | null) => void; @@ -107,7 +106,6 @@ export function createAppShellRevisionActions(deps: { messages, hasPendingAttachments, openSessionInChat, - refreshMessages, refreshSessions, setMessages, commitRevisionDraft, @@ -213,7 +211,6 @@ export function createAppShellRevisionActions(deps: { if (activeIdRef.current === revisionSessionId) { openSessionInChat(draft.sourceSessionId); setMessages([]); - await refreshMessages(draft.sourceSessionId).catch(() => false); } const abandonment = await abandonRevisionCopy(draft); const abandoningDraft = abandonment.draft; @@ -397,7 +394,6 @@ export function createAppShellRevisionActions(deps: { if (activeIdRef.current !== draft.sourceSessionId) { openSessionInChat(draft.sourceSessionId); setMessages([]); - await refreshMessages(draft.sourceSessionId).catch(() => false); } if (cleanupSessionId) { await refreshSessions().catch(() => []); diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index dfdf850ec3..7d873f4aff 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -332,7 +332,6 @@ export function createAppShellSessionEventHandlers(options: { break; case 'tool_result': setInteractionBySession((current) => dequeueInteractionByToolUseId(current, sessionId, event.toolUseId)); - void refreshMessages(sessionId); break; case 'error': onInteractionChanged?.(sessionId); @@ -357,13 +356,19 @@ export function createAppShellSessionEventHandlers(options: { } notifyRunEnded?.({ kind: 'errored', sessionId, body: sessionEventErrorMessage(event, uiLocale) }); void refreshSessions(); - void refreshMessages(sessionId, terminalRefreshOptions(before)); + { + const options = terminalRefreshOptions(before); + if (options) void refreshMessages(sessionId, options); + } break; case 'abort': onInteractionChanged?.(sessionId); setInteractionBySession((current) => clearInteractions(current, sessionId)); void refreshSessions(); - void refreshMessages(sessionId, terminalRefreshOptions(before)); + { + const options = terminalRefreshOptions(before); + if (options) void refreshMessages(sessionId, options); + } break; case 'complete': { onInteractionChanged?.(sessionId); @@ -383,8 +388,6 @@ export function createAppShellSessionEventHandlers(options: { // callback remains the fast path, but a remount or interrupted-turn // race can no longer strand the final reply in live-only state. void handoffAssistantStreaming(sessionId, terminalMessageId, false); - } else { - void refreshMessages(sessionId); } break; } diff --git a/apps/desktop/src/renderer/app-shell-session-ui-state.ts b/apps/desktop/src/renderer/app-shell-session-ui-state.ts index bd3ba23947..fac9a4258d 100644 --- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts @@ -20,7 +20,7 @@ import { useRef } from 'react'; import type { MessageQueueEntryProjection } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; -import { confirmLiveTurn, type InteractionQueues, type LiveTurnProjection } from '@maka/ui'; +import type { InteractionQueues, LiveTurnProjection } from '@maka/ui'; import type { ShellRunUpdatesBySession } from './shell-run-update-state.js'; type StateUpdater = (updater: (current: T) => T) => void; @@ -186,20 +186,6 @@ export function createAppShellSessionUiStateController( }) satisfies StateUpdater>, setPendingPermissionModeBySession: createMapSetter('pendingPermissionModeBySession'), setPendingSessionModelBySession: createMapSetter('pendingSessionModelBySession'), - /** - * The authority said something about `turnId` — it started, failed to - * start, or ended. Drop that arm's `unconfirmed` claim so a session list - * may settle it again. An answer about a turn this session is not on says - * nothing, and leaves the state untouched. - */ - confirmLiveTurn: (sessionId: string, turnId: string) => { - updateMap('liveTurnBySession', (current) => { - const armed = current[sessionId]; - if (!armed) return current; - const confirmed = confirmLiveTurn(armed, turnId); - return confirmed === armed ? current : { ...current, [sessionId]: confirmed! }; - }); - }, clearSessionUiState: (sessionId: string) => { sessionEventHealthBySessionRef.current = omitSessionKey( sessionEventHealthBySessionRef.current, @@ -248,7 +234,6 @@ export function useAppShellSessionUiState() { setSessionEventHealthBySession: controller.setSessionEventHealthBySession, setPendingPermissionModeBySession: controller.setPendingPermissionModeBySession, setPendingSessionModelBySession: controller.setPendingSessionModelBySession, - confirmLiveTurn: controller.confirmLiveTurn, clearSessionUiState: controller.clearSessionUiState, clearTurnTransientStateIfCurrent: controller.clearTurnTransientStateIfCurrent, }; diff --git a/apps/desktop/src/renderer/app-shell-turn-actions.ts b/apps/desktop/src/renderer/app-shell-turn-actions.ts index 1c594d0cc5..feccc20e40 100644 --- a/apps/desktop/src/renderer/app-shell-turn-actions.ts +++ b/apps/desktop/src/renderer/app-shell-turn-actions.ts @@ -54,7 +54,6 @@ export function createAppShellTurnActions(deps: { clearPendingTurnAction: (key: string) => void; openSessionInChat: (sessionId: string, turnId?: string) => void; pendingKeyOf: (sessionId: string, turnId: string, actionId: TurnFooterActionMeta['id']) => string; - refreshMessages: (sessionId: string) => Promise; refreshSessions: () => Promise; setMessages: MessageListUpdater; toastApi: ToastApi; @@ -66,7 +65,6 @@ export function createAppShellTurnActions(deps: { clearPendingTurnAction, openSessionInChat, pendingKeyOf, - refreshMessages, refreshSessions, setMessages, toastApi, @@ -107,7 +105,6 @@ export function createAppShellTurnActions(deps: { if (activeIdRef.current === sessionId) { openSessionInChat(newSession.id); setMessages([]); - await refreshMessages(newSession.id); toastApi.success(copy.branchCreatedTitle, copy.branchCreatedDescription(newSession.name)); } await refreshSessions(); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 29a97fb25b..7cb975c365 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -104,9 +104,9 @@ import { useNewTaskChoice } from './use-new-task-choice'; import { NEW_TASK_PENDING_KEY } from './pending-items'; import { parseDesktopSlashCommand } from './desktop-slash-command'; import { - hasActiveTurnAtSubmit, mergeWorkspaceReferences, resolveFollowUpModeAtSubmit, + returnToLatestBeforeSubmit, } from './follow-up-submit-routing'; import { PlanExecutionPanel, @@ -380,7 +380,6 @@ function AppShellContent({ setMessageRetryPendingBySession, setStopPendingBySession, setLiveTurnBySession, - confirmLiveTurn, setShellRunUpdatesBySession, setInteractionBySession, setMessageQueueBySession, @@ -1819,7 +1818,6 @@ function AppShellContent({ setMessages, transcriptRangeRef, setNavSelection, - setLiveTurnBySession, setInteractionBySession, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, @@ -1841,7 +1839,6 @@ function AppShellContent({ clearPendingTurnAction: turnActionRegistry.clearKey, openSessionInChat, pendingKeyOf, - refreshMessages, refreshSessions, setMessages, toastApi, @@ -1868,7 +1865,6 @@ function AppShellContent({ messages, hasPendingAttachments: () => pendingAttachments.length > 0, openSessionInChat, - refreshMessages, refreshSessions, setMessages, commitRevisionDraft, @@ -1909,6 +1905,9 @@ function AppShellContent({ mode: FollowUpMode, metadata?: ComposerSendMetadata, ): Promise { + if (!(await returnToLatestBeforeSubmit({ sessionId, activeIdRef, transcriptRangeRef }))) { + return false; + } const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; const attachmentItems = pending ? toComposerIngestItems(pending) : []; @@ -1930,7 +1929,6 @@ function AppShellContent({ if (pending) clearSubmittedAttachments(pending); if (quotes) clearQuotes(); if (result.kind === 'started') { - await refreshMessages(sessionId); await refreshSessions(); } return true; @@ -1956,24 +1954,15 @@ function AppShellContent({ revision && activeIdRef.current === revision.draftSessionId, ); const slashCommand = parseDesktopSlashCommand(text); - // Read the synchronous live-turn store at submit time. React's rendered - // `streaming` prop can lag one commit behind a just-started turn, which - // previously sent a second root turn and surfaced duplicate session_busy - // errors during burst input. const sessionId = activeIdRef.current; const workspaceFileReferences = mergeWorkspaceReferences( text, metadata?.workspaceFileReferences, sessionId ? retractedWorkspaceReferencesRef.current[sessionId] : undefined, ); - const liveTurn = sessionId ? liveTurnBySessionRef.current[sessionId] : undefined; - const runningTurnIds = sessionId - ? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds - : undefined; const followUpAtSubmit = !slashCommand ? resolveFollowUpModeAtSubmit({ requestedMode: metadata?.followUpMode, - hasActiveTurn: hasActiveTurnAtSubmit({ liveTurn, runningTurnIds }), }) : undefined; if (sessionId && followUpAtSubmit) { @@ -2292,7 +2281,6 @@ function AppShellContent({ applyE2eFixture, bootstrapSessions, clearPendingTurnActionsForSession: turnActionRegistry.clearForSession, - confirmLiveTurn, clearSessionRendererState, createSession, handleConnectionEvent, @@ -2306,7 +2294,6 @@ function AppShellContent({ projectPickerRequestRef, refreshConnections: refreshConnectionProjections, refreshMemoryActive, - refreshMessages, refreshScheduledTasks, refreshProjects, refreshShellSettings, @@ -2439,7 +2426,6 @@ function AppShellContent({ activeSession, activeStreamingLive, hasInFlightLiveTools, - refreshMessages, refreshSessions, sessionEventHealthBySessionRef, setSessionEventHealthBySession, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 7c4bcb5be8..920eb98408 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -21,7 +21,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { activeInteractionFor, applyLiveTurnEvent, - armLiveTurn, reconcileTerminalLiveTurn, useMountedRef, type InteractionQueues, @@ -439,13 +438,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan sessionId, }), onForkCommitted: () => {}, - // Arm the optimistic live turn right before the send. onBeforeSend: () => { stopRequestedRef.current = false; activeTurnIdRef.current = turnId; turnInFlightRef.current = true; setTurnInFlight(true); - setLiveTurn(armLiveTurn(turnId)); ownTurnIdsRef.current.add(turnId); setOwnTurnTick((tick) => tick + 1); }, @@ -565,7 +562,6 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan turnInFlightRef.current = true; setTurnInFlight(true); setError(null); - setLiveTurn(armLiveTurn(regenerationTurnId)); ownTurnIdsRef.current.add(regenerationTurnId); setOwnTurnTick((tick) => tick + 1); try { diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts index 768477dba3..645a401fd4 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -18,28 +18,41 @@ */ import type { FollowUpMode, InlineReference } from '@maka/core/events'; +import type { DesktopTranscriptRangeController } from './desktop-transcript-range-store.js'; export interface WorkspaceFileReferencePosition { value: string; start: number; } -export function hasActiveTurnAtSubmit(input: { - liveTurn?: { turnId: string; terminal?: boolean }; - runningTurnIds?: readonly string[]; -}): boolean { - if (input.liveTurn?.terminal !== true && input.liveTurn !== undefined) return true; - return input.runningTurnIds?.some((turnId) => turnId !== input.liveTurn?.turnId) === true; -} - export function resolveFollowUpModeAtSubmit(input: { requestedMode?: FollowUpMode; - hasActiveTurn: boolean; -}): FollowUpMode | undefined { - if (input.requestedMode) return input.requestedMode; - // Mid-turn submits always queue; Shift+Enter carries the one-shot steer as - // the requested mode. - return input.hasActiveTurn ? 'queue' : undefined; +}): FollowUpMode { + // Existing-session text always enters through the Host's atomic message + // admission. An idle Host starts a turn; an active Host queues it. Shift+Enter + // is the only renderer-owned choice because the user explicitly requested + // the current-turn steering lane. + return input.requestedMode ?? 'queue'; +} + +export async function returnToLatestBeforeSubmit(input: { + sessionId: string; + activeIdRef: { current: string | undefined }; + transcriptRangeRef: { current: DesktopTranscriptRangeController | undefined }; +}): Promise { + const controller = input.transcriptRangeRef.current; + if (!controller) return true; + let hasNewer = false; + try { + const range = controller.store.range(); + hasNewer = range.sessionId === input.sessionId && range.hasNewer; + } catch { + // An unopened transcript is not a sparse historical view. + } + if (!hasNewer) return true; + await controller.loadLatest(); + return input.activeIdRef.current === input.sessionId + && input.transcriptRangeRef.current === controller; } export function mergeWorkspaceReferences( diff --git a/apps/desktop/src/renderer/settled-session-transients.ts b/apps/desktop/src/renderer/settled-session-transients.ts index 4509f09a28..9eaa2d2db3 100644 --- a/apps/desktop/src/renderer/settled-session-transients.ts +++ b/apps/desktop/src/renderer/settled-session-transients.ts @@ -24,21 +24,9 @@ import type { LiveTurnProjection } from '@maka/ui'; * Which sessions' turn transients are safe to drop, i.e. whose turn the * authority says is over. * - * A session's `status` alone cannot say that. It reads `active` both before the - * runtime's `running` write — which lands only at the end of `AgentRun.begin`, - * announced by nothing until this renderer's own send is confirmed — and after - * the turn ends. A list refreshed inside that window is byte-identical to one - * taken after the turn finished, so reading it as a settle drops the arm the - * send just created, taking the whole first-token wait with it (the projection - * gets rebuilt by the first content event as `'streamed'`, downgrading the - * prominent "正在处理…" to the calm "继续中…"). - * - * The arm's own `unconfirmed` bit supplies the missing identity: while it is - * set, this renderer has sent a turn the authority has not yet answered about, - * and no session-level status may be read as an answer. It is cleared by the - * first word about that exact turn — its start, its failure to start, its end, - * or any of its events — so a status change caused by some OTHER turn (another - * client, a scheduled task) cannot release it early. + * Host-owned running turn identities outrank the coarser persisted status. The + * renderer no longer creates an optimistic live turn before Host admission, so + * there is no client-owned pending claim to reconcile here. */ export function settledSessionTransientIds(options: { activeId?: string; @@ -54,7 +42,6 @@ export function settledSessionTransientIds(options: { if (session.status === 'waiting_for_user') return []; if (session.runningTurnIds === undefined && session.status === 'running') return []; const projection = options.liveTurnBySession[session.id]; - if (projection?.unconfirmed) return []; if (session.id === options.activeId && projection?.terminal) return []; return [session.id]; }); diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index a43043eeda..151ae79c15 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -114,6 +114,5 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { setSessionEventHealthBySession: sessionUi.setSessionEventHealthBySession, setPendingPermissionModeBySession: sessionUi.setPendingPermissionModeBySession, setPendingSessionModelBySession: sessionUi.setPendingSessionModelBySession, - confirmLiveTurn: sessionUi.confirmLiveTurn, }; } diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 5b6656f08b..f77fb9bc7a 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -23,7 +23,6 @@ import { encodeToolStepProgress } from '@maka/core/events'; import { applyLiveTurnEvent, armLiveTurn, - confirmLiveTurn, reconcileTerminalLiveTurn, settleLiveTurnStep, type LiveTurnProjection, @@ -32,42 +31,6 @@ import { materializeTurns, overlayLiveTurn, type ToolActivityItem } from '../mat import { redactSecrets } from '../redact.js'; import { getConversationCopy } from '../conversation-copy.js'; -// A client that just sent cannot read "has my turn started" off session status: -// it is the same before the turn starts and after it ends. The arm carries -// `unconfirmed` until the authority says something about THAT turn, which is -// what stops a snapshot taken before the send landed from retiring it. -describe('the unconfirmed claim an arm carries', () => { - it('is set at arm and dropped by an answer naming the same turn', () => { - const armed = armLiveTurn('turn-1'); - assert.equal(armed.unconfirmed, true); - - const confirmed = confirmLiveTurn(armed, 'turn-1'); - assert.equal(confirmed?.unconfirmed, undefined); - assert.equal(confirmed?.turnId, 'turn-1'); - assert.equal(confirmed?.phase, 'waiting', 'confirming is not the same as streaming'); - }); - - // Another client's turn, or a scheduled task's, says nothing about this send. - it('survives an answer that names a different turn', () => { - const armed = armLiveTurn('turn-mine'); - - assert.equal(confirmLiveTurn(armed, 'turn-theirs'), armed); - }); - - it('is dropped by the turn\'s own events, not just by an explicit answer', () => { - const streamed = applyLiveTurnEvent(armLiveTurn('turn-1'), { - type: 'text_delta', - id: 'event-1', - turnId: 'turn-1', - messageId: 'step-1', - ts: 100, - text: '你', - }); - - assert.equal(streamed.unconfirmed, undefined); - }); -}); - describe('provider retry copy', () => { it('describes capacity retries without collapsing them into generic unavailability', () => { assert.match(getConversationCopy('zh').messages.providerRetryReason.provider_capacity, /满载/); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index c05d77fd26..1aacd20165 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -258,20 +258,7 @@ export function ChatView(props: { const copy = conversationCopy.chat; // chat survives for the empty-state path; the main message log is driven by // `turns` (per @kenji UI-04 turn-grouping projection). - const drainingMessageIdsKey = JSON.stringify( - props.liveTurn?.steps.flatMap((step) => step.text ? [step.stepId] : []) ?? [], - ); - const drainingMessageIds = useMemo( - () => new Set(JSON.parse(drainingMessageIdsKey) as string[]), - [drainingMessageIdsKey], - ); - const visibleMessages = useMemo( - () => drainingMessageIds.size > 0 - ? props.messages.filter((message) => !(message.type === 'assistant' && drainingMessageIds.has(message.id))) - : props.messages, - [drainingMessageIds, props.messages], - ); - const chat = useMemo(() => materializeChat(visibleMessages, locale), [visibleMessages, locale]); + const chat = useMemo(() => materializeChat(props.messages, locale), [props.messages, locale]); // The projection owns the derived turns, so a turn nothing said anything // about keeps its object identity and its memoized TurnView skips — across // deltas AND across the message refreshes that fire at every step/tool @@ -279,7 +266,7 @@ export function ChatView(props: { const turns = useTranscriptProjection({ sessionId: props.activeSession?.id, locale, - messages: visibleMessages, + messages: props.messages, liveTurn: props.liveTurn, shellRunUpdates: props.shellRunUpdates, }); diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index 5de4682f30..ff3428f62f 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -81,18 +81,6 @@ export interface LiveTurnProjection { terminal?: true; /** Steering acknowledged after the current content and awaiting its next provider step. */ pendingSteering?: LiveSteeringProjection[]; - /** - * Set by `armLiveTurn` and cleared by the first word the authority says about - * this turn (`confirmLiveTurn`, or any event carrying the same turnId). - * - * A client that just sent cannot tell "the authority has not reached my turn - * yet" from "my turn is over" by reading session status: it reads the same - * before a turn starts and after it ends. So a snapshot taken before the send - * landed would retire the arm the send just placed. This bit says the arm is - * still waiting for its answer, which is what keeps such a snapshot from - * settling it. Dropped for good once the answer arrives. - */ - unconfirmed?: true; providerRetry?: ProviderRetryEvent; steps: LiveTurnStepProjection[]; } @@ -152,27 +140,7 @@ function appendContentKind( } export function armLiveTurn(turnId: string): LiveTurnProjection { - return { turnId, phase: 'waiting', steps: [], unconfirmed: true }; -} - -/** Drop the `unconfirmed` claim; identity-preserving when there is none. */ -function confirmed(projection: LiveTurnProjection): LiveTurnProjection { - if (!projection.unconfirmed) return projection; - const { unconfirmed: _unconfirmed, ...rest } = projection; - return rest; -} - -/** - * The authority answered about `turnId`: clear the arm's pending claim so a - * later snapshot may retire it. A different turn's answer says nothing about - * this one, so the projection is returned unchanged (same reference). - */ -export function confirmLiveTurn( - current: LiveTurnProjection | undefined, - turnId: string, -): LiveTurnProjection | undefined { - if (!current || current.turnId !== turnId) return current; - return confirmed(current); + return { turnId, phase: 'waiting', steps: [] }; } export function applyLiveTurnEvent( @@ -195,10 +163,10 @@ export function applyLiveTurnEvent( ? current : { turnId: event.turnId, phase: 'waiting' as const, steps: [] }; if (liveSteeringMessages(prior).some((message) => message.id === event.messageId)) { - return confirmed(prior); + return prior; } return { - ...confirmed(prior), + ...prior, pendingSteering: [ ...(prior.pendingSteering ?? []), { @@ -213,13 +181,13 @@ export function applyLiveTurnEvent( const prior = current?.turnId === event.turnId ? current : { turnId: event.turnId, phase: 'waiting' as const, steps: [] }; - return { ...confirmed(prior), providerRetry: event }; + return { ...prior, providerRetry: event }; } if (event.type === 'error' || event.type === 'abort') { if (!current || current.turnId !== event.turnId) return current; const steps = terminalizeLiveSteps(current.steps); if (steps.length === 0 && liveSteeringMessages(current).length === 0) return undefined; - const { providerRetry: _providerRetry, ...withoutRetry } = confirmed(current); + const { providerRetry: _providerRetry, ...withoutRetry } = current; return { ...withoutRetry, terminal: true, steps }; } if (event.type === 'complete') { @@ -227,7 +195,7 @@ export function applyLiveTurnEvent( if (current.steps.length === 0 && liveSteeringMessages(current).length === 0) { return undefined; } - const { providerRetry: _providerRetry, ...withoutRetry } = confirmed(current); + const { providerRetry: _providerRetry, ...withoutRetry } = current; return { ...withoutRetry, terminal: true, @@ -250,7 +218,7 @@ export function applyLiveTurnEvent( const prior = current?.turnId === event.turnId ? current : { turnId: event.turnId, phase: 'streamed' as const, steps: [] }; - const { providerRetry: _providerRetry, ...priorWithoutRetry } = confirmed(prior); + const { providerRetry: _providerRetry, ...priorWithoutRetry } = prior; const messageEvent = event.type === 'thinking_delta' || event.type === 'thinking_complete' || event.type === 'text_delta' From 729aa0aa0bfa86105ca9ac9659da06add6ce12ca Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 05:17:16 +0800 Subject: [PATCH 16/33] test(desktop): exercise Host message admission over UDS Update the real-framing execution test to cover the ordinary-message operation now used by Desktop instead of retaining the removed direct turn-start contract. Generated-by: Codex --- .../main/__tests__/runtime-host-client-uds.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 887a852605..8585e1f610 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -345,20 +345,14 @@ test('drives the renderer Session execution facade through real UDS framing', as result: { kind: 'managed', access: 'read_only', revision: 2 }, }; }, - 'turn.start': async (input) => { + 'turn.message.submit': async (input) => { assert.equal(input.sessionId, projected.id); assert.equal(input.content.text, 'Run through the Host'); return { ok: true, result: { - kind: 'started', - turn: { - sessionId: input.sessionId, - turnId: input.turnId, - runId: 'run-1', - status: 'running', - }, - skillInvocation: { loaded: [], failed: [], receipts: [] }, + disposition: 'turn_started', + turnId: input.messageId, }, }; }, From c4fb4dfcde19193c952e539de5b7ff9de3999ff3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 05:41:40 +0800 Subject: [PATCH 17/33] fix(desktop): preserve control and companion turn identity Keep revision and Skill inputs on the turn-start control path while ordinary messages use Host admission. Rebind Side Chat to the Host-owned Turn identity from either the first event or admission response, and assert remount continuity without requiring an unobservable React intermediate paint. Generated-by: Codex --- apps/desktop/e2e/streaming-remount.spec.ts | 30 ++++++++----------- .../follow-up-submit-routing.test.ts | 8 +++++ .../quote-companion-disposal.test.ts | 22 +++++++++++--- apps/desktop/src/renderer/app-shell.tsx | 3 ++ .../src/renderer/features/workbar/ports.ts | 2 +- .../tools/side-chat/quote-companion-core.ts | 12 +++++--- .../tools/side-chat/use-quote-companion.ts | 14 +++++++-- .../src/renderer/follow-up-submit-routing.ts | 4 ++- .../stories/session-workbar.stories.tsx | 2 +- 9 files changed, 65 insertions(+), 32 deletions(-) diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index 8547e104f5..ba68479f74 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -77,29 +77,23 @@ test('remounting a live surface leaves accumulated output settled', async ({ ), ).toBe(0); - await liveBubble.evaluate((element) => { - const observed = { texts: [] as string[] }; - (window as typeof window & { __makaStreamingRemountObserved?: typeof observed }) - .__makaStreamingRemountObserved = observed; - new MutationObserver(() => { - observed.texts.push(element.textContent ?? ''); - }).observe(element, { childList: true, characterData: true, subtree: true }); - }); + const bubbleBeforeRewrite = await liveBubble.elementHandle(); + expect(bubbleBeforeRewrite).not.toBeNull(); const steering = 'trigger rewrite after returning to this conversation'; await steerActiveTurn(composer, steering); const finalText = 'prefix NEW streamed after the remount'; await expect(liveBubble).toContainText(finalText); - - const observed = await page.evaluate(() => ( - window as typeof window & { - __makaStreamingRemountObserved?: { - texts: string[]; - }; - } - ).__makaStreamingRemountObserved); - expect(observed?.texts.some((text) => text.includes('') && !text.includes(finalText))) - .toBe(true); + await expect(liveBubble).not.toContainText(accumulatedOutput); + // React may batch the one rewrite delta into its final redacted paint. The + // product invariant is that the live answer survives as the same DOM node, + // not that an intermediate frame is always observable. + expect( + await liveBubble.evaluate( + (element, before) => element.isSameNode(before), + bubbleBeforeRewrite, + ), + ).toBe(true); }); test('keeps a completed reply after an interrupted turn and conversation remount', async ({ diff --git a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts index c9c8e4361f..8817023834 100644 --- a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts +++ b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts @@ -45,6 +45,14 @@ describe('follow-up submit routing', () => { ); }); + it('keeps revision and Skill control input on the turn-start path', () => { + assert.equal(resolveFollowUpModeAtSubmit({ requiresTurnStart: true }), undefined); + assert.equal( + resolveFollowUpModeAtSubmit({ requestedMode: 'steer', requiresTurnStart: true }), + undefined, + ); + }); + it('restores workspace references after queued text returns to the draft', () => { assert.deepEqual( mergeWorkspaceReferences( diff --git a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts index 8553509e2d..534647bb7e 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts @@ -113,6 +113,20 @@ describe('quote companion disposal fencing', () => { ); }); + it('returns the Host-owned turn identity after message admission', async () => { + const defaults = createFakeWorkbarServices(); + const sideChat = { + ...defaults.sideChat, + send: async () => ({ ok: true as const, turnId: 'host-turn' }), + }; + + assert.deepEqual(await performCompanionTurn(turnDeps(sideChat)), { + status: 'sent', + forkId: 'side-chat-existing-fork', + turnId: 'host-turn', + }); + }); + it('does not start a send when the panel was disposed after fork setup', async () => { let sends = 0; let armed = 0; @@ -121,7 +135,7 @@ describe('quote companion disposal fencing', () => { ...defaults.sideChat, send: async () => { sends += 1; - return { ok: true as const }; + return { ok: true as const, turnId: 'host-turn' }; }, }; @@ -140,7 +154,7 @@ describe('quote companion disposal fencing', () => { }); it('does not consume quotes or report success when disposal wins the send race', async () => { - const pendingSend = deferred<{ ok: true }>(); + const pendingSend = deferred<{ ok: true; turnId: string }>(); let disposed = false; let consumed = 0; const defaults = createFakeWorkbarServices(); @@ -158,7 +172,7 @@ describe('quote companion disposal fencing', () => { ); disposed = true; - pendingSend.resolve({ ok: true }); + pendingSend.resolve({ ok: true, turnId: 'host-turn' }); assert.deepEqual(await turn, { status: 'disposed' }); assert.equal(consumed, 0); @@ -179,7 +193,7 @@ describe('quote companion disposal fencing', () => { }, send: async () => { sends += 1; - return { ok: true as const }; + return { ok: true as const, turnId: 'host-turn' }; }, }; const turn = performCompanionTurn( diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 7cb975c365..3b11767f3e 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -40,6 +40,7 @@ import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; +import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { collapseSessionRevisions } from '@maka/core/session-revisions'; import { isLinkedSubagentSession } from '@maka/core/session'; import { resolveUiLocale } from '@maka/core/ui-locale'; @@ -1963,6 +1964,8 @@ function AppShellContent({ const followUpAtSubmit = !slashCommand ? resolveFollowUpModeAtSubmit({ requestedMode: metadata?.followUpMode, + requiresTurnStart: + revisionSend || new RegExp(SKILL_INVOCATION_TOKEN_SOURCE).test(text), }) : undefined; if (sessionId && followUpAtSubmit) { diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index fb14676742..13beaf123c 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -197,7 +197,7 @@ export interface WorkbarAttachmentsService { } export type SideChatSendResult = - | { ok: true } + | { ok: true; turnId: string; steered?: true } | { ok: false; reason?: string }; export interface SideChatSessionPort { diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts index dceb846301..f3e81adb00 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts @@ -283,7 +283,7 @@ export async function ensureCompanionFork( } export type CompanionTurnResult = - | { status: 'sent'; forkId: string } + | { status: 'sent'; forkId: string; turnId?: string } | { status: 'disposed' } | { status: 'error'; code: CompanionErrorCode }; @@ -296,7 +296,7 @@ export interface PerformCompanionTurnDeps extends EnsureCompanionForkDeps { attachmentItems?: WorkbarIngestInput[]; /** Fired once a fork is ready, so the caller can commit it. */ onForkCommitted: (session: SessionSummary) => void; - /** Fired right before the send — the caller arms the optimistic live turn here. */ + /** Fired right before the send so the caller can close same-frame retries. */ onBeforeSend: (forkId: string) => void; /** Fired ONLY after `send` is accepted, so a failed send keeps the staged * quotes (and draft) in place for a retry. */ @@ -335,7 +335,7 @@ export async function performCompanionTurn( if (createdForkId) scheduleCompanionCleanup(deps, createdForkId); return { status: 'disposed' }; } - let result: { ok: true } | { ok: false; reason?: string }; + let result: { ok: true; turnId: string; steered?: true } | { ok: false; reason?: string }; try { result = await deps.api.send(forkId, { type: 'send', @@ -356,7 +356,11 @@ export async function performCompanionTurn( return { status: 'error', code: 'send_rejected' }; } deps.onQuotesConsumed(); - return { status: 'sent', forkId }; + return { + status: 'sent', + forkId, + ...(!result.steered ? { turnId: result.turnId } : {}), + }; } export function isCompanionTurnTerminal(event: SessionEvent): boolean { diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 920eb98408..d5279d03ae 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -206,6 +206,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }); unsubscribeRef.current = sideChat.subscribeEvents(forkId, (event: SessionEvent) => { if (!mountedRef.current) return; + if (turnInFlightRef.current && activeTurnIdRef.current === null && event.turnId) { + activeTurnIdRef.current = event.turnId; + ownTurnIdsRef.current.add(event.turnId); + setOwnTurnTick((tick) => tick + 1); + } const effect = companionRunEventEffect( event, activeTurnIdRef.current, @@ -440,15 +445,18 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan onForkCommitted: () => {}, onBeforeSend: () => { stopRequestedRef.current = false; - activeTurnIdRef.current = turnId; + activeTurnIdRef.current = null; turnInFlightRef.current = true; setTurnInFlight(true); - ownTurnIdsRef.current.add(turnId); - setOwnTurnTick((tick) => tick + 1); }, onQuotesConsumed: () => onQuotesConsumed(quoteSnapshot), }); if (result.status === 'sent') { + if (activeTurnIdRef.current === null && result.turnId) { + activeTurnIdRef.current = result.turnId; + ownTurnIdsRef.current.add(result.turnId); + setOwnTurnTick((tick) => tick + 1); + } setHasContent(true); // Surface the just-sent user message immediately, and reflect any // automatic connection/model rebound in the read-only model label. diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts index 645a401fd4..f66585c7ff 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -27,7 +27,9 @@ export interface WorkspaceFileReferencePosition { export function resolveFollowUpModeAtSubmit(input: { requestedMode?: FollowUpMode; -}): FollowUpMode { + requiresTurnStart?: boolean; +}): FollowUpMode | undefined { + if (input.requiresTurnStart) return undefined; // Existing-session text always enters through the Host's atomic message // admission. An idle Host starts a turn; an active Host queues it. Shift+Enter // is the only renderer-owned choice because the user explicitly requested diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 600fd6ebfa..3831e31ec0 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -709,7 +709,7 @@ function bridge(options: { branchFromTurn: async () => ({ ok: true, session: SIDE_CHAT_SESSION }), cleanupSessionCopy: async () => undefined, abandonSessionCopy: async () => undefined, - send: async () => ({ ok: true }), + send: async (_sessionId, command) => ({ ok: true, turnId: command.turnId }), stop: async () => undefined, steer: async () => ({ kind: 'queued' }), setPermissionMode: async (_sessionId, mode) => ({ From b12061bd833d0f776ea4079be3d768a0bf048a3b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 05:54:54 +0800 Subject: [PATCH 18/33] test(desktop): assert settled remount behavior Generated-by: Codex --- apps/desktop/e2e/streaming-remount.spec.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index ba68479f74..eb9540dfcd 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -77,23 +77,14 @@ test('remounting a live surface leaves accumulated output settled', async ({ ), ).toBe(0); - const bubbleBeforeRewrite = await liveBubble.elementHandle(); - expect(bubbleBeforeRewrite).not.toBeNull(); - const steering = 'trigger rewrite after returning to this conversation'; await steerActiveTurn(composer, steering); const finalText = 'prefix NEW streamed after the remount'; await expect(liveBubble).toContainText(finalText); await expect(liveBubble).not.toContainText(accumulatedOutput); // React may batch the one rewrite delta into its final redacted paint. The - // product invariant is that the live answer survives as the same DOM node, - // not that an intermediate frame is always observable. - expect( - await liveBubble.evaluate( - (element, before) => element.isSameNode(before), - bubbleBeforeRewrite, - ), - ).toBe(true); + // product contract is the settled text, not a particular intermediate frame + // or DOM node identity across the transcript handoff. }); test('keeps a completed reply after an interrupted turn and conversation remount', async ({ @@ -190,7 +181,7 @@ test('returning to a live conversation settles output accumulated while away', a const accumulatedOutput = 'Fake backend waiting for the test to stop the Turn.'; const liveBubble = page.locator('.maka-bubble-streaming'); - await expect(liveBubble).toContainText(accumulatedOutput); + await expect(liveBubble).toContainText(accumulatedOutput, { timeout: 20_000 }); const sidebar = page.getByRole('navigation', { name: '任务列表' }); await page.getByRole('button', { name: '展开侧边栏' }).click(); From 08ea477f94653d55adb7155a5a6c87cd7d50559e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 11:28:45 +0800 Subject: [PATCH 19/33] fix(runtime-host): close steering recovery cuts Generated-by: Maka --- .../src/__tests__/message-coordinator.test.ts | 124 +++++++++++++++++- .../src/server/message-coordinator.ts | 73 +++++++++-- .../src/server/root-turn-coordinator.ts | 14 +- 3 files changed, 189 insertions(+), 22 deletions(-) diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 0d281754dd..771106992d 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -36,6 +36,7 @@ import { import { HostMessageCoordinator, type HostMessageCoordinatorOptions, + type HostMessageRecoveryBatch, type HostMessageRootPort, type HostMessageRootState, } from '../server/message-coordinator.js'; @@ -268,7 +269,7 @@ test('binds the exact reserved Run after a pre-bind stop fence', async () => { fixture.coordinator.reserveRootTurn(ROOT); assert.equal((await submit(fixture, 'queued-before-bind', 'discard me', 'next_turn')).ok, true); - const fence = fixture.coordinator.commitStopFence(ROOT); + const fence = await fixture.coordinator.commitStopFence(ROOT); assert.equal(fence.retracted.length, 1); assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).followup, []); assert.equal(fixture.liveResidencies(), 0); @@ -899,6 +900,111 @@ test('entry promote durably admits the message before making it non-retractable' assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).steering, []); }); +test('retract settles a failed promotion so restart cannot recover it', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'promoted-followup', 'send this now', 'next_turn'); + const delay = fixture.delaySteeringAdmission(new Error('durable promotion failed')); + + const promotion = fixture.coordinator.handlers['queue.entry.promote']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: 'id-1', + promoteId: 'promote-durable', + }, + operationContext(), + ); + await delay.started.promise; + delay.release.resolve(undefined); + await assert.rejects(promotion, /durable promotion failed/); + assert.equal(fixture.pendingSteeringCount(), 1); + + const retracted = await fixture.coordinator.handlers['queue.entry.retract']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: 'id-1', + retractId: 'retract-failed-promotion', + }, + operationContext(), + ); + assert.equal(retracted.ok, true); + assert.equal(fixture.pendingSteeringCount(), 0); +}); + +test('failed promotion retry reuses the durable admission timestamp', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'promoted-followup', 'send this now', 'next_turn'); + const delay = fixture.delaySteeringAdmission(new Error('durable promotion failed')); + + const first = fixture.coordinator.handlers['queue.entry.promote']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: 'id-1', + promoteId: 'promote-first', + }, + operationContext(), + ); + await delay.started.promise; + delay.release.resolve(undefined); + await assert.rejects(first, /durable promotion failed/); + + const retry = await fixture.coordinator.handlers['queue.entry.promote']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: 'id-1', + promoteId: 'promote-retry', + }, + operationContext(), + ); + assert.equal(retry.ok, true); +}); + +test('restart recovers only the first contiguous initiating-client batch', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await fixture.coordinator.handlers['turn.message.submit']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + messageId: 'steering-b', + content: { text: 'from B' }, + placement: 'current_turn', + }, + operationContext('connection-b'), + ); + await fixture.coordinator.handlers['turn.message.submit']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + messageId: 'steering-a', + content: { text: 'from A' }, + placement: 'current_turn', + }, + operationContext('connection-a'), + ); + fixture.setRootState({ kind: 'idle' }); + + const restarted = fixture.restart('epoch-2'); + await restarted.recoverPendingAfterHostRestart(); + + assert.equal(fixture.recoveredBatches.length, 1); + assert.equal(fixture.recoveredBatches[0]?.initiatingConnectionId, 'connection-b'); + assert.deepEqual( + fixture.recoveredBatches[0]?.sources.map((source) => source.messageId), + ['steering-b'], + ); + assert.deepEqual( + restarted.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), + ['steering-a'], + ); + assert.equal(fixture.pendingSteeringCount(), 1); +}); + test('entry promote requires an active Turn', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); @@ -1693,6 +1799,8 @@ test('administrative drain preserves accepted entries until the terminal stop fe owner.release(); await fixture.coordinator.prepareTerminalTransition(ROOT); + assert.equal(fixture.pendingSteeringCount(), 1); + await fixture.coordinator.commitStopFence(ROOT); assert.equal(fixture.pendingSteeringCount(), 0); const batch = fixture.coordinator.beginTerminalTransition(ROOT); assert.deepEqual(batch.sources, []); @@ -2318,6 +2426,19 @@ function createFixture( }; }, }; + const recoveredBatches: HostMessageRecoveryBatch[] = []; + root.materializeSteeringAdmissions = async () => undefined; + root.startRecoveredSteering = async (input) => { + recoveredBatches.push(structuredClone(input)); + rootState = { + kind: 'active', + sessionId: input.sessionId, + turnId: 'recovered-turn', + runId: 'recovered-run', + }; + coordinator.reserveRootTurn(rootState); + return { turnId: 'recovered-turn' }; + }; const receiptStore = memoryReceiptStore( operationReceipts, async (operation, operationId) => { @@ -2387,6 +2508,7 @@ function createFixture( events, receipts, steeringAdmissions, + recoveredBatches, pendingSteeringCount: () => receiptStore.pendingSteeringCount(), delaySteeringAdmission: (error?: Error) => { const delay = { started: deferred(), release: deferred(), error }; diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index cbdd8b13c3..8e9fb9f69f 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -139,7 +139,7 @@ export interface HostMessageRootPort { readRootState(sessionId: string): Promise | HostMessageRootState; claimStopFence( input: Omit, - commitQueueFence: () => QueueFenceResult, + commitQueueFence: () => QueueFenceResult | Promise, admission: SessionAdmissionLease, ): Promise; startFromMessage( @@ -167,7 +167,7 @@ export interface HostMessageRootPort { }): Promise; claimStop( input: Omit, - commitQueueFence: () => QueueFenceResult, + commitQueueFence: () => QueueFenceResult | Promise, admission: SessionAdmissionLease, ): Promise; } @@ -226,6 +226,7 @@ interface LiveEntry { readonly disposition: 'steering' | 'followup'; readonly generation: number; readonly residency: RuntimeHostResidency; + pendingSteeringAdmittedAt?: number; state: 'queued' | 'in_flight' | 'released'; leaseId?: string; } @@ -483,14 +484,15 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { ); } await this.#root.materializeSteeringAdmissions(pending); - const sources = pending.map(pendingSteeringSource); + const firstBatch = sameInitiatingClientAdmissionPrefix(pending); + const sources = firstBatch.map(pendingSteeringSource); const started = await this.#root.startRecoveredSteering( { sessionId, - content: aggregateMessageContent(pending.map((entry) => entry.modelContent)), - submittedContent: aggregateMessageContent(pending.map((entry) => entry.content)), + content: aggregateMessageContent(firstBatch.map((entry) => entry.modelContent)), + submittedContent: aggregateMessageContent(firstBatch.map((entry) => entry.content)), sources, - initiatingConnectionId: pending[0]!.initiatingConnectionId, + initiatingConnectionId: firstBatch[0]!.initiatingConnectionId, }, admissionLease, ); @@ -501,8 +503,9 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } await this.#receipts.settlePendingSteering( sessionId, - pending.map((entry) => entry.messageId), + firstBatch.map((entry) => entry.messageId), ); + this.#queueRecoveredSteering(pending.slice(firstBatch.length)); }); } } @@ -630,7 +633,28 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { this.#draining = true; } - async prepareStopFence(identity: RuntimeMessageRunIdentity): Promise { + #queueRecoveredSteering(admissions: readonly PendingSteeringAdmission[]): void { + for (const admission of admissions) { + const state = this.#state(admission.sessionId); + const residency = this.#acquireResidency(); + state.followup.push({ + entryId: this.#createId(), + messageId: admission.messageId, + content: admission.content, + modelContent: admission.modelContent, + initiatingConnectionId: admission.initiatingConnectionId, + placement: 'next_turn', + disposition: 'followup', + generation: state.generation, + residency, + pendingSteeringAdmittedAt: admission.admittedAt, + state: 'queued', + }); + this.#mutated(state); + } + } + + prepareStopFence(identity: RuntimeMessageRunIdentity): void { const state = this.#sessions.get(identity.sessionId); // A root handoff can durably replace or release this identity before a concurrent // administrative Stop reaches the Session lane. The authoritative fence @@ -644,14 +668,15 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } return; } + state.steeringDiscardPreparedFor = { ...identity }; + } + + async commitStopFence(identity: RuntimeMessageRunIdentity): Promise { + const state = this.#requireState(identity.sessionId); await this.#receipts.settlePendingSteering( identity.sessionId, [...state.steering, ...state.inFlight.values()].map((entry) => entry.messageId), ); - state.steeringDiscardPreparedFor = { ...identity }; - } - - commitStopFence(identity: RuntimeMessageRunIdentity): QueueFenceResult { return this.#commitQueueFence(identity); } @@ -1012,8 +1037,15 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { queueRevision: state.revision + (queued.length > 0 ? 1 : 0), retracted: queued.map(retractedSnapshot), }; + const retractedEntries = [...state.followup]; const retracted = this.#retractFollowups(state); - if (retracted.length > 0) this.#mutated(state); + if (retracted.length > 0) { + await this.#receipts.settlePendingSteering( + input.sessionId, + retractedEntries.map((entry) => entry.messageId), + ); + this.#mutated(state); + } if (!isDeepStrictEqual(result, { queueRevision: state.revision, retracted })) { throw new RuntimeMessageAuthorityInvariantError( 'Retract mutation did not match its prepared result', @@ -1192,6 +1224,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return failure('not_found', 'Message queue entry does not exist'); } queued.remove(); + await this.#receipts.settlePendingSteering(input.sessionId, [queued.entry.messageId]); this.#releaseEntry(queued.entry); this.#mutated(state); this.#maybeReclaim(input.sessionId, state); @@ -1252,8 +1285,9 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { content: entry.content, modelContent: entry.modelContent, initiatingConnectionId: entry.initiatingConnectionId, - admittedAt: Date.now(), + admittedAt: entry.pendingSteeringAdmittedAt ?? Date.now(), }); + entry.pendingSteeringAdmittedAt = pending.admittedAt; await this.#root.commitSteeringAdmission({ sessionId: pending.sessionId, turnId: pending.turnId, @@ -2249,6 +2283,17 @@ function sameInitiatingClientPrefix(entries: readonly LiveEntry[]): LiveEntry[] return entries.slice(0, boundary === -1 ? entries.length : boundary); } +function sameInitiatingClientAdmissionPrefix( + admissions: readonly PendingSteeringAdmission[], +): PendingSteeringAdmission[] { + const initiatingConnectionId = admissions[0]?.initiatingConnectionId; + if (!initiatingConnectionId) return []; + const boundary = admissions.findIndex( + (admission) => admission.initiatingConnectionId !== initiatingConnectionId, + ); + return admissions.slice(0, boundary === -1 ? admissions.length : boundary); +} + function rootAdmissionPayloadFits(sources: readonly RootTurnSourceMessage[]): boolean { try { const content = aggregateMessageContent(sources.map((source) => source.content)); diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 4d5ad3481a..ebae2df1dc 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1198,7 +1198,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { claimStop( input: Pick, - commitQueueFence: () => QueueFenceResult, + commitQueueFence: () => QueueFenceResult | Promise, admission: SessionAdmissionLease, ): Promise { return this.runCommand(async () => { @@ -1228,7 +1228,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { claimStopFence( input: Pick, - commitQueueFence: () => QueueFenceResult, + commitQueueFence: () => QueueFenceResult | Promise, admission: SessionAdmissionLease, ): Promise { return this.declareStopFence(input, commitQueueFence, admission).then((declared) => ({ @@ -1884,7 +1884,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { private async declareStopFence( input: Pick, - commitQueueFence: () => QueueFenceResult, + commitQueueFence: () => QueueFenceResult | Promise, admission: SessionAdmissionLease, stopInput: { source?: 'stop_button' | 'graph_supervisor'; @@ -1898,7 +1898,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { if (active.startSettled.phase === 'rejected') { return { active, deliverStop: () => Promise.resolve() }; } - commitQueueFence(); + await commitQueueFence(); await this.interactions.claimRunClosure(input, 'turn_stopped', admission); const shouldDeliverStop = !active.stopRequested; active.stopRequested = true; @@ -1913,7 +1913,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { private async prepareStopDisposition( input: Pick, - commitQueueFence: () => QueueFenceResult, + commitQueueFence: () => QueueFenceResult | Promise, admissionLease: SessionAdmissionLease, ): Promise { const admission = await this.stores.agentRunStore.readRootTurnAdmission( @@ -1933,7 +1933,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const active = this.#executions.get(input.sessionId); if (isTerminalSnapshot(snapshot)) { if (active?.turnId === input.turnId && active.runId === input.runId) { - commitQueueFence(); + await commitQueueFence(); active.stopRequested = true; return { kind: 'await_terminal', active }; } @@ -1958,7 +1958,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { }; } - commitQueueFence(); + await commitQueueFence(); await this.interactions.claimRunClosure(input, 'turn_stopped', admissionLease); const shouldRequestStop = !active.stopRequested; active.stopRequested = true; From f3cd5f86918b54954ce92a273823d30ca2a4d3b7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 12:40:40 +0800 Subject: [PATCH 20/33] fix(runtime-host): unify durable message lifecycle Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 2 +- .../canonical-session-projection.test.ts | 4 +- .../client-capability-coordinator.test.ts | 45 + .../__tests__/execution-composition.test.ts | 8 +- .../__tests__/execution-host-message.test.ts | 48 ++ .../src/__tests__/goal-root-authority.test.ts | 18 +- .../src/__tests__/message-coordinator.test.ts | 426 +++++++-- .../__tests__/root-turn-coordinator.test.ts | 251 +----- .../server/client-capability-coordinator.ts | 53 +- .../src/server/execution-composition.ts | 54 +- .../src/server/hosted-execution-authority.ts | 2 +- .../src/server/message-coordinator.ts | 284 +++--- .../src/server/root-turn-coordinator.ts | 143 ++-- .../src/__tests__/session-manager.test.ts | 810 +----------------- packages/runtime/src/message-authority.ts | 4 +- packages/runtime/src/runtime-kernel.ts | 370 +------- packages/runtime/src/session-manager.ts | 51 +- .../runtime/src/session-projection-helpers.ts | 4 +- packages/storage/package.json | 1 + .../sqlite-core-execution-store.test.ts | 256 +++++- packages/storage/src/agent-run-store.ts | 16 +- .../src/conversation-operational-state.ts | 4 + packages/storage/src/execution-stores.ts | 20 +- .../src}/message-content-digest.ts | 0 packages/storage/src/message-receipt-store.ts | 289 +++++-- packages/storage/src/session-store.ts | 21 + .../src/sqlite-core-execution-schema.ts | 48 +- .../src/sqlite-session-metadata-store.ts | 128 +++ 28 files changed, 1454 insertions(+), 1906 deletions(-) rename packages/{runtime-host/src/server => storage/src}/message-content-digest.ts (100%) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 16792e56c1..f037b2c8f1 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -128,7 +128,7 @@ describe('Maka Pi TUI transcript', () => { test('renders the pending-queue edit shortcut for the current platform', () => { const state = createMakaPiTranscriptState(); - state.steering = ['Keep going']; + state.followup = ['Keep going']; const renderFor = (platform: NodeJS.Platform) => renderMakaPiPendingQueue(state, 80, platform).map(stripAnsi); diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index cc6d39b4d5..99e84ff184 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -534,7 +534,7 @@ function createMessages( throw new Error('unexpected root start'); }, prepareMessage: async (input) => ({ kind: 'ready', content: input.content }), - commitSteeringAdmission: async () => {}, + commitMessageAdmission: async (admission) => admission, claimStop: async () => { throw new Error('unexpected root stop'); }, @@ -545,9 +545,9 @@ function createMessages( durableProof: { readRootTurnSourceMessageReceipt: (requestedSessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(requestedSessionId, messageId), - readSteeringAdmission: async () => undefined, readImmutableSteeringMessageProof: (requestedSessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(requestedSessionId, messageId), + readExplicitStopProof: async () => false, }, receipts: stores.messageReceiptStore, sessionAdmission: new SessionAdmissionGate(), diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index 62a232723c..0486dac1c4 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -33,6 +33,51 @@ import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation import { clientCapabilityConnectionIdentity } from './fixtures/client-capability.js'; describe('Host Client Capability coordinator', () => { + test('drops ephemeral Client bindings before a durable queued root starts', async () => { + const coordinator = createCoordinator(); + const connection = coordinator.attachConnection( + clientCapabilityConnectionIdentity('connection-a'), + { send: async () => undefined }, + ); + await replace(coordinator, 'connection-a', 'registration-a', 'opaque'); + assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true }); + const bound = coordinator.snapshotForSession('session-a'); + assert.ok(bound); + bound.release(); + + await coordinator.bindDurableSession('session-a'); + + assert.equal(coordinator.snapshotForSession('session-a'), undefined); + await connection.close(); + await coordinator.close(); + }); + + test('durable queued roots do not discover call-affine Client tools', async () => { + const coordinator = createCoordinator(); + const connection = coordinator.attachConnection( + clientCapabilityConnectionIdentity('connection-a'), + { send: async () => undefined }, + ); + await replace( + coordinator, + 'connection-a', + 'registration-a', + 'call-tool', + '0', + 'call-offer', + 'call', + ); + const discovered = coordinator.snapshotForSession('session-a'); + assert.ok(discovered); + discovered.release(); + + await coordinator.bindDurableSession('session-a'); + + assert.equal(coordinator.snapshotForSession('session-a'), undefined); + await connection.close(); + await coordinator.close(); + }); + test('freezes active snapshots across replacement and releases stale registrations', async () => { const sent: unknown[] = []; const coordinator = createCoordinator(); diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index b8bc2a4b0d..fcf4a18806 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -313,14 +313,16 @@ test('production startup recovers steering admitted before an interrupted Run lo status: 'running', updatedAt: 11, }); - await stores.messageReceiptStore.commitPendingSteering({ + await stores.sessionStore.commitMessageAdmission({ sessionId: session.id, turnId: 'interrupted-turn', runId: 'interrupted-run', messageId: 'admitted-steering', content: { text: 'durable steering' }, modelContent: { text: 'durable steering' }, - initiatingConnectionId: 'crashed-client', + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', admittedAt: 12, }); await stores.sessionStore.appendMessage(session.id, { @@ -346,7 +348,7 @@ test('production startup recovers steering admitted before an interrupted Run lo assert.deepEqual(source?.content, { text: 'durable steering' }); assert.equal(source?.placement, 'current_turn'); assert.equal(source?.disposition, 'steering'); - assert.equal((await stores.messageReceiptStore.listPendingSteering()).length, 0); + assert.equal((await stores.messageReceiptStore.listPendingMessages()).length, 0); assert.equal( (await stores.agentRunStore.readRun(session.id, 'interrupted-run')).status, 'failed', diff --git a/packages/runtime-host/src/__tests__/execution-host-message.test.ts b/packages/runtime-host/src/__tests__/execution-host-message.test.ts index 1a32c9872c..42510f4623 100644 --- a/packages/runtime-host/src/__tests__/execution-host-message.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-message.test.ts @@ -298,6 +298,51 @@ test('explicit retract is durable across connections and prevents successor admi }); }); +test('accepted followup survives Host restart and opens its successor root', async () => { + await withExecutionRoot(async (fixture) => { + const firstHost = await fixture.startHost(); + const client = await connectClient(fixture.root); + const turnId = randomUUID(); + await client.startTurn({ + sessionId: fixture.sessionId, + turnId, + content: { text: FAKE_ASK_USER_QUESTION_PROMPT }, + }); + const messageId = randomUUID(); + const content = { + text: 'continue after the Host restart', + displayText: 'continue after the Host restart', + attachments: [attachment('restart-followup', 'restart.png')], + }; + const submitted = await client.request('turn.message.submit', { + originHostEpoch: firstHost.hostEpoch, + sessionId: fixture.sessionId, + messageId, + content, + placement: 'next_turn', + }); + assert.equal(submitted.disposition, 'followup'); + + await fixture.killHost(firstHost); + await client.close().catch(() => undefined); + + const restartedHost = await fixture.startHost(); + await fixture.stopHost(restartedHost); + const chain = await fixture.readAdmissionChain(); + assert.equal(chain.length, 2); + assert.equal(chain[1]?.previousRootTurnId, turnId); + assert.deepEqual(chain[1]?.sourceMessages, [ + { + messageId, + content, + submittedContentDigest: chain[1]?.sourceMessages[0]?.submittedContentDigest, + placement: 'next_turn', + disposition: 'followup', + }, + ]); + }); +}); + test('interrupt atomically retracts queued followup, stops the exact run, and is idempotent', async () => { await withExecutionRoot(async (fixture) => { const host = await fixture.startHost(); @@ -368,6 +413,9 @@ test('interrupt atomically retracts queued followup, stops the exact run, and is await second.close(); await fixture.stopHost(host); + const restartedHost = await fixture.startHost(); + await fixture.stopHost(restartedHost); + const ledger = await fixture.readTurn(turnId); assert.deepEqual( ledger.userMessages diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index 48425481c0..76e33ede96 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -562,8 +562,20 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro startFromMessage: (input, lease) => requireCoordinator(coordinator).startFromMessage(input, lease), prepareMessage: (input) => requireCoordinator(coordinator).prepareMessage(input), - commitSteeringAdmission: (input) => - requireCoordinator(coordinator).commitSteeringAdmission(input), + commitMessageAdmission: (admission, materializeTranscript) => + stores.sessionStore.commitMessageAdmission( + admission, + materializeTranscript + ? { + type: 'user', + id: admission.messageId, + turnId: admission.turnId, + ts: admission.admittedAt, + ...admission.content, + steeringEventId: admission.messageId, + } + : undefined, + ), claimStop: (input, commitQueueFence, lease) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, lease), }; @@ -575,9 +587,9 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro durableProof: { readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), - readSteeringAdmission: async () => undefined, readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), + readExplicitStopProof: async () => false, }, receipts: stores.messageReceiptStore, sessionAdmission: admission, diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 771106992d..9a446faf64 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -21,9 +21,11 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { MessageContent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { messageContentDigest } from '@maka/storage/message-content-digest'; import type { MessageOperationReceipt, MessageReceiptStore, + PendingMessageAdmission, RootTurnSourceMessageReceipt, } from '@maka/storage/execution-stores'; import { @@ -40,7 +42,6 @@ import { type HostMessageRootPort, type HostMessageRootState, } from '../server/message-coordinator.js'; -import { messageContentDigest } from '../server/message-content-digest.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; const ROOT = { sessionId: 'session-1', turnId: 'turn-1', runId: 'run-1' } as const; @@ -189,7 +190,7 @@ test('invalidates the canonical projection after each observable queue mutation' await fixture.coordinator.close(); }); -test('partitions a mixed-Client follow-up queue across root handoffs', async () => { +test('aggregates accepted followups under the durable Session execution contract', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); const owner = fixture.coordinator.bindRun(ROOT); @@ -216,49 +217,26 @@ test('partitions a mixed-Client follow-up queue across root handoffs', async () const batch = fixture.coordinator.beginTerminalTransition(ROOT); assert.deepEqual( batch.sources.map((source) => source.messageId), - ['steering-from-b'], + ['steering-from-b', 'followup-from-c'], ); - assert.equal(batch.initiatingConnectionId, 'connection-b'); fixture.coordinator.commitNextRoot(batch, { sessionId: ROOT.sessionId, turnId: 'turn-2', runId: 'run-2', }); - assert.equal(fixture.liveResidencies(), 1); - const nextOwner = fixture.coordinator.bindRun({ - sessionId: ROOT.sessionId, - turnId: 'turn-2', - runId: 'run-2', - }); - nextOwner.release(); - const secondBatch = fixture.coordinator.beginTerminalTransition({ - sessionId: ROOT.sessionId, - turnId: 'turn-2', - runId: 'run-2', - }); - assert.deepEqual( - secondBatch.sources.map((source) => source.messageId), - ['followup-from-c'], - ); - assert.equal(secondBatch.initiatingConnectionId, 'connection-c'); - fixture.coordinator.commitNextRoot(secondBatch, { - sessionId: ROOT.sessionId, - turnId: 'turn-3', - runId: 'run-3', - }); assert.equal(fixture.liveResidencies(), 0); const finalOwner = fixture.coordinator.bindRun({ sessionId: ROOT.sessionId, - turnId: 'turn-3', - runId: 'run-3', + turnId: 'turn-2', + runId: 'run-2', }); finalOwner.release(); fixture.coordinator.completeIdle( fixture.coordinator.beginTerminalTransition({ sessionId: ROOT.sessionId, - turnId: 'turn-3', - runId: 'run-3', + turnId: 'turn-2', + runId: 'run-2', }), ); await fixture.coordinator.close(); @@ -354,6 +332,10 @@ test('persists a steering message before admitting it to the active Turn queue', runId: ROOT.runId, messageId: 'durable-steering', content: { text: 'persist before queueing' }, + modelContent: { text: 'persist before queueing' }, + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', }); assert.equal(fixture.coordinator.projection(ROOT.sessionId).steering.length, 1); @@ -370,7 +352,7 @@ test('terminal transition settles steering after durable provider consumption', fixture.coordinator.reserveRootTurn(ROOT); const owner = fixture.coordinator.bindRun(ROOT); assert.equal((await submit(fixture, 'consumed-steering', 'consume me', 'current_turn')).ok, true); - assert.equal(fixture.pendingSteeringCount(), 1); + assert.equal(fixture.pendingAdmissionCount(), 1); const [lease] = owner.pull(); assert.ok(lease); @@ -379,7 +361,7 @@ test('terminal transition settles steering after durable provider consumption', owner.release(); await fixture.coordinator.prepareTerminalTransition(ROOT); - assert.equal(fixture.pendingSteeringCount(), 0); + assert.equal(fixture.pendingAdmissionCount(), 0); fixture.coordinator.completeIdle(fixture.coordinator.beginTerminalTransition(ROOT)); await fixture.coordinator.close(); }); @@ -479,6 +461,30 @@ test('pull crosses the retract commit cut and only queued entries are retracted' assert.equal(fixture.liveResidencies(), 0); }); +test('durable retraction failure leaves the live queue unchanged', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'follow-1', 'later', 'next_turn'); + fixture.failNextRetraction(new Error('settlement failed')); + + await assert.rejects( + fixture.coordinator.handlers['queue.retract']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + retractId: 'failed-retraction', + }, + operationContext(), + ), + /settlement failed/, + ); + assert.deepEqual( + fixture.coordinator.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), + ['follow-1'], + ); + assert.equal(fixture.pendingAdmissionCount(), 1); +}); + test('entry retract removes one queued entry, replays its receipt, and rejects stale targets', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); @@ -869,6 +875,43 @@ test('entry promote moves a follow-up into the steering queue', async () => { assert.equal(fixture.liveResidencies(), 0); }); +test('an unconsumed promoted Message enters its successor as materialized steering', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + const owner = fixture.coordinator.bindRun(ROOT); + await submit(fixture, 'promoted-followup', 'send this now', 'next_turn'); + const [entry] = fixture.coordinator.projection(ROOT.sessionId).followup; + assert.ok(entry); + const promoted = await fixture.coordinator.handlers['queue.entry.promote']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: entry.entryId, + promoteId: 'promote-unconsumed', + }, + operationContext(), + ); + assert.equal(promoted.ok, true); + + owner.release(); + const batch = fixture.coordinator.beginTerminalTransition(ROOT); + assert.deepEqual(batch.sources, [ + { + messageId: 'promoted-followup', + content: { text: 'send this now' }, + submittedContentDigest: messageContentDigest({ text: 'send this now' }), + submittedPlacement: 'next_turn', + placement: 'current_turn', + disposition: 'steering', + }, + ]); + fixture.coordinator.commitNextRoot(batch, { + sessionId: ROOT.sessionId, + turnId: 'turn-2', + runId: 'run-2', + }); +}); + test('entry promote durably admits the message before making it non-retractable', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); @@ -918,7 +961,7 @@ test('retract settles a failed promotion so restart cannot recover it', async () await delay.started.promise; delay.release.resolve(undefined); await assert.rejects(promotion, /durable promotion failed/); - assert.equal(fixture.pendingSteeringCount(), 1); + assert.equal(fixture.pendingAdmissionCount(), 1); const retracted = await fixture.coordinator.handlers['queue.entry.retract']( { @@ -930,7 +973,36 @@ test('retract settles a failed promotion so restart cannot recover it', async () operationContext(), ); assert.equal(retracted.ok, true); - assert.equal(fixture.pendingSteeringCount(), 0); + assert.equal(fixture.pendingAdmissionCount(), 0); + + const restarted = fixture.restart('epoch-2'); + const retried = await restarted.handlers['turn.message.submit']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + messageId: 'promoted-followup', + content: { text: 'send this now' }, + placement: 'next_turn', + }, + operationContext(), + ); + assert.equal(retried.ok, false); + if (!retried.ok) assert.equal(retried.error.code, 'operation_conflict'); + const reusedInCurrentEpoch = await restarted.handlers['turn.message.submit']( + { + originHostEpoch: 'epoch-2', + sessionId: ROOT.sessionId, + messageId: 'promoted-followup', + content: { text: 'send this now' }, + placement: 'next_turn', + }, + operationContext(), + ); + assert.equal(reusedInCurrentEpoch.ok, false); + if (!reusedInCurrentEpoch.ok) { + assert.equal(reusedInCurrentEpoch.error.code, 'operation_conflict'); + } + assert.equal(fixture.pendingAdmissionCount(), 0); }); test('failed promotion retry reuses the durable admission timestamp', async () => { @@ -964,7 +1036,43 @@ test('failed promotion retry reuses the durable admission timestamp', async () = assert.equal(retry.ok, true); }); -test('restart recovers only the first contiguous initiating-client batch', async () => { +test('old-Epoch retry retains the original placement after promoted provider consumption', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + const owner = fixture.coordinator.bindRun(ROOT); + await submit(fixture, 'promoted-followup', 'send this now', 'next_turn'); + const [entry] = fixture.coordinator.projection(ROOT.sessionId).followup; + assert.ok(entry); + const promoted = await fixture.coordinator.handlers['queue.entry.promote']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: entry.entryId, + promoteId: 'promote-for-provider', + }, + operationContext(), + ); + assert.equal(promoted.ok, true); + const [lease] = owner.pull(); + assert.ok(lease); + fixture.events.push( + steeringEvent('promoted-followup', { text: 'send this now' }, { text: 'send this now' }), + ); + owner.ack([lease.id]); + await fixture.coordinator.prepareTerminalTransition(ROOT); + + const retried = await submit( + { ...fixture, coordinator: fixture.restart('epoch-2') }, + 'promoted-followup', + 'send this now', + 'next_turn', + 'epoch-1', + ); + assert.equal(retried.ok, false); + if (!retried.ok) assert.equal(retried.error.code, 'outcome_unknown'); +}); + +test('restart recovers every admission under the durable Session execution contract', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); await fixture.coordinator.handlers['turn.message.submit']( @@ -993,16 +1101,81 @@ test('restart recovers only the first contiguous initiating-client batch', async await restarted.recoverPendingAfterHostRestart(); assert.equal(fixture.recoveredBatches.length, 1); - assert.equal(fixture.recoveredBatches[0]?.initiatingConnectionId, 'connection-b'); assert.deepEqual( fixture.recoveredBatches[0]?.sources.map((source) => source.messageId), - ['steering-b'], + ['steering-b', 'steering-a'], + ); + assert.deepEqual(restarted.projection(ROOT.sessionId).followup, []); + assert.equal(fixture.pendingAdmissionCount(), 0); +}); + +test('restart preserves durable reorder and promotion priority', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'followup-a', 'A', 'next_turn'); + await submit(fixture, 'followup-b', 'B', 'next_turn'); + await submit(fixture, 'steering-c', 'C', 'current_turn'); + await submit(fixture, 'followup-d', 'D', 'next_turn'); + await submit(fixture, 'followup-e', 'E', 'next_turn'); + const entries = new Map( + fixture.coordinator + .projection(ROOT.sessionId) + .followup.map((entry) => [entry.messageId, entry]), + ); + const entryA = entries.get('followup-a'); + const entryB = entries.get('followup-b'); + const entryD = entries.get('followup-d'); + const entryE = entries.get('followup-e'); + assert.ok(entryA && entryB && entryD && entryE); + + const reordered = await fixture.coordinator.handlers['queue.entries.reorder']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryIds: [entryB.entryId, entryA.entryId, entryE.entryId, entryD.entryId], + reorderId: 'reorder-before-restart', + }, + operationContext(), + ); + assert.equal(reordered.ok, true); + const promoted = await fixture.coordinator.handlers['queue.entry.promote']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: entryB.entryId, + promoteId: 'promote-before-restart', + }, + operationContext(), + ); + assert.equal(promoted.ok, true); + const promotedAgain = await fixture.coordinator.handlers['queue.entry.promote']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: entryA.entryId, + promoteId: 'promote-again-before-restart', + }, + operationContext(), ); + assert.equal(promotedAgain.ok, true); + + fixture.setRootState({ kind: 'idle' }); + await fixture.restart('epoch-2').recoverPendingAfterHostRestart(); assert.deepEqual( - restarted.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), - ['steering-a'], + fixture.recoveredBatches[0]?.sources.map((source) => [ + source.messageId, + source.submittedPlacement, + source.placement, + source.disposition, + ]), + [ + ['steering-c', undefined, 'current_turn', 'steering'], + ['followup-b', 'next_turn', 'current_turn', 'steering'], + ['followup-a', 'next_turn', 'current_turn', 'steering'], + ['followup-e', undefined, 'next_turn', 'followup'], + ['followup-d', undefined, 'next_turn', 'followup'], + ], ); - assert.equal(fixture.pendingSteeringCount(), 1); }); test('entry promote requires an active Turn', async () => { @@ -1795,13 +1968,16 @@ test('administrative drain preserves accepted entries until the terminal stop fe fixture.coordinator.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), ['follow-drain'], ); - assert.equal(fixture.pendingSteeringCount(), 1); + assert.equal(fixture.pendingAdmissionCount(), 2); owner.release(); await fixture.coordinator.prepareTerminalTransition(ROOT); - assert.equal(fixture.pendingSteeringCount(), 1); + assert.equal(fixture.pendingAdmissionCount(), 2); await fixture.coordinator.commitStopFence(ROOT); - assert.equal(fixture.pendingSteeringCount(), 0); + assert.equal(fixture.pendingAdmissionCount(), 2); + fixture.setExplicitStopProof(true); + await fixture.coordinator.prepareTerminalTransition(ROOT); + assert.equal(fixture.pendingAdmissionCount(), 0); const batch = fixture.coordinator.beginTerminalTransition(ROOT); assert.deepEqual(batch.sources, []); assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).steering, []); @@ -2337,10 +2513,6 @@ function createFixture( content: MessageContent; admittedAt: number; }> = []; - const durableSteeringAdmissions = new Map< - string, - { sessionId: string; turnId: string; messageId: string; content: MessageContent } - >(); let steeringAdmissionDelay: | { readonly started: ReturnType>; @@ -2359,7 +2531,13 @@ function createFixture( >(); const stopClaimed = deferred(); const terminal = deferred(); + let explicitStopProof = false; let coordinator: HostMessageCoordinator; + let receiptStore!: MessageReceiptStore & { + commitAdmission(admission: PendingMessageAdmission): Promise; + failNextRetraction(error: Error): void; + pendingAdmissionCount(): number; + }; const root: HostMessageRootPort = { readSessionHeader: async () => { rootReads += 1; @@ -2403,20 +2581,19 @@ function createFixture( return { turnId }; }, prepareMessage: (input) => prepareMessage(input), - commitSteeringAdmission: async (input) => { - steeringAdmissions.push(structuredClone(input)); - durableSteeringAdmissions.set(input.messageId, { - sessionId: input.sessionId, - turnId: input.turnId, - messageId: input.messageId, - content: structuredClone(input.content), - }); + commitMessageAdmission: async (input, materializeTranscript) => { const delay = steeringAdmissionDelay; - if (!delay) return; - steeringAdmissionDelay = undefined; - delay.started.resolve(undefined); - await delay.release.promise; - if (delay.error) throw delay.error; + if (materializeTranscript && delay) { + steeringAdmissionDelay = undefined; + delay.started.resolve(undefined); + await delay.release.promise; + if (delay.error) throw delay.error; + } + const committed = await receiptStore.commitAdmission(input); + if (materializeTranscript) { + steeringAdmissions.push(structuredClone(input)); + } + return committed; }, claimStop: async (_input, commitQueueFence) => { commitQueueFence(); @@ -2427,8 +2604,7 @@ function createFixture( }, }; const recoveredBatches: HostMessageRecoveryBatch[] = []; - root.materializeSteeringAdmissions = async () => undefined; - root.startRecoveredSteering = async (input) => { + root.startRecoveredMessages = async (input) => { recoveredBatches.push(structuredClone(input)); rootState = { kind: 'active', @@ -2439,7 +2615,7 @@ function createFixture( coordinator.reserveRootTurn(rootState); return { turnId: 'recovered-turn' }; }; - const receiptStore = memoryReceiptStore( + receiptStore = memoryReceiptStore( operationReceipts, async (operation, operationId) => { const delay = receiptDelays.get(`${operation}:${operationId}`); @@ -2458,8 +2634,6 @@ function createFixture( root, durableProof: { readRootTurnSourceMessageReceipt: async (_sessionId, messageId) => receipts.get(messageId), - readSteeringAdmission: async (_sessionId, messageId) => - durableSteeringAdmissions.get(messageId), readImmutableSteeringMessageProof: async (_sessionId, messageId) => { const event = events.find( (candidate) => @@ -2470,6 +2644,7 @@ function createFixture( ); return event ? { event } : undefined; }, + readExplicitStopProof: async () => explicitStopProof, }, receipts: receiptStore, sessionAdmission: new SessionAdmissionGate(), @@ -2501,6 +2676,9 @@ function createFixture( setRootState: (state: HostMessageRootState) => { rootState = state; }, + setExplicitStopProof: (value: boolean) => { + explicitStopProof = value; + }, setMessagePreparation: (prepare: NonNullable) => { prepareMessage = prepare; }, @@ -2509,7 +2687,8 @@ function createFixture( receipts, steeringAdmissions, recoveredBatches, - pendingSteeringCount: () => receiptStore.pendingSteeringCount(), + pendingAdmissionCount: () => receiptStore.pendingAdmissionCount(), + failNextRetraction: (error: Error) => receiptStore.failNextRetraction(error), delaySteeringAdmission: (error?: Error) => { const delay = { started: deferred(), release: deferred(), error }; steeringAdmissionDelay = delay; @@ -2545,10 +2724,25 @@ function memoryReceiptStore( receipts: Map, beforeCommit?: (operation: string, operationId: string) => Promise, onRead?: () => void, -): MessageReceiptStore & { pendingSteeringCount(): number } { +): MessageReceiptStore & { + commitAdmission(admission: PendingMessageAdmission): Promise; + failNextRetraction(error: Error): void; + pendingAdmissionCount(): number; +} { const key = (hostEpoch: string, operation: string, sessionId: string, operationId: string) => `${hostEpoch}:${operation}:${sessionId}:${operationId}`; - const pending = new Map[0]>(); + const pending = new Map(); + const admissionOrder: string[] = []; + const retracted = new Map< + string, + { + messageId: string; + settlement: 'retracted'; + submittedPlacement: 'current_turn' | 'next_turn'; + submittedContentDigest: `sha256:${string}`; + } + >(); + let retractionError: Error | undefined; return { beginHostEpoch: async () => undefined, read: async (hostEpoch, operation, sessionId, operationId) => { @@ -2564,19 +2758,105 @@ function memoryReceiptStore( receipts.set(receiptKey, snapshot); return snapshot; }, - commitPendingSteering: async (admission) => { + commitAdmission: async (admission) => { const admissionKey = `${admission.sessionId}:${admission.messageId}`; const existing = pending.get(admissionKey); - if (existing) return existing; + if (existing) { + assert.equal(existing.sessionId, admission.sessionId); + assert.equal(existing.turnId, admission.turnId); + assert.equal(existing.runId, admission.runId); + assert.deepEqual(existing.content, admission.content); + assert.deepEqual(existing.modelContent, admission.modelContent); + if ( + admission.placement === 'current_turn' && + admission.disposition === 'steering' && + existing.placement === 'next_turn' && + existing.disposition === 'followup' + ) { + const promoted = { + ...existing, + placement: 'current_turn' as const, + disposition: 'steering' as const, + }; + pending.set(admissionKey, promoted); + admissionOrder.splice(admissionOrder.indexOf(admissionKey), 1); + admissionOrder.push(admissionKey); + return promoted; + } + assert.equal(existing.placement, admission.placement); + assert.equal(existing.disposition, admission.disposition); + return existing; + } const snapshot = structuredClone(admission); pending.set(admissionKey, snapshot); + admissionOrder.push(admissionKey); return snapshot; }, - listPendingSteering: async () => [...pending.values()], - settlePendingSteering: async (sessionId, messageIds) => { + readMessageAdmission: async (sessionId, messageId) => { + const admissionKey = `${sessionId}:${messageId}`; + return retracted.has(admissionKey) ? undefined : pending.get(admissionKey); + }, + readMessageSettlement: async (sessionId, messageId) => + retracted.get(`${sessionId}:${messageId}`), + listPendingMessages: async () => + admissionOrder + .flatMap((admissionKey) => { + const admission = pending.get(admissionKey); + return admission && !retracted.has(admissionKey) ? [admission] : []; + }) + .sort((left, right) => + left.disposition === right.disposition ? 0 : left.disposition === 'steering' ? -1 : 1, + ), + commitMessageOrder: async (sessionId, messageIds) => { + const reorderedKeys: string[] = []; + for (const messageId of messageIds) { + const admissionKey = `${sessionId}:${messageId}`; + const admission = pending.get(admissionKey); + if (!admission || admission.disposition !== 'followup') { + throw new Error('Message order identity conflict'); + } + reorderedKeys.push(admissionKey); + } + const positions = admissionOrder.flatMap((admissionKey, index) => { + const admission = pending.get(admissionKey); + return admission?.sessionId === sessionId && admission.disposition === 'followup' + ? [index] + : []; + }); + if (positions.length !== reorderedKeys.length) { + throw new Error('Message order identity conflict'); + } + for (let index = 0; index < positions.length; index += 1) { + admissionOrder[positions[index]!] = reorderedKeys[index]!; + } + }, + commitMessageRetractions: async (sessionId, messageIds) => { + if (retractionError) { + const error = retractionError; + retractionError = undefined; + throw error; + } + for (const messageId of messageIds) { + const admissionKey = `${sessionId}:${messageId}`; + const admission = pending.get(admissionKey); + if (!admission) throw new Error('Message retraction identity does not exist'); + retracted.set(admissionKey, { + messageId, + settlement: 'retracted', + submittedPlacement: admission.submittedPlacement, + submittedContentDigest: messageContentDigest(admission.content), + }); + pending.delete(admissionKey); + } + }, + garbageCollectMessageAdmissions: async (sessionId, messageIds) => { for (const messageId of messageIds) pending.delete(`${sessionId}:${messageId}`); }, - pendingSteeringCount: () => pending.size, + pendingAdmissionCount: () => + [...pending.keys()].filter((admissionKey) => !retracted.has(admissionKey)).length, + failNextRetraction: (error) => { + retractionError = error; + }, }; } diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 93a7ce2e27..7681fca2ec 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -2162,8 +2162,20 @@ test('hosted linked child roots share admission, message, terminal, and stop aut startFromMessage: (input, admission) => requireCoordinator(coordinator).startFromMessage(input, admission), prepareMessage: (input) => requireCoordinator(coordinator).prepareMessage(input), - commitSteeringAdmission: (input) => - requireCoordinator(coordinator).commitSteeringAdmission(input), + commitMessageAdmission: (admission, materializeTranscript) => + stores.sessionStore.commitMessageAdmission( + admission, + materializeTranscript + ? { + type: 'user', + id: admission.messageId, + turnId: admission.turnId, + ts: admission.admittedAt, + ...admission.content, + steeringEventId: admission.messageId, + } + : undefined, + ), claimStop: (input, commitQueueFence, admission) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, admission), }; @@ -2175,9 +2187,9 @@ test('hosted linked child roots share admission, message, terminal, and stop aut durableProof: { readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), - readSteeringAdmission: async () => undefined, readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), + readExplicitStopProof: async () => false, }, receipts: stores.messageReceiptStore, sessionAdmission, @@ -3227,7 +3239,7 @@ test('an exact active retry preserves the Client Capability admission binding', } }); -test('mixed-Client queued follow-ups preserve each submitting connection through root handoff', { +test('queued follow-ups aggregate under the durable Session execution contract', { timeout: 20_000, }, async () => { const clientCapabilities = new HostClientCapabilityCoordinator({ @@ -3332,22 +3344,10 @@ test('mixed-Client queued follow-ups preserve each submitting connection through const firstFollowup = fixture.coordinator.readRootState(fixture.sessionId); assert.equal(firstFollowup.kind, 'active'); if (firstFollowup.kind !== 'active') return; - const firstFollowupSnapshot = clientCapabilities.snapshotForSession(fixture.sessionId); - assert.deepEqual(firstFollowupSnapshot?.registrationIds, ['registration-b']); - firstFollowupSnapshot?.release(); + assert.equal(clientCapabilities.snapshotForSession(fixture.sessionId), undefined); await waitUntil(() => backend?.sendCount === 2); backend?.release(); - await waitUntil(() => { - const state = fixture.coordinator.readRootState(fixture.sessionId); - return state.kind === 'active' && state.turnId !== firstFollowup.turnId; - }); - const secondFollowupSnapshot = clientCapabilities.snapshotForSession(fixture.sessionId); - assert.deepEqual(secondFollowupSnapshot?.registrationIds, ['registration-a']); - secondFollowupSnapshot?.release(); - - await waitUntil(() => backend?.sendCount === 3); - backend?.release(); await waitUntil( () => fixture.coordinator.readRootState(fixture.sessionId).kind === 'idle', 5_000, @@ -3357,7 +3357,7 @@ test('mixed-Client queued follow-ups preserve each submitting connection through ); assert.deepEqual( admissions.map((admission) => admission.sourceMessages.map((source) => source.messageId)), - [[], ['followup-from-provider-b'], ['followup-from-provider-a']], + [[], ['followup-from-provider-b', 'followup-from-provider-a']], ); } finally { first.close(); @@ -3367,203 +3367,6 @@ test('mixed-Client queued follow-ups preserve each submitting connection through } }); -test('queued follow-up degrades lost Session tools and rebinds ephemeral tools to its Client', { - timeout: 20_000, -}, async () => { - await assertFollowupCapabilityRebinding('call'); - await assertFollowupCapabilityRebinding('turn'); -}); - -async function assertFollowupCapabilityRebinding(affinity: 'call' | 'turn'): Promise { - const clientCapabilities = new HostClientCapabilityCoordinator({ - activation: new RuntimePolicyActivationGate(), - onModelToolsChanged: () => undefined, - }); - let backend: LinkedChildAuthorityBackend | undefined; - const fixture = await createFailureFixture({ - clientCapabilities, - registerBackend: (backends) => { - backends.register('ai-sdk', (context) => { - backend = new LinkedChildAuthorityBackend(context.sessionId); - return backend; - }); - }, - }); - const sessionProvider = clientCapabilities.attachConnection( - clientCapabilityConnectionIdentity('provider-session'), - { - send: async () => {}, - }, - ); - const calls: string[] = []; - let previousProvider!: ReturnType; - previousProvider = clientCapabilities.attachConnection( - clientCapabilityConnectionIdentity('provider-previous'), - { - send: async (frame) => { - if (frame.kind !== 'client.capability.call') return; - calls.push('provider-previous'); - previousProvider.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - }); - previousProvider.accept({ - kind: 'client.capability.result', - invocationId: frame.invocationId, - result: { content: [{ type: 'text', text: 'previous' }] }, - }); - }, - }, - ); - let followupProvider!: ReturnType; - followupProvider = clientCapabilities.attachConnection( - clientCapabilityConnectionIdentity('provider-followup'), - { - send: async (frame) => { - if (frame.kind !== 'client.capability.call') return; - calls.push('provider-followup'); - followupProvider.accept({ - kind: 'client.capability.accepted', - invocationId: frame.invocationId, - }); - followupProvider.accept({ - kind: 'client.capability.result', - invocationId: frame.invocationId, - result: { content: [{ type: 'text', text: 'followup' }] }, - }); - }, - }, - ); - - try { - const sessionReplaced = await clientCapabilities.handlers['client.capability.replace']( - { - registrationId: 'registration-session', - offers: [ - { - offerId: 'session-browser', - version: '0', - affinity: 'session', - hostPathAccess: 'cwd', - label: 'Session browser', - tools: [ - { - serverId: 'session_browser', - name: 'navigate_session', - inputSchema: { type: 'object' }, - }, - ], - }, - ], - }, - operationContext(fixture.hostEpoch, fixture.acquireResidency, 'provider-session'), - ); - assert.equal(sessionReplaced.ok, true); - for (const [connectionId, registrationId] of [ - ['provider-previous', 'registration-previous'], - ['provider-followup', 'registration-followup'], - ] as const) { - const replaced = await clientCapabilities.handlers['client.capability.replace']( - { - registrationId, - offers: [ - { - offerId: 'ephemeral-browser', - version: '0', - affinity, - hostPathAccess: 'cwd', - label: 'Ephemeral browser', - tools: [ - { - serverId: 'ephemeral_browser', - name: 'navigate_ephemeral', - inputSchema: { type: 'object' }, - }, - ], - }, - ], - }, - operationContext(fixture.hostEpoch, fixture.acquireResidency, connectionId), - ); - assert.equal(replaced.ok, true); - } - - const firstTurnId = `turn-client-capability-loss-${affinity}`; - const started = await fixture.interactiveTurns.handlers['turn.start']( - { - sessionId: fixture.sessionId, - turnId: firstTurnId, - content: { text: HOLD_EXTERNAL_PROMPT }, - }, - operationContext(fixture.hostEpoch, fixture.acquireResidency, 'provider-previous'), - ); - assert.equal(started.ok, true); - const queued = await fixture.messages.handlers['turn.message.submit']( - { - originHostEpoch: fixture.hostEpoch, - sessionId: fixture.sessionId, - messageId: `followup-after-provider-loss-${affinity}`, - content: { text: HOLD_EXTERNAL_PROMPT }, - placement: 'next_turn', - }, - operationContext(fixture.hostEpoch, fixture.acquireResidency, 'provider-followup'), - ); - assert.equal(queued.ok && queued.result.disposition, 'followup'); - - sessionProvider.close(); - backend?.release(); - await waitUntil(() => { - const state = fixture.coordinator.readRootState(fixture.sessionId); - return state.kind === 'active' && state.turnId !== firstTurnId; - }); - const snapshot = clientCapabilities.snapshotForSession(fixture.sessionId); - assert.ok(snapshot); - assert.equal( - snapshot.tools.some((tool) => tool.name.endsWith('navigate_session')), - false, - ); - const ephemeral = snapshot.tools.find((tool) => tool.name.endsWith('navigate_ephemeral')); - assert.ok(ephemeral); - await ephemeral.impl( - {}, - { - sessionId: fixture.sessionId, - turnId: 'followup-turn', - cwd: '/tmp', - toolCallId: `followup-${affinity}`, - abortSignal: new AbortController().signal, - emitOutput: () => undefined, - }, - ); - assert.deepEqual(calls, ['provider-followup']); - snapshot.release(); - - await waitUntil(() => backend?.sendCount === 2); - backend?.release(); - await waitUntil( - () => fixture.coordinator.readRootState(fixture.sessionId).kind === 'idle', - 5_000, - ); - const admissions = await fixture.stores.agentRunStore.listRootTurnAdmissionsForRecovery( - fixture.sessionId, - ); - assert.equal(admissions.length, 2); - const followup = admissions[1]; - assert.ok(followup); - assert.equal( - (await fixture.stores.agentRunStore.readRun(fixture.sessionId, followup.runId)).status, - 'completed', - ); - assert.equal(fixture.drainRequested(), false); - } finally { - sessionProvider.close(); - previousProvider.close(); - followupProvider.close(); - await clientCapabilities.close(); - await fixture.dispose(); - } -} - test('an exact terminal retry does not require a live Client Capability binding', { timeout: 20_000, }, async () => { @@ -4784,8 +4587,20 @@ async function createFailureFixture(options: { startFromMessage: (input, admission) => requireCoordinator(coordinator).startFromMessage(input, admission), prepareMessage: (input) => requireCoordinator(coordinator).prepareMessage(input), - commitSteeringAdmission: (input) => - requireCoordinator(coordinator).commitSteeringAdmission(input), + commitMessageAdmission: (admission, materializeTranscript) => + stores.sessionStore.commitMessageAdmission( + admission, + materializeTranscript + ? { + type: 'user', + id: admission.messageId, + turnId: admission.turnId, + ts: admission.admittedAt, + ...admission.content, + steeringEventId: admission.messageId, + } + : undefined, + ), claimStop: (input, commitQueueFence, admission) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, admission), }; @@ -4802,9 +4617,9 @@ async function createFailureFixture(options: { durableProof: { readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), - readSteeringAdmission: async () => undefined, readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), + readExplicitStopProof: async () => false, }, receipts: stores.messageReceiptStore, sessionAdmission, diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 40813af1b8..fdbb407603 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -105,6 +105,7 @@ type SessionCapabilityBinding = type SessionBindingMode = 'strict' | 'degrade'; interface SessionCapabilityState { + readonly durable: boolean; readonly initiatingProviderId?: string; readonly serviceProviderId?: string; readonly sessionBindings: ReadonlyMap; @@ -234,11 +235,19 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService return this.#bindSession(sessionId, initiatingConnectionId, 'strict'); } - async bindConfirmedFollowup(sessionId: string, initiatingConnectionId: string): Promise { - const result = await this.#bindSession(sessionId, initiatingConnectionId, 'degrade'); - if (!result.ok) { - throw new Error(`Confirmed follow-up capability binding failed: ${result.message}`); - } + bindDurableSession(sessionId: string): Promise { + return this.#activation.runMutation(() => { + const previous = this.#sessions.get(sessionId); + const durable: SessionCapabilityState = { + durable: true, + sessionBindings: new Map(), + turnBindings: new Map(), + }; + if (previous && sessionCapabilityStatesEqual(previous, durable)) return; + this.#sessions.set(sessionId, durable); + if (previous || this.#hasCallOffers()) this.#onModelToolsChanged(); + for (const provider of this.#providers.values()) this.#deleteProviderIfUnused(provider); + }); } async #bindSession( @@ -430,6 +439,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService return { ok: true, state: { + durable: false, ...(initiatingProviderId ? { initiatingProviderId } : {}), ...(serviceProviderId ? { serviceProviderId } : {}), sessionBindings: next, @@ -486,20 +496,22 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService selected.push({ registration, offer }); rememberOfferProxyNames(offer, proxyNames); } - const eligible = this.#eligibleOffersByContract(); - for (const [contractId, candidates] of [...eligible].sort(([left], [right]) => - left.localeCompare(right), - )) { - const offer = candidates[0]?.offer; - if ( - !offer || - offer.offer.affinity !== 'call' || - offerConflictsWithProxyNames(offer, proxyNames) - ) { - continue; + if (!state?.durable) { + const eligible = this.#eligibleOffersByContract(); + for (const [contractId, candidates] of [...eligible].sort(([left], [right]) => + left.localeCompare(right), + )) { + const offer = candidates[0]?.offer; + if ( + !offer || + offer.offer.affinity !== 'call' || + offerConflictsWithProxyNames(offer, proxyNames) + ) { + continue; + } + selected.push({ offer }); + rememberOfferProxyNames(offer, proxyNames); } - selected.push({ offer }); - rememberOfferProxyNames(offer, proxyNames); } if (selected.length === 0) return; const trusted = selected.filter((binding) => binding.registration?.trustedProvider === true); @@ -1111,6 +1123,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService state.sessionBindings.size === 0 && state.turnBindings.size === 0 && !state.serviceProviderId && + !state.durable && !this.#hasCallOffers() ) { this.#sessions.delete(sessionId); @@ -1125,7 +1138,8 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService if ( state.sessionBindings.size === 0 && state.turnBindings.size === 0 && - !state.serviceProviderId + !state.serviceProviderId && + !state.durable ) { this.#sessions.delete(sessionId); } @@ -1392,6 +1406,7 @@ function sessionCapabilityStatesEqual( return ( left.initiatingProviderId === right.initiatingProviderId && left.serviceProviderId === right.serviceProviderId && + left.durable === right.durable && bindingMapsEqual(left.sessionBindings, right.sessionBindings) && bindingMapsEqual(left.turnBindings, right.turnBindings) ); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 68d6ebade0..93ce55bef5 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -68,6 +68,7 @@ import { } from '@maka/runtime/shell-detect'; import { type MakaTool } from '@maka/runtime/tool-runtime'; import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority'; +import { classifyRuntimeEventTerminalFact } from '@maka/runtime/runtime-event-read-model'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; import { createArtifactAttachmentResourceReader, @@ -453,13 +454,23 @@ export async function createExecutionRuntimeHostComposition( requireRootCoordinator(rootCoordinator).claimStopFence(input, commitQueueFence, admission), startFromMessage: (input, admission) => requireRootCoordinator(rootCoordinator).startFromMessage(input, admission), - startRecoveredSteering: (input, admission) => - requireRootCoordinator(rootCoordinator).startRecoveredSteering(input, admission), - materializeSteeringAdmissions: (admissions) => - requireRootCoordinator(rootCoordinator).materializeSteeringAdmissions(admissions), + startRecoveredMessages: (input, admission) => + requireRootCoordinator(rootCoordinator).startRecoveredMessages(input, admission), prepareMessage: (input) => requireRootCoordinator(rootCoordinator).prepareMessage(input), - commitSteeringAdmission: (input) => - requireRootCoordinator(rootCoordinator).commitSteeringAdmission(input), + commitMessageAdmission: (admission, materializeTranscript) => + stores.sessionStore.commitMessageAdmission( + admission, + materializeTranscript + ? { + type: 'user', + id: admission.messageId, + turnId: admission.turnId, + ts: admission.admittedAt, + ...admission.content, + steeringEventId: admission.messageId, + } + : undefined, + ), claimStop: (input, commitQueueFence, admission) => requireRootCoordinator(rootCoordinator).claimStop(input, commitQueueFence, admission), }; @@ -469,24 +480,23 @@ export async function createExecutionRuntimeHostComposition( durableProof: { readRootTurnSourceMessageReceipt: (sessionId, messageId) => stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), - readSteeringAdmission: async (sessionId, messageId) => { - const message = (await stores.sessionStore.readMessages(sessionId)).find( - (candidate) => - candidate.type === 'user' && - candidate.id === messageId && - candidate.steeringEventId === messageId, - ); - return message?.type === 'user' - ? { - sessionId, - turnId: message.turnId, - messageId, - content: normalizeMessageContent(message), - } - : undefined; - }, readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), + readExplicitStopProof: async (sessionId, runId) => { + const run = await stores.agentRunStore.readRun(sessionId, runId); + if (!run) return false; + const events = await stores.runtimeEventStore.readImmutableRuntimeEvents( + sessionId, + runId, + ); + const fact = classifyRuntimeEventTerminalFact(run, events).fact; + return ( + fact?.runStatus === 'cancelled' && + (fact.abortSource === 'user_stop' || + fact.abortSource === 'renderer.stop_button' || + fact.abortSource === 'graph.supervisor') + ); + }, }, receipts: stores.messageReceiptStore, sessionAdmission, diff --git a/packages/runtime-host/src/server/hosted-execution-authority.ts b/packages/runtime-host/src/server/hosted-execution-authority.ts index 152eeace91..19a42c66e1 100644 --- a/packages/runtime-host/src/server/hosted-execution-authority.ts +++ b/packages/runtime-host/src/server/hosted-execution-authority.ts @@ -82,7 +82,7 @@ export interface HostedExecutionObserver { export interface HostedExecutionStopInput { readonly execution: HostedExecutionRef; - readonly source?: 'stop_button' | 'graph_supervisor'; + readonly source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; readonly mode?: BackendStopMode; } diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 8e9fb9f69f..9c94d2ac7c 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -38,7 +38,7 @@ import { type ImmutableSteeringMessageProof, type MessageReceiptOperation, type MessageReceiptStore, - type PendingSteeringAdmission, + type PendingMessageAdmission, type RootTurnSourceMessage, type RootTurnSourceMessageReceipt, } from '@maka/storage/execution-stores'; @@ -71,7 +71,7 @@ import type { RuntimeHostResidency } from './host-kernel.js'; import { worstCaseFailedTurnSnapshot } from './canonical-turn-snapshot.js'; import { worstCaseMessageQueueProjection } from './message-queue-capacity.js'; import type { ConnectionContext, MessageOperationHandlerMap } from './operation-dispatcher.js'; -import { messageContentDigest } from './message-content-digest.js'; +import { messageContentDigest } from '@maka/storage/message-content-digest'; import { type SessionAdmissionLease, SessionAdmissionGate } from './session-admission-gate.js'; type MessageOperationErrorCode = @@ -112,7 +112,6 @@ export interface HostMessageRecoveryBatch { readonly content: MessageContent; readonly submittedContent: MessageContent; readonly sources: readonly RootTurnSourceMessage[]; - readonly initiatingConnectionId: string; } export interface HostMessagePreparationInput { @@ -146,25 +145,20 @@ export interface HostMessageRootPort { input: HostMessageStartInput, admission: SessionAdmissionLease, ): Promise<{ readonly turnId: string } | { readonly error: string }>; - startRecoveredSteering?( + startRecoveredMessages?( input: HostMessageRecoveryBatch, admission: SessionAdmissionLease, ): Promise<{ readonly turnId: string } | { readonly error: string }>; - materializeSteeringAdmissions?(admissions: readonly PendingSteeringAdmission[]): Promise; prepareMessage( input: HostMessagePreparationInput, ): Promise< | { readonly kind: 'ready'; readonly content: MessageContent } | { readonly kind: 'rejected'; readonly error: string } >; - commitSteeringAdmission(input: { - readonly sessionId: string; - readonly turnId: string; - readonly runId: string; - readonly messageId: string; - readonly content: MessageContent; - readonly admittedAt: number; - }): Promise; + commitMessageAdmission( + admission: PendingMessageAdmission, + materializeTranscript: boolean, + ): Promise; claimStop( input: Omit, commitQueueFence: () => QueueFenceResult | Promise, @@ -172,27 +166,16 @@ export interface HostMessageRootPort { ): Promise; } -/** Durable facts used to prove or recover an earlier Host Epoch's submit disposition. */ -export interface DurableSteeringAdmission { - readonly sessionId: string; - readonly turnId: string; - readonly messageId: string; - readonly content: MessageContent; -} - export interface HostMessageDurableProofReader { readRootTurnSourceMessageReceipt( sessionId: string, messageId: string, ): Promise; - readSteeringAdmission( - sessionId: string, - messageId: string, - ): Promise; readImmutableSteeringMessageProof( sessionId: string, messageId: string, ): Promise; + readExplicitStopProof(sessionId: string, runId: string): Promise; } export interface HostMessageCoordinatorOptions { @@ -222,11 +205,12 @@ interface LiveEntry { content: MessageContent; modelContent: MessageContent; readonly initiatingConnectionId: string; + readonly submittedPlacement: MessagePlacement; readonly placement: MessagePlacement; readonly disposition: 'steering' | 'followup'; readonly generation: number; readonly residency: RuntimeHostResidency; - pendingSteeringAdmittedAt?: number; + durableAdmittedAt?: number; state: 'queued' | 'in_flight' | 'released'; leaseId?: string; } @@ -309,7 +293,6 @@ export interface RootFollowupBatch { readonly transitionId: string; readonly sessionId: string; readonly previousTurnId: string; - readonly initiatingConnectionId: string | undefined; readonly content: MessageContent; readonly submittedContent: MessageContent; readonly sources: readonly RootFollowupSource[]; @@ -439,15 +422,15 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } async recoverPendingAfterHostRestart(): Promise { - const bySession = new Map(); - for (const admission of await this.#receipts.listPendingSteering()) { + const bySession = new Map(); + for (const admission of await this.#receipts.listPendingMessages()) { const admissions = bySession.get(admission.sessionId); if (admissions) admissions.push(admission); else bySession.set(admission.sessionId, [admission]); } for (const [sessionId, durable] of bySession) { await this.#sessionAdmission.run(sessionId, async (admissionLease) => { - const pending: PendingSteeringAdmission[] = []; + const pending: PendingMessageAdmission[] = []; const settled: string[] = []; for (const candidate of durable) { const source = await this.#durableProof.readRootTurnSourceMessageReceipt( @@ -460,52 +443,52 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { sessionId, candidate.messageId, ); - if (source || consumed) settled.push(candidate.messageId); + const stopped = + !source && + !consumed && + (await this.#durableProof.readExplicitStopProof(sessionId, candidate.runId)); + if (source || consumed || stopped) settled.push(candidate.messageId); else pending.push(candidate); } if (settled.length > 0) { - await this.#receipts.settlePendingSteering(sessionId, settled); + await this.#receipts.garbageCollectMessageAdmissions(sessionId, settled); } if (pending.length === 0) return; const header = await this.#root.readSessionHeader(sessionId); if (!header || header.isArchived || header.unavailableReason) { throw new RuntimeMessageAuthorityInvariantError( - 'Pending steering recovery found an unavailable Session', + 'Pending Message recovery found an unavailable Session', ); } if ((await this.#root.readRootState(sessionId)).kind !== 'idle') { throw new RuntimeMessageAuthorityInvariantError( - 'Pending steering recovery requires an idle root after interrupted Run recovery', + 'Pending Message recovery requires an idle root after interrupted Run recovery', ); } - if (!this.#root.materializeSteeringAdmissions || !this.#root.startRecoveredSteering) { + if (!this.#root.startRecoveredMessages) { throw new RuntimeMessageAuthorityInvariantError( - 'Pending steering recovery authority is unavailable', + 'Pending Message recovery authority is unavailable', ); } - await this.#root.materializeSteeringAdmissions(pending); - const firstBatch = sameInitiatingClientAdmissionPrefix(pending); - const sources = firstBatch.map(pendingSteeringSource); - const started = await this.#root.startRecoveredSteering( + const sources = pending.map(pendingMessageSource); + const started = await this.#root.startRecoveredMessages( { sessionId, - content: aggregateMessageContent(firstBatch.map((entry) => entry.modelContent)), - submittedContent: aggregateMessageContent(firstBatch.map((entry) => entry.content)), + content: aggregateMessageContent(pending.map((entry) => entry.modelContent)), + submittedContent: aggregateMessageContent(pending.map((entry) => entry.content)), sources, - initiatingConnectionId: firstBatch[0]!.initiatingConnectionId, }, admissionLease, ); if ('error' in started) { throw new RuntimeMessageAuthorityInvariantError( - `Unable to recover pending steering: ${started.error}`, + `Unable to recover pending Message: ${started.error}`, ); } - await this.#receipts.settlePendingSteering( + await this.#receipts.garbageCollectMessageAdmissions( sessionId, - firstBatch.map((entry) => entry.messageId), + pending.map((entry) => entry.messageId), ); - this.#queueRecoveredSteering(pending.slice(firstBatch.length)); }); } } @@ -529,16 +512,25 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { async prepareTerminalTransition(identity: RuntimeMessageRunIdentity): Promise { const consumed: string[] = []; - for (const pending of await this.#receipts.listPendingSteering()) { - if (pending.sessionId !== identity.sessionId) continue; + const stopped: string[] = []; + const explicitStop = await this.#durableProof.readExplicitStopProof( + identity.sessionId, + identity.runId, + ); + for (const pending of await this.#receipts.listPendingMessages()) { + if (pending.sessionId !== identity.sessionId || pending.runId !== identity.runId) continue; const proof = await this.#durableProof.readImmutableSteeringMessageProof( identity.sessionId, pending.messageId, ); if (proof) consumed.push(pending.messageId); + else if (explicitStop) stopped.push(pending.messageId); } if (consumed.length > 0) { - await this.#receipts.settlePendingSteering(identity.sessionId, consumed); + await this.#receipts.garbageCollectMessageAdmissions(identity.sessionId, consumed); + } + if (stopped.length > 0) { + await this.#receipts.commitMessageRetractions(identity.sessionId, stopped); } if (this.#draining) await this.prepareStopFence(identity); } @@ -578,7 +570,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { this.#mutated(state); } state.run = undefined; - const entries = sameInitiatingClientPrefix(state.followup); + const entries = [...state.followup]; const followup = canonicalFollowupBatch(entries); const transition: TerminalTransition = { transitionId: this.#createId(), @@ -590,7 +582,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { transitionId: transition.transitionId, sessionId: identity.sessionId, previousTurnId: identity.turnId, - initiatingConnectionId: entries[0]?.initiatingConnectionId, content: followup.content, submittedContent: followup.submittedContent, sources: followup.sources, @@ -598,7 +589,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } async settleAdmittedRootSources(batch: RootFollowupBatch): Promise { - await this.#receipts.settlePendingSteering( + await this.#receipts.garbageCollectMessageAdmissions( batch.sessionId, batch.sources.map((source) => source.messageId), ); @@ -633,27 +624,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { this.#draining = true; } - #queueRecoveredSteering(admissions: readonly PendingSteeringAdmission[]): void { - for (const admission of admissions) { - const state = this.#state(admission.sessionId); - const residency = this.#acquireResidency(); - state.followup.push({ - entryId: this.#createId(), - messageId: admission.messageId, - content: admission.content, - modelContent: admission.modelContent, - initiatingConnectionId: admission.initiatingConnectionId, - placement: 'next_turn', - disposition: 'followup', - generation: state.generation, - residency, - pendingSteeringAdmittedAt: admission.admittedAt, - state: 'queued', - }); - this.#mutated(state); - } - } - prepareStopFence(identity: RuntimeMessageRunIdentity): void { const state = this.#sessions.get(identity.sessionId); // A root handoff can durably replace or release this identity before a concurrent @@ -672,11 +642,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } async commitStopFence(identity: RuntimeMessageRunIdentity): Promise { - const state = this.#requireState(identity.sessionId); - await this.#receipts.settlePendingSteering( - identity.sessionId, - [...state.steering, ...state.inFlight.values()].map((entry) => entry.messageId), - ); return this.#commitQueueFence(identity); } @@ -748,20 +713,39 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { : failure('operation_conflict', 'Message identity has a different payload'); } } + const settlement = await this.#receipts.readMessageSettlement( + input.sessionId, + input.messageId, + ); + if (this.#failStopped) { + return failure('host_draining', 'Runtime Host message authority has failed'); + } + if (settlement) { + const sameIdentity = + (!settlement.submittedPlacement || settlement.submittedPlacement === input.placement) && + (!settlement.submittedContentDigest || + settlement.submittedContentDigest === messageContentDigest(payload.content)); + return failure( + 'operation_conflict', + sameIdentity + ? 'Message identity was durably retracted' + : 'Durably settled message identity has a different payload', + ); + } const durableAdmission = isCurrentEpoch ? undefined - : await this.#durableProof.readSteeringAdmission(input.sessionId, input.messageId); + : await this.#receipts.readMessageAdmission(input.sessionId, input.messageId); if (this.#failStopped) { return failure('host_draining', 'Runtime Host message authority has failed'); } if ( durableAdmission && - (input.placement !== 'current_turn' || - durableAdmission.sessionId !== input.sessionId || + (durableAdmission.sessionId !== input.sessionId || durableAdmission.messageId !== input.messageId || + durableAdmission.submittedPlacement !== input.placement || !messageContentsEqual(durableAdmission.content, payload.content)) ) { - return failure('operation_conflict', 'Durable steering admission has a different payload'); + return failure('operation_conflict', 'Durable message admission has a different payload'); } const durableProof = await this.#queryDurableSubmitProof(input, payload); if (this.#failStopped) { @@ -855,12 +839,15 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { (entry) => entry.messageId === durableAdmission.messageId, ); if (existing) { - if (existing.disposition !== 'steering') { + if (existing.disposition !== durableAdmission.disposition) { throw new RuntimeMessageAuthorityInvariantError( - 'Durable steering admission collided with a non-steering entry', + 'Durable message admission collided with a different queue disposition', ); } - const result = { disposition: 'steering', queueRevision: state.revision } as const; + const result = { + disposition: durableAdmission.disposition, + queueRevision: state.revision, + } as const; try { await this.#commitReceipt( 'submit', @@ -957,25 +944,24 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { continue; } const result = { disposition, queueRevision: candidateRevision + 1 } as const; - if (disposition === 'steering' && !durableAdmission) { - const pending = await this.#receipts.commitPendingSteering({ - sessionId: input.sessionId, - turnId: rootState.turnId, - runId: rootState.runId, - messageId: input.messageId, - content: payload.content, - modelContent: prepared.content, - initiatingConnectionId, - admittedAt: Date.now(), - }); - await this.#root.commitSteeringAdmission({ - sessionId: pending.sessionId, - turnId: pending.turnId, - runId: pending.runId, - messageId: pending.messageId, - content: pending.content, - admittedAt: pending.admittedAt, - }); + let durableAdmittedAt: number | undefined; + if (!durableAdmission) { + const admitted = await this.#root.commitMessageAdmission( + { + sessionId: input.sessionId, + turnId: rootState.turnId, + runId: rootState.runId, + messageId: input.messageId, + content: payload.content, + modelContent: prepared.content, + submittedPlacement: input.placement, + placement: input.placement, + disposition, + admittedAt: Date.now(), + }, + disposition === 'steering', + ); + durableAdmittedAt = admitted.admittedAt; } const residency = this.#acquireResidency(); const entry: LiveEntry = { @@ -983,11 +969,12 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { messageId: input.messageId, content: payload.content, modelContent: prepared.content, - initiatingConnectionId, + submittedPlacement: input.placement, placement: input.placement, disposition, generation: state.generation, residency, + durableAdmittedAt: durableAdmittedAt ?? durableAdmission?.admittedAt, state: 'queued', }; if (disposition === 'steering') state.steering.push(entry); @@ -1038,12 +1025,14 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { retracted: queued.map(retractedSnapshot), }; const retractedEntries = [...state.followup]; - const retracted = this.#retractFollowups(state); - if (retracted.length > 0) { - await this.#receipts.settlePendingSteering( + if (retractedEntries.length > 0) { + await this.#receipts.commitMessageRetractions( input.sessionId, retractedEntries.map((entry) => entry.messageId), ); + } + const retracted = this.#retractFollowups(state); + if (retracted.length > 0) { this.#mutated(state); } if (!isDeepStrictEqual(result, { queueRevision: state.revision, retracted })) { @@ -1223,8 +1212,8 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } return failure('not_found', 'Message queue entry does not exist'); } + await this.#receipts.commitMessageRetractions(input.sessionId, [queued.entry.messageId]); queued.remove(); - await this.#receipts.settlePendingSteering(input.sessionId, [queued.entry.messageId]); this.#releaseEntry(queued.entry); this.#mutated(state); this.#maybeReclaim(input.sessionId, state); @@ -1277,25 +1266,22 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } return failure('not_found', 'Message queue entry does not exist'); } - const pending = await this.#receipts.commitPendingSteering({ - sessionId: input.sessionId, - turnId: rootState.turnId, - runId: rootState.runId, - messageId: entry.messageId, - content: entry.content, - modelContent: entry.modelContent, - initiatingConnectionId: entry.initiatingConnectionId, - admittedAt: entry.pendingSteeringAdmittedAt ?? Date.now(), - }); - entry.pendingSteeringAdmittedAt = pending.admittedAt; - await this.#root.commitSteeringAdmission({ - sessionId: pending.sessionId, - turnId: pending.turnId, - runId: pending.runId, - messageId: pending.messageId, - content: pending.content, - admittedAt: pending.admittedAt, - }); + const pending = await this.#root.commitMessageAdmission( + { + sessionId: input.sessionId, + turnId: rootState.turnId, + runId: rootState.runId, + messageId: entry.messageId, + content: entry.content, + modelContent: entry.modelContent, + submittedPlacement: entry.submittedPlacement, + placement: 'current_turn', + disposition: 'steering', + admittedAt: entry.durableAdmittedAt ?? Date.now(), + }, + true, + ); + entry.durableAdmittedAt = pending.admittedAt; state.followup.splice(index, 1); state.steering.push({ ...entry, placement: 'current_turn', disposition: 'steering' }); this.#mutated(state); @@ -1426,6 +1412,10 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { reordered.push(entry); } if (reordered.some((entry, index) => current[index] !== entry)) { + await this.#receipts.commitMessageOrder( + input.sessionId, + reordered.map((entry) => entry.messageId), + ); state.followup = reordered; this.#mutated(state); } @@ -1636,7 +1626,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { if (event) { const durableDigest = event.refs?.sourceMessageDigest; if ( - input.placement !== 'current_turn' || event.content?.kind !== 'text' || (durableDigest !== undefined ? durableDigest !== messageContentDigest(payload.content) @@ -1890,7 +1879,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { !transition || transition.transitionId !== batch.transitionId || transition.identity.turnId !== batch.previousTurnId || - transition.entries[0]?.initiatingConnectionId !== batch.initiatingConnectionId || !isDeepStrictEqual(transition.entries.map(sourceFromEntry), batch.sources) || !messageContentsEqual( aggregateMessageContent(transition.entries.map((entry) => entry.modelContent)), @@ -2112,7 +2100,7 @@ function sameSourcePayload( (durableDigest ? durableDigest === messageContentDigest(input.content) : messageContentsEqual(source.content, input.content)) && - source.placement === input.placement + (source.submittedPlacement ?? source.placement) === input.placement ); } @@ -2121,18 +2109,24 @@ function sourceFromEntry(entry: LiveEntry): RootFollowupSource { messageId: entry.messageId, content: normalizeMessageContent(entry.modelContent), submittedContentDigest: messageContentDigest(entry.content), + ...(entry.submittedPlacement !== entry.placement + ? { submittedPlacement: entry.submittedPlacement } + : {}), placement: entry.placement, disposition: entry.disposition, }; } -function pendingSteeringSource(entry: PendingSteeringAdmission): RootTurnSourceMessage { +function pendingMessageSource(entry: PendingMessageAdmission): RootTurnSourceMessage { return { messageId: entry.messageId, content: normalizeMessageContent(entry.modelContent), submittedContentDigest: messageContentDigest(entry.content), - placement: 'current_turn', - disposition: 'steering', + ...(entry.submittedPlacement !== entry.placement + ? { submittedPlacement: entry.submittedPlacement } + : {}), + placement: entry.placement, + disposition: entry.disposition, }; } @@ -2274,26 +2268,6 @@ function canonicalFollowupBatch(entries: readonly LiveEntry[]): { } } -function sameInitiatingClientPrefix(entries: readonly LiveEntry[]): LiveEntry[] { - const initiatingConnectionId = entries[0]?.initiatingConnectionId; - if (!initiatingConnectionId) return []; - const boundary = entries.findIndex( - (entry) => entry.initiatingConnectionId !== initiatingConnectionId, - ); - return entries.slice(0, boundary === -1 ? entries.length : boundary); -} - -function sameInitiatingClientAdmissionPrefix( - admissions: readonly PendingSteeringAdmission[], -): PendingSteeringAdmission[] { - const initiatingConnectionId = admissions[0]?.initiatingConnectionId; - if (!initiatingConnectionId) return []; - const boundary = admissions.findIndex( - (admission) => admission.initiatingConnectionId !== initiatingConnectionId, - ); - return admissions.slice(0, boundary === -1 ? admissions.length : boundary); -} - function rootAdmissionPayloadFits(sources: readonly RootTurnSourceMessage[]): boolean { try { const content = aggregateMessageContent(sources.map((source) => source.content)); diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index ebae2df1dc..fab9039d41 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -63,7 +63,7 @@ import { isSessionNotFoundError, normalizeRootTurnAdmissionPayload, type ExecutionStoresWriter, - type PendingSteeringAdmission, + type PendingMessageAdmission, type RootTurnAdmission, } from '@maka/storage/execution-stores'; import type { @@ -89,7 +89,7 @@ import { type QueueFenceResult, type RootFollowupBatch, } from './message-coordinator.js'; -import { messageContentDigest } from './message-content-digest.js'; +import { messageContentDigest } from '@maka/storage/message-content-digest'; import type { ConnectionContext, TurnOperationHandlerMap } from './operation-dispatcher.js'; import { RootAdmissionOwner } from './root-admission-owner.js'; import { type SessionAdmissionLease, SessionAdmissionGate } from './session-admission-gate.js'; @@ -869,7 +869,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { stopRoot( identity: RuntimeMessageRunIdentity, input: { - source?: 'stop_button' | 'graph_supervisor'; + source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; mode?: BackendStopMode; } = {}, ): Promise { @@ -902,7 +902,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { stopSession( sessionId: string, input: { - source?: 'stop_button' | 'graph_supervisor'; + source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; mode?: BackendStopMode; } = {}, ): Promise { @@ -955,7 +955,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { sessionId: string, input: { expectedGraphId?: string; - source?: 'stop_button' | 'graph_supervisor'; + source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; mode?: BackendStopMode; } = {}, ): Promise { @@ -1090,14 +1090,14 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { }); } - startRecoveredSteering( + startRecoveredMessages( input: HostMessageRecoveryBatch, admissionLease: SessionAdmissionLease, ): Promise<{ readonly turnId: string } | { readonly error: string }> { return this.runCommand(async () => { if (this.#executions.has(input.sessionId)) { throw new RuntimeMessageAuthorityInvariantError( - 'Pending steering recovery attempted to replace a live root Turn', + 'Pending Message recovery attempted to replace a live root Turn', ); } const reservation = this.reserveRootTurn(input.sessionId); @@ -1106,40 +1106,17 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const header = await this.stores.sessionStore.readHeaderSnapshot(input.sessionId); const unavailableReason = runtimeHostExternalTurnUnavailableReason(header); if (unavailableReason) return { error: unavailableReason }; - await this.clientCapabilities?.bindConfirmedFollowup( - input.sessionId, - input.initiatingConnectionId, - ); if (!this.beginRootAdmission(reservation)) { return { error: 'Root Turn reservation is no longer current' }; } - await this.prepareFreshAgentGraphEpoch(header); - const turnId = randomUUID(); - const admitted = await this.rootAdmissionOwner.admitRootTurn({ - sessionId: input.sessionId, - turnId, - proposedRunId: randomUUID(), - proposedUserMessageId: randomUUID(), - execution: { - kind: 'external_message', - inputDigest: messageContentDigest(input.submittedContent), - }, - normalizedInput: input.content, - sourceMessages: input.sources, - admittedAt: Date.now(), - }); - if (admitted.kind !== 'admitted') { - throw new RuntimeMessageAuthorityInvariantError( - 'Recovered steering root Turn identity already existed', - ); - } + const { turnId, admission } = await this.admitQueuedMessageRoot(input, header); const disposition = await this.prepareAdmittedTurn( { sessionId: input.sessionId, turnId, - content: admitted.admission.normalizedInput, + content: admission.normalizedInput, }, - admitted.admission, + admission, this.acquireRecoveryResidency, admissionLease, undefined, @@ -1148,7 +1125,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { ); if (disposition.kind !== 'await_start') { throw new RuntimeMessageAuthorityInvariantError( - 'Recovered steering root Turn did not reserve execution', + 'Recovered Message root Turn did not reserve execution', ); } return { turnId }; @@ -1158,21 +1135,6 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { }); } - materializeSteeringAdmissions(admissions: readonly PendingSteeringAdmission[]): Promise { - return this.runCommand(() => this.manager.materializeSteeringAdmissions(admissions)); - } - - commitSteeringAdmission(input: { - readonly sessionId: string; - readonly turnId: string; - readonly runId: string; - readonly messageId: string; - readonly content: MessageContent; - readonly admittedAt: number; - }): Promise { - return this.runCommand(() => this.manager.commitSteeringAdmission(input)); - } - prepareMessage( input: HostMessagePreparationInput, ): Promise< @@ -1887,7 +1849,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { commitQueueFence: () => QueueFenceResult | Promise, admission: SessionAdmissionLease, stopInput: { - source?: 'stop_button' | 'graph_supervisor'; + source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; mode?: BackendStopMode; } = {}, ): Promise { @@ -2379,43 +2341,13 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { previous: ActiveRootTurn, admissionLease: SessionAdmissionLease, ): Promise { - const initiatingConnectionId = batch.initiatingConnectionId; - if (!initiatingConnectionId) { - throw new RuntimeMessageAuthorityInvariantError( - 'Follow-up batch lost its initiating Client identity', - ); - } - // A confirmed follow-up must become a durable root even when a Session - // provider is unavailable. Lost tools are omitted while ephemeral - // capabilities bind to the Client that submitted this follow-up. - await this.clientCapabilities?.bindConfirmedFollowup(batch.sessionId, initiatingConnectionId); - - const turnId = randomUUID(); const header = await this.stores.sessionStore.readHeaderSnapshot(batch.sessionId); - await this.prepareFreshAgentGraphEpoch(header); - const admitted = await this.rootAdmissionOwner.admitRootTurn({ - sessionId: batch.sessionId, - turnId, - proposedRunId: randomUUID(), - proposedUserMessageId: randomUUID(), - execution: { - kind: 'external_message', - inputDigest: messageContentDigest(batch.submittedContent), - }, - normalizedInput: batch.content, - sourceMessages: batch.sources, - admittedAt: Date.now(), - }); - if (admitted.kind !== 'admitted') { - throw new RuntimeMessageAuthorityInvariantError( - 'Fresh follow-up root Turn identity already existed', - ); - } + const { turnId, admission } = await this.admitQueuedMessageRoot(batch, header); const nextIdentity = { sessionId: batch.sessionId, turnId, - runId: admitted.admission.runId, + runId: admission.runId, }; this.messages.commitNextRoot(batch, nextIdentity); previous.messageTransitionCommitted = true; @@ -2428,9 +2360,9 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { { sessionId: batch.sessionId, turnId, - content: admitted.admission.normalizedInput, + content: admission.normalizedInput, }, - admitted.admission, + admission, this.acquireRecoveryResidency, admissionLease, previous, @@ -2443,10 +2375,38 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { await this.messages.settleAdmittedRootSources(batch); } + private async admitQueuedMessageRoot( + batch: Pick, + header: SessionHeader, + ): Promise<{ readonly turnId: string; readonly admission: RootTurnAdmission }> { + await this.clientCapabilities?.bindDurableSession(batch.sessionId); + await this.prepareFreshAgentGraphEpoch(header); + const turnId = randomUUID(); + const admitted = await this.rootAdmissionOwner.admitRootTurn({ + sessionId: batch.sessionId, + turnId, + proposedRunId: randomUUID(), + proposedUserMessageId: randomUUID(), + execution: { + kind: 'external_message', + inputDigest: messageContentDigest(batch.submittedContent), + }, + normalizedInput: batch.content, + sourceMessages: batch.sources, + admittedAt: Date.now(), + }); + if (admitted.kind !== 'admitted') { + throw new RuntimeMessageAuthorityInvariantError( + 'Queued Message root Turn identity already existed', + ); + } + return { turnId, admission: admitted.admission }; + } + private async deliverRuntimeStopIntent( sessionId: string, input: { - source?: 'stop_button' | 'graph_supervisor'; + source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; mode?: BackendStopMode; } = { source: 'stop_button' }, ): Promise { @@ -2454,11 +2414,14 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { } private async stopActiveTurn(sessionId: string, active: ActiveRootTurn): Promise { - await this.stopRoot({ - sessionId, - turnId: active.turnId, - runId: active.runId, - }); + await this.stopRoot( + { + sessionId, + turnId: active.turnId, + runId: active.runId, + }, + { source: 'host_shutdown' }, + ); } private async readCanonicalSnapshot( diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 5545a5e44e..fc15e85544 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -48,7 +48,7 @@ import type { UserMessageInput, } from '@maka/core/runtime-inputs'; import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; -import type { QueueEnqueueOutcome, SessionEvent, ShellRunSnapshotResult } from '@maka/core/events'; +import type { SessionEvent, ShellRunSnapshotResult } from '@maka/core/events'; import type { AgentGraphIntentClaim, AgentGraphIntentClaimStore, @@ -120,7 +120,6 @@ import { WEB_RESEARCH_AGENT_ID, } from '../agent-catalog.js'; import { - RuntimeMessageAuthorityInvariantError, type RuntimeHostedRootAuthority, type RuntimeMessageRunIdentity, } from '../message-authority.js'; @@ -14751,44 +14750,7 @@ describe('SessionManager permission mode updates', () => { }); }); -describe('SessionManager steering and followup queues', () => { - function steeringManager() { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - backends.register('ai-sdk', (ctx) => new FakeBackend(ctx)); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(1_000), - }); - return { manager, store }; - } - - // Run a turn and invoke `duringFirstDelta` synchronously the first time the - // turn streams text — the point at which a real user would type while the - // agent works. Returns every streamed event. - async function runTurnWith( - manager: SessionManager, - sessionId: string, - turnId: string, - duringFirstDelta: () => void, - ): Promise { - const events: SessionEvent[] = []; - let fired = false; - for await (const event of manager.sendMessage(sessionId, { turnId, text: 'hello' })) { - events.push(event); - if (!fired && event.type === 'text_delta') { - fired = true; - duringFirstDelta(); - } - } - return events; - } - +describe('SessionManager hosted run ownership', () => { test('hosted root runs consume the Host owner and release it exactly once', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -14852,20 +14814,6 @@ describe('SessionManager steering and followup queues', () => { ), ).toBe(true); expect(events.some((event) => event.type === 'queue_update')).toBe(false); - for (const operation of [ - () => manager.steer(session.id, 'runtime mirror'), - () => manager.queueMessage(session.id, 'runtime mirror'), - () => manager.drainFollowup(session.id), - () => manager.retractQueue(session.id), - ]) { - let error: unknown; - try { - operation(); - } catch (caught) { - error = caught; - } - expect(error instanceof RuntimeMessageAuthorityInvariantError).toBe(true); - } }); test('hosted Interaction binds the durable Run identity and closes before release', async () => { @@ -15039,463 +14987,6 @@ describe('SessionManager steering and followup queues', () => { expect(releases).toBe(1); }); - test('a failed turn begin never leaks a steering owner', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - let failBuilds = 1; - backends.register('ai-sdk', (ctx) => { - if (failBuilds > 0) { - failBuilds -= 1; - throw new Error('backend build failed'); - } - return new FakeBackend(ctx); - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(1_000), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - - let failed: unknown; - try { - for await (const _event of manager.sendMessage(session.id, { - turnId: 'turn-fail', - text: 'hello', - })) { - // drain - } - } catch (error) { - failed = error; - } - expect((failed as Error).message).toBe('backend build failed'); - - // The failed begin must not have left a live owner: steering falls back - // instead of queueing a message no run will ever consume. - expect(manager.steer(session.id, 'orphaned')).toEqual({ kind: 'fallback' }); - expect(manager.queueMessage(session.id, 'orphaned too')).toEqual({ kind: 'fallback' }); - - // A later successful turn establishes ownership normally. - let outcome: QueueEnqueueOutcome | undefined; - const events = await runTurnWith(manager, session.id, 'turn-2', () => { - outcome = manager.steer(session.id, 'now consumed'); - }); - expect(outcome?.kind).toBe('queued'); - expect( - events.some( - (event) => event.type === 'steering_message' && event.content.text === 'now consumed', - ), - ).toBe(true); - }); - - test('an overlapping turn cannot drain steering queued for the current owner', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - let backend: GatedSteeringBackend | undefined; - backends.register('ai-sdk', (ctx) => { - backend = new GatedSteeringBackend(ctx); - return backend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(1_000), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - - const first = drainAll(manager.sendMessage(session.id, { turnId: 'turn-a', text: 'first' })); - await waitUntil(() => backend?.gates.has('turn-a') === true); - const second = drainAll(manager.sendMessage(session.id, { turnId: 'turn-b', text: 'second' })); - await waitUntil(() => backend?.gates.has('turn-b') === true); - - // turn-b established ownership last, so the steer targets it. - expect(manager.steer(session.id, 'for the owner').kind).toBe('queued'); - - // The stale turn's pull hook fails the identity check and drains nothing. - backend?.release('turn-a'); - const firstEvents = await first; - expect(backend?.pulls.get('turn-a')).toEqual([[]]); - expect(firstEvents.some((event) => event.type === 'steering_message')).toBe(false); - - // The owner drains exactly the queued message. - backend?.release('turn-b'); - const secondEvents = await second; - expect(backend?.pulls.get('turn-b')).toEqual([['for the owner']]); - expect( - secondEvents.some( - (event) => event.type === 'steering_message' && event.content.text === 'for the owner', - ), - ).toBe(true); - }); - - test('a pulled lease is past the retract point: retract excludes it and it delivers exactly once', async () => { - // Round-5 F1/D1: pull() is the single atomic commit point. Once leased, - // the message belongs to this turn's delivery — a retract during the - // (slow) durable append returns only still-queued text, never the - // in-flight lease; otherwise the retracted text would ALSO be executed by - // the provider once the append lands (refill + execute = two copies). - const gate = makeGate(); - const parked = makeGate(); - class GatedRuntimeEventStore extends MemoryAgentRunStore { - override async appendRuntimeEvent( - sessionId: string, - runId: string, - event: RuntimeEvent, - ): Promise { - if ( - event.content?.kind === 'text' && - (event.content as { steering?: boolean }).steering === true - ) { - parked.release(); - await gate.promise; - } - return super.appendRuntimeEvent(sessionId, runId, event); - } - } - const runStore = new GatedRuntimeEventStore(); - const model = steeringToolThenDoneModel(); - const { manager, session } = await steeringDeliverySession( - runStore, - model, - (manager, sessionId) => { - expect(manager.steer(sessionId, 'urgent steer').kind).toBe('queued'); - }, - ); - - const turnEvents: SessionEvent[] = []; - const turn = (async () => { - for await (const event of manager.sendMessage(session.id, { turnId: 'turn-1', text: 'go' })) { - turnEvents.push(event); - } - })(); - await parked.promise; - // The steering append has not committed: the next provider request must - // not have started while the message is not durable. - await new Promise((resolve) => setTimeout(resolve, 25)); - expect(model.doStreamCalls.length).toBe(1); - // Pulled means committed to this turn: retract returns nothing. - expect(manager.retractQueue(session.id)).toBe(''); - gate.release(); - await turn; - // The message delivered exactly once: in the next provider request… - expect(model.doStreamCalls.length).toBe(2); - expect(JSON.stringify(model.doStreamCalls[1]?.prompt).includes('urgent steer')).toBe(true); - // …echoed once in the stream/ledger… - expect(turnEvents.filter((event) => event.type === 'steering_message').length).toBe(1); - // …and owned by no queue afterwards. - expect(manager.drainFollowup(session.id)).toBe(null); - expect(manager.retractQueue(session.id)).toBe(''); - }); - - test('an abort never converts a durably appended steering message into a redelivery', async () => { - // Round-5 F1/D3: abort does not settle a pushed lease — settlement is - // decided only by the persistence fact. Here the append is parked when - // the stop arrives; once it commits, the message belongs to the ledger - // (history replay presents it to the next turn) and must NOT also be - // nacked into the followup queue, which would put the same directive in - // the account twice. - const gate = makeGate(); - const parked = makeGate(); - class GatedRuntimeEventStore extends MemoryAgentRunStore { - override async appendRuntimeEvent( - sessionId: string, - runId: string, - event: RuntimeEvent, - ): Promise { - if ( - event.content?.kind === 'text' && - (event.content as { steering?: boolean }).steering === true - ) { - parked.release(); - await gate.promise; - } - return super.appendRuntimeEvent(sessionId, runId, event); - } - } - const runStore = new GatedRuntimeEventStore(); - const model = steeringToolThenDoneModel(); - const { manager, session } = await steeringDeliverySession( - runStore, - model, - (manager, sessionId) => { - expect(manager.steer(sessionId, 'urgent steer').kind).toBe('queued'); - }, - ); - - const turn = (async () => { - try { - for await (const _event of manager.sendMessage(session.id, { - turnId: 'turn-1', - text: 'go', - })) { - // drain - } - } catch { - // the abort may end the stream abruptly - } - })(); - await parked.promise; - void manager.stopSession(session.id, { source: 'stop_button' }); - // Let the abort reach the backend's durability wait while the append is - // still parked — the exact window where an abort-settles-the-lease bug - // nacks a message that then also commits to the ledger. - await new Promise((resolve) => setTimeout(resolve, 25)); - gate.release(); - // Teardown converges: the parked append commits, the lease settles, and - // the aborted send terminates without hanging. - await turn; - - // The dying request was never sent… - expect(model.doStreamCalls.length).toBe(1); - // …the ledger owns the message (exactly one durable steering event)… - const runs = await runStore.listSessionRuns(session.id); - const steeringEvents: RuntimeEvent[] = []; - for (const run of runs) { - const events = await runStore.readRuntimeEvents(session.id, run.runId); - steeringEvents.push( - ...events.filter( - (event) => - event.content?.kind === 'text' && - (event.content as { steering?: boolean }).steering === true, - ), - ); - } - expect(steeringEvents.length).toBe(1); - // …and no queue redelivers it. - expect(manager.drainFollowup(session.id)).toBe(null); - expect(manager.retractQueue(session.id)).toBe(''); - }); - - test('a nack that lands after the owner released folds into the followup queue, not an ownerless steering queue', async () => { - // Round-5 F3: turn A's append fails only after turn B took over and - // released. A's nack can no longer target A (it will never pull again) — - // the text's only safe home is the followup queue, exactly where a - // release-time fold would have put it. - const gate = makeGate(); - const parked = makeGate(); - class ParkThenFailStore extends MemoryAgentRunStore { - override async appendRuntimeEvent( - sessionId: string, - runId: string, - event: RuntimeEvent, - ): Promise { - if ( - event.content?.kind === 'text' && - (event.content as { steering?: boolean }).steering === true - ) { - parked.release(); - await gate.promise; - throw new Error('steering append failed'); - } - return super.appendRuntimeEvent(sessionId, runId, event); - } - } - const runStore = new ParkThenFailStore(); - const model = steeringToolThenDoneModel(); - const { manager, session } = await steeringDeliverySession( - runStore, - model, - (manager, sessionId) => { - manager.steer(sessionId, 'urgent steer'); - }, - ); - - const turnA = (async () => { - try { - for await (const _event of manager.sendMessage(session.id, { - turnId: 'turn-1', - text: 'go', - })) { - // drain - } - } catch { - // the failed append ends the stream abruptly - } - })(); - await parked.promise; - // Turn B takes ownership and releases it while A is parked. - for await (const _event of manager.sendMessage(session.id, { - turnId: 'turn-2', - text: 'second', - })) { - // drain - } - gate.release(); - await turnA; - - // The failed message is redeliverable exactly once, via followup. - expect(manager.drainFollowup(session.id)).toBe('urgent steer'); - expect(manager.retractQueue(session.id)).toBe(''); - }); - - test('steer falls back when no RuntimeEventStore is configured', async () => { - // Round-5 F4: without a runtime event ledger, the steering durability ack - // has nothing to anchor to — the fail-closed persist contract cannot be - // honored. The fallback path opens a fresh turn whose user message is - // persisted by the SessionStore, keeping the same durability guarantee. - const store = new MemorySessionStore(); - const backends = new BackendRegistry(); - let backend: GatedSteeringBackend | undefined; - backends.register('ai-sdk', (ctx) => { - backend = new GatedSteeringBackend(ctx); - return backend; - }); - const manager = new SessionManager({ - store, - backends, - newId: nextId(), - now: nextNow(1_000), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - - const turn = drainAll(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'go' })); - await waitUntil(() => backend?.gates.has('turn-1') === true); - // A live turn exists, but steering cannot be made durable: fall back. - expect(manager.steer(session.id, 'no ledger')).toEqual({ kind: 'fallback' }); - // Followups are unaffected — they open a normal turn anyway. - expect(manager.queueMessage(session.id, 'later').kind).toBe('queued'); - backend?.gates.get('turn-1')?.release(); - backend?.pullDone.get('turn-1')?.release(); - await turn; - }); - - test('a failed steering append nacks the lease back to the queue and the request never carries it', async () => { - // Fail-CLOSED persistence: the steering append throws, the ack judgment - // propagates the failure (no fail-open swallow), the lease is nacked back - // to the queue (folded into followup at release), and neither the ledger - // nor the projection carries the undelivered message. - class FailingSteeringStore extends MemoryAgentRunStore { - override async appendRuntimeEvent( - sessionId: string, - runId: string, - event: RuntimeEvent, - ): Promise { - if ( - event.content?.kind === 'text' && - (event.content as { steering?: boolean }).steering === true - ) { - throw new Error('steering append failed'); - } - return super.appendRuntimeEvent(sessionId, runId, event); - } - } - const runStore = new FailingSteeringStore(); - const model = steeringToolThenDoneModel(); - const { manager, session } = await steeringDeliverySession( - runStore, - model, - (manager, sessionId) => { - expect(manager.steer(sessionId, 'urgent steer').kind).toBe('queued'); - }, - ); - - let failed: unknown; - try { - for await (const _event of manager.sendMessage(session.id, { - turnId: 'turn-1', - text: 'go', - })) { - // drain - } - } catch (error) { - failed = error; - } - expect(failed instanceof Error).toBe(true); - // The dying request never carried the steering: no second provider call. - expect(model.doStreamCalls.length).toBe(1); - // Nacked back to the queue and folded into followup at release — the - // text is redeliverable, not lost. - expect(manager.drainFollowup(session.id)).toBe('urgent steer'); - // Ledger and projection agree: the message was never persisted. - const messages = await manager.getMessages(session.id); - expect( - messages.some((message) => message.type === 'user' && message.text === 'urgent steer'), - ).toBe(false); - }); - - test('an overlapping turn cannot turn a delivered lease into a followup redelivery', async () => { - // Round-4 V1: turn A leases the steer and parks in the (gated) durable - // append; turn B starts meanwhile and takes the owner slot. A's append - // then commits and A's provider request carries the message — so A's ack - // MUST still settle the lease (it is keyed by issuer, not by the current - // owner), and B's teardown must not fold A's in-flight lease into the - // followup queue, which would redeliver an already-executed directive. - const gate = makeGate(); - const parked = makeGate(); - class GatedRuntimeEventStore extends MemoryAgentRunStore { - override async appendRuntimeEvent( - sessionId: string, - runId: string, - event: RuntimeEvent, - ): Promise { - if ( - event.content?.kind === 'text' && - (event.content as { steering?: boolean }).steering === true - ) { - parked.release(); - await gate.promise; - } - return super.appendRuntimeEvent(sessionId, runId, event); - } - } - const runStore = new GatedRuntimeEventStore(); - const model = steeringToolThenDoneModel(); - const { manager, session } = await steeringDeliverySession( - runStore, - model, - (manager, sessionId) => { - manager.steer(sessionId, 'urgent steer'); - }, - ); - - const turnAEvents: SessionEvent[] = []; - const turnA = (async () => { - try { - for await (const event of manager.sendMessage(session.id, { - turnId: 'turn-1', - text: 'go', - })) { - turnAEvents.push(event); - } - } catch { - // A gated teardown may end the stream abruptly. - } - })(); - await parked.promise; - - // Turn B runs to completion while A is parked mid-lease. - for await (const _event of manager.sendMessage(session.id, { - turnId: 'turn-2', - text: 'second', - })) { - // drain - } - expect(model.doStreamCalls.length).toBe(2); - - gate.release(); - await turnA; - - // A's post-steer request went out carrying the directive exactly once… - expect(model.doStreamCalls.length).toBe(3); - expect(JSON.stringify(model.doStreamCalls[2]?.prompt).includes('urgent steer')).toBe(true); - // …B's request never did… - expect(JSON.stringify(model.doStreamCalls[1]?.prompt).includes('urgent steer')).toBe(false); - // …the ledger echoes it exactly once… - expect(turnAEvents.filter((event) => event.type === 'steering_message').length).toBe(1); - // …and NOTHING redelivers it: the delivered lease was acked by its - // issuer, so no queue still holds the text. - expect(manager.drainFollowup(session.id)).toBe(null); - expect(manager.retractQueue(session.id)).toBe(''); - }); - test('a backend-forged queue_update never reaches the ledger or observers', async () => { // Round-6 R3: the kernel is the only legal producer of queue_update (it // pushes them directly into the turn stream). A backend that yields one @@ -15568,111 +15059,6 @@ describe('SessionManager steering and followup queues', () => { ), ).toBe(false); }); - - test('an append error after the write landed settles by the ledger read-back, not a duplicate nack', async () => { - // Round-6 R5: appendRuntimeEvent can fail AFTER the bytes landed (e.g. a - // close error). Treating every append error as not-durable would nack a - // message the ledger already owns — history replay plus the followup - // redelivery equals a double. The ambiguous failure is settled by reading - // the ledger back: present ⇒ durable ⇒ ack path. - class WriteThenThrowStore extends MemoryAgentRunStore { - override async appendRuntimeEvent( - sessionId: string, - runId: string, - event: RuntimeEvent, - ): Promise { - if ( - event.content?.kind === 'text' && - (event.content as { steering?: boolean }).steering === true - ) { - await super.appendRuntimeEvent(sessionId, runId, event); - throw new Error('close failed after the write landed'); - } - return super.appendRuntimeEvent(sessionId, runId, event); - } - } - const runStore = new WriteThenThrowStore(); - const model = steeringToolThenDoneModel(); - const { manager, session } = await steeringDeliverySession( - runStore, - model, - (manager, sessionId) => { - manager.steer(sessionId, 'urgent steer'); - }, - ); - - const turnEvents: SessionEvent[] = []; - for await (const event of manager.sendMessage(session.id, { turnId: 'turn-1', text: 'go' })) { - turnEvents.push(event); - } - - // Delivered exactly once: the next request carries it… - expect(model.doStreamCalls.length).toBe(2); - expect(JSON.stringify(model.doStreamCalls[1]?.prompt).includes('urgent steer')).toBe(true); - // …the ledger owns exactly one copy… - const runs = await runStore.listSessionRuns(session.id); - const steeringEvents: RuntimeEvent[] = []; - for (const run of runs) { - const events = await runStore.readRuntimeEvents(session.id, run.runId); - steeringEvents.push( - ...events.filter( - (event) => - event.content?.kind === 'text' && - (event.content as { steering?: boolean }).steering === true, - ), - ); - } - expect(steeringEvents.length).toBe(1); - // …and no queue redelivers it. - expect(manager.drainFollowup(session.id)).toBe(null); - expect(manager.retractQueue(session.id)).toBe(''); - }); - - test('stranded steering emits a final queue snapshot when it folds into the followup queue', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - let backend: GatedSteeringBackend | undefined; - backends.register('ai-sdk', (ctx) => { - backend = new GatedSteeringBackend(ctx); - return backend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(1_000), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - - const turn = drainAll(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'go' })); - await waitUntil(() => backend?.gates.has('turn-1') === true); - backend?.gates.get('turn-1')?.release(); - // The turn's only step boundary has already pulled (empty)… - await waitUntil(() => backend?.pulls.has('turn-1') === true); - // …so this steer is stranded: no step is left to consume it. - expect(manager.steer(session.id, 'late').kind).toBe('queued'); - backend?.pullDone.get('turn-1')?.release(); - const events = await turn; - - // The stranded → followup migration is a queue change; the LAST snapshot - // in the stream reflects it, not the stale pre-fold state. - const updates = events.filter( - (event): event is Extract => - event.type === 'queue_update', - ); - expect(updates.at(-1)?.steering).toEqual([]); - expect(updates.at(-1)?.followup).toEqual(['late']); - expect(updates.at(-1)?.steeringEntries).toEqual([]); - expect(updates.at(-1)?.followupEntries).toHaveLength(1); - expect(updates.at(-1)?.followupEntries?.[0]?.content).toEqual({ text: 'late' }); - expect(updates.at(-1)?.followupEntries?.[0]?.placement).toBe('next_turn'); - expect(updates.at(-1)?.followupEntries?.[0]?.state).toBe('queued'); - // And the followup queue is the authoritative owner of the text. - expect(manager.drainFollowup(session.id)).toBe('late'); - }); }); async function drainAll(iterable: AsyncIterable): Promise { @@ -15681,182 +15067,6 @@ async function drainAll(iterable: AsyncIterable): Promise boolean | Promise): Promise { - for (let i = 0; i < 500 && !(await predicate()); i += 1) { - await new Promise((resolve) => setTimeout(resolve, 2)); - } - expect(await predicate()).toBe(true); -} - -/** Mock model: first request calls the Probe tool, second finishes with text. */ -function steeringToolThenDoneModel(): MockLanguageModelV4 { - const usage = { - inputTokens: { total: 100, noCache: 100, cacheRead: 0, cacheWrite: 0 }, - outputTokens: { total: 10, text: 10, reasoning: 0 }, - }; - const model: MockLanguageModelV4 = new MockLanguageModelV4({ - doStream: async () => { - const call = model.doStreamCalls.length; - const chunks: LanguageModelV4StreamPart[] = - call === 1 - ? [ - { type: 'stream-start', warnings: [] }, - { - type: 'tool-call', - toolCallId: 'tool-1', - toolName: 'Probe', - input: JSON.stringify({ q: 'x' }), - }, - { type: 'finish', finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, usage }, - ] - : [ - { type: 'stream-start', warnings: [] }, - { type: 'text-start', id: 'text-1' }, - { type: 'text-delta', id: 'text-1', delta: 'done' }, - { type: 'text-end', id: 'text-1' }, - { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage }, - ]; - return { - stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), - }; - }, - }); - return model; -} - -/** - * A SessionManager wired to a REAL AiSdkBackend over a mock model, so the - * full steering delivery chain (kernel lease -> backend durability wait -> - * AgentRun fail-closed persist) is exercised. `duringTool` runs inside the - * first step's tool execution — the moment a real user steers. - */ -async function steeringDeliverySession( - runStore: MemoryAgentRunStore, - model: MockLanguageModelV4, - duringTool: (manager: SessionManager, sessionId: string) => Promise | void, -) { - const store = new MemorySessionStore(); - const backends = new BackendRegistry(); - let manager!: SessionManager; - let sessionId = ''; - backends.register('ai-sdk', (ctx) => - createTestAiSdkBackend({ - sessionId: ctx.sessionId, - header: ctx.header, - appendMessage: async () => {}, - connection: { - slug: 'mock-main', - providerType: 'anthropic', - defaultModel: 'mock-model-id', - }, - apiKey: 'sk-test', - modelId: 'mock-model-id', - modelFactory: () => model, - tools: [ - { - name: 'Probe', - description: 'Probe description', - parameters: z.object({ q: z.string() }), - impl: async () => { - await duringTool(manager, sessionId); - return { ok: true }; - }, - }, - ], - loadTurnRuntimeEvents: ctx.loadTurnRuntimeEvents, - allowMidTurnHistoryCompaction: ctx.allowMidTurnHistoryCompaction, - newId: nextId(), - now: nextNow(1), - }), - ); - const managerDeps = { - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(1_000), - }; - manager = new SessionManager(managerDeps); - const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - sessionId = session.id; - return { manager, session, store }; -} - -/** - * Parks each send behind a per-turn gate, pulls steering exactly once after - * release, then parks again behind a post-pull gate before finishing — a - * deterministic harness for the owner-identity rule and for enqueues that - * land after the final step boundary (stranded steering). - */ -class GatedSteeringBackend implements AgentBackend { - readonly kind = 'ai-sdk' as const; - readonly sessionId: string; - readonly gates = new Map(); - readonly pullDone = new Map(); - readonly pulls = new Map(); - - constructor(ctx: BackendFactoryContext) { - this.sessionId = ctx.sessionId; - } - - async *send(input: BackendSendInput): AsyncIterable { - const gate = makeGate(); - const afterPull = makeGate(); - this.gates.set(input.turnId, gate); - this.pullDone.set(input.turnId, afterPull); - await gate.promise; - const leases = input.pullSteering?.() ?? []; - const record = this.pulls.get(input.turnId) ?? []; - record.push(leases.map((lease) => lease.content.text)); - this.pulls.set(input.turnId, record); - let seq = 0; - for (const lease of leases) { - seq += 1; - yield { - type: 'steering_message', - id: `${input.turnId}-steer-${seq}`, - turnId: input.turnId, - ts: seq, - messageId: lease.messageId, - content: lease.content, - }; - } - // Delivery for this fake is the echo itself; ack the leases. - input.ackSteering?.(leases.map((lease) => lease.id)); - await afterPull.promise; - yield { - type: 'text_complete', - id: `${input.turnId}-final`, - turnId: input.turnId, - ts: 10, - messageId: `${input.turnId}-m`, - text: 'ok', - }; - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 11, - stopReason: 'end_turn', - }; - } - - /** Release both of a turn's gates (start + post-pull). */ - release(turnId: string): void { - this.gates.get(turnId)?.release(); - this.pullDone.get(turnId)?.release(); - } - - async stop(): Promise { - for (const turnId of this.gates.keys()) this.release(turnId); - } - - async respondToSandboxBoundary(_decision: SandboxBoundaryResponse): Promise {} - - async dispose(): Promise {} -} - class DelegatingRuntimeKernel implements RuntimeKernelLike { readonly starts: Array<{ sessionId: string; @@ -15946,22 +15156,6 @@ class DelegatingRuntimeKernel implements RuntimeKernelLike { this.permissionResponses.push(sessionId); } - steer(): QueueEnqueueOutcome { - return { kind: 'fallback' }; - } - - queueMessage(): QueueEnqueueOutcome { - return { kind: 'fallback' }; - } - - drainFollowup(): string | null { - return null; - } - - retractQueue(): string { - return ''; - } - hasActiveRuns(): boolean { return this.activeRuns; } diff --git a/packages/runtime/src/message-authority.ts b/packages/runtime/src/message-authority.ts index 09cc562786..20b8c6b9e4 100644 --- a/packages/runtime/src/message-authority.ts +++ b/packages/runtime/src/message-authority.ts @@ -65,14 +65,14 @@ export interface RuntimeHostedRootAuthority extends RuntimeMessageAuthority { stopRoot( identity: RuntimeMessageRunIdentity, input?: { - source?: 'stop_button' | 'graph_supervisor'; + source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; mode?: BackendStopMode; }, ): Promise; stopSession( sessionId: string, input?: { - source?: 'stop_button' | 'graph_supervisor'; + source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; mode?: BackendStopMode; }, ): Promise; diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index f1a5460b0c..ea0bd56e67 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -29,8 +29,6 @@ import type { ActiveInteractionRequestEvent, CompleteEvent, MessageContent, - QueueEnqueueOutcome, - QueueUpdateEvent, SessionEvent, TokenUsageEvent, } from '@maka/core/events'; @@ -169,37 +167,11 @@ export interface RuntimeKernelLike { respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise; listActiveInteractions?(sessionId: string): ActiveInteractionRequestEvent[]; respondToUserQuestion?(sessionId: string, response: UserQuestionResponse): Promise; - commitSteeringAdmission?(input: { - sessionId: string; - turnId: string; - runId: string; - messageId: string; - content: MessageContent; - admittedAt?: number; - }): Promise; - materializeSteeringAdmissions?( - admissions: readonly { - sessionId: string; - turnId: string; - runId: string; - messageId: string; - content: MessageContent; - admittedAt: number; - }[], - ): Promise; materializeRootSourceMessages?(input: { sessionId: string; turnId: string; messages: readonly { messageId: string; content: MessageContent }[]; }): Promise; - /** Queue a user message for mid-turn injection at the next step boundary. */ - steer(sessionId: string, text: string): QueueEnqueueOutcome; - /** Queue a user message to open the turn after the current one finishes. */ - queueMessage(sessionId: string, text: string): QueueEnqueueOutcome; - /** Drain the followup queue into one `\n\n`-joined prompt, or null if empty. */ - drainFollowup(sessionId: string): string | null; - /** Take back every queued message (both queues) as one `\n\n`-joined string. */ - retractQueue(sessionId: string): string; hasActiveRuns(sessionId: string): boolean; /** * The turns of the runs in flight for this session. The same fact @@ -279,44 +251,6 @@ export interface ChildAgentRetryInput { onRunStarted?: () => void | Promise; } -/** - * An embedded session's authoritative pending-message queues plus its event - * sink. Hosted composition never creates this state; its Host owns admission, - * snapshots, leases, and follow-up drain. - */ -interface PendingSteeringMessage extends SteeringLease {} - -/** - * A pulled lease is bound to the turn that pulled it: only the issuing turn's - * backend can settle it (ack/nack stay valid even after ownership moved to an - * overlapping turn — invalidating a delivered lease would leave it in-flight - * and redeliver an already-executed message), and no other turn's retract/ - * clear/release may reclaim it while its delivery is still undetermined. - */ -interface LeasedSteeringMessage extends PendingSteeringMessage { - issuingTurnId: string; -} - -interface SessionSteeringState { - /** Messages waiting to be injected into the running turn at a step boundary. */ - steering: PendingSteeringMessage[]; - /** - * Leased to the running turn's backend but not yet settled. pull() is the - * single atomic commit point: an in-flight lease is committed to that - * turn's delivery — retract/clear reclaim only QUEUED messages — and it - * settles exactly once, decided solely by the persistence fact: ack when - * the steering event is durably consumed (even under abort), nack when it - * provably never persisted. Snapshots count in-flight as still pending so - * the UI keeps showing the message until it lands in the transcript. - */ - inFlight: LeasedSteeringMessage[]; - /** Messages waiting to open the next turn. */ - followup: PendingSteeringMessage[]; - /** Pushes a `queue_update` into the active turn's stream; unset when idle. */ - sink?: (event: QueueUpdateEvent) => void; - activeTurnId?: string; -} - export type BackendActivationBoundary = (operation: () => Promise | T) => Promise; interface ChildToolActivation { @@ -468,7 +402,6 @@ export class RuntimeKernel implements RuntimeKernelLike { private readonly historyCompactCoordinator: HistoryCompactCheckpointCoordinator; private readonly pendingContinuationClaims = new Set(); private readonly pendingContinuationSessions = new Set(); - private readonly steeringBySession = new Map(); private readonly backendInvalidations = new Map(); private readonly interactionRequestOwners = new Map(); private nextBackendGeneration = 0; @@ -1584,71 +1517,6 @@ export class RuntimeKernel implements RuntimeKernelLike { pullSteering = () => messageOwner?.pull() ?? []; ackSteering = (leaseIds) => messageOwner?.ack(leaseIds); nackSteering = (leaseIds) => messageOwner?.nack(leaseIds); - } else if (steering) { - const state = this.ensureSteering(sessionId); - state.sink = (event) => { - void sessionEvents.push(event).catch(() => {}); - }; - state.activeTurnId = run.turnId; - // Lease, don't consume: pulled messages move to in-flight and only an - // ack (durable + injected) removes them; a nack or a retract/clear/ - // release reclaims them, so an abort window can never drop text. - pullSteering = () => { - const current = this.steeringBySession.get(sessionId); - if (!current || current.activeTurnId !== run.turnId) return []; - if (current.steering.length === 0) return []; - const leased = current.steering.splice(0); - current.inFlight.push( - ...leased.map((message) => ({ ...message, issuingTurnId: run.turnId })), - ); - return leased.map((message) => ({ ...message })); - }; - // Settlement is keyed by lease id + issuing turn, NOT by current - // ownership: an overlapping turn that takes the owner slot must not - // invalidate the issuer's ack (the message was delivered to ITS - // provider) or intercept its nack. A late settle for a reclaimed lease - // finds no match and is a no-op. - ackSteering = (leaseIds) => { - const current = this.steeringBySession.get(sessionId); - if (!current) return; - const ids = new Set(leaseIds); - const before = current.inFlight.length; - current.inFlight = current.inFlight.filter( - (message) => !(ids.has(message.id) && message.issuingTurnId === run.turnId), - ); - if (current.inFlight.length !== before) this.emitQueueUpdate(sessionId, current); - }; - nackSteering = (leaseIds) => { - const current = this.steeringBySession.get(sessionId); - if (!current) return; - const ids = new Set(leaseIds); - const returned = current.inFlight.filter( - (message) => ids.has(message.id) && message.issuingTurnId === run.turnId, - ); - if (returned.length === 0) return; - current.inFlight = current.inFlight.filter( - (message) => !(ids.has(message.id) && message.issuingTurnId === run.turnId), - ); - if (current.activeTurnId === run.turnId) { - // Back to the FRONT of the queue: a re-pull at the next step - // boundary preserves the user's original ordering. - current.steering = [ - ...returned.map(({ id, messageId, content }) => ({ id, messageId, content })), - ...current.steering, - ]; - } else { - // The issuer no longer owns the queue (an overlapping turn took - // over and possibly released): it will never pull again, so the - // steering queue would strand the text ownerless. The followup - // queue is its only safe home — the same direction a release-time - // fold takes. - current.followup = [ - ...returned.map(({ id, messageId, content }) => ({ id, messageId, content })), - ...current.followup, - ]; - } - this.emitQueueUpdate(sessionId, current); - }; } const aiSdkFlow = new AiSdkFlow({ @@ -1676,13 +1544,9 @@ export class RuntimeKernel implements RuntimeKernelLike { flowDone = true; try { await owners.finalize(); - // Release Runtime access BEFORE the event stream closes. Embedded - // queues still emit their final steering → followup projection here; - // a hosted owner is only sealed, then the Host performs that handoff - // under its Session admission gate. The outer finally remains an - // idempotent backstop for paths that never reach this hook. + // Release Runtime access before the event stream closes; the Host + // performs queue handoff under its Session admission gate. if (messageOwner) owners.releaseMessage(); - else if (steering) this.releaseSteeringTurn(sessionId, run.turnId); sessionEvents.close(); } catch (error) { sessionEvents.fail(error); @@ -1765,7 +1629,6 @@ export class RuntimeKernel implements RuntimeKernelLike { finalizeRun: () => owners.finalize(), releaseOwner: () => { if (messageOwner) owners.releaseMessage(); - else if (steering) this.releaseSteeringTurn(sessionId, run.turnId); }, }); } finally { @@ -2121,10 +1984,6 @@ export class RuntimeKernel implements RuntimeKernelLike { } private async stopSessionAttempt(sessionId: string, intent: SessionStopIntent): Promise { - // Interrupt clears both queues before the abort lands; the emitted empty - // snapshot lets the UI collapse its pending bar, and callers refill their - // editor from the mirror captured before the clear. - this.clearSteering(sessionId); const failures: unknown[] = []; let operation = this.stopOperations.get(sessionId); try { @@ -2419,230 +2278,6 @@ export class RuntimeKernel implements RuntimeKernelLike { ); } - // -------------------------------------------------------------------------- - // Steering / followup queues (authoritative source of truth) - // -------------------------------------------------------------------------- - - steer(sessionId: string, text: string): QueueEnqueueOutcome { - this.assertEmbeddedMessageQueue('steer'); - // Steering's delivery contract is anchored to the runtime event ledger - // (fail-closed persist + durable-consume ack). Without a RuntimeEventStore - // that anchor does not exist — same condition as requireTerminalWrite — - // so fall back to a fresh turn, whose user message the SessionStore - // persists with the ordinary turn-open guarantee. - if (!this.deps.runtimeEventStore) return { kind: 'fallback' }; - // Double responsibility (codex): with no live steering owner to inject - // into — the turn just ended, begin() failed, or only child/compact runs - // are active (they never consume this queue) — tell the caller to open a - // fresh turn instead so the message is never dropped. - const state = this.liveSteeringState(sessionId); - if (!state) return { kind: 'fallback' }; - const messageId = this.deps.newId(); - state.steering.push({ id: messageId, messageId, content: { text } }); - this.emitQueueUpdate(sessionId, state); - return { kind: 'queued' }; - } - - queueMessage(sessionId: string, text: string): QueueEnqueueOutcome { - this.assertEmbeddedMessageQueue('queueMessage'); - const state = this.liveSteeringState(sessionId); - if (!state) return { kind: 'fallback' }; - const messageId = this.deps.newId(); - state.followup.push({ id: messageId, messageId, content: { text } }); - this.emitQueueUpdate(sessionId, state); - return { kind: 'queued' }; - } - - drainFollowup(sessionId: string): string | null { - this.assertEmbeddedMessageQueue('drainFollowup'); - const state = this.steeringBySession.get(sessionId); - if (!state || state.followup.length === 0) return null; - const drained = state.followup.splice(0); - this.emitQueueUpdate(sessionId, state); - return drained.map((message) => message.content.text).join('\n\n'); - } - - retractQueue(sessionId: string): string { - this.assertEmbeddedMessageQueue('retractQueue'); - const state = this.steeringBySession.get(sessionId); - if (!state) return ''; - // Retract reclaims QUEUED messages only. pull() is the single atomic - // commit point of delivery: an in-flight lease is already committed to - // the running turn — its durable append may land at any moment, so - // handing its text back to the user here would refill AND execute the - // same directive. An in-flight lease settles only by the persistence - // fact (ack when the ledger owns it, nack back to a queue otherwise). - const all = [ - ...state.steering.map((message) => message.content.text), - ...state.followup.map((message) => message.content.text), - ]; - state.steering = []; - state.followup = []; - this.emitQueueUpdate(sessionId, state); - return all.join('\n\n'); - } - - private ensureSteering(sessionId: string): SessionSteeringState { - const existing = this.steeringBySession.get(sessionId); - if (existing) return existing; - const created: SessionSteeringState = { steering: [], inFlight: [], followup: [] }; - this.steeringBySession.set(sessionId, created); - return created; - } - - private assertEmbeddedMessageQueue(operation: string): void { - if (this.deps.messageAuthority) { - throw new RuntimeMessageAuthorityInvariantError( - `Hosted Runtime cannot ${operation}; the Runtime Host owns message admission and queues`, - ); - } - } - - /** - * The session's steering state only while a steering-capable top-level run - * owns it (sink registered after begin() succeeded and not yet released). - * Child agent and compact runs never establish ownership, so their activity - * alone yields undefined — enqueue must fall back rather than strand text. - */ - private liveSteeringState(sessionId: string): SessionSteeringState | undefined { - const state = this.steeringBySession.get(sessionId); - return state?.sink ? state : undefined; - } - - private emitQueueUpdate(sessionId: string, state: SessionSteeringState): void { - state.sink?.({ - type: 'queue_update', - id: this.deps.newId(), - turnId: state.activeTurnId ?? '', - ts: this.deps.now(), - steering: [ - ...state.inFlight.map((message) => message.content.text), - ...state.steering.map((message) => message.content.text), - ], - followup: state.followup.map((message) => message.content.text), - steeringEntries: [ - ...state.inFlight.map((message) => ({ - entryId: message.id, - messageId: message.messageId, - content: message.content, - placement: 'current_turn' as const, - state: 'in_flight' as const, - })), - ...state.steering.map((message) => ({ - entryId: message.id, - messageId: message.messageId, - content: message.content, - placement: 'current_turn' as const, - state: 'queued' as const, - })), - ], - followupEntries: state.followup.map((message) => ({ - entryId: message.id, - messageId: message.messageId, - content: message.content, - placement: 'next_turn' as const, - state: 'queued' as const, - })), - }); - } - - private clearSteering(sessionId: string): void { - const state = this.steeringBySession.get(sessionId); - if (!state) return; - // Same commit-point rule as retractQueue: only QUEUED messages are - // clearable. An in-flight lease is already committed to the running - // turn's delivery and settles only by the persistence fact. - if (state.steering.length === 0 && state.followup.length === 0) return; - state.steering = []; - state.followup = []; - this.emitQueueUpdate(sessionId, state); - } - - private releaseSteeringTurn(sessionId: string, turnId: string): void { - const state = this.steeringBySession.get(sessionId); - if (!state) return; - // A release folds only the leases THIS turn issued; an overlapping turn's - // in-flight lease stays for its issuer to settle (acked = delivered, so - // folding it into followup would redeliver an already-executed message). - const own = state.inFlight.filter((message) => message.issuingTurnId === turnId); - if (state.activeTurnId !== turnId) { - // Not (or no longer) the owner. The issuer's backend settles every - // lease before its turn ends, so `own` is normally empty; this is a - // backstop that keeps a never-settled lease from stranding invisibly. - if (own.length === 0) return; - state.inFlight = state.inFlight.filter((message) => message.issuingTurnId !== turnId); - state.followup = [...own, ...state.followup]; - this.emitQueueUpdate(sessionId, state); - return; - } - // Stranded steering (arrived after the final step boundary, so no step is - // left to consume it) becomes the head of the followup queue instead of - // vanishing — the next turn opens with it first (grok-build safety). The - // migration is a queue change, so emit the final snapshot BEFORE the sink - // is cleared; otherwise observers stay on the stale pre-fold snapshot. - if (state.steering.length > 0 || own.length > 0) { - state.followup = [...own, ...state.steering, ...state.followup]; - state.inFlight = state.inFlight.filter((message) => message.issuingTurnId !== turnId); - state.steering = []; - this.emitQueueUpdate(sessionId, state); - } - state.sink = undefined; - state.activeTurnId = undefined; - } - - async commitSteeringAdmission(input: { - sessionId: string; - turnId: string; - runId: string; - messageId: string; - content: MessageContent; - admittedAt?: number; - }): Promise { - if (!this.hasActiveRun(input.sessionId, input.runId, input.turnId)) { - throw new Error('Steering admission no longer matches the active root Turn'); - } - await this.deps.store.appendMessage(input.sessionId, { - type: 'user', - id: input.messageId, - turnId: input.turnId, - ts: input.admittedAt ?? this.deps.now(), - ...structuredClone(input.content), - steeringEventId: input.messageId, - }); - } - - async materializeSteeringAdmissions( - admissions: readonly { - sessionId: string; - turnId: string; - runId: string; - messageId: string; - content: MessageContent; - admittedAt: number; - }[], - ): Promise { - const idsBySession = new Map>(); - for (const admission of admissions) { - let existingIds = idsBySession.get(admission.sessionId); - if (!existingIds) { - existingIds = new Set( - (await this.deps.store.readMessages(admission.sessionId)).map((message) => message.id), - ); - idsBySession.set(admission.sessionId, existingIds); - } - if (existingIds.has(admission.messageId)) continue; - await this.deps.store.appendMessage(admission.sessionId, { - type: 'user', - id: admission.messageId, - turnId: admission.turnId, - ts: admission.admittedAt, - ...structuredClone(admission.content), - steeringEventId: admission.messageId, - }); - existingIds.add(admission.messageId); - } - } - async materializeRootSourceMessages(input: { sessionId: string; turnId: string; @@ -2726,7 +2361,6 @@ export class RuntimeKernel implements RuntimeKernelLike { private async disposeBackendNow(sessionId: string): Promise { const generations = this.backendGenerationsFor(sessionId); - this.steeringBySession.delete(sessionId); this.historyCompactCoordinator.clear(sessionId); let disposalError: unknown; for (const active of generations) { diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 7aabfb9bca..4a40c4de61 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -41,7 +41,6 @@ import type { AbortEvent, PermissionDecisionAckEvent, PermissionRequestEvent, - QueueEnqueueOutcome, ShellRunUpdate, MessageContent, } from '@maka/core/events'; @@ -271,7 +270,7 @@ function runtimeCommitSinkFromEventStore( } export interface StopSessionInput { - source?: 'stop_button' | 'graph_supervisor'; + source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; mode?: BackendStopMode; } @@ -4812,35 +4811,6 @@ export class SessionManager { : this.runtimeKernel.stopSession(identity.sessionId, input); } - /** Queue a user message for mid-turn injection at the next step boundary. */ - commitSteeringAdmission(input: { - sessionId: string; - turnId: string; - runId: string; - messageId: string; - content: MessageContent; - admittedAt?: number; - }): Promise { - const commit = this.runtimeKernel.commitSteeringAdmission; - if (!commit) throw new Error('Runtime steering admission authority is unavailable'); - return commit.call(this.runtimeKernel, input); - } - - materializeSteeringAdmissions( - admissions: readonly { - sessionId: string; - turnId: string; - runId: string; - messageId: string; - content: MessageContent; - admittedAt: number; - }[], - ): Promise { - const materialize = this.runtimeKernel.materializeSteeringAdmissions; - if (!materialize) throw new Error('Runtime steering materialization is unavailable'); - return materialize.call(this.runtimeKernel, admissions); - } - materializeRootSourceMessages(input: { sessionId: string; turnId: string; @@ -4851,25 +4821,6 @@ export class SessionManager { return materialize.call(this.runtimeKernel, input); } - steer(sessionId: string, text: string): QueueEnqueueOutcome { - return this.runtimeKernel.steer(sessionId, text); - } - - /** Queue a user message to open the turn after the current one finishes. */ - queueMessage(sessionId: string, text: string): QueueEnqueueOutcome { - return this.runtimeKernel.queueMessage(sessionId, text); - } - - /** Drain the followup queue into one `\n\n`-joined prompt, or null if empty. */ - drainFollowup(sessionId: string): string | null { - return this.runtimeKernel.drainFollowup(sessionId); - } - - /** Take back every queued message (both queues) as one `\n\n`-joined string. */ - retractQueue(sessionId: string): string { - return this.runtimeKernel.retractQueue(sessionId); - } - async *regenerateTurn( sessionId: string, input: RegenerateTurnInput, diff --git a/packages/runtime/src/session-projection-helpers.ts b/packages/runtime/src/session-projection-helpers.ts index 468005fc24..a26e95b473 100644 --- a/packages/runtime/src/session-projection-helpers.ts +++ b/packages/runtime/src/session-projection-helpers.ts @@ -95,13 +95,15 @@ export function turnHasRetainedOutput(messages: readonly StoredMessage[], turnId } export function normalizeStopSessionSource( - source: 'stop_button' | 'graph_supervisor' | undefined, + source: 'stop_button' | 'graph_supervisor' | 'host_shutdown' | undefined, ): string | undefined { switch (source) { case 'stop_button': return 'renderer.stop_button'; case 'graph_supervisor': return 'graph.supervisor'; + case 'host_shutdown': + return 'runtime_host.shutdown'; case undefined: return undefined; } diff --git a/packages/storage/package.json b/packages/storage/package.json index 0065779df1..ed8a35f406 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -16,6 +16,7 @@ "./deep-research-store": "./dist/deep-research-store.js", "./encrypted-file-managed-secret-store": "./dist/encrypted-file-managed-secret-store.js", "./execution-stores": "./dist/execution-stores.js", + "./message-content-digest": "./dist/message-content-digest.js", "./external-sessions": "./dist/external-sessions.js", "./file-update-lock": "./dist/file-update-lock.js", "./foreign-session-store": "./dist/foreign-session-store.js", diff --git a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts index 52dabc63cc..ec2aa2cedf 100644 --- a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts @@ -31,6 +31,7 @@ import { } from '@maka/core/model-call-attempt'; import type { InteractionCanonicalOutcome, InteractionRequest } from '@maka/core/interaction'; import type { ShellRunRecord } from '@maka/core/shell-run'; +import { messageContentDigest } from '../message-content-digest.js'; import { createSqliteAgentRunStore } from '../agent-run-store.js'; import { closeSqliteInteractionStoreFacade, @@ -38,8 +39,10 @@ import { type StoredInteractionRequest, } from '../interaction-store.js'; import { createSqliteMessageReceiptStore } from '../message-receipt-store.js'; +import { createSessionStore } from '../session-store.js'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; import { createSqliteShellRunStore } from '../shell-run-store.js'; +import { migrateSqliteCoreExecutionDatabase } from '../sqlite-core-execution-schema.js'; import { removeTrackedControlDirectories, trackControlDirectory, @@ -50,6 +53,57 @@ import { after(removeTrackedControlDirectories); describe('SQLite core execution stores', () => { + test('discards unproven legacy pending steering and upgrades settlement proofs', () => { + const database = new DatabaseSync(':memory:'); + try { + database.exec(` + CREATE TABLE core_pending_steering_admissions ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + run_id TEXT NOT NULL, + message_id TEXT NOT NULL, + content_json TEXT NOT NULL, + model_content_json TEXT NOT NULL, + admitted_at INTEGER NOT NULL + ); + INSERT INTO core_pending_steering_admissions( + session_id, turn_id, run_id, message_id, content_json, model_content_json, admitted_at + ) VALUES ('session-1', 'turn-1', 'run-1', 'message-1', '{"text":"lost"}', + '{"text":"lost"}', 1); + CREATE TABLE core_message_admission_settlements ( + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + settlement TEXT NOT NULL CHECK (settlement IN ('retracted')), + PRIMARY KEY (session_id, message_id) + ); + `); + + migrateSqliteCoreExecutionDatabase(database); + + assert.equal( + database + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'core_pending_steering_admissions'", + ) + .get(), + undefined, + ); + assert.deepEqual(database.prepare('SELECT * FROM core_message_admissions').all(), []); + const settlementColumns = new Set( + ( + database.prepare('PRAGMA table_info(core_message_admission_settlements)').all() as Array<{ + name: string; + }> + ).map((column) => column.name), + ); + assert.equal(settlementColumns.has('submitted_placement'), true); + assert.equal(settlementColumns.has('submitted_content_digest'), true); + } finally { + database.close(); + } + }); + test('persists AgentRun header and events', async () => { await withRoot(async (root) => { const store = createSqliteAgentRunStore(root); @@ -392,35 +446,223 @@ describe('SQLite core execution stores', () => { }); }); - test('persists pending steering across Host Epochs until it is settled', async () => { + test('persists message admission across Host Epochs until it is settled', async () => { await withRoot(async (root) => { + const sessions = createSessionStore(root); + const session = await sessions.create({ + cwd: root, + llmConnectionSlug: 'test', + model: 'test-model', + permissionMode: 'ask', + }); const admission = { - sessionId: 'session-1', + sessionId: session.id, turnId: 'turn-1', runId: 'run-1', messageId: 'message-1', content: { text: 'submitted' }, modelContent: { text: 'prepared' }, - initiatingConnectionId: 'connection-1', + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', admittedAt: 123, } as const; const store = createSqliteMessageReceiptStore(root); await store.beginHostEpoch('epoch-1'); - assert.deepEqual(await store.commitPendingSteering(admission), admission); + assert.deepEqual(await sessions.commitMessageAdmission(admission), admission); + await sessions.close?.(); store.close(); const reopened = createSqliteMessageReceiptStore(root); try { await reopened.beginHostEpoch('epoch-2'); - assert.deepEqual(await reopened.listPendingSteering(), [admission]); - await reopened.settlePendingSteering('session-1', ['message-1']); - assert.deepEqual(await reopened.listPendingSteering(), []); + assert.deepEqual(await reopened.listPendingMessages(), [admission]); + await reopened.garbageCollectMessageAdmissions(session.id, ['message-1']); + assert.deepEqual(await reopened.listPendingMessages(), []); } finally { reopened.close(); } }); }); + test('a retracted Message identity cannot be admitted again after restart', async () => { + await withRoot(async (root) => { + const sessions = createSessionStore(root); + const session = await sessions.create({ + cwd: root, + llmConnectionSlug: 'test', + model: 'test-model', + permissionMode: 'ask', + }); + const admission = { + sessionId: session.id, + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content: { text: 'submitted' }, + modelContent: { text: 'prepared' }, + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + admittedAt: 123, + } as const; + await sessions.commitMessageAdmission(admission); + const receipts = createSqliteMessageReceiptStore(root); + await receipts.commitMessageRetractions(session.id, [admission.messageId]); + assert.deepEqual(await receipts.readMessageSettlement(session.id, admission.messageId), { + messageId: admission.messageId, + settlement: 'retracted', + submittedPlacement: 'next_turn', + submittedContentDigest: messageContentDigest(admission.content), + }); + receipts.close(); + await sessions.close?.(); + + const reopenedSessions = createSessionStore(root); + await assert.rejects( + reopenedSessions.commitMessageAdmission({ + ...admission, + turnId: 'turn-2', + runId: 'run-2', + admittedAt: 456, + }), + /durably settled/, + ); + const reopenedReceipts = createSqliteMessageReceiptStore(root); + assert.deepEqual(await reopenedReceipts.listPendingMessages(), []); + reopenedReceipts.close(); + await reopenedSessions.close?.(); + }); + }); + + test('commits a Message admission and transcript row at one SQLite cut', async () => { + await withRoot(async (root) => { + const sessions = createSessionStore(root); + const session = await sessions.create({ + cwd: root, + llmConnectionSlug: 'test', + model: 'test-model', + permissionMode: 'ask', + }); + const admission = { + sessionId: session.id, + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content: { text: 'submitted' }, + modelContent: { text: 'prepared' }, + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + admittedAt: 123, + } as const; + const transcriptMessage = { + type: 'user', + id: admission.messageId, + turnId: admission.turnId, + ts: admission.admittedAt, + text: 'submitted', + steeringEventId: admission.messageId, + } as const; + + await sessions.commitMessageAdmission(admission, transcriptMessage); + assert.deepEqual(await sessions.readMessages(session.id), [transcriptMessage]); + assert.deepEqual( + await sessions.commitMessageAdmission({ ...admission, admittedAt: 999 }), + admission, + ); + await assert.rejects( + sessions.commitMessageAdmission(admission, { + ...transcriptMessage, + text: 'different transcript', + }), + /transcript identity conflict/, + ); + + await sessions.commitMessageAdmission({ + ...admission, + messageId: 'message-2', + runId: 'different-run', + }); + await assert.rejects( + sessions.commitMessageAdmission( + { ...admission, messageId: 'message-2' }, + { ...transcriptMessage, id: 'message-2' }, + ), + /identity conflict/, + ); + assert.deepEqual( + (await sessions.readMessages(session.id)).map((message) => message.id), + ['message-1'], + ); + await sessions.close?.(); + }); + }); + + test('persists pending Message reorder and promotion priority', async () => { + await withRoot(async (root) => { + const sessions = createSessionStore(root); + const session = await sessions.create({ + cwd: root, + llmConnectionSlug: 'test', + model: 'test-model', + permissionMode: 'ask', + }); + const admission = ( + messageId: string, + placement: 'current_turn' | 'next_turn', + admittedAt: number, + ) => ({ + sessionId: session.id, + turnId: 'turn-1', + runId: 'run-1', + messageId, + content: { text: messageId }, + modelContent: { text: messageId }, + submittedPlacement: placement, + placement, + disposition: placement === 'current_turn' ? ('steering' as const) : ('followup' as const), + admittedAt, + }); + const admissions = [ + admission('followup-a', 'next_turn', 1), + admission('followup-b', 'next_turn', 2), + admission('steering-c', 'current_turn', 3), + admission('followup-d', 'next_turn', 4), + admission('followup-e', 'next_turn', 5), + ]; + for (const pending of admissions) await sessions.commitMessageAdmission(pending); + const receipts = createSqliteMessageReceiptStore(root); + await receipts.commitMessageOrder(session.id, [ + 'followup-b', + 'followup-a', + 'followup-e', + 'followup-d', + ]); + for (const messageId of ['followup-b', 'followup-a']) { + const pending = admissions.find((candidate) => candidate.messageId === messageId); + assert.ok(pending); + await sessions.commitMessageAdmission( + { ...pending, placement: 'current_turn', disposition: 'steering' }, + { + type: 'user', + id: messageId, + turnId: pending.turnId, + ts: pending.admittedAt, + text: messageId, + steeringEventId: messageId, + }, + ); + } + assert.deepEqual( + (await receipts.listPendingMessages()).map((pending) => pending.messageId), + ['steering-c', 'followup-b', 'followup-a', 'followup-e', 'followup-d'], + ); + receipts.close(); + await sessions.close?.(); + }); + }); + test('persists interaction request and outcome', async () => { await withRoot(async (root) => { const capability = trackControlDirectory( diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 069635e85d..c9afd4a393 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -95,6 +95,7 @@ export interface RootTurnSourceMessage { messageId: string; content: MessageContent; submittedContentDigest?: `sha256:${string}`; + submittedPlacement?: 'current_turn' | 'next_turn'; placement: 'current_turn' | 'next_turn'; disposition: 'steering' | 'followup' | 'turn_started'; } @@ -1653,11 +1654,19 @@ function normalizeRootTurnSourceMessages(value: unknown): readonly RootTurnSourc 'placement', 'disposition', ...(Object.hasOwn(item, 'submittedContentDigest') ? ['submittedContentDigest'] : []), + ...(Object.hasOwn(item, 'submittedPlacement') ? ['submittedPlacement'] : []), ]) ) { throw new Error(`Invalid root turn source message at index ${index}`); } - const { messageId, content, submittedContentDigest, placement, disposition } = item; + const { + messageId, + content, + submittedContentDigest, + submittedPlacement, + placement, + disposition, + } = item; if ( typeof messageId !== 'string' || !isSafeId(messageId) || @@ -1667,6 +1676,9 @@ function normalizeRootTurnSourceMessages(value: unknown): readonly RootTurnSourc disposition !== 'turn_started') || (disposition === 'steering' && placement !== 'current_turn') || (disposition === 'followup' && placement !== 'next_turn') || + (submittedPlacement !== undefined && + submittedPlacement !== 'current_turn' && + submittedPlacement !== 'next_turn') || (submittedContentDigest !== undefined && !isSha256Digest(submittedContentDigest)) ) { throw new Error(`Invalid root turn source message at index ${index}`); @@ -1683,6 +1695,7 @@ function normalizeRootTurnSourceMessages(value: unknown): readonly RootTurnSourc MAX_ATTACHMENT_COUNT, ), ...(submittedContentDigest !== undefined ? { submittedContentDigest } : {}), + ...(submittedPlacement !== undefined ? { submittedPlacement } : {}), placement, disposition, }); @@ -1710,6 +1723,7 @@ function rootTurnAdmissionPayloadsEqual( source.placement === other.placement && source.disposition === other.disposition && source.submittedContentDigest === other.submittedContentDigest && + source.submittedPlacement === other.submittedPlacement && messageContentsEqual(source.content, other.content) ); }) diff --git a/packages/storage/src/conversation-operational-state.ts b/packages/storage/src/conversation-operational-state.ts index 2f87bd8921..bdd153ae6c 100644 --- a/packages/storage/src/conversation-operational-state.ts +++ b/packages/storage/src/conversation-operational-state.ts @@ -85,6 +85,10 @@ class SqliteConversationOperationalStateStore implements ConversationOperational database .prepare('DELETE FROM core_root_turn_start_rejections WHERE session_id = ?') .run(sessionId); + database + .prepare('DELETE FROM core_message_admission_settlements WHERE session_id = ?') + .run(sessionId); + database.prepare('DELETE FROM core_message_admissions WHERE session_id = ?').run(sessionId); database.prepare('DELETE FROM core_agent_runs WHERE session_id = ?').run(sessionId); database.prepare('DELETE FROM workflow_goal_authority WHERE session_id = ?').run(sessionId); }); diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 5b07c697e2..a1306178de 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -118,7 +118,7 @@ export type { MessageOperationReceipt, MessageReceiptOperation, MessageReceiptStore, - PendingSteeringAdmission, + PendingMessageAdmission, } from './message-receipt-store.js'; export type { ProbeSessionRemovalResult, @@ -422,6 +422,8 @@ async function createExecutionStoresForWrite sessionStore.appendMessage(sessionId, message)), appendMessages: (sessionId, messages) => run(() => sessionStore.appendMessages(sessionId, messages)), + commitMessageAdmission: (admission, transcriptMessage) => + run(() => sessionStore.commitMessageAdmission(admission, transcriptMessage)), subscribeTranscriptChanges: (listener) => sessionStore.subscribeTranscriptChanges(listener), updateHeader: (sessionId, patch) => run(() => sessionStore.updateHeader(sessionId, patch)), updateHeaderVersioned: (sessionId, patch, expectedRevision) => @@ -556,11 +558,17 @@ async function createExecutionStoresForWrite messageReceiptStore.commit(hostEpoch, operation, sessionId, operationId, receipt), ), - commitPendingSteering: (admission) => - run(() => messageReceiptStore.commitPendingSteering(admission)), - listPendingSteering: () => run(() => messageReceiptStore.listPendingSteering()), - settlePendingSteering: (sessionId, messageIds) => - run(() => messageReceiptStore.settlePendingSteering(sessionId, messageIds)), + readMessageAdmission: (sessionId, messageId) => + run(() => messageReceiptStore.readMessageAdmission(sessionId, messageId)), + readMessageSettlement: (sessionId, messageId) => + run(() => messageReceiptStore.readMessageSettlement(sessionId, messageId)), + listPendingMessages: () => run(() => messageReceiptStore.listPendingMessages()), + commitMessageOrder: (sessionId, messageIds) => + run(() => messageReceiptStore.commitMessageOrder(sessionId, messageIds)), + commitMessageRetractions: (sessionId, messageIds) => + run(() => messageReceiptStore.commitMessageRetractions(sessionId, messageIds)), + garbageCollectMessageAdmissions: (sessionId, messageIds) => + run(() => messageReceiptStore.garbageCollectMessageAdmissions(sessionId, messageIds)), }, }; freezeExecutionStoresFacade(stores); diff --git a/packages/runtime-host/src/server/message-content-digest.ts b/packages/storage/src/message-content-digest.ts similarity index 100% rename from packages/runtime-host/src/server/message-content-digest.ts rename to packages/storage/src/message-content-digest.ts diff --git a/packages/storage/src/message-receipt-store.ts b/packages/storage/src/message-receipt-store.ts index f6cc88a4fa..95f3078fa1 100644 --- a/packages/storage/src/message-receipt-store.ts +++ b/packages/storage/src/message-receipt-store.ts @@ -25,6 +25,7 @@ import { normalizeMessageContent, type MessageContent, } from '@maka/core/events'; +import { messageContentDigest } from './message-content-digest.js'; import { acquireOperationalStateDatabase, type OperationalStateDatabaseLease, @@ -48,17 +49,26 @@ export interface MessageOperationReceipt { readonly result: unknown; } -export interface PendingSteeringAdmission { +export interface PendingMessageAdmission { readonly sessionId: string; readonly turnId: string; readonly runId: string; readonly messageId: string; readonly content: MessageContent; readonly modelContent: MessageContent; - readonly initiatingConnectionId: string; + readonly submittedPlacement: 'current_turn' | 'next_turn'; + readonly placement: 'current_turn' | 'next_turn'; + readonly disposition: 'steering' | 'followup'; readonly admittedAt: number; } +export interface MessageAdmissionSettlement { + readonly messageId: string; + readonly settlement: 'retracted'; + readonly submittedPlacement?: 'current_turn' | 'next_turn'; + readonly submittedContentDigest?: `sha256:${string}`; +} + export interface MessageReceiptStore { beginHostEpoch(hostEpoch: string): Promise; read( @@ -74,9 +84,18 @@ export interface MessageReceiptStore { operationId: string, receipt: MessageOperationReceipt, ): Promise; - commitPendingSteering(admission: PendingSteeringAdmission): Promise; - listPendingSteering(): Promise; - settlePendingSteering(sessionId: string, messageIds: readonly string[]): Promise; + readMessageAdmission( + sessionId: string, + messageId: string, + ): Promise; + readMessageSettlement( + sessionId: string, + messageId: string, + ): Promise; + listPendingMessages(): Promise; + commitMessageOrder(sessionId: string, messageIds: readonly string[]): Promise; + commitMessageRetractions(sessionId: string, messageIds: readonly string[]): Promise; + garbageCollectMessageAdmissions(sessionId: string, messageIds: readonly string[]): Promise; } interface StoredMessageOperationReceipt { @@ -193,54 +212,131 @@ class SqliteMessageReceiptStore implements ClosableMessageReceiptStore { }); } - async commitPendingSteering( - admission: PendingSteeringAdmission, - ): Promise { - const stored = normalizePendingSteeringAdmission(admission); - return this.#lease.transaction('write', () => { - const inserted = this.#lease.database - .prepare(` - INSERT OR IGNORE INTO core_pending_steering_admissions( - session_id, turn_id, run_id, message_id, content_json, model_content_json, - initiating_connection_id, admitted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `) - .run( - stored.sessionId, - stored.turnId, - stored.runId, - stored.messageId, - JSON.stringify(stored.content), - JSON.stringify(stored.modelContent), - stored.initiatingConnectionId, - stored.admittedAt, - ); - if (inserted.changes !== 0) return stored; - const existing = readPendingSteeringAdmission( - this.#lease.database, - stored.sessionId, - stored.messageId, - ); - if (!existing || !samePendingSteeringAdmission(existing, stored)) { - throw new Error('Pending steering admission identity conflict'); - } - return existing; - }); + async readMessageAdmission( + sessionId: string, + messageId: string, + ): Promise { + assertSafeId(sessionId, 'Invalid Session identity'); + assertSafeId(messageId, 'Invalid Message identity'); + const settlement = this.#lease.database + .prepare(` + SELECT 1 + FROM core_message_admission_settlements + WHERE session_id = ? AND message_id = ? + `) + .get(sessionId, messageId); + if (settlement) return undefined; + return readPendingMessageAdmission(this.#lease.database, sessionId, messageId); + } + + async readMessageSettlement( + sessionId: string, + messageId: string, + ): Promise { + assertSafeId(sessionId, 'Invalid Session identity'); + assertSafeId(messageId, 'Invalid Message identity'); + const row = this.#lease.database + .prepare(` + SELECT message_id, settlement, submitted_placement, submitted_content_digest + FROM core_message_admission_settlements + WHERE session_id = ? AND message_id = ? + `) + .get(sessionId, messageId) as MessageAdmissionSettlementRow | undefined; + return row ? decodeMessageAdmissionSettlementRow(row) : undefined; } - async listPendingSteering(): Promise { + async listPendingMessages(): Promise { return this.#lease.database .prepare(` SELECT session_id, turn_id, run_id, message_id, content_json, model_content_json, - initiating_connection_id, admitted_at - FROM core_pending_steering_admissions - ORDER BY sequence + submitted_placement, placement, disposition, queue_order, admitted_at + FROM core_message_admissions + WHERE NOT EXISTS ( + SELECT 1 + FROM core_message_admission_settlements + WHERE core_message_admission_settlements.session_id = + core_message_admissions.session_id + AND core_message_admission_settlements.message_id = + core_message_admissions.message_id + ) + ORDER BY session_id, + CASE disposition WHEN 'steering' THEN 0 ELSE 1 END, + queue_order, + sequence `) .all() - .map(decodePendingSteeringAdmissionRow); + .map(decodePendingMessageAdmissionRow); + } + + async commitMessageOrder(sessionId: string, messageIds: readonly string[]): Promise { + assertSafeId(sessionId, 'Invalid Session identity'); + for (const messageId of messageIds) assertSafeId(messageId, 'Invalid Message identity'); + this.#lease.transaction('write', () => { + const statement = this.#lease.database.prepare(` + UPDATE core_message_admissions + SET queue_order = ? + WHERE session_id = ? AND message_id = ? AND disposition = 'followup' + `); + for (let index = 0; index < messageIds.length; index += 1) { + const updated = statement.run(index, sessionId, messageIds[index]); + if (updated.changes !== 1) throw new Error('Message order identity conflict'); + } + }); } - async settlePendingSteering(sessionId: string, messageIds: readonly string[]): Promise { + async commitMessageRetractions(sessionId: string, messageIds: readonly string[]): Promise { + assertSafeId(sessionId, 'Invalid Session identity'); + const uniqueMessageIds = [...new Set(messageIds)]; + for (const messageId of uniqueMessageIds) { + assertSafeId(messageId, 'Invalid Message identity'); + } + this.#lease.transaction('write', () => { + const readAdmission = this.#lease.database.prepare(` + SELECT session_id, turn_id, run_id, message_id, content_json, model_content_json, + submitted_placement, placement, disposition, queue_order, admitted_at + FROM core_message_admissions + WHERE session_id = ? AND message_id = ? + `); + const statement = this.#lease.database.prepare(` + INSERT OR IGNORE INTO core_message_admission_settlements( + session_id, message_id, settlement, submitted_placement, submitted_content_digest + ) VALUES (?, ?, 'retracted', ?, ?) + `); + const removeAdmission = this.#lease.database.prepare(` + DELETE FROM core_message_admissions WHERE session_id = ? AND message_id = ? + `); + for (const messageId of uniqueMessageIds) { + const existingSettlement = this.#lease.database + .prepare(` + SELECT message_id, settlement, submitted_placement, submitted_content_digest + FROM core_message_admission_settlements + WHERE session_id = ? AND message_id = ? + `) + .get(sessionId, messageId) as MessageAdmissionSettlementRow | undefined; + if (existingSettlement) { + decodeMessageAdmissionSettlementRow(existingSettlement); + continue; + } + const admissionRow = readAdmission.get(sessionId, messageId) as + | PendingMessageAdmissionRow + | undefined; + if (!admissionRow) throw new Error('Message retraction identity does not exist'); + const admission = decodePendingMessageAdmissionRow(admissionRow); + statement.run( + sessionId, + messageId, + admission.submittedPlacement, + messageContentDigest(admission.content), + ); + removeAdmission.run(sessionId, messageId); + } + }); + } + + async garbageCollectMessageAdmissions( + sessionId: string, + messageIds: readonly string[], + ): Promise { assertSafeId(sessionId, 'Invalid Session identity'); const uniqueMessageIds = [...new Set(messageIds)]; if (uniqueMessageIds.length === 0) return; @@ -249,7 +345,7 @@ class SqliteMessageReceiptStore implements ClosableMessageReceiptStore { } this.#lease.transaction('write', () => { const statement = this.#lease.database.prepare(` - DELETE FROM core_pending_steering_admissions + DELETE FROM core_message_admissions WHERE session_id = ? AND message_id = ? `); for (const messageId of uniqueMessageIds) statement.run(sessionId, messageId); @@ -373,27 +469,72 @@ function decodeStoredReceipt( return record as unknown as StoredMessageOperationReceipt; } -interface PendingSteeringAdmissionRow { +interface PendingMessageAdmissionRow { readonly session_id?: unknown; readonly turn_id?: unknown; readonly run_id?: unknown; readonly message_id?: unknown; readonly content_json?: unknown; readonly model_content_json?: unknown; - readonly initiating_connection_id?: unknown; + readonly submitted_placement?: unknown; + readonly placement?: unknown; + readonly disposition?: unknown; + readonly queue_order?: unknown; readonly admitted_at?: unknown; } -function normalizePendingSteeringAdmission( - admission: PendingSteeringAdmission, -): PendingSteeringAdmission { +interface MessageAdmissionSettlementRow { + readonly message_id?: unknown; + readonly settlement?: unknown; + readonly submitted_placement?: unknown; + readonly submitted_content_digest?: unknown; +} + +function decodeMessageAdmissionSettlementRow( + row: MessageAdmissionSettlementRow, +): MessageAdmissionSettlement { + if ( + typeof row.message_id !== 'string' || + row.settlement !== 'retracted' || + (row.submitted_placement !== null && + row.submitted_placement !== undefined && + row.submitted_placement !== 'current_turn' && + row.submitted_placement !== 'next_turn') || + (row.submitted_content_digest !== null && + row.submitted_content_digest !== undefined && + (typeof row.submitted_content_digest !== 'string' || + !/^sha256:[0-9a-f]{64}$/.test(row.submitted_content_digest))) + ) { + throw new Error('Invalid SQLite Message admission settlement'); + } + return { + messageId: row.message_id, + settlement: 'retracted', + ...(row.submitted_placement ? { submittedPlacement: row.submitted_placement } : {}), + ...(row.submitted_content_digest + ? { submittedContentDigest: row.submitted_content_digest as `sha256:${string}` } + : {}), + }; +} + +export function normalizePendingMessageAdmission( + admission: PendingMessageAdmission, +): PendingMessageAdmission { assertSafeId(admission.sessionId, 'Invalid Session identity'); assertSafeId(admission.turnId, 'Invalid Turn identity'); assertSafeId(admission.runId, 'Invalid Run identity'); assertSafeId(admission.messageId, 'Invalid Message identity'); - assertSafeId(admission.initiatingConnectionId, 'Invalid Connection identity'); + if ( + (admission.submittedPlacement !== 'current_turn' && + admission.submittedPlacement !== 'next_turn') || + (admission.placement !== 'current_turn' && admission.placement !== 'next_turn') || + (admission.disposition !== 'steering' && admission.disposition !== 'followup') || + (admission.placement === 'current_turn') !== (admission.disposition === 'steering') + ) { + throw new Error('Invalid pending Message placement'); + } if (!Number.isSafeInteger(admission.admittedAt) || admission.admittedAt < 0) { - throw new Error('Invalid steering admission timestamp'); + throw new Error('Invalid message admission timestamp'); } const normalized = Object.freeze({ ...admission, @@ -401,14 +542,14 @@ function normalizePendingSteeringAdmission( modelContent: normalizeMessageContent(admission.modelContent), }); if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > RECEIPT_MAX_BYTES) { - throw new Error('Pending steering admission exceeds size limit'); + throw new Error('Pending message admission exceeds size limit'); } return normalized; } -function decodePendingSteeringAdmissionRow( - row: PendingSteeringAdmissionRow, -): PendingSteeringAdmission { +function decodePendingMessageAdmissionRow( + row: PendingMessageAdmissionRow, +): PendingMessageAdmission { if ( typeof row.session_id !== 'string' || typeof row.turn_id !== 'string' || @@ -416,50 +557,56 @@ function decodePendingSteeringAdmissionRow( typeof row.message_id !== 'string' || typeof row.content_json !== 'string' || typeof row.model_content_json !== 'string' || - typeof row.initiating_connection_id !== 'string' || + (row.submitted_placement !== 'current_turn' && row.submitted_placement !== 'next_turn') || + (row.placement !== 'current_turn' && row.placement !== 'next_turn') || + (row.disposition !== 'steering' && row.disposition !== 'followup') || + typeof row.queue_order !== 'number' || + !Number.isSafeInteger(row.queue_order) || + row.queue_order < 0 || typeof row.admitted_at !== 'number' ) { - throw new Error('Invalid SQLite pending steering admission'); + throw new Error('Invalid SQLite pending message admission'); } - return normalizePendingSteeringAdmission({ + return normalizePendingMessageAdmission({ sessionId: row.session_id, turnId: row.turn_id, runId: row.run_id, messageId: row.message_id, content: JSON.parse(row.content_json), modelContent: JSON.parse(row.model_content_json), - initiatingConnectionId: row.initiating_connection_id, + submittedPlacement: row.submitted_placement, + placement: row.placement, + disposition: row.disposition, admittedAt: row.admitted_at, }); } -function readPendingSteeringAdmission( +export function readPendingMessageAdmission( db: DatabaseSync, sessionId: string, messageId: string, -): PendingSteeringAdmission | undefined { +): PendingMessageAdmission | undefined { const row = db .prepare(` SELECT session_id, turn_id, run_id, message_id, content_json, model_content_json, - initiating_connection_id, admitted_at - FROM core_pending_steering_admissions + submitted_placement, placement, disposition, queue_order, admitted_at + FROM core_message_admissions WHERE session_id = ? AND message_id = ? `) - .get(sessionId, messageId) as PendingSteeringAdmissionRow | undefined; - return row ? decodePendingSteeringAdmissionRow(row) : undefined; + .get(sessionId, messageId) as PendingMessageAdmissionRow | undefined; + return row ? decodePendingMessageAdmissionRow(row) : undefined; } -function samePendingSteeringAdmission( - left: PendingSteeringAdmission, - right: PendingSteeringAdmission, +export function samePendingMessageAdmission( + left: PendingMessageAdmission, + right: PendingMessageAdmission, ): boolean { return ( left.sessionId === right.sessionId && left.turnId === right.turnId && left.runId === right.runId && left.messageId === right.messageId && - left.initiatingConnectionId === right.initiatingConnectionId && - left.admittedAt === right.admittedAt && + left.submittedPlacement === right.submittedPlacement && messageContentsEqual(left.content, right.content) && messageContentsEqual(left.modelContent, right.modelContent) ); diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index a731808e92..792f5f8aab 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -57,6 +57,7 @@ import type { AgentGraphOperatorProvisionRequest, AgentGraphOperatorProvisionResult, } from '@maka/core/agent-graph-topology'; +import type { PendingMessageAdmission } from './message-receipt-store.js'; import type { CreateSandboxBoundaryRequest, @@ -309,6 +310,10 @@ export interface SessionAuthorityStore extends SessionStore { subscribeTranscriptChanges(listener: (sessionId: string) => void): () => void; /** Wait until the SQLite authority is ready for cross-domain transactions. */ ready(): Promise; + commitMessageAdmission( + admission: PendingMessageAdmission, + transcriptMessage?: StoredMessage, + ): Promise; /** Atomically create a Session from already-converted Maka raw messages. */ createImportedSession( input: CreateSessionInput, @@ -857,6 +862,22 @@ class SqliteSessionStore implements SessionAuthorityStore { for (const listener of this.transcriptChangeListeners) listener(sessionId); } + async commitMessageAdmission( + admission: PendingMessageAdmission, + transcriptMessage?: StoredMessage, + ): Promise { + await this.ensureReady(); + const committed = await this.metadata.commitMessageAdmission( + admission, + transcriptMessage, + transcriptMessage ? projectSessionCatalogMessages([transcriptMessage]) : undefined, + ); + if (transcriptMessage) { + for (const listener of this.transcriptChangeListeners) listener(admission.sessionId); + } + return committed; + } + subscribeTranscriptChanges(listener: (sessionId: string) => void): () => void { this.transcriptChangeListeners.add(listener); return () => this.transcriptChangeListeners.delete(listener); diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index f02161a3e1..57ffd0802a 100644 --- a/packages/storage/src/sqlite-core-execution-schema.ts +++ b/packages/storage/src/sqlite-core-execution-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_CORE_EXECUTION_SCHEMA_VERSION = 5; +export const SQLITE_CORE_EXECUTION_SCHEMA_VERSION = 6; export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { db.exec(` @@ -132,7 +132,7 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { ON DELETE CASCADE ); - CREATE TABLE IF NOT EXISTS core_pending_steering_admissions ( + CREATE TABLE IF NOT EXISTS core_message_admissions ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, turn_id TEXT NOT NULL, @@ -140,13 +140,25 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { message_id TEXT NOT NULL, content_json TEXT NOT NULL, model_content_json TEXT NOT NULL, - initiating_connection_id TEXT NOT NULL, + submitted_placement TEXT NOT NULL CHECK (submitted_placement IN ('current_turn', 'next_turn')), + placement TEXT NOT NULL CHECK (placement IN ('current_turn', 'next_turn')), + disposition TEXT NOT NULL CHECK (disposition IN ('steering', 'followup')), + queue_order INTEGER, admitted_at INTEGER NOT NULL CHECK (admitted_at >= 0), UNIQUE (session_id, message_id) ); - CREATE INDEX IF NOT EXISTS core_pending_steering_session_order - ON core_pending_steering_admissions(session_id, sequence); + CREATE INDEX IF NOT EXISTS core_message_admissions_session_order + ON core_message_admissions(session_id, sequence); + + CREATE TABLE IF NOT EXISTS core_message_admission_settlements ( + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + settlement TEXT NOT NULL CHECK (settlement IN ('retracted')), + submitted_placement TEXT CHECK (submitted_placement IN ('current_turn', 'next_turn')), + submitted_content_digest TEXT, + PRIMARY KEY (session_id, message_id) + ); CREATE TABLE IF NOT EXISTS core_shell_runs ( session_id TEXT NOT NULL, @@ -159,6 +171,25 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { CREATE INDEX IF NOT EXISTS core_shell_runs_session_order ON core_shell_runs(session_id, started_at, shell_run_id); `); + discardLegacyPendingSteeringAdmissions(db); + ensureColumn( + db, + 'core_message_admissions', + 'submitted_placement', + "TEXT CHECK (submitted_placement IN ('current_turn', 'next_turn'))", + ); + db.exec( + 'UPDATE core_message_admissions SET submitted_placement = placement WHERE submitted_placement IS NULL', + ); + ensureColumn(db, 'core_message_admissions', 'queue_order', 'INTEGER'); + db.exec('UPDATE core_message_admissions SET queue_order = sequence WHERE queue_order IS NULL'); + ensureColumn( + db, + 'core_message_admission_settlements', + 'submitted_placement', + "TEXT CHECK (submitted_placement IN ('current_turn', 'next_turn'))", + ); + ensureColumn(db, 'core_message_admission_settlements', 'submitted_content_digest', 'TEXT'); ensureColumn( db, 'core_agent_runs', @@ -189,6 +220,13 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { `); } +function discardLegacyPendingSteeringAdmissions(db: DatabaseSync): void { + db.exec(` + DROP INDEX IF EXISTS core_pending_steering_session_order; + DROP TABLE IF EXISTS core_pending_steering_admissions; + `); +} + function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string): void { const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>; if (columns.some((candidate) => candidate.name === column)) return; diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 3e13d67460..c9f5064048 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -96,6 +96,12 @@ import { decodeStoredMessage as decodePersistedStoredMessage, } from '@maka/core/session'; import { markPersisted } from '@maka/core/persisted-value'; +import { + normalizePendingMessageAdmission, + readPendingMessageAdmission, + samePendingMessageAdmission, + type PendingMessageAdmission, +} from './message-receipt-store.js'; import { type AgentGraphIntentAdmissionSnapshot, type AgentGraphTimelineMetadataSnapshot, @@ -1478,6 +1484,128 @@ export class SqliteSessionMetadataStore { }); } + async commitMessageAdmission( + admission: PendingMessageAdmission, + transcriptMessage?: StoredMessage, + projection?: SessionCatalogMessageProjection, + ): Promise { + this.assertOpen(); + const stored = normalizePendingMessageAdmission(admission); + const encodedMessage = transcriptMessage + ? (() => { + const json = JSON.stringify(transcriptMessage); + const message = decodeCanonicalMessage(JSON.parse(json) as unknown); + if (message.id !== stored.messageId) { + throw new Error('Message admission transcript identity mismatch'); + } + return { message, json }; + })() + : undefined; + if (encodedMessage && !projection) { + throw new Error('Message admission transcript projection is missing'); + } + return this.transaction(() => { + const record = this.readRecordSync(stored.sessionId); + if (!record) throw new SessionNotFoundError(stored.sessionId); + const settlement = this.db + .prepare(` + SELECT 1 + FROM core_message_admission_settlements + WHERE session_id = ? AND message_id = ? + `) + .get(stored.sessionId, stored.messageId); + if (settlement) throw new Error('Message admission identity is durably settled'); + const orderRow = this.db + .prepare( + 'SELECT COALESCE(MAX(queue_order), -1) + 1 AS next_order FROM core_message_admissions WHERE session_id = ?', + ) + .get(stored.sessionId) as { next_order?: unknown }; + if (typeof orderRow.next_order !== 'number' || !Number.isSafeInteger(orderRow.next_order)) { + throw new Error('Invalid Message admission order'); + } + const inserted = this.db + .prepare(` + INSERT OR IGNORE INTO core_message_admissions( + session_id, turn_id, run_id, message_id, content_json, model_content_json, + submitted_placement, placement, disposition, queue_order, admitted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + stored.sessionId, + stored.turnId, + stored.runId, + stored.messageId, + JSON.stringify(stored.content), + JSON.stringify(stored.modelContent), + stored.submittedPlacement, + stored.placement, + stored.disposition, + orderRow.next_order, + stored.admittedAt, + ); + let canonical = stored; + if (inserted.changes === 0) { + const existing = readPendingMessageAdmission(this.db, stored.sessionId, stored.messageId); + if (!existing || !samePendingMessageAdmission(existing, stored)) { + throw new Error('Message admission identity conflict'); + } + canonical = existing; + if ( + encodedMessage && + stored.placement === 'current_turn' && + stored.disposition === 'steering' && + existing.placement === 'next_turn' && + existing.disposition === 'followup' + ) { + this.db + .prepare(` + UPDATE core_message_admissions + SET placement = 'current_turn', disposition = 'steering', queue_order = ? + WHERE session_id = ? AND message_id = ? + `) + .run(orderRow.next_order, stored.sessionId, stored.messageId); + canonical = { ...existing, placement: 'current_turn', disposition: 'steering' }; + } else if ( + existing.placement !== stored.placement || + existing.disposition !== stored.disposition + ) { + throw new Error('Message admission queue disposition conflict'); + } + } + if (encodedMessage) { + const existingMessages = this.readMessagesWith( + stored.sessionId, + decodeStoredMessage, + ).filter((message) => message.id === stored.messageId); + if (existingMessages.length > 1) { + throw new Error('Message admission transcript identity is ambiguous'); + } + const [existingMessage] = existingMessages; + if (existingMessage && !isDeepStrictEqual(existingMessage, encodedMessage.message)) { + throw new Error('Message admission transcript identity conflict'); + } + if (!existingMessage) { + const row = this.db + .prepare( + 'SELECT COALESCE(MAX(sequence), -1) AS last_sequence FROM session_messages WHERE session_id = ?', + ) + .get(stored.sessionId) as { last_sequence?: unknown }; + if (typeof row.last_sequence !== 'number' || !Number.isSafeInteger(row.last_sequence)) { + throw new Error(`Invalid Session message sequence for ${stored.sessionId}`); + } + this.insertSessionMessagesSync(stored.sessionId, row.last_sequence + 1, [encodedMessage]); + this.updateCatalogProjectionSync( + stored.sessionId, + projection!, + false, + !record.header.connectionLocked && encodedMessage.message.type === 'user', + ); + } + } + return canonical; + }); + } + async readMessages(sessionId: string): Promise { return this.readMessagesWith(sessionId, decodeStoredMessage); } From c4ad910686a82dfe5c3f4140ff72a030291164de Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 14:26:01 +0800 Subject: [PATCH 21/33] fix(runtime-host): preserve restart message settlement Generated-by: Codex --- .../src/__tests__/message-coordinator.test.ts | 48 +++++++++++++++++++ .../src/server/message-coordinator.ts | 23 +++++---- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 9a446faf64..568ce3ea9b 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -1109,6 +1109,54 @@ test('restart recovers every admission under the durable Session execution contr assert.equal(fixture.pendingAdmissionCount(), 0); }); +test('restart recovers steering that was echoed before any later provider dispatch', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'echo-only-steering', 'run this after restart', 'current_turn'); + fixture.events.push( + steeringEvent( + 'echo-only-steering', + { text: 'run this after restart' }, + { text: 'run this after restart' }, + ), + ); + fixture.setRootState({ kind: 'idle' }); + + await fixture.restart('epoch-2').recoverPendingAfterHostRestart(); + + assert.deepEqual( + fixture.recoveredBatches[0]?.sources.map((source) => source.messageId), + ['echo-only-steering'], + ); + assert.equal(fixture.pendingAdmissionCount(), 0); +}); + +test('restart preserves explicit Stop as a durable message retraction', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'stopped-followup', 'do not resurrect this', 'next_turn'); + fixture.setExplicitStopProof(true); + fixture.setRootState({ kind: 'idle' }); + + const restarted = fixture.restart('epoch-2'); + await restarted.recoverPendingAfterHostRestart(); + + assert.deepEqual(fixture.recoveredBatches, []); + assert.equal(fixture.pendingAdmissionCount(), 0); + const retry = await restarted.handlers['turn.message.submit']( + { + originHostEpoch: 'epoch-2', + sessionId: ROOT.sessionId, + messageId: 'stopped-followup', + content: { text: 'do not resurrect this' }, + placement: 'next_turn', + }, + operationContext(), + ); + assert.equal(retry.ok, false); + if (!retry.ok) assert.equal(retry.error.code, 'operation_conflict'); +}); + test('restart preserves durable reorder and promotion priority', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 9c94d2ac7c..9f18ce351e 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -432,27 +432,26 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { await this.#sessionAdmission.run(sessionId, async (admissionLease) => { const pending: PendingMessageAdmission[] = []; const settled: string[] = []; + const stopped: string[] = []; for (const candidate of durable) { const source = await this.#durableProof.readRootTurnSourceMessageReceipt( sessionId, candidate.messageId, ); - const consumed = source - ? undefined - : await this.#durableProof.readImmutableSteeringMessageProof( - sessionId, - candidate.messageId, - ); - const stopped = - !source && - !consumed && - (await this.#durableProof.readExplicitStopProof(sessionId, candidate.runId)); - if (source || consumed || stopped) settled.push(candidate.messageId); - else pending.push(candidate); + if (source) { + settled.push(candidate.messageId); + } else if (await this.#durableProof.readExplicitStopProof(sessionId, candidate.runId)) { + stopped.push(candidate.messageId); + } else { + pending.push(candidate); + } } if (settled.length > 0) { await this.#receipts.garbageCollectMessageAdmissions(sessionId, settled); } + if (stopped.length > 0) { + await this.#receipts.commitMessageRetractions(sessionId, stopped); + } if (pending.length === 0) return; const header = await this.#root.readSessionHeader(sessionId); if (!header || header.isArchived || header.unavailableReason) { From f5fa8626aedf618781f2aa5cb9d68638d5db2bd7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 14:30:20 +0800 Subject: [PATCH 22/33] fix(runtime-host): resume partial root materialization Generated-by: Codex --- .../__tests__/execution-host-queue.test.ts | 51 ++++++++++++++ .../fixtures/execution-host-suite.ts | 62 ++++++++++++++++- .../src/server/hosted-execution-recovery.ts | 69 +++++++++++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/__tests__/execution-host-queue.test.ts b/packages/runtime-host/src/__tests__/execution-host-queue.test.ts index 6cc97ee966..211207c057 100644 --- a/packages/runtime-host/src/__tests__/execution-host-queue.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-queue.test.ts @@ -484,6 +484,57 @@ test('a durable admission without a Run resumes before the Host becomes ready', }); }); +test('startup resumes a queued root after only a prefix of its sources materialized', async () => { + await withExecutionRoot(async (fixture) => { + const turnId = randomUUID(); + const seeded = await fixture.seedPartiallyMaterializedQueuedAdmission(turnId, 1); + + const host = await fixture.startHost(); + const client = await connectClient(fixture.root); + const recovered = await client.queryTurn({ sessionId: fixture.sessionId, turnId }); + assert.equal(recovered.runId, seeded.runId); + assert.ok(recovered.status === 'running' || recovered.status === 'waiting_for_user'); + + await client.stopTurn( + { sessionId: fixture.sessionId, turnId, runId: seeded.runId }, + PROCESS_TIMEOUT_MS, + ); + await client.close(); + await fixture.stopHost(host); + + const ledger = await fixture.readTurn(turnId); + assert.deepEqual( + ledger.userMessages.map((message) => message.id), + seeded.sourceMessageIds, + ); + }); +}); + +test('startup resumes a queued root after all sources materialized before Run creation', async () => { + await withExecutionRoot(async (fixture) => { + const turnId = randomUUID(); + const seeded = await fixture.seedPartiallyMaterializedQueuedAdmission(turnId, 2); + + const host = await fixture.startHost(); + const client = await connectClient(fixture.root); + const recovered = await client.queryTurn({ sessionId: fixture.sessionId, turnId }); + assert.equal(recovered.runId, seeded.runId); + + await client.stopTurn( + { sessionId: fixture.sessionId, turnId, runId: seeded.runId }, + PROCESS_TIMEOUT_MS, + ); + await client.close(); + await fixture.stopHost(host); + + const ledger = await fixture.readTurn(turnId); + assert.deepEqual( + ledger.userMessages.map((message) => message.id), + seeded.sourceMessageIds, + ); + }); +}); + test('startup recovery compares an existing quoted UserMessage canonically', async () => { await withExecutionRoot(async (fixture) => { const turnId = randomUUID(); diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 567f2ccb0f..0c1337f80d 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -38,7 +38,11 @@ import { test } from 'node:test'; import { TOOL_BOUNDARY_PROTOCOL_V1 } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import type { AgentRunHeader } from '@maka/core/agent-run'; -import { normalizeMessageContent, type MessageContent } from '@maka/core/events'; +import { + aggregateMessageContents, + normalizeMessageContent, + type MessageContent, +} from '@maka/core/events'; import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; import type { StoredMessage } from '@maka/core/session'; import type { Task } from '@maka/core/task-ledger'; @@ -674,6 +678,62 @@ export class ExecutionFixture { return this.seedTurnState(turnId, content, false, false); } + async seedPartiallyMaterializedQueuedAdmission( + turnId: string, + materializedSourceCount: number, + ): Promise<{ runId: string; sourceMessageIds: readonly string[] }> { + const owner = await tryAcquireInteractiveRootOwner(this.capability); + assert.ok(owner); + if (!owner) throw new Error('Unable to acquire execution root for queued admission setup'); + let stores: Awaited> | undefined; + try { + stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const admittedAt = Date.now(); + const sources = [ + { + messageId: randomUUID(), + content: { text: 'first recovered source' }, + placement: 'current_turn' as const, + disposition: 'steering' as const, + }, + { + messageId: randomUUID(), + content: { text: 'second recovered source' }, + placement: 'next_turn' as const, + disposition: 'followup' as const, + }, + ]; + const result = await stores.agentRunStore.admitRootTurn({ + sessionId: this.sessionId, + turnId, + proposedRunId: randomUUID(), + proposedUserMessageId: randomUUID(), + execution: { kind: 'external_message' }, + previousRootTurnId: null, + normalizedInput: aggregateMessageContents(sources.map((source) => source.content)), + sourceMessages: sources, + admittedAt, + }); + assert.equal(result.kind, 'admitted'); + for (const source of sources.slice(0, materializedSourceCount)) { + await stores.sessionStore.appendMessage(this.sessionId, { + type: 'user', + id: source.messageId, + turnId, + ts: admittedAt, + ...source.content, + }); + } + return { + runId: result.admission.runId, + sourceMessageIds: sources.map((source) => source.messageId), + }; + } finally { + await stores?.sessionStore.close?.(); + await owner.close(); + } + } + async archiveSession(): Promise { const owner = await tryAcquireInteractiveRootOwner(this.capability); assert.ok(owner); diff --git a/packages/runtime-host/src/server/hosted-execution-recovery.ts b/packages/runtime-host/src/server/hosted-execution-recovery.ts index 5187583b16..2f9a56dcd9 100644 --- a/packages/runtime-host/src/server/hosted-execution-recovery.ts +++ b/packages/runtime-host/src/server/hosted-execution-recovery.ts @@ -118,6 +118,26 @@ export async function prepareHostedExecutionRecovery( } continue; } + if (materializesRootSourceMessages(admission)) { + verifyMaterializedRootSourceMessages( + admission, + rootUserMessages, + messageIdOwners, + messageIndex, + !run, + ); + if (!run) { + replayAdmissions.push(admission); + rootReplayAdmissions.push(admission); + continue; + } + await input.projection.assertRunIdentityAndContinuation( + run, + admission.turnId, + admission.execution, + ); + continue; + } if (messageIdOwners.length > 1) { throw new Error(`Admitted Turn ${admission.turnId} has a duplicated UserMessage identity`); } @@ -290,6 +310,55 @@ function verifyOrRecoverUserMessage( indexRecoveryMessage(index, recoveredMessage); } +function materializesRootSourceMessages(admission: RootTurnAdmission): boolean { + return admission.sourceMessages.some((source) => source.disposition === 'steering'); +} + +function verifyMaterializedRootSourceMessages( + admission: RootTurnAdmission, + rootUserMessages: readonly RecoveryUserMessage[], + aggregateMessageIdOwners: readonly StoredMessage[], + index: RecoveryMessageIndex, + allowPrefix: boolean, +): void { + if (aggregateMessageIdOwners.length > 0) { + throw new Error( + `Admitted queued Turn ${admission.turnId} unexpectedly recorded its aggregate UserMessage`, + ); + } + if ( + rootUserMessages.length > admission.sourceMessages.length || + (!allowPrefix && rootUserMessages.length !== admission.sourceMessages.length) + ) { + throw new Error( + `Admitted queued Turn ${admission.turnId} has incomplete source materialization`, + ); + } + for (let sourceIndex = 0; sourceIndex < admission.sourceMessages.length; sourceIndex += 1) { + const source = admission.sourceMessages[sourceIndex]!; + const materialized = rootUserMessages[sourceIndex]; + const identityOwners = index.messagesById.get(source.messageId) ?? []; + if (!materialized) { + if (identityOwners.length > 0) { + throw new Error( + `Admitted queued Turn ${admission.turnId} reuses a source message identity`, + ); + } + continue; + } + if ( + identityOwners.length !== 1 || + identityOwners[0] !== materialized || + materialized.id !== source.messageId || + !messageContentsEqual(normalizeMessageContent(materialized), source.content) + ) { + throw new Error( + `Admitted queued Turn ${admission.turnId} does not match its source messages`, + ); + } + } +} + function recoveryUserMessage(admission: RootTurnAdmission): RecoveryUserMessage { if (!admission.userMessageId || !admission.normalizedInput) { throw new Error(`Admitted Turn ${admission.turnId} does not own a UserMessage`); From 341f7850c8583a7541d3d22b713a16db41d584d0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 14:37:01 +0800 Subject: [PATCH 23/33] fix(runtime-host): preflight follow-up promotion Generated-by: Codex --- .../src/__tests__/message-coordinator.test.ts | 62 +++++++++++++++ .../src/server/message-coordinator.ts | 79 ++++++++++++++++--- 2 files changed, 128 insertions(+), 13 deletions(-) diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 568ce3ea9b..3e8a326b2a 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -943,6 +943,68 @@ test('entry promote durably admits the message before making it non-retractable' assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).steering, []); }); +test('entry promote preflights the promoted canonical queue before durable mutation', async () => { + const fixture = createFixture( + undefined, + (_sessionId, candidate) => (candidate.queue?.steering.length ?? 0) === 0, + ); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'promoted-followup', 'send this now', 'next_turn'); + const [entry] = fixture.coordinator.projection(ROOT.sessionId).followup; + assert.ok(entry); + + const promoted = await fixture.coordinator.handlers['queue.entry.promote']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: entry.entryId, + promoteId: 'promote-preflight', + }, + operationContext(), + ); + + assert.equal(promoted.ok, false); + if (!promoted.ok) assert.equal(promoted.error.code, 'session_busy'); + assert.deepEqual(fixture.coordinator.projection(ROOT.sessionId).steering, []); + assert.deepEqual( + fixture.coordinator.projection(ROOT.sessionId).followup.map((queued) => queued.messageId), + ['promoted-followup'], + ); +}); + +test('entry promote rejects a follow-up that only exceeds capacity as in-flight steering', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + const base = await submit(fixture, 'boundary-base', 'x'.repeat(32_000), 'next_turn'); + assert.equal(base.ok, true); + const submitted = await submit(fixture, 'boundary-followup', 'y'.repeat(20_937), 'next_turn'); + assert.equal(submitted.ok, true); + const entry = fixture.coordinator + .projection(ROOT.sessionId) + .followup.find((queued) => queued.messageId === 'boundary-followup'); + assert.ok(entry); + + const promoted = await fixture.coordinator.handlers['queue.entry.promote']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: entry.entryId, + promoteId: 'promote-boundary', + }, + operationContext(), + ); + + assert.equal(promoted.ok, false); + if (!promoted.ok) assert.equal(promoted.error.code, 'session_busy'); + const projection = fixture.coordinator.projection(ROOT.sessionId); + assert.equal(projection.steering.length, 0); + assert.deepEqual( + projection.followup.map((queued) => queued.messageId), + ['boundary-base', 'boundary-followup'], + ); + assert.doesNotThrow(() => decodeSessionMessageQueueProjection(projection)); +}); + test('retract settles a failed promotion so restart cannot recover it', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 9f18ce351e..bc0fc74c2a 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -906,15 +906,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { followup: disposition === 'followup' ? [...current.followup, candidateEntry] : current.followup, }; - if (!projectionFitsEveryEntryState(candidate)) { - return failure('session_busy', 'Message queue projection capacity is full'); - } - if (!(await this.#preflightSessionSnapshot(input.sessionId, { queue: candidate }))) { - return failure('session_busy', 'Session projection capacity is full'); - } - if (!interruptResultFits(candidate, rootState)) { - return failure('session_busy', 'Message queue interrupt result capacity is full'); - } const prospectiveSources = [ ...[...state.inFlight.values(), ...state.steering, ...state.followup].map( sourceFromEntry, @@ -927,9 +918,13 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { disposition, }, ] satisfies RootTurnSourceMessage[]; - if (!rootAdmissionPayloadFits(prospectiveSources)) { - return failure('session_busy', 'Message queue cannot form a durable follow-up Turn'); - } + const capacityError = await this.#queueCapacityError( + input.sessionId, + candidate, + prospectiveSources, + rootState, + ); + if (capacityError) return failure('session_busy', capacityError); if ( state.phase !== 'open' || state.revision !== candidateRevision || @@ -1265,6 +1260,45 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } return failure('not_found', 'Message queue entry does not exist'); } + const candidateRevision = state.revision; + const candidateGeneration = state.generation; + const promotedEntry: LiveEntry = { + ...entry, + placement: 'current_turn', + disposition: 'steering', + }; + const remainingFollowups = state.followup.filter( + (_queued, queuedIndex) => queuedIndex !== index, + ); + const candidate: SessionMessageQueueProjection = { + ...this.#project(state), + queueRevision: candidateRevision + 1, + steering: [ + ...[...state.inFlight.values()].map(inFlightSnapshot), + ...state.steering.map(queuedSteeringSnapshot), + queuedSteeringSnapshot(promotedEntry), + ], + followup: remainingFollowups.map(queuedFollowupSnapshot), + }; + const capacityError = await this.#queueCapacityError( + input.sessionId, + candidate, + [...state.inFlight.values(), ...state.steering, promotedEntry, ...remainingFollowups].map( + sourceFromEntry, + ), + rootState, + ); + if (capacityError) return failure('session_busy', capacityError); + if ( + state.phase !== 'open' || + state.revision !== candidateRevision || + state.generation !== candidateGeneration || + !state.reservedRoot || + !sameRun(state.reservedRoot, rootState) || + state.followup[index] !== entry + ) { + return failure('session_busy', 'Message queue changed during promotion'); + } const pending = await this.#root.commitMessageAdmission( { sessionId: input.sessionId, @@ -1282,7 +1316,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { ); entry.durableAdmittedAt = pending.admittedAt; state.followup.splice(index, 1); - state.steering.push({ ...entry, placement: 'current_turn', disposition: 'steering' }); + state.steering.push(promotedEntry); this.#mutated(state); const result = { queueRevision: state.revision }; try { @@ -1382,6 +1416,25 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { throw error; } return success(result); + async #queueCapacityError( + sessionId: string, + candidate: SessionMessageQueueProjection, + prospectiveSources: readonly RootTurnSourceMessage[], + identity: RuntimeMessageRunIdentity, + ): Promise { + if (!projectionFitsEveryEntryState(candidate)) { + return 'Message queue projection capacity is full'; + } + if (!(await this.#preflightSessionSnapshot(sessionId, { queue: candidate }))) { + return 'Session projection capacity is full'; + } + if (!interruptResultFits(candidate, identity)) { + return 'Message queue interrupt result capacity is full'; + } + if (!rootAdmissionPayloadFits(prospectiveSources)) { + return 'Message queue cannot form a durable follow-up Turn'; + } + return undefined; } async #reorderQueuedEntriesAdmitted( From 4ea3018edf7b45d5a0431164e18664d9c0fa06f5 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 14:40:16 +0800 Subject: [PATCH 24/33] fix(cli): deduplicate durable steering rows Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 27 +++++++++++++++++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 9 +++++++ packages/cli/src/pi-transcript.ts | 18 +++++++++++-- packages/cli/src/pi-tui-runner.ts | 2 +- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index f037b2c8f1..a7c7b6db12 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -868,6 +868,33 @@ describe('Maka Pi TUI transcript', () => { assert.doesNotMatch(rendered, /internal context/); }); + test('renders one user row for a steering identity in either durable and event arrival order', () => { + const durableFirst = createMakaPiTranscriptState(); + appendUserPrompt(durableFirst, 'same steering', 'steering-identity'); + applyMakaSessionEventToTranscript( + durableFirst, + event({ + type: 'steering_message', + messageId: 'steering-identity', + content: { text: 'same steering' }, + }), + ); + + const eventFirst = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript( + eventFirst, + event({ + type: 'steering_message', + messageId: 'steering-identity', + content: { text: 'same steering' }, + }), + ); + appendUserPrompt(eventFirst, 'same steering', 'steering-identity'); + + assert.deepEqual(durableFirst.entries, [{ kind: 'user', text: 'same steering' }]); + assert.deepEqual(eventFirst.entries, [{ kind: 'user', text: 'same steering' }]); + }); + test('shows failed-open compact diagnostics before success diagnostics', () => { const state = createMakaPiTranscriptState(); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 37cb08c2b9..673a81b949 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -1962,6 +1962,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => driver.steered.length === 1); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('also handle Y')); assert.deepEqual(driver.steered, ['also handle Y']); + assert.equal(plainTerminalOutput(terminal.screenOutput()).split('also handle Y').length - 1, 1); assert.equal( plainTerminalOutput(terminal.screenOutput()).includes('Steering: also handle Y'), false, @@ -6530,6 +6531,14 @@ class SteeringTurnDriver implements MakaSessionDriver { for (const listener of this.transcriptListeners) { listener(this.getSessionId(), 'turn-1', [message], 'reconcile'); } + this.pendingEvents.push({ + type: 'steering_message', + id: message.id, + turnId: message.turnId, + ts: message.ts, + messageId: message.id, + content: { text }, + }); return { kind: 'queued' }; } diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 3f764eac1b..f71b306f81 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -75,6 +75,8 @@ export interface MakaPiUsageSummary { export interface MakaPiTranscriptState { entries: MakaPiTranscriptEntry[]; + /** Stable identities of durable/live user rows already represented in entries. */ + renderedUserMessageIds: Set; pendingInteraction?: MakaPiPendingInteraction; queuedInteractions: MakaPiPendingInteraction[]; /** @@ -197,6 +199,7 @@ export interface MakaPiTranscriptMetadata { export function createMakaPiTranscriptState(): MakaPiTranscriptState { return { entries: [], + renderedUserMessageIds: new Set(), queuedInteractions: [], expandAllTools: false, expandAllThinking: false, @@ -227,7 +230,15 @@ function accumulateUsage( usage.contextRemaining = msg.contextRemaining; } -export function appendUserPrompt(state: MakaPiTranscriptState, text: string): void { +export function appendUserPrompt( + state: MakaPiTranscriptState, + text: string, + messageId?: string, +): void { + if (messageId) { + if (state.renderedUserMessageIds.has(messageId)) return; + state.renderedUserMessageIds.add(messageId); + } state.entries.push({ kind: 'user', text }); } @@ -314,6 +325,9 @@ export function replaceTranscriptWithStoredMessages( messages: readonly StoredMessage[], ): void { state.entries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); + state.renderedUserMessageIds = new Set( + messages.flatMap((message) => (message.type === 'user' ? [message.id] : [])), + ); clearPendingInteractions(state); state.expandAllTools = false; state.expandAllThinking = false; @@ -693,7 +707,7 @@ export function applyMakaSessionEventToTranscript( case 'steering_message': // A user interjection injected mid-turn; render it in place as a user turn. - appendUserPrompt(state, event.content.displayText ?? event.content.text); + appendUserPrompt(state, event.content.displayText ?? event.content.text, event.messageId); break; case 'queue_update': diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 79787cb9c5..514e1a3e97 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -551,7 +551,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { ); rememberTranscript(messages); for (const message of newSteeringMessages) { - appendUserPrompt(state, message.displayText ?? message.text); + appendUserPrompt(state, message.displayText ?? message.text, message.id); } if ( newSteeringMessages.length > 0 || From d8cc8c1a192c780f0d3287b50189a4d470c8c6a5 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 14:49:16 +0800 Subject: [PATCH 25/33] fix(desktop): bind companion events to message admission Generated-by: Codex --- .../quote-companion-turn-identity.test.ts | 177 ++++++++++++++++++ .../src/renderer/features/workbar/testing.ts | 5 +- .../tools/side-chat/use-quote-companion.ts | 21 ++- 3 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/quote-companion-turn-identity.test.ts diff --git a/apps/desktop/src/main/__tests__/quote-companion-turn-identity.test.ts b/apps/desktop/src/main/__tests__/quote-companion-turn-identity.test.ts new file mode 100644 index 0000000000..55ea841927 --- /dev/null +++ b/apps/desktop/src/main/__tests__/quote-companion-turn-identity.test.ts @@ -0,0 +1,177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import type { SessionEvent } from '@maka/core/events'; +import type { SessionSummary, TurnRecord } from '@maka/core/session'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { + createFakeWorkbarServices, + useQuoteCompanion, + WorkbarServicesProvider, + type UseQuoteCompanionResult, + type WorkbarServices, +} from '../../renderer/features/workbar/testing.js'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +function session(id: string): SessionSummary { + return { + id, + name: id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'test', + connectionLocked: false, + model: 'test-model', + permissionMode: 'ask', + }; +} + +const source = session('source'); +const fork = session('fork'); +let current: UseQuoteCompanionResult | undefined; + +function Probe() { + current = useQuoteCompanion({ + panelId: 'panel', + pendingQuotes: [], + sourceSession: source, + locale: 'en', + onQuotesConsumed: () => undefined, + }); + return null; +} + +function companion(): UseQuoteCompanionResult { + assert.ok(current); + return current; +} + +afterEach(() => { + current = undefined; + cleanupFakeDom(); +}); + +test('a late terminal event cannot claim a pending steered companion message', async () => { + const { root } = installReactRenderer(); + const pendingSend = deferred<{ ok: true; turnId: string; steered: true }>(); + let eventHandler: ((event: SessionEvent) => void) | undefined; + let submittedMessageId: string | undefined; + const defaults = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ + sideChat: { + ...defaults.sideChat, + listTurns: async (): Promise => [ + { turnId: 'source-turn', status: 'completed', partialOutputRetained: false }, + ], + branchFromTurn: async () => fork, + subscribeEvents: (_sessionId, handler) => { + eventHandler = handler; + return () => undefined; + }, + send: async (_sessionId, command) => { + submittedMessageId = command.turnId; + return pendingSend.promise; + }, + }, + }); + + await act(async () => { + root.render( + createElement( + WorkbarServicesProvider, + { services }, + createElement(Probe), + ), + ); + }); + assert.equal(companion().companionSession?.id, fork.id); + + let sendResult: Promise | undefined; + await act(async () => { + sendResult = companion().send('follow up'); + await Promise.resolve(); + }); + assert.ok(eventHandler); + assert.ok(submittedMessageId); + const messageId = submittedMessageId; + assert.equal(companion().processing, true); + + await act(async () => { + eventHandler?.({ + type: 'complete', + id: 'late-complete', + turnId: 'previous-turn', + ts: 1, + stopReason: 'end_turn', + }); + await Promise.resolve(); + }); + assert.equal(companion().processing, true); + + await act(async () => { + pendingSend.resolve({ ok: true, steered: true, turnId: messageId }); + assert.equal(await sendResult, true); + }); + await act(async () => { + eventHandler?.({ + type: 'queue_update', + id: 'steering-queue', + turnId: 'successor-turn', + ts: 2, + queueRevision: 1, + steering: ['follow up'], + followup: [], + steeringEntries: [ + { + entryId: 'steering-entry', + messageId, + content: { text: 'follow up' }, + placement: 'current_turn', + state: 'queued', + }, + ], + followupEntries: [], + }); + eventHandler?.({ + type: 'text_delta', + id: 'successor-text', + messageId: 'assistant-message', + turnId: 'successor-turn', + ts: 3, + text: 'answer', + }); + }); + + assert.equal(companion().liveTurn?.turnId, 'successor-turn'); + assert.equal(companion().streaming, true); +}); diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index f2e3e940b5..5848cef0a1 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -37,7 +37,10 @@ export * from './tools/inspector/session-inspector-overview-model.js'; export * from './tools/side-chat/quote-companion-panel-state.js'; export * from './tools/side-chat/quote-companion-core.js'; export * from './tools/side-chat/quote-companion-visibility.js'; -export { useQuoteCompanion } from './tools/side-chat/use-quote-companion.js'; +export { + useQuoteCompanion, + type UseQuoteCompanionResult, +} from './tools/side-chat/use-quote-companion.js'; export * from './tools/terminal/session-terminal-hydration.js'; export * from './tools/terminal/session-terminal-query.js'; export * from './tools/terminal/session-terminal-frame.js'; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index d5279d03ae..d90a805b65 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -158,6 +158,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const forkSetupPromiseRef = useRef | null>(null); const stopRequestedRef = useRef(false); const activeTurnIdRef = useRef(null); + const submittingMessageIdRef = useRef(null); const turnInFlightRef = useRef(false); const settlingTurnIdsRef = useRef>(new Set()); const onForkVisibilityChangeRef = useRef(onForkVisibilityChange); @@ -206,8 +207,17 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }); unsubscribeRef.current = sideChat.subscribeEvents(forkId, (event: SessionEvent) => { if (!mountedRef.current) return; - if (turnInFlightRef.current && activeTurnIdRef.current === null && event.turnId) { + const submittingMessageId = submittingMessageIdRef.current; + const admitsSubmittingMessage = + submittingMessageId !== null && + ((event.type === 'steering_message' && event.messageId === submittingMessageId) || + (event.type === 'queue_update' && + event.steeringEntries?.some( + (entry) => entry.messageId === submittingMessageId, + ) === true)); + if (admitsSubmittingMessage) { activeTurnIdRef.current = event.turnId; + submittingMessageIdRef.current = null; ownTurnIdsRef.current.add(event.turnId); setOwnTurnTick((tick) => tick + 1); } @@ -241,6 +251,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan setAllMessages((current) => mergeSettledMessages(current, next)); setLiveTurn((prev) => (prev ? reconcileTerminalLiveTurn(prev, next) : prev)); activeTurnIdRef.current = null; + submittingMessageIdRef.current = null; turnInFlightRef.current = false; stopRequestedRef.current = false; setTurnInFlight(false); @@ -248,6 +259,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan .catch(() => { if (!mountedRef.current || activeTurnIdRef.current !== settledTurnId) return; activeTurnIdRef.current = null; + submittingMessageIdRef.current = null; turnInFlightRef.current = false; stopRequestedRef.current = false; setTurnInFlight(false); @@ -445,15 +457,17 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan onForkCommitted: () => {}, onBeforeSend: () => { stopRequestedRef.current = false; - activeTurnIdRef.current = null; + activeTurnIdRef.current = turnId; + submittingMessageIdRef.current = turnId; turnInFlightRef.current = true; setTurnInFlight(true); }, onQuotesConsumed: () => onQuotesConsumed(quoteSnapshot), }); if (result.status === 'sent') { - if (activeTurnIdRef.current === null && result.turnId) { + if (turnInFlightRef.current && result.turnId) { activeTurnIdRef.current = result.turnId; + submittingMessageIdRef.current = null; ownTurnIdsRef.current.add(result.turnId); setOwnTurnTick((tick) => tick + 1); } @@ -490,6 +504,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }; setError(byCode[result.code]); activeTurnIdRef.current = null; + submittingMessageIdRef.current = null; turnInFlightRef.current = false; setTurnInFlight(false); setLiveTurn(undefined); From 06fee017e8b9a8116f7b4b8aef5a4764fdb39b20 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 14:50:33 +0800 Subject: [PATCH 26/33] refactor(runtime-host): remove duplicate live message state Generated-by: Codex --- .../src/server/message-coordinator.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index bc0fc74c2a..4463a8245a 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -207,12 +207,10 @@ interface LiveEntry { readonly initiatingConnectionId: string; readonly submittedPlacement: MessagePlacement; readonly placement: MessagePlacement; - readonly disposition: 'steering' | 'followup'; readonly generation: number; readonly residency: RuntimeHostResidency; durableAdmittedAt?: number; state: 'queued' | 'in_flight' | 'released'; - leaseId?: string; } interface BoundRun extends RuntimeMessageRunIdentity { @@ -838,7 +836,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { (entry) => entry.messageId === durableAdmission.messageId, ); if (existing) { - if (existing.disposition !== durableAdmission.disposition) { + if (dispositionFromPlacement(existing.placement) !== durableAdmission.disposition) { throw new RuntimeMessageAuthorityInvariantError( 'Durable message admission collided with a different queue disposition', ); @@ -965,7 +963,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { modelContent: prepared.content, submittedPlacement: input.placement, placement: input.placement, - disposition, generation: state.generation, residency, durableAdmittedAt: durableAdmittedAt ?? durableAdmission?.admittedAt, @@ -1265,7 +1262,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { const promotedEntry: LiveEntry = { ...entry, placement: 'current_turn', - disposition: 'steering', }; const remainingFollowups = state.followup.filter( (_queued, queuedIndex) => queuedIndex !== index, @@ -1787,7 +1783,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { const leases = entries.map((entry): SteeringLease => { const leaseId = this.#createId(); entry.state = 'in_flight'; - entry.leaseId = leaseId; state.inFlight.set(leaseId, entry); return { id: leaseId, @@ -1824,7 +1819,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { const entry = state.inFlight.get(leaseId); if (!entry) continue; state.inFlight.delete(leaseId); - entry.leaseId = undefined; if ( state.phase === 'open' && run.generation === state.generation && @@ -2015,7 +2009,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { #releaseEntry(entry: LiveEntry): void { if (entry.state === 'released') return; entry.state = 'released'; - entry.leaseId = undefined; entry.residency.release(); } } @@ -2165,10 +2158,14 @@ function sourceFromEntry(entry: LiveEntry): RootFollowupSource { ? { submittedPlacement: entry.submittedPlacement } : {}), placement: entry.placement, - disposition: entry.disposition, + disposition: dispositionFromPlacement(entry.placement), }; } +function dispositionFromPlacement(placement: MessagePlacement): 'steering' | 'followup' { + return placement === 'current_turn' ? 'steering' : 'followup'; +} + function pendingMessageSource(entry: PendingMessageAdmission): RootTurnSourceMessage { return { messageId: entry.messageId, From 8a0b1c3da0e99ec3166b95f9ebdc71b978dabe4a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 14:59:33 +0800 Subject: [PATCH 27/33] fix(desktop): replay events after companion admission Generated-by: Codex --- .../quote-companion-turn-identity.test.ts | 70 ++++++++++++ .../tools/side-chat/use-quote-companion.ts | 107 +++++++++++------- .../src/server/message-coordinator.ts | 2 +- 3 files changed, 135 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-turn-identity.test.ts b/apps/desktop/src/main/__tests__/quote-companion-turn-identity.test.ts index 55ea841927..79dacb7caa 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-turn-identity.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-turn-identity.test.ts @@ -175,3 +175,73 @@ test('a late terminal event cannot claim a pending steered companion message', a assert.equal(companion().liveTurn?.turnId, 'successor-turn'); assert.equal(companion().streaming, true); }); + +test('replays a new Host turn that starts before its send result arrives', async () => { + const { root } = installReactRenderer(); + const pendingSend = deferred<{ ok: true; turnId: string }>(); + let eventHandler: ((event: SessionEvent) => void) | undefined; + const defaults = createFakeWorkbarServices(); + const services: WorkbarServices = createFakeWorkbarServices({ + sideChat: { + ...defaults.sideChat, + listTurns: async (): Promise => [ + { turnId: 'source-turn', status: 'completed', partialOutputRetained: false }, + ], + branchFromTurn: async () => fork, + subscribeEvents: (_sessionId, handler) => { + eventHandler = handler; + return () => undefined; + }, + send: () => pendingSend.promise, + }, + }); + + await act(async () => { + root.render( + createElement( + WorkbarServicesProvider, + { services }, + createElement(Probe), + ), + ); + }); + assert.equal(companion().companionSession?.id, fork.id); + + let sendResult: Promise | undefined; + await act(async () => { + sendResult = companion().send('first question'); + await Promise.resolve(); + }); + assert.ok(eventHandler); + + await act(async () => { + eventHandler?.({ + type: 'text_delta', + id: 'early-text', + messageId: 'assistant-message', + turnId: 'host-root-turn', + ts: 1, + text: 'early answer', + }); + eventHandler?.({ + type: 'complete', + id: 'early-complete', + turnId: 'host-root-turn', + ts: 2, + stopReason: 'end_turn', + }); + }); + assert.equal(companion().liveTurn, undefined); + assert.equal(companion().processing, true); + + await act(async () => { + pendingSend.resolve({ ok: true, turnId: 'host-root-turn' }); + assert.equal(await sendResult, true); + await Promise.resolve(); + }); + + assert.equal(companion().liveTurn?.turnId, 'host-root-turn'); + assert.equal(companion().liveTurn?.steps[0]?.text?.text, 'early answer'); + assert.equal(companion().streaming, false); + assert.equal(companion().processing, false); +}); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index d90a805b65..ebf0fe9bc0 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -159,6 +159,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const stopRequestedRef = useRef(false); const activeTurnIdRef = useRef(null); const submittingMessageIdRef = useRef(null); + const submittingEventsRef = useRef([]); const turnInFlightRef = useRef(false); const settlingTurnIdsRef = useRef>(new Set()); const onForkVisibilityChangeRef = useRef(onForkVisibilityChange); @@ -192,35 +193,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const mountedRef = useMountedRef(); const dismissalGuardRef = useRef(createCompanionDismissalGuard()); - // Subscribe to the fork's event stream + load its transcript. Called - // synchronously the moment the fork is committed, BEFORE the run starts, so - // no boundary request / complete can be missed (the stream has no replay). - const subscribeToFork = useCallback((forkId: string) => { - void sideChat.readSettledMessages(forkId) - .then(({ messages }) => { - if (mountedRef.current) { - setAllMessages((current) => mergeSettledMessages(current, messages)); - } - }) - .catch(() => { - if (mountedRef.current) setError(copyRef.current.errors.settlementFailed); - }); - unsubscribeRef.current = sideChat.subscribeEvents(forkId, (event: SessionEvent) => { - if (!mountedRef.current) return; - const submittingMessageId = submittingMessageIdRef.current; - const admitsSubmittingMessage = - submittingMessageId !== null && - ((event.type === 'steering_message' && event.messageId === submittingMessageId) || - (event.type === 'queue_update' && - event.steeringEntries?.some( - (entry) => entry.messageId === submittingMessageId, - ) === true)); - if (admitsSubmittingMessage) { - activeTurnIdRef.current = event.turnId; - submittingMessageIdRef.current = null; - ownTurnIdsRef.current.add(event.turnId); - setOwnTurnTick((tick) => tick + 1); - } + const applyOwnedEvent = useCallback( + (forkId: string, event: SessionEvent) => { const effect = companionRunEventEffect( event, activeTurnIdRef.current, @@ -229,29 +203,26 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ); if (effect.kind === 'ignore') return; - // Interaction queue (so a boundary expansion surfaces) + live stream. setInteractions((current) => applyCompanionInteractionEvent(current, forkId, event)); setLiveTurn((prev) => applyLiveTurnEvent(prev, event, localeRef.current)); if (effect.error !== undefined) setError(effect.error); if (effect.terminal && event.turnId && !settlingTurnIdsRef.current.has(event.turnId)) { const settledTurnId = event.turnId; settlingTurnIdsRef.current.add(settledTurnId); - // Settlement: wait for the assistant message to persist before handing - // off from the live projection, then reconcile (shared with the main chat) - // so the finished exchange never flickers away. void sideChat.readSettledMessages(forkId, { ...(requiredAssistantMessageId(liveTurnRef.current) - ? { - requiredAssistantMessageId: requiredAssistantMessageId(liveTurnRef.current), - } + ? { + requiredAssistantMessageId: requiredAssistantMessageId(liveTurnRef.current), + } : {}), - }) + }) .then(({ messages: next }) => { if (!mountedRef.current || activeTurnIdRef.current !== settledTurnId) return; setAllMessages((current) => mergeSettledMessages(current, next)); setLiveTurn((prev) => (prev ? reconcileTerminalLiveTurn(prev, next) : prev)); activeTurnIdRef.current = null; submittingMessageIdRef.current = null; + submittingEventsRef.current = []; turnInFlightRef.current = false; stopRequestedRef.current = false; setTurnInFlight(false); @@ -260,6 +231,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan if (!mountedRef.current || activeTurnIdRef.current !== settledTurnId) return; activeTurnIdRef.current = null; submittingMessageIdRef.current = null; + submittingEventsRef.current = []; turnInFlightRef.current = false; stopRequestedRef.current = false; setTurnInFlight(false); @@ -269,8 +241,57 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan settlingTurnIdsRef.current.delete(settledTurnId); }); } + }, + [mountedRef, sideChat], + ); + + const bindSubmittingTurn = useCallback( + (forkId: string, turnId: string) => { + activeTurnIdRef.current = turnId; + submittingMessageIdRef.current = null; + ownTurnIdsRef.current.add(turnId); + setOwnTurnTick((tick) => tick + 1); + const buffered = submittingEventsRef.current; + submittingEventsRef.current = []; + for (const event of buffered) { + if (event.turnId === turnId) applyOwnedEvent(forkId, event); + } + }, + [applyOwnedEvent], + ); + + // Subscribe to the fork's event stream + load its transcript. Called + // synchronously the moment the fork is committed, BEFORE the run starts, so + // no boundary request / complete can be missed (the stream has no replay). + const subscribeToFork = useCallback((forkId: string) => { + void sideChat.readSettledMessages(forkId) + .then(({ messages }) => { + if (mountedRef.current) { + setAllMessages((current) => mergeSettledMessages(current, messages)); + } + }) + .catch(() => { + if (mountedRef.current) setError(copyRef.current.errors.settlementFailed); + }); + unsubscribeRef.current = sideChat.subscribeEvents(forkId, (event: SessionEvent) => { + if (!mountedRef.current) return; + const submittingMessageId = submittingMessageIdRef.current; + const admitsSubmittingMessage = + submittingMessageId !== null && + ((event.type === 'steering_message' && event.messageId === submittingMessageId) || + (event.type === 'queue_update' && + event.steeringEntries?.some( + (entry) => entry.messageId === submittingMessageId, + ) === true)); + if (admitsSubmittingMessage) { + bindSubmittingTurn(forkId, event.turnId); + } else if (submittingMessageId !== null && activeTurnIdRef.current === null) { + submittingEventsRef.current.push(event); + return; + } + applyOwnedEvent(forkId, event); }); - }, [mountedRef, sideChat]); + }, [applyOwnedEvent, bindSubmittingTurn, mountedRef, sideChat]); const commitFork = useCallback( (session: SessionSummary) => { @@ -457,8 +478,9 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan onForkCommitted: () => {}, onBeforeSend: () => { stopRequestedRef.current = false; - activeTurnIdRef.current = turnId; + activeTurnIdRef.current = null; submittingMessageIdRef.current = turnId; + submittingEventsRef.current = []; turnInFlightRef.current = true; setTurnInFlight(true); }, @@ -466,10 +488,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }); if (result.status === 'sent') { if (turnInFlightRef.current && result.turnId) { - activeTurnIdRef.current = result.turnId; - submittingMessageIdRef.current = null; - ownTurnIdsRef.current.add(result.turnId); - setOwnTurnTick((tick) => tick + 1); + bindSubmittingTurn(result.forkId, result.turnId); } setHasContent(true); // Surface the just-sent user message immediately, and reflect any @@ -505,6 +524,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan setError(byCode[result.code]); activeTurnIdRef.current = null; submittingMessageIdRef.current = null; + submittingEventsRef.current = []; turnInFlightRef.current = false; setTurnInFlight(false); setLiveTurn(undefined); @@ -521,6 +541,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ensureFork, mountedRef, sideChat, + bindSubmittingTurn, ], ); diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 4463a8245a..9095588c1f 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -2200,7 +2200,7 @@ function queuedSteeringSnapshot(entry: LiveEntry): SteeringMessageSnapshot { * Queue position, not origin: an entry in the followup queue is a next-turn * message by definition, including a steering entry the run never pulled and * the terminal transition folded ahead of the followups. Where the message was - * originally aimed stays on `disposition` and on the durable + * originally aimed stays on `submittedPlacement` and on the durable * {@link sourceFromEntry} record. Reporting a folded entry as `current_turn` * here makes the projection fail its own wire decode, which takes the Host * down through the session continuity snapshot (#3530). From 00c62942b47f3bd7c9c50c657e6b2a443212feed Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 15:00:06 +0800 Subject: [PATCH 28/33] refactor(cli): reuse rendered message identity Generated-by: Codex --- packages/cli/src/pi-tui-runner.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 514e1a3e97..bffeb9cfa2 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -281,10 +281,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const tui = new TuiMainScreen(terminal); const state = createMakaPiTranscriptState(); let transcriptLastUsedModel: string | undefined; - let transcriptMessageIds = new Set(); const rememberTranscript = (messages: readonly StoredMessage[]): void => { transcriptLastUsedModel = latestAssistantModelId(messages); - transcriptMessageIds = new Set(messages.map((message) => message.id)); }; const replaceTranscript = (messages: readonly StoredMessage[]): void => { rememberTranscript(messages); @@ -547,7 +545,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { message.type === 'user' && message.turnId === turnId && message.steeringEventId !== undefined && - !transcriptMessageIds.has(message.id), + !state.renderedUserMessageIds.has(message.id), ); rememberTranscript(messages); for (const message of newSteeringMessages) { From d4935776edbde5a18a0ab4960be0fd519ceba931 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 15:06:49 +0800 Subject: [PATCH 29/33] fix(runtime-host): return the committed queue revision Generated-by: Codex --- .../src/__tests__/message-coordinator.test.ts | 29 +++++++++++++++++++ .../src/server/message-coordinator.ts | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 3e8a326b2a..ab707758ae 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -347,6 +347,35 @@ test('persists a steering message before admitting it to the active Turn queue', await fixture.coordinator.close(); }); +test('submit reports the authoritative revision after a delayed durable admission', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + const owner = fixture.coordinator.bindRun(ROOT); + assert.equal((await submit(fixture, 'first-steering', 'first', 'current_turn')).ok, true); + const delay = fixture.delaySteeringAdmission(); + + const submitted = submit(fixture, 'delayed-steering', 'second', 'current_turn'); + await delay.started.promise; + const [firstLease] = owner.pull(); + assert.ok(firstLease); + owner.ack([firstLease.id]); + delay.release.resolve(undefined); + + const outcome = await submitted; + assert.equal(outcome.ok, true); + if (outcome.ok && outcome.result.disposition !== 'turn_started') { + assert.equal(outcome.result.queueRevision, 4); + } + assert.equal(fixture.coordinator.projection(ROOT.sessionId).queueRevision, 4); + + const [secondLease] = owner.pull(); + assert.ok(secondLease); + owner.ack([secondLease.id]); + owner.release(); + fixture.coordinator.completeIdle(fixture.coordinator.beginTerminalTransition(ROOT)); + await fixture.coordinator.close(); +}); + test('terminal transition settles steering after durable provider consumption', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 9095588c1f..4e610f29fd 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -935,7 +935,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } continue; } - const result = { disposition, queueRevision: candidateRevision + 1 } as const; let durableAdmittedAt: number | undefined; if (!durableAdmission) { const admitted = await this.#root.commitMessageAdmission( @@ -971,6 +970,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { if (disposition === 'steering') state.steering.push(entry); else state.followup.push(entry); this.#mutated(state); + const result = { disposition, queueRevision: state.revision } as const; try { await this.#commitReceipt('submit', input.sessionId, input.messageId, payload, result); } catch (error) { From 0ed34fb07f5beb2d26b658f85df578ace41b07db Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 17:05:08 +0800 Subject: [PATCH 30/33] fix(runtime-host): preserve queue editing after rebase Generated-by: Codex --- .../src/__tests__/message-coordinator.test.ts | 11 +++++++++-- .../runtime-host/src/server/message-coordinator.ts | 11 +++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index ab707758ae..62168ddb82 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -651,8 +651,10 @@ test('entry update preserves queue identity, order, and placement and replays it ); await submit(fixture, 'follow-2', 'second', 'next_turn'); let preparedUpdateContent: MessageContent | undefined; + let preparedUpdateConnectionId: string | undefined; fixture.setMessagePreparation(async (input) => { preparedUpdateContent = input.content; + preparedUpdateConnectionId = input.initiatingConnectionId; return { kind: 'ready', content: input.content }; }); @@ -665,9 +667,10 @@ test('entry update preserves queue identity, order, and placement and replays it expectedQueueRevision: 3, text: 'please first @src/a.ts', }, - operationContext(), + operationContext('editor-connection'), ); assert.equal(updated.ok, true); + assert.equal(preparedUpdateConnectionId, 'editor-connection'); assert.deepEqual(preparedUpdateContent, { text: 'please first @src/a.ts', inlineReferences: [ @@ -737,7 +740,11 @@ test('entry update preserves queue identity, order, and placement and replays it { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-update' }, operationContext(), ); - fixture.coordinator.abandonRootReservation(ROOT); + const owner = fixture.coordinator.bindRun(ROOT); + owner.ack(owner.pull().map((lease) => lease.id)); + owner.release(); + const batch = fixture.coordinator.beginTerminalTransition(ROOT); + fixture.coordinator.completeIdle(batch); await fixture.coordinator.close(); }); diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 4e610f29fd..c512b54107 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -204,7 +204,6 @@ interface LiveEntry { readonly messageId: string; content: MessageContent; modelContent: MessageContent; - readonly initiatingConnectionId: string; readonly submittedPlacement: MessagePlacement; readonly placement: MessagePlacement; readonly generation: number; @@ -317,7 +316,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { 'queue.retract': (input) => this.retract(input), 'queue.entry.retract': (input) => this.retractQueuedEntry(input), 'queue.entry.promote': (input) => this.promoteQueuedEntry(input), - 'queue.entry.update': (input) => this.updateQueuedEntry(input), + 'queue.entry.update': (input, context) => this.updateQueuedEntry(input, context.connectionId), 'queue.entries.reorder': (input) => this.reorderQueuedEntries(input), 'turn.interrupt': (input) => this.interrupt(input), }; @@ -1069,6 +1068,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { private updateQueuedEntry( input: QueueEntryUpdateInput, + initiatingConnectionId: string, ): Promise> { return this.#runQueuedMutation({ spec: MESSAGE_OPERATION_SPECS['queue.entry.update'], @@ -1076,7 +1076,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { operationId: input.updateId, verb: 'Update', input, - execute: () => this.#updateQueuedEntryAdmitted(input), + execute: () => this.#updateQueuedEntryAdmitted(input, initiatingConnectionId), }); } @@ -1326,6 +1326,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { async #updateQueuedEntryAdmitted( input: QueueEntryUpdateInput, + initiatingConnectionId: string, ): Promise> { const header = await this.#root.readSessionHeader(input.sessionId); if (this.#failStopped) { @@ -1362,7 +1363,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { turnId: state.reservedRoot.turnId, content, placement: queued.entry.placement, - initiatingConnectionId: queued.entry.initiatingConnectionId, + initiatingConnectionId, }); if (prepared.kind === 'rejected') return failure('operation_conflict', prepared.error); const modelContent = prepared.content; @@ -1412,6 +1413,8 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { throw error; } return success(result); + } + async #queueCapacityError( sessionId: string, candidate: SessionMessageQueueProjection, From 5dd9e296c7567b150b57212a2d53717d9e89e985 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 17:58:20 +0800 Subject: [PATCH 31/33] fix(runtime-host): preserve folded steering provenance Generated-by: Maka --- .../__tests__/execution-host-recovery.test.ts | 31 ++++++++++ .../fixtures/execution-host-suite.ts | 58 +++++++++++++++++++ .../src/server/hosted-execution-recovery.ts | 54 +++++++++++------ .../src/server/root-turn-coordinator.ts | 2 + packages/runtime/src/runtime-kernel.ts | 47 ++++++++++----- packages/runtime/src/session-manager.ts | 7 ++- 6 files changed, 167 insertions(+), 32 deletions(-) diff --git a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts index 86e95b4cf0..f2da98f343 100644 --- a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts @@ -217,6 +217,37 @@ test('startup recovery replays an admitted regenerate with its source lineage', }); }); +test('startup accepts successor steering materialized under its predecessor', async () => { + await withExecutionRoot(async (fixture) => { + const predecessorTurnId = randomUUID(); + const predecessorHost = await fixture.startHost(); + const predecessor = await connectClient(fixture.root); + await predecessor.startTurn({ + sessionId: fixture.sessionId, + turnId: predecessorTurnId, + content: { text: 'finish predecessor' }, + }); + await waitForTerminalTurn(predecessor, fixture.sessionId, predecessorTurnId); + await predecessor.close(); + await fixture.stopHost(predecessorHost); + + const turnId = randomUUID(); + const seeded = await fixture.seedQueuedRunWithPredecessorSteering(turnId, predecessorTurnId); + const firstHost = await fixture.startHost(); + const first = await connectClient(fixture.root); + const terminal = await waitForTerminalTurn(first, fixture.sessionId, turnId); + assert.equal(terminal.runId, seeded.runId); + await first.close(); + await fixture.stopHost(firstHost); + + const secondHost = await fixture.startHost(); + await fixture.stopHost(secondHost); + const ledger = await fixture.readTurn(turnId); + assert.equal(ledger.runs.length, 1); + assert.equal(ledger.userMessages.length, 0); + }); +}); + test('a fresh quoted Turn preserves durable and Runtime handoff content', async () => { await withExecutionRoot(async (fixture) => { const host = await fixture.startHost(); diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 0c1337f80d..ebd1e58936 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -734,6 +734,64 @@ export class ExecutionFixture { } } + async seedQueuedRunWithPredecessorSteering( + turnId: string, + predecessorTurnId: string, + ): Promise<{ runId: string; sourceMessageId: string }> { + const owner = await tryAcquireInteractiveRootOwner(this.capability); + assert.ok(owner); + if (!owner) throw new Error('Unable to acquire execution root for queued recovery setup'); + let stores: Awaited> | undefined; + try { + stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const admittedAt = Date.now(); + const source = { + messageId: randomUUID(), + content: { text: 'steering folded from predecessor' }, + placement: 'current_turn' as const, + disposition: 'steering' as const, + }; + await stores.sessionStore.appendMessage(this.sessionId, { + type: 'user', + id: source.messageId, + turnId: predecessorTurnId, + ts: admittedAt, + steeringEventId: source.messageId, + ...source.content, + }); + const result = await stores.agentRunStore.admitRootTurn({ + sessionId: this.sessionId, + turnId, + proposedRunId: randomUUID(), + proposedUserMessageId: randomUUID(), + execution: { kind: 'external_message' }, + previousRootTurnId: predecessorTurnId, + normalizedInput: source.content, + sourceMessages: [source], + admittedAt, + }); + assert.equal(result.kind, 'admitted'); + await stores.agentRunStore.createRun({ + runId: result.admission.runId, + invocationId: result.admission.runId, + sessionId: this.sessionId, + turnId, + status: 'created', + backendKind: 'fake', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + cwd: this.root, + permissionMode: 'ask', + createdAt: admittedAt, + updatedAt: admittedAt, + }); + return { runId: result.admission.runId, sourceMessageId: source.messageId }; + } finally { + await stores?.sessionStore.close?.(); + await owner.close(); + } + } + async archiveSession(): Promise { const owner = await tryAcquireInteractiveRootOwner(this.capability); assert.ok(owner); diff --git a/packages/runtime-host/src/server/hosted-execution-recovery.ts b/packages/runtime-host/src/server/hosted-execution-recovery.ts index 2f9a56dcd9..bf2bc3ab2d 100644 --- a/packages/runtime-host/src/server/hosted-execution-recovery.ts +++ b/packages/runtime-host/src/server/hosted-execution-recovery.ts @@ -326,36 +326,56 @@ function verifyMaterializedRootSourceMessages( `Admitted queued Turn ${admission.turnId} unexpectedly recorded its aggregate UserMessage`, ); } - if ( - rootUserMessages.length > admission.sourceMessages.length || - (!allowPrefix && rootUserMessages.length !== admission.sourceMessages.length) - ) { - throw new Error( - `Admitted queued Turn ${admission.turnId} has incomplete source materialization`, - ); - } - for (let sourceIndex = 0; sourceIndex < admission.sourceMessages.length; sourceIndex += 1) { - const source = admission.sourceMessages[sourceIndex]!; - const materialized = rootUserMessages[sourceIndex]; + let localSourceIndex = 0; + let missingSource = false; + for (const source of admission.sourceMessages) { const identityOwners = index.messagesById.get(source.messageId) ?? []; - if (!materialized) { - if (identityOwners.length > 0) { + if (identityOwners.length === 0) { + if (!allowPrefix) { throw new Error( - `Admitted queued Turn ${admission.turnId} reuses a source message identity`, + `Admitted queued Turn ${admission.turnId} has incomplete source materialization`, ); } + missingSource = true; continue; } + if (identityOwners.length !== 1) { + throw new Error(`Admitted queued Turn ${admission.turnId} reuses a source message identity`); + } + const materialized = identityOwners[0]!; if ( - identityOwners.length !== 1 || - identityOwners[0] !== materialized || - materialized.id !== source.messageId || + materialized.type !== 'user' || !messageContentsEqual(normalizeMessageContent(materialized), source.content) ) { throw new Error( `Admitted queued Turn ${admission.turnId} does not match its source messages`, ); } + if (missingSource) { + throw new Error( + `Admitted queued Turn ${admission.turnId} has incomplete source materialization`, + ); + } + if (materialized.turnId !== admission.turnId) { + if ( + source.disposition !== 'steering' || + materialized.turnId !== admission.previousRootTurnId + ) { + throw new Error( + `Admitted queued Turn ${admission.turnId} reuses a source message identity`, + ); + } + continue; + } + if (missingSource || rootUserMessages[localSourceIndex] !== materialized) { + throw new Error( + `Admitted queued Turn ${admission.turnId} has incomplete source materialization`, + ); + } + localSourceIndex += 1; + } + if (localSourceIndex !== rootUserMessages.length) { + throw new Error(`Admitted queued Turn ${admission.turnId} does not match its source messages`); } } diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index fab9039d41..e7b17f511d 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1966,9 +1966,11 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { await this.manager.materializeRootSourceMessages({ sessionId: input.sessionId, turnId: input.turnId, + previousRootTurnId: admission.previousRootTurnId, messages: admission.sourceMessages.map((source) => ({ messageId: source.messageId, content: source.content, + disposition: source.disposition, })), }); } diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index ea0bd56e67..2da97de3ef 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -25,12 +25,14 @@ import type { RuntimeEventStore, } from '@maka/core/runtime-event-store'; import { isSessionInlineRun } from '@maka/core/agent-run'; -import type { - ActiveInteractionRequestEvent, - CompleteEvent, - MessageContent, - SessionEvent, - TokenUsageEvent, +import { + messageContentsEqual, + normalizeMessageContent, + type ActiveInteractionRequestEvent, + type CompleteEvent, + type MessageContent, + type SessionEvent, + type TokenUsageEvent, } from '@maka/core/events'; import type { SessionBlockedReason, @@ -2281,21 +2283,38 @@ export class RuntimeKernel implements RuntimeKernelLike { async materializeRootSourceMessages(input: { sessionId: string; turnId: string; - messages: readonly { messageId: string; content: MessageContent }[]; + previousRootTurnId: string | null; + messages: readonly { + messageId: string; + content: MessageContent; + disposition: 'steering' | 'followup' | 'turn_started'; + }[]; }): Promise { - const existingIds = new Set( - (await this.deps.store.readMessages(input.sessionId)).map((message) => message.id), + const existingById = new Map( + (await this.deps.store.readMessages(input.sessionId)).map((message) => [message.id, message]), ); for (const message of input.messages) { - if (existingIds.has(message.messageId)) continue; - await this.deps.store.appendMessage(input.sessionId, { - type: 'user', + const existing = existingById.get(message.messageId); + if (existing) { + if ( + existing.type !== 'user' || + !messageContentsEqual(normalizeMessageContent(existing), message.content) || + (existing.turnId !== input.turnId && + (message.disposition !== 'steering' || existing.turnId !== input.previousRootTurnId)) + ) { + throw new Error(`Queued root source ${message.messageId} conflicts with its transcript`); + } + continue; + } + const materialized = { + type: 'user' as const, id: message.messageId, turnId: input.turnId, ts: this.deps.now(), ...structuredClone(message.content), - }); - existingIds.add(message.messageId); + }; + await this.deps.store.appendMessage(input.sessionId, materialized); + existingById.set(message.messageId, materialized); } } diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 4a40c4de61..0086c2f6c9 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -4814,7 +4814,12 @@ export class SessionManager { materializeRootSourceMessages(input: { sessionId: string; turnId: string; - messages: readonly { messageId: string; content: MessageContent }[]; + previousRootTurnId: string | null; + messages: readonly { + messageId: string; + content: MessageContent; + disposition: 'steering' | 'followup' | 'turn_started'; + }[]; }): Promise { const materialize = this.runtimeKernel.materializeRootSourceMessages; if (!materialize) throw new Error('Runtime root message materialization is unavailable'); From 20eaff6fcc139512bd59c87f22360f82055558b1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 17:58:30 +0800 Subject: [PATCH 32/33] fix(runtime-host): persist queued message edits Generated-by: Maka --- .../src/__tests__/message-coordinator.test.ts | 49 +++++++++++++++++++ .../src/server/message-coordinator.ts | 18 +++++++ .../sqlite-core-execution-store.test.ts | 43 ++++++++++++++++ packages/storage/src/execution-stores.ts | 2 + packages/storage/src/message-receipt-store.ts | 33 +++++++++++++ 5 files changed, 145 insertions(+) diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 62168ddb82..b93758eca9 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -1324,6 +1324,38 @@ test('restart preserves durable reorder and promotion priority', async () => { ); }); +test('restart recovers the durably edited queued Message content', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'edited-after-restart', 'original instruction', 'next_turn'); + + const updated = await fixture.coordinator.handlers['queue.entry.update']( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + entryId: 'id-1', + updateId: 'durable-update', + expectedQueueRevision: 1, + text: 'corrected instruction', + }, + operationContext(), + ); + assert.equal(updated.ok, true); + fixture.setRootState({ kind: 'idle' }); + + await fixture.restart('epoch-2').recoverPendingAfterHostRestart(); + + assert.deepEqual(fixture.recoveredBatches[0]?.sources, [ + { + messageId: 'edited-after-restart', + content: { text: 'corrected instruction' }, + submittedContentDigest: messageContentDigest({ text: 'corrected instruction' }), + placement: 'next_turn', + disposition: 'followup', + }, + ]); +}); + test('entry promote requires an active Turn', async () => { const fixture = createFixture(); fixture.coordinator.reserveRootTurn(ROOT); @@ -2953,6 +2985,23 @@ function memoryReceiptStore( .sort((left, right) => left.disposition === right.disposition ? 0 : left.disposition === 'steering' ? -1 : 1, ), + updatePendingMessage: async (admission) => { + const admissionKey = `${admission.sessionId}:${admission.messageId}`; + const existing = pending.get(admissionKey); + if ( + !existing || + existing.turnId !== admission.turnId || + existing.runId !== admission.runId || + existing.submittedPlacement !== admission.submittedPlacement || + existing.placement !== admission.placement || + existing.disposition !== admission.disposition || + existing.admittedAt !== admission.admittedAt || + retracted.has(admissionKey) + ) { + throw new Error('Message update identity conflict'); + } + pending.set(admissionKey, structuredClone(admission)); + }, commitMessageOrder: async (sessionId, messageIds) => { const reorderedKeys: string[] = []; for (const messageId of messageIds) { diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index c512b54107..1b0d2cb4e0 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -1402,6 +1402,24 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { ) { return failure('session_busy', 'Message queue changed during update'); } + const durableAdmittedAt = queued.entry.durableAdmittedAt; + if (durableAdmittedAt === undefined || !state.reservedRoot) { + throw new RuntimeMessageAuthorityInvariantError( + 'Queued Message update lost its durable admission identity', + ); + } + await this.#receipts.updatePendingMessage({ + sessionId: input.sessionId, + turnId: state.reservedRoot.turnId, + runId: state.reservedRoot.runId, + messageId: queued.entry.messageId, + content, + modelContent, + submittedPlacement: queued.entry.submittedPlacement, + placement: queued.entry.placement, + disposition: dispositionFromPlacement(queued.entry.placement), + admittedAt: durableAdmittedAt, + }); queued.entry.content = content; queued.entry.modelContent = modelContent; this.#mutated(state); diff --git a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts index ec2aa2cedf..e8484429b5 100644 --- a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts @@ -599,6 +599,49 @@ describe('SQLite core execution stores', () => { }); }); + test('updates pending Message content only for the exact unsettled admission', async () => { + await withRoot(async (root) => { + const sessions = createSessionStore(root); + const session = await sessions.create({ + cwd: root, + llmConnectionSlug: 'test', + model: 'test-model', + permissionMode: 'ask', + }); + const admission = { + sessionId: session.id, + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content: { text: 'original' }, + modelContent: { text: 'prepared original' }, + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + admittedAt: 123, + } as const; + await sessions.commitMessageAdmission(admission); + const receipts = createSqliteMessageReceiptStore(root); + const updated = { + ...admission, + content: { text: 'corrected' }, + modelContent: { text: 'prepared corrected' }, + } as const; + + await receipts.updatePendingMessage(updated); + assert.deepEqual(await receipts.listPendingMessages(), [updated]); + await assert.rejects( + receipts.updatePendingMessage({ ...updated, runId: 'wrong-run' }), + /identity conflict/, + ); + await receipts.commitMessageRetractions(session.id, [admission.messageId]); + await assert.rejects(receipts.updatePendingMessage(updated), /identity conflict/); + + receipts.close(); + await sessions.close?.(); + }); + }); + test('persists pending Message reorder and promotion priority', async () => { await withRoot(async (root) => { const sessions = createSessionStore(root); diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index a1306178de..f64d0e241d 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -563,6 +563,8 @@ async function createExecutionStoresForWrite run(() => messageReceiptStore.readMessageSettlement(sessionId, messageId)), listPendingMessages: () => run(() => messageReceiptStore.listPendingMessages()), + updatePendingMessage: (admission) => + run(() => messageReceiptStore.updatePendingMessage(admission)), commitMessageOrder: (sessionId, messageIds) => run(() => messageReceiptStore.commitMessageOrder(sessionId, messageIds)), commitMessageRetractions: (sessionId, messageIds) => diff --git a/packages/storage/src/message-receipt-store.ts b/packages/storage/src/message-receipt-store.ts index 95f3078fa1..5b5e909bd0 100644 --- a/packages/storage/src/message-receipt-store.ts +++ b/packages/storage/src/message-receipt-store.ts @@ -93,6 +93,7 @@ export interface MessageReceiptStore { messageId: string, ): Promise; listPendingMessages(): Promise; + updatePendingMessage(admission: PendingMessageAdmission): Promise; commitMessageOrder(sessionId: string, messageIds: readonly string[]): Promise; commitMessageRetractions(sessionId: string, messageIds: readonly string[]): Promise; garbageCollectMessageAdmissions(sessionId: string, messageIds: readonly string[]): Promise; @@ -268,6 +269,38 @@ class SqliteMessageReceiptStore implements ClosableMessageReceiptStore { .map(decodePendingMessageAdmissionRow); } + async updatePendingMessage(admission: PendingMessageAdmission): Promise { + const stored = normalizePendingMessageAdmission(admission); + const updated = this.#lease.database + .prepare(` + UPDATE core_message_admissions + SET content_json = ?, model_content_json = ? + WHERE session_id = ? AND turn_id = ? AND run_id = ? AND message_id = ? + AND submitted_placement = ? AND placement = ? AND disposition = ? AND admitted_at = ? + AND NOT EXISTS ( + SELECT 1 + FROM core_message_admission_settlements + WHERE core_message_admission_settlements.session_id = + core_message_admissions.session_id + AND core_message_admission_settlements.message_id = + core_message_admissions.message_id + ) + `) + .run( + JSON.stringify(stored.content), + JSON.stringify(stored.modelContent), + stored.sessionId, + stored.turnId, + stored.runId, + stored.messageId, + stored.submittedPlacement, + stored.placement, + stored.disposition, + stored.admittedAt, + ); + if (updated.changes !== 1) throw new Error('Message update identity conflict'); + } + async commitMessageOrder(sessionId: string, messageIds: readonly string[]): Promise { assertSafeId(sessionId, 'Invalid Session identity'); for (const messageId of messageIds) assertSafeId(messageId, 'Invalid Message identity'); From 7128a9720785b7b50a8620e76128201aed036e93 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 19:11:44 +0800 Subject: [PATCH 33/33] fix(runtime-host): atomically edit durable messages Generated-by: Codex --- .../canonical-session-projection.test.ts | 1 + .../__tests__/execution-host-recovery.test.ts | 7 +- .../fixtures/execution-host-suite.ts | 31 +++- .../src/__tests__/goal-root-authority.test.ts | 16 +- .../src/__tests__/message-coordinator.test.ts | 17 +- .../__tests__/root-turn-coordinator.test.ts | 32 +--- .../src/server/execution-composition.ts | 16 +- .../src/server/message-coordinator.ts | 62 +++---- .../sqlite-core-execution-store.test.ts | 98 +++++++---- packages/storage/src/execution-stores.ts | 8 +- packages/storage/src/message-receipt-store.ts | 33 ---- .../src/session-catalog-message-projection.ts | 74 ++++++++ packages/storage/src/session-store.ts | 83 ++------- .../src/sqlite-session-metadata-store.ts | 162 ++++++++++++++++-- 14 files changed, 390 insertions(+), 250 deletions(-) create mode 100644 packages/storage/src/session-catalog-message-projection.ts diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index 99e84ff184..6e11b77f2e 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -535,6 +535,7 @@ function createMessages( }, prepareMessage: async (input) => ({ kind: 'ready', content: input.content }), commitMessageAdmission: async (admission) => admission, + updateMessageAdmission: async () => {}, claimStop: async () => { throw new Error('unexpected root stop'); }, diff --git a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts index f2da98f343..899179cc7e 100644 --- a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts @@ -217,7 +217,7 @@ test('startup recovery replays an admitted regenerate with its source lineage', }); }); -test('startup accepts successor steering materialized under its predecessor', async () => { +test('startup accepts edited steering folded into a successor while preserving predecessor provenance', async () => { await withExecutionRoot(async (fixture) => { const predecessorTurnId = randomUUID(); const predecessorHost = await fixture.startHost(); @@ -245,6 +245,11 @@ test('startup accepts successor steering materialized under its predecessor', as const ledger = await fixture.readTurn(turnId); assert.equal(ledger.runs.length, 1); assert.equal(ledger.userMessages.length, 0); + const predecessorLedger = await fixture.readTurn(predecessorTurnId); + assert.equal( + predecessorLedger.userMessages.find((message) => message.id === seeded.sourceMessageId)?.text, + 'steering folded from predecessor', + ); }); }); diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index ebd1e58936..f04bacd9ab 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -745,19 +745,35 @@ export class ExecutionFixture { try { stores = await openInteractiveExecutionStoresForWrite(owner.lease); const admittedAt = Date.now(); + const predecessor = ( + await stores.agentRunStore.listRootTurnAdmissionsForRecovery(this.sessionId) + ).find((admission) => admission.turnId === predecessorTurnId); + assert.ok(predecessor); + if (!predecessor) throw new Error('Predecessor root admission is unavailable'); + const originalContent = { text: 'steering before queued edit' }; const source = { messageId: randomUUID(), content: { text: 'steering folded from predecessor' }, placement: 'current_turn' as const, disposition: 'steering' as const, }; - await stores.sessionStore.appendMessage(this.sessionId, { - type: 'user', - id: source.messageId, + const pending = { + sessionId: this.sessionId, turnId: predecessorTurnId, - ts: admittedAt, - steeringEventId: source.messageId, - ...source.content, + runId: predecessor.runId, + messageId: source.messageId, + content: originalContent, + modelContent: originalContent, + submittedPlacement: 'current_turn' as const, + placement: 'current_turn' as const, + disposition: 'steering' as const, + admittedAt, + }; + await stores.sessionStore.commitMessageAdmission(pending); + await stores.sessionStore.updateMessageAdmission({ + ...pending, + content: source.content, + modelContent: source.content, }); const result = await stores.agentRunStore.admitRootTurn({ sessionId: this.sessionId, @@ -771,6 +787,9 @@ export class ExecutionFixture { admittedAt, }); assert.equal(result.kind, 'admitted'); + await stores.messageReceiptStore.garbageCollectMessageAdmissions(this.sessionId, [ + source.messageId, + ]); await stores.agentRunStore.createRun({ runId: result.admission.runId, invocationId: result.admission.runId, diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index 76e33ede96..3582dfe4c8 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -562,20 +562,8 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro startFromMessage: (input, lease) => requireCoordinator(coordinator).startFromMessage(input, lease), prepareMessage: (input) => requireCoordinator(coordinator).prepareMessage(input), - commitMessageAdmission: (admission, materializeTranscript) => - stores.sessionStore.commitMessageAdmission( - admission, - materializeTranscript - ? { - type: 'user', - id: admission.messageId, - turnId: admission.turnId, - ts: admission.admittedAt, - ...admission.content, - steeringEventId: admission.messageId, - } - : undefined, - ), + commitMessageAdmission: (admission) => stores.sessionStore.commitMessageAdmission(admission), + updateMessageAdmission: (admission) => stores.sessionStore.updateMessageAdmission(admission), claimStop: (input, commitQueueFence, lease) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, lease), }; diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index b93758eca9..b012ec153e 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -2713,6 +2713,7 @@ function createFixture( let coordinator: HostMessageCoordinator; let receiptStore!: MessageReceiptStore & { commitAdmission(admission: PendingMessageAdmission): Promise; + updateAdmission(admission: PendingMessageAdmission): Promise; failNextRetraction(error: Error): void; pendingAdmissionCount(): number; }; @@ -2759,7 +2760,8 @@ function createFixture( return { turnId }; }, prepareMessage: (input) => prepareMessage(input), - commitMessageAdmission: async (input, materializeTranscript) => { + commitMessageAdmission: async (input) => { + const materializeTranscript = input.disposition === 'steering'; const delay = steeringAdmissionDelay; if (materializeTranscript && delay) { steeringAdmissionDelay = undefined; @@ -2773,6 +2775,16 @@ function createFixture( } return committed; }, + updateMessageAdmission: async (admission) => { + await receiptStore.updateAdmission(admission); + if (admission.disposition === 'steering') { + const index = steeringAdmissions.findIndex( + (candidate) => candidate.messageId === admission.messageId, + ); + if (index < 0) throw new Error('Message update transcript identity conflict'); + steeringAdmissions[index] = structuredClone(admission); + } + }, claimStop: async (_input, commitQueueFence) => { commitQueueFence(); return { @@ -2904,6 +2916,7 @@ function memoryReceiptStore( onRead?: () => void, ): MessageReceiptStore & { commitAdmission(admission: PendingMessageAdmission): Promise; + updateAdmission(admission: PendingMessageAdmission): Promise; failNextRetraction(error: Error): void; pendingAdmissionCount(): number; } { @@ -2985,7 +2998,7 @@ function memoryReceiptStore( .sort((left, right) => left.disposition === right.disposition ? 0 : left.disposition === 'steering' ? -1 : 1, ), - updatePendingMessage: async (admission) => { + updateAdmission: async (admission) => { const admissionKey = `${admission.sessionId}:${admission.messageId}`; const existing = pending.get(admissionKey); if ( diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 7681fca2ec..d78a2f9828 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -2162,20 +2162,8 @@ test('hosted linked child roots share admission, message, terminal, and stop aut startFromMessage: (input, admission) => requireCoordinator(coordinator).startFromMessage(input, admission), prepareMessage: (input) => requireCoordinator(coordinator).prepareMessage(input), - commitMessageAdmission: (admission, materializeTranscript) => - stores.sessionStore.commitMessageAdmission( - admission, - materializeTranscript - ? { - type: 'user', - id: admission.messageId, - turnId: admission.turnId, - ts: admission.admittedAt, - ...admission.content, - steeringEventId: admission.messageId, - } - : undefined, - ), + commitMessageAdmission: (admission) => stores.sessionStore.commitMessageAdmission(admission), + updateMessageAdmission: (admission) => stores.sessionStore.updateMessageAdmission(admission), claimStop: (input, commitQueueFence, admission) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, admission), }; @@ -4587,20 +4575,8 @@ async function createFailureFixture(options: { startFromMessage: (input, admission) => requireCoordinator(coordinator).startFromMessage(input, admission), prepareMessage: (input) => requireCoordinator(coordinator).prepareMessage(input), - commitMessageAdmission: (admission, materializeTranscript) => - stores.sessionStore.commitMessageAdmission( - admission, - materializeTranscript - ? { - type: 'user', - id: admission.messageId, - turnId: admission.turnId, - ts: admission.admittedAt, - ...admission.content, - steeringEventId: admission.messageId, - } - : undefined, - ), + commitMessageAdmission: (admission) => stores.sessionStore.commitMessageAdmission(admission), + updateMessageAdmission: (admission) => stores.sessionStore.updateMessageAdmission(admission), claimStop: (input, commitQueueFence, admission) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, admission), }; diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 93ce55bef5..4c917e5fcc 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -457,20 +457,8 @@ export async function createExecutionRuntimeHostComposition( startRecoveredMessages: (input, admission) => requireRootCoordinator(rootCoordinator).startRecoveredMessages(input, admission), prepareMessage: (input) => requireRootCoordinator(rootCoordinator).prepareMessage(input), - commitMessageAdmission: (admission, materializeTranscript) => - stores.sessionStore.commitMessageAdmission( - admission, - materializeTranscript - ? { - type: 'user', - id: admission.messageId, - turnId: admission.turnId, - ts: admission.admittedAt, - ...admission.content, - steeringEventId: admission.messageId, - } - : undefined, - ), + commitMessageAdmission: (admission) => stores.sessionStore.commitMessageAdmission(admission), + updateMessageAdmission: (admission) => stores.sessionStore.updateMessageAdmission(admission), claimStop: (input, commitQueueFence, admission) => requireRootCoordinator(rootCoordinator).claimStop(input, commitQueueFence, admission), }; diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 1b0d2cb4e0..9d4dc02b1b 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -155,10 +155,8 @@ export interface HostMessageRootPort { | { readonly kind: 'ready'; readonly content: MessageContent } | { readonly kind: 'rejected'; readonly error: string } >; - commitMessageAdmission( - admission: PendingMessageAdmission, - materializeTranscript: boolean, - ): Promise; + commitMessageAdmission(admission: PendingMessageAdmission): Promise; + updateMessageAdmission(admission: PendingMessageAdmission): Promise; claimStop( input: Omit, commitQueueFence: () => QueueFenceResult | Promise, @@ -936,21 +934,18 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } let durableAdmittedAt: number | undefined; if (!durableAdmission) { - const admitted = await this.#root.commitMessageAdmission( - { - sessionId: input.sessionId, - turnId: rootState.turnId, - runId: rootState.runId, - messageId: input.messageId, - content: payload.content, - modelContent: prepared.content, - submittedPlacement: input.placement, - placement: input.placement, - disposition, - admittedAt: Date.now(), - }, - disposition === 'steering', - ); + const admitted = await this.#root.commitMessageAdmission({ + sessionId: input.sessionId, + turnId: rootState.turnId, + runId: rootState.runId, + messageId: input.messageId, + content: payload.content, + modelContent: prepared.content, + submittedPlacement: input.placement, + placement: input.placement, + disposition, + admittedAt: Date.now(), + }); durableAdmittedAt = admitted.admittedAt; } const residency = this.#acquireResidency(); @@ -1295,21 +1290,18 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { ) { return failure('session_busy', 'Message queue changed during promotion'); } - const pending = await this.#root.commitMessageAdmission( - { - sessionId: input.sessionId, - turnId: rootState.turnId, - runId: rootState.runId, - messageId: entry.messageId, - content: entry.content, - modelContent: entry.modelContent, - submittedPlacement: entry.submittedPlacement, - placement: 'current_turn', - disposition: 'steering', - admittedAt: entry.durableAdmittedAt ?? Date.now(), - }, - true, - ); + const pending = await this.#root.commitMessageAdmission({ + sessionId: input.sessionId, + turnId: rootState.turnId, + runId: rootState.runId, + messageId: entry.messageId, + content: entry.content, + modelContent: entry.modelContent, + submittedPlacement: entry.submittedPlacement, + placement: 'current_turn', + disposition: 'steering', + admittedAt: entry.durableAdmittedAt ?? Date.now(), + }); entry.durableAdmittedAt = pending.admittedAt; state.followup.splice(index, 1); state.steering.push(promotedEntry); @@ -1408,7 +1400,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { 'Queued Message update lost its durable admission identity', ); } - await this.#receipts.updatePendingMessage({ + await this.#root.updateMessageAdmission({ sessionId: input.sessionId, turnId: state.reservedRoot.turnId, runId: state.reservedRoot.runId, diff --git a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts index e8484429b5..2ba5e84406 100644 --- a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts @@ -565,30 +565,28 @@ describe('SQLite core execution stores', () => { steeringEventId: admission.messageId, } as const; - await sessions.commitMessageAdmission(admission, transcriptMessage); + await sessions.commitMessageAdmission(admission); assert.deepEqual(await sessions.readMessages(session.id), [transcriptMessage]); assert.deepEqual( await sessions.commitMessageAdmission({ ...admission, admittedAt: 999 }), admission, ); - await assert.rejects( - sessions.commitMessageAdmission(admission, { - ...transcriptMessage, - text: 'different transcript', - }), - /transcript identity conflict/, - ); - await sessions.commitMessageAdmission({ ...admission, messageId: 'message-2', runId: 'different-run', + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', }); await assert.rejects( - sessions.commitMessageAdmission( - { ...admission, messageId: 'message-2' }, - { ...transcriptMessage, id: 'message-2' }, - ), + sessions.commitMessageAdmission({ + ...admission, + messageId: 'message-2', + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + }), /identity conflict/, ); assert.deepEqual( @@ -599,7 +597,7 @@ describe('SQLite core execution stores', () => { }); }); - test('updates pending Message content only for the exact unsettled admission', async () => { + test('atomically updates an exact unsettled Message admission and its existing transcript', async () => { await withRoot(async (root) => { const sessions = createSessionStore(root); const session = await sessions.create({ @@ -615,11 +613,19 @@ describe('SQLite core execution stores', () => { messageId: 'message-1', content: { text: 'original' }, modelContent: { text: 'prepared original' }, - submittedPlacement: 'next_turn', - placement: 'next_turn', - disposition: 'followup', + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', admittedAt: 123, } as const; + const transcriptMessage = { + type: 'user', + id: admission.messageId, + turnId: admission.turnId, + ts: admission.admittedAt, + text: admission.content.text, + steeringEventId: admission.messageId, + } as const; await sessions.commitMessageAdmission(admission); const receipts = createSqliteMessageReceiptStore(root); const updated = { @@ -627,15 +633,51 @@ describe('SQLite core execution stores', () => { content: { text: 'corrected' }, modelContent: { text: 'prepared corrected' }, } as const; + const updatedTranscriptMessage = { + ...transcriptMessage, + text: updated.content.text, + } as const; - await receipts.updatePendingMessage(updated); + await sessions.updateMessageAdmission(updated); assert.deepEqual(await receipts.listPendingMessages(), [updated]); + assert.deepEqual(await sessions.readMessages(session.id), [updatedTranscriptMessage]); + await assert.rejects( + sessions.updateMessageAdmission({ ...updated, runId: 'wrong-run' }), + /identity conflict/, + ); await assert.rejects( - receipts.updatePendingMessage({ ...updated, runId: 'wrong-run' }), + sessions.updateMessageAdmission({ ...updated, admittedAt: updated.admittedAt + 1 }), + /identity conflict/, + ); + await assert.rejects( + sessions.updateMessageAdmission({ + ...updated, + placement: 'next_turn', + disposition: 'followup', + }), /identity conflict/, ); await receipts.commitMessageRetractions(session.id, [admission.messageId]); - await assert.rejects(receipts.updatePendingMessage(updated), /identity conflict/); + await assert.rejects(sessions.updateMessageAdmission(updated), /identity conflict/); + + const followup = { + ...admission, + messageId: 'message-2', + content: { text: 'queued original' }, + modelContent: { text: 'prepared queued original' }, + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + } as const; + await sessions.commitMessageAdmission(followup); + const updatedFollowup = { + ...followup, + content: { text: 'queued corrected' }, + modelContent: { text: 'prepared queued corrected' }, + } as const; + await sessions.updateMessageAdmission(updatedFollowup); + assert.deepEqual(await receipts.listPendingMessages(), [updatedFollowup]); + assert.deepEqual(await sessions.readMessages(session.id), [updatedTranscriptMessage]); receipts.close(); await sessions.close?.(); @@ -685,17 +727,11 @@ describe('SQLite core execution stores', () => { for (const messageId of ['followup-b', 'followup-a']) { const pending = admissions.find((candidate) => candidate.messageId === messageId); assert.ok(pending); - await sessions.commitMessageAdmission( - { ...pending, placement: 'current_turn', disposition: 'steering' }, - { - type: 'user', - id: messageId, - turnId: pending.turnId, - ts: pending.admittedAt, - text: messageId, - steeringEventId: messageId, - }, - ); + await sessions.commitMessageAdmission({ + ...pending, + placement: 'current_turn', + disposition: 'steering', + }); } assert.deepEqual( (await receipts.listPendingMessages()).map((pending) => pending.messageId), diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index f64d0e241d..0bd1fa0b07 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -422,8 +422,10 @@ async function createExecutionStoresForWrite sessionStore.appendMessage(sessionId, message)), appendMessages: (sessionId, messages) => run(() => sessionStore.appendMessages(sessionId, messages)), - commitMessageAdmission: (admission, transcriptMessage) => - run(() => sessionStore.commitMessageAdmission(admission, transcriptMessage)), + commitMessageAdmission: (admission) => + run(() => sessionStore.commitMessageAdmission(admission)), + updateMessageAdmission: (admission) => + run(() => sessionStore.updateMessageAdmission(admission)), subscribeTranscriptChanges: (listener) => sessionStore.subscribeTranscriptChanges(listener), updateHeader: (sessionId, patch) => run(() => sessionStore.updateHeader(sessionId, patch)), updateHeaderVersioned: (sessionId, patch, expectedRevision) => @@ -563,8 +565,6 @@ async function createExecutionStoresForWrite run(() => messageReceiptStore.readMessageSettlement(sessionId, messageId)), listPendingMessages: () => run(() => messageReceiptStore.listPendingMessages()), - updatePendingMessage: (admission) => - run(() => messageReceiptStore.updatePendingMessage(admission)), commitMessageOrder: (sessionId, messageIds) => run(() => messageReceiptStore.commitMessageOrder(sessionId, messageIds)), commitMessageRetractions: (sessionId, messageIds) => diff --git a/packages/storage/src/message-receipt-store.ts b/packages/storage/src/message-receipt-store.ts index 5b5e909bd0..95f3078fa1 100644 --- a/packages/storage/src/message-receipt-store.ts +++ b/packages/storage/src/message-receipt-store.ts @@ -93,7 +93,6 @@ export interface MessageReceiptStore { messageId: string, ): Promise; listPendingMessages(): Promise; - updatePendingMessage(admission: PendingMessageAdmission): Promise; commitMessageOrder(sessionId: string, messageIds: readonly string[]): Promise; commitMessageRetractions(sessionId: string, messageIds: readonly string[]): Promise; garbageCollectMessageAdmissions(sessionId: string, messageIds: readonly string[]): Promise; @@ -269,38 +268,6 @@ class SqliteMessageReceiptStore implements ClosableMessageReceiptStore { .map(decodePendingMessageAdmissionRow); } - async updatePendingMessage(admission: PendingMessageAdmission): Promise { - const stored = normalizePendingMessageAdmission(admission); - const updated = this.#lease.database - .prepare(` - UPDATE core_message_admissions - SET content_json = ?, model_content_json = ? - WHERE session_id = ? AND turn_id = ? AND run_id = ? AND message_id = ? - AND submitted_placement = ? AND placement = ? AND disposition = ? AND admitted_at = ? - AND NOT EXISTS ( - SELECT 1 - FROM core_message_admission_settlements - WHERE core_message_admission_settlements.session_id = - core_message_admissions.session_id - AND core_message_admission_settlements.message_id = - core_message_admissions.message_id - ) - `) - .run( - JSON.stringify(stored.content), - JSON.stringify(stored.modelContent), - stored.sessionId, - stored.turnId, - stored.runId, - stored.messageId, - stored.submittedPlacement, - stored.placement, - stored.disposition, - stored.admittedAt, - ); - if (updated.changes !== 1) throw new Error('Message update identity conflict'); - } - async commitMessageOrder(sessionId: string, messageIds: readonly string[]): Promise { assertSafeId(sessionId, 'Invalid Session identity'); for (const messageId of messageIds) assertSafeId(messageId, 'Invalid Message identity'); diff --git a/packages/storage/src/session-catalog-message-projection.ts b/packages/storage/src/session-catalog-message-projection.ts new file mode 100644 index 0000000000..e6c6547e4e --- /dev/null +++ b/packages/storage/src/session-catalog-message-projection.ts @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { StoredMessage } from '@maka/core/session'; + +export function projectSessionCatalogMessages(messages: readonly StoredMessage[]): { + readonly lastMessageAt?: number; + readonly lastMessagePreview?: string; +} { + const lastMessageAt = latestVisibleMessageAt(messages); + const lastMessagePreview = lastMessagePreviewForMessages(messages); + return { + ...(lastMessageAt === undefined ? {} : { lastMessageAt }), + ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), + }; +} + +export function latestVisibleMessageAt(messages: readonly StoredMessage[]): number | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]!; + if (isVisibleSessionMessage(message)) return message.ts; + } + return undefined; +} + +export function isVisibleSessionMessage( + message: StoredMessage, +): message is Extract { + return message.type === 'user' || message.type === 'assistant'; +} + +export function lastMessagePreviewForMessages( + messages: readonly StoredMessage[], +): string | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]!; + if (message.type === 'user') { + const text = normalizePreviewText(message.displayText ?? message.text); + if (text) return truncatePreview(text); + if (message.attachments && message.attachments.length > 0) return '附件'; + } + if (message.type === 'assistant') { + const text = normalizePreviewText(message.text); + if (text) return truncatePreview(text); + } + } + return undefined; +} + +function normalizePreviewText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function truncatePreview(text: string, maxLength = 96): string { + const chars = Array.from(text); + if (chars.length <= maxLength) return text; + return `${chars.slice(0, maxLength - 1).join('')}…`; +} diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 792f5f8aab..ccd8826ee9 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -31,6 +31,12 @@ import { type VersionedSessionIdentity, } from './sqlite-session-metadata-store.js'; import { isDiscardableConversationCopy } from './session-conversation-copy.js'; +import { + isVisibleSessionMessage, + lastMessagePreviewForMessages, + latestVisibleMessageAt, + projectSessionCatalogMessages, +} from './session-catalog-message-projection.js'; import { acquireOperationalStateDatabase, OPERATIONAL_STATE_DATABASE_NAME, @@ -310,10 +316,8 @@ export interface SessionAuthorityStore extends SessionStore { subscribeTranscriptChanges(listener: (sessionId: string) => void): () => void; /** Wait until the SQLite authority is ready for cross-domain transactions. */ ready(): Promise; - commitMessageAdmission( - admission: PendingMessageAdmission, - transcriptMessage?: StoredMessage, - ): Promise; + commitMessageAdmission(admission: PendingMessageAdmission): Promise; + updateMessageAdmission(admission: PendingMessageAdmission): Promise; /** Atomically create a Session from already-converted Maka raw messages. */ createImportedSession( input: CreateSessionInput, @@ -864,20 +868,23 @@ class SqliteSessionStore implements SessionAuthorityStore { async commitMessageAdmission( admission: PendingMessageAdmission, - transcriptMessage?: StoredMessage, ): Promise { await this.ensureReady(); - const committed = await this.metadata.commitMessageAdmission( - admission, - transcriptMessage, - transcriptMessage ? projectSessionCatalogMessages([transcriptMessage]) : undefined, - ); - if (transcriptMessage) { + const committed = await this.metadata.commitMessageAdmission(admission); + if (admission.disposition === 'steering') { for (const listener of this.transcriptChangeListeners) listener(admission.sessionId); } return committed; } + async updateMessageAdmission(admission: PendingMessageAdmission): Promise { + await this.ensureReady(); + await this.metadata.updateMessageAdmission(admission); + if (admission.disposition === 'steering') { + for (const listener of this.transcriptChangeListeners) listener(admission.sessionId); + } + } + subscribeTranscriptChanges(listener: (sessionId: string) => void): () => void { this.transcriptChangeListeners.add(listener); return () => this.transcriptChangeListeners.delete(listener); @@ -1381,32 +1388,6 @@ function toCatalogSummary( }; } -export function projectSessionCatalogMessages(messages: readonly StoredMessage[]): { - readonly lastMessageAt?: number; - readonly lastMessagePreview?: string; -} { - const lastMessageAt = latestVisibleMessageAt(messages); - const lastMessagePreview = lastMessagePreviewForMessages(messages); - return { - ...(lastMessageAt === undefined ? {} : { lastMessageAt }), - ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), - }; -} - -function latestVisibleMessageAt(messages: readonly StoredMessage[]): number | undefined { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]!; - if (isVisibleSessionMessage(message)) return message.ts; - } - return undefined; -} - -function isVisibleSessionMessage( - message: StoredMessage, -): message is Extract { - return message.type === 'user' || message.type === 'assistant'; -} - function maxTimestamp(left: number | undefined, right: number | undefined): number | undefined { if (left === undefined) return right; if (right === undefined) return left; @@ -1417,34 +1398,6 @@ function normalizeSessionName(name: string): string { return name === 'New Session' ? DEFAULT_SESSION_NAME : name; } -function lastMessagePreviewForMessages(messages: readonly StoredMessage[]): string | undefined { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]!; - if (message.type === 'user') { - // Prefer the human-facing view when the stored model text is a composed - // envelope (e.g. explicit skill invocation). - const text = normalizePreviewText(message.displayText ?? message.text); - if (text) return truncatePreview(text); - if (message.attachments && message.attachments.length > 0) return '附件'; - } - if (message.type === 'assistant') { - const text = normalizePreviewText(message.text); - if (text) return truncatePreview(text); - } - } - return undefined; -} - -function normalizePreviewText(text: string): string { - return text.replace(/\s+/g, ' ').trim(); -} - -function truncatePreview(text: string, maxLength = 96): string { - const chars = Array.from(text); - if (chars.length <= maxLength) return text; - return `${chars.slice(0, maxLength - 1).join('')}…`; -} - export function createUserMessage(input: { turnId: string; text: string; diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index c9f5064048..a3d71e1c19 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -129,6 +129,7 @@ import { type SessionTurnContributionPage, type SessionTurnLandmarkSnapshot, } from './session-store.js'; +import { projectSessionCatalogMessages } from './session-catalog-message-projection.js'; import { isDiscardableConversationCopy, isValidConversationCopyTransition, @@ -158,6 +159,21 @@ function decodeStoredMessage(value: unknown): StoredMessage { return decodePersistedStoredMessage(markPersisted(value)); } +function encodePendingAdmissionTranscript( + admission: PendingMessageAdmission, +): { readonly message: StoredMessage; readonly json: string } | undefined { + if (admission.disposition !== 'steering') return undefined; + const message = decodeCanonicalMessage({ + type: 'user', + id: admission.messageId, + turnId: admission.turnId, + ts: admission.admittedAt, + ...admission.content, + steeringEventId: admission.messageId, + }); + return { message, json: JSON.stringify(message) }; +} + const require = createRequire(import.meta.url); const AGENT_GRAPH_CONTROL_DELETE_TABLES = SQLITE_AGENT_GRAPH_CONTROL_TABLES.filter( (table) => table !== 'agent_graph_epochs', @@ -1486,24 +1502,9 @@ export class SqliteSessionMetadataStore { async commitMessageAdmission( admission: PendingMessageAdmission, - transcriptMessage?: StoredMessage, - projection?: SessionCatalogMessageProjection, ): Promise { this.assertOpen(); const stored = normalizePendingMessageAdmission(admission); - const encodedMessage = transcriptMessage - ? (() => { - const json = JSON.stringify(transcriptMessage); - const message = decodeCanonicalMessage(JSON.parse(json) as unknown); - if (message.id !== stored.messageId) { - throw new Error('Message admission transcript identity mismatch'); - } - return { message, json }; - })() - : undefined; - if (encodedMessage && !projection) { - throw new Error('Message admission transcript projection is missing'); - } return this.transaction(() => { const record = this.readRecordSync(stored.sessionId); if (!record) throw new SessionNotFoundError(stored.sessionId); @@ -1551,7 +1552,6 @@ export class SqliteSessionMetadataStore { } canonical = existing; if ( - encodedMessage && stored.placement === 'current_turn' && stored.disposition === 'steering' && existing.placement === 'next_turn' && @@ -1572,6 +1572,7 @@ export class SqliteSessionMetadataStore { throw new Error('Message admission queue disposition conflict'); } } + const encodedMessage = encodePendingAdmissionTranscript(canonical); if (encodedMessage) { const existingMessages = this.readMessagesWith( stored.sessionId, @@ -1596,7 +1597,7 @@ export class SqliteSessionMetadataStore { this.insertSessionMessagesSync(stored.sessionId, row.last_sequence + 1, [encodedMessage]); this.updateCatalogProjectionSync( stored.sessionId, - projection!, + projectSessionCatalogMessages([encodedMessage.message]), false, !record.header.connectionLocked && encodedMessage.message.type === 'user', ); @@ -1606,6 +1607,133 @@ export class SqliteSessionMetadataStore { }); } + async updateMessageAdmission(admission: PendingMessageAdmission): Promise { + this.assertOpen(); + const stored = normalizePendingMessageAdmission(admission); + const encodedMessage = encodePendingAdmissionTranscript(stored); + this.transaction(() => { + if (!this.readRecordSync(stored.sessionId)) { + throw new SessionNotFoundError(stored.sessionId); + } + const settlement = this.db + .prepare(` + SELECT 1 + FROM core_message_admission_settlements + WHERE session_id = ? AND message_id = ? + `) + .get(stored.sessionId, stored.messageId); + const existingAdmission = readPendingMessageAdmission( + this.db, + stored.sessionId, + stored.messageId, + ); + if ( + settlement || + !existingAdmission || + existingAdmission.placement !== stored.placement || + existingAdmission.disposition !== stored.disposition || + existingAdmission.admittedAt !== stored.admittedAt || + !samePendingMessageAdmission( + { + ...existingAdmission, + content: stored.content, + modelContent: stored.modelContent, + }, + stored, + ) + ) { + throw new Error('Message update identity conflict'); + } + const transcriptRows = this.db + .prepare(` + SELECT message.sequence, message.record_json, payload.record_bytes, payload.sha256 + FROM session_messages AS message + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE message.session_id = ? AND message.message_id = ? + `) + .all(stored.sessionId, stored.messageId) as StoredSessionMessagePayloadRow[]; + if (transcriptRows.length > 1) { + throw new Error('Message update transcript identity is ambiguous'); + } + if (encodedMessage ? transcriptRows.length !== 1 : transcriptRows.length !== 0) { + throw new Error('Message update transcript identity conflict'); + } + const transcriptRow = transcriptRows[0]; + if (transcriptRow && encodedMessage) { + const existingMessage = decodeStoredMessageRecordRow( + this.db, + stored.sessionId, + transcriptRow, + ); + if ( + existingMessage.type !== 'user' || + existingMessage.id !== stored.messageId || + existingMessage.turnId !== stored.turnId || + existingMessage.ts !== stored.admittedAt || + existingMessage.steeringEventId !== stored.messageId + ) { + throw new Error('Message update transcript identity conflict'); + } + } + this.db + .prepare(` + UPDATE core_message_admissions + SET content_json = ?, model_content_json = ? + WHERE session_id = ? AND message_id = ? + `) + .run( + JSON.stringify(stored.content), + JSON.stringify(stored.modelContent), + stored.sessionId, + stored.messageId, + ); + if (transcriptRow && encodedMessage) { + const sequence = requireStoredMessageSequence(transcriptRow.sequence, stored.sessionId); + if (transcriptRow.record_bytes !== null) { + throw new Error('Message update transcript exceeds admission size contract'); + } + const updatedTranscript = this.db + .prepare(` + UPDATE session_messages + SET record_json = ? + WHERE session_id = ? AND sequence = ? + AND message_id = ? AND message_type = ? AND message_ts = ? + `) + .run( + encodedMessage.json, + stored.sessionId, + sequence, + encodedMessage.message.id, + encodedMessage.message.type, + encodedMessage.message.ts, + ); + if (updatedTranscript.changes !== 1) { + throw new Error('Message update transcript identity conflict'); + } + const latestVisible = this.db + .prepare(` + SELECT sequence + FROM session_messages + WHERE session_id = ? AND message_type IN ('user', 'assistant') + ORDER BY sequence DESC + LIMIT 1 + `) + .get(stored.sessionId) as { sequence?: unknown } | undefined; + if ( + latestVisible && + requireStoredMessageSequence(latestVisible.sequence, stored.sessionId) === sequence + ) { + this.updateCatalogProjectionSync( + stored.sessionId, + projectSessionCatalogMessages([encodedMessage.message]), + true, + ); + } + } + }); + } + async readMessages(sessionId: string): Promise { return this.readMessagesWith(sessionId, decodeStoredMessage); }