From b65c5c2313420578bd01df40122e0309639efa0c Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 25 Aug 2026 22:45:09 +0800 Subject: [PATCH 1/7] feat(workhub): add typed action gate Generated-by: Codex --- .../runtime-host-client-operations.test.ts | 23 + .../runtime-host-workhub-ipc-main.test.ts | 51 ++ .../main/__tests__/workhub-controller.test.ts | 176 ++++++- .../__tests__/workhub-session-port.test.ts | 8 + .../__tests__/workhub-surface-flow.test.ts | 2 +- apps/desktop/src/main/runtime-host-client.ts | 10 + .../main/runtime-host-desktop-candidate.ts | 5 +- .../src/main/runtime-host-workhub-ipc-main.ts | 46 ++ apps/desktop/src/preload/bridge-contract.d.ts | 9 + apps/desktop/src/preload/preload.ts | 41 ++ apps/desktop/src/renderer/app-shell.tsx | 4 + .../src/renderer/workhub-controller.ts | 150 +++++- .../src/renderer/workhub-coordination-port.ts | 9 + apps/desktop/src/renderer/workhub-surface.tsx | 1 + .../workhub-coordination-action-gate.test.ts | 356 ++++++++++++++ .../workhub-coordination-coordinator.test.ts | 5 + .../workhub-coordination-protocol.test.ts | 150 ++++++ packages/runtime-host/src/protocol/index.ts | 4 +- .../runtime-host/src/protocol/operations.ts | 2 + .../src/protocol/workhub-coordination.ts | 316 ++++++++++++- .../src/server/execution-composition.ts | 60 +++ .../src/server/session-catalog-coordinator.ts | 5 + .../workhub-coordination-action-gate.ts | 443 ++++++++++++++++++ .../workhub-coordination-coordinator.ts | 83 ++++ 24 files changed, 1938 insertions(+), 21 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts create mode 100644 packages/runtime-host/src/server/workhub-coordination-action-gate.ts 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 40dfc58ecc..6be9f11be2 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 @@ -98,6 +98,8 @@ test('restarts a paginated catalog read instead of mixing revisions', async () = test('resolves WorkHub coordination through the dedicated Host operation', async () => { const { client, requests } = clientWithResponses([ { sessionId: 'maka_workhub_coordination' }, + { candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }, + { disposition: 'answer_here', coordinationTurnId: 'action-turn' }, { turnId: 'answer-turn' }, { turnId: 'summary-turn' }, ]); @@ -105,6 +107,18 @@ test('resolves WorkHub coordination through the dedicated Host operation', async assert.deepEqual(await client.resolveWorkHubCoordinationSession(), { sessionId: 'maka_workhub_coordination', }); + assert.deepEqual(await client.listWorkHubCoordinationCandidates(), { + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [], + }); + assert.deepEqual( + await client.actWorkHubCoordination({ + actionId: 'action', + userText: 'Question', + proposal: { disposition: 'answer_here' }, + }), + { disposition: 'answer_here', coordinationTurnId: 'action-turn' }, + ); assert.deepEqual( await client.answerWorkHubCoordination({ turnId: 'answer-turn', text: 'Question' }), { turnId: 'answer-turn' }, @@ -119,6 +133,15 @@ test('resolves WorkHub coordination through the dedicated Host operation', async ); assert.deepEqual(requests, [ { operation: 'workhub.coordination.resolve', input: {} }, + { operation: 'workhub.coordination.candidates', input: {} }, + { + operation: 'workhub.coordination.act', + input: { + actionId: 'action', + userText: 'Question', + proposal: { disposition: 'answer_here' }, + }, + }, { operation: 'workhub.coordination.answer', input: { turnId: 'answer-turn', text: 'Question' }, 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 0a47cb7915..95e6f40d21 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 @@ -26,6 +26,9 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' let resolveCalls = 0; const answers: unknown[] = []; const records: unknown[] = []; + const actions: unknown[] = []; + const changes: unknown[] = []; + const createdSessionId = 'runtime-created-session'; registerRuntimeHostWorkHubIpc( { resolveWorkHubCoordinationSession: async () => { @@ -44,12 +47,31 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' records.push(input); return { turnId: input.turnId }; }, + listWorkHubCoordinationCandidates: async () => ({ + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [], + }), + actWorkHubCoordination: async (input: unknown) => { + actions.push(input); + return { + disposition: 'create_new', + targetSessionId: createdSessionId, + targetTurnId: 'created-turn', + }; + }, } as never, { handle: (channel: string, handler: (...args: unknown[]) => unknown) => { handlers.set(channel, handler); }, } as never, + { + resolveCreateProject: async () => ({ + kind: 'host_path', + path: '/tmp/workhub-project', + }), + emitSessionsChanged: (reason, sessionId) => changes.push({ reason, sessionId }), + }, ); const handler = handlers.get('workhub:resolveCoordinationSession'); @@ -74,4 +96,33 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' userText: 'Request', assistantText: 'Summary', }]); + assert.deepEqual(await handlers.get('workhub:candidates')?.({}), { + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [], + }); + assert.deepEqual( + await handlers.get('workhub:act')?.({}, { + actionId: 'create-action', + userText: 'Start accessibility review', + proposal: { disposition: 'create_new', title: 'Accessibility review' }, + create: { + sessionId: 'renderer-invented', + workspace: { kind: 'host_path', path: '/renderer-path' }, + }, + }), + { + disposition: 'create_new', + targetSessionId: createdSessionId, + targetTurnId: 'created-turn', + }, + ); + assert.deepEqual(actions, [{ + actionId: 'create-action', + userText: 'Start accessibility review', + proposal: { disposition: 'create_new', title: 'Accessibility review' }, + create: { + workspace: { kind: 'host_path', path: '/tmp/workhub-project' }, + }, + }]); + assert.deepEqual(changes, [{ reason: 'created', sessionId: createdSessionId }]); }); diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index a2921ccee5..0b805bfa17 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -21,7 +21,8 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; import { - createWorkHubController, + createLegacyWorkHubControllerForTests as createWorkHubController, + createWorkHubController as createGatedWorkHubController, WorkHubSessionSubmitError, WORKHUB_ROUTING_STRATEGY_ID, type WorkHubSessionFacts, @@ -2362,21 +2363,29 @@ test('submit lets strong foreign core evidence override a vague focus word', asy test('submit keeps unmatched non-executable conversation in WorkHub', async () => { let created = false; - const answered: Array<{ turnId: string; text: string }> = []; + const actions: unknown[] = []; const sessions = port([]); sessions.create = async () => { created = true; return session('unexpected'); }; - const controller = createWorkHubController({ + const controller = createGatedWorkHubController({ sessions, coordination: { open: async () => ({ close: async () => undefined }), - answer: async (input) => { - answered.push(input); - return { turnId: input.turnId }; - }, + answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'answer_here', + coordinationTurnId: 'coordination-turn', + }; + }, }, }); @@ -2392,11 +2401,160 @@ test('submit keeps unmatched non-executable conversation in WorkHub', async () = text: '你觉得统一入口最重要的价值是什么?', }); assert.equal(created, false); - assert.deepEqual(answered, [ - { turnId: 'request-discussion', text: '你觉得统一入口最重要的价值是什么?' }, + assert.deepEqual(actions, [ + { + actionId: 'request-discussion', + userText: '你觉得统一入口最重要的价值是什么?', + proposal: { disposition: 'answer_here' }, + }, ]); }); +test('production submission delegates only through the Runtime-owned candidate reference', async () => { + const actions: unknown[] = []; + const sessions = port([session('payment')]); + sessions.submit = async () => { + throw new Error('renderer direct submit must not be used'); + }; + sessions.stop = async () => { + throw new Error('renderer direct stop must not be used'); + }; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + answer: async (input) => ({ turnId: input.turnId }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'b'.repeat(64)}`, + candidates: [{ + candidateRef: 'candidate-payment', + sessionId: 'payment', + sessionName: 'payment', + workspace: { + target: { kind: 'host_path', path: '/workspace/payment' }, + hostCwd: '/workspace/payment', + }, + state: 'active', + updatedAt: 1, + }], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'delegate_existing', + targetSessionId: 'payment', + targetTurnId: 'target-turn', + }; + }, + }, + }); + + const result = await controller.submit({ + requestId: 'delegate-action', + text: '继续支付工作', + explicitTarget: { sessionId: 'payment' }, + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.turnId : undefined, 'target-turn'); + assert.deepEqual(actions, [{ + actionId: 'delegate-action', + userText: '继续支付工作', + candidateSetId: `sha256:${'b'.repeat(64)}`, + proposal: { + disposition: 'delegate_existing', + candidateRef: 'candidate-payment', + }, + }]); +}); + +test('production clarification is persisted through the typed Action Gate disposition', async () => { + const actions: unknown[] = []; + const controller = createGatedWorkHubController({ + sessions: port([]), + coordination: { + open: async () => ({ close: async () => undefined }), + answer: async (input) => ({ turnId: input.turnId }), + record: async () => { + throw new Error('legacy summary recording must not persist clarification'); + }, + candidates: async () => ({ + candidateSetId: `sha256:${'c'.repeat(64)}`, + candidates: [], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'clarify', + coordinationTurnId: 'clarification-turn', + }; + }, + }, + }); + + assert.deepEqual(await controller.recordConversationTurn({ + turnId: 'clarification-action', + userText: '继续稳定性问题', + assistantText: '请选择目标 Session', + disposition: 'clarify', + }), { turnId: 'clarification-turn' }); + assert.deepEqual(actions, [{ + actionId: 'clarification-action', + userText: '继续稳定性问题', + proposal: { + disposition: 'clarify', + assistantText: '请选择目标 Session', + }, + }]); +}); + +test('production creation leaves Session identity and workspace authority to main and Runtime', async () => { + const actions: unknown[] = []; + const sessions = port([]); + sessions.create = async () => { + throw new Error('renderer direct create must not be used'); + }; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + answer: async (input) => ({ turnId: input.turnId }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'c'.repeat(64)}`, + candidates: [], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'create_new', + targetSessionId: 'runtime-created', + targetTurnId: 'runtime-turn', + }; + }, + }, + }); + + const result = await controller.submit({ + requestId: 'create-action', + text: '请创建新任务,检查支付回调重复投递。', + }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { + sessionId: 'runtime-created', + }); + assert.deepEqual(actions, [{ + actionId: 'create-action', + userText: '请创建新任务,检查支付回调重复投递。', + proposal: { + disposition: 'create_new', + title: '检查支付回调重复投递', + }, + }]); +}); + test('submit treats a design question containing an action word as discussion', async () => { let created = false; const sessions = port([]); 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 662bcf4f98..af3d8d48d7 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -163,6 +163,14 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and }, answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [], + }), + act: async () => ({ + disposition: 'answer_here', + coordinationTurnId: 'coordination-turn', + }), }); const handle = await adapter.open((turns) => snapshots.push(turns), () => {}); 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 0b7812c0ad..eee76db894 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -32,7 +32,7 @@ import { workHubSubmissionClearsDraft, } from '../../renderer/workhub-surface.js'; import { - createWorkHubController, + createLegacyWorkHubControllerForTests as createWorkHubController, WORKHUB_ROUTING_STRATEGY_ID, type WorkHubController, type WorkHubSubmitInput, diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ff9e34e11b..c1b539c1a3 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -834,6 +834,16 @@ export class DesktopRuntimeHostClient { return this.request("workhub.coordination.resolve", {}); } + listWorkHubCoordinationCandidates() { + return this.request("workhub.coordination.candidates", {}); + } + + actWorkHubCoordination( + input: OperationInput<"workhub.coordination.act">, + ): Promise> { + return this.request("workhub.coordination.act", input); + } + answerWorkHubCoordination( input: OperationInput<"workhub.coordination.answer">, ): Promise> { diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index db8bab7e58..b455deca90 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -679,7 +679,10 @@ export async function createDesktopRuntimeHostCandidate( }, ipc, ); - registerRuntimeHostWorkHubIpc(client, ipc); + registerRuntimeHostWorkHubIpc(client, ipc, { + resolveCreateProject: () => deps.resolveSessionCreateProject({}, target), + emitSessionsChanged, + }); registerRuntimeHostExternalSessionsIpc( { client, 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 d0ad7e94e1..621bc5a570 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -17,20 +17,35 @@ * under the License. */ +import type { + WorkHubCoordinationActInput, + WorkHubCoordinationActResult, + WorkspaceTarget, +} from '@maka/runtime-host/protocol'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import type { ReconnectableReadIpcMain } from './ipc-reconnect-policy.js'; type RuntimeHostWorkHubClient = Pick< DesktopRuntimeHostClient, + | 'actWorkHubCoordination' | 'answerWorkHubCoordination' + | 'listWorkHubCoordinationCandidates' | 'recordWorkHubCoordination' | 'resolveWorkHubCoordinationSession' >; +type RendererWorkHubActionInput = Omit; + +export interface RuntimeHostWorkHubIpcOptions { + resolveCreateProject(): Promise; + emitSessionsChanged(reason: 'created' | 'status-change', sessionId: string): void; +} + /** Projects the Runtime Host WorkHub domain onto renderer IPC. */ export function registerRuntimeHostWorkHubIpc( client: RuntimeHostWorkHubClient, ipcMain: Pick, + options: RuntimeHostWorkHubIpcOptions, ): void { ipcMain.handle('workhub:resolveCoordinationSession', () => client.resolveWorkHubCoordinationSession(), @@ -41,4 +56,35 @@ export function registerRuntimeHostWorkHubIpc( ipcMain.handle('workhub:record', (_event, input) => client.recordWorkHubCoordination(input), ); + ipcMain.handle('workhub:candidates', () => client.listWorkHubCoordinationCandidates()); + ipcMain.handle('workhub:act', async (_event, rawInput: RendererWorkHubActionInput) => { + const proposal = rawInput?.proposal; + const base = { + actionId: rawInput?.actionId, + userText: rawInput?.userText, + proposal, + } as Pick; + let result: WorkHubCoordinationActResult; + if (proposal?.disposition === 'create_new') { + result = await client.actWorkHubCoordination({ + ...base, + create: { + workspace: await options.resolveCreateProject(), + }, + }); + } else { + result = await client.actWorkHubCoordination({ + ...base, + ...(rawInput?.candidateSetId === undefined + ? {} + : { candidateSetId: rawInput.candidateSetId }), + }); + } + if (result.disposition === 'create_new') { + options.emitSessionsChanged('created', result.targetSessionId); + } else if (result.disposition === 'delegate_existing') { + options.emitSessionsChanged('status-change', result.targetSessionId); + } + return result; + }); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 81e5862974..a15f6575af 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -770,6 +770,15 @@ export interface MakaBridge { 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, + ): Promise>; + /** Submit a typed proposal; trusted creation context is added outside the renderer. */ + act( + coordinationSessionId: string, + input: Omit, 'create'>, + ): Promise>; /** Create an ordinary Session on the exact Host owning the resolved conversation. */ createSession( coordinationSessionId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d6d68036f1..9e7a8563c5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1556,6 +1556,47 @@ const makaBridge = { ); return ipcRenderer.invoke('workhub:record', scope, input) as Promise<{ turnId: string }>; }, + async candidates( + coordinationSessionId: string, + ): Promise> { + const scope = await resolveDesktopWorkHubCoordinationCreateScope( + coordinationSessionId, + runtimeHostSessionRef, + ); + const result = await ipcRenderer.invoke( + 'workhub:candidates', + scope, + ) as OperationOutput<'workhub.coordination.candidates'>; + return { + ...result, + candidates: result.candidates.map((candidate) => ({ + ...candidate, + sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: candidate.sessionId }), + })), + }; + }, + async act( + coordinationSessionId: string, + input: Omit, 'create'>, + ): Promise> { + const scope = await resolveDesktopWorkHubCoordinationCreateScope( + coordinationSessionId, + runtimeHostSessionRef, + ); + const result = await ipcRenderer.invoke( + 'workhub:act', + scope, + input, + ) as OperationOutput<'workhub.coordination.act'>; + if (result.disposition === 'answer_here' || result.disposition === 'clarify') return result; + return { + ...result, + targetSessionId: desktopSessionKey({ + hostId: scope.hostId, + sessionId: result.targetSessionId, + }), + }; + }, async createSession( coordinationSessionId: string, input: { name: string }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 013ed52498..3d97a37bae 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1532,6 +1532,10 @@ function AppShellContent({ window.maka.workHub.answer(workHubCoordinationSessionId!, input), record: (input) => window.maka.workHub.record(workHubCoordinationSessionId!, input), + candidates: () => + window.maka.workHub.candidates(workHubCoordinationSessionId!), + act: (input) => + window.maka.workHub.act(workHubCoordinationSessionId!, input), }), sessions: createDesktopWorkHubSessionPort({ sessions: scopeWorkHubSessionsToCoordinationHost( diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index cf2b288a3c..a95ee4e52f 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -28,6 +28,11 @@ import { type WorkHubRouteEvidence, workHubNewSessionName, } from './workhub-route-policy.js'; +import type { + WorkHubCoordinationActInput, + WorkHubCoordinationActResult, + WorkHubCoordinationCandidatesResult, +} from '@maka/runtime-host/protocol'; export interface WorkHubSessionTarget { sessionId: string; @@ -197,6 +202,8 @@ export interface WorkHubCoordinationPort { userText: string; assistantText: string; }): Promise<{ turnId: string }>; + candidates(): Promise; + act(input: Omit): Promise; } export class WorkHubSessionSubmitError extends Error { @@ -221,6 +228,7 @@ export interface WorkHubController { turnId: string; userText: string; assistantText: string; + disposition?: 'clarify' | 'summary'; }): Promise<{ turnId: string }>; subscribe(handler: () => void): () => void; resetVisitContext(): void; @@ -243,6 +251,20 @@ interface WorkHubOwnershipTombstone { } export function createWorkHubController(deps: { + sessions: WorkHubSessionPort; + coordination: WorkHubCoordinationPort; +}): WorkHubController { + return createWorkHubControllerImplementation(deps); +} + +/** @internal Transitional R2.4 regression harness; application code must use the Action Gate. */ +export function createLegacyWorkHubControllerForTests(deps: { + sessions: WorkHubSessionPort; +}): WorkHubController { + return createWorkHubControllerImplementation(deps); +} + +function createWorkHubControllerImplementation(deps: { sessions: WorkHubSessionPort; coordination?: WorkHubCoordinationPort; }): WorkHubController { @@ -580,8 +602,26 @@ export function createWorkHubController(deps: { openConversation(handler, onError) { return coordination.open(handler, onError); }, - recordConversationTurn(input) { - return coordination.record(input); + async recordConversationTurn(input) { + if (deps.coordination && 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, + userText: input.userText, + assistantText: input.assistantText, + }); }, subscribe(handler) { return deps.sessions.subscribe(handler); @@ -644,9 +684,20 @@ export function createWorkHubController(deps: { const sessions = catalog.sessions; reconcileFocus(submissionPolicy, sessions); const ordinary = sessions.filter((session) => session.kind === 'ordinary'); + const candidateSet = deps.coordination + ? await coordination.candidates() + : undefined; + const candidateBySessionId = new Map( + candidateSet?.candidates.map((candidate) => [candidate.sessionId, candidate]), + ); // Archived Sessions remain visible as historical work, but Runtime Host - // rejects new root Turns for them. Never offer one as a routing target. - const routable = ordinary.filter((session) => !session.archived); + // rejects new root Turns for them. In production the Runtime-owned + // candidate set is the only target namespace the strategy can see. + const routable = ordinary.filter( + (session) => + !session.archived && + (!candidateSet || candidateBySessionId.has(session.target.sessionId)), + ); const routingEvidence = input.explicitTarget ? [] : await deps.sessions.routingEvidence(routable.map((session) => session.target)); @@ -676,10 +727,18 @@ export function createWorkHubController(deps: { }; } if (decision.kind === 'discussion') { - await coordination.answer({ - turnId: input.requestId, - text: input.text, - }); + if (candidateSet) { + await coordination.act({ + actionId: input.requestId, + userText: input.text, + proposal: { disposition: 'answer_here' }, + }); + } else { + await coordination.answer({ + turnId: input.requestId, + text: input.text, + }); + } return { kind: 'discussion', strategyId: WORKHUB_ROUTING_STRATEGY_ID, @@ -692,6 +751,30 @@ export function createWorkHubController(deps: { const correction = input.correction ?? (decision.kind === 'target' && decision.correctedFrom ? correctionFor(decision.correctedFrom) : undefined); + if (candidateSet && decision.kind === 'new_session') { + const admitted = await coordination.act({ + actionId: input.requestId, + userText: input.text, + proposal: { + disposition: 'create_new', + title: workHubNewSessionName(input.text), + }, + }); + if (admitted.disposition !== 'create_new') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } + target = { sessionId: admitted.targetSessionId }; + submissionPolicy.rememberTarget(target); + return { + kind: 'submitted', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + turnId: admitted.targetTurnId, + ...(admitted.steered ? { steered: true as const } : {}), + evidence: 'new_session', + }; + } if (decision.kind === 'new_session') { const created = await deps.sessions.create({ name: workHubNewSessionName(input.text) }); if (created.kind !== 'ordinary') { @@ -718,6 +801,51 @@ export function createWorkHubController(deps: { target, }; } + if (candidateSet) { + const candidate = candidateBySessionId.get(target.sessionId); + if (!candidate) { + throw new Error('WorkHub target Session is unavailable'); + } + const replacedCandidate = correction + ? candidateBySessionId.get(correction.from.sessionId) + : undefined; + const action: WorkHubCoordinationActInput = { + actionId: input.requestId, + userText: input.text, + candidateSetId: candidateSet.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidate.candidateRef, + ...(correction?.turnId && replacedCandidate + ? { + replace: { + candidateRef: replacedCandidate.candidateRef, + expectedTurnId: correction.turnId, + }, + } + : {}), + }, + }; + const admitted = await coordination.act(action); + if (admitted.disposition !== 'delegate_existing') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } + target = { sessionId: admitted.targetSessionId }; + submissionPolicy.rememberTarget(target); + if (correction) { + submissionPolicy.rememberCorrection(input.text, target, submissionOrder); + } + return { + kind: 'submitted', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + turnId: admitted.targetTurnId, + ...(admitted.steered ? { steered: true as const } : {}), + evidence, + ...(correction ? { correctedFrom: correction.from } : {}), + }; + } if (correction) { await stopOwnedRoots(correction, submissionOrder); } @@ -780,5 +908,11 @@ function legacyTestCoordinationPort(): WorkHubCoordinationPort { async record(input) { return { turnId: input.turnId }; }, + async candidates() { + throw new Error('The legacy WorkHub test adapter does not expose Action Gate candidates'); + }, + async act() { + throw new Error('The legacy WorkHub test adapter does not expose Action Gate actions'); + }, }; } diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 38bcbf1bbc..e2581cd67f 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -29,6 +29,11 @@ import type { WorkHubCoordinationTurn, WorkHubProjectedTurnState, } from './workhub-controller.js'; +import type { + WorkHubCoordinationActInput, + WorkHubCoordinationActResult, + WorkHubCoordinationCandidatesResult, +} from '@maka/runtime-host/protocol'; import { boundedWorkHubTimelineText } from './workhub-controller.js'; import type { WorkHubDesktopTranscriptBridge } from './workhub-session-port.js'; @@ -43,10 +48,14 @@ export function createDesktopWorkHubCoordinationPort(deps: { userText: string; assistantText: string; }): Promise<{ turnId: string }>; + candidates(): Promise; + act(input: Omit): Promise; }): WorkHubCoordinationPort { return { answer: deps.answer, record: deps.record, + candidates: deps.candidates, + act: deps.act, async open(handler, onError) { const store = new DesktopTranscriptRangeStore(deps.sessionId); let disposed = false; diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index c03b15ba53..61563749f8 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -213,6 +213,7 @@ export function WorkHubSurface(props: { turnId: input.requestId, userText: recordedUserText, assistantText: workHubCoordinationSummary(result, projection, copy), + disposition: result.kind === 'clarification' ? 'clarify' : 'summary', }); } catch { // The ordinary Session admission has already settled. A failed diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts new file mode 100644 index 0000000000..20f27825b3 --- /dev/null +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -0,0 +1,356 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + WorkHubActionGateFailure, + WorkHubCoordinationActionGate, + type WorkHubActionGateEffects, + type WorkHubActionGateSession, +} from '../server/workhub-coordination-action-gate.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; + +const CONTEXT: ConnectionContext = { + hostEpoch: 'workhub-action-gate-test', + connectionId: 'workhub-action-gate-client', + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), +}; + +describe('WorkHub Coordination Action Gate', () => { + test('exposes only bounded ordinary candidates and proposals use opaque refs', async () => { + const effects = fakeEffects([ + session('ordinary'), + session('archived', { isArchived: true }), + session('waiting', { status: 'waiting_for_user' }), + session('side', { labels: ['mode:side_conversation'] }), + session('child', { + subagentParent: { + kind: 'subagent', + parentSessionId: 'ordinary', + spawnedBy: { parentTurnId: 'turn', parentRunId: 'run', toolCallId: 'tool' }, + lifecycle: 'foreground', + }, + }), + session('maka_workhub_coordination', { role: 'workhub_coordination' }), + ]); + const result = await new WorkHubCoordinationActionGate(effects).candidates(); + + assert.deepEqual( + result.candidates.map(({ sessionId }) => sessionId), + ['ordinary', 'waiting'], + ); + assert.match(result.candidateSetId, /^sha256:[a-f0-9]{64}$/u); + assert.notEqual(result.candidates[0]?.candidateRef, 'ordinary'); + + const bounded = await new WorkHubCoordinationActionGate( + fakeEffects(Array.from({ length: 40 }, (_, index) => session(`ordinary-${index}`))), + ).candidates(); + assert.equal(bounded.candidates.length, 32); + }); + + test('rejects stale and invented candidates before any Session effect', async () => { + const effects = fakeEffects([session('payments')]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + effects.sessions[0] = session('payments', { statusUpdatedAt: 9 }); + + await assert.rejects( + gate.act( + { + actionId: 'stale-action', + userText: 'Continue payments', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'candidate_set_stale', + ); + assert.deepEqual(effects.submissions, []); + + const refreshed = await gate.candidates(); + const retried = await gate.act( + { + actionId: 'stale-action', + userText: 'Continue payments', + candidateSetId: refreshed.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: refreshed.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ); + assert.equal(retried.disposition, 'delegate_existing'); + effects.submissions.length = 0; + + const current = await gate.candidates(); + await assert.rejects( + gate.act( + { + actionId: 'invented-action', + userText: 'Continue payments', + candidateSetId: current.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: 'invented_candidate' }, + }, + CONTEXT, + ), + (error) => + error instanceof WorkHubActionGateFailure && error.code === 'candidate_unavailable', + ); + assert.deepEqual(effects.submissions, []); + }); + + test('rejects waiting targets independently of strategy behavior', async () => { + const effects = fakeEffects([session('waiting', { status: 'waiting_for_user' })]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + + await assert.rejects( + gate.act( + { + actionId: 'waiting-action', + userText: 'Do another thing', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ), + (error) => + error instanceof WorkHubActionGateFailure && error.code === 'target_waiting_for_user', + ); + assert.deepEqual(effects.submissions, []); + }); + + test('answers and clarifies only through the Coordination transcript effects', async () => { + const effects = fakeEffects([session('ordinary')]); + const gate = new WorkHubCoordinationActionGate(effects); + + const answered = await gate.act( + { + actionId: 'answer-action', + userText: 'What is useMemo?', + proposal: { disposition: 'answer_here' }, + }, + CONTEXT, + ); + const clarified = await gate.act( + { + actionId: 'clarify-action', + userText: 'Continue that task', + proposal: { disposition: 'clarify', assistantText: 'Which task do you mean?' }, + }, + CONTEXT, + ); + + assert.equal(answered.disposition, 'answer_here'); + assert.equal(clarified.disposition, 'clarify'); + assert.equal(effects.answers.length, 1); + assert.equal(effects.clarifications.length, 1); + assert.deepEqual(effects.submissions, []); + assert.deepEqual(effects.creations, []); + }); + + test('only create_new creates and retries the exact action idempotently', async () => { + const effects = fakeEffects([session('ordinary')]); + const gate = new WorkHubCoordinationActionGate(effects); + const input = { + actionId: 'create-action', + userText: 'Create an accessibility audit', + proposal: { disposition: 'create_new' as const, title: 'Accessibility audit' }, + create: { + workspace: { kind: 'host_path' as const, path: '/workspace' }, + }, + }; + + const first = await gate.act(input, CONTEXT); + const replay = await gate.act(input, CONTEXT); + + assert.deepEqual(replay, first); + assert.equal(effects.creations.length, 1); + assert.equal(effects.submissions.length, 1); + assert.match(effects.creations[0]?.sessionId ?? '', /^whs_[a-f0-9]{48}$/u); + assert.equal(first.disposition, 'create_new'); + if (first.disposition === 'create_new') { + assert.equal(first.targetSessionId, effects.creations[0]?.sessionId); + } + assert.deepEqual(Object.keys(effects.creations[0]!).sort(), [ + 'sessionId', + 'title', + 'workspace', + ]); + await assert.rejects( + gate.act({ ...input, proposal: { disposition: 'create_new', title: 'Different' } }, CONTEXT), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.creations.length, 1); + }); + + test('stops only an exact root previously admitted by this gate', async () => { + const effects = fakeEffects([session('source'), session('target', { statusUpdatedAt: 2 })]); + const gate = new WorkHubCoordinationActionGate(effects); + const firstSet = await gate.candidates(); + const source = firstSet.candidates.find(({ sessionId }) => sessionId === 'source')!; + const target = firstSet.candidates.find(({ sessionId }) => sessionId === 'target')!; + const first = await gate.act( + { + actionId: 'source-action', + userText: 'Start source work', + candidateSetId: firstSet.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, + }, + CONTEXT, + ); + assert.equal(first.disposition, 'delegate_existing'); + if (first.disposition !== 'delegate_existing') return; + + await assert.rejects( + gate.act( + { + actionId: 'bad-correction', + userText: 'No, use target', + candidateSetId: firstSet.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: target.candidateRef, + replace: { candidateRef: source.candidateRef, expectedTurnId: 'not-owned' }, + }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'stop_not_owned', + ); + assert.deepEqual(effects.stops, []); + + await assert.rejects( + gate.act( + { + actionId: 'unconfirmed-correction', + userText: 'Continue the target work', + candidateSetId: firstSet.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: target.candidateRef, + replace: { candidateRef: source.candidateRef, expectedTurnId: first.targetTurnId }, + }, + }, + CONTEXT, + ), + (error) => + error instanceof WorkHubActionGateFailure && error.code === 'confirmation_required', + ); + assert.deepEqual(effects.stops, []); + assert.equal(effects.submissions.length, 1); + + await gate.act( + { + actionId: 'good-correction', + userText: 'No, use target', + candidateSetId: firstSet.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: target.candidateRef, + replace: { candidateRef: source.candidateRef, expectedTurnId: first.targetTurnId }, + }, + }, + CONTEXT, + ); + assert.deepEqual(effects.stops, [{ sessionId: 'source', turnId: first.targetTurnId }]); + assert.equal(effects.submissions.at(-1)?.sessionId, 'target'); + }); +}); + +function session( + id: string, + patch: Partial = {}, +): WorkHubActionGateSession { + return { + id, + cwd: `/workspace/${id}`, + projectId: null, + createdAt: 1, + name: id, + labels: [], + isArchived: false, + status: 'active', + ...patch, + }; +} + +function fakeEffects(initialSessions: WorkHubActionGateSession[]) { + const state = { + sessions: [...initialSessions], + answers: [] as Array<{ turnId: string; text: string }>, + clarifications: [] as Array<{ + turnId: string; + userText: string; + assistantText: string; + }>, + creations: [] as Array<{ + sessionId: string; + workspace: { kind: 'project'; projectId: string } | { kind: 'host_path'; path: string }; + title: string; + }>, + submissions: [] as Array<{ sessionId: string; messageId: string; text: string }>, + stops: [] as Array<{ sessionId: string; turnId: string }>, + async listSessions() { + return this.sessions; + }, + async answer(input: { turnId: string; text: string }) { + this.answers.push(input); + }, + async clarify(input: { turnId: string; userText: string; assistantText: string }) { + this.clarifications.push(input); + }, + async create(input: { + sessionId: string; + workspace: { kind: 'project'; projectId: string } | { kind: 'host_path'; path: string }; + title: string; + }) { + this.creations.push(input); + }, + async submit(input: { sessionId: string; messageId: string; text: string }) { + this.submissions.push(input); + return { turnId: `turn-${input.sessionId}` }; + }, + async stop(input: { sessionId: string; turnId: string }) { + this.stops.push(input); + }, + } satisfies WorkHubActionGateEffects & { + sessions: WorkHubActionGateSession[]; + answers: Array<{ turnId: string; text: string }>; + clarifications: Array<{ turnId: string; userText: string; assistantText: string }>; + creations: Array<{ + sessionId: string; + workspace: { kind: 'project'; projectId: string } | { kind: 'host_path'; path: string }; + title: string; + }>; + submissions: Array<{ sessionId: string; messageId: string; text: string }>; + stops: Array<{ sessionId: string; turnId: string }>; + }; + return state; +} 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 18bdd5a793..c3ce5667f6 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -601,6 +601,11 @@ function coordinator( admission, continuity: { refreshCanonical: async () => undefined }, executions, + sessionActions: { + create: async () => undefined, + submit: async ({ sessionId }) => ({ turnId: `turn-${sessionId}` }), + stop: async () => undefined, + }, resolveCreateTarget: resolveCreateTarget ?? (async () => ({ 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 db90fb8d0c..1ea8e58e65 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -21,7 +21,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { RuntimeHostProtocolError } from '../protocol/errors.js'; import { + decodeWorkHubCoordinationActInput, + decodeWorkHubCoordinationActResult, decodeWorkHubCoordinationAnswerInput, + decodeWorkHubCoordinationCandidatesResult, decodeWorkHubCoordinationRecordInput, decodeWorkHubCoordinationResolveInput, decodeWorkHubCoordinationResolveResult, @@ -82,3 +85,150 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () (error) => error instanceof RuntimeHostProtocolError, ); }); + +test('WorkHub Coordination candidates are bounded and carry opaque proposal identities', () => { + const result = decodeWorkHubCoordinationCandidatesResult({ + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [ + { + candidateRef: 'candidate_a', + sessionId: 'session-a', + sessionName: 'Payments', + workspace: { + target: { kind: 'host_path', path: '/workspace/payments' }, + hostCwd: '/workspace/payments', + }, + state: 'active', + updatedAt: 7, + }, + ], + }); + assert.equal(result.candidates[0]?.candidateRef, 'candidate_a'); + assert.equal(HOST_OPERATION_SPECS['workhub.coordination.candidates'].mode, 'query'); + assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.candidates'), true); + assert.throws( + () => + decodeWorkHubCoordinationCandidatesResult({ + candidateSetId: 'caller-invented', + candidates: [], + }), + (error) => error instanceof RuntimeHostProtocolError, + ); +}); + +test('WorkHub Coordination action input is a closed disposition union', () => { + assert.deepEqual( + decodeWorkHubCoordinationActInput({ + actionId: 'action-answer', + userText: 'What changed?', + proposal: { disposition: 'answer_here' }, + }), + { + actionId: 'action-answer', + userText: 'What changed?', + proposal: { disposition: 'answer_here' }, + }, + ); + assert.deepEqual( + decodeWorkHubCoordinationActInput({ + actionId: 'action-delegate', + userText: 'Continue payments', + candidateSetId: `sha256:${'b'.repeat(64)}`, + proposal: { disposition: 'delegate_existing', candidateRef: 'candidate_payments' }, + }).proposal, + { disposition: 'delegate_existing', candidateRef: 'candidate_payments' }, + ); + assert.deepEqual( + decodeWorkHubCoordinationActInput({ + actionId: 'action-create', + userText: 'Create an accessibility audit', + proposal: { disposition: 'create_new', title: 'Accessibility audit' }, + create: { + workspace: { kind: 'host_path', path: '/workspace' }, + }, + }).create, + { + workspace: { kind: 'host_path', path: '/workspace' }, + }, + ); + assert.throws( + () => + decodeWorkHubCoordinationActInput({ + actionId: 'action-create-with-identity', + userText: 'Create an accessibility audit', + proposal: { disposition: 'create_new', title: 'Accessibility audit' }, + create: { + sessionId: 'renderer-invented', + workspace: { kind: 'host_path', path: '/workspace' }, + }, + }), + (error) => error instanceof RuntimeHostProtocolError, + ); + assert.throws( + () => + decodeWorkHubCoordinationActInput({ + actionId: 'action-bypass', + userText: 'Continue payments', + proposal: { + disposition: 'delegate_existing', + candidateRef: 'candidate_payments', + sessionId: 'invented-session', + }, + candidateSetId: `sha256:${'c'.repeat(64)}`, + }), + (error) => error instanceof RuntimeHostProtocolError, + ); + assert.throws( + () => + decodeWorkHubCoordinationActInput({ + actionId: 'action-escalate', + userText: 'Continue payments', + proposal: { disposition: 'answer_here' }, + permissionMode: 'bypass', + tools: ['shell'], + }), + (error) => error instanceof RuntimeHostProtocolError, + ); + assert.throws( + () => + decodeWorkHubCoordinationActInput({ + actionId: 'action-implicit-create', + userText: 'Continue payments', + proposal: { disposition: 'delegate_existing', candidateRef: 'candidate_payments' }, + candidateSetId: `sha256:${'d'.repeat(64)}`, + create: { + workspace: { kind: 'host_path', path: '/workspace' }, + }, + }), + (error) => error instanceof RuntimeHostProtocolError, + ); + assert.equal(HOST_OPERATION_SPECS['workhub.coordination.act'].mode, 'command'); + assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.act'), true); +}); + +test('WorkHub Coordination action results preserve the admitted disposition', () => { + assert.deepEqual( + decodeWorkHubCoordinationActResult({ + disposition: 'delegate_existing', + targetSessionId: 'payments', + targetTurnId: 'turn-payments', + steered: true, + }), + { + disposition: 'delegate_existing', + targetSessionId: 'payments', + targetTurnId: 'turn-payments', + steered: true, + }, + ); + assert.throws( + () => + decodeWorkHubCoordinationActResult({ + disposition: 'delegate_existing', + targetSessionId: 'payments', + targetTurnId: 'turn-payments', + permissionMode: 'bypass', + }), + (error) => error instanceof RuntimeHostProtocolError, + ); +}); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 30f01b2cb7..414fec8c33 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,7 +92,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 50 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 51 as const; +// 51: WorkHub exposes bounded coordination candidates and admits only typed +// actions through the deterministic Runtime Host Action Gate. // 50: WorkHub can append durable coordination summaries and admit tool-free // answers through its reserved Coordination Session authority. // 49: WorkHub resolves one durable Coordination Session per Runtime Host. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 9d95dec0bf..745d53e0ea 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -326,6 +326,8 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'usage.query', 'web-search.execute', '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/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 66866c0945..189d7b5844 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -17,11 +17,28 @@ * under the License. */ -import { requireEntityId, requireExactRecord, requireUtf8String } from './codec.js'; +import { + requireCount, + requireEntityId, + requireExactRecord, + requireRecord, + requireShapedRecord, + requireUtf8String, +} from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; import { defineOperation } from './operation-spec.js'; +import { + decodeWorkspaceProjection, + decodeWorkspaceTarget, + type WorkspaceProjection, + type WorkspaceTarget, +} from './workspace.js'; const COORDINATION_TEXT_MAX_BYTES = 48 * 1024; const COORDINATION_SUMMARY_MAX_BYTES = 8 * 1024; +const COORDINATION_TITLE_MAX_BYTES = 512; +const CANDIDATE_SET_ID_MAX_BYTES = 96; +export const WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS = 32; const RESOLVE_ERRORS = [ 'host_not_ready', @@ -46,6 +63,14 @@ const TURN_ERRORS = [ 'internal_failure', ] as const; +const CANDIDATE_ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'persistence_failed', + 'internal_failure', +] as const; + export type WorkHubCoordinationResolveInput = Record; export interface WorkHubCoordinationResolveResult { @@ -67,6 +92,73 @@ export interface WorkHubCoordinationTurnResult { readonly turnId: string; } +export type WorkHubCoordinationCandidateState = + | 'active' + | 'running' + | 'waiting_for_user' + | 'blocked' + | 'aborted'; + +export interface WorkHubCoordinationCandidate { + /** Opaque strategy-facing identity. Proposals never carry a Session id. */ + readonly candidateRef: string; + /** Presentation/navigation identity; adapters must not expose it to a model strategy. */ + readonly sessionId: string; + readonly sessionName: string; + readonly workspace: WorkspaceProjection; + readonly state: WorkHubCoordinationCandidateState; + readonly updatedAt: number; +} + +export type WorkHubCoordinationCandidatesInput = Record; + +export interface WorkHubCoordinationCandidatesResult { + readonly candidateSetId: string; + readonly candidates: readonly WorkHubCoordinationCandidate[]; +} + +export type WorkHubCoordinationProposal = + | { readonly disposition: 'answer_here' } + | { readonly disposition: 'clarify'; readonly assistantText: string } + | { + readonly disposition: 'delegate_existing'; + readonly candidateRef: string; + readonly replace?: { + readonly candidateRef: string; + readonly expectedTurnId: string; + }; + } + | { readonly disposition: 'create_new'; readonly title: string }; + +export interface WorkHubCoordinationCreateContext { + /** Trusted desktop context. Model/strategy output never contains a workspace or identity. */ + readonly workspace: WorkspaceTarget; +} + +export interface WorkHubCoordinationActInput { + readonly actionId: string; + readonly userText: string; + readonly proposal: WorkHubCoordinationProposal; + readonly candidateSetId?: string; + readonly create?: WorkHubCoordinationCreateContext; +} + +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; + }; + export const WORKHUB_COORDINATION_OPERATION_SPECS = { 'workhub.coordination.resolve': defineOperation< WorkHubCoordinationResolveInput, @@ -101,6 +193,28 @@ export const WORKHUB_COORDINATION_OPERATION_SPECS = { decodeInput: decodeWorkHubCoordinationRecordInput, decodeOutput: decodeWorkHubCoordinationTurnResult, }), + 'workhub.coordination.candidates': defineOperation< + WorkHubCoordinationCandidatesInput, + WorkHubCoordinationCandidatesResult, + (typeof CANDIDATE_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: CANDIDATE_ERRORS, + decodeInput: decodeWorkHubCoordinationCandidatesInput, + decodeOutput: decodeWorkHubCoordinationCandidatesResult, + }), + 'workhub.coordination.act': defineOperation< + WorkHubCoordinationActInput, + WorkHubCoordinationActResult, + (typeof TURN_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: TURN_ERRORS, + decodeInput: decodeWorkHubCoordinationActInput, + decodeOutput: decodeWorkHubCoordinationActResult, + }), } as const; export function decodeWorkHubCoordinationResolveInput( @@ -162,3 +276,203 @@ export function decodeWorkHubCoordinationTurnResult(value: unknown): WorkHubCoor turnId: requireEntityId(result.turnId, 'WorkHub Coordination Turn id'), }; } + +export function decodeWorkHubCoordinationCandidatesInput( + value: unknown, +): WorkHubCoordinationCandidatesInput { + requireExactRecord(value, 'WorkHub Coordination candidates input', []); + return {}; +} + +export function decodeWorkHubCoordinationCandidatesResult( + value: unknown, +): WorkHubCoordinationCandidatesResult { + const result = requireExactRecord(value, 'WorkHub Coordination candidates result', [ + 'candidateSetId', + 'candidates', + ]); + if (!Array.isArray(result.candidates)) { + throw invalidProtocolFrame('Invalid WorkHub Coordination candidates'); + } + if (result.candidates.length > WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS) { + throw invalidProtocolFrame('Too many WorkHub Coordination candidates'); + } + return { + candidateSetId: candidateSetId(result.candidateSetId), + candidates: result.candidates.map(decodeWorkHubCoordinationCandidate), + }; +} + +export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordinationActInput { + const input = requireShapedRecord( + value, + 'WorkHub Coordination action input', + ['actionId', 'userText', 'proposal'], + ['candidateSetId', 'create'], + ); + const proposal = decodeWorkHubCoordinationProposal(input.proposal); + const base = { + actionId: requireEntityId(input.actionId, 'WorkHub Coordination action id'), + userText: requireUtf8String( + input.userText, + 'WorkHub Coordination action text', + COORDINATION_TEXT_MAX_BYTES, + ), + proposal, + }; + if (proposal.disposition === 'delegate_existing') { + if (input.create !== undefined || input.candidateSetId === undefined) { + throw invalidProtocolFrame('Invalid WorkHub delegation context'); + } + return { ...base, candidateSetId: candidateSetId(input.candidateSetId) }; + } + if (proposal.disposition === 'create_new') { + if (input.candidateSetId !== undefined || input.create === undefined) { + throw invalidProtocolFrame('Invalid WorkHub creation context'); + } + return { ...base, create: decodeWorkHubCoordinationCreateContext(input.create) }; + } + if (input.candidateSetId !== undefined || input.create !== undefined) { + throw invalidProtocolFrame('Unexpected WorkHub action context'); + } + return base; +} + +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 } : {}), + }; + } + throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); +} + +function decodeWorkHubCoordinationCandidate(value: unknown): WorkHubCoordinationCandidate { + const candidate = requireExactRecord(value, 'WorkHub Coordination candidate', [ + 'candidateRef', + 'sessionId', + 'sessionName', + 'workspace', + 'state', + 'updatedAt', + ]); + return { + candidateRef: requireEntityId(candidate.candidateRef, 'WorkHub candidate ref'), + sessionId: requireEntityId(candidate.sessionId, 'WorkHub candidate Session id'), + sessionName: requireUtf8String(candidate.sessionName, 'WorkHub candidate name', 512), + workspace: decodeWorkspaceProjection(candidate.workspace), + state: candidateState(candidate.state), + updatedAt: requireCount(candidate.updatedAt, 'WorkHub candidate update time'), + }; +} + +function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationProposal { + const proposal = requireRecord(value, 'WorkHub Coordination proposal'); + if (proposal.disposition === 'answer_here') { + requireExactRecord(proposal, 'WorkHub answer proposal', ['disposition']); + return { disposition: 'answer_here' }; + } + if (proposal.disposition === 'clarify') { + const exact = requireExactRecord(proposal, 'WorkHub clarification proposal', [ + 'disposition', + 'assistantText', + ]); + return { + disposition: 'clarify', + assistantText: requireUtf8String( + exact.assistantText, + 'WorkHub clarification text', + COORDINATION_SUMMARY_MAX_BYTES, + ), + }; + } + if (proposal.disposition === 'delegate_existing') { + const exact = requireShapedRecord( + proposal, + 'WorkHub delegation proposal', + ['disposition', 'candidateRef'], + ['replace'], + ); + return { + disposition: 'delegate_existing', + candidateRef: requireEntityId(exact.candidateRef, 'WorkHub candidate ref'), + ...(exact.replace === undefined ? {} : { replace: decodeWorkHubReplacement(exact.replace) }), + }; + } + if (proposal.disposition === 'create_new') { + const exact = requireExactRecord(proposal, 'WorkHub creation proposal', [ + 'disposition', + 'title', + ]); + return { + disposition: 'create_new', + title: requireUtf8String(exact.title, 'WorkHub Session title', COORDINATION_TITLE_MAX_BYTES), + }; + } + throw invalidProtocolFrame('Invalid WorkHub Coordination proposal disposition'); +} + +function decodeWorkHubReplacement(value: unknown): { + readonly candidateRef: string; + readonly expectedTurnId: string; +} { + const replace = requireExactRecord(value, 'WorkHub replacement', [ + 'candidateRef', + 'expectedTurnId', + ]); + return { + candidateRef: requireEntityId(replace.candidateRef, 'WorkHub replacement candidate ref'), + expectedTurnId: requireEntityId(replace.expectedTurnId, 'WorkHub expected Turn id'), + }; +} + +function decodeWorkHubCoordinationCreateContext(value: unknown): WorkHubCoordinationCreateContext { + const context = requireExactRecord(value, 'WorkHub creation context', ['workspace']); + return { + workspace: decodeWorkspaceTarget(context.workspace), + }; +} + +function candidateSetId(value: unknown): string { + const id = requireUtf8String(value, 'WorkHub candidate set id', CANDIDATE_SET_ID_MAX_BYTES); + if (!/^sha256:[a-f0-9]{64}$/u.test(id)) { + throw invalidProtocolFrame('Invalid WorkHub candidate set id'); + } + return id; +} + +function candidateState(value: unknown): WorkHubCoordinationCandidateState { + if ( + value === 'active' || + value === 'running' || + value === 'waiting_for_user' || + value === 'blocked' || + value === 'aborted' + ) { + return value; + } + throw invalidProtocolFrame('Invalid WorkHub candidate state'); +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index ef0f62ba3d..5f1c105992 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -18,6 +18,7 @@ */ import { createHash, randomUUID } from 'node:crypto'; +import { normalizeMessageContent } from '@maka/core/events'; import { describeChatConfigurationReason, NO_REAL_CONNECTION_CODE, @@ -166,6 +167,7 @@ import { HostTurnControlCoordinator } from './turn-control-coordinator.js'; import { HostUsagePricingCoordinator } from './usage-pricing-coordinator.js'; import { HostWebSearchCoordinator } from './web-search-coordinator.js'; import { HostWorkHubCoordinationCoordinator } from './workhub-coordination-coordinator.js'; +import { WorkHubActionEffectFailure } from './workhub-coordination-action-gate.js'; import { createHostWebSearchService, createHostWebSearchToolFromService, @@ -1226,6 +1228,64 @@ export async function createExecutionRuntimeHostComposition( admission: sessionAdmission, continuity: continuityCoordinator, executions: coordinator, + sessionActions: { + create: async (input) => { + const outcome = await sessionCatalog.createForWorkHub({ + sessionId: input.sessionId, + workspace: input.workspace, + name: input.title, + modelTarget: { kind: 'default' }, + collaborationMode: 'agent', + orchestrationMode: 'default', + }); + if (!outcome.ok) { + throw new WorkHubActionEffectFailure( + outcome.error.code === 'invalid_request' ? 'operation_conflict' : outcome.error.code, + outcome.error.message, + ); + } + }, + submit: async (input, connection) => { + const outcome = await messages.handlers['turn.message.submit']( + { + originHostEpoch: connection.hostEpoch, + sessionId: input.sessionId, + messageId: input.messageId, + content: normalizeMessageContent({ text: input.text }), + placement: 'current_turn', + }, + connection, + ); + if (!outcome.ok) { + throw new WorkHubActionEffectFailure( + outcome.error.code === 'outcome_unknown' + ? 'commit_outcome_unknown' + : outcome.error.code, + outcome.error.message, + ); + } + return outcome.result.disposition === 'turn_started' + ? { turnId: outcome.result.turnId } + : { turnId: input.messageId, steered: true as const }; + }, + stop: async (input, connection) => { + const observed = await turnControl.handlers['turn.query'](input, connection); + if (!observed.ok) { + throw new WorkHubActionEffectFailure(observed.error.code, observed.error.message); + } + const stopped = await turnControl.handlers['turn.stop']( + { + sessionId: input.sessionId, + turnId: input.turnId, + runId: observed.result.runId, + }, + connection, + ); + if (!stopped.ok) { + throw new WorkHubActionEffectFailure(stopped.error.code, stopped.error.message); + } + }, + }, resolveCreateTarget: async () => { const { projectId: _projectId, ...target } = await sessionCatalog.resolveExternalSessionImportTarget(); diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 695536424a..01f7556259 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -199,6 +199,11 @@ export class HostSessionCatalogCoordinator { if (!outcome.ok) throw new Error(outcome.error.message); } + /** WorkHub Action Gate path; callers cannot bypass the typed operation outcome. */ + createForWorkHub(input: SessionCreateInput): Promise> { + return this.#create(input); + } + async #query( input: SessionCatalogQueryInput, ): Promise> { diff --git a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts new file mode 100644 index 0000000000..7778592134 --- /dev/null +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -0,0 +1,443 @@ +/* + * 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 { createHash } from 'node:crypto'; +import type { SessionHeader, SessionStatus } from '@maka/core/session'; +import { + WORKHUB_COORDINATION_SESSION_ID, + isWorkHubCoordinationSessionTarget, +} from '@maka/core/session'; +import type { + WorkHubCoordinationActInput, + WorkHubCoordinationActResult, + WorkHubCoordinationCandidate, + WorkHubCoordinationCandidatesResult, + WorkspaceTarget, + WorkspaceProjection, +} from '../protocol/index.js'; +import { WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS } from '../protocol/index.js'; +import type { ConnectionContext } from './operation-dispatcher.js'; + +const SIDE_CONVERSATION_LABEL = 'mode:side_conversation'; +const ACTION_REPLAY_MAX_ITEMS = 256; + +export type WorkHubActionGateSession = Pick< + SessionHeader, + | 'id' + | 'role' + | 'cwd' + | 'projectId' + | 'createdAt' + | 'lastMessageAt' + | 'name' + | 'labels' + | 'isArchived' + | 'status' + | 'statusUpdatedAt' + | 'subagentParent' +>; + +export interface WorkHubActionGateEffects { + listSessions(): Promise; + answer( + input: { readonly turnId: string; readonly text: string }, + context: ConnectionContext, + ): Promise; + clarify(input: { + readonly turnId: string; + readonly userText: string; + readonly assistantText: string; + }): Promise; + create(input: { + readonly sessionId: string; + readonly workspace: WorkspaceTarget; + readonly title: string; + }): Promise; + submit( + input: { + readonly sessionId: string; + readonly messageId: string; + readonly text: string; + }, + context: ConnectionContext, + ): Promise<{ readonly turnId: string; readonly steered?: true }>; + stop( + input: { readonly sessionId: string; readonly turnId: string }, + context: ConnectionContext, + ): Promise; +} + +export type WorkHubActionEffectFailureCode = + | 'host_not_ready' + | 'host_draining' + | 'operation_unavailable' + | 'not_found' + | 'session_archived' + | 'session_busy' + | 'operation_conflict' + | 'persistence_failed' + | 'commit_outcome_unknown' + | 'internal_failure' + | 'unauthorized'; + +export class WorkHubActionEffectFailure extends Error { + constructor( + readonly code: WorkHubActionEffectFailureCode, + message: string, + ) { + super(message); + this.name = 'WorkHubActionEffectFailure'; + } +} + +export type WorkHubActionGateFailureCode = + | 'candidate_set_stale' + | 'candidate_unavailable' + | 'target_waiting_for_user' + | 'self_route' + | 'confirmation_required' + | 'stop_not_owned' + | 'action_conflict'; + +export class WorkHubActionGateFailure extends Error { + constructor( + readonly code: WorkHubActionGateFailureCode, + message: string, + ) { + super(message); + this.name = 'WorkHubActionGateFailure'; + } +} + +interface ActionReplay { + readonly fingerprint: string; + readonly result: Promise; +} + +interface OwnedRoot { + readonly turnId: string; + readonly actionId: string; +} + +/** + * The sole admission module between a WorkHub strategy proposal and Session effects. + * + * Candidate discovery and fresh-state validation deliberately live behind the + * same interface as execution. A caller cannot turn a model-selected Session id + * into a write because proposals carry only an opaque candidateRef. + */ +export class WorkHubCoordinationActionGate { + readonly #effects: WorkHubActionGateEffects; + readonly #actions = new Map(); + readonly #ownedRoots = new Map(); + + constructor(effects: WorkHubActionGateEffects) { + this.#effects = effects; + } + + async candidates(): Promise { + return candidateSet(await this.#effects.listSessions()); + } + + act( + input: WorkHubCoordinationActInput, + context: ConnectionContext, + ): Promise { + const fingerprint = digest(input); + const replay = this.#actions.get(input.actionId); + if (replay) { + if (replay.fingerprint !== fingerprint) { + return Promise.reject( + new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub action identity belongs to a different proposal', + ), + ); + } + return replay.result; + } + + const result = this.#act(input, context); + const action = { fingerprint, result }; + this.#actions.set(input.actionId, action); + // Successful actions remain replayable. A rejected admission does not own + // the action identity forever: callers must be able to refresh stale + // candidates or satisfy an actionable precondition and retry safely. + void result.catch(() => { + if (this.#actions.get(input.actionId) === action) { + this.#actions.delete(input.actionId); + } + }); + this.#boundReplays(); + return result; + } + + async #act( + input: WorkHubCoordinationActInput, + context: ConnectionContext, + ): Promise { + const proposal = input.proposal; + if (proposal.disposition === 'answer_here') { + const turnId = coordinationTurnId(input.actionId, 'answer'); + await this.#effects.answer({ turnId, text: input.userText }, context); + return { disposition: 'answer_here', coordinationTurnId: turnId }; + } + if (proposal.disposition === 'clarify') { + const turnId = coordinationTurnId(input.actionId, 'clarify'); + await this.#effects.clarify({ + turnId, + userText: input.userText, + assistantText: proposal.assistantText, + }); + return { disposition: 'clarify', coordinationTurnId: turnId }; + } + if (proposal.disposition === 'create_new') { + if (!input.create) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub creation context is unavailable', + ); + } + const sessionId = workHubCreatedSessionId(input.actionId); + await this.#effects.create({ + sessionId, + workspace: input.create.workspace, + title: proposal.title, + }); + const submitted = await this.#effects.submit( + { + sessionId, + messageId: actionMessageId(input.actionId), + text: input.userText, + }, + context, + ); + this.#rememberRoot(sessionId, input.actionId, submitted); + return executionResult('create_new', sessionId, submitted); + } + + const candidates = await this.candidates(); + if (candidates.candidateSetId !== input.candidateSetId) { + throw new WorkHubActionGateFailure( + 'candidate_set_stale', + 'WorkHub Session candidates changed; refresh before delegating', + ); + } + const target = candidates.candidates.find( + (candidate) => candidate.candidateRef === proposal.candidateRef, + ); + if (!target) { + throw new WorkHubActionGateFailure( + 'candidate_unavailable', + 'WorkHub target is not in the admitted candidate set', + ); + } + this.#assertTarget(target); + + if (proposal.replace) { + if (!hasExplicitReplacementIntent(input.userText)) { + throw new WorkHubActionGateFailure( + 'confirmation_required', + 'Stopping and rerouting work requires an explicit user correction naming the replacement', + ); + } + const replaced = candidates.candidates.find( + (candidate) => candidate.candidateRef === proposal.replace?.candidateRef, + ); + if (!replaced) { + throw new WorkHubActionGateFailure( + 'candidate_unavailable', + 'WorkHub replacement source is not in the admitted candidate set', + ); + } + if (replaced.sessionId === target.sessionId) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement target did not change', + ); + } + const owned = this.#ownedRoots.get(replaced.sessionId); + if (!owned || owned.turnId !== proposal.replace.expectedTurnId) { + throw new WorkHubActionGateFailure( + 'stop_not_owned', + 'WorkHub cannot stop a Turn it did not admit', + ); + } + await this.#effects.stop({ sessionId: replaced.sessionId, turnId: owned.turnId }, context); + this.#ownedRoots.delete(replaced.sessionId); + } + + const submitted = await this.#effects.submit( + { + sessionId: target.sessionId, + messageId: actionMessageId(input.actionId), + text: input.userText, + }, + context, + ); + this.#rememberRoot(target.sessionId, input.actionId, submitted); + return executionResult('delegate_existing', target.sessionId, submitted); + } + + #assertTarget(target: WorkHubCoordinationCandidate): void { + if (target.sessionId === WORKHUB_COORDINATION_SESSION_ID) { + throw new WorkHubActionGateFailure('self_route', 'WorkHub cannot delegate to itself'); + } + if (target.state === 'waiting_for_user') { + throw new WorkHubActionGateFailure( + 'target_waiting_for_user', + 'Target Session is waiting for user input', + ); + } + } + + #rememberRoot( + sessionId: string, + actionId: string, + submitted: { readonly turnId: string; readonly steered?: true }, + ): void { + if (submitted.steered) return; + this.#ownedRoots.set(sessionId, { turnId: submitted.turnId, actionId }); + } + + #boundReplays(): void { + while (this.#actions.size > ACTION_REPLAY_MAX_ITEMS) { + const oldest = this.#actions.keys().next().value; + if (oldest === undefined) return; + this.#actions.delete(oldest); + } + } +} + +export function candidateSet( + sessions: readonly WorkHubActionGateSession[], +): WorkHubCoordinationCandidatesResult { + const eligible = sessions + .filter(isCandidateSession) + .sort((left, right) => updatedAt(right) - updatedAt(left) || left.id.localeCompare(right.id)) + .slice(0, WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS); + const candidateSetId = digest( + eligible.map((session) => ({ + id: session.id, + name: session.name, + workspace: workspaceProjection(session), + status: session.status, + updatedAt: updatedAt(session), + })), + ); + return { + candidateSetId, + candidates: eligible.map((session) => ({ + candidateRef: candidateRef(candidateSetId, session.id), + sessionId: session.id, + sessionName: session.name, + workspace: workspaceProjection(session), + state: candidateState(session.status), + updatedAt: updatedAt(session), + })), + }; +} + +function isCandidateSession(session: WorkHubActionGateSession): boolean { + return ( + !session.isArchived && + !isWorkHubCoordinationSessionTarget(session) && + session.role === undefined && + session.subagentParent === undefined && + !session.labels.includes(SIDE_CONVERSATION_LABEL) + ); +} + +function candidateRef(candidateSetId: string, sessionId: string): string { + return `whc_${hash(`${candidateSetId}\0${sessionId}`).slice(0, 48)}`; +} + +function coordinationTurnId(actionId: string, kind: 'answer' | 'clarify'): string { + return `wha_${hash(`${actionId}\0${kind}`).slice(0, 48)}`; +} + +function actionMessageId(actionId: string): string { + return `whm_${hash(actionId).slice(0, 48)}`; +} + +/** + * Replacement is the only Slice 4 coordination action that interrupts an + * admitted effect. Confirmation therefore comes from the exact user message, + * never from strategy output: the message must both reject/stop the old route + * and explicitly direct work toward a replacement. + */ +function hasExplicitReplacementIntent(userText: string): boolean { + const chineseCorrection = + /(?:不是|不对|搞错了?|弄错了?|错了|不要再继续)[^\n]{0,96}(?:而是|改成|改为|换成|换到|切到|转到|改派|改交|交给|用)/iu; + const chineseStopAndReroute = + /(?:停止|停掉|中止|取消)[^\n]{0,96}(?:改成|改为|换成|换到|切到|转到|改派|改交|交给|用)/iu; + const englishCorrection = + /\b(?:no|not|wrong|mistake)\b[^\n]{0,96}\b(?:instead|use|switch\s+to|change\s+to|move\s+to|route\s+to|send\s+to)\b/iu; + const englishStopAndReroute = + /\b(?:stop|cancel|abort)\b[^\n]{0,96}\b(?:use|switch\s+to|change\s+to|move\s+to|delegate\s+to|route\s+to|send\s+to)\b/iu; + return ( + chineseCorrection.test(userText) || + chineseStopAndReroute.test(userText) || + englishCorrection.test(userText) || + englishStopAndReroute.test(userText) + ); +} + +function workHubCreatedSessionId(actionId: string): string { + return `whs_${hash(`create\0${actionId}`).slice(0, 48)}`; +} + +function workspaceProjection(session: WorkHubActionGateSession): WorkspaceProjection { + return { + target: + typeof session.projectId === 'string' + ? { kind: 'project', projectId: session.projectId } + : { kind: 'host_path', path: session.cwd }, + hostCwd: session.cwd, + }; +} + +function candidateState(status: SessionStatus): WorkHubCoordinationCandidate['state'] { + return status; +} + +function updatedAt(session: WorkHubActionGateSession): number { + return session.lastMessageAt ?? session.statusUpdatedAt ?? session.createdAt; +} + +function executionResult( + disposition: 'delegate_existing' | 'create_new', + sessionId: string, + submitted: { readonly turnId: string; readonly steered?: true }, +): WorkHubCoordinationActResult { + return { + disposition, + targetSessionId: sessionId, + targetTurnId: submitted.turnId, + ...(submitted.steered ? { steered: true as const } : {}), + } as WorkHubCoordinationActResult; +} + +function digest(value: unknown): `sha256:${string}` { + return `sha256:${hash(JSON.stringify(value))}`; +} + +function hash(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index af08e6c419..0643571b20 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -33,6 +33,7 @@ import { import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; import type { OperationOutcome, + WorkHubCoordinationActInput, WorkHubCoordinationAnswerInput, WorkHubCoordinationRecordInput, } from '../protocol/index.js'; @@ -44,6 +45,12 @@ import type { RootTurnCoordinator } from './root-turn-coordinator.js'; import { SessionAdmissionGate } from './session-admission-gate.js'; import { SessionOperationFailure } from './session-catalog-coordinator.js'; import type { SessionContinuityCoordinator } from './session-continuity-coordinator.js'; +import { + WorkHubActionEffectFailure, + WorkHubActionGateFailure, + WorkHubCoordinationActionGate, + type WorkHubActionGateEffects, +} from './workhub-coordination-action-gate.js'; const CREATE_FINGERPRINT = `sha256:${createHash('sha256') .update('maka:workhub-coordination-session:v1', 'utf8') @@ -62,6 +69,7 @@ type CoordinationStores = Pick< SessionAuthorityStore, | 'appendMessages' | 'createStableSession' + | 'listHeaders' | 'probeStableSessionCreate' | 'readHeaderSnapshot' | 'readTranscriptHighWaterSnapshot' @@ -82,6 +90,7 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly admission: SessionAdmissionGate; readonly continuity: Pick; readonly executions: CoordinationExecutions; + readonly sessionActions: Pick; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; } @@ -92,6 +101,8 @@ export class HostWorkHubCoordinationCoordinator { '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), }; readonly #coordinationCwd: string; @@ -101,6 +112,7 @@ export class HostWorkHubCoordinationCoordinator { readonly #executions: CoordinationExecutions; readonly #resolveCreateTarget: () => Promise; readonly #requestDrain: () => void; + readonly #actionGate: WorkHubCoordinationActionGate; constructor(options: HostWorkHubCoordinationCoordinatorOptions) { this.#coordinationCwd = join(options.stateRoot, COORDINATION_CWD_DIRECTORY); @@ -110,6 +122,77 @@ export class HostWorkHubCoordinationCoordinator { this.#executions = options.executions; this.#resolveCreateTarget = options.resolveCreateTarget; this.#requestDrain = options.requestDrain; + this.#actionGate = new WorkHubCoordinationActionGate({ + listSessions: () => this.#stores.listHeaders(), + answer: async (input, context) => { + const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); + if (!outcome.ok) { + 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); + } + }, + create: options.sessionActions.create, + submit: options.sessionActions.submit, + stop: options.sessionActions.stop, + }); + } + + async #candidates(): Promise> { + try { + return { ok: true, result: await this.#actionGate.candidates() }; + } catch { + return { + ok: false, + error: { + code: 'persistence_failed', + message: 'WorkHub Session candidates are unavailable', + }, + }; + } + } + + async #act( + input: WorkHubCoordinationActInput, + context: ConnectionContext, + ): Promise> { + try { + return { ok: true, result: await this.#actionGate.act(input, context) }; + } catch (error) { + if (error instanceof WorkHubActionEffectFailure) { + return { + ok: false, + error: { + code: error.code === 'unauthorized' ? 'operation_unavailable' : error.code, + message: error.message, + }, + }; + } + if (error instanceof WorkHubActionGateFailure) { + return { + ok: false, + error: { + code: error.code === 'target_waiting_for_user' ? 'session_busy' : 'operation_conflict', + message: error.message, + }, + }; + } + return { + ok: false, + error: { + code: 'persistence_failed', + message: 'WorkHub action authority is unavailable', + }, + }; + } } #resolve(): Promise> { From 48666a0ceb83f5dfa09588b9bd0a841ca1c5e445 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 26 Aug 2026 01:41:11 +0800 Subject: [PATCH 2/7] fix(workhub): close action gate review gaps Generated-by: Codex --- .../main/__tests__/workhub-controller.test.ts | 64 +++++++++++++++ .../__tests__/workhub-surface-flow.test.ts | 22 ++++++ .../src/renderer/workhub-controller.ts | 29 ++++--- apps/desktop/src/renderer/workhub-surface.tsx | 21 ++++- .../workhub-coordination-action-gate.test.ts | 77 +++++++++++++++++++ .../workhub-coordination-coordinator.test.ts | 23 +++++- .../src/protocol/workhub-coordination.ts | 14 ++-- .../workhub-coordination-action-gate.ts | 71 +++++++++++++++-- .../workhub-coordination-coordinator.ts | 8 +- 9 files changed, 298 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 0b805bfa17..7b7c9f846e 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -2469,6 +2469,70 @@ test('production submission delegates only through the Runtime-owned candidate r }]); }); +test('production corrections fail closed instead of dropping an incomplete replacement', async () => { + const actions: unknown[] = []; + const sessions = port([session('source'), session('target')]); + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + answer: async (input) => ({ turnId: input.turnId }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'d'.repeat(64)}`, + candidates: [ + { + candidateRef: 'candidate-source', + sessionId: 'source', + sessionName: 'source', + workspace: { + target: { kind: 'host_path', path: '/workspace/source' }, + hostCwd: '/workspace/source', + }, + state: 'active', + updatedAt: 1, + }, + { + candidateRef: 'candidate-target', + sessionId: 'target', + sessionName: 'target', + workspace: { + target: { kind: 'host_path', path: '/workspace/target' }, + hostCwd: '/workspace/target', + }, + state: 'active', + updatedAt: 2, + }, + ], + }), + act: async (input) => { + actions.push(input); + throw new Error('incomplete correction must not reach the Action Gate'); + }, + }, + }); + + await assert.rejects( + controller.submit({ + requestId: 'missing-turn', + text: 'No, use target instead', + explicitTarget: { sessionId: 'target' }, + correction: { from: { sessionId: 'source' } }, + }), + /requires an exact owned Turn/u, + ); + await assert.rejects( + controller.submit({ + requestId: 'missing-source', + text: 'No, use target instead', + explicitTarget: { sessionId: 'target' }, + correction: { from: { sessionId: 'outside' }, turnId: 'source-turn' }, + }), + /source is outside the admitted candidate set/u, + ); + assert.deepEqual(actions, []); +}); + test('production clarification is persisted through the typed Action Gate disposition', async () => { const actions: unknown[] = []; const controller = createGatedWorkHubController({ 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 eee76db894..fb5c643123 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -28,6 +28,7 @@ import { WorkHubSurfaceRouteGate, submitWorkHubSurfaceInput, visibleWorkHubConversation, + workHubReplacementText, workHubSubmissionCanCorrect, workHubSubmissionClearsDraft, } from '../../renderer/workhub-surface.js'; @@ -42,6 +43,27 @@ import { type WorkHubDesktopSession, } from '../../renderer/workhub-session-port.js'; +test('correction clicks produce explicit replacement intent in both locales', () => { + assert.equal( + workHubReplacementText({ + locale: 'zh', + sourceName: '支付', + targetName: '登录', + originalText: '补上重试逻辑', + }), + '不是“支付”,改成“登录”:补上重试逻辑', + ); + assert.equal( + workHubReplacementText({ + locale: 'en', + sourceName: 'Payments', + targetName: 'Login', + originalText: 'Add the retry logic', + }), + 'Not “Payments”; use “Login” instead: Add the retry logic', + ); +}); + test('surface route gate rejects same-frame duplicate operations and reopens after settle', async () => { const gate = new WorkHubSurfaceRouteGate(); let release: (() => void) | undefined; diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index a95ee4e52f..53179072c6 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -806,9 +806,23 @@ function createWorkHubControllerImplementation(deps: { if (!candidate) { throw new Error('WorkHub target Session is unavailable'); } - const replacedCandidate = correction - ? candidateBySessionId.get(correction.from.sessionId) - : undefined; + let replace: { candidateRef: string; expectedTurnId: string } | undefined; + if (correction) { + if (correction.steered) { + throw new Error('WorkHub cannot replace a steered Turn'); + } + if (!correction.turnId) { + throw new Error('WorkHub correction requires an exact owned Turn'); + } + const replacedCandidate = candidateBySessionId.get(correction.from.sessionId); + if (!replacedCandidate) { + throw new Error('WorkHub correction source is outside the admitted candidate set'); + } + replace = { + candidateRef: replacedCandidate.candidateRef, + expectedTurnId: correction.turnId, + }; + } const action: WorkHubCoordinationActInput = { actionId: input.requestId, userText: input.text, @@ -816,14 +830,7 @@ function createWorkHubControllerImplementation(deps: { proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef, - ...(correction?.turnId && replacedCandidate - ? { - replace: { - candidateRef: replacedCandidate.candidateRef, - expectedTurnId: correction.turnId, - }, - } - : {}), + ...(replace ? { replace } : {}), }, }; const admitted = await coordination.act(action); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 61563749f8..75502f8080 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -80,6 +80,17 @@ export function workHubSubmissionClearsDraft( return Boolean(result && result.kind !== 'waiting'); } +export function workHubReplacementText(input: { + locale: UiLocale; + sourceName: string; + targetName: string; + originalText: string; +}): string { + return input.locale === 'zh' + ? `不是“${input.sourceName}”,改成“${input.targetName}”:${input.originalText}` + : `Not “${input.sourceName}”; use “${input.targetName}” instead: ${input.originalText}`; +} + export function workHubSubmissionCanCorrect( result: WorkHubSubmission, ): result is Extract { @@ -332,9 +343,17 @@ export function WorkHubSurface(props: { const selected = projection.sessions.find( (session) => session.target.sessionId === target.sessionId, ); + const source = projection.sessions.find( + (session) => session.target.sessionId === from.target.sessionId, + ); void route({ requestId: crypto.randomUUID(), - text: turn.text, + text: workHubReplacementText({ + locale: props.locale, + sourceName: source?.sessionName ?? copy.sessionFallback, + targetName: selected?.sessionName ?? copy.sessionFallback, + originalText: turn.text, + }), explicitTarget: target, correction: { from: from.target, diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 20f27825b3..f14874d043 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -282,6 +282,83 @@ describe('WorkHub Coordination Action Gate', () => { assert.deepEqual(effects.stops, [{ sessionId: 'source', turnId: first.targetTurnId }]); assert.equal(effects.submissions.at(-1)?.sessionId, 'target'); }); + + test('serializes replacements from one source and admits only one target', async () => { + const effects = fakeEffects([ + session('source'), + session('target-a', { statusUpdatedAt: 2 }), + session('target-b', { statusUpdatedAt: 3 }), + ]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const source = snapshot.candidates.find(({ sessionId }) => sessionId === 'source')!; + const targetA = snapshot.candidates.find(({ sessionId }) => sessionId === 'target-a')!; + const targetB = snapshot.candidates.find(({ sessionId }) => sessionId === 'target-b')!; + const admitted = await gate.act( + { + actionId: 'source-action', + userText: 'Start source work', + candidateSetId: snapshot.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, + }, + CONTEXT, + ); + assert.equal(admitted.disposition, 'delegate_existing'); + if (admitted.disposition !== 'delegate_existing') return; + + let signalStop!: () => void; + const stopStarted = new Promise((resolve) => { + signalStop = resolve; + }); + let releaseStop!: () => void; + const stopBarrier = new Promise((resolve) => { + releaseStop = resolve; + }); + effects.stop = async (input) => { + effects.stops.push(input); + signalStop(); + await stopBarrier; + }; + const replace = (actionId: string, candidateRef: string) => + gate.act( + { + actionId, + userText: `No, use ${actionId} instead`, + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef, + replace: { + candidateRef: source.candidateRef, + expectedTurnId: admitted.targetTurnId, + }, + }, + }, + CONTEXT, + ); + + const first = replace('target-a-action', targetA.candidateRef); + await stopStarted; + const second = replace('target-b-action', targetB.candidateRef); + await Promise.resolve(); + assert.equal(effects.stops.length, 1); + assert.deepEqual( + effects.submissions.map(({ sessionId }) => sessionId), + ['source'], + ); + + releaseStop(); + assert.equal((await first).disposition, 'delegate_existing'); + await assert.rejects( + second, + (error) => error instanceof WorkHubActionGateFailure && error.code === 'stop_not_owned', + ); + assert.deepEqual(effects.stops, [{ sessionId: 'source', turnId: admitted.targetTurnId }]); + assert.deepEqual( + effects.submissions.map(({ sessionId }) => sessionId), + ['source', 'target-a'], + ); + }); }); function session( 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 c3ce5667f6..94dc25416d 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -30,6 +30,10 @@ 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'; @@ -431,10 +435,23 @@ describe('Host WorkHub Coordination coordinator', () => { ok: true, result: { turnId: 'summary-turn' }, }); + const maximumInput = { + turnId: 'maximum-summary-turn', + userText: 'u'.repeat(WORKHUB_COORDINATION_TEXT_MAX_BYTES), + assistantText: 'a'.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, 3); + assert.equal(messages.length, 6); assert.deepEqual( - messages.map(({ type, turnId }) => ({ type, turnId })), + messages.slice(0, 3).map(({ type, turnId }) => ({ type, turnId })), [ { type: 'user', turnId: 'summary-turn' }, { type: 'assistant', turnId: 'summary-turn' }, @@ -447,7 +464,7 @@ describe('Host WorkHub Coordination coordinator', () => { ); 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, 3); + 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, diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 189d7b5844..09b52029e1 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -34,8 +34,8 @@ import { type WorkspaceTarget, } from './workspace.js'; -const COORDINATION_TEXT_MAX_BYTES = 48 * 1024; -const COORDINATION_SUMMARY_MAX_BYTES = 8 * 1024; +export const WORKHUB_COORDINATION_TEXT_MAX_BYTES = 48 * 1024; +export const WORKHUB_COORDINATION_SUMMARY_MAX_BYTES = 8 * 1024; const COORDINATION_TITLE_MAX_BYTES = 512; const CANDIDATE_SET_ID_MAX_BYTES = 96; export const WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS = 32; @@ -242,7 +242,7 @@ export function decodeWorkHubCoordinationAnswerInput( text: requireUtf8String( input.text, 'WorkHub Coordination answer text', - COORDINATION_TEXT_MAX_BYTES, + WORKHUB_COORDINATION_TEXT_MAX_BYTES, ), }; } @@ -260,12 +260,12 @@ export function decodeWorkHubCoordinationRecordInput( userText: requireUtf8String( input.userText, 'WorkHub Coordination user text', - COORDINATION_TEXT_MAX_BYTES, + WORKHUB_COORDINATION_TEXT_MAX_BYTES, ), assistantText: requireUtf8String( input.assistantText, 'WorkHub Coordination assistant text', - COORDINATION_SUMMARY_MAX_BYTES, + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, ), }; } @@ -316,7 +316,7 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi userText: requireUtf8String( input.userText, 'WorkHub Coordination action text', - COORDINATION_TEXT_MAX_BYTES, + WORKHUB_COORDINATION_TEXT_MAX_BYTES, ), proposal, }; @@ -405,7 +405,7 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP assistantText: requireUtf8String( exact.assistantText, 'WorkHub clarification text', - COORDINATION_SUMMARY_MAX_BYTES, + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, ), }; } 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 7778592134..579e5e25d0 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -146,6 +146,7 @@ export class WorkHubCoordinationActionGate { readonly #effects: WorkHubActionGateEffects; readonly #actions = new Map(); readonly #ownedRoots = new Map(); + readonly #replacementLanes = new Map>(); constructor(effects: WorkHubActionGateEffects) { this.#effects = effects; @@ -272,17 +273,52 @@ export class WorkHubCoordinationActionGate { 'WorkHub replacement target did not change', ); } - const owned = this.#ownedRoots.get(replaced.sessionId); - if (!owned || owned.turnId !== proposal.replace.expectedTurnId) { - throw new WorkHubActionGateFailure( - 'stop_not_owned', - 'WorkHub cannot stop a Turn it did not admit', + const replacement = proposal.replace; + return this.#withReplacementLease(replaced.sessionId, async () => { + const freshCandidates = await this.candidates(); + if (freshCandidates.candidateSetId !== input.candidateSetId) { + throw new WorkHubActionGateFailure( + 'candidate_set_stale', + 'WorkHub Session candidates changed; refresh before delegating', + ); + } + const freshTarget = freshCandidates.candidates.find( + (candidate) => candidate.candidateRef === proposal.candidateRef, ); - } - await this.#effects.stop({ sessionId: replaced.sessionId, turnId: owned.turnId }, context); - this.#ownedRoots.delete(replaced.sessionId); + const freshSource = freshCandidates.candidates.find( + (candidate) => candidate.candidateRef === replacement.candidateRef, + ); + if (!freshTarget || !freshSource) { + throw new WorkHubActionGateFailure( + 'candidate_unavailable', + 'WorkHub replacement source or target is not in the admitted candidate set', + ); + } + this.#assertTarget(freshTarget); + const owned = this.#ownedRoots.get(freshSource.sessionId); + if (!owned || owned.turnId !== replacement.expectedTurnId) { + throw new WorkHubActionGateFailure( + 'stop_not_owned', + 'WorkHub cannot stop a Turn it did not admit', + ); + } + await this.#effects.stop( + { sessionId: freshSource.sessionId, turnId: owned.turnId }, + context, + ); + this.#ownedRoots.delete(freshSource.sessionId); + return this.#submitExisting(input, freshTarget, context); + }); } + return this.#submitExisting(input, target, context); + } + + async #submitExisting( + input: WorkHubCoordinationActInput, + target: WorkHubCoordinationCandidate, + context: ConnectionContext, + ): Promise { const submitted = await this.#effects.submit( { sessionId: target.sessionId, @@ -295,6 +331,25 @@ export class WorkHubCoordinationActionGate { return executionResult('delegate_existing', target.sessionId, submitted); } + async #withReplacementLease(sessionId: string, action: () => Promise): Promise { + const predecessor = this.#replacementLanes.get(sessionId) ?? Promise.resolve(); + let release!: () => void; + const ownership = new Promise((resolve) => { + release = resolve; + }); + const tail = predecessor.then(() => ownership); + this.#replacementLanes.set(sessionId, tail); + await predecessor; + try { + return await action(); + } finally { + release(); + if (this.#replacementLanes.get(sessionId) === tail) { + this.#replacementLanes.delete(sessionId); + } + } + } + #assertTarget(target: WorkHubCoordinationCandidate): void { if (target.sessionId === WORKHUB_COORDINATION_SESSION_ID) { throw new WorkHubActionGateFailure('self_route', 'WorkHub cannot delegate to itself'); diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 0643571b20..6a12a0b82f 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -37,6 +37,10 @@ import type { WorkHubCoordinationAnswerInput, WorkHubCoordinationRecordInput, } from '../protocol/index.js'; +import { + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, + WORKHUB_COORDINATION_TEXT_MAX_BYTES, +} from '../protocol/index.js'; import type { ConnectionContext, WorkHubCoordinationOperationHandlerMap, @@ -64,6 +68,8 @@ const COORDINATION_ORCHESTRATION_MODE = 'default' as const; const COORDINATION_SUMMARY_MESSAGE_KINDS = ['user', 'assistant', 'state'] as const; const TURN_IDENTITY_CONFLICT_MESSAGE = 'WorkHub Coordination Turn identity belongs to a different operation'; +const COORDINATION_SUMMARY_READ_MAX_BYTES = + WORKHUB_COORDINATION_TEXT_MAX_BYTES + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES + 16 * 1024; type CoordinationStores = Pick< SessionAuthorityStore, @@ -394,7 +400,7 @@ export class HostWorkHubCoordinationCoordinator { coordinationSummaryMessageId(turnId, kind), ), throughSequence, - maxBytes: 32 * 1024, + maxBytes: COORDINATION_SUMMARY_READ_MAX_BYTES, maxMessages: COORDINATION_SUMMARY_MESSAGE_KINDS.length, }); } From f2785c5dc0dd8ecd1a4aa353b60494ee0cc00a9f Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 26 Aug 2026 02:15:27 +0800 Subject: [PATCH 3/7] fix(workhub): preserve correction receipts Preserve Runtime-admitted root receipts for natural-language corrections and avoid deleting newer ownership after a concurrent Stop.\n\nGenerated-by: Codex --- .../main/__tests__/workhub-controller.test.ts | 89 +++++++++ .../__tests__/workhub-surface-flow.test.ts | 42 ++++- .../src/renderer/workhub-controller.ts | 47 ++++- apps/desktop/src/renderer/workhub-surface.tsx | 66 ++++++- .../workhub-coordination-action-gate.test.ts | 177 +++++++++++++++++- .../workhub-coordination-action-gate.ts | 4 +- 6 files changed, 413 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 7b7c9f846e..a0a45194ca 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -20,6 +20,7 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; +import type { WorkHubCoordinationActInput } from '@maka/runtime-host/protocol'; import { createLegacyWorkHubControllerForTests as createWorkHubController, createWorkHubController as createGatedWorkHubController, @@ -2533,6 +2534,94 @@ test('production corrections fail closed instead of dropping an incomplete repla assert.deepEqual(actions, []); }); +test('production natural-language correction carries the Runtime-admitted source Turn', async () => { + const actions: WorkHubCoordinationActInput[] = []; + const sessions = port([ + session('login', { + sessionName: '登录稳定性', + latestResult: '刷新令牌过期导致重复登录', + updatedAt: 20, + }), + session('payment', { + sessionName: '支付稳定性', + latestResult: '支付回调重复投递', + updatedAt: 30, + }), + ]); + const candidateSetId = `sha256:${'e'.repeat(64)}`; + const candidates = [ + { + candidateRef: 'candidate-login', + sessionId: 'login', + sessionName: '登录稳定性', + workspace: { + target: { kind: 'host_path' as const, path: '/workspace/login' }, + hostCwd: '/workspace/login', + }, + state: 'active' as const, + updatedAt: 20, + }, + { + candidateRef: 'candidate-payment', + sessionId: 'payment', + sessionName: '支付稳定性', + workspace: { + target: { kind: 'host_path' as const, path: '/workspace/payment' }, + hostCwd: '/workspace/payment', + }, + state: 'active' as const, + updatedAt: 30, + }, + ]; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + answer: async (input) => ({ turnId: input.turnId }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId, candidates }), + act: async (input) => { + actions.push(input); + return { + disposition: 'delegate_existing', + targetSessionId: input.proposal.disposition === 'delegate_existing' && + input.proposal.candidateRef === 'candidate-login' + ? 'login' + : 'payment', + targetTurnId: input.actionId === 'production-wrong-payment' + ? 'runtime-payment-turn' + : 'runtime-login-turn', + }; + }, + }, + }); + await controller.read(); + await controller.submit({ + requestId: 'production-wrong-payment', + text: '继续这个工作,补充验收项', + }); + + const corrected = await controller.submit({ + requestId: 'production-natural-correction', + text: '不是这个,换成登录那个,补充刷新令牌失败判定', + }); + + assert.equal(corrected.kind, 'submitted'); + assert.deepEqual(actions[1], { + actionId: 'production-natural-correction', + userText: '不是这个,换成登录那个,补充刷新令牌失败判定', + candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: 'candidate-login', + replace: { + candidateRef: 'candidate-payment', + expectedTurnId: 'runtime-payment-turn', + }, + }, + }); +}); + test('production clarification is persisted through the typed Action Gate disposition', async () => { const actions: unknown[] = []; const controller = createGatedWorkHubController({ 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 fb5c643123..79731f1f66 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -29,6 +29,7 @@ import { submitWorkHubSurfaceInput, visibleWorkHubConversation, workHubReplacementText, + workHubSurfaceFailure, workHubSubmissionCanCorrect, workHubSubmissionClearsDraft, } from '../../renderer/workhub-surface.js'; @@ -51,7 +52,7 @@ test('correction clicks produce explicit replacement intent in both locales', () targetName: '登录', originalText: '补上重试逻辑', }), - '不是“支付”,改成“登录”:补上重试逻辑', + '不是原目标,改成“登录”:补上重试逻辑(原目标:“支付”)', ); assert.equal( workHubReplacementText({ @@ -60,10 +61,47 @@ test('correction clicks produce explicit replacement intent in both locales', () targetName: 'Login', originalText: 'Add the retry logic', }), - 'Not “Payments”; use “Login” instead: Add the retry logic', + 'Not the previous target; use “Login” instead: Add the retry logic (previous target: “Payments”)', ); }); +test('replacement intent keywords stay adjacent when a Session name is long', () => { + const sourceName = 'Source'.repeat(100); + const text = workHubReplacementText({ + locale: 'en', + sourceName, + targetName: 'Login', + originalText: 'Add the retry logic', + }); + + assert.match(text, /^Not the previous target; use /u); + assert.match(text, new RegExp(sourceName, 'u')); +}); + +test('surface turns Action Gate rejections into safe actionable failures', () => { + assert.equal( + workHubSurfaceFailure( + new Error('WorkHub Session candidates changed; refresh before delegating'), + ), + 'candidates_changed', + ); + assert.equal( + workHubSurfaceFailure(new Error('WorkHub cannot stop a Turn it did not admit')), + 'correction_expired', + ); + assert.equal( + workHubSurfaceFailure( + new Error('Stopping and rerouting work requires an explicit user correction'), + ), + 'confirmation_required', + ); + assert.equal( + workHubSurfaceFailure(new Error('Target Session is waiting for user input')), + 'target_waiting', + ); + assert.equal(workHubSurfaceFailure(new Error('private transport detail')), 'delivery_failed'); +}); + test('surface route gate rejects same-frame duplicate operations and reopens after settle', async () => { const gate = new WorkHubSurfaceRouteGate(); let release: (() => void) | undefined; diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 53179072c6..d61a375b71 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -275,6 +275,10 @@ function createWorkHubControllerImplementation(deps: { const confirmedOwnershipBySessionId = new Map(); const pendingAdmissionsBySessionId = new Map(); const ownershipTombstoneBySessionId = new Map(); + // This is a bounded, non-authoritative receipt used only to name the Turn + // in a later natural-language correction. Runtime Host remains authoritative + // and revalidates the exact root before Stop. + const gatedRootReceiptBySessionId = new Map(); const stopAttemptByTurn = new Map>(); const stopOperationCountBySessionId = new Map(); let ownershipRevision = 0; @@ -288,15 +292,45 @@ function createWorkHubControllerImplementation(deps: { .map((session) => session.target)); }; const correctionFor = (from: WorkHubSessionTarget): WorkHubCorrectionContext => { + const gated = deps.coordination + ? gatedRootReceiptBySessionId.get(from.sessionId) + : undefined; const confirmed = confirmedOwnershipBySessionId.get(from.sessionId); const pending = pendingAdmissionsBySessionId.get(from.sessionId); - const turnId = confirmed?.turnId ?? pending?.at(-1)?.turnId; + const turnId = gated?.turnId ?? confirmed?.turnId ?? pending?.at(-1)?.turnId; if (!turnId) return { from }; return { from, turnId, }; }; + const rememberGatedAdmission = ( + target: WorkHubSessionTarget, + admitted: { turnId: string; steered?: true }, + order: number, + correction?: WorkHubCorrectionContext, + ) => { + if (correction) { + const sourceReceipt = gatedRootReceiptBySessionId.get(correction.from.sessionId); + if (sourceReceipt?.turnId === correction.turnId) { + gatedRootReceiptBySessionId.delete(correction.from.sessionId); + } + } + // Steering joins an already-running root. The Action Gate deliberately + // preserves any earlier root it admitted for that Session, so the + // renderer's bounded correction receipt must do the same. + if (admitted.steered) return; + gatedRootReceiptBySessionId.set(target.sessionId, { + order, + turnId: admitted.turnId, + }); + while (gatedRootReceiptBySessionId.size > MAX_TRACKED_WORKHUB_ROOTS) { + const oldest = [...gatedRootReceiptBySessionId.entries()] + .sort((left, right) => left[1].order - right[1].order)[0]; + if (!oldest) return; + gatedRootReceiptBySessionId.delete(oldest[0]); + } + }; const pendingAdmissions = (sessionId: string): WorkHubPendingAdmission[] => pendingAdmissionsBySessionId.get(sessionId) ?? []; const setPendingAdmissions = ( @@ -764,6 +798,11 @@ function createWorkHubControllerImplementation(deps: { throw new Error('WorkHub Action Gate returned an unexpected disposition'); } target = { sessionId: admitted.targetSessionId }; + rememberGatedAdmission( + target, + { turnId: admitted.targetTurnId, ...(admitted.steered ? { steered: true } : {}) }, + submissionOrder, + ); submissionPolicy.rememberTarget(target); return { kind: 'submitted', @@ -838,6 +877,12 @@ function createWorkHubControllerImplementation(deps: { throw new Error('WorkHub Action Gate returned an unexpected disposition'); } target = { sessionId: admitted.targetSessionId }; + rememberGatedAdmission( + target, + { turnId: admitted.targetTurnId, ...(admitted.steered ? { steered: true } : {}) }, + submissionOrder, + correction, + ); submissionPolicy.rememberTarget(target); if (correction) { submissionPolicy.rememberCorrection(input.text, target, submissionOrder); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 75502f8080..94c7c45124 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -41,8 +41,17 @@ export interface WorkHubConversationTurn { text: string; state: 'routing' | 'settled' | 'failed'; outcome?: WorkHubSubmission; + failure?: WorkHubSurfaceFailure; } +export type WorkHubSurfaceFailure = + | 'candidates_changed' + | 'correction_expired' + | 'confirmation_required' + | 'target_waiting' + | 'action_changed' + | 'delivery_failed'; + export class WorkHubSurfaceRouteGate { #pending = false; @@ -87,8 +96,30 @@ export function workHubReplacementText(input: { originalText: string; }): string { return input.locale === 'zh' - ? `不是“${input.sourceName}”,改成“${input.targetName}”:${input.originalText}` - : `Not “${input.sourceName}”; use “${input.targetName}” instead: ${input.originalText}`; + ? `不是原目标,改成“${input.targetName}”:${input.originalText}(原目标:“${input.sourceName}”)` + : `Not the previous target; use “${input.targetName}” instead: ${input.originalText} (previous target: “${input.sourceName}”)`; +} + +export function workHubSurfaceFailure(error: unknown): WorkHubSurfaceFailure { + const message = error instanceof Error ? error.message : ''; + if ( + /candidates changed|not in the admitted candidate set|source or target is not in/iu.test( + message, + ) + ) { + return 'candidates_changed'; + } + if (/cannot stop a Turn it did not admit|source is outside/iu.test(message)) { + return 'correction_expired'; + } + if (/explicit user correction|requires an exact owned Turn/iu.test(message)) { + return 'confirmation_required'; + } + if (/waiting for user input/iu.test(message)) return 'target_waiting'; + if (/identity belongs to a different proposal|replacement target did not change/iu.test(message)) { + return 'action_changed'; + } + return 'delivery_failed'; } export function workHubSubmissionCanCorrect( @@ -239,10 +270,15 @@ export function WorkHubSurface(props: { )); if (result.kind === 'submitted') await refresh(); return result; - } catch { + } catch (error) { setTurns((current) => current.map((turn) => turn.requestId === localRequestId - ? { ...turn, state: 'failed', outcome: undefined } + ? { + ...turn, + state: 'failed', + outcome: undefined, + failure: workHubSurfaceFailure(error), + } : turn, )); return undefined; @@ -521,7 +557,9 @@ function WorkHubTurnView(props: { {turn.state === 'routing' ? (

{copy.routing}

) : turn.state === 'failed' ? ( -

{copy.submitFailed}

+

+ {copy.submitFailures[turn.failure ?? 'delivery_failed']} +

) : turn.outcome?.kind === 'clarification' ? ( <>

{copy.chooseWork}

@@ -695,7 +733,14 @@ function workHubCopy(locale: UiLocale) { coordinationFailedTitle: 'WorkHub 暂时无法启动', coordinationFailedBody: '请检查当前 Runtime Host 的默认模型配置,然后重试。', retry: '重试', - submitFailed: '输入未能送达,请重试。', scrollToBottom: '滚动到底部', archived: '已归档', + submitFailures: { + candidates_changed: '工作列表已变化,请重新发送以使用最新目标。', + correction_expired: '原目标已无法安全更正,请重新选择目标后发送。', + confirmation_required: '请明确说明停止原目标并改交给哪个 Session。', + target_waiting: '目标 Session 正在等待你的处理;请先打开并完成该交互。', + action_changed: '这次操作已发生变化,请重新发送。', + delivery_failed: '输入未能送达,请重试。', + }, scrollToBottom: '滚动到底部', archived: '已归档', states: { active: '活跃', running: '进行中', waiting_for_user: '等待你', blocked: '受阻', aborted: '已中止' }, turnStates: { running: '进行中', completed: '已完成', aborted: '已中止', failed: '失败' }, } as const; @@ -725,7 +770,14 @@ function workHubCopy(locale: UiLocale) { coordinationFailedTitle: 'WorkHub could not start', coordinationFailedBody: 'Check the default model for the current Runtime Host, then retry.', retry: 'Retry', - submitFailed: 'The input could not be delivered. Try again.', scrollToBottom: 'Scroll to bottom', archived: 'Archived', + submitFailures: { + candidates_changed: 'The work list changed. Send again to use the latest targets.', + correction_expired: 'The previous target can no longer be corrected safely. Choose a target again.', + confirmation_required: 'Explicitly say to stop the previous target and name its replacement.', + target_waiting: 'The target Session needs your input. Open it and resolve that interaction first.', + action_changed: 'This action changed. Send it again.', + delivery_failed: 'The input could not be delivered. Try again.', + }, scrollToBottom: 'Scroll to bottom', archived: 'Archived', states: { active: 'Active', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Blocked', aborted: 'Aborted' }, turnStates: { running: 'Running', completed: 'Completed', aborted: 'Aborted', failed: 'Failed' }, } as const; diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index f14874d043..6f96cffa1d 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -20,6 +20,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { + WorkHubActionEffectFailure, WorkHubActionGateFailure, WorkHubCoordinationActionGate, type WorkHubActionGateEffects, @@ -172,12 +173,24 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.answers.length, 1); assert.equal(effects.clarifications.length, 1); assert.deepEqual(effects.submissions, []); - assert.deepEqual(effects.creations, []); + assert.equal(effects.creations.length, 0); }); test('only create_new creates and retries the exact action idempotently', async () => { const effects = fakeEffects([session('ordinary')]); const gate = new WorkHubCoordinationActionGate(effects); + await assert.rejects( + gate.act( + { + actionId: 'missing-create-context', + userText: 'Create an accessibility audit', + proposal: { disposition: 'create_new', title: 'Accessibility audit' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.creations.length, 0); const input = { actionId: 'create-action', userText: 'Create an accessibility audit', @@ -210,6 +223,32 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.creations.length, 1); }); + test('effect rejection grants no root ownership and releases the action identity', async () => { + const effects = fakeEffects([session('ordinary')]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const submit = effects.submit; + effects.submit = async () => { + throw new WorkHubActionEffectFailure('unauthorized', 'Target permission denied'); + }; + const input = { + actionId: 'permission-rejected-action', + userText: 'Continue ordinary work', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing' as const, + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }; + + await assert.rejects( + gate.act(input, CONTEXT), + (error) => error instanceof WorkHubActionEffectFailure && error.code === 'unauthorized', + ); + effects.submit = submit; + assert.equal((await gate.act(input, CONTEXT)).disposition, 'delegate_existing'); + }); + test('stops only an exact root previously admitted by this gate', async () => { const effects = fakeEffects([session('source'), session('target', { statusUpdatedAt: 2 })]); const gate = new WorkHubCoordinationActionGate(effects); @@ -246,6 +285,44 @@ describe('WorkHub Coordination Action Gate', () => { ); assert.deepEqual(effects.stops, []); + await assert.rejects( + gate.act( + { + actionId: 'missing-source-correction', + userText: 'No, use target instead', + candidateSetId: firstSet.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: target.candidateRef, + replace: { candidateRef: 'missing-source', expectedTurnId: first.targetTurnId }, + }, + }, + CONTEXT, + ), + (error) => + error instanceof WorkHubActionGateFailure && error.code === 'candidate_unavailable', + ); + await assert.rejects( + gate.act( + { + actionId: 'same-target-correction', + userText: 'No, use source instead', + candidateSetId: firstSet.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: source.candidateRef, + replace: { + candidateRef: source.candidateRef, + expectedTurnId: first.targetTurnId, + }, + }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.deepEqual(effects.stops, []); + await assert.rejects( gate.act( { @@ -359,6 +436,104 @@ describe('WorkHub Coordination Action Gate', () => { ['source', 'target-a'], ); }); + + test('a completed Stop cannot erase a newer root admitted to the same source', async () => { + const effects = fakeEffects([ + session('source'), + session('target-a', { statusUpdatedAt: 2 }), + session('target-b', { statusUpdatedAt: 3 }), + ]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const source = snapshot.candidates.find(({ sessionId }) => sessionId === 'source')!; + const targetA = snapshot.candidates.find(({ sessionId }) => sessionId === 'target-a')!; + const targetB = snapshot.candidates.find(({ sessionId }) => sessionId === 'target-b')!; + const original = await gate.act( + { + actionId: 'original-source-action', + userText: 'Start source work', + candidateSetId: snapshot.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, + }, + CONTEXT, + ); + assert.equal(original.disposition, 'delegate_existing'); + if (original.disposition !== 'delegate_existing') return; + + let signalStop!: () => void; + const stopStarted = new Promise((resolve) => { + signalStop = resolve; + }); + let releaseStop!: () => void; + const stopBarrier = new Promise((resolve) => { + releaseStop = resolve; + }); + effects.stop = async (input) => { + effects.stops.push(input); + signalStop(); + await stopBarrier; + }; + effects.submit = async (input) => { + effects.submissions.push(input); + return { + turnId: input.sessionId === 'source' + ? 'turn-source-renewed' + : `turn-${input.sessionId}`, + }; + }; + + const firstReplacement = gate.act( + { + actionId: 'first-replacement', + userText: 'No, use target-a instead', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: targetA.candidateRef, + replace: { + candidateRef: source.candidateRef, + expectedTurnId: original.targetTurnId, + }, + }, + }, + CONTEXT, + ); + await stopStarted; + const renewed = await gate.act( + { + actionId: 'renew-source', + userText: 'Start newer source work', + candidateSetId: snapshot.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, + }, + CONTEXT, + ); + assert.equal(renewed.disposition, 'delegate_existing'); + if (renewed.disposition !== 'delegate_existing') return; + + releaseStop(); + await firstReplacement; + await gate.act( + { + actionId: 'replace-renewed-source', + userText: 'No, use target-b instead', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: targetB.candidateRef, + replace: { + candidateRef: source.candidateRef, + expectedTurnId: renewed.targetTurnId, + }, + }, + }, + CONTEXT, + ); + assert.deepEqual(effects.stops, [ + { sessionId: 'source', turnId: original.targetTurnId }, + { sessionId: 'source', turnId: renewed.targetTurnId }, + ]); + }); }); function session( 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 579e5e25d0..495c9cdfe4 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -306,7 +306,9 @@ export class WorkHubCoordinationActionGate { { sessionId: freshSource.sessionId, turnId: owned.turnId }, context, ); - this.#ownedRoots.delete(freshSource.sessionId); + if (this.#ownedRoots.get(freshSource.sessionId) === owned) { + this.#ownedRoots.delete(freshSource.sessionId); + } return this.#submitExisting(input, freshTarget, context); }); } From 05d3d26e20d87bae11356dcacb6a33a5e2206c22 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 26 Aug 2026 02:37:31 +0800 Subject: [PATCH 4/7] style(workhub): satisfy formatter Generated-by: Codex --- .../src/__tests__/workhub-coordination-action-gate.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 6f96cffa1d..8a1edb010d 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -476,9 +476,7 @@ describe('WorkHub Coordination Action Gate', () => { effects.submit = async (input) => { effects.submissions.push(input); return { - turnId: input.sessionId === 'source' - ? 'turn-source-renewed' - : `turn-${input.sessionId}`, + turnId: input.sessionId === 'source' ? 'turn-source-renewed' : `turn-${input.sessionId}`, }; }; From 92d0947890d2aeec9a6363f17b68ff0850deb5b0 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 26 Aug 2026 02:55:50 +0800 Subject: [PATCH 5/7] fix(workhub): recover replacement retries Resume the exact target submission after a replacement Stop and budget summary replay reads for worst-case JSON escaping.\n\nGenerated-by: Codex --- .../workhub-coordination-action-gate.test.ts | 61 ++++++++++++ .../workhub-coordination-coordinator.test.ts | 7 +- .../workhub-coordination-action-gate.ts | 94 +++++++++++++++++-- .../workhub-coordination-coordinator.ts | 6 +- 4 files changed, 156 insertions(+), 12 deletions(-) diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 8a1edb010d..324976a349 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -360,6 +360,67 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.submissions.at(-1)?.sessionId, 'target'); }); + test('retries target submission without stopping the source twice after an unknown outcome', async () => { + const effects = fakeEffects([session('source'), session('target', { statusUpdatedAt: 2 })]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const source = snapshot.candidates.find(({ sessionId }) => sessionId === 'source')!; + const target = snapshot.candidates.find(({ sessionId }) => sessionId === 'target')!; + const admitted = await gate.act( + { + actionId: 'source-action-before-unknown-submit', + userText: 'Start source work', + candidateSetId: snapshot.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, + }, + CONTEXT, + ); + assert.equal(admitted.disposition, 'delegate_existing'); + if (admitted.disposition !== 'delegate_existing') return; + + let targetAttempts = 0; + effects.submit = async (input) => { + effects.submissions.push(input); + if (input.sessionId === 'target' && targetAttempts++ === 0) { + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + 'Target submission may have committed', + ); + } + return { turnId: `turn-${input.sessionId}` }; + }; + const replacement = { + actionId: 'replacement-with-unknown-submit', + userText: 'No, use target instead', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing' as const, + candidateRef: target.candidateRef, + replace: { + candidateRef: source.candidateRef, + expectedTurnId: admitted.targetTurnId, + }, + }, + }; + + await assert.rejects( + gate.act(replacement, CONTEXT), + (error) => + error instanceof WorkHubActionEffectFailure && error.code === 'commit_outcome_unknown', + ); + await assert.rejects( + gate.act({ ...replacement, userText: 'No, use a different target instead' }, CONTEXT), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + const retried = await gate.act(replacement, CONTEXT); + + assert.equal(retried.disposition, 'delegate_existing'); + assert.deepEqual(effects.stops, [{ sessionId: 'source', turnId: admitted.targetTurnId }]); + const targetSubmissions = effects.submissions.filter(({ sessionId }) => sessionId === 'target'); + assert.equal(targetSubmissions.length, 2); + assert.equal(targetSubmissions[0]?.messageId, targetSubmissions[1]?.messageId); + }); + test('serializes replacements from one source and admits only one target', async () => { const effects = fakeEffects([ session('source'), 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 94dc25416d..46799f00ac 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -437,8 +437,11 @@ describe('Host WorkHub Coordination coordinator', () => { }); const maximumInput = { turnId: 'maximum-summary-turn', - userText: 'u'.repeat(WORKHUB_COORDINATION_TEXT_MAX_BYTES), - assistantText: 'a'.repeat(WORKHUB_COORDINATION_SUMMARY_MAX_BYTES), + // 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), 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 495c9cdfe4..6795a66cd5 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -36,6 +36,7 @@ import type { ConnectionContext } from './operation-dispatcher.js'; const SIDE_CONVERSATION_LABEL = 'mode:side_conversation'; const ACTION_REPLAY_MAX_ITEMS = 256; +const REPLACEMENT_RECOVERY_MAX_ITEMS = ACTION_REPLAY_MAX_ITEMS; export type WorkHubActionGateSession = Pick< SessionHeader, @@ -135,6 +136,14 @@ interface OwnedRoot { readonly actionId: string; } +/** Host-lifetime checkpoint for the non-atomic Stop-then-submit boundary. */ +interface ReplacementRecovery { + readonly fingerprint: string; + readonly sourceSessionId: string; + readonly targetSessionId: string; + stopped: boolean; +} + /** * The sole admission module between a WorkHub strategy proposal and Session effects. * @@ -146,6 +155,7 @@ export class WorkHubCoordinationActionGate { readonly #effects: WorkHubActionGateEffects; readonly #actions = new Map(); readonly #ownedRoots = new Map(); + readonly #replacementRecoveries = new Map(); readonly #replacementLanes = new Map>(); constructor(effects: WorkHubActionGateEffects) { @@ -161,6 +171,15 @@ export class WorkHubCoordinationActionGate { context: ConnectionContext, ): Promise { const fingerprint = digest(input); + const recovery = this.#replacementRecoveries.get(input.actionId); + if (recovery && recovery.fingerprint !== fingerprint) { + return Promise.reject( + new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub action identity belongs to a different proposal', + ), + ); + } const replay = this.#actions.get(input.actionId); if (replay) { if (replay.fingerprint !== fingerprint) { @@ -174,7 +193,7 @@ export class WorkHubCoordinationActionGate { return replay.result; } - const result = this.#act(input, context); + const result = this.#act(input, context, fingerprint); const action = { fingerprint, result }; this.#actions.set(input.actionId, action); // Successful actions remain replayable. A rejected admission does not own @@ -192,7 +211,23 @@ export class WorkHubCoordinationActionGate { async #act( input: WorkHubCoordinationActInput, context: ConnectionContext, + fingerprint: string, ): Promise { + const recovery = this.#replacementRecoveries.get(input.actionId); + if (recovery) { + if (!recovery.stopped) { + throw new WorkHubActionEffectFailure( + 'host_not_ready', + 'WorkHub replacement Stop is still settling', + ); + } + // The destructive half already committed. Reconcile only the exact + // idempotent target submission; a fresh candidate snapshot must not turn + // a lost reply into a second Stop or strand the replacement permanently. + return this.#withReplacementLease(recovery.sourceSessionId, () => + this.#resumeReplacement(input, recovery, context), + ); + } const proposal = input.proposal; if (proposal.disposition === 'answer_here') { const turnId = coordinationTurnId(input.actionId, 'answer'); @@ -302,14 +337,35 @@ export class WorkHubCoordinationActionGate { 'WorkHub cannot stop a Turn it did not admit', ); } - await this.#effects.stop( - { sessionId: freshSource.sessionId, turnId: owned.turnId }, - context, - ); + if (this.#replacementRecoveries.size >= REPLACEMENT_RECOVERY_MAX_ITEMS) { + throw new WorkHubActionEffectFailure( + 'host_not_ready', + 'WorkHub replacement recovery capacity is unavailable', + ); + } + const recovery: ReplacementRecovery = { + fingerprint, + sourceSessionId: freshSource.sessionId, + targetSessionId: freshTarget.sessionId, + stopped: false, + }; + this.#replacementRecoveries.set(input.actionId, recovery); + try { + await this.#effects.stop( + { sessionId: freshSource.sessionId, turnId: owned.turnId }, + context, + ); + recovery.stopped = true; + } catch (error) { + if (this.#replacementRecoveries.get(input.actionId) === recovery) { + this.#replacementRecoveries.delete(input.actionId); + } + throw error; + } if (this.#ownedRoots.get(freshSource.sessionId) === owned) { this.#ownedRoots.delete(freshSource.sessionId); } - return this.#submitExisting(input, freshTarget, context); + return this.#resumeReplacement(input, recovery, context); }); } @@ -320,17 +376,37 @@ export class WorkHubCoordinationActionGate { input: WorkHubCoordinationActInput, target: WorkHubCoordinationCandidate, context: ConnectionContext, + ): Promise { + return this.#submitExistingSession(input, target.sessionId, context); + } + + async #submitExistingSession( + input: WorkHubCoordinationActInput, + sessionId: string, + context: ConnectionContext, ): Promise { const submitted = await this.#effects.submit( { - sessionId: target.sessionId, + sessionId, messageId: actionMessageId(input.actionId), text: input.userText, }, context, ); - this.#rememberRoot(target.sessionId, input.actionId, submitted); - return executionResult('delegate_existing', target.sessionId, submitted); + this.#rememberRoot(sessionId, input.actionId, submitted); + return executionResult('delegate_existing', sessionId, submitted); + } + + async #resumeReplacement( + input: WorkHubCoordinationActInput, + recovery: ReplacementRecovery, + context: ConnectionContext, + ): Promise { + const result = await this.#submitExistingSession(input, recovery.targetSessionId, context); + if (this.#replacementRecoveries.get(input.actionId) === recovery) { + this.#replacementRecoveries.delete(input.actionId); + } + return result; } async #withReplacementLease(sessionId: string, action: () => Promise): Promise { diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 6a12a0b82f..21037f94af 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -68,8 +68,12 @@ const COORDINATION_ORCHESTRATION_MODE = 'default' as const; const COORDINATION_SUMMARY_MESSAGE_KINDS = ['user', 'assistant', 'state'] as const; const TURN_IDENTITY_CONFLICT_MESSAGE = 'WorkHub Coordination Turn identity belongs to a different operation'; +// A one-byte control character can occupy six bytes as a JSON `\u0000` escape. +const JSON_ESCAPE_MAX_BYTES_PER_INPUT_BYTE = 6; const COORDINATION_SUMMARY_READ_MAX_BYTES = - WORKHUB_COORDINATION_TEXT_MAX_BYTES + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES + 16 * 1024; + JSON_ESCAPE_MAX_BYTES_PER_INPUT_BYTE * + (WORKHUB_COORDINATION_TEXT_MAX_BYTES + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES) + + 16 * 1024; type CoordinationStores = Pick< SessionAuthorityStore, From ba1eec3fe94217e0181280be893990836889ae1f Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 26 Aug 2026 10:20:21 +0800 Subject: [PATCH 6/7] fix(workhub): bound replacement recovery Release recovery checkpoints after definitive target failures and expire uncertain outcomes after a bounded reconciliation window.\n\nGenerated-by: Codex --- .../workhub-coordination-action-gate.test.ts | 149 ++++++++++++++++++ .../workhub-coordination-action-gate.ts | 51 ++++-- 2 files changed, 190 insertions(+), 10 deletions(-) diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 324976a349..e22bcfe685 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -421,6 +421,136 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(targetSubmissions[0]?.messageId, targetSubmissions[1]?.messageId); }); + test('definitive target failures release replacement recovery capacity', async () => { + const effects = fakeEffects([session('source'), session('target', { statusUpdatedAt: 2 })]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const source = snapshot.candidates.find(({ sessionId }) => sessionId === 'source')!; + const target = snapshot.candidates.find(({ sessionId }) => sessionId === 'target')!; + effects.submit = async (input) => { + effects.submissions.push(input); + if (input.sessionId === 'target') { + throw new WorkHubActionEffectFailure('session_busy', 'Target cannot accept this message'); + } + return { turnId: `turn-source-${input.messageId}` }; + }; + + // One more than the recovery bound proves permanent failures cannot + // accumulate until every replacement is rejected Host-wide. + for (let index = 0; index <= 256; index += 1) { + const admitted = await gate.act( + { + actionId: `source-before-definitive-failure-${index}`, + userText: 'Start source work', + candidateSetId: snapshot.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, + }, + CONTEXT, + ); + assert.equal(admitted.disposition, 'delegate_existing'); + if (admitted.disposition !== 'delegate_existing') return; + await assert.rejects( + gate.act( + { + actionId: `definitive-target-failure-${index}`, + userText: 'No, use target instead', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: target.candidateRef, + replace: { + candidateRef: source.candidateRef, + expectedTurnId: admitted.targetTurnId, + }, + }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionEffectFailure && error.code === 'session_busy', + ); + } + + assert.equal(effects.stops.length, 257); + }); + + test('unknown replacement outcomes expire instead of exhausting the Host until restart', async () => { + const effects = fakeEffects([session('source'), session('target', { statusUpdatedAt: 2 })]); + let now = 0; + const gate = new WorkHubCoordinationActionGate(effects, { now: () => now }); + const snapshot = await gate.candidates(); + const source = snapshot.candidates.find(({ sessionId }) => sessionId === 'source')!; + const target = snapshot.candidates.find(({ sessionId }) => sessionId === 'target')!; + effects.submit = async (input) => { + effects.submissions.push(input); + if (input.sessionId === 'target') { + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + 'Target submission may have committed', + ); + } + return { turnId: `turn-source-${input.messageId}` }; + }; + + for (let index = 0; index < 256; index += 1) { + const admitted = await gate.act( + { + actionId: `source-before-unknown-expiry-${index}`, + userText: 'Start source work', + candidateSetId: snapshot.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, + }, + CONTEXT, + ); + assert.equal(admitted.disposition, 'delegate_existing'); + if (admitted.disposition !== 'delegate_existing') return; + await assert.rejects( + gate.act( + replacementInput( + `unknown-target-outcome-${index}`, + snapshot.candidateSetId, + source.candidateRef, + target.candidateRef, + admitted.targetTurnId, + ), + CONTEXT, + ), + (error) => + error instanceof WorkHubActionEffectFailure && error.code === 'commit_outcome_unknown', + ); + } + + const nextSource = await gate.act( + { + actionId: 'source-before-capacity-recovery', + userText: 'Start source work', + candidateSetId: snapshot.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, + }, + CONTEXT, + ); + assert.equal(nextSource.disposition, 'delegate_existing'); + if (nextSource.disposition !== 'delegate_existing') return; + const afterCapacity = replacementInput( + 'replacement-after-capacity-recovery', + snapshot.candidateSetId, + source.candidateRef, + target.candidateRef, + nextSource.targetTurnId, + ); + await assert.rejects( + gate.act(afterCapacity, CONTEXT), + (error) => error instanceof WorkHubActionEffectFailure && error.code === 'host_not_ready', + ); + + now += 24 * 60 * 60 * 1000; + await assert.rejects( + gate.act(afterCapacity, CONTEXT), + (error) => + error instanceof WorkHubActionEffectFailure && error.code === 'commit_outcome_unknown', + ); + assert.equal(effects.stops.length, 257); + }); + test('serializes replacements from one source and admits only one target', async () => { const effects = fakeEffects([ session('source'), @@ -595,6 +725,25 @@ describe('WorkHub Coordination Action Gate', () => { }); }); +function replacementInput( + actionId: string, + candidateSetId: string, + sourceCandidateRef: string, + targetCandidateRef: string, + expectedTurnId: string, +) { + return { + actionId, + userText: 'No, use target instead', + candidateSetId, + proposal: { + disposition: 'delegate_existing' as const, + candidateRef: targetCandidateRef, + replace: { candidateRef: sourceCandidateRef, expectedTurnId }, + }, + }; +} + function session( id: string, patch: Partial = {}, 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 6795a66cd5..86bff35b71 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -37,6 +37,7 @@ import type { ConnectionContext } from './operation-dispatcher.js'; const SIDE_CONVERSATION_LABEL = 'mode:side_conversation'; const ACTION_REPLAY_MAX_ITEMS = 256; const REPLACEMENT_RECOVERY_MAX_ITEMS = ACTION_REPLAY_MAX_ITEMS; +const REPLACEMENT_RECOVERY_TTL_MS = 5 * 60 * 1000; export type WorkHubActionGateSession = Pick< SessionHeader, @@ -141,7 +142,8 @@ interface ReplacementRecovery { readonly fingerprint: string; readonly sourceSessionId: string; readonly targetSessionId: string; - stopped: boolean; + state: 'stopping' | 'submitting' | 'uncertain'; + expiresAt: number; } /** @@ -153,13 +155,15 @@ interface ReplacementRecovery { */ export class WorkHubCoordinationActionGate { readonly #effects: WorkHubActionGateEffects; + readonly #now: () => number; readonly #actions = new Map(); readonly #ownedRoots = new Map(); readonly #replacementRecoveries = new Map(); readonly #replacementLanes = new Map>(); - constructor(effects: WorkHubActionGateEffects) { + constructor(effects: WorkHubActionGateEffects, options: { readonly now?: () => number } = {}) { this.#effects = effects; + this.#now = options.now ?? Date.now; } async candidates(): Promise { @@ -170,6 +174,7 @@ export class WorkHubCoordinationActionGate { input: WorkHubCoordinationActInput, context: ConnectionContext, ): Promise { + this.#pruneExpiredRecoveries(); const fingerprint = digest(input); const recovery = this.#replacementRecoveries.get(input.actionId); if (recovery && recovery.fingerprint !== fingerprint) { @@ -215,10 +220,10 @@ export class WorkHubCoordinationActionGate { ): Promise { const recovery = this.#replacementRecoveries.get(input.actionId); if (recovery) { - if (!recovery.stopped) { + if (recovery.state !== 'uncertain') { throw new WorkHubActionEffectFailure( 'host_not_ready', - 'WorkHub replacement Stop is still settling', + 'WorkHub replacement is still settling', ); } // The destructive half already committed. Reconcile only the exact @@ -337,6 +342,7 @@ export class WorkHubCoordinationActionGate { 'WorkHub cannot stop a Turn it did not admit', ); } + this.#pruneExpiredRecoveries(); if (this.#replacementRecoveries.size >= REPLACEMENT_RECOVERY_MAX_ITEMS) { throw new WorkHubActionEffectFailure( 'host_not_ready', @@ -347,7 +353,8 @@ export class WorkHubCoordinationActionGate { fingerprint, sourceSessionId: freshSource.sessionId, targetSessionId: freshTarget.sessionId, - stopped: false, + state: 'stopping', + expiresAt: 0, }; this.#replacementRecoveries.set(input.actionId, recovery); try { @@ -355,7 +362,6 @@ export class WorkHubCoordinationActionGate { { sessionId: freshSource.sessionId, turnId: owned.turnId }, context, ); - recovery.stopped = true; } catch (error) { if (this.#replacementRecoveries.get(input.actionId) === recovery) { this.#replacementRecoveries.delete(input.actionId); @@ -402,11 +408,36 @@ export class WorkHubCoordinationActionGate { recovery: ReplacementRecovery, context: ConnectionContext, ): Promise { - const result = await this.#submitExistingSession(input, recovery.targetSessionId, context); - if (this.#replacementRecoveries.get(input.actionId) === recovery) { - this.#replacementRecoveries.delete(input.actionId); + recovery.state = 'submitting'; + try { + const result = await this.#submitExistingSession(input, recovery.targetSessionId, context); + if (this.#replacementRecoveries.get(input.actionId) === recovery) { + this.#replacementRecoveries.delete(input.actionId); + } + return result; + } catch (error) { + if (this.#replacementRecoveries.get(input.actionId) === recovery) { + if ( + error instanceof WorkHubActionEffectFailure && + error.code === 'commit_outcome_unknown' + ) { + recovery.state = 'uncertain'; + recovery.expiresAt = this.#now() + REPLACEMENT_RECOVERY_TTL_MS; + } else { + this.#replacementRecoveries.delete(input.actionId); + } + } + throw error; + } + } + + #pruneExpiredRecoveries(): void { + const now = this.#now(); + for (const [actionId, recovery] of this.#replacementRecoveries) { + if (recovery.state === 'uncertain' && recovery.expiresAt <= now) { + this.#replacementRecoveries.delete(actionId); + } } - return result; } async #withReplacementLease(sessionId: string, action: () => Promise): Promise { From 9532d2d742adbf6fb27105bdfd0e53cb1e14b81e Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 26 Aug 2026 11:51:41 +0800 Subject: [PATCH 7/7] refactor(workhub): defer destructive correction to slice 5 Generated-by: Codex --- .../e2e/workhub-reconstruction.spec.ts | 15 +- .../main/__tests__/workhub-controller.test.ts | 48 +- .../__tests__/workhub-surface-flow.test.ts | 58 +- .../src/renderer/workhub-controller.ts | 84 +-- apps/desktop/src/renderer/workhub-surface.tsx | 113 +--- .../workhub-coordination-action-gate.test.ts | 499 +----------------- .../workhub-coordination-coordinator.test.ts | 1 - .../workhub-coordination-protocol.test.ts | 17 + .../src/protocol/workhub-coordination.ts | 29 +- .../src/server/execution-composition.ts | 17 - .../workhub-coordination-action-gate.ts | 243 +-------- .../workhub-coordination-coordinator.ts | 3 +- 12 files changed, 87 insertions(+), 1040 deletions(-) diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index 4cf098c93d..bda08351cc 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -65,7 +65,7 @@ test('WorkHub rebuilds Session conversation after navigating away and back', asy ).toBeVisible(); }); -test('WorkHub handles a first natural-language correction', async ({ +test('WorkHub defers destructive correction until linked delegation exists', async ({ window: page, }) => { const sourceSessionName = '检查支付回调重复投递时的幂等性'; @@ -107,10 +107,11 @@ test('WorkHub handles a first natural-language correction', async ({ ).toBeEnabled(); await workHubComposer.press('Enter'); - await expect(page.locator('.workhub-correction-note').last()).toBeVisible(); - await expect( - page.locator('.workhub-turn', { - hasText: '不是这个,换成登录稳定性,补充刷新令牌失败判定。', - }).locator('.workhub-submitted-session strong'), - ).toHaveText('登录稳定性'); + const correctionTurn = page.locator('.workhub-turn', { + hasText: '不是这个,换成登录稳定性,补充刷新令牌失败判定。', + }); + await expect(correctionTurn.locator('.workhub-error')).toContainText( + '跨 Session 更正将在持久委托关联完成后开放', + ); + await expect(correctionTurn.locator('.workhub-submitted')).toHaveCount(0); }); diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index a0a45194ca..12a9e8f0f2 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -20,7 +20,6 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; -import type { WorkHubCoordinationActInput } from '@maka/runtime-host/protocol'; import { createLegacyWorkHubControllerForTests as createWorkHubController, createWorkHubController as createGatedWorkHubController, @@ -2470,7 +2469,7 @@ test('production submission delegates only through the Runtime-owned candidate r }]); }); -test('production corrections fail closed instead of dropping an incomplete replacement', async () => { +test('production defers destructive correction until persistent delegation exists', async () => { const actions: unknown[] = []; const sessions = port([session('source'), session('target')]); const controller = createGatedWorkHubController({ @@ -2515,27 +2514,18 @@ test('production corrections fail closed instead of dropping an incomplete repla await assert.rejects( controller.submit({ - requestId: 'missing-turn', + requestId: 'deferred-correction', text: 'No, use target instead', explicitTarget: { sessionId: 'target' }, - correction: { from: { sessionId: 'source' } }, + correction: { from: { sessionId: 'source' }, turnId: 'source-turn' }, }), - /requires an exact owned Turn/u, - ); - await assert.rejects( - controller.submit({ - requestId: 'missing-source', - text: 'No, use target instead', - explicitTarget: { sessionId: 'target' }, - correction: { from: { sessionId: 'outside' }, turnId: 'source-turn' }, - }), - /source is outside the admitted candidate set/u, + /linked correction requires persistent delegation support/u, ); assert.deepEqual(actions, []); }); -test('production natural-language correction carries the Runtime-admitted source Turn', async () => { - const actions: WorkHubCoordinationActInput[] = []; +test('production natural-language correction fails closed before a second delegation', async () => { + const actions: unknown[] = []; const sessions = port([ session('login', { sessionName: '登录稳定性', @@ -2601,25 +2591,23 @@ test('production natural-language correction carries the Runtime-admitted source text: '继续这个工作,补充验收项', }); - const corrected = await controller.submit({ - requestId: 'production-natural-correction', - text: '不是这个,换成登录那个,补充刷新令牌失败判定', - }); + await assert.rejects( + controller.submit({ + requestId: 'production-natural-correction', + text: '不是这个,换成登录那个,补充刷新令牌失败判定', + }), + /linked correction requires persistent delegation support/u, + ); - assert.equal(corrected.kind, 'submitted'); - assert.deepEqual(actions[1], { - actionId: 'production-natural-correction', - userText: '不是这个,换成登录那个,补充刷新令牌失败判定', + assert.deepEqual(actions, [{ + actionId: 'production-wrong-payment', + userText: '继续这个工作,补充验收项', candidateSetId, proposal: { disposition: 'delegate_existing', - candidateRef: 'candidate-login', - replace: { - candidateRef: 'candidate-payment', - expectedTurnId: 'runtime-payment-turn', - }, + candidateRef: 'candidate-payment', }, - }); + }]); }); test('production clarification is persisted through the typed Action Gate disposition', async () => { 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 79731f1f66..3440e0b854 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -28,9 +28,7 @@ import { WorkHubSurfaceRouteGate, submitWorkHubSurfaceInput, visibleWorkHubConversation, - workHubReplacementText, workHubSurfaceFailure, - workHubSubmissionCanCorrect, workHubSubmissionClearsDraft, } from '../../renderer/workhub-surface.js'; import { @@ -44,40 +42,6 @@ import { type WorkHubDesktopSession, } from '../../renderer/workhub-session-port.js'; -test('correction clicks produce explicit replacement intent in both locales', () => { - assert.equal( - workHubReplacementText({ - locale: 'zh', - sourceName: '支付', - targetName: '登录', - originalText: '补上重试逻辑', - }), - '不是原目标,改成“登录”:补上重试逻辑(原目标:“支付”)', - ); - assert.equal( - workHubReplacementText({ - locale: 'en', - sourceName: 'Payments', - targetName: 'Login', - originalText: 'Add the retry logic', - }), - 'Not the previous target; use “Login” instead: Add the retry logic (previous target: “Payments”)', - ); -}); - -test('replacement intent keywords stay adjacent when a Session name is long', () => { - const sourceName = 'Source'.repeat(100); - const text = workHubReplacementText({ - locale: 'en', - sourceName, - targetName: 'Login', - originalText: 'Add the retry logic', - }); - - assert.match(text, /^Not the previous target; use /u); - assert.match(text, new RegExp(sourceName, 'u')); -}); - test('surface turns Action Gate rejections into safe actionable failures', () => { assert.equal( workHubSurfaceFailure( @@ -85,15 +49,11 @@ test('surface turns Action Gate rejections into safe actionable failures', () => ), 'candidates_changed', ); - assert.equal( - workHubSurfaceFailure(new Error('WorkHub cannot stop a Turn it did not admit')), - 'correction_expired', - ); assert.equal( workHubSurfaceFailure( - new Error('Stopping and rerouting work requires an explicit user correction'), + new Error('WorkHub linked correction requires persistent delegation support'), ), - 'confirmation_required', + 'linked_correction_unavailable', ); assert.equal( workHubSurfaceFailure(new Error('Target Session is waiting for user input')), @@ -171,20 +131,6 @@ test('surface keeps the Composer draft when routing fails or the target is waiti }), true); }); -test('surface disables correction after a request was steered into existing work', () => { - const submission = { - kind: 'submitted' as const, - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'steered', - target: { sessionId: 'payment' }, - turnId: 'turn-existing', - evidence: 'explicit_target' as const, - }; - - assert.equal(workHubSubmissionCanCorrect(submission), true); - assert.equal(workHubSubmissionCanCorrect({ ...submission, steered: true }), false); -}); - test('surface replaces a local discussion placeholder with its durable model answer', () => { const local = [{ requestId: 'discussion-turn', diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index d61a375b71..420e11a8af 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -275,10 +275,6 @@ function createWorkHubControllerImplementation(deps: { const confirmedOwnershipBySessionId = new Map(); const pendingAdmissionsBySessionId = new Map(); const ownershipTombstoneBySessionId = new Map(); - // This is a bounded, non-authoritative receipt used only to name the Turn - // in a later natural-language correction. Runtime Host remains authoritative - // and revalidates the exact root before Stop. - const gatedRootReceiptBySessionId = new Map(); const stopAttemptByTurn = new Map>(); const stopOperationCountBySessionId = new Map(); let ownershipRevision = 0; @@ -292,45 +288,15 @@ function createWorkHubControllerImplementation(deps: { .map((session) => session.target)); }; const correctionFor = (from: WorkHubSessionTarget): WorkHubCorrectionContext => { - const gated = deps.coordination - ? gatedRootReceiptBySessionId.get(from.sessionId) - : undefined; const confirmed = confirmedOwnershipBySessionId.get(from.sessionId); const pending = pendingAdmissionsBySessionId.get(from.sessionId); - const turnId = gated?.turnId ?? confirmed?.turnId ?? pending?.at(-1)?.turnId; + const turnId = confirmed?.turnId ?? pending?.at(-1)?.turnId; if (!turnId) return { from }; return { from, turnId, }; }; - const rememberGatedAdmission = ( - target: WorkHubSessionTarget, - admitted: { turnId: string; steered?: true }, - order: number, - correction?: WorkHubCorrectionContext, - ) => { - if (correction) { - const sourceReceipt = gatedRootReceiptBySessionId.get(correction.from.sessionId); - if (sourceReceipt?.turnId === correction.turnId) { - gatedRootReceiptBySessionId.delete(correction.from.sessionId); - } - } - // Steering joins an already-running root. The Action Gate deliberately - // preserves any earlier root it admitted for that Session, so the - // renderer's bounded correction receipt must do the same. - if (admitted.steered) return; - gatedRootReceiptBySessionId.set(target.sessionId, { - order, - turnId: admitted.turnId, - }); - while (gatedRootReceiptBySessionId.size > MAX_TRACKED_WORKHUB_ROOTS) { - const oldest = [...gatedRootReceiptBySessionId.entries()] - .sort((left, right) => left[1].order - right[1].order)[0]; - if (!oldest) return; - gatedRootReceiptBySessionId.delete(oldest[0]); - } - }; const pendingAdmissions = (sessionId: string): WorkHubPendingAdmission[] => pendingAdmissionsBySessionId.get(sessionId) ?? []; const setPendingAdmissions = ( @@ -707,6 +673,11 @@ function createWorkHubControllerImplementation(deps: { // learned only after successful delivery, but their precedence follows // user submission order rather than network completion order. const submissionOrder = submissionPolicy.reserveSubmissionOrder(); + if (deps.coordination && input.correction) { + throw new Error( + 'WorkHub linked correction requires persistent delegation support', + ); + } const { catalog, allowAuthoritativePruning } = await readCatalog(); reconcileConfirmedOwnership(catalog, allowAuthoritativePruning); @@ -744,6 +715,11 @@ function createWorkHubControllerImplementation(deps: { ...(input.explicitTarget ? { explicitTarget: input.explicitTarget } : {}), }); if (decision.kind === 'clarification') { + if (deps.coordination && decision.correctedFrom) { + throw new Error( + 'WorkHub linked correction requires persistent delegation support', + ); + } const correction = decision.correctedFrom ? correctionFor(decision.correctedFrom) : undefined; @@ -785,6 +761,11 @@ function createWorkHubControllerImplementation(deps: { const correction = input.correction ?? (decision.kind === 'target' && decision.correctedFrom ? correctionFor(decision.correctedFrom) : undefined); + if (deps.coordination && correction) { + throw new Error( + 'WorkHub linked correction requires persistent delegation support', + ); + } if (candidateSet && decision.kind === 'new_session') { const admitted = await coordination.act({ actionId: input.requestId, @@ -798,11 +779,6 @@ function createWorkHubControllerImplementation(deps: { throw new Error('WorkHub Action Gate returned an unexpected disposition'); } target = { sessionId: admitted.targetSessionId }; - rememberGatedAdmission( - target, - { turnId: admitted.targetTurnId, ...(admitted.steered ? { steered: true } : {}) }, - submissionOrder, - ); submissionPolicy.rememberTarget(target); return { kind: 'submitted', @@ -845,23 +821,6 @@ function createWorkHubControllerImplementation(deps: { if (!candidate) { throw new Error('WorkHub target Session is unavailable'); } - let replace: { candidateRef: string; expectedTurnId: string } | undefined; - if (correction) { - if (correction.steered) { - throw new Error('WorkHub cannot replace a steered Turn'); - } - if (!correction.turnId) { - throw new Error('WorkHub correction requires an exact owned Turn'); - } - const replacedCandidate = candidateBySessionId.get(correction.from.sessionId); - if (!replacedCandidate) { - throw new Error('WorkHub correction source is outside the admitted candidate set'); - } - replace = { - candidateRef: replacedCandidate.candidateRef, - expectedTurnId: correction.turnId, - }; - } const action: WorkHubCoordinationActInput = { actionId: input.requestId, userText: input.text, @@ -869,7 +828,6 @@ function createWorkHubControllerImplementation(deps: { proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef, - ...(replace ? { replace } : {}), }, }; const admitted = await coordination.act(action); @@ -877,16 +835,7 @@ function createWorkHubControllerImplementation(deps: { throw new Error('WorkHub Action Gate returned an unexpected disposition'); } target = { sessionId: admitted.targetSessionId }; - rememberGatedAdmission( - target, - { turnId: admitted.targetTurnId, ...(admitted.steered ? { steered: true } : {}) }, - submissionOrder, - correction, - ); submissionPolicy.rememberTarget(target); - if (correction) { - submissionPolicy.rememberCorrection(input.text, target, submissionOrder); - } return { kind: 'submitted', strategyId: WORKHUB_ROUTING_STRATEGY_ID, @@ -895,7 +844,6 @@ function createWorkHubControllerImplementation(deps: { turnId: admitted.targetTurnId, ...(admitted.steered ? { steered: true as const } : {}), evidence, - ...(correction ? { correctedFrom: correction.from } : {}), }; } if (correction) { diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 94c7c45124..71f0179a6d 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -46,8 +46,7 @@ export interface WorkHubConversationTurn { export type WorkHubSurfaceFailure = | 'candidates_changed' - | 'correction_expired' - | 'confirmation_required' + | 'linked_correction_unavailable' | 'target_waiting' | 'action_changed' | 'delivery_failed'; @@ -89,17 +88,6 @@ export function workHubSubmissionClearsDraft( return Boolean(result && result.kind !== 'waiting'); } -export function workHubReplacementText(input: { - locale: UiLocale; - sourceName: string; - targetName: string; - originalText: string; -}): string { - return input.locale === 'zh' - ? `不是原目标,改成“${input.targetName}”:${input.originalText}(原目标:“${input.sourceName}”)` - : `Not the previous target; use “${input.targetName}” instead: ${input.originalText} (previous target: “${input.sourceName}”)`; -} - export function workHubSurfaceFailure(error: unknown): WorkHubSurfaceFailure { const message = error instanceof Error ? error.message : ''; if ( @@ -109,25 +97,16 @@ export function workHubSurfaceFailure(error: unknown): WorkHubSurfaceFailure { ) { return 'candidates_changed'; } - if (/cannot stop a Turn it did not admit|source is outside/iu.test(message)) { - return 'correction_expired'; - } - if (/explicit user correction|requires an exact owned Turn/iu.test(message)) { - return 'confirmation_required'; + if (/linked correction requires persistent delegation support/iu.test(message)) { + return 'linked_correction_unavailable'; } if (/waiting for user input/iu.test(message)) return 'target_waiting'; - if (/identity belongs to a different proposal|replacement target did not change/iu.test(message)) { + if (/identity belongs to a different proposal/iu.test(message)) { return 'action_changed'; } return 'delivery_failed'; } -export function workHubSubmissionCanCorrect( - result: WorkHubSubmission, -): result is Extract { - return result.kind === 'submitted' && !result.steered; -} - export function visibleWorkHubConversation( coordination: readonly WorkHubCoordinationTurn[], local: readonly WorkHubConversationTurn[], @@ -375,29 +354,6 @@ export function WorkHubSurface(props: { : {}), }, turn.requestId, copy.choseWork(selected?.sessionName ?? copy.sessionFallback)); }} - onCorrect={(from, target) => { - const selected = projection.sessions.find( - (session) => session.target.sessionId === target.sessionId, - ); - const source = projection.sessions.find( - (session) => session.target.sessionId === from.target.sessionId, - ); - void route({ - requestId: crypto.randomUUID(), - text: workHubReplacementText({ - locale: props.locale, - sourceName: source?.sessionName ?? copy.sessionFallback, - targetName: selected?.sessionName ?? copy.sessionFallback, - originalText: turn.text, - }), - explicitTarget: target, - correction: { - from: from.target, - turnId: from.turnId, - ...(from.steered ? { steered: true } : {}), - }, - }, turn.requestId, copy.correctedWork(selected?.sessionName ?? copy.sessionFallback)); - }} onOpenSession={props.onOpenSession} /> ))} @@ -540,10 +496,6 @@ function WorkHubTurnView(props: { copy: ReturnType; pending: boolean; onChoose(target: { sessionId: string }): void; - onCorrect( - from: Extract, - target: { sessionId: string }, - ): void; onOpenSession(sessionId: string): void; }) { const { turn, copy } = props; @@ -593,12 +545,6 @@ function WorkHubTurnView(props: { ) : submitted ? ( - session.target.sessionId === submitted.correctedFrom?.sessionId, - ) - : undefined} targetSessionId={submitted.target.sessionId} heading={copy.sentTo} state={target @@ -606,15 +552,6 @@ function WorkHubTurnView(props: { : copy.accepted} result={target?.latestResult} copy={copy} - correctionOptions={workHubSubmissionCanCorrect(submitted) - ? props.projection.sessions.filter( - (session) => - !session.archived && - session.target.sessionId !== submitted.target.sessionId, - ) - : []} - pending={props.pending} - onCorrect={(target) => props.onCorrect(submitted, target)} onOpenSession={props.onOpenSession} /> ) : null} @@ -649,15 +586,11 @@ function WorkHubMessageFrame(props: { function SubmittedWorkView(props: { session: WorkHubSessionSummary | undefined; - correctedFrom: WorkHubSessionSummary | undefined; targetSessionId: string; heading: string; state: string; result: string | undefined; copy: ReturnType; - correctionOptions: WorkHubSessionSummary[]; - pending: boolean; - onCorrect?(target: { sessionId: string }): void; onOpenSession(sessionId: string): void; }) { const { session, copy } = props; @@ -675,33 +608,7 @@ function SubmittedWorkView(props: { {session?.projectName ? {session.projectName} : null} - {props.correctedFrom ? ( - - {copy.correctedFrom(props.correctedFrom.sessionName)} - - ) : null} {props.result ?

{props.result}

: null} - {props.correctionOptions.length > 0 ? ( -
- {copy.correctTarget} -
- {props.correctionOptions.map((option) => ( - - ))} -
-
- ) : null} ); } @@ -721,10 +628,7 @@ function workHubCopy(locale: UiLocale) { discussionHint: '提出明确的执行目标后,我会把它交给对应的 Session。', answering: '正在回答…', choseWork: (name: string) => `选择“${name}”`, - correctedWork: (name: string) => `更正目标为“${name}”`, sentTo: '已交给:', accepted: '已接收', sessionFallback: '普通 Session', - correctTarget: '更正目标', - correctedFrom: (name: string) => `已从“${name}”更正`, waitingForDecision: '这项工作正在等待你的决定。', requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', @@ -735,8 +639,7 @@ function workHubCopy(locale: UiLocale) { retry: '重试', submitFailures: { candidates_changed: '工作列表已变化,请重新发送以使用最新目标。', - correction_expired: '原目标已无法安全更正,请重新选择目标后发送。', - confirmation_required: '请明确说明停止原目标并改交给哪个 Session。', + linked_correction_unavailable: '跨 Session 更正将在持久委托关联完成后开放;请先打开原 Session 停止当前工作。', target_waiting: '目标 Session 正在等待你的处理;请先打开并完成该交互。', action_changed: '这次操作已发生变化,请重新发送。', delivery_failed: '输入未能送达,请重试。', @@ -758,10 +661,7 @@ function workHubCopy(locale: UiLocale) { discussionHint: 'State an executable goal and I will hand it to the owning Session.', answering: 'Answering…', choseWork: (name: string) => `Choose “${name}”`, - correctedWork: (name: string) => `Correct the target to “${name}”`, sentTo: 'Sent to:', accepted: 'Accepted', sessionFallback: 'Ordinary Session', - correctTarget: 'Correct target', - correctedFrom: (name: string) => `Corrected from “${name}”`, waitingForDecision: 'This work is waiting for your decision.', requestNotSent: 'The new request was not sent. Resolve the interaction in its Session, then send again.', routing: 'Choosing the right Session…', loadFailed: 'Could not read existing work.', @@ -772,8 +672,7 @@ function workHubCopy(locale: UiLocale) { retry: 'Retry', submitFailures: { candidates_changed: 'The work list changed. Send again to use the latest targets.', - correction_expired: 'The previous target can no longer be corrected safely. Choose a target again.', - confirmation_required: 'Explicitly say to stop the previous target and name its replacement.', + linked_correction_unavailable: 'Cross-Session correction will be available with persistent delegation. Open the original Session to stop its current work first.', target_waiting: 'The target Session needs your input. Open it and resolve that interaction first.', action_changed: 'This action changed. Send it again.', delivery_failed: 'The input could not be delivered. Try again.', diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index e22bcfe685..9d36dae540 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -249,501 +249,29 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal((await gate.act(input, CONTEXT)).disposition, 'delegate_existing'); }); - test('stops only an exact root previously admitted by this gate', async () => { - const effects = fakeEffects([session('source'), session('target', { statusUpdatedAt: 2 })]); - const gate = new WorkHubCoordinationActionGate(effects); - const firstSet = await gate.candidates(); - const source = firstSet.candidates.find(({ sessionId }) => sessionId === 'source')!; - const target = firstSet.candidates.find(({ sessionId }) => sessionId === 'target')!; - const first = await gate.act( - { - actionId: 'source-action', - userText: 'Start source work', - candidateSetId: firstSet.candidateSetId, - proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, - }, - CONTEXT, - ); - assert.equal(first.disposition, 'delegate_existing'); - if (first.disposition !== 'delegate_existing') return; - - await assert.rejects( - gate.act( - { - actionId: 'bad-correction', - userText: 'No, use target', - candidateSetId: firstSet.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: target.candidateRef, - replace: { candidateRef: source.candidateRef, expectedTurnId: 'not-owned' }, - }, - }, - CONTEXT, - ), - (error) => error instanceof WorkHubActionGateFailure && error.code === 'stop_not_owned', - ); - assert.deepEqual(effects.stops, []); - - await assert.rejects( - gate.act( - { - actionId: 'missing-source-correction', - userText: 'No, use target instead', - candidateSetId: firstSet.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: target.candidateRef, - replace: { candidateRef: 'missing-source', expectedTurnId: first.targetTurnId }, - }, - }, - CONTEXT, - ), - (error) => - error instanceof WorkHubActionGateFailure && error.code === 'candidate_unavailable', - ); - await assert.rejects( - gate.act( - { - actionId: 'same-target-correction', - userText: 'No, use source instead', - candidateSetId: firstSet.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: source.candidateRef, - replace: { - candidateRef: source.candidateRef, - expectedTurnId: first.targetTurnId, - }, - }, - }, - CONTEXT, - ), - (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', - ); - assert.deepEqual(effects.stops, []); - - await assert.rejects( - gate.act( - { - actionId: 'unconfirmed-correction', - userText: 'Continue the target work', - candidateSetId: firstSet.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: target.candidateRef, - replace: { candidateRef: source.candidateRef, expectedTurnId: first.targetTurnId }, - }, - }, - CONTEXT, - ), - (error) => - error instanceof WorkHubActionGateFailure && error.code === 'confirmation_required', - ); - assert.deepEqual(effects.stops, []); - assert.equal(effects.submissions.length, 1); - - await gate.act( - { - actionId: 'good-correction', - userText: 'No, use target', - candidateSetId: firstSet.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: target.candidateRef, - replace: { candidateRef: source.candidateRef, expectedTurnId: first.targetTurnId }, - }, - }, - CONTEXT, - ); - assert.deepEqual(effects.stops, [{ sessionId: 'source', turnId: first.targetTurnId }]); - assert.equal(effects.submissions.at(-1)?.sessionId, 'target'); - }); - - test('retries target submission without stopping the source twice after an unknown outcome', async () => { - const effects = fakeEffects([session('source'), session('target', { statusUpdatedAt: 2 })]); + test('replays an ordinary delegation without submitting twice', async () => { + const effects = fakeEffects([session('ordinary')]); const gate = new WorkHubCoordinationActionGate(effects); const snapshot = await gate.candidates(); - const source = snapshot.candidates.find(({ sessionId }) => sessionId === 'source')!; - const target = snapshot.candidates.find(({ sessionId }) => sessionId === 'target')!; - const admitted = await gate.act( - { - actionId: 'source-action-before-unknown-submit', - userText: 'Start source work', - candidateSetId: snapshot.candidateSetId, - proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, - }, - CONTEXT, - ); - assert.equal(admitted.disposition, 'delegate_existing'); - if (admitted.disposition !== 'delegate_existing') return; - - let targetAttempts = 0; - effects.submit = async (input) => { - effects.submissions.push(input); - if (input.sessionId === 'target' && targetAttempts++ === 0) { - throw new WorkHubActionEffectFailure( - 'commit_outcome_unknown', - 'Target submission may have committed', - ); - } - return { turnId: `turn-${input.sessionId}` }; - }; - const replacement = { - actionId: 'replacement-with-unknown-submit', - userText: 'No, use target instead', + const input = { + actionId: 'delegate-action', + userText: 'Continue ordinary work', candidateSetId: snapshot.candidateSetId, proposal: { disposition: 'delegate_existing' as const, - candidateRef: target.candidateRef, - replace: { - candidateRef: source.candidateRef, - expectedTurnId: admitted.targetTurnId, - }, - }, - }; - - await assert.rejects( - gate.act(replacement, CONTEXT), - (error) => - error instanceof WorkHubActionEffectFailure && error.code === 'commit_outcome_unknown', - ); - await assert.rejects( - gate.act({ ...replacement, userText: 'No, use a different target instead' }, CONTEXT), - (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', - ); - const retried = await gate.act(replacement, CONTEXT); - - assert.equal(retried.disposition, 'delegate_existing'); - assert.deepEqual(effects.stops, [{ sessionId: 'source', turnId: admitted.targetTurnId }]); - const targetSubmissions = effects.submissions.filter(({ sessionId }) => sessionId === 'target'); - assert.equal(targetSubmissions.length, 2); - assert.equal(targetSubmissions[0]?.messageId, targetSubmissions[1]?.messageId); - }); - - test('definitive target failures release replacement recovery capacity', async () => { - const effects = fakeEffects([session('source'), session('target', { statusUpdatedAt: 2 })]); - const gate = new WorkHubCoordinationActionGate(effects); - const snapshot = await gate.candidates(); - const source = snapshot.candidates.find(({ sessionId }) => sessionId === 'source')!; - const target = snapshot.candidates.find(({ sessionId }) => sessionId === 'target')!; - effects.submit = async (input) => { - effects.submissions.push(input); - if (input.sessionId === 'target') { - throw new WorkHubActionEffectFailure('session_busy', 'Target cannot accept this message'); - } - return { turnId: `turn-source-${input.messageId}` }; - }; - - // One more than the recovery bound proves permanent failures cannot - // accumulate until every replacement is rejected Host-wide. - for (let index = 0; index <= 256; index += 1) { - const admitted = await gate.act( - { - actionId: `source-before-definitive-failure-${index}`, - userText: 'Start source work', - candidateSetId: snapshot.candidateSetId, - proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, - }, - CONTEXT, - ); - assert.equal(admitted.disposition, 'delegate_existing'); - if (admitted.disposition !== 'delegate_existing') return; - await assert.rejects( - gate.act( - { - actionId: `definitive-target-failure-${index}`, - userText: 'No, use target instead', - candidateSetId: snapshot.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: target.candidateRef, - replace: { - candidateRef: source.candidateRef, - expectedTurnId: admitted.targetTurnId, - }, - }, - }, - CONTEXT, - ), - (error) => error instanceof WorkHubActionEffectFailure && error.code === 'session_busy', - ); - } - - assert.equal(effects.stops.length, 257); - }); - - test('unknown replacement outcomes expire instead of exhausting the Host until restart', async () => { - const effects = fakeEffects([session('source'), session('target', { statusUpdatedAt: 2 })]); - let now = 0; - const gate = new WorkHubCoordinationActionGate(effects, { now: () => now }); - const snapshot = await gate.candidates(); - const source = snapshot.candidates.find(({ sessionId }) => sessionId === 'source')!; - const target = snapshot.candidates.find(({ sessionId }) => sessionId === 'target')!; - effects.submit = async (input) => { - effects.submissions.push(input); - if (input.sessionId === 'target') { - throw new WorkHubActionEffectFailure( - 'commit_outcome_unknown', - 'Target submission may have committed', - ); - } - return { turnId: `turn-source-${input.messageId}` }; - }; - - for (let index = 0; index < 256; index += 1) { - const admitted = await gate.act( - { - actionId: `source-before-unknown-expiry-${index}`, - userText: 'Start source work', - candidateSetId: snapshot.candidateSetId, - proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, - }, - CONTEXT, - ); - assert.equal(admitted.disposition, 'delegate_existing'); - if (admitted.disposition !== 'delegate_existing') return; - await assert.rejects( - gate.act( - replacementInput( - `unknown-target-outcome-${index}`, - snapshot.candidateSetId, - source.candidateRef, - target.candidateRef, - admitted.targetTurnId, - ), - CONTEXT, - ), - (error) => - error instanceof WorkHubActionEffectFailure && error.code === 'commit_outcome_unknown', - ); - } - - const nextSource = await gate.act( - { - actionId: 'source-before-capacity-recovery', - userText: 'Start source work', - candidateSetId: snapshot.candidateSetId, - proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, - }, - CONTEXT, - ); - assert.equal(nextSource.disposition, 'delegate_existing'); - if (nextSource.disposition !== 'delegate_existing') return; - const afterCapacity = replacementInput( - 'replacement-after-capacity-recovery', - snapshot.candidateSetId, - source.candidateRef, - target.candidateRef, - nextSource.targetTurnId, - ); - await assert.rejects( - gate.act(afterCapacity, CONTEXT), - (error) => error instanceof WorkHubActionEffectFailure && error.code === 'host_not_ready', - ); - - now += 24 * 60 * 60 * 1000; - await assert.rejects( - gate.act(afterCapacity, CONTEXT), - (error) => - error instanceof WorkHubActionEffectFailure && error.code === 'commit_outcome_unknown', - ); - assert.equal(effects.stops.length, 257); - }); - - test('serializes replacements from one source and admits only one target', async () => { - const effects = fakeEffects([ - session('source'), - session('target-a', { statusUpdatedAt: 2 }), - session('target-b', { statusUpdatedAt: 3 }), - ]); - const gate = new WorkHubCoordinationActionGate(effects); - const snapshot = await gate.candidates(); - const source = snapshot.candidates.find(({ sessionId }) => sessionId === 'source')!; - const targetA = snapshot.candidates.find(({ sessionId }) => sessionId === 'target-a')!; - const targetB = snapshot.candidates.find(({ sessionId }) => sessionId === 'target-b')!; - const admitted = await gate.act( - { - actionId: 'source-action', - userText: 'Start source work', - candidateSetId: snapshot.candidateSetId, - proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, - }, - CONTEXT, - ); - assert.equal(admitted.disposition, 'delegate_existing'); - if (admitted.disposition !== 'delegate_existing') return; - - let signalStop!: () => void; - const stopStarted = new Promise((resolve) => { - signalStop = resolve; - }); - let releaseStop!: () => void; - const stopBarrier = new Promise((resolve) => { - releaseStop = resolve; - }); - effects.stop = async (input) => { - effects.stops.push(input); - signalStop(); - await stopBarrier; - }; - const replace = (actionId: string, candidateRef: string) => - gate.act( - { - actionId, - userText: `No, use ${actionId} instead`, - candidateSetId: snapshot.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef, - replace: { - candidateRef: source.candidateRef, - expectedTurnId: admitted.targetTurnId, - }, - }, - }, - CONTEXT, - ); - - const first = replace('target-a-action', targetA.candidateRef); - await stopStarted; - const second = replace('target-b-action', targetB.candidateRef); - await Promise.resolve(); - assert.equal(effects.stops.length, 1); - assert.deepEqual( - effects.submissions.map(({ sessionId }) => sessionId), - ['source'], - ); - - releaseStop(); - assert.equal((await first).disposition, 'delegate_existing'); - await assert.rejects( - second, - (error) => error instanceof WorkHubActionGateFailure && error.code === 'stop_not_owned', - ); - assert.deepEqual(effects.stops, [{ sessionId: 'source', turnId: admitted.targetTurnId }]); - assert.deepEqual( - effects.submissions.map(({ sessionId }) => sessionId), - ['source', 'target-a'], - ); - }); - - test('a completed Stop cannot erase a newer root admitted to the same source', async () => { - const effects = fakeEffects([ - session('source'), - session('target-a', { statusUpdatedAt: 2 }), - session('target-b', { statusUpdatedAt: 3 }), - ]); - const gate = new WorkHubCoordinationActionGate(effects); - const snapshot = await gate.candidates(); - const source = snapshot.candidates.find(({ sessionId }) => sessionId === 'source')!; - const targetA = snapshot.candidates.find(({ sessionId }) => sessionId === 'target-a')!; - const targetB = snapshot.candidates.find(({ sessionId }) => sessionId === 'target-b')!; - const original = await gate.act( - { - actionId: 'original-source-action', - userText: 'Start source work', - candidateSetId: snapshot.candidateSetId, - proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, + candidateRef: snapshot.candidates[0]!.candidateRef, }, - CONTEXT, - ); - assert.equal(original.disposition, 'delegate_existing'); - if (original.disposition !== 'delegate_existing') return; - - let signalStop!: () => void; - const stopStarted = new Promise((resolve) => { - signalStop = resolve; - }); - let releaseStop!: () => void; - const stopBarrier = new Promise((resolve) => { - releaseStop = resolve; - }); - effects.stop = async (input) => { - effects.stops.push(input); - signalStop(); - await stopBarrier; - }; - effects.submit = async (input) => { - effects.submissions.push(input); - return { - turnId: input.sessionId === 'source' ? 'turn-source-renewed' : `turn-${input.sessionId}`, - }; }; - const firstReplacement = gate.act( - { - actionId: 'first-replacement', - userText: 'No, use target-a instead', - candidateSetId: snapshot.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: targetA.candidateRef, - replace: { - candidateRef: source.candidateRef, - expectedTurnId: original.targetTurnId, - }, - }, - }, - CONTEXT, - ); - await stopStarted; - const renewed = await gate.act( - { - actionId: 'renew-source', - userText: 'Start newer source work', - candidateSetId: snapshot.candidateSetId, - proposal: { disposition: 'delegate_existing', candidateRef: source.candidateRef }, - }, - CONTEXT, - ); - assert.equal(renewed.disposition, 'delegate_existing'); - if (renewed.disposition !== 'delegate_existing') return; + const first = await gate.act(input, CONTEXT); + const replay = await gate.act(input, CONTEXT); - releaseStop(); - await firstReplacement; - await gate.act( - { - actionId: 'replace-renewed-source', - userText: 'No, use target-b instead', - candidateSetId: snapshot.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: targetB.candidateRef, - replace: { - candidateRef: source.candidateRef, - expectedTurnId: renewed.targetTurnId, - }, - }, - }, - CONTEXT, - ); - assert.deepEqual(effects.stops, [ - { sessionId: 'source', turnId: original.targetTurnId }, - { sessionId: 'source', turnId: renewed.targetTurnId }, - ]); + assert.deepEqual(replay, first); + assert.equal(effects.submissions.length, 1); + assert.equal(effects.submissions[0]?.sessionId, 'ordinary'); }); }); -function replacementInput( - actionId: string, - candidateSetId: string, - sourceCandidateRef: string, - targetCandidateRef: string, - expectedTurnId: string, -) { - return { - actionId, - userText: 'No, use target instead', - candidateSetId, - proposal: { - disposition: 'delegate_existing' as const, - candidateRef: targetCandidateRef, - replace: { candidateRef: sourceCandidateRef, expectedTurnId }, - }, - }; -} - function session( id: string, patch: Partial = {}, @@ -776,7 +304,6 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { title: string; }>, submissions: [] as Array<{ sessionId: string; messageId: string; text: string }>, - stops: [] as Array<{ sessionId: string; turnId: string }>, async listSessions() { return this.sessions; }, @@ -797,9 +324,6 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { this.submissions.push(input); return { turnId: `turn-${input.sessionId}` }; }, - async stop(input: { sessionId: string; turnId: string }) { - this.stops.push(input); - }, } satisfies WorkHubActionGateEffects & { sessions: WorkHubActionGateSession[]; answers: Array<{ turnId: string; text: string }>; @@ -810,7 +334,6 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { title: string; }>; submissions: Array<{ sessionId: string; messageId: string; text: string }>; - stops: Array<{ sessionId: string; turnId: string }>; }; return state; } 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 46799f00ac..d78d325d19 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -624,7 +624,6 @@ function coordinator( sessionActions: { create: async () => undefined, submit: async ({ sessionId }) => ({ turnId: `turn-${sessionId}` }), - stop: async () => undefined, }, resolveCreateTarget: resolveCreateTarget ?? 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 1ea8e58e65..b188f97284 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -202,6 +202,23 @@ test('WorkHub Coordination action input is a closed disposition union', () => { }), (error) => error instanceof RuntimeHostProtocolError, ); + assert.throws( + () => + decodeWorkHubCoordinationActInput({ + actionId: 'action-replace', + userText: 'No, use login instead', + candidateSetId: `sha256:${'e'.repeat(64)}`, + proposal: { + disposition: 'delegate_existing', + candidateRef: 'candidate_login', + replace: { + candidateRef: 'candidate_payments', + expectedTurnId: 'turn-payments', + }, + }, + }), + (error) => error instanceof RuntimeHostProtocolError, + ); assert.equal(HOST_OPERATION_SPECS['workhub.coordination.act'].mode, 'command'); assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.act'), true); }); diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 09b52029e1..1af2559f70 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -123,10 +123,6 @@ export type WorkHubCoordinationProposal = | { readonly disposition: 'delegate_existing'; readonly candidateRef: string; - readonly replace?: { - readonly candidateRef: string; - readonly expectedTurnId: string; - }; } | { readonly disposition: 'create_new'; readonly title: string }; @@ -410,16 +406,13 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP }; } if (proposal.disposition === 'delegate_existing') { - const exact = requireShapedRecord( - proposal, - 'WorkHub delegation proposal', - ['disposition', 'candidateRef'], - ['replace'], - ); + const exact = requireExactRecord(proposal, 'WorkHub delegation proposal', [ + 'disposition', + 'candidateRef', + ]); return { disposition: 'delegate_existing', candidateRef: requireEntityId(exact.candidateRef, 'WorkHub candidate ref'), - ...(exact.replace === undefined ? {} : { replace: decodeWorkHubReplacement(exact.replace) }), }; } if (proposal.disposition === 'create_new') { @@ -435,20 +428,6 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP throw invalidProtocolFrame('Invalid WorkHub Coordination proposal disposition'); } -function decodeWorkHubReplacement(value: unknown): { - readonly candidateRef: string; - readonly expectedTurnId: string; -} { - const replace = requireExactRecord(value, 'WorkHub replacement', [ - 'candidateRef', - 'expectedTurnId', - ]); - return { - candidateRef: requireEntityId(replace.candidateRef, 'WorkHub replacement candidate ref'), - expectedTurnId: requireEntityId(replace.expectedTurnId, 'WorkHub expected Turn id'), - }; -} - function decodeWorkHubCoordinationCreateContext(value: unknown): WorkHubCoordinationCreateContext { const context = requireExactRecord(value, 'WorkHub creation context', ['workspace']); return { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 5f1c105992..cf46c22b9c 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1268,23 +1268,6 @@ export async function createExecutionRuntimeHostComposition( ? { turnId: outcome.result.turnId } : { turnId: input.messageId, steered: true as const }; }, - stop: async (input, connection) => { - const observed = await turnControl.handlers['turn.query'](input, connection); - if (!observed.ok) { - throw new WorkHubActionEffectFailure(observed.error.code, observed.error.message); - } - const stopped = await turnControl.handlers['turn.stop']( - { - sessionId: input.sessionId, - turnId: input.turnId, - runId: observed.result.runId, - }, - connection, - ); - if (!stopped.ok) { - throw new WorkHubActionEffectFailure(stopped.error.code, stopped.error.message); - } - }, }, resolveCreateTarget: async () => { const { projectId: _projectId, ...target } = 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 86bff35b71..bf95b82ae8 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -36,8 +36,6 @@ import type { ConnectionContext } from './operation-dispatcher.js'; const SIDE_CONVERSATION_LABEL = 'mode:side_conversation'; const ACTION_REPLAY_MAX_ITEMS = 256; -const REPLACEMENT_RECOVERY_MAX_ITEMS = ACTION_REPLAY_MAX_ITEMS; -const REPLACEMENT_RECOVERY_TTL_MS = 5 * 60 * 1000; export type WorkHubActionGateSession = Pick< SessionHeader, @@ -79,10 +77,6 @@ export interface WorkHubActionGateEffects { }, context: ConnectionContext, ): Promise<{ readonly turnId: string; readonly steered?: true }>; - stop( - input: { readonly sessionId: string; readonly turnId: string }, - context: ConnectionContext, - ): Promise; } export type WorkHubActionEffectFailureCode = @@ -113,8 +107,6 @@ export type WorkHubActionGateFailureCode = | 'candidate_unavailable' | 'target_waiting_for_user' | 'self_route' - | 'confirmation_required' - | 'stop_not_owned' | 'action_conflict'; export class WorkHubActionGateFailure extends Error { @@ -132,20 +124,6 @@ interface ActionReplay { readonly result: Promise; } -interface OwnedRoot { - readonly turnId: string; - readonly actionId: string; -} - -/** Host-lifetime checkpoint for the non-atomic Stop-then-submit boundary. */ -interface ReplacementRecovery { - readonly fingerprint: string; - readonly sourceSessionId: string; - readonly targetSessionId: string; - state: 'stopping' | 'submitting' | 'uncertain'; - expiresAt: number; -} - /** * The sole admission module between a WorkHub strategy proposal and Session effects. * @@ -155,15 +133,10 @@ interface ReplacementRecovery { */ export class WorkHubCoordinationActionGate { readonly #effects: WorkHubActionGateEffects; - readonly #now: () => number; readonly #actions = new Map(); - readonly #ownedRoots = new Map(); - readonly #replacementRecoveries = new Map(); - readonly #replacementLanes = new Map>(); - constructor(effects: WorkHubActionGateEffects, options: { readonly now?: () => number } = {}) { + constructor(effects: WorkHubActionGateEffects) { this.#effects = effects; - this.#now = options.now ?? Date.now; } async candidates(): Promise { @@ -174,17 +147,7 @@ export class WorkHubCoordinationActionGate { input: WorkHubCoordinationActInput, context: ConnectionContext, ): Promise { - this.#pruneExpiredRecoveries(); const fingerprint = digest(input); - const recovery = this.#replacementRecoveries.get(input.actionId); - if (recovery && recovery.fingerprint !== fingerprint) { - return Promise.reject( - new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub action identity belongs to a different proposal', - ), - ); - } const replay = this.#actions.get(input.actionId); if (replay) { if (replay.fingerprint !== fingerprint) { @@ -198,7 +161,7 @@ export class WorkHubCoordinationActionGate { return replay.result; } - const result = this.#act(input, context, fingerprint); + const result = this.#act(input, context); const action = { fingerprint, result }; this.#actions.set(input.actionId, action); // Successful actions remain replayable. A rejected admission does not own @@ -216,23 +179,7 @@ export class WorkHubCoordinationActionGate { async #act( input: WorkHubCoordinationActInput, context: ConnectionContext, - fingerprint: string, ): Promise { - const recovery = this.#replacementRecoveries.get(input.actionId); - if (recovery) { - if (recovery.state !== 'uncertain') { - throw new WorkHubActionEffectFailure( - 'host_not_ready', - 'WorkHub replacement is still settling', - ); - } - // The destructive half already committed. Reconcile only the exact - // idempotent target submission; a fresh candidate snapshot must not turn - // a lost reply into a second Stop or strand the replacement permanently. - return this.#withReplacementLease(recovery.sourceSessionId, () => - this.#resumeReplacement(input, recovery, context), - ); - } const proposal = input.proposal; if (proposal.disposition === 'answer_here') { const turnId = coordinationTurnId(input.actionId, 'answer'); @@ -269,7 +216,6 @@ export class WorkHubCoordinationActionGate { }, context, ); - this.#rememberRoot(sessionId, input.actionId, submitted); return executionResult('create_new', sessionId, submitted); } @@ -291,90 +237,6 @@ export class WorkHubCoordinationActionGate { } this.#assertTarget(target); - if (proposal.replace) { - if (!hasExplicitReplacementIntent(input.userText)) { - throw new WorkHubActionGateFailure( - 'confirmation_required', - 'Stopping and rerouting work requires an explicit user correction naming the replacement', - ); - } - const replaced = candidates.candidates.find( - (candidate) => candidate.candidateRef === proposal.replace?.candidateRef, - ); - if (!replaced) { - throw new WorkHubActionGateFailure( - 'candidate_unavailable', - 'WorkHub replacement source is not in the admitted candidate set', - ); - } - if (replaced.sessionId === target.sessionId) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub replacement target did not change', - ); - } - const replacement = proposal.replace; - return this.#withReplacementLease(replaced.sessionId, async () => { - const freshCandidates = await this.candidates(); - if (freshCandidates.candidateSetId !== input.candidateSetId) { - throw new WorkHubActionGateFailure( - 'candidate_set_stale', - 'WorkHub Session candidates changed; refresh before delegating', - ); - } - const freshTarget = freshCandidates.candidates.find( - (candidate) => candidate.candidateRef === proposal.candidateRef, - ); - const freshSource = freshCandidates.candidates.find( - (candidate) => candidate.candidateRef === replacement.candidateRef, - ); - if (!freshTarget || !freshSource) { - throw new WorkHubActionGateFailure( - 'candidate_unavailable', - 'WorkHub replacement source or target is not in the admitted candidate set', - ); - } - this.#assertTarget(freshTarget); - const owned = this.#ownedRoots.get(freshSource.sessionId); - if (!owned || owned.turnId !== replacement.expectedTurnId) { - throw new WorkHubActionGateFailure( - 'stop_not_owned', - 'WorkHub cannot stop a Turn it did not admit', - ); - } - this.#pruneExpiredRecoveries(); - if (this.#replacementRecoveries.size >= REPLACEMENT_RECOVERY_MAX_ITEMS) { - throw new WorkHubActionEffectFailure( - 'host_not_ready', - 'WorkHub replacement recovery capacity is unavailable', - ); - } - const recovery: ReplacementRecovery = { - fingerprint, - sourceSessionId: freshSource.sessionId, - targetSessionId: freshTarget.sessionId, - state: 'stopping', - expiresAt: 0, - }; - this.#replacementRecoveries.set(input.actionId, recovery); - try { - await this.#effects.stop( - { sessionId: freshSource.sessionId, turnId: owned.turnId }, - context, - ); - } catch (error) { - if (this.#replacementRecoveries.get(input.actionId) === recovery) { - this.#replacementRecoveries.delete(input.actionId); - } - throw error; - } - if (this.#ownedRoots.get(freshSource.sessionId) === owned) { - this.#ownedRoots.delete(freshSource.sessionId); - } - return this.#resumeReplacement(input, recovery, context); - }); - } - return this.#submitExisting(input, target, context); } @@ -382,81 +244,16 @@ export class WorkHubCoordinationActionGate { input: WorkHubCoordinationActInput, target: WorkHubCoordinationCandidate, context: ConnectionContext, - ): Promise { - return this.#submitExistingSession(input, target.sessionId, context); - } - - async #submitExistingSession( - input: WorkHubCoordinationActInput, - sessionId: string, - context: ConnectionContext, ): Promise { const submitted = await this.#effects.submit( { - sessionId, + sessionId: target.sessionId, messageId: actionMessageId(input.actionId), text: input.userText, }, context, ); - this.#rememberRoot(sessionId, input.actionId, submitted); - return executionResult('delegate_existing', sessionId, submitted); - } - - async #resumeReplacement( - input: WorkHubCoordinationActInput, - recovery: ReplacementRecovery, - context: ConnectionContext, - ): Promise { - recovery.state = 'submitting'; - try { - const result = await this.#submitExistingSession(input, recovery.targetSessionId, context); - if (this.#replacementRecoveries.get(input.actionId) === recovery) { - this.#replacementRecoveries.delete(input.actionId); - } - return result; - } catch (error) { - if (this.#replacementRecoveries.get(input.actionId) === recovery) { - if ( - error instanceof WorkHubActionEffectFailure && - error.code === 'commit_outcome_unknown' - ) { - recovery.state = 'uncertain'; - recovery.expiresAt = this.#now() + REPLACEMENT_RECOVERY_TTL_MS; - } else { - this.#replacementRecoveries.delete(input.actionId); - } - } - throw error; - } - } - - #pruneExpiredRecoveries(): void { - const now = this.#now(); - for (const [actionId, recovery] of this.#replacementRecoveries) { - if (recovery.state === 'uncertain' && recovery.expiresAt <= now) { - this.#replacementRecoveries.delete(actionId); - } - } - } - - async #withReplacementLease(sessionId: string, action: () => Promise): Promise { - const predecessor = this.#replacementLanes.get(sessionId) ?? Promise.resolve(); - let release!: () => void; - const ownership = new Promise((resolve) => { - release = resolve; - }); - const tail = predecessor.then(() => ownership); - this.#replacementLanes.set(sessionId, tail); - await predecessor; - try { - return await action(); - } finally { - release(); - if (this.#replacementLanes.get(sessionId) === tail) { - this.#replacementLanes.delete(sessionId); - } - } + return executionResult('delegate_existing', target.sessionId, submitted); } #assertTarget(target: WorkHubCoordinationCandidate): void { @@ -471,15 +268,6 @@ export class WorkHubCoordinationActionGate { } } - #rememberRoot( - sessionId: string, - actionId: string, - submitted: { readonly turnId: string; readonly steered?: true }, - ): void { - if (submitted.steered) return; - this.#ownedRoots.set(sessionId, { turnId: submitted.turnId, actionId }); - } - #boundReplays(): void { while (this.#actions.size > ACTION_REPLAY_MAX_ITEMS) { const oldest = this.#actions.keys().next().value; @@ -540,29 +328,6 @@ function actionMessageId(actionId: string): string { return `whm_${hash(actionId).slice(0, 48)}`; } -/** - * Replacement is the only Slice 4 coordination action that interrupts an - * admitted effect. Confirmation therefore comes from the exact user message, - * never from strategy output: the message must both reject/stop the old route - * and explicitly direct work toward a replacement. - */ -function hasExplicitReplacementIntent(userText: string): boolean { - const chineseCorrection = - /(?:不是|不对|搞错了?|弄错了?|错了|不要再继续)[^\n]{0,96}(?:而是|改成|改为|换成|换到|切到|转到|改派|改交|交给|用)/iu; - const chineseStopAndReroute = - /(?:停止|停掉|中止|取消)[^\n]{0,96}(?:改成|改为|换成|换到|切到|转到|改派|改交|交给|用)/iu; - const englishCorrection = - /\b(?:no|not|wrong|mistake)\b[^\n]{0,96}\b(?:instead|use|switch\s+to|change\s+to|move\s+to|route\s+to|send\s+to)\b/iu; - const englishStopAndReroute = - /\b(?:stop|cancel|abort)\b[^\n]{0,96}\b(?:use|switch\s+to|change\s+to|move\s+to|delegate\s+to|route\s+to|send\s+to)\b/iu; - return ( - chineseCorrection.test(userText) || - chineseStopAndReroute.test(userText) || - englishCorrection.test(userText) || - englishStopAndReroute.test(userText) - ); -} - function workHubCreatedSessionId(actionId: string): string { return `whs_${hash(`create\0${actionId}`).slice(0, 48)}`; } diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 21037f94af..a90e8449b9 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -100,7 +100,7 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly admission: SessionAdmissionGate; readonly continuity: Pick; readonly executions: CoordinationExecutions; - readonly sessionActions: Pick; + readonly sessionActions: Pick; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; } @@ -152,7 +152,6 @@ export class HostWorkHubCoordinationCoordinator { }, create: options.sessionActions.create, submit: options.sessionActions.submit, - stop: options.sessionActions.stop, }); }