From 5fd23bf4905896e74e28dbf753876b386ec783d8 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 09:08:24 +0800 Subject: [PATCH 01/12] fix(workhub): bound coordination history replay Generated-by: Codex --- .../runtime-host-workhub-ipc-main.test.ts | 2 + .../__tests__/workhub-session-port.test.ts | 115 +++----- apps/desktop/src/preload/preload.ts | 4 + .../src/renderer/workhub-coordination-port.ts | 96 ++----- .../workhub-coordination-coordinator.test.ts | 267 ++++++++++-------- .../workhub-coordination-protocol.test.ts | 6 + packages/runtime-host/src/protocol/index.ts | 4 +- .../src/protocol/workhub-coordination.ts | 29 +- .../src/server/execution-composition.ts | 11 + .../workhub-coordination-coordinator.ts | 176 +++++++++--- packages/storage/src/session-store.ts | 1 + .../src/sqlite-session-metadata-store.ts | 45 ++- 12 files changed, 433 insertions(+), 323 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts index 70a84dedf9..0755f7ce12 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts @@ -46,6 +46,7 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' listWorkHubCoordinationCandidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], + delegations: [{ actionId: 'action-a', targetSessionId: 'session-a', sequence: 3 }], }), actWorkHubCoordination: async (input: unknown) => { actions.push(input); @@ -90,6 +91,7 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' assert.deepEqual(await handlers.get('workhub:candidates')?.({}), { candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], + delegations: [{ actionId: 'action-a', targetSessionId: 'session-a', sequence: 3 }], }); assert.deepEqual( await handlers.get('workhub:act')?.({}, { diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index 00ba4f9303..1ed3a71590 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -29,7 +29,6 @@ import { } from '../../renderer/workhub-session-port.js'; import { createDesktopWorkHubCoordinationPort, - projectWorkHubActiveDelegations, projectWorkHubCoordinationTurns, } from '../../renderer/workhub-coordination-port.js'; @@ -168,16 +167,9 @@ test('projects the durable Coordination transcript into the WorkHub conversation }, updatedAt: 20, }]); - assert.deepEqual(projectWorkHubActiveDelegations( - messages.map((message, sequence) => ({ message, sequence })), - ), [{ - actionId: 'action-1', - targetSessionId: 'payments', - sequence: 3, - }]); }); -test('rebuilds active linkage outside the bounded visible timeline in transcript order', () => { +test('bounds the visible timeline independently of old delegation linkage', () => { const assignment: StoredMessage = { type: 'workhub_coordination', id: 'assignment-old', @@ -211,13 +203,6 @@ test('rebuilds active linkage outside the bounded visible timeline in transcript projectWorkHubCoordinationTurns(messages).some((turn) => turn.messageId === assignment.id), false, ); - assert.deepEqual(projectWorkHubActiveDelegations( - messages.map((message, sequence) => ({ message, sequence })), - ), [{ - actionId: 'action-old', - targetSessionId: 'payments', - sequence: 0, - }]); }); test('projects durable create_new disposition as an explicit new-work announcement', () => { @@ -285,10 +270,6 @@ test('a durable replacement abort terminalizes the retired source linkage', () = reason: 'target_unavailable', }; - assert.deepEqual(projectWorkHubActiveDelegations([ - { sequence: 0, message: assignment }, - { sequence: 1, message: aborted }, - ]), []); assert.equal( projectWorkHubCoordinationTurns([assignment, aborted])[0]?.assignment?.linkState, 'aborted', @@ -328,22 +309,12 @@ test('direct-stop projection is retryable until resolved and preserves not_owned outcome: 'not_owned', }); assert.equal(projected[0]?.assignment?.linkState, 'active'); - assert.deepEqual(projectWorkHubActiveDelegations([ - { sequence: 0, message: assignment }, - { sequence: 1, message: requested }, - { sequence: 2, message: notOwned }, - ]), [{ actionId: 'source-action', targetSessionId: 'payments', sequence: 0 }]); const stopped = { ...notOwned, outcome: 'stop_delivered' as const }; assert.equal( projectWorkHubCoordinationTurns([assignment, requested, stopped])[0]?.assignment?.linkState, 'stopped', ); - assert.deepEqual(projectWorkHubActiveDelegations([ - { sequence: 0, message: assignment }, - { sequence: 1, message: requested }, - { sequence: 2, message: stopped }, - ]), []); }); test('durable supersession terminalizes only the replaced linkage', () => { @@ -403,9 +374,6 @@ test('durable supersession terminalizes only the replaced linkage', () => { projectWorkHubCoordinationTurns(messages).map((turn) => turn.assignment?.linkState), ['superseded', 'active'], ); - assert.deepEqual(projectWorkHubActiveDelegations( - messages.map((message, sequence) => ({ message, sequence })), - ), [{ actionId: 'action-new', targetSessionId: 'login', sequence: 1 }]); }); test('Coordination transcript adapter emits an initial empty ready snapshot and closes cleanly', async () => { @@ -446,6 +414,7 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], + delegations: [], }), act: async () => ({ ok: true, @@ -465,7 +434,7 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and assert.equal(closes, 1); }); -test('Coordination transcript reset rebuilds active linkage outside the resident window', async () => { +test('Coordination transcript reads old active linkage without replaying older history', async () => { const sessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'coordination' }); const assignment: StoredMessage = { type: 'workhub_coordination', @@ -485,13 +454,13 @@ test('Coordination transcript reset rebuilds active linkage outside the resident disposition: 'delegate_existing', userText: 'Continue payments', }; - const recent: StoredMessage = { + const recent: StoredMessage[] = Array.from({ length: 1 }, (_, index) => ({ type: 'user', - id: 'recent-user', - turnId: 'recent-turn', - ts: 2, - text: 'Recent coordination', - }; + id: `recent-user-${index}`, + turnId: `recent-turn-${index}`, + ts: index + 2, + text: `Recent coordination ${index}`, + })); const fragment = (message: StoredMessage, sequence: number) => { const data = new TextEncoder().encode(JSON.stringify(message)); return { @@ -506,9 +475,9 @@ test('Coordination transcript reset rebuilds active linkage outside the resident let deliver: ((batch: DesktopTranscriptBatch) => void) | undefined; let generation = 'generation-1'; let historyLoads = 0; - let historyBatchReady = true; + let snapshotReads = 0; const snapshots: unknown[] = []; - const adapter = createDesktopWorkHubCoordinationPort({ + const portDeps: Parameters[0] = { sessionId, transcripts: { open: async (_requestedSessionId, handler) => { @@ -519,7 +488,7 @@ test('Coordination transcript reset rebuilds active linkage outside the resident generation, hostEpoch: 'epoch-1', durableThrough: 1, - fragments: [fragment(recent, 1)], + fragments: recent.map((message, index) => fragment(message, index + 1)), evictedDurableSequences: [], completedOverlayMessageIds: [], hasOlder: true, @@ -534,20 +503,6 @@ test('Coordination transcript reset rebuilds active linkage outside the resident readThroughMessageId: null, loadBefore: async () => { historyLoads += 1; - handler({ - sessionId: 'coordination', - deliverySequence: historyLoads + 1, - generation, - hostEpoch: 'epoch-1', - durableThrough: 1, - fragments: [fragment(assignment, 0)], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - reset: false, - ready: historyBatchReady, - }); }, loadAround: async () => {}, close: async () => {}, @@ -555,15 +510,24 @@ test('Coordination transcript reset rebuilds active linkage outside the resident }, }, record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'b'.repeat(64)}`, - candidates: [], - }), + candidates: async () => { + snapshotReads += 1; + return { + candidateSetId: `sha256:${'b'.repeat(64)}`, + candidates: [], + delegations: [{ + actionId: assignment.actionId, + targetSessionId: desktopSessionKey({ hostId: 'local-host', sessionId: 'payments' }), + sequence: 0, + }], + }; + }, act: async () => ({ ok: true, result: { disposition: 'answer_here', coordinationTurnId: 'coordination-turn' }, }), - }); + }; + const adapter = createDesktopWorkHubCoordinationPort(portDeps); const handle = await adapter.open( (_turns, activeDelegations) => snapshots.push(activeDelegations), @@ -574,17 +538,17 @@ test('Coordination transcript reset rebuilds active linkage outside the resident targetSessionId: desktopSessionKey({ hostId: 'local-host', sessionId: 'payments' }), sequence: 0, }]); + assert.equal(historyLoads, 0); + assert.equal(snapshotReads, 1); generation = 'generation-2'; - historyBatchReady = false; - const snapshotsBeforeReset = snapshots.length; deliver?.({ sessionId: 'coordination', deliverySequence: 3, generation, hostEpoch: 'epoch-1', durableThrough: 1, - fragments: [fragment(recent, 1)], + fragments: recent.map((message, index) => fragment(message, index + 1)), evictedDurableSequences: [], completedOverlayMessageIds: [], hasOlder: true, @@ -592,25 +556,10 @@ test('Coordination transcript reset rebuilds active linkage outside the resident reset: true, ready: false, }); - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); - assert.equal(historyLoads, 2); - assert.equal(snapshots.length, snapshotsBeforeReset); - deliver?.({ - sessionId: 'coordination', - deliverySequence: 5, - generation, - hostEpoch: 'epoch-1', - durableThrough: 1, - fragments: [fragment(recent, 1)], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - reset: false, - ready: true, - }); + assert.equal(historyLoads, 0); + assert.equal(snapshotReads, 2); assert.deepEqual(snapshots.at(-1), [{ actionId: 'action-old', targetSessionId: desktopSessionKey({ hostId: 'local-host', sessionId: 'payments' }), diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index bcb2babbf9..a17565b16c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2028,6 +2028,10 @@ const makaBridge = { ...candidate, sessionId: recordRuntimeHostSessionScope(scope, candidate.sessionId), })), + delegations: result.delegations.map((delegation) => ({ + ...delegation, + targetSessionId: recordRuntimeHostSessionScope(scope, delegation.targetSessionId), + })), }; }, async act( diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 114fed8532..3659435811 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -34,6 +34,7 @@ import type { WorkHubCoordinationActInput, WorkHubCoordinationActResult, WorkHubCoordinationCandidatesResult, + WorkHubCoordinationSnapshotResult, OperationOutcome, OperationError, } from '@maka/runtime-host/protocol'; @@ -52,7 +53,7 @@ export function createDesktopWorkHubCoordinationPort(deps: { userText: string; assistantText: string; }): Promise<{ turnId: string }>; - candidates(): Promise; + candidates(): Promise; act( input: Omit, ): Promise>; @@ -71,49 +72,37 @@ export function createDesktopWorkHubCoordinationPort(deps: { const store = new DesktopTranscriptRangeStore(deps.sessionId); let disposed = false; let ready = false; - let historyReady = false; - let historyGeneration = 0; + let refreshGeneration = 0; let handle: Awaited> | undefined; - let historyLane = Promise.resolve(); - const coordinationMessagesBySequence = new Map(); + let refreshLane = Promise.resolve(); + let activeDelegations: readonly WorkHubActiveDelegation[] = []; const emit = () => { - const messages = store.snapshot().messages; - handler( - projectWorkHubCoordinationTurns(messages), - projectWorkHubActiveDelegations( - [...coordinationMessagesBySequence.entries()] - .sort(([left], [right]) => left - right) - .map(([sequence, message]) => ({ sequence, message })), - ), - ); + handler(projectWorkHubCoordinationTurns(store.snapshot().messages), activeDelegations); + }; + const scheduleRefresh = (generation: number) => { + refreshLane = refreshLane + .then(async () => { + if (disposed || generation !== refreshGeneration) return; + const result = await deps.candidates(); + if (disposed || generation !== refreshGeneration) return; + activeDelegations = result.delegations; + if (ready) emit(); + }) + .catch(onError); }; const opened = await deps.transcripts.open( deps.sessionId, (batch) => { if (disposed) return; try { + const reset = batch.reset === true; if (batch.reset) { - coordinationMessagesBySequence.clear(); ready = false; - historyReady = false; - const generation = ++historyGeneration; - if (handle) { - historyLane = historyLane.then(async () => { - if (disposed || generation !== historyGeneration) return; - await rebuildCompleteHistory(generation); - }).catch((error) => { - onError(error); - }); - } + refreshGeneration += 1; } const changed = store.accept(batch); - for (const { sequence, message } of store.durableEntries()) { - if (message.type === 'workhub_coordination') { - coordinationMessagesBySequence.set(sequence, message); - } - } ready ||= batch.ready; - if (historyReady && ready && (changed || batch.ready)) emit(); + if (handle && (reset || changed || batch.ready)) scheduleRefresh(refreshGeneration); } catch (error) { onError(error); } @@ -127,28 +116,10 @@ export function createDesktopWorkHubCoordinationPort(deps: { }); handle = opened; - async function rebuildCompleteHistory(generation: number): Promise { - if (!handle) return; - while (!disposed && generation === historyGeneration && store.range().hasOlder) { - const before = store.range().oldestSequence; - await handle.loadBefore(before); - const after = store.range(); - if (after.hasOlder && after.oldestSequence === before) { - throw new Error('WorkHub Coordination transcript history did not advance'); - } - } - if (disposed || generation !== historyGeneration) return; - const range = store.range(); - if (range.hasNewer && range.durableThrough !== null) { - await handle.loadAround(range.durableThrough); - } - if (disposed || generation !== historyGeneration) return; - historyReady = true; - if (ready) emit(); - } - try { - await rebuildCompleteHistory(historyGeneration); + const result = await deps.candidates(); + activeDelegations = result.delegations; + if (ready) emit(); } catch (error) { disposed = true; await handle.close().catch(() => undefined); @@ -164,27 +135,6 @@ export function createDesktopWorkHubCoordinationPort(deps: { }; } -export function projectWorkHubActiveDelegations( - entries: ReadonlyArray<{ readonly sequence: number; readonly message: StoredMessage }>, -): WorkHubActiveDelegation[] { - const terminalDelegationIds = new Set( - entries.flatMap(({ message }) => { - const terminal = terminalDelegationLink(message); - return terminal ? [terminal.delegationId] : []; - }), - ); - return entries.flatMap(({ message, sequence }) => - message.type === 'workhub_coordination' && - message.kind === 'delegation_assigned' && - !terminalDelegationIds.has(message.delegationId) - ? [{ - actionId: message.actionId, - targetSessionId: message.targetSessionId, - sequence, - }] - : []); -} - export function projectWorkHubCoordinationTurns( messages: readonly StoredMessage[], ): WorkHubCoordinationTurn[] { diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index cb70f8d3b9..074494176f 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -34,6 +34,7 @@ import { WORKHUB_COORDINATION_SESSION_ROLE, WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, type StoredMessage, + type WorkHubDelegationAssignedMessage, } from '@maka/core/session'; import { createSessionStore, type SessionAuthorityStore } from '@maka/storage/session-store'; import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store'; @@ -545,6 +546,17 @@ describe('Host WorkHub Coordination coordinator', () => { ], ); assert.equal(assignments.length, 1); + assert.deepEqual(candidates.result.delegations, []); + const current = await first.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(current.ok, true); + if (!current.ok) return; + assert.deepEqual(current.result.delegations, [ + { + actionId: 'payments-action', + targetSessionId: candidates.result.candidates[0]!.sessionId, + sequence: 0, + }, + ]); } finally { await store.close?.(); } @@ -582,6 +594,86 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('rebuilds chunked active linkage once per Host lifetime from the Coordination ledger', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-active-ledger-')); + const store = createSessionStore(root); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + assert.equal( + (await coordinator(root, store).handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, + true, + ); + await store.appendMessages( + WORKHUB_COORDINATION_SESSION_ID, + Array.from({ length: 256 }, (_, index) => ({ + type: 'user' as const, + id: `historical-message-${index}`, + turnId: `historical-turn-${index}`, + ts: index, + text: 'historical coordination message', + })), + ); + await persistTestAssignment( + store, + { + actionId: 'chunked-action', + actionFingerprint: `sha256:${'7'.repeat(64)}`, + targetSessionId: target.id, + targetSessionName: target.name, + disposition: 'delegate_existing', + userText: '\\'.repeat(40 * 1024), + }, + 'payments-turn', + ); + + let ledgerScans = 0; + const stores = new Proxy(store, { + get(authority, property, receiver) { + if (property === 'readTranscriptRecordsSnapshot') { + return async ( + ...args: Parameters + ) => { + ledgerScans += 1; + return authority.readTranscriptRecordsSnapshot(...args); + }; + } + const value = Reflect.get(authority, property, receiver) as unknown; + return typeof value === 'function' ? value.bind(authority) : value; + }, + }) as SessionAuthorityStore; + + for (const host of [coordinator(root, stores), coordinator(root, stores)]) { + const first = await host.handlers['workhub.coordination.candidates']({}, CONTEXT); + const second = await host.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(first.ok, true); + assert.equal(second.ok, true); + if (!first.ok || !second.ok) continue; + assert.deepEqual(first.result.delegations, second.result.delegations); + assert.deepEqual(first.result.delegations, [ + { + actionId: 'chunked-action', + targetSessionId: target.id, + sequence: 256, + }, + ]); + } + assert.equal( + ledgerScans, + 4, + 'each Host rebuilds two ledger pages once, then serves snapshots from memory', + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('persists direct-stop request and resolution before replaying after restart', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-')); let store = createSessionStore(root); @@ -698,51 +790,43 @@ describe('Host WorkHub Coordination coordinator', () => { model: 'test-model', permissionMode: 'ask', }); + const admission = new SessionAdmissionGate(); let injected = false; - const stores = new Proxy(store, { + let race: (() => Promise) | undefined; + const racingAdmission = new Proxy(admission, { get(authority, property, receiver) { - if (property === 'readMessagesSnapshot') { - return async (sessionId: string) => { - const messages = await authority.readMessagesSnapshot(sessionId); - if ( - !injected && - sessionId === WORKHUB_COORDINATION_SESSION_ID && - messages.some( - (message) => - message.type === 'workhub_coordination' && - message.kind === 'delegation_assigned' && - message.actionId === 'source-action', - ) - ) { + if (property === 'runMany') { + return async ( + sessionIds: readonly string[], + operation: Parameters[1], + ): Promise => { + if (!injected && race) { injected = true; - await persistTestAssignment( - authority, - { - actionId: 'racing-action', - actionFingerprint: `sha256:${'8'.repeat(64)}`, - targetSessionId: target.id, - targetSessionName: 'Payments', - disposition: 'delegate_existing', - userText: 'A second payment delegation', - }, - 'racing-turn', - ); + await race(); } - return messages; + return authority.runMany(sessionIds, operation) as Promise; }; } const value = Reflect.get(authority, property, receiver) as unknown; return typeof value === 'function' ? value.bind(authority) : value; }, - }) as SessionAuthorityStore; + }) as SessionAdmissionGate; let retireCalls = 0; - const workhub = coordinator(root, stores, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, 'source-turn'), - retireDelegation: async () => { - retireCalls += 1; - return { outcome: 'cancelled_pending' }; + const workhub = coordinator( + root, + store, + () => undefined, + undefined, + undefined, + racingAdmission, + { + assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`), + retireDelegation: async () => { + retireCalls += 1; + return { outcome: 'cancelled_pending' }; + }, }, - }); + ); assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); assert.equal(candidates.ok, true); @@ -764,6 +848,18 @@ describe('Host WorkHub Coordination coordinator', () => { ).ok, true, ); + race = async () => { + const raced = await workhub.handlers['workhub.coordination.act']( + { + actionId: 'racing-action', + userText: 'A second payment delegation', + candidateSetId: candidates.result.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef }, + }, + CONTEXT, + ); + assert.equal(raced.ok, true); + }; const stopped = await workhub.handlers['workhub.coordination.act']( { @@ -1009,7 +1105,7 @@ describe('Host WorkHub Coordination coordinator', () => { } }); - test('a claimed stop refuses by name when its delegation was replaced', async () => { + test('a claimed stop refuses by name after restart when its delegation was replaced', async () => { // The claim survived a crash before its request. By the retry the link it // bound itself to is gone and another has taken its place on the same // Session, so re-deriving would silently bind this action to a delegation @@ -1096,7 +1192,11 @@ describe('Host WorkHub Coordination coordinator', () => { 'successor-turn', ); - const refused = await workhub.handlers['workhub.coordination.act']( + const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`), + retireDelegation: async () => assert.fail('a spent stop identity must not retire work'), + }); + const refused = await restarted.handlers['workhub.coordination.act']( { actionId: 'stop-action', userText: 'Stop Payments', @@ -1305,85 +1405,6 @@ describe('Host WorkHub Coordination coordinator', () => { } }); - test('one stop reads the Coordination transcript twice, not once per proof', async () => { - // The Gate derives the delegation from the active links, then admission - // reproves it under the lease. Those are the two reads that decide. Any - // further pass re-derives an answer the stop already holds, on a transcript - // that only grows. - const root = await mkdtemp(join(tmpdir(), 'maka-workhub-scan-count-')); - const store = createSessionStore(root); - try { - const target = await store.create({ - cwd: root, - name: 'Payments', - llmConnectionSlug: 'test-connection', - model: 'test-model', - permissionMode: 'ask', - }); - let coordinationReads = 0; - let counting = false; - const stores = new Proxy(store, { - get(authority, property, receiver) { - if (property === 'readMessagesSnapshot') { - return async (sessionId: string) => { - if (counting && sessionId === WORKHUB_COORDINATION_SESSION_ID) coordinationReads += 1; - return authority.readMessagesSnapshot(sessionId); - }; - } - const value = Reflect.get(authority, property, receiver) as unknown; - return typeof value === 'function' ? value.bind(authority) : value; - }, - }) as SessionAuthorityStore; - const workhub = coordinator(root, stores, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, 'payments-turn'), - retireDelegation: async () => ({ - outcome: 'stop_delivered' as const, - targetTurnId: 'payments-turn', - }), - }); - assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); - const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); - assert.equal(candidates.ok, true); - if (!candidates.ok) return; - assert.equal( - ( - await workhub.handlers['workhub.coordination.act']( - { - actionId: 'source-action', - userText: 'Fix payment retry', - candidateSetId: candidates.result.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: candidates.result.candidates.find( - ({ sessionId }) => sessionId === target.id, - )!.candidateRef, - }, - }, - CONTEXT, - ) - ).ok, - true, - ); - - counting = true; - const stopped = await workhub.handlers['workhub.coordination.act']( - { - actionId: 'stop-action', - userText: 'Stop Payments', - proposal: { disposition: 'stop_work', expects: { targetSessionId: target.id } }, - confirmation: { kind: 'user_stop' }, - }, - CONTEXT, - ); - - assert.equal(stopped.ok, true); - assert.equal(coordinationReads, 2, 'a stop derives once and reproves once'); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); - test('refuses to merge a Turn identity shared across answer and record', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-turn-identity-')); const store = createSessionStore(root); @@ -1551,7 +1572,14 @@ async function persistTestAssignment( store: SessionAuthorityStore, input: Parameters[0], targetTurnId: string, -): Promise<{ turnId: string }> { +): Promise< + { readonly turnId: string } & { + readonly committedAssignment?: { + readonly sequence: number; + readonly assignment: WorkHubDelegationAssignedMessage; + }; + } +> { const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); const content = normalizeMessageContent({ text: input.userText }); const result = await store.assignWorkHubMessage({ @@ -1588,5 +1616,10 @@ async function persistTestAssignment( admittedAt: Date.now(), }, }); - return { turnId: result.assignment.targetTurnId }; + return { + turnId: result.assignment.targetTurnId, + ...(result.kind === 'assigned' + ? { committedAssignment: { sequence: result.sequence, assignment: result.assignment } } + : {}), + }; } diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts index 25cd512c4e..b0da5e34f8 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -190,6 +190,7 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () }); test('WorkHub Coordination candidates are bounded and carry opaque proposal identities', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 110); const result = decodeWorkHubCoordinationCandidatesResult({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [ @@ -205,8 +206,12 @@ test('WorkHub Coordination candidates are bounded and carry opaque proposal iden updatedAt: 7, }, ], + delegations: [{ actionId: 'action-a', targetSessionId: 'session-a', sequence: 3 }], }); assert.equal(result.candidates[0]?.candidateRef, 'candidate_a'); + assert.deepEqual(result.delegations, [ + { actionId: 'action-a', targetSessionId: 'session-a', sequence: 3 }, + ]); assert.equal(HOST_OPERATION_SPECS['workhub.coordination.candidates'].mode, 'query'); assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.candidates'), true); assert.throws( @@ -214,6 +219,7 @@ test('WorkHub Coordination candidates are bounded and carry opaque proposal iden decodeWorkHubCoordinationCandidatesResult({ candidateSetId: 'caller-invented', candidates: [], + delegations: [], }), (error) => error instanceof RuntimeHostProtocolError, ); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b58467c6ee..ddd3d9fee4 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 112 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 113 as const; +// 113: WorkHub exposes current active delegation linkage in the existing Host +// snapshot. Older peers disagree on the closed result shape. // 112: Owners can query the Host execution environment through an extensible, // bounded resource-envelope contract. Older Hosts do not implement the query. // 111: Client Capability tool schemas may use draft-07 tuple additionalItems. diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 735c6a840e..c5d609d5ef 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -112,11 +112,21 @@ export interface WorkHubCoordinationCandidate { export type WorkHubCoordinationCandidatesInput = Record; +export interface WorkHubCoordinationActiveDelegation { + readonly actionId: string; + readonly targetSessionId: string; + readonly sequence: number; +} + export interface WorkHubCoordinationCandidatesResult { readonly candidateSetId: string; readonly candidates: readonly WorkHubCoordinationCandidate[]; } +export interface WorkHubCoordinationSnapshotResult extends WorkHubCoordinationCandidatesResult { + readonly delegations: readonly WorkHubCoordinationActiveDelegation[]; +} + export type WorkHubCoordinationProposal = | { readonly disposition: 'answer_here' } | { readonly disposition: 'clarify'; readonly assistantText: string } @@ -240,7 +250,7 @@ export const WORKHUB_COORDINATION_OPERATION_SPECS = { }), 'workhub.coordination.candidates': defineOperation< WorkHubCoordinationCandidatesInput, - WorkHubCoordinationCandidatesResult, + WorkHubCoordinationSnapshotResult, (typeof CANDIDATE_ERRORS)[number] >({ mode: 'query', @@ -331,12 +341,13 @@ export function decodeWorkHubCoordinationCandidatesInput( export function decodeWorkHubCoordinationCandidatesResult( value: unknown, -): WorkHubCoordinationCandidatesResult { +): WorkHubCoordinationSnapshotResult { const result = requireExactRecord(value, 'WorkHub Coordination candidates result', [ 'candidateSetId', 'candidates', + 'delegations', ]); - if (!Array.isArray(result.candidates)) { + if (!Array.isArray(result.candidates) || !Array.isArray(result.delegations)) { throw invalidProtocolFrame('Invalid WorkHub Coordination candidates'); } if (result.candidates.length > WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS) { @@ -345,6 +356,18 @@ export function decodeWorkHubCoordinationCandidatesResult( return { candidateSetId: candidateSetId(result.candidateSetId), candidates: result.candidates.map(decodeWorkHubCoordinationCandidate), + delegations: result.delegations.map((value) => { + const delegation = requireExactRecord(value, 'Active WorkHub delegation', [ + 'actionId', + 'targetSessionId', + 'sequence', + ]); + return { + actionId: requireEntityId(delegation.actionId, 'WorkHub action id'), + targetSessionId: requireEntityId(delegation.targetSessionId, 'WorkHub target Session id'), + sequence: requireCount(delegation.sequence, 'WorkHub transcript sequence'), + }; + }), }; } diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 2e38c38f8d..1188ecd735 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -33,6 +33,7 @@ import { import { isDeepResearchSession, type SessionHeader, + type WorkHubDelegationAssignedMessage, WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, } from '@maka/core/session'; @@ -1446,6 +1447,9 @@ export async function createExecutionRuntimeHostComposition( .slice(0, 48); const messageId = `whm_${suffix}`; const content = normalizeMessageContent({ text: input.userText }); + let committedAssignment: + | { readonly sequence: number; readonly assignment: WorkHubDelegationAssignedMessage } + | undefined; const persisted = durable ?? (await sessionAdmission.runMany( @@ -1528,6 +1532,12 @@ export async function createExecutionRuntimeHostComposition( ...(create ? { create } : {}), ...(supersession ? { supersession } : {}), }); + if (result.kind === 'assigned') { + committedAssignment = { + sequence: result.sequence, + assignment: result.assignment, + }; + } // Keep the durable steering identity and its live queue owner // under one Session admission. A terminal transition must not // observe the committed Message before the queue does. @@ -1549,6 +1559,7 @@ export async function createExecutionRuntimeHostComposition( return { turnId: persisted.targetTurnId, ...(persisted.steered ? { steered: true as const } : {}), + ...(committedAssignment ? { committedAssignment } : {}), }; }, }, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 80eaf04b2a..9b09815b20 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -38,7 +38,11 @@ import { type WorkHubDelegationStopRequestedMessage, type WorkHubDelegationStopResolvedMessage, } from '@maka/core/session'; -import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; +import { + isSessionNotFoundError, + type SessionAuthorityStore, + type SessionHeaderSnapshot, +} from '@maka/storage/session-store'; import type { OperationOutcome, WorkHubCoordinationActInput, @@ -82,6 +86,13 @@ const COORDINATION_SUMMARY_READ_MAX_BYTES = JSON_ESCAPE_MAX_BYTES_PER_INPUT_BYTE * (WORKHUB_COORDINATION_TEXT_MAX_BYTES + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES) + 16 * 1024; +const ACTIVE_DELEGATION_SCAN_MAX_BYTES = 4 * 1024 * 1024; +const ACTIVE_DELEGATION_SCAN_MAX_MESSAGES = 256; + +interface ActiveWorkHubAssignment { + readonly sequence: number; + readonly assignment: WorkHubDelegationAssignedMessage; +} type CoordinationStores = Pick< SessionAuthorityStore, @@ -93,7 +104,6 @@ type CoordinationStores = Pick< | 'probeSessionRemoval' | 'probeStableSessionCreate' | 'readHeaderSnapshot' - | 'readMessagesSnapshot' | 'readWorkHubAssignment' | 'readWorkHubReplacement' | 'readWorkHubReplacementAbort' @@ -102,6 +112,7 @@ type CoordinationStores = Pick< | 'readWorkHubStopResolution' | 'readTranscriptHighWaterSnapshot' | 'readTranscriptMessagesSnapshot' + | 'readTranscriptRecordsSnapshot' | 'updateHeaderVersioned' >; @@ -120,8 +131,14 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly executions: CoordinationExecutions; readonly sessionActions: Pick< WorkHubActionGateEffects, - 'assign' | 'readDelegationRetirement' | 'retireDelegation' - >; + 'readDelegationRetirement' | 'retireDelegation' + > & { + assign(...args: Parameters): Promise< + Awaited> & { + readonly committedAssignment?: ActiveWorkHubAssignment; + } + >; + }; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; } @@ -145,6 +162,7 @@ export class HostWorkHubCoordinationCoordinator { readonly #requestDrain: () => void; readonly #actionGate: WorkHubCoordinationActionGate; readonly #readDelegationRetirement: HostWorkHubCoordinationCoordinatorOptions['sessionActions']['readDelegationRetirement']; + #activeAssignments: Promise> | undefined; constructor(options: HostWorkHubCoordinationCoordinatorOptions) { this.#coordinationCwd = join(options.stateRoot, COORDINATION_CWD_DIRECTORY); @@ -193,7 +211,16 @@ export class HostWorkHubCoordinationCoordinator { throw new WorkHubActionEffectFailure(outcome.error.code, outcome.error.message); } }, - assign: options.sessionActions.assign, + assign: async (input, context) => { + const result = await options.sessionActions.assign(input, context); + if (result.committedAssignment) { + await this.#applyActiveMessage( + result.committedAssignment.sequence, + result.committedAssignment.assignment, + ); + } + return result; + }, prepareReplacement: (input) => this.#prepareReplacement(input), abortReplacement: (input) => this.#abortReplacement(input), prepareStop: (input) => this.#prepareStop(input), @@ -286,10 +313,10 @@ export class HostWorkHubCoordinationCoordinator { }), conflictMessage: 'WorkHub delegation already has a different stop claim', beforeAppend: async () => { - const [replacement, supersession, messages] = await Promise.all([ + const [replacement, supersession, activeAssignments] = await Promise.all([ this.#stores.readWorkHubReplacement(input.stopsDelegationId), this.#stores.readWorkHubSupersession(input.stopsDelegationId), - this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID), + this.#listActiveAssignments(), ]); if (replacement || supersession) { throw new WorkHubActionGateFailure( @@ -297,7 +324,6 @@ export class HostWorkHubCoordinationCoordinator { 'WorkHub delegation is already being replaced', ); } - const activeAssignments = activeWorkHubAssignments(messages); // Held lanes make this the last moment the one-target proof can change. // It is proved from opaque delegation identity, so a concurrent rename // is harmless while a concurrent delegation to the same Session is not. @@ -333,9 +359,7 @@ export class HostWorkHubCoordinationCoordinator { } async #listActiveAssignments(): Promise { - return activeWorkHubAssignments( - await this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID), - ); + return (await this.#listActiveAssignmentRecords()).map(({ assignment }) => assignment); } #resolveStop( @@ -435,11 +459,15 @@ export class HostWorkHubCoordinationCoordinator { await options.beforeAppend(); try { await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [requested]); + await this.#applyLatestActiveMessage(requested); await this.#continuity.refreshCanonical(WORKHUB_COORDINATION_SESSION_ID, lease); return requested; } catch { const replay = await options.read().catch(() => undefined); - if (replay && isDeepStrictEqual(replay, requested)) return replay; + if (replay && isDeepStrictEqual(replay, requested)) { + await this.#applyLatestActiveMessage(replay); + return replay; + } this.#requestDrain(); throw new WorkHubActionEffectFailure( 'commit_outcome_unknown', @@ -452,7 +480,21 @@ export class HostWorkHubCoordinationCoordinator { async #candidates(): Promise> { try { - return { ok: true, result: await this.#actionGate.candidates() }; + const [result, activeAssignments] = await Promise.all([ + this.#actionGate.candidates(), + this.#listActiveAssignmentRecords(), + ]); + return { + ok: true, + result: { + ...result, + delegations: activeAssignments.map(({ sequence, assignment }) => ({ + actionId: assignment.actionId, + targetSessionId: assignment.targetSessionId, + sequence, + })), + }, + }; } catch { return { ok: false, @@ -464,6 +506,37 @@ export class HostWorkHubCoordinationCoordinator { } } + async #listActiveAssignmentRecords(): Promise { + const active = await this.#activeAssignmentIndex(); + return [...active.values()].sort( + (left, right) => + left.sequence - right.sequence || + left.assignment.delegationId.localeCompare(right.assignment.delegationId), + ); + } + + #activeAssignmentIndex(): Promise> { + return (this.#activeAssignments ??= readActiveWorkHubAssignments(this.#stores)); + } + + async #applyLatestActiveMessage(message: StoredMessage): Promise { + if (!activeDelegationTransition(message)) return; + const sequence = await this.#stores.readTranscriptHighWaterSnapshot( + WORKHUB_COORDINATION_SESSION_ID, + ); + if (sequence === null) { + throw new WorkHubActionEffectFailure( + 'persistence_failed', + 'WorkHub Coordination transcript is unavailable', + ); + } + await this.#applyActiveMessage(sequence, message); + } + + async #applyActiveMessage(sequence: number, message: StoredMessage): Promise { + projectActiveWorkHubMessage(await this.#activeAssignmentIndex(), sequence, message); + } + async #act( input: WorkHubCoordinationActInput, context: ConnectionContext, @@ -788,28 +861,67 @@ function validCoordinationHeader(header: SessionHeader): boolean { ); } -function digest(value: unknown): `sha256:${string}` { - return `sha256:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`; +async function readActiveWorkHubAssignments( + stores: Pick, +): Promise> { + const active = new Map(); + let position = 0; + let throughSequence: number | null | undefined; + try { + while (true) { + const page = await stores.readTranscriptRecordsSnapshot(WORKHUB_COORDINATION_SESSION_ID, { + direction: 'newer', + position, + maxStoredBytes: ACTIVE_DELEGATION_SCAN_MAX_BYTES, + maxMessages: ACTIVE_DELEGATION_SCAN_MAX_MESSAGES, + ...(throughSequence === undefined ? {} : { throughSequence }), + }); + throughSequence = page.throughSequence; + for (const { sequence, message } of page.records) { + projectActiveWorkHubMessage(active, sequence, message); + } + if (page.nextPosition === null) return active; + if (page.nextPosition <= position) { + throw new Error('WorkHub Coordination transcript scan did not advance'); + } + position = page.nextPosition; + } + } catch (error) { + if (isSessionNotFoundError(error)) return active; + throw error; + } } -function activeWorkHubAssignments( - messages: readonly StoredMessage[], -): WorkHubDelegationAssignedMessage[] { - const terminalDelegationIds = new Set(); - const assignments: WorkHubDelegationAssignedMessage[] = []; - for (const message of messages) { - if (message.type !== 'workhub_coordination') continue; - if (message.kind === 'delegation_assigned') { - assignments.push(message); - } else if (message.kind === 'delegation_superseded') { - terminalDelegationIds.add(message.supersededDelegationId); - } else if (message.kind === 'delegation_replacement_aborted') { - terminalDelegationIds.add(message.abortedDelegationId); - } else if (message.kind === 'delegation_stop_resolved' && message.outcome !== 'not_owned') { - terminalDelegationIds.add(message.stopsDelegationId); - } +function activeDelegationTransition(message: StoredMessage): boolean { + return ( + message.type === 'workhub_coordination' && + (message.kind === 'delegation_assigned' || + message.kind === 'delegation_superseded' || + message.kind === 'delegation_replacement_aborted' || + (message.kind === 'delegation_stop_resolved' && message.outcome !== 'not_owned')) + ); +} + +function projectActiveWorkHubMessage( + active: Map, + sequence: number, + message: StoredMessage, +): void { + if (message.type !== 'workhub_coordination') return; + if (message.kind === 'delegation_assigned') { + if (message.replacesDelegationId) active.delete(message.replacesDelegationId); + active.set(message.delegationId, { sequence, assignment: message }); + } else if (message.kind === 'delegation_superseded') { + active.delete(message.supersededDelegationId); + } else if (message.kind === 'delegation_replacement_aborted') { + active.delete(message.abortedDelegationId); + } else if (message.kind === 'delegation_stop_resolved' && message.outcome !== 'not_owned') { + active.delete(message.stopsDelegationId); } - return assignments.filter(({ delegationId }) => !terminalDelegationIds.has(delegationId)); +} + +function digest(value: unknown): `sha256:${string}` { + return `sha256:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`; } function workHubDestructiveClaimIdentitySuffix(delegationId: string): string { diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index c745b72675..2defabf72c 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -200,6 +200,7 @@ export interface WorkHubMessageAssignmentRequest { export interface WorkHubMessageAssignmentResult { readonly kind: 'assigned' | 'existing'; readonly targetCreated: boolean; + readonly sequence: number; readonly assignment: WorkHubDelegationAssignedMessage; } diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index c1a13de8d5..9f14732f0f 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -241,6 +241,7 @@ export interface SqliteWorkHubMessageAssignmentRequest { export interface SqliteWorkHubMessageAssignmentResult { readonly kind: 'assigned' | 'existing'; readonly targetCreated: boolean; + readonly sequence: number; readonly assignment: WorkHubDelegationAssignedMessage; } @@ -1782,10 +1783,11 @@ export class SqliteSessionMetadataStore { throw new SessionMetadataConflictError('WorkHub Coordination Session is unavailable'); } - const existingAssignment = this.readMessageByIdSync( + const existingAssignmentRecord = this.readMessageRecordByIdSync( WORKHUB_COORDINATION_SESSION_ID, assignment.id, ); + const existingAssignment = existingAssignmentRecord?.message; if (existingAssignment) { if ( existingAssignment.type !== 'workhub_coordination' || @@ -1799,6 +1801,7 @@ export class SqliteSessionMetadataStore { return { kind: 'existing' as const, targetCreated: false, + sequence: existingAssignmentRecord.sequence, assignment: existingAssignment, }; } @@ -1924,18 +1927,21 @@ export class SqliteSessionMetadataStore { ) { throw new SessionMetadataConflictError('Invalid WorkHub transcript sequence'); } - this.insertSessionMessagesSync( - WORKHUB_COORDINATION_SESSION_ID, - sequenceRow.last_sequence + 1, - [ - { message: committedAssignment, json: committedAssignmentJson }, - ...(supersession && supersessionJson - ? [{ message: supersession, json: supersessionJson }] - : []), - ], - ); + const firstSequence = sequenceRow.last_sequence + 1; + const entries = [ + { message: committedAssignment, json: committedAssignmentJson }, + ...(supersession && supersessionJson + ? [{ message: supersession, json: supersessionJson }] + : []), + ]; + this.insertSessionMessagesSync(WORKHUB_COORDINATION_SESSION_ID, firstSequence, entries); this.updateCatalogProjectionSync(WORKHUB_COORDINATION_SESSION_ID, request.projection, false); - return { kind: 'assigned' as const, targetCreated, assignment: committedAssignment }; + return { + kind: 'assigned' as const, + targetCreated, + sequence: firstSequence, + assignment: committedAssignment, + }; }); } @@ -5326,7 +5332,10 @@ export class SqliteSessionMetadataStore { return row ? decodeRecord(row) : undefined; } - private readMessageByIdSync(sessionId: string, messageId: string): StoredMessage | undefined { + private readMessageRecordByIdSync( + sessionId: string, + messageId: string, + ): { readonly sequence: number; readonly message: StoredMessage } | undefined { const row = this.db .prepare( ` @@ -5338,7 +5347,15 @@ export class SqliteSessionMetadataStore { `, ) .get(sessionId, messageId) as StoredSessionMessagePayloadRow | undefined; - return row ? decodeStoredMessageRecordRow(this.db, sessionId, row) : undefined; + if (!row) return undefined; + return { + sequence: requireStoredMessageSequence(row.sequence, sessionId), + message: decodeStoredMessageRecordRow(this.db, sessionId, row), + }; + } + + private readMessageByIdSync(sessionId: string, messageId: string): StoredMessage | undefined { + return this.readMessageRecordByIdSync(sessionId, messageId)?.message; } private readSessionMessageOrderingSync( From c09e28931b06f6b2c5cd0e0ef0685f80900a088a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 14:45:58 +0800 Subject: [PATCH 02/12] fix(runtime-host): linearize WorkHub active linkage Generated-by: Codex --- .../__tests__/execution-composition.test.ts | 25 ++++ .../workhub-coordination-coordinator.test.ts | 133 +++++++++++++++--- .../src/server/execution-composition.ts | 13 +- .../workhub-coordination-coordinator.ts | 29 ++-- 4 files changed, 161 insertions(+), 39 deletions(-) diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index f9a68677ec..1de371c068 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -680,6 +680,31 @@ test('WorkHub creates new work through the production assignment composition', a const session = (await manager.listSessions()).find(({ id }) => id === targetSessionId); assert.equal(session?.name, 'Login stability'); assert.equal(session?.llmConnectionId, connectionId); + + const current = await composition.handlers['workhub.coordination.candidates']({}, context); + assert.equal(current.ok, true); + if (!current.ok) return; + assert.deepEqual(current.result.delegations, [ + { + actionId: 'workhub-create-action', + targetSessionId, + sequence: 0, + }, + ]); + const stopped = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-create-stop-action', + userText: 'Stop Login stability', + confirmation: { kind: 'user_stop' }, + proposal: { + disposition: 'stop_work', + expects: { targetSessionId }, + }, + }, + context, + ); + assert.equal(stopped.ok, true, JSON.stringify(stopped)); + if (stopped.ok) assert.equal(stopped.result.disposition, 'stop_work'); } finally { await composition.close(); } diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index 074494176f..2a23a6daa1 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -29,6 +29,7 @@ import { normalizeMessageContent, type MessageContent, } from '@maka/core/events'; +import { deferred } from '@maka/core/test-only/async-primitives'; import { WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_SESSION_ROLE, @@ -50,6 +51,7 @@ import type { WorkHubActionGateEffects } from '../server/workhub-coordination-ac import { HostWorkHubCoordinationCoordinator, type CoordinationCreateTarget, + type HostWorkHubCoordinationCoordinatorOptions, } from '../server/workhub-coordination-coordinator.js'; const CONTEXT: ConnectionContext = { @@ -503,9 +505,13 @@ describe('Host WorkHub Coordination coordinator', () => { }); const assignments: string[] = []; const first = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - assign: async (input) => { + assign: async (input, context, publishCommittedAssignment) => { assignments.push(input.actionId); - return persistTestAssignment(store, input, 'payments-turn'); + return persistTestAssignmentAction(store, 'payments-turn')( + input, + context, + publishCommittedAssignment, + ); }, }); assert.equal((await first.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); @@ -564,7 +570,7 @@ describe('Host WorkHub Coordination coordinator', () => { store = createSessionStore(root); try { const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, 'payments-turn'), + assign: persistTestAssignmentAction(store, 'payments-turn'), }); const candidates = await restarted.handlers['workhub.coordination.candidates']({}, CONTEXT); assert.equal(candidates.ok, true); @@ -689,7 +695,7 @@ describe('Host WorkHub Coordination coordinator', () => { targetId = target.id; let retireCalls = 0; const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, 'payments-turn'), + assign: persistTestAssignmentAction(store, 'payments-turn'), retireDelegation: async () => { retireCalls += 1; return { outcome: 'stop_delivered', targetTurnId: 'payments-turn' }; @@ -779,6 +785,79 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('a stop observes an assignment committed by a concurrent admitted action', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-concurrent-stop-')); + const store = createSessionStore(root); + const admission = new SessionAdmissionGate(); + const committed = deferred(); + const releaseAssignment = deferred(); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + const workhub = coordinator(root, store, () => undefined, undefined, undefined, admission, { + assign: (input, _context, publishCommittedAssignment) => + admission.runMany([WORKHUB_COORDINATION_SESSION_ID, input.targetSessionId], async () => { + const result = await persistTestAssignment(store, input, 'payments-turn'); + committed.resolve(); + await releaseAssignment.promise; + if (result.newAssignment) { + await publishCommittedAssignment(result.newAssignment); + } + return { turnId: result.turnId }; + }), + retireDelegation: async () => ({ outcome: 'cancelled_pending' }), + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + ); + assert.ok(candidate); + if (!candidate) return; + + const assignment = workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef }, + }, + CONTEXT, + ); + await committed.promise; + const stop = workhub.handlers['workhub.coordination.act']( + { + actionId: 'stop-action', + userText: 'Stop Payments', + proposal: { + disposition: 'stop_work', + expects: { targetSessionId: target.id }, + }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + await new Promise((resolve) => setImmediate(resolve)); + releaseAssignment.resolve(); + + assert.equal((await assignment).ok, true); + const stopped = await stop; + assert.equal(stopped.ok, true, JSON.stringify(stopped)); + if (stopped.ok) assert.equal(stopped.result.disposition, 'stop_work'); + } finally { + releaseAssignment.resolve(); + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('rechecks sole-delegation stop preconditions after the advisory active-link read', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-race-')); const store = createSessionStore(root); @@ -820,7 +899,7 @@ describe('Host WorkHub Coordination coordinator', () => { undefined, racingAdmission, { - assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`), + assign: persistTestAssignmentAction(store, (input) => `${input.actionId}-turn`), retireDelegation: async () => { retireCalls += 1; return { outcome: 'cancelled_pending' }; @@ -934,7 +1013,7 @@ describe('Host WorkHub Coordination coordinator', () => { undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, 'payments-turn'), + assign: persistTestAssignmentAction(store, 'payments-turn'), retireDelegation: async () => ({ outcome: 'cancelled_pending' }), }, ); @@ -1042,7 +1121,7 @@ describe('Host WorkHub Coordination coordinator', () => { }, }) as SessionAuthorityStore; const workhub = coordinator(root, stores, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, 'payments-turn'), + assign: persistTestAssignmentAction(store, 'payments-turn'), retireDelegation: async () => ({ outcome: 'stop_delivered' as const, targetTurnId: 'payments-turn', @@ -1123,7 +1202,7 @@ describe('Host WorkHub Coordination coordinator', () => { permissionMode: 'ask', }); const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`), + assign: persistTestAssignmentAction(store, (input) => `${input.actionId}-turn`), retireDelegation: async () => assert.fail('a spent stop identity must not retire work'), }); assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); @@ -1193,7 +1272,7 @@ describe('Host WorkHub Coordination coordinator', () => { ); const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`), + assign: persistTestAssignmentAction(store, (input) => `${input.actionId}-turn`), retireDelegation: async () => assert.fail('a spent stop identity must not retire work'), }); const refused = await restarted.handlers['workhub.coordination.act']( @@ -1236,7 +1315,7 @@ describe('Host WorkHub Coordination coordinator', () => { permissionMode: 'ask', }); const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, 'payments-turn'), + assign: persistTestAssignmentAction(store, 'payments-turn'), retireDelegation: async () => assert.fail('a terminal delegation must not be retired'), }); assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); @@ -1345,7 +1424,7 @@ describe('Host WorkHub Coordination coordinator', () => { }, }) as SessionAdmissionGate; const workhub = coordinator(root, store, () => undefined, undefined, undefined, observed, { - assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`), + assign: persistTestAssignmentAction(store, (input) => `${input.actionId}-turn`), retireDelegation: async () => ({ outcome: 'stop_delivered' as const, targetTurnId: 'source-action-turn', @@ -1539,10 +1618,13 @@ function coordinator( hasRootTurnAdmission: async () => false, }, admission: SessionAdmissionGate = new SessionAdmissionGate(), - sessionActions: Partial< - Pick - > = {}, + sessionActions: Partial = {}, ) { + const assign = + sessionActions.assign ?? + (async ({ targetSessionId }: Parameters[0]) => ({ + turnId: `turn-${targetSessionId}`, + })); return new HostWorkHubCoordinationCoordinator({ stateRoot: root, stores: store, @@ -1550,10 +1632,10 @@ function coordinator( continuity: { refreshCanonical: async () => undefined }, executions, sessionActions: { - assign: async ({ targetSessionId }) => ({ turnId: `turn-${targetSessionId}` }), readDelegationRetirement: async () => 'not_retired', retireDelegation: async () => ({ outcome: 'cancelled_pending' }), ...sessionActions, + assign, }, resolveCreateTarget: resolveCreateTarget ?? @@ -1574,7 +1656,7 @@ async function persistTestAssignment( targetTurnId: string, ): Promise< { readonly turnId: string } & { - readonly committedAssignment?: { + readonly newAssignment?: { readonly sequence: number; readonly assignment: WorkHubDelegationAssignedMessage; }; @@ -1619,7 +1701,24 @@ async function persistTestAssignment( return { turnId: result.assignment.targetTurnId, ...(result.kind === 'assigned' - ? { committedAssignment: { sequence: result.sequence, assignment: result.assignment } } + ? { newAssignment: { sequence: result.sequence, assignment: result.assignment } } : {}), }; } + +function persistTestAssignmentAction( + store: SessionAuthorityStore, + targetTurnId: string | ((input: Parameters[0]) => string), +): HostWorkHubCoordinationCoordinatorOptions['sessionActions']['assign'] { + return async (input, _context, publishCommittedAssignment) => { + const result = await persistTestAssignment( + store, + input, + typeof targetTurnId === 'string' ? targetTurnId : targetTurnId(input), + ); + if (result.newAssignment) { + await publishCommittedAssignment(result.newAssignment); + } + return { turnId: result.turnId }; + }; +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 1188ecd735..64216b7e41 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1425,7 +1425,7 @@ export async function createExecutionRuntimeHostComposition( disposition satisfies never; throw new Error('Unhandled WorkHub Message retirement disposition'); }, - assign: async (input) => { + assign: async (input, _context, publishCommittedAssignment) => { const durable = await stores.sessionStore.readWorkHubAssignment(input.actionId); if (durable) { await messages.consumePendingAdmissions([durable.targetSessionId]); @@ -1447,9 +1447,6 @@ export async function createExecutionRuntimeHostComposition( .slice(0, 48); const messageId = `whm_${suffix}`; const content = normalizeMessageContent({ text: input.userText }); - let committedAssignment: - | { readonly sequence: number; readonly assignment: WorkHubDelegationAssignedMessage } - | undefined; const persisted = durable ?? (await sessionAdmission.runMany( @@ -1533,10 +1530,13 @@ export async function createExecutionRuntimeHostComposition( ...(supersession ? { supersession } : {}), }); if (result.kind === 'assigned') { - committedAssignment = { + // Publish the rebuildable Host index before this shared + // admission is released, so readers cannot observe the + // durable assignment without its active linkage. + await publishCommittedAssignment({ sequence: result.sequence, assignment: result.assignment, - }; + }); } // Keep the durable steering identity and its live queue owner // under one Session admission. A terminal transition must not @@ -1559,7 +1559,6 @@ export async function createExecutionRuntimeHostComposition( return { turnId: persisted.targetTurnId, ...(persisted.steered ? { steered: true as const } : {}), - ...(committedAssignment ? { committedAssignment } : {}), }; }, }, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 9b09815b20..d6447eb32c 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -133,11 +133,12 @@ export interface HostWorkHubCoordinationCoordinatorOptions { WorkHubActionGateEffects, 'readDelegationRetirement' | 'retireDelegation' > & { - assign(...args: Parameters): Promise< - Awaited> & { - readonly committedAssignment?: ActiveWorkHubAssignment; - } - >; + assign( + ...args: [ + ...Parameters, + publishCommittedAssignment: (assignment: ActiveWorkHubAssignment) => Promise, + ] + ): ReturnType; }; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; @@ -188,7 +189,8 @@ export class HostWorkHubCoordinationCoordinator { probeTargetRemoval: async (sessionId) => (await this.#stores.probeSessionRemoval(sessionId)).kind, readAssignment: (actionId) => this.#stores.readWorkHubAssignment(actionId), - listActiveAssignments: () => this.#listActiveAssignments(), + listActiveAssignments: () => + this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, () => this.#listActiveAssignments()), readReplacement: (delegationId) => this.#stores.readWorkHubReplacement(delegationId), readReplacementAbort: (delegationId) => this.#stores.readWorkHubReplacementAbort(delegationId), @@ -212,14 +214,9 @@ export class HostWorkHubCoordinationCoordinator { } }, assign: async (input, context) => { - const result = await options.sessionActions.assign(input, context); - if (result.committedAssignment) { - await this.#applyActiveMessage( - result.committedAssignment.sequence, - result.committedAssignment.assignment, - ); - } - return result; + return options.sessionActions.assign(input, context, (assignment) => + this.#applyActiveMessage(assignment.sequence, assignment.assignment), + ); }, prepareReplacement: (input) => this.#prepareReplacement(input), abortReplacement: (input) => this.#abortReplacement(input), @@ -482,7 +479,9 @@ export class HostWorkHubCoordinationCoordinator { try { const [result, activeAssignments] = await Promise.all([ this.#actionGate.candidates(), - this.#listActiveAssignmentRecords(), + this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, () => + this.#listActiveAssignmentRecords(), + ), ]); return { ok: true, From bc9e2469ff5fc79137cf84d0e37f9d9baac04aaa Mon Sep 17 00:00:00 2001 From: ChengBo Zhang Date: Fri, 4 Sep 2026 04:31:18 +0800 Subject: [PATCH 03/12] feat(workhub): read a resume request from trusted user text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resume asks the Host to carry on work an interruption left unfinished. It is admitted on the same terms as a stop — a direct speech act naming one existing Session — so it reuses the stop reader's rules: a question is not a command, a malformed literal is refused, and an anaphoric target carries the cue without claiming a target so the surface can ask which work rather than guess. English covers resume / continue / restart and Chinese 继续 / 恢复 / 接着跑 / 重新开始, the same colloquial range each language already has for stop. A cue alone resumes nothing: the reference must still resolve to a Session, which is what keeps `continue with the refactor` ordinary work. This is the reader only. The disposition that consumes it lands with the Gate admission in the same change. Generated-by: Claude Opus --- .../__tests__/workhub-creation-intent.test.ts | 38 ++++++++++++++++ packages/core/src/workhub-creation-intent.ts | 44 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/core/src/__tests__/workhub-creation-intent.test.ts b/packages/core/src/__tests__/workhub-creation-intent.test.ts index 04cc0f8bb6..0151cd8870 100644 --- a/packages/core/src/__tests__/workhub-creation-intent.test.ts +++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts @@ -1126,3 +1126,41 @@ test('a spoken Chinese stop is a stop, in the same range English already covers' assert.equal(readWorkHubRequestIntent(text).stop.cue, false, text); } }); + +test('a resume names one Session, and reads like a stop everywhere else', () => { + // Resume asks the Host to carry on work an interruption left unfinished, so + // it is admitted on the same terms as a stop: a direct speech act naming one + // existing Session, in either language. + for (const [text, target] of [ + ['Resume Payments', 'Payments'], + ['Continue Payments', 'Payments'], + ['Restart Payments', 'Payments'], + ['继续支付任务', '支付任务'], + ['恢复支付任务', '支付任务'], + ['请继续支付任务', '支付任务'], + ['接着跑支付任务', '支付任务'], + ] as const) { + assert.deepEqual( + readWorkHubRequestIntent(text).resume, + { cue: true, imperative: true, target }, + text, + ); + } + + // Anaphora carries the cue and claims no target, so the surface asks which + // work instead of guessing — the same shape `Stop it` produces. + for (const text of ['Resume it', '继续它']) { + assert.deepEqual(readWorkHubRequestIntent(text).resume, { cue: true, imperative: false }, text); + } + + // A question, a negation and an unterminated quote are not commands. + for (const text of ['Should I resume Payments?', 'Do not resume Payments', 'Resume "Payments']) { + assert.equal(readWorkHubRequestIntent(text).resume.cue, false, text); + } + + // Stop and resume are separate speech acts; neither reads as the other, and + // ordinary work is neither. + assert.equal(readWorkHubRequestIntent('Stop Payments').resume.cue, false); + assert.equal(readWorkHubRequestIntent('Resume Payments').stop.cue, false); + assert.equal(readWorkHubRequestIntent('Fix the login bug').resume.cue, false); +}); diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index f55679b311..427ee79e00 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -110,6 +110,15 @@ const DIRECT_STOP_REQUEST = // equivalent either. const DIRECT_CHINESE_STOP_REQUEST = /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:停止|停掉|停下|取消|终止|中止)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; +// Resume asks the Host to carry on work that was interrupted, so it reads the +// same shape as a stop: a direct speech act naming one existing Session. It is +// deliberately narrower than the everyday senses of these words — `continue` +// and `继续` also introduce ordinary instructions ("continue with the refactor"), +// which is why a resume that names nothing resolvable stays ordinary work. +const DIRECT_RESUME_REQUEST = + /^\s*(?:(?:please|kindly)\s+)?(?:resume|continue|restart)\s+(?:(?:the|this)\s+)?(?:(?:session|work|task|job)\s+)?(.+?)\s*[.!。!]?\s*$/iu; +const DIRECT_CHINESE_RESUME_REQUEST = + /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:继续|恢复|接着跑|重新开始)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; const UNSAFE_STOP_TARGET = /^(?:it|this|that|one|everything|all|current|session|work|task|job|(?:this|that|current)\s+(?:session|work|task|job)|它|这个|那个|全部|当前|会话|工作|任务|(?:这个|那个|当前)(?:会话|工作|任务))$/iu; @@ -151,6 +160,13 @@ export interface WorkHubRequestIntent { readonly imperative: boolean; readonly target?: string; }; + readonly resume: { + /** A direct resume speech act was present, but its target may still be unsafe. */ + readonly cue: boolean; + /** True only for a direct, explicitly named resume command. */ + readonly imperative: boolean; + readonly target?: string; + }; } /** @@ -317,6 +333,8 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { const correctionCue = hasWorkHubCorrectionCue(source); const existingTarget = affirmativeWorkHubExistingCorrectionTarget(source); const stopCue = directWorkHubStopCue(source, literalMask.malformed); + const resumeCue = directWorkHubResumeCue(source, literalMask.malformed); + const resumeTarget = resumeCue ? directWorkHubResumeTarget(source, false) : undefined; const stopTarget = stopCue ? directWorkHubStopTarget(source, false) : undefined; const actions = allMatches(masked, EXECUTION_ACTION); const execution: WorkHubExecutionIntent = @@ -339,6 +357,11 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { imperative: Boolean(stopTarget), ...(stopTarget ? { target: stopTarget } : {}), }, + resume: { + cue: resumeCue, + imperative: Boolean(resumeTarget), + ...(resumeTarget ? { target: resumeTarget } : {}), + }, }; } @@ -496,6 +519,27 @@ function directWorkHubStopCue(value: string, malformedLiteral: boolean): boolean return Boolean(DIRECT_STOP_REQUEST.test(value) || DIRECT_CHINESE_STOP_REQUEST.test(value)); } +/** + * Resume reuses the stop reader's rules: a question is not a command, a + * malformed literal is refused outright, and an anaphoric target — `it`, `它`, + * `这个工作` — reads the cue without claiming a target, so the surface can ask + * which work rather than guess at it. + */ +function directWorkHubResumeTarget(value: string, malformedLiteral: boolean): string | undefined { + if (malformedLiteral || /[??]\s*$/u.test(value)) return undefined; + const match = DIRECT_RESUME_REQUEST.exec(value) ?? DIRECT_CHINESE_RESUME_REQUEST.exec(value); + const rawTarget = match?.[1]?.trim(); + if (!rawTarget) return undefined; + const target = stripMatchingStopQuotes(rawTarget.replace(/[.!。!]+\s*$/u, '').trim()); + if (!target || UNSAFE_STOP_TARGET.test(target)) return undefined; + return target; +} + +function directWorkHubResumeCue(value: string, malformedLiteral: boolean): boolean { + if (malformedLiteral || /[??]\s*$/u.test(value)) return false; + return Boolean(DIRECT_RESUME_REQUEST.test(value) || DIRECT_CHINESE_RESUME_REQUEST.test(value)); +} + function stripMatchingStopQuotes(value: string): string { const pairs = new Map([ ['"', '"'], From 20e0fd54f7ac96ee820c16af18278ffdcc7be1b7 Mon Sep 17 00:00:00 2001 From: ChengBo Zhang Date: Fri, 4 Sep 2026 04:43:32 +0800 Subject: [PATCH 04/12] feat(workhub): admit a resume through the Action Gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop was the only way WorkHub could change delegated execution, so work an interruption left unfinished could only be restarted by leaving the conversation, opening the Session and using its own banner. Slice 5 asks for resume from the coordination transcript, and the capability to do it already exists. Resume composes the two operations that banner uses — ask the Host whether this Session has a continuation to make, then make it — and adds no recovery machinery of its own. A repeat is safe because the Host parks a continuation that already exists rather than forking a second one, so the Turn identity is derived from the delegation and the source run rather than minted, and two attempts name the same Turn. It goes through the Gate for the same reason every other disposition does: it changes execution state, and the Gate is where that is admitted. It carries no confirmation, because it destroys nothing and grants no authority a delegation did not already grant — it proves only that the words named one Session and that the Session still owns one link. `WorkHubActionOperation` gains `resume` so one action identity still means one operation. That is a durable vocabulary change with no migration: the column already exists, and only a Host that predates this value would refuse to read a claim carrying it. Refs #3492 Generated-by: Claude Opus --- .../main/__tests__/workhub-controller.test.ts | 8 + .../__tests__/workhub-surface-flow.test.ts | 8 + packages/core/src/session.ts | 7 +- .../workhub-coordination-action-gate.test.ts | 168 ++++++++++++++++++ .../workhub-coordination-coordinator.test.ts | 4 + packages/runtime-host/src/protocol/index.ts | 5 +- .../src/protocol/workhub-coordination.ts | 62 +++++++ .../src/server/execution-composition.ts | 40 ++++- .../workhub-coordination-action-gate.ts | 88 +++++++++ .../workhub-coordination-coordinator.ts | 3 +- .../src/sqlite-session-metadata-store.ts | 3 +- 11 files changed, 391 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 13b46ce35c..7ac7d4d3ea 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -193,6 +193,14 @@ function createWorkHubController({ sessions }: { sessions: TestSessionPort }) { targetSessionId: input.proposal.expects.targetSessionId, }; } + if (input.proposal.disposition === 'resume_work') { + return { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: input.proposal.expects.targetSessionId, + targetTurnId: 'resumed-turn', + }; + } const target = candidateByRef.get(input.proposal.candidateRef); if (!target) throw new Error('unknown test candidate'); const admitted = await sessions.submit(target.target, input.userText, input.actionId); diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts index 8d9166794e..e88af5b222 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -665,6 +665,14 @@ test('real Session projection creates new guide topics and preserves origin ambi targetSessionId: input.proposal.expects.targetSessionId, }; } + if (input.proposal.disposition === 'resume_work') { + return { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: input.proposal.expects.targetSessionId, + targetTurnId: 'resumed-turn', + }; + } const targetSessionId = input.proposal.candidateRef.replace(/^candidate-/u, ''); const admitted = await send(targetSessionId, { type: 'send', diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index dc03aea7be..a58c5b1d39 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1091,7 +1091,12 @@ export type WorkHubActionOperation = | 'delegate_existing' | 'create_new' | 'replace' - | 'stop'; + | 'stop' + // Resume claims like every other disposition, so one action identity still + // means one operation. A Host that predates this value refuses to read a + // claim carrying it, which is a downgrade hazard and not a wire one: the + // table is local, and the row only exists once a resume has been admitted. + | 'resume'; /** Durable global binding from one action identity to one exact operation. */ export interface WorkHubActionClaim { diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 5d79ca924e..3ee0ac9cc7 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -329,6 +329,160 @@ describe('WorkHub Coordination Action Gate', () => { expects: { targetSessionId }, }); + const resumeProposal = (targetSessionId: string) => ({ + disposition: 'resume_work' as const, + expects: { targetSessionId }, + }); + + const delegatedTo = (effects: ReturnType, sessionId: string) => { + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + targetSessionId: sessionId, + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix payment retry', + }, + 'source-turn', + ), + ); + }; + + test('resumes the one delegation the named Session owns', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'resume-action', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ); + + assert.deepEqual(result, { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: 'payments', + targetTurnId: 'resumed-turn', + }); + assert.equal(effects.resumeCalls.length, 1); + assert.equal(effects.resumeCalls[0]?.actionId, 'source-action'); + // Resume claims like every other disposition, so the identity is spent. + assert.equal(effects.actionClaims.get('resume-action')?.operation, 'resume'); + }); + + test('resume needs a named command and carries no destructive confirmation', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + const gate = new WorkHubCoordinationActionGate(effects); + + // Anaphora names nothing, so it never reaches the delegation. + await assert.rejects( + () => + gate.act( + { + actionId: 'resume-anaphora', + userText: 'Resume it', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ), + /explicit named command/u, + ); + // A question is not a command. + await assert.rejects( + () => + gate.act( + { + actionId: 'resume-question', + userText: 'Should I resume Payments?', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ), + /explicit named command/u, + ); + assert.equal(effects.resumeCalls.length, 0); + }); + + test('resume refuses a Session that does not own exactly one delegation', async () => { + const none = fakeEffects([session('payments', { name: 'Payments' })]); + await assert.rejects( + () => + new WorkHubCoordinationActionGate(none).act( + { + actionId: 'resume-none', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ), + /no active durable delegation to resume/u, + ); + + const several = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(several, 'payments'); + several.assignmentRecords.set( + 'second-action', + assignmentRecord( + { + actionId: 'second-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Also fix the receipts', + }, + 'second-turn', + ), + ); + await assert.rejects( + () => + new WorkHubCoordinationActionGate(several).act( + { + actionId: 'resume-many', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ), + /does not identify one active durable delegation/u, + ); + assert.equal(several.resumeCalls.length, 0); + }); + + test('resume reports the Host answer it was given, including a park', async () => { + for (const [outcome, targetTurnId] of [ + ['already_running', undefined], + ['parked', undefined], + ] as const) { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + effects.resumeOutcome = { outcome }; + + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: `resume-${outcome}`, + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ); + + assert.deepEqual(result, { + disposition: 'resume_work', + outcome, + targetSessionId: 'payments', + ...(targetTurnId ? { targetTurnId } : {}), + }); + } + }); + test('stops exactly one named durable delegation and replays its observed outcome', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); effects.assignmentRecords.set( @@ -2563,6 +2717,15 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { ? 'same_claim' : 'conflict'; }, + resumeCalls: [] as WorkHubDelegationAssignedMessage[], + resumeOutcome: { outcome: 'resume_started' as const, targetTurnId: 'resumed-turn' } as { + outcome: 'resume_started' | 'already_running' | 'parked'; + targetTurnId?: string; + }, + async resumeDelegation(assignment: WorkHubDelegationAssignedMessage) { + this.resumeCalls.push(assignment); + return this.resumeOutcome; + }, async readActionClaim(actionId: string) { return actionClaims.get(actionId); }, @@ -2757,6 +2920,11 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { stopRequests: Map; stopResolutions: Map; retirements: WorkHubDelegationAssignedMessage[]; + resumeCalls: WorkHubDelegationAssignedMessage[]; + resumeOutcome: { + outcome: 'resume_started' | 'already_running' | 'parked'; + targetTurnId?: string; + }; }; } diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index 2a23a6daa1..f9a4942762 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -1633,6 +1633,10 @@ function coordinator( executions, sessionActions: { readDelegationRetirement: async () => 'not_retired', + resumeDelegation: async () => ({ + outcome: 'resume_started' as const, + targetTurnId: 'resumed-turn', + }), retireDelegation: async () => ({ outcome: 'cancelled_pending' }), ...sessionActions, assign, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index ddd3d9fee4..efd451fd35 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 113 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 114 as const; +// 114: WorkHub Coordination admits a `resume_work` proposal and answers with a +// `resume_work` outcome. An older peer's closed decoder rejects both the +// disposition it does not know and the result kind it cannot read. // 113: WorkHub exposes current active delegation linkage in the existing Host // snapshot. Older peers disagree on the closed result shape. // 112: Owners can query the Host execution environment through an extensible, diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index c5d609d5ef..d356c438e7 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -156,6 +156,16 @@ export type WorkHubCoordinationProposal = * links, and on replay from the durable claim this action already owns. */ readonly expects: WorkHubCoordinationStopPreconditions; + } + | { + readonly disposition: 'resume_work'; + /** + * Resume names the Session it resolved and nothing else, for the same + * reason a stop does: which delegation is live is the Host's to know. + * The Gate revalidates it, so a resolution that has gone stale fails + * closed instead of restarting work the user never named. + */ + readonly expects: WorkHubCoordinationStopPreconditions; }; export interface WorkHubCoordinationStopPreconditions { @@ -212,6 +222,18 @@ export type WorkHubCoordinationActResult = readonly outcome: 'cancelled_pending' | 'stop_delivered' | 'already_terminal' | 'not_owned'; readonly targetSessionId: string; readonly targetTurnId?: string; + } + | { + readonly disposition: 'resume_work'; + /** + * `parked` is the Host declining to continue — the reason is its own and + * is not restated here, because nothing the client can do changes it. + * `already_running` means the work never stopped, which is an answer, not + * a failure. + */ + readonly outcome: 'resume_started' | 'already_running' | 'parked'; + readonly targetSessionId: string; + readonly targetTurnId?: string; }; export const WORKHUB_COORDINATION_OPERATION_SPECS = { @@ -539,6 +561,36 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord }), }; } + if (result.disposition === 'resume_work') { + const exact = requireShapedRecord( + result, + 'WorkHub Coordination resume result', + ['disposition', 'outcome', 'targetSessionId'], + ['targetTurnId'], + ); + if ( + exact.outcome !== 'resume_started' && + exact.outcome !== 'already_running' && + exact.outcome !== 'parked' + ) { + throw invalidProtocolFrame('Invalid WorkHub resume outcome'); + } + // Only a started continuation names a Turn: the Host has one to name, and + // the other two outcomes changed nothing that could carry an identity. + if ((exact.outcome === 'resume_started') !== (exact.targetTurnId !== undefined)) { + throw invalidProtocolFrame('Invalid WorkHub resume target Turn'); + } + return { + disposition: 'resume_work', + outcome: exact.outcome, + targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), + ...(exact.targetTurnId === undefined + ? {} + : { + targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), + }), + }; + } throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); } @@ -649,6 +701,16 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP expects: decodeWorkHubCoordinationStopPreconditions(exact.expects), }; } + if (proposal.disposition === 'resume_work') { + const exact = requireExactRecord(proposal, 'WorkHub resume proposal', [ + 'disposition', + 'expects', + ]); + return { + disposition: 'resume_work', + expects: decodeWorkHubCoordinationStopPreconditions(exact.expects), + }; + } throw invalidProtocolFrame('Invalid WorkHub Coordination proposal disposition'); } diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 64216b7e41..6a05f9646a 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -185,7 +185,10 @@ import type { TurnOperationHandlerMap } from './operation-dispatcher.js'; import { HostUsagePricingCoordinator } from './usage-pricing-coordinator.js'; import { HostWebSearchCoordinator } from './web-search-coordinator.js'; import { HostWorkHubCoordinationCoordinator } from './workhub-coordination-coordinator.js'; -import { WorkHubActionEffectFailure } from './workhub-coordination-action-gate.js'; +import { + WorkHubActionEffectFailure, + workHubResumedTurnId, +} from './workhub-coordination-action-gate.js'; type ExecutionConnectionRef = Parameters< RuntimePolicyStoresWriter['operations']['resolveExecutionConnection'] @@ -1394,6 +1397,41 @@ export async function createExecutionRuntimeHostComposition( const snapshot = await coordinator.read(identity); return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; }, + // Resume is the same two steps the Desktop banner takes: ask the Host + // whether this Session has a continuation to make, then make it. The + // Host owns both answers, so a repeat parks rather than forking, and + // WorkHub records only which of the three things happened. + resumeDelegation: async (assignment, context) => { + const plan = await coordinator.handlers['turn.resume.query']( + { sessionId: assignment.targetSessionId }, + context, + ); + if (!plan.ok) return { outcome: 'parked' as const }; + if (plan.result.disposition === 'parked') { + return { + outcome: + plan.result.reason === 'resume_candidate_missing' + ? ('already_running' as const) + : ('parked' as const), + }; + } + const started = await coordinator.handlers['turn.resume.start']( + { + sessionId: assignment.targetSessionId, + turnId: workHubResumedTurnId(assignment.delegationId, plan.result.sourceRunId), + sourceRunId: plan.result.sourceRunId, + sourceRuntimeEventHighWater: plan.result.sourceRuntimeEventHighWater, + }, + context, + ); + if (!started.ok || started.result.kind === 'parked') { + return { outcome: 'parked' as const }; + } + return { + outcome: 'resume_started' as const, + targetTurnId: started.result.turn.turnId, + }; + }, retireDelegation: async (assignment, retirement) => { const disposition = await messages.cancelMessageIfPending( assignment.targetSessionId, diff --git a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts index bea4056209..b5c17822db 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -141,6 +141,15 @@ export interface WorkHubActionGateEffects { assignment: WorkHubDelegationAssignedMessage, retirement: WorkHubDelegationRetirementClaim, ): Promise; + /** + * Ask the target Session to carry on the work this delegation left + * unfinished. The Host owns whether that is possible; a repeat is safe + * because a continuation that already exists parks rather than forks. + */ + resumeDelegation( + assignment: WorkHubDelegationAssignedMessage, + context: ConnectionContext, + ): Promise; } /** @@ -155,6 +164,11 @@ export interface WorkHubDelegationRetirementClaim { readonly cause: 'direct_stop' | 'replacement'; } +export interface WorkHubResumeResult { + readonly outcome: 'resume_started' | 'already_running' | 'parked'; + readonly targetTurnId?: string; +} + export interface WorkHubRetirementResult { readonly outcome: WorkHubDelegationStopOutcome | 'recovering'; readonly targetTurnId?: string; @@ -422,6 +436,29 @@ export class WorkHubCoordinationActionGate { }); return this.#stop(requested, source); } + if (proposal.disposition === 'resume_work') { + // Resume carries no confirmation. It starts work that was already + // delegated and already interrupted, so it destroys nothing and needs no + // authority a delegation did not already grant — only proof that the + // words asked for it and that the Session named still owns one link. + if (!requestIntent.resume.imperative) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume requires an explicit named command in trusted user text', + ); + } + const source = await this.#resumeSource(proposal.expects.targetSessionId); + const resumeFingerprint = resumeActionFingerprint(input, source); + await this.#claimAction(input.actionId, 'resume', resumeFingerprint, source.delegationId); + const resumed = await this.#effects.resumeDelegation(source, context); + return { + disposition: 'resume_work', + outcome: resumed.outcome, + targetSessionId: source.targetSessionId, + ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), + }; + } + if (proposal.disposition === 'create_new') { if (!input.create || !workHubCreationAuthorizesTitle(requestIntent, proposal.title)) { throw new WorkHubActionGateFailure( @@ -524,6 +561,29 @@ export class WorkHubCoordinationActionGate { ); } + /** + * The delegation a resume names. + * + * Unlike a stop this needs no claim to find its way back: resume changes no + * durable link, so the delegation it names is still in the active set on the + * next attempt exactly as it was on the first. One link on the Session is the + * answer; several is the same ambiguity a stop refuses, and none means there + * is nothing here to carry on. + */ + async #resumeSource(targetSessionId: string): Promise { + const active = await this.#effects.listActiveAssignments(); + const onTarget = active.filter((assignment) => assignment.targetSessionId === targetSessionId); + if (onTarget.length !== 1) { + throw new WorkHubActionGateFailure( + 'action_conflict', + onTarget.length === 0 + ? 'WorkHub has no active durable delegation to resume on that Session' + : 'WorkHub resume target does not identify one active durable delegation', + ); + } + return onTarget[0]!; + } + /** * The delegation a stop names, by the only two keys that can name it. * @@ -1048,6 +1108,17 @@ function workHubCreatedSessionId(actionId: string): string { return `whs_${hash(`create\0${actionId}`).slice(0, 48)}`; } +/** + * The continuation identity a resume would start, derived rather than minted. + * + * Two attempts at the same interrupted run must name the same Turn, or the + * second would ask the Host to start a second continuation instead of finding + * the first already there. + */ +export function workHubResumedTurnId(delegationId: string, sourceRunId: string): string { + return `wht_${hash(`resume\0${delegationId}\0${sourceRunId}`).slice(0, 48)}`; +} + function workspaceProjection(session: WorkHubActionGateSession): WorkspaceProjection { return { target: @@ -1119,6 +1190,23 @@ function replacementActionFingerprint( }); } +function resumeActionFingerprint( + input: WorkHubCoordinationActInput, + source: WorkHubDelegationAssignedMessage, +): `sha256:${string}` { + if (input.proposal.disposition !== 'resume_work') { + throw new WorkHubActionGateFailure('action_conflict', 'Invalid WorkHub resume replay'); + } + return digest({ + userText: input.userText, + disposition: 'resume_work', + resumesActionId: source.actionId, + resumesDelegationId: source.delegationId, + targetSessionId: source.targetSessionId, + targetMessageId: source.targetMessageId, + }); +} + function stopActionFingerprint( input: WorkHubCoordinationActInput, source: WorkHubDelegationAssignedMessage, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index d6447eb32c..ccba77c62b 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -131,7 +131,7 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly executions: CoordinationExecutions; readonly sessionActions: Pick< WorkHubActionGateEffects, - 'readDelegationRetirement' | 'retireDelegation' + 'readDelegationRetirement' | 'retireDelegation' | 'resumeDelegation' > & { assign( ...args: [ @@ -224,6 +224,7 @@ export class HostWorkHubCoordinationCoordinator { resolveStop: (input) => this.#resolveStop(input), readDelegationRetirement: options.sessionActions.readDelegationRetirement, retireDelegation: options.sessionActions.retireDelegation, + resumeDelegation: options.sessionActions.resumeDelegation, }); } diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 9f14732f0f..94240f46a8 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -7076,7 +7076,8 @@ function isWorkHubActionOperation(value: unknown): value is WorkHubActionOperati value === 'delegate_existing' || value === 'create_new' || value === 'replace' || - value === 'stop' + value === 'stop' || + value === 'resume' ); } From 28644dba94542167e95cefe27272f16ae51be605 Mon Sep 17 00:00:00 2001 From: ChengBo Zhang Date: Fri, 4 Sep 2026 10:41:42 +0800 Subject: [PATCH 05/12] feat(workhub): resume delegated work from the coordination conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Host could resume interrupted work, but only its own Session could ask: the user had to leave WorkHub, open the Session and use its banner. This wires the conversation to the disposition the Gate already admits. Stop and resume ask the same question of the same words — which visible Session does this reference name — so they now ask it with one matcher. Two copies would be two chances for `Resume Payments` and `Stop Payments` to disagree about which Session they mean. They diverge on what an unnamed reference means, and that asymmetry is the point. A stop must answer it: stopping is destructive, so `Stop it` has to be met with a question rather than a guess. A resume must not: `继续这个工作` is how someone carries on with the Session they are already in, and answering it would take an ordinary instruction away from ordinary routing. Resuming nothing costs nothing, so an unnamed resume falls through and only a named one becomes an action. The three answers the Host can give are all reported as themselves. `already_running` and `parked` are outcomes, not failures: the first says the work never stopped, and the second is the Host declining, whose reason is its own. Refs #3492 Generated-by: Claude Opus --- .../main/__tests__/workhub-controller.test.ts | 126 ++++++++++++++++++ .../src/renderer/workhub-controller.ts | 60 +++++++++ .../src/renderer/workhub-route-policy.ts | 124 ++++++++++++----- apps/desktop/src/renderer/workhub-surface.tsx | 25 +++- 4 files changed, 299 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 7ac7d4d3ea..b693268ebd 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -428,6 +428,132 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou await handle.close(); }); +test('a named resume submits and reports what the Host did', async () => { + const sessions = port([session('payments', { sessionName: 'Payments' })]); + const actions: WorkHubCoordinationActInput[] = []; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([], []); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('a resume must not read route candidates'), + act: async (input) => { + actions.push(input); + return { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: 'payments', + targetTurnId: 'resumed-turn', + }; + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + const result = await controller.submit({ requestId: 'resume-1', text: 'Resume Payments' }); + + assert.deepEqual(result, { + kind: 'resume', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'resume-1', + target: { sessionId: 'payments' }, + outcome: 'resume_started', + targetTurnId: 'resumed-turn', + }); + // The proposal names the Session and carries no confirmation: resume ends + // nothing, so it needs no authority a delegation did not already grant. + assert.deepEqual(actions, [{ + actionId: 'resume-1', + userText: 'Resume Payments', + proposal: { disposition: 'resume_work', expects: { targetSessionId: 'payments' } }, + }]); + await handle.close(); +}); + +test('a resume the Host will not admit becomes its clarification', async () => { + const sessions = port([session('payments', { sessionName: 'Payments' })]); + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([], []); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('a resume must not read route candidates'), + act: async () => { + throw new WorkHubCoordinationFailure( + 'operation_conflict', + 'WorkHub has no active durable delegation to resume on that Session', + ); + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + assert.deepEqual(await controller.submit({ requestId: 'resume-2', text: 'Resume Payments' }), { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'resume-2', + text: 'Resume Payments', + options: [], + reason: 'resume_target_unavailable', + }); + await handle.close(); +}); + +test('resume-shaped ordinary work routes normally instead of resuming', async () => { + // `Continue` is an ordinary English verb. A resume that recalls no WorkHub + // identity is work to do, not a command over a delegation. + const sessions = port([session('payments', { sessionName: 'Payments' })]); + const actions: WorkHubCoordinationActInput[] = []; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([], []); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'c'.repeat(64)}`, + candidates: [{ + candidateRef: 'ref-payments', + sessionId: 'payments', + sessionName: 'Payments', + workspace: { + target: { kind: 'host_path' as const, path: '/workspace/payments' }, + hostCwd: '/workspace/payments', + }, + state: 'active' as const, + updatedAt: 1, + }], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'delegate_existing', + targetSessionId: 'payments', + targetTurnId: 'delegated-turn', + }; + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + const result = await controller.submit({ + requestId: 'ordinary-1', + text: 'Continue the refactor in Payments', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(actions[0]?.proposal.disposition, 'delegate_existing'); + await handle.close(); +}); + test('a named stop reports the Gate refusal instead of judging the target itself', async () => { // The renderer no longer decides whether a Session can be stopped, so it // submits and lets the Gate answer. Its refusal is the clarification, which diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index f900300483..d4142c8a55 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -215,6 +215,13 @@ export type WorkHubSubmission = ( outcome: Extract['outcome']; targetTurnId?: string; } + | { + kind: 'resume'; + requestId: string; + target: WorkHubSessionTarget; + outcome: Extract['outcome']; + targetTurnId?: string; + } ) & { strategyId: WorkHubRoutingStrategyId }; /** @@ -490,6 +497,59 @@ export function createWorkHubController(deps: { const sessions = await deps.sessions.list(); reconcileFocus(submissionPolicy, sessions); const ordinary = sessions.filter((session) => session.kind === 'ordinary'); + const resumeDecision = submissionPolicy.resolveResume({ + text: input.text, + sessions: ordinary, + }); + if (resumeDecision.kind !== 'not_requested') { + if (resumeDecision.kind === 'clarification') { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: resumeDecision.reason, + }; + } + const { target } = resumeDecision; + let resumed; + try { + resumed = await coordination.act({ + actionId: input.requestId, + userText: input.text, + // No confirmation: resume ends nothing. Which delegation it carries + // on, and whether the Host can, is decided where the stop is. + proposal: { + disposition: 'resume_work', + expects: { targetSessionId: target.sessionId }, + }, + }); + } catch (error) { + if (error instanceof WorkHubCoordinationFailure && error.code === 'operation_conflict') { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: 'resume_target_unavailable', + }; + } + throw error; + } + if (resumed.disposition !== 'resume_work') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } + return { + kind: 'resume', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + outcome: resumed.outcome, + ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), + }; + } const stopDecision = submissionPolicy.resolveStop({ text: input.text, sessions: ordinary, diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index af2960c94f..f6929338d2 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -79,7 +79,11 @@ export type WorkHubStopClarificationReason = /** The stop names more than one existing Session. */ | 'stop_target_ambiguous' /** The Host refused the stop; its conflict is the whole answer. */ - | 'stop_target_unavailable'; + | 'stop_target_unavailable' + /** The resume names more than one existing Session. */ + | 'resume_target_ambiguous' + /** The Host refused the resume; its conflict is the whole answer. */ + | 'resume_target_unavailable'; /** * A stop clarification never offers route options. Choosing one re-sends the @@ -96,6 +100,15 @@ export interface WorkHubRoutePolicy { text: string; sessions: WorkHubRoutableSession[]; }): WorkHubStopRouteDecision; + /** + * Resume reads the same way a stop does and refuses on the same terms. The + * two share one resolver and one tail rule so `Resume Payments` and + * `Stop Payments` cannot disagree about which Session they mean. + */ + resolveResume(input: { + text: string; + sessions: WorkHubRoutableSession[]; + }): WorkHubStopRouteDecision; resolve(input: { text: string; sessions: WorkHubRoutableSession[]; @@ -132,6 +145,67 @@ const MIN_STRONG_SINGLE_LATIN_LENGTH = 8; const MAX_UNCERTAINTY_OPTIONS = 5; const MAX_RELATED_CLARIFICATION_OPTIONS = 4; +/** + * The reference half of a direct action over one existing delegation. + * + * Action Intent says only that the user issued this imperative and what work it + * refers to; the shared Session Resolver recalls which visible Sessions that + * reference names; this decides whether the resolution is sufficient to submit. + * + * Stop and resume ask exactly this, so they share it. What the Host then does + * with the named Session — end its delegation, or carry it on — is the Host's, + * and neither action claims to know whether there is one to act on. + */ +function resolveNamedDelegationAction( + sessionResolver: WorkHubSessionResolver, + action: { readonly cue: boolean; readonly imperative: boolean; readonly target?: string }, + sessions: WorkHubRoutableSession[], + reasons: { + /** + * What an unnamed reference means. A stop must say so: it is destructive, + * and `Stop it` has to be answered rather than delivered as work. A resume + * must not: `继续这个工作` is how a user carries on with the Session they + * are already in, and answering it would take an ordinary instruction away + * from ordinary routing. Resuming nothing costs nothing, so it falls + * through and only an explicitly named resume becomes an action. + */ + readonly unnamed: WorkHubStopClarificationReason | 'not_requested'; + readonly ambiguous: WorkHubStopClarificationReason; + }, +): WorkHubStopRouteDecision { + if (!action.cue) return { kind: 'not_requested' }; + const reference = action.imperative ? action.target : undefined; + if (!reference) { + return reasons.unnamed === 'not_requested' + ? { kind: 'not_requested' } + : { kind: 'clarification', reason: reasons.unnamed }; + } + const sessionByRef = new Map(sessions.map((session) => [session.target.sessionId, session])); + const resolution = sessionResolver.resolve({ + reference: { text: reference }, + sessions: sessions.map(resolverSession), + }); + if (resolution.kind === 'none') return { kind: 'not_requested' }; + // The tail rule. The Resolver reports what the reference said after the name; + // one of these commands may add punctuation and nothing else, so + // `Stop Payments and Login` names no target here even though `Payments` + // matched. + const admissible = resolution.candidates.filter( + ({ evidence }) => + evidence.kind === 'elided_name_punctuation' || + /^[.!?。!?]*$/u.test(evidence.remainder), + ); + if (admissible.length === 0) return { kind: 'not_requested' }; + // One candidate only. A ranked resolver may return several; neither action + // picks a winner from a ranking it cannot justify. + if (resolution.kind === 'ambiguous' || admissible.length > 1) { + return { kind: 'clarification', reason: reasons.ambiguous }; + } + const resolved = sessionByRef.get(admissible[0]!.ref); + if (!resolved) return { kind: 'not_requested' }; + return { kind: 'target', target: resolved.target }; +} + /** * Deep routing module for R2.4. * @@ -163,41 +237,23 @@ function createWorkHubRoutePolicyVisit( // reference still fails closed, and a resolved Session that is not uniquely // stoppable says why. resolveStop({ text, sessions }) { - const intent = readWorkHubRequestIntent(text); - if (!intent.stop.cue) return { kind: 'not_requested' }; - const reference = intent.stop.imperative ? intent.stop.target : undefined; - if (!reference) { - return { kind: 'clarification', reason: 'stop_target_required' }; - } - const sessionByRef = new Map( - sessions.map((session) => [session.target.sessionId, session]), + return resolveNamedDelegationAction( + sessionResolver, + readWorkHubRequestIntent(text).stop, + sessions, + { unnamed: 'stop_target_required', ambiguous: 'stop_target_ambiguous' }, ); - const resolution = sessionResolver.resolve({ - reference: { text: reference }, - sessions: sessions.map(resolverSession), - }); - if (resolution.kind === 'none') return { kind: 'not_requested' }; - // Stop's own tail rule. The Resolver reports what the reference said - // after the name; a destructive command may add punctuation and nothing - // else, so `Stop Payments and Login` names no stoppable target here even - // though `Payments` matched. - const admissible = resolution.candidates.filter( - ({ evidence }) => - evidence.kind === 'elided_name_punctuation' || - /^[.!?。!?]*$/u.test(evidence.remainder), + }, + // Resume asks the same question of the same words, so it asks it with the + // same code. Two copies of this would be two chances for `Resume Payments` + // and `Stop Payments` to disagree about which Session they name. + resolveResume({ text, sessions }) { + return resolveNamedDelegationAction( + sessionResolver, + readWorkHubRequestIntent(text).resume, + sessions, + { unnamed: 'not_requested', ambiguous: 'resume_target_ambiguous' }, ); - if (admissible.length === 0) return { kind: 'not_requested' }; - // Stop admits one candidate only. A ranked resolver may return several; - // this action never picks a winner from a ranking it cannot justify. - if (resolution.kind === 'ambiguous' || admissible.length > 1) { - return { kind: 'clarification', reason: 'stop_target_ambiguous' }; - } - const resolved = sessionByRef.get(admissible[0]!.ref); - if (!resolved) return { kind: 'not_requested' }; - // The reference resolved, which is everything this policy can prove. - // Which delegation to end, and whether there is one at all, is the - // Host's answer and is made under the lease that performs the stop. - return { kind: 'target', target: resolved.target }; }, resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { const intent = readWorkHubRequestIntent(text); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 8a7cf93ad2..99526ebef3 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -176,7 +176,8 @@ export async function submitAndRecordWorkHubSurfaceInput(input: { result.kind === 'discussion' || result.kind === 'waiting' || result.kind === 'submitted' || - result.kind === 'stop' + result.kind === 'stop' || + result.kind === 'resume' ) { return result; } @@ -320,7 +321,8 @@ export function WorkHubSurface(props: { ? { ...turn, state: 'settled', outcome: result } : turn, )); - if (result.kind === 'submitted' || result.kind === 'stop') await refresh(); + if (result.kind === 'submitted' || result.kind === 'stop' || result.kind === 'resume') + await refresh(); return result; } catch (error) { if (isTerminalWorkHubSurfaceFailure(error)) { @@ -639,6 +641,8 @@ function workHubClarificationPrompt( if (reason === 'stop_target_required') return copy.stopTargetRequired; if (reason === 'stop_target_ambiguous') return copy.stopTargetAmbiguous; if (reason === 'stop_target_unavailable') return copy.stopTargetUnavailable; + if (reason === 'resume_target_ambiguous') return copy.resumeTargetAmbiguous; + if (reason === 'resume_target_unavailable') return copy.resumeTargetUnavailable; return undefined; } @@ -660,6 +664,7 @@ export function workHubCoordinationSummary( return `${copy.waitingForDecision} ${copy.requestNotSent}`; } if (result.kind === 'stop') return copy.stopOutcomes[result.outcome]; + if (result.kind === 'resume') return copy.resumeOutcomes[result.outcome]; const target = projection.sessions.find( (session) => session.target.sessionId === result.target.sessionId, ); @@ -856,6 +861,13 @@ function workHubCopy(locale: UiLocale) { already_terminal: '这项工作已经结束:', not_owned: '未停止共享或用户拥有的 Turn:', }, + resumeOutcomes: { + resume_started: '已让中断的工作继续:', + already_running: '这项工作还在跑,不需要恢复:', + parked: '无法继续这项工作;请打开该 Session 查看原因:', + }, + resumeTargetAmbiguous: '这个名称对应多项工作;请打开具体的 Session 继续它。', + resumeTargetUnavailable: '这项工作现在没有可以继续的单个 WorkHub 委派;请打开该 Session 查看。', waitingForDecision: '这项工作正在等待你的决定。', requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', @@ -980,6 +992,15 @@ function workHubCopy(locale: UiLocale) { already_terminal: 'This work had already ended:', not_owned: 'Did not stop a shared or user-owned Turn:', }, + resumeOutcomes: { + resume_started: 'Carried on the interrupted work:', + already_running: 'This work is still running, so there was nothing to resume:', + parked: 'Could not carry this work on. Open its Session to see why:', + }, + resumeTargetAmbiguous: + 'That name matches more than one work item. Open the exact Session to resume it.', + resumeTargetUnavailable: + 'This work has no single WorkHub delegation to resume right now. Open its Session to see what is running.', waitingForDecision: 'This work is waiting for your decision.', requestNotSent: 'The new request was not sent. Resolve the interaction in its Session, then send again.', routing: 'Choosing the right Session…', loadFailed: 'Could not read existing work.', From d2a293556416af3ff1bd40005c8093cbdbc1a40a Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 00:52:09 +0800 Subject: [PATCH 06/12] fix(workhub): make resume durable and lineage-bound --- .../__tests__/workhub-session-port.test.ts | 36 ++++ .../src/renderer/workhub-controller.ts | 5 + .../src/renderer/workhub-coordination-port.ts | 23 +++ apps/desktop/src/renderer/workhub-surface.tsx | 41 +++++ .../workhub-coordination-record.test.ts | 62 +++++++ packages/core/src/session.ts | 149 +++++++++++++++- packages/core/src/workhub-creation-intent.ts | 13 ++ .../workhub-coordination-action-gate.test.ts | 162 +++++++++++++++++- .../workhub-coordination-coordinator.test.ts | 111 ++++++++++++ .../src/server/execution-composition.ts | 93 ++++++++-- .../workhub-coordination-action-gate.ts | 138 ++++++++++++++- .../workhub-coordination-coordinator.ts | 109 +++++++++++- .../sqlite-session-metadata-store.test.ts | 8 + packages/storage/src/execution-stores.ts | 4 + packages/storage/src/session-store.ts | 31 ++++ .../src/sqlite-session-metadata-schema.ts | 40 ++++- 16 files changed, 990 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index 1ed3a71590..becde6b3fb 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -317,6 +317,42 @@ test('direct-stop projection is retryable until resolved and preserves not_owned ); }); +test('resume projection survives reload and exposes its durable outcome', () => { + const requested: StoredMessage = { + type: 'workhub_coordination', id: 'resume-request', turnId: 'resume-action', ts: 2, + schemaVersion: 4, kind: 'delegation_resume_requested', actionId: 'resume-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'resume-action', + resumesActionId: 'source-action', resumesDelegationId: 'payments-delegation', + targetSessionId: 'payments', targetMessageId: 'payments-message', + targetSessionName: 'Payments', userText: 'Resume Payments', plan: 'ready', + sourceTurnId: 'failed-turn', sourceRunId: 'failed-run', + sourceRuntimeEventHighWater: 4, targetTurnId: 'resumed-turn', + }; + const resolved: StoredMessage = { + type: 'workhub_coordination', id: 'resume-resolution', turnId: 'resume-action', ts: 3, + schemaVersion: 4, kind: 'delegation_resume_resolved', actionId: 'resume-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'resume-action', + resumesActionId: 'source-action', resumesDelegationId: 'payments-delegation', + targetSessionId: 'payments', outcome: 'resume_started', + targetTurnId: 'resumed-turn', targetRunId: 'resumed-run', + }; + + assert.deepEqual(projectWorkHubCoordinationTurns([requested]), [{ + messageId: 'resume-request', turnId: 'resume-action', text: 'Resume Payments', + state: 'running', + resume: { targetSessionId: 'payments', targetSessionName: 'Payments' }, + updatedAt: 2, + }]); + assert.deepEqual(projectWorkHubCoordinationTurns([requested, resolved]), [{ + messageId: 'resume-request', turnId: 'resume-action', text: 'Resume Payments', + state: 'completed', + resume: { + targetSessionId: 'payments', targetSessionName: 'Payments', outcome: 'resume_started', + }, + updatedAt: 3, + }]); +}); + test('durable supersession terminalizes only the replaced linkage', () => { const source: StoredMessage = { type: 'workhub_coordination', diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index d4142c8a55..d2377a9f51 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -132,6 +132,11 @@ export interface WorkHubCoordinationTurn { readonly targetSessionName: string; readonly outcome?: Extract['outcome']; }; + resume?: { + readonly targetSessionId: string; + readonly targetSessionName: string; + readonly outcome?: Extract['outcome']; + }; updatedAt: number; } diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 3659435811..0433d568ec 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -151,12 +151,35 @@ export function projectWorkHubCoordinationTurns( : [], ), ); + const resumeResolutionByActionId = new Map( + messages.flatMap((message) => + message.type === 'workhub_coordination' && message.kind === 'delegation_resume_resolved' + ? [[message.actionId, message] as const] + : [], + ), + ); for (const message of messages) { const terminal = terminalDelegationLink(message); if (terminal) terminalLinkState.set(terminal.delegationId, terminal.state); } for (const message of messages) { + if (message.type === 'workhub_coordination' && message.kind === 'delegation_resume_requested') { + const resolution = resumeResolutionByActionId.get(message.actionId); + turns.push({ + messageId: message.id, + turnId: message.coordinationTurnId, + text: boundedWorkHubTimelineText(message.userText), + state: resolution ? 'completed' : 'running', + resume: { + targetSessionId: message.targetSessionId, + targetSessionName: message.targetSessionName, + ...(resolution ? { outcome: resolution.outcome } : {}), + }, + updatedAt: resolution ? Math.max(message.ts, resolution.ts) : message.ts, + }); + continue; + } if (message.type === 'workhub_coordination' && message.kind === 'delegation_stop_requested') { const resolution = stopResolutionByDelegationId.get(message.stopsDelegationId); turns.push({ diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 99526ebef3..6ffdcf9c2a 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -575,6 +575,11 @@ export function WorkHubCoordinationTurnView(props: { (candidate) => candidate.target.sessionId === props.turn.stop!.targetSessionId, ) : undefined; + const resumedSession = props.turn.resume + ? props.projection.sessions.find( + (candidate) => candidate.target.sessionId === props.turn.resume!.targetSessionId, + ) + : undefined; return ( + ) : props.turn.resume ? ( + ) : assignment ? ( session.target.sessionId === submitted.target.sessionId) : undefined; @@ -744,6 +763,18 @@ function WorkHubTurnView(props: { copy={copy} onOpenSession={props.onOpenSession} /> + ) : resumed ? ( + session.target.sessionId === resumed.target.sessionId, + )} + targetSessionId={resumed.target.sessionId} + heading={copy.resumeOutcomes[resumed.outcome]} + state={copy.resumeRecorded} + result={undefined} + copy={copy} + onOpenSession={props.onOpenSession} + /> ) : submitted ? ( { }, ); }); + + test('decodes exact resume plan and observed resolution records', () => { + const requested = { + type: 'workhub_coordination', + id: 'resume-request-id', + turnId: 'resume-action', + ts: 6, + schemaVersion: 4, + kind: 'delegation_resume_requested', + actionId: 'resume-action', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'resume-action', + resumesActionId: 'original-action', + resumesDelegationId: 'original-delegation', + targetSessionId: 'payments', + targetMessageId: 'payments-message', + targetSessionName: 'Payments', + userText: 'Resume Payments', + plan: 'ready', + sourceTurnId: 'failed-turn', + sourceRunId: 'failed-run', + sourceRuntimeEventHighWater: 12, + targetTurnId: 'resumed-turn', + } as const; + const resolved = { + type: 'workhub_coordination', + id: 'resume-resolution-id', + turnId: 'resume-action', + ts: 7, + schemaVersion: 4, + kind: 'delegation_resume_resolved', + actionId: 'resume-action', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'resume-action', + resumesActionId: 'original-action', + resumesDelegationId: 'original-delegation', + targetSessionId: 'payments', + outcome: 'resume_started', + targetTurnId: 'resumed-turn', + targetRunId: 'resumed-run', + } as const; + + assert.deepEqual(decodeCanonicalMessage(requested), requested); + assert.deepEqual(decodeCanonicalMessage(resolved), resolved); + for (const invalid of [ + { ...requested, sourceRunId: undefined }, + { ...requested, sourceRuntimeEventHighWater: -1 }, + { ...requested, plan: 'parked' }, + { ...resolved, targetRunId: undefined }, + { ...resolved, outcome: 'parked' }, + { ...resolved, sourceRunId: 'injected' }, + ]) { + assert.throws(() => decodeCanonicalMessage(invalid), /Invalid stored message schema/u); + } + const parked = { + ...resolved, + outcome: 'parked' as const, + targetTurnId: undefined, + targetRunId: undefined, + }; + assert.deepEqual(decodeCanonicalMessage(parked), parked); + }); }); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index a58c5b1d39..26817df577 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -941,6 +941,7 @@ export interface TurnStateMessage { export const WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION = 1 as const; export const WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION = 2 as const; export const WORKHUB_COORDINATION_STOP_SCHEMA_VERSION = 3 as const; +export const WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION = 4 as const; export type WorkHubDelegationDisposition = 'delegate_existing' | 'create_new'; @@ -1077,6 +1078,51 @@ export interface WorkHubDelegationStopResolvedMessage { targetTurnId?: string; } +export type WorkHubDelegationResumeOutcome = 'resume_started' | 'already_running' | 'parked'; + +/** Durable resume plan written before attempting a continuation. */ +export interface WorkHubDelegationResumeRequestedMessage { + type: 'workhub_coordination'; + id: string; + turnId: string; + ts: number; + schemaVersion: typeof WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION; + kind: 'delegation_resume_requested'; + actionId: string; + actionFingerprint: `sha256:${string}`; + coordinationTurnId: string; + resumesActionId: string; + resumesDelegationId: string; + targetSessionId: string; + targetMessageId: string; + targetSessionName: string; + userText: string; + plan: 'ready' | 'already_running' | 'parked'; + sourceTurnId?: string; + sourceRunId?: string; + sourceRuntimeEventHighWater?: number; + targetTurnId?: string; +} + +/** Durable observed result of a resume attempt. */ +export interface WorkHubDelegationResumeResolvedMessage { + type: 'workhub_coordination'; + id: string; + turnId: string; + ts: number; + schemaVersion: typeof WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION; + kind: 'delegation_resume_resolved'; + actionId: string; + actionFingerprint: `sha256:${string}`; + coordinationTurnId: string; + resumesActionId: string; + resumesDelegationId: string; + targetSessionId: string; + outcome: WorkHubDelegationResumeOutcome; + targetTurnId?: string; + targetRunId?: string; +} + /** * The exact durable operation one WorkHub action identity is allowed to own. * @@ -1115,7 +1161,9 @@ export type WorkHubCoordinationMessage = | WorkHubDelegationReplacementAbortedMessage | WorkHubDelegationSupersededMessage | WorkHubDelegationStopRequestedMessage - | WorkHubDelegationStopResolvedMessage; + | WorkHubDelegationStopResolvedMessage + | WorkHubDelegationResumeRequestedMessage + | WorkHubDelegationResumeResolvedMessage; function isWorkHubDelegationStopResolution( outcome: unknown, @@ -1385,6 +1433,47 @@ const WORKHUB_DELEGATION_STOP_RESOLVED_MESSAGE_SHAPE = ], ['targetTurnId'], ); +const WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE = + defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'resumesActionId', + 'resumesDelegationId', + 'targetSessionId', + 'targetMessageId', + 'targetSessionName', + 'userText', + 'plan', + ], + ['sourceTurnId', 'sourceRunId', 'sourceRuntimeEventHighWater', 'targetTurnId'], + ); +const WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE = + defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'resumesActionId', + 'resumesDelegationId', + 'targetSessionId', + 'outcome', + ], + ['targetTurnId', 'targetRunId'], + ); const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( ['title', 'workspace'], [], @@ -1575,6 +1664,64 @@ function decodeMessage( } function isWorkHubCoordinationMessage(message: Record): boolean { + if (message.kind === 'delegation_resume_requested') { + const ready = message.plan === 'ready'; + return ( + hasMessageEnvelope(message, true) && + hasExactShape(message, WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE) && + message.schemaVersion === WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION && + isWorkHubActionIdentity(message) && + typeof message.resumesActionId === 'string' && + message.resumesActionId.length > 0 && + typeof message.resumesDelegationId === 'string' && + message.resumesDelegationId.length > 0 && + typeof message.targetSessionId === 'string' && + message.targetSessionId.length > 0 && + typeof message.targetMessageId === 'string' && + message.targetMessageId.length > 0 && + typeof message.targetSessionName === 'string' && + message.targetSessionName.trim().length > 0 && + typeof message.userText === 'string' && + message.userText.trim().length > 0 && + (ready || message.plan === 'already_running' || message.plan === 'parked') && + (ready + ? typeof message.sourceTurnId === 'string' && + message.sourceTurnId.length > 0 && + typeof message.sourceRunId === 'string' && + message.sourceRunId.length > 0 && + typeof message.sourceRuntimeEventHighWater === 'number' && + Number.isSafeInteger(message.sourceRuntimeEventHighWater) && + message.sourceRuntimeEventHighWater >= 0 && + typeof message.targetTurnId === 'string' && + message.targetTurnId.length > 0 + : message.sourceTurnId === undefined && + message.sourceRunId === undefined && + message.sourceRuntimeEventHighWater === undefined && + message.targetTurnId === undefined) + ); + } + if (message.kind === 'delegation_resume_resolved') { + const started = message.outcome === 'resume_started'; + return ( + hasMessageEnvelope(message, true) && + hasExactShape(message, WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE) && + message.schemaVersion === WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION && + isWorkHubActionIdentity(message) && + typeof message.resumesActionId === 'string' && + message.resumesActionId.length > 0 && + typeof message.resumesDelegationId === 'string' && + message.resumesDelegationId.length > 0 && + typeof message.targetSessionId === 'string' && + message.targetSessionId.length > 0 && + (started || message.outcome === 'already_running' || message.outcome === 'parked') && + (started + ? typeof message.targetTurnId === 'string' && + message.targetTurnId.length > 0 && + typeof message.targetRunId === 'string' && + message.targetRunId.length > 0 + : message.targetTurnId === undefined && message.targetRunId === undefined) + ); + } if (message.kind === 'delegation_stop_requested') { return ( hasMessageEnvelope(message, true) && diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index 427ee79e00..cad6aeb79c 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -473,6 +473,19 @@ export function matchWorkHubSessionName( return { kind: 'named', remainder: normalizedTarget.slice(matchedName.length).trim() }; } +/** Whether a direct stop/resume reference names exactly this Session. */ +export function workHubNamedDelegationActionTargetsSession( + action: { readonly imperative: boolean; readonly target?: string }, + sessionName: string, +): boolean { + if (!action.imperative || !action.target) return false; + const match = matchWorkHubSessionName(action.target, sessionName); + return ( + match.kind === 'elided_name_punctuation' || + (match.kind === 'named' && /^[.!?。!?]*$/u.test(match.remainder)) + ); +} + /** * The correction policy's tail rule. A correction may name its target and then * say what to do with it, but a withdrawal anywhere in the reference retracts diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 3ee0ac9cc7..b4793bd5d4 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -25,6 +25,8 @@ import type { WorkHubDelegationAssignedMessage, WorkHubDelegationReplacementAbortedMessage, WorkHubDelegationReplacementRequestedMessage, + WorkHubDelegationResumeRequestedMessage, + WorkHubDelegationResumeResolvedMessage, WorkHubDelegationStopRequestedMessage, WorkHubDelegationStopResolvedMessage, WorkHubDelegationSupersededMessage, @@ -39,10 +41,13 @@ import { type WorkHubDelegationAssignmentInput, type WorkHubDelegationReplacementAbortInput, type WorkHubDelegationReplacementInput, + type WorkHubDelegationResumeInput, + type WorkHubDelegationResumeResolutionInput, type WorkHubDelegationRetirementClaim, type WorkHubDelegationStopInput, type WorkHubDelegationStopResolutionInput, type WorkHubRetirementResult, + type WorkHubResumePlan, } from '../server/workhub-coordination-action-gate.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; @@ -371,11 +376,55 @@ describe('WorkHub Coordination Action Gate', () => { targetTurnId: 'resumed-turn', }); assert.equal(effects.resumeCalls.length, 1); - assert.equal(effects.resumeCalls[0]?.actionId, 'source-action'); + assert.equal(effects.resumeCalls[0]?.resumesActionId, 'source-action'); // Resume claims like every other disposition, so the identity is spent. assert.equal(effects.actionClaims.get('resume-action')?.operation, 'resume'); }); + test('resume binds the trusted named target to the proposed Session', async () => { + const effects = fakeEffects([ + session('payments', { name: 'Payments' }), + session('login', { name: 'Login' }), + ]); + delegatedTo(effects, 'payments'); + + await assert.rejects( + () => + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'resume-wrong-target', + userText: 'Resume Login', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.resumeCalls.length, 0); + }); + + test('resume replays one durable result without planning or starting twice', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + const gate = new WorkHubCoordinationActionGate(effects); + const input = { + actionId: 'resume-replay', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }; + + const first = await gate.act(input, CONTEXT); + effects.resumeOutcome = { outcome: 'parked' }; + effects.resumePlan = { kind: 'parked' }; + const replay = await gate.act(input, CONTEXT); + + assert.deepEqual(replay, first); + assert.equal(effects.planResumeCalls.length, 1); + assert.equal(effects.resumeCalls.length, 1); + assert.equal(effects.resumeRequests.size, 1); + assert.equal(effects.resumeResolutions.size, 1); + }); + test('resume needs a named command and carries no destructive confirmation', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); delegatedTo(effects, 'payments'); @@ -2682,6 +2731,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { const supersessions = new Map(); const stopRequests = new Map(); const stopResolutions = new Map(); + const resumeRequests = new Map(); + const resumeResolutions = new Map(); const actionClaims = new Map(); return { sessions: [...initialSessions], @@ -2700,6 +2751,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { supersessions, stopRequests, stopResolutions, + resumeRequests, + resumeResolutions, retirements: [] as WorkHubDelegationAssignedMessage[], retirementClaims: [] as WorkHubDelegationRetirementClaim[], async listSessions() { @@ -2717,15 +2770,104 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { ? 'same_claim' : 'conflict'; }, - resumeCalls: [] as WorkHubDelegationAssignedMessage[], - resumeOutcome: { outcome: 'resume_started' as const, targetTurnId: 'resumed-turn' } as { + resumeCalls: [] as WorkHubDelegationResumeRequestedMessage[], + planResumeCalls: [] as Array<{ + assignment: WorkHubDelegationAssignedMessage; + previous: WorkHubDelegationResumeResolvedMessage | undefined; + }>, + resumePlan: { + kind: 'ready', + sourceTurnId: 'source-turn', + sourceRunId: 'source-run', + sourceRuntimeEventHighWater: 1, + targetTurnId: 'resumed-turn', + } as WorkHubResumePlan, + resumeOutcome: { + outcome: 'resume_started' as const, + targetTurnId: 'resumed-turn', + targetRunId: 'resumed-run', + } as { outcome: 'resume_started' | 'already_running' | 'parked'; targetTurnId?: string; + targetRunId?: string; + }, + async readResumeRequest(actionId: string) { + return resumeRequests.get(actionId); + }, + async readResumeResolution(actionId: string) { + return resumeResolutions.get(actionId); + }, + async listResumeResolutions(delegationId: string) { + return [...resumeResolutions.values()].filter( + (resolution) => resolution.resumesDelegationId === delegationId, + ); + }, + async planResume( + assignment: WorkHubDelegationAssignedMessage, + previous: WorkHubDelegationResumeResolvedMessage | undefined, + ) { + this.planResumeCalls.push({ assignment, previous }); + return this.resumePlan; }, - async resumeDelegation(assignment: WorkHubDelegationAssignedMessage) { - this.resumeCalls.push(assignment); + async prepareResume(input: WorkHubDelegationResumeInput) { + const existing = resumeRequests.get(input.actionId); + if (existing) return existing; + const requested: WorkHubDelegationResumeRequestedMessage = { + type: 'workhub_coordination', + id: `resume-${input.actionId}`, + turnId: input.actionId, + ts: 7, + schemaVersion: 4, + kind: 'delegation_resume_requested', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + resumesActionId: input.resumesActionId, + resumesDelegationId: input.resumesDelegationId, + targetSessionId: input.targetSessionId, + targetMessageId: input.targetMessageId, + targetSessionName: input.targetSessionName, + userText: input.userText, + plan: input.plan.kind, + ...(input.plan.kind === 'ready' + ? { + sourceTurnId: input.plan.sourceTurnId, + sourceRunId: input.plan.sourceRunId, + sourceRuntimeEventHighWater: input.plan.sourceRuntimeEventHighWater, + targetTurnId: input.plan.targetTurnId, + } + : {}), + }; + resumeRequests.set(input.actionId, requested); + return requested; + }, + async resumeDelegation(request: WorkHubDelegationResumeRequestedMessage) { + this.resumeCalls.push(request); return this.resumeOutcome; }, + async resolveResume(input: WorkHubDelegationResumeResolutionInput) { + const existing = resumeResolutions.get(input.request.actionId); + if (existing) return existing; + const resolved: WorkHubDelegationResumeResolvedMessage = { + type: 'workhub_coordination', + id: `resume-resolved-${input.request.actionId}`, + turnId: input.request.actionId, + ts: 8, + schemaVersion: 4, + kind: 'delegation_resume_resolved', + actionId: input.request.actionId, + actionFingerprint: input.request.actionFingerprint, + coordinationTurnId: input.request.coordinationTurnId, + resumesActionId: input.request.resumesActionId, + resumesDelegationId: input.request.resumesDelegationId, + targetSessionId: input.request.targetSessionId, + outcome: input.outcome, + ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}), + ...(input.targetRunId ? { targetRunId: input.targetRunId } : {}), + }; + resumeResolutions.set(input.request.actionId, resolved); + return resolved; + }, async readActionClaim(actionId: string) { return actionClaims.get(actionId); }, @@ -2919,11 +3061,19 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { supersessions: Map; stopRequests: Map; stopResolutions: Map; + resumeRequests: Map; + resumeResolutions: Map; retirements: WorkHubDelegationAssignedMessage[]; - resumeCalls: WorkHubDelegationAssignedMessage[]; + resumeCalls: WorkHubDelegationResumeRequestedMessage[]; + planResumeCalls: Array<{ + assignment: WorkHubDelegationAssignedMessage; + previous: WorkHubDelegationResumeResolvedMessage | undefined; + }>; + resumePlan: WorkHubResumePlan; resumeOutcome: { outcome: 'resume_started' | 'already_running' | 'parked'; targetTurnId?: string; + targetRunId?: string; }; }; } diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index f9a4942762..8629be898a 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -858,6 +858,109 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('persists a resume plan and result before replaying after Host restart', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-resume-')); + let store = createSessionStore(root); + let targetId = ''; + const resumeInput = () => ({ + actionId: 'resume-action', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work' as const, + expects: { targetSessionId: targetId }, + }, + }); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + targetId = target.id; + let resumeCalls = 0; + const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + assign: persistTestAssignmentAction(store, 'payments-turn'), + planResume: async () => ({ + kind: 'ready', + sourceTurnId: 'payments-turn', + sourceRunId: 'payments-run', + sourceRuntimeEventHighWater: 9, + targetTurnId: 'resumed-turn', + }), + resumeDelegation: async () => { + resumeCalls += 1; + return { + outcome: 'resume_started', + targetTurnId: 'resumed-turn', + targetRunId: 'resumed-run', + }; + }, + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef }, + }, + CONTEXT, + ) + ).ok, + true, + ); + + const resumed = await workhub.handlers['workhub.coordination.act'](resumeInput(), CONTEXT); + assert.deepEqual(resumed, { + ok: true, + result: { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: target.id, + targetTurnId: 'resumed-turn', + }, + }); + assert.equal(resumeCalls, 1); + assert.equal( + (await store.readWorkHubResumeRequest('resume-action'))?.sourceRunId, + 'payments-run', + ); + assert.equal( + (await store.readWorkHubResumeResolution('resume-action'))?.targetRunId, + 'resumed-run', + ); + } finally { + await store.close?.(); + } + + store = createSessionStore(root); + try { + const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + planResume: async () => assert.fail('durable resume replay must not replan'), + resumeDelegation: async () => assert.fail('durable resume replay must not restart'), + }); + const replay = await restarted.handlers['workhub.coordination.act'](resumeInput(), CONTEXT); + assert.equal(replay.ok, true); + if (replay.ok && replay.result.disposition === 'resume_work') { + assert.equal(replay.result.outcome, 'resume_started'); + assert.equal(replay.result.targetTurnId, 'resumed-turn'); + } + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('rechecks sole-delegation stop preconditions after the advisory active-link read', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-race-')); const store = createSessionStore(root); @@ -1633,9 +1736,17 @@ function coordinator( executions, sessionActions: { readDelegationRetirement: async () => 'not_retired', + planResume: async () => ({ + kind: 'ready' as const, + sourceTurnId: 'source-turn', + sourceRunId: 'source-run', + sourceRuntimeEventHighWater: 1, + targetTurnId: 'resumed-turn', + }), resumeDelegation: async () => ({ outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', + targetRunId: 'resumed-run', }), retireDelegation: async () => ({ outcome: 'cancelled_pending' }), ...sessionActions, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 6a05f9646a..014bfdbb13 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1397,30 +1397,84 @@ export async function createExecutionRuntimeHostComposition( const snapshot = await coordinator.read(identity); return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; }, - // Resume is the same two steps the Desktop banner takes: ask the Host - // whether this Session has a continuation to make, then make it. The - // Host owns both answers, so a repeat parks rather than forking, and - // WorkHub records only which of the three things happened. - resumeDelegation: async (assignment, context) => { + // Resolve only the execution lineage owned by this delegation. A + // Session-wide latest-failure query could otherwise continue unrelated + // work started directly in the same Session. + planResume: async (assignment, previous, context) => { + let source: + | { readonly sessionId: string; readonly turnId: string; readonly runId: string } + | undefined; + if ( + previous?.outcome === 'resume_started' && + previous.targetTurnId && + previous.targetRunId + ) { + source = { + sessionId: assignment.targetSessionId, + turnId: previous.targetTurnId, + runId: previous.targetRunId, + }; + } else { + const disposition = await messages.readMessageExecutionDisposition( + assignment.targetSessionId, + assignment.targetMessageId, + ); + if (disposition.kind !== 'owned_root') return { kind: 'parked' as const }; + source = { + sessionId: assignment.targetSessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }; + } + let snapshot; + try { + snapshot = await coordinator.read(source); + } catch { + return { kind: 'parked' as const }; + } + if ( + snapshot.status === 'admitted' || + snapshot.status === 'created' || + snapshot.status === 'running' + ) { + return { kind: 'already_running' as const }; + } + if (snapshot.status !== 'failed' && snapshot.status !== 'cancelled') { + return { kind: 'parked' as const }; + } const plan = await coordinator.handlers['turn.resume.query']( - { sessionId: assignment.targetSessionId }, + { sessionId: assignment.targetSessionId, sourceRunId: source.runId }, context, ); - if (!plan.ok) return { outcome: 'parked' as const }; - if (plan.result.disposition === 'parked') { - return { - outcome: - plan.result.reason === 'resume_candidate_missing' - ? ('already_running' as const) - : ('parked' as const), - }; - } + if ( + !plan.ok || + plan.result.disposition === 'parked' || + plan.result.sourceRunId !== source.runId || + plan.result.sourceTurnId !== source.turnId + ) + return { kind: 'parked' as const }; + return { + kind: 'ready' as const, + sourceTurnId: plan.result.sourceTurnId, + sourceRunId: plan.result.sourceRunId, + sourceRuntimeEventHighWater: plan.result.sourceRuntimeEventHighWater, + targetTurnId: workHubResumedTurnId(assignment.delegationId, plan.result.sourceRunId), + }; + }, + resumeDelegation: async (request, context) => { + if ( + request.plan !== 'ready' || + !request.sourceRunId || + request.sourceRuntimeEventHighWater === undefined || + !request.targetTurnId + ) + return { outcome: 'parked' as const }; const started = await coordinator.handlers['turn.resume.start']( { - sessionId: assignment.targetSessionId, - turnId: workHubResumedTurnId(assignment.delegationId, plan.result.sourceRunId), - sourceRunId: plan.result.sourceRunId, - sourceRuntimeEventHighWater: plan.result.sourceRuntimeEventHighWater, + sessionId: request.targetSessionId, + turnId: request.targetTurnId, + sourceRunId: request.sourceRunId, + sourceRuntimeEventHighWater: request.sourceRuntimeEventHighWater, }, context, ); @@ -1430,6 +1484,7 @@ export async function createExecutionRuntimeHostComposition( return { outcome: 'resume_started' as const, targetTurnId: started.result.turn.turnId, + targetRunId: started.result.turn.runId, }; }, retireDelegation: async (assignment, retirement) => { diff --git a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts index b5c17822db..5069d992c8 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -32,6 +32,8 @@ import type { WorkHubDelegationStopRequestedMessage, WorkHubDelegationStopResolvedMessage, WorkHubDelegationStopOutcome, + WorkHubDelegationResumeRequestedMessage, + WorkHubDelegationResumeResolvedMessage, WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { @@ -42,6 +44,7 @@ import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, workHubCreationAuthorizesTitle, + workHubNamedDelegationActionTargetsSession, } from '@maka/core/workhub-creation-intent'; import type { WorkHubCoordinationActInput, @@ -111,6 +114,13 @@ export interface WorkHubActionGateEffects { readStopResolution( delegationId: string, ): Promise; + readResumeRequest(actionId: string): Promise; + readResumeResolution( + actionId: string, + ): Promise; + listResumeResolutions( + delegationId: string, + ): Promise; answer( input: { readonly turnId: string; readonly text: string }, context: ConnectionContext, @@ -146,10 +156,21 @@ export interface WorkHubActionGateEffects { * unfinished. The Host owns whether that is possible; a repeat is safe * because a continuation that already exists parks rather than forks. */ - resumeDelegation( + planResume( assignment: WorkHubDelegationAssignedMessage, + previous: WorkHubDelegationResumeResolvedMessage | undefined, + context: ConnectionContext, + ): Promise; + prepareResume( + input: WorkHubDelegationResumeInput, + ): Promise; + resumeDelegation( + request: WorkHubDelegationResumeRequestedMessage, context: ConnectionContext, ): Promise; + resolveResume( + input: WorkHubDelegationResumeResolutionInput, + ): Promise; } /** @@ -167,6 +188,36 @@ export interface WorkHubDelegationRetirementClaim { export interface WorkHubResumeResult { readonly outcome: 'resume_started' | 'already_running' | 'parked'; readonly targetTurnId?: string; + readonly targetRunId?: string; +} + +export type WorkHubResumePlan = + | { readonly kind: 'already_running' | 'parked' } + | { + readonly kind: 'ready'; + readonly sourceTurnId: string; + readonly sourceRunId: string; + readonly sourceRuntimeEventHighWater: number; + readonly targetTurnId: string; + }; + +export interface WorkHubDelegationResumeInput { + readonly actionId: string; + readonly actionFingerprint: `sha256:${string}`; + readonly resumesActionId: string; + readonly resumesDelegationId: string; + readonly targetSessionId: string; + readonly targetMessageId: string; + readonly targetSessionName: string; + readonly userText: string; + readonly plan: WorkHubResumePlan; +} + +export interface WorkHubDelegationResumeResolutionInput { + readonly request: WorkHubDelegationResumeRequestedMessage; + readonly outcome: 'resume_started' | 'already_running' | 'parked'; + readonly targetTurnId?: string; + readonly targetRunId?: string; } export interface WorkHubRetirementResult { @@ -447,16 +498,59 @@ export class WorkHubCoordinationActionGate { 'WorkHub resume requires an explicit named command in trusted user text', ); } + const replay = await this.#effects.readResumeRequest(input.actionId); + if (replay) { + if ( + replay.userText !== input.userText || + replay.targetSessionId !== proposal.expects.targetSessionId || + !workHubNamedDelegationActionTargetsSession( + requestIntent.resume, + replay.targetSessionName, + ) + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume identity belongs to a different request', + ); + } + await this.#claimAction( + input.actionId, + 'resume', + replay.actionFingerprint, + replay.resumesDelegationId, + ); + return this.#resume(replay, context); + } const source = await this.#resumeSource(proposal.expects.targetSessionId); + const sessions = await this.#effects.listSessions(); + const currentTargetName = sessions.find(({ id }) => id === source.targetSessionId)?.name; + if ( + !currentTargetName || + !workHubNamedDelegationActionTargetsSession(requestIntent.resume, currentTargetName) + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume target is not affirmed in trusted user text', + ); + } const resumeFingerprint = resumeActionFingerprint(input, source); await this.#claimAction(input.actionId, 'resume', resumeFingerprint, source.delegationId); - const resumed = await this.#effects.resumeDelegation(source, context); - return { - disposition: 'resume_work', - outcome: resumed.outcome, + const previous = (await this.#effects.listResumeResolutions(source.delegationId)) + .filter(({ outcome }) => outcome === 'resume_started') + .at(-1); + const plan = await this.#effects.planResume(source, previous, context); + const request = await this.#effects.prepareResume({ + actionId: input.actionId, + actionFingerprint: resumeFingerprint, + resumesActionId: source.actionId, + resumesDelegationId: source.delegationId, targetSessionId: source.targetSessionId, - ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), - }; + targetMessageId: source.targetMessageId, + targetSessionName: currentTargetName, + userText: input.userText, + plan, + }); + return this.#resume(request, context); } if (proposal.disposition === 'create_new') { @@ -584,6 +678,25 @@ export class WorkHubCoordinationActionGate { return onTarget[0]!; } + async #resume( + request: WorkHubDelegationResumeRequestedMessage, + context: ConnectionContext, + ): Promise> { + const existing = await this.#effects.readResumeResolution(request.actionId); + if (existing) return resumeResult(existing); + const resumed: WorkHubResumeResult = + request.plan === 'ready' + ? await this.#effects.resumeDelegation(request, context) + : { outcome: request.plan }; + const resolution = await this.#effects.resolveResume({ + request, + outcome: resumed.outcome, + ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), + ...(resumed.targetRunId ? { targetRunId: resumed.targetRunId } : {}), + }); + return resumeResult(resolution); + } + /** * The delegation a stop names, by the only two keys that can name it. * @@ -1256,6 +1369,17 @@ function stopResult( }; } +function resumeResult( + resolution: WorkHubDelegationResumeResolvedMessage, +): Extract { + return { + disposition: 'resume_work', + outcome: resolution.outcome, + targetSessionId: resolution.targetSessionId, + ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), + }; +} + function stopResultFromRecord( resolution: WorkHubDelegationStopResolvedMessage, request: WorkHubDelegationStopRequestedMessage, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index ccba77c62b..233a48bb0f 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -28,6 +28,7 @@ import { WORKHUB_COORDINATION_SESSION_ROLE, WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, WORKHUB_COORDINATION_STOP_SCHEMA_VERSION, + WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, isWorkHubCoordinationSession, isWorkHubCoordinationSessionId, type SessionHeader, @@ -37,6 +38,8 @@ import { type WorkHubDelegationReplacementRequestedMessage, type WorkHubDelegationStopRequestedMessage, type WorkHubDelegationStopResolvedMessage, + type WorkHubDelegationResumeRequestedMessage, + type WorkHubDelegationResumeResolvedMessage, } from '@maka/core/session'; import { isSessionNotFoundError, @@ -110,6 +113,8 @@ type CoordinationStores = Pick< | 'readWorkHubSupersession' | 'readWorkHubStopRequest' | 'readWorkHubStopResolution' + | 'readWorkHubResumeRequest' + | 'readWorkHubResumeResolution' | 'readTranscriptHighWaterSnapshot' | 'readTranscriptMessagesSnapshot' | 'readTranscriptRecordsSnapshot' @@ -131,7 +136,7 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly executions: CoordinationExecutions; readonly sessionActions: Pick< WorkHubActionGateEffects, - 'readDelegationRetirement' | 'retireDelegation' | 'resumeDelegation' + 'readDelegationRetirement' | 'retireDelegation' | 'planResume' | 'resumeDelegation' > & { assign( ...args: [ @@ -197,6 +202,9 @@ export class HostWorkHubCoordinationCoordinator { readSupersession: (delegationId) => this.#stores.readWorkHubSupersession(delegationId), readStopRequest: (delegationId) => this.#stores.readWorkHubStopRequest(delegationId), readStopResolution: (delegationId) => this.#stores.readWorkHubStopResolution(delegationId), + readResumeRequest: (actionId) => this.#stores.readWorkHubResumeRequest(actionId), + readResumeResolution: (actionId) => this.#stores.readWorkHubResumeResolution(actionId), + listResumeResolutions: (delegationId) => this.#listResumeResolutions(delegationId), answer: async (input, context) => { const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); if (!outcome.ok) { @@ -222,8 +230,11 @@ export class HostWorkHubCoordinationCoordinator { abortReplacement: (input) => this.#abortReplacement(input), prepareStop: (input) => this.#prepareStop(input), resolveStop: (input) => this.#resolveStop(input), + prepareResume: (input) => this.#prepareResume(input), + resolveResume: (input) => this.#resolveResume(input), readDelegationRetirement: options.sessionActions.readDelegationRetirement, retireDelegation: options.sessionActions.retireDelegation, + planResume: options.sessionActions.planResume, resumeDelegation: options.sessionActions.resumeDelegation, }); } @@ -397,6 +408,102 @@ export class HostWorkHubCoordinationCoordinator { }); } + #prepareResume( + input: Parameters[0], + ): Promise { + const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); + return this.#commitCoordinationFact({ + admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.targetSessionId], + read: () => this.#stores.readWorkHubResumeRequest(input.actionId), + build: (existing) => ({ + type: 'workhub_coordination', + id: `whr_${suffix}`, + turnId: input.actionId, + ts: existing?.ts ?? Date.now(), + schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, + kind: 'delegation_resume_requested', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + resumesActionId: input.resumesActionId, + resumesDelegationId: input.resumesDelegationId, + targetSessionId: input.targetSessionId, + targetMessageId: input.targetMessageId, + targetSessionName: input.targetSessionName, + userText: input.userText, + plan: input.plan.kind, + ...(input.plan.kind === 'ready' + ? { + sourceTurnId: input.plan.sourceTurnId, + sourceRunId: input.plan.sourceRunId, + sourceRuntimeEventHighWater: input.plan.sourceRuntimeEventHighWater, + targetTurnId: input.plan.targetTurnId, + } + : {}), + }), + conflictMessage: 'WorkHub resume identity belongs to a different plan', + beforeAppend: async () => { + const source = (await this.#listActiveAssignments()).find( + ({ actionId, delegationId }) => + actionId === input.resumesActionId && delegationId === input.resumesDelegationId, + ); + if (!source || source.targetSessionId !== input.targetSessionId) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume source is no longer active', + ); + } + }, + unknownOutcomeMessage: 'WorkHub resume plan outcome is unknown', + }); + } + + #resolveResume( + input: Parameters[0], + ): Promise { + const request = input.request; + const suffix = createHash('sha256').update(request.actionId, 'utf8').digest('hex').slice(0, 48); + return this.#commitCoordinationFact({ + read: () => this.#stores.readWorkHubResumeResolution(request.actionId), + build: (existing) => ({ + type: 'workhub_coordination', + id: `whn_${suffix}`, + turnId: request.actionId, + ts: existing?.ts ?? Date.now(), + schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, + kind: 'delegation_resume_resolved', + actionId: request.actionId, + actionFingerprint: request.actionFingerprint, + coordinationTurnId: request.coordinationTurnId, + resumesActionId: request.resumesActionId, + resumesDelegationId: request.resumesDelegationId, + targetSessionId: request.targetSessionId, + outcome: input.outcome, + ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}), + ...(input.targetRunId ? { targetRunId: input.targetRunId } : {}), + }), + conflictMessage: 'WorkHub resume already has a different resolution', + beforeAppend: async () => { + const durable = await this.#stores.readWorkHubResumeRequest(request.actionId); + if (!durable || !isDeepStrictEqual(durable, request)) { + throw new WorkHubActionGateFailure('action_conflict', 'WorkHub resume plan changed'); + } + }, + unknownOutcomeMessage: 'WorkHub resume resolution outcome is unknown', + }); + } + + async #listResumeResolutions( + delegationId: string, + ): Promise { + return (await this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).filter( + (message): message is WorkHubDelegationResumeResolvedMessage => + message.type === 'workhub_coordination' && + message.kind === 'delegation_resume_resolved' && + message.resumesDelegationId === delegationId, + ); + } + #abortReplacement( input: Parameters[0], ): Promise { diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 906c5d6ace..4a809c22d2 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -1513,10 +1513,17 @@ describe('SqliteSessionMetadataStore', () => { actionFingerprint: `sha256:${'a'.repeat(64)}` as const, subject: 'whd_payments', }; + const resumeClaim = { + actionId: 'resume-action', + operation: 'resume' as const, + actionFingerprint: `sha256:${'b'.repeat(64)}` as const, + subject: 'whd_payments', + }; let store = createSqliteSessionMetadataStore(path); try { assert.equal(await store.claimWorkHubAction(stopClaim), 'claimed'); assert.equal(await store.claimWorkHubAction(stopClaim), 'same_claim'); + assert.equal(await store.claimWorkHubAction(resumeClaim), 'claimed'); } finally { store.close(); } @@ -1524,6 +1531,7 @@ describe('SqliteSessionMetadataStore', () => { store = createSqliteSessionMetadataStore(path); try { assert.deepEqual(await store.readWorkHubActionClaim('stop-action'), stopClaim); + assert.deepEqual(await store.readWorkHubActionClaim('resume-action'), resumeClaim); assert.equal(await store.claimWorkHubAction(stopClaim), 'same_claim'); // A second delegation, a second disposition, and a changed payload are // each a different operation for the same identity. diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 29e0679225..b485657f77 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -380,6 +380,10 @@ async function createExecutionStoresForWrite sessionStore.readWorkHubStopRequest(delegationId)), readWorkHubStopResolution: (delegationId) => run(() => sessionStore.readWorkHubStopResolution(delegationId)), + readWorkHubResumeRequest: (actionId) => + run(() => sessionStore.readWorkHubResumeRequest(actionId)), + readWorkHubResumeResolution: (actionId) => + run(() => sessionStore.readWorkHubResumeResolution(actionId)), claimWorkHubAction: (claim) => run(() => sessionStore.claimWorkHubAction(claim)), readWorkHubActionClaim: (actionId) => run(() => sessionStore.readWorkHubActionClaim(actionId)), diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 2defabf72c..6f3451dc91 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -90,6 +90,8 @@ import { type WorkHubActionClaimOutcome, type WorkHubDelegationStopRequestedMessage, type WorkHubDelegationStopResolvedMessage, + type WorkHubDelegationResumeRequestedMessage, + type WorkHubDelegationResumeResolvedMessage, type WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import type { @@ -441,6 +443,12 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto readWorkHubStopResolution( delegationId: string, ): Promise; + readWorkHubResumeRequest( + actionId: string, + ): Promise; + readWorkHubResumeResolution( + actionId: string, + ): Promise; /** * Durably binds one action identity to one exact WorkHub operation before its * effect. Survives removal of the target Session so a committed destructive @@ -757,6 +765,29 @@ class SqliteSessionStore implements SessionAuthorityStore { : undefined; } + async readWorkHubResumeRequest( + actionId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whr_${workHubIdentitySuffix(actionId)}`, + ); + return message?.type === 'workhub_coordination' && + message.kind === 'delegation_resume_requested' + ? message + : undefined; + } + + async readWorkHubResumeResolution( + actionId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whn_${workHubIdentitySuffix(actionId)}`, + ); + return message?.type === 'workhub_coordination' && message.kind === 'delegation_resume_resolved' + ? message + : undefined; + } + async claimWorkHubAction(claim: WorkHubActionClaim): Promise { await this.ensureReady(); return this.metadata.claimWorkHubAction(claim); diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 525934c7de..af375aaa44 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 38; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 39; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -1268,6 +1268,44 @@ const MIGRATIONS: ReadonlyMap = new Map([ ); `, ], + [ + 39, + ` + -- SQLite cannot widen a CHECK constraint in place. Rebuild the action + -- claim table so resume receives the same durable one-action/one-operation + -- ownership as every other WorkHub action. + ALTER TABLE workhub_action_claims RENAME TO workhub_action_claims_v38; + + CREATE TABLE workhub_action_claims ( + action_id TEXT PRIMARY KEY, + operation TEXT NOT NULL CHECK ( + operation IN ( + 'answer_here', 'clarify', 'delegate_existing', 'create_new', 'replace', 'stop', 'resume' + ) + ), + action_fingerprint TEXT NOT NULL, + subject TEXT NOT NULL, + claimed_at INTEGER NOT NULL CHECK (claimed_at >= 0) + ); + + INSERT INTO workhub_action_claims( + action_id, + operation, + action_fingerprint, + subject, + claimed_at + ) + SELECT + action_id, + operation, + action_fingerprint, + subject, + claimed_at + FROM workhub_action_claims_v38; + + DROP TABLE workhub_action_claims_v38; + `, + ], ]); if (MIGRATIONS.size !== SQLITE_SESSION_METADATA_SCHEMA_VERSION) { From d25b51c1f24276525d6b44d45153312e3858ee60 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 10:53:29 +0800 Subject: [PATCH 07/12] refactor(workhub): deepen resume coordination boundary --- .../workhub-coordination-action-gate.test.ts | 145 +++++------- .../workhub-coordination-action-gate.ts | 119 ++-------- .../workhub-coordination-coordinator.ts | 215 +++++++++++------- 3 files changed, 210 insertions(+), 269 deletions(-) diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index b4793bd5d4..25d02a429a 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -42,12 +42,10 @@ import { type WorkHubDelegationReplacementAbortInput, type WorkHubDelegationReplacementInput, type WorkHubDelegationResumeInput, - type WorkHubDelegationResumeResolutionInput, type WorkHubDelegationRetirementClaim, type WorkHubDelegationStopInput, type WorkHubDelegationStopResolutionInput, type WorkHubRetirementResult, - type WorkHubResumePlan, } from '../server/workhub-coordination-action-gate.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; @@ -376,7 +374,9 @@ describe('WorkHub Coordination Action Gate', () => { targetTurnId: 'resumed-turn', }); assert.equal(effects.resumeCalls.length, 1); - assert.equal(effects.resumeCalls[0]?.resumesActionId, 'source-action'); + const resumeCall = effects.resumeCalls[0]; + assert.ok(resumeCall && !('request' in resumeCall)); + assert.equal(resumeCall.source.actionId, 'source-action'); // Resume claims like every other disposition, so the identity is spent. assert.equal(effects.actionClaims.get('resume-action')?.operation, 'resume'); }); @@ -403,7 +403,7 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.resumeCalls.length, 0); }); - test('resume replays one durable result without planning or starting twice', async () => { + test('resume replays the durable request through the same deep effect', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); delegatedTo(effects, 'payments'); const gate = new WorkHubCoordinationActionGate(effects); @@ -415,12 +415,11 @@ describe('WorkHub Coordination Action Gate', () => { const first = await gate.act(input, CONTEXT); effects.resumeOutcome = { outcome: 'parked' }; - effects.resumePlan = { kind: 'parked' }; - const replay = await gate.act(input, CONTEXT); + const replay = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); assert.deepEqual(replay, first); - assert.equal(effects.planResumeCalls.length, 1); - assert.equal(effects.resumeCalls.length, 1); + assert.equal(effects.resumeCalls.length, 2); + assert.ok('request' in effects.resumeCalls[1]!); assert.equal(effects.resumeRequests.size, 1); assert.equal(effects.resumeResolutions.size, 1); }); @@ -2770,18 +2769,7 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { ? 'same_claim' : 'conflict'; }, - resumeCalls: [] as WorkHubDelegationResumeRequestedMessage[], - planResumeCalls: [] as Array<{ - assignment: WorkHubDelegationAssignedMessage; - previous: WorkHubDelegationResumeResolvedMessage | undefined; - }>, - resumePlan: { - kind: 'ready', - sourceTurnId: 'source-turn', - sourceRunId: 'source-run', - sourceRuntimeEventHighWater: 1, - targetTurnId: 'resumed-turn', - } as WorkHubResumePlan, + resumeCalls: [] as WorkHubDelegationResumeInput[], resumeOutcome: { outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', @@ -2794,79 +2782,73 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { async readResumeRequest(actionId: string) { return resumeRequests.get(actionId); }, - async readResumeResolution(actionId: string) { - return resumeResolutions.get(actionId); - }, - async listResumeResolutions(delegationId: string) { - return [...resumeResolutions.values()].filter( - (resolution) => resolution.resumesDelegationId === delegationId, - ); - }, - async planResume( - assignment: WorkHubDelegationAssignedMessage, - previous: WorkHubDelegationResumeResolvedMessage | undefined, - ) { - this.planResumeCalls.push({ assignment, previous }); - return this.resumePlan; - }, - async prepareResume(input: WorkHubDelegationResumeInput) { - const existing = resumeRequests.get(input.actionId); - if (existing) return existing; + async resume(input: WorkHubDelegationResumeInput) { + this.resumeCalls.push(input); + const actionId = 'request' in input ? input.request.actionId : input.actionId; + const existingResolution = resumeResolutions.get(actionId); + if (existingResolution) { + return { + disposition: 'resume_work' as const, + outcome: existingResolution.outcome, + targetSessionId: existingResolution.targetSessionId, + ...(existingResolution.targetTurnId + ? { targetTurnId: existingResolution.targetTurnId } + : {}), + }; + } + if ('request' in input) { + throw new Error('missing durable fake resume resolution'); + } + const source = input.source; const requested: WorkHubDelegationResumeRequestedMessage = { type: 'workhub_coordination', - id: `resume-${input.actionId}`, - turnId: input.actionId, + id: `resume-${actionId}`, + turnId: actionId, ts: 7, schemaVersion: 4, kind: 'delegation_resume_requested', - actionId: input.actionId, + actionId, actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, - resumesActionId: input.resumesActionId, - resumesDelegationId: input.resumesDelegationId, - targetSessionId: input.targetSessionId, - targetMessageId: input.targetMessageId, + coordinationTurnId: actionId, + resumesActionId: source.actionId, + resumesDelegationId: source.delegationId, + targetSessionId: source.targetSessionId, + targetMessageId: source.targetMessageId, targetSessionName: input.targetSessionName, userText: input.userText, - plan: input.plan.kind, - ...(input.plan.kind === 'ready' - ? { - sourceTurnId: input.plan.sourceTurnId, - sourceRunId: input.plan.sourceRunId, - sourceRuntimeEventHighWater: input.plan.sourceRuntimeEventHighWater, - targetTurnId: input.plan.targetTurnId, - } - : {}), + plan: 'ready', + sourceTurnId: 'source-turn', + sourceRunId: 'source-run', + sourceRuntimeEventHighWater: 1, + targetTurnId: 'resumed-turn', }; - resumeRequests.set(input.actionId, requested); - return requested; - }, - async resumeDelegation(request: WorkHubDelegationResumeRequestedMessage) { - this.resumeCalls.push(request); - return this.resumeOutcome; - }, - async resolveResume(input: WorkHubDelegationResumeResolutionInput) { - const existing = resumeResolutions.get(input.request.actionId); - if (existing) return existing; + resumeRequests.set(actionId, requested); const resolved: WorkHubDelegationResumeResolvedMessage = { type: 'workhub_coordination', - id: `resume-resolved-${input.request.actionId}`, - turnId: input.request.actionId, + id: `resume-resolved-${actionId}`, + turnId: actionId, ts: 8, schemaVersion: 4, kind: 'delegation_resume_resolved', - actionId: input.request.actionId, - actionFingerprint: input.request.actionFingerprint, - coordinationTurnId: input.request.coordinationTurnId, - resumesActionId: input.request.resumesActionId, - resumesDelegationId: input.request.resumesDelegationId, - targetSessionId: input.request.targetSessionId, - outcome: input.outcome, - ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}), - ...(input.targetRunId ? { targetRunId: input.targetRunId } : {}), + actionId, + actionFingerprint: requested.actionFingerprint, + coordinationTurnId: requested.coordinationTurnId, + resumesActionId: requested.resumesActionId, + resumesDelegationId: requested.resumesDelegationId, + targetSessionId: requested.targetSessionId, + outcome: this.resumeOutcome.outcome, + ...(this.resumeOutcome.targetTurnId + ? { targetTurnId: this.resumeOutcome.targetTurnId } + : {}), + ...(this.resumeOutcome.targetRunId ? { targetRunId: this.resumeOutcome.targetRunId } : {}), + }; + resumeResolutions.set(actionId, resolved); + return { + disposition: 'resume_work' as const, + outcome: resolved.outcome, + targetSessionId: resolved.targetSessionId, + ...(resolved.targetTurnId ? { targetTurnId: resolved.targetTurnId } : {}), }; - resumeResolutions.set(input.request.actionId, resolved); - return resolved; }, async readActionClaim(actionId: string) { return actionClaims.get(actionId); @@ -3064,12 +3046,7 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { resumeRequests: Map; resumeResolutions: Map; retirements: WorkHubDelegationAssignedMessage[]; - resumeCalls: WorkHubDelegationResumeRequestedMessage[]; - planResumeCalls: Array<{ - assignment: WorkHubDelegationAssignedMessage; - previous: WorkHubDelegationResumeResolvedMessage | undefined; - }>; - resumePlan: WorkHubResumePlan; + resumeCalls: WorkHubDelegationResumeInput[]; resumeOutcome: { outcome: 'resume_started' | 'already_running' | 'parked'; targetTurnId?: string; diff --git a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts index 5069d992c8..bf06b74bee 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -33,7 +33,6 @@ import type { WorkHubDelegationStopResolvedMessage, WorkHubDelegationStopOutcome, WorkHubDelegationResumeRequestedMessage, - WorkHubDelegationResumeResolvedMessage, WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { @@ -115,12 +114,6 @@ export interface WorkHubActionGateEffects { delegationId: string, ): Promise; readResumeRequest(actionId: string): Promise; - readResumeResolution( - actionId: string, - ): Promise; - listResumeResolutions( - delegationId: string, - ): Promise; answer( input: { readonly turnId: string; readonly text: string }, context: ConnectionContext, @@ -156,21 +149,10 @@ export interface WorkHubActionGateEffects { * unfinished. The Host owns whether that is possible; a repeat is safe * because a continuation that already exists parks rather than forks. */ - planResume( - assignment: WorkHubDelegationAssignedMessage, - previous: WorkHubDelegationResumeResolvedMessage | undefined, - context: ConnectionContext, - ): Promise; - prepareResume( + resume( input: WorkHubDelegationResumeInput, - ): Promise; - resumeDelegation( - request: WorkHubDelegationResumeRequestedMessage, context: ConnectionContext, - ): Promise; - resolveResume( - input: WorkHubDelegationResumeResolutionInput, - ): Promise; + ): Promise>; } /** @@ -185,41 +167,16 @@ export interface WorkHubDelegationRetirementClaim { readonly cause: 'direct_stop' | 'replacement'; } -export interface WorkHubResumeResult { - readonly outcome: 'resume_started' | 'already_running' | 'parked'; - readonly targetTurnId?: string; - readonly targetRunId?: string; -} - -export type WorkHubResumePlan = - | { readonly kind: 'already_running' | 'parked' } +export type WorkHubDelegationResumeInput = + | { readonly request: WorkHubDelegationResumeRequestedMessage } | { - readonly kind: 'ready'; - readonly sourceTurnId: string; - readonly sourceRunId: string; - readonly sourceRuntimeEventHighWater: number; - readonly targetTurnId: string; + readonly actionId: string; + readonly actionFingerprint: `sha256:${string}`; + readonly source: WorkHubDelegationAssignedMessage; + readonly targetSessionName: string; + readonly userText: string; }; -export interface WorkHubDelegationResumeInput { - readonly actionId: string; - readonly actionFingerprint: `sha256:${string}`; - readonly resumesActionId: string; - readonly resumesDelegationId: string; - readonly targetSessionId: string; - readonly targetMessageId: string; - readonly targetSessionName: string; - readonly userText: string; - readonly plan: WorkHubResumePlan; -} - -export interface WorkHubDelegationResumeResolutionInput { - readonly request: WorkHubDelegationResumeRequestedMessage; - readonly outcome: 'resume_started' | 'already_running' | 'parked'; - readonly targetTurnId?: string; - readonly targetRunId?: string; -} - export interface WorkHubRetirementResult { readonly outcome: WorkHubDelegationStopOutcome | 'recovering'; readonly targetTurnId?: string; @@ -519,7 +476,7 @@ export class WorkHubCoordinationActionGate { replay.actionFingerprint, replay.resumesDelegationId, ); - return this.#resume(replay, context); + return this.#effects.resume({ request: replay }, context); } const source = await this.#resumeSource(proposal.expects.targetSessionId); const sessions = await this.#effects.listSessions(); @@ -535,22 +492,16 @@ export class WorkHubCoordinationActionGate { } const resumeFingerprint = resumeActionFingerprint(input, source); await this.#claimAction(input.actionId, 'resume', resumeFingerprint, source.delegationId); - const previous = (await this.#effects.listResumeResolutions(source.delegationId)) - .filter(({ outcome }) => outcome === 'resume_started') - .at(-1); - const plan = await this.#effects.planResume(source, previous, context); - const request = await this.#effects.prepareResume({ - actionId: input.actionId, - actionFingerprint: resumeFingerprint, - resumesActionId: source.actionId, - resumesDelegationId: source.delegationId, - targetSessionId: source.targetSessionId, - targetMessageId: source.targetMessageId, - targetSessionName: currentTargetName, - userText: input.userText, - plan, - }); - return this.#resume(request, context); + return this.#effects.resume( + { + actionId: input.actionId, + actionFingerprint: resumeFingerprint, + source, + targetSessionName: currentTargetName, + userText: input.userText, + }, + context, + ); } if (proposal.disposition === 'create_new') { @@ -678,25 +629,6 @@ export class WorkHubCoordinationActionGate { return onTarget[0]!; } - async #resume( - request: WorkHubDelegationResumeRequestedMessage, - context: ConnectionContext, - ): Promise> { - const existing = await this.#effects.readResumeResolution(request.actionId); - if (existing) return resumeResult(existing); - const resumed: WorkHubResumeResult = - request.plan === 'ready' - ? await this.#effects.resumeDelegation(request, context) - : { outcome: request.plan }; - const resolution = await this.#effects.resolveResume({ - request, - outcome: resumed.outcome, - ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), - ...(resumed.targetRunId ? { targetRunId: resumed.targetRunId } : {}), - }); - return resumeResult(resolution); - } - /** * The delegation a stop names, by the only two keys that can name it. * @@ -1369,17 +1301,6 @@ function stopResult( }; } -function resumeResult( - resolution: WorkHubDelegationResumeResolvedMessage, -): Extract { - return { - disposition: 'resume_work', - outcome: resolution.outcome, - targetSessionId: resolution.targetSessionId, - ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), - }; -} - function stopResultFromRecord( resolution: WorkHubDelegationStopResolvedMessage, request: WorkHubDelegationStopRequestedMessage, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 233a48bb0f..9113de3a11 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -48,6 +48,7 @@ import { } from '@maka/storage/session-store'; import type { OperationOutcome, + WorkHubCoordinationActResult, WorkHubCoordinationActInput, WorkHubCoordinationAnswerInput, WorkHubCoordinationRecordInput, @@ -69,6 +70,7 @@ import { WorkHubActionGateFailure, WorkHubCoordinationActionGate, type WorkHubActionGateEffects, + type WorkHubDelegationResumeInput, } from './workhub-coordination-action-gate.js'; const CREATE_FINGERPRINT = `sha256:${createHash('sha256') @@ -126,6 +128,43 @@ type CoordinationExecutions = Pick< 'startWorkHubCoordinationMessage' | 'hasRootTurnAdmission' >; +type WorkHubResumePlan = + | { readonly kind: 'already_running' | 'parked' } + | { + readonly kind: 'ready'; + readonly sourceTurnId: string; + readonly sourceRunId: string; + readonly sourceRuntimeEventHighWater: number; + readonly targetTurnId: string; + }; + +interface WorkHubResumeResult { + readonly outcome: 'resume_started' | 'already_running' | 'parked'; + readonly targetTurnId?: string; + readonly targetRunId?: string; +} + +type CoordinationSessionActions = Pick< + WorkHubActionGateEffects, + 'readDelegationRetirement' | 'retireDelegation' +> & { + assign( + ...args: [ + ...Parameters, + publishCommittedAssignment: (assignment: ActiveWorkHubAssignment) => Promise, + ] + ): ReturnType; + planResume( + assignment: WorkHubDelegationAssignedMessage, + previous: WorkHubDelegationResumeResolvedMessage | undefined, + context: ConnectionContext, + ): Promise; + resumeDelegation( + request: WorkHubDelegationResumeRequestedMessage, + context: ConnectionContext, + ): Promise; +}; + export type CoordinationCreateTarget = Omit; export interface HostWorkHubCoordinationCoordinatorOptions { @@ -134,17 +173,7 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly admission: SessionAdmissionGate; readonly continuity: Pick; readonly executions: CoordinationExecutions; - readonly sessionActions: Pick< - WorkHubActionGateEffects, - 'readDelegationRetirement' | 'retireDelegation' | 'planResume' | 'resumeDelegation' - > & { - assign( - ...args: [ - ...Parameters, - publishCommittedAssignment: (assignment: ActiveWorkHubAssignment) => Promise, - ] - ): ReturnType; - }; + readonly sessionActions: CoordinationSessionActions; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; } @@ -203,8 +232,6 @@ export class HostWorkHubCoordinationCoordinator { readStopRequest: (delegationId) => this.#stores.readWorkHubStopRequest(delegationId), readStopResolution: (delegationId) => this.#stores.readWorkHubStopResolution(delegationId), readResumeRequest: (actionId) => this.#stores.readWorkHubResumeRequest(actionId), - readResumeResolution: (actionId) => this.#stores.readWorkHubResumeResolution(actionId), - listResumeResolutions: (delegationId) => this.#listResumeResolutions(delegationId), answer: async (input, context) => { const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); if (!outcome.ok) { @@ -230,12 +257,9 @@ export class HostWorkHubCoordinationCoordinator { abortReplacement: (input) => this.#abortReplacement(input), prepareStop: (input) => this.#prepareStop(input), resolveStop: (input) => this.#resolveStop(input), - prepareResume: (input) => this.#prepareResume(input), - resolveResume: (input) => this.#resolveResume(input), readDelegationRetirement: options.sessionActions.readDelegationRetirement, retireDelegation: options.sessionActions.retireDelegation, - planResume: options.sessionActions.planResume, - resumeDelegation: options.sessionActions.resumeDelegation, + resume: (input, context) => this.#resume(input, context, options.sessionActions), }); } @@ -408,68 +432,86 @@ export class HostWorkHubCoordinationCoordinator { }); } - #prepareResume( - input: Parameters[0], - ): Promise { - const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); - return this.#commitCoordinationFact({ - admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.targetSessionId], - read: () => this.#stores.readWorkHubResumeRequest(input.actionId), - build: (existing) => ({ - type: 'workhub_coordination', - id: `whr_${suffix}`, - turnId: input.actionId, - ts: existing?.ts ?? Date.now(), - schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, - kind: 'delegation_resume_requested', - actionId: input.actionId, - actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, - resumesActionId: input.resumesActionId, - resumesDelegationId: input.resumesDelegationId, - targetSessionId: input.targetSessionId, - targetMessageId: input.targetMessageId, - targetSessionName: input.targetSessionName, - userText: input.userText, - plan: input.plan.kind, - ...(input.plan.kind === 'ready' - ? { - sourceTurnId: input.plan.sourceTurnId, - sourceRunId: input.plan.sourceRunId, - sourceRuntimeEventHighWater: input.plan.sourceRuntimeEventHighWater, - targetTurnId: input.plan.targetTurnId, - } - : {}), - }), - conflictMessage: 'WorkHub resume identity belongs to a different plan', - beforeAppend: async () => { - const source = (await this.#listActiveAssignments()).find( - ({ actionId, delegationId }) => - actionId === input.resumesActionId && delegationId === input.resumesDelegationId, - ); - if (!source || source.targetSessionId !== input.targetSessionId) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub resume source is no longer active', + async #resume( + input: WorkHubDelegationResumeInput, + context: ConnectionContext, + actions: CoordinationSessionActions, + ): Promise> { + let request: WorkHubDelegationResumeRequestedMessage; + if ('request' in input) { + request = input.request; + } else { + const previous = (await this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)) + .filter( + (message): message is WorkHubDelegationResumeResolvedMessage => + message.type === 'workhub_coordination' && + message.kind === 'delegation_resume_resolved' && + message.resumesDelegationId === input.source.delegationId && + message.outcome === 'resume_started', + ) + .at(-1); + const plan = await actions.planResume(input.source, previous, context); + const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); + request = await this.#commitCoordinationFact({ + admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.source.targetSessionId], + read: () => this.#stores.readWorkHubResumeRequest(input.actionId), + build: (existing) => ({ + type: 'workhub_coordination', + id: `whr_${suffix}`, + turnId: input.actionId, + ts: existing?.ts ?? Date.now(), + schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, + kind: 'delegation_resume_requested', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + resumesActionId: input.source.actionId, + resumesDelegationId: input.source.delegationId, + targetSessionId: input.source.targetSessionId, + targetMessageId: input.source.targetMessageId, + targetSessionName: input.targetSessionName, + userText: input.userText, + plan: plan.kind, + ...(plan.kind === 'ready' + ? { + sourceTurnId: plan.sourceTurnId, + sourceRunId: plan.sourceRunId, + sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, + targetTurnId: plan.targetTurnId, + } + : {}), + }), + conflictMessage: 'WorkHub resume identity belongs to a different plan', + beforeAppend: async () => { + const source = (await this.#listActiveAssignments()).find( + ({ actionId, delegationId }) => + actionId === input.source.actionId && delegationId === input.source.delegationId, ); - } - }, - unknownOutcomeMessage: 'WorkHub resume plan outcome is unknown', - }); - } + if (!source || source.targetSessionId !== input.source.targetSessionId) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume source is no longer active', + ); + } + }, + unknownOutcomeMessage: 'WorkHub resume plan outcome is unknown', + }); + } - #resolveResume( - input: Parameters[0], - ): Promise { - const request = input.request; + const existing = await this.#stores.readWorkHubResumeResolution(request.actionId); + if (existing) return coordinationResumeResult(existing); + const resumed: WorkHubResumeResult = + request.plan === 'ready' + ? await actions.resumeDelegation(request, context) + : { outcome: request.plan }; const suffix = createHash('sha256').update(request.actionId, 'utf8').digest('hex').slice(0, 48); - return this.#commitCoordinationFact({ + const resolution = await this.#commitCoordinationFact({ read: () => this.#stores.readWorkHubResumeResolution(request.actionId), - build: (existing) => ({ + build: (durable) => ({ type: 'workhub_coordination', id: `whn_${suffix}`, turnId: request.actionId, - ts: existing?.ts ?? Date.now(), + ts: durable?.ts ?? Date.now(), schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, kind: 'delegation_resume_resolved', actionId: request.actionId, @@ -478,9 +520,9 @@ export class HostWorkHubCoordinationCoordinator { resumesActionId: request.resumesActionId, resumesDelegationId: request.resumesDelegationId, targetSessionId: request.targetSessionId, - outcome: input.outcome, - ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}), - ...(input.targetRunId ? { targetRunId: input.targetRunId } : {}), + outcome: resumed.outcome, + ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), + ...(resumed.targetRunId ? { targetRunId: resumed.targetRunId } : {}), }), conflictMessage: 'WorkHub resume already has a different resolution', beforeAppend: async () => { @@ -491,17 +533,7 @@ export class HostWorkHubCoordinationCoordinator { }, unknownOutcomeMessage: 'WorkHub resume resolution outcome is unknown', }); - } - - async #listResumeResolutions( - delegationId: string, - ): Promise { - return (await this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).filter( - (message): message is WorkHubDelegationResumeResolvedMessage => - message.type === 'workhub_coordination' && - message.kind === 'delegation_resume_resolved' && - message.resumesDelegationId === delegationId, - ); + return coordinationResumeResult(resolution); } #abortReplacement( @@ -1031,6 +1063,17 @@ function digest(value: unknown): `sha256:${string}` { return `sha256:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`; } +function coordinationResumeResult( + resolution: WorkHubDelegationResumeResolvedMessage, +): Extract { + return { + disposition: 'resume_work', + outcome: resolution.outcome, + targetSessionId: resolution.targetSessionId, + ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), + }; +} + function workHubDestructiveClaimIdentitySuffix(delegationId: string): string { return createHash('sha256').update(delegationId, 'utf8').digest('hex').slice(0, 48); } From efeed7c064f4f4d91ecd852b485b0b121e32e29e Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 15:34:13 +0800 Subject: [PATCH 08/12] fix(workhub): preserve resumed execution ownership --- .../main/__tests__/workhub-controller.test.ts | 33 +++- .../__tests__/workhub-session-port.test.ts | 2 +- .../__tests__/workhub-surface-flow.test.ts | 31 +++ .../src/renderer/workhub-controller.ts | 178 +++++++----------- .../src/renderer/workhub-coordination-port.ts | 7 +- .../src/renderer/workhub-route-policy.ts | 47 ++--- apps/desktop/src/renderer/workhub-surface.tsx | 8 +- .../workhub-coordination-record.test.ts | 9 +- .../__tests__/workhub-creation-intent.test.ts | 36 ++-- packages/core/src/runtime-invocation.ts | 15 ++ packages/core/src/session.ts | 29 ++- packages/core/src/workhub-creation-intent.ts | 77 +++----- .../__tests__/execution-composition.test.ts | 137 +++++++++++++- .../workhub-coordination-action-gate.test.ts | 17 +- .../workhub-coordination-coordinator.test.ts | 86 ++++++++- .../workhub-coordination-protocol.test.ts | 101 ++++++++++ packages/runtime-host/src/protocol/turn.ts | 21 +-- .../src/protocol/workhub-coordination.ts | 18 +- .../src/server/execution-composition.ts | 93 +++++---- .../src/server/root-turn-coordinator.ts | 27 +++ .../workhub-coordination-coordinator.ts | 48 ++--- packages/storage/src/session-store.ts | 2 +- 22 files changed, 699 insertions(+), 323 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index b693268ebd..1111ea248f 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -24,6 +24,7 @@ import type { WorkHubCoordinationActInput } from '@maka/runtime-host/protocol'; import { createWorkHubController as createGatedWorkHubController, WORKHUB_ROUTING_STRATEGY_ID, + WorkHubCoordinationFailure, type WorkHubSessionFacts, type WorkHubSessionPort, type WorkHubCoordinationTurn, @@ -32,7 +33,6 @@ import { createWorkHubRoutePolicy, workHubNewSessionName, } from '../../renderer/workhub-route-policy.js'; -import { WorkHubCoordinationFailure } from '../../renderer/workhub-coordination-port.js'; const appShellUrl = [ new URL('../../renderer/app-shell.tsx', import.meta.url), @@ -473,6 +473,37 @@ test('a named resume submits and reports what the Host did', async () => { await handle.close(); }); +test('a parked resume preserves the Host park reason', async () => { + const controller = createGatedWorkHubController({ + sessions: port([session('payments', { sessionName: 'Payments' })]), + coordination: { + open: async (handler) => { + handler([], []); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('a resume must not read route candidates'), + act: async () => ({ + disposition: 'resume_work', + outcome: 'parked', + targetSessionId: 'payments', + parkReason: 'safety_check_failed', + }), + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + assert.deepEqual(await controller.submit({ requestId: 'resume-parked', text: 'Resume Payments' }), { + kind: 'resume', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'resume-parked', + target: { sessionId: 'payments' }, + outcome: 'parked', + parkReason: 'safety_check_failed', + }); + await handle.close(); +}); + test('a resume the Host will not admit becomes its clarification', async () => { const sessions = port([session('payments', { sessionName: 'Payments' })]); const controller = createGatedWorkHubController({ diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index becde6b3fb..10e468e161 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -334,7 +334,7 @@ test('resume projection survives reload and exposes its durable outcome', () => actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'resume-action', resumesActionId: 'source-action', resumesDelegationId: 'payments-delegation', targetSessionId: 'payments', outcome: 'resume_started', - targetTurnId: 'resumed-turn', targetRunId: 'resumed-run', + targetTurnId: 'resumed-turn', }; assert.deepEqual(projectWorkHubCoordinationTurns([requested]), [{ diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts index e88af5b222..fcb62fe77a 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -210,6 +210,37 @@ test('durable delegation renders terminal link state instead of stale execution } }); +test('a parked resume owns the durable frame state', () => { + const turn: WorkHubCoordinationTurn = { + messageId: 'resume-parked', + turnId: 'resume-parked', + text: 'Resume Payments', + state: 'completed', + resume: { + targetSessionId: 'payments', + targetSessionName: 'Payments', + outcome: 'parked', + }, + updatedAt: 10, + }; + const markup = renderToStaticMarkup( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(WorkHubCoordinationTurnView, { + turn, + projection: { sessions: [], turns: [] }, + locale: 'en', + onOpenSession: () => undefined, + }), + }), + }), + ); + + assert.match(markup, /data-state="parked"/u); + assert.doesNotMatch(markup, /data-state="completed"/u); +}); + test('durable creation explicitly announces the new work', () => { const turn: WorkHubCoordinationTurn = { messageId: 'created-assignment', diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index d2377a9f51..e8c534c9ab 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -27,6 +27,7 @@ import { createWorkHubRoutePolicy, type WorkHubRouteEvidence, type WorkHubStopClarificationReason, + type WorkHubNamedActionRouteDecision, } from './workhub-route-policy.js'; import type { OperationError, @@ -226,6 +227,10 @@ export type WorkHubSubmission = ( target: WorkHubSessionTarget; outcome: Extract['outcome']; targetTurnId?: string; + parkReason?: Extract< + WorkHubCoordinationActResult, + { disposition: 'resume_work' } + >['parkReason']; } ) & { strategyId: WorkHubRoutingStrategyId }; @@ -370,6 +375,69 @@ export function createWorkHubController(deps: { ...(correction ? { correctedFrom: correction.from } : {}), }; }; + const submitNamedDelegationAction = async ( + input: WorkHubSubmitInput, + decision: WorkHubNamedActionRouteDecision, + kind: 'resume' | 'stop', + ): Promise | undefined> => { + if (decision.kind === 'not_requested') return undefined; + if (decision.kind === 'clarification') { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: decision.reason, + }; + } + const { target } = decision; + try { + const admitted = await coordination.act({ + actionId: input.requestId, + userText: input.text, + proposal: { + disposition: kind === 'resume' ? 'resume_work' : 'stop_work', + expects: { targetSessionId: target.sessionId }, + }, + ...(kind === 'stop' ? { confirmation: { kind: 'user_stop' as const } } : {}), + }); + if (kind === 'resume' && admitted.disposition === 'resume_work') { + return { + kind, + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + outcome: admitted.outcome, + ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), + ...(admitted.parkReason ? { parkReason: admitted.parkReason } : {}), + }; + } + if (kind === 'stop' && admitted.disposition === 'stop_work') { + return { + kind, + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + outcome: admitted.outcome, + ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), + }; + } + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } catch (error) { + if (error instanceof WorkHubCoordinationFailure && error.code === 'operation_conflict') { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: kind === 'resume' ? 'resume_target_unavailable' : 'stop_target_unavailable', + }; + } + throw error; + } + }; return { async openConversation(handler, onError) { let disposed = false; @@ -506,116 +574,14 @@ export function createWorkHubController(deps: { text: input.text, sessions: ordinary, }); - if (resumeDecision.kind !== 'not_requested') { - if (resumeDecision.kind === 'clarification') { - return { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - text: input.text, - options: [], - reason: resumeDecision.reason, - }; - } - const { target } = resumeDecision; - let resumed; - try { - resumed = await coordination.act({ - actionId: input.requestId, - userText: input.text, - // No confirmation: resume ends nothing. Which delegation it carries - // on, and whether the Host can, is decided where the stop is. - proposal: { - disposition: 'resume_work', - expects: { targetSessionId: target.sessionId }, - }, - }); - } catch (error) { - if (error instanceof WorkHubCoordinationFailure && error.code === 'operation_conflict') { - return { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - text: input.text, - options: [], - reason: 'resume_target_unavailable', - }; - } - throw error; - } - if (resumed.disposition !== 'resume_work') { - throw new Error('WorkHub Action Gate returned an unexpected disposition'); - } - return { - kind: 'resume', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - target, - outcome: resumed.outcome, - ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), - }; - } + const resume = await submitNamedDelegationAction(input, resumeDecision, 'resume'); + if (resume) return resume; const stopDecision = submissionPolicy.resolveStop({ text: input.text, sessions: ordinary, }); - if (stopDecision.kind !== 'not_requested') { - if (stopDecision.kind === 'clarification') { - return { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - text: input.text, - options: [], - reason: stopDecision.reason, - }; - } - const { target } = stopDecision; - let admitted; - try { - admitted = await coordination.act({ - actionId: input.requestId, - userText: input.text, - proposal: { - disposition: 'stop_work', - // Only the Session the reference resolved to. Which delegation - // that Session still owns is the Host's to decide, under the - // lease that ends it. - expects: { targetSessionId: target.sessionId }, - }, - confirmation: { kind: 'user_stop' }, - }); - } catch (error) { - // The Gate refusing the stop is an answer, not a fault: it is the - // only party that can say the Session owns no single stoppable - // delegation. Anything else is a real failure and still throws. - if ( - error instanceof WorkHubCoordinationFailure && - error.code === 'operation_conflict' - ) { - return { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - text: input.text, - options: [], - reason: 'stop_target_unavailable', - }; - } - throw error; - } - if (admitted.disposition !== 'stop_work') { - throw new Error('WorkHub Action Gate returned an unexpected disposition'); - } - return { - kind: 'stop', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - target, - outcome: admitted.outcome, - ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), - }; - } + const stop = await submitNamedDelegationAction(input, stopDecision, 'stop'); + if (stop) return stop; const candidateSet = await coordination.candidates(); const candidateBySessionId = new Map( candidateSet.candidates.map((candidate) => [candidate.sessionId, candidate]), diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 0433d568ec..5f2febe784 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -40,7 +40,12 @@ import type { } from '@maka/runtime-host/protocol'; import { boundedWorkHubTimelineText, WorkHubCoordinationFailure } from './workhub-controller.js'; -export { WorkHubCoordinationFailure }; +export function isWorkHubCoordinationFailure( + error: unknown, +): error is WorkHubCoordinationFailure { + return error instanceof WorkHubCoordinationFailure; +} + import type { WorkHubDesktopTranscriptBridge } from './workhub-session-port.js'; const WORKHUB_COORDINATION_TURN_LIMIT = 40; diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index f6929338d2..68733abb80 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -90,7 +90,7 @@ export type WorkHubStopClarificationReason = * original text as work, and stop-shaped text is exactly what must not be * delivered to a Session that way, so the reason carries the whole answer. */ -export type WorkHubStopRouteDecision = +export type WorkHubNamedActionRouteDecision = | { kind: 'not_requested' } | { kind: 'clarification'; reason: WorkHubStopClarificationReason } | { kind: 'target'; target: WorkHubRouteTarget }; @@ -99,7 +99,7 @@ export interface WorkHubRoutePolicy { resolveStop(input: { text: string; sessions: WorkHubRoutableSession[]; - }): WorkHubStopRouteDecision; + }): WorkHubNamedActionRouteDecision; /** * Resume reads the same way a stop does and refuses on the same terms. The * two share one resolver and one tail rule so `Resume Payments` and @@ -108,7 +108,7 @@ export interface WorkHubRoutePolicy { resolveResume(input: { text: string; sessions: WorkHubRoutableSession[]; - }): WorkHubStopRouteDecision; + }): WorkHubNamedActionRouteDecision; resolve(input: { text: string; sessions: WorkHubRoutableSession[]; @@ -158,27 +158,17 @@ const MAX_RELATED_CLARIFICATION_OPTIONS = 4; */ function resolveNamedDelegationAction( sessionResolver: WorkHubSessionResolver, - action: { readonly cue: boolean; readonly imperative: boolean; readonly target?: string }, + action: { readonly requested: boolean; readonly target?: string }, sessions: WorkHubRoutableSession[], - reasons: { - /** - * What an unnamed reference means. A stop must say so: it is destructive, - * and `Stop it` has to be answered rather than delivered as work. A resume - * must not: `继续这个工作` is how a user carries on with the Session they - * are already in, and answering it would take an ordinary instruction away - * from ordinary routing. Resuming nothing costs nothing, so it falls - * through and only an explicitly named resume becomes an action. - */ - readonly unnamed: WorkHubStopClarificationReason | 'not_requested'; - readonly ambiguous: WorkHubStopClarificationReason; - }, -): WorkHubStopRouteDecision { - if (!action.cue) return { kind: 'not_requested' }; - const reference = action.imperative ? action.target : undefined; + ambiguousReason: WorkHubStopClarificationReason, + unnamedReason?: WorkHubStopClarificationReason, +): WorkHubNamedActionRouteDecision { + if (!action.requested) return { kind: 'not_requested' }; + const reference = action.target; if (!reference) { - return reasons.unnamed === 'not_requested' - ? { kind: 'not_requested' } - : { kind: 'clarification', reason: reasons.unnamed }; + return unnamedReason + ? { kind: 'clarification', reason: unnamedReason } + : { kind: 'not_requested' }; } const sessionByRef = new Map(sessions.map((session) => [session.target.sessionId, session])); const resolution = sessionResolver.resolve({ @@ -199,7 +189,7 @@ function resolveNamedDelegationAction( // One candidate only. A ranked resolver may return several; neither action // picks a winner from a ranking it cannot justify. if (resolution.kind === 'ambiguous' || admissible.length > 1) { - return { kind: 'clarification', reason: reasons.ambiguous }; + return { kind: 'clarification', reason: ambiguousReason }; } const resolved = sessionByRef.get(admissible[0]!.ref); if (!resolved) return { kind: 'not_requested' }; @@ -237,22 +227,25 @@ function createWorkHubRoutePolicyVisit( // reference still fails closed, and a resolved Session that is not uniquely // stoppable says why. resolveStop({ text, sessions }) { + const action = readWorkHubRequestIntent(text).stop; return resolveNamedDelegationAction( sessionResolver, - readWorkHubRequestIntent(text).stop, + { requested: action.cue, ...(action.imperative ? { target: action.target } : {}) }, sessions, - { unnamed: 'stop_target_required', ambiguous: 'stop_target_ambiguous' }, + 'stop_target_ambiguous', + 'stop_target_required', ); }, // Resume asks the same question of the same words, so it asks it with the // same code. Two copies of this would be two chances for `Resume Payments` // and `Stop Payments` to disagree about which Session they name. resolveResume({ text, sessions }) { + const action = readWorkHubRequestIntent(text).resume; return resolveNamedDelegationAction( sessionResolver, - readWorkHubRequestIntent(text).resume, + { requested: action.imperative, ...(action.target ? { target: action.target } : {}) }, sessions, - { unnamed: 'not_requested', ambiguous: 'resume_target_ambiguous' }, + 'resume_target_ambiguous', ); }, resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 6ffdcf9c2a..05f0316278 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -40,7 +40,7 @@ import { WorkHubSendLease, type WorkHubSendAttempt, } from './workhub-send-lease.js'; -import { WorkHubCoordinationFailure } from './workhub-coordination-port.js'; +import { isWorkHubCoordinationFailure } from './workhub-coordination-port.js'; export interface WorkHubConversationTurn { requestId: string; @@ -95,7 +95,7 @@ export function workHubSubmissionClearsDraft( } export function workHubSurfaceFailure(error: unknown): WorkHubSurfaceFailure { - if (error instanceof WorkHubCoordinationFailure) { + if (isWorkHubCoordinationFailure(error)) { if (error.code === 'operation_conflict') return 'action_changed'; if (error.code === 'not_found' || error.code === 'session_archived') { return 'candidates_changed'; @@ -467,7 +467,7 @@ export function WorkHubSurface(props: { function isTerminalWorkHubSurfaceFailure(error: unknown): boolean { return ( - error instanceof WorkHubCoordinationFailure && + isWorkHubCoordinationFailure(error) && (error.code === 'operation_conflict' || error.code === 'not_found' || error.code === 'session_archived' || @@ -583,7 +583,7 @@ export function WorkHubCoordinationTurnView(props: { return ( { targetSessionId: 'payments', outcome: 'resume_started', targetTurnId: 'resumed-turn', - targetRunId: 'resumed-run', } as const; assert.deepEqual(decodeCanonicalMessage(requested), requested); @@ -308,9 +307,9 @@ describe('WorkHub Coordination stored records', () => { for (const invalid of [ { ...requested, sourceRunId: undefined }, { ...requested, sourceRuntimeEventHighWater: -1 }, - { ...requested, plan: 'parked' }, - { ...resolved, targetRunId: undefined }, - { ...resolved, outcome: 'parked' }, + { ...requested, plan: 'parked', parkReason: undefined }, + { ...resolved, targetTurnId: undefined }, + { ...resolved, outcome: 'parked', parkReason: undefined }, { ...resolved, sourceRunId: 'injected' }, ]) { assert.throws(() => decodeCanonicalMessage(invalid), /Invalid stored message schema/u); @@ -318,8 +317,8 @@ describe('WorkHub Coordination stored records', () => { const parked = { ...resolved, outcome: 'parked' as const, + parkReason: 'safety_check_failed' as const, targetTurnId: undefined, - targetRunId: undefined, }; assert.deepEqual(decodeCanonicalMessage(parked), parked); }); diff --git a/packages/core/src/__tests__/workhub-creation-intent.test.ts b/packages/core/src/__tests__/workhub-creation-intent.test.ts index 0151cd8870..6e96a18cc7 100644 --- a/packages/core/src/__tests__/workhub-creation-intent.test.ts +++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts @@ -1133,34 +1133,34 @@ test('a resume names one Session, and reads like a stop everywhere else', () => // existing Session, in either language. for (const [text, target] of [ ['Resume Payments', 'Payments'], - ['Continue Payments', 'Payments'], - ['Restart Payments', 'Payments'], - ['继续支付任务', '支付任务'], ['恢复支付任务', '支付任务'], - ['请继续支付任务', '支付任务'], ['接着跑支付任务', '支付任务'], ] as const) { - assert.deepEqual( - readWorkHubRequestIntent(text).resume, - { cue: true, imperative: true, target }, - text, - ); + assert.deepEqual(readWorkHubRequestIntent(text).resume, { imperative: true, target }, text); } - // Anaphora carries the cue and claims no target, so the surface asks which - // work instead of guessing — the same shape `Stop it` produces. - for (const text of ['Resume it', '继续它']) { - assert.deepEqual(readWorkHubRequestIntent(text).resume, { cue: true, imperative: false }, text); + for (const text of ['Resume it', '恢复它']) { + assert.deepEqual(readWorkHubRequestIntent(text).resume, { imperative: false }, text); } - // A question, a negation and an unterminated quote are not commands. - for (const text of ['Should I resume Payments?', 'Do not resume Payments', 'Resume "Payments']) { - assert.equal(readWorkHubRequestIntent(text).resume.cue, false, text); + // Ambiguous verbs remain ordinary Session instructions rather than being + // consumed as WorkHub resume commands. + for (const text of [ + 'Continue Payments', + 'Restart Payments', + '继续支付任务', + '请继续支付任务', + '重新开始支付任务', + 'Should I resume Payments?', + 'Do not resume Payments', + 'Resume "Payments', + ]) { + assert.equal(readWorkHubRequestIntent(text).resume.imperative, false, text); } // Stop and resume are separate speech acts; neither reads as the other, and // ordinary work is neither. - assert.equal(readWorkHubRequestIntent('Stop Payments').resume.cue, false); + assert.equal(readWorkHubRequestIntent('Stop Payments').resume.imperative, false); assert.equal(readWorkHubRequestIntent('Resume Payments').stop.cue, false); - assert.equal(readWorkHubRequestIntent('Fix the login bug').resume.cue, false); + assert.equal(readWorkHubRequestIntent('Fix the login bug').resume.imperative, false); }); diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index e020d7f14f..a8050d7752 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -34,6 +34,21 @@ import type { } from './runtime-event.js'; import { isTerminalRuntimeEvent } from './runtime-event.js'; +export const SAFE_BOUNDARY_RESUME_PARK_REASONS = [ + 'resume_candidate_missing', + 'source_run_unreadable', + 'safety_check_failed', + 'continuation_already_exists', + 'continuation_repair_required', + 'continuation_started_indeterminate', + 'resume_feature_disabled', + 'continuation_authority_unavailable', + 'safety_observation_unavailable', + 'session_busy', +] as const; + +export type SafeBoundaryResumeParkReason = (typeof SAFE_BOUNDARY_RESUME_PARK_REASONS)[number]; + export interface RuntimeInvocationRecord { sessionId: string; invocationId: string; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 26817df577..07ea5a08b9 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -49,6 +49,10 @@ import { import { markPersisted, type PersistedValue } from './persisted-value.js'; import type { SubagentWorkspaceBinding } from './subagent-workspace.js'; import { decodeTurnOrigin, type TurnOrigin } from './turn-origin.js'; +import { + SAFE_BOUNDARY_RESUME_PARK_REASONS, + type SafeBoundaryResumeParkReason, +} from './runtime-invocation.js'; export { DEEP_RESEARCH_SESSION_LABEL, isDeepResearchSession } from './deep-research.js'; @@ -1078,8 +1082,6 @@ export interface WorkHubDelegationStopResolvedMessage { targetTurnId?: string; } -export type WorkHubDelegationResumeOutcome = 'resume_started' | 'already_running' | 'parked'; - /** Durable resume plan written before attempting a continuation. */ export interface WorkHubDelegationResumeRequestedMessage { type: 'workhub_coordination'; @@ -1098,6 +1100,7 @@ export interface WorkHubDelegationResumeRequestedMessage { targetSessionName: string; userText: string; plan: 'ready' | 'already_running' | 'parked'; + parkReason?: SafeBoundaryResumeParkReason; sourceTurnId?: string; sourceRunId?: string; sourceRuntimeEventHighWater?: number; @@ -1118,9 +1121,9 @@ export interface WorkHubDelegationResumeResolvedMessage { resumesActionId: string; resumesDelegationId: string; targetSessionId: string; - outcome: WorkHubDelegationResumeOutcome; + outcome: 'resume_started' | 'already_running' | 'parked'; + parkReason?: SafeBoundaryResumeParkReason; targetTurnId?: string; - targetRunId?: string; } /** @@ -1453,7 +1456,7 @@ const WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE = 'userText', 'plan', ], - ['sourceTurnId', 'sourceRunId', 'sourceRuntimeEventHighWater', 'targetTurnId'], + ['parkReason', 'sourceTurnId', 'sourceRunId', 'sourceRuntimeEventHighWater', 'targetTurnId'], ); const WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE = defineObjectShape()( @@ -1472,7 +1475,7 @@ const WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE = 'targetSessionId', 'outcome', ], - ['targetTurnId', 'targetRunId'], + ['parkReason', 'targetTurnId'], ); const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( ['title', 'workspace'], @@ -1666,6 +1669,7 @@ function decodeMessage( function isWorkHubCoordinationMessage(message: Record): boolean { if (message.kind === 'delegation_resume_requested') { const ready = message.plan === 'ready'; + const parked = message.plan === 'parked'; return ( hasMessageEnvelope(message, true) && hasExactShape(message, WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE) && @@ -1684,6 +1688,9 @@ function isWorkHubCoordinationMessage(message: Record): boolean typeof message.userText === 'string' && message.userText.trim().length > 0 && (ready || message.plan === 'already_running' || message.plan === 'parked') && + (parked + ? (SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(message.parkReason) + : message.parkReason === undefined) && (ready ? typeof message.sourceTurnId === 'string' && message.sourceTurnId.length > 0 && @@ -1702,6 +1709,7 @@ function isWorkHubCoordinationMessage(message: Record): boolean } if (message.kind === 'delegation_resume_resolved') { const started = message.outcome === 'resume_started'; + const parked = message.outcome === 'parked'; return ( hasMessageEnvelope(message, true) && hasExactShape(message, WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE) && @@ -1714,12 +1722,13 @@ function isWorkHubCoordinationMessage(message: Record): boolean typeof message.targetSessionId === 'string' && message.targetSessionId.length > 0 && (started || message.outcome === 'already_running' || message.outcome === 'parked') && + (parked + ? (SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(message.parkReason) + : message.parkReason === undefined) && (started ? typeof message.targetTurnId === 'string' && - message.targetTurnId.length > 0 && - typeof message.targetRunId === 'string' && - message.targetRunId.length > 0 - : message.targetTurnId === undefined && message.targetRunId === undefined) + message.targetTurnId.length > 0 + : message.targetTurnId === undefined) ); } if (message.kind === 'delegation_stop_requested') { diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index cad6aeb79c..7982a66f57 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -112,13 +112,10 @@ const DIRECT_CHINESE_STOP_REQUEST = /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:停止|停掉|停下|取消|终止|中止)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; // Resume asks the Host to carry on work that was interrupted, so it reads the // same shape as a stop: a direct speech act naming one existing Session. It is -// deliberately narrower than the everyday senses of these words — `continue` -// and `继续` also introduce ordinary instructions ("continue with the refactor"), -// which is why a resume that names nothing resolvable stays ordinary work. const DIRECT_RESUME_REQUEST = - /^\s*(?:(?:please|kindly)\s+)?(?:resume|continue|restart)\s+(?:(?:the|this)\s+)?(?:(?:session|work|task|job)\s+)?(.+?)\s*[.!。!]?\s*$/iu; + /^\s*(?:(?:please|kindly)\s+)?resume\s+(?:(?:the|this)\s+)?(?:(?:session|work|task|job)\s+)?(.+?)\s*[.!。!]?\s*$/iu; const DIRECT_CHINESE_RESUME_REQUEST = - /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:继续|恢复|接着跑|重新开始)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; + /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:恢复|接着跑)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; const UNSAFE_STOP_TARGET = /^(?:it|this|that|one|everything|all|current|session|work|task|job|(?:this|that|current)\s+(?:session|work|task|job)|它|这个|那个|全部|当前|会话|工作|任务|(?:这个|那个|当前)(?:会话|工作|任务))$/iu; @@ -161,8 +158,6 @@ export interface WorkHubRequestIntent { readonly target?: string; }; readonly resume: { - /** A direct resume speech act was present, but its target may still be unsafe. */ - readonly cue: boolean; /** True only for a direct, explicitly named resume command. */ readonly imperative: boolean; readonly target?: string; @@ -332,10 +327,14 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { : { kind: 'unusable' }; const correctionCue = hasWorkHubCorrectionCue(source); const existingTarget = affirmativeWorkHubExistingCorrectionTarget(source); - const stopCue = directWorkHubStopCue(source, literalMask.malformed); - const resumeCue = directWorkHubResumeCue(source, literalMask.malformed); - const resumeTarget = resumeCue ? directWorkHubResumeTarget(source, false) : undefined; - const stopTarget = stopCue ? directWorkHubStopTarget(source, false) : undefined; + const stop = directWorkHubNamedAction(source, literalMask.malformed, [ + DIRECT_STOP_REQUEST, + DIRECT_CHINESE_STOP_REQUEST, + ]); + const resume = directWorkHubNamedAction(source, literalMask.malformed, [ + DIRECT_RESUME_REQUEST, + DIRECT_CHINESE_RESUME_REQUEST, + ]); const actions = allMatches(masked, EXECUTION_ACTION); const execution: WorkHubExecutionIntent = literalMask.malformed || naming.kind === 'unusable' || hasDominatingDeliberation(masked) @@ -353,14 +352,13 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { ...(existingTarget ? { existingTarget } : {}), }, stop: { - cue: stopCue, - imperative: Boolean(stopTarget), - ...(stopTarget ? { target: stopTarget } : {}), + cue: stop.cue, + imperative: Boolean(stop.target), + ...(stop.target ? { target: stop.target } : {}), }, resume: { - cue: resumeCue, - imperative: Boolean(resumeTarget), - ...(resumeTarget ? { target: resumeTarget } : {}), + imperative: Boolean(resume.target), + ...(resume.target ? { target: resume.target } : {}), }, }; } @@ -517,43 +515,20 @@ function correctionTargetMatchesSession(target: string, sessionName: string): bo return workHubCorrectionAdmitsReference(target, matchWorkHubSessionName(target, sessionName)); } -function directWorkHubStopTarget(value: string, malformedLiteral: boolean): string | undefined { - if (malformedLiteral || /[??]\s*$/u.test(value)) return undefined; - const match = DIRECT_STOP_REQUEST.exec(value) ?? DIRECT_CHINESE_STOP_REQUEST.exec(value); - const rawTarget = match?.[1]?.trim(); - if (!rawTarget) return undefined; - const target = stripMatchingStopQuotes(rawTarget.replace(/[.!。!]+\s*$/u, '').trim()); - if (!target || UNSAFE_STOP_TARGET.test(target)) return undefined; - return target; -} - -function directWorkHubStopCue(value: string, malformedLiteral: boolean): boolean { - if (malformedLiteral || /[??]\s*$/u.test(value)) return false; - return Boolean(DIRECT_STOP_REQUEST.test(value) || DIRECT_CHINESE_STOP_REQUEST.test(value)); -} - -/** - * Resume reuses the stop reader's rules: a question is not a command, a - * malformed literal is refused outright, and an anaphoric target — `it`, `它`, - * `这个工作` — reads the cue without claiming a target, so the surface can ask - * which work rather than guess at it. - */ -function directWorkHubResumeTarget(value: string, malformedLiteral: boolean): string | undefined { - if (malformedLiteral || /[??]\s*$/u.test(value)) return undefined; - const match = DIRECT_RESUME_REQUEST.exec(value) ?? DIRECT_CHINESE_RESUME_REQUEST.exec(value); +function directWorkHubNamedAction( + value: string, + malformedLiteral: boolean, + patterns: readonly [RegExp, RegExp], +): { readonly cue: boolean; readonly target?: string } { + if (malformedLiteral || /[??]\s*$/u.test(value)) return { cue: false }; + const match = patterns[0].exec(value) ?? patterns[1].exec(value); const rawTarget = match?.[1]?.trim(); - if (!rawTarget) return undefined; - const target = stripMatchingStopQuotes(rawTarget.replace(/[.!。!]+\s*$/u, '').trim()); - if (!target || UNSAFE_STOP_TARGET.test(target)) return undefined; - return target; -} - -function directWorkHubResumeCue(value: string, malformedLiteral: boolean): boolean { - if (malformedLiteral || /[??]\s*$/u.test(value)) return false; - return Boolean(DIRECT_RESUME_REQUEST.test(value) || DIRECT_CHINESE_RESUME_REQUEST.test(value)); + if (!rawTarget) return { cue: Boolean(match) }; + const target = stripMatchingActionQuotes(rawTarget.replace(/[.!。!]+\s*$/u, '').trim()); + return !target || UNSAFE_STOP_TARGET.test(target) ? { cue: true } : { cue: true, target }; } -function stripMatchingStopQuotes(value: string): string { +function stripMatchingActionQuotes(value: string): string { const pairs = new Map([ ['"', '"'], ["'", "'"], diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 1de371c068..31d79eaf62 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -711,6 +711,131 @@ test('WorkHub creates new work through the production assignment composition', a }); }); +test('WorkHub Stop retires the running continuation after Resume', async () => { + await withCompositionRoot(async ({ root, owner }) => { + const connectionId = await configureFakeDefaultTarget(owner); + const { composition, manager } = await createCapturedExecutionComposition(owner, { + safeBoundaryResume: true, + }); + const context = { + hostEpoch: 'execution-composition-test', + connectionId: 'workhub-resume-stop-client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + let continuation: { turnId: string; runId: string } | undefined; + let targetSessionId: string | undefined; + try { + const target = await manager.createSession({ + cwd: root, + llmConnectionId: connectionId, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + name: 'Payments', + }); + targetSessionId = target.id; + await composition.handlers['workhub.coordination.resolve']({}, context); + const candidates = await composition.handlers['workhub.coordination.candidates']({}, context); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + ); + assert.ok(candidate); + if (!candidate) return; + + const delegated = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-resume-stop-delegation', + userText: FAKE_HOLD_OPEN_PROMPT, + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidate.candidateRef, + }, + }, + context, + ); + assert.equal(delegated.ok, true, JSON.stringify(delegated)); + if (!delegated.ok || delegated.result.disposition !== 'delegate_existing') return; + const original = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: delegated.result.targetTurnId }, + context, + ); + assert.equal(original.ok, true); + if (!original.ok) return; + await composition.handlers['turn.stop']( + { sessionId: target.id, turnId: original.result.turnId, runId: original.result.runId }, + context, + ); + + const resumed = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-resume-stop-resume', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + expects: { targetSessionId: target.id }, + }, + }, + context, + ); + assert.equal(resumed.ok, true, JSON.stringify(resumed)); + if ( + !resumed.ok || + resumed.result.disposition !== 'resume_work' || + !resumed.result.targetTurnId + ) + return; + const resumedTurn = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: resumed.result.targetTurnId }, + context, + ); + assert.equal(resumedTurn.ok, true); + if (!resumedTurn.ok) return; + continuation = { turnId: resumedTurn.result.turnId, runId: resumedTurn.result.runId }; + assert.equal(resumedTurn.result.status, 'running'); + + const stopped = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-resume-stop-stop', + userText: 'Stop Payments', + confirmation: { kind: 'user_stop' }, + proposal: { + disposition: 'stop_work', + expects: { targetSessionId: target.id }, + }, + }, + context, + ); + assert.deepEqual(stopped, { + ok: true, + result: { + disposition: 'stop_work', + outcome: 'stop_delivered', + targetSessionId: target.id, + targetTurnId: continuation.turnId, + }, + }); + const terminal = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: continuation.turnId }, + context, + ); + assert.equal(terminal.ok, true); + if (terminal.ok) assert.equal(terminal.result.status, 'cancelled'); + } finally { + if (continuation && targetSessionId) { + await composition.handlers['turn.stop']( + { sessionId: targetSessionId, ...continuation }, + context, + ); + } + await composition.close(); + } + }); +}); + test('WorkHub correction replaces its link without stopping a shared manual Turn', async () => { await withCompositionRoot(async ({ root, owner }) => { const connectionId = await configureFakeDefaultTarget(owner); @@ -1576,17 +1701,22 @@ async function seedLegacyFakeBackendSession( return sessionId; } -async function createCapturedExecutionComposition(owner: InteractiveRootOwner): Promise<{ +async function createCapturedExecutionComposition( + owner: InteractiveRootOwner, + options: { readonly safeBoundaryResume?: boolean } = {}, +): Promise<{ composition: Awaited>; manager: SessionManager; }> { const originalRecover = SessionManager.prototype.recoverInterruptedSessionsStrict; + const originalSafeBoundaryResume = process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME; let manager: SessionManager | undefined; SessionManager.prototype.recoverInterruptedSessionsStrict = async function (stores) { manager = this; return originalRecover.call(this, stores); }; try { + if (options.safeBoundaryResume) process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = '1'; // The production composition no longer registers a test backend of its // own; the deterministic one arrives through the same `primaryBackendFactory` // seam the Desktop E2E run uses. @@ -1599,6 +1729,11 @@ async function createCapturedExecutionComposition(owner: InteractiveRootOwner): if (!manager) throw new Error('Production execution composition did not construct Runtime'); return { composition, manager }; } finally { + if (originalSafeBoundaryResume === undefined) { + delete process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME; + } else { + process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = originalSafeBoundaryResume; + } SessionManager.prototype.recoverInterruptedSessionsStrict = originalRecover; } } diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 25d02a429a..c0f339eb7a 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -414,7 +414,7 @@ describe('WorkHub Coordination Action Gate', () => { }; const first = await gate.act(input, CONTEXT); - effects.resumeOutcome = { outcome: 'parked' }; + effects.resumeOutcome = { outcome: 'parked', parkReason: 'safety_check_failed' }; const replay = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); assert.deepEqual(replay, first); @@ -511,7 +511,8 @@ describe('WorkHub Coordination Action Gate', () => { ] as const) { const effects = fakeEffects([session('payments', { name: 'Payments' })]); delegatedTo(effects, 'payments'); - effects.resumeOutcome = { outcome }; + effects.resumeOutcome = + outcome === 'parked' ? { outcome, parkReason: 'safety_check_failed' } : { outcome }; const result = await new WorkHubCoordinationActionGate(effects).act( { @@ -526,6 +527,7 @@ describe('WorkHub Coordination Action Gate', () => { disposition: 'resume_work', outcome, targetSessionId: 'payments', + ...(outcome === 'parked' ? { parkReason: 'safety_check_failed' } : {}), ...(targetTurnId ? { targetTurnId } : {}), }); } @@ -2773,11 +2775,10 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { resumeOutcome: { outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', - targetRunId: 'resumed-run', } as { outcome: 'resume_started' | 'already_running' | 'parked'; + parkReason?: 'safety_check_failed'; targetTurnId?: string; - targetRunId?: string; }, async readResumeRequest(actionId: string) { return resumeRequests.get(actionId); @@ -2794,6 +2795,7 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { ...(existingResolution.targetTurnId ? { targetTurnId: existingResolution.targetTurnId } : {}), + ...(existingResolution.parkReason ? { parkReason: existingResolution.parkReason } : {}), }; } if ('request' in input) { @@ -2837,10 +2839,12 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { resumesDelegationId: requested.resumesDelegationId, targetSessionId: requested.targetSessionId, outcome: this.resumeOutcome.outcome, + ...(this.resumeOutcome.outcome === 'parked' + ? { parkReason: this.resumeOutcome.parkReason ?? 'safety_check_failed' } + : {}), ...(this.resumeOutcome.targetTurnId ? { targetTurnId: this.resumeOutcome.targetTurnId } : {}), - ...(this.resumeOutcome.targetRunId ? { targetRunId: this.resumeOutcome.targetRunId } : {}), }; resumeResolutions.set(actionId, resolved); return { @@ -2848,6 +2852,7 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { outcome: resolved.outcome, targetSessionId: resolved.targetSessionId, ...(resolved.targetTurnId ? { targetTurnId: resolved.targetTurnId } : {}), + ...(resolved.parkReason ? { parkReason: resolved.parkReason } : {}), }; }, async readActionClaim(actionId: string) { @@ -3049,8 +3054,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { resumeCalls: WorkHubDelegationResumeInput[]; resumeOutcome: { outcome: 'resume_started' | 'already_running' | 'parked'; + parkReason?: 'safety_check_failed'; targetTurnId?: string; - targetRunId?: string; }; }; } diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index 8629be898a..91a6958872 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -894,7 +894,6 @@ describe('Host WorkHub Coordination coordinator', () => { return { outcome: 'resume_started', targetTurnId: 'resumed-turn', - targetRunId: 'resumed-run', }; }, }); @@ -936,8 +935,8 @@ describe('Host WorkHub Coordination coordinator', () => { 'payments-run', ); assert.equal( - (await store.readWorkHubResumeResolution('resume-action'))?.targetRunId, - 'resumed-run', + (await store.readWorkHubResumeResolution('resume-action'))?.targetTurnId, + 'resumed-turn', ); } finally { await store.close?.(); @@ -961,6 +960,86 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('reports recovery without durably parking a resume action', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-resume-recovering-')); + const store = createSessionStore(root); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + let recovering = true; + const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + assign: (input) => persistTestAssignment(store, input, 'payments-turn'), + planResume: async () => + recovering + ? { kind: 'recovering' } + : { + kind: 'ready', + sourceTurnId: 'payments-turn', + sourceRunId: 'payments-run', + sourceRuntimeEventHighWater: 9, + targetTurnId: 'resumed-turn', + }, + resumeDelegation: async () => ({ + outcome: 'resume_started', + targetTurnId: 'resumed-turn', + }), + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef }, + }, + CONTEXT, + ) + ).ok, + true, + ); + + const input = { + actionId: 'resume-after-recovery', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work' as const, + expects: { targetSessionId: target.id }, + }, + }; + assert.deepEqual(await workhub.handlers['workhub.coordination.act'](input, CONTEXT), { + ok: false, + error: { + code: 'operation_unavailable', + message: 'WorkHub is still recovering the delegated execution', + }, + }); + assert.equal(await store.readWorkHubResumeRequest(input.actionId), undefined); + + recovering = false; + const retried = await workhub.handlers['workhub.coordination.act'](input, CONTEXT); + assert.equal(retried.ok, true); + if (retried.ok && retried.result.disposition === 'resume_work') { + assert.equal(retried.result.outcome, 'resume_started'); + } + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('rechecks sole-delegation stop preconditions after the advisory active-link read', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-race-')); const store = createSessionStore(root); @@ -1746,7 +1825,6 @@ function coordinator( resumeDelegation: async () => ({ outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', - targetRunId: 'resumed-run', }), retireDelegation: async () => ({ outcome: 'cancelled_pending' }), ...sessionActions, diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts index b0da5e34f8..802772cfe0 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -189,6 +189,107 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () ); }); +test('WorkHub Coordination resume has closed input and outcome shapes', () => { + assert.deepEqual( + decodeWorkHubCoordinationActInput({ + actionId: 'action-resume', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + expects: { targetSessionId: 'payments' }, + }, + }), + { + actionId: 'action-resume', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + expects: { targetSessionId: 'payments' }, + }, + }, + ); + + for (const invalid of [ + { + actionId: 'action-resume-confirmed', + userText: 'Resume Payments', + proposal: { disposition: 'resume_work', expects: { targetSessionId: 'payments' } }, + confirmation: { kind: 'user_stop' }, + }, + { + actionId: 'action-resume-missing-target', + userText: 'Resume Payments', + proposal: { disposition: 'resume_work' }, + }, + { + actionId: 'action-resume-injected', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + expects: { targetSessionId: 'payments' }, + targetSessionId: 'injected', + }, + }, + ]) { + assert.throws( + () => decodeWorkHubCoordinationActInput(invalid), + (error) => error instanceof RuntimeHostProtocolError, + ); + } + + for (const result of [ + { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: 'payments', + targetTurnId: 'turn-2', + }, + { + disposition: 'resume_work', + outcome: 'already_running', + targetSessionId: 'payments', + }, + { + disposition: 'resume_work', + outcome: 'parked', + targetSessionId: 'payments', + parkReason: 'safety_check_failed', + }, + ]) { + assert.deepEqual(decodeWorkHubCoordinationActResult(result), result); + } + + for (const invalid of [ + { + disposition: 'resume_work', + outcome: 'parked', + targetSessionId: 'payments', + }, + { + disposition: 'resume_work', + outcome: 'parked', + targetSessionId: 'payments', + parkReason: 'invented', + }, + { + disposition: 'resume_work', + outcome: 'already_running', + targetSessionId: 'payments', + parkReason: 'safety_check_failed', + }, + { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: 'payments', + }, + ]) { + assert.throws( + () => decodeWorkHubCoordinationActResult(invalid), + (error) => error instanceof RuntimeHostProtocolError, + ); + } +}); + test('WorkHub Coordination candidates are bounded and carry opaque proposal identities', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 110); const result = decodeWorkHubCoordinationCandidatesResult({ diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index dafa428649..5f3dc33cda 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -35,6 +35,10 @@ import { decodeSkillInvocationResult, type SkillInvocationResult, } from '@maka/core/skill-invocation'; +import { + SAFE_BOUNDARY_RESUME_PARK_REASONS, + type SafeBoundaryResumeParkReason, +} from '@maka/core/runtime-invocation'; import { invalidProtocolFrame } from './errors.js'; import { assertExactKeys, @@ -113,20 +117,9 @@ export interface TurnResumeStartInput { sourceRuntimeEventHighWater: number; } -export const TURN_RESUME_PARK_REASONS = [ - 'resume_candidate_missing', - 'source_run_unreadable', - 'safety_check_failed', - 'continuation_already_exists', - 'continuation_repair_required', - 'continuation_started_indeterminate', - 'resume_feature_disabled', - 'continuation_authority_unavailable', - 'safety_observation_unavailable', - 'session_busy', -] as const; - -export type TurnResumeParkReason = (typeof TURN_RESUME_PARK_REASONS)[number]; +export const TURN_RESUME_PARK_REASONS = SAFE_BOUNDARY_RESUME_PARK_REASONS; + +export type TurnResumeParkReason = SafeBoundaryResumeParkReason; export type TurnResumePlan = | { diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index d356c438e7..7b79f52d30 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -17,6 +17,11 @@ * under the License. */ +import { + SAFE_BOUNDARY_RESUME_PARK_REASONS, + type SafeBoundaryResumeParkReason, +} from '@maka/core/runtime-invocation'; + import { requireCount, requireEntityId, @@ -234,6 +239,7 @@ export type WorkHubCoordinationActResult = readonly outcome: 'resume_started' | 'already_running' | 'parked'; readonly targetSessionId: string; readonly targetTurnId?: string; + readonly parkReason?: SafeBoundaryResumeParkReason; }; export const WORKHUB_COORDINATION_OPERATION_SPECS = { @@ -566,7 +572,7 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord result, 'WorkHub Coordination resume result', ['disposition', 'outcome', 'targetSessionId'], - ['targetTurnId'], + ['targetTurnId', 'parkReason'], ); if ( exact.outcome !== 'resume_started' && @@ -580,6 +586,13 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord if ((exact.outcome === 'resume_started') !== (exact.targetTurnId !== undefined)) { throw invalidProtocolFrame('Invalid WorkHub resume target Turn'); } + if ( + exact.outcome === 'parked' + ? !(SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(exact.parkReason) + : exact.parkReason !== undefined + ) { + throw invalidProtocolFrame('Invalid WorkHub resume park reason'); + } return { disposition: 'resume_work', outcome: exact.outcome, @@ -589,6 +602,9 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord : { targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), }), + ...(exact.parkReason === undefined + ? {} + : { parkReason: exact.parkReason as SafeBoundaryResumeParkReason }), }; } throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 014bfdbb13..37934a999c 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1391,68 +1391,55 @@ export async function createExecutionRuntimeHostComposition( turnId: disposition.turnId, runId: disposition.runId, }; - if (isActiveWorkHubRoot(coordinator, identity)) return 'not_retired'; + const latest = await coordinator.readLatestRootTurnLineage(identity); + if (isActiveWorkHubRoot(coordinator, latest)) return 'not_retired'; // The same restart window as `stopOwnedWorkHubRoot`: an unregistered // root is not evidence that its work ended. - const snapshot = await coordinator.read(identity); + const snapshot = await coordinator.read(latest); return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; }, // Resolve only the execution lineage owned by this delegation. A // Session-wide latest-failure query could otherwise continue unrelated // work started directly in the same Session. - planResume: async (assignment, previous, context) => { - let source: - | { readonly sessionId: string; readonly turnId: string; readonly runId: string } - | undefined; - if ( - previous?.outcome === 'resume_started' && - previous.targetTurnId && - previous.targetRunId - ) { - source = { - sessionId: assignment.targetSessionId, - turnId: previous.targetTurnId, - runId: previous.targetRunId, - }; - } else { - const disposition = await messages.readMessageExecutionDisposition( - assignment.targetSessionId, - assignment.targetMessageId, - ); - if (disposition.kind !== 'owned_root') return { kind: 'parked' as const }; - source = { - sessionId: assignment.targetSessionId, - turnId: disposition.turnId, - runId: disposition.runId, - }; - } - let snapshot; - try { - snapshot = await coordinator.read(source); - } catch { - return { kind: 'parked' as const }; + planResume: async (assignment, context) => { + const disposition = await messages.readMessageExecutionDisposition( + assignment.targetSessionId, + assignment.targetMessageId, + ); + if (disposition.kind === 'recovering') return { kind: 'recovering' as const }; + if (disposition.kind !== 'owned_root') { + return { kind: 'parked' as const, parkReason: 'resume_candidate_missing' as const }; } - if ( - snapshot.status === 'admitted' || - snapshot.status === 'created' || - snapshot.status === 'running' - ) { + const source = await coordinator.readLatestRootTurnLineage({ + sessionId: assignment.targetSessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }); + if (isActiveWorkHubRoot(coordinator, source)) { return { kind: 'already_running' as const }; } + const snapshot = await coordinator.read(source); + if (!isHostedExecutionTerminal(snapshot)) return { kind: 'recovering' as const }; if (snapshot.status !== 'failed' && snapshot.status !== 'cancelled') { - return { kind: 'parked' as const }; + return { kind: 'parked' as const, parkReason: 'resume_candidate_missing' as const }; } const plan = await coordinator.handlers['turn.resume.query']( { sessionId: assignment.targetSessionId, sourceRunId: source.runId }, context, ); + if (!plan.ok) throw new WorkHubActionEffectFailure(plan.error.code, plan.error.message); + if (plan.result.disposition === 'parked') { + return { kind: 'parked' as const, parkReason: plan.result.reason }; + } if ( - !plan.ok || - plan.result.disposition === 'parked' || plan.result.sourceRunId !== source.runId || plan.result.sourceTurnId !== source.turnId - ) - return { kind: 'parked' as const }; + ) { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub resume source lineage changed during planning', + ); + } return { kind: 'ready' as const, sourceTurnId: plan.result.sourceTurnId, @@ -1467,8 +1454,12 @@ export async function createExecutionRuntimeHostComposition( !request.sourceRunId || request.sourceRuntimeEventHighWater === undefined || !request.targetTurnId - ) - return { outcome: 'parked' as const }; + ) { + throw new WorkHubActionEffectFailure( + 'persistence_failed', + 'WorkHub durable resume plan is incomplete', + ); + } const started = await coordinator.handlers['turn.resume.start']( { sessionId: request.targetSessionId, @@ -1478,13 +1469,15 @@ export async function createExecutionRuntimeHostComposition( }, context, ); - if (!started.ok || started.result.kind === 'parked') { - return { outcome: 'parked' as const }; + if (!started.ok) { + throw new WorkHubActionEffectFailure(started.error.code, started.error.message); + } + if (started.result.kind === 'parked') { + return { outcome: 'parked' as const, parkReason: started.result.plan.reason }; } return { outcome: 'resume_started' as const, targetTurnId: started.result.turn.turnId, - targetRunId: started.result.turn.runId, }; }, retireDelegation: async (assignment, retirement) => { @@ -1506,11 +1499,11 @@ export async function createExecutionRuntimeHostComposition( return { outcome: 'not_owned' as const, targetTurnId: disposition.turnId }; } if (disposition.kind === 'owned_root') { - const identity = { + const identity = await coordinator.readLatestRootTurnLineage({ sessionId: assignment.targetSessionId, turnId: disposition.turnId, runId: disposition.runId, - }; + }); return retirement.cause === 'direct_stop' ? stopOwnedWorkHubRoot(coordinator, identity, retirement.cancellationClaimId) : stopReplacedWorkHubRoot(coordinator, identity); diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index f5db2de586..60ed63b2a8 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -519,6 +519,33 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { return this.#admissions.has(sessionId) ? { kind: 'reserved' } : { kind: 'idle' }; } + /** Returns the newest Host-admitted continuation descended from one root execution. */ + async readLatestRootTurnLineage(identity: HostedExecutionRef): Promise { + const admissions = await this.stores.agentRunStore.listRootTurnAdmissionsForRecovery( + identity.sessionId, + ); + const originIndex = admissions.findIndex( + ({ turnId, runId }) => turnId === identity.turnId && runId === identity.runId, + ); + if (originIndex === -1) { + throw new RuntimeMessageAuthorityInvariantError( + `Root execution ${identity.turnId}/${identity.runId} has no durable admission`, + ); + } + let latest = admissions[originIndex]!; + for (const admission of admissions.slice(originIndex + 1)) { + const execution = admission.execution; + if ( + execution.kind === 'safe_boundary_continuation' && + execution.sourceTurnId === latest.turnId && + execution.sourceRunId === latest.runId + ) { + latest = admission; + } + } + return { sessionId: latest.sessionId, turnId: latest.turnId, runId: latest.runId }; + } + startHostedExternalTransition( input: HostedExternalTurnTransitionInput, context: ConnectionContext, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 9113de3a11..a406fc32e8 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -48,6 +48,7 @@ import { } from '@maka/storage/session-store'; import type { OperationOutcome, + TurnResumeParkReason, WorkHubCoordinationActResult, WorkHubCoordinationActInput, WorkHubCoordinationAnswerInput, @@ -129,7 +130,9 @@ type CoordinationExecutions = Pick< >; type WorkHubResumePlan = - | { readonly kind: 'already_running' | 'parked' } + | { readonly kind: 'already_running' } + | { readonly kind: 'recovering' } + | { readonly kind: 'parked'; readonly parkReason: TurnResumeParkReason } | { readonly kind: 'ready'; readonly sourceTurnId: string; @@ -138,11 +141,12 @@ type WorkHubResumePlan = readonly targetTurnId: string; }; -interface WorkHubResumeResult { - readonly outcome: 'resume_started' | 'already_running' | 'parked'; - readonly targetTurnId?: string; - readonly targetRunId?: string; -} +type WorkHubResumeResult = + | { + readonly outcome: 'resume_started'; + readonly targetTurnId: string; + } + | { readonly outcome: 'parked'; readonly parkReason: TurnResumeParkReason }; type CoordinationSessionActions = Pick< WorkHubActionGateEffects, @@ -156,7 +160,6 @@ type CoordinationSessionActions = Pick< ): ReturnType; planResume( assignment: WorkHubDelegationAssignedMessage, - previous: WorkHubDelegationResumeResolvedMessage | undefined, context: ConnectionContext, ): Promise; resumeDelegation( @@ -441,23 +444,20 @@ export class HostWorkHubCoordinationCoordinator { if ('request' in input) { request = input.request; } else { - const previous = (await this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)) - .filter( - (message): message is WorkHubDelegationResumeResolvedMessage => - message.type === 'workhub_coordination' && - message.kind === 'delegation_resume_resolved' && - message.resumesDelegationId === input.source.delegationId && - message.outcome === 'resume_started', - ) - .at(-1); - const plan = await actions.planResume(input.source, previous, context); + const plan = await actions.planResume(input.source, context); + if (plan.kind === 'recovering') { + throw new WorkHubActionEffectFailure( + 'operation_unavailable', + 'WorkHub is still recovering the delegated execution', + ); + } const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); request = await this.#commitCoordinationFact({ admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.source.targetSessionId], read: () => this.#stores.readWorkHubResumeRequest(input.actionId), build: (existing) => ({ type: 'workhub_coordination', - id: `whr_${suffix}`, + id: `whu_${suffix}`, turnId: input.actionId, ts: existing?.ts ?? Date.now(), schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, @@ -472,6 +472,7 @@ export class HostWorkHubCoordinationCoordinator { targetSessionName: input.targetSessionName, userText: input.userText, plan: plan.kind, + ...(plan.kind === 'parked' ? { parkReason: plan.parkReason } : {}), ...(plan.kind === 'ready' ? { sourceTurnId: plan.sourceTurnId, @@ -500,10 +501,12 @@ export class HostWorkHubCoordinationCoordinator { const existing = await this.#stores.readWorkHubResumeResolution(request.actionId); if (existing) return coordinationResumeResult(existing); - const resumed: WorkHubResumeResult = + const resumed = request.plan === 'ready' ? await actions.resumeDelegation(request, context) - : { outcome: request.plan }; + : request.plan === 'parked' + ? { outcome: 'parked' as const, parkReason: request.parkReason! } + : { outcome: 'already_running' as const }; const suffix = createHash('sha256').update(request.actionId, 'utf8').digest('hex').slice(0, 48); const resolution = await this.#commitCoordinationFact({ read: () => this.#stores.readWorkHubResumeResolution(request.actionId), @@ -521,8 +524,8 @@ export class HostWorkHubCoordinationCoordinator { resumesDelegationId: request.resumesDelegationId, targetSessionId: request.targetSessionId, outcome: resumed.outcome, - ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), - ...(resumed.targetRunId ? { targetRunId: resumed.targetRunId } : {}), + ...(resumed.outcome === 'parked' ? { parkReason: resumed.parkReason } : {}), + ...(resumed.outcome === 'resume_started' ? { targetTurnId: resumed.targetTurnId } : {}), }), conflictMessage: 'WorkHub resume already has a different resolution', beforeAppend: async () => { @@ -1071,6 +1074,7 @@ function coordinationResumeResult( outcome: resolution.outcome, targetSessionId: resolution.targetSessionId, ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), + ...(resolution.parkReason ? { parkReason: resolution.parkReason } : {}), }; } diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 6f3451dc91..ecc4a21cdd 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -769,7 +769,7 @@ class SqliteSessionStore implements SessionAuthorityStore { actionId: string, ): Promise { const message = await this.readWorkHubCoordinationMessage( - `whr_${workHubIdentitySuffix(actionId)}`, + `whu_${workHubIdentitySuffix(actionId)}`, ); return message?.type === 'workhub_coordination' && message.kind === 'delegation_resume_requested' From c31b8f2a6717f7748a6ff836f2c32541fb2dd1f0 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 15:56:34 +0800 Subject: [PATCH 09/12] style(core): format resume record validation --- packages/core/src/session.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 07ea5a08b9..38287f7d37 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1726,8 +1726,7 @@ function isWorkHubCoordinationMessage(message: Record): boolean ? (SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(message.parkReason) : message.parkReason === undefined) && (started - ? typeof message.targetTurnId === 'string' && - message.targetTurnId.length > 0 + ? typeof message.targetTurnId === 'string' && message.targetTurnId.length > 0 : message.targetTurnId === undefined) ); } From db17b3f33c2d4cdc84ccc0bc7e51ca894b0a9a47 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 17:46:19 +0800 Subject: [PATCH 10/12] refactor(workhub): collapse resume coordination --- .../main/__tests__/workhub-controller.test.ts | 79 ++------ .../__tests__/workhub-session-port.test.ts | 29 +-- .../__tests__/workhub-surface-flow.test.ts | 31 --- .../src/renderer/workhub-controller.ts | 38 ++-- .../src/renderer/workhub-coordination-port.ts | 22 +-- .../src/renderer/workhub-route-policy.ts | 39 +--- apps/desktop/src/renderer/workhub-surface.tsx | 43 ++--- .../workhub-coordination-record.test.ts | 49 ++--- packages/core/src/runtime-invocation.ts | 15 -- packages/core/src/session.ts | 148 +++----------- packages/core/src/workhub-creation-intent.ts | 2 - .../__tests__/execution-composition.test.ts | 111 ++++++++--- .../__tests__/root-admission-owner.test.ts | 4 + .../__tests__/root-turn-coordinator.test.ts | 6 + .../workhub-coordination-action-gate.test.ts | 182 ++++++------------ .../workhub-coordination-coordinator.test.ts | 80 +++----- .../workhub-coordination-protocol.test.ts | 12 -- packages/runtime-host/src/protocol/turn.ts | 21 +- .../src/protocol/workhub-coordination.ts | 32 +-- .../src/server/execution-composition.ts | 84 ++++---- .../src/server/root-turn-coordinator.ts | 27 ++- .../workhub-coordination-action-gate.ts | 114 +++-------- .../workhub-coordination-coordinator.ts | 135 +++---------- ...fe-boundary-continuation-admission.test.ts | 79 ++++++++ packages/storage/src/agent-run-store.ts | 44 +++++ packages/storage/src/execution-stores.ts | 18 +- packages/storage/src/session-store.ts | 28 +-- .../src/sqlite-core-execution-schema.ts | 10 +- 28 files changed, 580 insertions(+), 902 deletions(-) create mode 100644 packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 1111ea248f..2a4aa15a85 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -473,37 +473,6 @@ test('a named resume submits and reports what the Host did', async () => { await handle.close(); }); -test('a parked resume preserves the Host park reason', async () => { - const controller = createGatedWorkHubController({ - sessions: port([session('payments', { sessionName: 'Payments' })]), - coordination: { - open: async (handler) => { - handler([], []); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => assert.fail('a resume must not read route candidates'), - act: async () => ({ - disposition: 'resume_work', - outcome: 'parked', - targetSessionId: 'payments', - parkReason: 'safety_check_failed', - }), - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - assert.deepEqual(await controller.submit({ requestId: 'resume-parked', text: 'Resume Payments' }), { - kind: 'resume', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'resume-parked', - target: { sessionId: 'payments' }, - outcome: 'parked', - parkReason: 'safety_check_failed', - }); - await handle.close(); -}); - test('a resume the Host will not admit becomes its clarification', async () => { const sessions = port([session('payments', { sessionName: 'Payments' })]); const controller = createGatedWorkHubController({ @@ -536,52 +505,34 @@ test('a resume the Host will not admit becomes its clarification', async () => { await handle.close(); }); -test('resume-shaped ordinary work routes normally instead of resuming', async () => { - // `Continue` is an ordinary English verb. A resume that recalls no WorkHub - // identity is work to do, not a command over a delegation. - const sessions = port([session('payments', { sessionName: 'Payments' })]); - const actions: WorkHubCoordinationActInput[] = []; +test('a Runtime Host without safe-boundary resume explains why it cannot resume', async () => { const controller = createGatedWorkHubController({ - sessions, + sessions: port([session('payments', { sessionName: 'Payments' })]), coordination: { open: async (handler) => { handler([], []); return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'c'.repeat(64)}`, - candidates: [{ - candidateRef: 'ref-payments', - sessionId: 'payments', - sessionName: 'Payments', - workspace: { - target: { kind: 'host_path' as const, path: '/workspace/payments' }, - hostCwd: '/workspace/payments', - }, - state: 'active' as const, - updatedAt: 1, - }], - }), - act: async (input) => { - actions.push(input); - return { - disposition: 'delegate_existing', - targetSessionId: 'payments', - targetTurnId: 'delegated-turn', - }; + candidates: async () => assert.fail('a resume must not read route candidates'), + act: async () => { + throw new WorkHubCoordinationFailure( + 'operation_unavailable', + 'Safe-boundary resume is disabled for this Runtime Host', + ); }, }, }); const handle = await controller.openConversation(() => undefined, () => undefined); - const result = await controller.submit({ - requestId: 'ordinary-1', - text: 'Continue the refactor in Payments', + assert.deepEqual(await controller.submit({ requestId: 'resume-disabled', text: 'Resume Payments' }), { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'resume-disabled', + text: 'Resume Payments', + options: [], + reason: 'resume_target_unavailable', }); - - assert.equal(result.kind, 'submitted'); - assert.equal(actions[0]?.proposal.disposition, 'delegate_existing'); await handle.close(); }); diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index 10e468e161..753f37e996 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -318,33 +318,18 @@ test('direct-stop projection is retryable until resolved and preserves not_owned }); test('resume projection survives reload and exposes its durable outcome', () => { - const requested: StoredMessage = { - type: 'workhub_coordination', id: 'resume-request', turnId: 'resume-action', ts: 2, - schemaVersion: 4, kind: 'delegation_resume_requested', actionId: 'resume-action', - actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'resume-action', - resumesActionId: 'source-action', resumesDelegationId: 'payments-delegation', - targetSessionId: 'payments', targetMessageId: 'payments-message', - targetSessionName: 'Payments', userText: 'Resume Payments', plan: 'ready', - sourceTurnId: 'failed-turn', sourceRunId: 'failed-run', - sourceRuntimeEventHighWater: 4, targetTurnId: 'resumed-turn', - }; - const resolved: StoredMessage = { - type: 'workhub_coordination', id: 'resume-resolution', turnId: 'resume-action', ts: 3, - schemaVersion: 4, kind: 'delegation_resume_resolved', actionId: 'resume-action', + const resumed: StoredMessage = { + type: 'workhub_coordination', id: 'resume', turnId: 'resume-action', ts: 3, + schemaVersion: 4, kind: 'delegation_resume', actionId: 'resume-action', actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'resume-action', resumesActionId: 'source-action', resumesDelegationId: 'payments-delegation', - targetSessionId: 'payments', outcome: 'resume_started', + targetSessionId: 'payments', + targetSessionName: 'Payments', userText: 'Resume Payments', outcome: 'resume_started', targetTurnId: 'resumed-turn', }; - assert.deepEqual(projectWorkHubCoordinationTurns([requested]), [{ - messageId: 'resume-request', turnId: 'resume-action', text: 'Resume Payments', - state: 'running', - resume: { targetSessionId: 'payments', targetSessionName: 'Payments' }, - updatedAt: 2, - }]); - assert.deepEqual(projectWorkHubCoordinationTurns([requested, resolved]), [{ - messageId: 'resume-request', turnId: 'resume-action', text: 'Resume Payments', + assert.deepEqual(projectWorkHubCoordinationTurns([resumed]), [{ + messageId: 'resume', turnId: 'resume-action', text: 'Resume Payments', state: 'completed', resume: { targetSessionId: 'payments', targetSessionName: 'Payments', outcome: 'resume_started', diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts index fcb62fe77a..e88af5b222 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -210,37 +210,6 @@ test('durable delegation renders terminal link state instead of stale execution } }); -test('a parked resume owns the durable frame state', () => { - const turn: WorkHubCoordinationTurn = { - messageId: 'resume-parked', - turnId: 'resume-parked', - text: 'Resume Payments', - state: 'completed', - resume: { - targetSessionId: 'payments', - targetSessionName: 'Payments', - outcome: 'parked', - }, - updatedAt: 10, - }; - const markup = renderToStaticMarkup( - createElement(LocaleProvider, { - locale: 'en', - children: createElement(AstryxLocaleProvider, { - children: createElement(WorkHubCoordinationTurnView, { - turn, - projection: { sessions: [], turns: [] }, - locale: 'en', - onOpenSession: () => undefined, - }), - }), - }), - ); - - assert.match(markup, /data-state="parked"/u); - assert.doesNotMatch(markup, /data-state="completed"/u); -}); - test('durable creation explicitly announces the new work', () => { const turn: WorkHubCoordinationTurn = { messageId: 'created-assignment', diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index e8c534c9ab..ab92e39e94 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -136,7 +136,7 @@ export interface WorkHubCoordinationTurn { resume?: { readonly targetSessionId: string; readonly targetSessionName: string; - readonly outcome?: Extract['outcome']; + readonly outcome: Extract['outcome']; }; updatedAt: number; } @@ -227,10 +227,6 @@ export type WorkHubSubmission = ( target: WorkHubSessionTarget; outcome: Extract['outcome']; targetTurnId?: string; - parkReason?: Extract< - WorkHubCoordinationActResult, - { disposition: 'resume_work' } - >['parkReason']; } ) & { strategyId: WorkHubRoutingStrategyId }; @@ -402,29 +398,43 @@ export function createWorkHubController(deps: { }, ...(kind === 'stop' ? { confirmation: { kind: 'user_stop' as const } } : {}), }); + const result = { + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + }; if (kind === 'resume' && admitted.disposition === 'resume_work') { return { - kind, - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - target, + ...result, + kind: 'resume', outcome: admitted.outcome, ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), - ...(admitted.parkReason ? { parkReason: admitted.parkReason } : {}), }; } if (kind === 'stop' && admitted.disposition === 'stop_work') { return { - kind, - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - target, + ...result, + kind: 'stop', outcome: admitted.outcome, ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), }; } throw new Error('WorkHub Action Gate returned an unexpected disposition'); } catch (error) { + if ( + kind === 'resume' && + error instanceof WorkHubCoordinationFailure && + (error.code === 'operation_unavailable' || error.code === 'host_not_ready') + ) { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: 'resume_target_unavailable', + }; + } if (error instanceof WorkHubCoordinationFailure && error.code === 'operation_conflict') { return { kind: 'clarification', diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 5f2febe784..6d1bb8201b 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -40,12 +40,6 @@ import type { } from '@maka/runtime-host/protocol'; import { boundedWorkHubTimelineText, WorkHubCoordinationFailure } from './workhub-controller.js'; -export function isWorkHubCoordinationFailure( - error: unknown, -): error is WorkHubCoordinationFailure { - return error instanceof WorkHubCoordinationFailure; -} - import type { WorkHubDesktopTranscriptBridge } from './workhub-session-port.js'; const WORKHUB_COORDINATION_TURN_LIMIT = 40; @@ -156,32 +150,24 @@ export function projectWorkHubCoordinationTurns( : [], ), ); - const resumeResolutionByActionId = new Map( - messages.flatMap((message) => - message.type === 'workhub_coordination' && message.kind === 'delegation_resume_resolved' - ? [[message.actionId, message] as const] - : [], - ), - ); for (const message of messages) { const terminal = terminalDelegationLink(message); if (terminal) terminalLinkState.set(terminal.delegationId, terminal.state); } for (const message of messages) { - if (message.type === 'workhub_coordination' && message.kind === 'delegation_resume_requested') { - const resolution = resumeResolutionByActionId.get(message.actionId); + if (message.type === 'workhub_coordination' && message.kind === 'delegation_resume') { turns.push({ messageId: message.id, turnId: message.coordinationTurnId, text: boundedWorkHubTimelineText(message.userText), - state: resolution ? 'completed' : 'running', + state: 'completed', resume: { targetSessionId: message.targetSessionId, targetSessionName: message.targetSessionName, - ...(resolution ? { outcome: resolution.outcome } : {}), + outcome: message.outcome, }, - updatedAt: resolution ? Math.max(message.ts, resolution.ts) : message.ts, + updatedAt: message.ts, }); continue; } diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index 68733abb80..8a47325efe 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -100,11 +100,6 @@ export interface WorkHubRoutePolicy { text: string; sessions: WorkHubRoutableSession[]; }): WorkHubNamedActionRouteDecision; - /** - * Resume reads the same way a stop does and refuses on the same terms. The - * two share one resolver and one tail rule so `Resume Payments` and - * `Stop Payments` cannot disagree about which Session they mean. - */ resolveResume(input: { text: string; sessions: WorkHubRoutableSession[]; @@ -145,31 +140,12 @@ const MIN_STRONG_SINGLE_LATIN_LENGTH = 8; const MAX_UNCERTAINTY_OPTIONS = 5; const MAX_RELATED_CLARIFICATION_OPTIONS = 4; -/** - * The reference half of a direct action over one existing delegation. - * - * Action Intent says only that the user issued this imperative and what work it - * refers to; the shared Session Resolver recalls which visible Sessions that - * reference names; this decides whether the resolution is sufficient to submit. - * - * Stop and resume ask exactly this, so they share it. What the Host then does - * with the named Session — end its delegation, or carry it on — is the Host's, - * and neither action claims to know whether there is one to act on. - */ function resolveNamedDelegationAction( sessionResolver: WorkHubSessionResolver, - action: { readonly requested: boolean; readonly target?: string }, + reference: string, sessions: WorkHubRoutableSession[], ambiguousReason: WorkHubStopClarificationReason, - unnamedReason?: WorkHubStopClarificationReason, ): WorkHubNamedActionRouteDecision { - if (!action.requested) return { kind: 'not_requested' }; - const reference = action.target; - if (!reference) { - return unnamedReason - ? { kind: 'clarification', reason: unnamedReason } - : { kind: 'not_requested' }; - } const sessionByRef = new Map(sessions.map((session) => [session.target.sessionId, session])); const resolution = sessionResolver.resolve({ reference: { text: reference }, @@ -228,22 +204,23 @@ function createWorkHubRoutePolicyVisit( // stoppable says why. resolveStop({ text, sessions }) { const action = readWorkHubRequestIntent(text).stop; + if (!action.cue) return { kind: 'not_requested' }; + if (!action.imperative || !action.target) { + return { kind: 'clarification', reason: 'stop_target_required' }; + } return resolveNamedDelegationAction( sessionResolver, - { requested: action.cue, ...(action.imperative ? { target: action.target } : {}) }, + action.target, sessions, 'stop_target_ambiguous', - 'stop_target_required', ); }, - // Resume asks the same question of the same words, so it asks it with the - // same code. Two copies of this would be two chances for `Resume Payments` - // and `Stop Payments` to disagree about which Session they name. resolveResume({ text, sessions }) { const action = readWorkHubRequestIntent(text).resume; + if (!action.imperative || !action.target) return { kind: 'not_requested' }; return resolveNamedDelegationAction( sessionResolver, - { requested: action.imperative, ...(action.target ? { target: action.target } : {}) }, + action.target, sessions, 'resume_target_ambiguous', ); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 05f0316278..1a2960cb4c 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -27,20 +27,20 @@ import { import { Button } from '@astryxdesign/core/Button'; import type { UiLocale } from '@maka/core/ui-locale'; import { ChatSurfaceLayout, Composer } from '@maka/ui'; -import type { - WorkHubController, - WorkHubCoordinationTurn, - WorkHubDelegationLinkState, - WorkHubProjection, - WorkHubSessionSummary, - WorkHubSubmission, - WorkHubSubmitInput, +import { + WorkHubCoordinationFailure, + type WorkHubController, + type WorkHubCoordinationTurn, + type WorkHubDelegationLinkState, + type WorkHubProjection, + type WorkHubSessionSummary, + type WorkHubSubmission, + type WorkHubSubmitInput, } from './workhub-controller.js'; import { WorkHubSendLease, type WorkHubSendAttempt, } from './workhub-send-lease.js'; -import { isWorkHubCoordinationFailure } from './workhub-coordination-port.js'; export interface WorkHubConversationTurn { requestId: string; @@ -95,7 +95,7 @@ export function workHubSubmissionClearsDraft( } export function workHubSurfaceFailure(error: unknown): WorkHubSurfaceFailure { - if (isWorkHubCoordinationFailure(error)) { + if (error instanceof WorkHubCoordinationFailure) { if (error.code === 'operation_conflict') return 'action_changed'; if (error.code === 'not_found' || error.code === 'session_archived') { return 'candidates_changed'; @@ -467,7 +467,7 @@ export function WorkHubSurface(props: { function isTerminalWorkHubSurfaceFailure(error: unknown): boolean { return ( - isWorkHubCoordinationFailure(error) && + error instanceof WorkHubCoordinationFailure && (error.code === 'operation_conflict' || error.code === 'not_found' || error.code === 'session_archived' || @@ -611,10 +611,8 @@ export function WorkHubCoordinationTurnView(props: { session={resumedSession} targetSessionId={props.turn.resume.targetSessionId} fallbackName={props.turn.resume.targetSessionName} - heading={props.turn.resume.outcome - ? copy.resumeOutcomes[props.turn.resume.outcome] - : copy.resumingWork} - state={props.turn.resume.outcome ? copy.resumeRecorded : copy.resuming} + heading={copy.resumeOutcomes[props.turn.resume.outcome]} + state={copy.resumeRecorded} result={undefined} copy={copy} onOpenSession={props.onOpenSession} @@ -895,11 +893,10 @@ function workHubCopy(locale: UiLocale) { resumeOutcomes: { resume_started: '已让中断的工作继续:', already_running: '这项工作还在跑,不需要恢复:', - parked: '无法继续这项工作;请打开该 Session 查看原因:', }, - resumingWork: '正在请求继续:', resuming: '正在处理', resumeRecorded: '结果已记录', + resumeRecorded: '结果已记录', resumeTargetAmbiguous: '这个名称对应多项工作;请打开具体的 Session 继续它。', - resumeTargetUnavailable: '这项工作现在没有可以继续的单个 WorkHub 委派;请打开该 Session 查看。', + resumeTargetUnavailable: '当前 Runtime Host 无法继续这项工作;安全边界恢复可能未启用,或 Host 仍在恢复。', waitingForDecision: '这项工作正在等待你的决定。', requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', @@ -972,11 +969,10 @@ function workHubCopy(locale: UiLocale) { resumeOutcomes: { resume_started: '已讓中斷的工作繼續:', already_running: '這項工作仍在執行,不需要恢復:', - parked: '無法繼續這項工作;請開啟該 Session 檢視原因:', }, - resumingWork: '正在請求繼續:', resuming: '正在處理', resumeRecorded: '結果已記錄', + resumeRecorded: '結果已記錄', resumeTargetAmbiguous: '這個名稱對應多項工作;請開啟具體的 Session 繼續它。', - resumeTargetUnavailable: '這項工作現在沒有可以繼續的單一 WorkHub 委派;請開啟該 Session 檢視。', + resumeTargetUnavailable: '目前 Runtime Host 無法繼續這項工作;安全邊界恢復可能未啟用,或 Host 仍在恢復。', submitFailures: { candidates_changed: '工作清單已變更,請重新傳送以使用最新目標。', linked_correction_unavailable: '跨 Session 更正將於持久委派關聯完成後開放;請先開啟原 Session 並停止目前工作。', @@ -1035,13 +1031,12 @@ function workHubCopy(locale: UiLocale) { resumeOutcomes: { resume_started: 'Carried on the interrupted work:', already_running: 'This work is still running, so there was nothing to resume:', - parked: 'Could not carry this work on. Open its Session to see why:', }, - resumingWork: 'Requesting resume:', resuming: 'Resuming', resumeRecorded: 'Result recorded', + resumeRecorded: 'Result recorded', resumeTargetAmbiguous: 'That name matches more than one work item. Open the exact Session to resume it.', resumeTargetUnavailable: - 'This work has no single WorkHub delegation to resume right now. Open its Session to see what is running.', + 'The current Runtime Host cannot resume this work. Safe-boundary resume may be disabled, or the Host may still be recovering.', waitingForDecision: 'This work is waiting for your decision.', requestNotSent: 'The new request was not sent. Resolve the interaction in its Session, then send again.', routing: 'Choosing the right Session…', loadFailed: 'Could not read existing work.', diff --git a/packages/core/src/__tests__/workhub-coordination-record.test.ts b/packages/core/src/__tests__/workhub-coordination-record.test.ts index 04037f7e01..9a9ee1e51a 100644 --- a/packages/core/src/__tests__/workhub-coordination-record.test.ts +++ b/packages/core/src/__tests__/workhub-coordination-record.test.ts @@ -262,64 +262,39 @@ describe('WorkHub Coordination stored records', () => { ); }); - test('decodes exact resume plan and observed resolution records', () => { - const requested = { + test('decodes one exact observed resume record', () => { + const resumed = { type: 'workhub_coordination', - id: 'resume-request-id', + id: 'resume-id', turnId: 'resume-action', ts: 6, schemaVersion: 4, - kind: 'delegation_resume_requested', + kind: 'delegation_resume', actionId: 'resume-action', actionFingerprint: FINGERPRINT, coordinationTurnId: 'resume-action', resumesActionId: 'original-action', resumesDelegationId: 'original-delegation', targetSessionId: 'payments', - targetMessageId: 'payments-message', targetSessionName: 'Payments', userText: 'Resume Payments', - plan: 'ready', - sourceTurnId: 'failed-turn', - sourceRunId: 'failed-run', - sourceRuntimeEventHighWater: 12, - targetTurnId: 'resumed-turn', - } as const; - const resolved = { - type: 'workhub_coordination', - id: 'resume-resolution-id', - turnId: 'resume-action', - ts: 7, - schemaVersion: 4, - kind: 'delegation_resume_resolved', - actionId: 'resume-action', - actionFingerprint: FINGERPRINT, - coordinationTurnId: 'resume-action', - resumesActionId: 'original-action', - resumesDelegationId: 'original-delegation', - targetSessionId: 'payments', outcome: 'resume_started', targetTurnId: 'resumed-turn', } as const; - assert.deepEqual(decodeCanonicalMessage(requested), requested); - assert.deepEqual(decodeCanonicalMessage(resolved), resolved); + assert.deepEqual(decodeCanonicalMessage(resumed), resumed); for (const invalid of [ - { ...requested, sourceRunId: undefined }, - { ...requested, sourceRuntimeEventHighWater: -1 }, - { ...requested, plan: 'parked', parkReason: undefined }, - { ...resolved, targetTurnId: undefined }, - { ...resolved, outcome: 'parked', parkReason: undefined }, - { ...resolved, sourceRunId: 'injected' }, + { ...resumed, targetTurnId: undefined }, + { ...resumed, outcome: 'parked' }, + { ...resumed, sourceRunId: 'injected' }, ]) { assert.throws(() => decodeCanonicalMessage(invalid), /Invalid stored message schema/u); } - const parked = { - ...resolved, - outcome: 'parked' as const, - parkReason: 'safety_check_failed' as const, + const alreadyRunning = { + ...resumed, + outcome: 'already_running' as const, targetTurnId: undefined, }; - assert.deepEqual(decodeCanonicalMessage(parked), parked); + assert.deepEqual(decodeCanonicalMessage(alreadyRunning), alreadyRunning); }); }); diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index a8050d7752..e020d7f14f 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -34,21 +34,6 @@ import type { } from './runtime-event.js'; import { isTerminalRuntimeEvent } from './runtime-event.js'; -export const SAFE_BOUNDARY_RESUME_PARK_REASONS = [ - 'resume_candidate_missing', - 'source_run_unreadable', - 'safety_check_failed', - 'continuation_already_exists', - 'continuation_repair_required', - 'continuation_started_indeterminate', - 'resume_feature_disabled', - 'continuation_authority_unavailable', - 'safety_observation_unavailable', - 'session_busy', -] as const; - -export type SafeBoundaryResumeParkReason = (typeof SAFE_BOUNDARY_RESUME_PARK_REASONS)[number]; - export interface RuntimeInvocationRecord { sessionId: string; invocationId: string; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 38287f7d37..4e4e7044bd 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -49,10 +49,6 @@ import { import { markPersisted, type PersistedValue } from './persisted-value.js'; import type { SubagentWorkspaceBinding } from './subagent-workspace.js'; import { decodeTurnOrigin, type TurnOrigin } from './turn-origin.js'; -import { - SAFE_BOUNDARY_RESUME_PARK_REASONS, - type SafeBoundaryResumeParkReason, -} from './runtime-invocation.js'; export { DEEP_RESEARCH_SESSION_LABEL, isDeepResearchSession } from './deep-research.js'; @@ -1082,47 +1078,23 @@ export interface WorkHubDelegationStopResolvedMessage { targetTurnId?: string; } -/** Durable resume plan written before attempting a continuation. */ -export interface WorkHubDelegationResumeRequestedMessage { +/** Durable observed result of a resume attempt. */ +export interface WorkHubDelegationResumeMessage { type: 'workhub_coordination'; id: string; turnId: string; ts: number; schemaVersion: typeof WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION; - kind: 'delegation_resume_requested'; + kind: 'delegation_resume'; actionId: string; actionFingerprint: `sha256:${string}`; coordinationTurnId: string; resumesActionId: string; resumesDelegationId: string; targetSessionId: string; - targetMessageId: string; targetSessionName: string; userText: string; - plan: 'ready' | 'already_running' | 'parked'; - parkReason?: SafeBoundaryResumeParkReason; - sourceTurnId?: string; - sourceRunId?: string; - sourceRuntimeEventHighWater?: number; - targetTurnId?: string; -} - -/** Durable observed result of a resume attempt. */ -export interface WorkHubDelegationResumeResolvedMessage { - type: 'workhub_coordination'; - id: string; - turnId: string; - ts: number; - schemaVersion: typeof WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION; - kind: 'delegation_resume_resolved'; - actionId: string; - actionFingerprint: `sha256:${string}`; - coordinationTurnId: string; - resumesActionId: string; - resumesDelegationId: string; - targetSessionId: string; - outcome: 'resume_started' | 'already_running' | 'parked'; - parkReason?: SafeBoundaryResumeParkReason; + outcome: 'resume_started' | 'already_running'; targetTurnId?: string; } @@ -1165,8 +1137,7 @@ export type WorkHubCoordinationMessage = | WorkHubDelegationSupersededMessage | WorkHubDelegationStopRequestedMessage | WorkHubDelegationStopResolvedMessage - | WorkHubDelegationResumeRequestedMessage - | WorkHubDelegationResumeResolvedMessage; + | WorkHubDelegationResumeMessage; function isWorkHubDelegationStopResolution( outcome: unknown, @@ -1436,47 +1407,26 @@ const WORKHUB_DELEGATION_STOP_RESOLVED_MESSAGE_SHAPE = ], ['targetTurnId'], ); -const WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE = - defineObjectShape()( - [ - 'type', - 'id', - 'turnId', - 'ts', - 'schemaVersion', - 'kind', - 'actionId', - 'actionFingerprint', - 'coordinationTurnId', - 'resumesActionId', - 'resumesDelegationId', - 'targetSessionId', - 'targetMessageId', - 'targetSessionName', - 'userText', - 'plan', - ], - ['parkReason', 'sourceTurnId', 'sourceRunId', 'sourceRuntimeEventHighWater', 'targetTurnId'], - ); -const WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE = - defineObjectShape()( - [ - 'type', - 'id', - 'turnId', - 'ts', - 'schemaVersion', - 'kind', - 'actionId', - 'actionFingerprint', - 'coordinationTurnId', - 'resumesActionId', - 'resumesDelegationId', - 'targetSessionId', - 'outcome', - ], - ['parkReason', 'targetTurnId'], - ); +const WORKHUB_DELEGATION_RESUME_MESSAGE_SHAPE = defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'resumesActionId', + 'resumesDelegationId', + 'targetSessionId', + 'targetSessionName', + 'userText', + 'outcome', + ], + ['targetTurnId'], +); const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( ['title', 'workspace'], [], @@ -1667,12 +1617,11 @@ function decodeMessage( } function isWorkHubCoordinationMessage(message: Record): boolean { - if (message.kind === 'delegation_resume_requested') { - const ready = message.plan === 'ready'; - const parked = message.plan === 'parked'; + if (message.kind === 'delegation_resume') { + const started = message.outcome === 'resume_started'; return ( hasMessageEnvelope(message, true) && - hasExactShape(message, WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE) && + hasExactShape(message, WORKHUB_DELEGATION_RESUME_MESSAGE_SHAPE) && message.schemaVersion === WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION && isWorkHubActionIdentity(message) && typeof message.resumesActionId === 'string' && @@ -1681,50 +1630,11 @@ function isWorkHubCoordinationMessage(message: Record): boolean message.resumesDelegationId.length > 0 && typeof message.targetSessionId === 'string' && message.targetSessionId.length > 0 && - typeof message.targetMessageId === 'string' && - message.targetMessageId.length > 0 && typeof message.targetSessionName === 'string' && message.targetSessionName.trim().length > 0 && typeof message.userText === 'string' && message.userText.trim().length > 0 && - (ready || message.plan === 'already_running' || message.plan === 'parked') && - (parked - ? (SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(message.parkReason) - : message.parkReason === undefined) && - (ready - ? typeof message.sourceTurnId === 'string' && - message.sourceTurnId.length > 0 && - typeof message.sourceRunId === 'string' && - message.sourceRunId.length > 0 && - typeof message.sourceRuntimeEventHighWater === 'number' && - Number.isSafeInteger(message.sourceRuntimeEventHighWater) && - message.sourceRuntimeEventHighWater >= 0 && - typeof message.targetTurnId === 'string' && - message.targetTurnId.length > 0 - : message.sourceTurnId === undefined && - message.sourceRunId === undefined && - message.sourceRuntimeEventHighWater === undefined && - message.targetTurnId === undefined) - ); - } - if (message.kind === 'delegation_resume_resolved') { - const started = message.outcome === 'resume_started'; - const parked = message.outcome === 'parked'; - return ( - hasMessageEnvelope(message, true) && - hasExactShape(message, WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE) && - message.schemaVersion === WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION && - isWorkHubActionIdentity(message) && - typeof message.resumesActionId === 'string' && - message.resumesActionId.length > 0 && - typeof message.resumesDelegationId === 'string' && - message.resumesDelegationId.length > 0 && - typeof message.targetSessionId === 'string' && - message.targetSessionId.length > 0 && - (started || message.outcome === 'already_running' || message.outcome === 'parked') && - (parked - ? (SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(message.parkReason) - : message.parkReason === undefined) && + (started || message.outcome === 'already_running') && (started ? typeof message.targetTurnId === 'string' && message.targetTurnId.length > 0 : message.targetTurnId === undefined) diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index 7982a66f57..2c2130e2b1 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -110,8 +110,6 @@ const DIRECT_STOP_REQUEST = // equivalent either. const DIRECT_CHINESE_STOP_REQUEST = /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:停止|停掉|停下|取消|终止|中止)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; -// Resume asks the Host to carry on work that was interrupted, so it reads the -// same shape as a stop: a direct speech act naming one existing Session. It is const DIRECT_RESUME_REQUEST = /^\s*(?:(?:please|kindly)\s+)?resume\s+(?:(?:the|this)\s+)?(?:(?:session|work|task|job)\s+)?(.+?)\s*[.!。!]?\s*$/iu; const DIRECT_CHINESE_RESUME_REQUEST = diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 31d79eaf62..b2492054a3 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -680,31 +680,6 @@ test('WorkHub creates new work through the production assignment composition', a const session = (await manager.listSessions()).find(({ id }) => id === targetSessionId); assert.equal(session?.name, 'Login stability'); assert.equal(session?.llmConnectionId, connectionId); - - const current = await composition.handlers['workhub.coordination.candidates']({}, context); - assert.equal(current.ok, true); - if (!current.ok) return; - assert.deepEqual(current.result.delegations, [ - { - actionId: 'workhub-create-action', - targetSessionId, - sequence: 0, - }, - ]); - const stopped = await composition.handlers['workhub.coordination.act']( - { - actionId: 'workhub-create-stop-action', - userText: 'Stop Login stability', - confirmation: { kind: 'user_stop' }, - proposal: { - disposition: 'stop_work', - expects: { targetSessionId }, - }, - }, - context, - ); - assert.equal(stopped.ok, true, JSON.stringify(stopped)); - if (stopped.ok) assert.equal(stopped.result.disposition, 'stop_work'); } finally { await composition.close(); } @@ -836,6 +811,89 @@ test('WorkHub Stop retires the running continuation after Resume', async () => { }); }); +test('WorkHub does not record resume while safe-boundary resume is disabled', async () => { + await withCompositionRoot(async ({ root, owner }) => { + const connectionId = await configureFakeDefaultTarget(owner); + const { composition, manager } = await createCapturedExecutionComposition(owner, { + safeBoundaryResume: false, + }); + const context = { + hostEpoch: 'execution-composition-test', + connectionId: 'workhub-disabled-resume-client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + try { + const target = await manager.createSession({ + cwd: root, + llmConnectionId: connectionId, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + name: 'Payments', + }); + await composition.handlers['workhub.coordination.resolve']({}, context); + const candidates = await composition.handlers['workhub.coordination.candidates']({}, context); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + ); + assert.ok(candidate); + if (!candidate) return; + const delegated = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-disabled-resume-delegation', + userText: FAKE_HOLD_OPEN_PROMPT, + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidate.candidateRef, + }, + }, + context, + ); + assert.equal(delegated.ok, true, JSON.stringify(delegated)); + if (!delegated.ok || delegated.result.disposition !== 'delegate_existing') return; + const original = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: delegated.result.targetTurnId }, + context, + ); + assert.equal(original.ok, true); + if (!original.ok) return; + await composition.handlers['turn.stop']( + { sessionId: target.id, turnId: original.result.turnId, runId: original.result.runId }, + context, + ); + + const actionId = 'workhub-disabled-resume'; + const resumed = await composition.handlers['workhub.coordination.act']( + { + actionId, + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + expects: { targetSessionId: target.id }, + }, + }, + context, + ); + assert.deepEqual(resumed, { + ok: false, + error: { + code: 'operation_unavailable', + message: 'Safe-boundary resume is disabled for this Runtime Host', + }, + }); + await composition.close(); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + assert.equal(await stores.sessionStore.readWorkHubResume(actionId), undefined); + } finally { + await composition.close(); + } + }); +}); + test('WorkHub correction replaces its link without stopping a shared manual Turn', async () => { await withCompositionRoot(async ({ root, owner }) => { const connectionId = await configureFakeDefaultTarget(owner); @@ -1716,7 +1774,8 @@ async function createCapturedExecutionComposition( return originalRecover.call(this, stores); }; try { - if (options.safeBoundaryResume) process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = '1'; + if (options.safeBoundaryResume === true) process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = '1'; + if (options.safeBoundaryResume === false) delete process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME; // The production composition no longer registers a test backend of its // own; the deterministic one arrives through the same `primaryBackendFactory` // seam the Desktop E2E run uses. diff --git a/packages/runtime-host/src/__tests__/root-admission-owner.test.ts b/packages/runtime-host/src/__tests__/root-admission-owner.test.ts index bb0c443909..8fd25edd13 100644 --- a/packages/runtime-host/src/__tests__/root-admission-owner.test.ts +++ b/packages/runtime-host/src/__tests__/root-admission-owner.test.ts @@ -45,6 +45,8 @@ test('poisons a Session after an ambiguous durable admission failure', async () }, readRootTurnAdmission: (sessionId, turnId) => durableStore.readRootTurnAdmission(sessionId, turnId), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + durableStore.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), readRootTurnSourceMessageReceipt: (sessionId, sourceMessageId) => durableStore.readRootTurnSourceMessageReceipt(sessionId, sourceMessageId), listRootTurnAdmissionsForRecovery: (sessionId) => @@ -284,6 +286,7 @@ test('snapshots recovered admissions without retaining mutable caller references const store: RootTurnAdmissionStore = { admitRootTurn: async () => ({ kind: 'admitted', admission }), readRootTurnAdmission: async () => admission, + readRootTurnContinuationAdmission: async () => undefined, readRootTurnSourceMessageReceipt: async () => undefined, listRootTurnAdmissionsForRecovery: async () => [admission], }; @@ -366,6 +369,7 @@ test('returns an owned admission instead of retaining the mutable store result', const store: RootTurnAdmissionStore = { admitRootTurn: async () => ({ kind: 'admitted', admission: durableAdmission }), readRootTurnAdmission: async () => durableAdmission, + readRootTurnContinuationAdmission: async () => undefined, readRootTurnSourceMessageReceipt: async () => undefined, listRootTurnAdmissionsForRecovery: async () => [], }; 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 553d5d1d4b..e95b794c60 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -1268,6 +1268,8 @@ test('idle Skill admission persists a canonical draft without history before roo throw new Error('injected root admission failure'); }, readRootTurnAdmission: (sessionId, turnId) => store.readRootTurnAdmission(sessionId, turnId), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + store.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), readRootTurnSourceMessageReceipt: (sessionId, messageId) => store.readRootTurnSourceMessageReceipt(sessionId, messageId), listRootTurnAdmissionsForRecovery: (sessionId) => @@ -3195,6 +3197,8 @@ test('successor admission failure retains the terminal transition and its confir return store.admitRootTurn(input); }, readRootTurnAdmission: (sessionId, turnId) => store.readRootTurnAdmission(sessionId, turnId), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + store.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), readRootTurnSourceMessageReceipt: (sessionId, messageId) => store.readRootTurnSourceMessageReceipt(sessionId, messageId), listRootTurnAdmissionsForRecovery: (sessionId) => @@ -3303,6 +3307,8 @@ test('shutdown contains a successor backend start rejected by Interaction drain' return store.admitRootTurn(input); }, readRootTurnAdmission: (sessionId, turnId) => store.readRootTurnAdmission(sessionId, turnId), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + store.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), readRootTurnSourceMessageReceipt: (sessionId, messageId) => store.readRootTurnSourceMessageReceipt(sessionId, messageId), listRootTurnAdmissionsForRecovery: (sessionId) => diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index c0f339eb7a..7be94fe3c8 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -25,8 +25,6 @@ import type { WorkHubDelegationAssignedMessage, WorkHubDelegationReplacementAbortedMessage, WorkHubDelegationReplacementRequestedMessage, - WorkHubDelegationResumeRequestedMessage, - WorkHubDelegationResumeResolvedMessage, WorkHubDelegationStopRequestedMessage, WorkHubDelegationStopResolvedMessage, WorkHubDelegationSupersededMessage, @@ -375,7 +373,7 @@ describe('WorkHub Coordination Action Gate', () => { }); assert.equal(effects.resumeCalls.length, 1); const resumeCall = effects.resumeCalls[0]; - assert.ok(resumeCall && !('request' in resumeCall)); + assert.ok(resumeCall); assert.equal(resumeCall.source.actionId, 'source-action'); // Resume claims like every other disposition, so the identity is spent. assert.equal(effects.actionClaims.get('resume-action')?.operation, 'resume'); @@ -403,27 +401,6 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.resumeCalls.length, 0); }); - test('resume replays the durable request through the same deep effect', async () => { - const effects = fakeEffects([session('payments', { name: 'Payments' })]); - delegatedTo(effects, 'payments'); - const gate = new WorkHubCoordinationActionGate(effects); - const input = { - actionId: 'resume-replay', - userText: 'Resume Payments', - proposal: resumeProposal('payments'), - }; - - const first = await gate.act(input, CONTEXT); - effects.resumeOutcome = { outcome: 'parked', parkReason: 'safety_check_failed' }; - const replay = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); - - assert.deepEqual(replay, first); - assert.equal(effects.resumeCalls.length, 2); - assert.ok('request' in effects.resumeCalls[1]!); - assert.equal(effects.resumeRequests.size, 1); - assert.equal(effects.resumeResolutions.size, 1); - }); - test('resume needs a named command and carries no destructive confirmation', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); delegatedTo(effects, 'payments'); @@ -504,33 +481,60 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(several.resumeCalls.length, 0); }); - test('resume reports the Host answer it was given, including a park', async () => { - for (const [outcome, targetTurnId] of [ - ['already_running', undefined], - ['parked', undefined], - ] as const) { - const effects = fakeEffects([session('payments', { name: 'Payments' })]); - delegatedTo(effects, 'payments'); - effects.resumeOutcome = - outcome === 'parked' ? { outcome, parkReason: 'safety_check_failed' } : { outcome }; - - const result = await new WorkHubCoordinationActionGate(effects).act( + test('resume ignores a retired link when one delegation still holds work', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + const retired = effects.assignmentRecords.get('source-action')!; + effects.assignmentRecords.set( + 'second-action', + assignmentRecord( { - actionId: `resume-${outcome}`, - userText: 'Resume Payments', - proposal: resumeProposal('payments'), + actionId: 'second-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix the interrupted receipt retry', }, - CONTEXT, - ); + 'second-turn', + ), + ); + effects.retirements.push(retired); - assert.deepEqual(result, { - disposition: 'resume_work', - outcome, - targetSessionId: 'payments', - ...(outcome === 'parked' ? { parkReason: 'safety_check_failed' } : {}), - ...(targetTurnId ? { targetTurnId } : {}), - }); - } + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'resume-one-live', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ); + + assert.equal(result.disposition, 'resume_work'); + const call = effects.resumeCalls[0]; + assert.ok(call); + assert.equal(call.source.actionId, 'second-action'); + }); + + test('resume reports when the delegated work is already running', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + effects.resumeOutcome = { outcome: 'already_running' }; + + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'resume-already-running', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ); + + assert.deepEqual(result, { + disposition: 'resume_work', + outcome: 'already_running', + targetSessionId: 'payments', + }); }); test('stops exactly one named durable delegation and replays its observed outcome', async () => { @@ -2732,8 +2736,6 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { const supersessions = new Map(); const stopRequests = new Map(); const stopResolutions = new Map(); - const resumeRequests = new Map(); - const resumeResolutions = new Map(); const actionClaims = new Map(); return { sessions: [...initialSessions], @@ -2752,8 +2754,6 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { supersessions, stopRequests, stopResolutions, - resumeRequests, - resumeResolutions, retirements: [] as WorkHubDelegationAssignedMessage[], retirementClaims: [] as WorkHubDelegationRetirementClaim[], async listSessions() { @@ -2776,84 +2776,19 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', } as { - outcome: 'resume_started' | 'already_running' | 'parked'; - parkReason?: 'safety_check_failed'; + outcome: 'resume_started' | 'already_running'; targetTurnId?: string; }, - async readResumeRequest(actionId: string) { - return resumeRequests.get(actionId); - }, async resume(input: WorkHubDelegationResumeInput) { this.resumeCalls.push(input); - const actionId = 'request' in input ? input.request.actionId : input.actionId; - const existingResolution = resumeResolutions.get(actionId); - if (existingResolution) { - return { - disposition: 'resume_work' as const, - outcome: existingResolution.outcome, - targetSessionId: existingResolution.targetSessionId, - ...(existingResolution.targetTurnId - ? { targetTurnId: existingResolution.targetTurnId } - : {}), - ...(existingResolution.parkReason ? { parkReason: existingResolution.parkReason } : {}), - }; - } - if ('request' in input) { - throw new Error('missing durable fake resume resolution'); - } - const source = input.source; - const requested: WorkHubDelegationResumeRequestedMessage = { - type: 'workhub_coordination', - id: `resume-${actionId}`, - turnId: actionId, - ts: 7, - schemaVersion: 4, - kind: 'delegation_resume_requested', - actionId, - actionFingerprint: input.actionFingerprint, - coordinationTurnId: actionId, - resumesActionId: source.actionId, - resumesDelegationId: source.delegationId, - targetSessionId: source.targetSessionId, - targetMessageId: source.targetMessageId, - targetSessionName: input.targetSessionName, - userText: input.userText, - plan: 'ready', - sourceTurnId: 'source-turn', - sourceRunId: 'source-run', - sourceRuntimeEventHighWater: 1, - targetTurnId: 'resumed-turn', - }; - resumeRequests.set(actionId, requested); - const resolved: WorkHubDelegationResumeResolvedMessage = { - type: 'workhub_coordination', - id: `resume-resolved-${actionId}`, - turnId: actionId, - ts: 8, - schemaVersion: 4, - kind: 'delegation_resume_resolved', - actionId, - actionFingerprint: requested.actionFingerprint, - coordinationTurnId: requested.coordinationTurnId, - resumesActionId: requested.resumesActionId, - resumesDelegationId: requested.resumesDelegationId, - targetSessionId: requested.targetSessionId, + return { + disposition: 'resume_work' as const, outcome: this.resumeOutcome.outcome, - ...(this.resumeOutcome.outcome === 'parked' - ? { parkReason: this.resumeOutcome.parkReason ?? 'safety_check_failed' } - : {}), + targetSessionId: input.source.targetSessionId, ...(this.resumeOutcome.targetTurnId ? { targetTurnId: this.resumeOutcome.targetTurnId } : {}), }; - resumeResolutions.set(actionId, resolved); - return { - disposition: 'resume_work' as const, - outcome: resolved.outcome, - targetSessionId: resolved.targetSessionId, - ...(resolved.targetTurnId ? { targetTurnId: resolved.targetTurnId } : {}), - ...(resolved.parkReason ? { parkReason: resolved.parkReason } : {}), - }; }, async readActionClaim(actionId: string) { return actionClaims.get(actionId); @@ -3048,13 +2983,10 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { supersessions: Map; stopRequests: Map; stopResolutions: Map; - resumeRequests: Map; - resumeResolutions: Map; retirements: WorkHubDelegationAssignedMessage[]; resumeCalls: WorkHubDelegationResumeInput[]; resumeOutcome: { - outcome: 'resume_started' | 'already_running' | 'parked'; - parkReason?: 'safety_check_failed'; + outcome: 'resume_started' | 'already_running'; targetTurnId?: string; }; }; diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index 91a6958872..cb81e62c57 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -47,7 +47,10 @@ import type { ConnectionContext } from '../server/operation-dispatcher.js'; import type { RootTurnCoordinator } from '../server/root-turn-coordinator.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; import { SessionOperationFailure } from '../server/session-catalog-coordinator.js'; -import type { WorkHubActionGateEffects } from '../server/workhub-coordination-action-gate.js'; +import { + WorkHubActionEffectFailure, + type WorkHubActionGateEffects, +} from '../server/workhub-coordination-action-gate.js'; import { HostWorkHubCoordinationCoordinator, type CoordinationCreateTarget, @@ -858,7 +861,7 @@ describe('Host WorkHub Coordination coordinator', () => { } }); - test('persists a resume plan and result before replaying after Host restart', async () => { + test('persists one resume result after the Host starts the continuation', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-resume-')); let store = createSessionStore(root); let targetId = ''; @@ -882,13 +885,6 @@ describe('Host WorkHub Coordination coordinator', () => { let resumeCalls = 0; const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { assign: persistTestAssignmentAction(store, 'payments-turn'), - planResume: async () => ({ - kind: 'ready', - sourceTurnId: 'payments-turn', - sourceRunId: 'payments-run', - sourceRuntimeEventHighWater: 9, - targetTurnId: 'resumed-turn', - }), resumeDelegation: async () => { resumeCalls += 1; return { @@ -930,30 +926,12 @@ describe('Host WorkHub Coordination coordinator', () => { }, }); assert.equal(resumeCalls, 1); - assert.equal( - (await store.readWorkHubResumeRequest('resume-action'))?.sourceRunId, - 'payments-run', - ); - assert.equal( - (await store.readWorkHubResumeResolution('resume-action'))?.targetTurnId, - 'resumed-turn', - ); - } finally { - await store.close?.(); - } - - store = createSessionStore(root); - try { - const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - planResume: async () => assert.fail('durable resume replay must not replan'), - resumeDelegation: async () => assert.fail('durable resume replay must not restart'), - }); - const replay = await restarted.handlers['workhub.coordination.act'](resumeInput(), CONTEXT); - assert.equal(replay.ok, true); - if (replay.ok && replay.result.disposition === 'resume_work') { - assert.equal(replay.result.outcome, 'resume_started'); - assert.equal(replay.result.targetTurnId, 'resumed-turn'); - } + const durable = await store.readWorkHubResume('resume-action'); + assert.equal(durable?.kind, 'delegation_resume'); + assert.equal(durable?.resumesActionId, 'source-action'); + assert.equal(durable?.targetSessionId, target.id); + assert.equal(durable?.outcome, 'resume_started'); + assert.equal(durable?.targetTurnId, 'resumed-turn'); } finally { await store.close?.(); await rm(root, { recursive: true, force: true }); @@ -973,21 +951,16 @@ describe('Host WorkHub Coordination coordinator', () => { }); let recovering = true; const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, 'payments-turn'), - planResume: async () => - recovering - ? { kind: 'recovering' } - : { - kind: 'ready', - sourceTurnId: 'payments-turn', - sourceRunId: 'payments-run', - sourceRuntimeEventHighWater: 9, - targetTurnId: 'resumed-turn', - }, - resumeDelegation: async () => ({ - outcome: 'resume_started', - targetTurnId: 'resumed-turn', - }), + assign: persistTestAssignmentAction(store, 'payments-turn'), + resumeDelegation: async () => { + if (recovering) { + throw new WorkHubActionEffectFailure( + 'host_not_ready', + 'WorkHub is still recovering the delegated execution', + ); + } + return { outcome: 'resume_started', targetTurnId: 'resumed-turn' }; + }, }); assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); @@ -1022,11 +995,11 @@ describe('Host WorkHub Coordination coordinator', () => { assert.deepEqual(await workhub.handlers['workhub.coordination.act'](input, CONTEXT), { ok: false, error: { - code: 'operation_unavailable', + code: 'host_not_ready', message: 'WorkHub is still recovering the delegated execution', }, }); - assert.equal(await store.readWorkHubResumeRequest(input.actionId), undefined); + assert.equal(await store.readWorkHubResume(input.actionId), undefined); recovering = false; const retried = await workhub.handlers['workhub.coordination.act'](input, CONTEXT); @@ -1815,13 +1788,6 @@ function coordinator( executions, sessionActions: { readDelegationRetirement: async () => 'not_retired', - planResume: async () => ({ - kind: 'ready' as const, - sourceTurnId: 'source-turn', - sourceRunId: 'source-run', - sourceRuntimeEventHighWater: 1, - targetTurnId: 'resumed-turn', - }), resumeDelegation: async () => ({ outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts index 802772cfe0..7601be7af1 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -249,12 +249,6 @@ test('WorkHub Coordination resume has closed input and outcome shapes', () => { outcome: 'already_running', targetSessionId: 'payments', }, - { - disposition: 'resume_work', - outcome: 'parked', - targetSessionId: 'payments', - parkReason: 'safety_check_failed', - }, ]) { assert.deepEqual(decodeWorkHubCoordinationActResult(result), result); } @@ -265,12 +259,6 @@ test('WorkHub Coordination resume has closed input and outcome shapes', () => { outcome: 'parked', targetSessionId: 'payments', }, - { - disposition: 'resume_work', - outcome: 'parked', - targetSessionId: 'payments', - parkReason: 'invented', - }, { disposition: 'resume_work', outcome: 'already_running', diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 5f3dc33cda..dafa428649 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -35,10 +35,6 @@ import { decodeSkillInvocationResult, type SkillInvocationResult, } from '@maka/core/skill-invocation'; -import { - SAFE_BOUNDARY_RESUME_PARK_REASONS, - type SafeBoundaryResumeParkReason, -} from '@maka/core/runtime-invocation'; import { invalidProtocolFrame } from './errors.js'; import { assertExactKeys, @@ -117,9 +113,20 @@ export interface TurnResumeStartInput { sourceRuntimeEventHighWater: number; } -export const TURN_RESUME_PARK_REASONS = SAFE_BOUNDARY_RESUME_PARK_REASONS; - -export type TurnResumeParkReason = SafeBoundaryResumeParkReason; +export const TURN_RESUME_PARK_REASONS = [ + 'resume_candidate_missing', + 'source_run_unreadable', + 'safety_check_failed', + 'continuation_already_exists', + 'continuation_repair_required', + 'continuation_started_indeterminate', + 'resume_feature_disabled', + 'continuation_authority_unavailable', + 'safety_observation_unavailable', + 'session_busy', +] as const; + +export type TurnResumeParkReason = (typeof TURN_RESUME_PARK_REASONS)[number]; export type TurnResumePlan = | { diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 7b79f52d30..06f372ad6f 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -17,11 +17,6 @@ * under the License. */ -import { - SAFE_BOUNDARY_RESUME_PARK_REASONS, - type SafeBoundaryResumeParkReason, -} from '@maka/core/runtime-invocation'; - import { requireCount, requireEntityId, @@ -230,16 +225,9 @@ export type WorkHubCoordinationActResult = } | { readonly disposition: 'resume_work'; - /** - * `parked` is the Host declining to continue — the reason is its own and - * is not restated here, because nothing the client can do changes it. - * `already_running` means the work never stopped, which is an answer, not - * a failure. - */ - readonly outcome: 'resume_started' | 'already_running' | 'parked'; + readonly outcome: 'resume_started' | 'already_running'; readonly targetSessionId: string; readonly targetTurnId?: string; - readonly parkReason?: SafeBoundaryResumeParkReason; }; export const WORKHUB_COORDINATION_OPERATION_SPECS = { @@ -572,13 +560,9 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord result, 'WorkHub Coordination resume result', ['disposition', 'outcome', 'targetSessionId'], - ['targetTurnId', 'parkReason'], + ['targetTurnId'], ); - if ( - exact.outcome !== 'resume_started' && - exact.outcome !== 'already_running' && - exact.outcome !== 'parked' - ) { + if (exact.outcome !== 'resume_started' && exact.outcome !== 'already_running') { throw invalidProtocolFrame('Invalid WorkHub resume outcome'); } // Only a started continuation names a Turn: the Host has one to name, and @@ -586,13 +570,6 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord if ((exact.outcome === 'resume_started') !== (exact.targetTurnId !== undefined)) { throw invalidProtocolFrame('Invalid WorkHub resume target Turn'); } - if ( - exact.outcome === 'parked' - ? !(SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(exact.parkReason) - : exact.parkReason !== undefined - ) { - throw invalidProtocolFrame('Invalid WorkHub resume park reason'); - } return { disposition: 'resume_work', outcome: exact.outcome, @@ -602,9 +579,6 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord : { targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), }), - ...(exact.parkReason === undefined - ? {} - : { parkReason: exact.parkReason as SafeBoundaryResumeParkReason }), }; } throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 37934a999c..c2e7211dc0 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1398,17 +1398,25 @@ export async function createExecutionRuntimeHostComposition( const snapshot = await coordinator.read(latest); return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; }, - // Resolve only the execution lineage owned by this delegation. A - // Session-wide latest-failure query could otherwise continue unrelated - // work started directly in the same Session. - planResume: async (assignment, context) => { + // Resolve and resume only the execution lineage owned by this + // delegation. A Session-wide latest-failure query could otherwise + // continue unrelated work started directly in the same Session. + resumeDelegation: async (assignment, context) => { const disposition = await messages.readMessageExecutionDisposition( assignment.targetSessionId, assignment.targetMessageId, ); - if (disposition.kind === 'recovering') return { kind: 'recovering' as const }; + if (disposition.kind === 'recovering') { + throw new WorkHubActionEffectFailure( + 'host_not_ready', + 'WorkHub is still recovering the delegated execution', + ); + } if (disposition.kind !== 'owned_root') { - return { kind: 'parked' as const, parkReason: 'resume_candidate_missing' as const }; + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub delegated execution is not resumable', + ); } const source = await coordinator.readLatestRootTurnLineage({ sessionId: assignment.targetSessionId, @@ -1416,12 +1424,20 @@ export async function createExecutionRuntimeHostComposition( runId: disposition.runId, }); if (isActiveWorkHubRoot(coordinator, source)) { - return { kind: 'already_running' as const }; + return { outcome: 'already_running' as const }; } const snapshot = await coordinator.read(source); - if (!isHostedExecutionTerminal(snapshot)) return { kind: 'recovering' as const }; + if (!isHostedExecutionTerminal(snapshot)) { + throw new WorkHubActionEffectFailure( + 'host_not_ready', + 'WorkHub is still recovering the delegated execution', + ); + } if (snapshot.status !== 'failed' && snapshot.status !== 'cancelled') { - return { kind: 'parked' as const, parkReason: 'resume_candidate_missing' as const }; + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub delegated execution is not resumable', + ); } const plan = await coordinator.handlers['turn.resume.query']( { sessionId: assignment.targetSessionId, sourceRunId: source.runId }, @@ -1429,7 +1445,14 @@ export async function createExecutionRuntimeHostComposition( ); if (!plan.ok) throw new WorkHubActionEffectFailure(plan.error.code, plan.error.message); if (plan.result.disposition === 'parked') { - return { kind: 'parked' as const, parkReason: plan.result.reason }; + throw new WorkHubActionEffectFailure( + plan.result.reason === 'resume_feature_disabled' + ? 'operation_unavailable' + : 'operation_conflict', + plan.result.reason === 'resume_feature_disabled' + ? 'Safe-boundary resume is disabled for this Runtime Host' + : 'WorkHub delegated execution is not resumable', + ); } if ( plan.result.sourceRunId !== source.runId || @@ -1440,32 +1463,16 @@ export async function createExecutionRuntimeHostComposition( 'WorkHub resume source lineage changed during planning', ); } - return { - kind: 'ready' as const, - sourceTurnId: plan.result.sourceTurnId, - sourceRunId: plan.result.sourceRunId, - sourceRuntimeEventHighWater: plan.result.sourceRuntimeEventHighWater, - targetTurnId: workHubResumedTurnId(assignment.delegationId, plan.result.sourceRunId), - }; - }, - resumeDelegation: async (request, context) => { - if ( - request.plan !== 'ready' || - !request.sourceRunId || - request.sourceRuntimeEventHighWater === undefined || - !request.targetTurnId - ) { - throw new WorkHubActionEffectFailure( - 'persistence_failed', - 'WorkHub durable resume plan is incomplete', - ); - } + const targetTurnId = workHubResumedTurnId( + assignment.delegationId, + plan.result.sourceRunId, + ); const started = await coordinator.handlers['turn.resume.start']( { - sessionId: request.targetSessionId, - turnId: request.targetTurnId, - sourceRunId: request.sourceRunId, - sourceRuntimeEventHighWater: request.sourceRuntimeEventHighWater, + sessionId: assignment.targetSessionId, + turnId: targetTurnId, + sourceRunId: plan.result.sourceRunId, + sourceRuntimeEventHighWater: plan.result.sourceRuntimeEventHighWater, }, context, ); @@ -1473,7 +1480,14 @@ export async function createExecutionRuntimeHostComposition( throw new WorkHubActionEffectFailure(started.error.code, started.error.message); } if (started.result.kind === 'parked') { - return { outcome: 'parked' as const, parkReason: started.result.plan.reason }; + throw new WorkHubActionEffectFailure( + started.result.plan.reason === 'resume_feature_disabled' + ? 'operation_unavailable' + : 'operation_conflict', + started.result.plan.reason === 'resume_feature_disabled' + ? 'Safe-boundary resume is disabled for this Runtime Host' + : 'WorkHub delegated execution is not resumable', + ); } return { outcome: 'resume_started' as const, diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 60ed63b2a8..6d2a53ef8b 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -521,27 +521,24 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { /** Returns the newest Host-admitted continuation descended from one root execution. */ async readLatestRootTurnLineage(identity: HostedExecutionRef): Promise { - const admissions = await this.stores.agentRunStore.listRootTurnAdmissionsForRecovery( + const origin = await this.stores.agentRunStore.readRootTurnAdmission( identity.sessionId, + identity.turnId, ); - const originIndex = admissions.findIndex( - ({ turnId, runId }) => turnId === identity.turnId && runId === identity.runId, - ); - if (originIndex === -1) { + if (!origin || origin.runId !== identity.runId) { throw new RuntimeMessageAuthorityInvariantError( `Root execution ${identity.turnId}/${identity.runId} has no durable admission`, ); } - let latest = admissions[originIndex]!; - for (const admission of admissions.slice(originIndex + 1)) { - const execution = admission.execution; - if ( - execution.kind === 'safe_boundary_continuation' && - execution.sourceTurnId === latest.turnId && - execution.sourceRunId === latest.runId - ) { - latest = admission; - } + let latest = origin; + while (true) { + const continuation = await this.stores.agentRunStore.readRootTurnContinuationAdmission( + identity.sessionId, + latest.turnId, + latest.runId, + ); + if (!continuation) break; + latest = continuation; } return { sessionId: latest.sessionId, turnId: latest.turnId, runId: latest.runId }; } diff --git a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts index bf06b74bee..01575074c7 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -32,7 +32,6 @@ import type { WorkHubDelegationStopRequestedMessage, WorkHubDelegationStopResolvedMessage, WorkHubDelegationStopOutcome, - WorkHubDelegationResumeRequestedMessage, WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { @@ -113,7 +112,6 @@ export interface WorkHubActionGateEffects { readStopResolution( delegationId: string, ): Promise; - readResumeRequest(actionId: string): Promise; answer( input: { readonly turnId: string; readonly text: string }, context: ConnectionContext, @@ -144,11 +142,6 @@ export interface WorkHubActionGateEffects { assignment: WorkHubDelegationAssignedMessage, retirement: WorkHubDelegationRetirementClaim, ): Promise; - /** - * Ask the target Session to carry on the work this delegation left - * unfinished. The Host owns whether that is possible; a repeat is safe - * because a continuation that already exists parks rather than forks. - */ resume( input: WorkHubDelegationResumeInput, context: ConnectionContext, @@ -167,15 +160,13 @@ export interface WorkHubDelegationRetirementClaim { readonly cause: 'direct_stop' | 'replacement'; } -export type WorkHubDelegationResumeInput = - | { readonly request: WorkHubDelegationResumeRequestedMessage } - | { - readonly actionId: string; - readonly actionFingerprint: `sha256:${string}`; - readonly source: WorkHubDelegationAssignedMessage; - readonly targetSessionName: string; - readonly userText: string; - }; +export interface WorkHubDelegationResumeInput { + readonly actionId: string; + readonly actionFingerprint: `sha256:${string}`; + readonly source: WorkHubDelegationAssignedMessage; + readonly targetSessionName: string; + readonly userText: string; +} export interface WorkHubRetirementResult { readonly outcome: WorkHubDelegationStopOutcome | 'recovering'; @@ -445,39 +436,12 @@ export class WorkHubCoordinationActionGate { return this.#stop(requested, source); } if (proposal.disposition === 'resume_work') { - // Resume carries no confirmation. It starts work that was already - // delegated and already interrupted, so it destroys nothing and needs no - // authority a delegation did not already grant — only proof that the - // words asked for it and that the Session named still owns one link. if (!requestIntent.resume.imperative) { throw new WorkHubActionGateFailure( 'action_conflict', 'WorkHub resume requires an explicit named command in trusted user text', ); } - const replay = await this.#effects.readResumeRequest(input.actionId); - if (replay) { - if ( - replay.userText !== input.userText || - replay.targetSessionId !== proposal.expects.targetSessionId || - !workHubNamedDelegationActionTargetsSession( - requestIntent.resume, - replay.targetSessionName, - ) - ) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub resume identity belongs to a different request', - ); - } - await this.#claimAction( - input.actionId, - 'resume', - replay.actionFingerprint, - replay.resumesDelegationId, - ); - return this.#effects.resume({ request: replay }, context); - } const source = await this.#resumeSource(proposal.expects.targetSessionId); const sessions = await this.#effects.listSessions(); const currentTargetName = sessions.find(({ id }) => id === source.targetSessionId)?.name; @@ -606,27 +570,8 @@ export class WorkHubCoordinationActionGate { ); } - /** - * The delegation a resume names. - * - * Unlike a stop this needs no claim to find its way back: resume changes no - * durable link, so the delegation it names is still in the active set on the - * next attempt exactly as it was on the first. One link on the Session is the - * answer; several is the same ambiguity a stop refuses, and none means there - * is nothing here to carry on. - */ async #resumeSource(targetSessionId: string): Promise { - const active = await this.#effects.listActiveAssignments(); - const onTarget = active.filter((assignment) => assignment.targetSessionId === targetSessionId); - if (onTarget.length !== 1) { - throw new WorkHubActionGateFailure( - 'action_conflict', - onTarget.length === 0 - ? 'WorkHub has no active durable delegation to resume on that Session' - : 'WorkHub resume target does not identify one active durable delegation', - ); - } - return onTarget[0]!; + return this.#soleWorkingDelegation(targetSessionId, 'resume'); } /** @@ -669,12 +614,32 @@ export class WorkHubCoordinationActionGate { return claimed; } } + const resolved = await this.#soleWorkingDelegation(targetSessionId, 'stop'); + // A claim with no request behind it resolves from the active links like a + // first attempt, but only while those links still name the delegation it + // bound itself to. If that one left and another took its place, the + // fingerprint derived here would no longer match the claim, and since + // claims are never deleted the refusal would be permanent and unexplained. + // Say why instead: the identity is spent, and the retry needs a new one. + if (claim?.operation === 'stop' && resolved.delegationId !== claim.subject) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop identity is already bound to a different delegation', + ); + } + return resolved; + } + + async #soleWorkingDelegation( + targetSessionId: string, + operation: 'resume' | 'stop', + ): Promise { const active = await this.#effects.listActiveAssignments(); const onTarget = active.filter((assignment) => assignment.targetSessionId === targetSessionId); if (onTarget.length === 0) { throw new WorkHubActionGateFailure( 'action_conflict', - 'WorkHub has no active durable delegation to stop on that Session', + `WorkHub has no active durable delegation to ${operation} on that Session`, ); } // One link is the answer whatever state its work is in. Whether that work @@ -697,23 +662,11 @@ export class WorkHubCoordinationActionGate { if (holdingWork.length !== 1) { throw new WorkHubActionGateFailure( 'action_conflict', - 'WorkHub stop target does not identify one active durable delegation', + `WorkHub ${operation} target does not identify one active durable delegation`, ); } resolved = holdingWork[0]!; } - // A claim with no request behind it resolves from the active links like a - // first attempt, but only while those links still name the delegation it - // bound itself to. If that one left and another took its place, the - // fingerprint derived here would no longer match the claim, and since - // claims are never deleted the refusal would be permanent and unexplained. - // Say why instead: the identity is spent, and the retry needs a new one. - if (claim?.operation === 'stop' && resolved.delegationId !== claim.subject) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub stop identity is already bound to a different delegation', - ); - } return resolved; } @@ -1153,13 +1106,6 @@ function workHubCreatedSessionId(actionId: string): string { return `whs_${hash(`create\0${actionId}`).slice(0, 48)}`; } -/** - * The continuation identity a resume would start, derived rather than minted. - * - * Two attempts at the same interrupted run must name the same Turn, or the - * second would ask the Host to start a second continuation instead of finding - * the first already there. - */ export function workHubResumedTurnId(delegationId: string, sourceRunId: string): string { return `wht_${hash(`resume\0${delegationId}\0${sourceRunId}`).slice(0, 48)}`; } diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index a406fc32e8..1daa38e546 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -38,8 +38,7 @@ import { type WorkHubDelegationReplacementRequestedMessage, type WorkHubDelegationStopRequestedMessage, type WorkHubDelegationStopResolvedMessage, - type WorkHubDelegationResumeRequestedMessage, - type WorkHubDelegationResumeResolvedMessage, + type WorkHubDelegationResumeMessage, } from '@maka/core/session'; import { isSessionNotFoundError, @@ -48,7 +47,6 @@ import { } from '@maka/storage/session-store'; import type { OperationOutcome, - TurnResumeParkReason, WorkHubCoordinationActResult, WorkHubCoordinationActInput, WorkHubCoordinationAnswerInput, @@ -116,8 +114,7 @@ type CoordinationStores = Pick< | 'readWorkHubSupersession' | 'readWorkHubStopRequest' | 'readWorkHubStopResolution' - | 'readWorkHubResumeRequest' - | 'readWorkHubResumeResolution' + | 'readWorkHubResume' | 'readTranscriptHighWaterSnapshot' | 'readTranscriptMessagesSnapshot' | 'readTranscriptRecordsSnapshot' @@ -129,24 +126,12 @@ type CoordinationExecutions = Pick< 'startWorkHubCoordinationMessage' | 'hasRootTurnAdmission' >; -type WorkHubResumePlan = - | { readonly kind: 'already_running' } - | { readonly kind: 'recovering' } - | { readonly kind: 'parked'; readonly parkReason: TurnResumeParkReason } - | { - readonly kind: 'ready'; - readonly sourceTurnId: string; - readonly sourceRunId: string; - readonly sourceRuntimeEventHighWater: number; - readonly targetTurnId: string; - }; - type WorkHubResumeResult = | { readonly outcome: 'resume_started'; readonly targetTurnId: string; } - | { readonly outcome: 'parked'; readonly parkReason: TurnResumeParkReason }; + | { readonly outcome: 'already_running' }; type CoordinationSessionActions = Pick< WorkHubActionGateEffects, @@ -158,12 +143,8 @@ type CoordinationSessionActions = Pick< publishCommittedAssignment: (assignment: ActiveWorkHubAssignment) => Promise, ] ): ReturnType; - planResume( - assignment: WorkHubDelegationAssignedMessage, - context: ConnectionContext, - ): Promise; resumeDelegation( - request: WorkHubDelegationResumeRequestedMessage, + assignment: WorkHubDelegationAssignedMessage, context: ConnectionContext, ): Promise; }; @@ -234,7 +215,6 @@ export class HostWorkHubCoordinationCoordinator { readSupersession: (delegationId) => this.#stores.readWorkHubSupersession(delegationId), readStopRequest: (delegationId) => this.#stores.readWorkHubStopRequest(delegationId), readStopResolution: (delegationId) => this.#stores.readWorkHubStopResolution(delegationId), - readResumeRequest: (actionId) => this.#stores.readWorkHubResumeRequest(actionId), answer: async (input, context) => { const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); if (!outcome.ok) { @@ -440,98 +420,42 @@ export class HostWorkHubCoordinationCoordinator { context: ConnectionContext, actions: CoordinationSessionActions, ): Promise> { - let request: WorkHubDelegationResumeRequestedMessage; - if ('request' in input) { - request = input.request; - } else { - const plan = await actions.planResume(input.source, context); - if (plan.kind === 'recovering') { - throw new WorkHubActionEffectFailure( - 'operation_unavailable', - 'WorkHub is still recovering the delegated execution', - ); - } - const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); - request = await this.#commitCoordinationFact({ - admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.source.targetSessionId], - read: () => this.#stores.readWorkHubResumeRequest(input.actionId), - build: (existing) => ({ - type: 'workhub_coordination', - id: `whu_${suffix}`, - turnId: input.actionId, - ts: existing?.ts ?? Date.now(), - schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, - kind: 'delegation_resume_requested', - actionId: input.actionId, - actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, - resumesActionId: input.source.actionId, - resumesDelegationId: input.source.delegationId, - targetSessionId: input.source.targetSessionId, - targetMessageId: input.source.targetMessageId, - targetSessionName: input.targetSessionName, - userText: input.userText, - plan: plan.kind, - ...(plan.kind === 'parked' ? { parkReason: plan.parkReason } : {}), - ...(plan.kind === 'ready' - ? { - sourceTurnId: plan.sourceTurnId, - sourceRunId: plan.sourceRunId, - sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, - targetTurnId: plan.targetTurnId, - } - : {}), - }), - conflictMessage: 'WorkHub resume identity belongs to a different plan', - beforeAppend: async () => { - const source = (await this.#listActiveAssignments()).find( - ({ actionId, delegationId }) => - actionId === input.source.actionId && delegationId === input.source.delegationId, - ); - if (!source || source.targetSessionId !== input.source.targetSessionId) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub resume source is no longer active', - ); - } - }, - unknownOutcomeMessage: 'WorkHub resume plan outcome is unknown', - }); - } - - const existing = await this.#stores.readWorkHubResumeResolution(request.actionId); + const existing = await this.#stores.readWorkHubResume(input.actionId); if (existing) return coordinationResumeResult(existing); - const resumed = - request.plan === 'ready' - ? await actions.resumeDelegation(request, context) - : request.plan === 'parked' - ? { outcome: 'parked' as const, parkReason: request.parkReason! } - : { outcome: 'already_running' as const }; - const suffix = createHash('sha256').update(request.actionId, 'utf8').digest('hex').slice(0, 48); + const resumed = await actions.resumeDelegation(input.source, context); + const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); const resolution = await this.#commitCoordinationFact({ - read: () => this.#stores.readWorkHubResumeResolution(request.actionId), + admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.source.targetSessionId], + read: () => this.#stores.readWorkHubResume(input.actionId), build: (durable) => ({ type: 'workhub_coordination', id: `whn_${suffix}`, - turnId: request.actionId, + turnId: input.actionId, ts: durable?.ts ?? Date.now(), schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, - kind: 'delegation_resume_resolved', - actionId: request.actionId, - actionFingerprint: request.actionFingerprint, - coordinationTurnId: request.coordinationTurnId, - resumesActionId: request.resumesActionId, - resumesDelegationId: request.resumesDelegationId, - targetSessionId: request.targetSessionId, + kind: 'delegation_resume', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + resumesActionId: input.source.actionId, + resumesDelegationId: input.source.delegationId, + targetSessionId: input.source.targetSessionId, + targetSessionName: input.targetSessionName, + userText: input.userText, outcome: resumed.outcome, - ...(resumed.outcome === 'parked' ? { parkReason: resumed.parkReason } : {}), ...(resumed.outcome === 'resume_started' ? { targetTurnId: resumed.targetTurnId } : {}), }), conflictMessage: 'WorkHub resume already has a different resolution', beforeAppend: async () => { - const durable = await this.#stores.readWorkHubResumeRequest(request.actionId); - if (!durable || !isDeepStrictEqual(durable, request)) { - throw new WorkHubActionGateFailure('action_conflict', 'WorkHub resume plan changed'); + const source = (await this.#listActiveAssignments()).find( + ({ actionId, delegationId }) => + actionId === input.source.actionId && delegationId === input.source.delegationId, + ); + if (!source || source.targetSessionId !== input.source.targetSessionId) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume source is no longer active', + ); } }, unknownOutcomeMessage: 'WorkHub resume resolution outcome is unknown', @@ -1067,14 +991,13 @@ function digest(value: unknown): `sha256:${string}` { } function coordinationResumeResult( - resolution: WorkHubDelegationResumeResolvedMessage, + resolution: WorkHubDelegationResumeMessage, ): Extract { return { disposition: 'resume_work', outcome: resolution.outcome, targetSessionId: resolution.targetSessionId, ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), - ...(resolution.parkReason ? { parkReason: resolution.parkReason } : {}), }; } diff --git a/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts b/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts new file mode 100644 index 0000000000..2a0fed2289 --- /dev/null +++ b/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts @@ -0,0 +1,79 @@ +/* + * 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 assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { createSqliteAgentRunStore } from '../agent-run-store.js'; + +test('safe-boundary continuation admission is indexed by its source execution', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-continuation-admission-')); + try { + const store = createSqliteAgentRunStore(root); + const origin = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'source-turn', + proposedRunId: 'source-run', + proposedUserMessageId: 'source-message', + execution: { kind: 'external_message' }, + previousRootTurnId: null, + normalizedInput: { text: 'Start work' }, + sourceMessages: [], + admittedAt: 10, + }); + assert.equal(origin.kind, 'admitted'); + const continuation = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'continuation-turn', + proposedRunId: 'continuation-run', + proposedUserMessageId: null, + execution: { + kind: 'safe_boundary_continuation', + sourceInvocationId: 'source-invocation', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 7, + claimId: 'continuation-claim', + boundaryDigest: `sha256:${'a'.repeat(64)}`, + providerReplayDigest: `sha256:${'b'.repeat(64)}`, + safetyDigest: `sha256:${'c'.repeat(64)}`, + targetInvocationId: 'continuation-invocation', + }, + previousRootTurnId: 'source-turn', + normalizedInput: null, + sourceMessages: [], + admittedAt: 20, + }); + assert.equal(continuation.kind, 'admitted'); + + assert.deepEqual( + await store.readRootTurnContinuationAdmission('session', 'source-turn', 'source-run'), + continuation.admission, + ); + assert.equal( + await store.readRootTurnContinuationAdmission('session', 'source-turn', 'other-run'), + undefined, + ); + store.close?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index dd855c9b3f..b1ae6f9f25 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -202,6 +202,11 @@ export type AdmitRootTurnResult = export interface RootTurnAdmissionStore { admitRootTurn(input: AdmitRootTurnInput): Promise; readRootTurnAdmission(sessionId: string, turnId: string): Promise; + readRootTurnContinuationAdmission( + sessionId: string, + sourceTurnId: string, + sourceRunId: string, + ): Promise; readRootTurnSourceMessageReceipt( sessionId: string, sourceMessageId: string, @@ -635,6 +640,45 @@ class SqliteAgentRunStore implements DurableAgentRunStore { return readSqliteRootTurnAdmission(this.#lease.database, sessionId, turnId); } + async readRootTurnContinuationAdmission( + sessionId: string, + sourceTurnId: string, + sourceRunId: string, + ): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(sourceTurnId, 'Invalid source turn id'); + assertSafeId(sourceRunId, 'Invalid source run id'); + const row = this.#lease.database + .prepare(` + SELECT turn_id, record_json + FROM core_root_turn_admissions + WHERE session_id = ? + AND json_extract(record_json, '$.execution.sourceTurnId') = ? + AND json_extract(record_json, '$.execution.sourceRunId') = ? + AND json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation' + `) + .get(sessionId, sourceTurnId, sourceRunId) as + | { turn_id?: unknown; record_json?: unknown } + | undefined; + if (!row) return undefined; + if (typeof row.turn_id !== 'string' || typeof row.record_json !== 'string') { + throw new Error('Invalid SQLite root turn continuation admission row'); + } + const admission = normalizeRootTurnAdmission( + JSON.parse(row.record_json), + sessionId, + row.turn_id, + ); + if ( + admission.execution.kind !== 'safe_boundary_continuation' || + admission.execution.sourceTurnId !== sourceTurnId || + admission.execution.sourceRunId !== sourceRunId + ) { + throw new Error('Root turn continuation index disagrees with its durable admission'); + } + return admission; + } + async readRootTurnStartRejection( sessionId: string, turnId: string, diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index b485657f77..8216a86406 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -193,6 +193,11 @@ export interface ExecutionAgentRunReader { type: AgentRunProjectionKey, ): Promise; readRootTurnAdmission(sessionId: string, turnId: string): Promise; + readRootTurnContinuationAdmission( + sessionId: string, + sourceTurnId: string, + sourceRunId: string, + ): Promise; readRootTurnSourceMessageReceipt( sessionId: string, sourceMessageId: string, @@ -380,10 +385,7 @@ async function createExecutionStoresForWrite sessionStore.readWorkHubStopRequest(delegationId)), readWorkHubStopResolution: (delegationId) => run(() => sessionStore.readWorkHubStopResolution(delegationId)), - readWorkHubResumeRequest: (actionId) => - run(() => sessionStore.readWorkHubResumeRequest(actionId)), - readWorkHubResumeResolution: (actionId) => - run(() => sessionStore.readWorkHubResumeResolution(actionId)), + readWorkHubResume: (actionId) => run(() => sessionStore.readWorkHubResume(actionId)), claimWorkHubAction: (claim) => run(() => sessionStore.claimWorkHubAction(claim)), readWorkHubActionClaim: (actionId) => run(() => sessionStore.readWorkHubActionClaim(actionId)), @@ -524,6 +526,10 @@ async function createExecutionStoresForWrite agentRunStore.admitRootTurn(input)), readRootTurnAdmission: (sessionId, turnId) => run(() => agentRunStore.readRootTurnAdmission(sessionId, turnId)), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + run(() => + agentRunStore.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), + ), readRootTurnStartRejection: (sessionId, turnId) => run(() => agentRunStore.readRootTurnStartRejection(sessionId, turnId)), commitRootTurnStartRejection: (input: CommitRootTurnStartRejectionInput) => @@ -660,6 +666,10 @@ async function openExecutionStoresForRead agentRunStore.readEventProjection(sessionId, type)), readRootTurnAdmission: (sessionId, turnId) => run(() => agentRunStore.readRootTurnAdmission(sessionId, turnId)), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + run(() => + agentRunStore.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), + ), readRootTurnSourceMessageReceipt: (sessionId, sourceMessageId) => run(() => agentRunStore.readRootTurnSourceMessageReceipt(sessionId, sourceMessageId)), }, diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index ecc4a21cdd..cb12cb6e43 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -90,8 +90,7 @@ import { type WorkHubActionClaimOutcome, type WorkHubDelegationStopRequestedMessage, type WorkHubDelegationStopResolvedMessage, - type WorkHubDelegationResumeRequestedMessage, - type WorkHubDelegationResumeResolvedMessage, + type WorkHubDelegationResumeMessage, type WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import type { @@ -443,12 +442,7 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto readWorkHubStopResolution( delegationId: string, ): Promise; - readWorkHubResumeRequest( - actionId: string, - ): Promise; - readWorkHubResumeResolution( - actionId: string, - ): Promise; + readWorkHubResume(actionId: string): Promise; /** * Durably binds one action identity to one exact WorkHub operation before its * effect. Survives removal of the target Session so a committed destructive @@ -765,25 +759,11 @@ class SqliteSessionStore implements SessionAuthorityStore { : undefined; } - async readWorkHubResumeRequest( - actionId: string, - ): Promise { - const message = await this.readWorkHubCoordinationMessage( - `whu_${workHubIdentitySuffix(actionId)}`, - ); - return message?.type === 'workhub_coordination' && - message.kind === 'delegation_resume_requested' - ? message - : undefined; - } - - async readWorkHubResumeResolution( - actionId: string, - ): Promise { + async readWorkHubResume(actionId: string): Promise { const message = await this.readWorkHubCoordinationMessage( `whn_${workHubIdentitySuffix(actionId)}`, ); - return message?.type === 'workhub_coordination' && message.kind === 'delegation_resume_resolved' + return message?.type === 'workhub_coordination' && message.kind === 'delegation_resume' ? message : undefined; } diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index caefc73fca..b51416532b 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 = 7; +export const SQLITE_CORE_EXECUTION_SCHEMA_VERSION = 8; export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { db.exec(` @@ -172,6 +172,14 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { ON core_agent_runs(session_id, latest_model_call_sequence, run_id) WHERE latest_model_call_sequence IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS core_root_turn_continuation_source + ON core_root_turn_admissions( + session_id, + json_extract(record_json, '$.execution.sourceTurnId'), + json_extract(record_json, '$.execution.sourceRunId') + ) + WHERE json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation'; + DROP INDEX IF EXISTS core_agent_runs_identity; DROP TABLE IF EXISTS core_message_receipts; From b2289fba53905374c717758b84a513d28bf20a5d Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 18:12:08 +0800 Subject: [PATCH 11/12] fix(desktop): preserve WorkHub renderer boundary --- apps/desktop/src/renderer/workhub-coordination-port.ts | 2 ++ apps/desktop/src/renderer/workhub-surface.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 6d1bb8201b..41496d71e6 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -40,6 +40,8 @@ import type { } from '@maka/runtime-host/protocol'; import { boundedWorkHubTimelineText, WorkHubCoordinationFailure } from './workhub-controller.js'; +export { WorkHubCoordinationFailure }; + import type { WorkHubDesktopTranscriptBridge } from './workhub-session-port.js'; const WORKHUB_COORDINATION_TURN_LIMIT = 40; diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 1a2960cb4c..c5f3ad0de3 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -28,7 +28,6 @@ import { Button } from '@astryxdesign/core/Button'; import type { UiLocale } from '@maka/core/ui-locale'; import { ChatSurfaceLayout, Composer } from '@maka/ui'; import { - WorkHubCoordinationFailure, type WorkHubController, type WorkHubCoordinationTurn, type WorkHubDelegationLinkState, @@ -37,6 +36,7 @@ import { type WorkHubSubmission, type WorkHubSubmitInput, } from './workhub-controller.js'; +import { WorkHubCoordinationFailure } from './workhub-coordination-port.js'; import { WorkHubSendLease, type WorkHubSendAttempt, From 14305236824044102cb37af2d6e1692e9dc997f9 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 21:21:13 +0800 Subject: [PATCH 12/12] fix(workhub): address current-head review --- .../main/__tests__/workhub-controller.test.ts | 51 ++++++++++++- .../__tests__/workhub-session-port.test.ts | 74 +++++++++++++++++++ .../src/renderer/workhub-controller.ts | 4 +- .../src/renderer/workhub-coordination-port.ts | 11 ++- .../src/renderer/workhub-route-policy.ts | 13 +++- apps/desktop/src/renderer/workhub-surface.tsx | 19 ++++- .../__tests__/workhub-creation-intent.test.ts | 8 +- packages/core/src/workhub-creation-intent.ts | 3 + ...fe-boundary-continuation-admission.test.ts | 73 ++++++++++++++++++ packages/storage/src/agent-run-store.ts | 37 ++++++++-- .../src/sqlite-core-execution-schema.ts | 4 +- 11 files changed, 280 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 2a4aa15a85..c69eb8ce9e 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -473,6 +473,32 @@ test('a named resume submits and reports what the Host did', async () => { await handle.close(); }); +test('an anaphoric resume asks for a named work item', async () => { + const controller = createGatedWorkHubController({ + sessions: port([session('payments', { sessionName: 'Payments' })]), + coordination: { + open: async (handler) => { + handler([], []); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('resume clarification must not read route candidates'), + act: async () => assert.fail('anaphoric resume must not reach the Action Gate'), + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + assert.deepEqual(await controller.submit({ requestId: 'resume-it', text: 'Resume it' }), { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'resume-it', + text: 'Resume it', + options: [], + reason: 'resume_target_required', + }); + await handle.close(); +}); + test('a resume the Host will not admit becomes its clarification', async () => { const sessions = port([session('payments', { sessionName: 'Payments' })]); const controller = createGatedWorkHubController({ @@ -531,11 +557,34 @@ test('a Runtime Host without safe-boundary resume explains why it cannot resume' requestId: 'resume-disabled', text: 'Resume Payments', options: [], - reason: 'resume_target_unavailable', + reason: 'resume_operation_unavailable', }); await handle.close(); }); +test('a recovering Runtime Host tells the user to retry resume', async () => { + const controller = createGatedWorkHubController({ + sessions: port([session('payments', { sessionName: 'Payments' })]), + coordination: { + open: async (handler) => { + handler([], []); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('a resume must not read route candidates'), + act: async () => { + throw new WorkHubCoordinationFailure('host_not_ready', 'Runtime Host is recovering'); + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + const result = await controller.submit({ requestId: 'resume-recovering', text: 'Resume Payments' }); + assert.equal(result.kind, 'clarification'); + if (result.kind === 'clarification') assert.equal(result.reason, 'resume_host_recovering'); + await handle.close(); +}); + test('a named stop reports the Gate refusal instead of judging the target itself', async () => { // The renderer no longer decides whether a Session can be stopped, so it // submits and lets the Gate answer. Its refusal is the clarification, which diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index 753f37e996..108b1bb594 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -589,6 +589,80 @@ test('Coordination transcript reads old active linkage without replaying older h await handle.close(); }); +test('Coordination transcript coalesces a burst into one Host snapshot refresh', async () => { + const sessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'coordination' }); + let deliver: ((batch: DesktopTranscriptBatch) => void) | undefined; + let snapshotReads = 0; + const adapter = createDesktopWorkHubCoordinationPort({ + sessionId, + transcripts: { + open: async (_requestedSessionId, handler) => { + deliver = handler; + handler({ + sessionId: 'coordination', + deliverySequence: 1, + generation: 'generation-1', + hostEpoch: 'epoch-1', + durableThrough: null, + fragments: [], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: false, + hasNewer: false, + reset: true, + ready: true, + }); + return { + sessionId, + generation: 'generation-1', + hostEpoch: 'epoch-1', + readThroughMessageId: null, + loadBefore: async () => {}, + loadAround: async () => {}, + close: async () => {}, + }; + }, + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => { + snapshotReads += 1; + return { + candidateSetId: `sha256:${'c'.repeat(64)}`, + candidates: [], + delegations: [], + }; + }, + act: async () => ({ + ok: true, + result: { disposition: 'answer_here', coordinationTurnId: 'coordination-turn' }, + }), + }); + const handle = await adapter.open(() => {}, (error) => assert.fail(String(error))); + assert.equal(snapshotReads, 1); + + for (let deliverySequence = 2; deliverySequence <= 4; deliverySequence += 1) { + deliver?.({ + sessionId: 'coordination', + deliverySequence, + generation: 'generation-1', + hostEpoch: 'epoch-1', + durableThrough: null, + fragments: [], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: false, + hasNewer: false, + reset: false, + ready: true, + }); + } + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(snapshotReads, 2); + await handle.close(); +}); + test('projects durable Session messages into an ordered WorkHub conversation', () => { const turns = projectWorkHubSessionTurns({ target: { sessionId: 'payment' }, diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index ab92e39e94..2795705a26 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -432,7 +432,9 @@ export function createWorkHubController(deps: { requestId: input.requestId, text: input.text, options: [], - reason: 'resume_target_unavailable', + reason: error.code === 'host_not_ready' + ? 'resume_host_recovering' + : 'resume_operation_unavailable', }; } if (error instanceof WorkHubCoordinationFailure && error.code === 'operation_conflict') { diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 41496d71e6..9858ddb3e7 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -76,16 +76,23 @@ export function createDesktopWorkHubCoordinationPort(deps: { let refreshGeneration = 0; let handle: Awaited> | undefined; let refreshLane = Promise.resolve(); + let refreshScheduled = false; + let scheduledRefreshGeneration = 0; let activeDelegations: readonly WorkHubActiveDelegation[] = []; const emit = () => { handler(projectWorkHubCoordinationTurns(store.snapshot().messages), activeDelegations); }; const scheduleRefresh = (generation: number) => { + scheduledRefreshGeneration = generation; + if (refreshScheduled) return; + refreshScheduled = true; refreshLane = refreshLane .then(async () => { - if (disposed || generation !== refreshGeneration) return; + const scheduledGeneration = scheduledRefreshGeneration; + refreshScheduled = false; + if (disposed || scheduledGeneration !== refreshGeneration) return; const result = await deps.candidates(); - if (disposed || generation !== refreshGeneration) return; + if (disposed || scheduledGeneration !== refreshGeneration) return; activeDelegations = result.delegations; if (ready) emit(); }) diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index 8a47325efe..90c855b7ef 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -82,8 +82,14 @@ export type WorkHubStopClarificationReason = | 'stop_target_unavailable' /** The resume names more than one existing Session. */ | 'resume_target_ambiguous' + /** The resume names no safe target of its own. */ + | 'resume_target_required' /** The Host refused the resume; its conflict is the whole answer. */ - | 'resume_target_unavailable'; + | 'resume_target_unavailable' + /** This Host does not expose safe-boundary resume. */ + | 'resume_operation_unavailable' + /** The Host is still recovering; retry may succeed. */ + | 'resume_host_recovering'; /** * A stop clarification never offers route options. Choosing one re-sends the @@ -217,7 +223,10 @@ function createWorkHubRoutePolicyVisit( }, resolveResume({ text, sessions }) { const action = readWorkHubRequestIntent(text).resume; - if (!action.imperative || !action.target) return { kind: 'not_requested' }; + if (!action.cue) return { kind: 'not_requested' }; + if (!action.imperative || !action.target) { + return { kind: 'clarification', reason: 'resume_target_required' }; + } return resolveNamedDelegationAction( sessionResolver, action.target, diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index c5f3ad0de3..b944edd7ff 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -657,8 +657,11 @@ function workHubClarificationPrompt( if (reason === 'stop_target_required') return copy.stopTargetRequired; if (reason === 'stop_target_ambiguous') return copy.stopTargetAmbiguous; if (reason === 'stop_target_unavailable') return copy.stopTargetUnavailable; + if (reason === 'resume_target_required') return copy.resumeTargetRequired; if (reason === 'resume_target_ambiguous') return copy.resumeTargetAmbiguous; if (reason === 'resume_target_unavailable') return copy.resumeTargetUnavailable; + if (reason === 'resume_operation_unavailable') return copy.resumeOperationUnavailable; + if (reason === 'resume_host_recovering') return copy.resumeHostRecovering; return undefined; } @@ -895,8 +898,11 @@ function workHubCopy(locale: UiLocale) { already_running: '这项工作还在跑,不需要恢复:', }, resumeRecorded: '结果已记录', + resumeTargetRequired: '请明确说出要继续的工作名称,例如“恢复 支付任务”。', resumeTargetAmbiguous: '这个名称对应多项工作;请打开具体的 Session 继续它。', - resumeTargetUnavailable: '当前 Runtime Host 无法继续这项工作;安全边界恢复可能未启用,或 Host 仍在恢复。', + resumeTargetUnavailable: '这项工作当前没有可恢复的单个 WorkHub 委派。', + resumeOperationUnavailable: '当前 Runtime Host 未启用安全边界恢复;请打开原 Session 继续处理。', + resumeHostRecovering: 'Runtime Host 仍在恢复;请稍后重试。', waitingForDecision: '这项工作正在等待你的决定。', requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', @@ -971,8 +977,11 @@ function workHubCopy(locale: UiLocale) { already_running: '這項工作仍在執行,不需要恢復:', }, resumeRecorded: '結果已記錄', + resumeTargetRequired: '請明確說出要繼續的工作名稱,例如「恢復 支付任務」。', resumeTargetAmbiguous: '這個名稱對應多項工作;請開啟具體的 Session 繼續它。', - resumeTargetUnavailable: '目前 Runtime Host 無法繼續這項工作;安全邊界恢復可能未啟用,或 Host 仍在恢復。', + resumeTargetUnavailable: '這項工作目前沒有可恢復的單一 WorkHub 委派。', + resumeOperationUnavailable: '目前 Runtime Host 未啟用安全邊界恢復;請開啟原 Session 繼續處理。', + resumeHostRecovering: 'Runtime Host 仍在恢復;請稍後重試。', submitFailures: { candidates_changed: '工作清單已變更,請重新傳送以使用最新目標。', linked_correction_unavailable: '跨 Session 更正將於持久委派關聯完成後開放;請先開啟原 Session 並停止目前工作。', @@ -1033,10 +1042,14 @@ function workHubCopy(locale: UiLocale) { already_running: 'This work is still running, so there was nothing to resume:', }, resumeRecorded: 'Result recorded', + resumeTargetRequired: 'Name the work explicitly, for example “Resume Payments”.', resumeTargetAmbiguous: 'That name matches more than one work item. Open the exact Session to resume it.', resumeTargetUnavailable: - 'The current Runtime Host cannot resume this work. Safe-boundary resume may be disabled, or the Host may still be recovering.', + 'This work has no single WorkHub delegation that can be resumed.', + resumeOperationUnavailable: + 'Safe-boundary resume is not enabled on this Runtime Host. Open the original Session to continue.', + resumeHostRecovering: 'The Runtime Host is still recovering. Try again shortly.', waitingForDecision: 'This work is waiting for your decision.', requestNotSent: 'The new request was not sent. Resolve the interaction in its Session, then send again.', routing: 'Choosing the right Session…', loadFailed: 'Could not read existing work.', diff --git a/packages/core/src/__tests__/workhub-creation-intent.test.ts b/packages/core/src/__tests__/workhub-creation-intent.test.ts index 6e96a18cc7..4a1107f119 100644 --- a/packages/core/src/__tests__/workhub-creation-intent.test.ts +++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts @@ -1136,11 +1136,15 @@ test('a resume names one Session, and reads like a stop everywhere else', () => ['恢复支付任务', '支付任务'], ['接着跑支付任务', '支付任务'], ] as const) { - assert.deepEqual(readWorkHubRequestIntent(text).resume, { imperative: true, target }, text); + assert.deepEqual( + readWorkHubRequestIntent(text).resume, + { cue: true, imperative: true, target }, + text, + ); } for (const text of ['Resume it', '恢复它']) { - assert.deepEqual(readWorkHubRequestIntent(text).resume, { imperative: false }, text); + assert.deepEqual(readWorkHubRequestIntent(text).resume, { cue: true, imperative: false }, text); } // Ambiguous verbs remain ordinary Session instructions rather than being diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index 2c2130e2b1..640bcee31d 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -156,6 +156,8 @@ export interface WorkHubRequestIntent { readonly target?: string; }; readonly resume: { + /** A direct resume speech act was present, but its target may still be unsafe. */ + readonly cue: boolean; /** True only for a direct, explicitly named resume command. */ readonly imperative: boolean; readonly target?: string; @@ -355,6 +357,7 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { ...(stop.target ? { target: stop.target } : {}), }, resume: { + cue: resume.cue, imperative: Boolean(resume.target), ...(resume.target ? { target: resume.target } : {}), }, diff --git a/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts b/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts index 2a0fed2289..cac49621f3 100644 --- a/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts +++ b/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts @@ -21,8 +21,56 @@ import assert from 'node:assert/strict'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { test } from 'node:test'; import { createSqliteAgentRunStore } from '../agent-run-store.js'; +import { migrateSqliteCoreExecutionDatabase } from '../sqlite-core-execution-schema.js'; + +test('core execution migration preserves databases with historical continuation forks', () => { + const database = new DatabaseSync(':memory:'); + try { + database.exec(` + CREATE TABLE core_root_turn_admissions ( + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + admitted_at INTEGER NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (session_id, turn_id) + ); + `); + const insert = database.prepare(` + INSERT INTO core_root_turn_admissions(session_id, turn_id, admitted_at, record_json) + VALUES (?, ?, ?, ?) + `); + for (const [turnId, admittedAt] of [ + ['continuation-a', 20], + ['continuation-b', 30], + ] as const) { + insert.run( + 'session', + turnId, + admittedAt, + JSON.stringify({ + sessionId: 'session', + turnId, + execution: { + kind: 'safe_boundary_continuation', + sourceTurnId: 'source-turn', + sourceRunId: 'source-run', + }, + }), + ); + } + + assert.doesNotThrow(() => migrateSqliteCoreExecutionDatabase(database)); + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM core_root_turn_admissions').get()?.count, + 2, + ); + } finally { + database.close(); + } +}); test('safe-boundary continuation admission is indexed by its source execution', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-continuation-admission-')); @@ -63,6 +111,31 @@ test('safe-boundary continuation admission is indexed by its source execution', admittedAt: 20, }); assert.equal(continuation.kind, 'admitted'); + await assert.rejects( + store.admitRootTurn({ + sessionId: 'session', + turnId: 'competing-continuation-turn', + proposedRunId: 'competing-continuation-run', + proposedUserMessageId: null, + execution: { + kind: 'safe_boundary_continuation', + sourceInvocationId: 'source-invocation', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 7, + claimId: 'competing-continuation-claim', + boundaryDigest: `sha256:${'d'.repeat(64)}`, + providerReplayDigest: `sha256:${'e'.repeat(64)}`, + safetyDigest: `sha256:${'f'.repeat(64)}`, + targetInvocationId: 'competing-continuation-invocation', + }, + previousRootTurnId: 'source-turn', + normalizedInput: null, + sourceMessages: [], + admittedAt: 30, + }), + /already has continuation continuation-turn/, + ); assert.deepEqual( await store.readRootTurnContinuationAdmission('session', 'source-turn', 'source-run'), diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index b1ae6f9f25..283778133b 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -593,6 +593,26 @@ class SqliteAgentRunStore implements DurableAgentRunStore { ) { throw new Error('Root Turn identity is already rejected'); } + if (admission.execution.kind === 'safe_boundary_continuation') { + const sourceOwner = this.#lease.database + .prepare(` + SELECT turn_id + FROM core_root_turn_admissions + WHERE session_id = ? + AND json_extract(record_json, '$.execution.sourceTurnId') = ? + AND json_extract(record_json, '$.execution.sourceRunId') = ? + AND json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation' + LIMIT 1 + `) + .get( + admission.sessionId, + admission.execution.sourceTurnId, + admission.execution.sourceRunId, + ) as { turn_id?: unknown } | undefined; + if (sourceOwner) { + throw new Error(`Root execution already has continuation ${String(sourceOwner.turn_id)}`); + } + } for (const source of admission.sourceMessages) { const proof = this.#lease.database .prepare(` @@ -648,7 +668,7 @@ class SqliteAgentRunStore implements DurableAgentRunStore { assertSafeId(sessionId, 'Invalid session id'); assertSafeId(sourceTurnId, 'Invalid source turn id'); assertSafeId(sourceRunId, 'Invalid source run id'); - const row = this.#lease.database + const rows = this.#lease.database .prepare(` SELECT turn_id, record_json FROM core_root_turn_admissions @@ -656,11 +676,18 @@ class SqliteAgentRunStore implements DurableAgentRunStore { AND json_extract(record_json, '$.execution.sourceTurnId') = ? AND json_extract(record_json, '$.execution.sourceRunId') = ? AND json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation' + ORDER BY admitted_at, turn_id + LIMIT 2 `) - .get(sessionId, sourceTurnId, sourceRunId) as - | { turn_id?: unknown; record_json?: unknown } - | undefined; - if (!row) return undefined; + .all(sessionId, sourceTurnId, sourceRunId) as Array<{ + turn_id?: unknown; + record_json?: unknown; + }>; + if (rows.length === 0) return undefined; + if (rows.length > 1) { + throw new Error('Root execution has multiple durable continuation admissions'); + } + const row = rows[0]!; if (typeof row.turn_id !== 'string' || typeof row.record_json !== 'string') { throw new Error('Invalid SQLite root turn continuation admission row'); } diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index b51416532b..d094dfe63a 100644 --- a/packages/storage/src/sqlite-core-execution-schema.ts +++ b/packages/storage/src/sqlite-core-execution-schema.ts @@ -172,7 +172,9 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { ON core_agent_runs(session_id, latest_model_call_sequence, run_id) WHERE latest_model_call_sequence IS NOT NULL; - CREATE UNIQUE INDEX IF NOT EXISTS core_root_turn_continuation_source + DROP INDEX IF EXISTS core_root_turn_continuation_source; + + CREATE INDEX core_root_turn_continuation_source ON core_root_turn_admissions( session_id, json_extract(record_json, '$.execution.sourceTurnId'),