diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index ca47298563..eb9540dfcd 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -77,29 +77,14 @@ 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 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 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 ({ @@ -196,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(); @@ -231,7 +216,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__/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..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 @@ -20,53 +20,35 @@ 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, - }), + resolveFollowUpModeAtSubmit({}), + 'queue', + ); + }); + + 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, ); }); 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/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..79dacb7caa --- /dev/null +++ b/apps/desktop/src/main/__tests__/quote-companion-turn-identity.test.ts @@ -0,0 +1,247 @@ +/* + * 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); +}); + +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/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, }, }; }, 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..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", @@ -907,16 +882,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 +922,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 +933,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__/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/__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/__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..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 - // `sessions:steer`, 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, @@ -347,19 +329,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 +770,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/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/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/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..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'; @@ -104,9 +105,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 +381,6 @@ function AppShellContent({ setMessageRetryPendingBySession, setStopPendingBySession, setLiveTurnBySession, - confirmLiveTurn, setShellRunUpdatesBySession, setInteractionBySession, setMessageQueueBySession, @@ -1819,7 +1819,6 @@ function AppShellContent({ setMessages, transcriptRangeRef, setNavSelection, - setLiveTurnBySession, setInteractionBySession, onInteractionChanged: markInteractionChanged, onExecutionBoundaryChanged: reloadActiveExecutionBoundary, @@ -1841,7 +1840,6 @@ function AppShellContent({ clearPendingTurnAction: turnActionRegistry.clearKey, openSessionInChat, pendingKeyOf, - refreshMessages, refreshSessions, setMessages, toastApi, @@ -1868,7 +1866,6 @@ function AppShellContent({ messages, hasPendingAttachments: () => pendingAttachments.length > 0, openSessionInChat, - refreshMessages, refreshSessions, setMessages, commitRevisionDraft, @@ -1909,6 +1906,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 +1930,6 @@ function AppShellContent({ if (pending) clearSubmittedAttachments(pending); if (quotes) clearQuotes(); if (result.kind === 'started') { - await refreshMessages(sessionId); await refreshSessions(); } return true; @@ -1956,24 +1955,17 @@ 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 }), + requiresTurnStart: + revisionSend || new RegExp(SKILL_INVOCATION_TOKEN_SOURCE).test(text), }) : undefined; if (sessionId && followUpAtSubmit) { @@ -2292,7 +2284,6 @@ function AppShellContent({ applyE2eFixture, bootstrapSessions, clearPendingTurnActionsForSession: turnActionRegistry.clearForSession, - confirmLiveTurn, clearSessionRendererState, createSession, handleConnectionEvent, @@ -2306,7 +2297,6 @@ function AppShellContent({ projectPickerRequestRef, refreshConnections: refreshConnectionProjections, refreshMemoryActive, - refreshMessages, refreshScheduledTasks, refreshProjects, refreshShellSettings, @@ -2439,7 +2429,6 @@ function AppShellContent({ activeSession, activeStreamingLive, hasInFlightLiveTools, - refreshMessages, refreshSessions, sessionEventHealthBySessionRef, setSessionEventHealthBySession, 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/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/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 7c4bcb5be8..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 @@ -21,7 +21,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { activeInteractionFor, applyLiveTurnEvent, - armLiveTurn, reconcileTerminalLiveTurn, useMountedRef, type InteractionQueues, @@ -159,6 +158,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const forkSetupPromiseRef = useRef | null>(null); 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,21 +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 applyOwnedEvent = useCallback( + (forkId: string, event: SessionEvent) => { const effect = companionRunEventEffect( event, activeTurnIdRef.current, @@ -215,28 +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); @@ -244,6 +230,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan .catch(() => { if (!mountedRef.current || activeTurnIdRef.current !== settledTurnId) return; activeTurnIdRef.current = null; + submittingMessageIdRef.current = null; + submittingEventsRef.current = []; turnInFlightRef.current = false; stopRequestedRef.current = false; setTurnInFlight(false); @@ -253,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) => { @@ -439,19 +476,20 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan sessionId, }), onForkCommitted: () => {}, - // Arm the optimistic live turn right before the send. onBeforeSend: () => { stopRequestedRef.current = false; - activeTurnIdRef.current = turnId; + activeTurnIdRef.current = null; + submittingMessageIdRef.current = turnId; + submittingEventsRef.current = []; turnInFlightRef.current = true; setTurnInFlight(true); - setLiveTurn(armLiveTurn(turnId)); - ownTurnIdsRef.current.add(turnId); - setOwnTurnTick((tick) => tick + 1); }, onQuotesConsumed: () => onQuotesConsumed(quoteSnapshot), }); if (result.status === 'sent') { + if (turnInFlightRef.current && result.turnId) { + bindSubmittingTurn(result.forkId, result.turnId); + } setHasContent(true); // Surface the just-sent user message immediately, and reflect any // automatic connection/model rebound in the read-only model label. @@ -485,6 +523,8 @@ 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); @@ -501,6 +541,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ensureFork, mountedRef, sideChat, + bindSubmittingTurn, ], ); @@ -565,7 +606,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..f66585c7ff 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -18,28 +18,43 @@ */ 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; + requiresTurnStart?: 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; + 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 + // 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/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) => 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/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) => ({ diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b57e43775c..a7c7b6db12 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); @@ -372,8 +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']; - state.pendingFallback = [{ text: 'Try again', enqueue: 'steer' }]; assert.equal( hydrateToolsWithStoredMessages(state, 'turn-1', [ @@ -404,8 +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.deepEqual(state.pendingFallback, [{ text: 'Try again', enqueue: 'steer' }]); assert.equal(state.entries.at(-1)?.kind, 'notice'); }); @@ -872,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 84d559537f..673a81b949 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'; @@ -1939,7 +1940,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,15 +1959,19 @@ describe('Maka Pi TUI runner', () => { terminal.input('also handle Y'); terminal.input('\r'); - await waitFor(() => + 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, ); - assert.deepEqual(driver.steered, ['also handle Y']); 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'); @@ -2082,9 +2087,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 +2097,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 +2132,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 +2167,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 +2179,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 +2207,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 +2215,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'); @@ -2225,10 +2224,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'); @@ -2277,199 +2273,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 +2319,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(); @@ -5775,9 +5535,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'); @@ -6676,6 +6434,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[] = []; @@ -6738,10 +6504,41 @@ 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'); + } + this.pendingEvents.push({ + type: 'steering_message', + id: message.id, + turnId: message.turnId, + ts: message.ts, + messageId: message.id, + content: { text }, + }); return { kind: 'queued' }; } @@ -6752,17 +6549,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; @@ -6804,217 +6593,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. - */ -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(); - } -} - +/** 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/__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-transcript.ts b/packages/cli/src/pi-transcript.ts index 35de5bbd80..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[]; /** @@ -98,21 +100,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. - */ - pendingFallback: Array<{ text: string; enqueue: 'steer' | 'queue' }>; /** Current non-durable provider retry progress for the activity strip. */ providerRetry?: ProviderRetryEvent; } @@ -210,14 +199,13 @@ export interface MakaPiTranscriptMetadata { export function createMakaPiTranscriptState(): MakaPiTranscriptState { return { entries: [], + renderedUserMessageIds: new Set(), queuedInteractions: [], expandAllTools: false, expandAllThinking: false, renderGeometry: { entryFirstLine: undefined, viewportTop: 0 }, usage: { costUsd: 0, cacheHitInput: 0, cacheMissInput: 0 }, - steering: [], followup: [], - pendingFallback: [], }; } @@ -242,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 }); } @@ -329,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; @@ -342,9 +341,7 @@ 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 = []; - state.pendingFallback = []; for (const msg of messages) { if (msg.type === 'token_usage') accumulateUsage(state.usage, msg); } @@ -710,12 +707,12 @@ 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': - // 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; @@ -1456,44 +1453,18 @@ 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 && - state.pendingFallback.length === 0 - ) { - return []; - } + if (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) { - 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-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) {} diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 61d335b547..bffeb9cfa2 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, @@ -281,11 +281,11 @@ 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 => { + const rememberTranscript = (messages: readonly StoredMessage[]): void => { transcriptLastUsedModel = latestAssistantModelId(messages); }; const replaceTranscript = (messages: readonly StoredMessage[]): void => { - rememberTranscriptModel(messages); + rememberTranscript(messages); replaceTranscriptWithStoredMessages(state, messages); }; let cwd = input.cwd; @@ -540,8 +540,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 && + !state.renderedUserMessageIds.has(message.id), + ); + rememberTranscript(messages); + for (const message of newSteeringMessages) { + appendUserPrompt(state, message.displayText ?? message.text, message.id); + } + if ( + newSteeringMessages.length > 0 || + hydrateToolsWithStoredMessages(state, turnId, messages) + ) { shellRunElapsedTicker.sync(); requestRender(); } @@ -719,7 +732,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. @@ -802,7 +814,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(); @@ -839,8 +850,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 +901,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 +911,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 +947,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 +963,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 +1229,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 +1242,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-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) { 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; 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/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index ab624029d6..6e11b77f2e 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(); }); }); @@ -531,6 +534,8 @@ function createMessages( throw new Error('unexpected root start'); }, prepareMessage: async (input) => ({ kind: 'ready', content: input.content }), + commitMessageAdmission: async (admission) => admission, + updateMessageAdmission: async () => {}, claimStop: async () => { throw new Error('unexpected root stop'); }, @@ -543,6 +548,7 @@ function createMessages( stores.agentRunStore.readRootTurnSourceMessageReceipt(requestedSessionId, messageId), 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 67180218e0..fcf4a18806 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -259,6 +259,112 @@ 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.sessionStore.commitMessageAdmission({ + sessionId: session.id, + turnId: 'interrupted-turn', + runId: 'interrupted-run', + messageId: 'admitted-steering', + content: { text: 'durable steering' }, + modelContent: { text: 'durable steering' }, + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + 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.listPendingMessages()).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__/execution-host-message.test.ts b/packages/runtime-host/src/__tests__/execution-host-message.test.ts index 8c75c21e5a..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(); @@ -311,6 +356,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 +413,23 @@ 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 + .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/__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__/execution-host-recovery.test.ts b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts index 86e95b4cf0..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,6 +217,42 @@ test('startup recovery replays an admitted regenerate with its source lineage', }); }); +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(); + 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); + const predecessorLedger = await fixture.readTurn(predecessorTurnId); + assert.equal( + predecessorLedger.userMessages.find((message) => message.id === seeded.sourceMessageId)?.text, + 'steering folded from predecessor', + ); + }); +}); + 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 567f2ccb0f..f04bacd9ab 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,139 @@ 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 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 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, + }; + const pending = { + sessionId: this.sessionId, + turnId: predecessorTurnId, + 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, + turnId, + proposedRunId: randomUUID(), + proposedUserMessageId: randomUUID(), + execution: { kind: 'external_message' }, + previousRootTurnId: predecessorTurnId, + normalizedInput: source.content, + sourceMessages: [source], + 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, + 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/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index f5f74ebaa0..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,6 +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) => stores.sessionStore.commitMessageAdmission(admission), + updateMessageAdmission: (admission) => stores.sessionStore.updateMessageAdmission(admission), claimStop: (input, commitQueueFence, lease) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, lease), }; @@ -575,6 +577,7 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), 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 934227bd5a..b012ec153e 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 { @@ -36,10 +38,10 @@ import { import { HostMessageCoordinator, type HostMessageCoordinatorOptions, + type HostMessageRecoveryBatch, 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; @@ -188,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); @@ -215,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(); @@ -268,7 +247,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); @@ -328,6 +307,111 @@ test('full snapshot preflight rejection leaves queue, receipt, residency, and pu { originHostEpoch: 'epoch-1', sessionId: ROOT.sessionId, retractId: 'cleanup-capacity' }, operationContext(), ); + completeActiveRoot(fixture); + 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.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' }, + modelContent: { text: 'persist before queueing' }, + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + }); + 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(), + ); + completeActiveRoot(fixture); + 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); + const owner = fixture.coordinator.bindRun(ROOT); + assert.equal((await submit(fixture, 'consumed-steering', 'consume me', 'current_turn')).ok, true); + assert.equal(fixture.pendingAdmissionCount(), 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.pendingAdmissionCount(), 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)); + 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(); }); @@ -356,7 +440,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(); }); @@ -406,6 +490,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); @@ -479,15 +587,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(); }); @@ -542,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 }; }); @@ -556,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: [ @@ -628,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(); }); @@ -795,6 +911,451 @@ 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); + 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 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); + 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.pendingAdmissionCount(), 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.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 () => { + 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('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']( + { + 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.deepEqual( + fixture.recoveredBatches[0]?.sources.map((source) => source.messageId), + ['steering-b', 'steering-a'], + ); + assert.deepEqual(restarted.projection(ROOT.sessionId).followup, []); + 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); + 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( + 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'], + ], + ); +}); + +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); @@ -989,6 +1550,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); @@ -1056,6 +1619,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); @@ -1581,8 +2146,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.pendingAdmissionCount(), 2); owner.release(); + await fixture.coordinator.prepareTerminalTransition(ROOT); + assert.equal(fixture.pendingAdmissionCount(), 2); + await fixture.coordinator.commitStopFence(ROOT); + 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, []); @@ -1737,6 +2310,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); @@ -2075,8 +2650,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); @@ -2106,6 +2683,21 @@ function createFixture( | undefined; const receipts = new Map(); const events: RuntimeEvent[] = []; + const steeringAdmissions: Array<{ + sessionId: string; + turnId: string; + runId: string; + messageId: string; + content: MessageContent; + admittedAt: number; + }> = []; + let steeringAdmissionDelay: + | { + readonly started: ReturnType>; + readonly release: ReturnType>; + readonly error?: Error; + } + | undefined; const operationReceipts = new Map(); const receiptDelays = new Map< string, @@ -2117,7 +2709,14 @@ function createFixture( >(); const stopClaimed = deferred(); const terminal = deferred(); + let explicitStopProof = false; let coordinator: HostMessageCoordinator; + let receiptStore!: MessageReceiptStore & { + commitAdmission(admission: PendingMessageAdmission): Promise; + updateAdmission(admission: PendingMessageAdmission): Promise; + failNextRetraction(error: Error): void; + pendingAdmissionCount(): number; + }; const root: HostMessageRootPort = { readSessionHeader: async () => { rootReads += 1; @@ -2161,6 +2760,31 @@ function createFixture( return { turnId }; }, prepareMessage: (input) => prepareMessage(input), + commitMessageAdmission: async (input) => { + const materializeTranscript = input.disposition === 'steering'; + const delay = steeringAdmissionDelay; + 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; + }, + 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 { @@ -2169,6 +2793,32 @@ function createFixture( }; }, }; + const recoveredBatches: HostMessageRecoveryBatch[] = []; + root.startRecoveredMessages = 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' }; + }; + 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, @@ -2184,21 +2834,9 @@ function createFixture( ); return event ? { event } : undefined; }, + readExplicitStopProof: async () => explicitStopProof, }, - 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; @@ -2221,15 +2859,31 @@ function createFixture( coordinator = new HostMessageCoordinator(options); return { coordinator, + restart: (hostEpoch: string) => { + coordinator = new HostMessageCoordinator({ ...options, hostEpoch }); + return coordinator; + }, setRootState: (state: HostMessageRootState) => { rootState = state; }, + setExplicitStopProof: (value: boolean) => { + explicitStopProof = value; + }, setMessagePreparation: (prepare: NonNullable) => { prepareMessage = prepare; }, startCalls: () => startCalls, events, receipts, + steeringAdmissions, + recoveredBatches, + pendingAdmissionCount: () => receiptStore.pendingAdmissionCount(), + failNextRetraction: (error: Error) => receiptStore.failNextRetraction(error), + delaySteeringAdmission: (error?: Error) => { + const delay = { started: deferred(), release: deferred(), error }; + steeringAdmissionDelay = delay; + return delay; + }, stopClaimed, resolveTerminal: terminal.resolve, liveResidencies: () => liveResidencies, @@ -2260,9 +2914,26 @@ function memoryReceiptStore( receipts: Map, beforeCommit?: (operation: string, operationId: string) => Promise, onRead?: () => void, -): MessageReceiptStore { +): MessageReceiptStore & { + commitAdmission(admission: PendingMessageAdmission): Promise; + updateAdmission(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(); + 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) => { @@ -2278,9 +2949,135 @@ function memoryReceiptStore( receipts.set(receiptKey, snapshot); return snapshot; }, + commitAdmission: async (admission) => { + const admissionKey = `${admission.sessionId}:${admission.messageId}`; + const existing = pending.get(admissionKey); + 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; + }, + 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, + ), + updateAdmission: 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) { + 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}`); + }, + pendingAdmissionCount: () => + [...pending.keys()].filter((admissionKey) => !retracted.has(admissionKey)).length, + failNextRetraction: (error) => { + retractionError = error; + }, }; } +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/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index aad3fdb039..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,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), + commitMessageAdmission: (admission) => stores.sessionStore.commitMessageAdmission(admission), + updateMessageAdmission: (admission) => stores.sessionStore.updateMessageAdmission(admission), claimStop: (input, commitQueueFence, admission) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, admission), }; @@ -2175,6 +2177,7 @@ test('hosted linked child roots share admission, message, terminal, and stop aut stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), + readExplicitStopProof: async () => false, }, receipts: stores.messageReceiptStore, sessionAdmission, @@ -3224,7 +3227,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({ @@ -3329,22 +3332,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, @@ -3354,7 +3345,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(); @@ -3364,203 +3355,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 () => { @@ -4781,6 +4575,8 @@ async function createFailureFixture(options: { startFromMessage: (input, admission) => requireCoordinator(coordinator).startFromMessage(input, admission), prepareMessage: (input) => requireCoordinator(coordinator).prepareMessage(input), + commitMessageAdmission: (admission) => stores.sessionStore.commitMessageAdmission(admission), + updateMessageAdmission: (admission) => stores.sessionStore.updateMessageAdmission(admission), claimStop: (input, commitQueueFence, admission) => requireCoordinator(coordinator).claimStop(input, commitQueueFence, admission), }; @@ -4799,6 +4595,7 @@ async function createFailureFixture(options: { stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), readImmutableSteeringMessageProof: (sessionId, messageId) => stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), + readExplicitStopProof: async () => false, }, receipts: stores.messageReceiptStore, sessionAdmission, 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; } 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 153468a1e2..4c917e5fcc 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'; @@ -67,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, @@ -452,7 +454,11 @@ export async function createExecutionRuntimeHostComposition( requireRootCoordinator(rootCoordinator).claimStopFence(input, commitQueueFence, admission), startFromMessage: (input, admission) => requireRootCoordinator(rootCoordinator).startFromMessage(input, admission), + startRecoveredMessages: (input, admission) => + requireRootCoordinator(rootCoordinator).startRecoveredMessages(input, admission), prepareMessage: (input) => requireRootCoordinator(rootCoordinator).prepareMessage(input), + commitMessageAdmission: (admission) => stores.sessionStore.commitMessageAdmission(admission), + updateMessageAdmission: (admission) => stores.sessionStore.updateMessageAdmission(admission), claimStop: (input, commitQueueFence, admission) => requireRootCoordinator(rootCoordinator).claimStop(input, commitQueueFence, admission), }; @@ -464,6 +470,21 @@ export async function createExecutionRuntimeHostComposition( stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), 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, @@ -1469,6 +1490,7 @@ export async function createExecutionRuntimeHostComposition( ), ); await coordinator.recover(); + await messages.recoverPendingAfterHostRestart(); rootRecoveryCompleted = true; }, }, 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/hosted-execution-recovery.ts b/packages/runtime-host/src/server/hosted-execution-recovery.ts index 5187583b16..bf2bc3ab2d 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,75 @@ 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`, + ); + } + let localSourceIndex = 0; + let missingSource = false; + for (const source of admission.sourceMessages) { + const identityOwners = index.messagesById.get(source.messageId) ?? []; + if (identityOwners.length === 0) { + if (!allowPrefix) { + throw new Error( + `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 ( + 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`); + } +} + function recoveryUserMessage(admission: RootTurnAdmission): RecoveryUserMessage { if (!admission.userMessageId || !admission.normalizedInput) { throw new Error(`Admitted Turn ${admission.turnId} does not own a UserMessage`); diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 8d8ac47e9b..9d4dc02b1b 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 PendingMessageAdmission, type RootTurnSourceMessage, type RootTurnSourceMessageReceipt, } from '@maka/storage/execution-stores'; @@ -70,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 = @@ -106,6 +107,13 @@ export interface HostMessageStartInput { readonly initiatingConnectionId: string; } +export interface HostMessageRecoveryBatch { + readonly sessionId: string; + readonly content: MessageContent; + readonly submittedContent: MessageContent; + readonly sources: readonly RootTurnSourceMessage[]; +} + export interface HostMessagePreparationInput { readonly sessionId: string; readonly turnId: string; @@ -130,27 +138,32 @@ export interface HostMessageRootPort { readRootState(sessionId: string): Promise | HostMessageRootState; claimStopFence( input: Omit, - commitQueueFence: () => QueueFenceResult, + commitQueueFence: () => QueueFenceResult | Promise, admission: SessionAdmissionLease, ): Promise; startFromMessage( input: HostMessageStartInput, admission: SessionAdmissionLease, ): Promise<{ readonly turnId: string } | { readonly error: string }>; + startRecoveredMessages?( + input: HostMessageRecoveryBatch, + admission: SessionAdmissionLease, + ): Promise<{ readonly turnId: string } | { readonly error: string }>; prepareMessage( input: HostMessagePreparationInput, ): Promise< | { readonly kind: 'ready'; readonly content: MessageContent } | { readonly kind: 'rejected'; readonly error: string } >; + commitMessageAdmission(admission: PendingMessageAdmission): Promise; + updateMessageAdmission(admission: PendingMessageAdmission): Promise; claimStop( input: Omit, - commitQueueFence: () => QueueFenceResult, + commitQueueFence: () => QueueFenceResult | Promise, admission: SessionAdmissionLease, ): Promise; } -/** Existing durable facts used only to prove an earlier Host Epoch's submit disposition. */ export interface HostMessageDurableProofReader { readRootTurnSourceMessageReceipt( sessionId: string, @@ -160,6 +173,7 @@ export interface HostMessageDurableProofReader { sessionId: string, messageId: string, ): Promise; + readExplicitStopProof(sessionId: string, runId: string): Promise; } export interface HostMessageCoordinatorOptions { @@ -188,13 +202,12 @@ interface LiveEntry { readonly messageId: string; content: MessageContent; modelContent: MessageContent; - 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 { @@ -259,6 +272,7 @@ interface SessionState { reservedRoot?: RuntimeMessageRunIdentity; run?: BoundRun; transition?: TerminalTransition; + steeringDiscardPreparedFor?: RuntimeMessageRunIdentity; stopFence?: { readonly identity: RuntimeMessageRunIdentity; readonly result: QueueFenceResult; @@ -274,7 +288,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[]; @@ -301,7 +314,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), }; @@ -403,6 +416,77 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { state.phase = 'open'; } + async recoverPendingAfterHostRestart(): Promise { + 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: PendingMessageAdmission[] = []; + const settled: string[] = []; + const stopped: string[] = []; + for (const candidate of durable) { + const source = await this.#durableProof.readRootTurnSourceMessageReceipt( + sessionId, + candidate.messageId, + ); + 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) { + throw new RuntimeMessageAuthorityInvariantError( + 'Pending Message recovery found an unavailable Session', + ); + } + if ((await this.#root.readRootState(sessionId)).kind !== 'idle') { + throw new RuntimeMessageAuthorityInvariantError( + 'Pending Message recovery requires an idle root after interrupted Run recovery', + ); + } + if (!this.#root.startRecoveredMessages) { + throw new RuntimeMessageAuthorityInvariantError( + 'Pending Message recovery authority is unavailable', + ); + } + const sources = pending.map(pendingMessageSource); + const started = await this.#root.startRecoveredMessages( + { + sessionId, + content: aggregateMessageContent(pending.map((entry) => entry.modelContent)), + submittedContent: aggregateMessageContent(pending.map((entry) => entry.content)), + sources, + }, + admissionLease, + ); + if ('error' in started) { + throw new RuntimeMessageAuthorityInvariantError( + `Unable to recover pending Message: ${started.error}`, + ); + } + await this.#receipts.garbageCollectMessageAdmissions( + sessionId, + pending.map((entry) => entry.messageId), + ); + }); + } + } + abandonRootReservation(identity: RuntimeMessageRunIdentity): void { const state = this.#requireState(identity.sessionId); if (!state.reservedRoot || !sameRun(state.reservedRoot, identity) || state.run) { @@ -420,6 +504,31 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { this.#maybeReclaim(identity.sessionId, state); } + async prepareTerminalTransition(identity: RuntimeMessageRunIdentity): Promise { + const consumed: string[] = []; + 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.garbageCollectMessageAdmissions(identity.sessionId, consumed); + } + if (stopped.length > 0) { + await this.#receipts.commitMessageRetractions(identity.sessionId, stopped); + } + if (this.#draining) await this.prepareStopFence(identity); + } + beginTerminalTransition(identity: RuntimeMessageRunIdentity): RootFollowupBatch { const state = this.#requireState(identity.sessionId); const run = state.run; @@ -455,7 +564,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(), @@ -467,13 +576,19 @@ 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, }; } + async settleAdmittedRootSources(batch: RootFollowupBatch): Promise { + await this.#receipts.garbageCollectMessageAdmissions( + batch.sessionId, + batch.sources.map((source) => source.messageId), + ); + } + commitNextRoot(batch: RootFollowupBatch, identity: RuntimeMessageRunIdentity): void { const state = this.#requireTransition(batch); if (identity.sessionId !== batch.sessionId) { @@ -503,7 +618,24 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { this.#draining = true; } - commitStopFence(identity: RuntimeMessageRunIdentity): QueueFenceResult { + 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 + // 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; + } + state.steeringDiscardPreparedFor = { ...identity }; + } + + async commitStopFence(identity: RuntimeMessageRunIdentity): Promise { return this.#commitQueueFence(identity); } @@ -575,12 +707,46 @@ 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.#receipts.readMessageAdmission(input.sessionId, input.messageId); + if (this.#failStopped) { + return failure('host_draining', 'Runtime Host message authority has failed'); + } + if ( + durableAdmission && + (durableAdmission.sessionId !== input.sessionId || + durableAdmission.messageId !== input.messageId || + durableAdmission.submittedPlacement !== input.placement || + !messageContentsEqual(durableAdmission.content, payload.content)) + ) { + return failure('operation_conflict', 'Durable message 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', @@ -607,6 +773,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)) { @@ -653,6 +828,35 @@ 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 (dispositionFromPlacement(existing.placement) !== durableAdmission.disposition) { + throw new RuntimeMessageAuthorityInvariantError( + 'Durable message admission collided with a different queue disposition', + ); + } + const result = { + disposition: durableAdmission.disposition, + 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'); } @@ -697,15 +901,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, @@ -718,9 +913,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 || @@ -733,23 +932,39 @@ 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({ + 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(); const entry: LiveEntry = { entryId, 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); 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) { @@ -783,19 +998,28 @@ 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); - if (retracted.length > 0) this.#mutated(state); + const retractedEntries = [...state.followup]; + 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 })) { throw new RuntimeMessageAuthorityInvariantError( 'Retract mutation did not match its prepared result', @@ -839,6 +1063,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { private updateQueuedEntry( input: QueueEntryUpdateInput, + initiatingConnectionId: string, ): Promise> { return this.#runQueuedMutation({ spec: MESSAGE_OPERATION_SPECS['queue.entry.update'], @@ -846,7 +1071,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { operationId: input.updateId, verb: 'Update', input, - execute: () => this.#updateQueuedEntryAdmitted(input), + execute: () => this.#updateQueuedEntryAdmitted(input, initiatingConnectionId), }); } @@ -965,11 +1190,15 @@ 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'); } + await this.#receipts.commitMessageRetractions(input.sessionId, [queued.entry.messageId]); queued.remove(); this.#releaseEntry(queued.entry); this.#mutated(state); @@ -1023,8 +1252,59 @@ 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', + }; + 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, + 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({ ...entry, placement: 'current_turn', disposition: 'steering' }); + state.steering.push(promotedEntry); this.#mutated(state); const result = { queueRevision: state.revision }; try { @@ -1038,6 +1318,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { async #updateQueuedEntryAdmitted( input: QueueEntryUpdateInput, + initiatingConnectionId: string, ): Promise> { const header = await this.#root.readSessionHeader(input.sessionId); if (this.#failStopped) { @@ -1074,7 +1355,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; @@ -1113,6 +1394,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.#root.updateMessageAdmission({ + 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); @@ -1126,6 +1425,27 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { 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( input: QueueEntriesReorderInput, ): Promise> { @@ -1153,6 +1473,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); } @@ -1247,6 +1571,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 }, @@ -1362,7 +1687,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) @@ -1472,11 +1796,11 @@ 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, messageId: entry.messageId, + eventId: entry.messageId, content: normalizeMessageContent(entry.modelContent), submittedContentDigest: messageContentDigest(entry.content), }; @@ -1508,7 +1832,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 && @@ -1555,13 +1878,23 @@ 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', ); } 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 }; @@ -1569,14 +1902,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'); @@ -1589,6 +1927,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; } @@ -1599,7 +1938,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)), @@ -1684,7 +2022,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { #releaseEntry(entry: LiveEntry): void { if (entry.state === 'released') return; entry.state = 'released'; - entry.leaseId = undefined; entry.residency.release(); } } @@ -1719,13 +2056,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( @@ -1825,7 +2158,7 @@ function sameSourcePayload( (durableDigest ? durableDigest === messageContentDigest(input.content) : messageContentsEqual(source.content, input.content)) && - source.placement === input.placement + (source.submittedPlacement ?? source.placement) === input.placement ); } @@ -1834,6 +2167,26 @@ 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: dispositionFromPlacement(entry.placement), + }; +} + +function dispositionFromPlacement(placement: MessagePlacement): 'steering' | 'followup' { + return placement === 'current_turn' ? 'steering' : 'followup'; +} + +function pendingMessageSource(entry: PendingMessageAdmission): RootTurnSourceMessage { + return { + messageId: entry.messageId, + content: normalizeMessageContent(entry.modelContent), + submittedContentDigest: messageContentDigest(entry.content), + ...(entry.submittedPlacement !== entry.placement + ? { submittedPlacement: entry.submittedPlacement } + : {}), placement: entry.placement, disposition: entry.disposition, }; @@ -1860,7 +2213,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). @@ -1918,7 +2271,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); } @@ -1977,15 +2330,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 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 c05a8a019d..e7b17f511d 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 PendingMessageAdmission, 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, @@ -87,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'; @@ -156,6 +158,7 @@ interface ActiveRootTurn { residency: RuntimeHostResidency; stopRequested: boolean; messageTransitionCommitted: boolean; + initialUserMessagesMaterialized: boolean; } export type TurnStartOutcome = OperationOutcome<'turn.start'>; @@ -866,19 +869,20 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { stopRoot( identity: RuntimeMessageRunIdentity, input: { - source?: 'stop_button' | 'graph_supervisor'; + source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; mode?: BackendStopMode; } = {}, ): 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) => @@ -898,12 +902,12 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { stopSession( sessionId: string, input: { - source?: 'stop_button' | 'graph_supervisor'; + source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; mode?: BackendStopMode; } = {}, ): 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 = { @@ -911,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), @@ -950,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 { @@ -1085,6 +1090,51 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { }); } + 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 Message 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 }; + if (!this.beginRootAdmission(reservation)) { + return { error: 'Root Turn reservation is no longer current' }; + } + const { turnId, admission } = await this.admitQueuedMessageRoot(input, header); + const disposition = await this.prepareAdmittedTurn( + { + sessionId: input.sessionId, + turnId, + content: admission.normalizedInput, + }, + admission, + this.acquireRecoveryResidency, + admissionLease, + undefined, + undefined, + reservation, + ); + if (disposition.kind !== 'await_start') { + throw new RuntimeMessageAuthorityInvariantError( + 'Recovered Message root Turn did not reserve execution', + ); + } + return { turnId }; + } finally { + this.releaseRootReservation(reservation); + } + }); + } + prepareMessage( input: HostMessagePreparationInput, ): Promise< @@ -1110,7 +1160,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { claimStop( input: Pick, - commitQueueFence: () => QueueFenceResult, + commitQueueFence: () => QueueFenceResult | Promise, admission: SessionAdmissionLease, ): Promise { return this.runCommand(async () => { @@ -1140,7 +1190,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) => ({ @@ -1796,10 +1846,10 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { private async declareStopFence( input: Pick, - commitQueueFence: () => QueueFenceResult, + commitQueueFence: () => QueueFenceResult | Promise, admission: SessionAdmissionLease, stopInput: { - source?: 'stop_button' | 'graph_supervisor'; + source?: 'stop_button' | 'graph_supervisor' | 'host_shutdown'; mode?: BackendStopMode; } = {}, ): Promise { @@ -1810,7 +1860,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; @@ -1825,7 +1875,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( @@ -1845,7 +1895,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 }; } @@ -1870,7 +1920,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { }; } - commitQueueFence(); + await commitQueueFence(); await this.interactions.claimRunClosure(input, 'turn_stopped', admissionLease); const shouldRequestStop = !active.stopRequested; active.stopRequested = true; @@ -1909,6 +1959,21 @@ 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, + previousRootTurnId: admission.previousRootTurnId, + messages: admission.sourceMessages.map((source) => ({ + messageId: source.messageId, + content: source.content, + disposition: source.disposition, + })), + }); + } const { runId } = admission; const existingRun = await this.readRunIfPresent(input.sessionId, runId); if (replacing && existingRun) { @@ -2001,6 +2066,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { residency, stopRequested: false, messageTransitionCommitted: false, + initialUserMessagesMaterialized, }; if (replacing && this.#executions.get(input.sessionId) !== replacing) { residency.release(); @@ -2098,6 +2164,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) { @@ -2253,6 +2320,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, @@ -2275,43 +2343,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; @@ -2324,9 +2362,9 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { { sessionId: batch.sessionId, turnId, - content: admitted.admission.normalizedInput, + content: admission.normalizedInput, }, - admitted.admission, + admission, this.acquireRecoveryResidency, admissionLease, previous, @@ -2336,12 +2374,41 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { 'Fresh follow-up root Turn did not reserve execution', ); } + 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 { @@ -2349,11 +2416,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/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/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 48bb09af68..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 = this.newId(); + const eventId = lease.eventId ?? this.newId(); const providerContent = await this.appendImageParts( scope.imageBudget, buildSteeringEnvelope(formatTextWithInlineRefs(lease.content.text, lease.content)), 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 968b54db95..2da97de3ef 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -25,13 +25,14 @@ import type { RuntimeEventStore, } from '@maka/core/runtime-event-store'; import { isSessionInlineRun } from '@maka/core/agent-run'; -import type { - ActiveInteractionRequestEvent, - CompleteEvent, - QueueEnqueueOutcome, - QueueUpdateEvent, - SessionEvent, - TokenUsageEvent, +import { + messageContentsEqual, + normalizeMessageContent, + type ActiveInteractionRequestEvent, + type CompleteEvent, + type MessageContent, + type SessionEvent, + type TokenUsageEvent, } from '@maka/core/events'; import type { SessionBlockedReason, @@ -168,14 +169,11 @@ export interface RuntimeKernelLike { respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise; listActiveInteractions?(sessionId: string): ActiveInteractionRequestEvent[]; respondToUserQuestion?(sessionId: string, response: UserQuestionResponse): 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; + materializeRootSourceMessages?(input: { + sessionId: string; + turnId: string; + messages: readonly { messageId: string; content: MessageContent }[]; + }): Promise; hasActiveRuns(sessionId: string): boolean; /** * The turns of the runs in flight for this session. The same fact @@ -216,6 +214,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 @@ -254,44 +253,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 { @@ -443,7 +404,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; @@ -707,6 +667,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, @@ -1558,71 +1519,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({ @@ -1650,13 +1546,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); @@ -1739,7 +1631,6 @@ export class RuntimeKernel implements RuntimeKernelLike { finalizeRun: () => owners.finalize(), releaseOwner: () => { if (messageOwner) owners.releaseMessage(); - else if (steering) this.releaseSteeringTurn(sessionId, run.turnId); }, }); } finally { @@ -2095,10 +1986,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 { @@ -2393,175 +2280,42 @@ 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); + async materializeRootSourceMessages(input: { + sessionId: string; + turnId: string; + previousRootTurnId: string | null; + messages: readonly { + messageId: string; + content: MessageContent; + disposition: 'steering' | 'followup' | 'turn_started'; + }[]; + }): Promise { + const existingById = new Map( + (await this.deps.store.readMessages(input.sessionId)).map((message) => [message.id, message]), + ); + for (const message of input.messages) { + 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), + }; + await this.deps.store.appendMessage(input.sessionId, materialized); + existingById.set(message.messageId, materialized); } - state.sink = undefined; - state.activeTurnId = undefined; } hasActiveRuns(sessionId: string): boolean { @@ -2626,7 +2380,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 0707b96998..0086c2f6c9 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,24 +4811,19 @@ export class SessionManager { : this.runtimeKernel.stopSession(identity.sessionId, input); } - /** Queue a user message for mid-turn injection at the next step boundary. */ - 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); + materializeRootSourceMessages(input: { + sessionId: string; + turnId: string; + 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'); + return materialize.call(this.runtimeKernel, input); } async *regenerateTurn( 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 5f0e590fdc..2ba5e84406 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,6 +446,302 @@ describe('SQLite core execution stores', () => { }); }); + 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.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 store = createSqliteMessageReceiptStore(root); + await store.beginHostEpoch('epoch-1'); + 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.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); + assert.deepEqual(await sessions.readMessages(session.id), [transcriptMessage]); + assert.deepEqual( + await sessions.commitMessageAdmission({ ...admission, admittedAt: 999 }), + admission, + ); + 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', + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + }), + /identity conflict/, + ); + assert.deepEqual( + (await sessions.readMessages(session.id)).map((message) => message.id), + ['message-1'], + ); + await sessions.close?.(); + }); + }); + + 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({ + 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: '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 = { + ...admission, + content: { text: 'corrected' }, + modelContent: { text: 'prepared corrected' }, + } as const; + const updatedTranscriptMessage = { + ...transcriptMessage, + text: updated.content.text, + } as const; + + 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( + 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(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?.(); + }); + }); + + 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', + }); + } + 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 ea8e49bcda..0bd1fa0b07 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -118,6 +118,7 @@ export type { MessageOperationReceipt, MessageReceiptOperation, MessageReceiptStore, + PendingMessageAdmission, } from './message-receipt-store.js'; export type { ProbeSessionRemovalResult, @@ -421,6 +422,10 @@ async function createExecutionStoresForWrite sessionStore.appendMessage(sessionId, message)), appendMessages: (sessionId, messages) => run(() => sessionStore.appendMessages(sessionId, messages)), + 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) => @@ -555,6 +560,17 @@ async function createExecutionStoresForWrite messageReceiptStore.commit(hostEpoch, operation, sessionId, operationId, receipt), ), + 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 82bcd27277..95f3078fa1 100644 --- a/packages/storage/src/message-receipt-store.ts +++ b/packages/storage/src/message-receipt-store.ts @@ -20,6 +20,12 @@ 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 { messageContentDigest } from './message-content-digest.js'; import { acquireOperationalStateDatabase, type OperationalStateDatabaseLease, @@ -43,6 +49,26 @@ export interface MessageOperationReceipt { readonly result: unknown; } +export interface PendingMessageAdmission { + readonly sessionId: string; + readonly turnId: string; + readonly runId: string; + readonly messageId: string; + readonly content: MessageContent; + readonly modelContent: MessageContent; + 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( @@ -58,6 +84,18 @@ export interface MessageReceiptStore { operationId: string, receipt: MessageOperationReceipt, ): 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 { @@ -174,6 +212,146 @@ class SqliteMessageReceiptStore implements ClosableMessageReceiptStore { }); } + 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 listPendingMessages(): Promise { + return 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 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(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 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; + for (const messageId of uniqueMessageIds) { + assertSafeId(messageId, 'Invalid Message identity'); + } + this.#lease.transaction('write', () => { + const statement = this.#lease.database.prepare(` + DELETE FROM core_message_admissions + WHERE session_id = ? AND message_id = ? + `); + for (const messageId of uniqueMessageIds) statement.run(sessionId, messageId); + }); + } + close(): void { this.#lease.close(); } @@ -291,6 +469,149 @@ function decodeStoredReceipt( return record as unknown as StoredMessageOperationReceipt; } +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 submitted_placement?: unknown; + readonly placement?: unknown; + readonly disposition?: unknown; + readonly queue_order?: unknown; + readonly admitted_at?: unknown; +} + +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'); + 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 message 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 message admission exceeds size limit'); + } + return normalized; +} + +function decodePendingMessageAdmissionRow( + row: PendingMessageAdmissionRow, +): PendingMessageAdmission { + 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' || + (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 message admission'); + } + 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), + submittedPlacement: row.submitted_placement, + placement: row.placement, + disposition: row.disposition, + admittedAt: row.admitted_at, + }); +} + +export function readPendingMessageAdmission( + db: DatabaseSync, + sessionId: string, + messageId: string, +): PendingMessageAdmission | undefined { + const row = db + .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 = ? + `) + .get(sessionId, messageId) as PendingMessageAdmissionRow | undefined; + return row ? decodePendingMessageAdmissionRow(row) : undefined; +} + +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.submittedPlacement === right.submittedPlacement && + 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/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 a731808e92..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, @@ -57,6 +63,7 @@ import type { AgentGraphOperatorProvisionRequest, AgentGraphOperatorProvisionResult, } from '@maka/core/agent-graph-topology'; +import type { PendingMessageAdmission } from './message-receipt-store.js'; import type { CreateSandboxBoundaryRequest, @@ -309,6 +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): Promise; + updateMessageAdmission(admission: PendingMessageAdmission): Promise; /** Atomically create a Session from already-converted Maka raw messages. */ createImportedSession( input: CreateSessionInput, @@ -857,6 +866,25 @@ class SqliteSessionStore implements SessionAuthorityStore { for (const listener of this.transcriptChangeListeners) listener(sessionId); } + async commitMessageAdmission( + admission: PendingMessageAdmission, + ): Promise { + await this.ensureReady(); + 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); @@ -1360,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; @@ -1396,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-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index 2e394e61b4..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 = 4; +export const SQLITE_CORE_EXECUTION_SCHEMA_VERSION = 6; export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { db.exec(` @@ -132,6 +132,34 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS core_message_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, + 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_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, shell_run_id TEXT NOT NULL, @@ -143,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', @@ -173,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..a3d71e1c19 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, @@ -123,6 +129,7 @@ import { type SessionTurnContributionPage, type SessionTurnLandmarkSnapshot, } from './session-store.js'; +import { projectSessionCatalogMessages } from './session-catalog-message-projection.js'; import { isDiscardableConversationCopy, isValidConversationCopyTransition, @@ -152,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', @@ -1478,6 +1500,240 @@ export class SqliteSessionMetadataStore { }); } + async commitMessageAdmission( + admission: PendingMessageAdmission, + ): Promise { + this.assertOpen(); + const stored = normalizePendingMessageAdmission(admission); + 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 ( + 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'); + } + } + const encodedMessage = encodePendingAdmissionTranscript(canonical); + 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, + projectSessionCatalogMessages([encodedMessage.message]), + false, + !record.header.connectionLocked && encodedMessage.message.type === 'user', + ); + } + } + return canonical; + }); + } + + 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); } 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'