diff --git a/.github/assets/workhub-turn-admission/receipts-dark.png b/.github/assets/workhub-turn-admission/receipts-dark.png new file mode 100644 index 0000000000..2d6fb9594a Binary files /dev/null and b/.github/assets/workhub-turn-admission/receipts-dark.png differ diff --git a/.github/assets/workhub-turn-admission/receipts-light.png b/.github/assets/workhub-turn-admission/receipts-light.png new file mode 100644 index 0000000000..7042dcdb20 Binary files /dev/null and b/.github/assets/workhub-turn-admission/receipts-light.png differ diff --git a/apps/desktop/e2e/new-task-reload.spec.ts b/apps/desktop/e2e/new-task-reload.spec.ts index b35056981d..a6c54cac76 100644 --- a/apps/desktop/e2e/new-task-reload.spec.ts +++ b/apps/desktop/e2e/new-task-reload.spec.ts @@ -28,6 +28,9 @@ test('archived-only history boots into a usable new task', async ({ window: page await composer.press('Enter'); await expect(reply).toBeVisible({ timeout: 20_000 }); + // Visible streaming text is not proof that the Host has released the Turn. + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1); + // Prove bootstrap can restore this history before archiving it. await page.reload(); await expect(reply).toBeVisible(); diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index 3acf19c471..f1cc999677 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -36,10 +36,13 @@ test('WorkHub rebuilds delegated execution feedback after navigating away and ba timeout: 20_000, }); - const sessionName = await page.evaluate(async () => - (await window.maka.sessions.list())[0]?.name, - ); - expect(sessionName).toBeTruthy(); + // This test owns navigation identity, not asynchronous title generation. + const sessionName = initialPrompt; + await page.evaluate(async (name) => { + const session = (await window.maka.sessions.list())[0]; + if (!session) throw new Error('Source Session was not found'); + await window.maka.sessions.rename(session.id, name); + }, sessionName); await page.evaluate(async () => { await window.maka.settings.updateClient({ workHub: { enabled: true } }); }); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 4712d5e0c1..098cfae5ac 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -747,7 +747,6 @@ "window.maka.transcripts": 2, "window.maka.workHub.act": 1, "window.maka.workHub.candidates": 1, - "window.maka.workHub.record": 1, "window.maka.workHub.resolveCoordinationSession": 1 }, "environmentCapabilities": { @@ -895,7 +894,7 @@ "react": 1 }, "importSpecifiers": 116, - "nonTriviaTokens": 14338 + "nonTriviaTokens": 14318 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 302d644243..e5e97357da 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -100,7 +100,6 @@ test('resolves WorkHub coordination through the dedicated Host operation', async { sessionId: 'maka_workhub_coordination' }, { candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }, { disposition: 'answer_here', coordinationTurnId: 'action-turn' }, - { turnId: 'summary-turn' }, ]); assert.deepEqual(await client.resolveWorkHubCoordinationSession(), { @@ -118,14 +117,6 @@ test('resolves WorkHub coordination through the dedicated Host operation', async }), { disposition: 'answer_here', coordinationTurnId: 'action-turn' }, ); - assert.deepEqual( - await client.recordWorkHubCoordination({ - turnId: 'summary-turn', - userText: 'Request', - assistantText: 'Summary', - }), - { turnId: 'summary-turn' }, - ); assert.deepEqual(requests, [ { operation: 'workhub.coordination.resolve', input: {} }, { operation: 'workhub.coordination.candidates', input: {} }, @@ -137,14 +128,7 @@ test('resolves WorkHub coordination through the dedicated Host operation', async proposal: { disposition: 'answer_here' }, }, }, - { - operation: 'workhub.coordination.record', - input: { - turnId: 'summary-turn', - userText: 'Request', - assistantText: 'Summary', - }, - }, + ]); }); 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 33e8313b62..bd15684858 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 @@ -25,7 +25,6 @@ import { registerRuntimeHostWorkHubIpc } from '../runtime-host-workhub-ipc-main. test('projects WorkHub coordination resolution through its dedicated IPC domain', async () => { const handlers = new Map unknown>(); let resolveCalls = 0; - const records: unknown[] = []; const actions: unknown[] = []; const changes: unknown[] = []; const createdSessionId = 'runtime-created-session'; @@ -35,14 +34,6 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' resolveCalls += 1; return { sessionId: 'maka_workhub_coordination' }; }, - recordWorkHubCoordination: async (input: { - turnId: string; - userText: string; - assistantText: string; - }) => { - records.push(input); - return { turnId: input.turnId }; - }, listWorkHubCoordinationCandidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], @@ -74,19 +65,7 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' assert.ok(handler); assert.deepEqual(await handler({}), { sessionId: 'maka_workhub_coordination' }); assert.equal(resolveCalls, 1); - assert.deepEqual( - await handlers.get('workhub:record')?.({}, { - turnId: 'record', - userText: 'Request', - assistantText: 'Summary', - }), - { turnId: 'record' }, - ); - assert.deepEqual(records, [{ - turnId: 'record', - userText: 'Request', - assistantText: 'Summary', - }]); + assert.equal(handlers.has('workhub:record'), false); assert.deepEqual(await handlers.get('workhub:candidates')?.({}), { candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], @@ -123,14 +102,15 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' assert.deepEqual(changes, [{ reason: 'created', sessionId: createdSessionId }]); }); -test('serializes typed WorkHub action failures across Electron IPC', async () => { +for (const code of ['operation_conflict', 'candidate_set_stale'] as const) { +test(`serializes typed WorkHub action failures across Electron IPC (${code})`, async () => { const handlers = new Map unknown>(); registerRuntimeHostWorkHubIpc( { actWorkHubCoordination: async () => { throw new RuntimeHostOperationError( 'workhub.coordination.act', - 'operation_conflict', + code, 'WorkHub action is permanently abandoned', ); }, @@ -156,9 +136,10 @@ test('serializes typed WorkHub action failures across Electron IPC', async () => { ok: false, error: { - code: 'operation_conflict', + code, message: 'WorkHub action is permanently abandoned', }, }, ); }); +} diff --git a/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts b/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts index e0dcbb3c1a..d2f701052f 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts @@ -83,7 +83,7 @@ export function createWorkHubController({ ...(routingStrategy ? { routingStrategy } : {}), coordination: { open: async (handler) => { handler(transcript); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => { const candidates = (await sessions.list()) .filter((entry) => entry.kind === 'ordinary' && !entry.archived) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 98b68f833d..04e259ca0a 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -112,7 +112,7 @@ test('conversation acknowledges a durable assignment before projecting target ex handler([assignment]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), }, @@ -159,7 +159,7 @@ test('conversation feedback never lets an older refresh overwrite newer target s handler([assignment]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), }, @@ -201,7 +201,7 @@ test('direct stop bypasses routing candidates and preserves a not_owned delegati handler([coordinationAssignmentTurn()]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => { candidateReads += 1; return { candidateSetId: `sha256:${'d'.repeat(64)}`, candidates: [] }; @@ -257,7 +257,7 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => assert.fail('anaphoric stop must not reach the Action Gate'), }, @@ -284,7 +284,7 @@ test('a named resume submits and reports what the Host did', async () => { handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -329,7 +329,7 @@ test('an anaphoric resume asks for a named work item', async () => { 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'), }, @@ -356,7 +356,7 @@ test('a resume the Host will not admit becomes its clarification', async () => { handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -394,7 +394,7 @@ test('a resume identity conflict is not mislabeled as a missing target', async ( handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -421,7 +421,7 @@ test('a Runtime Host without safe-boundary resume explains why it cannot resume' handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -455,7 +455,7 @@ test('a recovering Runtime Host tells the user to retry resume', async () => { handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -486,7 +486,7 @@ test('a named stop reports the Gate refusal instead of judging the target itself handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => { submitted += 1; @@ -520,7 +520,7 @@ test('a stop that fails for any other reason is a fault, not a clarification', a handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => { throw new WorkHubCoordinationFailure('persistence_failed', 'WorkHub stop state is unavailable'); @@ -550,7 +550,7 @@ test('stop-shaped ordinary work routes normally instead of looping on clarificat handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ @@ -1669,7 +1669,7 @@ test('submit keeps unmatched non-executable conversation in WorkHub', async () = sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], @@ -1716,7 +1716,7 @@ test('production submission delegates only through the Runtime-owned candidate r sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, candidates: [{ @@ -1771,7 +1771,7 @@ test('production retry reaches durable Action Gate replay while target is waitin sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [{ @@ -1817,7 +1817,7 @@ test('production sends an explicit correction as a linked replacement', async () sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'d'.repeat(64)}`, candidates: [ @@ -1943,7 +1943,7 @@ test('production natural-language corrections retain the prior delegation link', sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId, candidates: candidates.map((candidate) => { @@ -2043,7 +2043,7 @@ test('production correction-shaped creation stays create_new without an existing sessions: port([]), coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [], @@ -2074,9 +2074,6 @@ test('production clarification is persisted through the typed Action Gate dispos sessions: port([]), coordination: { open: async () => ({ close: async () => undefined }), - record: async () => { - throw new Error('legacy summary recording must not persist clarification'); - }, candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [], @@ -2091,11 +2088,10 @@ test('production clarification is persisted through the typed Action Gate dispos }, }); - assert.deepEqual(await controller.recordConversationTurn({ + assert.deepEqual(await controller.requestClarification({ turnId: 'clarification-action', userText: '继续稳定性问题', assistantText: '请选择目标 Session', - disposition: 'clarify', }), { turnId: 'clarification-turn' }); assert.deepEqual(actions, [{ actionId: 'clarification-action', @@ -2117,7 +2113,7 @@ test('production creation leaves Session identity and workspace authority to mai sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [], @@ -3281,7 +3277,7 @@ for (const createStrategy of [createWorkHubR24RoutingStrategy, () => createWorkH routingStrategy, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'payments-ref', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }] }), act: async (input) => { assert.equal(input.proposal.disposition, 'resume_work'); @@ -3362,3 +3358,45 @@ test('composer defaults apply only to creation while attachments follow explicit assert.deepEqual(actions[1]?.newWorkDefaults, newWorkDefaults); assert.deepEqual(actions[1]?.attachments, attachments); }); + +for (const mode of ['refresh', 'missing', 'renamed', 'churn', 'conflict'] as const) { + test(`replacement candidate refresh preserves the chosen Session (${mode})`, async () => { + const actions: WorkHubCoordinationActInput[] = []; + let reads = 0; + const controller = createGatedWorkHubController({ + sessions: port([session('source'), session('target')]), + coordination: { + open: async () => ({ close: async () => undefined }), + candidates: async () => { + const version = reads++; + return { + candidateSetId: `sha256:${String(version).repeat(64)}`, + candidates: ['other', 'target', 'source'].filter((id) => !(mode === 'missing' && version > 0 && id === 'target')).map((id) => ({ + candidateRef: `${id}-${version}`, sessionId: id, + sessionName: mode === 'renamed' && version > 0 && id === 'target' ? 'different work' : id, + workspace: { target: { kind: 'host_path' as const, path: `/workspace/${id}` }, hostCwd: `/workspace/${id}` }, + state: 'active' as const, updatedAt: version, + })), + }; + }, + act: async (input) => { + actions.push(input); + if (actions.length === 1 || mode === 'churn') throw new WorkHubCoordinationFailure( + mode === 'conflict' ? 'operation_conflict' : 'candidate_set_stale', 'Snapshot changed'); + return { disposition: 'replace', replacementDisposition: 'delegate_existing', targetSessionId: 'target', targetTurnId: 'replacement-turn' }; + }, + }, + }); + const submit = () => controller.submit({ newSessionFallbackTitle: 'New task', requestId: 'same-action', text: 'No, use target instead', explicitTarget: { sessionId: 'target' }, correction: { from: { sessionId: 'source' }, sourceActionId: 'source-action' } }); + if (mode === 'refresh') { + assert.equal((await submit()).kind, 'submitted'); + assert.equal(actions.length, 2); + assert.deepEqual(actions.map((action) => action.actionId), ['same-action', 'same-action']); + assert.deepEqual(actions.map((action) => action.proposal), [0, 1].map((version) => ({ disposition: 'replace', replacesActionId: 'source-action', target: { disposition: 'delegate_existing', candidateRef: `target-${version}` } }))); + assert.notEqual(actions[0]!.candidateSetId, actions[1]!.candidateSetId); + } else { + await assert.rejects(submit, WorkHubCoordinationFailure); + assert.equal(actions.length, mode === 'churn' ? 3 : 1); + } + }); +} diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index 8d3220cfb8..3cba8f91ee 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -138,7 +138,6 @@ test('Coordination tail recovery converges through the preload with a fragmented return bridge!.transcripts.open(requestedSessionId, handler, registerCancellation); }, }, - record: async (input) => ({ turnId: input.turnId }), candidates: async () => assert.fail('unused'), act: async () => assert.fail('unused'), }); 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 e5b668ca78..c4568225bb 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -106,68 +106,71 @@ function transcriptsWith(messages: readonly StoredMessage[]) { }; } -test('projects the durable Coordination transcript into the WorkHub conversation', () => { - const messages: StoredMessage[] = [ - { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 10, text: 'What is next?' }, - { - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 11, - text: 'Slice 3 is next.', - modelId: 'test-model', - }, - { - type: 'turn_state', - id: 'state-1', +for (const physicalTurnId of ['action-1', 'action-1-retry']) { + test(`projects the durable Coordination transcript into the WorkHub conversation (${physicalTurnId})`, () => { + const messages: StoredMessage[] = [ + { type: 'user', id: 'action-user', turnId: physicalTurnId, ts: 19, text: 'Continue payments' }, + { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 10, text: 'What is next?' }, + { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 11, + text: 'Slice 3 is next.', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 12, + status: 'completed', + }, + { + type: 'workhub_coordination', + id: 'assignment-1', + turnId: physicalTurnId, + ts: 20, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId: 'action-1', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + coordinationTurnId: physicalTurnId, + targetSessionId: 'payments', + targetSessionName: 'Payments', + targetTurnId: 'payments-turn', + targetMessageId: 'payments-message', + delegationId: 'payments-delegation', + disposition: 'delegate_existing', + userText: 'Continue payments', + }, + ]; + assert.deepEqual(projectWorkHubCoordinationTurns(messages), [{ + messageId: 'user-1', turnId: 'turn-1', - ts: 12, - status: 'completed', - }, - { - type: 'workhub_coordination', - id: 'assignment-1', + text: 'What is next?', + result: 'Slice 3 is next.', + state: 'completed', + updatedAt: 11, + }, { + messageId: 'assignment-1', turnId: 'action-1', - ts: 20, - schemaVersion: 1, - kind: 'delegation_assigned', - actionId: 'action-1', - actionFingerprint: `sha256:${'a'.repeat(64)}`, - coordinationTurnId: 'action-1', - targetSessionId: 'payments', - targetSessionName: 'Payments', - targetTurnId: 'payments-turn', - targetMessageId: 'payments-message', - delegationId: 'payments-delegation', - disposition: 'delegate_existing', - userText: 'Continue payments', - }, - ]; - assert.deepEqual(projectWorkHubCoordinationTurns(messages), [{ - messageId: 'user-1', - turnId: 'turn-1', - text: 'What is next?', - result: 'Slice 3 is next.', - state: 'completed', - updatedAt: 11, - }, { - messageId: 'assignment-1', - turnId: 'action-1', - text: 'Continue payments', - state: 'completed', - assignment: { - actionId: 'action-1', - delegationId: 'payments-delegation', - targetSessionId: 'payments', - targetSessionName: 'Payments', - targetMessageId: 'payments-message', - targetTurnId: 'payments-turn', - feedbackState: 'accepted', - linkState: 'active', - }, - updatedAt: 20, - }]); -}); + text: 'Continue payments', + state: 'completed', + assignment: { + actionId: 'action-1', + delegationId: 'payments-delegation', + targetSessionId: 'payments', + targetSessionName: 'Payments', + targetMessageId: 'payments-message', + targetTurnId: 'payments-turn', + feedbackState: 'accepted', + linkState: 'active', + }, + updatedAt: 20, + }]); + }); +} test('bounds the visible timeline independently of old delegation linkage', () => { const assignment: StoredMessage = { @@ -309,6 +312,11 @@ test('direct-stop projection is retryable until resolved and preserves not_owned outcome: 'not_owned', }); assert.equal(projected[0]?.assignment?.linkState, 'active'); + const retriedStop = { ...requested, turnId: 'stop-retry', coordinationTurnId: 'stop-retry' }; + assert.equal( + projectWorkHubCoordinationTurns([assignment, retriedStop])[1]?.turnId, + 'stop-action', + ); const stopped = { ...notOwned, outcome: 'stop_delivered' as const }; assert.equal( @@ -448,7 +456,7 @@ test('Coordination transcript adapter never replays history and completes only t }; }, }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('conversation open must not read route candidates'), act: async () => ({ ok: true, @@ -532,7 +540,7 @@ test('Coordination transcript adapter retries latest-record completion in the sa }; }, }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('conversation open must not read route candidates'), act: async () => ({ ok: true, @@ -630,7 +638,7 @@ test('Coordination transcript adapter ignores a stale latest-record failure afte }; }, }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('conversation open must not read route candidates'), act: async () => ({ ok: true, @@ -1158,3 +1166,55 @@ test('desktop adapter derives stable origin evidence from the existing Session l assert.deepEqual(second, first); assert.equal(reads, 1); }); + + +test('admitted clarification and resume project receipts without assistant messages', () => { + const turns = projectWorkHubCoordinationTurns([ + { type: 'user', id: 'failed-user', turnId: 'request', ts: 0, text: 'Which task?' }, + { type: 'turn_state', id: 'failed-state', turnId: 'request', ts: 1, status: 'failed' }, + { type: 'user', id: 'u', turnId: 'retry-turn', ts: 1, text: 'Which task?' }, + { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'receipt', turnId: 'retry-turn', ts: 2, + receipt: { actionId: 'request', userText: 'Which task?', clarification: 'Please name a task.', + result: { disposition: 'clarify', coordinationTurnId: 'retry-turn' } } }, + { type: 'turn_state', id: 'done', turnId: 'retry-turn', ts: 3, status: 'completed' }, + { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'resume-receipt', turnId: 'resume-turn', ts: 4, + receipt: { actionId: 'resume', userText: 'Resume Payments', + result: { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'target-turn' } } }, + { type: 'turn_state', id: 'resume-done', turnId: 'resume-turn', ts: 5, status: 'completed' }, + { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'resume-retry-receipt', turnId: 'resume-retry-turn', ts: 6, + receipt: { actionId: 'resume', userText: 'Resume Payments', + result: { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'target-turn' } } }, + { type: 'turn_state', id: 'resume-retry-done', turnId: 'resume-retry-turn', ts: 7, status: 'completed' }, + + ]); + assert.equal(turns.length, 2); + assert.equal(turns[0]?.turnId, 'request'); + assert.equal(turns[0]?.result, 'Please name a task.'); + assert.equal(turns[0]?.state, 'completed'); + assert.equal(turns[1]?.result, undefined); + assert.deepEqual(turns[1]?.resume, { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'target-turn' }); +}); + + +test('failed action inputs remain visible until a visible receipt replaces every physical retry', () => { + const failed: StoredMessage[] = ['resume', 'retry-one', 'retry-two'].flatMap((turnId, index) => [ + { type: 'user' as const, id: `u-${turnId}`, turnId, ts: index * 2, + text: 'Resume Payments', coordinationActionId: 'resume' }, + { type: 'turn_state' as const, id: `s-${turnId}`, turnId, ts: index * 2 + 1, status: 'failed' as const }, + ]); + const receipt: StoredMessage = { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'resumed', turnId: 'retry-three', ts: 10, + receipt: { actionId: 'resume', userText: 'Resume Payments', result: { + disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'original-target', + } } }; + assert.deepEqual(projectWorkHubCoordinationTurns(failed).map((row) => [row.text, row.state, row.coordinationActionId]), + Array.from({ length: 3 }, () => ['Resume Payments', 'failed', 'resume'])); + const visible = projectWorkHubCoordinationTurns([...failed, receipt]); + assert.equal(visible.length, 1); + assert.equal(visible[0]?.resume?.targetTurnId, 'original-target'); + // A bounded older page with no visible receipt must still reconstruct its inputs. + assert.equal(projectWorkHubCoordinationTurns(failed).length, 3); +}); 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 0644ad6553..f43e1e83cc 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -449,7 +449,7 @@ test('surface keeps clarification and successful routing in WorkHub', async () = handler([]); return { close: async () => undefined }; }, - recordConversationTurn: async ({ turnId }) => ({ turnId }), + requestClarification: async ({ turnId }) => ({ turnId }), resetVisitContext: () => {}, subscribe: () => () => {}, submit: async (input) => { @@ -511,7 +511,7 @@ test('ambiguous creation is durably clarified before a fresh imperative creates }, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], @@ -593,7 +593,7 @@ test('surface leaves discussion in WorkHub instead of creating a task view', asy handler([]); return { close: async () => undefined }; }, - recordConversationTurn: async ({ turnId }) => ({ turnId }), + requestClarification: async ({ turnId }) => ({ turnId }), resetVisitContext: () => {}, subscribe: () => () => {}, submit: async (input) => ({ @@ -684,7 +684,7 @@ test('real Session projection creates new guide topics and preserves origin ambi sessions: port, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: sessions.map((entry) => ({ @@ -879,7 +879,7 @@ test('successful delegated submission needs no renderer summary write', async () assert.equal(records, 0); }); -test('resume records ordinary conversation text without persisting execution fields', async () => { +test('resume relies on the admitted Host receipt without a second conversation write', async () => { const records: unknown[] = []; const controller = fakeController({ submit: async (input) => ({ @@ -895,11 +895,7 @@ test('resume records ordinary conversation text without persisting execution fie summary: () => 'Resume requested. See the target Session for current progress.', onSummaryError: () => assert.fail('conversation write must succeed'), }); - assert.deepEqual(records, [{ - turnId: 'resume-1', userText: 'Resume Payments', - assistantText: 'Resume requested. See the target Session for current progress.', disposition: 'summary', - }]); -}); + assert.deepEqual(records, []);}); test('lease retires only after an acknowledged submission', async () => { const { storage } = memoryStorage(); @@ -969,13 +965,13 @@ function memoryStorage() { function fakeController(input: { submit: WorkHubController['submit']; - record: WorkHubController['recordConversationTurn']; + record: WorkHubController['requestClarification']; }): WorkHubController { return { read: async () => ({ sessions: [], turns: [] }), submit: input.submit, openConversation: async () => ({ close: async () => undefined }), - recordConversationTurn: input.record, + requestClarification: input.record, subscribe: () => () => undefined, resetVisitContext: () => undefined, }; diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 966bc58334..b36fee7732 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -987,12 +987,6 @@ export class DesktopRuntimeHostClient { - recordWorkHubCoordination( - input: OperationInput<"workhub.coordination.record">, - ): Promise> { - return this.request("workhub.coordination.record", input); - } - listExternalSessionSources(): Promise { return this.request("external-session.source.query", {}); } diff --git a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts index 7bb4283f0a..b89f428d18 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -35,7 +35,6 @@ type RuntimeHostWorkHubClient = Pick< | 'ingestAttachment' | 'actWorkHubCoordination' | 'listWorkHubCoordinationCandidates' - | 'recordWorkHubCoordination' | 'resolveWorkHubCoordinationSession' >; @@ -56,9 +55,6 @@ export function registerRuntimeHostWorkHubIpc( ipcMain.handle('workhub:resolveCoordinationSession', () => client.resolveWorkHubCoordinationSession(), ); - ipcMain.handle('workhub:record', (_event, input) => - client.recordWorkHubCoordination(input), - ); ipcMain.handle('workhub:candidates', () => client.listWorkHubCoordinationCandidates()); ipcMain.handle('workhub:prepareAttachments', async (event, items: unknown) => { if (!options.attachmentIngest) throw new Error('WorkHub attachments are unavailable'); @@ -139,6 +135,7 @@ function workHubActError( case 'not_found': case 'session_archived': case 'session_busy': + case 'candidate_set_stale': case 'operation_conflict': case 'persistence_failed': case 'commit_outcome_unknown': diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 68465dac92..46d52f4f33 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1011,11 +1011,6 @@ export interface MakaBridge { prepareAttachments(coordinationSessionId: string, items: RendererIngestInput[]): Promise; /** Resolve the active Runtime Host's stable coordination conversation. */ resolveCoordinationSession(): Promise; - /** Persist one deterministic clarification or routing summary. */ - record( - coordinationSessionId: string, - input: { turnId: string; userText: string; assistantText: string }, - ): Promise<{ turnId: string }>; /** Read one bounded, Host-issued candidate set for a coordination action. */ candidates( coordinationSessionId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 23164f1de7..fead146cef 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2016,16 +2016,6 @@ const makaBridge = { (scope) => ipcRenderer.invoke('workhub:resolveCoordinationSession', scope), ); }, - async record( - coordinationSessionId: string, - input: { turnId: string; userText: string; assistantText: string }, - ): Promise<{ turnId: string }> { - const scope = await resolveDesktopWorkHubCoordinationCreateScope( - coordinationSessionId, - runtimeHostSessionRef, - ); - return ipcRenderer.invoke('workhub:record', scope, input) as Promise<{ turnId: string }>; - }, async candidates( coordinationSessionId: string, ): Promise> { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index cdd11d6866..63a7300f0e 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1283,8 +1283,6 @@ function AppShellContent({ coordination: createDesktopWorkHubCoordinationPort({ sessionId: workHubCoordinationSessionId ?? 'workhub-coordination-unresolved', transcripts: window.maka.transcripts, - record: (input) => - window.maka.workHub.record(workHubCoordinationSessionId!, input), candidates: () => window.maka.workHub.candidates(workHubCoordinationSessionId!), act: (input) => diff --git a/apps/desktop/src/renderer/locales/workhub-copy.ts b/apps/desktop/src/renderer/locales/workhub-copy.ts index 86f67eedfa..dfc67ceddd 100644 --- a/apps/desktop/src/renderer/locales/workhub-copy.ts +++ b/apps/desktop/src/renderer/locales/workhub-copy.ts @@ -213,6 +213,7 @@ export interface WorkHubCopy { readonly aborted: string; readonly stopped: string; }; + readonly actionConfirmationIncomplete: string; readonly turnStates: Record<'running' | 'completed' | 'aborted' | 'failed', string>; } @@ -286,6 +287,7 @@ const WORKHUB_COPY = { aborted: '更正已中止', stopped: '已停止关联', }, + actionConfirmationIncomplete: '操作确认未完成;请查看目标任务状态后重试。', turnStates: { running: '进行中', completed: '已完成', aborted: '已中止', failed: '失败' }, }, 'zh-TW': { @@ -357,6 +359,7 @@ const WORKHUB_COPY = { aborted: '更正已中止', stopped: '已停止關聯', }, + actionConfirmationIncomplete: '操作確認未完成;請查看目標任務狀態後重試。', turnStates: { running: '進行中', completed: '已完成', aborted: '已中止', failed: '失敗' }, }, en: { @@ -433,6 +436,7 @@ const WORKHUB_COPY = { aborted: 'Aborted replacement', stopped: 'Stopped link', }, + actionConfirmationIncomplete: 'Action confirmation is incomplete. Check the target task before retrying.', turnStates: { running: 'Running', completed: 'Completed', aborted: 'Aborted', failed: 'Failed' }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 5c00022927..397ca48771 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -122,6 +122,7 @@ export interface WorkHubProjectedTurn { } export interface WorkHubCoordinationTurn { + coordinationActionId?: string; attachments?: WorkHubCoordinationActInput['attachments']; messageId: string; turnId: string; @@ -144,6 +145,7 @@ export interface WorkHubCoordinationTurn { readonly targetSessionName: string; readonly outcome?: Extract['outcome']; }; + resume?: Extract; updatedAt: number; } @@ -264,11 +266,6 @@ export interface WorkHubCoordinationPort { handler: (turns: readonly WorkHubCoordinationTurn[]) => void, onError: (error: unknown) => void, ): Promise<{ close(): Promise }>; - record(input: { - turnId: string; - userText: string; - assistantText: string; - }): Promise<{ turnId: string }>; candidates(): Promise; act(input: Omit): Promise; } @@ -282,11 +279,10 @@ export interface WorkHubController { ) => void, onError: (error: unknown) => void, ): Promise<{ close(): Promise }>; - recordConversationTurn(input: { + requestClarification(input: { turnId: string; userText: string; assistantText: string; - disposition?: 'clarify' | 'summary'; }): Promise<{ turnId: string }>; subscribe(handler: () => void): () => void; resetVisitContext(): void; @@ -527,26 +523,16 @@ export function createWorkHubController(deps: { }, }; }, - async recordConversationTurn(input) { - if (input.disposition === 'clarify') { - const result = await coordination.act({ - actionId: input.turnId, - userText: input.userText, - proposal: { - disposition: 'clarify', - assistantText: input.assistantText, - }, - }); - if (result.disposition !== 'clarify') { - throw new Error('WorkHub Action Gate returned an unexpected disposition'); - } - return { turnId: result.coordinationTurnId }; - } - return coordination.record({ - turnId: input.turnId, + async requestClarification(input) { + const result = await coordination.act({ + actionId: input.turnId, userText: input.userText, - assistantText: input.assistantText, + proposal: { disposition: 'clarify', assistantText: input.assistantText }, }); + if (result.disposition !== 'clarify') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } + return { turnId: result.coordinationTurnId }; }, subscribe(handler) { return deps.sessions.subscribe(handler); @@ -745,7 +731,7 @@ export function createWorkHubController(deps: { if (!candidate) { throw new ExpectedOperationError('candidates_changed'); } - const action: WorkHubCoordinationActInput = correction + let action: WorkHubCoordinationActInput = correction ? { actionId: input.requestId, userText: input.text, @@ -771,7 +757,32 @@ export function createWorkHubController(deps: { candidateRef: candidate.candidateRef, }, }; - const admitted = await coordination.act(action); + let admitted: WorkHubCoordinationActResult; + for (let attempt = 0; ; attempt++) { + try { + admitted = await coordination.act(action); + break; + } catch (error) { + if ( + !(error instanceof WorkHubCoordinationFailure) || error.code !== 'candidate_set_stale' || + attempt >= 2 || action.proposal.disposition !== 'replace' || + action.proposal.target.disposition !== 'delegate_existing' + ) throw error; + // Refresh only the opaque reference for the already chosen Session. + // Do not rerun routing or change action/source identity on this retry. + const refreshed = await coordination.candidates(); + const sameTarget = refreshed.candidates.find((item) => item.sessionId === target.sessionId); + if (!sameTarget || sameTarget.sessionName !== candidate.sessionName) throw error; + action = { + ...action, + candidateSetId: refreshed.candidateSetId, + proposal: { + ...action.proposal, + target: { disposition: 'delegate_existing', candidateRef: sameTarget.candidateRef }, + }, + }; + } + } if ( (!correction && admitted.disposition !== 'delegate_existing') || (correction && diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index a157a60b3f..574434c997 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -50,18 +50,12 @@ const WORKHUB_COORDINATION_LATEST_RECORD_MAX_BYTES = 512 * 1024; export function createDesktopWorkHubCoordinationPort(deps: { sessionId: string; transcripts: WorkHubDesktopTranscriptBridge; - record(input: { - turnId: string; - userText: string; - assistantText: string; - }): Promise<{ turnId: string }>; candidates(): Promise; act( input: Omit, ): Promise>; }): WorkHubCoordinationPort { return { - record: deps.record, candidates: deps.candidates, async act(input) { const outcome = await deps.act(input); @@ -164,8 +158,19 @@ export function projectWorkHubCoordinationTurns( const stateByTurnId = new Map( deriveTurnRecords(messages).map((turn) => [turn.turnId, projectState(turn.status)]), ); + const factualTurnIds = new Set(messages.flatMap((message) => { + if (message.type !== 'workhub_coordination') return []; + if (message.kind === 'action_receipt' && + (message.receipt.result.disposition === 'clarify' || message.receipt.result.disposition === 'resume_work')) { + return [message.receipt.actionId, message.turnId]; + } + return message.kind === 'delegation_assigned' || message.kind === 'delegation_stop_requested' + ? [message.coordinationTurnId, message.actionId] + : []; + })); const turns: WorkHubCoordinationTurn[] = []; const latestUserIndexByTurnId = new Map(); + const receiptIndexByActionId = new Map(); const terminalLinkState = new Map(); const stopResolutionByDelegationId = new Map( messages.flatMap((message) => @@ -180,11 +185,32 @@ export function projectWorkHubCoordinationTurns( } for (const message of messages) { + if (message.type === 'workhub_coordination' && message.kind === 'action_receipt') { + const { receipt } = message; + if (receipt.result.disposition === 'clarify' || receipt.result.disposition === 'resume_work') { + const earlier = receiptIndexByActionId.get(receipt.actionId) ?? latestUserIndexByTurnId.get(message.turnId); + const row = { + messageId: message.id, + turnId: receipt.actionId, + text: boundedWorkHubTimelineText(receipt.userText), + ...(receipt.result.disposition === 'resume_work' ? { resume: receipt.result } : {}), + ...(receipt.clarification + ? { result: boundedWorkHubTimelineText(receipt.clarification) } + : {}), + state: stateByTurnId.get(message.turnId) ?? 'running', + updatedAt: message.ts, + }; + const index = earlier ?? turns.length; + turns[index] = row; + receiptIndexByActionId.set(receipt.actionId, index); + } + continue; + } if (message.type === 'workhub_coordination' && message.kind === 'delegation_stop_requested') { const resolution = stopResolutionByDelegationId.get(message.stopsDelegationId); turns.push({ messageId: message.id, - turnId: message.coordinationTurnId, + turnId: message.actionId, text: boundedWorkHubTimelineText(message.userText), state: resolution ? 'completed' : 'running', stop: { @@ -199,7 +225,7 @@ export function projectWorkHubCoordinationTurns( if (message.type === 'workhub_coordination' && message.kind === 'delegation_assigned') { turns.push({ messageId: message.id, - turnId: message.coordinationTurnId, + turnId: message.actionId, text: boundedWorkHubTimelineText(message.userText), ...(message.attachments ? { attachments: message.attachments } : {}), state: 'completed', @@ -219,6 +245,7 @@ export function projectWorkHubCoordinationTurns( continue; } if (message.type === 'user') { + if (factualTurnIds.has(message.coordinationActionId ?? message.turnId)) continue; const text = boundedWorkHubTimelineText(userFacingText(message)); if (!text) continue; turns.push({ @@ -226,6 +253,7 @@ export function projectWorkHubCoordinationTurns( turnId: message.turnId, text, ...(message.attachments ? { attachments: message.attachments } : {}), + ...(message.coordinationActionId ? { coordinationActionId: message.coordinationActionId } : {}), state: stateByTurnId.get(message.turnId) ?? 'running', updatedAt: message.ts, }); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index a215fab538..837418655c 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -113,7 +113,7 @@ export function workHubSurfaceFailure(error: unknown): WorkHubSurfaceFailure { } if (error instanceof WorkHubCoordinationFailure) { if (error.code === 'operation_conflict') return 'action_changed'; - if (error.code === 'not_found' || error.code === 'session_archived') { + if (error.code === 'candidate_set_stale' || error.code === 'not_found' || error.code === 'session_archived') { return 'candidates_changed'; } if (error.code === 'session_busy') return 'target_waiting'; @@ -173,21 +173,21 @@ export async function submitAndRecordWorkHubSurfaceInput(input: { // accepted and must not consume the immutable Coordination summary owned by // this action identity. A later same-identity retry may still be admitted. // Delegations project directly from the Host's atomic delegation_assigned - // record. Only local clarification still needs the generic summary path. + // record. Local clarification is submitted as its own admitted Host action. if ( result.kind === 'discussion' || result.kind === 'waiting' || result.kind === 'submitted' || - result.kind === 'stop' + result.kind === 'stop' || + result.kind === 'resume' ) { return result; } try { - await input.controller.recordConversationTurn({ + await input.controller.requestClarification({ turnId: input.request.requestId, userText: input.recordedUserText, assistantText: input.summary(result), - disposition: result.kind === 'clarification' ? 'clarify' : 'summary', }); } catch (error) { input.onSummaryError(); @@ -662,6 +662,18 @@ export function WorkHubCoordinationTurnView(props: { copy={copy} onOpenSession={props.onOpenSession} /> + ) : props.turn.resume ? ( + candidate.target.sessionId === props.turn.resume!.targetSessionId, + )} + targetSessionId={props.turn.resume.targetSessionId} + heading={copy.resumeOutcomes[props.turn.resume.outcome]} + state={copy.resumeRequested} + result={undefined} + copy={copy} + onOpenSession={props.onOpenSession} + /> ) : assignment ? ( {copy.answering}

) : (

- {copy.turnStates[props.turn.state]} + {props.turn.coordinationActionId + ? copy.actionConfirmationIncomplete : copy.turnStates[props.turn.state]}

)} diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index 6cc3c49786..ea1ae94874 100644 --- a/apps/desktop/src/shared/desktop-session-projection.ts +++ b/apps/desktop/src/shared/desktop-session-projection.ts @@ -132,6 +132,17 @@ export function projectDesktopStoredMessage( : message; case 'workhub_coordination': if (message.kind === 'delegation_superseded') return message; + if (message.kind === 'action_receipt') { + const result = message.receipt.result; + if (!('targetSessionId' in result)) return message; + return { + ...message, + receipt: { + ...message.receipt, + result: { ...result, targetSessionId: projectSessionId(host, result.targetSessionId) }, + }, + }; + } return { ...message, targetSessionId: projectSessionId(host, message.targetSessionId), diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index afc79d125a..040808a8a8 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -100,7 +100,7 @@ function controller(turns: readonly WorkHubCoordinationTurn[]): WorkHubControlle handler(turns); return { close: async () => {} }; }, - recordConversationTurn: async ({ turnId }) => ({ turnId }), + requestClarification: async ({ turnId }) => ({ turnId }), subscribe: () => () => {}, resetVisitContext: () => {}, }; @@ -466,3 +466,19 @@ export const ComposerRetainsFailedAttachment: Story = { canvasElement.dataset.workhubComposerVerified = 'true'; }, }; + +// Real path: a completed Coordination Run projects host action receipts through +// the shared transcript, with clarification text and a navigable resume target. +export const CoordinationActionReceipts: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('你是指支付回调幂等性任务吗?')).toBeVisible(); + await expect(await canvas.findByText('已让中断的工作继续:')).toBeVisible(); + }, +}; diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 8b9a848019..0e0db33acf 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -48,7 +48,7 @@ durable conversation and execution substrate. The role is provisioned lazily when WorkHub first needs it and resolves to the same Session after Runtime Host or application restarts. The Session role representation, lookup, recovery, and per-Host UI resolution enforce this lifecycle contract. The -coordination transcript and disposition semantics remain separate later work. +coordination transcript and typed dispositions use that same Session substrate. The per-Host boundary is intentional. A Coordination Session coordinates only the ordinary Sessions belonging to the same Runtime Host. Switching Runtime Hosts @@ -73,6 +73,68 @@ It never acquires authority over an ordinary Session's execution or lifecycle. ## Dispositions and action admission +An admitted Coordination request owns a real root Turn and Run in the reserved +WorkHub Session. `answer_here` executes the existing model answer path. Action +Turns execute the Host operation through the same Runtime admission, execution +ownership, terminal commit, and recovery machinery; admission does not require an +extra model call. Intent, Resolver, and clarification can later invoke models +inside this coordination execution without changing target Session authority. + +A successful Action Run writes a host-authored, model-hidden +`RuntimeEvent.actions.coordination` receipt. The transcript projects it as an +`action_receipt`, not an invented assistant response. Clarification carries its +prompt; resume carries the target reference and admission acknowledgement. +The synthetic `workhub.coordination.record` operation is removed. Released history +remains readable without inventing admissions for old summary rows. + +A receipt acknowledges what the operation accepted; it is not the target's current +execution state. Candidate-snapshot expiry is a distinct refusal. For a replacement +of an existing Session, the client may refresh its opaque candidate reference at +most twice while preserving the action, source delegation, and chosen target. +Routing is not rerun; a missing or renamed target, another refusal, or continued +snapshot churn stops the attempt. The Host still validates every refreshed proposal. + +Re-delivery of a completed request returns that receipt, including +after restart, without repeating the effect. Incoming execution content is validated +against the admitted descriptor even when another request wins admission concurrently; +legacy compatibility ignores only an absent action identity field, never the input digest. +Failed attempts remain terminal; +a same-action retry gets a subsequent admitted Turn. If the failed attempt already +committed a receipt, the new Turn reuses that result without repeating the effect. +When target resume admission committed before a missing receipt, retry first +consults the deterministic target Turn admission and acknowledges that original +Turn. It does not plan another continuation from the newer target lineage. + +The shared transcript reader derives receipts directly from RuntimeEvents and +retains legacy atomic linkage facts and released history. A rebuildable SQLite +index holds only `(source, sourceSequence)` references in stable page order; it +contains no message bodies, action results, or execution authority. Initial +backfill and incremental refresh commit at most 64 references per foreground +request. An unfinished catch-up returns `transcript_preparing`, including the +committed index position; it publishes no incomplete snapshot or empty-history +claim. Subscription clients yield between resumable requests and retain their +loading state within the open deadline. Later page and overlay-release requests +retain their independent per-request timeout, not the remaining preparation time. +Reader recreation resumes the committed +source positions. There is no detached maintenance worker or second task lifecycle. +Once caught up, normal pages +seek the index and project bounded source batches/Turns. Wall-clock regressions +and later appends cannot renumber existing pages. No receipt is written back into +the legacy message store. The WorkHub view groups receipt retries by action +identity rather than exposing each physical attempt as a new conversation card. +Persisted user inputs and Run terminal states always remain readable, including +a failed attempt whose receipt was never committed. The projection carries the +admitted action identity alongside the physical Turn identity. Only a visible +receipt or atomic link suppresses its input rows; a bounded page without that +replacement still shows the failed inputs. Missing acknowledgement is presented +as incomplete confirmation, without claiming the target effect failed. +Host-only Turns retain execution ownership without activating a model provider. +An interrupted Host action is +closed by Runtime recovery and never replayed as a model answer. Target-owned +claims, assignment atomicity, and resume source-boundary checks still decide +whether an unfinished effect can continue. Transactional delegation/Stop facts +remain authoritative for their existing ownership and linkage projections. + Every WorkHub input resolves to exactly one proposed **disposition**: - `answer_here`: answer in the Coordination Session. @@ -277,8 +339,8 @@ lets the stop reach a terminal resolution. replacement. Its target comes from the shared Session Resolver port, whose first implementation is a temporary exact-name baseline; replacing it changes recall only, because admission revalidates opaque identity and expected state - rather than any display name. Pause, resume, and pronoun-based stop controls - remain later work. + rather than any display name. Named resume uses ordinary Session continuation admission. Pause and + pronoun-based stop controls remain later work. Reevaluate the per-Host decision if supported workflows require one WorkHub conversation to coordinate ordinary Sessions on multiple Runtime Hosts, or if Host diff --git a/packages/core/package.json b/packages/core/package.json index df82465aa7..a5570ae15e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -158,7 +158,8 @@ "./unified-diff": "./dist/unified-diff.js", "./dev-single-instance": "./dist/dev-single-instance.js", "./maka-wordmark": "./dist/maka-wordmark.js", - "./test-only/async-primitives": "./dist/test-only/async-primitives.js" + "./test-only/async-primitives": "./dist/test-only/async-primitives.js", + "./workhub-action-result": "./dist/workhub-action-result.js" }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo src/model-metadata.generated.ts", diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 3bfb380177..a4e8e7ea25 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -893,3 +893,56 @@ describe('RuntimeEvent reference validation', () => { ); }); }); + +test('Coordination Runtime receipts survive decoding and reject unrecognized results', () => { + const coordination = { + actionId: 'action', + userText: 'Which task?', + clarification: 'Name a task.', + result: { disposition: 'clarify' as const, coordinationTurnId: 'turn-1' }, + }; + const event = baseEvent({ + role: 'system', + author: 'host', + modelVisibility: 'hidden', + actions: { coordination }, + }); + assert.deepEqual(decodeRuntimeEvent(event).actions?.coordination, coordination); + for (const result of [ + { disposition: 'clarify', coordinationTurnId: '../invalid' }, + { disposition: 'stop_work', outcome: 'stop_delivered', targetSessionId: 'target' }, + { + disposition: 'stop_work', + outcome: 'cancelled_pending', + targetSessionId: 'target', + targetTurnId: 'turn', + }, + { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'target' }, + { + disposition: 'resume_work', + outcome: 'already_running', + targetSessionId: 'target', + targetTurnId: 'turn', + }, + ]) { + assert.throws(() => + decodeRuntimeEvent({ + ...event, + actions: { coordination: { ...coordination, result } }, + }), + ); + } + + assert.throws(() => + decodeRuntimeEvent({ + ...event, + actions: { coordination: { ...coordination, result: { disposition: 'execute_anything' } } }, + }), + ); + assert.throws(() => + decodeRuntimeEvent({ + ...event, + actions: { coordination: { ...coordination, executionStatus: 'completed' } }, + }), + ); +}); diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 6c7d37d226..28d89273bd 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -33,6 +33,7 @@ * projection, or ledger logic lives here. Those arrive in later nodes. */ +import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js'; import { isModelRetryDecision, type ModelRetryDecision } from './model-failure.js'; import { @@ -536,6 +537,8 @@ export interface RuntimeEventPermissionClosureAccepted { * event without `actions.endInvocation` MUST assert a terminal `status`. */ export interface RuntimeEventActions { + /** Host coordination receipt linked to this admitted Run. */ + coordination?: WorkHubActionReceipt; /** Durable physical pause; does not complete or cancel the owning logical Turn. */ handoffPause?: RuntimeHandoffPause; /** Patch applied to invocation-scoped runtime state. */ @@ -838,6 +841,7 @@ const RUNTIME_ACTIONS_SHAPE = defineObjectShape()( [], [ 'handoffPause', + 'coordination', 'stateDelta', 'artifactDelta', 'permissionRequest', @@ -1272,6 +1276,7 @@ function isRuntimeEventActions(value: unknown): value is RuntimeEventActions { } return ( (value.handoffPause === undefined || isRuntimeHandoffPause(value.handoffPause)) && + (value.coordination === undefined || isWorkHubActionReceipt(value.coordination)) && (value.stateDelta === undefined || isRecord(value.stateDelta)) && (value.artifactDelta === undefined || (isRecord(value.artifactDelta) && diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index 9108319565..d6c635c7d2 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -241,6 +241,9 @@ export type RootExecutionDescriptor = | { /** Tool-free conversational execution admitted only by WorkHub authority. */ kind: 'workhub_coordination'; + operation?: 'action'; + /** Stable request identity shared by physical action retries. */ + actionId?: string; inputDigest: `sha256:${string}`; } | { kind: 'regenerate'; sourceTurnId: string } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e06eddfe00..5e3276cafa 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -17,6 +17,8 @@ * under the License. */ +import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js'; + import { MODEL_FAILURE_MESSAGE_MAX_BYTES, isModelRetryDecision, @@ -763,6 +765,8 @@ export type StoredMessage = | SystemNoteMessage; export interface UserMessage extends MessageContent { + /** Derived from the admitted WorkHub action; does not change physical Turn identity. */ + coordinationActionId?: string; type: 'user'; id: string; turnId: string; @@ -1118,7 +1122,18 @@ export interface WorkHubActionClaim { export type WorkHubActionClaimOutcome = 'claimed' | 'same_claim' | 'conflict'; +export interface WorkHubCoordinationActionMessage { + type: 'workhub_coordination'; + kind: 'action_receipt'; + schemaVersion: 1; + id: string; + turnId: string; + ts: number; + receipt: WorkHubActionReceipt; +} + export type WorkHubCoordinationMessage = + | WorkHubCoordinationActionMessage | WorkHubDelegationAssignedMessage | WorkHubDelegationReplacementRequestedMessage | WorkHubDelegationReplacementAbortedMessage @@ -1224,6 +1239,7 @@ const USER_MESSAGE_SHAPE = defineObjectShape()( 'quotes', 'inlineReferences', 'steeringEventId', + 'coordinationActionId', 'origin', ], ); @@ -1474,7 +1490,10 @@ function decodeMessage( if ( hasExactShape(message, USER_MESSAGE_SHAPE) && hasMessageEnvelope(message, true) && - (message.origin === undefined || decodeTurnOrigin(message.origin) !== undefined) + (message.origin === undefined || decodeTurnOrigin(message.origin) !== undefined) && + (message.coordinationActionId === undefined || + (typeof message.coordinationActionId === 'string' && + message.coordinationActionId.length > 0)) ) { const { displayText, @@ -1608,6 +1627,16 @@ function decodeMessage( } function isWorkHubCoordinationMessage(message: Record): boolean { + if (message.kind === 'action_receipt') + return ( + hasMessageEnvelope(message, true) && + message.schemaVersion === 1 && + Object.keys(message).every((k) => + ['type', 'kind', 'schemaVersion', 'id', 'turnId', 'ts', 'receipt'].includes(k), + ) && + isWorkHubActionReceipt(message.receipt) + ); + if (message.kind === 'delegation_stop_requested') { return ( hasMessageEnvelope(message, true) && diff --git a/packages/core/src/workhub-action-result.ts b/packages/core/src/workhub-action-result.ts new file mode 100644 index 0000000000..b993249dec --- /dev/null +++ b/packages/core/src/workhub-action-result.ts @@ -0,0 +1,124 @@ +/* + * 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 { isRecord } from './record-schema.js'; + +export type WorkHubActionResult = + | { readonly disposition: 'answer_here'; readonly coordinationTurnId: string } + | { readonly disposition: 'clarify'; readonly coordinationTurnId: string } + | { + readonly disposition: 'delegate_existing'; + readonly targetSessionId: string; + readonly targetTurnId: string; + readonly steered?: true; + } + | { + readonly disposition: 'create_new'; + readonly targetSessionId: string; + readonly targetTurnId: string; + readonly steered?: true; + } + | { + readonly disposition: 'replace'; + readonly replacementDisposition: 'delegate_existing' | 'create_new'; + readonly targetSessionId: string; + readonly targetTurnId: string; + readonly steered?: true; + } + | { + readonly disposition: 'stop_work'; + readonly outcome: 'cancelled_pending' | 'stop_delivered' | 'already_terminal' | 'not_owned'; + readonly targetSessionId: string; + readonly targetTurnId?: string; + } + | { + readonly disposition: 'resume_work'; + readonly outcome: 'resume_started' | 'already_running'; + readonly targetSessionId: string; + readonly targetTurnId?: string; + }; + +/** Coordination receipt, not a copy of target execution state. */ +export interface WorkHubActionReceipt { + actionId: string; + userText: string; + result: WorkHubActionResult; + clarification?: string; +} + +export function isWorkHubActionReceipt(value: unknown): value is WorkHubActionReceipt { + if ( + !isRecord(value) || + Object.keys(value).some( + (k) => !['actionId', 'userText', 'result', 'clarification'].includes(k), + ) || + typeof value.actionId !== 'string' || + !value.actionId || + typeof value.userText !== 'string' || + !value.userText.trim() || + (value.clarification !== undefined && typeof value.clarification !== 'string') + ) + return false; + return isWorkHubActionResult(value.result); +} + +/** Shared closed result contract for Runtime receipts and Host protocol replies. */ +export function isWorkHubActionResult(r: unknown): r is WorkHubActionResult { + if (!isRecord(r)) return false; + const text = (k: string) => typeof r[k] === 'string' && /^[A-Za-z0-9_-]{1,128}$/u.test(r[k]); + const keys = (allowed: string[]) => Object.keys(r).every((k) => allowed.includes(k)); + if (r.disposition === 'answer_here' || r.disposition === 'clarify') + return keys(['disposition', 'coordinationTurnId']) && text('coordinationTurnId'); + if ( + r.disposition === 'delegate_existing' || + r.disposition === 'create_new' || + r.disposition === 'replace' + ) + return ( + keys([ + 'disposition', + 'targetSessionId', + 'targetTurnId', + 'steered', + ...(r.disposition === 'replace' ? ['replacementDisposition'] : []), + ]) && + text('targetSessionId') && + text('targetTurnId') && + (r.steered === undefined || r.steered === true) && + (r.disposition !== 'replace' || + r.replacementDisposition === 'delegate_existing' || + r.replacementDisposition === 'create_new') + ); + if (r.disposition === 'stop_work' || r.disposition === 'resume_work') + return ( + (r.disposition === 'stop_work' + ? ((r.outcome !== 'stop_delivered' && r.outcome !== 'not_owned') || + r.targetTurnId !== undefined) && + (r.outcome !== 'cancelled_pending' || r.targetTurnId === undefined) + : (r.outcome === 'resume_started') === (r.targetTurnId !== undefined)) && + keys(['disposition', 'outcome', 'targetSessionId', 'targetTurnId']) && + text('targetSessionId') && + (r.targetTurnId === undefined || text('targetTurnId')) && + (r.disposition === 'stop_work' + ? ['cancelled_pending', 'stop_delivered', 'already_terminal', 'not_owned'] + : ['resume_started', 'already_running'] + ).includes(String(r.outcome)) + ); + return false; +} diff --git a/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts b/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts index aac8e62468..d36a3124e0 100644 --- a/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts +++ b/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts @@ -135,6 +135,22 @@ test('a released operation is dropped from the record and reported as accounted assert.deepEqual(unresolvedPersistedGrants(file), []); }); +test('retired Coordination record grant is released through read and rewrite', async () => { + const path = await writeAccessFile({ + schemaVersion: 3, + credentials: [storedCredential(['host.status', 'workhub.coordination.record'])], + sessionGrants: [], + turnAccessRequests: [], + }); + const file = await readAccessCredentialFile(path); + assert.deepEqual(file.credentials[0]?.grants, ['host.status']); + assert.deepEqual(unresolvedPersistedGrants(file), []); + await writeAccessCredentialFile(path, file); + assert.deepEqual(JSON.parse(await readFile(path, 'utf8')).credentials[0].operationGrants, [ + 'host.status', + ]); +}); + test('a Session Guest holds the current guest policy, not what its record says', async () => { const path = await writeAccessFile({ schemaVersion: 3, diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index fef1a5020a..650170a4ed 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -17,11 +17,20 @@ * under the License. */ +import { RootTurnCoordinator } from '../server/root-turn-coordinator.js'; +import { + WorkHubCoordinationActionGate, + workHubCoordinationTurnId, + workHubResumedTurnId, +} from '../server/workhub-coordination-action-gate.js'; import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; import { createRunCompositionSnapshot } from '@maka/core/run-composition'; import type { BackendSendInput } from '@maka/core/backend-types'; import type { SessionEvent } from '@maka/core/events'; +import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; +import type { RuntimeKernelLike } from '@maka/runtime/runtime-kernel'; import { runtimeHandoffPause } from '@maka/core/runtime-handoff'; import { deferred } from '@maka/core/test-only/async-primitives'; import { runtimeInvocationFailureClass } from '@maka/runtime/runtime-event-read-model'; @@ -44,6 +53,10 @@ import { } from '@maka/runtime/test-only/fake-backend'; import { LOCAL_READ_AGENT_DEFINITION } from '@maka/runtime/agent-catalog'; import { SessionManager, type BackendFactory } from '@maka/runtime/session-manager'; +import { createSessionTranscriptReader } from '../server/session-transcript-reader.js'; +import { ClientSessionSubscription } from '../client/session-subscription.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; +import type { StoredMessage } from '@maka/core/session'; import { workHubDirectStopAbortSource } from '@maka/runtime/session-manager'; import { fingerprintAgentGraphRunnableIntent } from '@maka/runtime/stream-graph-admission'; import type { AgentGraphRunnableIntent } from '@maka/runtime/stream-graph-readiness'; @@ -832,7 +845,16 @@ test('production composition commits automatic titles through Host-owned Session test('WorkHub creates new work through the production assignment composition', async () => { await withCompositionRoot(async ({ root, owner }) => { const connectionId = await configureFakeDefaultTarget(owner); - const { composition, manager } = await createCapturedExecutionComposition(owner); + let coordinationModelCalls = 0; + const { composition, manager } = await createCapturedExecutionComposition(owner, { + primaryBackendFactory: (backendContext) => { + if (backendContext.header.id === 'maka_workhub_coordination') { + coordinationModelCalls += 1; + throw new Error('Coordination provider is unavailable'); + } + return new FakeBackend(backendContext); + }, + }); const context = { hostEpoch: 'execution-composition-test', connectionId: 'workhub-create-client', @@ -854,6 +876,211 @@ test('WorkHub creates new work through the production assignment composition', a assert.equal(created.ok, true, JSON.stringify(created)); if (!created.ok || created.result.disposition !== 'create_new') return; + const coordinationStores = await openInteractiveExecutionStoresForWrite(owner.lease); + const admission = await coordinationStores.agentRunStore.readRootTurnAdmission( + 'maka_workhub_coordination', + 'workhub-create-action', + ); + assert.ok(admission, 'Delegation must belong to an admitted Coordination Turn'); + assert.equal(admission.execution.kind, 'workhub_coordination'); + const events = await coordinationStores.runtimeEventStore.readImmutableRuntimeEvents( + admission.sessionId, + admission.runId, + ); + assert.equal( + events.find((event) => event.actions?.coordination)?.actions?.coordination?.result + .disposition, + 'create_new', + ); + assert.ok(events.some((event) => event.status === 'completed')); + assert.equal( + events.some((event) => event.role === 'model'), + false, + ); + const transcript = await readLedgerMessages( + coordinationStores.runtimeEventStore, + admission.sessionId, + ); + assert.ok( + transcript.some( + (message) => message.type === 'workhub_coordination' && message.kind === 'action_receipt', + ), + 'The shared transcript must expose the Runtime receipt to Desktop', + ); + const replayed = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-create-action', + userText: 'Fix login stability', + proposal: { disposition: 'create_new', title: 'Login stability' }, + create: { workspace: { kind: 'host_path', path: root } }, + }, + context, + ); + assert.deepEqual(replayed, created); + const clarifyInput = { + actionId: 'workhub-clarify', + userText: 'Which task?', + proposal: { disposition: 'clarify' as const, assistantText: 'Please name the task.' }, + }; + const clarified = await composition.handlers['workhub.coordination.act']( + clarifyInput, + context, + ); + const durable = await readProductionTranscript(composition, context); + assert.ok( + durable.some( + (message) => + message.type === 'workhub_coordination' && + message.kind === 'action_receipt' && + message.receipt.actionId === clarifyInput.actionId, + ), + 'Production reader lost clarification receipt', + ); + assert.ok( + durable.some( + (message) => + message.type === 'workhub_coordination' && message.kind === 'delegation_assigned', + ), + 'Production reader lost atomic assignment', + ); + const reader = createSessionTranscriptReader({ + stores: coordinationStores, + canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, + }); + const all = await reader.readDurableRecords(admission.sessionId, { + direction: 'newer', + maxMessages: 100, + maxStoredBytes: 1024 * 1024, + }); + const landmarks = await reader.readDurableTurnLandmarks(admission.sessionId, 100); + for (const landmark of landmarks.landmarks) { + assert.ok( + all.records.some(({ message }) => message.turnId === landmark.turnId), + 'Landmarks must refer to physical transcript Turns, not logical action IDs', + ); + } + for (const direction of ['newer', 'older'] as const) { + const records = []; + let position: number | undefined; + do { + const page = await reader.readDurableRecords(admission.sessionId, { + direction, + throughSequence: all.throughSequence, + position, + maxMessages: 1, + maxStoredBytes: 1024 * 1024, + }); + records.push(...page.records); + position = page.nextPosition ?? undefined; + assert.ok(records.length <= all.records.length, 'Pagination must advance'); + } while (position !== undefined); + assert.deepEqual(direction === 'older' ? records.reverse() : records, all.records); + } + assert.ok(clarified.ok, JSON.stringify(clarified)); + assert.equal(clarified.result.disposition, 'clarify'); + if (clarified.result.disposition === 'clarify') { + assert.ok( + await coordinationStores.agentRunStore.readRootTurnAdmission( + admission.sessionId, + clarified.result.coordinationTurnId, + ), + ); + } + assert.deepEqual( + await composition.handlers['workhub.coordination.act'](clarifyInput, context), + clarified, + ); + const changed = await composition.handlers['workhub.coordination.act']( + { + ...clarifyInput, + proposal: { disposition: 'clarify', assistantText: 'Different content' }, + }, + context, + ); + assert.equal(changed.ok, false); + for (const legacy of [false, true]) + for (const changePayload of [false, true]) { + const missingRead = deferred(); + const releaseRead = deferred(); + const originalOperation = RootTurnCoordinator.prototype.runWorkHubCoordinationOperation; + let restoreRead: (() => void) | undefined; + let intercept = true; + RootTurnCoordinator.prototype.runWorkHubCoordinationOperation = function (request, ctx) { + const isRacingRequest = intercept; + if (intercept) { + intercept = false; + const coordinator = this as unknown as { stores: typeof coordinationStores }; + const originalStores = coordinator.stores; + let paused = false; + coordinator.stores = { + ...originalStores, + agentRunStore: { + ...originalStores.agentRunStore, + readRootTurnAdmission: async (...args) => { + const admission = await originalStores.agentRunStore.readRootTurnAdmission( + ...args, + ); + if (!paused && args[1] === request.turnId && !admission) { + paused = true; + missingRead.resolve(); + await releaseRead.promise; + } + return admission; + }, + }, + }; + restoreRead = () => { + coordinator.stores = originalStores; + }; + } + if (legacy && !isRacingRequest) { + const { actionId: _actionId, ...execution } = request.execution; + return originalOperation.call(this, { ...request, execution }, ctx); + } + return originalOperation.call(this, request, ctx); + }; + const concurrentInput = { + ...clarifyInput, + actionId: `concurrent-clarification-${legacy}-${changePayload}`, + }; + const pendingChanged = composition.handlers['workhub.coordination.act']( + { + ...concurrentInput, + proposal: changePayload + ? { disposition: 'clarify', assistantText: 'Changed concurrent content' } + : concurrentInput.proposal, + }, + context, + ); + try { + await Promise.race([ + missingRead.promise, + pendingChanged.then((value) => { + throw new Error(`Concurrent probe did not pause: ${JSON.stringify(value)}`); + }), + ]); + const accepted = await composition.handlers['workhub.coordination.act']( + concurrentInput, + context, + ); + assert.ok(accepted.ok, JSON.stringify(accepted)); + releaseRead.resolve(); + const rejected = await pendingChanged; + assert.equal( + rejected.ok, + !changePayload, + 'concurrent replay must validate incoming content', + ); + if (!changePayload) assert.deepEqual(rejected, accepted); + if (!rejected.ok) assert.equal(rejected.error.code, 'operation_conflict'); + } finally { + releaseRead.resolve(); + await pendingChanged; + restoreRead?.(); + RootTurnCoordinator.prototype.runWorkHubCoordinationOperation = originalOperation; + } + } + assert.equal(coordinationModelCalls, 0, 'Actions must not start a model answer in WorkHub'); const targetSessionId = created.result.targetSessionId; const session = (await manager.listSessions()).find(({ id }) => id === targetSessionId); assert.equal(session?.name, 'Login stability'); @@ -887,263 +1114,679 @@ test('WorkHub creates new work through the production assignment composition', a }); }); -test('WorkHub Resume and Stop follow logical lineage across repeated physical handoffs', async () => { +test('Coordination clarification survives a production transcript reopen', async () => { await withCompositionRoot(async ({ root, owner }) => { - const connectionId = await configureFakeDefaultTarget(owner); - let pauseNext = true; - let boundary = deferred(); - const primaryBackendFactory: BackendFactory = (backendContext) => - new (class extends FakeBackend { - async prepareRunComposition(input: { runId: string; turnId: string }): Promise { - await backendContext.recordRunComposition!(input.runId, HANDOFF_TEST_COMPOSITION); - } + await configureFakeDefaultTarget(owner); + let { composition } = await createCapturedExecutionComposition(owner); + const context = { + hostEpoch: 'clarify-reopen', + connectionId: 'client', + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), + }; + await composition.handlers['workhub.coordination.resolve']({}, context); + const request = { + actionId: 'clarify-reopen', + userText: 'Which task?', + proposal: { disposition: 'clarify' as const, assistantText: 'Please choose a task.' }, + }; + assert.ok((await composition.handlers['workhub.coordination.act'](request, context)).ok); + const before = await readProductionTranscript(composition, context); + await composition.close(); + await owner.close(); + const reopened = await tryAcquireInteractiveRootOwner( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + assert.ok(reopened); + try { + ({ composition } = await createCapturedExecutionComposition(reopened)); + const after = await readProductionTranscript(composition, context); + assert.deepEqual(after, before); + assert.equal( + after.filter( + (message) => message.type === 'workhub_coordination' && message.kind === 'action_receipt', + ).length, + 1, + ); + assert.ok((await composition.handlers['workhub.coordination.act'](request, context)).ok); + assert.deepEqual(await readProductionTranscript(composition, context), before); + } finally { + await composition.close(); + await reopened.close(); + } + }); +}); - override async *send(input: BackendSendInput): AsyncIterable { - assert.ok(input.runId); - await this.prepareRunComposition({ runId: input.runId, turnId: input.turnId }); - if (backendContext.header.name === 'Payments' && pauseNext) { - pauseNext = false; - await boundary.promise; - assert.equal(await input.handoffBoundary!(new AbortController().signal, null), 'pause'); - return; +test('Coordination Host ownership survives Stop and deferred backend invalidation', { + timeout: 10_000, +}, async () => { + await withCompositionRoot(async ({ owner }) => { + await configureFakeDefaultTarget(owner); + let disposed = 0; + const { composition, manager } = await createCapturedExecutionComposition(owner, { + primaryBackendFactory: (backendContext) => + new (class extends FakeBackend { + override async dispose(): Promise { + if (backendContext.sessionId === 'maka_workhub_coordination') disposed += 1; + await super.dispose(); } - yield* super.send(input); - } - })(backendContext); - let { composition, manager } = await createCapturedExecutionComposition(owner, { - safeBoundaryResume: true, - primaryBackendFactory, + })(backendContext), }); const context = { - hostEpoch: 'execution-composition-test', - connectionId: 'workhub-resume-stop-client', + hostEpoch: 'coordination-lifecycle', + connectionId: 'client', principal: 'local_os_user' as const, acquireResidency: () => ({ release() {} }), }; - let closed = false; - let restartedOwner: InteractiveRootOwner | undefined; - let continuation: { turnId: string; runId: string } | undefined; - let targetSessionId: string | undefined; - const handoffAndReopen = async () => { - const requested = deferred(); - const request = manager.requestRunHandoff.bind(manager); - manager.requestRunHandoff = (...args) => { - const result = request(...args); - requested.resolve(); - return result; + const sessionId = 'maka_workhub_coordination'; + const kernel = (manager as unknown as { runtimeKernel: RuntimeKernelLike }).runtimeKernel; + const entered = deferred(); + const release = deferred(); + const originalRunner = manager.runCoordinationOperation; + let stopped = false; + let invalidated = false; + try { + assert.ok((await composition.handlers['workhub.coordination.resolve']({}, context)).ok); + assert.ok( + ( + await composition.handlers['workhub.coordination.answer']( + { + turnId: 'cached-model-answer', + text: 'Say hello', + }, + context, + ) + ).ok, + ); + await waitFor(async () => { + const state = await composition.handlers['turn.query']( + { sessionId, turnId: 'cached-model-answer' }, + context, + ); + return state.ok && state.result.status === 'completed'; + }); + manager.runCoordinationOperation = function (id, input, options, execute) { + return originalRunner.call(this, id, input, options, async () => { + entered.resolve(); + await release.promise; + return execute(); + }); }; - const preparing = composition.prepareHandoff!( - context.hostEpoch, - new AbortController().signal, + const request = { + actionId: 'lifecycle-action', + userText: 'Which task?', + proposal: { disposition: 'clarify' as const, assistantText: 'Please name a task.' }, + }; + const action = composition.handlers['workhub.coordination.act'](request, context); + await entered.promise; + const turnId = workHubCoordinationTurnId(request.actionId, 'clarify'); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const admission = await stores.agentRunStore.readRootTurnAdmission(sessionId, turnId); + assert.ok(admission); + assert.equal(kernel.hasActiveRun?.(sessionId, admission.runId, turnId), true); + assert.deepEqual(manager.runningTurnIds(sessionId), [turnId]); + const invalidation = kernel.invalidateCachedBackends().then(() => { + invalidated = true; + }); + const stop = kernel.stopSession(sessionId).then(() => { + stopped = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(stopped, false, 'Stop must await the admitted Host callback'); + assert.equal(invalidated, false, 'Cached backend invalidation must wait for Host ownership'); + assert.equal(disposed, 0); + release.resolve(); + const [result] = await Promise.all([action, stop, invalidation]); + assert.equal(result.ok, false, 'A stopped Coordination Run must not report completed'); + assert.equal(kernel.hasActiveRun?.(sessionId, admission.runId, turnId), false); + assert.deepEqual(manager.runningTurnIds(sessionId), []); + assert.equal(disposed, 1); + const runs = await stores.runtimeEventStore.listSessionInvocations(sessionId); + assert.equal( + runtimeInvocationOutcome(runs.find((run) => run.runId === admission.runId)!), + 'cancelled', ); - await requested.promise; - boundary.resolve(); - const preparation = await preparing; - assert.ok(preparation); - assert.equal(await preparation.seal(), true); - assert.ok(await preparation.residencies()); - await preparation.detach(); - composition.beginDrain(); + } finally { + release.resolve(); + manager.runCoordinationOperation = originalRunner; await composition.close(); - closed = true; - await owner.close(); - restartedOwner = await tryAcquireInteractiveRootOwner( - await resolveStorageRoot({ path: root, kind: 'interactive' }), - ); - assert.ok(restartedOwner); - owner = restartedOwner; - ({ composition, manager } = await createCapturedExecutionComposition(owner, { - safeBoundaryResume: true, - primaryBackendFactory, - })); - closed = false; + } + }); +}); + +test('Coordination retries a durable receipt without repeating its Host action', async () => { + await withCompositionRoot(async ({ owner }) => { + await configureFakeDefaultTarget(owner); + const { composition, manager } = await createCapturedExecutionComposition(owner); + const context = { + hostEpoch: 'coordination-terminal-cut', + connectionId: 'client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + const kernel = ( + manager as unknown as { + runtimeKernel: { deps: { runtimeEventStore: RuntimeEventStore; newId: () => string } }; + } + ).runtimeKernel; + const eventStore = kernel.deps.runtimeEventStore; + const originalAppend = eventStore.appendRuntimeEvent; + const originalNewId = kernel.deps.newId; + const originalAct = WorkHubCoordinationActionGate.prototype.act; + let actions = 0; + let cutNextId = false; + let cuts = 0; + WorkHubCoordinationActionGate.prototype.act = function (...args) { + actions += 1; + return originalAct.apply(this, args); + }; + kernel.deps.runtimeEventStore = { + ...eventStore, + async appendRuntimeEvent(sessionId, runId, event, options) { + await originalAppend.call(eventStore, sessionId, runId, event, options); + if (event.actions?.coordination && cuts === 0) cutNextId = true; + }, + }; + kernel.deps.newId = () => { + if (cutNextId) { + cutNextId = false; + cuts += 1; + throw new Error('Injected crash cut after receipt and before terminal'); + } + return originalNewId(); }; 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((await composition.handlers['workhub.coordination.resolve']({}, context)).ok); + const request = { + actionId: 'terminal-cut-action', + userText: 'Which task?', + proposal: { disposition: 'clarify' as const, assistantText: 'Please name a task.' }, + }; + const first = await composition.handlers['workhub.coordination.act'](request, context); + assert.equal(first.ok, false); + assert.equal(cuts, 1); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const oldTurnId = workHubCoordinationTurnId(request.actionId, 'clarify'); + const oldAdmission = await stores.agentRunStore.readRootTurnAdmission( + 'maka_workhub_coordination', + oldTurnId, + ); + assert.ok(oldAdmission); + const before = await stores.runtimeEventStore.readImmutableRuntimeEvents( + oldAdmission.sessionId, + oldAdmission.runId, + ); + assert.ok(before.some((event) => event.actions?.coordination)); + assert.ok(before.some((event) => event.status === 'failed')); + const replay = await composition.handlers['workhub.coordination.act'](request, context); + assert.ok(replay.ok, JSON.stringify(replay)); + assert.equal(replay.result.disposition, 'clarify'); + if (replay.result.disposition !== 'clarify') return; + assert.notEqual(replay.result.coordinationTurnId, oldTurnId); + const retryAdmission = await stores.agentRunStore.readRootTurnAdmission( + oldAdmission.sessionId, + replay.result.coordinationTurnId, + ); + assert.ok(retryAdmission); + assert.notEqual(retryAdmission.runId, oldAdmission.runId); + assert.equal(actions, 1, 'A durable receipt must bypass the Host action on retry'); + assert.deepEqual( + await stores.runtimeEventStore.readImmutableRuntimeEvents( + oldAdmission.sessionId, + oldAdmission.runId, + ), + before, ); - 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, + const events = await stores.runtimeEventStore.readImmutableRuntimeEvents( + retryAdmission.sessionId, + retryAdmission.runId, ); - 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.ok(events.some((event) => event.status === 'completed')); + assert.deepEqual( + events.find((event) => event.actions?.coordination)?.actions?.coordination?.result, + replay.result, ); - assert.equal(original.ok, true); - if (!original.ok) return; - await handoffAndReopen(); - await composition.handlers['turn.stop']( - { sessionId: target.id, turnId: original.result.turnId, runId: original.result.runId }, - context, + assert.deepEqual( + await composition.handlers['workhub.coordination.act'](request, context), + replay, ); + assert.equal(actions, 1); + } finally { + kernel.deps.runtimeEventStore = eventStore; + kernel.deps.newId = originalNewId; + WorkHubCoordinationActionGate.prototype.act = originalAct; + await composition.close(); + } + }); +}); - pauseNext = true; - boundary = deferred(); - const resumed = await composition.handlers['workhub.coordination.act']( - { - actionId: 'workhub-resume-stop-resume', - userText: 'Resume Payments', - proposal: { - disposition: 'resume_work', - resumesActionId: 'workhub-resume-stop-delegation', - 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'); - await handoffAndReopen(); +test('interrupted Coordination admission recovers without a model and retries in a new Turn', async () => { + await withCompositionRoot(async ({ root, owner }) => { + await configureFakeDefaultTarget(owner); + const context = { + hostEpoch: 'coordination-recovery', + connectionId: 'client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + const first = await createCapturedExecutionComposition(owner); + await first.composition.handlers['workhub.coordination.resolve']({}, context); + await first.composition.close(); + const request = { + actionId: 'orphaned-action', + userText: 'Which task?', + proposal: { disposition: 'clarify' as const, assistantText: 'Please name a task.' }, + }; + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + await stores.agentRunStore.admitRootTurn({ + sessionId: 'maka_workhub_coordination', + turnId: workHubCoordinationTurnId(request.actionId, 'clarify'), + proposedRunId: 'orphaned-run', + proposedUserMessageId: 'orphaned-user', + execution: { + kind: 'workhub_coordination', + operation: 'action', + inputDigest: `sha256:${createHash('sha256').update(JSON.stringify(request)).digest('hex')}`, + }, + previousRootTurnId: null, + normalizedInput: { text: request.userText }, + sourceMessages: [], + admittedAt: 1, + }); + await owner.close(); + const reopened = await tryAcquireInteractiveRootOwner( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + assert.ok(reopened); + let modelCalls = 0; + try { + const recovered = await createCapturedExecutionComposition(reopened, { + primaryBackendFactory: (backendContext) => + new (class extends FakeBackend { + override async *send(input: BackendSendInput): AsyncIterable { + modelCalls += 1; + yield* super.send(input); + } + })(backendContext), + }); + try { + const reopenedStores = await openInteractiveExecutionStoresForWrite(reopened.lease); + const oldRun = ( + await reopenedStores.runtimeEventStore.listSessionInvocations('maka_workhub_coordination') + ).find((run) => run.runId === 'orphaned-run'); + assert.ok(oldRun); + assert.equal(runtimeInvocationOutcome(oldRun), 'failed'); + const retried = await recovered.composition.handlers['workhub.coordination.act']( + request, + context, + ); + assert.ok(retried.ok, JSON.stringify(retried)); + assert.equal(retried.result.disposition, 'clarify'); + if (retried.result.disposition !== 'clarify') return; + assert.notEqual( + retried.result.coordinationTurnId, + workHubCoordinationTurnId(request.actionId, 'clarify'), + ); + const retryAdmission = await reopenedStores.agentRunStore.readRootTurnAdmission( + 'maka_workhub_coordination', + retried.result.coordinationTurnId, + ); + assert.ok(retryAdmission); + assert.notEqual(retryAdmission.runId, 'orphaned-run'); + assert.deepEqual( + await recovered.composition.handlers['workhub.coordination.act'](request, context), + retried, + ); + assert.equal(modelCalls, 0); + } finally { + await recovered.composition.close(); + } + } finally { + await reopened.close(); + } + }); +}); - // Lose the response, interrupt the continuation, then discard all - // in-memory Gate replay state by reopening the production composition. - await composition.handlers['turn.stop']({ sessionId: target.id, ...continuation }, context); - await composition.close(); - closed = true; - await owner.close(); - restartedOwner = await tryAcquireInteractiveRootOwner( - await resolveStorageRoot({ path: root, kind: 'interactive' }), - ); - assert.ok(restartedOwner); - owner = restartedOwner; - ({ composition } = await createCapturedExecutionComposition(owner, { +for (const missingReceipt of [false, true]) + test(`WorkHub Resume and Stop across restart (missing receipt: ${missingReceipt})`, async () => { + await withCompositionRoot(async ({ root, owner }) => { + const connectionId = await configureFakeDefaultTarget(owner); + let pauseNext = true; + let boundary = deferred(); + const primaryBackendFactory: BackendFactory = (backendContext) => + new (class extends FakeBackend { + async prepareRunComposition(input: { runId: string; turnId: string }): Promise { + await backendContext.recordRunComposition!(input.runId, HANDOFF_TEST_COMPOSITION); + } + + override async *send(input: BackendSendInput): AsyncIterable { + assert.ok(input.runId); + await this.prepareRunComposition({ runId: input.runId, turnId: input.turnId }); + if (backendContext.header.name === 'Payments' && pauseNext) { + pauseNext = false; + await boundary.promise; + assert.equal( + await input.handoffBoundary!(new AbortController().signal, null), + 'pause', + ); + return; + } + yield* super.send(input); + } + })(backendContext); + let { composition, manager } = await createCapturedExecutionComposition(owner, { safeBoundaryResume: true, - })); - const retry = { - actionId: 'workhub-resume-stop-resume', - userText: 'Resume Payments', - proposal: { - disposition: 'resume_work' as const, - resumesActionId: 'workhub-resume-stop-delegation', - expects: { targetSessionId: target.id }, - }, + primaryBackendFactory, + }); + const context = { + hostEpoch: 'execution-composition-test', + connectionId: 'workhub-resume-stop-client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), }; - const replayed = await composition.handlers['workhub.coordination.act'](retry, context); - assert.equal(replayed.ok, false, JSON.stringify(replayed)); - if (!replayed.ok) assert.equal(replayed.error.code, 'operation_conflict'); - const fresh = await composition.handlers['workhub.coordination.act']( - { ...retry, actionId: 'workhub-resume-again' }, - context, - ); - assert.equal(fresh.ok, true, JSON.stringify(fresh)); - if (!fresh.ok || fresh.result.disposition !== 'resume_work' || !fresh.result.targetTurnId) - return; - const freshTurn = await composition.handlers['turn.query']( - { sessionId: target.id, turnId: fresh.result.targetTurnId }, - context, - ); - assert.equal(freshTurn.ok, true); - if (!freshTurn.ok) return; - assert.equal(freshTurn.result.status, 'running'); - assert.notEqual(freshTurn.result.turnId, continuation.turnId); - continuation = { turnId: freshTurn.result.turnId, runId: freshTurn.result.runId }; + let closed = false; + let restartedOwner: InteractiveRootOwner | undefined; + let continuation: { turnId: string; runId: string } | undefined; + let targetSessionId: string | undefined; + const handoffAndReopen = async () => { + await waitFor(async () => !pauseNext); + const requested = deferred(); + const request = manager.requestRunHandoff.bind(manager); + manager.requestRunHandoff = (...args) => { + const result = request(...args); + requested.resolve(); + return result; + }; + const preparing = composition.prepareHandoff!( + context.hostEpoch, + new AbortController().signal, + ); + await requested.promise; + boundary.resolve(); + const preparation = await preparing; + assert.ok(preparation); + assert.equal(await preparation.seal(), true); + assert.ok(await preparation.residencies()); + await preparation.detach(); + composition.beginDrain(); + await composition.close(); + closed = true; + await owner.close(); + restartedOwner = await tryAcquireInteractiveRootOwner( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + assert.ok(restartedOwner); + owner = restartedOwner; + ({ composition, manager } = await createCapturedExecutionComposition(owner, { + safeBoundaryResume: true, + primaryBackendFactory, + })); + closed = false; + }; + 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 stores = await openInteractiveExecutionStoresForWrite(owner.lease); - const assignment = await stores.sessionStore.readWorkHubAssignment( - 'workhub-resume-stop-delegation', - ); - assert.ok(assignment); - // The existing Desktop card query must resolve the resumed execution, - // rather than keep projecting the original interrupted Turn. - const feedback = await composition.handlers['turn.message.execution.query']( - { - sessionId: target.id, - messageIds: [assignment.targetMessageId], - }, - context, - ); - assert.deepEqual(feedback, { - ok: true, - result: { - resolutions: [ - { - messageId: assignment.targetMessageId, - state: 'owned', - ...continuation, + 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 handoffAndReopen(); + await composition.handlers['turn.stop']( + { sessionId: target.id, turnId: original.result.turnId, runId: original.result.runId }, + context, + ); - const stopped = await composition.handlers['workhub.coordination.act']( - { - actionId: 'workhub-resume-stop-stop', - userText: 'Stop Payments', - confirmation: { kind: 'user_stop' }, + pauseNext = !missingReceipt; + boundary = deferred(); + const kernel = ( + manager as unknown as { + runtimeKernel: { deps: { runtimeEventStore: RuntimeEventStore } }; + } + ).runtimeKernel; + const originalEvents = kernel.deps.runtimeEventStore; + let cut = false; + if (missingReceipt) + kernel.deps.runtimeEventStore = { + ...originalEvents, + async appendRuntimeEvent(sessionId, runId, event, options) { + if (!cut && event.actions?.coordination?.result.disposition === 'resume_work') { + cut = true; + throw new Error('Crash after target admission before Coordination receipt'); + } + return originalEvents.appendRuntimeEvent(sessionId, runId, event, options); + }, + }; + const resumed = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-resume-stop-resume', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + resumesActionId: 'workhub-resume-stop-delegation', + expects: { targetSessionId: target.id }, + }, + }, + context, + ); + kernel.deps.runtimeEventStore = originalEvents; + assert.equal(resumed.ok, !missingReceipt, JSON.stringify(resumed)); + assert.equal(cut, missingReceipt); + const resumedTurn = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: workHubResumedTurnId('workhub-resume-stop-resume') }, + 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'); + if (!missingReceipt) await handoffAndReopen(); + + // Lose the response, interrupt the continuation, then discard all + // in-memory Gate replay state by reopening the production composition. + await composition.handlers['turn.stop']({ sessionId: target.id, ...continuation }, context); + await composition.close().catch((error: unknown) => { + const messages = (failure: unknown): string => + failure instanceof AggregateError + ? failure.errors.map(messages).join('\n') + : String(failure); + if ( + !missingReceipt || + !messages(error).includes('Crash after target admission before Coordination receipt') + ) + throw error; + }); + closed = true; + await owner.close(); + restartedOwner = await tryAcquireInteractiveRootOwner( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + assert.ok(restartedOwner); + owner = restartedOwner; + ({ composition } = await createCapturedExecutionComposition(owner, { + safeBoundaryResume: true, + })); + const retry = { + actionId: 'workhub-resume-stop-resume', + userText: 'Resume Payments', proposal: { - disposition: 'stop_work', + disposition: 'resume_work' as const, + resumesActionId: 'workhub-resume-stop-delegation', expects: { targetSessionId: target.id }, }, - }, - context, - ); - assert.deepEqual(stopped, { - ok: true, - result: { - disposition: 'stop_work', - outcome: 'stop_delivered', + }; + if (missingReceipt) { + const failed = await readProductionTranscript(composition, context); + assert.ok( + failed.some( + (message) => + message.type === 'user' && + message.turnId === retry.actionId && + message.coordinationActionId === retry.actionId && + message.text === retry.userText, + ), + 'failed admitted input must remain visible after reopen, before retry', + ); + assert.ok( + failed.some( + (message) => + message.type === 'turn_state' && + message.turnId === retry.actionId && + message.status === 'failed', + ), + ); + } + const replayed = await composition.handlers['workhub.coordination.act'](retry, context); + // Re-delivery acknowledges the original Coordination Run; it must never + // resume a later interruption under the same action identity. + assert.equal(replayed.ok, true, JSON.stringify(replayed)); + assert.deepEqual(replayed.result, { + disposition: 'resume_work', + outcome: 'resume_started', 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 (!closed && continuation && targetSessionId) { - await composition.handlers['turn.stop']( - { sessionId: targetSessionId, ...continuation }, + }); + const transcript = await readProductionTranscript(composition, context); + assert.equal( + transcript.filter( + (message) => + message.type === 'workhub_coordination' && + message.kind === 'action_receipt' && + message.receipt.actionId === retry.actionId, + ).length, + 1, + ); + assert.ok( + transcript.some( + (message) => + message.type === 'workhub_coordination' && message.kind === 'delegation_assigned', + ), + ); + + const stillInterrupted = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: continuation.turnId }, + context, + ); + assert.ok(stillInterrupted.ok); + assert.notEqual(stillInterrupted.result.status, 'running'); + const fresh = await composition.handlers['workhub.coordination.act']( + { ...retry, actionId: 'workhub-resume-again' }, + context, + ); + assert.equal(fresh.ok, true, JSON.stringify(fresh)); + if (!fresh.ok || fresh.result.disposition !== 'resume_work' || !fresh.result.targetTurnId) + return; + const freshTurn = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: fresh.result.targetTurnId }, + context, + ); + assert.equal(freshTurn.ok, true); + if (!freshTurn.ok) return; + assert.equal(freshTurn.result.status, 'running'); + assert.notEqual(freshTurn.result.turnId, continuation.turnId); + continuation = { turnId: freshTurn.result.turnId, runId: freshTurn.result.runId }; + + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const assignment = await stores.sessionStore.readWorkHubAssignment( + 'workhub-resume-stop-delegation', + ); + assert.ok(assignment); + // The existing Desktop card query must resolve the resumed execution, + // rather than keep projecting the original interrupted Turn. + const feedback = await composition.handlers['turn.message.execution.query']( + { + sessionId: target.id, + messageIds: [assignment.targetMessageId], + }, context, ); + assert.deepEqual(feedback, { + ok: true, + result: { + resolutions: [ + { + messageId: assignment.targetMessageId, + state: 'owned', + ...continuation, + }, + ], + }, + }); + + 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 (!closed && continuation && targetSessionId) { + await composition.handlers['turn.stop']( + { sessionId: targetSessionId, ...continuation }, + context, + ).catch(() => {}); + } + if (!closed) await composition.close().catch(() => {}); + await restartedOwner?.close(); } - if (!closed) await composition.close(); - await restartedOwner?.close(); - } + }); }); -}); test('WorkHub does not record resume while safe-boundary resume is disabled', async () => { await withCompositionRoot(async ({ root, owner }) => { @@ -1220,9 +1863,21 @@ test('WorkHub does not record resume while safe-boundary resume is disabled', as message: 'Safe-boundary resume is disabled for this Runtime Host', }, }); - await composition.close(); const stores = await openInteractiveExecutionStoresForWrite(owner.lease); assert.equal(await stores.sessionStore.readWorkHubActionClaim(actionId), undefined); + const clarified = await composition.handlers['workhub.coordination.act']( + { + actionId, + userText: 'Resume Payments', + proposal: { + disposition: 'clarify', + assistantText: 'Resume is unavailable on this Host.', + }, + }, + context, + ); + assert.ok(clarified.ok, JSON.stringify(clarified)); + assert.equal(clarified.result.disposition, 'clarify'); } finally { await composition.close(); } @@ -1370,21 +2025,28 @@ test('WorkHub correction replaces its link without stopping a shared manual Turn assert.ok(correctionDestination); if (!correctionDestination) return; - const correction = await composition.handlers['workhub.coordination.act']( - { - actionId: 'workhub-correction-action', - userText: `No, move this to ${correctionDestination.sessionName} instead`, - candidateSetId: correctionCandidates.result.candidateSetId, - confirmation: { kind: 'user_correction' }, - proposal: { - disposition: 'replace', - replacesActionId: assignment.actionId, - target: { - disposition: 'delegate_existing', - candidateRef: correctionDestination.candidateRef, - }, + const correctionInput = { + actionId: 'workhub-correction-action', + userText: `No, move this to ${correctionDestination.sessionName} instead`, + candidateSetId: correctionCandidates.result.candidateSetId, + confirmation: { kind: 'user_correction' }, + proposal: { + disposition: 'replace', + replacesActionId: assignment.actionId, + target: { + disposition: 'delegate_existing', + candidateRef: correctionDestination.candidateRef, }, }, + } as const; + const stale = await composition.handlers['workhub.coordination.act']( + { ...correctionInput, candidateSetId: `sha256:${'0'.repeat(64)}` }, + context, + ); + assert.equal(stale.ok, false); + if (!stale.ok) assert.equal(stale.error.code, 'candidate_set_stale'); + const correction = await composition.handlers['workhub.coordination.act']( + correctionInput, context, ); assert.equal(correction.ok, true, JSON.stringify(correction)); @@ -2098,6 +2760,43 @@ async function seedLegacyFakeBackendSession( return sessionId; } +async function readProductionTranscript( + composition: Awaited>, + context: ConnectionContext, +): Promise { + assert.ok(composition.continuity); + context = { ...context, principalKind: 'local_owner' }; + const connection = composition.continuity.attachConnection(context.connectionId, { + send: async () => {}, + }); + let pages = 0; + try { + const opened = await composition.handlers['subscription.open']( + { + sessionId: 'maka_workhub_coordination', + transcript: { kind: 'tail', maxBytes: 128 }, + }, + context, + ); + assert.ok(opened.ok, JSON.stringify(opened)); + const client = new ClientSessionSubscription( + opened.result, + async () => undefined, + async (input) => { + pages += 1; + const page = await composition.handlers['session.transcript.page'](input, context); + assert.ok(page.ok, JSON.stringify(page)); + return page.result; + }, + ); + const messages = await client.loadTranscript((value) => value as StoredMessage); + assert.ok(pages > 0, 'Must exercise production pagination, not only bootstrap'); + return messages; + } finally { + connection.close(); + } +} + async function createCapturedExecutionComposition( owner: InteractiveRootOwner, options: { diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts index a33f361175..c5efadcb9d 100644 --- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts +++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts @@ -406,6 +406,64 @@ test('loads a canonical transcript while live frames continue on the same connec ); }); +test('resumes bounded index preparation before publishing the canonical transcript', async () => { + const message = { + type: 'assistant' as const, + id: 'message-1', + turnId: 'turn-1', + ts: 1, + text: 'snapshot text', + modelId: 'test-model', + }; + await withProtocolPeer( + async (transport, hostEpoch, rootId) => { + let openRequest = await acceptConnectionAndReadOpen(transport, hostEpoch, rootId); + for (let batch = 0; batch < 3; batch++) { + await writeProtocolFrame(transport, { + requestId: openRequest.requestId, + operation: 'subscription.open', + ok: false, + error: { code: 'transcript_preparing', message: `indexed through ${batch * 64}` }, + }); + const next = decodeClientFrame(await transport.read(1_000)); + assert.ok(!('kind' in next) && next.operation === 'subscription.open'); + assert.deepEqual(next.input, openRequest.input); + openRequest = next; + } + const opened = openResult( + hostEpoch, + 'subscription-transcript', + transcriptBootstrap(Buffer.from(JSON.stringify(message), 'utf8')), + ); + await writeRawLocalIpc( + transport, + Buffer.concat([ + encodeLocalIpcTestFrame({ + requestId: openRequest.requestId, + operation: 'subscription.open', + ok: true, + result: opened, + }), + encodeLocalIpcTestFrame(deltaFrame(hostEpoch, opened.subscriptionId, 1)), + ]), + ); + await answerClose(transport, opened.subscriptionId); + }, + async (connection) => { + const subscription = await connection.openSessionSubscription({ + sessionId: 'session-1', + transcript: { kind: 'tail', maxBytes: 16 * 1024 }, + }); + assert.deepEqual(await subscription.loadTranscript(decodeStoredMessage), [message]); + assert.deepEqual(await subscription[Symbol.asyncIterator]().next(), { + done: false, + value: deltaFrame(connection.hostEpoch, subscription.subscriptionId, 1), + }); + await subscription.close(); + }, + ); +}); + test('reassembles a large message from bounded backward pages', async () => { const message = { type: 'user' as const, @@ -487,6 +545,101 @@ test('reassembles a large message from bounded backward pages', async () => { ); }); +test('keeps page timeout independent of index preparation time', async () => { + const message = { + type: 'user' as const, + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'hello', + }; + const encoded = Buffer.from(JSON.stringify(message), 'utf8'); + const splitAt = Math.floor(encoded.byteLength / 2); + await withProtocolPeer( + async (transport, hostEpoch, rootId) => { + let openRequest = await acceptConnectionAndReadOpen(transport, hostEpoch, rootId); + await new Promise((resolve) => setTimeout(resolve, 700)); + await writeProtocolFrame(transport, { + requestId: openRequest.requestId, + operation: 'subscription.open', + ok: false, + error: { code: 'transcript_preparing', message: 'Preparing history' }, + }); + const next = decodeClientFrame(await transport.read(1_000)); + assert.ok(!('kind' in next) && next.operation === 'subscription.open'); + openRequest = next; + const opened = openResult(hostEpoch, 'subscription-fragmented', { + throughSequence: 0, + overlayMessageCount: 0, + durable: transcriptPage({ + rawBytes: encoded.byteLength - splitAt, + fragments: [ + { + kind: 'durable', + sequence: 0, + byteOffset: splitAt, + totalBytes: encoded.byteLength, + payloadDigest: null, + data: encoded.subarray(splitAt).toString('base64'), + }, + ], + nextCursor: 'cursor-1', + }), + overlay: transcriptPage({ source: 'overlay' }), + }); + await writeProtocolFrame(transport, { + requestId: openRequest.requestId, + operation: 'subscription.open', + ok: true, + result: opened, + }); + const continuationRequest = decodeClientFrame(await transport.read(1_000)); + assert.ok(!('kind' in continuationRequest)); + assert.equal(continuationRequest.operation, 'session.transcript.page'); + assert.deepEqual(continuationRequest.input, { + subscriptionId: opened.subscriptionId, + source: 'durable', + direction: 'older', + throughSequence: 0, + cursor: 'cursor-1', + anchorSequence: null, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + await writeProtocolFrame(transport, { + requestId: continuationRequest.requestId, + operation: 'session.transcript.page', + ok: true, + result: transcriptPage({ + rawBytes: splitAt, + fragments: [ + { + kind: 'durable', + sequence: 0, + byteOffset: 0, + totalBytes: encoded.byteLength, + payloadDigest: null, + data: encoded.subarray(0, splitAt).toString('base64'), + }, + ], + }), + }); + await answerClose(transport, opened.subscriptionId); + }, + async (connection) => { + const subscription = await connection.openSessionSubscription( + { + sessionId: 'session-1', + transcript: { kind: 'tail', maxBytes: 16 * 1024 }, + }, + 1_000, + ); + assert.deepEqual(await subscription.loadTranscript(decodeStoredMessage), [message]); + await subscription.close(); + }, + ); +}); + test('decodes one bounded page without walking the remaining transcript', async () => { const message = { type: 'user' as const, diff --git a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts index 00fc141529..b760b7b414 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts @@ -986,3 +986,18 @@ function decodeBootstrap( JSON.parse(Buffer.from(fragment.data, 'base64').toString('utf8')), ); } + +test('shared transcript preserves admitted action identity alongside its physical Turn', () => { + const message = { + type: 'user' as const, + id: 'input', + turnId: 'physical-retry', + ts: 1, + text: 'Resume Payments', + coordinationActionId: 'resume-action', + }; + assert.deepEqual( + projectSharedSessionTranscriptMessage(message, 'maka_workhub_coordination'), + message, + ); +}); diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts index b288e834ec..60659b2c56 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -696,21 +696,47 @@ test('reads the WorkHub Coordination transcript from its own rows', async () => }, }, ]; + const indexed: Array<{ sequence: number; source: 'legacy' | 'runtime'; sourceSequence: number }> = + []; let ledgerReads = 0; const stores = { agentRunStore: {}, - // Any ledger read is the defect: this Session's Turns are never admitted, - // so nothing ever converts these rows and a ledger read returns nothing. - runtimeEventStore: new Proxy( - {}, - { - get: () => () => { - ledgerReads += 1; - return Promise.resolve([]); - }, + runtimeEventStore: { + readTranscriptHighWater: async () => { + ledgerReads += 1; + return null; }, - ), + readTranscriptInvocations: async () => [], + }, sessionStore: { + readCoordinationTranscriptIndexState: async () => ({ + highWater: indexed.at(-1)?.sequence ?? null, + legacy: indexed.at(-1)?.sourceSequence ?? null, + runtime: null, + }), + appendCoordinationTranscriptIndex: async ( + refs: Array<{ source: 'legacy' | 'runtime'; sourceSequence: number }>, + ) => { + for (const ref of refs) indexed.push({ ...ref, sequence: indexed.length }); + }, + readCoordinationTranscriptIndex: async (request: { + direction: string; + throughSequence: number; + position: number; + limit: number; + }) => { + const selected = indexed.filter( + (ref) => + ref.sequence <= request.throughSequence && + (request.direction === 'older' + ? ref.sequence <= request.position + : ref.sequence >= request.position), + ); + return (request.direction === 'older' ? selected.reverse() : selected).slice( + 0, + request.limit, + ); + }, readTranscriptHighWaterSnapshot: async () => rows.at(-1)!.sequence, readMessagesAfter: async ( _sessionId: string, @@ -740,7 +766,7 @@ test('reads the WorkHub Coordination transcript from its own rows', async () => page.records.map(({ message }) => message.id), ['wha_1-user', 'wha_1'], ); - assert.equal(ledgerReads, 0); + assert.ok(ledgerReads > 0); assert.deepEqual( (await read.readDurableTurnLandmarks(WORKHUB_COORDINATION_SESSION_ID, 4)).landmarks.map( ({ turnId }) => turnId, @@ -749,6 +775,273 @@ test('reads the WorkHub Coordination transcript from its own rows', async () => ); }); +for (const historySize of [257, 10000]) { + test(`Coordination small-page foreground work is bounded (${historySize} rows)`, async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-coordination-growth-')); + const root = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(root); + assert.ok(owner); + try { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const sessionId = WORKHUB_COORDINATION_SESSION_ID; + await stores.sessionStore.createStableSession({ + sessionId, + requestFingerprint: `sha256:${'0'.repeat(64)}`, + input: { + role: 'workhub_coordination', + cwd: root.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }, + }); + for (let i = 0; i < historySize; i++) + await stores.sessionStore.appendMessage(sessionId, { + type: 'user', + id: `u-${i}`, + turnId: `t-${i}`, + ts: i, + text: `message ${i}`, + }); + let reads = 0, + decoded = 0, + writes = 0; + const makeReader = () => + createSessionTranscriptReader({ + stores: { + ...stores, + sessionStore: { + ...stores.sessionStore, + async readMessagesAfter(...args) { + reads++; + const page = await stores.sessionStore.readMessagesAfter(...args); + decoded += page.records.length; + return page; + }, + async appendCoordinationTranscriptIndex(...args) { + writes++; + return stores.sessionStore.appendCoordinationTranscriptIndex(...args); + }, + }, + }, + canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, + }); + let batches = 0; + let previousThrough = -1; + for (;;) { + reads = decoded = writes = 0; + let complete = false; + try { + const page = await makeReader().readDurablePage(sessionId, { + direction: 'older', + maxBytes: 128, + maxMessages: 1, + }); + assert.ok( + page.fragments.length > 0, + 'catch-up must not report an empty complete history', + ); + assert.equal( + JSON.parse(Buffer.concat(page.fragments.map((f) => f.data)).toString()).id, + `u-${historySize - 1}`, + ); + complete = true; + } catch (error) { + assert.equal((error as Error).name, 'CoordinationTranscriptIndexPending'); + const through = (error as { indexedThrough: number }).indexedThrough; + assert.ok(through > previousThrough, 'recreated reader resumes committed progress'); + previousThrough = through; + } + assert.ok(reads <= 3, `foreground source reads: ${reads}`); + assert.ok(decoded <= 192, `foreground decoded rows: ${decoded}`); + assert.ok(writes <= 1, `foreground index writes: ${writes}`); + assert.ok(++batches <= Math.ceil(historySize / 64)); + if (complete) break; + } + } finally { + await owner.close(); + await rm(base, { recursive: true, force: true }); + } + }); +} + +test('Coordination page positions survive regressing clocks, late appends and reader recreation', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-coordination-index-')); + const root = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(root); + assert.ok(owner); + try { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const sessionId = WORKHUB_COORDINATION_SESSION_ID; + await stores.sessionStore.createStableSession({ + sessionId, + requestFingerprint: `sha256:${'0'.repeat(64)}`, + input: { + role: 'workhub_coordination', + cwd: root.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }, + }); + for (const [id, ts] of [ + ['legacy-1', 100], + ['legacy-2', 1], + ] as const) { + await stores.sessionStore.appendMessage(sessionId, { + type: 'user', + id, + turnId: id, + ts, + text: id, + }); + } + await seedInvocation(stores.runtimeEventStore, { + sessionId, + runId: 'index-run', + turnId: 'index-turn', + openedAt: 20, + }); + for (const [id, ts] of [ + ['runtime-1', 50], + ['runtime-2', 2], + ] as const) { + await stores.runtimeEventStore.appendRuntimeEvent( + sessionId, + 'index-run', + runtimeEvent(sessionId, { + id, + invocationId: 'index-run', + runId: 'index-run', + turnId: 'index-turn', + ts, + role: 'user', + author: 'user', + content: { kind: 'text', text: id }, + refs: { storedMessageId: id }, + }), + ); + } + await stores.runtimeEventStore.appendRuntimeEvent( + sessionId, + 'index-run', + runtimeEvent(sessionId, { + id: 'runtime-terminal', + invocationId: 'index-run', + runId: 'index-run', + turnId: 'index-turn', + ts: 3, + status: 'completed', + actions: { endInvocation: true }, + }), + ); + const makeReader = () => + createSessionTranscriptReader({ + stores, + canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, + }); + const reader = makeReader(); + const before = await reader.readDurableRecords(sessionId, { + direction: 'newer', + maxMessages: 64, + maxStoredBytes: 65536, + }); + assert.equal(before.records.length, 5); + // A new record can predate every displayed timestamp, in either store. + await stores.sessionStore.appendMessage(sessionId, { + type: 'user', + id: 'late-legacy', + turnId: 'late', + ts: 0, + text: 'late', + }); + await seedInvocation(stores.runtimeEventStore, { + sessionId, + runId: 'late-run', + turnId: 'late-turn', + openedAt: 0, + }); + await stores.runtimeEventStore.appendRuntimeEvent( + sessionId, + 'late-run', + runtimeEvent(sessionId, { + id: 'late-runtime', + invocationId: 'late-run', + runId: 'late-run', + turnId: 'late-turn', + ts: 0, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'late runtime' }, + refs: { storedMessageId: 'late-runtime' }, + }), + ); + await stores.runtimeEventStore.appendRuntimeEvent( + sessionId, + 'late-run', + runtimeEvent(sessionId, { + id: 'late-terminal', + invocationId: 'late-run', + runId: 'late-run', + turnId: 'late-turn', + ts: 0, + status: 'completed', + actions: { endInvocation: true }, + }), + ); + const recreated = makeReader(); + const after = await recreated.readDurableRecords(sessionId, { + direction: 'newer', + maxMessages: 64, + maxStoredBytes: 65536, + }); + assert.deepEqual(after.records.slice(0, before.records.length), before.records); + assert.ok(after.records.some(({ message }) => message.id === 'late-legacy')); + assert.ok(after.records.some(({ message }) => message.id === 'late-runtime')); + assert.deepEqual( + await recreated.readDurableRecords(sessionId, { + direction: 'newer', + throughSequence: before.throughSequence, + maxMessages: 64, + maxStoredBytes: 65536, + }), + before, + ); + for (const direction of ['newer', 'older'] as const) { + const records: (typeof after.records)[number][] = []; + let position: number | undefined; + do { + const page = await recreated.readDurableRecords(sessionId, { + direction, + throughSequence: after.throughSequence, + position, + maxMessages: 1, + maxStoredBytes: 65536, + }); + records.push(...page.records); + position = page.nextPosition ?? undefined; + assert.ok(records.length <= after.records.length); + } while (position !== undefined); + assert.deepEqual(direction === 'older' ? records.reverse() : records, after.records); + } + // Anchor +/- 1 is part of the public pager contract. + const anchor = after.records[2]!.sequence; + const preceding = await recreated.readDurableRecords(sessionId, { + direction: 'older', + position: anchor - 1, + throughSequence: after.throughSequence, + maxMessages: 64, + maxStoredBytes: 65536, + }); + assert.deepEqual(preceding.records, after.records.slice(0, 2).reverse()); + } finally { + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + test('pages a nested Turn the same way a single sweep reads it', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-nested-paging-')); const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); 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 f6ae747120..06fddd5e77 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -39,10 +39,6 @@ import { } from '@maka/core/session'; import { createSessionStore, type SessionAuthorityStore } from '@maka/storage/session-store'; import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store'; -import { - WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, - WORKHUB_COORDINATION_TEXT_MAX_BYTES, -} from '../protocol/index.js'; 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'; @@ -430,70 +426,6 @@ describe('Host WorkHub Coordination coordinator', () => { } }); - test('records synthetic coordination summaries durably and retries idempotently', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-workhub-record-')); - const store = createSessionStore(root); - try { - const workhub = coordinator(root, store); - assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); - const input = { - turnId: 'summary-turn', - userText: 'Continue payment work', - assistantText: 'Submitted to Payment', - }; - assert.deepEqual(await workhub.handlers['workhub.coordination.record'](input, CONTEXT), { - ok: true, - result: { turnId: 'summary-turn' }, - }); - assert.deepEqual(await workhub.handlers['workhub.coordination.record'](input, CONTEXT), { - ok: true, - result: { turnId: 'summary-turn' }, - }); - const maximumInput = { - turnId: 'maximum-summary-turn', - // Each NUL is one UTF-8 input byte but six bytes once JSON-escaped in - // the durable transcript record. Retry lookup must budget for that - // worst case, not only the decoded text sizes. - userText: '\0'.repeat(WORKHUB_COORDINATION_TEXT_MAX_BYTES), - assistantText: '\0'.repeat(WORKHUB_COORDINATION_SUMMARY_MAX_BYTES), - }; - assert.deepEqual( - await workhub.handlers['workhub.coordination.record'](maximumInput, CONTEXT), - { ok: true, result: { turnId: 'maximum-summary-turn' } }, - ); - assert.deepEqual( - await workhub.handlers['workhub.coordination.record'](maximumInput, CONTEXT), - { ok: true, result: { turnId: 'maximum-summary-turn' } }, - ); - const messages = await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID); - assert.equal(messages.length, 6); - assert.deepEqual( - messages.slice(0, 3).map(({ type, turnId }) => ({ type, turnId })), - [ - { type: 'user', turnId: 'summary-turn' }, - { type: 'assistant', turnId: 'summary-turn' }, - { type: 'turn_state', turnId: 'summary-turn' }, - ], - ); - const conflict = await workhub.handlers['workhub.coordination.record']( - { ...input, assistantText: 'Different summary' }, - CONTEXT, - ); - assert.equal(conflict.ok, false); - if (!conflict.ok) assert.equal(conflict.error.code, 'operation_conflict'); - assert.equal((await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).length, 6); - const empty = await workhub.handlers['workhub.coordination.record']( - { ...input, turnId: 'empty-summary', assistantText: ' ' }, - CONTEXT, - ); - assert.equal(empty.ok, false); - if (!empty.ok) assert.equal(empty.error.code, 'operation_conflict'); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); - test('persists delegated action ownership and replays it after Host restart', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-delegation-')); const userText = 'Continue payment work. '.repeat(900); @@ -1921,90 +1853,11 @@ describe('Host WorkHub Coordination coordinator', () => { 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); - const admission = new SessionAdmissionGate(); - const { executions } = coordinationExecutions(admission); - try { - const workhub = coordinator(root, store, () => undefined, undefined, executions, admission); - assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); - - // An answered Turn is owned by the root admission ledger. - assert.equal( - ( - await workhub.handlers['workhub.coordination.answer']( - { turnId: 'shared-turn', text: 'What is left on payments?' }, - CONTEXT, - ) - ).ok, - true, - ); - const recordAfterAnswer = await workhub.handlers['workhub.coordination.record']( - { turnId: 'shared-turn', userText: 'Continue payments', assistantText: 'Sent to Payments' }, - CONTEXT, - ); - assert.deepEqual(recordAfterAnswer, { - ok: false, - error: { - code: 'operation_conflict', - message: 'WorkHub Coordination Turn identity belongs to a different operation', - }, - }); - assert.deepEqual(await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID), []); - - // A recorded Turn is owned by the durable summary triplet. - assert.equal( - ( - await workhub.handlers['workhub.coordination.record']( - { - turnId: 'recorded-turn', - userText: 'Continue payments', - assistantText: 'Sent to Payments', - }, - CONTEXT, - ) - ).ok, - true, - ); - const answerAfterRecord = await workhub.handlers['workhub.coordination.answer']( - { turnId: 'recorded-turn', text: 'What is left on payments?' }, - CONTEXT, - ); - assert.deepEqual(answerAfterRecord, { - ok: false, - error: { - code: 'operation_conflict', - message: 'WorkHub Coordination Turn identity belongs to a different operation', - }, - }); - assert.deepEqual( - (await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).map( - ({ type, turnId }) => ({ type, turnId }), - ), - [ - { type: 'user', turnId: 'recorded-turn' }, - { type: 'assistant', turnId: 'recorded-turn' }, - { type: 'turn_state', turnId: 'recorded-turn' }, - ], - ); - assert.deepEqual( - (await store.listTurnsSnapshot(WORKHUB_COORDINATION_SESSION_ID)).map( - ({ turnId }) => turnId, - ), - ['recorded-turn'], - ); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); }); type CoordinationExecutions = Pick< RootTurnCoordinator, - 'startWorkHubCoordinationMessage' | 'hasRootTurnAdmission' + 'startWorkHubCoordinationMessage' | 'runWorkHubCoordinationOperation' >; /** @@ -2013,7 +1866,6 @@ type CoordinationExecutions = Pick< * reproduce the ordering the real ledger enforces. */ function coordinationExecutions(admission: SessionAdmissionGate) { - const admitted = new Set(); const starts: Parameters[0][] = []; const prepared: MessageContent[] = []; const executions: CoordinationExecutions = { @@ -2023,7 +1875,6 @@ function coordinationExecutions(admission: SessionAdmissionGate) { const content = await request.prepareFreshContent(lease); if (content.kind === 'rejected') return content.outcome; prepared.push(content.content); - admitted.add(request.turnId); return { ok: true, result: { @@ -2035,7 +1886,10 @@ function coordinationExecutions(admission: SessionAdmissionGate) { }; }); }, - hasRootTurnAdmission: async (_sessionId, turnId) => admitted.has(turnId), + runWorkHubCoordinationOperation: async (request) => { + if (!request.operation) throw new Error('Missing operation'); + return { ok: true, result: await request.operation(request.turnId) }; + }, }; return { executions, starts, prepared }; } @@ -2053,7 +1907,10 @@ function coordinator( message: 'WorkHub test execution is not configured', }, }), - hasRootTurnAdmission: async () => false, + runWorkHubCoordinationOperation: async (request) => { + if (!request.operation) throw new Error('Missing operation'); + return { ok: true, result: await request.operation(request.turnId) }; + }, }, admission: SessionAdmissionGate = new SessionAdmissionGate(), sessionActions: Partial = {}, 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 30c0f9b40e..e826224dae 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -25,7 +25,6 @@ import { decodeWorkHubCoordinationActResult, decodeWorkHubCoordinationAnswerInput, decodeWorkHubCoordinationCandidatesResult, - decodeWorkHubCoordinationRecordInput, decodeWorkHubCoordinationResolveInput, decodeWorkHubCoordinationResolveResult, HOST_OPERATION_SPECS, @@ -55,18 +54,6 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () decodeWorkHubCoordinationAnswerInput({ turnId: 'answer-turn', text: 'What changed?' }), { turnId: 'answer-turn', text: 'What changed?' }, ); - assert.deepEqual( - decodeWorkHubCoordinationRecordInput({ - turnId: 'summary-turn', - userText: 'Continue payment work', - assistantText: 'Submitted to Payment', - }), - { - turnId: 'summary-turn', - userText: 'Continue payment work', - assistantText: 'Submitted to Payment', - }, - ); assert.deepEqual( decodeWorkHubCoordinationActInput({ actionId: 'action-correction', @@ -171,22 +158,11 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () (error) => error instanceof RuntimeHostProtocolError, ); assert.equal(HOST_OPERATION_SPECS['workhub.coordination.answer'].mode, 'command'); - assert.equal(HOST_OPERATION_SPECS['workhub.coordination.record'].mode, 'command'); assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.answer'), true); - assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.record'), true); assert.throws( () => decodeWorkHubCoordinationAnswerInput({ turnId: 'turn', text: 'answer', extra: true }), (error) => error instanceof RuntimeHostProtocolError, ); - assert.throws( - () => - decodeWorkHubCoordinationRecordInput({ - turnId: 'turn', - userText: 'user', - assistantText: 'x'.repeat(8 * 1024 + 1), - }), - (error) => error instanceof RuntimeHostProtocolError, - ); }); test('WorkHub Coordination resume has closed input and outcome shapes', () => { diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index 56de724cee..2e53ba2654 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -589,15 +589,45 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { return status; } - openSessionSubscription( + async openSessionSubscription( input: SubscriptionOpenInput, timeoutMs?: number, + ): Promise { + const deadline = + Date.now() + (timeoutMs === undefined ? 30_000 : requireTimeout(timeoutMs, 'timeoutMs')); + for (;;) { + try { + return await this.#openSessionSubscription( + input, + Math.max(1, deadline - Date.now()), + timeoutMs, + ); + } catch (error) { + if ( + !(error instanceof RuntimeHostOperationError) || + error.code !== 'transcript_preparing' || + input.transcript.kind !== 'tail' || + this.#terminalError || + Date.now() >= deadline + ) + throw error; + // Each refusal committed a bounded, resumable index batch. Keep the + // caller in its loading state and yield before requesting more work. + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + } + + #openSessionSubscription( + input: SubscriptionOpenInput, + openTimeoutMs: number, + requestTimeoutMs?: number, ): Promise { const expectedSessionId = input.sessionId; return this.#requestOperation( 'subscription.open', input, - timeoutMs, + openTimeoutMs, (result) => { if (result.hostEpoch !== this.hostEpoch) { throw new RuntimeHostSubscriptionError( @@ -620,13 +650,13 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { const subscription = new ClientSessionSubscription( result, () => this.#closeSessionSubscription(result.subscriptionId), - (query) => this.request('session.transcript.page', query, timeoutMs), + (query) => this.request('session.transcript.page', query, requestTimeoutMs), async () => { try { await this.request( 'session.transcript.overlay.release', { subscriptionId: result.subscriptionId }, - timeoutMs, + requestTimeoutMs, ); } catch (error) { this.#fail(asError(error)); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 3078a049b2..021024a461 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 = 133 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 134 as const; +// 134: Coordination actions own real Runtime Turns. Removes the synthetic record +// operation, projects typed action receipts and admitted action identities, and +// distinguishes stale candidate refusals and resumable transcript preparation. // 133: WorkHub actions carry attachments and new-Work model/permission defaults. // Epoch-132 peers reject these additional fields on strict action shapes. // 132: new Tool Result archives use versioned ledger references, not Artifact payloads. diff --git a/packages/runtime-host/src/protocol/operation-spec.ts b/packages/runtime-host/src/protocol/operation-spec.ts index cbdd9a6768..24505ff9d9 100644 --- a/packages/runtime-host/src/protocol/operation-spec.ts +++ b/packages/runtime-host/src/protocol/operation-spec.ts @@ -28,6 +28,8 @@ export type HostOperationErrorCode = | 'not_found' | 'session_archived' | 'session_busy' + | 'transcript_preparing' + | 'candidate_set_stale' | 'operation_conflict' | 'capability_unavailable' | 'invalid_request' diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 1134fdb917..cbc856d960 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -356,7 +356,6 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'workhub.coordination.answer', 'workhub.coordination.act', 'workhub.coordination.candidates', - 'workhub.coordination.record', 'workhub.coordination.resolve', ] as const satisfies readonly OperationKey[]); diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index ae600aa5de..9a839c4c69 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -314,6 +314,7 @@ export type OrderedSubscriptionFrame = Exclude< >; const SUBSCRIPTION_OPEN_ERRORS = [ + 'transcript_preparing', 'host_not_ready', 'host_draining', 'operation_unavailable', diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index fb9b6a3e5a..b767ac7bcd 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -20,6 +20,8 @@ import type { AttachmentRef } from '@maka/core/events'; import { decodeMessageContent } from './turn.js'; import { isWorkHubCreateDefaults, type WorkHubCreateDefaults } from '@maka/core/session'; +import { isWorkHubActionResult } from '@maka/core/workhub-action-result'; + import { requireCount, requireEntityId, @@ -86,12 +88,6 @@ export interface WorkHubCoordinationAnswerInput { readonly attachments?: AttachmentRef[]; } -export interface WorkHubCoordinationRecordInput { - readonly turnId: string; - readonly userText: string; - readonly assistantText: string; -} - export interface WorkHubCoordinationTurnResult { readonly turnId: string; } @@ -189,40 +185,8 @@ export interface WorkHubCoordinationActInput { readonly confirmation?: WorkHubCoordinationDestructiveConfirmation; } -export type WorkHubCoordinationActResult = - | { readonly disposition: 'answer_here'; readonly coordinationTurnId: string } - | { readonly disposition: 'clarify'; readonly coordinationTurnId: string } - | { - readonly disposition: 'delegate_existing'; - readonly targetSessionId: string; - readonly targetTurnId: string; - readonly steered?: true; - } - | { - readonly disposition: 'create_new'; - readonly targetSessionId: string; - readonly targetTurnId: string; - readonly steered?: true; - } - | { - readonly disposition: 'replace'; - readonly replacementDisposition: 'delegate_existing' | 'create_new'; - readonly targetSessionId: string; - readonly targetTurnId: string; - readonly steered?: true; - } - | { - readonly disposition: 'stop_work'; - readonly outcome: 'cancelled_pending' | 'stop_delivered' | 'already_terminal' | 'not_owned'; - readonly targetSessionId: string; - readonly targetTurnId?: string; - } - | { - readonly disposition: 'resume_work'; - readonly outcome: 'resume_started' | 'already_running'; - readonly targetSessionId: string; - readonly targetTurnId?: string; - }; +export type { WorkHubActionResult as WorkHubCoordinationActResult } from '@maka/core/workhub-action-result'; +import type { WorkHubActionResult as WorkHubCoordinationActResult } from '@maka/core/workhub-action-result'; export const WORKHUB_COORDINATION_OPERATION_SPECS = { 'workhub.coordination.resolve': defineOperation< @@ -247,17 +211,6 @@ export const WORKHUB_COORDINATION_OPERATION_SPECS = { decodeInput: decodeWorkHubCoordinationAnswerInput, decodeOutput: decodeWorkHubCoordinationTurnResult, }), - 'workhub.coordination.record': defineOperation< - WorkHubCoordinationRecordInput, - WorkHubCoordinationTurnResult, - (typeof TURN_ERRORS)[number] - >({ - mode: 'command', - availability: 'ready', - errors: TURN_ERRORS, - decodeInput: decodeWorkHubCoordinationRecordInput, - decodeOutput: decodeWorkHubCoordinationTurnResult, - }), 'workhub.coordination.candidates': defineOperation< WorkHubCoordinationCandidatesInput, WorkHubCoordinationCandidatesResult, @@ -272,11 +225,11 @@ export const WORKHUB_COORDINATION_OPERATION_SPECS = { 'workhub.coordination.act': defineOperation< WorkHubCoordinationActInput, WorkHubCoordinationActResult, - (typeof TURN_ERRORS)[number] + (typeof TURN_ERRORS)[number] | 'candidate_set_stale' >({ mode: 'command', availability: 'ready', - errors: TURN_ERRORS, + errors: [...TURN_ERRORS, 'candidate_set_stale'], decodeInput: decodeWorkHubCoordinationActInput, decodeOutput: decodeWorkHubCoordinationActResult, }), @@ -323,29 +276,6 @@ export function decodeWorkHubCoordinationAnswerInput( }; } -export function decodeWorkHubCoordinationRecordInput( - value: unknown, -): WorkHubCoordinationRecordInput { - const input = requireExactRecord(value, 'WorkHub Coordination record input', [ - 'turnId', - 'userText', - 'assistantText', - ]); - return { - turnId: requireEntityId(input.turnId, 'WorkHub Coordination Turn id'), - userText: requireUtf8String( - input.userText, - 'WorkHub Coordination user text', - WORKHUB_COORDINATION_TEXT_MAX_BYTES, - ), - assistantText: requireUtf8String( - input.assistantText, - 'WorkHub Coordination assistant text', - WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, - ), - }; -} - export function decodeWorkHubCoordinationTurnResult(value: unknown): WorkHubCoordinationTurnResult { const result = requireExactRecord(value, 'WorkHub Coordination Turn result', ['turnId']); return { @@ -489,118 +419,12 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi } export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoordinationActResult { - const result = requireRecord(value, 'WorkHub Coordination action result'); - if (result.disposition === 'answer_here' || result.disposition === 'clarify') { - const exact = requireExactRecord(result, 'WorkHub Coordination local action result', [ - 'disposition', - 'coordinationTurnId', - ]); - return { - disposition: result.disposition, - coordinationTurnId: requireEntityId(exact.coordinationTurnId, 'WorkHub Coordination Turn id'), - }; - } - if (result.disposition === 'delegate_existing' || result.disposition === 'create_new') { - const exact = requireShapedRecord( - result, - 'WorkHub Coordination execution action result', - ['disposition', 'targetSessionId', 'targetTurnId'], - ['steered'], - ); - if (exact.steered !== undefined && exact.steered !== true) { - throw invalidProtocolFrame('Invalid WorkHub Coordination steering result'); - } - return { - disposition: result.disposition, - targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), - targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), - ...(exact.steered === true ? { steered: true as const } : {}), - }; - } - if (result.disposition === 'replace') { - const exact = requireShapedRecord( - result, - 'WorkHub Coordination replacement result', - ['disposition', 'replacementDisposition', 'targetSessionId', 'targetTurnId'], - ['steered'], - ); - if ( - exact.replacementDisposition !== 'delegate_existing' && - exact.replacementDisposition !== 'create_new' - ) { - throw invalidProtocolFrame('Invalid WorkHub replacement disposition'); - } - if (exact.steered !== undefined && exact.steered !== true) { - throw invalidProtocolFrame('Invalid WorkHub Coordination steering result'); - } - return { - disposition: 'replace', - replacementDisposition: exact.replacementDisposition, - targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), - targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), - ...(exact.steered === true ? { steered: true as const } : {}), - }; - } - if (result.disposition === 'stop_work') { - const exact = requireShapedRecord( - result, - 'WorkHub Coordination stop result', - ['disposition', 'outcome', 'targetSessionId'], - ['targetTurnId'], - ); - if ( - exact.outcome !== 'cancelled_pending' && - exact.outcome !== 'stop_delivered' && - exact.outcome !== 'already_terminal' && - exact.outcome !== 'not_owned' - ) { - throw invalidProtocolFrame('Invalid WorkHub stop outcome'); - } - if ( - ((exact.outcome === 'stop_delivered' || exact.outcome === 'not_owned') && - exact.targetTurnId === undefined) || - (exact.outcome === 'cancelled_pending' && exact.targetTurnId !== undefined) - ) { - throw invalidProtocolFrame('Invalid WorkHub stop target Turn'); - } - return { - disposition: 'stop_work', - outcome: exact.outcome, - targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), - ...(exact.targetTurnId === undefined - ? {} - : { - targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), - }), - }; - } - 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') { - 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'), - }), - }; + if (!isWorkHubActionResult(value)) { + throw invalidProtocolFrame('Invalid WorkHub Coordination action result'); } - throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); + return Object.fromEntries( + Object.entries(value).filter(([, field]) => field !== undefined), + ) as WorkHubCoordinationActResult; } function decodeWorkHubCoordinationCandidate(value: unknown): WorkHubCoordinationCandidate { diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts index b655573352..840749b816 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -84,6 +84,8 @@ const PERSISTED_GRANT_MIGRATIONS: ReadonlyMap = // Retired with the second execution-inspection contract; no shipped surface // called execution.inspect.resolve. ['execution.inspect.resolve', { kind: 'release' }], + // Synthetic Coordination recording was retired, not widened into action authority. + ['workhub.coordination.record', { kind: 'release' }], ]); export const ACCESS_FILE_NAME = 'runtime-host-access.json'; diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index f098c3ab81..5442b3197d 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1431,6 +1431,20 @@ export async function createExecutionRuntimeHostComposition( // delegation. A Session-wide latest-failure query could otherwise // continue unrelated work started directly in the same Session. resumeDelegation: async (assignment, context, actionId) => { + const targetTurnId = workHubResumedTurnId(actionId); + const admitted = await stores.agentRunStore.readRootTurnAdmission( + assignment.targetSessionId, + targetTurnId, + ); + if (admitted) { + if (admitted.execution.kind !== 'safe_boundary_continuation') { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub resume identity is not a continuation', + ); + } + return { outcome: 'resume_started' as const, targetTurnId }; + } const disposition = await messages.readMessageExecutionDisposition( assignment.targetSessionId, assignment.targetMessageId, @@ -1491,7 +1505,6 @@ export async function createExecutionRuntimeHostComposition( 'WorkHub resume source lineage changed during planning', ); } - const targetTurnId = workHubResumedTurnId(actionId); const started = await coordinator.handlers['turn.resume.start']( { sessionId: assignment.targetSessionId, @@ -1619,13 +1632,13 @@ export async function createExecutionRuntimeHostComposition( .update(input.replacesDelegationId, 'utf8') .digest('hex') .slice(0, 48)}`, - turnId: input.actionId, + turnId: input.coordinationTurnId ?? input.actionId, ts: assignedAt, schemaVersion: WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, kind: 'delegation_superseded' as const, actionId: input.actionId, actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, + coordinationTurnId: input.coordinationTurnId ?? input.actionId, supersededActionId: input.replacesActionId, supersededDelegationId: input.replacesDelegationId, replacementDelegationId: delegationId, @@ -1635,7 +1648,7 @@ export async function createExecutionRuntimeHostComposition( assignment: { type: 'workhub_coordination', id: `wha_${suffix}`, - turnId: input.actionId, + turnId: input.coordinationTurnId ?? input.actionId, ts: assignedAt, schemaVersion: supersession ? WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION @@ -1643,7 +1656,7 @@ export async function createExecutionRuntimeHostComposition( kind: 'delegation_assigned', actionId: input.actionId, actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, + coordinationTurnId: input.coordinationTurnId ?? input.actionId, targetSessionId: input.targetSessionId, targetSessionName: input.targetSessionName, targetTurnId: turnId, diff --git a/packages/runtime-host/src/server/hosted-execution-recovery.ts b/packages/runtime-host/src/server/hosted-execution-recovery.ts index 9c595bbe6d..7423a3d928 100644 --- a/packages/runtime-host/src/server/hosted-execution-recovery.ts +++ b/packages/runtime-host/src/server/hosted-execution-recovery.ts @@ -454,7 +454,11 @@ function recoveryExecutionContract(execution: RootExecutionDescriptor): Recovery case 'external_message': return contract(true, true, 'root_replay'); case 'workhub_coordination': - return contract(false, true, 'root_replay'); + return contract( + false, + true, + execution.operation === 'action' ? 'host_recovery_closure' : 'root_replay', + ); case 'regenerate': return contract(false, true, 'root_replay'); case 'context_compact': @@ -491,6 +495,7 @@ function usesHostRecoveryClosure(execution: RootExecutionDescriptor): execution RootExecutionDescriptor, { kind: + | 'workhub_coordination' | 'goal' | 'legacy_automation' | 'agent_graph_supervisor_wake' @@ -501,6 +506,7 @@ function usesHostRecoveryClosure(execution: RootExecutionDescriptor): execution } > { return ( + (execution.kind === 'workhub_coordination' && execution.operation === 'action') || execution.kind === 'legacy_automation' || execution.kind === 'goal' || execution.kind === 'agent_graph_supervisor_wake' || diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 76a6208b09..8e19acf6d3 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { WorkHubActionReceipt } from '@maka/core/workhub-action-result'; import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { BackendStopMode } from '@maka/core/backend-types'; @@ -222,6 +223,7 @@ export type RootMessageStartRequest = }) | (RootMessageStartRequestBase & { readonly execution: Extract; + readonly operation?: (turnId: string) => Promise; readonly turnOrchestration?: undefined; prepareFreshContent(lease: SessionAdmissionLease): Promise; }); @@ -1678,16 +1680,109 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { operationUnavailable('WorkHub Coordination execution requires its reserved Session'), ); } + if ((request.execution.operation === 'action') !== (request.operation !== undefined)) { + return Promise.resolve( + operationUnavailable('Coordination execution mode does not match its runner'), + ); + } return this.startRootMessage(request, context); } - /** - * Whether a durable root Turn already owns this identity. WorkHub also writes - * Coordination Turns outside this coordinator, and must not append a second - * triplet into a Turn this admission ledger already owns. - */ - async hasRootTurnAdmission(sessionId: string, turnId: string): Promise { - return (await this.stores.agentRunStore.readRootTurnAdmission(sessionId, turnId)) !== undefined; + async runWorkHubCoordinationOperation( + request: Extract, + context: ConnectionContext, + ): Promise< + { ok: true; result: WorkHubActionReceipt } | Extract + > { + if (request.execution.operation !== 'action' || !request.operation) { + return operationUnavailable('Coordination action requires a Host operation'); + } + let turnId = request.turnId; + let recoveredReceipt: WorkHubActionReceipt | undefined; + // A retry preserves the action identity, but never reopens a terminal Run. + // The existing admission chain and terminal facts identify prior attempts. + for (;;) { + const admission = await this.stores.agentRunStore.readRootTurnAdmission( + request.sessionId, + turnId, + ); + if (!admission) break; + if (!rootExecutionMatches(admission.execution, request.execution)) + return operationConflict('Coordination action identity belongs to different content'); + const run = await this.readRunIfPresent(request.sessionId, admission.runId); + if (!run) break; + const snapshot = await this.readCanonicalSnapshot( + request.sessionId, + turnId, + admission.runId, + run, + ); + if (!isTerminalSnapshot(snapshot) || snapshot.status === 'completed') break; + // A crash after the receipt does not erase a completed Host effect. + const events = await this.stores.runtimeEventStore.readImmutableRuntimeEvents( + request.sessionId, + admission.runId, + ); + recoveredReceipt ??= events.find((event) => event.actions?.coordination)?.actions + ?.coordination; + turnId = `whretry_${createHash('sha256').update(admission.runId).digest('hex').slice(0, 48)}`; + } + const receiptToReplay = recoveredReceipt; + const started = await this.startWorkHubCoordinationMessage( + { + ...request, + turnId, + ...(receiptToReplay ? { operation: async () => receiptToReplay } : {}), + }, + context, + ); + if (!started.ok) return started; + const active = this.#executions.get(request.sessionId); + if (active?.turnId === turnId) await active.done; + const snapshot = await this.readCanonicalSnapshot( + request.sessionId, + turnId, + started.result.runId, + ); + if (snapshot.status !== 'completed') + return operationUnavailable('Coordination operation did not complete'); + const events = await this.stores.runtimeEventStore.readImmutableRuntimeEvents( + request.sessionId, + started.result.runId, + ); + const receipt = events.find((event) => event.actions?.coordination)?.actions?.coordination; + return receipt + ? { ok: true, result: receipt } + : operationUnavailable('Coordination receipt is unavailable'); + } + + private coordinationOperation( + request: RootMessageStartRequest, + admission: RootTurnAdmission, + ): HostedExecutionAdmission | undefined { + if ( + request.execution.kind !== 'workhub_coordination' || + !('operation' in request) || + !request.operation + ) + return undefined; + const execute = request.operation; + const content = requireHostedExecutionMessageContent(admission); + return { + sessionId: admission.sessionId, + turnId: admission.turnId, + runId: admission.runId, + userMessageId: admission.userMessageId, + execution: admission.execution, + content, + start: ({ runId, userMessageId, onRunStarted }) => + this.manager.runCoordinationOperation( + admission.sessionId, + { turnId: admission.turnId, ...content }, + { runId, userMessageId, onRunStarted }, + () => execute(admission.turnId), + ), + }; } private startRootMessage( @@ -1713,7 +1808,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { operationConflict('Turn identity belongs to a different execution kind'), ); } - if (!isDeepStrictEqual(existing.execution, request.execution)) { + if (!rootExecutionMatches(existing.execution, request.execution)) { return completedStart( operationConflict('Turn identity belongs to a different execution payload'), ); @@ -1759,7 +1854,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { context.acquireResidency, lease, undefined, - undefined, + this.coordinationOperation(request, existing), reservation, ); } @@ -1872,7 +1967,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { context.acquireResidency, lease, undefined, - undefined, + this.coordinationOperation(request, admitted.admission), reservation, ); }); @@ -3127,6 +3222,22 @@ function throwHostedStopError( } } +/** Compatibility may omit only the action identity absent from an older admission. */ +function rootExecutionMatches( + stored: RootExecutionDescriptor, + incoming: RootExecutionDescriptor, +): boolean { + if ( + stored.kind === 'workhub_coordination' && + incoming.kind === 'workhub_coordination' && + stored.actionId === undefined + ) { + const { actionId: _actionId, ...legacyIncoming } = incoming; + return isDeepStrictEqual(stored, legacyIncoming); + } + return isDeepStrictEqual(stored, incoming); +} + function rootMessageAdmissionMatches( admission: RootTurnAdmission, request: RootMessageStartRequest, @@ -3134,7 +3245,7 @@ function rootMessageAdmissionMatches( authorization: ConnectionContext['turnAdmissionAuthorization'], ): boolean { return ( - isDeepStrictEqual(admission.execution, request.execution) && + rootExecutionMatches(admission.execution, request.execution) && (request.execution.kind === 'external_message' && request.execution.inputDigest ? true : messageContentsEqual(requireHostedExecutionMessageContent(admission), content)) && diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index ae70ff3d27..2d632b7f9f 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -17,6 +17,7 @@ * under the License. */ +import { CoordinationTranscriptIndexPending } from './session-transcript-reader.js'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { SessionEvent, ShellRunUpdate } from '@maka/core/events'; @@ -866,7 +867,12 @@ export class SessionContinuityCoordinator implements SessionContinuityService { | { ok: true; value: SubscriptionOpenResult } | { ok: false; - code: 'not_found' | 'operation_conflict' | 'operation_unavailable' | 'persistence_failed'; + code: + | 'not_found' + | 'operation_conflict' + | 'operation_unavailable' + | 'persistence_failed' + | 'transcript_preparing'; message: string; } > { @@ -977,6 +983,12 @@ export class SessionContinuityCoordinator implements SessionContinuityService { transcriptBootstrap = created.bootstrap; } catch (error) { if (error instanceof TranscriptOverlayPreparationRequired) throw error; + if (error instanceof CoordinationTranscriptIndexPending) + return { + ok: false as const, + code: 'transcript_preparing' as const, + message: error.message, + }; return { ok: false as const, code: diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts index 5977acf25b..090a38b714 100644 --- a/packages/runtime-host/src/server/session-transcript-reader.ts +++ b/packages/runtime-host/src/server/session-transcript-reader.ts @@ -36,6 +36,7 @@ import { } from '@maka/runtime/interaction-authority'; import type { ExecutionStoresWriter, + CoordinationTranscriptReference, SessionTranscriptMessageLookupRequest, SessionTranscriptPageRequest, SessionTranscriptRecordScanPage, @@ -78,6 +79,16 @@ const TRANSCRIPT_LOOKUP_MAX_TURNS = 2; const COORDINATION_TRANSCRIPT_SCAN_LIMIT = 64; const COORDINATION_TRANSCRIPT_SCAN_MAX_BYTES = DURABLE_TRANSCRIPT_TURN_MAX_BYTES; +/** A bounded index batch committed; no complete snapshot is available yet. */ +export class CoordinationTranscriptIndexPending extends Error { + readonly name = 'CoordinationTranscriptIndexPending'; + constructor(readonly indexedThrough: number | null) { + super( + `Coordination history is preparing (indexed through ${indexedThrough ?? 'none'}); retry to continue`, + ); + } +} + export function createSessionTranscriptReader(input: { stores: ExecutionStoresWriter<'interactive'>; canonicalPermissionOutcomes: CanonicalPermissionOutcomeReader; @@ -89,11 +100,11 @@ export function createSessionTranscriptReader(input: { ensureTranscriptLedger?: (sessionId: string) => Promise; }): SessionTranscriptReader { const ledger = createDurableLedgerTranscriptReader(input); - const coordination = createCoordinationTranscriptReader(input.stores); + const coordination = createCoordinationTranscriptReader(input.stores, ledger.source); const isCoordination = (sessionId: string): boolean => sessionId === WORKHUB_COORDINATION_SESSION_ID; - // Only a ledger-backed Session has a conversion; the Coordination Session's - // rows are the transcript, not something a run left behind. + // Coordination retains atomic link facts and released history in the legacy + // source, while admitted Run output comes from the same ledger reader. const prepared = async (sessionId: string): Promise => { if (isCoordination(sessionId)) return coordination; await input.ensureTranscriptLedger?.(sessionId); @@ -245,6 +256,17 @@ function createDurableLedgerTranscriptReader(input: { if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) { throw new Error('Durable RuntimeEvent transcript projection is incomplete'); } + const admission = + turn.invocation.sessionId === WORKHUB_COORDINATION_SESSION_ID + ? await input.stores.agentRunStore.readRootTurnAdmission( + turn.invocation.sessionId, + turn.invocation.turnId, + ) + : undefined; + const actionId = + admission?.execution.kind === 'workhub_coordination' + ? admission.execution.actionId + : undefined; const ordinals = new Map(turn.events.map((entry) => [entry.event.id, entry.ordinal])); const emitted = new Map(); return projected.messages.map((message, index) => { @@ -257,7 +279,13 @@ function createDurableLedgerTranscriptReader(input: { throw new Error('RuntimeEvent exceeds its transcript sequence stride'); } emitted.set(ordinal, offset + 1); - return { sequence: ordinal * EVENT_SEQUENCE_STRIDE + offset, message }; + return { + sequence: ordinal * EVENT_SEQUENCE_STRIDE + offset, + message: + message.type === 'user' && actionId + ? { ...message, coordinationActionId: actionId } + : message, + }; }); }; @@ -337,10 +365,12 @@ function createDurableLedgerTranscriptReader(input: { } }; + const source: TranscriptRecordSource = { readHighWater: highWater, scan }; return { + source, readHighWater: highWater, - ...pagedTranscriptReads({ readHighWater: highWater, scan }), + ...pagedTranscriptReads(source), /** One row per Turn, folded from the Turn's own projected messages. */ async readTurnContributions( @@ -552,28 +582,16 @@ function pagedTranscriptReads(source: TranscriptRecordSource) { }; } -/** - * The WorkHub Coordination Session's transcript, read from the rows the WorkHub - * writes. - * - * Every other Session's transcript is what its runs did, so the ledger holds - * all of it. The Coordination Session's is not: a delegation, a stop and a - * routing summary are appended under a Turn id that no root Turn admission ever - * minted, so there is no invocation for the ledger to hang them on and no - * conversion that could lift them. `workhub.coordination.answer` is the one - * path that would admit a real Turn and nothing in the renderer calls it. - * - * Delete this source once the WorkHub admits a Coordination Turn for every - * action, which its own ADR already requires (#3492, - * `docs/architecture/workhub-coordination-session-adr.md`): the Session then - * reads like any other and this reader has nothing left to do. - */ -function createCoordinationTranscriptReader(stores: ExecutionStoresWriter<'interactive'>) { +/** Legacy atomic facts and Runtime output share one bounded cursor projection. */ +function createCoordinationTranscriptReader( + stores: ExecutionStoresWriter<'interactive'>, + ledger: TranscriptRecordSource, +) { const store = stores.sessionStore; - const highWater = (sessionId: string): Promise => + const legacyHighWater = (sessionId: string): Promise => store.readTranscriptHighWaterSnapshot(sessionId); - const scan = async function* ( + const legacyScan = async function* ( sessionId: string, request: { direction: 'older' | 'newer'; @@ -582,7 +600,9 @@ function createCoordinationTranscriptReader(stores: ExecutionStoresWriter<'inter }, ): AsyncGenerator<{ sequence: number; message: StoredMessage }> { const throughSequence = - request.throughSequence === undefined ? await highWater(sessionId) : request.throughSequence; + request.throughSequence === undefined + ? await legacyHighWater(sessionId) + : request.throughSequence; if (throughSequence === null) return; const older = request.direction === 'older'; const position = request.position ?? (older ? throughSequence : 0); @@ -602,8 +622,15 @@ function createCoordinationTranscriptReader(stores: ExecutionStoresWriter<'inter } }; - const source: TranscriptRecordSource = { readHighWater: highWater, scan }; + const source = mergeTranscriptSources( + { readHighWater: legacyHighWater, scan: legacyScan }, + ledger, + store, + ); + const highWater = source.readHighWater; + const scan = source.scan; return { + source, readHighWater: highWater, ...pagedTranscriptReads(source), @@ -645,14 +672,24 @@ function createCoordinationTranscriptReader(stores: ExecutionStoresWriter<'inter const throughSequence = await highWater(sessionId); if (throughSequence === null) return { throughSequence: null, landmarks: [] }; const landmarks: SessionTurnLandmark[] = []; + const seen = new Set(); for await (const { sequence, message } of scan(sessionId, { direction: 'newer', throughSequence, })) { - if (message.type !== 'user' || message.turnId === undefined) continue; - const label = (message.displayText ?? message.text ?? '').trim(); - if (!label) continue; - landmarks.push({ turnId: message.turnId, sequence, label }); + const receipt = + message.type === 'workhub_coordination' && message.kind === 'action_receipt' + ? message.receipt + : undefined; + const turnId = message.turnId; + const identity = receipt?.actionId ?? turnId; + const label = ( + receipt?.userText ?? + (message.type === 'user' ? (message.displayText ?? message.text) : '') + ).trim(); + if (!turnId || !identity || !label || seen.has(identity)) continue; + seen.add(identity); + landmarks.push({ turnId, sequence, label }); if (landmarks.length === maxLandmarks) break; } return { throughSequence, landmarks }; @@ -660,6 +697,111 @@ function createCoordinationTranscriptReader(stores: ExecutionStoresWriter<'inter }; } +/** + * The stores have independent append orders, and event timestamps can regress. + * Retain only source references in a rebuildable index, allocating stable page + * positions once. Bodies and execution facts remain in their original store. + * Refresh drains bounded batches; pages seek the index and project one source + * Turn/batch at a time, including after a Host restart. + */ +function mergeTranscriptSources( + legacy: TranscriptRecordSource, + ledger: TranscriptRecordSource, + store: ExecutionStoresWriter<'interactive'>['sessionStore'], +): TranscriptRecordSource { + const sources = { legacy, runtime: ledger }; + let refreshing: Promise | undefined; + const refresh = async (sessionId: string): Promise => { + const state = await store.readCoordinationTranscriptIndexState(); + const lanes = ['legacy', 'runtime'] as const; + const limits = await Promise.all(lanes.map((lane) => sources[lane].readHighWater(sessionId))); + const walks = lanes.map((lane, index) => + sources[lane].scan(sessionId, { + direction: 'newer', + throughSequence: limits[index]!, + position: (state[lane] ?? -1) + 1, + }), + ); + try { + const heads = await Promise.all(walks.map((walk) => walk.next())); + let batch: CoordinationTranscriptReference[] = []; + while ( + heads.some((head) => !head.done) && + batch.length < COORDINATION_TRANSCRIPT_SCAN_LIMIT + ) { + // Time is only a presentation hint for newly observed facts, never a + // cursor or a reason to move an already indexed record. + const lane = heads[0]!.done + ? 1 + : heads[1]!.done + ? 0 + : heads[0]!.value.message.ts <= heads[1]!.value.message.ts + ? 0 + : 1; + batch.push({ source: lanes[lane]!, sourceSequence: heads[lane]!.value!.sequence }); + heads[lane] = await walks[lane]!.next(); + } + if (batch.length) await store.appendCoordinationTranscriptIndex(batch); + const highWater = (await store.readCoordinationTranscriptIndexState()).highWater; + if (heads.some((head) => !head.done)) throw new CoordinationTranscriptIndexPending(highWater); + return highWater; + } finally { + await Promise.all(walks.map((walk) => walk.return(undefined))); + } + }; + const readHighWater = (sessionId: string): Promise => { + refreshing ??= refresh(sessionId).finally(() => { + refreshing = undefined; + }); + return refreshing; + }; + const scan: TranscriptRecordSource['scan'] = async function* (sessionId, request) { + const watermark = + request.throughSequence === undefined + ? await readHighWater(sessionId) + : request.throughSequence; + if (watermark === null) return; + const state = await store.readCoordinationTranscriptIndexState(); + const older = request.direction === 'older'; + let position = request.position ?? (older ? watermark : 0); + let batches = 0; + for (;;) { + if (request.maxTurns !== undefined && batches++ >= request.maxTurns) return; + const refs = await store.readCoordinationTranscriptIndex({ + direction: request.direction, + throughSequence: watermark, + position, + limit: COORDINATION_TRANSCRIPT_SCAN_LIMIT, + }); + if (!refs.length) return; + const walks: Partial< + Record< + CoordinationTranscriptReference['source'], + ReturnType + > + > = {}; + try { + for (const ref of refs) { + const walk = (walks[ref.source] ??= sources[ref.source].scan(sessionId, { + direction: request.direction, + throughSequence: state[ref.source], + position: ref.sourceSequence, + })); + const record = await walk.next(); + if (record.done || record.value.sequence !== ref.sourceSequence) { + throw new Error('Coordination transcript source reference is missing'); + } + yield { sequence: ref.sequence, message: record.value.message }; + } + } finally { + await Promise.all(Object.values(walks).map((walk) => walk.return(undefined))); + } + position = refs.at(-1)!.sequence + (older ? -1 : 1); + } + }; + return { readHighWater, scan }; +} + function ordinalOf(sequence: number): number { return Math.floor(sequence / EVENT_SEQUENCE_STRIDE); } diff --git a/packages/runtime-host/src/server/shared-session-transcript.ts b/packages/runtime-host/src/server/shared-session-transcript.ts index 0995206c7e..243512859f 100644 --- a/packages/runtime-host/src/server/shared-session-transcript.ts +++ b/packages/runtime-host/src/server/shared-session-transcript.ts @@ -61,6 +61,9 @@ export function projectSharedSessionTranscriptMessage( ? {} : { steeringEventId: message.steeringEventId }), ...(message.origin === undefined ? {} : { origin: message.origin }), + ...(message.coordinationActionId === undefined + ? {} + : { coordinationActionId: message.coordinationActionId }), }; } case 'assistant': 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 b9c9e88019..ee1905265b 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -179,7 +179,10 @@ export interface WorkHubRetirementResult { readonly targetTurnId?: string; } +type AdmittedWorkHubAction = WorkHubCoordinationActInput & { readonly coordinationTurnId?: string }; + export interface WorkHubDelegationAssignmentInput { + readonly coordinationTurnId?: string; readonly actionId: string; readonly actionFingerprint: `sha256:${string}`; readonly targetSessionId: string; @@ -205,6 +208,7 @@ export interface WorkHubDelegationReplacementAbortInput { } export interface WorkHubDelegationStopInput { + readonly coordinationTurnId?: string; readonly actionId: string; readonly actionFingerprint: `sha256:${string}`; readonly stopsActionId: string; @@ -285,9 +289,32 @@ export class WorkHubCoordinationActionGate { return candidateSet(await this.#effects.listSessions()); } + /** Bind admitted retries to the same stable replacement destination as the Gate. */ + async coordinationInputDigest(input: WorkHubCoordinationActInput): Promise<`sha256:${string}`> { + if (input.proposal.disposition !== 'replace') + return digest({ ...input, candidateSetId: undefined }); + const replaced = await this.#effects.readAssignment(input.proposal.replacesActionId); + if (!replaced) + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement source is unavailable', + ); + const prepared = await this.#effects.readReplacement(replaced.delegationId); + const assigned = await this.#effects.readAssignment(input.actionId); + const destination = assigned?.targetSessionId ?? prepared?.targetSessionId; + if (destination) await this.#assertReplacementReplayTarget(input, destination); + const targetSessionId = + destination ?? (await this.#replacementAssignment(input, replaced)).targetSessionId; + return digest({ + fingerprint: replacementActionFingerprint(input, targetSessionId), + confirmation: input.confirmation, + }); + } + act( input: WorkHubCoordinationActInput, context: ConnectionContext, + admittedTurnId?: string, ): Promise { if (!input.userText.trim()) { return Promise.reject( @@ -314,7 +341,11 @@ export class WorkHubCoordinationActionGate { return replay.result; } - const result = this.#act(input, fingerprint, context); + const result = this.#act( + { ...input, ...(admittedTurnId ? { coordinationTurnId: admittedTurnId } : {}) }, + fingerprint, + context, + ); const action = { requestFingerprint, result }; this.#actions.set(input.actionId, action); // Successful actions remain a Host-lifetime fast path. Rejections release @@ -331,7 +362,7 @@ export class WorkHubCoordinationActionGate { } async #act( - input: WorkHubCoordinationActInput, + input: AdmittedWorkHubAction, fingerprint: `sha256:${string}`, context: ConnectionContext, ): Promise { @@ -364,7 +395,7 @@ export class WorkHubCoordinationActionGate { return this.#assign(assignmentInputFromRecord(durable), context); } if (proposal.disposition === 'answer_here') { - const turnId = coordinationTurnId(input.actionId, 'answer'); + const turnId = workHubCoordinationTurnId(input.actionId, 'answer'); await this.#claimAction(input.actionId, 'answer_here', fingerprint, turnId); await this.#effects.answer( { @@ -377,8 +408,14 @@ export class WorkHubCoordinationActionGate { return { disposition: 'answer_here', coordinationTurnId: turnId }; } if (proposal.disposition === 'clarify') { - const turnId = coordinationTurnId(input.actionId, 'clarify'); - await this.#claimAction(input.actionId, 'clarify', fingerprint, turnId); + const turnId = + input.coordinationTurnId ?? workHubCoordinationTurnId(input.actionId, 'clarify'); + await this.#claimAction( + input.actionId, + 'clarify', + fingerprint, + workHubCoordinationTurnId(input.actionId, 'clarify'), + ); await this.#effects.clarify({ turnId, userText: input.userText, @@ -439,6 +476,7 @@ export class WorkHubCoordinationActionGate { } const requested = await this.#effects.prepareStop({ actionId: input.actionId, + ...(input.coordinationTurnId ? { coordinationTurnId: input.coordinationTurnId } : {}), actionFingerprint: stopFingerprint, stopsActionId: source.actionId, stopsDelegationId: source.delegationId, @@ -747,7 +785,7 @@ export class WorkHubCoordinationActionGate { } async #replacementAssignment( - input: WorkHubCoordinationActInput, + input: AdmittedWorkHubAction, replaced: WorkHubDelegationAssignedMessage, ): Promise { if (input.proposal.disposition !== 'replace') { @@ -770,6 +808,7 @@ export class WorkHubCoordinationActionGate { const targetSessionId = workHubCreatedSessionId(input.actionId); return { actionId: input.actionId, + ...(input.coordinationTurnId ? { coordinationTurnId: input.coordinationTurnId } : {}), actionFingerprint: replacementActionFingerprint(input, targetSessionId), targetSessionId, targetSessionName: target.title, @@ -820,6 +859,7 @@ export class WorkHubCoordinationActionGate { } return { actionId: input.actionId, + ...(input.coordinationTurnId ? { coordinationTurnId: input.coordinationTurnId } : {}), actionFingerprint: replacementActionFingerprint(input, destination.sessionId), targetSessionId: destination.sessionId, targetSessionName: destination.sessionName, @@ -1077,12 +1117,12 @@ function candidateRef(candidateSetId: string, sessionId: string): string { return `whc_${hash(`${candidateSetId}\0${sessionId}`).slice(0, 48)}`; } -function coordinationTurnId(actionId: string, kind: 'answer' | 'clarify'): string { +export function workHubCoordinationTurnId(actionId: string, kind: 'answer' | 'clarify'): string { return `wha_${hash(`${actionId}\0${kind}`).slice(0, 48)}`; } function delegationAssignment( - input: WorkHubCoordinationActInput, + input: AdmittedWorkHubAction, actionFingerprint: `sha256:${string}`, targetSessionId: string, targetSessionName: string, @@ -1099,6 +1139,7 @@ function delegationAssignment( } const base = { actionId: input.actionId, + coordinationTurnId: input.coordinationTurnId ?? input.actionId, actionFingerprint, targetSessionId, targetSessionName, @@ -1281,6 +1322,7 @@ function assignmentInputFromRecord( ): WorkHubDelegationAssignmentInput { return { actionId: assignment.actionId, + coordinationTurnId: assignment.coordinationTurnId, actionFingerprint: assignment.actionFingerprint, targetSessionId: assignment.targetSessionId, targetSessionName: assignment.targetSessionName, @@ -1302,6 +1344,7 @@ function assignmentInputFromReplacement( ): WorkHubDelegationAssignmentInput { return { actionId: replacement.actionId, + coordinationTurnId: replacement.coordinationTurnId, actionFingerprint: replacement.actionFingerprint, targetSessionId: replacement.targetSessionId, targetSessionName: replacement.targetSessionName, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 1084180500..15ed951b72 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -44,7 +44,6 @@ import type { WorkHubCoordinationActResult, WorkHubCoordinationActInput, WorkHubCoordinationAnswerInput, - WorkHubCoordinationRecordInput, } from '../protocol/index.js'; import { WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, @@ -63,6 +62,7 @@ import { WorkHubActionGateFailure, WorkHubCoordinationActionGate, type WorkHubActionGateEffects, + workHubCoordinationTurnId, } from './workhub-coordination-action-gate.js'; const CREATE_FINGERPRINT = `sha256:${createHash('sha256') @@ -70,7 +70,6 @@ const CREATE_FINGERPRINT = `sha256:${createHash('sha256') .digest('hex')}`; const COORDINATION_CWD_DIRECTORY = 'workhub-coordination'; const COORDINATION_TOOL_PROFILE = 'workhub-coordination-v1' as const; -const SYNTHETIC_COORDINATION_MODEL_ID = 'maka-workhub-coordination'; const COORDINATION_PERMISSION_MODE = 'explore' as const; const COORDINATION_COLLABORATION_MODE = 'agent' as const; const COORDINATION_ORCHESTRATION_MODE = 'default' as const; @@ -108,7 +107,7 @@ type CoordinationStores = Pick< type CoordinationExecutions = Pick< RootTurnCoordinator, - 'startWorkHubCoordinationMessage' | 'hasRootTurnAdmission' + 'startWorkHubCoordinationMessage' | 'runWorkHubCoordinationOperation' >; type WorkHubResumeResult = @@ -147,7 +146,6 @@ export class HostWorkHubCoordinationCoordinator { readonly handlers: WorkHubCoordinationOperationHandlerMap = { 'workhub.coordination.resolve': () => this.#resolve(), 'workhub.coordination.answer': (input, context) => this.#answer(input, context), - 'workhub.coordination.record': (input) => this.#record(input), 'workhub.coordination.candidates': () => this.#candidates(), 'workhub.coordination.act': (input, context) => this.#act(input, context), }; @@ -202,16 +200,8 @@ export class HostWorkHubCoordinationCoordinator { throw new WorkHubActionEffectFailure(outcome.error.code, outcome.error.message); } }, - clarify: async (input) => { - const outcome = await this.#record({ - turnId: input.turnId, - userText: input.userText, - assistantText: input.assistantText, - }); - if (!outcome.ok) { - throw new WorkHubActionEffectFailure(outcome.error.code, outcome.error.message); - } - }, + // Clarification is recorded by the admitted Run as a host receipt. + clarify: async () => undefined, assign: options.sessionActions.assign, prepareReplacement: (input) => this.#prepareReplacement(input), abortReplacement: (input) => this.#abortReplacement(input), @@ -237,13 +227,14 @@ export class HostWorkHubCoordinationCoordinator { build: (existing) => ({ type: 'workhub_coordination', id: `whp_${suffix}`, - turnId: input.actionId, + turnId: existing?.turnId ?? input.coordinationTurnId ?? input.actionId, ts: existing?.ts ?? Date.now(), schemaVersion: WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, kind: 'delegation_replacement_requested', actionId: input.actionId, actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, + coordinationTurnId: + existing?.coordinationTurnId ?? input.coordinationTurnId ?? input.actionId, targetSessionId: input.targetSessionId, targetSessionName: input.targetSessionName, disposition: input.disposition, @@ -311,13 +302,14 @@ export class HostWorkHubCoordinationCoordinator { build: (existing) => ({ type: 'workhub_coordination', id: `whq_${suffix}`, - turnId: input.actionId, + turnId: existing?.turnId ?? input.coordinationTurnId ?? input.actionId, ts: existing?.ts ?? Date.now(), schemaVersion: WORKHUB_COORDINATION_STOP_SCHEMA_VERSION, kind: 'delegation_stop_requested', actionId: input.actionId, actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, + coordinationTurnId: + existing?.coordinationTurnId ?? input.coordinationTurnId ?? input.actionId, stopsActionId: input.stopsActionId, stopsDelegationId: input.stopsDelegationId, targetSessionId: input.targetSessionId, @@ -522,7 +514,51 @@ export class HostWorkHubCoordinationCoordinator { context: ConnectionContext, ): Promise> { try { - return { ok: true, result: await this.#actionGate.act(input, context) }; + if (input.proposal.disposition === 'answer_here') { + return { ok: true, result: await this.#actionGate.act(input, context) }; + } + const coordinationTurnId = + input.proposal.disposition === 'clarify' + ? workHubCoordinationTurnId(input.actionId, 'clarify') + : input.actionId; + let failure: unknown; + const outcome = await this.#executions.runWorkHubCoordinationOperation( + { + sessionId: WORKHUB_COORDINATION_SESSION_ID, + turnId: coordinationTurnId, + execution: { + kind: 'workhub_coordination', + operation: 'action', + actionId: input.actionId, + inputDigest: await this.#actionGate.coordinationInputDigest(input), + }, + archivedMessage: 'WorkHub Coordination Session is unavailable', + prepareFreshContent: async () => + (await this.#readSummaryMessages(coordinationTurnId)).length > 0 + ? { kind: 'rejected', outcome: turnIdentityConflict() } + : { kind: 'ready', content: normalizeMessageContent({ text: input.userText }) }, + operation: async (turnId) => { + try { + const result = await this.#actionGate.act(input, context, turnId); + return { + actionId: input.actionId, + userText: input.userText, + result, + ...(input.proposal.disposition === 'clarify' + ? { clarification: input.proposal.assistantText } + : {}), + }; + } catch (error) { + failure = error; + throw error; + } + }, + }, + context, + ); + if (failure) throw failure; + if (!outcome.ok) return outcome; + return { ok: true, result: outcome.result.result }; } catch (error) { if (error instanceof WorkHubActionEffectFailure) { return { @@ -537,7 +573,12 @@ export class HostWorkHubCoordinationCoordinator { return { ok: false, error: { - code: error.code === 'target_waiting_for_user' ? 'session_busy' : 'operation_conflict', + code: + error.code === 'target_waiting_for_user' + ? 'session_busy' + : error.code === 'candidate_set_stale' + ? 'candidate_set_stale' + : 'operation_conflict', message: error.message, }, }; @@ -649,9 +690,8 @@ export class HostWorkHubCoordinationCoordinator { }), }, archivedMessage: 'WorkHub Coordination Session is unavailable', - // A recorded summary owns its Turn identity durably but is admitted - // outside this ledger, so the probe runs under the admission lease: a - // concurrent `record` cannot slip a second triplet into this Turn. + // Released summaries have no admission row. Keep their Turn identities + // reserved when admitting a new Runtime-owned answer. prepareFreshContent: async () => { let recorded: readonly StoredMessage[]; try { @@ -680,76 +720,7 @@ export class HostWorkHubCoordinationCoordinator { return outcome.ok ? { ok: true, result: { turnId: input.turnId } } : outcome; } - #record( - input: WorkHubCoordinationRecordInput, - ): Promise> { - if (!input.userText.trim() || !input.assistantText.trim()) { - return Promise.resolve( - turnFailure( - 'workhub.coordination.record', - 'operation_conflict', - 'WorkHub Coordination summary text is empty', - ), - ); - } - return this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, async (lease) => { - let header: SessionHeader; - try { - header = await this.#stores.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID); - } catch { - return turnFailure( - 'workhub.coordination.record', - 'persistence_failed', - 'WorkHub Coordination Session state is unavailable', - ); - } - if (!validCoordinationHeader(header)) { - return turnFailure( - 'workhub.coordination.record', - 'operation_conflict', - 'WorkHub Coordination Session identity is unavailable', - ); - } - - const messages = coordinationSummaryMessages(input); - try { - const existing = await this.#readSummaryMessages(input.turnId); - if (existing.length > 0) { - return coordinationSummaryMatches(existing, input) - ? { ok: true, result: { turnId: input.turnId } } - : turnFailure( - 'workhub.coordination.record', - 'operation_conflict', - 'WorkHub Coordination Turn identity belongs to different content', - ); - } - // An answer owns its Turn identity in the root admission ledger. Both - // operations take the same Session admission, so this probe settles the - // race in one direction and the answer's own probe settles the other. - if ( - await this.#executions.hasRootTurnAdmission(WORKHUB_COORDINATION_SESSION_ID, input.turnId) - ) { - return turnFailure( - 'workhub.coordination.record', - 'operation_conflict', - TURN_IDENTITY_CONFLICT_MESSAGE, - ); - } - await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, messages); - await this.#continuity.refreshCanonical(WORKHUB_COORDINATION_SESSION_ID, lease); - return { ok: true, result: { turnId: input.turnId } }; - } catch { - this.#requestDrain(); - return turnFailure( - 'workhub.coordination.record', - 'commit_outcome_unknown', - 'WorkHub Coordination summary outcome is unknown', - ); - } - }); - } - - /** Reads the durable summary triplet a `record` would own for this Turn. */ + /** Reads released synthetic summaries solely to reject identity collisions. */ async #readSummaryMessages(turnId: string): Promise { const throughSequence = await this.#stores.readTranscriptHighWaterSnapshot( WORKHUB_COORDINATION_SESSION_ID, @@ -868,55 +839,6 @@ function coordinationSummaryMessageId( .slice(0, 48)}`; } -function coordinationSummaryMessages(input: WorkHubCoordinationRecordInput): StoredMessage[] { - const ts = Date.now(); - const messageId = (kind: (typeof COORDINATION_SUMMARY_MESSAGE_KINDS)[number]) => - coordinationSummaryMessageId(input.turnId, kind); - return [ - { - type: 'user', - id: messageId('user'), - turnId: input.turnId, - ts, - text: input.userText, - }, - { - type: 'assistant', - id: messageId('assistant'), - turnId: input.turnId, - ts: ts + 1, - text: input.assistantText, - modelId: SYNTHETIC_COORDINATION_MODEL_ID, - }, - { - type: 'turn_state', - id: messageId('state'), - turnId: input.turnId, - ts: ts + 2, - status: 'completed', - }, - ]; -} - -function coordinationSummaryMatches( - existing: readonly StoredMessage[], - input: WorkHubCoordinationRecordInput, -): boolean { - if (existing.length !== 3) return false; - const user = existing.find((message) => message.type === 'user'); - const assistant = existing.find((message) => message.type === 'assistant'); - const state = existing.find((message) => message.type === 'turn_state'); - return ( - user?.turnId === input.turnId && - user.text === input.userText && - assistant?.turnId === input.turnId && - assistant.text === input.assistantText && - assistant.modelId === SYNTHETIC_COORDINATION_MODEL_ID && - state?.turnId === input.turnId && - state.status === 'completed' - ); -} - function success(): OperationOutcome<'workhub.coordination.resolve'> { return { ok: true, @@ -950,7 +872,7 @@ function operationUnavailable(message: string) { return { ok: false, error: { code: 'operation_unavailable', message } } as const; } -function turnFailure( +function turnFailure( _operation: K, code: Extract, { readonly ok: false }>['error']['code'], message: string, diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index aea8ca2ef5..6e9cb120bc 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1883,6 +1883,14 @@ type ActionCoverageSamples = { }; const ACTION_COVERAGE_SAMPLES: ActionCoverageSamples = { + coordination: { + action: { + actionId: 'clarify', + userText: 'Which task?', + result: { disposition: 'clarify', coordinationTurnId: 'turn-1' }, + clarification: 'Please name a task.', + }, + }, handoffPause: { action: { protocol: 'runtime_handoff_pause_v1', @@ -2405,3 +2413,32 @@ function makeHeader(id: string): SessionHeader { schemaVersion: 1, }; } + +test('Coordination receipts materialize as host facts, never assistant output', () => { + const receipt = { + actionId: 'clarify', + userText: 'Which task?', + clarification: 'Please name a task.', + result: { disposition: 'clarify' as const, coordinationTurnId: turnId }, + }; + const out = projectRuntimeEventsToStoredMessages( + [ + ev({ + id: 'coordination', + author: 'host', + modelVisibility: 'hidden', + actions: { coordination: receipt }, + }), + ], + { invocations: [invocation] }, + ); + assert.ok( + out.messages.some( + (message) => message.type === 'workhub_coordination' && message.kind === 'action_receipt', + ), + ); + assert.equal( + out.messages.some((message) => message.type === 'assistant'), + false, + ); +}); diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 124d79ff28..4a5c547872 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -11922,6 +11922,15 @@ class GatedSteeringBackend implements AgentBackend { } class DelegatingRuntimeKernel implements RuntimeKernelLike { + async *runCoordinationOperation( + _sessionId: string, + _input: Parameters[1], + _options: unknown, + execute: Parameters[3], + ): AsyncIterable { + await execute(); + } + readonly starts: Array<{ sessionId: string; input: Parameters[1]; diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 2f59c35986..6ad82960db 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -944,7 +944,7 @@ export class AgentRun { }; } - async begin(): Promise { + private async beginUserTurn(): Promise { // Owed from here, not from after the opening: `openInvocation` can leave the // invocation open and still throw, and `finalize` reopens what it can. this.initialRuntimeEventPending = true; @@ -953,6 +953,17 @@ export class AgentRun { this.lastTs = this.input.now(); const initialRuntimeEvent = await this.recordInitialRuntimeEvent(this.lastTs); + return initialRuntimeEvent; + } + + /** Host actions share Turn facts and finalization without activating a provider. */ + async beginCoordination(): Promise { + await this.beginUserTurn(); + await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, this.lastTs); + } + + async begin(): Promise { + const initialRuntimeEvent = await this.beginUserTurn(); this.active = await this.input.hooks.reserveRun(this.sessionId, this.header, this); await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, this.lastTs); diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 10a79aa96c..f65410d420 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -20,7 +20,12 @@ import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; -import type { AssistantStepContentKind, StoredMessage, TurnStatus } from '@maka/core/session'; +import type { + AssistantStepContentKind, + StoredMessage, + TurnStatus, + WorkHubCoordinationActionMessage, +} from '@maka/core/session'; import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; import type { ToolActivityKind, ToolResultContent } from '@maka/core/events'; import { markPersisted } from '@maka/core/persisted-value'; @@ -99,6 +104,21 @@ export function isContinuationStartRuntimeEvent(event: RuntimeEvent): boolean { ); } +export function projectRuntimeEventCoordinationReceipt( + event: RuntimeEvent, +): WorkHubCoordinationActionMessage | undefined { + if (!event.actions?.coordination) return undefined; + return { + type: 'workhub_coordination', + kind: 'action_receipt', + schemaVersion: 1, + id: event.id, + turnId: event.turnId, + ts: event.ts, + receipt: event.actions.coordination, + }; +} + /** * Whether the event can affect the StoredMessage projection or the state needed * to construct one. Pure control-plane facts are intentionally absent so a @@ -106,6 +126,7 @@ export function isContinuationStartRuntimeEvent(event: RuntimeEvent): boolean { */ export function affectsRuntimeEventStoredMessageProjection(event: RuntimeEvent): boolean { return ( + event.actions?.coordination !== undefined || event.content !== undefined || isTerminalRuntimeEvent(event) || event.actions?.permissionRequest !== undefined || @@ -284,6 +305,12 @@ export function projectRuntimeEventsToStoredMessages( } } + const coordinationReceipt = projectRuntimeEventCoordinationReceipt(event); + if (coordinationReceipt) { + messages.push(coordinationReceipt); + projected = true; + } + if (event.actions?.permissionRequest) { const request = event.actions.permissionRequest; state.permissionRequestById.set(request.requestId, { diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index f061a9eb33..dfe788e54f 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { WorkHubActionReceipt } from '@maka/core/workhub-action-result'; import type { AgentRunStore } from '@maka/core/agent-run'; import { agentRunCompositionFromEvents } from '@maka/core/agent-run'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; @@ -171,6 +172,12 @@ export interface RuntimeKernelLike { continuation: RuntimeContinuation, options?: ResumeContinuationOptions, ): AsyncIterable; + runCoordinationOperation( + sessionId: string, + input: UserMessageInput, + options: TurnStartOptions, + execute: () => Promise, + ): AsyncIterable; compactSession(sessionId: string, input?: CompactSessionInput): AsyncIterable; preflightContextCompaction(sessionId: string): Promise; stopSession(sessionId: string, input?: StopSessionInput): Promise; @@ -347,6 +354,7 @@ interface PendingExecutionClaim { rejectSettled(error: unknown): void; phase: 'pending' | 'attached' | 'reserved' | 'released' | 'failed'; run?: AgentRun; + hostOperation?: true; backendPreparation?: PreparedBackendActivation; stopIntent?: SessionStopIntent; finalization?: ExecutionClaimOutcome; @@ -956,6 +964,119 @@ export class RuntimeKernel implements RuntimeKernelLike { ); } + /** Host coordination uses the same Run owner and terminal authority without a provider send. */ + async *runCoordinationOperation( + sessionId: string, + input: UserMessageInput, + options: TurnStartOptions, + execute: () => Promise, + ): AsyncIterable { + const execution = this.takeExecutionClaim(sessionId); + execution.hostOperation = true; + try { + await this.enterExecutionClaim(execution); + const header = await this.deps.store.readHeader(sessionId); + const run = new AgentRun({ + sessionId, + header, + userInput: input, + runId: options.runId, + userMessageId: options.userMessageId, + durability: 'required', + runStore: this.deps.runStore, + runtimeEventStore: this.deps.runtimeEventStore, + newId: this.deps.newId, + now: this.deps.now, + effectiveOrchestration: resolveEffectiveOrchestration('default', undefined), + hooks: { + reserveRun: async (id, nextHeader, activeRun) => { + const active = await this.reserveParentRun(id, nextHeader, activeRun, execution); + this.reserveExecutionClaim(execution, active, activeRun); + return active; + }, + unregisterRun: (active, activeRun) => this.unregisterParentRun(active, activeRun), + updateHeader: (id, patch) => this.updateHeader(id, patch), + updateStatus: (id, status, reason, ts) => this.updateStatus(id, status, reason, ts), + ...this.messageProjectionHook(), + }, + }); + this.attachExecutionClaim(execution, run); + const owners = this.createRunOwnerScope(run, execution); + try { + owners.bindMessage(this.deps.messageAuthority, { + sessionId, + turnId: input.turnId, + runId: run.runId, + }); + // Keep the execution claim attached until finalization. Stop/drain can + // therefore cancel and await this Run without a provider generation. + await run.beginCoordination(); + await options.onRunStarted?.(run.runId, header); + } catch (error) { + await this.finalizeFailedRunStart(owners, run, execution, error); + return; + } + try { + if (run.isStopped()) return; + const executed = await execute(); + const receipt: WorkHubActionReceipt = { + ...executed, + result: + executed.result.disposition === 'clarify' + ? { ...executed.result, coordinationTurnId: input.turnId } + : executed.result, + }; + const receiptEvent: RuntimeEvent = { + id: this.deps.newId(), + sessionId, + turnId: input.turnId, + runId: run.runId, + invocationId: run.runId, + ts: this.deps.now(), + partial: false, + role: 'system', + author: 'host', + modelVisibility: 'hidden', + actions: { coordination: receipt }, + }; + await run.recordRuntimeEvents([receiptEvent], { requireDurableWrite: true }); + if (run.isStopped()) return; + const complete: CompleteEvent = { + type: 'complete', + id: this.deps.newId(), + turnId: input.turnId, + ts: this.deps.now(), + stopReason: 'end_turn', + }; + await run.acceptMappedEvent( + complete, + mapSessionEventToRuntimeEvent( + complete, + this.runtimeEventMapContext({ + sessionId, + invocationId: run.runId, + runId: run.runId, + turnId: input.turnId, + }), + ), + { requireTerminalWrite: true }, + ); + yield complete; + } catch (error) { + await run.recordFailure(error); + throw error; + } finally { + const failures = new FailureCollector(); + await failures.capture(() => owners.finalize()); + await failures.capture(() => owners.releaseMessage()); + failures.throwIfAny(`Coordination cleanup failed for ${run.runId}`); + } + } finally { + this.releaseExecutionClaim(execution); + await this.flushBackendInvalidation(sessionId); + } + } + async *compactSession( sessionId: string, input: CompactSessionInput = {}, @@ -2066,25 +2187,29 @@ export class RuntimeKernel implements RuntimeKernelLike { ); } + private activeRunsFor(sessionId: string): AgentRun[] { + const runs = new Set(); + for (const active of this.backendGenerationsFor(sessionId)) { + for (const run of active.activeRuns.values()) runs.add(run); + } + for (const claim of this.executionClaims.get(sessionId) ?? []) { + if (claim.hostOperation && claim.run) runs.add(claim.run); + } + return [...runs]; + } + hasActiveRuns(sessionId: string): boolean { - return this.backendGenerationsFor(sessionId).some((active) => active.activeRuns.size > 0); + return this.activeRunsFor(sessionId).length > 0; } runningTurnIds(sessionId: string): string[] { - const turnIds: string[] = []; - for (const active of this.backendGenerationsFor(sessionId)) { - for (const run of active.activeRuns.values()) { - if (!turnIds.includes(run.turnId)) turnIds.push(run.turnId); - } - } - return turnIds; + return [...new Set(this.activeRunsFor(sessionId).map((run) => run.turnId))]; } hasActiveRun(sessionId: string, runId: string, turnId?: string): boolean { - return this.backendGenerationsFor(sessionId).some((active) => { - const run = active.activeRuns.get(runId); - return run !== undefined && (turnId === undefined || run.turnId === turnId); - }); + return this.activeRunsFor(sessionId).some( + (run) => run.runId === runId && (turnId === undefined || run.turnId === turnId), + ); } requestRunHandoff( diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 40000d95d0..abfa95e8ed 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -29,6 +29,7 @@ * persistence and same-session serialization semantics. */ +import type { WorkHubActionReceipt } from '@maka/core/workhub-action-result'; import { createHash } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import { setTimeout as delay } from 'node:timers/promises'; @@ -2342,6 +2343,15 @@ export class SessionManager { } } + runCoordinationOperation( + sessionId: string, + input: UserMessageInput, + options: TurnStartOptions, + execute: () => Promise, + ): AsyncIterable { + return this.runtimeKernel.runCoordinationOperation(sessionId, input, options, execute); + } + async *compactSession( sessionId: string, input: CompactSessionInput = {}, @@ -3694,6 +3704,12 @@ export class SessionManager { executionKind: input.execution.kind, goalId: input.execution.goalId, }; + } else if ( + input.execution.kind === 'workhub_coordination' && + input.execution.operation === 'action' + ) { + recoveryReason = 'coordination_action_admission_without_run'; + diagnostic = { executionKind: input.execution.kind, operation: input.execution.operation }; } else if (input.execution.kind === 'legacy_automation') { root = { kind: 'legacy_automation', legacyAutomationId: input.execution.automationId }; recoveryReason = 'legacy_automation_authority_removed'; 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 078183b55f..4f408bc856 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -58,6 +58,71 @@ import { import { SQLITE_AGENT_GRAPH_CONTROL_TABLES } from '../sqlite-session-metadata-schema.js'; describe('SqliteSessionMetadataStore', () => { + test('migrates version 38 and resumes the body-free Coordination index idempotently', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-coordination-index-migration-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path); + setup.close(); + const baseline = new DatabaseSync(path); + baseline.exec( + "DROP TABLE coordination_transcript_index; UPDATE session_metadata_schema SET version = 38 WHERE scope = 'session_metadata'", + ); + baseline.close(); + const migrated = createSqliteSessionMetadataStore(path); + await migrated.appendCoordinationTranscriptIndex([ + { source: 'legacy', sourceSequence: 0 }, + { source: 'runtime', sourceSequence: 8 }, + ]); + migrated.close(); + const reopened = createSqliteSessionMetadataStore(path); + try { + await reopened.appendCoordinationTranscriptIndex([ + { source: 'runtime', sourceSequence: 8 }, + { source: 'legacy', sourceSequence: 1 }, + ]); + assert.deepEqual( + { ...(await reopened.readCoordinationTranscriptIndexState()) }, + { highWater: 2, legacy: 1, runtime: 8 }, + ); + const records = await reopened.readCoordinationTranscriptIndex({ + direction: 'older', + throughSequence: 1, + position: 1, + limit: 64, + }); + assert.deepEqual( + records.map((record) => ({ ...record })), + [ + { sequence: 1, source: 'runtime', sourceSequence: 8 }, + { sequence: 0, source: 'legacy', sourceSequence: 0 }, + ], + ); + await assert.rejects( + () => + reopened.appendCoordinationTranscriptIndex( + Array.from({ length: 65 }, () => ({ source: 'legacy' as const, sourceSequence: 2 })), + ), + /batch exceeds limit/, + ); + assert.equal((await reopened.readCoordinationTranscriptIndexState()).highWater, 2); + } finally { + reopened.close(); + } + const inspect = new DatabaseSync(path); + assert.deepEqual( + inspect + .prepare('PRAGMA table_info(coordination_transcript_index)') + .all() + .map((column) => column.name), + ['sequence', 'source', 'source_sequence'], + ); + inspect.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + for (const version30Shape of ['admissions-only', 'coordination-only', 'complete'] as const) { test(`converges the ${version30Shape} version-30 schema after the merge`, async () => { const root = await mkdtemp(join(tmpdir(), `maka-session-v30-${version30Shape}-`)); diff --git a/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts b/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts index 7eca36f5a6..c818f28e4f 100644 --- a/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts +++ b/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts @@ -25,50 +25,56 @@ import { test } from 'node:test'; import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import { createSqliteAgentRunStore } from '../agent-run-store.js'; -test('WorkHub Coordination admission preserves its bounded content identity across restart', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-workhub-admission-')); - const inputDigest = `sha256:${'a'.repeat(64)}` as const; - try { - const store = createSqliteAgentRunStore(root); - const admitted = await store.admitRootTurn({ - sessionId: 'coordination-session', - turnId: 'coordination-turn', - proposedRunId: 'coordination-run', - proposedUserMessageId: 'coordination-message', - execution: { kind: 'workhub_coordination', inputDigest }, - previousRootTurnId: null, - normalizedInput: { text: 'What should happen next?' }, - sourceMessages: [], - admittedAt: 50, - }); - assert.equal(admitted.kind, 'admitted'); - store.close?.(); +for (const actionId of [undefined, 'stable-action']) { + test(`WorkHub Coordination admission preserves its bounded content identity across restart (${actionId ?? 'legacy'})`, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-admission-')); + const inputDigest = `sha256:${'a'.repeat(64)}` as const; + try { + const store = createSqliteAgentRunStore(root); + const admitted = await store.admitRootTurn({ + sessionId: 'coordination-session', + turnId: 'coordination-turn', + proposedRunId: 'coordination-run', + proposedUserMessageId: 'coordination-message', + execution: { + kind: 'workhub_coordination', + inputDigest, + ...(actionId ? { operation: 'action' as const, actionId } : {}), + }, + previousRootTurnId: null, + normalizedInput: { text: 'What should happen next?' }, + sourceMessages: [], + admittedAt: 50, + }); + assert.equal(admitted.kind, 'admitted'); + store.close?.(); - const reopened = createSqliteAgentRunStore(root); - assert.deepEqual( - await reopened.readRootTurnAdmission('coordination-session', 'coordination-turn'), - admitted.admission, - ); - await assert.rejects( - () => - reopened.admitRootTurn({ - sessionId: 'coordination-session', - turnId: 'invalid-coordination-turn', - proposedRunId: 'invalid-coordination-run', - proposedUserMessageId: 'invalid-coordination-message', - execution: { - kind: 'workhub_coordination', - inputDigest: 'sha256:not-a-digest', - } as RootExecutionDescriptor, - previousRootTurnId: 'coordination-turn', - normalizedInput: { text: 'Invalid identity' }, - sourceMessages: [], - admittedAt: 60, - }), - /Invalid root execution descriptor/u, - ); - reopened.close?.(); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); + const reopened = createSqliteAgentRunStore(root); + assert.deepEqual( + await reopened.readRootTurnAdmission('coordination-session', 'coordination-turn'), + admitted.admission, + ); + await assert.rejects( + () => + reopened.admitRootTurn({ + sessionId: 'coordination-session', + turnId: 'invalid-coordination-turn', + proposedRunId: 'invalid-coordination-run', + proposedUserMessageId: 'invalid-coordination-message', + execution: { + kind: 'workhub_coordination', + inputDigest: 'sha256:not-a-digest', + } as RootExecutionDescriptor, + previousRootTurnId: 'coordination-turn', + normalizedInput: { text: 'Invalid identity' }, + sourceMessages: [], + admittedAt: 60, + }), + /Invalid root execution descriptor/u, + ); + reopened.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 58aa12a584..8497b706b4 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -2029,12 +2029,30 @@ function normalizeRootExecutionDescriptor(value: unknown): RootExecutionDescript }); } if (value.kind === 'workhub_coordination') { - if (!hasExactKeys(value, ['kind', 'inputDigest']) || !isSha256Digest(value.inputDigest)) { + if ( + !hasExactKeys( + value, + value.operation === undefined + ? ['kind', 'inputDigest'] + : [ + 'kind', + 'inputDigest', + 'operation', + ...(value.actionId === undefined ? [] : ['actionId']), + ], + ) || + (value.operation !== undefined && value.operation !== 'action') || + (value.actionId !== undefined && + (typeof value.actionId !== 'string' || !isSafeId(value.actionId))) || + !isSha256Digest(value.inputDigest) + ) { throw new Error('Invalid root execution descriptor'); } return Object.freeze({ kind: 'workhub_coordination', + ...(value.operation === 'action' ? { operation: 'action' as const } : {}), inputDigest: value.inputDigest, + ...(typeof value.actionId === 'string' ? { actionId: value.actionId } : {}), }); } if (value.kind === 'regenerate') { diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 9d5bc3542b..a85b6d23c5 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -125,6 +125,7 @@ export type { SessionHeaderSnapshot, SessionTranscriptMessageLookupRequest, SessionTranscriptPageRequest, + CoordinationTranscriptReference, SessionTranscriptRecordScanPage, SessionTranscriptRecordScanRequest, SessionTranscriptStoragePage, @@ -443,6 +444,12 @@ async function createExecutionStoresForWrite run(() => sessionStore.readMessagesSnapshot(sessionId)), readTranscriptMessagesSnapshot: (sessionId, request) => run(() => sessionStore.readTranscriptMessagesSnapshot(sessionId, request)), + readCoordinationTranscriptIndexState: () => + run(() => sessionStore.readCoordinationTranscriptIndexState()), + appendCoordinationTranscriptIndex: (records) => + run(() => sessionStore.appendCoordinationTranscriptIndex(records)), + readCoordinationTranscriptIndex: (request) => + run(() => sessionStore.readCoordinationTranscriptIndex(request)), readTranscriptHighWaterSnapshot: (sessionId) => run(() => sessionStore.readTranscriptHighWaterSnapshot(sessionId)), listTurnsSnapshot: (sessionId) => run(() => sessionStore.listTurnsSnapshot(sessionId)), diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 04809ad117..45810704c0 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -355,7 +355,31 @@ export interface SessionStore { close?(): Promise; } +/** Rebuildable ordering only; the message body remains in its original store. */ +export interface CoordinationTranscriptReference { + readonly source: 'legacy' | 'runtime'; + readonly sourceSequence: number; +} +export interface CoordinationTranscriptIndexRecord extends CoordinationTranscriptReference { + readonly sequence: number; +} +export interface CoordinationTranscriptIndexState { + readonly highWater: number | null; + readonly legacy: number | null; + readonly runtime: number | null; +} + export interface SessionAuthorityStore extends SessionStore, MessageAdmissionStore { + readCoordinationTranscriptIndexState(): Promise; + appendCoordinationTranscriptIndex( + records: readonly CoordinationTranscriptReference[], + ): Promise; + readCoordinationTranscriptIndex(request: { + direction: 'older' | 'newer'; + throughSequence: number; + position: number; + limit: number; + }): Promise; /** Read a bounded set of durable messages at an inclusive transcript watermark. */ readTranscriptMessagesSnapshot( sessionId: string, @@ -986,6 +1010,28 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.readTranscriptHighWater(sessionId); } + async readCoordinationTranscriptIndexState(): Promise { + await this.ensureReady(); + return this.metadata.readCoordinationTranscriptIndexState(); + } + + async appendCoordinationTranscriptIndex( + records: readonly CoordinationTranscriptReference[], + ): Promise { + await this.ensureReady(); + return this.metadata.appendCoordinationTranscriptIndex(records); + } + + async readCoordinationTranscriptIndex(request: { + direction: 'older' | 'newer'; + throughSequence: number; + position: number; + limit: number; + }): Promise { + await this.ensureReady(); + return this.metadata.readCoordinationTranscriptIndex(request); + } + async listTurnsSnapshot(sessionId: string): Promise { return deriveTurnRecords(await this.readMessagesSnapshot(sessionId)); } diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 525934c7de..3c843f5302 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"}'; @@ -37,6 +37,17 @@ export const SQLITE_AGENT_GRAPH_CONTROL_TABLES = [ ] as const; const MIGRATIONS: ReadonlyMap = new Map([ + [ + 39, + ` + CREATE TABLE IF NOT EXISTS coordination_transcript_index ( + sequence INTEGER PRIMARY KEY, + source TEXT NOT NULL CHECK (source IN ('legacy', 'runtime')), + source_sequence INTEGER NOT NULL CHECK (source_sequence >= 0), + UNIQUE (source, source_sequence) + ); + `, + ], [ 1, ` diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index f40d62b19f..5b31404958 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -142,6 +142,9 @@ import { normalizeSessionHeader, SessionNotFoundError, type ExternalSessionImportLookupResult, + type CoordinationTranscriptReference, + type CoordinationTranscriptIndexRecord, + type CoordinationTranscriptIndexState, type SessionMessageScanPage, type SessionMessageScanRecord, type SessionMessageScanRequest, @@ -2585,6 +2588,60 @@ export class SqliteSessionMetadataStore { }); } + async readCoordinationTranscriptIndexState(): Promise { + this.assertOpen(); + return this.db + .prepare(`SELECT (SELECT MAX(sequence) FROM coordination_transcript_index) AS highWater, + (SELECT MAX(source_sequence) FROM coordination_transcript_index WHERE source = 'legacy') AS legacy, + (SELECT MAX(source_sequence) FROM coordination_transcript_index WHERE source = 'runtime') AS runtime`) + .get() as unknown as CoordinationTranscriptIndexState; + } + + async appendCoordinationTranscriptIndex( + records: readonly CoordinationTranscriptReference[], + ): Promise { + this.assertOpen(); + if (records.length > 64) throw new Error('Coordination transcript index batch exceeds limit'); + this.transaction(() => { + let sequence = + ( + this.db + .prepare('SELECT MAX(sequence) AS value FROM coordination_transcript_index') + .get() as { value: number | null } + ).value ?? -1; + const insert = this.db.prepare(`INSERT INTO coordination_transcript_index + (sequence, source, source_sequence) VALUES (?, ?, ?) + ON CONFLICT(source, source_sequence) DO NOTHING`); + for (const record of records) { + if (!Number.isSafeInteger(record.sourceSequence) || record.sourceSequence < 0) + throw new Error('Invalid Coordination source sequence'); + const result = insert.run(sequence + 1, record.source, record.sourceSequence); + if (result.changes) sequence++; + } + }); + } + + async readCoordinationTranscriptIndex(request: { + direction: 'older' | 'newer'; + throughSequence: number; + position: number; + limit: number; + }): Promise { + this.assertOpen(); + if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 64) + throw new Error('Invalid Coordination transcript index limit'); + const older = request.direction === 'older'; + return this.db + .prepare(`SELECT sequence, source, source_sequence AS sourceSequence + FROM coordination_transcript_index WHERE sequence <= ? AND sequence ${older ? '<=' : '>='} ? + ORDER BY sequence ${older ? 'DESC' : 'ASC'} LIMIT ?`) + .all( + request.throughSequence, + request.position, + request.limit, + ) as unknown as CoordinationTranscriptIndexRecord[]; + } + async readMessages(sessionId: string): Promise { return this.readMessagesWith(sessionId, decodeStoredMessage); }