From 3bf54adac16a3b5ac041c0de2ec633fa2493aece Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 1 Sep 2026 13:31:39 +0800 Subject: [PATCH 01/19] feat(workhub): add direct stop coordination Generated-by: Codex --- .../main/__tests__/workhub-controller.test.ts | 119 +++++++ .../__tests__/workhub-session-port.test.ts | 51 +++ .../__tests__/workhub-surface-flow.test.ts | 7 + .../contracts/workhub-request-intent.ts | 1 + .../src/renderer/workhub-controller.ts | 93 +++++- .../src/renderer/workhub-coordination-port.ts | 30 +- apps/desktop/src/renderer/workhub-surface.tsx | 79 ++++- .../workhub-coordination-session-adr.md | 16 +- docs/workhub-domain-language.md | 28 +- .../workhub-coordination-record.test.ts | 62 ++++ .../__tests__/workhub-creation-intent.test.ts | 56 ++++ packages/core/src/session.ts | 145 ++++++++- packages/core/src/workhub-creation-intent.ts | 87 +++++ .../__tests__/execution-composition.test.ts | 141 +++++++++ .../src/__tests__/message-coordinator.test.ts | 44 ++- .../__tests__/root-turn-coordinator.test.ts | 74 ++++- .../workhub-coordination-action-gate.test.ts | 296 +++++++++++++++++- .../workhub-coordination-coordinator.test.ts | 292 ++++++++++++++++- .../workhub-coordination-protocol.test.ts | 90 +++++- packages/runtime-host/src/protocol/index.ts | 5 +- .../src/protocol/workhub-coordination.ts | 77 ++++- .../src/server/execution-composition.ts | 69 ++-- .../src/server/hosted-execution-authority.ts | 10 +- .../src/server/message-coordinator.ts | 34 +- .../src/server/root-turn-coordinator.ts | 40 +-- .../workhub-coordination-action-gate.ts | 220 ++++++++++++- .../workhub-coordination-coordinator.ts | 236 ++++++++++++-- .../session-manager-terminal-ledger.test.ts | 36 +++ .../session-projection-helpers.test.ts | 21 ++ packages/runtime/src/agent-run.ts | 8 +- packages/runtime/src/message-authority.ts | 19 +- packages/runtime/src/runtime-kernel.ts | 13 +- packages/runtime/src/session-manager.ts | 20 +- .../runtime/src/session-projection-helpers.ts | 17 +- .../sqlite-session-metadata-store.test.ts | 115 +++++++ .../workhub-message-assignment.test.ts | 104 ++++++ packages/storage/src/execution-stores.ts | 6 + .../storage/src/message-admission-store.ts | 10 + packages/storage/src/session-store.ts | 35 +++ .../src/sqlite-session-metadata-schema.ts | 14 +- .../src/sqlite-session-metadata-store.ts | 80 +++++ 41 files changed, 2748 insertions(+), 152 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 5301584e28..0734434135 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -185,6 +185,13 @@ function createWorkHubController({ sessions }: { sessions: TestSessionPort }) { ...(admitted.steered ? { steered: true as const } : {}), }; } + if (input.proposal.disposition === 'stop_work') { + return { + disposition: 'stop_work', + outcome: 'cancelled_pending', + targetSessionId: input.proposal.stopsActionId, + }; + } const target = candidateByRef.get(input.proposal.candidateRef); if (!target) throw new Error('unknown test candidate'); const admitted = await sessions.submit(target.target, input.userText, input.actionId); @@ -324,6 +331,118 @@ test('conversation feedback never lets an older refresh overwrite newer target s await handle.close(); }); +test('direct stop bypasses routing candidates and preserves a not_owned delegation link', async () => { + const sessions = port([session('payments', { sessionName: 'Payments' })]); + const actions: WorkHubCoordinationActInput[] = []; + let candidateReads = 0; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([coordinationAssignmentTurn()], [{ + actionId: 'action-1', + targetSessionId: 'payments', + sequence: 0, + }]); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => { + candidateReads += 1; + return { candidateSetId: `sha256:${'d'.repeat(64)}`, candidates: [] }; + }, + act: async (input) => { + actions.push(input); + return { + disposition: 'stop_work', + outcome: 'not_owned', + targetSessionId: 'payments', + targetTurnId: 'shared-turn', + }; + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + const result = await controller.submit({ requestId: 'stop-1', text: 'Stop Payments' }); + assert.deepEqual(result, { + kind: 'stop', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'stop-1', + target: { sessionId: 'payments' }, + outcome: 'not_owned', + targetTurnId: 'shared-turn', + }); + assert.deepEqual(actions, [{ + actionId: 'stop-1', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'action-1' }, + confirmation: { kind: 'user_stop' }, + }]); + assert.equal(candidateReads, 0); + + const retry = await controller.submit({ requestId: 'stop-2', text: 'Stop Payments' }); + assert.equal(retry.kind, 'stop'); + assert.equal(actions.length, 2); + await handle.close(); +}); + +test('an anaphoric stop asks for a fresh named imperative without offering a route choice', async () => { + const sessions = port([session('payments', { sessionName: 'Payments' })]); + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([], [{ actionId: 'action-1', targetSessionId: 'payments', sequence: 0 }]); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), + act: async () => assert.fail('anaphoric stop must not reach the Action Gate'), + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + assert.deepEqual(await controller.submit({ requestId: 'stop-it', text: 'Stop it' }), { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'stop-it', + text: 'Stop it', + options: [], + reason: 'stop_target_required', + }); + await handle.close(); +}); + +test('a named stop stays fail-closed when the Session has multiple active delegations', async () => { + const sessions = port([session('payments', { sessionName: 'Payments' })]); + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([], [ + { actionId: 'action-1', targetSessionId: 'payments', sequence: 0 }, + { actionId: 'action-2', targetSessionId: 'payments', sequence: 1 }, + ]); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), + act: async () => assert.fail('an ambiguous delegation stop must not reach the Action Gate'), + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + assert.deepEqual(await controller.submit({ requestId: 'stop-payments', text: 'Stop Payments' }), { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'stop-payments', + text: 'Stop Payments', + options: [], + reason: 'stop_target_required', + }); + await handle.close(); +}); + test('read exposes existing ordinary Sessions as factual Work summaries', async () => { const controller = createWorkHubController({ 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 b982a265d2..00ba4f9303 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -295,6 +295,57 @@ test('a durable replacement abort terminalizes the retired source linkage', () = ); }); +test('direct-stop projection is retryable until resolved and preserves not_owned links', () => { + const assignment: StoredMessage = { + type: 'workhub_coordination', id: 'assignment', turnId: 'source-action', ts: 1, + schemaVersion: 1, kind: 'delegation_assigned', actionId: 'source-action', + actionFingerprint: `sha256:${'a'.repeat(64)}`, coordinationTurnId: 'source-action', + targetSessionId: 'payments', targetSessionName: 'Payments', targetTurnId: 'payments-turn', + targetMessageId: 'payments-message', delegationId: 'payments-delegation', + disposition: 'delegate_existing', userText: 'Fix payment retry', + }; + const requested: StoredMessage = { + type: 'workhub_coordination', id: 'stop-request', turnId: 'stop-action', ts: 2, + schemaVersion: 3, kind: 'delegation_stop_requested', actionId: 'stop-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'stop-action', + stopsActionId: 'source-action', stopsDelegationId: 'payments-delegation', + targetSessionId: 'payments', targetMessageId: 'payments-message', + targetSessionName: 'Payments', userText: 'Stop Payments', + }; + const notOwned: StoredMessage = { + type: 'workhub_coordination', id: 'stop-resolution', turnId: 'stop-action', ts: 3, + schemaVersion: 3, kind: 'delegation_stop_resolved', actionId: 'stop-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'stop-action', + stopsActionId: 'source-action', stopsDelegationId: 'payments-delegation', + targetSessionId: 'payments', targetTurnId: 'shared-turn', outcome: 'not_owned', + }; + + assert.equal(projectWorkHubCoordinationTurns([assignment, requested])[1]?.state, 'running'); + const projected = projectWorkHubCoordinationTurns([assignment, requested, notOwned]); + assert.deepEqual(projected[1]?.stop, { + targetSessionId: 'payments', + targetSessionName: 'Payments', + outcome: 'not_owned', + }); + assert.equal(projected[0]?.assignment?.linkState, 'active'); + assert.deepEqual(projectWorkHubActiveDelegations([ + { sequence: 0, message: assignment }, + { sequence: 1, message: requested }, + { sequence: 2, message: notOwned }, + ]), [{ actionId: 'source-action', targetSessionId: 'payments', sequence: 0 }]); + + const stopped = { ...notOwned, outcome: 'stop_delivered' as const }; + assert.equal( + projectWorkHubCoordinationTurns([assignment, requested, stopped])[0]?.assignment?.linkState, + 'stopped', + ); + assert.deepEqual(projectWorkHubActiveDelegations([ + { sequence: 0, message: assignment }, + { sequence: 1, message: requested }, + { sequence: 2, message: stopped }, + ]), []); +}); + test('durable supersession terminalizes only the replaced linkage', () => { const source: StoredMessage = { type: 'workhub_coordination', 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 447b5969b5..c7d8259c8e 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -657,6 +657,13 @@ test('real Session projection creates new guide topics and preserves origin ambi targetTurnId: admitted.turnId, }; } + if (input.proposal.disposition === 'stop_work') { + return { + disposition: 'stop_work', + outcome: 'cancelled_pending', + targetSessionId: input.proposal.stopsActionId, + }; + } const targetSessionId = input.proposal.candidateRef.replace(/^candidate-/u, ''); const admitted = await send(targetSessionId, { type: 'send', diff --git a/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts b/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts index ce7d1e8993..a1fedc89d0 100644 --- a/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts +++ b/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts @@ -20,5 +20,6 @@ export { readWorkHubRequestIntent, workHubCorrectionTargetsSession, + workHubStopTargetsSession, } from '@maka/core/workhub-creation-intent'; export type { WorkHubRequestIntent } from '@maka/core/workhub-creation-intent'; diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index b6d3eac025..eca235c3d3 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -32,6 +32,10 @@ import type { WorkHubCoordinationActResult, WorkHubCoordinationCandidatesResult, } from '@maka/runtime-host/protocol'; +import { + readWorkHubRequestIntent, + workHubStopTargetsSession, +} from './application/contracts/workhub-request-intent.js'; export interface WorkHubSessionTarget { sessionId: string; @@ -110,10 +114,15 @@ export interface WorkHubCoordinationTurn { readonly linkState: WorkHubDelegationLinkState; readonly createdNew?: true; }; + stop?: { + readonly targetSessionId: string; + readonly targetSessionName: string; + readonly outcome?: Extract['outcome']; + }; updatedAt: number; } -export type WorkHubDelegationLinkState = 'active' | 'superseded' | 'aborted'; +export type WorkHubDelegationLinkState = 'active' | 'superseded' | 'aborted' | 'stopped'; /** Unbounded, rebuildable linkage state kept separate from the bounded timeline. */ export interface WorkHubActiveDelegation { @@ -172,7 +181,7 @@ export type WorkHubSubmission = ( requestId: string; text: string; options: Array>; - reason?: 'ambiguous_command'; + reason?: 'ambiguous_command' | 'stop_target_required'; correction?: WorkHubCorrectionContext; } | { @@ -186,6 +195,13 @@ export type WorkHubSubmission = ( text: string; target: WorkHubSessionTarget; } + | { + kind: 'stop'; + requestId: string; + target: WorkHubSessionTarget; + outcome: Extract['outcome']; + targetTurnId?: string; + } ) & { strategyId: WorkHubRoutingStrategyId }; /** @@ -259,9 +275,25 @@ export function createWorkHubController(deps: { let routePolicy = createWorkHubRoutePolicy(); let focusReadVersion = 0; let pendingFocusReadVersion: number | undefined; - const activeActionIdBySessionId = new Map(); + const activeActionIdsBySessionId = new Map(); + const removeActiveAction = (sessionId: string, actionId: string) => { + const remaining = (activeActionIdsBySessionId.get(sessionId) ?? []).filter( + (candidate) => candidate !== actionId, + ); + if (remaining.length === 0) { + activeActionIdsBySessionId.delete(sessionId); + return; + } + activeActionIdsBySessionId.set(sessionId, remaining); + }; + const addActiveAction = (sessionId: string, actionId: string) => { + const active = activeActionIdsBySessionId.get(sessionId) ?? []; + if (!active.includes(actionId)) { + activeActionIdsBySessionId.set(sessionId, [...active, actionId]); + } + }; const correctionFor = (from: WorkHubSessionTarget): WorkHubCorrectionContext => { - const sourceActionId = activeActionIdBySessionId.get(from.sessionId); + const sourceActionId = activeActionIdsBySessionId.get(from.sessionId)?.at(-1); if (!sourceActionId) { throw new Error('WorkHub linked correction requires an active durable delegation'); } @@ -270,11 +302,11 @@ export function createWorkHubController(deps: { const reconcileActiveDelegations = ( activeDelegations: readonly WorkHubActiveDelegation[], ) => { - activeActionIdBySessionId.clear(); + activeActionIdsBySessionId.clear(); for (const delegation of [...activeDelegations].sort( (left, right) => left.sequence - right.sequence, )) { - activeActionIdBySessionId.set(delegation.targetSessionId, delegation.actionId); + addActiveAction(delegation.targetSessionId, delegation.actionId); } }; const reconcileFocus = ( @@ -297,13 +329,10 @@ export function createWorkHubController(deps: { correction: WorkHubCorrectionContext | undefined, ): Extract => { const target = { sessionId: admitted.targetSessionId }; - if ( - correction && - activeActionIdBySessionId.get(correction.from.sessionId) === correction.sourceActionId - ) { - activeActionIdBySessionId.delete(correction.from.sessionId); + if (correction) { + removeActiveAction(correction.from.sessionId, correction.sourceActionId); } - activeActionIdBySessionId.set(target.sessionId, input.requestId); + addActiveAction(target.sessionId, input.requestId); policy.rememberTarget(target); return { kind: 'submitted', @@ -448,6 +477,46 @@ export function createWorkHubController(deps: { const sessions = await deps.sessions.list(); reconcileFocus(submissionPolicy, sessions); const ordinary = sessions.filter((session) => session.kind === 'ordinary'); + const requestIntent = readWorkHubRequestIntent(input.text); + if (requestIntent.stop.cue) { + const matching = ordinary.filter( + (session) => + activeActionIdsBySessionId.get(session.target.sessionId)?.length === 1 && + workHubStopTargetsSession(requestIntent, session.sessionName), + ); + if (!requestIntent.stop.imperative || matching.length !== 1) { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: 'stop_target_required', + }; + } + const target = matching[0]!; + const sourceActionId = activeActionIdsBySessionId.get(target.target.sessionId)![0]!; + const admitted = await coordination.act({ + actionId: input.requestId, + userText: input.text, + proposal: { disposition: 'stop_work', stopsActionId: sourceActionId }, + confirmation: { kind: 'user_stop' }, + }); + if (admitted.disposition !== 'stop_work') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } + if (admitted.outcome !== 'not_owned') { + removeActiveAction(target.target.sessionId, sourceActionId); + } + return { + kind: 'stop', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target: target.target, + outcome: admitted.outcome, + ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), + }; + } const candidateSet = await coordination.candidates(); const candidateBySessionId = new Map( candidateSet.candidates.map((candidate) => [candidate.sessionId, candidate]), diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 1eb40fd3b7..c5505c245f 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -201,13 +201,36 @@ export function projectWorkHubCoordinationTurns( ); const turns: WorkHubCoordinationTurn[] = []; const latestUserIndexByTurnId = new Map(); - const terminalLinkState = new Map(); + const terminalLinkState = new Map(); + const stopResolutionByDelegationId = new Map( + messages.flatMap((message) => + message.type === 'workhub_coordination' && message.kind === 'delegation_stop_resolved' + ? [[message.stopsDelegationId, message] as const] + : [], + ), + ); for (const message of messages) { const terminal = terminalDelegationLink(message); if (terminal) terminalLinkState.set(terminal.delegationId, terminal.state); } for (const message of messages) { + if (message.type === 'workhub_coordination' && message.kind === 'delegation_stop_requested') { + const resolution = stopResolutionByDelegationId.get(message.stopsDelegationId); + turns.push({ + messageId: message.id, + turnId: message.coordinationTurnId, + text: boundedWorkHubTimelineText(message.userText), + state: resolution ? 'completed' : 'running', + stop: { + targetSessionId: message.targetSessionId, + targetSessionName: message.targetSessionName, + ...(resolution ? { outcome: resolution.outcome } : {}), + }, + updatedAt: resolution ? Math.max(message.ts, resolution.ts) : message.ts, + }); + continue; + } if (message.type === 'workhub_coordination' && message.kind === 'delegation_assigned') { turns.push({ messageId: message.id, @@ -262,7 +285,7 @@ export function projectWorkHubCoordinationTurns( function terminalDelegationLink( message: StoredMessage, -): { readonly delegationId: string; readonly state: 'superseded' | 'aborted' } | undefined { +): { readonly delegationId: string; readonly state: 'superseded' | 'aborted' | 'stopped' } | undefined { if (message.type !== 'workhub_coordination') return undefined; if (message.kind === 'delegation_superseded') { return { delegationId: message.supersededDelegationId, state: 'superseded' }; @@ -270,6 +293,9 @@ function terminalDelegationLink( if (message.kind === 'delegation_replacement_aborted') { return { delegationId: message.abortedDelegationId, state: 'aborted' }; } + if (message.kind === 'delegation_stop_resolved' && message.outcome !== 'not_owned') { + return { delegationId: message.stopsDelegationId, state: 'stopped' }; + } return undefined; } diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 1713c6b9df..764a0a7b0c 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -134,14 +134,17 @@ export function visibleWorkHubConversation( const localTurn = localByRequestId.get(turn.turnId); return !localTurn || localTurn.outcome?.kind === 'discussion' || - localTurn.outcome?.kind === 'submitted'; + localTurn.outcome?.kind === 'submitted' || + localTurn.outcome?.kind === 'stop'; }, ); const coordinationTurnIds = new Set(coordination.map(({ turnId }) => turnId)); const visibleLocal = local.filter( (turn) => !coordinationTurnIds.has(turn.requestId) || - (turn.outcome?.kind !== 'discussion' && turn.outcome?.kind !== 'submitted'), + (turn.outcome?.kind !== 'discussion' && + turn.outcome?.kind !== 'submitted' && + turn.outcome?.kind !== 'stop'), ); return { coordination: visibleCoordination, local: visibleLocal }; } @@ -172,7 +175,8 @@ export async function submitAndRecordWorkHubSurfaceInput(input: { if ( result.kind === 'discussion' || result.kind === 'waiting' || - result.kind === 'submitted' + result.kind === 'submitted' || + result.kind === 'stop' ) { return result; } @@ -316,7 +320,7 @@ export function WorkHubSurface(props: { ? { ...turn, state: 'settled', outcome: result } : turn, )); - if (result.kind === 'submitted') await refresh(); + if (result.kind === 'submitted' || result.kind === 'stop') await refresh(); return result; } catch (error) { if (isTerminalWorkHubSurfaceFailure(error)) { @@ -564,16 +568,38 @@ export function WorkHubCoordinationTurnView(props: { (candidate) => candidate.target.sessionId === assignment.targetSessionId, ) : undefined; + const stoppedSession = props.turn.stop + ? props.projection.sessions.find( + (candidate) => candidate.target.sessionId === props.turn.stop!.targetSessionId, + ) + : undefined; return ( - {assignment ? ( + {props.turn.stop ? ( + + ) : assignment ? ( sessionName).join('、')}`; } if (result.kind === 'waiting') { return `${copy.waitingForDecision} ${copy.requestNotSent}`; } + if (result.kind === 'stop') return copy.stopOutcomes[result.outcome]; const target = projection.sessions.find( (session) => session.target.sessionId === result.target.sessionId, ); @@ -633,6 +661,7 @@ function WorkHubTurnView(props: { }) { const { turn, copy } = props; const submitted = turn.outcome?.kind === 'submitted' ? turn.outcome : undefined; + const stopped = turn.outcome?.kind === 'stop' ? turn.outcome : undefined; const target = submitted ? props.projection.sessions.find((session) => session.target.sessionId === submitted.target.sessionId) : undefined; @@ -649,7 +678,9 @@ function WorkHubTurnView(props: { <>

{turn.outcome.reason === 'ambiguous_command' ? copy.confirmCommand - : copy.chooseWork}

+ : turn.outcome.reason === 'stop_target_required' + ? copy.stopTargetRequired + : copy.chooseWork}

{turn.outcome.options.length > 0 ? (
{turn.outcome.options.map((option) => ( @@ -679,6 +710,18 @@ function WorkHubTurnView(props: {

{copy.waitingForDecision}

{copy.requestNotSent}
+ ) : stopped ? ( + session.target.sessionId === stopped.target.sessionId, + )} + targetSessionId={stopped.target.sessionId} + heading={copy.stopOutcomes[stopped.outcome]} + state={stopped.outcome === 'not_owned' ? copy.openSessionToStop : copy.stopRecorded} + result={undefined} + copy={copy} + onOpenSession={props.onOpenSession} + /> ) : submitted ? ( `${count} 项工作`, clarification: '选择工作', chooseWork: '这条输入可能与多项工作有关,请选择目标:', confirmCommand: workHubAmbiguousCommandPrompt(locale), + stopTargetRequired: '请明确说出要停止的工作名称,例如“停止 支付任务”。', discussionStayed: '这条内容暂时保留在 WorkHub,没有创建或改动 Session。', discussionHint: '提出明确的执行目标后,我会把它交给对应的 Session。', answering: '正在回答…', choseWork: (name: string) => `选择“${name}”`, sentTo: '已交给:', createdWork: '已创建新工作:', accepted: '已接收', sessionFallback: '普通 Session', + stoppingWork: '正在请求停止:', stopping: '正在处理', stopRecorded: '结果已记录', + openSessionToStop: '这个 Turn 不由该委托独占;请打开 Session 处理', + stopOutcomes: { + cancelled_pending: '已取消尚未开始的工作:', + stop_delivered: '已向运行中的工作发出停止请求:', + already_terminal: '这项工作已经结束:', + not_owned: '未停止共享或用户拥有的 Turn:', + }, waitingForDecision: '这项工作正在等待你的决定。', requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', @@ -810,6 +862,7 @@ function workHubCopy(locale: UiLocale) { active: (execution: string) => `关联有效 · ${execution}`, superseded: '已被更正', aborted: '更正已中止', + stopped: '已停止关联', }, turnStates: { running: '进行中', completed: '已完成', aborted: '已中止', failed: '失败' }, } as const; @@ -824,11 +877,20 @@ function workHubCopy(locale: UiLocale) { workCount: (count: number) => `${count} work item${count === 1 ? '' : 's'}`, clarification: 'Choose work', chooseWork: 'This input may relate to more than one task. Choose a target:', confirmCommand: workHubAmbiguousCommandPrompt(locale), + stopTargetRequired: 'Name the work explicitly, for example “Stop Payments”.', discussionStayed: 'This stayed in WorkHub without creating or changing a Session.', discussionHint: 'State an executable goal and I will hand it to the owning Session.', answering: 'Answering…', choseWork: (name: string) => `Choose “${name}”`, sentTo: 'Sent to:', createdWork: 'Created new work:', accepted: 'Accepted', sessionFallback: 'Ordinary Session', + stoppingWork: 'Requesting stop:', stopping: 'Stopping', stopRecorded: 'Result recorded', + openSessionToStop: 'This Turn is shared or user-owned. Open the Session to stop it.', + stopOutcomes: { + cancelled_pending: 'Cancelled work that had not started:', + stop_delivered: 'Asked the running work to stop:', + already_terminal: 'This work had already ended:', + not_owned: 'Did not stop a shared or user-owned Turn:', + }, 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.', @@ -858,6 +920,7 @@ function workHubCopy(locale: UiLocale) { active: (execution: string) => `Active link · ${execution}`, superseded: 'Superseded link', aborted: 'Aborted replacement', + stopped: 'Stopped link', }, turnStates: { running: 'Running', completed: 'Completed', aborted: 'Aborted', failed: 'Failed' }, } as const; diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 06ae83fd5e..710836c837 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -184,6 +184,17 @@ waiting after the destructive retirement boundary, Coordination appends a retired source from active linkage and makes later retries return the same terminal outcome instead of displaying a stopped, unsuperseded link. +Direct stop persists a distinct `delegation_stop_requested` claim before +retirement and a `delegation_stop_resolved` observation afterward. The pending +cancellation tombstone retains the destructive action identity, preserving +`cancelled_pending` across a crash between those two Coordination records. Its +owning-root Stop uses an action-derived abort source on the exact target Turn, +so recovery cannot mistake a normal Session stop for WorkHub delivery. Its +admission holds the Coordination Session together with every active target +Session lane while re-reading the active links and current display names. A +concurrent assignment or rename must therefore settle before the uniqueness +proof, wait until after the stop claim, or cause admission to fail closed. + ## Consequences, costs, and reevaluation - WorkHub gains persistent conversational continuity without adding another @@ -206,7 +217,10 @@ outcome instead of displaying a stopped, unsuperseded link. that transcript; target lifecycle projection and the hybrid first-response contract are implemented as rebuildable reads. Linked correction, exact target-owned pending cancellation/Turn Stop, atomic supersession, and retry-based - replacement recovery are implemented. Broader stop/resume controls remain later + replacement recovery and explicit named direct-stop are implemented. Direct + stop uses durable `delegation_stop_requested` / `delegation_stop_resolved` + facts, exact Message ownership, and first-claim-wins arbitration with + replacement. Pause, resume, and pronoun-based stop controls remain later work. Reevaluate the per-Host decision if supported workflows require one WorkHub diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md index a78f03655a..c5b7770ba2 100644 --- a/docs/workhub-domain-language.md +++ b/docs/workhub-domain-language.md @@ -69,7 +69,7 @@ Coordination Session without guessing or creating. **delegation**: A bounded reference from a Coordination Turn to one target ordinary Session and Turn, including only its identity, disposition, and coordination-owned -link status (`active`, `superseded`, or `aborted`). A link is `aborted` only when a +link status (`active`, `superseded`, `aborted`, or `stopped`). A link is `aborted` only when a correction retired its source but the replacement target became unavailable or started waiting before admission; it is not the target Turn's execution status. Delegation links the separately authoritative transcripts; it does not copy the @@ -105,6 +105,32 @@ the Coordination transcript records an auditable replacement-aborted terminal fact and removes the retired source from active linkage. Correction never replaces either Session's transcript authority. +**Direct stop**: A user's explicit, named imperative to retire one active durable +delegation, such as `Stop Payments` or `停止支付任务`. Pronouns, pause/wait language, +questions, advice, negation, malformed literals, and model-supplied Session, Turn, +Run, or Message identities grant no Stop authority. WorkHub first records +`delegation_stop_requested`, resolves the named source action to its durable +delegation, and lets the target Session's Message authority observe one of four +outcomes: `cancelled_pending`, `stop_delivered`, `already_terminal`, or `not_owned`. +It then records the neutral `delegation_stop_resolved` fact. `stop_delivered` means +the exact owning root accepted the Stop operation; the UI says that WorkHub asked +it to stop rather than inventing an execution result. `not_owned` means the +Message was consumed by a shared or user-owned Turn; WorkHub does not stop that +Turn, preserves the active link, and navigates the user to the owning Session. +An unresolved direct-stop claim and a replacement claim are mutually exclusive; +the first durable destructive claim wins. A `not_owned` resolution releases that +exclusion so a later explicit route correction can proceed. +The pending-Message cancellation tombstone binds the durable stop action that +created it, so a crash after cancellation but before resolution still replays +`cancelled_pending` rather than degrading to `already_terminal`. Owning-root Stop +likewise writes the direct-stop action identity into the exact root Turn's +durable abort source. A retry recognizes only that matching proof; an earlier or +concurrent manual Stop remains `already_terminal`. Stop admission holds the +Coordination Session and every currently active target Session lane +while it rechecks current names and active links; a concurrent rename or new +delegation therefore cannot invalidate the named-one-target proof before the +request record commits. + **R2.4**: The deterministic context-continuity routing baseline. It remains useful as an experiment baseline or target resolver behind WorkHub's coordination layer; it is not the final architecture or authority boundary of WorkHub. diff --git a/packages/core/src/__tests__/workhub-coordination-record.test.ts b/packages/core/src/__tests__/workhub-coordination-record.test.ts index ad167c6cc3..3d31480799 100644 --- a/packages/core/src/__tests__/workhub-coordination-record.test.ts +++ b/packages/core/src/__tests__/workhub-coordination-record.test.ts @@ -199,4 +199,66 @@ describe('WorkHub Coordination stored records', () => { /Invalid stored message schema/u, ); }); + + test('decodes exact direct-stop request and observed resolution records', () => { + const requested = { + type: 'workhub_coordination', + id: 'stop-request-id', + turnId: 'stop-action', + ts: 4, + schemaVersion: 3, + kind: 'delegation_stop_requested', + actionId: 'stop-action', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'stop-action', + stopsActionId: 'original-action', + stopsDelegationId: 'original-delegation', + targetSessionId: 'payments', + targetMessageId: 'payments-message', + targetSessionName: 'Payments', + userText: 'Stop Payments', + } as const; + const resolved = { + type: 'workhub_coordination', + id: 'stop-resolution-id', + turnId: 'stop-action', + ts: 5, + schemaVersion: 3, + kind: 'delegation_stop_resolved', + actionId: 'stop-action', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'stop-action', + stopsActionId: 'original-action', + stopsDelegationId: 'original-delegation', + targetSessionId: 'payments', + targetTurnId: 'payments-turn', + outcome: 'stop_delivered', + } as const; + + assert.deepEqual(decodeCanonicalMessage(requested), requested); + assert.deepEqual(decodeCanonicalMessage(resolved), resolved); + for (const invalid of [ + { ...requested, candidateRef: 'injected' }, + { ...requested, schemaVersion: 2 }, + { ...resolved, outcome: 'stopped' }, + { ...resolved, runId: 'injected' }, + { ...resolved, targetTurnId: '' }, + { ...resolved, targetTurnId: undefined }, + { ...resolved, outcome: 'cancelled_pending' }, + ]) { + assert.throws(() => decodeCanonicalMessage(invalid), /Invalid stored message schema/u); + } + assert.deepEqual( + decodeCanonicalMessage({ + ...resolved, + outcome: 'cancelled_pending', + targetTurnId: undefined, + }), + { + ...resolved, + outcome: 'cancelled_pending', + targetTurnId: undefined, + }, + ); + }); }); diff --git a/packages/core/src/__tests__/workhub-creation-intent.test.ts b/packages/core/src/__tests__/workhub-creation-intent.test.ts index bce72071bf..9efbc06bd9 100644 --- a/packages/core/src/__tests__/workhub-creation-intent.test.ts +++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts @@ -23,6 +23,7 @@ import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, workHubCreationAuthorizesTitle, + workHubStopTargetsSession, } from '../workhub-creation-intent.js'; const intentFor = readWorkHubRequestIntent; @@ -185,6 +186,21 @@ test('requires an affirmative target action for destructive corrections', () => assert.equal(isAffirmativeWorkHubCorrectionRequest(text), false, text); assert.equal(isExplicitWorkHubCreationRequest(text), false, text); } + + for (const sessionName of ['U.S.', 'Dr.']) { + assert.equal( + workHubStopTargetsSession(readWorkHubRequestIntent(`Stop ${sessionName}`), sessionName), + true, + sessionName, + ); + } + for (const text of ['Stop Payments, fix Login', 'Stop Payments and Login']) { + assert.equal( + workHubStopTargetsSession(readWorkHubRequestIntent(text), 'Payments'), + false, + text, + ); + } }); test('recognizes affirmative creation after an explicit contrast', () => { @@ -997,3 +1013,43 @@ test('returns one bounded intent record for routing and admission', () => { assert.equal(readWorkHubRequestIntent(text).execution, 'non_executable', text); } }); + +test('requires a direct, explicitly named command for WorkHub stop authority', () => { + for (const [text, target] of [ + ['Stop Payments', 'Payments'], + ['Please cancel the session Payments.', 'Payments'], + ['Terminate work "API migration"', 'API migration'], + ['停止支付任务', '支付任务'], + ['请取消这个会话 登录稳定性。', '登录稳定性'], + ] as const) { + const intent = readWorkHubRequestIntent(text); + assert.deepEqual(intent.stop, { cue: true, imperative: true, target }, text); + assert.equal(workHubStopTargetsSession(intent, target), true, text); + assert.equal(workHubStopTargetsSession(intent, `${target} extra`), false, text); + } + + for (const text of [ + 'Stop it', + 'Cancel this work', + '取消这个工作', + 'Pause Payments', + 'Wait on Payments', + 'How do I stop Payments?', + 'Can you stop Payments?', + 'Do not stop Payments', + "Don't cancel Payments", + '不要停止支付任务', + 'The literal text is "Stop Payments"', + '"Stop Payments"', + 'Stop "Payments', + ]) { + assert.deepEqual( + readWorkHubRequestIntent(text).stop, + { + cue: text === 'Stop it' || text === 'Cancel this work' || text === '取消这个工作', + imperative: false, + }, + text, + ); + } +}); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index fb29abb545..7853001b99 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -935,6 +935,7 @@ export interface TurnStateMessage { export const WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION = 1 as const; export const WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION = 2 as const; +export const WORKHUB_COORDINATION_STOP_SCHEMA_VERSION = 3 as const; export type WorkHubDelegationDisposition = 'delegate_existing' | 'create_new'; @@ -1028,11 +1029,66 @@ export interface WorkHubDelegationReplacementAbortedMessage { reason: 'target_unavailable' | 'target_waiting_for_user'; } +export type WorkHubDelegationStopOutcome = + | 'cancelled_pending' + | 'stop_delivered' + | 'already_terminal' + | 'not_owned'; + +/** Durable destructive claim written before attempting to retire one delegation. */ +export interface WorkHubDelegationStopRequestedMessage { + type: 'workhub_coordination'; + id: string; + turnId: string; + ts: number; + schemaVersion: typeof WORKHUB_COORDINATION_STOP_SCHEMA_VERSION; + kind: 'delegation_stop_requested'; + actionId: string; + actionFingerprint: `sha256:${string}`; + coordinationTurnId: string; + stopsActionId: string; + stopsDelegationId: string; + targetSessionId: string; + targetMessageId: string; + targetSessionName: string; + userText: string; +} + +/** Durable observed result of a direct-stop attempt. */ +export interface WorkHubDelegationStopResolvedMessage { + type: 'workhub_coordination'; + id: string; + turnId: string; + ts: number; + schemaVersion: typeof WORKHUB_COORDINATION_STOP_SCHEMA_VERSION; + kind: 'delegation_stop_resolved'; + actionId: string; + actionFingerprint: `sha256:${string}`; + coordinationTurnId: string; + stopsActionId: string; + stopsDelegationId: string; + targetSessionId: string; + outcome: WorkHubDelegationStopOutcome; + targetTurnId?: string; +} + export type WorkHubCoordinationMessage = | WorkHubDelegationAssignedMessage | WorkHubDelegationReplacementRequestedMessage | WorkHubDelegationReplacementAbortedMessage - | WorkHubDelegationSupersededMessage; + | WorkHubDelegationSupersededMessage + | WorkHubDelegationStopRequestedMessage + | WorkHubDelegationStopResolvedMessage; + +function isWorkHubDelegationStopResolution( + outcome: unknown, + targetTurnId: unknown, +): outcome is WorkHubDelegationStopOutcome { + const hasTargetTurnId = typeof targetTurnId === 'string' && targetTurnId.length > 0; + if (outcome === 'stop_delivered' || outcome === 'not_owned') return hasTargetTurnId; + if (outcome === 'cancelled_pending') return targetTurnId === undefined; + return outcome === 'already_terminal' && (targetTurnId === undefined || hasTargetTurnId); +} export interface TurnRecord { turnId: string; @@ -1247,6 +1303,46 @@ const WORKHUB_DELEGATION_REPLACEMENT_ABORTED_MESSAGE_SHAPE = ], [], ); +const WORKHUB_DELEGATION_STOP_REQUESTED_MESSAGE_SHAPE = + defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'stopsActionId', + 'stopsDelegationId', + 'targetSessionId', + 'targetMessageId', + 'targetSessionName', + 'userText', + ], + [], + ); +const WORKHUB_DELEGATION_STOP_RESOLVED_MESSAGE_SHAPE = + defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'stopsActionId', + 'stopsDelegationId', + 'targetSessionId', + 'outcome', + ], + ['targetTurnId'], + ); const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( ['title', 'workspace'], [], @@ -1432,6 +1528,41 @@ function decodeMessage( } function isWorkHubCoordinationMessage(message: Record): boolean { + if (message.kind === 'delegation_stop_requested') { + return ( + hasMessageEnvelope(message, true) && + hasExactShape(message, WORKHUB_DELEGATION_STOP_REQUESTED_MESSAGE_SHAPE) && + message.schemaVersion === WORKHUB_COORDINATION_STOP_SCHEMA_VERSION && + isWorkHubActionIdentity(message) && + typeof message.stopsActionId === 'string' && + message.stopsActionId.length > 0 && + typeof message.stopsDelegationId === 'string' && + message.stopsDelegationId.length > 0 && + typeof message.targetSessionId === 'string' && + message.targetSessionId.length > 0 && + typeof message.targetMessageId === 'string' && + message.targetMessageId.length > 0 && + typeof message.targetSessionName === 'string' && + message.targetSessionName.trim().length > 0 && + typeof message.userText === 'string' && + message.userText.trim().length > 0 + ); + } + if (message.kind === 'delegation_stop_resolved') { + return ( + hasMessageEnvelope(message, true) && + hasExactShape(message, WORKHUB_DELEGATION_STOP_RESOLVED_MESSAGE_SHAPE) && + message.schemaVersion === WORKHUB_COORDINATION_STOP_SCHEMA_VERSION && + isWorkHubActionIdentity(message) && + typeof message.stopsActionId === 'string' && + message.stopsActionId.length > 0 && + typeof message.stopsDelegationId === 'string' && + message.stopsDelegationId.length > 0 && + typeof message.targetSessionId === 'string' && + message.targetSessionId.length > 0 && + isWorkHubDelegationStopResolution(message.outcome, message.targetTurnId) + ); + } if (message.kind === 'delegation_replacement_aborted') { return ( hasMessageEnvelope(message, true) && @@ -1521,6 +1652,18 @@ function isWorkHubCoordinationMessage(message: Record): boolean ); } +function isWorkHubActionIdentity(message: Record): boolean { + return ( + typeof message.actionId === 'string' && + message.actionId.length > 0 && + typeof message.actionFingerprint === 'string' && + /^sha256:[a-f0-9]{64}$/u.test(message.actionFingerprint) && + typeof message.coordinationTurnId === 'string' && + message.coordinationTurnId.length > 0 && + message.turnId === message.coordinationTurnId + ); +} + function isWorkHubDelegationCreateSpec(value: unknown): value is WorkHubDelegationCreateSpec { if ( !isRecord(value) || diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index 613afb6684..e35a9f05be 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -99,6 +99,12 @@ const CREATION_REQUEST_PREFIX = const NAMED_CREATION_TITLE_INTRODUCER = /\b(?:new|brand[- ]new)\s+(?:session|work|task)[\s,,::-]+(?:(?:called|named|titled)|with\s+(?:the\s+)?title)\s+|(?:新的?|全新的?)?\s*(?:Session|会话|工作|任务)[\s,,::-]*(?:叫做?|名叫|名为|命名为|标题为|名称为|名字为)\s*/iu; const LEADING_CORRECTION_SEPARATOR = /^[\s,.;:!?,。;:!?—–-]+/u; +const DIRECT_STOP_REQUEST = + /^\s*(?:(?:please|kindly)\s+)?(?:stop|cancel|terminate|halt)\s+(?:(?:the|this)\s+)?(?:(?:session|work|task|job)\s+)?(.+?)\s*[.!。!]?\s*$/iu; +const DIRECT_CHINESE_STOP_REQUEST = + /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:停止|取消|终止|中止)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; +const UNSAFE_STOP_TARGET = + /^(?:it|this|that|one|everything|all|current|session|work|task|job|(?:this|that|current)\s+(?:session|work|task|job)|它|这个|那个|全部|当前|会话|工作|任务|(?:这个|那个|当前)(?:会话|工作|任务))$/iu; /** How much authority trusted user text carries for starting work. */ export type WorkHubExecutionIntent = 'imperative' | 'ambiguous' | 'non_executable'; @@ -119,6 +125,13 @@ export interface WorkHubRequestIntent { readonly cue: boolean; readonly existingTarget?: string; }; + readonly stop: { + /** A direct stop speech act was present, but its target may still be unsafe. */ + readonly cue: boolean; + /** True only for a direct, explicitly named stop command. */ + readonly imperative: boolean; + readonly target?: string; + }; } /** @@ -276,6 +289,8 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { : { kind: 'unusable' }; const correctionCue = hasWorkHubCorrectionCue(source); const existingTarget = affirmativeWorkHubExistingCorrectionTarget(source); + const stopCue = directWorkHubStopCue(source, literalMask.malformed); + const stopTarget = stopCue ? directWorkHubStopTarget(source, false) : undefined; const actions = allMatches(masked, EXECUTION_ACTION); const execution: WorkHubExecutionIntent = literalMask.malformed || naming.kind === 'unusable' || hasDominatingDeliberation(masked) @@ -292,9 +307,26 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { cue: correctionCue, ...(existingTarget ? { existingTarget } : {}), }, + stop: { + cue: stopCue, + imperative: Boolean(stopTarget), + ...(stopTarget ? { target: stopTarget } : {}), + }, }; } +/** Whether a parsed direct-stop command names exactly this Session. */ +export function workHubStopTargetsSession( + intent: WorkHubRequestIntent, + sessionName: string, +): boolean { + return Boolean( + intent.stop.imperative && + intent.stop.target && + stopTargetMatchesSession(intent.stop.target, sessionName), + ); +} + /** Whether a parsed correction names exactly this Session. */ export function workHubCorrectionTargetsSession( intent: WorkHubRequestIntent, @@ -397,6 +429,61 @@ function correctionTargetMatchesSession(target: string, sessionName: string): bo ); } +function directWorkHubStopTarget(value: string, malformedLiteral: boolean): string | undefined { + if (malformedLiteral || /[??]\s*$/u.test(value)) return undefined; + const match = DIRECT_STOP_REQUEST.exec(value) ?? DIRECT_CHINESE_STOP_REQUEST.exec(value); + const rawTarget = match?.[1]?.trim(); + if (!rawTarget) return undefined; + const target = stripMatchingStopQuotes(rawTarget.replace(/[.!。!]+\s*$/u, '').trim()); + if (!target || UNSAFE_STOP_TARGET.test(target)) return undefined; + return target; +} + +function stopTargetMatchesSession(target: string, sessionName: string): boolean { + const normalizedTarget = normalizeCorrectionIdentity(target); + const normalizedName = normalizeCorrectionIdentity(sessionName); + if (!normalizedName) return false; + const quotedNames = [ + `"${normalizedName}"`, + `“${normalizedName}”`, + `'${normalizedName}'`, + `‘${normalizedName}’`, + ]; + const matchedName = [normalizedName, ...quotedNames] + .sort((left, right) => right.length - left.length) + .find( + (candidate) => + normalizedTarget.startsWith(candidate) && + !/[\p{L}\p{N}]/u.test(normalizedTarget[candidate.length] ?? ''), + ); + if (matchedName) { + if (matchedName === normalizedName && hasUnsafeUnquotedHardClauseBoundary(sessionName)) { + return false; + } + return /^[.!?。!?]*$/u.test(normalizedTarget.slice(matchedName.length).trim()); + } + return ( + /[.!。!]$/u.test(normalizedName) && + normalizedTarget === normalizedName.replace(/[.!。!]+$/u, '').trim() + ); +} + +function directWorkHubStopCue(value: string, malformedLiteral: boolean): boolean { + if (malformedLiteral || /[??]\s*$/u.test(value)) return false; + return Boolean(DIRECT_STOP_REQUEST.test(value) || DIRECT_CHINESE_STOP_REQUEST.test(value)); +} + +function stripMatchingStopQuotes(value: string): string { + const pairs = new Map([ + ['"', '"'], + ["'", "'"], + ['“', '”'], + ['‘', '’'], + ]); + const closer = pairs.get(value[0] ?? ''); + return closer && value.endsWith(closer) ? value.slice(1, -1).trim() : value; +} + function normalizeCorrectionIdentity(value: string): string { return value.normalize('NFKC').toLocaleLowerCase().replace(/\s+/gu, ' ').trim(); } diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 0f25236dc9..c30c0ed940 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -37,6 +37,7 @@ import { } from '@maka/runtime/test-only/fake-backend'; import { LOCAL_READ_AGENT_DEFINITION } from '@maka/runtime/agent-catalog'; import { SessionManager } from '@maka/runtime/session-manager'; +import { workHubDirectStopAbortSource } from '@maka/runtime/session-manager'; import { fingerprintAgentGraphRunnableIntent } from '@maka/runtime/stream-graph-admission'; import type { AgentGraphRunnableIntent } from '@maka/runtime/stream-graph-readiness'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; @@ -58,6 +59,7 @@ import { HostResidencyRegistry } from '../server/host-residency-registry.js'; import { createExecutionRuntimeHostComposition, runtimeHostFilesystemWorkerRuntime, + stopOwnedWorkHubRoot, } from '../server/execution-composition.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; @@ -70,6 +72,119 @@ test('filesystem worker follows the candidate executable runtime', () => { assert.equal(runtimeHostFilesystemWorkerRuntime({}), 'node'); }); +test('WorkHub recovers a delivered root Stop from its durable cancelled Turn', async () => { + let stopCalls = 0; + const outcome = await stopOwnedWorkHubRoot( + { + readRootState: () => ({ kind: 'idle' }), + read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({ + ...identity, + status: 'cancelled', + terminalEventId: 'terminal-workhub-stop', + abortSource: workHubDirectStopAbortSource('workhub-stop-action'), + }), + stopRoot: async () => { + stopCalls += 1; + }, + } as unknown as Parameters[0], + { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' }, + 'workhub-stop-action', + ); + + assert.deepEqual(outcome, { + outcome: 'stop_delivered', + targetTurnId: 'target-turn', + }); + assert.equal(stopCalls, 0); +}); + +test('WorkHub binds a fresh owning-root Stop to its action identity', async () => { + let source: string | undefined; + let actionId: string | undefined; + const outcome = await stopOwnedWorkHubRoot( + { + readRootState: () => ({ + kind: 'active', + sessionId: 'target-session', + turnId: 'target-turn', + runId: 'target-run', + }), + read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({ + ...identity, + status: 'cancelled', + terminalEventId: 'terminal-workhub-stop', + abortSource: workHubDirectStopAbortSource('workhub-stop-action'), + }), + stopRoot: async ( + _identity: { sessionId: string; turnId: string; runId: string }, + input: { + source?: 'stop_button' | 'graph_supervisor' | 'workhub_direct_stop'; + workHubActionId?: string; + }, + ) => { + source = input.source; + actionId = input.workHubActionId; + }, + } as unknown as Parameters[0], + { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' }, + 'workhub-stop-action', + ); + + assert.equal(source, 'workhub_direct_stop'); + assert.equal(actionId, 'workhub-stop-action'); + assert.equal(outcome.outcome, 'stop_delivered'); +}); + +test('WorkHub detects a manual Stop that wins after its active-root check', async () => { + let stopCalls = 0; + const outcome = await stopOwnedWorkHubRoot( + { + readRootState: () => ({ + kind: 'active', + sessionId: 'target-session', + turnId: 'target-turn', + runId: 'target-run', + }), + read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({ + ...identity, + status: 'cancelled', + terminalEventId: 'concurrent-manual-stop', + abortSource: 'renderer.stop_button', + }), + stopRoot: async () => { + stopCalls += 1; + }, + } as unknown as Parameters[0], + { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' }, + 'workhub-stop-action', + ); + + assert.equal(stopCalls, 1); + assert.equal(outcome.outcome, 'already_terminal'); +}); + +test('WorkHub does not claim an unrelated manual Stop as its delivery', async () => { + const outcome = await stopOwnedWorkHubRoot( + { + readRootState: () => ({ kind: 'idle' }), + read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({ + ...identity, + status: 'cancelled', + terminalEventId: 'earlier-manual-stop', + abortSource: 'renderer.stop_button', + }), + stopRoot: async () => assert.fail('a terminal root must not be stopped again'), + } as unknown as Parameters[0], + { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' }, + 'workhub-stop-action', + ); + + assert.deepEqual(outcome, { + outcome: 'already_terminal', + targetTurnId: 'target-turn', + }); +}); + test('production composition owns the long-term memory database lifecycle', async () => { await withCompositionRoot(async ({ root, owner }) => { const databasePath = join(root, LONG_TERM_MEMORY_DATABASE_NAME); @@ -590,6 +705,32 @@ test('WorkHub correction replaces its link without stopping a shared manual Turn return proof.ok && proof.result.resolutions[0]?.state === 'owned'; }); + const stopped = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-stop-shared-action', + userText: `Stop ${sourceCandidate.sessionName}`, + confirmation: { kind: 'user_stop' }, + proposal: { + disposition: 'stop_work', + stopsActionId: assignment.actionId, + }, + }, + context, + ); + assert.deepEqual(stopped, { + ok: true, + result: { + disposition: 'stop_work', + outcome: 'not_owned', + targetSessionId: source.id, + targetTurnId: 'manual-active-turn', + }, + }); + assert.equal( + (await stores.sessionStore.readWorkHubStopResolution(assignment.delegationId))?.outcome, + 'not_owned', + ); + const unrelated = await composition.handlers['turn.message.submit']( { originHostEpoch: context.hostEpoch, diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index ae6c2e168f..f897e8ba51 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -490,7 +490,7 @@ test('exact pending cancellation removes only the linked Message', async () => { assert.deepEqual( await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'), - { kind: 'cancelled' }, + { kind: 'cancelled_pending' }, ); assert.deepEqual( fixture.coordinator.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), @@ -502,6 +502,37 @@ test('exact pending cancellation removes only the linked Message', async () => { ); }); +test('a durable cancellation claim preserves cancelled_pending across restart-style replay', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + await submit(fixture, 'linked-message', 'wrong delegation', 'next_turn'); + + assert.deepEqual( + await fixture.coordinator.cancelMessageIfPending( + ROOT.sessionId, + 'linked-message', + 'stop-claim', + ), + { kind: 'cancelled_pending' }, + ); + assert.deepEqual( + await fixture.coordinator.cancelMessageIfPending( + ROOT.sessionId, + 'linked-message', + 'stop-claim', + ), + { kind: 'cancelled_pending' }, + ); + assert.deepEqual( + await fixture.coordinator.cancelMessageIfPending( + ROOT.sessionId, + 'linked-message', + 'different-claim', + ), + { kind: 'cancelled' }, + ); +}); + test('a consumed steering Message cannot claim ownership of its pre-existing root Turn', async () => { const fixture = createFixture(); fixture.events.push(steeringEvent('linked-message', 'wrong delegation')); @@ -3804,6 +3835,7 @@ function memoryMessageAdmissionStore( >, onMessagesHandedOff?: (input: MarkMessagesHandedOffInput) => void, ): MessageAdmissionStore { + const cancellationClaims = new Map(); return { commitMessageAdmission: async (admission) => { const existing = admissions.get(admission.messageId); @@ -3814,6 +3846,16 @@ function memoryMessageAdmissionStore( readMessageAdmission: async (_sessionId, messageId) => admissions.get(messageId)?.admission, hasCancelledMessageAdmission: async (_sessionId, messageId) => admissions.get(messageId)?.state === 'cancelled', + claimMessageAdmissionCancellation: async (_sessionId, messageId, claimId) => { + const existing = admissions.get(messageId); + if (existing?.state === 'cancelled') { + return cancellationClaims.get(messageId) === claimId ? 'same_claim' : 'already_cancelled'; + } + if (!existing) throw new Error(`Missing admission ${messageId}`); + existing.state = 'cancelled'; + cancellationClaims.set(messageId, claimId); + return 'cancelled_by_claim'; + }, listMessageAdmissions: async (sessionId) => [...admissions.values()] .filter(({ admission, state }) => admission.sessionId === sessionId && state === 'accepted') diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 8ba15639e0..c53171b3f8 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -4204,6 +4204,74 @@ test('public turn.interrupt releases the Session lane while a queried Run is sti } }); +test('invalid WorkHub Stop provenance fails before the root fence mutates authority', async () => { + let backend: BlockingRootBackend | undefined; + const fixture = await createFailureFixture({ + registerBackend: (backends) => + backends.register('ai-sdk', (context) => { + backend = new BlockingRootBackend(context.sessionId); + return backend; + }), + }); + try { + const started = await fixture.interactiveTurns.handlers['turn.start']( + { + sessionId: fixture.sessionId, + turnId: 'turn-invalid-workhub-stop', + content: { text: 'keep this root active' }, + }, + operationContext(fixture.hostEpoch, fixture.acquireResidency), + ); + assert.equal(started.ok, true); + if (!started.ok) return; + assertStartedTurn(started); + await backend?.started.promise; + + const queued = await fixture.messages.handlers['turn.message.submit']( + { + originHostEpoch: fixture.hostEpoch, + sessionId: fixture.sessionId, + messageId: 'queued-before-invalid-workhub-stop', + content: { text: 'preserve this follow-up' }, + placement: 'next_turn', + }, + operationContext(fixture.hostEpoch, fixture.acquireResidency), + ); + assert.equal(queued.ok, true); + const before = fixture.messages.projection(fixture.sessionId); + + const invalidInputs = [ + { source: 'workhub_direct_stop' }, + { source: 'workhub_direct_stop', workHubActionId: '' }, + { source: 'stop_button', workHubActionId: 'wrong-source-action' }, + ]; + for (const input of invalidInputs) { + await assert.rejects( + async () => + fixture.coordinator.stopRoot( + { + sessionId: fixture.sessionId, + turnId: 'turn-invalid-workhub-stop', + runId: started.result.turn.runId, + }, + input as never, + ), + /WorkHub direct-stop/, + ); + } + + assert.deepEqual(fixture.messages.projection(fixture.sessionId), before); + assert.equal(fixture.coordinator.readRootState(fixture.sessionId).kind, 'active'); + assert.equal(fixture.fallbackRunClosureClaims(), 0); + assert.equal(backend?.stopCount, 0); + } finally { + backend?.release(); + await fixture.coordinator.close(); + await fixture.messages.close(); + await fixture.dispose(); + } +}); + test('Runtime stop lets a running admission publish before its exact-Run closure', { timeout: 20_000, }, async () => { @@ -5077,6 +5145,7 @@ async function createFailureFixture(options: { let canonicalProjection: CanonicalSessionProjectionReader | undefined; let messages!: HostMessageCoordinator; let interactions: HostInteractionCoordinator | undefined; + let fallbackRunClosureClaims = 0; const rootPort: HostMessageRootPort = { readSessionHeader: (sessionId) => requireCoordinator(coordinator).readSessionHeader(sessionId), readRootState: (sessionId) => requireCoordinator(coordinator).readRootState(sessionId), @@ -5187,7 +5256,9 @@ async function createFailureFixture(options: { admissionOwner, interactions ?? { assertTerminalFence: async () => undefined, - claimRunClosure: async () => undefined, + claimRunClosure: async () => { + fallbackRunClosureClaims += 1; + }, }, messages, requireContinuity(continuity), @@ -5267,6 +5338,7 @@ async function createFailureFixture(options: { }, liveResidencies: () => liveResidencies, drainRequested: () => drainRequested, + fallbackRunClosureClaims: () => fallbackRunClosureClaims, dispose: async () => { requireContinuity(continuity).close(); artifacts?.close(); 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 a1bed88c73..1ca27c8e4d 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 @@ -23,6 +23,8 @@ import type { WorkHubDelegationAssignedMessage, WorkHubDelegationReplacementAbortedMessage, WorkHubDelegationReplacementRequestedMessage, + WorkHubDelegationStopRequestedMessage, + WorkHubDelegationStopResolvedMessage, WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { @@ -35,6 +37,9 @@ import { type WorkHubDelegationAssignmentInput, type WorkHubDelegationReplacementAbortInput, type WorkHubDelegationReplacementInput, + type WorkHubDelegationStopInput, + type WorkHubDelegationStopResolutionInput, + type WorkHubRetirementResult, } from '../server/workhub-coordination-action-gate.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; @@ -312,6 +317,218 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.assignments.length, 1); }); + test('stops exactly one named durable delegation and replays its observed outcome', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix payment retry', + }, + 'source-turn', + ), + ); + const input = { + actionId: 'stop-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work' as const, stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' as const }, + }; + + const first = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); + assert.deepEqual(first, { + disposition: 'stop_work', + outcome: 'cancelled_pending', + targetSessionId: 'payments', + }); + assert.equal(effects.retirements.length, 1); + assert.equal(effects.stopRequests.size, 1); + assert.equal(effects.stopResolutions.size, 1); + + const replay = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); + assert.deepEqual(replay, first); + assert.equal(effects.retirements.length, 1); + }); + + test('rejects a named stop that does not identify one active durable delegation', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + for (const actionId of ['source-action', 'other-action']) { + effects.assignmentRecords.set( + actionId, + assignmentRecord( + { + actionId, + actionFingerprint: `sha256:${(actionId === 'source-action' ? '1' : '2').repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: `Work from ${actionId}`, + }, + `${actionId}-turn`, + ), + ); + } + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-ambiguous-payments', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.stopRequests.size, 0); + assert.equal(effects.retirements.length, 0); + }); + + test('rejects stop authority from confirmation alone or a different named target', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix payment retry', + }, + 'source-turn', + ), + ); + for (const userText of [ + 'Stop it', + 'Pause Payments', + 'How do I stop Payments?', + 'Do not stop Payments', + 'Stop Login', + ]) { + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: `stop-${userText}`, + userText, + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + userText, + ); + } + assert.equal(effects.retirements.length, 0); + }); + + test('records not_owned without treating a shared user Turn as stopped', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'c'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix payment retry', + }, + 'source-turn', + ), + ); + effects.retireDelegation = async () => ({ + outcome: 'not_owned', + targetTurnId: 'shared-turn', + }); + + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-shared', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + assert.deepEqual(result, { + disposition: 'stop_work', + outcome: 'not_owned', + targetSessionId: 'payments', + targetTurnId: 'shared-turn', + }); + assert.equal(effects.supersessions.size, 0); + effects.supersessions.set('delegation-source-action', { + type: 'workhub_coordination', + id: 'later-supersession', + turnId: 'later-correction', + ts: 9, + schemaVersion: 2, + kind: 'delegation_superseded', + actionId: 'later-correction', + actionFingerprint: `sha256:${'d'.repeat(64)}`, + coordinationTurnId: 'later-correction', + supersededActionId: 'source-action', + supersededDelegationId: 'delegation-source-action', + replacementDelegationId: 'replacement-delegation', + }); + assert.deepEqual( + await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-shared', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + result, + ); + }); + + test('binds a fresh stop to the current display name while replay keeps its durable name', async () => { + const effects = fakeEffects([session('payments', { name: 'Renamed Payments' })]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'e'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Old Payments', + disposition: 'delegate_existing', + userText: 'Fix payment retry', + }, + 'source-turn', + ), + ); + const input = { + actionId: 'stop-renamed', + userText: 'Stop Renamed Payments', + proposal: { disposition: 'stop_work' as const, stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' as const }, + }; + await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); + assert.equal( + effects.stopRequests.get('delegation-source-action')?.targetSessionName, + 'Renamed Payments', + ); + effects.sessions[0] = session('payments', { name: 'Renamed Again' }); + assert.equal( + (await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT)).disposition, + 'stop_work', + ); + }); + test('rejects waiting targets independently of strategy behavior', async () => { const effects = fakeEffects([session('waiting', { status: 'waiting_for_user' })]); const gate = new WorkHubCoordinationActionGate(effects); @@ -1573,10 +1790,11 @@ describe('WorkHub Coordination Action Gate', () => { )!; const retireDelegation = effects.retireDelegation; effects.retireDelegation = async (assignment) => { - await retireDelegation.call(effects, assignment); + const result = await retireDelegation.call(effects, assignment); effects.sessions = effects.sessions.map((candidate) => candidate.id === 'destination' ? { ...candidate, name: 'Renamed destination' } : candidate, ); + return result; }; const assign = effects.assign; effects.assign = async (input) => { @@ -1636,7 +1854,7 @@ describe('WorkHub Coordination Action Gate', () => { )!; const retireDelegation = effects.retireDelegation; effects.retireDelegation = async (assignment) => { - await retireDelegation.call(effects, assignment); + const result = await retireDelegation.call(effects, assignment); effects.sessions = effects.sessions.map((candidate) => candidate.id !== 'destination' ? candidate @@ -1644,6 +1862,7 @@ describe('WorkHub Coordination Action Gate', () => { ? { ...candidate, isArchived: true } : { ...candidate, status: 'waiting_for_user' }, ); + return result; }; const input = { actionId: `target-became-${lifecycle}`, @@ -1829,6 +2048,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { const replacements = new Map(); const replacementAborts = new Map(); const supersessions = new Map(); + const stopRequests = new Map(); + const stopResolutions = new Map(); return { sessions: [...initialSessions], answers: [] as Array<{ turnId: string; text: string }>, @@ -1842,6 +2063,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { replacements, replacementAborts, supersessions, + stopRequests, + stopResolutions, retirements: [] as WorkHubDelegationAssignedMessage[], async listSessions() { return this.sessions; @@ -1849,6 +2072,16 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { async readAssignment(actionId: string) { return assignmentRecords.get(actionId); }, + async listActiveAssignments() { + return [...assignmentRecords.values()].filter((assignment) => { + const stopOutcome = stopResolutions.get(assignment.delegationId)?.outcome; + return ( + !supersessions.has(assignment.delegationId) && + !replacementAborts.has(assignment.delegationId) && + (stopOutcome === undefined || stopOutcome === 'not_owned') + ); + }); + }, async readReplacement(delegationId: string) { return replacements.get(delegationId); }, @@ -1858,6 +2091,12 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { async readSupersession(delegationId: string) { return supersessions.get(delegationId); }, + async readStopRequest(delegationId: string) { + return stopRequests.get(delegationId); + }, + async readStopResolution(delegationId: string) { + return stopResolutions.get(delegationId); + }, async answer(input: { turnId: string; text: string }) { this.answers.push(input); }, @@ -1941,13 +2180,62 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { replacementAborts.set(replacement.replacesDelegationId, aborted); return aborted; }, + async prepareStop(input: WorkHubDelegationStopInput) { + const existing = stopRequests.get(input.stopsDelegationId); + if (existing) return existing; + const requested: WorkHubDelegationStopRequestedMessage = { + type: 'workhub_coordination', + id: `stop-${input.actionId}`, + turnId: input.actionId, + ts: 5, + schemaVersion: 3, + kind: 'delegation_stop_requested', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + stopsActionId: input.stopsActionId, + stopsDelegationId: input.stopsDelegationId, + targetSessionId: input.targetSessionId, + targetMessageId: input.targetMessageId, + targetSessionName: input.targetSessionName, + userText: input.userText, + }; + stopRequests.set(input.stopsDelegationId, requested); + return requested; + }, + async resolveStop(input: WorkHubDelegationStopResolutionInput) { + const request = input.request; + const existing = stopResolutions.get(request.stopsDelegationId); + if (existing) return existing; + const resolved: WorkHubDelegationStopResolvedMessage = { + type: 'workhub_coordination', + id: `resolved-${request.actionId}`, + turnId: request.actionId, + ts: 6, + schemaVersion: 3, + kind: 'delegation_stop_resolved', + actionId: request.actionId, + actionFingerprint: request.actionFingerprint, + coordinationTurnId: request.coordinationTurnId, + stopsActionId: request.stopsActionId, + stopsDelegationId: request.stopsDelegationId, + targetSessionId: request.targetSessionId, + outcome: input.outcome, + ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}), + }; + stopResolutions.set(request.stopsDelegationId, resolved); + return resolved; + }, async readDelegationRetirement(assignment: WorkHubDelegationAssignedMessage) { return this.retirements.some((retired) => retired.delegationId === assignment.delegationId) ? ('retired' as const) : ('not_retired' as const); }, - async retireDelegation(assignment: WorkHubDelegationAssignedMessage) { + async retireDelegation( + assignment: WorkHubDelegationAssignedMessage, + ): Promise { this.retirements.push(assignment); + return { outcome: 'cancelled_pending' as const }; }, } satisfies WorkHubActionGateEffects & { sessions: WorkHubActionGateSession[]; @@ -1958,6 +2246,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { replacements: Map; replacementAborts: Map; supersessions: Map; + stopRequests: Map; + stopResolutions: Map; retirements: WorkHubDelegationAssignedMessage[]; }; } 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 598f476da0..726eb2c600 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -580,6 +580,296 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('persists direct-stop request and resolution before replaying after restart', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-')); + let store = createSessionStore(root); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + let retireCalls = 0; + const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + assign: (input) => persistTestAssignment(store, input, 'payments-turn'), + retireDelegation: async () => { + retireCalls += 1; + return { outcome: 'stop_delivered', targetTurnId: 'payments-turn' }; + }, + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef }, + }, + CONTEXT, + ) + ).ok, + true, + ); + const stopped = await workhub.handlers['workhub.coordination.act']( + { + actionId: 'stop-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + assert.deepEqual(stopped, { + ok: true, + result: { + disposition: 'stop_work', + outcome: 'stop_delivered', + targetSessionId: target.id, + targetTurnId: 'payments-turn', + }, + }); + assert.equal(retireCalls, 1); + const assignment = await store.readWorkHubAssignment('source-action'); + assert.ok(assignment); + assert.equal( + (await store.readWorkHubStopRequest(assignment.delegationId))?.actionId, + 'stop-action', + ); + assert.equal( + (await store.readWorkHubStopResolution(assignment.delegationId))?.outcome, + 'stop_delivered', + ); + } finally { + await store.close?.(); + } + + store = createSessionStore(root); + try { + const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + retireDelegation: async () => assert.fail('durable stop replay must not retire twice'), + }); + const replay = await restarted.handlers['workhub.coordination.act']( + { + actionId: 'stop-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + assert.equal(replay.ok, true); + if (replay.ok && replay.result.disposition === 'stop_work') { + assert.equal(replay.result.outcome, 'stop_delivered'); + } + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('rechecks stop-name uniqueness after the advisory active-link read', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-race-')); + const store = createSessionStore(root); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + let injected = false; + const stores = new Proxy(store, { + get(authority, property, receiver) { + if (property === 'readMessagesSnapshot') { + return async (sessionId: string) => { + const messages = await authority.readMessagesSnapshot(sessionId); + if ( + !injected && + sessionId === WORKHUB_COORDINATION_SESSION_ID && + messages.some( + (message) => + message.type === 'workhub_coordination' && + message.kind === 'delegation_assigned' && + message.actionId === 'source-action', + ) + ) { + injected = true; + await persistTestAssignment( + authority, + { + actionId: 'racing-action', + actionFingerprint: `sha256:${'8'.repeat(64)}`, + targetSessionId: target.id, + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'A second payment delegation', + }, + 'racing-turn', + ); + } + return messages; + }; + } + const value = Reflect.get(authority, property, receiver) as unknown; + return typeof value === 'function' ? value.bind(authority) : value; + }, + }) as SessionAuthorityStore; + let retireCalls = 0; + const workhub = coordinator(root, stores, () => undefined, undefined, undefined, undefined, { + assign: (input) => persistTestAssignment(store, input, 'source-turn'), + retireDelegation: async () => { + retireCalls += 1; + return { outcome: 'cancelled_pending' }; + }, + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef }, + }, + CONTEXT, + ) + ).ok, + true, + ); + + const stopped = await workhub.handlers['workhub.coordination.act']( + { + actionId: 'stop-racing-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + assert.equal(stopped.ok, false); + if (!stopped.ok) assert.equal(stopped.error.code, 'operation_conflict'); + const source = await store.readWorkHubAssignment('source-action'); + assert.ok(source); + assert.equal( + source ? await store.readWorkHubStopRequest(source.delegationId) : undefined, + undefined, + ); + assert.equal(retireCalls, 0); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('rejects a stop when its target is removed before stop admission', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-removed-target-')); + const store = createSessionStore(root); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + let removed = false; + const stores = new Proxy(store, { + get(authority, property, receiver) { + if (property === 'readMessagesSnapshot') { + return async (sessionId: string) => { + const messages = await authority.readMessagesSnapshot(sessionId); + if ( + !removed && + sessionId === WORKHUB_COORDINATION_SESSION_ID && + messages.some( + (message) => + message.type === 'workhub_coordination' && + message.kind === 'delegation_assigned' && + message.actionId === 'source-action', + ) + ) { + removed = true; + await authority.remove(target.id); + } + return messages; + }; + } + const value = Reflect.get(authority, property, receiver) as unknown; + return typeof value === 'function' ? value.bind(authority) : value; + }, + }) as SessionAuthorityStore; + let retireCalls = 0; + const workhub = coordinator(root, stores, () => undefined, undefined, undefined, undefined, { + assign: (input) => persistTestAssignment(store, input, 'source-turn'), + retireDelegation: async () => { + retireCalls += 1; + return { outcome: 'cancelled_pending' }; + }, + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef }, + }, + CONTEXT, + ) + ).ok, + true, + ); + + const stopped = await workhub.handlers['workhub.coordination.act']( + { + actionId: 'stop-removed-target-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + assert.equal(stopped.ok, false); + if (!stopped.ok) assert.equal(stopped.error.code, 'operation_conflict'); + const source = await store.readWorkHubAssignment('source-action'); + assert.ok(source); + assert.equal( + source ? await store.readWorkHubStopRequest(source.delegationId) : undefined, + undefined, + ); + assert.equal(retireCalls, 0); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('refuses to merge a Turn identity shared across answer and record', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-turn-identity-')); const store = createSessionStore(root); @@ -727,7 +1017,7 @@ function coordinator( sessionActions: { assign: async ({ targetSessionId }) => ({ turnId: `turn-${targetSessionId}` }), readDelegationRetirement: async () => 'not_retired', - retireDelegation: async () => undefined, + retireDelegation: async () => ({ outcome: 'cancelled_pending' }), ...sessionActions, }, 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 b6cbfbf3fa..4b8cb18761 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -39,7 +39,7 @@ test('WorkHub Coordination resolve has a closed empty input and bounded identity sessionId: 'coordination', }); assert.equal(HOST_OPERATION_SPECS['workhub.coordination.resolve'].mode, 'command'); - assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 49); + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 86); assert.throws( () => decodeWorkHubCoordinationResolveInput({ sessionId: 'caller-selected' }), (error) => error instanceof RuntimeHostProtocolError, @@ -85,6 +85,48 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () target: { disposition: 'delegate_existing', candidateRef: 'candidate_login' }, }, ); + assert.deepEqual( + decodeWorkHubCoordinationActInput({ + actionId: 'action-stop', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'action-payments' }, + confirmation: { kind: 'user_stop' }, + }), + { + actionId: 'action-stop', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'action-payments' }, + confirmation: { kind: 'user_stop' }, + }, + ); + for (const invalid of [ + { + actionId: 'action-stop-no-confirmation', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'action-payments' }, + }, + { + actionId: 'action-stop-wrong-confirmation', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'action-payments' }, + confirmation: { kind: 'user_correction' }, + }, + { + actionId: 'action-stop-injected', + userText: 'Stop Payments', + proposal: { + disposition: 'stop_work', + stopsActionId: 'action-payments', + targetSessionId: 'injected', + }, + confirmation: { kind: 'user_stop' }, + }, + ]) { + assert.throws( + () => decodeWorkHubCoordinationActInput(invalid), + (error) => error instanceof RuntimeHostProtocolError, + ); + } assert.throws( () => decodeWorkHubCoordinationActInput({ @@ -294,4 +336,50 @@ test('WorkHub Coordination action results preserve the admitted disposition', () targetTurnId: 'turn-login', }, ); + assert.deepEqual( + decodeWorkHubCoordinationActResult({ + disposition: 'stop_work', + outcome: 'not_owned', + targetSessionId: 'payments', + targetTurnId: 'shared-turn', + }), + { + disposition: 'stop_work', + outcome: 'not_owned', + targetSessionId: 'payments', + targetTurnId: 'shared-turn', + }, + ); + assert.throws( + () => + decodeWorkHubCoordinationActResult({ + disposition: 'stop_work', + outcome: 'stopped', + targetSessionId: 'payments', + }), + (error) => error instanceof RuntimeHostProtocolError, + ); + for (const invalid of [ + { + disposition: 'stop_work', + outcome: 'stop_delivered', + targetSessionId: 'payments', + }, + { + disposition: 'stop_work', + outcome: 'not_owned', + targetSessionId: 'payments', + }, + { + disposition: 'stop_work', + outcome: 'cancelled_pending', + targetSessionId: 'payments', + targetTurnId: 'unexpected-turn', + }, + ]) { + assert.throws( + () => decodeWorkHubCoordinationActResult(invalid), + (error) => error instanceof RuntimeHostProtocolError, + ); + } }); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 32d21d3f7b..90e7197a60 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 103 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 104 as const; +// 104: WorkHub Coordination actions add closed direct-stop proposals, +// confirmations, expected-state preconditions, and outcomes. Older peers +// reject these strict shapes. // 103: `github-copilot` joins `OAUTH_LOGIN_PROVIDERS`, the Host answers the // closed `oauth.enrollment.query`, and `connection.onboarding.save` admits // canonical OAuth material with an empty enable-all-discovered selection. diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index f3859f04b1..4cced2ecc2 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -132,12 +132,16 @@ export type WorkHubCoordinationProposal = readonly target: | { readonly disposition: 'delegate_existing'; readonly candidateRef: string } | { readonly disposition: 'create_new'; readonly title: string }; + } + | { + readonly disposition: 'stop_work'; + /** Action identity of the exact durable delegation link being stopped. */ + readonly stopsActionId: string; }; -export interface WorkHubCoordinationDestructiveConfirmation { +export type WorkHubCoordinationDestructiveConfirmation = /** Kept outside strategy output so a model proposal cannot authorize Stop. */ - readonly kind: 'user_correction'; -} + { readonly kind: 'user_correction' } | { readonly kind: 'user_stop' }; export interface WorkHubCoordinationCreateContext { /** Trusted desktop context. Model/strategy output never contains a workspace or identity. */ @@ -174,6 +178,12 @@ export type WorkHubCoordinationActResult = readonly targetSessionId: string; readonly targetTurnId: string; readonly steered?: true; + } + | { + readonly disposition: 'stop_work'; + readonly outcome: 'cancelled_pending' | 'stop_delivered' | 'already_terminal' | 'not_owned'; + readonly targetSessionId: string; + readonly targetTurnId?: string; }; export const WORKHUB_COORDINATION_OPERATION_SPECS = { @@ -359,6 +369,9 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi } if (proposal.disposition === 'replace') { const confirmation = decodeWorkHubCoordinationDestructiveConfirmation(input.confirmation); + if (confirmation.kind !== 'user_correction') { + throw invalidProtocolFrame('Invalid WorkHub replacement confirmation'); + } if (proposal.target.disposition === 'delegate_existing') { if (input.candidateSetId === undefined || input.create !== undefined) { throw invalidProtocolFrame('Invalid WorkHub replacement context'); @@ -378,6 +391,17 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi confirmation, }; } + if (proposal.disposition === 'stop_work') { + const confirmation = decodeWorkHubCoordinationDestructiveConfirmation(input.confirmation); + if ( + confirmation.kind !== 'user_stop' || + input.candidateSetId !== undefined || + input.create !== undefined + ) { + throw invalidProtocolFrame('Invalid WorkHub stop context'); + } + return { ...base, confirmation }; + } if ( input.candidateSetId !== undefined || input.create !== undefined || @@ -441,6 +465,39 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord ...(exact.steered === true ? { steered: true as const } : {}), }; } + if (result.disposition === 'stop_work') { + const exact = requireShapedRecord( + result, + 'WorkHub Coordination stop result', + ['disposition', 'outcome', 'targetSessionId'], + ['targetTurnId'], + ); + if ( + exact.outcome !== 'cancelled_pending' && + exact.outcome !== 'stop_delivered' && + exact.outcome !== 'already_terminal' && + exact.outcome !== 'not_owned' + ) { + throw invalidProtocolFrame('Invalid WorkHub stop outcome'); + } + if ( + ((exact.outcome === 'stop_delivered' || exact.outcome === 'not_owned') && + exact.targetTurnId === undefined) || + (exact.outcome === 'cancelled_pending' && exact.targetTurnId !== undefined) + ) { + throw invalidProtocolFrame('Invalid WorkHub stop target Turn'); + } + return { + disposition: 'stop_work', + outcome: exact.outcome, + targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), + ...(exact.targetTurnId === undefined + ? {} + : { + targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), + }), + }; + } throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); } @@ -544,6 +601,16 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP } throw invalidProtocolFrame('Invalid WorkHub replacement target'); } + if (proposal.disposition === 'stop_work') { + const exact = requireExactRecord(proposal, 'WorkHub stop proposal', [ + 'disposition', + 'stopsActionId', + ]); + return { + disposition: 'stop_work', + stopsActionId: requireEntityId(exact.stopsActionId, 'WorkHub stopped action id'), + }; + } throw invalidProtocolFrame('Invalid WorkHub Coordination proposal disposition'); } @@ -558,10 +625,10 @@ function decodeWorkHubCoordinationDestructiveConfirmation( value: unknown, ): WorkHubCoordinationDestructiveConfirmation { const confirmation = requireExactRecord(value, 'WorkHub destructive confirmation', ['kind']); - if (confirmation.kind !== 'user_correction') { + if (confirmation.kind !== 'user_correction' && confirmation.kind !== 'user_stop') { throw invalidProtocolFrame('Invalid WorkHub destructive confirmation'); } - return { kind: 'user_correction' }; + return { kind: confirmation.kind }; } function candidateSetId(value: unknown): string { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 6496697eff..7a0b08f613 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -37,6 +37,7 @@ import { AgentGraphSupervisorWakeCoordinator } from '@maka/runtime/agent-graph-s import { BackendRegistry, SessionManager, + workHubDirectStopAbortSource, type BackendFactory, type BackendPreparationContext, } from '@maka/runtime/session-manager'; @@ -1364,33 +1365,37 @@ export async function createExecutionRuntimeHostComposition( ? 'not_retired' : 'retired'; }, - retireDelegation: async (assignment) => { + retireDelegation: async (assignment, cancellationClaimId) => { const disposition = await messages.cancelMessageIfPending( assignment.targetSessionId, assignment.targetMessageId, + cancellationClaimId, ); if (disposition.kind === 'recovering') { - throw new WorkHubActionEffectFailure( - 'operation_unavailable', - 'WorkHub is still resolving the delegated Message owner', - ); + return { outcome: 'recovering' as const }; + } + if (disposition.kind === 'cancelled') { + return { outcome: 'already_terminal' as const }; + } + if (disposition.kind === 'cancelled_pending') { + return { outcome: 'cancelled_pending' as const }; + } + if (disposition.kind === 'shared_turn') { + return { outcome: 'not_owned' as const, targetTurnId: disposition.turnId }; } - if (disposition.kind === 'shared_turn') return; if (disposition.kind === 'owned_root') { - const rootState = coordinator.readRootState(assignment.targetSessionId); - if ( - rootState.kind !== 'active' || - rootState.turnId !== disposition.turnId || - rootState.runId !== disposition.runId - ) { - return; - } - await coordinator.stopRoot({ - sessionId: assignment.targetSessionId, - turnId: disposition.turnId, - runId: disposition.runId, - }); + return stopOwnedWorkHubRoot( + coordinator, + { + sessionId: assignment.targetSessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }, + cancellationClaimId, + ); } + disposition satisfies never; + throw new Error('Unhandled WorkHub Message retirement disposition'); }, assign: async (input) => { const durable = await stores.sessionStore.readWorkHubAssignment(input.actionId); @@ -1991,6 +1996,32 @@ export async function createExecutionRuntimeHostComposition( } } +export async function stopOwnedWorkHubRoot( + coordinator: Pick, + identity: { readonly sessionId: string; readonly turnId: string; readonly runId: string }, + actionId: string, +): Promise<{ + readonly outcome: 'stop_delivered' | 'already_terminal'; + readonly targetTurnId: string; +}> { + const rootState = coordinator.readRootState(identity.sessionId); + if ( + rootState.kind === 'active' && + rootState.turnId === identity.turnId && + rootState.runId === identity.runId + ) { + await coordinator.stopRoot(identity, { + source: 'workhub_direct_stop', + workHubActionId: actionId, + }); + } + const terminal = await coordinator.read(identity); + return terminal.status === 'cancelled' && + terminal.abortSource === workHubDirectStopAbortSource(actionId) + ? { outcome: 'stop_delivered', targetTurnId: identity.turnId } + : { outcome: 'already_terminal', targetTurnId: identity.turnId }; +} + function sessionExecutionConnectionRef( header: Pick, ): ExecutionConnectionRef { diff --git a/packages/runtime-host/src/server/hosted-execution-authority.ts b/packages/runtime-host/src/server/hosted-execution-authority.ts index 152eeace91..41c23e5555 100644 --- a/packages/runtime-host/src/server/hosted-execution-authority.ts +++ b/packages/runtime-host/src/server/hosted-execution-authority.ts @@ -17,10 +17,10 @@ * under the License. */ -import type { BackendStopMode } from '@maka/core/backend-types'; import type { RootExecutionDescriptor } from '@maka/core/agent-run'; import type { MessageContent, SessionEvent } from '@maka/core/events'; import type { UserMessageInput } from '@maka/core/runtime-inputs'; +import type { StopSessionInput } from '@maka/runtime/session-manager'; import type { TurnSnapshot } from '../protocol/index.js'; export interface HostedExecutionRef { @@ -80,11 +80,9 @@ export interface HostedExecutionObserver { begin(input: HostedExecutionObservation): HostedExecutionCompletionObserver | undefined; } -export interface HostedExecutionStopInput { +export type HostedExecutionStopInput = { readonly execution: HostedExecutionRef; - readonly source?: 'stop_button' | 'graph_supervisor'; - readonly mode?: BackendStopMode; -} +} & StopSessionInput; export type HostedExecutionListener = (execution: HostedExecutionRef) => void; @@ -120,7 +118,7 @@ export interface HostedExecutionAuthority { input: { readonly sessionId: string; readonly abortSignal: AbortSignal; - readonly stopSource?: HostedExecutionStopInput['source']; + readonly stopSource?: Exclude; }, operation: () => Promise, ): Promise; diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 2f483a7ee1..0827a4cfbc 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -178,14 +178,18 @@ export interface HostMessageStopFence { deliverStop(): Promise; } -export type HostMessageCancellationDisposition = +type HostMessageResolvedDisposition = | { readonly kind: 'cancelled' } | { readonly kind: 'owned_root'; readonly turnId: string; readonly runId: string } | { readonly kind: 'shared_turn'; readonly turnId: string; readonly runId: string } | { readonly kind: 'recovering' }; +export type HostMessageCancellationDisposition = + | HostMessageResolvedDisposition + | { readonly kind: 'cancelled_pending' }; + export type HostMessageExecutionDisposition = - | HostMessageCancellationDisposition + | HostMessageResolvedDisposition | { readonly kind: 'pending' }; /** Root execution operations that must share the message coordinator's Session gate. */ @@ -493,9 +497,20 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { cancelMessageIfPending( sessionId: string, messageId: string, + cancellationClaimId?: string, ): Promise { return this.#sessionAdmission.run(sessionId, async () => { const disposition = await this.#resolveMessageExecution(sessionId, messageId); + if (disposition.kind === 'cancelled' && cancellationClaimId) { + const outcome = await this.#admissions.claimMessageAdmissionCancellation( + sessionId, + messageId, + cancellationClaimId, + ); + return outcome === 'same_claim' + ? { kind: 'cancelled_pending' as const } + : { kind: 'cancelled' as const }; + } if (disposition.kind !== 'pending') return disposition; const state = this.#sessions.get(sessionId); @@ -513,7 +528,16 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return { kind: 'recovering' }; } - await this.#admissions.cancelMessageAdmissions(sessionId, [messageId]); + const claimOutcome = cancellationClaimId + ? await this.#admissions.claimMessageAdmissionCancellation( + sessionId, + messageId, + cancellationClaimId, + ) + : undefined; + if (!cancellationClaimId) { + await this.#admissions.cancelMessageAdmissions(sessionId, [messageId]); + } if (state && steeringIndex >= 0) { const [entry] = state.steering.splice(steeringIndex, 1); if (entry) this.#releaseEntry(entry); @@ -527,7 +551,9 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } else { this.#onProjectionChanged(sessionId); } - return { kind: 'cancelled' }; + return claimOutcome === 'already_cancelled' + ? { kind: 'cancelled' } + : { kind: 'cancelled_pending' }; }); } diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index d246e0c4a6..f7442e572e 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -48,7 +48,12 @@ import { RuntimeInteractionFailStopError, RuntimeInteractionInvariantError, } from '@maka/runtime/interaction-authority'; -import { RuntimeRegenerateTurnError, type SessionManager } from '@maka/runtime/session-manager'; +import { + normalizeStopSessionSource, + RuntimeRegenerateTurnError, + type SessionManager, + type StopSessionInput, +} from '@maka/runtime/session-manager'; import { RuntimeOwnerCleanupError } from '@maka/runtime/runtime-kernel'; import { parseSkillInvocationTokens, @@ -565,7 +570,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { input: { readonly sessionId: string; readonly abortSignal: AbortSignal; - readonly stopSource?: HostedExecutionStopInput['source']; + readonly stopSource?: Exclude; }, operation: () => Promise, ): Promise { @@ -893,8 +898,9 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { async requestStop(input: HostedExecutionStopInput): Promise { await this.stopRoot(input.execution, { ...(input.source ? { source: input.source } : {}), + ...(input.workHubActionId !== undefined ? { workHubActionId: input.workHubActionId } : {}), ...(input.mode ? { mode: input.mode } : {}), - }); + } as StopSessionInput); return await this.read(input.execution); } @@ -912,13 +918,8 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { ); } - stopRoot( - identity: RuntimeMessageRunIdentity, - input: { - source?: 'stop_button' | 'graph_supervisor'; - mode?: BackendStopMode; - } = {}, - ): Promise { + stopRoot(identity: RuntimeMessageRunIdentity, input: StopSessionInput = {}): Promise { + normalizeStopSessionSource(input.source, input.workHubActionId); return this.runCommand(async () => { const declared = await this.sessionAdmission.run(identity.sessionId, (lease) => this.declareStopFence( @@ -944,13 +945,8 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { }); } - stopSession( - sessionId: string, - input: { - source?: 'stop_button' | 'graph_supervisor'; - mode?: BackendStopMode; - } = {}, - ): Promise { + stopSession(sessionId: string, input: StopSessionInput = {}): Promise { + normalizeStopSessionSource(input.source, input.workHubActionId); return this.runCommand(async () => { const declared = await this.sessionAdmission.run(sessionId, (lease) => { const active = this.#executions.get(sessionId); @@ -2062,10 +2058,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { input: Pick, commitQueueFence: () => QueueFenceResult, admission: SessionAdmissionLease, - stopInput: { - source?: 'stop_button' | 'graph_supervisor'; - mode?: BackendStopMode; - } = {}, + stopInput: StopSessionInput = {}, ): Promise { const active = this.#executions.get(input.sessionId); if (!active || active.turnId !== input.turnId || active.runId !== input.runId) { @@ -2656,10 +2649,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { private async deliverRuntimeStopIntent( sessionId: string, - input: { - source?: 'stop_button' | 'graph_supervisor'; - mode?: BackendStopMode; - } = { source: 'stop_button' }, + input: StopSessionInput = { source: 'stop_button' }, ): Promise { await this.manager.deliverHostedRootStop(sessionId, input); } 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 181f422d3c..918e3a4c66 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -26,6 +26,9 @@ import type { WorkHubDelegationDisposition, WorkHubDelegationReplacementAbortedMessage, WorkHubDelegationReplacementRequestedMessage, + WorkHubDelegationStopRequestedMessage, + WorkHubDelegationStopResolvedMessage, + WorkHubDelegationStopOutcome, WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { @@ -36,6 +39,7 @@ import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, workHubCreationAuthorizesTitle, + workHubStopTargetsSession, } from '@maka/core/workhub-creation-intent'; import type { WorkHubCoordinationActInput, @@ -70,6 +74,7 @@ export type WorkHubActionGateSession = Pick< export interface WorkHubActionGateEffects { listSessions(): Promise; readAssignment(actionId: string): Promise; + listActiveAssignments(): Promise; readReplacement( delegationId: string, ): Promise; @@ -77,6 +82,10 @@ export interface WorkHubActionGateEffects { delegationId: string, ): Promise; readSupersession(delegationId: string): Promise; + readStopRequest(delegationId: string): Promise; + readStopResolution( + delegationId: string, + ): Promise; answer( input: { readonly turnId: string; readonly text: string }, context: ConnectionContext, @@ -96,10 +105,22 @@ export interface WorkHubActionGateEffects { abortReplacement( input: WorkHubDelegationReplacementAbortInput, ): Promise; + prepareStop(input: WorkHubDelegationStopInput): Promise; + resolveStop( + input: WorkHubDelegationStopResolutionInput, + ): Promise; readDelegationRetirement( assignment: WorkHubDelegationAssignedMessage, ): Promise<'not_retired' | 'retired' | 'recovering'>; - retireDelegation(assignment: WorkHubDelegationAssignedMessage): Promise; + retireDelegation( + assignment: WorkHubDelegationAssignedMessage, + cancellationClaimId: string, + ): Promise; +} + +export interface WorkHubRetirementResult { + readonly outcome: WorkHubDelegationStopOutcome | 'recovering'; + readonly targetTurnId?: string; } export interface WorkHubDelegationAssignmentInput { @@ -126,6 +147,23 @@ export interface WorkHubDelegationReplacementAbortInput { readonly reason: WorkHubDelegationReplacementAbortedMessage['reason']; } +export interface WorkHubDelegationStopInput { + readonly actionId: string; + readonly actionFingerprint: `sha256:${string}`; + readonly stopsActionId: string; + readonly stopsDelegationId: string; + readonly targetSessionId: string; + readonly targetMessageId: string; + readonly targetSessionName: string; + readonly userText: string; +} + +export interface WorkHubDelegationStopResolutionInput { + readonly request: WorkHubDelegationStopRequestedMessage; + readonly outcome: WorkHubDelegationStopOutcome; + readonly targetTurnId?: string; +} + export type WorkHubActionEffectFailureCode = | 'host_not_ready' | 'host_draining' @@ -281,6 +319,93 @@ export class WorkHubCoordinationActionGate { }); return { disposition: 'clarify', coordinationTurnId: turnId }; } + if (proposal.disposition === 'stop_work') { + if (input.confirmation?.kind !== 'user_stop' || !requestIntent.stop.imperative) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop requires an explicit named command in trusted user text', + ); + } + const source = await this.#effects.readAssignment(proposal.stopsActionId); + if (!source) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub can stop only the named durable delegation it owns', + ); + } + const existing = await this.#effects.readStopRequest(source.delegationId); + if (existing) { + if (!workHubStopTargetsSession(requestIntent, existing.targetSessionName)) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub can stop only the named durable delegation it owns', + ); + } + const fingerprint = stopActionFingerprint(input, source); + assertStopReplay(existing, input, source, fingerprint); + return this.#stop(existing, source); + } + const [sessions, activeAssignments] = await Promise.all([ + this.#effects.listSessions(), + this.#effects.listActiveAssignments(), + ]); + const sessionNameById = new Map(sessions.map((session) => [session.id, session.name])); + if ( + activeAssignments.some((assignment) => !sessionNameById.has(assignment.targetSessionId)) + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub active delegation target is unavailable', + ); + } + const matchingAssignments = activeAssignments.filter((assignment) => + workHubStopTargetsSession(requestIntent, sessionNameById.get(assignment.targetSessionId)!), + ); + if ( + matchingAssignments.length !== 1 || + matchingAssignments[0]?.actionId !== source.actionId || + matchingAssignments[0]?.delegationId !== source.delegationId + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop target does not identify one active durable delegation', + ); + } + const currentTargetName = sessionNameById.get(source.targetSessionId); + if (!currentTargetName) { + throw new WorkHubActionGateFailure('action_conflict', 'WorkHub stop target is unavailable'); + } + if (!workHubStopTargetsSession(requestIntent, currentTargetName)) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub can stop only the named durable delegation it owns', + ); + } + const fingerprint = stopActionFingerprint(input, source); + if (await this.#effects.readSupersession(source.delegationId)) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub delegation has already been superseded', + ); + } + if (await this.#effects.readReplacement(source.delegationId)) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub delegation already has a replacement claim', + ); + } + const requested = await this.#effects.prepareStop({ + actionId: input.actionId, + actionFingerprint: fingerprint, + stopsActionId: source.actionId, + stopsDelegationId: source.delegationId, + targetSessionId: source.targetSessionId, + targetMessageId: source.targetMessageId, + targetSessionName: currentTargetName, + userText: input.userText, + }); + return this.#stop(requested, source); + } if (proposal.disposition === 'create_new') { if (!input.create || !workHubCreationAuthorizesTitle(requestIntent, proposal.title)) { throw new WorkHubActionGateFailure( @@ -371,6 +496,27 @@ export class WorkHubCoordinationActionGate { ); } + async #stop( + request: WorkHubDelegationStopRequestedMessage, + source: WorkHubDelegationAssignedMessage, + ): Promise { + const resolved = await this.#effects.readStopResolution(source.delegationId); + if (resolved) return stopResultFromRecord(resolved, request); + const retirement = await this.#effects.retireDelegation(source, request.actionId); + if (retirement.outcome === 'recovering') { + throw new WorkHubActionEffectFailure( + 'operation_unavailable', + 'WorkHub is still resolving the delegated Message owner', + ); + } + const resolution = await this.#effects.resolveStop({ + request, + outcome: retirement.outcome, + ...(retirement.targetTurnId ? { targetTurnId: retirement.targetTurnId } : {}), + }); + return stopResultFromRecord(resolution, request); + } + async #replacementAssignment( input: WorkHubCoordinationActInput, replaced: WorkHubDelegationAssignedMessage, @@ -502,7 +648,15 @@ export class WorkHubCoordinationActionGate { if (retirement === 'not_retired' && replacement.disposition === 'delegate_existing') { await this.#replacementTarget(replacement); } - if (retirement === 'not_retired') await this.#effects.retireDelegation(source); + if (retirement === 'not_retired') { + const result = await this.#effects.retireDelegation(source, replacement.actionId); + if (result.outcome === 'recovering') { + throw new WorkHubActionEffectFailure( + 'operation_unavailable', + 'WorkHub is still resolving the delegated Message owner', + ); + } + } try { if (replacement.disposition === 'delegate_existing') { // Retirement can await cancellation or Stop long enough for display @@ -798,6 +952,68 @@ function replacementActionFingerprint( }); } +function stopActionFingerprint( + input: WorkHubCoordinationActInput, + source: WorkHubDelegationAssignedMessage, +): `sha256:${string}` { + if (input.proposal.disposition !== 'stop_work') { + throw new WorkHubActionGateFailure('action_conflict', 'Invalid WorkHub stop replay'); + } + return digest({ + userText: input.userText, + disposition: 'stop_work', + stopsActionId: source.actionId, + stopsDelegationId: source.delegationId, + targetSessionId: source.targetSessionId, + targetMessageId: source.targetMessageId, + }); +} + +function assertStopReplay( + request: WorkHubDelegationStopRequestedMessage, + input: WorkHubCoordinationActInput, + source: WorkHubDelegationAssignedMessage, + fingerprint: `sha256:${string}`, +): void { + if ( + request.actionId !== input.actionId || + request.actionFingerprint !== fingerprint || + request.stopsActionId !== source.actionId || + request.stopsDelegationId !== source.delegationId || + request.targetSessionId !== source.targetSessionId || + request.targetMessageId !== source.targetMessageId + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub delegation already has a different stop claim', + ); + } +} + +function stopResultFromRecord( + resolution: WorkHubDelegationStopResolvedMessage, + request: WorkHubDelegationStopRequestedMessage, +): WorkHubCoordinationActResult { + if ( + resolution.actionId !== request.actionId || + resolution.actionFingerprint !== request.actionFingerprint || + resolution.stopsActionId !== request.stopsActionId || + resolution.stopsDelegationId !== request.stopsDelegationId || + resolution.targetSessionId !== request.targetSessionId + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop has a different durable resolution', + ); + } + return { + disposition: 'stop_work', + outcome: resolution.outcome, + targetSessionId: resolution.targetSessionId, + ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), + }; +} + function assignmentInputFromRecord( assignment: WorkHubDelegationAssignedMessage, ): WorkHubDelegationAssignmentInput { diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 8e66215953..a3dcc326b9 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -27,13 +27,21 @@ import { WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_SESSION_ROLE, WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, + WORKHUB_COORDINATION_STOP_SCHEMA_VERSION, isWorkHubCoordinationSession, isWorkHubCoordinationSessionId, type SessionHeader, type StoredMessage, + type WorkHubDelegationAssignedMessage, type WorkHubDelegationReplacementAbortedMessage, type WorkHubDelegationReplacementRequestedMessage, + type WorkHubDelegationStopRequestedMessage, + type WorkHubDelegationStopResolvedMessage, } from '@maka/core/session'; +import { + readWorkHubRequestIntent, + workHubStopTargetsSession, +} from '@maka/core/workhub-creation-intent'; import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; import type { OperationOutcome, @@ -86,10 +94,13 @@ type CoordinationStores = Pick< | 'listHeaders' | 'probeStableSessionCreate' | 'readHeaderSnapshot' + | 'readMessagesSnapshot' | 'readWorkHubAssignment' | 'readWorkHubReplacement' | 'readWorkHubReplacementAbort' | 'readWorkHubSupersession' + | 'readWorkHubStopRequest' + | 'readWorkHubStopResolution' | 'readTranscriptHighWaterSnapshot' | 'readTranscriptMessagesSnapshot' | 'updateHeaderVersioned' @@ -146,10 +157,13 @@ export class HostWorkHubCoordinationCoordinator { this.#actionGate = new WorkHubCoordinationActionGate({ listSessions: () => this.#stores.listHeaders(), readAssignment: (actionId) => this.#stores.readWorkHubAssignment(actionId), + listActiveAssignments: () => this.#listActiveAssignments(), readReplacement: (delegationId) => this.#stores.readWorkHubReplacement(delegationId), readReplacementAbort: (delegationId) => this.#stores.readWorkHubReplacementAbort(delegationId), readSupersession: (delegationId) => this.#stores.readWorkHubSupersession(delegationId), + readStopRequest: (delegationId) => this.#stores.readWorkHubStopRequest(delegationId), + readStopResolution: (delegationId) => this.#stores.readWorkHubStopResolution(delegationId), answer: async (input, context) => { const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); if (!outcome.ok) { @@ -169,6 +183,8 @@ export class HostWorkHubCoordinationCoordinator { assign: options.sessionActions.assign, prepareReplacement: (input) => this.#prepareReplacement(input), abortReplacement: (input) => this.#abortReplacement(input), + prepareStop: (input) => this.#prepareStop(input), + resolveStop: (input) => this.#resolveStop(input), readDelegationRetirement: options.sessionActions.readDelegationRetirement, retireDelegation: options.sessionActions.retireDelegation, }); @@ -177,8 +193,8 @@ export class HostWorkHubCoordinationCoordinator { #prepareReplacement( input: Parameters[0], ): Promise { - const suffix = workHubReplacementIdentitySuffix(input.replacesDelegationId); - return this.#commitReplacementFact({ + const suffix = workHubDestructiveClaimIdentitySuffix(input.replacesDelegationId); + return this.#commitCoordinationFact({ read: () => this.#stores.readWorkHubReplacement(input.replacesDelegationId), build: (existing) => ({ type: 'workhub_coordination', @@ -202,6 +218,18 @@ export class HostWorkHubCoordinationCoordinator { }), conflictMessage: 'WorkHub action identity belongs to a different replacement', beforeAppend: async () => { + const stopRequest = await this.#stores.readWorkHubStopRequest(input.replacesDelegationId); + if (stopRequest) { + const resolution = await this.#stores.readWorkHubStopResolution( + input.replacesDelegationId, + ); + if (resolution?.outcome !== 'not_owned') { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub delegation already has a stop claim', + ); + } + } const header = await this.#stores.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID); if (!validCoordinationHeader(header)) { throw new WorkHubActionEffectFailure( @@ -214,12 +242,138 @@ export class HostWorkHubCoordinationCoordinator { }); } + async #prepareStop( + input: Parameters[0], + ): Promise { + const initiallyActive = await this.#listActiveAssignments(); + const admittedTargetSessionIds = new Set( + initiallyActive.map((assignment) => assignment.targetSessionId), + ); + const suffix = workHubDestructiveClaimIdentitySuffix(input.stopsDelegationId); + return this.#commitCoordinationFact({ + admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, ...admittedTargetSessionIds], + read: () => this.#stores.readWorkHubStopRequest(input.stopsDelegationId), + build: (existing) => ({ + type: 'workhub_coordination', + id: `whq_${suffix}`, + turnId: input.actionId, + ts: existing?.ts ?? Date.now(), + schemaVersion: WORKHUB_COORDINATION_STOP_SCHEMA_VERSION, + kind: 'delegation_stop_requested', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + stopsActionId: input.stopsActionId, + stopsDelegationId: input.stopsDelegationId, + targetSessionId: input.targetSessionId, + targetMessageId: input.targetMessageId, + targetSessionName: input.targetSessionName, + userText: input.userText, + }), + conflictMessage: 'WorkHub delegation already has a different stop claim', + beforeAppend: async () => { + const [replacement, supersession, headers, messages] = await Promise.all([ + this.#stores.readWorkHubReplacement(input.stopsDelegationId), + this.#stores.readWorkHubSupersession(input.stopsDelegationId), + this.#stores.listHeaders(), + this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID), + ]); + if (replacement || supersession) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub delegation is already being replaced', + ); + } + const intent = readWorkHubRequestIntent(input.userText); + const sessionNameById = new Map(headers.map((header) => [header.id, header.name])); + const activeAssignments = activeWorkHubAssignments(messages); + if ( + activeAssignments.some( + (assignment) => !admittedTargetSessionIds.has(assignment.targetSessionId), + ) + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub active delegation set changed during stop admission', + ); + } + if ( + activeAssignments.some((assignment) => !sessionNameById.has(assignment.targetSessionId)) + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub active delegation target is unavailable', + ); + } + const matching = activeAssignments.filter((assignment) => + workHubStopTargetsSession(intent, sessionNameById.get(assignment.targetSessionId)!), + ); + const currentName = sessionNameById.get(input.targetSessionId); + if ( + matching.length !== 1 || + matching[0]?.actionId !== input.stopsActionId || + matching[0]?.delegationId !== input.stopsDelegationId || + currentName !== input.targetSessionName + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop target does not identify one active durable delegation', + ); + } + }, + unknownOutcomeMessage: 'WorkHub stop request outcome is unknown', + }); + } + + async #listActiveAssignments(): Promise { + return activeWorkHubAssignments( + await this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID), + ); + } + + #resolveStop( + input: Parameters[0], + ): Promise { + const request = input.request; + const suffix = workHubDestructiveClaimIdentitySuffix(request.stopsDelegationId); + return this.#commitCoordinationFact({ + read: () => this.#stores.readWorkHubStopResolution(request.stopsDelegationId), + build: (existing) => ({ + type: 'workhub_coordination', + id: `whz_${suffix}`, + turnId: request.actionId, + ts: existing?.ts ?? Date.now(), + schemaVersion: WORKHUB_COORDINATION_STOP_SCHEMA_VERSION, + kind: 'delegation_stop_resolved', + actionId: request.actionId, + actionFingerprint: request.actionFingerprint, + coordinationTurnId: request.coordinationTurnId, + stopsActionId: request.stopsActionId, + stopsDelegationId: request.stopsDelegationId, + targetSessionId: request.targetSessionId, + outcome: input.outcome, + ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}), + }), + conflictMessage: 'WorkHub stop already has a different resolution', + beforeAppend: async () => { + const durable = await this.#stores.readWorkHubStopRequest(request.stopsDelegationId); + if (!durable || !isDeepStrictEqual(durable, request)) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop request identity changed', + ); + } + }, + unknownOutcomeMessage: 'WorkHub stop resolution outcome is unknown', + }); + } + #abortReplacement( input: Parameters[0], ): Promise { const replacement = input.replacement; - const suffix = workHubReplacementIdentitySuffix(replacement.replacesDelegationId); - return this.#commitReplacementFact({ + const suffix = workHubDestructiveClaimIdentitySuffix(replacement.replacesDelegationId); + return this.#commitCoordinationFact({ read: () => this.#stores.readWorkHubReplacementAbort(replacement.replacesDelegationId), build: (existing) => ({ type: 'workhub_coordination', @@ -252,37 +406,41 @@ export class HostWorkHubCoordinationCoordinator { }); } - #commitReplacementFact(options: { + #commitCoordinationFact(options: { + readonly admissionSessionIds?: readonly string[]; readonly read: () => Promise; readonly build: (existing: T | undefined) => T; readonly conflictMessage: string; readonly beforeAppend: () => Promise; readonly unknownOutcomeMessage: string; }): Promise { - return this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, async (lease) => { - const existing = await options.read(); - const requested = options.build(existing); - if (existing) { - if (!isDeepStrictEqual(existing, requested)) { - throw new WorkHubActionGateFailure('action_conflict', options.conflictMessage); + return this.#admission.runMany( + options.admissionSessionIds ?? [WORKHUB_COORDINATION_SESSION_ID], + async (lease) => { + const existing = await options.read(); + const requested = options.build(existing); + if (existing) { + if (!isDeepStrictEqual(existing, requested)) { + throw new WorkHubActionGateFailure('action_conflict', options.conflictMessage); + } + return existing; } - return existing; - } - await options.beforeAppend(); - try { - await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [requested]); - await this.#continuity.refreshCanonical(WORKHUB_COORDINATION_SESSION_ID, lease); - return requested; - } catch { - const replay = await options.read().catch(() => undefined); - if (replay && isDeepStrictEqual(replay, requested)) return replay; - this.#requestDrain(); - throw new WorkHubActionEffectFailure( - 'commit_outcome_unknown', - options.unknownOutcomeMessage, - ); - } - }); + await options.beforeAppend(); + try { + await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [requested]); + await this.#continuity.refreshCanonical(WORKHUB_COORDINATION_SESSION_ID, lease); + return requested; + } catch { + const replay = await options.read().catch(() => undefined); + if (replay && isDeepStrictEqual(replay, requested)) return replay; + this.#requestDrain(); + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + options.unknownOutcomeMessage, + ); + } + }, + ); } async #candidates(): Promise> { @@ -627,7 +785,27 @@ function digest(value: unknown): `sha256:${string}` { return `sha256:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`; } -function workHubReplacementIdentitySuffix(delegationId: string): string { +function activeWorkHubAssignments( + messages: readonly StoredMessage[], +): WorkHubDelegationAssignedMessage[] { + const terminalDelegationIds = new Set(); + const assignments: WorkHubDelegationAssignedMessage[] = []; + for (const message of messages) { + if (message.type !== 'workhub_coordination') continue; + if (message.kind === 'delegation_assigned') { + assignments.push(message); + } else if (message.kind === 'delegation_superseded') { + terminalDelegationIds.add(message.supersededDelegationId); + } else if (message.kind === 'delegation_replacement_aborted') { + terminalDelegationIds.add(message.abortedDelegationId); + } else if (message.kind === 'delegation_stop_resolved' && message.outcome !== 'not_owned') { + terminalDelegationIds.add(message.stopsDelegationId); + } + } + return assignments.filter(({ delegationId }) => !terminalDelegationIds.has(delegationId)); +} + +function workHubDestructiveClaimIdentitySuffix(delegationId: string): string { return createHash('sha256').update(delegationId, 'utf8').digest('hex').slice(0, 48); } diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 156eae2614..f1c59932fb 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -40,6 +40,7 @@ import { AgentRun } from '../agent-run.js'; import { BackendRegistry, SessionManager, + workHubDirectStopAbortSource, type BackendFactoryContext, type SessionStore, } from '../session-manager.js'; @@ -282,6 +283,41 @@ describe('SessionManager terminal ledger invariants', () => { assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.abortSource, 'renderer.stop_button'); }); + test('stopSession persists a WorkHub action-bound abort source', async () => { + const store = new TinySessionStore(); + const runStore = new TinyAgentRunStore(); + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => new NeverEndingBackend(ctx)); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(20_500), + }); + const session = await manager.createSession(makeInput()); + const iterator = manager + .sendMessage(session.id, { turnId: 'turn-workhub-stop', text: 'hello' }) + [Symbol.asyncIterator](); + assert.strictEqual((await iterator.next()).value?.type, 'text_delta'); + + await manager.stopSession(session.id, { + source: 'workhub_direct_stop', + workHubActionId: 'workhub-stop-action', + }); + + const expected = workHubDirectStopAbortSource('workhub-stop-action'); + const [run] = await runStore.listSessionRuns(session.id); + if (!run) throw new Error('run was not recorded'); + assert.strictEqual(run.status, 'cancelled'); + assert.strictEqual(run.abortSource, expected); + const [terminal] = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( + isTerminalRuntimeEvent, + ); + assert.strictEqual(terminal?.actions?.stateDelta?.abortSource, expected); + }); + test('stopSession commits a terminal fact when the backend stream never ends', async () => { const store = new TinySessionStore(); const runStore = new TinyAgentRunStore(); diff --git a/packages/runtime/src/__tests__/session-projection-helpers.test.ts b/packages/runtime/src/__tests__/session-projection-helpers.test.ts index 80790ee3ad..bacf2c9971 100644 --- a/packages/runtime/src/__tests__/session-projection-helpers.test.ts +++ b/packages/runtime/src/__tests__/session-projection-helpers.test.ts @@ -24,12 +24,33 @@ import { buildStatusPatch, buildTurnStateMessage, isTerminalRunStatus, + normalizeStopSessionSource, statusFromEvent, turnStatusFromEvent, turnHasRetainedOutput, + workHubDirectStopAbortSource, } from '../session-projection-helpers.js'; describe('session projection helpers', () => { + test('binds WorkHub Stop provenance to one valid action identity', () => { + assert.equal( + normalizeStopSessionSource('workhub_direct_stop', 'stop-action'), + workHubDirectStopAbortSource('stop-action'), + ); + assert.notEqual( + workHubDirectStopAbortSource('stop-action'), + workHubDirectStopAbortSource('different-action'), + ); + assert.throws( + () => normalizeStopSessionSource('workhub_direct_stop'), + /Invalid WorkHub direct-stop action identity/, + ); + assert.throws( + () => normalizeStopSessionSource('stop_button', 'stop-action'), + /requires its dedicated Stop source/, + ); + }); + test('buildStatusPatch normalizes blocked reasons and clears non-blocked reasons', () => { assert.deepStrictEqual(buildStatusPatch('blocked', 100), { status: 'blocked', diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index cff8aaed16..a507862d0e 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -317,11 +317,15 @@ export class AgentRun { }; } - stop(source: StopSessionInput['source'] | undefined): boolean { + stop( + source: StopSessionInput['source'] | undefined, + workHubActionId?: StopSessionInput['workHubActionId'], + ): boolean { + const abortSource = normalizeStopSessionSource(source, workHubActionId); if (this.terminalClaim) return false; this.terminalClaim = { owner: 'stop' }; this.stopped = true; - this.abortSource = normalizeStopSessionSource(source); + this.abortSource = abortSource; return true; } diff --git a/packages/runtime/src/message-authority.ts b/packages/runtime/src/message-authority.ts index 09cc562786..55498687f8 100644 --- a/packages/runtime/src/message-authority.ts +++ b/packages/runtime/src/message-authority.ts @@ -17,9 +17,10 @@ * under the License. */ -import type { BackendStopMode, SteeringLease } from '@maka/core/backend-types'; +import type { SteeringLease } from '@maka/core/backend-types'; import type { RootExecutionDescriptor } from '@maka/core/agent-run'; import type { MessageContent, SessionEvent } from '@maka/core/events'; +import type { StopSessionInput } from './session-manager.js'; export interface RuntimeMessageRunIdentity { readonly sessionId: string; @@ -62,20 +63,8 @@ export interface RuntimeHostedRootExecutionInput extends RuntimeMessageRunIdenti /** Host-only root lifecycle capability. Embedded compositions must omit it. */ export interface RuntimeHostedRootAuthority extends RuntimeMessageAuthority { executeRoot(input: RuntimeHostedRootExecutionInput): Promise; - stopRoot( - identity: RuntimeMessageRunIdentity, - input?: { - source?: 'stop_button' | 'graph_supervisor'; - mode?: BackendStopMode; - }, - ): Promise; - stopSession( - sessionId: string, - input?: { - source?: 'stop_button' | 'graph_supervisor'; - mode?: BackendStopMode; - }, - ): Promise; + stopRoot(identity: RuntimeMessageRunIdentity, input?: StopSessionInput): Promise; + stopSession(sessionId: string, input?: StopSessionInput): Promise; } export function isRuntimeHostedRootAuthority( diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 1da029c273..f9974c0a57 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -532,7 +532,9 @@ export class RuntimeKernel implements RuntimeKernelLike { } execution.run = run; execution.phase = 'attached'; - if (execution.stopIntent) run.stop(execution.stopIntent.input.source); + if (execution.stopIntent) { + run.stop(execution.stopIntent.input.source, execution.stopIntent.input.workHubActionId); + } } private reserveExecutionClaim( @@ -1712,6 +1714,7 @@ export class RuntimeKernel implements RuntimeKernelLike { } stopSession(sessionId: string, input: StopSessionInput = {}): Promise { + normalizeStopSessionSource(input.source, input.workHubActionId); const existing = this.stopAttempts.get(sessionId); if (existing) return existing; const intent: SessionStopIntent = { input, claims: new Set() }; @@ -1721,7 +1724,9 @@ export class RuntimeKernel implements RuntimeKernelLike { execution.stopIntent = intent; intent.claims.add(execution); } - for (const execution of executions) execution.run?.stop(input.source); + for (const execution of executions) { + execution.run?.stop(input.source, input.workHubActionId); + } for (const execution of executions) { execution.abortController.abort(execution.cancellation); } @@ -1777,7 +1782,7 @@ export class RuntimeKernel implements RuntimeKernelLike { active: BackendGeneration, run: AgentRun, ): StopOperation | undefined { - run.stop(input.source); + run.stop(input.source, input.workHubActionId); if (!run.hasPendingStop()) return this.stopOperations.get(sessionId); const existingOperation = this.stopOperations.get(sessionId); const operation = existingOperation ?? this.buildStopOperation(input); @@ -1820,7 +1825,7 @@ export class RuntimeKernel implements RuntimeKernelLike { } private buildStopOperation(input: StopSessionInput): StopOperation { - const abortSource = normalizeStopSessionSource(input.source); + const abortSource = normalizeStopSessionSource(input.source, input.workHubActionId); const ts = this.deps.now(); return { abortSource, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index fe98e096f7..e602dba5c4 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -248,10 +248,22 @@ function runtimeCommitSinkFromEventStore( : undefined; } -export interface StopSessionInput { - source?: 'stop_button' | 'graph_supervisor'; - mode?: BackendStopMode; -} +export type StopSessionInput = + | { + source?: 'stop_button' | 'graph_supervisor'; + workHubActionId?: never; + mode?: BackendStopMode; + } + | { + source: 'workhub_direct_stop'; + workHubActionId: string; + mode?: BackendStopMode; + }; + +export { + normalizeStopSessionSource, + workHubDirectStopAbortSource, +} from './session-projection-helpers.js'; export type CompactSessionInput = | { diff --git a/packages/runtime/src/session-projection-helpers.ts b/packages/runtime/src/session-projection-helpers.ts index 468005fc24..b57598339e 100644 --- a/packages/runtime/src/session-projection-helpers.ts +++ b/packages/runtime/src/session-projection-helpers.ts @@ -17,6 +17,7 @@ * under the License. */ +import { createHash } from 'node:crypto'; import type { AgentRunHeader } from '@maka/core/agent-run'; import { failureClassFromCompleteStopReason, type SessionEvent } from '@maka/core/events'; import type { @@ -95,18 +96,32 @@ export function turnHasRetainedOutput(messages: readonly StoredMessage[], turnId } export function normalizeStopSessionSource( - source: 'stop_button' | 'graph_supervisor' | undefined, + source: 'stop_button' | 'graph_supervisor' | 'workhub_direct_stop' | undefined, + workHubActionId?: string, ): string | undefined { + if (source !== 'workhub_direct_stop' && workHubActionId !== undefined) { + throw new Error('WorkHub direct-stop identity requires its dedicated Stop source'); + } switch (source) { case 'stop_button': return 'renderer.stop_button'; case 'graph_supervisor': return 'graph.supervisor'; + case 'workhub_direct_stop': + return workHubDirectStopAbortSource(workHubActionId); case undefined: return undefined; } } +export function workHubDirectStopAbortSource(actionId: string | undefined): string { + if (!actionId || !/^[A-Za-z0-9_-]{1,128}$/u.test(actionId)) { + throw new Error('Invalid WorkHub direct-stop action identity'); + } + const suffix = createHash('sha256').update(actionId, 'utf8').digest('hex').slice(0, 48); + return `workhub.direct_stop.${suffix}`; +} + export function isTerminalRunStatus(status: AgentRunHeader['status']): boolean { return status === 'completed' || status === 'failed' || status === 'cancelled'; } diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 8aea6a22be..a027cc3773 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -501,6 +501,65 @@ describe('SqliteSessionMetadataStore', () => { } }); + test('migrates a v36 cancellation tombstone without inventing a claim owner', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-message-cancellation-v36-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path); + try { + await setup.create(fullHeader({ id: 'session-v36-cancellation' })); + const content = { text: 'cancelled before claim provenance existed' }; + await setup.commitMessageAdmission({ + sessionId: 'session-v36-cancellation', + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 10, + }); + await setup.cancelMessageAdmissions('session-v36-cancellation', ['message-1']); + } finally { + setup.close(); + } + + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + ALTER TABLE cancelled_message_admissions DROP COLUMN cancellation_claim_id; + UPDATE session_metadata_schema SET version = 36 WHERE scope = 'session_metadata'; + `); + } finally { + legacy.close(); + } + + const migrated = createSqliteSessionMetadataStore(path); + try { + assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + assert.equal( + await migrated.hasCancelledMessageAdmission('session-v36-cancellation', 'message-1'), + true, + ); + assert.equal( + await migrated.claimMessageAdmissionCancellation( + 'session-v36-cancellation', + 'message-1', + 'later-workhub-claim', + ), + 'already_cancelled', + ); + } finally { + migrated.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test('materializes a proven Root message when its admission is absent', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { @@ -1445,6 +1504,62 @@ describe('SqliteSessionMetadataStore', () => { } }); + test('cancellation tombstones retain the durable claim that created them', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-message-cancellation-claim-')); + const path = join(root, 'state.sqlite'); + let store = createSqliteSessionMetadataStore(path); + try { + await store.create(fullHeader({ id: 'session-claim' })); + const content = { text: 'cancel this pending work' }; + await store.commitMessageAdmission({ + sessionId: 'session-claim', + turnId: 'turn-claim', + runId: 'run-claim', + messageId: 'message-claim', + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 10, + }); + assert.equal( + await store.claimMessageAdmissionCancellation( + 'session-claim', + 'message-claim', + 'stop-claim', + ), + 'cancelled_by_claim', + ); + } finally { + store.close(); + } + + store = createSqliteSessionMetadataStore(path); + try { + assert.equal( + await store.claimMessageAdmissionCancellation( + 'session-claim', + 'message-claim', + 'stop-claim', + ), + 'same_claim', + ); + assert.equal( + await store.claimMessageAdmissionCancellation( + 'session-claim', + 'message-claim', + 'other-claim', + ), + 'already_cancelled', + ); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + test('materializes an accepted follow-up under its successor root', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { diff --git a/packages/storage/src/__tests__/workhub-message-assignment.test.ts b/packages/storage/src/__tests__/workhub-message-assignment.test.ts index 27828855ab..983cfacefc 100644 --- a/packages/storage/src/__tests__/workhub-message-assignment.test.ts +++ b/packages/storage/src/__tests__/workhub-message-assignment.test.ts @@ -29,6 +29,8 @@ import { WORKHUB_COORDINATION_SESSION_ROLE, type WorkHubDelegationAssignedMessage, type WorkHubDelegationReplacementAbortedMessage, + type WorkHubDelegationStopRequestedMessage, + type WorkHubDelegationStopResolvedMessage, type WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { createSessionStore, isSessionNotFoundError } from '../session-store.js'; @@ -332,6 +334,108 @@ test('an aborted replacement cannot later commit a supersession', async () => { } }); +test('an unresolved stop claim blocks replacement while not_owned releases the link', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-arbitration-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const source = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const destination = await store.create({ + cwd: root, + name: 'Login', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const original = assignmentRequest('stop-source', source.id, 'Payments', 'source-turn'); + await store.assignWorkHubMessage(original); + const delegationSuffix = createHash('sha256') + .update(original.assignment.delegationId) + .digest('hex') + .slice(0, 48); + const request: WorkHubDelegationStopRequestedMessage = { + type: 'workhub_coordination', + id: `whq_${delegationSuffix}`, + turnId: 'stop-action', + ts: 11, + schemaVersion: 3, + kind: 'delegation_stop_requested', + actionId: 'stop-action', + actionFingerprint: `sha256:${'d'.repeat(64)}`, + coordinationTurnId: 'stop-action', + stopsActionId: original.assignment.actionId, + stopsDelegationId: original.assignment.delegationId, + targetSessionId: source.id, + targetMessageId: original.assignment.targetMessageId, + targetSessionName: 'Payments', + userText: 'Stop Payments', + }; + await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [request]); + assert.deepEqual(await store.readWorkHubStopRequest(original.assignment.delegationId), request); + + const base = assignmentRequest('after-stop', destination.id, 'Login', 'destination-turn'); + const assignment: WorkHubDelegationAssignedMessage = { + ...base.assignment, + schemaVersion: 2, + replacesActionId: original.assignment.actionId, + replacesDelegationId: original.assignment.delegationId, + }; + const supersession: WorkHubDelegationSupersededMessage = { + type: 'workhub_coordination', + id: `whx_${delegationSuffix}`, + turnId: assignment.actionId, + ts: assignment.ts, + schemaVersion: 2, + kind: 'delegation_superseded', + actionId: assignment.actionId, + actionFingerprint: assignment.actionFingerprint, + coordinationTurnId: assignment.coordinationTurnId, + supersededActionId: original.assignment.actionId, + supersededDelegationId: original.assignment.delegationId, + replacementDelegationId: assignment.delegationId, + }; + await assert.rejects( + store.assignWorkHubMessage({ ...base, assignment, supersession }), + /stop claim/u, + ); + + const resolution: WorkHubDelegationStopResolvedMessage = { + type: 'workhub_coordination', + id: `whz_${delegationSuffix}`, + turnId: 'stop-action', + ts: 12, + schemaVersion: 3, + kind: 'delegation_stop_resolved', + actionId: 'stop-action', + actionFingerprint: request.actionFingerprint, + coordinationTurnId: 'stop-action', + stopsActionId: original.assignment.actionId, + stopsDelegationId: original.assignment.delegationId, + targetSessionId: source.id, + targetTurnId: 'shared-turn', + outcome: 'not_owned', + }; + await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [resolution]); + assert.deepEqual( + await store.readWorkHubStopResolution(original.assignment.delegationId), + resolution, + ); + assert.equal( + (await store.assignWorkHubMessage({ ...base, assignment, supersession })).kind, + 'assigned', + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + async function createCoordinationSession( store: ReturnType, root: string, diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index dc62748e68..26692bdef5 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -369,6 +369,10 @@ async function createExecutionStoresForWrite sessionStore.readWorkHubReplacementAbort(delegationId)), readWorkHubSupersession: (delegationId) => run(() => sessionStore.readWorkHubSupersession(delegationId)), + readWorkHubStopRequest: (delegationId) => + run(() => sessionStore.readWorkHubStopRequest(delegationId)), + readWorkHubStopResolution: (delegationId) => + run(() => sessionStore.readWorkHubStopResolution(delegationId)), discardStableConversationCopy: (sessionId, requestFingerprint) => run(() => sessionStore.discardStableConversationCopy(sessionId, requestFingerprint)), createSubagent: (input, initialBoundary) => @@ -437,6 +441,8 @@ async function createExecutionStoresForWrite sessionStore.readMessageAdmission(sessionId, messageId)), hasCancelledMessageAdmission: (sessionId, messageId) => run(() => sessionStore.hasCancelledMessageAdmission(sessionId, messageId)), + claimMessageAdmissionCancellation: (sessionId, messageId, claimId) => + run(() => sessionStore.claimMessageAdmissionCancellation(sessionId, messageId, claimId)), listMessageAdmissions: (sessionId) => run(() => sessionStore.listMessageAdmissions(sessionId)), markMessagesHandedOff: (input) => run(() => sessionStore.markMessagesHandedOff(input)), diff --git a/packages/storage/src/message-admission-store.ts b/packages/storage/src/message-admission-store.ts index c759c3703a..d284a04ebb 100644 --- a/packages/storage/src/message-admission-store.ts +++ b/packages/storage/src/message-admission-store.ts @@ -85,6 +85,11 @@ export interface MarkMessagesHandedOffInput { readonly provenSteeringMessages?: readonly ProvenSteeringMessageHandoff[]; } +export type MessageAdmissionCancellationClaimOutcome = + | 'cancelled_by_claim' + | 'same_claim' + | 'already_cancelled'; + export interface MessageAdmissionStore { commitMessageAdmission(admission: PendingMessageAdmission): Promise; readMessageAdmission( @@ -97,6 +102,11 @@ export interface MessageAdmissionStore { * own columns never leave this layer. */ hasCancelledMessageAdmission(sessionId: string, messageId: string): Promise; + claimMessageAdmissionCancellation( + sessionId: string, + messageId: string, + claimId: string, + ): Promise; listMessageAdmissions(sessionId: string): Promise; markMessagesHandedOff(input: MarkMessagesHandedOffInput): Promise; updateMessageAdmission(admission: PendingMessageAdmission): Promise; diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index f51971958c..e8845ffe3a 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -86,6 +86,8 @@ import { type WorkHubDelegationAssignedMessage, type WorkHubDelegationReplacementAbortedMessage, type WorkHubDelegationReplacementRequestedMessage, + type WorkHubDelegationStopRequestedMessage, + type WorkHubDelegationStopResolvedMessage, type WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import type { @@ -430,6 +432,12 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto readWorkHubSupersession( delegationId: string, ): Promise; + readWorkHubStopRequest( + delegationId: string, + ): Promise; + readWorkHubStopResolution( + delegationId: string, + ): Promise; discardStableConversationCopy(sessionId: string, requestFingerprint: string): Promise; listCatalogPage( filter: SessionListFilter | undefined, @@ -717,6 +725,28 @@ class SqliteSessionStore implements SessionAuthorityStore { : undefined; } + async readWorkHubStopRequest( + delegationId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whq_${workHubIdentitySuffix(delegationId)}`, + ); + return message?.type === 'workhub_coordination' && message.kind === 'delegation_stop_requested' + ? message + : undefined; + } + + async readWorkHubStopResolution( + delegationId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whz_${workHubIdentitySuffix(delegationId)}`, + ); + return message?.type === 'workhub_coordination' && message.kind === 'delegation_stop_resolved' + ? message + : undefined; + } + private async readWorkHubCoordinationMessage( messageId: string, ): Promise { @@ -1059,6 +1089,11 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.hasCancelledMessageAdmission(sessionId, messageId); } + async claimMessageAdmissionCancellation(sessionId: string, messageId: string, claimId: string) { + await this.ensureReady(); + return this.metadata.claimMessageAdmissionCancellation(sessionId, messageId, claimId); + } + async listMessageAdmissions(sessionId: string): Promise { await this.ensureReady(); return this.metadata.listMessageAdmissions(sessionId); diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index b4e3c037a5..57aba9a43d 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 36; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 37; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -1242,6 +1242,13 @@ const MIGRATIONS: ReadonlyMap = new Map([ SELECT 1; `, ], + [ + 37, + ` + ALTER TABLE cancelled_message_admissions + ADD COLUMN cancellation_claim_id TEXT; + `, + ], ]); if (MIGRATIONS.size !== SQLITE_SESSION_METADATA_SCHEMA_VERSION) { @@ -1299,13 +1306,14 @@ export function migrateSqliteSessionMetadataDatabase( ) { const sql = MIGRATIONS.get(version); if (!sql) throw new Error(`Missing SQLite session metadata migration ${version}`); - // Versions 32 and 35 each add one column, and the post-merge convergence + // Versions 32, 35, and 37 each add one column, and the post-merge convergence // path can replay them onto a database that already carries the current // table shape. SQLite has no `ADD COLUMN IF NOT EXISTS`, so the guards // live here. const columnAlreadyPresent = (version === 32 && hasColumn(db, 'message_admissions', 'submitted_intent_json')) || - (version === 35 && hasColumn(db, 'message_admissions', 'skill_invocation_json')); + (version === 35 && hasColumn(db, 'message_admissions', 'skill_invocation_json')) || + (version === 37 && hasColumn(db, 'cancelled_message_admissions', 'cancellation_claim_id')); if (!columnAlreadyPresent) { db.exec(sql); } diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 770147b5d3..f544fffada 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -106,6 +106,7 @@ import { normalizeProvenSteeringMessageHandoff, samePendingMessageAdmission, type MarkMessagesHandedOffInput, + type MessageAdmissionCancellationClaimOutcome, type PendingMessageAdmission, type ProvenRootMessageHandoff, type ProvenSteeringMessageHandoff, @@ -1819,6 +1820,23 @@ export class SqliteSessionMetadataStore { .update(assignment.replacesDelegationId) .digest('hex') .slice(0, 48); + const stopRequest = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `whq_${abortSuffix}`, + ); + if (stopRequest) { + const stopResolution = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + `whz_${abortSuffix}`, + ); + if ( + stopResolution?.type !== 'workhub_coordination' || + stopResolution.kind !== 'delegation_stop_resolved' || + stopResolution.outcome !== 'not_owned' + ) { + throw new SessionMetadataConflictError('WorkHub delegation already has a stop claim'); + } + } const existingAbort = this.readMessageByIdSync( WORKHUB_COORDINATION_SESSION_ID, `whb_${abortSuffix}`, @@ -1955,6 +1973,68 @@ export class SqliteSessionMetadataStore { }); } + async claimMessageAdmissionCancellation( + sessionId: string, + messageId: string, + claimId: string, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSafeSessionId(messageId); + assertSafeSessionId(claimId); + return this.transaction(() => { + const cancelled = this.db + .prepare( + 'SELECT cancellation_claim_id FROM cancelled_message_admissions WHERE session_id = ? AND message_id = ?', + ) + .get(sessionId, messageId) as { cancellation_claim_id?: unknown } | undefined; + if (cancelled) { + return cancelled.cancellation_claim_id === claimId ? 'same_claim' : 'already_cancelled'; + } + const admission = this.db + .prepare( + ` + SELECT submitted_content_digest, submitted_placement + FROM message_admissions + WHERE session_id = ? AND message_id = ? + `, + ) + .get(sessionId, messageId) as + | { submitted_content_digest?: unknown; submitted_placement?: unknown } + | undefined; + if ( + typeof admission?.submitted_content_digest !== 'string' || + (admission.submitted_placement !== 'current_turn' && + admission.submitted_placement !== 'next_turn') + ) { + throw new SessionMetadataConflictError('Message admission cancellation identity conflict'); + } + this.db + .prepare( + ` + INSERT INTO cancelled_message_admissions( + session_id, message_id, submitted_content_digest, submitted_placement, + cancellation_claim_id + ) VALUES (?, ?, ?, ?, ?) + `, + ) + .run( + sessionId, + messageId, + admission.submitted_content_digest, + admission.submitted_placement, + claimId, + ); + const deleted = this.db + .prepare('DELETE FROM message_admissions WHERE session_id = ? AND message_id = ?') + .run(sessionId, messageId); + if (deleted.changes !== 1) { + throw new SessionMetadataConflictError('Message admission cancellation identity conflict'); + } + return 'cancelled_by_claim'; + }); + } + async listMessageAdmissions(sessionId: string): Promise { this.assertOpen(); assertSafeSessionId(sessionId); From 84af1c5a5fd01319cf72ee2347356774cb736864 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 1 Sep 2026 15:07:31 +0800 Subject: [PATCH 02/19] fix(desktop): keep WorkHub stop routing behind policy Generated-by: Codex --- .../src/renderer/workhub-controller.ts | 25 +++++++------------ .../src/renderer/workhub-route-policy.ts | 20 +++++++++++++++ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index eca235c3d3..969d51404b 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -32,10 +32,6 @@ import type { WorkHubCoordinationActResult, WorkHubCoordinationCandidatesResult, } from '@maka/runtime-host/protocol'; -import { - readWorkHubRequestIntent, - workHubStopTargetsSession, -} from './application/contracts/workhub-request-intent.js'; export interface WorkHubSessionTarget { sessionId: string; @@ -477,14 +473,11 @@ export function createWorkHubController(deps: { const sessions = await deps.sessions.list(); reconcileFocus(submissionPolicy, sessions); const ordinary = sessions.filter((session) => session.kind === 'ordinary'); - const requestIntent = readWorkHubRequestIntent(input.text); - if (requestIntent.stop.cue) { - const matching = ordinary.filter( - (session) => - activeActionIdsBySessionId.get(session.target.sessionId)?.length === 1 && - workHubStopTargetsSession(requestIntent, session.sessionName), - ); - if (!requestIntent.stop.imperative || matching.length !== 1) { + const stoppable = ordinary.filter((session) => + activeActionIdsBySessionId.get(session.target.sessionId)?.length === 1); + const stopDecision = submissionPolicy.resolveStop({ text: input.text, sessions: stoppable }); + if (stopDecision.kind !== 'not_requested') { + if (stopDecision.kind === 'clarification') { return { kind: 'clarification', strategyId: WORKHUB_ROUTING_STRATEGY_ID, @@ -494,8 +487,8 @@ export function createWorkHubController(deps: { reason: 'stop_target_required', }; } - const target = matching[0]!; - const sourceActionId = activeActionIdsBySessionId.get(target.target.sessionId)![0]!; + const target = stopDecision.target; + const sourceActionId = activeActionIdsBySessionId.get(target.sessionId)![0]!; const admitted = await coordination.act({ actionId: input.requestId, userText: input.text, @@ -506,13 +499,13 @@ export function createWorkHubController(deps: { throw new Error('WorkHub Action Gate returned an unexpected disposition'); } if (admitted.outcome !== 'not_owned') { - removeActiveAction(target.target.sessionId, sourceActionId); + removeActiveAction(target.sessionId, sourceActionId); } return { kind: 'stop', strategyId: WORKHUB_ROUTING_STRATEGY_ID, requestId: input.requestId, - target: target.target, + target, outcome: admitted.outcome, ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), }; diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index 170a9c4be6..6dc4a49522 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -20,6 +20,7 @@ import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, + workHubStopTargetsSession, type WorkHubRequestIntent, } from './application/contracts/workhub-request-intent.js'; @@ -58,7 +59,16 @@ export type WorkHubRouteDecision = | { kind: 'discussion' } | { kind: 'new_session'; title: string; correctedFrom?: WorkHubRouteTarget }; +export type WorkHubStopRouteDecision = + | { kind: 'not_requested' } + | { kind: 'clarification' } + | { kind: 'target'; target: WorkHubRouteTarget }; + export interface WorkHubRoutePolicy { + resolveStop(input: { + text: string; + sessions: WorkHubRoutableSession[]; + }): WorkHubStopRouteDecision; resolve(input: { text: string; sessions: WorkHubRoutableSession[]; @@ -110,6 +120,16 @@ function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy { let previousFocus: WorkHubRouteTarget | undefined; return { + resolveStop({ text, sessions }) { + const intent = readWorkHubRequestIntent(text); + if (!intent.stop.cue) return { kind: 'not_requested' }; + if (!intent.stop.imperative) return { kind: 'clarification' }; + const matching = sessions.filter((session) => + workHubStopTargetsSession(intent, session.sessionName)); + return matching.length === 1 + ? { kind: 'target', target: matching[0]!.target } + : { kind: 'clarification' }; + }, resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { const intent = readWorkHubRequestIntent(text); if (intent.execution === 'ambiguous') { From 19eaa70148ef0fc74d341af22404075d123869d5 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 1 Sep 2026 19:19:00 +0800 Subject: [PATCH 03/19] fix(workhub): harden direct stop recovery Generated-by: Claude Opus --- .../main/__tests__/workhub-controller.test.ts | 116 +++++++-- .../src/renderer/workhub-controller.ts | 19 +- .../src/renderer/workhub-route-policy.ts | 48 +++- apps/desktop/src/renderer/workhub-surface.tsx | 40 ++- .../workhub-coordination-session-adr.md | 15 ++ docs/workhub-domain-language.md | 13 +- packages/core/src/session.ts | 27 ++ .../__tests__/execution-composition.test.ts | 44 ++++ .../src/__tests__/message-coordinator.test.ts | 37 ++- .../workhub-coordination-action-gate.test.ts | 245 +++++++++++++++++- .../workhub-coordination-coordinator.test.ts | 201 ++++++++++++++ .../src/server/execution-composition.ts | 66 +++-- .../src/server/message-coordinator.ts | 24 +- .../workhub-coordination-action-gate.ts | 137 ++++++++-- .../workhub-coordination-coordinator.ts | 11 + .../sqlite-session-metadata-store.test.ts | 45 ++++ packages/storage/src/execution-stores.ts | 3 + packages/storage/src/session-store.ts | 19 ++ .../src/sqlite-session-metadata-schema.ts | 21 +- .../src/sqlite-session-metadata-store.ts | 83 ++++++ 20 files changed, 1104 insertions(+), 110 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 0734434135..9c6d36a10f 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -413,34 +413,98 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou await handle.close(); }); -test('a named stop stays fail-closed when the Session has multiple active delegations', async () => { - const sessions = port([session('payments', { sessionName: 'Payments' })]); - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async (handler) => { - handler([], [ - { actionId: 'action-1', targetSessionId: 'payments', sequence: 0 }, - { actionId: 'action-2', targetSessionId: 'payments', sequence: 1 }, - ]); - return { close: async () => undefined }; +test('a named stop explains a Session that is not uniquely stoppable', async () => { + for (const [reason, activeDelegations] of [ + ['stop_target_not_unique', 2], + ['stop_target_not_active', 0], + ] as const) { + const sessions = port([session('payments', { sessionName: 'Payments' })]); + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler( + [], + Array.from({ length: activeDelegations }, (_unused, index) => ({ + actionId: `action-${index}`, + targetSessionId: 'payments', + sequence: index, + })), + ); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), + act: async () => assert.fail('an unstoppable named target must not reach the Action Gate'), }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => assert.fail('stop clarification must not read route candidates'), - act: async () => assert.fail('an ambiguous delegation stop must not reach the Action Gate'), - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); + }); + const handle = await controller.openConversation(() => undefined, () => undefined); - assert.deepEqual(await controller.submit({ requestId: 'stop-payments', text: 'Stop Payments' }), { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'stop-payments', - text: 'Stop Payments', - options: [], - reason: 'stop_target_required', - }); - await handle.close(); + assert.deepEqual( + await controller.submit({ requestId: 'stop-payments', text: 'Stop Payments' }), + { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'stop-payments', + text: 'Stop Payments', + options: [], + reason, + }, + ); + await handle.close(); + } +}); + +test('stop-shaped ordinary work routes normally instead of looping on clarification', async () => { + for (const [sessionName, text] of [ + ['Payments', 'Stop using the deprecated API in Payments'], + ['支付任务', '停止使用支付任务里的旧接口'], + ] as const) { + const sessions = port([session('payments', { sessionName })]); + const actions: WorkHubCoordinationActInput[] = []; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([], [{ actionId: 'action-1', targetSessionId: 'payments', sequence: 0 }]); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'e'.repeat(64)}`, + candidates: [{ + candidateRef: 'candidate-payments', + sessionId: 'payments', + sessionName, + workspace: { + target: { kind: 'host_path' as const, path: '/workspace/payments' }, + hostCwd: '/workspace/payments', + }, + state: 'active' as const, + updatedAt: 1, + }], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'delegate_existing', + targetSessionId: 'payments', + targetTurnId: 'payments-turn', + }; + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + const result = await controller.submit({ requestId: `work-${sessionName}`, text }); + assert.equal(result.kind, 'submitted', text); + assert.deepEqual( + actions.map((action) => action.proposal.disposition), + ['delegate_existing'], + text, + ); + await handle.close(); + } }); test('read exposes existing ordinary Sessions as factual Work summaries', async () => { diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 969d51404b..f5e9a43b28 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -26,6 +26,7 @@ import { createWorkHubRoutePolicy, type WorkHubRouteEvidence, + type WorkHubStopClarificationReason, } from './workhub-route-policy.js'; import type { WorkHubCoordinationActInput, @@ -177,7 +178,7 @@ export type WorkHubSubmission = ( requestId: string; text: string; options: Array>; - reason?: 'ambiguous_command' | 'stop_target_required'; + reason?: 'ambiguous_command' | WorkHubStopClarificationReason; correction?: WorkHubCorrectionContext; } | { @@ -473,9 +474,17 @@ export function createWorkHubController(deps: { const sessions = await deps.sessions.list(); reconcileFocus(submissionPolicy, sessions); const ordinary = sessions.filter((session) => session.kind === 'ordinary'); - const stoppable = ordinary.filter((session) => - activeActionIdsBySessionId.get(session.target.sessionId)?.length === 1); - const stopDecision = submissionPolicy.resolveStop({ text: input.text, sessions: stoppable }); + const stopDecision = submissionPolicy.resolveStop({ + text: input.text, + sessions: ordinary.map((session) => ({ + target: session.target, + projectName: session.projectName, + sessionName: session.sessionName, + updatedAt: session.updatedAt, + activeDelegations: + activeActionIdsBySessionId.get(session.target.sessionId)?.length ?? 0, + })), + }); if (stopDecision.kind !== 'not_requested') { if (stopDecision.kind === 'clarification') { return { @@ -484,7 +493,7 @@ export function createWorkHubController(deps: { requestId: input.requestId, text: input.text, options: [], - reason: 'stop_target_required', + reason: stopDecision.reason, }; } const target = stopDecision.target; diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index 6dc4a49522..af8d40e9ec 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -59,15 +59,35 @@ export type WorkHubRouteDecision = | { kind: 'discussion' } | { kind: 'new_session'; title: string; correctedFrom?: WorkHubRouteTarget }; +/** An existing WorkHub identity together with how much active work it owns. */ +export interface WorkHubStoppableSession extends WorkHubRoutableSession { + activeDelegations: number; +} + +export type WorkHubStopClarificationReason = + /** The stop names no safe target of its own — a pronoun or a bare noun. */ + | 'stop_target_required' + /** The stop names more than one existing Session. */ + | 'stop_target_ambiguous' + /** The named Session exists but owns no WorkHub-delegated active work. */ + | 'stop_target_not_active' + /** The named Session owns more than one active delegation. */ + | 'stop_target_not_unique'; + +/** + * A stop clarification never offers route options. Choosing one re-sends the + * original text as work, and stop-shaped text is exactly what must not be + * delivered to a Session that way, so the reason carries the whole answer. + */ export type WorkHubStopRouteDecision = | { kind: 'not_requested' } - | { kind: 'clarification' } + | { kind: 'clarification'; reason: WorkHubStopClarificationReason } | { kind: 'target'; target: WorkHubRouteTarget }; export interface WorkHubRoutePolicy { resolveStop(input: { text: string; - sessions: WorkHubRoutableSession[]; + sessions: WorkHubStoppableSession[]; }): WorkHubStopRouteDecision; resolve(input: { text: string; @@ -120,15 +140,29 @@ function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy { let previousFocus: WorkHubRouteTarget | undefined; return { + // Direct stop is a narrow claim over WorkHub's own active delegations, not + // a filter over every sentence that begins with "stop". Text that names no + // WorkHub identity — "Stop using the deprecated API" — is ordinary work and + // falls through to routing; an unsafe or anaphoric target still fails + // closed, and a named identity that is not uniquely stoppable says why. resolveStop({ text, sessions }) { const intent = readWorkHubRequestIntent(text); if (!intent.stop.cue) return { kind: 'not_requested' }; - if (!intent.stop.imperative) return { kind: 'clarification' }; - const matching = sessions.filter((session) => + if (!intent.stop.imperative) { + return { kind: 'clarification', reason: 'stop_target_required' }; + } + const named = sessions.filter((session) => workHubStopTargetsSession(intent, session.sessionName)); - return matching.length === 1 - ? { kind: 'target', target: matching[0]!.target } - : { kind: 'clarification' }; + if (named.length === 0) return { kind: 'not_requested' }; + if (named.length > 1) return { kind: 'clarification', reason: 'stop_target_ambiguous' }; + const target = named[0]!; + if (target.activeDelegations === 1) return { kind: 'target', target: target.target }; + return { + kind: 'clarification', + reason: target.activeDelegations === 0 + ? 'stop_target_not_active' + : 'stop_target_not_unique', + }; }, resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { const intent = readWorkHubRequestIntent(text); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 764a0a7b0c..496561024f 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -625,14 +625,36 @@ export function WorkHubCoordinationTurnView(props: { ); } +/** + * A stop clarification has to say what WorkHub could not decide. Every reason + * here is a distinct dead end for the user — an unnamed target, a name that + * fits several Sessions, a named Session with nothing to stop, and one holding + * more work than a single stop may retire. + */ +function workHubClarificationPrompt( + reason: Extract['reason'], + copy: ReturnType, +): string | undefined { + if (reason === 'ambiguous_command') return copy.confirmCommand; + if (reason === 'stop_target_required') return copy.stopTargetRequired; + if (reason === 'stop_target_ambiguous') return copy.stopTargetAmbiguous; + if (reason === 'stop_target_not_active') return copy.stopTargetNotActive; + if (reason === 'stop_target_not_unique') return copy.stopTargetNotUnique; + return undefined; +} + export function workHubCoordinationSummary( result: Exclude, projection: WorkHubProjection, copy: ReturnType, ): string { if (result.kind === 'clarification') { - if (result.reason === 'ambiguous_command') return copy.confirmCommand; - if (result.reason === 'stop_target_required') return copy.stopTargetRequired; + const prompt = workHubClarificationPrompt(result.reason, copy); + if (prompt) { + return result.options.length > 0 + ? `${prompt} ${result.options.map(({ sessionName }) => sessionName).join('、')}` + : prompt; + } return `${copy.chooseWork} ${result.options.map(({ sessionName }) => sessionName).join('、')}`; } if (result.kind === 'waiting') { @@ -676,11 +698,7 @@ function WorkHubTurnView(props: {

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

{turn.outcome.reason === 'ambiguous_command' - ? copy.confirmCommand - : turn.outcome.reason === 'stop_target_required' - ? copy.stopTargetRequired - : copy.chooseWork}

+

{workHubClarificationPrompt(turn.outcome.reason, copy) ?? copy.chooseWork}

{turn.outcome.options.length > 0 ? (
{turn.outcome.options.map((option) => ( @@ -820,6 +838,9 @@ function workHubCopy(locale: UiLocale) { chooseWork: '这条输入可能与多项工作有关,请选择目标:', confirmCommand: workHubAmbiguousCommandPrompt(locale), stopTargetRequired: '请明确说出要停止的工作名称,例如“停止 支付任务”。', + stopTargetAmbiguous: '这个名称对应多项工作;请打开具体的 Session 停止对应委托。', + stopTargetNotActive: '这项工作当前没有由 WorkHub 委托的进行中请求,无需停止。', + stopTargetNotUnique: '这项工作有多个进行中的委托;请打开该 Session 停止具体的那一个。', discussionStayed: '这条内容暂时保留在 WorkHub,没有创建或改动 Session。', discussionHint: '提出明确的执行目标后,我会把它交给对应的 Session。', answering: '正在回答…', @@ -878,6 +899,11 @@ function workHubCopy(locale: UiLocale) { chooseWork: 'This input may relate to more than one task. Choose a target:', confirmCommand: workHubAmbiguousCommandPrompt(locale), stopTargetRequired: 'Name the work explicitly, for example “Stop Payments”.', + stopTargetAmbiguous: + 'That name matches more than one work item. Open the exact Session to stop its delegation.', + stopTargetNotActive: 'This work has no WorkHub-delegated request running, so there is nothing to stop.', + stopTargetNotUnique: + 'This work has more than one delegation running. Open its Session to stop the exact one.', discussionStayed: 'This stayed in WorkHub without creating or changing a Session.', discussionHint: 'State an executable goal and I will hand it to the owning Session.', answering: 'Answering…', diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 710836c837..9c2a3983ee 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -194,6 +194,21 @@ admission holds the Coordination Session together with every active target Session lane while re-reading the active links and current display names. A concurrent assignment or rename must therefore settle before the uniqueness proof, wait until after the stop claim, or cause admission to fail closed. +Only a confirmed direct stop records that provenance: a route correction +retiring the same owning root carries its own cancellation claim but keeps the +neutral Stop source, so replay cannot read a correction as a delivered stop. + +Every durable WorkHub record is keyed by what it is about — an assignment by its +action, a stop or replacement by its delegation — so no single record can see an +action identity that moved to a second delegation or a second disposition. A +separate durable action claim, taken under the same Coordination admission +before any effect, is that global owner. Exact replay converges on it; any other +reuse of the identity fails closed before an effect, including after a rejected +or still-recovering attempt and across Host restarts. The claim carries no +Session foreign key, because a committed destructive claim has to outlive the +removal of its target: when the target Session is gone, its removal tombstone — +not the vanished Message proof, and never a merely unreadable target — is what +lets the stop reach a terminal resolution. ## Consequences, costs, and reevaluation diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md index c5b7770ba2..6ace0f7e32 100644 --- a/docs/workhub-domain-language.md +++ b/docs/workhub-domain-language.md @@ -117,9 +117,15 @@ the exact owning root accepted the Stop operation; the UI says that WorkHub aske it to stop rather than inventing an execution result. `not_owned` means the Message was consumed by a shared or user-owned Turn; WorkHub does not stop that Turn, preserves the active link, and navigates the user to the owning Session. +A stop cue that names no existing WorkHub Session is ordinary work — `Stop using +the deprecated API` is a task, not a destructive command — and routes normally. A +named Session that is not uniquely stoppable, and an unsafe or anaphoric target, +each fail closed with the reason they failed rather than an unanswerable prompt. An unresolved direct-stop claim and a replacement claim are mutually exclusive; the first durable destructive claim wins. A `not_owned` resolution releases that -exclusion so a later explicit route correction can proceed. +exclusion so a later explicit route correction can proceed, and because it leaves +the delegation active, a later attempt under a fresh request identity converges on +that same immutable `not_owned` outcome instead of colliding with the first claim. The pending-Message cancellation tombstone binds the durable stop action that created it, so a crash after cancellation but before resolution still replays `cancelled_pending` rather than degrading to `already_terminal`. Owning-root Stop @@ -129,7 +135,10 @@ concurrent manual Stop remains `already_terminal`. Stop admission holds the Coordination Session and every currently active target Session lane while it rechecks current names and active links; a concurrent rename or new delegation therefore cannot invalidate the named-one-target proof before the -request record commits. +request record commits. Removing the target Session destroys the Message proof a +committed claim still needs; the removal tombstone outlives that Session and +resolves the claim as `already_terminal`, while a target that is merely +unreadable, or one that never existed here, stays unresolved. **R2.4**: The deterministic context-continuity routing baseline. It remains useful as an experiment baseline or target resolver behind WorkHub's coordination layer; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 7853001b99..4d7c85ea0a 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1072,6 +1072,33 @@ export interface WorkHubDelegationStopResolvedMessage { targetTurnId?: string; } +/** + * The exact durable operation one WorkHub action identity is allowed to own. + * + * Per-record identity is keyed by the thing each record is about — an + * assignment by its action, a stop or replacement by its delegation — so no + * single record can reject an action id that crossed to another delegation or + * another disposition. This vocabulary names the one global owner that can. + */ +export type WorkHubActionOperation = + | 'answer_here' + | 'clarify' + | 'delegate_existing' + | 'create_new' + | 'replace' + | 'stop'; + +/** Durable global binding from one action identity to one exact operation. */ +export interface WorkHubActionClaim { + readonly actionId: string; + readonly operation: WorkHubActionOperation; + readonly actionFingerprint: `sha256:${string}`; + /** The durable identity this action owns: a delegation or a Coordination Turn. */ + readonly subject: string; +} + +export type WorkHubActionClaimOutcome = 'claimed' | 'same_claim' | 'conflict'; + export type WorkHubCoordinationMessage = | WorkHubDelegationAssignedMessage | WorkHubDelegationReplacementRequestedMessage diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index c30c0ed940..6b3d424465 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -60,6 +60,7 @@ import { createExecutionRuntimeHostComposition, runtimeHostFilesystemWorkerRuntime, stopOwnedWorkHubRoot, + stopReplacedWorkHubRoot, } from '../server/execution-composition.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; @@ -185,6 +186,49 @@ test('WorkHub does not claim an unrelated manual Stop as its delivery', async () }); }); +test('a replacement retirement never records direct-stop provenance', async () => { + const stops: Array | undefined> = []; + const outcome = await stopReplacedWorkHubRoot( + { + readRootState: () => ({ + kind: 'active', + sessionId: 'target-session', + turnId: 'target-turn', + runId: 'target-run', + }), + read: async () => assert.fail('replacement retirement must not re-read stop provenance'), + stopRoot: async ( + _identity: { sessionId: string; turnId: string; runId: string }, + input?: Record, + ) => { + stops.push(input); + }, + } as unknown as Parameters[0], + { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' }, + ); + + assert.deepEqual(stops, [undefined]); + assert.deepEqual(outcome, { outcome: 'stop_delivered', targetTurnId: 'target-turn' }); +}); + +test('a replacement leaves a root it no longer owns alone', async () => { + const outcome = await stopReplacedWorkHubRoot( + { + readRootState: () => ({ + kind: 'active', + sessionId: 'target-session', + turnId: 'other-turn', + runId: 'other-run', + }), + read: async () => assert.fail('replacement retirement must not re-read stop provenance'), + stopRoot: async () => assert.fail('a root owned by another Turn must not be stopped'), + } as unknown as Parameters[0], + { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' }, + ); + + assert.deepEqual(outcome, { outcome: 'already_terminal', targetTurnId: 'target-turn' }); +}); + test('production composition owns the long-term memory database lifecycle', async () => { await withCompositionRoot(async ({ root, owner }) => { const databasePath = join(root, LONG_TERM_MEMORY_DATABASE_NAME); diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index f897e8ba51..56e31728ff 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -489,7 +489,11 @@ test('exact pending cancellation removes only the linked Message', async () => { await submit(fixture, 'unrelated-message', 'keep this queued', 'next_turn'); assert.deepEqual( - await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'), + await fixture.coordinator.cancelMessageIfPending( + ROOT.sessionId, + 'linked-message', + 'first-claim', + ), { kind: 'cancelled_pending' }, ); assert.deepEqual( @@ -497,7 +501,11 @@ test('exact pending cancellation removes only the linked Message', async () => { ['unrelated-message'], ); assert.deepEqual( - await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'), + await fixture.coordinator.cancelMessageIfPending( + ROOT.sessionId, + 'linked-message', + 'second-claim', + ), { kind: 'cancelled' }, ); }); @@ -538,7 +546,11 @@ test('a consumed steering Message cannot claim ownership of its pre-existing roo fixture.events.push(steeringEvent('linked-message', 'wrong delegation')); assert.deepEqual( - await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'), + await fixture.coordinator.cancelMessageIfPending( + ROOT.sessionId, + 'linked-message', + 'stop-claim', + ), { kind: 'shared_turn', turnId: ROOT.turnId, @@ -555,7 +567,11 @@ test('a root source Message owns only the root Turn it created', async () => { ); assert.deepEqual( - await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'), + await fixture.coordinator.cancelMessageIfPending( + ROOT.sessionId, + 'linked-message', + 'stop-claim', + ), { kind: 'owned_root', turnId: 'durable-turn', @@ -579,11 +595,14 @@ test('a recovered multi-source successor remains shared by every source Message' }); for (const messageId of ['linked-message', 'other-message']) { - assert.deepEqual(await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, messageId), { - kind: 'shared_turn', - turnId: 'durable-turn', - runId: 'durable-run', - }); + assert.deepEqual( + await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, messageId, 'stop-claim'), + { + kind: 'shared_turn', + turnId: 'durable-turn', + runId: 'durable-run', + }, + ); } }); 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 1ca27c8e4d..4a06eb215a 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,8 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { + WorkHubActionClaim, + WorkHubActionClaimOutcome, WorkHubDelegationAssignedMessage, WorkHubDelegationReplacementAbortedMessage, WorkHubDelegationReplacementRequestedMessage, @@ -37,6 +39,7 @@ import { type WorkHubDelegationAssignmentInput, type WorkHubDelegationReplacementAbortInput, type WorkHubDelegationReplacementInput, + type WorkHubDelegationRetirementClaim, type WorkHubDelegationStopInput, type WorkHubDelegationStopResolutionInput, type WorkHubRetirementResult, @@ -495,6 +498,211 @@ describe('WorkHub Coordination Action Gate', () => { ); }); + test('a recovering stop keeps its action identity out of a second delegation', async () => { + const effects = fakeEffects([ + session('payments', { name: 'Payments' }), + session('login', { name: 'Login' }), + ]); + for (const [actionId, targetSessionId, name] of [ + ['source-action', 'payments', 'Payments'], + ['other-action', 'login', 'Login'], + ] as const) { + effects.assignmentRecords.set( + actionId, + assignmentRecord( + { + actionId, + actionFingerprint: `sha256:${(actionId === 'source-action' ? '1' : '2').repeat(64)}`, + targetSessionId, + targetSessionName: name, + disposition: 'delegate_existing', + userText: `Work in ${name}`, + }, + `${actionId}-turn`, + ), + ); + } + effects.retireDelegation = async () => ({ outcome: 'recovering' as const }); + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'reused-stop', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => + error instanceof WorkHubActionEffectFailure && error.code === 'operation_unavailable', + ); + assert.deepEqual([...effects.stopRequests.keys()], ['delegation-source-action']); + + // A fresh gate is the Host after restart: only the durable action owner can + // refuse the second delegation this identity is now trying to claim. + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'reused-stop', + userText: 'Stop Login', + proposal: { disposition: 'stop_work', stopsActionId: 'other-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.deepEqual([...effects.stopRequests.keys()], ['delegation-source-action']); + assert.equal(effects.stopResolutions.size, 0); + }); + + test('a stop action identity cannot cross into a delegation assignment', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'7'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix payment retry', + }, + 'source-turn', + ), + ); + await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'crossing-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + await assert.rejects( + gate.act( + { + actionId: 'crossing-action', + userText: 'Fix the login redirect', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.assignments.length, 0); + }); + + test('a fresh attempt after not_owned converges instead of conflicting forever', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'8'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix payment retry', + }, + 'source-turn', + ), + ); + let retirements = 0; + effects.retireDelegation = async () => { + retirements += 1; + return { outcome: 'not_owned' as const, targetTurnId: 'shared-turn' }; + }; + const first = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-first', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + + const retried = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-second', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + + assert.deepEqual(retried, first); + assert.equal(retirements, 1); + assert.equal(effects.stopResolutions.size, 1); + }); + + test('a committed stop converges once its target Session is durably removed', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'9'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix payment retry', + }, + 'source-turn', + ), + ); + effects.retireDelegation = async () => ({ outcome: 'recovering' as const }); + const input = { + actionId: 'stop-removed-target', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work' as const, stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' as const }, + }; + const unresolved = (error: unknown) => + error instanceof WorkHubActionEffectFailure && error.code === 'operation_unavailable'; + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + unresolved, + ); + assert.equal(effects.stopRequests.size, 1); + + // Unreadable is not proof. Only the removal tombstone resolves the claim. + effects.sessions = []; + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + unresolved, + ); + assert.equal(effects.stopResolutions.size, 0); + + effects.removedSessionIds.add('payments'); + const resolved = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); + assert.deepEqual(resolved, { + disposition: 'stop_work', + outcome: 'already_terminal', + targetSessionId: 'payments', + }); + assert.deepEqual( + await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + resolved, + ); + assert.equal(effects.stopResolutions.size, 1); + }); + test('binds a fresh stop to the current display name while replay keeps its durable name', async () => { const effects = fakeEffects([session('payments', { name: 'Renamed Payments' })]); effects.assignmentRecords.set( @@ -1789,8 +1997,8 @@ describe('WorkHub Coordination Action Gate', () => { (candidate) => candidate.sessionId === 'destination', )!; const retireDelegation = effects.retireDelegation; - effects.retireDelegation = async (assignment) => { - const result = await retireDelegation.call(effects, assignment); + effects.retireDelegation = async (assignment, retirement) => { + const result = await retireDelegation.call(effects, assignment, retirement); effects.sessions = effects.sessions.map((candidate) => candidate.id === 'destination' ? { ...candidate, name: 'Renamed destination' } : candidate, ); @@ -1853,8 +2061,8 @@ describe('WorkHub Coordination Action Gate', () => { (candidate) => candidate.sessionId === 'destination', )!; const retireDelegation = effects.retireDelegation; - effects.retireDelegation = async (assignment) => { - const result = await retireDelegation.call(effects, assignment); + effects.retireDelegation = async (assignment, retirement) => { + const result = await retireDelegation.call(effects, assignment, retirement); effects.sessions = effects.sessions.map((candidate) => candidate.id !== 'destination' ? candidate @@ -1941,8 +2149,8 @@ describe('WorkHub Coordination Action Gate', () => { }, }; const retireDelegation = effects.retireDelegation; - effects.retireDelegation = async (assignment) => { - await retireDelegation.call(effects, assignment); + effects.retireDelegation = async (assignment, retirement) => { + await retireDelegation.call(effects, assignment, retirement); throw new Error('simulated process exit after retirement'); }; @@ -2050,8 +2258,11 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { const supersessions = new Map(); const stopRequests = new Map(); const stopResolutions = new Map(); + const actionClaims = new Map(); return { sessions: [...initialSessions], + actionClaims, + removedSessionIds: new Set(), answers: [] as Array<{ turnId: string; text: string }>, clarifications: [] as Array<{ turnId: string; @@ -2066,9 +2277,26 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { stopRequests, stopResolutions, retirements: [] as WorkHubDelegationAssignedMessage[], + retirementClaims: [] as WorkHubDelegationRetirementClaim[], async listSessions() { return this.sessions; }, + async claimAction(claim: WorkHubActionClaim): Promise { + const existing = actionClaims.get(claim.actionId); + if (!existing) { + actionClaims.set(claim.actionId, claim); + return 'claimed'; + } + return existing.operation === claim.operation && + existing.actionFingerprint === claim.actionFingerprint && + existing.subject === claim.subject + ? 'same_claim' + : 'conflict'; + }, + async probeTargetRemoval(sessionId: string) { + if (this.sessions.some((session) => session.id === sessionId)) return 'present' as const; + return this.removedSessionIds.has(sessionId) ? ('removed' as const) : ('absent' as const); + }, async readAssignment(actionId: string) { return assignmentRecords.get(actionId); }, @@ -2233,12 +2461,17 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { }, async retireDelegation( assignment: WorkHubDelegationAssignedMessage, + retirement: WorkHubDelegationRetirementClaim, ): Promise { this.retirements.push(assignment); + this.retirementClaims.push(retirement); return { outcome: 'cancelled_pending' as const }; }, } satisfies WorkHubActionGateEffects & { sessions: WorkHubActionGateSession[]; + actionClaims: Map; + removedSessionIds: Set; + retirementClaims: WorkHubDelegationRetirementClaim[]; answers: Array<{ turnId: string; text: string }>; clarifications: Array<{ turnId: string; userText: string; assistantText: string }>; assignments: WorkHubDelegationAssignmentInput[]; 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 726eb2c600..e31844777b 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -870,6 +870,207 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('converges a committed stop after the target Session is removed and the Host restarts', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-removed-')); + let store = createSessionStore(root); + const stopInput = { + actionId: 'stop-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work' as const, stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' as const }, + }; + let targetId: string; + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + targetId = target.id; + // The exact crash seam: the pending cancellation succeeds and the durable + // resolution never lands. + const crashing = new Proxy(store, { + get(authority, property, receiver) { + if (property === 'appendMessages') { + return async (sessionId: string, messages: readonly { kind?: unknown }[]) => { + if (messages.some((message) => message.kind === 'delegation_stop_resolved')) { + throw new Error('simulated process exit before the stop resolution'); + } + return authority.appendMessages(sessionId, messages as never); + }; + } + const value = Reflect.get(authority, property, receiver) as unknown; + return typeof value === 'function' ? value.bind(authority) : value; + }, + }) as SessionAuthorityStore; + const workhub = coordinator( + root, + crashing, + () => undefined, + undefined, + undefined, + undefined, + { + assign: (input) => persistTestAssignment(store, input, 'payments-turn'), + retireDelegation: async () => ({ outcome: 'cancelled_pending' }), + }, + ); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!.candidateRef, + }, + }, + CONTEXT, + ) + ).ok, + true, + ); + const crashed = await workhub.handlers['workhub.coordination.act'](stopInput, CONTEXT); + assert.equal(crashed.ok, false); + const assignment = await store.readWorkHubAssignment('source-action'); + assert.ok(assignment); + assert.ok(await store.readWorkHubStopRequest(assignment.delegationId)); + assert.equal(await store.readWorkHubStopResolution(assignment.delegationId), undefined); + + await store.remove(target.id); + } finally { + await store.close?.(); + } + + store = createSessionStore(root); + try { + let retireCalls = 0; + const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + // The removed Session took every Message-ownership proof with it. + retireDelegation: async () => { + retireCalls += 1; + return { outcome: 'recovering' }; + }, + }); + const resolved = await restarted.handlers['workhub.coordination.act'](stopInput, CONTEXT); + assert.deepEqual(resolved, { + ok: true, + result: { + disposition: 'stop_work', + outcome: 'already_terminal', + targetSessionId: targetId, + }, + }); + assert.equal(retireCalls, 1); + assert.deepEqual( + await restarted.handlers['workhub.coordination.act'](stopInput, CONTEXT), + resolved, + ); + assert.equal(retireCalls, 1); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('keeps one durable action identity bound to one delegation across restart', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-action-claim-')); + let store = createSessionStore(root); + const stopLogin = { + actionId: 'reused-stop', + userText: 'Stop Login', + proposal: { disposition: 'stop_work' as const, stopsActionId: 'login-action' }, + confirmation: { kind: 'user_stop' as const }, + }; + let loginDelegationId: string | undefined; + try { + const targets: Array<{ id: string; name: string }> = []; + for (const name of ['Payments', 'Login']) { + targets.push( + await store.create({ + cwd: root, + name, + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }), + ); + } + const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`), + retireDelegation: async () => ({ outcome: 'recovering' }), + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + for (const [actionId, name, userText] of [ + ['source-action', 'Payments', 'Fix payment retry'], + ['login-action', 'Login', 'Fix the login redirect'], + ] as const) { + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const target = targets.find((session) => session.name === name)!; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId, + userText, + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!.candidateRef, + }, + }, + CONTEXT, + ) + ).ok, + true, + ); + } + loginDelegationId = (await store.readWorkHubAssignment('login-action'))?.delegationId; + const recovering = await workhub.handlers['workhub.coordination.act']( + { + actionId: 'reused-stop', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + assert.equal(recovering.ok, false); + if (!recovering.ok) assert.equal(recovering.error.code, 'operation_unavailable'); + } finally { + await store.close?.(); + } + + store = createSessionStore(root); + try { + const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + retireDelegation: async () => assert.fail('a reused action identity must not retire work'), + }); + const crossed = await restarted.handlers['workhub.coordination.act'](stopLogin, CONTEXT); + assert.equal(crossed.ok, false); + if (!crossed.ok) assert.equal(crossed.error.code, 'operation_conflict'); + assert.ok(loginDelegationId); + assert.equal(await store.readWorkHubStopRequest(loginDelegationId), undefined); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('refuses to merge a Turn identity shared across answer and record', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-turn-identity-')); const store = createSessionStore(root); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 7a0b08f613..26607f6719 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1365,11 +1365,11 @@ export async function createExecutionRuntimeHostComposition( ? 'not_retired' : 'retired'; }, - retireDelegation: async (assignment, cancellationClaimId) => { + retireDelegation: async (assignment, retirement) => { const disposition = await messages.cancelMessageIfPending( assignment.targetSessionId, assignment.targetMessageId, - cancellationClaimId, + retirement.cancellationClaimId, ); if (disposition.kind === 'recovering') { return { outcome: 'recovering' as const }; @@ -1384,15 +1384,14 @@ export async function createExecutionRuntimeHostComposition( return { outcome: 'not_owned' as const, targetTurnId: disposition.turnId }; } if (disposition.kind === 'owned_root') { - return stopOwnedWorkHubRoot( - coordinator, - { - sessionId: assignment.targetSessionId, - turnId: disposition.turnId, - runId: disposition.runId, - }, - cancellationClaimId, - ); + const identity = { + sessionId: assignment.targetSessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }; + return retirement.cause === 'direct_stop' + ? stopOwnedWorkHubRoot(coordinator, identity, retirement.cancellationClaimId) + : stopReplacedWorkHubRoot(coordinator, identity); } disposition satisfies never; throw new Error('Unhandled WorkHub Message retirement disposition'); @@ -1996,6 +1995,11 @@ export async function createExecutionRuntimeHostComposition( } } +/** + * Confirmed direct stop. The action-derived abort source is written onto the + * exact root Turn so a retry after a crash can tell WorkHub's own delivery + * apart from an earlier or concurrent manual Stop. + */ export async function stopOwnedWorkHubRoot( coordinator: Pick, identity: { readonly sessionId: string; readonly turnId: string; readonly runId: string }, @@ -2004,12 +2008,7 @@ export async function stopOwnedWorkHubRoot( readonly outcome: 'stop_delivered' | 'already_terminal'; readonly targetTurnId: string; }> { - const rootState = coordinator.readRootState(identity.sessionId); - if ( - rootState.kind === 'active' && - rootState.turnId === identity.turnId && - rootState.runId === identity.runId - ) { + if (isActiveWorkHubRoot(coordinator, identity)) { await coordinator.stopRoot(identity, { source: 'workhub_direct_stop', workHubActionId: actionId, @@ -2022,6 +2021,39 @@ export async function stopOwnedWorkHubRoot( : { outcome: 'already_terminal', targetTurnId: identity.turnId }; } +/** + * Route correction retiring the root it is replacing. It carries its own + * cancellation claim, but it is not a direct stop: recording direct-stop + * provenance here would let replay mistake a correction for one, so the + * retirement keeps the neutral Stop source ordinary supersession has always + * used. + */ +export async function stopReplacedWorkHubRoot( + coordinator: Pick, + identity: { readonly sessionId: string; readonly turnId: string; readonly runId: string }, +): Promise<{ + readonly outcome: 'stop_delivered' | 'already_terminal'; + readonly targetTurnId: string; +}> { + if (!isActiveWorkHubRoot(coordinator, identity)) { + return { outcome: 'already_terminal', targetTurnId: identity.turnId }; + } + await coordinator.stopRoot(identity); + return { outcome: 'stop_delivered', targetTurnId: identity.turnId }; +} + +function isActiveWorkHubRoot( + coordinator: Pick, + identity: { readonly sessionId: string; readonly turnId: string; readonly runId: string }, +): boolean { + const rootState = coordinator.readRootState(identity.sessionId); + return ( + rootState.kind === 'active' && + rootState.turnId === identity.turnId && + rootState.runId === identity.runId + ); +} + function sessionExecutionConnectionRef( header: Pick, ): ExecutionConnectionRef { diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 0827a4cfbc..0d5a6eec87 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -493,15 +493,20 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { * Cancels exactly one durable pending Message, or returns the Turn that has * already consumed it. This is the target Session's ordinary Message * authority; WorkHub never edits the queue or admission tables directly. + * + * The claim identity is required: the cancellation tombstone it writes is the + * only proof that distinguishes this caller's own cancellation from one that + * had already happened, which is what makes a crash between cancelling and + * recording the outcome recoverable. */ cancelMessageIfPending( sessionId: string, messageId: string, - cancellationClaimId?: string, + cancellationClaimId: string, ): Promise { return this.#sessionAdmission.run(sessionId, async () => { const disposition = await this.#resolveMessageExecution(sessionId, messageId); - if (disposition.kind === 'cancelled' && cancellationClaimId) { + if (disposition.kind === 'cancelled') { const outcome = await this.#admissions.claimMessageAdmissionCancellation( sessionId, messageId, @@ -528,16 +533,11 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return { kind: 'recovering' }; } - const claimOutcome = cancellationClaimId - ? await this.#admissions.claimMessageAdmissionCancellation( - sessionId, - messageId, - cancellationClaimId, - ) - : undefined; - if (!cancellationClaimId) { - await this.#admissions.cancelMessageAdmissions(sessionId, [messageId]); - } + const claimOutcome = await this.#admissions.claimMessageAdmissionCancellation( + sessionId, + messageId, + cancellationClaimId, + ); if (state && steeringIndex >= 0) { const [entry] = state.steering.splice(steeringIndex, 1); if (entry) this.#releaseEntry(entry); 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 918e3a4c66..5dd1cc7bf6 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -21,6 +21,9 @@ import { createHash } from 'node:crypto'; import type { SessionHeader, SessionStatus, + WorkHubActionClaim, + WorkHubActionClaimOutcome, + WorkHubActionOperation, WorkHubDelegationAssignedMessage, WorkHubDelegationCreateSpec, WorkHubDelegationDisposition, @@ -73,6 +76,18 @@ export type WorkHubActionGateSession = Pick< export interface WorkHubActionGateEffects { listSessions(): Promise; + /** + * Durably binds this action identity to one exact operation before any + * effect. Every other WorkHub record is keyed by the delegation or the + * assignment it describes, so this is the only owner that can reject an + * action id reused across delegations or across dispositions. + */ + claimAction(claim: WorkHubActionClaim): Promise; + /** + * Durable lifetime proof for a delegation target that is no longer readable. + * `removed` is a tombstone; `absent` is an identity that never existed here. + */ + probeTargetRemoval(sessionId: string): Promise<'present' | 'removed' | 'absent'>; readAssignment(actionId: string): Promise; listActiveAssignments(): Promise; readReplacement( @@ -114,10 +129,22 @@ export interface WorkHubActionGateEffects { ): Promise<'not_retired' | 'retired' | 'recovering'>; retireDelegation( assignment: WorkHubDelegationAssignedMessage, - cancellationClaimId: string, + retirement: WorkHubDelegationRetirementClaim, ): Promise; } +/** + * Cancellation claim identity and retirement cause are separate concerns. + * + * Both a direct stop and a route correction retire a delegation and both need a + * crash-safe pending-cancellation claim, but only a confirmed direct stop may + * record direct-stop provenance on the target Turn. + */ +export interface WorkHubDelegationRetirementClaim { + readonly cancellationClaimId: string; + readonly cause: 'direct_stop' | 'replacement'; +} + export interface WorkHubRetirementResult { readonly outcome: WorkHubDelegationStopOutcome | 'recovering'; readonly targetTurnId?: string; @@ -261,8 +288,9 @@ export class WorkHubCoordinationActionGate { const action = { requestFingerprint, result }; this.#actions.set(input.actionId, action); // Successful actions remain a Host-lifetime fast path. Rejections release - // the slot so a pre-assignment admission can retry; once assigned, SQLite - // independently owns the durable action identity. + // the slot so a pre-assignment admission can retry; the durable action + // claim, not this map, is what owns the identity across that retry and + // across restarts. void result.catch(() => { if (this.#actions.get(input.actionId) === action) { this.#actions.delete(input.actionId); @@ -307,11 +335,13 @@ export class WorkHubCoordinationActionGate { } if (proposal.disposition === 'answer_here') { const turnId = coordinationTurnId(input.actionId, 'answer'); + await this.#claimAction(input.actionId, 'answer_here', fingerprint, turnId); 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.#claimAction(input.actionId, 'clarify', fingerprint, turnId); await this.#effects.clarify({ turnId, userText: input.userText, @@ -333,6 +363,8 @@ export class WorkHubCoordinationActionGate { 'WorkHub can stop only the named durable delegation it owns', ); } + const stopFingerprint = stopActionFingerprint(input, source); + await this.#claimAction(input.actionId, 'stop', stopFingerprint, source.delegationId); const existing = await this.#effects.readStopRequest(source.delegationId); if (existing) { if (!workHubStopTargetsSession(requestIntent, existing.targetSessionName)) { @@ -341,8 +373,15 @@ export class WorkHubCoordinationActionGate { 'WorkHub can stop only the named durable delegation it owns', ); } - const fingerprint = stopActionFingerprint(input, source); - assertStopReplay(existing, input, source, fingerprint); + if (existing.actionId !== input.actionId) { + // `not_owned` deliberately leaves the delegation active, so the user + // can and will try again with a fresh request. That later attempt has + // its own identity and must converge on the immutable non-destructive + // outcome instead of colliding with the first attempt's stop claim. + const resolved = await this.#effects.readStopResolution(source.delegationId); + if (resolved?.outcome === 'not_owned') return stopResult(resolved); + } + assertStopReplay(existing, input, source, stopFingerprint); return this.#stop(existing, source); } const [sessions, activeAssignments] = await Promise.all([ @@ -381,7 +420,6 @@ export class WorkHubCoordinationActionGate { 'WorkHub can stop only the named durable delegation it owns', ); } - const fingerprint = stopActionFingerprint(input, source); if (await this.#effects.readSupersession(source.delegationId)) { throw new WorkHubActionGateFailure( 'action_conflict', @@ -396,7 +434,7 @@ export class WorkHubCoordinationActionGate { } const requested = await this.#effects.prepareStop({ actionId: input.actionId, - actionFingerprint: fingerprint, + actionFingerprint: stopFingerprint, stopsActionId: source.actionId, stopsDelegationId: source.delegationId, targetSessionId: source.targetSessionId, @@ -468,6 +506,12 @@ export class WorkHubCoordinationActionGate { return this.#replace(prepared, context); } const replacement = await this.#replacementAssignment(input, replaced); + await this.#claimAction( + replacement.actionId, + 'replace', + replacement.actionFingerprint, + replacement.replacesDelegationId, + ); const intent = await this.#effects.prepareReplacement(replacement); return this.#replace(intent, context); } @@ -502,21 +546,63 @@ export class WorkHubCoordinationActionGate { ): Promise { const resolved = await this.#effects.readStopResolution(source.delegationId); if (resolved) return stopResultFromRecord(resolved, request); - const retirement = await this.#effects.retireDelegation(source, request.actionId); - if (retirement.outcome === 'recovering') { + const retirement = await this.#effects.retireDelegation(source, { + cancellationClaimId: request.actionId, + cause: 'direct_stop', + }); + const outcome = + retirement.outcome === 'recovering' + ? await this.#removedTargetOutcome(source) + : retirement.outcome; + if (!outcome) { throw new WorkHubActionEffectFailure( 'operation_unavailable', 'WorkHub is still resolving the delegated Message owner', ); } + const targetTurnId = retirement.outcome === outcome ? retirement.targetTurnId : undefined; const resolution = await this.#effects.resolveStop({ request, - outcome: retirement.outcome, - ...(retirement.targetTurnId ? { targetTurnId: retirement.targetTurnId } : {}), + outcome, + ...(targetTurnId ? { targetTurnId } : {}), }); return stopResultFromRecord(resolution, request); } + /** + * A removed target Session takes its Message-ownership proof with it, so a + * committed stop claim would otherwise recover forever. The removal tombstone + * outlives that Session and proves the delegated work ended; a target that is + * merely unreadable, or an identity that never existed here, stays unresolved + * rather than being reported as stopped. + */ + async #removedTargetOutcome( + source: WorkHubDelegationAssignedMessage, + ): Promise<'already_terminal' | undefined> { + const lifetime = await this.#effects.probeTargetRemoval(source.targetSessionId); + return lifetime === 'removed' ? 'already_terminal' : undefined; + } + + async #claimAction( + actionId: string, + operation: WorkHubActionOperation, + actionFingerprint: `sha256:${string}`, + subject: string, + ): Promise { + const outcome = await this.#effects.claimAction({ + actionId, + operation, + actionFingerprint, + subject, + }); + if (outcome === 'conflict') { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub action identity already owns a different operation', + ); + } + } + async #replacementAssignment( input: WorkHubCoordinationActInput, replaced: WorkHubDelegationAssignedMessage, @@ -649,7 +735,10 @@ export class WorkHubCoordinationActionGate { await this.#replacementTarget(replacement); } if (retirement === 'not_retired') { - const result = await this.#effects.retireDelegation(source, replacement.actionId); + const result = await this.#effects.retireDelegation(source, { + cancellationClaimId: replacement.actionId, + cause: 'replacement', + }); if (result.outcome === 'recovering') { throw new WorkHubActionEffectFailure( 'operation_unavailable', @@ -751,6 +840,12 @@ export class WorkHubCoordinationActionGate { assignment: WorkHubDelegationAssignmentInput, context: ConnectionContext, ): Promise { + await this.#claimAction( + assignment.actionId, + assignment.replacesDelegationId ? 'replace' : assignment.disposition, + assignment.actionFingerprint, + assignment.replacesDelegationId ?? assignment.targetSessionId, + ); const admitted = await this.#effects.assign(assignment, context); if (assignment.replacesDelegationId) { return { @@ -990,6 +1085,17 @@ function assertStopReplay( } } +function stopResult( + resolution: WorkHubDelegationStopResolvedMessage, +): WorkHubCoordinationActResult { + return { + disposition: 'stop_work', + outcome: resolution.outcome, + targetSessionId: resolution.targetSessionId, + ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), + }; +} + function stopResultFromRecord( resolution: WorkHubDelegationStopResolvedMessage, request: WorkHubDelegationStopRequestedMessage, @@ -1006,12 +1112,7 @@ function stopResultFromRecord( 'WorkHub stop has a different durable resolution', ); } - return { - disposition: 'stop_work', - outcome: resolution.outcome, - targetSessionId: resolution.targetSessionId, - ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), - }; + return stopResult(resolution); } function assignmentInputFromRecord( diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index a3dcc326b9..38d02eb2bc 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -92,6 +92,8 @@ type CoordinationStores = Pick< | 'appendMessages' | 'createStableSession' | 'listHeaders' + | 'claimWorkHubAction' + | 'probeSessionRemoval' | 'probeStableSessionCreate' | 'readHeaderSnapshot' | 'readMessagesSnapshot' @@ -156,6 +158,15 @@ export class HostWorkHubCoordinationCoordinator { this.#requestDrain = options.requestDrain; this.#actionGate = new WorkHubCoordinationActionGate({ listSessions: () => this.#stores.listHeaders(), + // The global action owner is committed under the same Coordination + // admission that serializes every durable Coordination fact, so a + // concurrent action cannot slip between the claim and the fact it owns. + claimAction: (claim) => + this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, () => + this.#stores.claimWorkHubAction(claim), + ), + probeTargetRemoval: async (sessionId) => + (await this.#stores.probeSessionRemoval(sessionId)).kind, readAssignment: (actionId) => this.#stores.readWorkHubAssignment(actionId), listActiveAssignments: () => this.#listActiveAssignments(), readReplacement: (delegationId) => this.#stores.readWorkHubReplacement(delegationId), diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index a027cc3773..906c5d6ace 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -1504,6 +1504,51 @@ describe('SqliteSessionMetadataStore', () => { } }); + test('a WorkHub action identity owns one operation across store restarts', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-action-claim-')); + const path = join(root, 'state.sqlite'); + const stopClaim = { + actionId: 'stop-action', + operation: 'stop' as const, + actionFingerprint: `sha256:${'a'.repeat(64)}` as const, + subject: 'whd_payments', + }; + let store = createSqliteSessionMetadataStore(path); + try { + assert.equal(await store.claimWorkHubAction(stopClaim), 'claimed'); + assert.equal(await store.claimWorkHubAction(stopClaim), 'same_claim'); + } finally { + store.close(); + } + + store = createSqliteSessionMetadataStore(path); + try { + assert.deepEqual(await store.readWorkHubActionClaim('stop-action'), stopClaim); + assert.equal(await store.claimWorkHubAction(stopClaim), 'same_claim'); + // A second delegation, a second disposition, and a changed payload are + // each a different operation for the same identity. + assert.equal( + await store.claimWorkHubAction({ ...stopClaim, subject: 'whd_login' }), + 'conflict', + ); + assert.equal( + await store.claimWorkHubAction({ ...stopClaim, operation: 'delegate_existing' }), + 'conflict', + ); + assert.equal( + await store.claimWorkHubAction({ + ...stopClaim, + actionFingerprint: `sha256:${'b'.repeat(64)}`, + }), + 'conflict', + ); + assert.equal(await store.readWorkHubActionClaim('unclaimed-action'), undefined); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + test('cancellation tombstones retain the durable claim that created them', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-message-cancellation-claim-')); const path = join(root, 'state.sqlite'); diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 26692bdef5..ed9714e275 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -373,6 +373,9 @@ async function createExecutionStoresForWrite sessionStore.readWorkHubStopRequest(delegationId)), readWorkHubStopResolution: (delegationId) => run(() => sessionStore.readWorkHubStopResolution(delegationId)), + claimWorkHubAction: (claim) => run(() => sessionStore.claimWorkHubAction(claim)), + readWorkHubActionClaim: (actionId) => + run(() => sessionStore.readWorkHubActionClaim(actionId)), discardStableConversationCopy: (sessionId, requestFingerprint) => run(() => sessionStore.discardStableConversationCopy(sessionId, requestFingerprint)), createSubagent: (input, initialBoundary) => diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index e8845ffe3a..c745b72675 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -86,6 +86,8 @@ import { type WorkHubDelegationAssignedMessage, type WorkHubDelegationReplacementAbortedMessage, type WorkHubDelegationReplacementRequestedMessage, + type WorkHubActionClaim, + type WorkHubActionClaimOutcome, type WorkHubDelegationStopRequestedMessage, type WorkHubDelegationStopResolvedMessage, type WorkHubDelegationSupersededMessage, @@ -438,6 +440,13 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto readWorkHubStopResolution( delegationId: string, ): Promise; + /** + * Durably binds one action identity to one exact WorkHub operation before its + * effect. Survives removal of the target Session so a committed destructive + * claim can still converge afterwards. + */ + claimWorkHubAction(claim: WorkHubActionClaim): Promise; + readWorkHubActionClaim(actionId: string): Promise; discardStableConversationCopy(sessionId: string, requestFingerprint: string): Promise; listCatalogPage( filter: SessionListFilter | undefined, @@ -747,6 +756,16 @@ class SqliteSessionStore implements SessionAuthorityStore { : undefined; } + async claimWorkHubAction(claim: WorkHubActionClaim): Promise { + await this.ensureReady(); + return this.metadata.claimWorkHubAction(claim); + } + + async readWorkHubActionClaim(actionId: string): Promise { + await this.ensureReady(); + return this.metadata.readWorkHubActionClaim(actionId); + } + private async readWorkHubCoordinationMessage( messageId: string, ): Promise { diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 57aba9a43d..525934c7de 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 37; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 38; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -1249,6 +1249,25 @@ const MIGRATIONS: ReadonlyMap = new Map([ ADD COLUMN cancellation_claim_id TEXT; `, ], + [ + 38, + ` + -- The one global owner of a WorkHub action identity. It deliberately has no + -- Session foreign key: the claim must outlive removal of the target Session + -- so a committed destructive claim still converges after that removal. + CREATE TABLE IF NOT EXISTS workhub_action_claims ( + action_id TEXT PRIMARY KEY, + operation TEXT NOT NULL CHECK ( + operation IN ( + 'answer_here', 'clarify', 'delegate_existing', 'create_new', 'replace', 'stop' + ) + ), + action_fingerprint TEXT NOT NULL, + subject TEXT NOT NULL, + claimed_at INTEGER NOT NULL CHECK (claimed_at >= 0) + ); + `, + ], ]); if (MIGRATIONS.size !== SQLITE_SESSION_METADATA_SCHEMA_VERSION) { diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index f544fffada..c1a13de8d5 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -92,6 +92,9 @@ import { type SessionHeaderPatch, type StoredMessage, type SubagentSessionParent, + type WorkHubActionClaim, + type WorkHubActionClaimOutcome, + type WorkHubActionOperation, type WorkHubDelegationAssignedMessage, type WorkHubDelegationSupersededMessage, WORKHUB_COORDINATION_SESSION_ID, @@ -1973,6 +1976,75 @@ export class SqliteSessionMetadataStore { }); } + /** + * Binds one WorkHub action identity to one exact operation, for good. + * + * Every other durable WorkHub record is keyed by what it is about, so none of + * them can see an action id that moved to a second delegation or a second + * disposition. This row is the global owner that rejects both, and it is + * written before the action's effect so a rejected or recovering attempt can + * never leak its identity into a different operation. + */ + async claimWorkHubAction(claim: WorkHubActionClaim): Promise { + this.assertOpen(); + assertSafeSessionId(claim.actionId); + assertSafeSessionId(claim.subject); + if (!/^sha256:[a-f0-9]{64}$/u.test(claim.actionFingerprint)) { + throw new SessionMetadataConflictError('Invalid WorkHub action fingerprint'); + } + return this.transaction(() => { + const existing = this.readWorkHubActionClaimSync(claim.actionId); + if (existing) { + return existing.operation === claim.operation && + existing.actionFingerprint === claim.actionFingerprint && + existing.subject === claim.subject + ? 'same_claim' + : 'conflict'; + } + this.db + .prepare( + ` + INSERT INTO workhub_action_claims( + action_id, operation, action_fingerprint, subject, claimed_at + ) VALUES (?, ?, ?, ?, ?) + `, + ) + .run(claim.actionId, claim.operation, claim.actionFingerprint, claim.subject, this.now()); + return 'claimed'; + }); + } + + async readWorkHubActionClaim(actionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(actionId); + return this.readTransaction(() => this.readWorkHubActionClaimSync(actionId)); + } + + private readWorkHubActionClaimSync(actionId: string): WorkHubActionClaim | undefined { + const row = this.db + .prepare( + 'SELECT operation, action_fingerprint, subject FROM workhub_action_claims WHERE action_id = ?', + ) + .get(actionId) as + | { operation?: unknown; action_fingerprint?: unknown; subject?: unknown } + | undefined; + if (!row) return undefined; + if ( + !isWorkHubActionOperation(row.operation) || + typeof row.action_fingerprint !== 'string' || + !/^sha256:[a-f0-9]{64}$/u.test(row.action_fingerprint) || + typeof row.subject !== 'string' + ) { + throw new SessionMetadataConflictError('Invalid WorkHub action claim row'); + } + return { + actionId, + operation: row.operation, + actionFingerprint: row.action_fingerprint as `sha256:${string}`, + subject: row.subject, + }; + } + async claimMessageAdmissionCancellation( sessionId: string, messageId: string, @@ -6980,6 +7052,17 @@ function readStoredMessageRecordJson( return recordJson; } +function isWorkHubActionOperation(value: unknown): value is WorkHubActionOperation { + return ( + value === 'answer_here' || + value === 'clarify' || + value === 'delegate_existing' || + value === 'create_new' || + value === 'replace' || + value === 'stop' + ); +} + function sameWorkHubAssignmentRequest( existing: WorkHubDelegationAssignedMessage, requested: WorkHubDelegationAssignedMessage, From 2ec065ca7569acb2afd14a2507304372eb108c38 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 1 Sep 2026 21:17:56 +0800 Subject: [PATCH 04/19] fix(workhub): claim prepared replacements before effects --- .../workhub-coordination-action-gate.test.ts | 60 +++++++++++++++++++ .../workhub-coordination-action-gate.ts | 6 ++ 2 files changed, 66 insertions(+) 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 4a06eb215a..7478a1ad50 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 @@ -1972,6 +1972,66 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.supersessions.has('delegation-source-action'), true); }); + test('rejects a conflicting post-migration claim before replaying a prepared replacement', async () => { + const effects = fakeEffects([session('source'), session('destination')]); + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'c'.repeat(64)}`, + targetSessionId: 'source', + targetSessionName: 'source', + disposition: 'delegate_existing', + userText: 'Wrong target', + }, + 'source-turn', + ), + ); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const input = { + actionId: 'migrated-prepared-replacement', + userText: 'No, send this to destination', + candidateSetId: snapshot.candidateSetId, + confirmation: { kind: 'user_correction' as const }, + proposal: { + disposition: 'replace' as const, + replacesActionId: 'source-action', + target: { + disposition: 'delegate_existing' as const, + candidateRef: snapshot.candidates.find( + (candidate) => candidate.sessionId === 'destination', + )!.candidateRef, + }, + }, + }; + const prepareReplacement = effects.prepareReplacement; + effects.prepareReplacement = async (replacement) => { + await prepareReplacement(replacement); + throw new WorkHubActionEffectFailure('internal_failure', 'simulated pre-retirement crash'); + }; + + await assert.rejects(gate.act(input, CONTEXT)); + assert.equal(effects.replacements.has('delegation-source-action'), true); + assert.equal(effects.retirements.length, 0); + + effects.actionClaims.clear(); + effects.actionClaims.set(input.actionId, { + actionId: input.actionId, + operation: 'answer_here', + actionFingerprint: `sha256:${'d'.repeat(64)}`, + subject: 'coordination-session', + }); + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.retirements.length, 0); + assert.equal(effects.assignments.length, 0); + }); + test('refreshes replacement target display identity after retiring the source', async () => { const effects = fakeEffects([ session('source'), 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 5dd1cc7bf6..4e57184d28 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -503,6 +503,12 @@ export class WorkHubCoordinationActionGate { ); } await this.#assertReplacementReplayTarget(input, prepared.targetSessionId); + await this.#claimAction( + prepared.actionId, + 'replace', + prepared.actionFingerprint, + prepared.replacesDelegationId, + ); return this.#replace(prepared, context); } const replacement = await this.#replacementAssignment(input, replaced); From d668357b3799a4dd5e079c25524887d7053d3beb Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 2 Sep 2026 15:14:35 +0800 Subject: [PATCH 05/19] docs(workhub): record shared session resolution design --- .../workhub-action-resolution-design.md | 312 ++++++++++++++++++ docs/workhub-domain-language.md | 37 ++- 2 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 docs/architecture/workhub-action-resolution-design.md diff --git a/docs/architecture/workhub-action-resolution-design.md b/docs/architecture/workhub-action-resolution-design.md new file mode 100644 index 0000000000..6571deaa0b --- /dev/null +++ b/docs/architecture/workhub-action-resolution-design.md @@ -0,0 +1,312 @@ + + +# WorkHub action and Session resolution design + +- Status: Proposed +- Date: 2026-09-02 +- Scope: WorkHub routing, Session resolution, and action admission +- Architecture source: [Discussion #3286](https://github.com/apache/maka/discussions/3286) +- Delivery tracker: [Issue #3492](https://github.com/apache/maka/issues/3492) +- Review source: [PR #4439 architecture discussion](https://github.com/apache/maka/pull/4439#issuecomment-5496214312) + +## Purpose + +This document preserves the path from the first WorkHub experiment to the +current production slices, records why the original R2.4/R3 experiment needs a +sharper boundary, and defines the work required to reach that boundary. + +The central change is: + +> WorkHub first identifies the kind of action the user is requesting, then uses +> one shared Session Resolver to recall existing work, and finally lets an +> action-specific deterministic policy decide whether that resolution is +> sufficient to propose an action. The Action Gate remains the authority that +> revalidates and admits the proposal before any write or effect. + +## History and original experiment branches + +The first feasibility work intentionally explored the whole user experience +before the production authority boundaries were settled. + +| Branch or PR | Purpose | Outcome | +| --- | --- | --- | +| `codex/unified-session-experiment` | End-to-end feasibility prototype for one conversational entry point, work cards, routing, clarification, creation, and coordination | Demonstrated the user model; not a production authority design | +| `codex/workhub-mainline` / #3426 | First integrated WorkHub implementation | Closed after the work was decomposed | +| `codex/workhub-session-router` / #3497 | Conservative R2.3 Session router | Merged | +| `codex/workhub-rebuildable-projection` / #3648 | Rebuild WorkHub from ordinary Session transcripts | Merged | +| `codex/workhub-context-continuity` / #3674 | R2.4 deterministic context continuity and correction behavior | Merged as the deterministic baseline | + +Four later local branches implemented the original post-Slice-5 plan ahead of +review. They are useful prototypes, but they predate the shared Resolver design +and are not suitable for direct publication without rebasing and redesign: + +| Branch | Original purpose | +| --- | --- | +| `feat/workhub-routing-strategies` | Versioned R2.4, R3-A, and R3-B strategy interface | +| `test/workhub-routing-evaluation` | Common routing evaluation harness | +| `feat/workhub-routing-rollout` | Feature-flagged strategy rollout and telemetry | +| `feat/workhub-anchor-rail` | Filtered, rebuildable WorkHub Anchor Rail | + +## Original routing plan + +The original plan compared three complete routing strategies behind the same +Action Gate: + +| Strategy | Disposition decision | Target selection | +| --- | --- | --- | +| R2.4 baseline | Deterministic regex and heuristics | Deterministic exact-name, lexical/core-entity, focus, and recency rules | +| R3-A model-direct | Model | Model selects one opaque reference from bounded valid candidates | +| R3-B model-gated-R2.4 | Model | R2.4 selects the target only after `delegate_existing` | + +The evaluation plan called for a fixed Session snapshot, common model and +reasoning settings, bounded candidate summaries, the same Coordination +transcript prefix and Runtime facts, repeated model-backed runs, separate +disposition and target accuracy, safety metrics, latency, tokens, and cost. + +This was a valid experiment plan, but its strategy boundary was too coarse. +R2.4 combined action recognition, existing-Session retrieval, target selection, +creation, clarification, and final disposition in one policy. R3-A similarly +asked one model decision to select both behavior and target. Those shapes make +it difficult to improve Session recall without changing action semantics, or to +tell whether an error came from intent recognition, retrieval, or policy. + +## Production work already delivered + +The production implementation kept the important authority boundaries while +delivering the tracker in reviewable slices: + +| Slice | Delivery | Implemented boundary | +| --- | --- | --- | +| 1 | #3742 | Per-Runtime-Host Coordination Session ADR and domain language | +| 2 | #3764 | Stable Coordination Session lifecycle, recovery, Host scope, and self-route exclusion | +| 3 | #3798 | Persistent WorkHub conversation and `answer_here` | +| 4 | #3818 | Typed non-destructive coordination protocol and deterministic Action Gate | +| 5A | #3935 | Durable delegation linkage and atomic target admission | +| 5B | #4115 | Rebuildable delegated execution-status projection | +| 5C | #4242 | Linked correction, exact Message ownership, replacement arbitration, and replay | +| 5D | #4439 | Direct stop claims, pending cancellation, owning-root Stop, and stop/replacement arbitration; under review | + +Projection checkpoint stabilization in #4210 supports this path without adding +a second lifecycle authority. + +These pieces remain valid under the new design. In particular, the Coordination +Session, target Session authority, durable delegation identities, and Action +Gate do not depend on one natural-language resolver. + +## Problem exposed by direct stop + +The first direct-stop path recognizes stop-specific text and performs exact +Session display-name matching before it enters the durable action protocol. +Although intentionally conservative, leaving that implementation embedded in +the stop path would establish a second target resolver by construction. Future +actions such as inspect, continue, pause, and resume would then tend to acquire +their own parsers and target rules. + +Display names and raw messages are useful retrieval evidence. They are not +stable execution authority. A destructive action must ultimately refer to an +opaque Session/delegation identity and be revalidated against current Runtime +facts by the Action Gate. + +## Target architecture + +```text +user input + -> Action Intent + -> shared Session Resolver, when the action may refer to existing work + -> per-action policy + -> typed Action Proposal + -> deterministic Action Gate + -> durable persistence and owning-Host execution +``` + +### Action Intent + +Action Intent identifies what the user is trying to do, for example discuss, +delegate, inspect, continue, stop, or resume. It carries trusted evidence from +the user input but does not select a Session and does not authorize an effect. + +The first implementation may use deterministic parsing. A later classifier may +use a model, but its output remains advisory and bounded. + +### Session Resolver + +The shared Session Resolver answers only which visible existing Sessions are +relevant to the user's reference. Its result is one of: + +```text +ranked existing Session candidates +none +ambiguous candidates +``` + +It does not return `create_new`, decide the final action, or grant authority. +Resolver inputs may include structured Session references, permitted Session +metadata, current and previous focus, recency, active/running state, active +delegation presence, and permitted raw-message evidence. Resolver output uses +opaque Runtime-issued candidate references rather than model-invented Session +identities. + +The initial implementation can preserve exact-name behavior behind a shared +`SessionResolver` contract. A later deterministic ranked resolver can add +lexical retrieval such as BM25. Any index is a rebuildable projection: hidden, +archived, or logically deleted Sessions are included or excluded by explicit +visibility policy, and the index never becomes Session lifecycle authority. + +Raw cross-Session messages may support retrieval, but are not injected into the +target execution context merely because they matched. + +### Action Policy + +An action-specific deterministic policy combines Action Intent, Session +resolution, and current product rules. It decides whether to: + +- propose an action against an existing Session; +- explicitly create new work; +- ask the user to clarify; +- answer in the Coordination Session; or +- reject the request safely. + +`create_new` belongs here, not in Session retrieval. A policy may skip existing +Session resolution when the trusted user request unambiguously requires a new +Work and existing work is irrelevant. Otherwise, it can resolve first and allow +creation only when explicit creation evidence and the absence of a suitable +existing Session satisfy that action's rules. When WorkHub creates a Work, the +result must say so explicitly to the user. + +Different actions have different sufficiency rules. Stop may require one unique +active WorkHub delegation; inspect may allow several ranked read-only results; +delegate may clarify, reuse an existing Session, or create a new one; resume may +require a resumable lifecycle state. + +### Typed Action Proposal + +The policy produces a closed typed proposal containing stable target identities +and expected-state preconditions. It is not yet permission to execute. Natural +language, display names, relevance scores, and model explanations are evidence, +not durable identifiers. + +### Action Gate and execution + +The existing Action Gate remains the final deterministic admission boundary. It +revalidates current Host scope, target existence, visibility and lifecycle, +ownership, active delegation identity, idempotency, confirmation, tools, and +permissions immediately before persistence or execution. + +The owning Host then persists the admitted action and executes it through the +authoritative target Session. Retrieval indexes and WorkHub projections remain +rebuildable and non-authoritative. + +## Reframing the R-series experiment + +The original R2.4/R3-A/R3-B work should be retained as experimental hypotheses, +but expressed as replaceable components rather than complete routers: + +| Arm | Action Intent | Session Resolver | Action Policy and Gate | +| --- | --- | --- | --- | +| Deterministic baseline | Deterministic parser | Exact-name plus deterministic lexical/focus rules | Deterministic | +| Model-intent + deterministic resolution | Bounded model classifier | Deterministic ranked resolver | Deterministic | +| Model-intent + model-ranked resolution | Bounded model classifier | Model ranks opaque bounded candidates | Deterministic | + +This preserves the safety comparison while exposing where each error occurs. +Evaluation must separately report intent classification, candidate recall, +ranking/target accuracy, policy outcome, gate rejection, and downstream target +execution. + +## Required work + +### 1. Establish the port in Slice 5D + +- Introduce the shared `SessionResolver` contract. +- Put the current exact-name behavior behind a temporary deterministic + implementation. +- Make direct stop consume the resolved opaque Session/delegation identity. +- Keep durable stop execution, replay, ownership, and arbitration unchanged. +- Avoid documenting exact-name stop grammar as the long-term product contract. +- Record follow-up removal criteria for the temporary resolver. + +The temporary resolver can be removed when all target-bearing WorkHub actions use +the shared contract, the replacement resolver passes the common evaluation, and +the rollout retains a tested rollback path. + +### 2. Build the deterministic shared Resolver baseline + +- Define visibility and candidate-bounding policy. +- Combine structured references, exact names, focus, recency, lifecycle, active + delegation, and deterministic lexical evidence. +- Return ranked candidates with typed evidence and explicit none/ambiguity. +- Route continue, inspect, stop, resume, and delegation through the same port. + +### 3. Add rebuildable lexical retrieval + +- Index permitted Session metadata and bounded raw-message chunks. +- Start with a deterministic BM25 shadow implementation. +- Aggregate message hits by Session identity, then rank Sessions using a small, + observable feature set. +- Measure recall and ranking without granting actions or injecting matched text + into execution context. + +### 4. Rebuild the experiment harness + +- Rebase useful code from `feat/workhub-routing-strategies` and + `test/workhub-routing-evaluation` onto the component boundaries above. +- Hold the Session snapshot, transcript, Runtime facts, model configuration, and + inputs constant across arms. +- Report each pipeline stage separately and repeat model-backed runs. +- Preserve adversarial tests for prompt injection, stale candidates, ambiguous + references, implicit creation, and destructive actions. + +### 5. Select and roll out a production composition + +- Select intent and resolver implementations from evidence. +- Adapt the useful flag, telemetry, and rollback ideas from + `feat/workhub-routing-rollout`. +- Shadow new resolution before it can propose effects. +- Keep the Action Gate and ordinary Session authority invariant across rollout. + +### 6. Complete projection enhancements independently + +- Rebase the useful filtered Anchor Rail work from `feat/workhub-anchor-rail`. +- Add Work filtering and generation-safe bounded refresh. +- Keep every projection non-authoritative and rebuildable. + +## Acceptance criteria + +- Stop, continue, inspect, resume, and delegation do not own separate natural- + language target resolvers. +- `create_new` is never emitted by Session retrieval. +- Every executable proposal contains opaque stable identities and expected-state + preconditions. +- The Action Gate revalidates all authority immediately before effects. +- Resolver replacement requires no change to durable stop/delegation protocols. +- Evaluation attributes failures to the correct pipeline stage. +- Creating a new Work is explicit in both trusted input evidence and user-visible + acknowledgement. +- Resolver indexes and UI projections can be discarded and rebuilt without + losing Session or coordination truth. + +## Deferred decisions + +- Whether Work remains 1:1 with Session, becomes 1:N, or gains an independent + durable identity. +- Cross-Runtime-Host coordination. +- The final retrieval algorithm and ranking weights. +- Large-scale semantic/vector retrieval beyond the deterministic baseline. +- Removing R2.4 compatibility behavior before evaluation and rollback criteria + are satisfied. diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md index 6ace0f7e32..8594b2985a 100644 --- a/docs/workhub-domain-language.md +++ b/docs/workhub-domain-language.md @@ -67,6 +67,26 @@ bounded, valid ordinary Session; `create_new` creates an ordinary Session before delegating and is visibly announced as new work; and `clarify` continues in the Coordination Session without guessing or creating. +**Action Intent**: A bounded interpretation of what the user is trying to do, +such as discuss, delegate, inspect, continue, stop, or resume. It carries trusted +user-input evidence but no selected Session and no execution authority. + +**Session Resolver**: The shared, replaceable capability that recalls and ranks +visible existing ordinary Sessions for a user reference. It may return ranked +candidates, no candidate, or ambiguity. It never returns `create_new`, chooses a +final coordination outcome, or grants execution authority. Exact-name matching is +only a temporary deterministic implementation; future ranked implementations use +the same contract. + +**Action Policy**: Deterministic, action-specific rules that combine Action Intent, +Session resolution, and current product constraints to propose an existing-target +action, explicit creation, clarification, local discussion, or safe rejection. +Creation is a policy decision rather than a retrieval result. + +**Action Proposal**: A closed typed request produced by an Action Policy. It uses +opaque stable target identities and expected-state preconditions, but remains +advisory until the Action Gate revalidates and admits it. + **delegation**: A bounded reference from a Coordination Turn to one target ordinary Session and Turn, including only its identity, disposition, and coordination-owned link status (`active`, `superseded`, `aborted`, or `stopped`). A link is `aborted` only when a @@ -105,10 +125,15 @@ the Coordination transcript records an auditable replacement-aborted terminal fact and removes the retired source from active linkage. Correction never replaces either Session's transcript authority. -**Direct stop**: A user's explicit, named imperative to retire one active durable -delegation, such as `Stop Payments` or `停止支付任务`. Pronouns, pause/wait language, -questions, advice, negation, malformed literals, and model-supplied Session, Turn, -Run, or Message identities grant no Stop authority. WorkHub first records +**Direct stop**: A user's explicit imperative to retire one active durable +delegation. The initial deterministic implementation accepts exact display-name +references behind the shared Session Resolver contract; exact-name syntax is not +the long-term product boundary. Pronouns, pause/wait language, questions, advice, +negation, unresolved or ambiguous targets, and model-supplied Session, Turn, Run, +or Message identities grant no Stop authority. A future ranked resolver may recall +a Session from other permitted evidence, but the Action Policy must still require a +sufficiently resolved active WorkHub delegation and the Action Gate must revalidate +its stable identity. WorkHub first records `delegation_stop_requested`, resolves the named source action to its durable delegation, and lets the target Session's Message authority observe one of four outcomes: `cancelled_pending`, `stop_delivered`, `already_terminal`, or `not_owned`. @@ -133,8 +158,8 @@ likewise writes the direct-stop action identity into the exact root Turn's durable abort source. A retry recognizes only that matching proof; an earlier or concurrent manual Stop remains `already_terminal`. Stop admission holds the Coordination Session and every currently active target Session lane -while it rechecks current names and active links; a concurrent rename or new -delegation therefore cannot invalidate the named-one-target proof before the +while it rechecks current target identities and active links; a concurrent rename +or new delegation therefore cannot invalidate the one-target proof before the request record commits. Removing the target Session destroys the Message proof a committed claim still needs; the removal tombstone outlives that Session and resolves the claim as `already_terminal`, while a target that is merely From 89cc3a11243efbc35f6410321aafd9f4e2b006da Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 2 Sep 2026 16:53:39 +0800 Subject: [PATCH 06/19] refactor(workhub): resolve stop targets through a shared Session Resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct stop owned its own target resolver: it recognized stop-specific text and then matched Session display names itself, ahead of the ordinary routing path. Every future control verb would have grown one the same way. Introduce the shared `SessionResolver` port. It answers one question — which visible existing Sessions a trusted user reference recalls — as ranked candidates, nothing, or ambiguity. Candidates are opaque Runtime-issued references bounded by the caller's visible set, so a resolver can neither invent an identity nor widen its own visibility. `create_new` is absent from the result type: creation is an Action Policy decision, never a retrieval result. Exact display-name matching becomes the first implementation behind that port rather than the stop path's own rule, and stop becomes an Action Policy: Action Intent supplies the reference, the Resolver recalls Sessions, and the policy alone decides destructive sufficiency. The stop decision now carries the resolved delegation identity, so the renderer no longer re-derives it from its own active-delegation bookkeeping. Exact-name syntax is not the long-term product boundary. It can be removed once every target-bearing WorkHub action resolves through this port, the replacement resolver passes the common routing evaluation, and its rollout retains a tested rollback path. Generated-by: Claude Opus --- .../workhub-session-resolver-port.test.ts | 164 ++++++++++++++++++ .../contracts/workhub-request-intent.ts | 6 +- .../src/renderer/workhub-controller.ts | 12 +- .../src/renderer/workhub-route-policy.ts | 90 +++++++--- packages/core/package.json | 1 + .../workhub-session-resolver.test.ts | 86 +++++++++ packages/core/src/workhub-creation-intent.ts | 23 ++- packages/core/src/workhub-session-resolver.ts | 113 ++++++++++++ 8 files changed, 463 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts create mode 100644 packages/core/src/__tests__/workhub-session-resolver.test.ts create mode 100644 packages/core/src/workhub-session-resolver.ts diff --git a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts new file mode 100644 index 0000000000..f0d479775d --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts @@ -0,0 +1,164 @@ +/* + * 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 test from 'node:test'; +import type { + WorkHubSessionResolution, + WorkHubSessionResolver, +} from '../../renderer/application/contracts/workhub-request-intent.js'; +import { + createWorkHubRoutePolicy, + type WorkHubStoppableSession, +} from '../../renderer/workhub-route-policy.js'; + +const stoppable = ( + sessionId: string, + sessionName: string, + activeActionIds: readonly string[], +): WorkHubStoppableSession => ({ + target: { sessionId }, + projectName: 'demo', + sessionName, + updatedAt: 1, + activeActionIds, +}); + +/** + * A stand-in for a later ranked resolver. It recalls by remembered description + * rather than display name, which is exactly the recall the exact-name baseline + * cannot do, and it answers in the same contract. + */ +const describedResolver = ( + descriptions: ReadonlyMap, +): WorkHubSessionResolver => ({ + resolve({ reference, sessions }): WorkHubSessionResolution { + const candidates = sessions + .filter((session) => descriptions.get(session.ref) === reference.text) + .map((session) => ({ ref: session.ref, evidence: 'exact_session_name' as const })); + const [first, ...rest] = candidates; + if (!first) return { kind: 'none' }; + if (rest.length > 0) return { kind: 'ambiguous', candidates }; + return { kind: 'ranked', candidates: [first] }; + }, +}); + +test('stop resolves through the shared port rather than a stop-specific grammar', () => { + const sessions = [ + stoppable('payments', 'Payments', ['action-1']), + stoppable('login', 'Login', ['action-2']), + ]; + + // Action Intent extracts the reference ("Stop the payment timeout work" -> + // "payment timeout work"); resolving it is the Resolver's business alone. + // The exact-name baseline recalls the display name and nothing else. + const baseline = createWorkHubRoutePolicy(); + assert.deepEqual(baseline.resolveStop({ text: 'Stop Payments', sessions }), { + kind: 'target', + target: { sessionId: 'payments' }, + stopsActionId: 'action-1', + activeActionIds: ['action-1'], + }); + assert.deepEqual( + baseline.resolveStop({ text: 'Stop the payment timeout work', sessions }), + { kind: 'not_requested' }, + ); + + // Swapping the resolver changes only recall. The decision the stop policy + // produces keeps the same opaque identities and the same durable protocol. + const ranked = createWorkHubRoutePolicy( + describedResolver(new Map([['payments', 'payment timeout work']])), + ); + assert.deepEqual(ranked.resolveStop({ text: 'Stop the payment timeout work', sessions }), { + kind: 'target', + target: { sessionId: 'payments' }, + stopsActionId: 'action-1', + activeActionIds: ['action-1'], + }); +}); + +test('the stop policy, not the resolver, owns destructive sufficiency', () => { + const descriptions = new Map([['payments', 'payment timeout work']]); + const text = 'Stop the payment timeout work'; + + // A confidently resolved Session with no active WorkHub delegation, and one + // with several, are both refused with the reason they were refused. + assert.deepEqual( + createWorkHubRoutePolicy(describedResolver(descriptions)).resolveStop({ + text, + sessions: [stoppable('payments', 'Payments', [])], + }), + { kind: 'clarification', reason: 'stop_target_not_active' }, + ); + assert.deepEqual( + createWorkHubRoutePolicy(describedResolver(descriptions)).resolveStop({ + text, + sessions: [stoppable('payments', 'Payments', ['action-1', 'action-2'])], + }), + { kind: 'clarification', reason: 'stop_target_not_unique' }, + ); +}); + +test('an ambiguous recall never becomes a destructive target', () => { + const resolver = describedResolver( + new Map([ + ['payments', 'payment timeout work'], + ['payments-eu', 'payment timeout work'], + ]), + ); + assert.deepEqual( + createWorkHubRoutePolicy(resolver).resolveStop({ + text: 'Stop the payment timeout work', + sessions: [ + stoppable('payments', 'Payments', ['action-1']), + stoppable('payments-eu', 'Payments EU', ['action-2']), + ], + }), + { kind: 'clarification', reason: 'stop_target_ambiguous' }, + ); +}); + +test('a resolver cannot widen stop beyond the visible candidate set it was given', () => { + const resolver: WorkHubSessionResolver = { + resolve: () => ({ + kind: 'ranked', + candidates: [{ ref: 'never-offered', evidence: 'exact_session_name' }], + }), + }; + assert.deepEqual( + createWorkHubRoutePolicy(resolver).resolveStop({ + text: 'Stop Payments', + sessions: [stoppable('payments', 'Payments', ['action-1'])], + }), + { kind: 'not_requested' }, + ); +}); + +test('a stop cue with no safe reference asks for one instead of resolving', () => { + const resolver: WorkHubSessionResolver = { + resolve: () => assert.fail('an unsafe reference must not reach the Session Resolver'), + }; + assert.deepEqual( + createWorkHubRoutePolicy(resolver).resolveStop({ + text: 'Stop it', + sessions: [stoppable('payments', 'Payments', ['action-1'])], + }), + { kind: 'clarification', reason: 'stop_target_required' }, + ); +}); diff --git a/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts b/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts index a1fedc89d0..3e0f3b5d55 100644 --- a/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts +++ b/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts @@ -20,6 +20,10 @@ export { readWorkHubRequestIntent, workHubCorrectionTargetsSession, - workHubStopTargetsSession, } from '@maka/core/workhub-creation-intent'; export type { WorkHubRequestIntent } from '@maka/core/workhub-creation-intent'; +export { createExactNameSessionResolver } from '@maka/core/workhub-session-resolver'; +export type { + WorkHubSessionResolution, + WorkHubSessionResolver, +} from '@maka/core/workhub-session-resolver'; diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index f5e9a43b28..b79e84ab93 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -481,8 +481,7 @@ export function createWorkHubController(deps: { projectName: session.projectName, sessionName: session.sessionName, updatedAt: session.updatedAt, - activeDelegations: - activeActionIdsBySessionId.get(session.target.sessionId)?.length ?? 0, + activeActionIds: activeActionIdsBySessionId.get(session.target.sessionId) ?? [], })), }); if (stopDecision.kind !== 'not_requested') { @@ -496,19 +495,20 @@ export function createWorkHubController(deps: { reason: stopDecision.reason, }; } - const target = stopDecision.target; - const sourceActionId = activeActionIdsBySessionId.get(target.sessionId)![0]!; + const { target, stopsActionId } = stopDecision; const admitted = await coordination.act({ actionId: input.requestId, + // The proposal carries the opaque delegation identity the Session + // Resolver produced, never the display name it was recalled by. userText: input.text, - proposal: { disposition: 'stop_work', stopsActionId: sourceActionId }, + proposal: { disposition: 'stop_work', stopsActionId }, confirmation: { kind: 'user_stop' }, }); if (admitted.disposition !== 'stop_work') { throw new Error('WorkHub Action Gate returned an unexpected disposition'); } if (admitted.outcome !== 'not_owned') { - removeActiveAction(target.sessionId, sourceActionId); + removeActiveAction(target.sessionId, stopsActionId); } return { kind: 'stop', diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index af8d40e9ec..6900a4e0c2 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -18,10 +18,11 @@ */ import { + createExactNameSessionResolver, readWorkHubRequestIntent, workHubCorrectionTargetsSession, - workHubStopTargetsSession, type WorkHubRequestIntent, + type WorkHubSessionResolver, } from './application/contracts/workhub-request-intent.js'; interface WorkHubRouteTarget { @@ -59,9 +60,10 @@ export type WorkHubRouteDecision = | { kind: 'discussion' } | { kind: 'new_session'; title: string; correctedFrom?: WorkHubRouteTarget }; -/** An existing WorkHub identity together with how much active work it owns. */ +/** An existing WorkHub identity together with the active work it owns. */ export interface WorkHubStoppableSession extends WorkHubRoutableSession { - activeDelegations: number; + /** Opaque action identities of this Session's active WorkHub delegations. */ + activeActionIds: readonly string[]; } export type WorkHubStopClarificationReason = @@ -82,7 +84,14 @@ export type WorkHubStopClarificationReason = export type WorkHubStopRouteDecision = | { kind: 'not_requested' } | { kind: 'clarification'; reason: WorkHubStopClarificationReason } - | { kind: 'target'; target: WorkHubRouteTarget }; + | { + kind: 'target'; + target: WorkHubRouteTarget; + /** The one active delegation the policy resolved, by opaque identity. */ + stopsActionId: string; + /** The active delegation state the Action Gate must revalidate. */ + activeActionIds: readonly string[]; + }; export interface WorkHubRoutePolicy { resolveStop(input: { @@ -131,37 +140,72 @@ const MAX_RELATED_CLARIFICATION_OPTIONS = 4; * It owns only transient inference context. Session identity, transcript, * execution state, and recovery continue to come from the Session port. */ -export function createWorkHubRoutePolicy(): WorkHubRoutePolicy { - return createWorkHubRoutePolicyVisit(); +export function createWorkHubRoutePolicy( + sessionResolver: WorkHubSessionResolver = createExactNameSessionResolver(), +): WorkHubRoutePolicy { + return createWorkHubRoutePolicyVisit(sessionResolver); } -function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy { +function createWorkHubRoutePolicyVisit( + sessionResolver: WorkHubSessionResolver, +): WorkHubRoutePolicy { let currentFocus: WorkHubRouteTarget | undefined; let previousFocus: WorkHubRouteTarget | undefined; return { + // The stop Action Policy. Action Intent says only that the user issued a + // stop imperative and what work it refers to; the shared Session Resolver + // recalls which visible Sessions that reference names; this policy decides + // whether the resolution is sufficient for a destructive action. + // // Direct stop is a narrow claim over WorkHub's own active delegations, not - // a filter over every sentence that begins with "stop". Text that names no - // WorkHub identity — "Stop using the deprecated API" — is ordinary work and - // falls through to routing; an unsafe or anaphoric target still fails - // closed, and a named identity that is not uniquely stoppable says why. + // a filter over every sentence that begins with "stop". A reference that + // recalls no WorkHub identity — "Stop using the deprecated API" — is + // ordinary work and falls through to routing; an unsafe or anaphoric + // reference still fails closed, and a resolved Session that is not uniquely + // stoppable says why. resolveStop({ text, sessions }) { const intent = readWorkHubRequestIntent(text); if (!intent.stop.cue) return { kind: 'not_requested' }; - if (!intent.stop.imperative) { + const reference = intent.stop.imperative ? intent.stop.target : undefined; + if (!reference) { return { kind: 'clarification', reason: 'stop_target_required' }; } - const named = sessions.filter((session) => - workHubStopTargetsSession(intent, session.sessionName)); - if (named.length === 0) return { kind: 'not_requested' }; - if (named.length > 1) return { kind: 'clarification', reason: 'stop_target_ambiguous' }; - const target = named[0]!; - if (target.activeDelegations === 1) return { kind: 'target', target: target.target }; + const sessionByRef = new Map( + sessions.map((session) => [session.target.sessionId, session]), + ); + const resolution = sessionResolver.resolve({ + reference: { text: reference }, + sessions: sessions.map((session) => ({ + ref: session.target.sessionId, + sessionName: session.sessionName, + projectName: session.projectName, + updatedAt: session.updatedAt, + })), + }); + if (resolution.kind === 'none') return { kind: 'not_requested' }; + if (resolution.kind === 'ambiguous') { + return { kind: 'clarification', reason: 'stop_target_ambiguous' }; + } + // Stop admits one candidate only. A ranked resolver may return several; + // this action never picks a winner from a ranking it cannot justify. + if (resolution.candidates.length > 1) { + return { kind: 'clarification', reason: 'stop_target_ambiguous' }; + } + const resolved = sessionByRef.get(resolution.candidates[0].ref); + if (!resolved) return { kind: 'not_requested' }; + const [stopsActionId, ...furtherActive] = resolved.activeActionIds; + if (!stopsActionId) { + return { kind: 'clarification', reason: 'stop_target_not_active' }; + } + if (furtherActive.length > 0) { + return { kind: 'clarification', reason: 'stop_target_not_unique' }; + } return { - kind: 'clarification', - reason: target.activeDelegations === 0 - ? 'stop_target_not_active' - : 'stop_target_not_unique', + kind: 'target', + target: resolved.target, + stopsActionId, + activeActionIds: resolved.activeActionIds, }; }, resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { @@ -322,7 +366,7 @@ function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy { } }, newVisit() { - return createWorkHubRoutePolicyVisit(); + return createWorkHubRoutePolicyVisit(sessionResolver); }, rememberTarget(target) { if (currentFocus?.sessionId === target.sessionId) return; diff --git a/packages/core/package.json b/packages/core/package.json index 8ef4b2638c..11e29d6f3b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -78,6 +78,7 @@ "./daily-review": "./dist/daily-review.js", "./work-board": "./dist/work-board.js", "./workhub-creation-intent": "./dist/workhub-creation-intent.js", + "./workhub-session-resolver": "./dist/workhub-session-resolver.js", "./deep-research": "./dist/deep-research.js", "./session-start-mode": "./dist/session-start-mode.js", "./long-term-memory": "./dist/long-term-memory.js", diff --git a/packages/core/src/__tests__/workhub-session-resolver.test.ts b/packages/core/src/__tests__/workhub-session-resolver.test.ts new file mode 100644 index 0000000000..35dc4f0294 --- /dev/null +++ b/packages/core/src/__tests__/workhub-session-resolver.test.ts @@ -0,0 +1,86 @@ +/* + * 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 test from 'node:test'; +import { readWorkHubRequestIntent } from '../workhub-creation-intent.js'; +import { + createExactNameSessionResolver, + type WorkHubResolverSession, +} from '../workhub-session-resolver.js'; + +const session = (ref: string, sessionName: string, updatedAt = 1): WorkHubResolverSession => ({ + ref, + sessionName, + projectName: 'demo', + updatedAt, +}); + +const resolveText = (text: string, sessions: readonly WorkHubResolverSession[]) => { + const reference = readWorkHubRequestIntent(text).stop.target; + assert.ok(reference, text); + return createExactNameSessionResolver().resolve({ reference: { text: reference }, sessions }); +}; + +test('the exact-name resolver recalls one visible Session by opaque reference', () => { + assert.deepEqual( + resolveText('Stop Payments', [session('s1', 'Payments'), session('s2', 'Login')]), + { kind: 'ranked', candidates: [{ ref: 's1', evidence: 'exact_session_name' }] }, + ); + assert.deepEqual(resolveText('停止支付任务', [session('s1', '支付任务')]), { + kind: 'ranked', + candidates: [{ ref: 's1', evidence: 'exact_session_name' }], + }); +}); + +test('a reference that names nothing visible resolves to none', () => { + assert.deepEqual(resolveText('Stop using the deprecated API', [session('s1', 'Payments')]), { + kind: 'none', + }); + assert.deepEqual(resolveText('Stop Payments', []), { kind: 'none' }); +}); + +test('equal exact matches are ambiguity rather than an unjustified ranking', () => { + assert.deepEqual( + resolveText('Stop Payments', [session('s1', 'Payments'), session('s2', 'Payments')]), + { + kind: 'ambiguous', + candidates: [ + { ref: 's1', evidence: 'exact_session_name' }, + { ref: 's2', evidence: 'exact_session_name' }, + ], + }, + ); +}); + +test('resolution is bounded to the offered candidate set', () => { + // A reference that names real work outside the permitted candidate set + // recalls nothing. Retrieval cannot widen its own visibility, and it has no + // vocabulary for creating work either — that stays an Action Policy decision. + assert.deepEqual(resolveText('Stop Payments', [session('s2', 'Login')]), { kind: 'none' }); + const resolution = resolveText('Stop Payments', [ + session('s1', 'Payments'), + session('s2', 'Login'), + ]); + assert.equal(resolution.kind, 'ranked'); + const offered = new Set(['s1', 's2']); + assert.ok( + resolution.kind === 'ranked' && resolution.candidates.every(({ ref }) => offered.has(ref)), + ); +}); diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index e35a9f05be..343640eca7 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -315,7 +315,26 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { }; } -/** Whether a parsed direct-stop command names exactly this Session. */ +/** + * Whether trusted reference text names exactly this Session. + * + * This is the matching rule behind the temporary exact-name Session Resolver. + * It answers a retrieval question only: naming a Session grants no authority + * over it, and the Action Gate revalidates the resolved opaque identity. + */ +export function workHubSessionReferenceNamesSession( + reference: string, + sessionName: string, +): boolean { + return stopTargetMatchesSession(reference, sessionName); +} + +/** + * Whether a parsed direct-stop command names exactly this Session. + * + * Only Runtime stop admission still derives a target this way. It moves onto + * the resolved opaque identity next, and this predicate goes with it. + */ export function workHubStopTargetsSession( intent: WorkHubRequestIntent, sessionName: string, @@ -323,7 +342,7 @@ export function workHubStopTargetsSession( return Boolean( intent.stop.imperative && intent.stop.target && - stopTargetMatchesSession(intent.stop.target, sessionName), + workHubSessionReferenceNamesSession(intent.stop.target, sessionName), ); } diff --git a/packages/core/src/workhub-session-resolver.ts b/packages/core/src/workhub-session-resolver.ts new file mode 100644 index 0000000000..d90f1a391f --- /dev/null +++ b/packages/core/src/workhub-session-resolver.ts @@ -0,0 +1,113 @@ +/* + * 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 { workHubSessionReferenceNamesSession } from './workhub-creation-intent.js'; + +/** + * The shared Session Resolver port. + * + * Every WorkHub action that may refer to existing work asks this one capability + * which visible Sessions a trusted user reference recalls. It answers with + * ranked candidates, nothing, or ambiguity, and nothing else: it never chooses + * the action, never returns creation, and never grants execution authority. + * The action-specific policy decides whether a resolution is sufficient, and + * the Action Gate revalidates every identity immediately before an effect. + */ +export interface WorkHubSessionResolver { + resolve(input: WorkHubSessionResolverInput): WorkHubSessionResolution; +} + +export interface WorkHubSessionResolverInput { + readonly reference: WorkHubSessionReference; + /** The bounded, visible candidate set the caller is permitted to resolve over. */ + readonly sessions: readonly WorkHubResolverSession[]; +} + +/** + * A trusted user reference to existing work, carried by Action Intent. It is + * retrieval evidence only; display text never becomes execution authority. + */ +export interface WorkHubSessionReference { + readonly text: string; +} + +/** One visible existing Session offered to the Resolver as a bounded candidate. */ +export interface WorkHubResolverSession { + /** + * Opaque Runtime-issued identity. Resolvers select among these references + * and never invent one from user or model text. + */ + readonly ref: string; + readonly sessionName: string; + readonly projectName: string; + readonly updatedAt: number; +} + +/** Why a candidate was recalled. Evidence explains a ranking; it authorizes nothing. */ +export type WorkHubSessionResolutionEvidence = 'exact_session_name'; + +export interface WorkHubSessionCandidate { + readonly ref: string; + readonly evidence: WorkHubSessionResolutionEvidence; +} + +/** + * Resolution is total: nothing recalled, one ranked list a policy may act on, + * or an ambiguity a policy must clarify. `create_new` is deliberately absent — + * creation is a policy decision, never a retrieval result. + */ +export type WorkHubSessionResolution = + | { readonly kind: 'none' } + | { + readonly kind: 'ranked'; + readonly candidates: readonly [WorkHubSessionCandidate, ...WorkHubSessionCandidate[]]; + } + | { readonly kind: 'ambiguous'; readonly candidates: readonly WorkHubSessionCandidate[] }; + +/** + * The temporary deterministic baseline: a reference resolves only when it names + * one visible Session exactly. Exact display names are conservative retrieval + * evidence, not the long-term product boundary, and this implementation exists + * to keep the port real while a ranked resolver is built behind it. + * + * It can be removed once every target-bearing WorkHub action resolves through + * this port, the replacement resolver passes the common routing evaluation, and + * its rollout retains a tested rollback path. + */ +export function createExactNameSessionResolver(): WorkHubSessionResolver { + return { + resolve({ reference, sessions }) { + const named = sessions.filter((session) => + workHubSessionReferenceNamesSession(reference.text, session.sessionName), + ); + const candidates = named.map( + (session): WorkHubSessionCandidate => ({ + ref: session.ref, + evidence: 'exact_session_name', + }), + ); + const [first, ...rest] = candidates; + if (!first) return { kind: 'none' }; + // Exact naming has no score to separate equals by, so more than one match + // is ambiguity rather than a ranking a policy could safely act on. + if (rest.length > 0) return { kind: 'ambiguous', candidates }; + return { kind: 'ranked', candidates: [first] }; + }, + }; +} From 84a7e51d195c13a7dd03f28f55ead9637fd0e195 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 2 Sep 2026 16:55:41 +0800 Subject: [PATCH 07/19] fix(workhub): admit stop by resolved identity and expected state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop admission re-derived its target from the request text: the Action Gate and the coordinator's under-lock recheck both parsed the user's words again and proved uniqueness by matching Session display names. That made display names the destructive authority and coupled the durable stop protocol to one resolver's grammar, so replacing the resolver could not have kept the protocol intact. The stop proposal now carries what the Action Policy resolved: the opaque delegation identity, the Session it belongs to, and every active WorkHub delegation the policy observed for that Session. The Gate revalidates exactly that immediately before any effect — the assignment exists, it still belongs to the proposed Session, and that Session's current active delegations are still the set the policy saw, which for stop must be the one delegation being stopped. A stale resolution fails closed; a rename between resolution and admission correctly no longer invalidates a claim. Trusted user text must still carry a direct stop imperative, and `user_stop` confirmation stays outside strategy output, so neither model output nor a display name can select what gets stopped. The Gate no longer proves that the text names the target: that binding now rests on the proposal being produced by trusted policy, which is what lets a ranked resolver replace exact naming without touching durable stop execution, replay, ownership, or arbitration. `stop_work` shapes are introduced by this compatibility epoch, so refining the proposal needs no further epoch. Generated-by: Claude Opus --- .../main/__tests__/workhub-controller.test.ts | 8 +- .../src/renderer/workhub-controller.ts | 13 +- .../__tests__/workhub-creation-intent.test.ts | 10 +- packages/core/src/workhub-creation-intent.ts | 17 --- .../__tests__/execution-composition.test.ts | 1 + .../workhub-coordination-action-gate.test.ts | 111 +++++++++++++----- .../workhub-coordination-coordinator.test.ts | 69 ++++++++--- .../workhub-coordination-protocol.test.ts | 58 ++++++++- .../src/protocol/workhub-coordination.ts | 45 +++++++ .../workhub-coordination-action-gate.ts | 61 ++++++---- .../workhub-coordination-coordinator.ts | 25 ++-- 11 files changed, 309 insertions(+), 109 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 9c6d36a10f..152c2f8893 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -373,10 +373,16 @@ test('direct stop bypasses routing candidates and preserves a not_owned delegati outcome: 'not_owned', targetTurnId: 'shared-turn', }); + // The proposal carries only opaque identities and the resolved active state. + // No display name reaches the Action Gate. assert.deepEqual(actions, [{ actionId: 'stop-1', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'action-1' }, + proposal: { + disposition: 'stop_work', + stopsActionId: 'action-1', + expects: { targetSessionId: 'payments', activeActionIds: ['action-1'] }, + }, confirmation: { kind: 'user_stop' }, }]); assert.equal(candidateReads, 0); diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index b79e84ab93..0ba80a49ad 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -495,13 +495,18 @@ export function createWorkHubController(deps: { reason: stopDecision.reason, }; } - const { target, stopsActionId } = stopDecision; + const { target, stopsActionId, activeActionIds } = stopDecision; const admitted = await coordination.act({ actionId: input.requestId, - // The proposal carries the opaque delegation identity the Session - // Resolver produced, never the display name it was recalled by. userText: input.text, - proposal: { disposition: 'stop_work', stopsActionId }, + proposal: { + disposition: 'stop_work', + stopsActionId, + // The proposal carries only opaque identities and the state the + // policy resolved against. The Action Gate revalidates both, so a + // resolution that went stale is refused rather than acted on. + expects: { targetSessionId: target.sessionId, activeActionIds }, + }, confirmation: { kind: 'user_stop' }, }); if (admitted.disposition !== 'stop_work') { diff --git a/packages/core/src/__tests__/workhub-creation-intent.test.ts b/packages/core/src/__tests__/workhub-creation-intent.test.ts index 9efbc06bd9..58f9e029ec 100644 --- a/packages/core/src/__tests__/workhub-creation-intent.test.ts +++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts @@ -23,10 +23,18 @@ import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, workHubCreationAuthorizesTitle, - workHubStopTargetsSession, + workHubSessionReferenceNamesSession, + type WorkHubRequestIntent, } from '../workhub-creation-intent.js'; const intentFor = readWorkHubRequestIntent; +/** The stop Action Policy's own sufficiency rule, kept out of the matcher. */ +const workHubStopTargetsSession = (intent: WorkHubRequestIntent, sessionName: string): boolean => + Boolean( + intent.stop.imperative && + intent.stop.target && + workHubSessionReferenceNamesSession(intent.stop.target, sessionName), + ); const affirmativeWorkHubExistingCorrectionTarget = (value: string) => intentFor(value).correction.existingTarget; const affirmativeWorkHubNamedCreationTitle = (value: string) => { diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index 343640eca7..ec5ccf3beb 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -329,23 +329,6 @@ export function workHubSessionReferenceNamesSession( return stopTargetMatchesSession(reference, sessionName); } -/** - * Whether a parsed direct-stop command names exactly this Session. - * - * Only Runtime stop admission still derives a target this way. It moves onto - * the resolved opaque identity next, and this predicate goes with it. - */ -export function workHubStopTargetsSession( - intent: WorkHubRequestIntent, - sessionName: string, -): boolean { - return Boolean( - intent.stop.imperative && - intent.stop.target && - workHubSessionReferenceNamesSession(intent.stop.target, sessionName), - ); -} - /** Whether a parsed correction names exactly this Session. */ export function workHubCorrectionTargetsSession( intent: WorkHubRequestIntent, diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 6b3d424465..092e4626da 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -757,6 +757,7 @@ test('WorkHub correction replaces its link without stopping a shared manual Turn proposal: { disposition: 'stop_work', stopsActionId: assignment.actionId, + expects: { targetSessionId: source.id, activeActionIds: [assignment.actionId] }, }, }, context, 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 7478a1ad50..54b414ae20 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 @@ -320,6 +320,20 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.assignments.length, 1); }); + /** + * A stop proposal as the Action Policy produces it: opaque identities plus + * the active-delegation state it resolved against, never a display name. + */ + const stopProposal = ( + stopsActionId: string, + targetSessionId: string, + activeActionIds: readonly string[] = [stopsActionId], + ) => ({ + disposition: 'stop_work' as const, + stopsActionId, + expects: { targetSessionId, activeActionIds }, + }); + test('stops exactly one named durable delegation and replays its observed outcome', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); effects.assignmentRecords.set( @@ -339,7 +353,7 @@ describe('WorkHub Coordination Action Gate', () => { const input = { actionId: 'stop-action', userText: 'Stop Payments', - proposal: { disposition: 'stop_work' as const, stopsActionId: 'source-action' }, + proposal: stopProposal('source-action', 'payments'), confirmation: { kind: 'user_stop' as const }, }; @@ -358,7 +372,7 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.retirements.length, 1); }); - test('rejects a named stop that does not identify one active durable delegation', async () => { + test('rejects a stop that does not identify one active durable delegation', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); for (const actionId of ['source-action', 'other-action']) { effects.assignmentRecords.set( @@ -377,23 +391,30 @@ describe('WorkHub Coordination Action Gate', () => { ); } - await assert.rejects( - new WorkHubCoordinationActionGate(effects).act( - { - actionId: 'stop-ambiguous-payments', - userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, - confirmation: { kind: 'user_stop' }, - }, - CONTEXT, - ), - (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', - ); + // A proposal that understates the Session's active work is stale, and one + // that states it honestly still fails: stop admits a sole delegation only. + for (const proposal of [ + stopProposal('source-action', 'payments'), + stopProposal('source-action', 'payments', ['source-action', 'other-action']), + ]) { + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-ambiguous-payments', + userText: 'Stop Payments', + proposal, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + } assert.equal(effects.stopRequests.size, 0); assert.equal(effects.retirements.length, 0); }); - test('rejects stop authority from confirmation alone or a different named target', async () => { + test('rejects stop authority from confirmation alone or a stale precondition', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); effects.assignmentRecords.set( 'source-action', @@ -409,19 +430,21 @@ describe('WorkHub Coordination Action Gate', () => { 'source-turn', ), ); + // Action Intent still has to carry a direct stop imperative. It only says + // that the user asked to stop work; which work is the Resolver's answer and + // this Gate's revalidated precondition, so no text here selects a target. for (const userText of [ 'Stop it', 'Pause Payments', 'How do I stop Payments?', 'Do not stop Payments', - 'Stop Login', ]) { await assert.rejects( new WorkHubCoordinationActionGate(effects).act( { actionId: `stop-${userText}`, userText, - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: stopProposal('source-action', 'payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -430,6 +453,26 @@ describe('WorkHub Coordination Action Gate', () => { userText, ); } + // A precondition that disagrees with durable state fails closed, whether it + // names the wrong Session or an active delegation set that never held. + for (const proposal of [ + stopProposal('source-action', 'login'), + stopProposal('source-action', 'payments', ['other-action']), + stopProposal('source-action', 'payments', []), + ]) { + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: `stop-${proposal.expects.targetSessionId}-${proposal.expects.activeActionIds.join('+')}`, + userText: 'Stop Payments', + proposal, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + } assert.equal(effects.retirements.length, 0); }); @@ -458,7 +501,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-shared', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: stopProposal('source-action', 'payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -489,7 +532,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-shared', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: stopProposal('source-action', 'payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -529,7 +572,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'reused-stop', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: stopProposal('source-action', 'payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -546,7 +589,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'reused-stop', userText: 'Stop Login', - proposal: { disposition: 'stop_work', stopsActionId: 'other-action' }, + proposal: stopProposal('other-action', 'login'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -577,7 +620,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'crossing-action', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: stopProposal('source-action', 'payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -628,7 +671,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-first', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: stopProposal('source-action', 'payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -638,7 +681,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-second', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: stopProposal('source-action', 'payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -669,7 +712,7 @@ describe('WorkHub Coordination Action Gate', () => { const input = { actionId: 'stop-removed-target', userText: 'Stop Payments', - proposal: { disposition: 'stop_work' as const, stopsActionId: 'source-action' }, + proposal: stopProposal('source-action', 'payments'), confirmation: { kind: 'user_stop' as const }, }; const unresolved = (error: unknown) => @@ -703,7 +746,7 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.stopResolutions.size, 1); }); - test('binds a fresh stop to the current display name while replay keeps its durable name', async () => { + test('keeps display names as stop evidence rather than admission authority', async () => { const effects = fakeEffects([session('payments', { name: 'Renamed Payments' })]); effects.assignmentRecords.set( 'source-action', @@ -719,13 +762,19 @@ describe('WorkHub Coordination Action Gate', () => { 'source-turn', ), ); + // The reference the user typed is the Session's old name. Resolution is the + // Resolver's business; admission proves the opaque identity, so a rename + // between resolution and admission cannot invalidate the claim. const input = { actionId: 'stop-renamed', - userText: 'Stop Renamed Payments', - proposal: { disposition: 'stop_work' as const, stopsActionId: 'source-action' }, + userText: 'Stop Old Payments', + proposal: stopProposal('source-action', 'payments'), confirmation: { kind: 'user_stop' as const }, }; - await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); + assert.equal( + (await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT)).disposition, + 'stop_work', + ); assert.equal( effects.stopRequests.get('delegation-source-action')?.targetSessionName, 'Renamed Payments', @@ -735,6 +784,10 @@ describe('WorkHub Coordination Action Gate', () => { (await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT)).disposition, 'stop_work', ); + assert.equal( + effects.stopRequests.get('delegation-source-action')?.targetSessionName, + 'Renamed Payments', + ); }); test('rejects waiting targets independently of strategy behavior', async () => { 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 e31844777b..96092b50c8 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -583,6 +583,7 @@ describe('Host WorkHub Coordination coordinator', () => { test('persists direct-stop request and resolution before replaying after restart', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-')); let store = createSessionStore(root); + let targetId = ''; try { const target = await store.create({ cwd: root, @@ -591,6 +592,7 @@ describe('Host WorkHub Coordination coordinator', () => { model: 'test-model', permissionMode: 'ask', }); + targetId = target.id; let retireCalls = 0; const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { assign: (input) => persistTestAssignment(store, input, 'payments-turn'), @@ -624,7 +626,11 @@ describe('Host WorkHub Coordination coordinator', () => { { actionId: 'stop-action', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: { + disposition: 'stop_work', + stopsActionId: 'source-action', + expects: { targetSessionId: target.id, activeActionIds: ['source-action'] }, + }, confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -662,7 +668,11 @@ describe('Host WorkHub Coordination coordinator', () => { { actionId: 'stop-action', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: { + disposition: 'stop_work', + stopsActionId: 'source-action', + expects: { targetSessionId: targetId, activeActionIds: ['source-action'] }, + }, confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -677,7 +687,7 @@ describe('Host WorkHub Coordination coordinator', () => { } }); - test('rechecks stop-name uniqueness after the advisory active-link read', async () => { + test('rechecks sole-delegation stop preconditions after the advisory active-link read', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-race-')); const store = createSessionStore(root); try { @@ -759,7 +769,11 @@ describe('Host WorkHub Coordination coordinator', () => { { actionId: 'stop-racing-action', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: { + disposition: 'stop_work', + stopsActionId: 'source-action', + expects: { targetSessionId: target.id, activeActionIds: ['source-action'] }, + }, confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -850,7 +864,11 @@ describe('Host WorkHub Coordination coordinator', () => { { actionId: 'stop-removed-target-action', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: { + disposition: 'stop_work', + stopsActionId: 'source-action', + expects: { targetSessionId: target.id, activeActionIds: ['source-action'] }, + }, confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -873,13 +891,17 @@ describe('Host WorkHub Coordination coordinator', () => { test('converges a committed stop after the target Session is removed and the Host restarts', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-removed-')); let store = createSessionStore(root); - const stopInput = { + let targetId: string; + const stopInput = () => ({ actionId: 'stop-action', userText: 'Stop Payments', - proposal: { disposition: 'stop_work' as const, stopsActionId: 'source-action' }, + proposal: { + disposition: 'stop_work' as const, + stopsActionId: 'source-action', + expects: { targetSessionId: targetId, activeActionIds: ['source-action'] }, + }, confirmation: { kind: 'user_stop' as const }, - }; - let targetId: string; + }); try { const target = await store.create({ cwd: root, @@ -940,7 +962,7 @@ describe('Host WorkHub Coordination coordinator', () => { ).ok, true, ); - const crashed = await workhub.handlers['workhub.coordination.act'](stopInput, CONTEXT); + const crashed = await workhub.handlers['workhub.coordination.act'](stopInput(), CONTEXT); assert.equal(crashed.ok, false); const assignment = await store.readWorkHubAssignment('source-action'); assert.ok(assignment); @@ -962,7 +984,7 @@ describe('Host WorkHub Coordination coordinator', () => { return { outcome: 'recovering' }; }, }); - const resolved = await restarted.handlers['workhub.coordination.act'](stopInput, CONTEXT); + const resolved = await restarted.handlers['workhub.coordination.act'](stopInput(), CONTEXT); assert.deepEqual(resolved, { ok: true, result: { @@ -973,7 +995,7 @@ describe('Host WorkHub Coordination coordinator', () => { }); assert.equal(retireCalls, 1); assert.deepEqual( - await restarted.handlers['workhub.coordination.act'](stopInput, CONTEXT), + await restarted.handlers['workhub.coordination.act'](stopInput(), CONTEXT), resolved, ); assert.equal(retireCalls, 1); @@ -986,12 +1008,17 @@ describe('Host WorkHub Coordination coordinator', () => { test('keeps one durable action identity bound to one delegation across restart', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-action-claim-')); let store = createSessionStore(root); - const stopLogin = { + let loginSessionId = ''; + const stopLogin = () => ({ actionId: 'reused-stop', userText: 'Stop Login', - proposal: { disposition: 'stop_work' as const, stopsActionId: 'login-action' }, + proposal: { + disposition: 'stop_work' as const, + stopsActionId: 'login-action', + expects: { targetSessionId: loginSessionId, activeActionIds: ['login-action'] }, + }, confirmation: { kind: 'user_stop' as const }, - }; + }); let loginDelegationId: string | undefined; try { const targets: Array<{ id: string; name: string }> = []; @@ -1039,12 +1066,20 @@ describe('Host WorkHub Coordination coordinator', () => { true, ); } + loginSessionId = targets.find((session) => session.name === 'Login')!.id; loginDelegationId = (await store.readWorkHubAssignment('login-action'))?.delegationId; const recovering = await workhub.handlers['workhub.coordination.act']( { actionId: 'reused-stop', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'source-action' }, + proposal: { + disposition: 'stop_work', + stopsActionId: 'source-action', + expects: { + targetSessionId: targets.find((session) => session.name === 'Payments')!.id, + activeActionIds: ['source-action'], + }, + }, confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -1060,7 +1095,7 @@ describe('Host WorkHub Coordination coordinator', () => { const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { retireDelegation: async () => assert.fail('a reused action identity must not retire work'), }); - const crossed = await restarted.handlers['workhub.coordination.act'](stopLogin, CONTEXT); + const crossed = await restarted.handlers['workhub.coordination.act'](stopLogin(), CONTEXT); assert.equal(crossed.ok, false); if (!crossed.ok) assert.equal(crossed.error.code, 'operation_conflict'); assert.ok(loginDelegationId); 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 4b8cb18761..a9b8bf893b 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -89,13 +89,21 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () decodeWorkHubCoordinationActInput({ actionId: 'action-stop', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'action-payments' }, + proposal: { + disposition: 'stop_work', + stopsActionId: 'action-payments', + expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, + }, confirmation: { kind: 'user_stop' }, }), { actionId: 'action-stop', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'action-payments' }, + proposal: { + disposition: 'stop_work', + stopsActionId: 'action-payments', + expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, + }, confirmation: { kind: 'user_stop' }, }, ); @@ -103,12 +111,20 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () { actionId: 'action-stop-no-confirmation', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'action-payments' }, + proposal: { + disposition: 'stop_work', + stopsActionId: 'action-payments', + expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, + }, }, { actionId: 'action-stop-wrong-confirmation', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', stopsActionId: 'action-payments' }, + proposal: { + disposition: 'stop_work', + stopsActionId: 'action-payments', + expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, + }, confirmation: { kind: 'user_correction' }, }, { @@ -117,10 +133,44 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () proposal: { disposition: 'stop_work', stopsActionId: 'action-payments', + expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, targetSessionId: 'injected', }, confirmation: { kind: 'user_stop' }, }, + // Preconditions are part of the closed proposal shape, not an optional hint. + { + actionId: 'action-stop-missing-preconditions', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', stopsActionId: 'action-payments' }, + confirmation: { kind: 'user_stop' }, + }, + { + actionId: 'action-stop-unbounded-preconditions', + userText: 'Stop Payments', + proposal: { + disposition: 'stop_work', + stopsActionId: 'action-payments', + expects: { + targetSessionId: 'payments', + activeActionIds: Array.from({ length: 33 }, (_unused, index) => `action-${index}`), + }, + }, + confirmation: { kind: 'user_stop' }, + }, + { + actionId: 'action-stop-duplicate-preconditions', + userText: 'Stop Payments', + proposal: { + disposition: 'stop_work', + stopsActionId: 'action-payments', + expects: { + targetSessionId: 'payments', + activeActionIds: ['action-payments', 'action-payments'], + }, + }, + confirmation: { kind: 'user_stop' }, + }, ]) { assert.throws( () => decodeWorkHubCoordinationActInput(invalid), diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 4cced2ecc2..586c4132bf 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -137,8 +137,26 @@ export type WorkHubCoordinationProposal = readonly disposition: 'stop_work'; /** Action identity of the exact durable delegation link being stopped. */ readonly stopsActionId: string; + /** + * The expected state the Action Policy resolved against. It carries no + * authority of its own; the Action Gate revalidates it against current + * durable facts, so a resolution that has gone stale fails closed instead + * of stopping work the user never resolved. + */ + readonly expects: WorkHubCoordinationStopPreconditions; }; +export interface WorkHubCoordinationStopPreconditions { + /** Session the resolved delegation was proposed against. */ + readonly targetSessionId: string; + /** + * Every active WorkHub delegation the policy observed for that Session. Stop + * admits only a sole active delegation, so a concurrent delegation to the + * same Session invalidates the proposal rather than silently widening it. + */ + readonly activeActionIds: readonly string[]; +} + export type WorkHubCoordinationDestructiveConfirmation = /** Kept outside strategy output so a model proposal cannot authorize Stop. */ { readonly kind: 'user_correction' } | { readonly kind: 'user_stop' }; @@ -605,15 +623,42 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP const exact = requireExactRecord(proposal, 'WorkHub stop proposal', [ 'disposition', 'stopsActionId', + 'expects', ]); return { disposition: 'stop_work', stopsActionId: requireEntityId(exact.stopsActionId, 'WorkHub stopped action id'), + expects: decodeWorkHubCoordinationStopPreconditions(exact.expects), }; } throw invalidProtocolFrame('Invalid WorkHub Coordination proposal disposition'); } +function decodeWorkHubCoordinationStopPreconditions( + value: unknown, +): WorkHubCoordinationStopPreconditions { + const expects = requireExactRecord(value, 'WorkHub stop preconditions', [ + 'targetSessionId', + 'activeActionIds', + ]); + if (!Array.isArray(expects.activeActionIds)) { + throw invalidProtocolFrame('Invalid WorkHub stop preconditions'); + } + if (expects.activeActionIds.length > WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS) { + throw invalidProtocolFrame('Too many WorkHub expected active delegations'); + } + const activeActionIds = expects.activeActionIds.map((actionId) => + requireEntityId(actionId, 'WorkHub expected active action id'), + ); + if (new Set(activeActionIds).size !== activeActionIds.length) { + throw invalidProtocolFrame('Duplicate WorkHub expected active delegation'); + } + return { + targetSessionId: requireEntityId(expects.targetSessionId, 'WorkHub target Session id'), + activeActionIds, + }; +} + function decodeWorkHubCoordinationCreateContext(value: unknown): WorkHubCoordinationCreateContext { const context = requireExactRecord(value, 'WorkHub creation context', ['workspace']); return { 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 4e57184d28..02593ba66d 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -42,7 +42,6 @@ import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, workHubCreationAuthorizesTitle, - workHubStopTargetsSession, } from '@maka/core/workhub-creation-intent'; import type { WorkHubCoordinationActInput, @@ -357,22 +356,16 @@ export class WorkHubCoordinationActionGate { ); } const source = await this.#effects.readAssignment(proposal.stopsActionId); - if (!source) { + if (!source || source.targetSessionId !== proposal.expects.targetSessionId) { throw new WorkHubActionGateFailure( 'action_conflict', - 'WorkHub can stop only the named durable delegation it owns', + 'WorkHub can stop only the resolved durable delegation it owns', ); } const stopFingerprint = stopActionFingerprint(input, source); await this.#claimAction(input.actionId, 'stop', stopFingerprint, source.delegationId); const existing = await this.#effects.readStopRequest(source.delegationId); if (existing) { - if (!workHubStopTargetsSession(requestIntent, existing.targetSessionName)) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub can stop only the named durable delegation it owns', - ); - } if (existing.actionId !== input.actionId) { // `not_owned` deliberately leaves the delegation active, so the user // can and will try again with a fresh request. That later attempt has @@ -397,27 +390,33 @@ export class WorkHubCoordinationActionGate { 'WorkHub active delegation target is unavailable', ); } - const matchingAssignments = activeAssignments.filter((assignment) => - workHubStopTargetsSession(requestIntent, sessionNameById.get(assignment.targetSessionId)!), - ); + const currentTargetName = sessionNameById.get(source.targetSessionId); + if (!currentTargetName) { + throw new WorkHubActionGateFailure('action_conflict', 'WorkHub stop target is unavailable'); + } + // Authority is the opaque delegation identity, never the display name the + // Resolver recalled it by. The Gate proves that identity is still the one + // active delegation of the Session the policy resolved, and that the + // policy's view of that Session has not changed underneath the proposal. if ( - matchingAssignments.length !== 1 || - matchingAssignments[0]?.actionId !== source.actionId || - matchingAssignments[0]?.delegationId !== source.delegationId + !sameActiveDelegationSet( + activeAssignments, + source.targetSessionId, + proposal.expects.activeActionIds, + ) ) { throw new WorkHubActionGateFailure( 'action_conflict', - 'WorkHub stop target does not identify one active durable delegation', + 'WorkHub stop target active delegations changed during admission', ); } - const currentTargetName = sessionNameById.get(source.targetSessionId); - if (!currentTargetName) { - throw new WorkHubActionGateFailure('action_conflict', 'WorkHub stop target is unavailable'); - } - if (!workHubStopTargetsSession(requestIntent, currentTargetName)) { + if ( + proposal.expects.activeActionIds.length !== 1 || + proposal.expects.activeActionIds[0] !== source.actionId + ) { throw new WorkHubActionGateFailure( 'action_conflict', - 'WorkHub can stop only the named durable delegation it owns', + 'WorkHub stop target does not identify one active durable delegation', ); } if (await this.#effects.readSupersession(source.delegationId)) { @@ -1053,6 +1052,24 @@ function replacementActionFingerprint( }); } +/** + * Whether one Session's current active WorkHub delegations are exactly the set + * the Action Policy resolved against. Comparison is by opaque action identity + * and order-insensitive, so a rename cannot invalidate a proposal and a + * concurrent delegation to the same Session always does. + */ +function sameActiveDelegationSet( + activeAssignments: readonly WorkHubDelegationAssignedMessage[], + targetSessionId: string, + expectedActionIds: readonly string[], +): boolean { + const current = activeAssignments + .filter((assignment) => assignment.targetSessionId === targetSessionId) + .map((assignment) => assignment.actionId); + const expected = new Set(expectedActionIds); + return current.length === expected.size && current.every((actionId) => expected.has(actionId)); +} + function stopActionFingerprint( input: WorkHubCoordinationActInput, source: WorkHubDelegationAssignedMessage, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 38d02eb2bc..50b6b679ee 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -38,10 +38,6 @@ import { type WorkHubDelegationStopRequestedMessage, type WorkHubDelegationStopResolvedMessage, } from '@maka/core/session'; -import { - readWorkHubRequestIntent, - workHubStopTargetsSession, -} from '@maka/core/workhub-creation-intent'; import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; import type { OperationOutcome, @@ -295,8 +291,7 @@ export class HostWorkHubCoordinationCoordinator { 'WorkHub delegation is already being replaced', ); } - const intent = readWorkHubRequestIntent(input.userText); - const sessionNameById = new Map(headers.map((header) => [header.id, header.name])); + const visibleSessionIds = new Set(headers.map((header) => header.id)); const activeAssignments = activeWorkHubAssignments(messages); if ( activeAssignments.some( @@ -309,22 +304,24 @@ export class HostWorkHubCoordinationCoordinator { ); } if ( - activeAssignments.some((assignment) => !sessionNameById.has(assignment.targetSessionId)) + activeAssignments.some((assignment) => !visibleSessionIds.has(assignment.targetSessionId)) ) { throw new WorkHubActionGateFailure( 'action_conflict', 'WorkHub active delegation target is unavailable', ); } - const matching = activeAssignments.filter((assignment) => - workHubStopTargetsSession(intent, sessionNameById.get(assignment.targetSessionId)!), + // Held lanes make this the last moment the one-target proof can change. + // It is proved from opaque delegation identity, so a concurrent rename + // is harmless while a concurrent delegation to the same Session is not. + const targetActive = activeAssignments.filter( + (assignment) => assignment.targetSessionId === input.targetSessionId, ); - const currentName = sessionNameById.get(input.targetSessionId); if ( - matching.length !== 1 || - matching[0]?.actionId !== input.stopsActionId || - matching[0]?.delegationId !== input.stopsDelegationId || - currentName !== input.targetSessionName + !visibleSessionIds.has(input.targetSessionId) || + targetActive.length !== 1 || + targetActive[0]?.actionId !== input.stopsActionId || + targetActive[0]?.delegationId !== input.stopsDelegationId ) { throw new WorkHubActionGateFailure( 'action_conflict', From 4649241b13381a0ba113ff56cd0e495de09d8457 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 2 Sep 2026 16:56:47 +0800 Subject: [PATCH 08/19] docs(workhub): record resolver-backed stop resolution and admission Describe direct stop as it now behaves: its target comes from the shared Session Resolver, its proposal carries opaque identities and the expected active-delegation state, and admission revalidates that state rather than re-deriving a display-name match. Say plainly that a rename between resolution and admission is irrelevant while a concurrent delegation to the same Session is not, and that the trusted-text binding is now the stop imperative and the out-of-band confirmation rather than a name. Record in the design doc why admission by expected state is what makes the port real, and note that correction still resolves its own target and joins the port with continue, inspect, and resume under item 2. Generated-by: Claude Opus --- .../workhub-action-resolution-design.md | 14 ++++++++ .../workhub-coordination-session-adr.md | 34 ++++++++++++++----- docs/workhub-domain-language.md | 24 ++++++++----- 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/docs/architecture/workhub-action-resolution-design.md b/docs/architecture/workhub-action-resolution-design.md index 6571deaa0b..4e4d0697c0 100644 --- a/docs/architecture/workhub-action-resolution-design.md +++ b/docs/architecture/workhub-action-resolution-design.md @@ -237,10 +237,24 @@ execution. - Put the current exact-name behavior behind a temporary deterministic implementation. - Make direct stop consume the resolved opaque Session/delegation identity. +- Move stop admission off display names: the proposal carries the opaque + delegation identity plus the expected active-delegation state, and the Action + Gate revalidates that state instead of re-deriving a name match from user text. - Keep durable stop execution, replay, ownership, and arbitration unchanged. - Avoid documenting exact-name stop grammar as the long-term product contract. - Record follow-up removal criteria for the temporary resolver. +Admission by expected state rather than by name is what makes the port real: a +ranked resolver can change how a Session is recalled without touching the durable +stop protocol, and a rename between resolution and admission stops being able to +invalidate a claim. The Gate no longer proves that user text names the target, so +that binding now rests where it belongs — the stop proposal is produced by trusted +policy, never by strategy output, and `user_stop` confirmation stays outside the +proposal a model can influence. + +Correction still resolves its own target and moves onto the port under item 2, +together with continue, inspect, and resume. + The temporary resolver can be removed when all target-bearing WorkHub actions use the shared contract, the replacement resolver passes the common evaluation, and the rollout retains a tested rollback path. diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 9c2a3983ee..ab53f9aa1a 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -184,6 +184,19 @@ waiting after the destructive retirement boundary, Coordination appends a retired source from active linkage and makes later retries return the same terminal outcome instead of displaying a stopped, unsuperseded link. +Direct stop resolves its target through the shared Session Resolver and proposes +only what that resolution produced: the opaque delegation identity, the Session +it belongs to, and the active-delegation state the Action Policy resolved +against. Display names are retrieval evidence on the proposal side and never +appear in admission. The Action Gate revalidates those preconditions immediately +before any effect — the assignment still exists, it still belongs to the proposed +Session, and that Session's current active delegations are exactly the set the +policy saw, which for stop must be the one delegation being stopped. A stale +resolution therefore fails closed, while a rename between resolution and +admission is correctly irrelevant. Trusted user text still has to carry a direct +stop imperative, and the `user_stop` confirmation stays outside strategy output, +so neither model output nor a display name can select what gets stopped. + Direct stop persists a distinct `delegation_stop_requested` claim before retirement and a `delegation_stop_resolved` observation afterward. The pending cancellation tombstone retains the destructive action identity, preserving @@ -191,12 +204,12 @@ cancellation tombstone retains the destructive action identity, preserving owning-root Stop uses an action-derived abort source on the exact target Turn, so recovery cannot mistake a normal Session stop for WorkHub delivery. Its admission holds the Coordination Session together with every active target -Session lane while re-reading the active links and current display names. A -concurrent assignment or rename must therefore settle before the uniqueness -proof, wait until after the stop claim, or cause admission to fail closed. -Only a confirmed direct stop records that provenance: a route correction -retiring the same owning root carries its own cancellation claim but keeps the -neutral Stop source, so replay cannot read a correction as a delivered stop. +Session lane while re-reading the active links. A concurrent assignment must +therefore settle before the sole-delegation proof, wait until after the stop +claim, or cause admission to fail closed. Only a confirmed direct stop records +that provenance: a route correction retiring the same owning root carries its own +cancellation claim but keeps the neutral Stop source, so replay cannot read a +correction as a delivered stop. Every durable WorkHub record is keyed by what it is about — an assignment by its action, a stop or replacement by its delegation — so no single record can see an @@ -232,11 +245,14 @@ lets the stop reach a terminal resolution. that transcript; target lifecycle projection and the hybrid first-response contract are implemented as rebuildable reads. Linked correction, exact target-owned pending cancellation/Turn Stop, atomic supersession, and retry-based - replacement recovery and explicit named direct-stop are implemented. Direct + replacement recovery and direct stop are implemented. Direct stop uses durable `delegation_stop_requested` / `delegation_stop_resolved` facts, exact Message ownership, and first-claim-wins arbitration with - replacement. Pause, resume, and pronoun-based stop controls remain later - work. + replacement. Its target comes from the shared Session Resolver port, whose + first implementation is a temporary exact-name baseline; replacing it changes + recall only, because admission revalidates opaque identity and expected state + rather than any display name. Pause, resume, and pronoun-based stop controls + remain later work. Reevaluate the per-Host decision if supported workflows require one WorkHub conversation to coordinate ordinary Sessions on multiple Runtime Hosts, or if Host diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md index 8594b2985a..8db9be8aba 100644 --- a/docs/workhub-domain-language.md +++ b/docs/workhub-domain-language.md @@ -133,8 +133,13 @@ negation, unresolved or ambiguous targets, and model-supplied Session, Turn, Run or Message identities grant no Stop authority. A future ranked resolver may recall a Session from other permitted evidence, but the Action Policy must still require a sufficiently resolved active WorkHub delegation and the Action Gate must revalidate -its stable identity. WorkHub first records -`delegation_stop_requested`, resolves the named source action to its durable +its stable identity. The stop proposal therefore carries opaque identities and the +expected active-delegation state the policy resolved against, never a display name; +the Action Gate readmits it only while the assignment still belongs to that Session +and that Session's active delegations are still exactly the one being stopped. +A rename between resolution and admission is irrelevant, and a stale resolution +fails closed. WorkHub first records +`delegation_stop_requested`, resolves the source action to its durable delegation, and lets the target Session's Message authority observe one of four outcomes: `cancelled_pending`, `stop_delivered`, `already_terminal`, or `not_owned`. It then records the neutral `delegation_stop_resolved` fact. `stop_delivered` means @@ -142,10 +147,11 @@ the exact owning root accepted the Stop operation; the UI says that WorkHub aske it to stop rather than inventing an execution result. `not_owned` means the Message was consumed by a shared or user-owned Turn; WorkHub does not stop that Turn, preserves the active link, and navigates the user to the owning Session. -A stop cue that names no existing WorkHub Session is ordinary work — `Stop using -the deprecated API` is a task, not a destructive command — and routes normally. A -named Session that is not uniquely stoppable, and an unsafe or anaphoric target, -each fail closed with the reason they failed rather than an unanswerable prompt. +A stop reference that recalls no existing WorkHub Session is ordinary work — `Stop +using the deprecated API` is a task, not a destructive command — and routes +normally. An ambiguous recall, a resolved Session that is not uniquely stoppable, +and an unsafe or anaphoric reference each fail closed with the reason they failed +rather than an unanswerable prompt. An unresolved direct-stop claim and a replacement claim are mutually exclusive; the first durable destructive claim wins. A `not_owned` resolution releases that exclusion so a later explicit route correction can proceed, and because it leaves @@ -158,9 +164,9 @@ likewise writes the direct-stop action identity into the exact root Turn's durable abort source. A retry recognizes only that matching proof; an earlier or concurrent manual Stop remains `already_terminal`. Stop admission holds the Coordination Session and every currently active target Session lane -while it rechecks current target identities and active links; a concurrent rename -or new delegation therefore cannot invalidate the one-target proof before the -request record commits. Removing the target Session destroys the Message proof a +while it rechecks current target identities and active links; a concurrent new +delegation therefore cannot invalidate the one-target proof after the request +record commits. Removing the target Session destroys the Message proof a committed claim still needs; the removal tombstone outlives that Session and resolves the claim as `already_terminal`, while a target that is merely unreadable, or one that never existed here, stays unresolved. From 2a8c50d598ecbac738cf6334c0adab5bb4dc9dab Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 2 Sep 2026 21:57:53 +0800 Subject: [PATCH 09/19] fix(workhub): scope the stop visibility proof to one delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop admission proved that *every* active delegation still had a visible target Session. Nothing ever retires a delegation whose Session the user deleted: `activeWorkHubAssignments` ends a delegation only on supersession, replacement abort, or a resolved stop, and Session removal writes nothing to the coordination log. One deleted target therefore left a permanently active delegation that failed the global check, and from that point every direct stop in the system was refused — including stops aimed at healthy, unrelated Sessions. Prove visibility only for the delegation being stopped, in both the Gate and the coordinator's under-lock recheck. A dangling delegation still fails closed when it is itself the target, which is correct: its Message proof is gone. Reported by Astro-Han in review of #4439. Generated-by: Claude Opus --- .../workhub-coordination-action-gate.test.ts | 130 ++++++++++++------ .../workhub-coordination-action-gate.ts | 55 ++------ .../workhub-coordination-coordinator.ts | 8 -- 3 files changed, 95 insertions(+), 98 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 54b414ae20..bfa78e938b 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 @@ -324,14 +324,10 @@ describe('WorkHub Coordination Action Gate', () => { * A stop proposal as the Action Policy produces it: opaque identities plus * the active-delegation state it resolved against, never a display name. */ - const stopProposal = ( - stopsActionId: string, - targetSessionId: string, - activeActionIds: readonly string[] = [stopsActionId], - ) => ({ + const stopProposal = (stopsActionId: string, targetSessionId: string) => ({ disposition: 'stop_work' as const, stopsActionId, - expects: { targetSessionId, activeActionIds }, + expects: { targetSessionId }, }); test('stops exactly one named durable delegation and replays its observed outcome', async () => { @@ -372,6 +368,61 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.retirements.length, 1); }); + test('a deleted delegation target does not disable stop for every other Session', async () => { + const effects = fakeEffects([ + session('payments', { name: 'Payments' }), + session('login', { name: 'Login' }), + ]); + for (const [actionId, targetSessionId, name] of [ + ['pay-action', 'payments', 'Payments'], + ['login-action', 'login', 'Login'], + ] as const) { + effects.assignmentRecords.set( + actionId, + assignmentRecord( + { + actionId, + actionFingerprint: `sha256:${(actionId === 'pay-action' ? '4' : '5').repeat(64)}`, + targetSessionId, + targetSessionName: name, + disposition: 'delegate_existing', + userText: `Work in ${name}`, + }, + `${actionId}-turn`, + ), + ); + } + + // Nothing retires a delegation when its Session is deleted, so this one + // stays active forever. It must not be able to veto an unrelated stop. + effects.sessions = effects.sessions.filter(({ id }) => id !== 'payments'); + + const stopped = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-login', + userText: 'Stop Login', + proposal: stopProposal('login-action', 'login'), + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + assert.equal(stopped.disposition, 'stop_work'); + + // The dangling delegation itself still fails closed: its own target is gone. + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-payments', + userText: 'Stop Payments', + proposal: stopProposal('pay-action', 'payments'), + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + }); + test('rejects a stop that does not identify one active durable delegation', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); for (const actionId of ['source-action', 'other-action']) { @@ -391,25 +442,20 @@ describe('WorkHub Coordination Action Gate', () => { ); } - // A proposal that understates the Session's active work is stale, and one - // that states it honestly still fails: stop admits a sole delegation only. - for (const proposal of [ - stopProposal('source-action', 'payments'), - stopProposal('source-action', 'payments', ['source-action', 'other-action']), - ]) { - await assert.rejects( - new WorkHubCoordinationActionGate(effects).act( - { - actionId: 'stop-ambiguous-payments', - userText: 'Stop Payments', - proposal, - confirmation: { kind: 'user_stop' }, - }, - CONTEXT, - ), - (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', - ); - } + // Stop admits a sole active delegation, and the Host proves that from + // durable state — the proposal cannot assert its way past it. + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-ambiguous-payments', + userText: 'Stop Payments', + proposal: stopProposal('source-action', 'payments'), + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); assert.equal(effects.stopRequests.size, 0); assert.equal(effects.retirements.length, 0); }); @@ -453,26 +499,20 @@ describe('WorkHub Coordination Action Gate', () => { userText, ); } - // A precondition that disagrees with durable state fails closed, whether it - // names the wrong Session or an active delegation set that never held. - for (const proposal of [ - stopProposal('source-action', 'login'), - stopProposal('source-action', 'payments', ['other-action']), - stopProposal('source-action', 'payments', []), - ]) { - await assert.rejects( - new WorkHubCoordinationActionGate(effects).act( - { - actionId: `stop-${proposal.expects.targetSessionId}-${proposal.expects.activeActionIds.join('+')}`, - userText: 'Stop Payments', - proposal, - confirmation: { kind: 'user_stop' }, - }, - CONTEXT, - ), - (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', - ); - } + // A precondition that disagrees with durable state fails closed: this + // delegation does not belong to the Session the proposal resolved. + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-wrong-session', + userText: 'Stop Payments', + proposal: stopProposal('source-action', 'login'), + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); assert.equal(effects.retirements.length, 0); }); 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 02593ba66d..e5a3ef1952 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -382,38 +382,21 @@ export class WorkHubCoordinationActionGate { this.#effects.listActiveAssignments(), ]); const sessionNameById = new Map(sessions.map((session) => [session.id, session.name])); - if ( - activeAssignments.some((assignment) => !sessionNameById.has(assignment.targetSessionId)) - ) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub active delegation target is unavailable', - ); - } + // Only this delegation's target has to be visible. A delegation whose + // Session the user deleted stays in the active set forever — nothing + // retires it — so proving visibility over the whole set would let one + // deleted Session block every stop in the system from then on. const currentTargetName = sessionNameById.get(source.targetSessionId); if (!currentTargetName) { throw new WorkHubActionGateFailure('action_conflict', 'WorkHub stop target is unavailable'); } // Authority is the opaque delegation identity, never the display name the - // Resolver recalled it by. The Gate proves that identity is still the one - // active delegation of the Session the policy resolved, and that the - // policy's view of that Session has not changed underneath the proposal. - if ( - !sameActiveDelegationSet( - activeAssignments, - source.targetSessionId, - proposal.expects.activeActionIds, - ) - ) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub stop target active delegations changed during admission', - ); - } - if ( - proposal.expects.activeActionIds.length !== 1 || - proposal.expects.activeActionIds[0] !== source.actionId - ) { + // Resolver recalled it by. This is the advisory read; the coordinator + // reproves it from durable state under the admission lease. + const targetActive = activeAssignments.filter( + (assignment) => assignment.targetSessionId === source.targetSessionId, + ); + if (targetActive.length !== 1 || targetActive[0]?.actionId !== source.actionId) { throw new WorkHubActionGateFailure( 'action_conflict', 'WorkHub stop target does not identify one active durable delegation', @@ -1052,24 +1035,6 @@ function replacementActionFingerprint( }); } -/** - * Whether one Session's current active WorkHub delegations are exactly the set - * the Action Policy resolved against. Comparison is by opaque action identity - * and order-insensitive, so a rename cannot invalidate a proposal and a - * concurrent delegation to the same Session always does. - */ -function sameActiveDelegationSet( - activeAssignments: readonly WorkHubDelegationAssignedMessage[], - targetSessionId: string, - expectedActionIds: readonly string[], -): boolean { - const current = activeAssignments - .filter((assignment) => assignment.targetSessionId === targetSessionId) - .map((assignment) => assignment.actionId); - const expected = new Set(expectedActionIds); - return current.length === expected.size && current.every((actionId) => expected.has(actionId)); -} - function stopActionFingerprint( input: WorkHubCoordinationActInput, source: WorkHubDelegationAssignedMessage, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 50b6b679ee..cd43c0c814 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -303,14 +303,6 @@ export class HostWorkHubCoordinationCoordinator { 'WorkHub active delegation set changed during stop admission', ); } - if ( - activeAssignments.some((assignment) => !visibleSessionIds.has(assignment.targetSessionId)) - ) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub active delegation target is unavailable', - ); - } // Held lanes make this the last moment the one-target proof can change. // It is proved from opaque delegation identity, so a concurrent rename // is harmless while a concurrent delegation to the same Session is not. From 6030c6fab15ea40fea0df9dfa9fd9688a5097ebe Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 2 Sep 2026 21:58:25 +0800 Subject: [PATCH 10/19] refactor(workhub): single-source the Session name rule behind the port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port went in but the paths it was meant to replace stayed: stop resolved through it, correction kept a second near-identical copy of the same name-matching rule, and the exact-name grammar was still canonical by construction. That is a third path, not a shared one. The two matchers differed only in what they allowed *after* the name — a stop reference may carry punctuation and nothing else, a correction may name its target and go on to say what to do with it. That difference is an action's rule, not a retrieval rule, and it is why folding the two matchers into the Resolver would have forced the Resolver to know which action it was serving, contradicting its own contract. So the Resolver reports where the name matched and what text was left over, and says nothing about whether that leftover is acceptable. One `matchWorkHubSessionName` now owns the matching rule for both actions. Stop's tail rule moves into the stop Action Policy; correction's stays in its own predicate, which now takes a match so a caller that already resolved candidates applies the rule to exactly that recall instead of matching names a second time. Correction resolves through the port too. The Resolver stays action-agnostic: reporting a remainder is a neutral fact about a match, not a decision about what to do with it. Generated-by: Claude Opus --- .../workhub-session-resolver-port.test.ts | 11 +- .../contracts/workhub-request-intent.ts | 3 +- .../src/renderer/workhub-route-policy.ts | 60 ++++++---- .../__tests__/workhub-creation-intent.test.ts | 19 +-- .../workhub-session-resolver.test.ts | 8 +- packages/core/src/workhub-creation-intent.ts | 110 +++++++++--------- packages/core/src/workhub-session-resolver.ts | 32 +++-- 7 files changed, 142 insertions(+), 101 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts index f0d479775d..492100e83b 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts @@ -51,7 +51,10 @@ const describedResolver = ( resolve({ reference, sessions }): WorkHubSessionResolution { const candidates = sessions .filter((session) => descriptions.get(session.ref) === reference.text) - .map((session) => ({ ref: session.ref, evidence: 'exact_session_name' as const })); + .map((session) => ({ + ref: session.ref, + evidence: { kind: 'named' as const, remainder: '' }, + })); const [first, ...rest] = candidates; if (!first) return { kind: 'none' }; if (rest.length > 0) return { kind: 'ambiguous', candidates }; @@ -73,7 +76,6 @@ test('stop resolves through the shared port rather than a stop-specific grammar' kind: 'target', target: { sessionId: 'payments' }, stopsActionId: 'action-1', - activeActionIds: ['action-1'], }); assert.deepEqual( baseline.resolveStop({ text: 'Stop the payment timeout work', sessions }), @@ -89,7 +91,6 @@ test('stop resolves through the shared port rather than a stop-specific grammar' kind: 'target', target: { sessionId: 'payments' }, stopsActionId: 'action-1', - activeActionIds: ['action-1'], }); }); @@ -138,7 +139,9 @@ test('a resolver cannot widen stop beyond the visible candidate set it was given const resolver: WorkHubSessionResolver = { resolve: () => ({ kind: 'ranked', - candidates: [{ ref: 'never-offered', evidence: 'exact_session_name' }], + candidates: [ + { ref: 'never-offered', evidence: { kind: 'named', remainder: '' } }, + ], }), }; assert.deepEqual( diff --git a/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts b/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts index 3e0f3b5d55..8164ee8e5c 100644 --- a/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts +++ b/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts @@ -19,11 +19,12 @@ export { readWorkHubRequestIntent, - workHubCorrectionTargetsSession, + workHubCorrectionAdmitsReference, } from '@maka/core/workhub-creation-intent'; export type { WorkHubRequestIntent } from '@maka/core/workhub-creation-intent'; export { createExactNameSessionResolver } from '@maka/core/workhub-session-resolver'; export type { + WorkHubResolverSession, WorkHubSessionResolution, WorkHubSessionResolver, } from '@maka/core/workhub-session-resolver'; diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index 6900a4e0c2..cb6c89ef78 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -20,8 +20,9 @@ import { createExactNameSessionResolver, readWorkHubRequestIntent, - workHubCorrectionTargetsSession, + workHubCorrectionAdmitsReference, type WorkHubRequestIntent, + type WorkHubResolverSession, type WorkHubSessionResolver, } from './application/contracts/workhub-request-intent.js'; @@ -89,8 +90,6 @@ export type WorkHubStopRouteDecision = target: WorkHubRouteTarget; /** The one active delegation the policy resolved, by opaque identity. */ stopsActionId: string; - /** The active delegation state the Action Gate must revalidate. */ - activeActionIds: readonly string[]; }; export interface WorkHubRoutePolicy { @@ -176,23 +175,25 @@ function createWorkHubRoutePolicyVisit( ); const resolution = sessionResolver.resolve({ reference: { text: reference }, - sessions: sessions.map((session) => ({ - ref: session.target.sessionId, - sessionName: session.sessionName, - projectName: session.projectName, - updatedAt: session.updatedAt, - })), + sessions: sessions.map(resolverSession), }); if (resolution.kind === 'none') return { kind: 'not_requested' }; - if (resolution.kind === 'ambiguous') { - return { kind: 'clarification', reason: 'stop_target_ambiguous' }; - } + // Stop's own tail rule. The Resolver reports what the reference said + // after the name; a destructive command may add punctuation and nothing + // else, so `Stop Payments and Login` names no stoppable target here even + // though `Payments` matched. + const admissible = resolution.candidates.filter( + ({ evidence }) => + evidence.kind === 'elided_name_punctuation' || + /^[.!?。!?]*$/u.test(evidence.remainder), + ); + if (admissible.length === 0) return { kind: 'not_requested' }; // Stop admits one candidate only. A ranked resolver may return several; // this action never picks a winner from a ranking it cannot justify. - if (resolution.candidates.length > 1) { + if (resolution.kind === 'ambiguous' || admissible.length > 1) { return { kind: 'clarification', reason: 'stop_target_ambiguous' }; } - const resolved = sessionByRef.get(resolution.candidates[0].ref); + const resolved = sessionByRef.get(admissible[0]!.ref); if (!resolved) return { kind: 'not_requested' }; const [stopsActionId, ...furtherActive] = resolved.activeActionIds; if (!stopsActionId) { @@ -201,12 +202,7 @@ function createWorkHubRoutePolicyVisit( if (furtherActive.length > 0) { return { kind: 'clarification', reason: 'stop_target_not_unique' }; } - return { - kind: 'target', - target: resolved.target, - stopsActionId, - activeActionIds: resolved.activeActionIds, - }; + return { kind: 'target', target: resolved.target, stopsActionId }; }, resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { const intent = readWorkHubRequestIntent(text); @@ -240,8 +236,20 @@ function createWorkHubRoutePolicyVisit( } const alternatives = sessions.filter((session) => session.target.sessionId !== correctedFrom.sessionId); + // Correction recalls its target through the same shared port as stop, + // then applies its own tail rule: a correction may name the Session and + // go on to say what to do with it. + const correctionResolution = sessionResolver.resolve({ + reference: { text: correctionText }, + sessions: alternatives.map(resolverSession), + }); + const affirmed = new Set( + (correctionResolution.kind === 'none' ? [] : correctionResolution.candidates) + .filter(({ evidence }) => workHubCorrectionAdmitsReference(correctionText, evidence)) + .map(({ ref }) => ref), + ); const affirmedCorrections = alternatives.filter((session) => - workHubCorrectionTargetsSession(intent, session.sessionName)); + affirmed.has(session.target.sessionId)); if (affirmedCorrections.length === 1) { return { kind: 'target', @@ -376,6 +384,16 @@ function createWorkHubRoutePolicyVisit( }; } +/** Presents one routable Session to the Resolver as a bounded opaque candidate. */ +function resolverSession(session: WorkHubRoutableSession): WorkHubResolverSession { + return { + ref: session.target.sessionId, + sessionName: session.sessionName, + projectName: session.projectName, + updatedAt: session.updatedAt, + }; +} + function rankExactSessions( text: string, sessions: WorkHubRoutableSession[], diff --git a/packages/core/src/__tests__/workhub-creation-intent.test.ts b/packages/core/src/__tests__/workhub-creation-intent.test.ts index 58f9e029ec..5919ddedb8 100644 --- a/packages/core/src/__tests__/workhub-creation-intent.test.ts +++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts @@ -23,18 +23,23 @@ import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, workHubCreationAuthorizesTitle, - workHubSessionReferenceNamesSession, + matchWorkHubSessionName, type WorkHubRequestIntent, } from '../workhub-creation-intent.js'; const intentFor = readWorkHubRequestIntent; -/** The stop Action Policy's own sufficiency rule, kept out of the matcher. */ -const workHubStopTargetsSession = (intent: WorkHubRequestIntent, sessionName: string): boolean => - Boolean( - intent.stop.imperative && - intent.stop.target && - workHubSessionReferenceNamesSession(intent.stop.target, sessionName), +/** + * The stop Action Policy, reproduced here over the shared matcher: a stop + * reference may carry punctuation after the name and nothing else. + */ +const workHubStopTargetsSession = (intent: WorkHubRequestIntent, sessionName: string): boolean => { + if (!intent.stop.imperative || !intent.stop.target) return false; + const match = matchWorkHubSessionName(intent.stop.target, sessionName); + return ( + match.kind === 'elided_name_punctuation' || + (match.kind === 'named' && /^[.!?。!?]*$/u.test(match.remainder)) ); +}; const affirmativeWorkHubExistingCorrectionTarget = (value: string) => intentFor(value).correction.existingTarget; const affirmativeWorkHubNamedCreationTitle = (value: string) => { diff --git a/packages/core/src/__tests__/workhub-session-resolver.test.ts b/packages/core/src/__tests__/workhub-session-resolver.test.ts index 35dc4f0294..30ca3666fb 100644 --- a/packages/core/src/__tests__/workhub-session-resolver.test.ts +++ b/packages/core/src/__tests__/workhub-session-resolver.test.ts @@ -41,11 +41,11 @@ const resolveText = (text: string, sessions: readonly WorkHubResolverSession[]) test('the exact-name resolver recalls one visible Session by opaque reference', () => { assert.deepEqual( resolveText('Stop Payments', [session('s1', 'Payments'), session('s2', 'Login')]), - { kind: 'ranked', candidates: [{ ref: 's1', evidence: 'exact_session_name' }] }, + { kind: 'ranked', candidates: [{ ref: 's1', evidence: { kind: 'named', remainder: '' } }] }, ); assert.deepEqual(resolveText('停止支付任务', [session('s1', '支付任务')]), { kind: 'ranked', - candidates: [{ ref: 's1', evidence: 'exact_session_name' }], + candidates: [{ ref: 's1', evidence: { kind: 'named', remainder: '' } }], }); }); @@ -62,8 +62,8 @@ test('equal exact matches are ambiguity rather than an unjustified ranking', () { kind: 'ambiguous', candidates: [ - { ref: 's1', evidence: 'exact_session_name' }, - { ref: 's2', evidence: 'exact_session_name' }, + { ref: 's1', evidence: { kind: 'named', remainder: '' } }, + { ref: 's2', evidence: { kind: 'named', remainder: '' } }, ], }, ); diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index ec5ccf3beb..c767cf5442 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -106,6 +106,18 @@ const DIRECT_CHINESE_STOP_REQUEST = const UNSAFE_STOP_TARGET = /^(?:it|this|that|one|everything|all|current|session|work|task|job|(?:this|that|current)\s+(?:session|work|task|job)|它|这个|那个|全部|当前|会话|工作|任务|(?:这个|那个|当前)(?:会话|工作|任务))$/iu; +/** + * Where a Session name matched inside a trusted reference, and what followed. + * + * `remainder` is neutral evidence, not a verdict: the action's policy decides + * whether that leftover text is acceptable for what it is about to do. + */ +export type WorkHubSessionNameMatch = + | { readonly kind: 'none' } + | { readonly kind: 'named'; readonly remainder: string } + /** The reference is the name with its own trailing punctuation dropped. */ + | { readonly kind: 'elided_name_punctuation' }; + /** How much authority trusted user text carries for starting work. */ export type WorkHubExecutionIntent = 'imperative' | 'ambiguous' | 'non_executable'; @@ -315,20 +327,6 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { }; } -/** - * Whether trusted reference text names exactly this Session. - * - * This is the matching rule behind the temporary exact-name Session Resolver. - * It answers a retrieval question only: naming a Session grants no authority - * over it, and the Action Gate revalidates the resolved opaque identity. - */ -export function workHubSessionReferenceNamesSession( - reference: string, - sessionName: string, -): boolean { - return stopTargetMatchesSession(reference, sessionName); -} - /** Whether a parsed correction names exactly this Session. */ export function workHubCorrectionTargetsSession( intent: WorkHubRequestIntent, @@ -394,10 +392,22 @@ function affirmativeWorkHubExistingCorrectionTarget(value: string): string | und return lastTarget; } -function correctionTargetMatchesSession(target: string, sessionName: string): boolean { - const normalizedTarget = normalizeCorrectionIdentity(target); +/** + * The one rule for reading a Session name out of a trusted reference. + * + * It is deliberately action-agnostic: it reports where the name matched and + * what text was left over, and says nothing about whether that leftover is + * acceptable. Each action's policy owns that question, because the answer + * genuinely differs — a stop reference may carry only punctuation after the + * name, while a correction may carry a further instruction. + */ +export function matchWorkHubSessionName( + reference: string, + sessionName: string, +): WorkHubSessionNameMatch { + const normalizedTarget = normalizeCorrectionIdentity(reference); const normalizedName = normalizeCorrectionIdentity(sessionName); - if (!normalizedName) return false; + if (!normalizedName) return { kind: 'none' }; const quotedNames = [ `"${normalizedName}"`, `“${normalizedName}”`, @@ -411,14 +421,35 @@ function correctionTargetMatchesSession(target: string, sessionName: string): bo normalizedTarget.startsWith(candidate) && !/[\p{L}\p{N}]/u.test(normalizedTarget[candidate.length] ?? ''), ); - if (!matchedName) return false; - if (matchedName === normalizedName && hasUnsafeUnquotedHardClauseBoundary(sessionName)) { - return false; + if (!matchedName) { + // A name whose own trailing punctuation the reference dropped still names + // it, but nothing may follow: there is no boundary left to trust. + return /[.!。!]$/u.test(normalizedName) && + normalizedTarget === normalizedName.replace(/[.!。!]+$/u, '').trim() + ? { kind: 'elided_name_punctuation' } + : { kind: 'none' }; } - if (hasUnquotedTerminalWithdrawal(target)) { - return false; + if (matchedName === normalizedName && hasUnsafeUnquotedHardClauseBoundary(sessionName)) { + return { kind: 'none' }; } - const remainder = normalizedTarget.slice(matchedName.length).trim(); + return { kind: 'named', remainder: normalizedTarget.slice(matchedName.length).trim() }; +} + +/** + * The correction policy's tail rule. A correction may name its target and then + * say what to do with it, but a withdrawal anywhere in the reference retracts + * the whole thing. + * + * It takes a match rather than a Session name so that a caller which already + * resolved candidates through the shared Session Resolver applies exactly this + * rule to exactly that recall, instead of matching names a second time. + */ +export function workHubCorrectionAdmitsReference( + reference: string, + match: WorkHubSessionNameMatch, +): boolean { + if (match.kind !== 'named' || hasUnquotedTerminalWithdrawal(reference)) return false; + const { remainder } = match; if (!remainder || /^(?:instead\s*)?[.!?。!?]?$/iu.test(remainder)) return true; const supplemental = remainder.match(/^[,;,;]\s*(.+)$/u)?.[1]?.trim(); const supplementalBody = supplemental?.replace(/[.!?。!?]+\s*$/u, '').trim(); @@ -431,6 +462,10 @@ function correctionTargetMatchesSession(target: string, sessionName: string): bo ); } +function correctionTargetMatchesSession(target: string, sessionName: string): boolean { + return workHubCorrectionAdmitsReference(target, matchWorkHubSessionName(target, sessionName)); +} + function directWorkHubStopTarget(value: string, malformedLiteral: boolean): string | undefined { if (malformedLiteral || /[??]\s*$/u.test(value)) return undefined; const match = DIRECT_STOP_REQUEST.exec(value) ?? DIRECT_CHINESE_STOP_REQUEST.exec(value); @@ -441,35 +476,6 @@ function directWorkHubStopTarget(value: string, malformedLiteral: boolean): stri return target; } -function stopTargetMatchesSession(target: string, sessionName: string): boolean { - const normalizedTarget = normalizeCorrectionIdentity(target); - const normalizedName = normalizeCorrectionIdentity(sessionName); - if (!normalizedName) return false; - const quotedNames = [ - `"${normalizedName}"`, - `“${normalizedName}”`, - `'${normalizedName}'`, - `‘${normalizedName}’`, - ]; - const matchedName = [normalizedName, ...quotedNames] - .sort((left, right) => right.length - left.length) - .find( - (candidate) => - normalizedTarget.startsWith(candidate) && - !/[\p{L}\p{N}]/u.test(normalizedTarget[candidate.length] ?? ''), - ); - if (matchedName) { - if (matchedName === normalizedName && hasUnsafeUnquotedHardClauseBoundary(sessionName)) { - return false; - } - return /^[.!?。!?]*$/u.test(normalizedTarget.slice(matchedName.length).trim()); - } - return ( - /[.!。!]$/u.test(normalizedName) && - normalizedTarget === normalizedName.replace(/[.!。!]+$/u, '').trim() - ); -} - function directWorkHubStopCue(value: string, malformedLiteral: boolean): boolean { if (malformedLiteral || /[??]\s*$/u.test(value)) return false; return Boolean(DIRECT_STOP_REQUEST.test(value) || DIRECT_CHINESE_STOP_REQUEST.test(value)); diff --git a/packages/core/src/workhub-session-resolver.ts b/packages/core/src/workhub-session-resolver.ts index d90f1a391f..eb063f73fe 100644 --- a/packages/core/src/workhub-session-resolver.ts +++ b/packages/core/src/workhub-session-resolver.ts @@ -17,7 +17,10 @@ * under the License. */ -import { workHubSessionReferenceNamesSession } from './workhub-creation-intent.js'; +import { + matchWorkHubSessionName, + type WorkHubSessionNameMatch, +} from './workhub-creation-intent.js'; /** * The shared Session Resolver port. @@ -59,8 +62,16 @@ export interface WorkHubResolverSession { readonly updatedAt: number; } -/** Why a candidate was recalled. Evidence explains a ranking; it authorizes nothing. */ -export type WorkHubSessionResolutionEvidence = 'exact_session_name'; +/** + * Why a candidate was recalled. Evidence explains a recall and authorizes + * nothing, but it must be rich enough for an action's policy to apply its own + * rules — so exact naming reports the reference text left over after the name, + * which stop and correction are each entitled to judge differently. + */ +export type WorkHubSessionResolutionEvidence = Exclude< + WorkHubSessionNameMatch, + { readonly kind: 'none' } +>; export interface WorkHubSessionCandidate { readonly ref: string; @@ -93,15 +104,12 @@ export type WorkHubSessionResolution = export function createExactNameSessionResolver(): WorkHubSessionResolver { return { resolve({ reference, sessions }) { - const named = sessions.filter((session) => - workHubSessionReferenceNamesSession(reference.text, session.sessionName), - ); - const candidates = named.map( - (session): WorkHubSessionCandidate => ({ - ref: session.ref, - evidence: 'exact_session_name', - }), - ); + const candidates: WorkHubSessionCandidate[] = []; + for (const session of sessions) { + const match = matchWorkHubSessionName(reference.text, session.sessionName); + if (match.kind === 'none') continue; + candidates.push({ ref: session.ref, evidence: match }); + } const [first, ...rest] = candidates; if (!first) return { kind: 'none' }; // Exact naming has no score to separate equals by, so more than one match From 13ec555903a28208fd28743a460289e84969ad3a Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 2 Sep 2026 21:58:46 +0800 Subject: [PATCH 11/19] refactor(workhub): let the Host alone prove sole delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stop proposal carried the active delegation set the policy observed, and the Gate compared it against current state. That proof was already made from durable facts under the admission lease, where it is authoritative; the client-supplied copy could not reject anything the durable proof would admit, and as a member of a closed protocol shape it would have cost another compatibility epoch to remove later. Drop it. The proposal keeps the one precondition it can meaningfully assert — the Session it resolved the delegation against — and the Gate proves sole-active-delegation from the assignments it just read. Reported by Astro-Han in review of #4439. Generated-by: Claude Opus --- .../main/__tests__/workhub-controller.test.ts | 2 +- .../src/renderer/workhub-controller.ts | 4 +-- .../__tests__/execution-composition.test.ts | 2 +- .../workhub-coordination-coordinator.test.ts | 13 ++++---- .../workhub-coordination-protocol.test.ts | 31 +++++-------------- .../src/protocol/workhub-coordination.ts | 28 +++-------------- 6 files changed, 23 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 152c2f8893..1f719d8d24 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -381,7 +381,7 @@ test('direct stop bypasses routing candidates and preserves a not_owned delegati proposal: { disposition: 'stop_work', stopsActionId: 'action-1', - expects: { targetSessionId: 'payments', activeActionIds: ['action-1'] }, + expects: { targetSessionId: 'payments' }, }, confirmation: { kind: 'user_stop' }, }]); diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 0ba80a49ad..ca84124124 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -495,7 +495,7 @@ export function createWorkHubController(deps: { reason: stopDecision.reason, }; } - const { target, stopsActionId, activeActionIds } = stopDecision; + const { target, stopsActionId } = stopDecision; const admitted = await coordination.act({ actionId: input.requestId, userText: input.text, @@ -505,7 +505,7 @@ export function createWorkHubController(deps: { // The proposal carries only opaque identities and the state the // policy resolved against. The Action Gate revalidates both, so a // resolution that went stale is refused rather than acted on. - expects: { targetSessionId: target.sessionId, activeActionIds }, + expects: { targetSessionId: target.sessionId }, }, confirmation: { kind: 'user_stop' }, }); diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 092e4626da..e580825fb3 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -757,7 +757,7 @@ test('WorkHub correction replaces its link without stopping a shared manual Turn proposal: { disposition: 'stop_work', stopsActionId: assignment.actionId, - expects: { targetSessionId: source.id, activeActionIds: [assignment.actionId] }, + expects: { targetSessionId: source.id }, }, }, context, 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 96092b50c8..e0ed99bb1f 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -629,7 +629,7 @@ describe('Host WorkHub Coordination coordinator', () => { proposal: { disposition: 'stop_work', stopsActionId: 'source-action', - expects: { targetSessionId: target.id, activeActionIds: ['source-action'] }, + expects: { targetSessionId: target.id }, }, confirmation: { kind: 'user_stop' }, }, @@ -671,7 +671,7 @@ describe('Host WorkHub Coordination coordinator', () => { proposal: { disposition: 'stop_work', stopsActionId: 'source-action', - expects: { targetSessionId: targetId, activeActionIds: ['source-action'] }, + expects: { targetSessionId: targetId }, }, confirmation: { kind: 'user_stop' }, }, @@ -772,7 +772,7 @@ describe('Host WorkHub Coordination coordinator', () => { proposal: { disposition: 'stop_work', stopsActionId: 'source-action', - expects: { targetSessionId: target.id, activeActionIds: ['source-action'] }, + expects: { targetSessionId: target.id }, }, confirmation: { kind: 'user_stop' }, }, @@ -867,7 +867,7 @@ describe('Host WorkHub Coordination coordinator', () => { proposal: { disposition: 'stop_work', stopsActionId: 'source-action', - expects: { targetSessionId: target.id, activeActionIds: ['source-action'] }, + expects: { targetSessionId: target.id }, }, confirmation: { kind: 'user_stop' }, }, @@ -898,7 +898,7 @@ describe('Host WorkHub Coordination coordinator', () => { proposal: { disposition: 'stop_work' as const, stopsActionId: 'source-action', - expects: { targetSessionId: targetId, activeActionIds: ['source-action'] }, + expects: { targetSessionId: targetId }, }, confirmation: { kind: 'user_stop' as const }, }); @@ -1015,7 +1015,7 @@ describe('Host WorkHub Coordination coordinator', () => { proposal: { disposition: 'stop_work' as const, stopsActionId: 'login-action', - expects: { targetSessionId: loginSessionId, activeActionIds: ['login-action'] }, + expects: { targetSessionId: loginSessionId }, }, confirmation: { kind: 'user_stop' as const }, }); @@ -1077,7 +1077,6 @@ describe('Host WorkHub Coordination coordinator', () => { stopsActionId: 'source-action', expects: { targetSessionId: targets.find((session) => session.name === 'Payments')!.id, - activeActionIds: ['source-action'], }, }, confirmation: { kind: 'user_stop' }, 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 a9b8bf893b..84579f7c31 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -92,7 +92,7 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () proposal: { disposition: 'stop_work', stopsActionId: 'action-payments', - expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, + expects: { targetSessionId: 'payments' }, }, confirmation: { kind: 'user_stop' }, }), @@ -102,7 +102,7 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () proposal: { disposition: 'stop_work', stopsActionId: 'action-payments', - expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, + expects: { targetSessionId: 'payments' }, }, confirmation: { kind: 'user_stop' }, }, @@ -114,7 +114,7 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () proposal: { disposition: 'stop_work', stopsActionId: 'action-payments', - expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, + expects: { targetSessionId: 'payments' }, }, }, { @@ -123,7 +123,7 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () proposal: { disposition: 'stop_work', stopsActionId: 'action-payments', - expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, + expects: { targetSessionId: 'payments' }, }, confirmation: { kind: 'user_correction' }, }, @@ -133,7 +133,7 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () proposal: { disposition: 'stop_work', stopsActionId: 'action-payments', - expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, + expects: { targetSessionId: 'payments' }, targetSessionId: 'injected', }, confirmation: { kind: 'user_stop' }, @@ -145,29 +145,14 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () proposal: { disposition: 'stop_work', stopsActionId: 'action-payments' }, confirmation: { kind: 'user_stop' }, }, + // Preconditions are a closed shape: no room for a second, client-asserted proof. { - actionId: 'action-stop-unbounded-preconditions', - userText: 'Stop Payments', - proposal: { - disposition: 'stop_work', - stopsActionId: 'action-payments', - expects: { - targetSessionId: 'payments', - activeActionIds: Array.from({ length: 33 }, (_unused, index) => `action-${index}`), - }, - }, - confirmation: { kind: 'user_stop' }, - }, - { - actionId: 'action-stop-duplicate-preconditions', + actionId: 'action-stop-extra-precondition', userText: 'Stop Payments', proposal: { disposition: 'stop_work', stopsActionId: 'action-payments', - expects: { - targetSessionId: 'payments', - activeActionIds: ['action-payments', 'action-payments'], - }, + expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, }, confirmation: { kind: 'user_stop' }, }, diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 586c4132bf..edcf677b72 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -147,14 +147,12 @@ export type WorkHubCoordinationProposal = }; export interface WorkHubCoordinationStopPreconditions { - /** Session the resolved delegation was proposed against. */ - readonly targetSessionId: string; /** - * Every active WorkHub delegation the policy observed for that Session. Stop - * admits only a sole active delegation, so a concurrent delegation to the - * same Session invalidates the proposal rather than silently widening it. + * Session the resolved delegation was proposed against. Sole-active-delegation + * is proved by the Host from durable state under the admission lease, so the + * proposal states only what it resolved, never its own proof. */ - readonly activeActionIds: readonly string[]; + readonly targetSessionId: string; } export type WorkHubCoordinationDestructiveConfirmation = @@ -637,25 +635,9 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP function decodeWorkHubCoordinationStopPreconditions( value: unknown, ): WorkHubCoordinationStopPreconditions { - const expects = requireExactRecord(value, 'WorkHub stop preconditions', [ - 'targetSessionId', - 'activeActionIds', - ]); - if (!Array.isArray(expects.activeActionIds)) { - throw invalidProtocolFrame('Invalid WorkHub stop preconditions'); - } - if (expects.activeActionIds.length > WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS) { - throw invalidProtocolFrame('Too many WorkHub expected active delegations'); - } - const activeActionIds = expects.activeActionIds.map((actionId) => - requireEntityId(actionId, 'WorkHub expected active action id'), - ); - if (new Set(activeActionIds).size !== activeActionIds.length) { - throw invalidProtocolFrame('Duplicate WorkHub expected active delegation'); - } + const expects = requireExactRecord(value, 'WorkHub stop preconditions', ['targetSessionId']); return { targetSessionId: requireEntityId(expects.targetSessionId, 'WorkHub target Session id'), - activeActionIds, }; } From dc9511bea0a8430369b5b00b264c91ca09b0b877 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 2 Sep 2026 22:12:14 +0800 Subject: [PATCH 12/19] fix(workhub): never read an unrecovered root as already terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root registration is in-memory. Between a Host restart and execution recovery a still-running root looks inactive, so `stopOwnedWorkHubRoot` skipped `stopRoot`, read a snapshot that was still `running`, and reported `already_terminal` — which the Gate then commits as an immutable `delegation_stop_resolved`. The user is told the work stopped while the Turn keeps running. `already_terminal` is an observation that can never be revised, so only a durably terminal snapshot may claim it. Anything else is `recovering`, which admission already handles by leaving the claim unresolved for a later attempt. `readDelegationRetirement` proved retirement the same way and gains the same rule: an unregistered root is not evidence that its work ended. Reported by Astro-Han in review of #4439. Generated-by: Claude Opus --- .../workhub-action-resolution-design.md | 326 ------------------ .../__tests__/execution-composition.test.ts | 36 ++ .../src/server/execution-composition.ts | 34 +- 3 files changed, 60 insertions(+), 336 deletions(-) delete mode 100644 docs/architecture/workhub-action-resolution-design.md diff --git a/docs/architecture/workhub-action-resolution-design.md b/docs/architecture/workhub-action-resolution-design.md deleted file mode 100644 index 4e4d0697c0..0000000000 --- a/docs/architecture/workhub-action-resolution-design.md +++ /dev/null @@ -1,326 +0,0 @@ - - -# WorkHub action and Session resolution design - -- Status: Proposed -- Date: 2026-09-02 -- Scope: WorkHub routing, Session resolution, and action admission -- Architecture source: [Discussion #3286](https://github.com/apache/maka/discussions/3286) -- Delivery tracker: [Issue #3492](https://github.com/apache/maka/issues/3492) -- Review source: [PR #4439 architecture discussion](https://github.com/apache/maka/pull/4439#issuecomment-5496214312) - -## Purpose - -This document preserves the path from the first WorkHub experiment to the -current production slices, records why the original R2.4/R3 experiment needs a -sharper boundary, and defines the work required to reach that boundary. - -The central change is: - -> WorkHub first identifies the kind of action the user is requesting, then uses -> one shared Session Resolver to recall existing work, and finally lets an -> action-specific deterministic policy decide whether that resolution is -> sufficient to propose an action. The Action Gate remains the authority that -> revalidates and admits the proposal before any write or effect. - -## History and original experiment branches - -The first feasibility work intentionally explored the whole user experience -before the production authority boundaries were settled. - -| Branch or PR | Purpose | Outcome | -| --- | --- | --- | -| `codex/unified-session-experiment` | End-to-end feasibility prototype for one conversational entry point, work cards, routing, clarification, creation, and coordination | Demonstrated the user model; not a production authority design | -| `codex/workhub-mainline` / #3426 | First integrated WorkHub implementation | Closed after the work was decomposed | -| `codex/workhub-session-router` / #3497 | Conservative R2.3 Session router | Merged | -| `codex/workhub-rebuildable-projection` / #3648 | Rebuild WorkHub from ordinary Session transcripts | Merged | -| `codex/workhub-context-continuity` / #3674 | R2.4 deterministic context continuity and correction behavior | Merged as the deterministic baseline | - -Four later local branches implemented the original post-Slice-5 plan ahead of -review. They are useful prototypes, but they predate the shared Resolver design -and are not suitable for direct publication without rebasing and redesign: - -| Branch | Original purpose | -| --- | --- | -| `feat/workhub-routing-strategies` | Versioned R2.4, R3-A, and R3-B strategy interface | -| `test/workhub-routing-evaluation` | Common routing evaluation harness | -| `feat/workhub-routing-rollout` | Feature-flagged strategy rollout and telemetry | -| `feat/workhub-anchor-rail` | Filtered, rebuildable WorkHub Anchor Rail | - -## Original routing plan - -The original plan compared three complete routing strategies behind the same -Action Gate: - -| Strategy | Disposition decision | Target selection | -| --- | --- | --- | -| R2.4 baseline | Deterministic regex and heuristics | Deterministic exact-name, lexical/core-entity, focus, and recency rules | -| R3-A model-direct | Model | Model selects one opaque reference from bounded valid candidates | -| R3-B model-gated-R2.4 | Model | R2.4 selects the target only after `delegate_existing` | - -The evaluation plan called for a fixed Session snapshot, common model and -reasoning settings, bounded candidate summaries, the same Coordination -transcript prefix and Runtime facts, repeated model-backed runs, separate -disposition and target accuracy, safety metrics, latency, tokens, and cost. - -This was a valid experiment plan, but its strategy boundary was too coarse. -R2.4 combined action recognition, existing-Session retrieval, target selection, -creation, clarification, and final disposition in one policy. R3-A similarly -asked one model decision to select both behavior and target. Those shapes make -it difficult to improve Session recall without changing action semantics, or to -tell whether an error came from intent recognition, retrieval, or policy. - -## Production work already delivered - -The production implementation kept the important authority boundaries while -delivering the tracker in reviewable slices: - -| Slice | Delivery | Implemented boundary | -| --- | --- | --- | -| 1 | #3742 | Per-Runtime-Host Coordination Session ADR and domain language | -| 2 | #3764 | Stable Coordination Session lifecycle, recovery, Host scope, and self-route exclusion | -| 3 | #3798 | Persistent WorkHub conversation and `answer_here` | -| 4 | #3818 | Typed non-destructive coordination protocol and deterministic Action Gate | -| 5A | #3935 | Durable delegation linkage and atomic target admission | -| 5B | #4115 | Rebuildable delegated execution-status projection | -| 5C | #4242 | Linked correction, exact Message ownership, replacement arbitration, and replay | -| 5D | #4439 | Direct stop claims, pending cancellation, owning-root Stop, and stop/replacement arbitration; under review | - -Projection checkpoint stabilization in #4210 supports this path without adding -a second lifecycle authority. - -These pieces remain valid under the new design. In particular, the Coordination -Session, target Session authority, durable delegation identities, and Action -Gate do not depend on one natural-language resolver. - -## Problem exposed by direct stop - -The first direct-stop path recognizes stop-specific text and performs exact -Session display-name matching before it enters the durable action protocol. -Although intentionally conservative, leaving that implementation embedded in -the stop path would establish a second target resolver by construction. Future -actions such as inspect, continue, pause, and resume would then tend to acquire -their own parsers and target rules. - -Display names and raw messages are useful retrieval evidence. They are not -stable execution authority. A destructive action must ultimately refer to an -opaque Session/delegation identity and be revalidated against current Runtime -facts by the Action Gate. - -## Target architecture - -```text -user input - -> Action Intent - -> shared Session Resolver, when the action may refer to existing work - -> per-action policy - -> typed Action Proposal - -> deterministic Action Gate - -> durable persistence and owning-Host execution -``` - -### Action Intent - -Action Intent identifies what the user is trying to do, for example discuss, -delegate, inspect, continue, stop, or resume. It carries trusted evidence from -the user input but does not select a Session and does not authorize an effect. - -The first implementation may use deterministic parsing. A later classifier may -use a model, but its output remains advisory and bounded. - -### Session Resolver - -The shared Session Resolver answers only which visible existing Sessions are -relevant to the user's reference. Its result is one of: - -```text -ranked existing Session candidates -none -ambiguous candidates -``` - -It does not return `create_new`, decide the final action, or grant authority. -Resolver inputs may include structured Session references, permitted Session -metadata, current and previous focus, recency, active/running state, active -delegation presence, and permitted raw-message evidence. Resolver output uses -opaque Runtime-issued candidate references rather than model-invented Session -identities. - -The initial implementation can preserve exact-name behavior behind a shared -`SessionResolver` contract. A later deterministic ranked resolver can add -lexical retrieval such as BM25. Any index is a rebuildable projection: hidden, -archived, or logically deleted Sessions are included or excluded by explicit -visibility policy, and the index never becomes Session lifecycle authority. - -Raw cross-Session messages may support retrieval, but are not injected into the -target execution context merely because they matched. - -### Action Policy - -An action-specific deterministic policy combines Action Intent, Session -resolution, and current product rules. It decides whether to: - -- propose an action against an existing Session; -- explicitly create new work; -- ask the user to clarify; -- answer in the Coordination Session; or -- reject the request safely. - -`create_new` belongs here, not in Session retrieval. A policy may skip existing -Session resolution when the trusted user request unambiguously requires a new -Work and existing work is irrelevant. Otherwise, it can resolve first and allow -creation only when explicit creation evidence and the absence of a suitable -existing Session satisfy that action's rules. When WorkHub creates a Work, the -result must say so explicitly to the user. - -Different actions have different sufficiency rules. Stop may require one unique -active WorkHub delegation; inspect may allow several ranked read-only results; -delegate may clarify, reuse an existing Session, or create a new one; resume may -require a resumable lifecycle state. - -### Typed Action Proposal - -The policy produces a closed typed proposal containing stable target identities -and expected-state preconditions. It is not yet permission to execute. Natural -language, display names, relevance scores, and model explanations are evidence, -not durable identifiers. - -### Action Gate and execution - -The existing Action Gate remains the final deterministic admission boundary. It -revalidates current Host scope, target existence, visibility and lifecycle, -ownership, active delegation identity, idempotency, confirmation, tools, and -permissions immediately before persistence or execution. - -The owning Host then persists the admitted action and executes it through the -authoritative target Session. Retrieval indexes and WorkHub projections remain -rebuildable and non-authoritative. - -## Reframing the R-series experiment - -The original R2.4/R3-A/R3-B work should be retained as experimental hypotheses, -but expressed as replaceable components rather than complete routers: - -| Arm | Action Intent | Session Resolver | Action Policy and Gate | -| --- | --- | --- | --- | -| Deterministic baseline | Deterministic parser | Exact-name plus deterministic lexical/focus rules | Deterministic | -| Model-intent + deterministic resolution | Bounded model classifier | Deterministic ranked resolver | Deterministic | -| Model-intent + model-ranked resolution | Bounded model classifier | Model ranks opaque bounded candidates | Deterministic | - -This preserves the safety comparison while exposing where each error occurs. -Evaluation must separately report intent classification, candidate recall, -ranking/target accuracy, policy outcome, gate rejection, and downstream target -execution. - -## Required work - -### 1. Establish the port in Slice 5D - -- Introduce the shared `SessionResolver` contract. -- Put the current exact-name behavior behind a temporary deterministic - implementation. -- Make direct stop consume the resolved opaque Session/delegation identity. -- Move stop admission off display names: the proposal carries the opaque - delegation identity plus the expected active-delegation state, and the Action - Gate revalidates that state instead of re-deriving a name match from user text. -- Keep durable stop execution, replay, ownership, and arbitration unchanged. -- Avoid documenting exact-name stop grammar as the long-term product contract. -- Record follow-up removal criteria for the temporary resolver. - -Admission by expected state rather than by name is what makes the port real: a -ranked resolver can change how a Session is recalled without touching the durable -stop protocol, and a rename between resolution and admission stops being able to -invalidate a claim. The Gate no longer proves that user text names the target, so -that binding now rests where it belongs — the stop proposal is produced by trusted -policy, never by strategy output, and `user_stop` confirmation stays outside the -proposal a model can influence. - -Correction still resolves its own target and moves onto the port under item 2, -together with continue, inspect, and resume. - -The temporary resolver can be removed when all target-bearing WorkHub actions use -the shared contract, the replacement resolver passes the common evaluation, and -the rollout retains a tested rollback path. - -### 2. Build the deterministic shared Resolver baseline - -- Define visibility and candidate-bounding policy. -- Combine structured references, exact names, focus, recency, lifecycle, active - delegation, and deterministic lexical evidence. -- Return ranked candidates with typed evidence and explicit none/ambiguity. -- Route continue, inspect, stop, resume, and delegation through the same port. - -### 3. Add rebuildable lexical retrieval - -- Index permitted Session metadata and bounded raw-message chunks. -- Start with a deterministic BM25 shadow implementation. -- Aggregate message hits by Session identity, then rank Sessions using a small, - observable feature set. -- Measure recall and ranking without granting actions or injecting matched text - into execution context. - -### 4. Rebuild the experiment harness - -- Rebase useful code from `feat/workhub-routing-strategies` and - `test/workhub-routing-evaluation` onto the component boundaries above. -- Hold the Session snapshot, transcript, Runtime facts, model configuration, and - inputs constant across arms. -- Report each pipeline stage separately and repeat model-backed runs. -- Preserve adversarial tests for prompt injection, stale candidates, ambiguous - references, implicit creation, and destructive actions. - -### 5. Select and roll out a production composition - -- Select intent and resolver implementations from evidence. -- Adapt the useful flag, telemetry, and rollback ideas from - `feat/workhub-routing-rollout`. -- Shadow new resolution before it can propose effects. -- Keep the Action Gate and ordinary Session authority invariant across rollout. - -### 6. Complete projection enhancements independently - -- Rebase the useful filtered Anchor Rail work from `feat/workhub-anchor-rail`. -- Add Work filtering and generation-safe bounded refresh. -- Keep every projection non-authoritative and rebuildable. - -## Acceptance criteria - -- Stop, continue, inspect, resume, and delegation do not own separate natural- - language target resolvers. -- `create_new` is never emitted by Session retrieval. -- Every executable proposal contains opaque stable identities and expected-state - preconditions. -- The Action Gate revalidates all authority immediately before effects. -- Resolver replacement requires no change to durable stop/delegation protocols. -- Evaluation attributes failures to the correct pipeline stage. -- Creating a new Work is explicit in both trusted input evidence and user-visible - acknowledgement. -- Resolver indexes and UI projections can be discarded and rebuilt without - losing Session or coordination truth. - -## Deferred decisions - -- Whether Work remains 1:1 with Session, becomes 1:N, or gains an independent - durable identity. -- Cross-Runtime-Host coordination. -- The final retrieval algorithm and ranking weights. -- Large-scale semantic/vector retrieval beyond the deterministic baseline. -- Removing R2.4 compatibility behavior before evaluation and rollback criteria - are satisfied. diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index e580825fb3..24d959e984 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -99,6 +99,42 @@ test('WorkHub recovers a delivered root Stop from its durable cancelled Turn', a assert.equal(stopCalls, 0); }); +test('WorkHub never reports a still-running root as already terminal', async () => { + // The restart window: the execution is not registered in memory yet, so the + // root looks inactive while its durable snapshot is still running. + const outcome = await stopOwnedWorkHubRoot( + { + readRootState: () => ({ kind: 'idle' }), + read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({ + ...identity, + status: 'running', + }), + stopRoot: async () => assert.fail('an unregistered root cannot be stopped'), + } as unknown as Parameters[0], + { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' }, + 'workhub-stop-action', + ); + + assert.deepEqual(outcome, { outcome: 'recovering', targetTurnId: 'target-turn' }); + + // A durably terminal snapshot is still the proof `already_terminal` needs. + const settled = await stopOwnedWorkHubRoot( + { + readRootState: () => ({ kind: 'idle' }), + read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({ + ...identity, + status: 'completed', + terminalEventId: 'terminal-complete', + }), + stopRoot: async () => assert.fail('a completed root cannot be stopped'), + } as unknown as Parameters[0], + { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' }, + 'workhub-stop-action', + ); + + assert.deepEqual(settled, { outcome: 'already_terminal', targetTurnId: 'target-turn' }); +}); + test('WorkHub binds a fresh owning-root Stop to its action identity', async () => { let source: string | undefined; let actionId: string | undefined; diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 26607f6719..27d3e69c4b 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -74,6 +74,7 @@ import { } from '@maka/runtime/shell-detect'; import { type MakaTool } from '@maka/runtime/tool-runtime'; import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority'; +import { isHostedExecutionTerminal } from './hosted-execution-authority.js'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; import { createArtifactAttachmentResourceReader } from '@maka/storage/artifact-stores'; import { createReadImageSnapshotStore } from '@maka/storage/read-image-snapshot-store'; @@ -1358,12 +1359,16 @@ export async function createExecutionRuntimeHostComposition( if (disposition.kind === 'cancelled' || disposition.kind === 'shared_turn') { return 'retired'; } - const rootState = coordinator.readRootState(assignment.targetSessionId); - return rootState.kind === 'active' && - rootState.turnId === disposition.turnId && - rootState.runId === disposition.runId - ? 'not_retired' - : 'retired'; + const identity = { + sessionId: assignment.targetSessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }; + if (isActiveWorkHubRoot(coordinator, identity)) return 'not_retired'; + // The same restart window as `stopOwnedWorkHubRoot`: an unregistered + // root is not evidence that its work ended. + const snapshot = await coordinator.read(identity); + return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; }, retireDelegation: async (assignment, retirement) => { const disposition = await messages.cancelMessageIfPending( @@ -2005,7 +2010,7 @@ export async function stopOwnedWorkHubRoot( identity: { readonly sessionId: string; readonly turnId: string; readonly runId: string }, actionId: string, ): Promise<{ - readonly outcome: 'stop_delivered' | 'already_terminal'; + readonly outcome: 'stop_delivered' | 'already_terminal' | 'recovering'; readonly targetTurnId: string; }> { if (isActiveWorkHubRoot(coordinator, identity)) { @@ -2015,10 +2020,19 @@ export async function stopOwnedWorkHubRoot( }); } const terminal = await coordinator.read(identity); - return terminal.status === 'cancelled' && + if ( + terminal.status === 'cancelled' && terminal.abortSource === workHubDirectStopAbortSource(actionId) - ? { outcome: 'stop_delivered', targetTurnId: identity.turnId } - : { outcome: 'already_terminal', targetTurnId: identity.turnId }; + ) { + return { outcome: 'stop_delivered', targetTurnId: identity.turnId }; + } + // Registration is in-memory, so between Host restart and execution recovery + // this root looks inactive while it is still running. `already_terminal` is + // committed as an immutable fact, so only a durably terminal snapshot may + // claim it; anything else is still resolving. + return isHostedExecutionTerminal(terminal) + ? { outcome: 'already_terminal', targetTurnId: identity.turnId } + : { outcome: 'recovering', targetTurnId: identity.turnId }; } /** From e1fe6296a44fcf76b596b2079b341b3f75eb5793 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 2 Sep 2026 22:12:33 +0800 Subject: [PATCH 13/19] fix(workhub): let finished delegations stop competing for stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing records that a delegation's work is done. A link ends only by supersession or a resolved stop, so delegating to one Session twice and letting the first complete normally left it with two permanently active delegations: the renderer answered `stop_target_not_unique`, the Gate and coordinator refused on the count, and that Session could never be direct-stopped again. The two senses of "active" were being conflated. The link is still active — WorkHub still owns it, and correction still works through it — but a delegation whose work has finished is not a competing stop target, because there is nothing left in it to stop. Only work that could still be stopped makes a Session's stop target ambiguous. Prove that from the target Session, which owns execution state, through the existing non-destructive `readDelegationRetirement`. The renderer applies the same rule to its own delegation mirror using the read-only execution projection it already receives. Execution state that cannot be read counts as competing, never as finished, so the stop still fails closed while the owner is unresolved. This deliberately does not add a completion fact to the coordination log: target execution state stays an ordinary Session fact that WorkHub reads as a rebuildable projection. Reported by Astro-Han in review of #4439. Generated-by: Claude Opus --- .../src/renderer/workhub-controller.ts | 31 ++++++- .../workhub-coordination-action-gate.test.ts | 80 ++++++++++++++++++- .../workhub-coordination-action-gate.ts | 37 ++++++++- .../workhub-coordination-coordinator.ts | 26 ++++-- 4 files changed, 157 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index ca84124124..8b274d076d 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -128,6 +128,13 @@ export interface WorkHubActiveDelegation { readonly sequence: number; } +/** Target-owned execution states that leave a delegation with nothing to stop. */ +const SETTLED_DELEGATION_STATES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'aborted', +]); + const WORKHUB_TIMELINE_TEXT_LIMIT = 600; export function boundedWorkHubTimelineText(value: string): string { @@ -273,6 +280,18 @@ export function createWorkHubController(deps: { let focusReadVersion = 0; let pendingFocusReadVersion: number | undefined; const activeActionIdsBySessionId = new Map(); + /** + * A delegation link ends only by supersession or a resolved stop, so work + * that simply finished stays linked. It is no longer a stop target, though: + * counting it would make a Session delegated to twice look permanently + * ambiguous once the first delegation completed. Execution state is a + * read-only target-owned projection, so an unreadable one is never settled. + */ + const settledActionIds = new Set(); + const stoppableActionIds = (sessionId: string): readonly string[] => + (activeActionIdsBySessionId.get(sessionId) ?? []).filter( + (actionId) => !settledActionIds.has(actionId), + ); const removeActiveAction = (sessionId: string, actionId: string) => { const remaining = (activeActionIdsBySessionId.get(sessionId) ?? []).filter( (candidate) => candidate !== actionId, @@ -378,9 +397,13 @@ export function createWorkHubController(deps: { handler(turns.map((turn) => { if (!turn.assignment) return turn; const next = feedbackByDelegationId.get(turn.assignment.delegationId); - return next - ? { ...turn, assignment: { ...turn.assignment, feedbackState: next.state } } - : turn; + if (!next) return turn; + if (SETTLED_DELEGATION_STATES.has(next.state)) { + settledActionIds.add(turn.assignment.actionId); + } else { + settledActionIds.delete(turn.assignment.actionId); + } + return { ...turn, assignment: { ...turn.assignment, feedbackState: next.state } }; })); }; @@ -481,7 +504,7 @@ export function createWorkHubController(deps: { projectName: session.projectName, sessionName: session.sessionName, updatedAt: session.updatedAt, - activeActionIds: activeActionIdsBySessionId.get(session.target.sessionId) ?? [], + activeActionIds: stoppableActionIds(session.target.sessionId), })), }); if (stopDecision.kind !== 'not_requested') { 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 bfa78e938b..ebee3b132e 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 @@ -423,6 +423,78 @@ describe('WorkHub Coordination Action Gate', () => { ); }); + test('a finished delegation stops competing for the sole-delegation proof', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + for (const actionId of ['finished-action', 'live-action']) { + effects.assignmentRecords.set( + actionId, + assignmentRecord( + { + actionId, + actionFingerprint: `sha256:${(actionId === 'live-action' ? '6' : '7').repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: `Work from ${actionId}`, + }, + `${actionId}-turn`, + ), + ); + } + // The link outlives the work, so the completed delegation is still active. + const settled = new Set(['delegation-finished-action']); + effects.readDelegationRetirement = async (assignment) => + settled.has(assignment.delegationId) ? 'retired' : 'not_retired'; + + const stopped = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-live', + userText: 'Stop Payments', + proposal: stopProposal('live-action', 'payments'), + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + assert.equal(stopped.disposition, 'stop_work'); + + }); + + test('a competitor the Host cannot resolve yet fails the stop closed', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + for (const actionId of ['unreadable-action', 'live-action']) { + effects.assignmentRecords.set( + actionId, + assignmentRecord( + { + actionId, + actionFingerprint: `sha256:${(actionId === 'live-action' ? '6' : '7').repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: `Work from ${actionId}`, + }, + `${actionId}-turn`, + ), + ); + } + // Unreadable is not the same as finished, so it still blocks the proof. + effects.readDelegationRetirement = async () => 'recovering'; + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-unresolved-competitor', + userText: 'Stop Payments', + proposal: stopProposal('live-action', 'payments'), + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.stopRequests.size, 0); + }); + test('rejects a stop that does not identify one active durable delegation', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); for (const actionId of ['source-action', 'other-action']) { @@ -2607,10 +2679,12 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { stopResolutions.set(request.stopsDelegationId, resolved); return resolved; }, - async readDelegationRetirement(assignment: WorkHubDelegationAssignedMessage) { + async readDelegationRetirement( + assignment: WorkHubDelegationAssignedMessage, + ): Promise<'not_retired' | 'retired' | 'recovering'> { return this.retirements.some((retired) => retired.delegationId === assignment.delegationId) - ? ('retired' as const) - : ('not_retired' as const); + ? 'retired' + : 'not_retired'; }, async retireDelegation( assignment: WorkHubDelegationAssignedMessage, 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 e5a3ef1952..4ce2931f68 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -393,10 +393,19 @@ export class WorkHubCoordinationActionGate { // Authority is the opaque delegation identity, never the display name the // Resolver recalled it by. This is the advisory read; the coordinator // reproves it from durable state under the admission lease. - const targetActive = activeAssignments.filter( - (assignment) => assignment.targetSessionId === source.targetSessionId, - ); - if (targetActive.length !== 1 || targetActive[0]?.actionId !== source.actionId) { + // + // A delegation link ends only by supersession or a resolved stop, so a + // delegation whose work already finished is still linked. It is not a + // competing stop target though — there is nothing left in it to stop — + // and counting it would make a Session that was delegated to twice + // permanently unstoppable once the first delegation completed. + if (!activeAssignments.some((assignment) => assignment.actionId === source.actionId)) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop target is no longer an active durable delegation', + ); + } + if (await this.#hasCompetingWork(activeAssignments, source)) { throw new WorkHubActionGateFailure( 'action_conflict', 'WorkHub stop target does not identify one active durable delegation', @@ -528,6 +537,26 @@ export class WorkHubCoordinationActionGate { ); } + /** + * Whether any other delegation on the stop target's Session still holds work. + * A retirement read that cannot see the owner yet fails the stop closed + * rather than guessing that the other delegation is finished. + */ + async #hasCompetingWork( + activeAssignments: readonly WorkHubDelegationAssignedMessage[], + source: WorkHubDelegationAssignedMessage, + ): Promise { + const competitors = activeAssignments.filter( + (assignment) => + assignment.targetSessionId === source.targetSessionId && + assignment.delegationId !== source.delegationId, + ); + for (const competitor of competitors) { + if ((await this.#effects.readDelegationRetirement(competitor)) !== 'retired') return true; + } + return false; + } + async #stop( request: WorkHubDelegationStopRequestedMessage, source: WorkHubDelegationAssignedMessage, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index cd43c0c814..54a2936b49 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -143,10 +143,12 @@ export class HostWorkHubCoordinationCoordinator { readonly #resolveCreateTarget: () => Promise; readonly #requestDrain: () => void; readonly #actionGate: WorkHubCoordinationActionGate; + readonly #readDelegationRetirement: HostWorkHubCoordinationCoordinatorOptions['sessionActions']['readDelegationRetirement']; constructor(options: HostWorkHubCoordinationCoordinatorOptions) { this.#coordinationCwd = join(options.stateRoot, COORDINATION_CWD_DIRECTORY); this.#stores = options.stores; + this.#readDelegationRetirement = options.sessionActions.readDelegationRetirement; this.#admission = options.admission; this.#continuity = options.continuity; this.#executions = options.executions; @@ -309,17 +311,29 @@ export class HostWorkHubCoordinationCoordinator { const targetActive = activeAssignments.filter( (assignment) => assignment.targetSessionId === input.targetSessionId, ); - if ( - !visibleSessionIds.has(input.targetSessionId) || - targetActive.length !== 1 || - targetActive[0]?.actionId !== input.stopsActionId || - targetActive[0]?.delegationId !== input.stopsDelegationId - ) { + const source = targetActive.find( + (assignment) => + assignment.actionId === input.stopsActionId && + assignment.delegationId === input.stopsDelegationId, + ); + if (!visibleSessionIds.has(input.targetSessionId) || !source) { throw new WorkHubActionGateFailure( 'action_conflict', 'WorkHub stop target does not identify one active durable delegation', ); } + // A delegation whose work already finished stays linked but competes + // for nothing; only work that could still be stopped makes the target + // ambiguous. + for (const competitor of targetActive) { + if (competitor.delegationId === source.delegationId) continue; + if ((await this.#readDelegationRetirement(competitor)) !== 'retired') { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop target does not identify one active durable delegation', + ); + } + } }, unknownOutcomeMessage: 'WorkHub stop request outcome is unknown', }); From 789e92f64d686e947bc216a6d64f97d60bfec931 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 2 Sep 2026 22:12:33 +0800 Subject: [PATCH 14/19] docs(workhub): fold the resolution design back into the ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design document duplicated the contract half of the ADR and the domain-language file, and its remaining half — history, delivered-slice tables, plan and deferred decisions — is what docs/README.md assigns to issues and discussions. It also cited branches that do not exist on this repository. Delete it; the narrative belongs in discussion #3286. Record what this slice actually settled in the two files that keep contracts: which sense of "active delegation" carries destructive authority, why visibility is proved for one delegation rather than the whole set, and why an unrecovered root can never be reported terminal. Generated-by: Claude Opus --- .../workhub-coordination-session-adr.md | 27 +++++++++++-------- docs/workhub-domain-language.md | 11 ++++++-- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index ab53f9aa1a..e1fd53900a 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -185,17 +185,22 @@ retired source from active linkage and makes later retries return the same termi outcome instead of displaying a stopped, unsuperseded link. Direct stop resolves its target through the shared Session Resolver and proposes -only what that resolution produced: the opaque delegation identity, the Session -it belongs to, and the active-delegation state the Action Policy resolved -against. Display names are retrieval evidence on the proposal side and never -appear in admission. The Action Gate revalidates those preconditions immediately -before any effect — the assignment still exists, it still belongs to the proposed -Session, and that Session's current active delegations are exactly the set the -policy saw, which for stop must be the one delegation being stopped. A stale -resolution therefore fails closed, while a rename between resolution and -admission is correctly irrelevant. Trusted user text still has to carry a direct -stop imperative, and the `user_stop` confirmation stays outside strategy output, -so neither model output nor a display name can select what gets stopped. +only what that resolution produced: the opaque delegation identity and the Session +it belongs to. Display names are retrieval evidence on the proposal side and never +appear in admission, and the proposal asserts no proof of its own — the Host makes +those from durable state. The Action Gate revalidates immediately before any +effect: the assignment still exists, it still belongs to the proposed Session, and +no other delegation on that Session still holds work that could be stopped. A +delegation link ends only by supersession or a resolved stop, so finished work +stays linked while ceasing to be a competing stop target; execution state that +cannot be read counts as competing, never as finished. Visibility is proved for +the stopped delegation alone, because nothing retires a delegation whose Session +was deleted and proving it over the whole active set would let one deleted Session +block every stop. A stale resolution therefore fails closed, while a rename between +resolution and admission is correctly irrelevant. Trusted user text still has to +carry a direct stop imperative, and the `user_stop` confirmation stays outside +strategy output, so neither model output nor a display name can select what gets +stopped. Direct stop persists a distinct `delegation_stop_requested` claim before retirement and a `delegation_stop_resolved` observation afterward. The pending diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md index 8db9be8aba..053331e442 100644 --- a/docs/workhub-domain-language.md +++ b/docs/workhub-domain-language.md @@ -126,7 +126,11 @@ fact and removes the retired source from active linkage. Correction never replaces either Session's transcript authority. **Direct stop**: A user's explicit imperative to retire one active durable -delegation. The initial deterministic implementation accepts exact display-name +delegation. A delegation link ends only by supersession or a resolved stop, so a +delegation whose work has finished is still linked; it is no longer a stop target, +because there is nothing left in it to stop. Only work that could still be stopped +makes a Session's stop target ambiguous, and execution state that cannot be read +is never treated as finished. The initial deterministic implementation accepts exact display-name references behind the shared Session Resolver contract; exact-name syntax is not the long-term product boundary. Pronouns, pause/wait language, questions, advice, negation, unresolved or ambiguous targets, and model-supplied Session, Turn, Run, @@ -162,7 +166,10 @@ created it, so a crash after cancellation but before resolution still replays `cancelled_pending` rather than degrading to `already_terminal`. Owning-root Stop likewise writes the direct-stop action identity into the exact root Turn's durable abort source. A retry recognizes only that matching proof; an earlier or -concurrent manual Stop remains `already_terminal`. Stop admission holds the +concurrent manual Stop remains `already_terminal`. Root registration is in-memory, +so between a Host restart and execution recovery a running root looks inactive. +`already_terminal` is an immutable observation, so only a durably terminal target +snapshot may claim it; an unrecovered target is still resolving instead. Stop admission holds the Coordination Session and every currently active target Session lane while it rechecks current target identities and active links; a concurrent new delegation therefore cannot invalidate the one-target proof after the request From 2022a4d1eed0884bcd7b289ad3f5d13b821df3ab Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 2 Sep 2026 23:02:59 +0800 Subject: [PATCH 15/19] fix(workhub): ask the Host before answering a stop with a refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stop_target_not_active` and `stop_target_not_unique` were decided from the renderer's own delegation mirror, and both returned without ever calling `coordination.act` — so the Host never saw the request and never got to disagree. That mirror is filled only by the Coordination stream, which means it is empty in a second window, after a reload, and across a reconnect. In that window WorkHub told the user, specifically and confidently, that a Session had no running work while it did. Add a read-only `workhub.coordination.delegations` query and ask it once a reference has resolved to one Session, so an ordinary message never pays for the round trip. The Host answers with its active links and, for each, whether it still holds work a stop could reach — the same judgement admission makes, from the same durable state. That also removes the client-side settled-delegation mirror added for the completion-fact fix: which delegations are stoppable now has one owner instead of a renderer projection that had to agree with the Host. Reported by Astro-Han in review of #4439. Generated-by: Claude Opus --- apps/desktop/renderer-architecture.json | 43 ++++++++-- .../main/__tests__/workhub-controller.test.ts | 77 +++++++++++++++-- .../__tests__/workhub-session-port.test.ts | 4 + .../workhub-session-resolver-port.test.ts | 79 +++++++++-------- .../__tests__/workhub-surface-flow.test.ts | 5 ++ apps/desktop/src/main/runtime-host-client.ts | 6 ++ .../src/main/runtime-host-workhub-ipc-main.ts | 6 ++ apps/desktop/src/preload/bridge-contract.d.ts | 8 ++ apps/desktop/src/preload/preload.ts | 24 ++++++ apps/desktop/src/renderer/app-shell.tsx | 2 + .../src/renderer/workhub-controller.ts | 53 +++++------- .../src/renderer/workhub-coordination-port.ts | 3 + .../src/renderer/workhub-route-policy.ts | 22 +++-- .../workhub-coordination-session-adr.md | 9 +- docs/workhub-domain-language.md | 6 +- .../workhub-coordination-action-gate.test.ts | 1 - .../src/protocol/workhub-coordination.ts | 85 +++++++++++++++++++ .../workhub-coordination-coordinator.ts | 31 +++++++ 18 files changed, 366 insertions(+), 98 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index e54ddacfd2..5bc21c3b42 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -121,6 +121,7 @@ "src/renderer/session-read-state.ts", "src/renderer/session-status-presentation.ts", "src/renderer/session-trace-refresh.ts", + "src/renderer/session-turn-request-composer.tsx", "src/renderer/session-workspace-actions.ts", "src/renderer/session-workspace-errors.ts", "src/renderer/settings/about-settings-page.tsx", @@ -761,7 +762,7 @@ "@maka/ui": 1 }, "importSpecifiers": 10, - "nonTriviaTokens": 650 + "nonTriviaTokens": 654 }, "src/renderer/app-shell-turn-view-model.ts": { "importDeclarations": 7, @@ -785,10 +786,10 @@ "react": 1 }, "importSpecifiers": 18, - "nonTriviaTokens": 1410 + "nonTriviaTokens": 1425 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 103, + "importDeclarations": 105, "bridgePaths": { "window.maka.app.installUpdate": 1, "window.maka.app.retryUpdateDownload": 1, @@ -820,6 +821,7 @@ "window.maka.transcripts": 2, "window.maka.workHub.act": 1, "window.maka.workHub.candidates": 1, + "window.maka.workHub.delegations": 1, "window.maka.workHub.record": 1, "window.maka.workHub.resolveCoordinationSession": 1 }, @@ -921,6 +923,7 @@ "./live-content-seed": 1, "./live-turn-reconciler": 1, "./locales/conversation-copy": 1, + "./locales/session-collaboration-copy": 1, "./locales/shell-copy": 1, "./locales/shell-remaining-copy.js": 1, "./model-connection-errors": 1, @@ -930,6 +933,7 @@ "./plan-mode-panel": 1, "./scroll-motion-policy": 1, "./session-collaboration-dialog": 1, + "./session-turn-request-composer.js": 1, "./session-workspace-errors": 1, "./settings/provider-brand-marks": 1, "./settings/provider-display": 1, @@ -979,8 +983,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 184, - "nonTriviaTokens": 15692 + "importSpecifiers": 186, + "nonTriviaTokens": 15745 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, @@ -2382,8 +2386,8 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./features/session-collaboration": 1, "./locales/session-collaboration-copy.js": 1, + "./session-turn-request-composer.js": 1, "@astryxdesign/core": 1, "@astryxdesign/core/Dialog": 1, "@astryxdesign/core/Layout": 1, @@ -2498,6 +2502,33 @@ "@maka/core/events": 1 } }, + "src/renderer/session-turn-request-composer.tsx": { + "bridgePaths": { + "window.maka.sessionCollaboration.acknowledgeTurnRequest": 1, + "window.maka.sessionCollaboration.getTurnRequests": 2, + "window.maka.sessionCollaboration.requestTurn": 1 + }, + "environmentCapabilities": { + "window.clearTimeout": 1, + "window.setTimeout": 1 + }, + "hookCalls": { + "useEffect": 1, + "useRef": 2, + "useState": 6, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/session-collaboration-copy.js": 1, + "@maka/runtime-host/protocol": 1, + "@maka/ui": 1, + "react": 1 + } + }, "src/renderer/session-workspace-actions.ts": { "bridgePaths": { "window.maka.sessions.queryCancelledMessages": 1 diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 1f719d8d24..38907df45a 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -109,6 +109,7 @@ function createWorkHubController({ sessions }: { sessions: TestSessionPort }) { coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => { const candidates = (await sessions.list()) .filter((entry) => entry.kind === 'ordinary' && !entry.archived) @@ -252,6 +253,7 @@ test('conversation acknowledges a durable assignment before projecting target ex return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), }, @@ -301,6 +303,7 @@ test('conversation feedback never lets an older refresh overwrite newer target s return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), }, @@ -347,6 +350,9 @@ test('direct stop bypasses routing candidates and preserves a not_owned delegati return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), + delegations: async (targetSessionId) => ({ + delegations: [{ actionId: 'action-1', targetSessionId, stoppable: true }], + }), candidates: async () => { candidateReads += 1; return { candidateSetId: `sha256:${'d'.repeat(64)}`, candidates: [] }; @@ -403,6 +409,7 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => assert.fail('anaphoric stop must not reach the Action Gate'), }, @@ -419,8 +426,43 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou await handle.close(); }); +test('a stop answer never comes from an unfilled delegation mirror', async () => { + // A second window, a reload, or a reconnect: the coordination stream has not + // delivered anything yet, so the renderer's mirror is empty. Answering from + // it would tell the user there is nothing to stop while the work is running. + const sessions = port([session('payments', { sessionName: 'Payments' })]); + const actions: Array> = []; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + record: async (input) => ({ turnId: input.turnId }), + delegations: async (targetSessionId) => ({ + delegations: [{ actionId: 'action-1', targetSessionId, stoppable: true }], + }), + candidates: async () => assert.fail('a direct stop must not read route candidates'), + act: async (input) => { + actions.push(input); + return { + disposition: 'stop_work', + outcome: 'cancelled_pending', + targetSessionId: 'payments', + }; + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + const result = await controller.submit({ requestId: 'stop-1', text: 'Stop Payments' }); + assert.equal(result.kind, 'stop'); + assert.equal(actions[0]?.proposal.disposition, 'stop_work'); + await handle.close(); +}); + test('a named stop explains a Session that is not uniquely stoppable', async () => { - for (const [reason, activeDelegations] of [ + // The renderer's own delegation mirror is deliberately empty here: what the + // user is told comes from the Host, not from whatever the client last saw. + for (const [reason, stoppable] of [ ['stop_target_not_unique', 2], ['stop_target_not_active', 0], ] as const) { @@ -429,17 +471,17 @@ test('a named stop explains a Session that is not uniquely stoppable', async () sessions, coordination: { open: async (handler) => { - handler( - [], - Array.from({ length: activeDelegations }, (_unused, index) => ({ - actionId: `action-${index}`, - targetSessionId: 'payments', - sequence: index, - })), - ); + handler([], []); return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), + delegations: async (targetSessionId) => ({ + delegations: Array.from({ length: stoppable }, (_unused, index) => ({ + actionId: `action-${index}`, + targetSessionId, + stoppable: true, + })), + }), candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => assert.fail('an unstoppable named target must not reach the Action Gate'), }, @@ -476,8 +518,10 @@ test('stop-shaped ordinary work routes normally instead of looping on clarificat return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', @@ -1523,8 +1567,10 @@ test('submit keeps unmatched non-executable conversation in WorkHub', async () = coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: [], }), act: async (input) => { @@ -1569,8 +1615,10 @@ test('production submission delegates only through the Runtime-owned candidate r coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: [{ candidateRef: 'candidate-payment', sessionId: 'payment', @@ -1623,8 +1671,10 @@ test('production retry reaches durable Action Gate replay while target is waitin coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: [{ candidateRef: 'candidate-payment', sessionId: 'payment', @@ -1668,8 +1718,10 @@ test('production sends an explicit correction as a linked replacement', async () coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'d'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: [ { candidateRef: 'candidate-source', @@ -1792,6 +1844,7 @@ test('production natural-language corrections retain the prior delegation link', coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId, candidates }), act: async (input) => { actions.push(input); @@ -1874,8 +1927,10 @@ test('production correction-shaped creation stays create_new without an existing coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: [], }), act: async (input) => { @@ -1907,8 +1962,10 @@ test('production clarification is persisted through the typed Action Gate dispos record: async () => { throw new Error('legacy summary recording must not persist clarification'); }, + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: [], }), act: async (input) => { @@ -1948,8 +2005,10 @@ test('production creation leaves Session identity and workspace authority to mai coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: [], }), act: async (input) => { diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index 00ba4f9303..7f67fc589a 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -443,8 +443,10 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and }, }, record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: [], }), act: async () => ({ @@ -555,8 +557,10 @@ test('Coordination transcript reset rebuilds active linkage outside the resident }, }, record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: [], }), act: async () => ({ diff --git a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts index 492100e83b..8b160ab89c 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts @@ -23,23 +23,19 @@ import type { WorkHubSessionResolution, WorkHubSessionResolver, } from '../../renderer/application/contracts/workhub-request-intent.js'; -import { - createWorkHubRoutePolicy, - type WorkHubStoppableSession, -} from '../../renderer/workhub-route-policy.js'; +import { createWorkHubRoutePolicy } from '../../renderer/workhub-route-policy.js'; -const stoppable = ( - sessionId: string, - sessionName: string, - activeActionIds: readonly string[], -): WorkHubStoppableSession => ({ +const routable = (sessionId: string, sessionName: string) => ({ target: { sessionId }, projectName: 'demo', sessionName, updatedAt: 1, - activeActionIds, }); +/** Stands in for the Host read the stop policy makes once a reference resolves. */ +const hostDelegations = (bySessionId: Readonly>) => + async (sessionId: string): Promise => bySessionId[sessionId] ?? []; + /** * A stand-in for a later ranked resolver. It recalls by remembered description * rather than display name, which is exactly the recall the exact-name baseline @@ -62,23 +58,28 @@ const describedResolver = ( }, }); -test('stop resolves through the shared port rather than a stop-specific grammar', () => { - const sessions = [ - stoppable('payments', 'Payments', ['action-1']), - stoppable('login', 'Login', ['action-2']), - ]; +test('stop resolves through the shared port rather than a stop-specific grammar', async () => { + const sessions = [routable('payments', 'Payments'), routable('login', 'Login')]; + const readStoppableDelegations = hostDelegations({ + payments: ['action-1'], + login: ['action-2'], + }); // Action Intent extracts the reference ("Stop the payment timeout work" -> // "payment timeout work"); resolving it is the Resolver's business alone. // The exact-name baseline recalls the display name and nothing else. const baseline = createWorkHubRoutePolicy(); - assert.deepEqual(baseline.resolveStop({ text: 'Stop Payments', sessions }), { + assert.deepEqual(await baseline.resolveStop({ text: 'Stop Payments', sessions, readStoppableDelegations }), { kind: 'target', target: { sessionId: 'payments' }, stopsActionId: 'action-1', }); assert.deepEqual( - baseline.resolveStop({ text: 'Stop the payment timeout work', sessions }), + await baseline.resolveStop({ + text: 'Stop the payment timeout work', + sessions, + readStoppableDelegations, + }), { kind: 'not_requested' }, ); @@ -87,36 +88,44 @@ test('stop resolves through the shared port rather than a stop-specific grammar' const ranked = createWorkHubRoutePolicy( describedResolver(new Map([['payments', 'payment timeout work']])), ); - assert.deepEqual(ranked.resolveStop({ text: 'Stop the payment timeout work', sessions }), { + assert.deepEqual( + await ranked.resolveStop({ + text: 'Stop the payment timeout work', + sessions, + readStoppableDelegations, + }), { kind: 'target', target: { sessionId: 'payments' }, stopsActionId: 'action-1', }); }); -test('the stop policy, not the resolver, owns destructive sufficiency', () => { +test('the stop policy, not the resolver, owns destructive sufficiency', async () => { const descriptions = new Map([['payments', 'payment timeout work']]); const text = 'Stop the payment timeout work'; // A confidently resolved Session with no active WorkHub delegation, and one // with several, are both refused with the reason they were refused. + // Both answers come from the Host read, never from a renderer mirror. assert.deepEqual( - createWorkHubRoutePolicy(describedResolver(descriptions)).resolveStop({ + await createWorkHubRoutePolicy(describedResolver(descriptions)).resolveStop({ text, - sessions: [stoppable('payments', 'Payments', [])], + sessions: [routable('payments', 'Payments')], + readStoppableDelegations: hostDelegations({}), }), { kind: 'clarification', reason: 'stop_target_not_active' }, ); assert.deepEqual( - createWorkHubRoutePolicy(describedResolver(descriptions)).resolveStop({ + await createWorkHubRoutePolicy(describedResolver(descriptions)).resolveStop({ text, - sessions: [stoppable('payments', 'Payments', ['action-1', 'action-2'])], + sessions: [routable('payments', 'Payments')], + readStoppableDelegations: hostDelegations({ payments: ['action-1', 'action-2'] }), }), { kind: 'clarification', reason: 'stop_target_not_unique' }, ); }); -test('an ambiguous recall never becomes a destructive target', () => { +test('an ambiguous recall never becomes a destructive target', async () => { const resolver = describedResolver( new Map([ ['payments', 'payment timeout work'], @@ -124,18 +133,16 @@ test('an ambiguous recall never becomes a destructive target', () => { ]), ); assert.deepEqual( - createWorkHubRoutePolicy(resolver).resolveStop({ + await createWorkHubRoutePolicy(resolver).resolveStop({ text: 'Stop the payment timeout work', - sessions: [ - stoppable('payments', 'Payments', ['action-1']), - stoppable('payments-eu', 'Payments EU', ['action-2']), - ], + sessions: [routable('payments', 'Payments'), routable('payments-eu', 'Payments EU')], + readStoppableDelegations: () => assert.fail('an ambiguous recall must not read the Host'), }), { kind: 'clarification', reason: 'stop_target_ambiguous' }, ); }); -test('a resolver cannot widen stop beyond the visible candidate set it was given', () => { +test('a resolver cannot widen stop beyond the visible candidate set it was given', async () => { const resolver: WorkHubSessionResolver = { resolve: () => ({ kind: 'ranked', @@ -145,22 +152,24 @@ test('a resolver cannot widen stop beyond the visible candidate set it was given }), }; assert.deepEqual( - createWorkHubRoutePolicy(resolver).resolveStop({ + await createWorkHubRoutePolicy(resolver).resolveStop({ text: 'Stop Payments', - sessions: [stoppable('payments', 'Payments', ['action-1'])], + sessions: [routable('payments', 'Payments')], + readStoppableDelegations: hostDelegations({ payments: ['action-1'] }), }), { kind: 'not_requested' }, ); }); -test('a stop cue with no safe reference asks for one instead of resolving', () => { +test('a stop cue with no safe reference asks for one instead of resolving', async () => { const resolver: WorkHubSessionResolver = { resolve: () => assert.fail('an unsafe reference must not reach the Session Resolver'), }; assert.deepEqual( - createWorkHubRoutePolicy(resolver).resolveStop({ + await createWorkHubRoutePolicy(resolver).resolveStop({ text: 'Stop it', - sessions: [stoppable('payments', 'Payments', ['action-1'])], + sessions: [routable('payments', 'Payments')], + readStoppableDelegations: () => assert.fail('an unsafe reference must not read the Host'), }), { kind: 'clarification', reason: 'stop_target_required' }, ); 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 c7d8259c8e..08d0ac557d 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -165,6 +165,7 @@ test('durable delegation renders terminal link state instead of stale execution const terminalLinks = [ ['superseded', 'Superseded link', '已被更正'], ['aborted', 'Aborted replacement', '更正已中止'], + ['stopped', 'Stopped link', '已停止关联'], ] as const; for (const [linkState, english, chinese] of terminalLinks) { const turn: WorkHubCoordinationTurn = { @@ -419,8 +420,10 @@ test('ambiguous creation is durably clarified before a fresh imperative creates coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: [], }), act: async (input) => { @@ -592,8 +595,10 @@ test('real Session projection creates new guide topics and preserves origin ambi coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), + delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, + delegations: async () => ({ delegations: [] }), candidates: sessions.map((entry) => ({ candidateRef: `candidate-${entry.id}`, sessionId: entry.id, diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 5a39dba44b..d666a470c8 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -971,6 +971,12 @@ export class DesktopRuntimeHostClient { return this.request("workhub.coordination.candidates", {}); } + listWorkHubCoordinationDelegations( + input: OperationInput<"workhub.coordination.delegations">, + ): Promise> { + return this.request("workhub.coordination.delegations", input); + } + actWorkHubCoordination( input: OperationInput<"workhub.coordination.act">, ): Promise> { 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 4210a0c38c..ac6410ffc0 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -33,6 +33,7 @@ type RuntimeHostWorkHubClient = Pick< | 'actWorkHubCoordination' | 'answerWorkHubCoordination' | 'listWorkHubCoordinationCandidates' + | 'listWorkHubCoordinationDelegations' | 'recordWorkHubCoordination' | 'resolveWorkHubCoordinationSession' >; @@ -60,6 +61,11 @@ export function registerRuntimeHostWorkHubIpc( client.recordWorkHubCoordination(input), ); ipcMain.handle('workhub:candidates', () => client.listWorkHubCoordinationCandidates()); + ipcMain.handle('workhub:delegations', (_event, _scope, targetSessionId?: string) => + client.listWorkHubCoordinationDelegations( + targetSessionId === undefined ? {} : { targetSessionId }, + ), + ); ipcMain.handle('workhub:act', async (_event, rawInput: RendererWorkHubActionInput) => { try { const proposal = rawInput?.proposal; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d68e5c75c2..4b9bf10f97 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1048,6 +1048,14 @@ export interface MakaBridge { candidates( coordinationSessionId: string, ): Promise>; + /** + * Read the Host's active delegation links, optionally for one Session. + * A renderer projection of these links can be stale or not yet built. + */ + delegations( + coordinationSessionId: string, + targetSessionId?: string, + ): Promise>; /** Submit a typed proposal; trusted creation context is added outside the renderer. */ act( coordinationSessionId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b4013452f4..9026616fd9 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1915,6 +1915,30 @@ const makaBridge = { ); return ipcRenderer.invoke('workhub:record', scope, input) as Promise<{ turnId: string }>; }, + async delegations( + coordinationSessionId: string, + targetSessionId?: string, + ): Promise> { + const scope = await resolveDesktopWorkHubCoordinationCreateScope( + coordinationSessionId, + runtimeHostSessionRef, + ); + const hostTargetSessionId = + targetSessionId === undefined + ? undefined + : (await runtimeHostSessionRef(targetSessionId)).sessionId; + const result = await ipcRenderer.invoke( + 'workhub:delegations', + scope, + hostTargetSessionId, + ) as OperationOutput<'workhub.coordination.delegations'>; + return { + delegations: result.delegations.map((delegation) => ({ + ...delegation, + targetSessionId: recordRuntimeHostSessionScope(scope, delegation.targetSessionId), + })), + }; + }, async candidates( coordinationSessionId: string, ): Promise> { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index a847a21794..1a1a8ee6c1 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1479,6 +1479,8 @@ function AppShellContent({ window.maka.workHub.record(workHubCoordinationSessionId!, input), candidates: () => window.maka.workHub.candidates(workHubCoordinationSessionId!), + delegations: (targetSessionId) => + window.maka.workHub.delegations(workHubCoordinationSessionId!, targetSessionId), act: (input) => window.maka.workHub.act(workHubCoordinationSessionId!, input), }), diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 8b274d076d..a73aa14dd7 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -32,6 +32,7 @@ import type { WorkHubCoordinationActInput, WorkHubCoordinationActResult, WorkHubCoordinationCandidatesResult, + WorkHubCoordinationDelegationsResult, } from '@maka/runtime-host/protocol'; export interface WorkHubSessionTarget { @@ -128,13 +129,6 @@ export interface WorkHubActiveDelegation { readonly sequence: number; } -/** Target-owned execution states that leave a delegation with nothing to stop. */ -const SETTLED_DELEGATION_STATES: ReadonlySet = new Set([ - 'completed', - 'failed', - 'aborted', -]); - const WORKHUB_TIMELINE_TEXT_LIMIT = 600; export function boundedWorkHubTimelineText(value: string): string { @@ -251,6 +245,11 @@ export interface WorkHubCoordinationPort { assistantText: string; }): Promise<{ turnId: string }>; candidates(): Promise; + /** + * The Host's active delegation links for one Session, each with its own + * answer to whether it still holds work a stop could reach. + */ + delegations(targetSessionId: string): Promise; act(input: Omit): Promise; } @@ -280,18 +279,6 @@ export function createWorkHubController(deps: { let focusReadVersion = 0; let pendingFocusReadVersion: number | undefined; const activeActionIdsBySessionId = new Map(); - /** - * A delegation link ends only by supersession or a resolved stop, so work - * that simply finished stays linked. It is no longer a stop target, though: - * counting it would make a Session delegated to twice look permanently - * ambiguous once the first delegation completed. Execution state is a - * read-only target-owned projection, so an unreadable one is never settled. - */ - const settledActionIds = new Set(); - const stoppableActionIds = (sessionId: string): readonly string[] => - (activeActionIdsBySessionId.get(sessionId) ?? []).filter( - (actionId) => !settledActionIds.has(actionId), - ); const removeActiveAction = (sessionId: string, actionId: string) => { const remaining = (activeActionIdsBySessionId.get(sessionId) ?? []).filter( (candidate) => candidate !== actionId, @@ -397,13 +384,9 @@ export function createWorkHubController(deps: { handler(turns.map((turn) => { if (!turn.assignment) return turn; const next = feedbackByDelegationId.get(turn.assignment.delegationId); - if (!next) return turn; - if (SETTLED_DELEGATION_STATES.has(next.state)) { - settledActionIds.add(turn.assignment.actionId); - } else { - settledActionIds.delete(turn.assignment.actionId); - } - return { ...turn, assignment: { ...turn.assignment, feedbackState: next.state } }; + return next + ? { ...turn, assignment: { ...turn.assignment, feedbackState: next.state } } + : turn; })); }; @@ -497,15 +480,17 @@ export function createWorkHubController(deps: { const sessions = await deps.sessions.list(); reconcileFocus(submissionPolicy, sessions); const ordinary = sessions.filter((session) => session.kind === 'ordinary'); - const stopDecision = submissionPolicy.resolveStop({ + const stopDecision = await submissionPolicy.resolveStop({ text: input.text, - sessions: ordinary.map((session) => ({ - target: session.target, - projectName: session.projectName, - sessionName: session.sessionName, - updatedAt: session.updatedAt, - activeActionIds: stoppableActionIds(session.target.sessionId), - })), + sessions: ordinary, + readStoppableDelegations: async (sessionId) => { + const { delegations } = await coordination.delegations(sessionId); + return delegations + .filter( + (delegation) => delegation.targetSessionId === sessionId && delegation.stoppable, + ) + .map((delegation) => delegation.actionId); + }, }); if (stopDecision.kind !== 'not_requested') { if (stopDecision.kind === 'clarification') { diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index c5505c245f..f12d4bb5e8 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -34,6 +34,7 @@ import type { WorkHubCoordinationActInput, WorkHubCoordinationActResult, WorkHubCoordinationCandidatesResult, + WorkHubCoordinationDelegationsResult, OperationOutcome, OperationError, } from '@maka/runtime-host/protocol'; @@ -61,6 +62,7 @@ export function createDesktopWorkHubCoordinationPort(deps: { assistantText: string; }): Promise<{ turnId: string }>; candidates(): Promise; + delegations(targetSessionId: string): Promise; act( input: Omit, ): Promise>; @@ -68,6 +70,7 @@ export function createDesktopWorkHubCoordinationPort(deps: { return { record: deps.record, candidates: deps.candidates, + delegations: deps.delegations, async act(input) { const outcome = await deps.act(input); if (!outcome.ok) { diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index cb6c89ef78..f93db4fbf5 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -61,11 +61,6 @@ export type WorkHubRouteDecision = | { kind: 'discussion' } | { kind: 'new_session'; title: string; correctedFrom?: WorkHubRouteTarget }; -/** An existing WorkHub identity together with the active work it owns. */ -export interface WorkHubStoppableSession extends WorkHubRoutableSession { - /** Opaque action identities of this Session's active WorkHub delegations. */ - activeActionIds: readonly string[]; -} export type WorkHubStopClarificationReason = /** The stop names no safe target of its own — a pronoun or a bare noun. */ @@ -95,8 +90,15 @@ export type WorkHubStopRouteDecision = export interface WorkHubRoutePolicy { resolveStop(input: { text: string; - sessions: WorkHubStoppableSession[]; - }): WorkHubStopRouteDecision; + sessions: WorkHubRoutableSession[]; + /** + * The Host's stoppable delegations for one resolved Session, by opaque + * action identity. It is read only once a reference has resolved, so an + * ordinary message never pays for it, and the answer the user sees comes + * from the same authority admission uses rather than a client mirror. + */ + readStoppableDelegations: (sessionId: string) => Promise; + }): Promise; resolve(input: { text: string; sessions: WorkHubRoutableSession[]; @@ -163,7 +165,7 @@ function createWorkHubRoutePolicyVisit( // ordinary work and falls through to routing; an unsafe or anaphoric // reference still fails closed, and a resolved Session that is not uniquely // stoppable says why. - resolveStop({ text, sessions }) { + async resolveStop({ text, sessions, readStoppableDelegations }) { const intent = readWorkHubRequestIntent(text); if (!intent.stop.cue) return { kind: 'not_requested' }; const reference = intent.stop.imperative ? intent.stop.target : undefined; @@ -195,7 +197,9 @@ function createWorkHubRoutePolicyVisit( } const resolved = sessionByRef.get(admissible[0]!.ref); if (!resolved) return { kind: 'not_requested' }; - const [stopsActionId, ...furtherActive] = resolved.activeActionIds; + const [stopsActionId, ...furtherActive] = await readStoppableDelegations( + resolved.target.sessionId, + ); if (!stopsActionId) { return { kind: 'clarification', reason: 'stop_target_not_active' }; } diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index e1fd53900a..93522fc3d4 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -184,9 +184,12 @@ waiting after the destructive retirement boundary, Coordination appends a retired source from active linkage and makes later retries return the same terminal outcome instead of displaying a stopped, unsuperseded link. -Direct stop resolves its target through the shared Session Resolver and proposes -only what that resolution produced: the opaque delegation identity and the Session -it belongs to. Display names are retrieval evidence on the proposal side and never +Direct stop resolves its target through the shared Session Resolver, then asks the +Host which of that Session's delegations still hold stoppable work before it +answers the user or proposes anything. WorkHub projections are rebuildable and may +be empty when a window opens, so a destructive answer is never given from one. The +proposal then carries only what resolution produced: the opaque delegation identity +and the Session it belongs to. Display names are retrieval evidence on the proposal side and never appear in admission, and the proposal asserts no proof of its own — the Host makes those from durable state. The Action Gate revalidates immediately before any effect: the assignment still exists, it still belongs to the proposed Session, and diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md index 053331e442..c0888ca291 100644 --- a/docs/workhub-domain-language.md +++ b/docs/workhub-domain-language.md @@ -155,7 +155,11 @@ A stop reference that recalls no existing WorkHub Session is ordinary work — ` using the deprecated API` is a task, not a destructive command — and routes normally. An ambiguous recall, a resolved Session that is not uniquely stoppable, and an unsafe or anaphoric reference each fail closed with the reason they failed -rather than an unanswerable prompt. +rather than an unanswerable prompt. Whether a resolved Session is uniquely +stoppable is asked of the Host once a reference resolves, never answered from a +client's delegation projection: that projection is empty until the Coordination +stream fills it, so a fresh window or a reconnect would otherwise state +confidently that running work does not exist. An unresolved direct-stop claim and a replacement claim are mutually exclusive; the first durable destructive claim wins. A `not_owned` resolution releases that exclusion so a later explicit route correction can proceed, and because it leaves 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 ebee3b132e..8a00891a77 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 @@ -456,7 +456,6 @@ describe('WorkHub Coordination Action Gate', () => { CONTEXT, ); assert.equal(stopped.disposition, 'stop_work'); - }); test('a competitor the Host cannot resolve yet fails the stop closed', async () => { diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index edcf677b72..beff2f2d9e 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -39,6 +39,7 @@ 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; +export const WORKHUB_COORDINATION_DELEGATION_MAX_ITEMS = 256; const RESOLVE_ERRORS = [ 'host_not_ready', @@ -117,6 +118,34 @@ export interface WorkHubCoordinationCandidatesResult { readonly candidates: readonly WorkHubCoordinationCandidate[]; } +/** + * Reads the Coordination Session's active delegation links. + * + * A client mirror of these links is a projection that can be empty or stale — + * a fresh window, a reload, a reconnect — so a policy about to answer the user + * from it asks the Host instead of trusting what it happens to have seen. + */ +export interface WorkHubCoordinationDelegationsInput { + /** Limits both the result and the target-owned work probe to one Session. */ + readonly targetSessionId?: string; +} + +export interface WorkHubCoordinationDelegation { + readonly actionId: string; + readonly targetSessionId: string; + /** + * Whether this delegation still holds work a stop could reach. A link + * outlives the work it delegated, and target execution state that cannot be + * read is reported as still holding work rather than as finished. + */ + readonly stoppable: boolean; +} + +export interface WorkHubCoordinationDelegationsResult { + /** Active delegations in Coordination transcript order. */ + readonly delegations: readonly WorkHubCoordinationDelegation[]; +} + export type WorkHubCoordinationProposal = | { readonly disposition: 'answer_here' } | { readonly disposition: 'clarify'; readonly assistantText: string } @@ -247,6 +276,17 @@ export const WORKHUB_COORDINATION_OPERATION_SPECS = { decodeInput: decodeWorkHubCoordinationCandidatesInput, decodeOutput: decodeWorkHubCoordinationCandidatesResult, }), + 'workhub.coordination.delegations': defineOperation< + WorkHubCoordinationDelegationsInput, + WorkHubCoordinationDelegationsResult, + (typeof CANDIDATE_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: CANDIDATE_ERRORS, + decodeInput: decodeWorkHubCoordinationDelegationsInput, + decodeOutput: decodeWorkHubCoordinationDelegationsResult, + }), 'workhub.coordination.act': defineOperation< WorkHubCoordinationActInput, WorkHubCoordinationActResult, @@ -346,6 +386,51 @@ export function decodeWorkHubCoordinationCandidatesResult( }; } +export function decodeWorkHubCoordinationDelegationsInput( + value: unknown, +): WorkHubCoordinationDelegationsInput { + const input = requireShapedRecord( + value, + 'WorkHub Coordination delegations input', + [], + ['targetSessionId'], + ); + return input.targetSessionId === undefined + ? {} + : { targetSessionId: requireEntityId(input.targetSessionId, 'WorkHub target Session id') }; +} + +export function decodeWorkHubCoordinationDelegationsResult( + value: unknown, +): WorkHubCoordinationDelegationsResult { + const result = requireExactRecord(value, 'WorkHub Coordination delegations result', [ + 'delegations', + ]); + if (!Array.isArray(result.delegations)) { + throw invalidProtocolFrame('Invalid WorkHub Coordination delegations'); + } + if (result.delegations.length > WORKHUB_COORDINATION_DELEGATION_MAX_ITEMS) { + throw invalidProtocolFrame('Too many WorkHub Coordination delegations'); + } + return { delegations: result.delegations.map(decodeWorkHubCoordinationDelegation) }; +} + +function decodeWorkHubCoordinationDelegation(value: unknown): WorkHubCoordinationDelegation { + const delegation = requireExactRecord(value, 'WorkHub Coordination delegation', [ + 'actionId', + 'targetSessionId', + 'stoppable', + ]); + if (typeof delegation.stoppable !== 'boolean') { + throw invalidProtocolFrame('Invalid WorkHub delegation work state'); + } + return { + actionId: requireEntityId(delegation.actionId, 'WorkHub Coordination action id'), + targetSessionId: requireEntityId(delegation.targetSessionId, 'WorkHub target Session id'), + stoppable: delegation.stoppable, + }; +} + export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordinationActInput { const input = requireShapedRecord( value, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 54a2936b49..c72d3d6f56 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -42,6 +42,8 @@ import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage import type { OperationOutcome, WorkHubCoordinationActInput, + WorkHubCoordinationDelegation, + WorkHubCoordinationDelegationsInput, WorkHubCoordinationAnswerInput, WorkHubCoordinationRecordInput, } from '../protocol/index.js'; @@ -132,6 +134,7 @@ export class HostWorkHubCoordinationCoordinator { 'workhub.coordination.answer': (input, context) => this.#answer(input, context), 'workhub.coordination.record': (input) => this.#record(input), 'workhub.coordination.candidates': () => this.#candidates(), + 'workhub.coordination.delegations': (input) => this.#delegations(input), 'workhub.coordination.act': (input, context) => this.#act(input, context), }; @@ -457,6 +460,34 @@ export class HostWorkHubCoordinationCoordinator { ); } + /** + * The active delegation links, with the Host's own answer to whether each + * still holds work a stop could reach. A client projection of these links + * can be stale or not yet built, so a policy that would otherwise answer the + * user from its mirror asks here and gets the same judgement admission uses. + */ + async #delegations( + input: WorkHubCoordinationDelegationsInput, + ): Promise> { + try { + const active = await this.#listActiveAssignments(); + const scoped = input.targetSessionId + ? active.filter((assignment) => assignment.targetSessionId === input.targetSessionId) + : active; + const delegations: WorkHubCoordinationDelegation[] = []; + for (const assignment of scoped) { + delegations.push({ + actionId: assignment.actionId, + targetSessionId: assignment.targetSessionId, + stoppable: (await this.#readDelegationRetirement(assignment)) !== 'retired', + }); + } + return { ok: true, result: { delegations } }; + } catch { + return { ok: false, error: { code: 'internal_failure', message: 'WorkHub delegations' } }; + } + } + async #candidates(): Promise> { try { return { ok: true, result: await this.#actionGate.candidates() }; From b2c9612b29d8a44b54d980205b719dbe2b177f9d Mon Sep 17 00:00:00 2001 From: ChengBo Zhang Date: Thu, 3 Sep 2026 12:30:52 +0800 Subject: [PATCH 16/19] fix(workhub): let the Host name the delegation a stop ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stopsActionId` made the client name the delegation to stop, so the policy had to learn one before it could submit. It did that by reading `workhub.coordination.delegations`, which reads the whole global Coordination transcript and parses every record — once per stop-shaped message, and twice more inside `#prepareStop`. The client cannot prove which link is live anyway. The Gate already resolved the assignment, checked it against the named Session, and proved sole-active-delegation from durable state under the admission lease, so the field was a weaker copy of an answer the Host was making regardless. Drop it. The proposal names only the Session it resolved, and the Gate resolves the delegation from its own active links: one link is the answer whatever state its work is in, and only several need separating, by the rule competition already used. Resolving again on replay would fail, because a resolved stop takes its delegation out of the active set — the second attempt would find nothing where the first found one. `workhub_action_claims` already records the delegation each action bound itself to, is written before any effect, is never deleted, and outlives removal of the target Session; it is read here for the first time. The claim-then-request order leaves one seam, where an action owns a stop with no request behind it, and nothing destructive has happened there, so it resolves like a first attempt. That retires the `delegations` query with its transcript scan, and with it the renderer's last stop-state judgement: `stop_target_not_active` and `stop_target_not_unique` were the two answers a client mirror could give while contradicting the Host, and the Gate's refusal now carries them. Reported by Astro-Han in review of #4439. Generated-by: Claude Opus --- apps/desktop/renderer-architecture.json | 3 +- .../main/__tests__/workhub-controller.test.ts | 133 ++++++------ .../__tests__/workhub-session-port.test.ts | 4 - .../workhub-session-resolver-port.test.ts | 50 ++--- .../__tests__/workhub-surface-flow.test.ts | 6 +- apps/desktop/src/main/runtime-host-client.ts | 6 - .../src/main/runtime-host-workhub-ipc-main.ts | 6 - apps/desktop/src/preload/bridge-contract.d.ts | 8 - apps/desktop/src/preload/preload.ts | 24 --- apps/desktop/src/renderer/app-shell.tsx | 2 - .../src/renderer/workhub-controller.ts | 83 +++++--- .../src/renderer/workhub-coordination-port.ts | 17 +- .../src/renderer/workhub-route-policy.ts | 49 ++--- apps/desktop/src/renderer/workhub-surface.tsx | 15 +- .../__tests__/execution-composition.test.ts | 1 - .../workhub-coordination-action-gate.test.ts | 3 + .../workhub-coordination-coordinator.test.ts | 198 +++++++++++++++++- .../workhub-coordination-protocol.test.ts | 6 - .../src/protocol/workhub-coordination.ts | 98 +-------- .../workhub-coordination-action-gate.ts | 90 +++++++- .../workhub-coordination-coordinator.ts | 35 +--- 21 files changed, 446 insertions(+), 391 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 5bc21c3b42..9dc6bc4eea 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -821,7 +821,6 @@ "window.maka.transcripts": 2, "window.maka.workHub.act": 1, "window.maka.workHub.candidates": 1, - "window.maka.workHub.delegations": 1, "window.maka.workHub.record": 1, "window.maka.workHub.resolveCoordinationSession": 1 }, @@ -984,7 +983,7 @@ "react": 1 }, "importSpecifiers": 186, - "nonTriviaTokens": 15745 + "nonTriviaTokens": 15725 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 38907df45a..f011215107 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -32,6 +32,7 @@ import { createWorkHubRoutePolicy, workHubNewSessionName, } from '../../renderer/workhub-route-policy.js'; +import { WorkHubCoordinationFailure } from '../../renderer/workhub-coordination-port.js'; const appShellUrl = [ new URL('../../renderer/app-shell.tsx', import.meta.url), @@ -109,7 +110,6 @@ function createWorkHubController({ sessions }: { sessions: TestSessionPort }) { coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => { const candidates = (await sessions.list()) .filter((entry) => entry.kind === 'ordinary' && !entry.archived) @@ -190,7 +190,7 @@ function createWorkHubController({ sessions }: { sessions: TestSessionPort }) { return { disposition: 'stop_work', outcome: 'cancelled_pending', - targetSessionId: input.proposal.stopsActionId, + targetSessionId: input.proposal.expects.targetSessionId, }; } const target = candidateByRef.get(input.proposal.candidateRef); @@ -253,7 +253,6 @@ test('conversation acknowledges a durable assignment before projecting target ex return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), }, @@ -303,7 +302,6 @@ test('conversation feedback never lets an older refresh overwrite newer target s return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), }, @@ -350,9 +348,6 @@ test('direct stop bypasses routing candidates and preserves a not_owned delegati return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), - delegations: async (targetSessionId) => ({ - delegations: [{ actionId: 'action-1', targetSessionId, stoppable: true }], - }), candidates: async () => { candidateReads += 1; return { candidateSetId: `sha256:${'d'.repeat(64)}`, candidates: [] }; @@ -379,14 +374,14 @@ test('direct stop bypasses routing candidates and preserves a not_owned delegati outcome: 'not_owned', targetTurnId: 'shared-turn', }); - // The proposal carries only opaque identities and the resolved active state. - // No display name reaches the Action Gate. + // The proposal carries only the Session the reference resolved to. No display + // name and no delegation identity reach the Action Gate: which link to end is + // the Host's to decide. assert.deepEqual(actions, [{ actionId: 'stop-1', userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'action-1', expects: { targetSessionId: 'payments' }, }, confirmation: { kind: 'user_stop' }, @@ -409,7 +404,6 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => assert.fail('anaphoric stop must not reach the Action Gate'), }, @@ -437,9 +431,6 @@ test('a stop answer never comes from an unfilled delegation mirror', async () => coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), - delegations: async (targetSessionId) => ({ - delegations: [{ actionId: 'action-1', targetSessionId, stoppable: true }], - }), candidates: async () => assert.fail('a direct stop must not read route candidates'), act: async (input) => { actions.push(input); @@ -459,48 +450,67 @@ test('a stop answer never comes from an unfilled delegation mirror', async () => await handle.close(); }); -test('a named stop explains a Session that is not uniquely stoppable', async () => { - // The renderer's own delegation mirror is deliberately empty here: what the - // user is told comes from the Host, not from whatever the client last saw. - for (const [reason, stoppable] of [ - ['stop_target_not_unique', 2], - ['stop_target_not_active', 0], - ] as const) { - const sessions = port([session('payments', { sessionName: 'Payments' })]); - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async (handler) => { - handler([], []); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - delegations: async (targetSessionId) => ({ - delegations: Array.from({ length: stoppable }, (_unused, index) => ({ - actionId: `action-${index}`, - targetSessionId, - stoppable: true, - })), - }), - candidates: async () => assert.fail('stop clarification must not read route candidates'), - act: async () => assert.fail('an unstoppable named target must not reach the Action Gate'), +test('a named stop reports the Gate refusal instead of judging the target itself', async () => { + // The renderer no longer decides whether a Session can be stopped, so it + // submits and lets the Gate answer. Its refusal is the clarification, which + // is the only version of this answer that cannot contradict the Host. + const sessions = port([session('payments', { sessionName: 'Payments' })]); + let submitted = 0; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([], []); + return { close: async () => undefined }; }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), + act: async () => { + submitted += 1; + throw new WorkHubCoordinationFailure( + 'operation_conflict', + 'WorkHub has no active durable delegation to stop on that Session', + ); + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); - assert.deepEqual( - await controller.submit({ requestId: 'stop-payments', text: 'Stop Payments' }), - { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'stop-payments', - text: 'Stop Payments', - options: [], - reason, + assert.deepEqual(await controller.submit({ requestId: 'stop-payments', text: 'Stop Payments' }), { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'stop-payments', + text: 'Stop Payments', + options: [], + reason: 'stop_target_unavailable', + }); + assert.equal(submitted, 1, 'the Host is the one that decides, so it must be asked'); + await handle.close(); +}); + +test('a stop that fails for any other reason is a fault, not a clarification', async () => { + const sessions = port([session('payments', { sessionName: 'Payments' })]); + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([], []); + return { close: async () => undefined }; }, - ); - await handle.close(); - } + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), + act: async () => { + throw new WorkHubCoordinationFailure('persistence_failed', 'WorkHub stop state is unavailable'); + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + await assert.rejects( + () => controller.submit({ requestId: 'stop-payments', text: 'Stop Payments' }), + /WorkHub stop state is unavailable/, + ); + await handle.close(); }); test('stop-shaped ordinary work routes normally instead of looping on clarification', async () => { @@ -518,10 +528,8 @@ test('stop-shaped ordinary work routes normally instead of looping on clarificat return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', @@ -1567,10 +1575,8 @@ test('submit keeps unmatched non-executable conversation in WorkHub', async () = coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: [], }), act: async (input) => { @@ -1615,10 +1621,8 @@ test('production submission delegates only through the Runtime-owned candidate r coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: [{ candidateRef: 'candidate-payment', sessionId: 'payment', @@ -1671,10 +1675,8 @@ test('production retry reaches durable Action Gate replay while target is waitin coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: [{ candidateRef: 'candidate-payment', sessionId: 'payment', @@ -1718,10 +1720,8 @@ test('production sends an explicit correction as a linked replacement', async () coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'d'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: [ { candidateRef: 'candidate-source', @@ -1844,7 +1844,6 @@ test('production natural-language corrections retain the prior delegation link', coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId, candidates }), act: async (input) => { actions.push(input); @@ -1927,10 +1926,8 @@ test('production correction-shaped creation stays create_new without an existing coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: [], }), act: async (input) => { @@ -1962,10 +1959,8 @@ test('production clarification is persisted through the typed Action Gate dispos record: async () => { throw new Error('legacy summary recording must not persist clarification'); }, - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: [], }), act: async (input) => { @@ -2005,10 +2000,8 @@ test('production creation leaves Session identity and workspace authority to mai coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: [], }), act: async (input) => { 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 7f67fc589a..00ba4f9303 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -443,10 +443,8 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and }, }, record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: [], }), act: async () => ({ @@ -557,10 +555,8 @@ test('Coordination transcript reset rebuilds active linkage outside the resident }, }, record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: [], }), act: async () => ({ diff --git a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts index 8b160ab89c..5f716a010e 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts @@ -33,9 +33,6 @@ const routable = (sessionId: string, sessionName: string) => ({ }); /** Stands in for the Host read the stop policy makes once a reference resolves. */ -const hostDelegations = (bySessionId: Readonly>) => - async (sessionId: string): Promise => bySessionId[sessionId] ?? []; - /** * A stand-in for a later ranked resolver. It recalls by remembered description * rather than display name, which is exactly the recall the exact-name baseline @@ -60,25 +57,19 @@ const describedResolver = ( test('stop resolves through the shared port rather than a stop-specific grammar', async () => { const sessions = [routable('payments', 'Payments'), routable('login', 'Login')]; - const readStoppableDelegations = hostDelegations({ - payments: ['action-1'], - login: ['action-2'], - }); // Action Intent extracts the reference ("Stop the payment timeout work" -> // "payment timeout work"); resolving it is the Resolver's business alone. // The exact-name baseline recalls the display name and nothing else. const baseline = createWorkHubRoutePolicy(); - assert.deepEqual(await baseline.resolveStop({ text: 'Stop Payments', sessions, readStoppableDelegations }), { + assert.deepEqual(baseline.resolveStop({ text: 'Stop Payments', sessions}), { kind: 'target', target: { sessionId: 'payments' }, - stopsActionId: 'action-1', }); assert.deepEqual( - await baseline.resolveStop({ + baseline.resolveStop({ text: 'Stop the payment timeout work', sessions, - readStoppableDelegations, }), { kind: 'not_requested' }, ); @@ -89,39 +80,31 @@ test('stop resolves through the shared port rather than a stop-specific grammar' describedResolver(new Map([['payments', 'payment timeout work']])), ); assert.deepEqual( - await ranked.resolveStop({ + ranked.resolveStop({ text: 'Stop the payment timeout work', sessions, - readStoppableDelegations, }), { kind: 'target', target: { sessionId: 'payments' }, - stopsActionId: 'action-1', }); }); -test('the stop policy, not the resolver, owns destructive sufficiency', async () => { +test('a resolved reference submits instead of judging the Host state itself', () => { const descriptions = new Map([['payments', 'payment timeout work']]); const text = 'Stop the payment timeout work'; - // A confidently resolved Session with no active WorkHub delegation, and one - // with several, are both refused with the reason they were refused. - // Both answers come from the Host read, never from a renderer mirror. - assert.deepEqual( - await createWorkHubRoutePolicy(describedResolver(descriptions)).resolveStop({ - text, - sessions: [routable('payments', 'Payments')], - readStoppableDelegations: hostDelegations({}), - }), - { kind: 'clarification', reason: 'stop_target_not_active' }, - ); + // Whether that Session still owns a single stoppable delegation is not asked + // here, and deliberately so: only the Host can answer it, and it re-proves it + // under the lease that performs the stop. A renderer that answered from its + // own view would contradict the Host in exactly the windows where its view is + // empty. A confidently resolved reference therefore becomes a target, and a + // Session with nothing to stop is refused by the Gate, not here. assert.deepEqual( - await createWorkHubRoutePolicy(describedResolver(descriptions)).resolveStop({ + createWorkHubRoutePolicy(describedResolver(descriptions)).resolveStop({ text, sessions: [routable('payments', 'Payments')], - readStoppableDelegations: hostDelegations({ payments: ['action-1', 'action-2'] }), }), - { kind: 'clarification', reason: 'stop_target_not_unique' }, + { kind: 'target', target: { sessionId: 'payments' } }, ); }); @@ -133,10 +116,9 @@ test('an ambiguous recall never becomes a destructive target', async () => { ]), ); assert.deepEqual( - await createWorkHubRoutePolicy(resolver).resolveStop({ + createWorkHubRoutePolicy(resolver).resolveStop({ text: 'Stop the payment timeout work', sessions: [routable('payments', 'Payments'), routable('payments-eu', 'Payments EU')], - readStoppableDelegations: () => assert.fail('an ambiguous recall must not read the Host'), }), { kind: 'clarification', reason: 'stop_target_ambiguous' }, ); @@ -152,10 +134,9 @@ test('a resolver cannot widen stop beyond the visible candidate set it was given }), }; assert.deepEqual( - await createWorkHubRoutePolicy(resolver).resolveStop({ + createWorkHubRoutePolicy(resolver).resolveStop({ text: 'Stop Payments', sessions: [routable('payments', 'Payments')], - readStoppableDelegations: hostDelegations({ payments: ['action-1'] }), }), { kind: 'not_requested' }, ); @@ -166,10 +147,9 @@ test('a stop cue with no safe reference asks for one instead of resolving', asyn resolve: () => assert.fail('an unsafe reference must not reach the Session Resolver'), }; assert.deepEqual( - await createWorkHubRoutePolicy(resolver).resolveStop({ + createWorkHubRoutePolicy(resolver).resolveStop({ text: 'Stop it', sessions: [routable('payments', 'Payments')], - readStoppableDelegations: () => assert.fail('an unsafe reference must not read the Host'), }), { kind: 'clarification', reason: 'stop_target_required' }, ); 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 08d0ac557d..076040c109 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -420,10 +420,8 @@ test('ambiguous creation is durably clarified before a fresh imperative creates coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: [], }), act: async (input) => { @@ -595,10 +593,8 @@ test('real Session projection creates new guide topics and preserves origin ambi coordination: { open: async () => ({ close: async () => undefined }), record: async (input) => ({ turnId: input.turnId }), - delegations: async () => ({ delegations: [] }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, - delegations: async () => ({ delegations: [] }), candidates: sessions.map((entry) => ({ candidateRef: `candidate-${entry.id}`, sessionId: entry.id, @@ -666,7 +662,7 @@ test('real Session projection creates new guide topics and preserves origin ambi return { disposition: 'stop_work', outcome: 'cancelled_pending', - targetSessionId: input.proposal.stopsActionId, + targetSessionId: input.proposal.expects.targetSessionId, }; } const targetSessionId = input.proposal.candidateRef.replace(/^candidate-/u, ''); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index d666a470c8..5a39dba44b 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -971,12 +971,6 @@ export class DesktopRuntimeHostClient { return this.request("workhub.coordination.candidates", {}); } - listWorkHubCoordinationDelegations( - input: OperationInput<"workhub.coordination.delegations">, - ): Promise> { - return this.request("workhub.coordination.delegations", input); - } - actWorkHubCoordination( input: OperationInput<"workhub.coordination.act">, ): Promise> { 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 ac6410ffc0..4210a0c38c 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -33,7 +33,6 @@ type RuntimeHostWorkHubClient = Pick< | 'actWorkHubCoordination' | 'answerWorkHubCoordination' | 'listWorkHubCoordinationCandidates' - | 'listWorkHubCoordinationDelegations' | 'recordWorkHubCoordination' | 'resolveWorkHubCoordinationSession' >; @@ -61,11 +60,6 @@ export function registerRuntimeHostWorkHubIpc( client.recordWorkHubCoordination(input), ); ipcMain.handle('workhub:candidates', () => client.listWorkHubCoordinationCandidates()); - ipcMain.handle('workhub:delegations', (_event, _scope, targetSessionId?: string) => - client.listWorkHubCoordinationDelegations( - targetSessionId === undefined ? {} : { targetSessionId }, - ), - ); ipcMain.handle('workhub:act', async (_event, rawInput: RendererWorkHubActionInput) => { try { const proposal = rawInput?.proposal; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 4b9bf10f97..d68e5c75c2 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1048,14 +1048,6 @@ export interface MakaBridge { candidates( coordinationSessionId: string, ): Promise>; - /** - * Read the Host's active delegation links, optionally for one Session. - * A renderer projection of these links can be stale or not yet built. - */ - delegations( - coordinationSessionId: string, - targetSessionId?: string, - ): Promise>; /** Submit a typed proposal; trusted creation context is added outside the renderer. */ act( coordinationSessionId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 9026616fd9..b4013452f4 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1915,30 +1915,6 @@ const makaBridge = { ); return ipcRenderer.invoke('workhub:record', scope, input) as Promise<{ turnId: string }>; }, - async delegations( - coordinationSessionId: string, - targetSessionId?: string, - ): Promise> { - const scope = await resolveDesktopWorkHubCoordinationCreateScope( - coordinationSessionId, - runtimeHostSessionRef, - ); - const hostTargetSessionId = - targetSessionId === undefined - ? undefined - : (await runtimeHostSessionRef(targetSessionId)).sessionId; - const result = await ipcRenderer.invoke( - 'workhub:delegations', - scope, - hostTargetSessionId, - ) as OperationOutput<'workhub.coordination.delegations'>; - return { - delegations: result.delegations.map((delegation) => ({ - ...delegation, - targetSessionId: recordRuntimeHostSessionScope(scope, delegation.targetSessionId), - })), - }; - }, async candidates( coordinationSessionId: string, ): Promise> { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 1a1a8ee6c1..a847a21794 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1479,8 +1479,6 @@ function AppShellContent({ window.maka.workHub.record(workHubCoordinationSessionId!, input), candidates: () => window.maka.workHub.candidates(workHubCoordinationSessionId!), - delegations: (targetSessionId) => - window.maka.workHub.delegations(workHubCoordinationSessionId!, targetSessionId), act: (input) => window.maka.workHub.act(workHubCoordinationSessionId!, input), }), diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index a73aa14dd7..f900300483 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -29,12 +29,27 @@ import { type WorkHubStopClarificationReason, } from './workhub-route-policy.js'; import type { + OperationError, WorkHubCoordinationActInput, WorkHubCoordinationActResult, WorkHubCoordinationCandidatesResult, - WorkHubCoordinationDelegationsResult, } from '@maka/runtime-host/protocol'; +/** + * A Host operation the Coordination port could not complete. It lives beside + * the port interface rather than beside its Desktop implementation, so a + * caller can tell a refusal from a fault without depending on the adapter. + */ +export class WorkHubCoordinationFailure extends Error { + constructor( + readonly code: OperationError<'workhub.coordination.act'>['code'], + message: string, + ) { + super(message); + this.name = 'WorkHubCoordinationFailure'; + } +} + export interface WorkHubSessionTarget { sessionId: string; } @@ -245,11 +260,6 @@ export interface WorkHubCoordinationPort { assistantText: string; }): Promise<{ turnId: string }>; candidates(): Promise; - /** - * The Host's active delegation links for one Session, each with its own - * answer to whether it still holds work a stop could reach. - */ - delegations(targetSessionId: string): Promise; act(input: Omit): Promise; } @@ -480,17 +490,9 @@ export function createWorkHubController(deps: { const sessions = await deps.sessions.list(); reconcileFocus(submissionPolicy, sessions); const ordinary = sessions.filter((session) => session.kind === 'ordinary'); - const stopDecision = await submissionPolicy.resolveStop({ + const stopDecision = submissionPolicy.resolveStop({ text: input.text, sessions: ordinary, - readStoppableDelegations: async (sessionId) => { - const { delegations } = await coordination.delegations(sessionId); - return delegations - .filter( - (delegation) => delegation.targetSessionId === sessionId && delegation.stoppable, - ) - .map((delegation) => delegation.actionId); - }, }); if (stopDecision.kind !== 'not_requested') { if (stopDecision.kind === 'clarification') { @@ -503,26 +505,43 @@ export function createWorkHubController(deps: { reason: stopDecision.reason, }; } - const { target, stopsActionId } = stopDecision; - const admitted = await coordination.act({ - actionId: input.requestId, - userText: input.text, - proposal: { - disposition: 'stop_work', - stopsActionId, - // The proposal carries only opaque identities and the state the - // policy resolved against. The Action Gate revalidates both, so a - // resolution that went stale is refused rather than acted on. - expects: { targetSessionId: target.sessionId }, - }, - confirmation: { kind: 'user_stop' }, - }); + const { target } = stopDecision; + let admitted; + try { + admitted = await coordination.act({ + actionId: input.requestId, + userText: input.text, + proposal: { + disposition: 'stop_work', + // Only the Session the reference resolved to. Which delegation + // that Session still owns is the Host's to decide, under the + // lease that ends it. + expects: { targetSessionId: target.sessionId }, + }, + confirmation: { kind: 'user_stop' }, + }); + } catch (error) { + // The Gate refusing the stop is an answer, not a fault: it is the + // only party that can say the Session owns no single stoppable + // delegation. Anything else is a real failure and still throws. + if ( + error instanceof WorkHubCoordinationFailure && + error.code === 'operation_conflict' + ) { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: 'stop_target_unavailable', + }; + } + throw error; + } if (admitted.disposition !== 'stop_work') { throw new Error('WorkHub Action Gate returned an unexpected disposition'); } - if (admitted.outcome !== 'not_owned') { - removeActiveAction(target.sessionId, stopsActionId); - } return { kind: 'stop', strategyId: WORKHUB_ROUTING_STRATEGY_ID, diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index f12d4bb5e8..114fed8532 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -34,25 +34,16 @@ import type { WorkHubCoordinationActInput, WorkHubCoordinationActResult, WorkHubCoordinationCandidatesResult, - WorkHubCoordinationDelegationsResult, OperationOutcome, OperationError, } from '@maka/runtime-host/protocol'; -import { boundedWorkHubTimelineText } from './workhub-controller.js'; +import { boundedWorkHubTimelineText, WorkHubCoordinationFailure } from './workhub-controller.js'; + +export { WorkHubCoordinationFailure }; import type { WorkHubDesktopTranscriptBridge } from './workhub-session-port.js'; const WORKHUB_COORDINATION_TURN_LIMIT = 40; -export class WorkHubCoordinationFailure extends Error { - constructor( - readonly code: OperationError<'workhub.coordination.act'>['code'], - message: string, - ) { - super(message); - this.name = 'WorkHubCoordinationFailure'; - } -} - export function createDesktopWorkHubCoordinationPort(deps: { sessionId: string; transcripts: WorkHubDesktopTranscriptBridge; @@ -62,7 +53,6 @@ export function createDesktopWorkHubCoordinationPort(deps: { assistantText: string; }): Promise<{ turnId: string }>; candidates(): Promise; - delegations(targetSessionId: string): Promise; act( input: Omit, ): Promise>; @@ -70,7 +60,6 @@ export function createDesktopWorkHubCoordinationPort(deps: { return { record: deps.record, candidates: deps.candidates, - delegations: deps.delegations, async act(input) { const outcome = await deps.act(input); if (!outcome.ok) { diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index f93db4fbf5..af2960c94f 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -62,15 +62,24 @@ export type WorkHubRouteDecision = | { kind: 'new_session'; title: string; correctedFrom?: WorkHubRouteTarget }; +/** + * Both reasons are about the reference itself — what the user's words name — + * which is the only question this policy can answer on its own. + * + * Whether the named Session still owns work a stop can reach is not asked + * here. Only the Host knows that, it proves it under the admission lease + * anyway, and a renderer that answered from its own view would contradict the + * Host in exactly the windows where its view is empty: a second window, a + * reload, a reconnect. So a resolved reference submits, and a Session with + * nothing to stop is refused by the Gate. + */ export type WorkHubStopClarificationReason = /** The stop names no safe target of its own — a pronoun or a bare noun. */ | 'stop_target_required' /** The stop names more than one existing Session. */ | 'stop_target_ambiguous' - /** The named Session exists but owns no WorkHub-delegated active work. */ - | 'stop_target_not_active' - /** The named Session owns more than one active delegation. */ - | 'stop_target_not_unique'; + /** The Host refused the stop; its conflict is the whole answer. */ + | 'stop_target_unavailable'; /** * A stop clarification never offers route options. Choosing one re-sends the @@ -80,25 +89,13 @@ export type WorkHubStopClarificationReason = export type WorkHubStopRouteDecision = | { kind: 'not_requested' } | { kind: 'clarification'; reason: WorkHubStopClarificationReason } - | { - kind: 'target'; - target: WorkHubRouteTarget; - /** The one active delegation the policy resolved, by opaque identity. */ - stopsActionId: string; - }; + | { kind: 'target'; target: WorkHubRouteTarget }; export interface WorkHubRoutePolicy { resolveStop(input: { text: string; sessions: WorkHubRoutableSession[]; - /** - * The Host's stoppable delegations for one resolved Session, by opaque - * action identity. It is read only once a reference has resolved, so an - * ordinary message never pays for it, and the answer the user sees comes - * from the same authority admission uses rather than a client mirror. - */ - readStoppableDelegations: (sessionId: string) => Promise; - }): Promise; + }): WorkHubStopRouteDecision; resolve(input: { text: string; sessions: WorkHubRoutableSession[]; @@ -165,7 +162,7 @@ function createWorkHubRoutePolicyVisit( // ordinary work and falls through to routing; an unsafe or anaphoric // reference still fails closed, and a resolved Session that is not uniquely // stoppable says why. - async resolveStop({ text, sessions, readStoppableDelegations }) { + resolveStop({ text, sessions }) { const intent = readWorkHubRequestIntent(text); if (!intent.stop.cue) return { kind: 'not_requested' }; const reference = intent.stop.imperative ? intent.stop.target : undefined; @@ -197,16 +194,10 @@ function createWorkHubRoutePolicyVisit( } const resolved = sessionByRef.get(admissible[0]!.ref); if (!resolved) return { kind: 'not_requested' }; - const [stopsActionId, ...furtherActive] = await readStoppableDelegations( - resolved.target.sessionId, - ); - if (!stopsActionId) { - return { kind: 'clarification', reason: 'stop_target_not_active' }; - } - if (furtherActive.length > 0) { - return { kind: 'clarification', reason: 'stop_target_not_unique' }; - } - return { kind: 'target', target: resolved.target, stopsActionId }; + // The reference resolved, which is everything this policy can prove. + // Which delegation to end, and whether there is one at all, is the + // Host's answer and is made under the lease that performs the stop. + return { kind: 'target', target: resolved.target }; }, resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { const intent = readWorkHubRequestIntent(text); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 496561024f..8f9bf0532a 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -628,8 +628,8 @@ export function WorkHubCoordinationTurnView(props: { /** * A stop clarification has to say what WorkHub could not decide. Every reason * here is a distinct dead end for the user — an unnamed target, a name that - * fits several Sessions, a named Session with nothing to stop, and one holding - * more work than a single stop may retire. + * fits several Sessions, and a Session the Host will not stop because it owns + * no single delegation a stop can reach. */ function workHubClarificationPrompt( reason: Extract['reason'], @@ -638,8 +638,7 @@ function workHubClarificationPrompt( if (reason === 'ambiguous_command') return copy.confirmCommand; if (reason === 'stop_target_required') return copy.stopTargetRequired; if (reason === 'stop_target_ambiguous') return copy.stopTargetAmbiguous; - if (reason === 'stop_target_not_active') return copy.stopTargetNotActive; - if (reason === 'stop_target_not_unique') return copy.stopTargetNotUnique; + if (reason === 'stop_target_unavailable') return copy.stopTargetUnavailable; return undefined; } @@ -839,8 +838,7 @@ function workHubCopy(locale: UiLocale) { confirmCommand: workHubAmbiguousCommandPrompt(locale), stopTargetRequired: '请明确说出要停止的工作名称,例如“停止 支付任务”。', stopTargetAmbiguous: '这个名称对应多项工作;请打开具体的 Session 停止对应委托。', - stopTargetNotActive: '这项工作当前没有由 WorkHub 委托的进行中请求,无需停止。', - stopTargetNotUnique: '这项工作有多个进行中的委托;请打开该 Session 停止具体的那一个。', + stopTargetUnavailable: '这项工作现在没有可以停止的单个 WorkHub 委托;请打开该 Session 查看。', discussionStayed: '这条内容暂时保留在 WorkHub,没有创建或改动 Session。', discussionHint: '提出明确的执行目标后,我会把它交给对应的 Session。', answering: '正在回答…', @@ -901,9 +899,8 @@ function workHubCopy(locale: UiLocale) { stopTargetRequired: 'Name the work explicitly, for example “Stop Payments”.', stopTargetAmbiguous: 'That name matches more than one work item. Open the exact Session to stop its delegation.', - stopTargetNotActive: 'This work has no WorkHub-delegated request running, so there is nothing to stop.', - stopTargetNotUnique: - 'This work has more than one delegation running. Open its Session to stop the exact one.', + stopTargetUnavailable: + 'This work has no single WorkHub delegation to stop right now. Open its Session to see what is running.', discussionStayed: 'This stayed in WorkHub without creating or changing a Session.', discussionHint: 'State an executable goal and I will hand it to the owning Session.', answering: 'Answering…', diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 24d959e984..78aad8a616 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -792,7 +792,6 @@ test('WorkHub correction replaces its link without stopping a shared manual Turn confirmation: { kind: 'user_stop' }, proposal: { disposition: 'stop_work', - stopsActionId: assignment.actionId, expects: { targetSessionId: source.id }, }, }, 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 8a00891a77..5c96997b4c 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 @@ -2517,6 +2517,9 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { ? 'same_claim' : 'conflict'; }, + async readActionClaim(actionId: string) { + return actionClaims.get(actionId); + }, async probeTargetRemoval(sessionId: string) { if (this.sessions.some((session) => session.id === sessionId)) return 'present' as const; return this.removedSessionIds.has(sessionId) ? ('removed' as const) : ('absent' as const); 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 e0ed99bb1f..395b5b0dd5 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -32,6 +32,8 @@ import { import { WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_SESSION_ROLE, + WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, + type StoredMessage, } from '@maka/core/session'; import { createSessionStore, type SessionAuthorityStore } from '@maka/storage/session-store'; import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store'; @@ -628,7 +630,6 @@ describe('Host WorkHub Coordination coordinator', () => { userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'source-action', expects: { targetSessionId: target.id }, }, confirmation: { kind: 'user_stop' }, @@ -670,7 +671,6 @@ describe('Host WorkHub Coordination coordinator', () => { userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'source-action', expects: { targetSessionId: targetId }, }, confirmation: { kind: 'user_stop' }, @@ -771,7 +771,6 @@ describe('Host WorkHub Coordination coordinator', () => { userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'source-action', expects: { targetSessionId: target.id }, }, confirmation: { kind: 'user_stop' }, @@ -866,7 +865,6 @@ describe('Host WorkHub Coordination coordinator', () => { userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'source-action', expects: { targetSessionId: target.id }, }, confirmation: { kind: 'user_stop' }, @@ -897,7 +895,6 @@ describe('Host WorkHub Coordination coordinator', () => { userText: 'Stop Payments', proposal: { disposition: 'stop_work' as const, - stopsActionId: 'source-action', expects: { targetSessionId: targetId }, }, confirmation: { kind: 'user_stop' as const }, @@ -1014,7 +1011,6 @@ describe('Host WorkHub Coordination coordinator', () => { userText: 'Stop Login', proposal: { disposition: 'stop_work' as const, - stopsActionId: 'login-action', expects: { targetSessionId: loginSessionId }, }, confirmation: { kind: 'user_stop' as const }, @@ -1074,7 +1070,6 @@ describe('Host WorkHub Coordination coordinator', () => { userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'source-action', expects: { targetSessionId: targets.find((session) => session.name === 'Payments')!.id, }, @@ -1105,6 +1100,195 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('a claim written before its stop request resolves like a first attempt', async () => { + // The claim is committed before the request, so a crash between them leaves + // an action that owns a stop with nothing to converge on. Nothing + // destructive happened either, so the delegation is still linked and the + // retry must resolve from the active links rather than refuse. + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-claim-only-')); + const store = createSessionStore(root); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + let failStopRequest = true; + const stores = new Proxy(store, { + get(authority, property, receiver) { + if (property === 'appendMessages') { + return async (sessionId: string, messages: StoredMessage[]) => { + if ( + failStopRequest && + messages.some( + (message) => + message.type === 'workhub_coordination' && + message.kind === 'delegation_stop_requested', + ) + ) { + failStopRequest = false; + throw new Error('crash before the stop request is durable'); + } + return authority.appendMessages(sessionId, messages); + }; + } + return Reflect.get(authority, property, receiver); + }, + }) as SessionAuthorityStore; + const workhub = coordinator(root, stores, () => undefined, undefined, undefined, undefined, { + assign: (input) => persistTestAssignment(store, input, 'payments-turn'), + retireDelegation: async () => ({ + outcome: 'stop_delivered' as const, + targetTurnId: 'payments-turn', + }), + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!.candidateRef, + }, + }, + CONTEXT, + ) + ).ok, + true, + ); + const assignment = await store.readWorkHubAssignment('source-action'); + assert.ok(assignment); + + const stop = () => + workhub.handlers['workhub.coordination.act']( + { + actionId: 'stop-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', expects: { targetSessionId: target.id } }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + + assert.equal((await stop()).ok, false); + // Exactly the seam: the action owns a stop claim, and no request behind it. + assert.equal((await store.readWorkHubActionClaim('stop-action'))?.operation, 'stop'); + assert.equal(await store.readWorkHubStopRequest(assignment.delegationId), undefined); + + const retried = await stop(); + assert.equal(retried.ok, true); + if (retried.ok && retried.result.disposition === 'stop_work') { + assert.equal(retried.result.outcome, 'stop_delivered'); + } + assert.equal( + (await store.readWorkHubStopResolution(assignment.delegationId))?.outcome, + 'stop_delivered', + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('a claimed stop whose delegation went terminal elsewhere conflicts', async () => { + // Claim present, no request and no resolution to converge on, and the + // delegation is gone from the active set because another path superseded + // it. There is nothing left to resolve and nothing was destroyed, so this + // refuses exactly as it did before the claim became the replay key. + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-claim-terminal-')); + const store = createSessionStore(root); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + assign: (input) => persistTestAssignment(store, input, 'payments-turn'), + retireDelegation: async () => assert.fail('a terminal delegation must not be retired'), + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!.candidateRef, + }, + }, + CONTEXT, + ) + ).ok, + true, + ); + const assignment = await store.readWorkHubAssignment('source-action'); + assert.ok(assignment); + assert.equal( + await store.claimWorkHubAction({ + actionId: 'stop-action', + operation: 'stop', + actionFingerprint: `sha256:${'b'.repeat(64)}`, + subject: assignment.delegationId, + }), + 'claimed', + ); + await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [ + { + type: 'workhub_coordination', + id: 'whs_terminal_probe', + turnId: 'terminal-probe-turn', + ts: Date.now(), + schemaVersion: WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, + kind: 'delegation_superseded', + actionId: 'supersede-probe-action', + actionFingerprint: `sha256:${'c'.repeat(64)}`, + coordinationTurnId: 'terminal-probe-turn', + supersededActionId: 'source-action', + supersededDelegationId: assignment.delegationId, + replacementDelegationId: 'whd_replacement_probe', + }, + ]); + + const conflicted = await workhub.handlers['workhub.coordination.act']( + { + actionId: 'stop-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', expects: { targetSessionId: target.id } }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + assert.equal(conflicted.ok, false); + if (!conflicted.ok) assert.equal(conflicted.error.code, 'operation_conflict'); + assert.equal(await store.readWorkHubStopResolution(assignment.delegationId), undefined); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('refuses to merge a Turn identity shared across answer and record', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-turn-identity-')); const store = createSessionStore(root); 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 84579f7c31..25cd512c4e 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -91,7 +91,6 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'action-payments', expects: { targetSessionId: 'payments' }, }, confirmation: { kind: 'user_stop' }, @@ -101,7 +100,6 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'action-payments', expects: { targetSessionId: 'payments' }, }, confirmation: { kind: 'user_stop' }, @@ -113,7 +111,6 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'action-payments', expects: { targetSessionId: 'payments' }, }, }, @@ -122,7 +119,6 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'action-payments', expects: { targetSessionId: 'payments' }, }, confirmation: { kind: 'user_correction' }, @@ -132,7 +128,6 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'action-payments', expects: { targetSessionId: 'payments' }, targetSessionId: 'injected', }, @@ -151,7 +146,6 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () userText: 'Stop Payments', proposal: { disposition: 'stop_work', - stopsActionId: 'action-payments', expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, }, confirmation: { kind: 'user_stop' }, diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index beff2f2d9e..735c6a840e 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -39,7 +39,6 @@ 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; -export const WORKHUB_COORDINATION_DELEGATION_MAX_ITEMS = 256; const RESOLVE_ERRORS = [ 'host_not_ready', @@ -118,34 +117,6 @@ export interface WorkHubCoordinationCandidatesResult { readonly candidates: readonly WorkHubCoordinationCandidate[]; } -/** - * Reads the Coordination Session's active delegation links. - * - * A client mirror of these links is a projection that can be empty or stale — - * a fresh window, a reload, a reconnect — so a policy about to answer the user - * from it asks the Host instead of trusting what it happens to have seen. - */ -export interface WorkHubCoordinationDelegationsInput { - /** Limits both the result and the target-owned work probe to one Session. */ - readonly targetSessionId?: string; -} - -export interface WorkHubCoordinationDelegation { - readonly actionId: string; - readonly targetSessionId: string; - /** - * Whether this delegation still holds work a stop could reach. A link - * outlives the work it delegated, and target execution state that cannot be - * read is reported as still holding work rather than as finished. - */ - readonly stoppable: boolean; -} - -export interface WorkHubCoordinationDelegationsResult { - /** Active delegations in Coordination transcript order. */ - readonly delegations: readonly WorkHubCoordinationDelegation[]; -} - export type WorkHubCoordinationProposal = | { readonly disposition: 'answer_here' } | { readonly disposition: 'clarify'; readonly assistantText: string } @@ -164,13 +135,15 @@ export type WorkHubCoordinationProposal = } | { readonly disposition: 'stop_work'; - /** Action identity of the exact durable delegation link being stopped. */ - readonly stopsActionId: string; /** * The expected state the Action Policy resolved against. It carries no * authority of its own; the Action Gate revalidates it against current * durable facts, so a resolution that has gone stale fails closed instead * of stopping work the user never resolved. + * + * Which delegation the stop ends is not stated here. A client cannot + * prove which link is live, so the Gate resolves it from its own active + * links, and on replay from the durable claim this action already owns. */ readonly expects: WorkHubCoordinationStopPreconditions; }; @@ -276,17 +249,6 @@ export const WORKHUB_COORDINATION_OPERATION_SPECS = { decodeInput: decodeWorkHubCoordinationCandidatesInput, decodeOutput: decodeWorkHubCoordinationCandidatesResult, }), - 'workhub.coordination.delegations': defineOperation< - WorkHubCoordinationDelegationsInput, - WorkHubCoordinationDelegationsResult, - (typeof CANDIDATE_ERRORS)[number] - >({ - mode: 'query', - availability: 'ready', - errors: CANDIDATE_ERRORS, - decodeInput: decodeWorkHubCoordinationDelegationsInput, - decodeOutput: decodeWorkHubCoordinationDelegationsResult, - }), 'workhub.coordination.act': defineOperation< WorkHubCoordinationActInput, WorkHubCoordinationActResult, @@ -386,51 +348,6 @@ export function decodeWorkHubCoordinationCandidatesResult( }; } -export function decodeWorkHubCoordinationDelegationsInput( - value: unknown, -): WorkHubCoordinationDelegationsInput { - const input = requireShapedRecord( - value, - 'WorkHub Coordination delegations input', - [], - ['targetSessionId'], - ); - return input.targetSessionId === undefined - ? {} - : { targetSessionId: requireEntityId(input.targetSessionId, 'WorkHub target Session id') }; -} - -export function decodeWorkHubCoordinationDelegationsResult( - value: unknown, -): WorkHubCoordinationDelegationsResult { - const result = requireExactRecord(value, 'WorkHub Coordination delegations result', [ - 'delegations', - ]); - if (!Array.isArray(result.delegations)) { - throw invalidProtocolFrame('Invalid WorkHub Coordination delegations'); - } - if (result.delegations.length > WORKHUB_COORDINATION_DELEGATION_MAX_ITEMS) { - throw invalidProtocolFrame('Too many WorkHub Coordination delegations'); - } - return { delegations: result.delegations.map(decodeWorkHubCoordinationDelegation) }; -} - -function decodeWorkHubCoordinationDelegation(value: unknown): WorkHubCoordinationDelegation { - const delegation = requireExactRecord(value, 'WorkHub Coordination delegation', [ - 'actionId', - 'targetSessionId', - 'stoppable', - ]); - if (typeof delegation.stoppable !== 'boolean') { - throw invalidProtocolFrame('Invalid WorkHub delegation work state'); - } - return { - actionId: requireEntityId(delegation.actionId, 'WorkHub Coordination action id'), - targetSessionId: requireEntityId(delegation.targetSessionId, 'WorkHub target Session id'), - stoppable: delegation.stoppable, - }; -} - export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordinationActInput { const input = requireShapedRecord( value, @@ -703,14 +620,9 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP throw invalidProtocolFrame('Invalid WorkHub replacement target'); } if (proposal.disposition === 'stop_work') { - const exact = requireExactRecord(proposal, 'WorkHub stop proposal', [ - 'disposition', - 'stopsActionId', - 'expects', - ]); + const exact = requireExactRecord(proposal, 'WorkHub stop proposal', ['disposition', 'expects']); return { disposition: 'stop_work', - stopsActionId: requireEntityId(exact.stopsActionId, 'WorkHub stopped action id'), expects: decodeWorkHubCoordinationStopPreconditions(exact.expects), }; } 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 4ce2931f68..d890b81114 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -82,6 +82,17 @@ export interface WorkHubActionGateEffects { * action id reused across delegations or across dispositions. */ claimAction(claim: WorkHubActionClaim): Promise; + /** + * The operation this action identity already owns, if any. + * + * A stop names its target Session, not the delegation to end — the Host + * resolves that from its own active links. Resolving again on replay would + * fail, because a resolved stop takes its delegation out of the active set: + * the second attempt would find nothing where the first found one. The claim + * is the durable key that survives that, and it outlives removal of the + * target Session, so a committed destructive claim still converges. + */ + readActionClaim(actionId: string): Promise; /** * Durable lifetime proof for a delegation target that is no longer readable. * `removed` is a tombstone; `absent` is an identity that never existed here. @@ -355,13 +366,7 @@ export class WorkHubCoordinationActionGate { 'WorkHub stop requires an explicit named command in trusted user text', ); } - const source = await this.#effects.readAssignment(proposal.stopsActionId); - if (!source || source.targetSessionId !== proposal.expects.targetSessionId) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub can stop only the resolved durable delegation it owns', - ); - } + const source = await this.#stopSource(input.actionId, proposal.expects.targetSessionId); const stopFingerprint = stopActionFingerprint(input, source); await this.#claimAction(input.actionId, 'stop', stopFingerprint, source.delegationId); const existing = await this.#effects.readStopRequest(source.delegationId); @@ -537,6 +542,77 @@ export class WorkHubCoordinationActionGate { ); } + /** + * The delegation a stop names, by the only two keys that can name it. + * + * A stop carries its target Session and its own action identity; it never + * carries the delegation, because a client cannot prove which link is live. + * + * Replay reads the claim first. A resolved stop takes its delegation out of + * the active set, so re-deriving after one succeeded would find nothing and + * turn a converging replay into a conflict. The claim records the delegation + * this exact action already bound itself to, and it is written before any + * effect, so whatever the first attempt reached is reachable again. + * + * A first attempt has no claim and resolves from the active links: exactly + * one delegation on that Session must still hold work a stop could reach. + * Zero or several is the same refusal admission has always made, from the + * same durable state, rather than a client's guess about either. + */ + async #stopSource( + actionId: string, + targetSessionId: string, + ): Promise { + const claim = await this.#effects.readActionClaim(actionId); + if (claim?.operation === 'stop') { + // The request records which delegation this action bound itself to. It is + // written after the claim, so a crash between the two leaves a claim with + // nothing to converge on — and nothing destructive happened either, so + // that case resolves from the active links below like a first attempt. + const requested = await this.#effects.readStopRequest(claim.subject); + if (requested) { + const claimed = await this.#effects.readAssignment(requested.stopsActionId); + if (!claimed || claimed.targetSessionId !== targetSessionId) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop identity is already bound to a different delegation', + ); + } + return claimed; + } + } + const active = await this.#effects.listActiveAssignments(); + const onTarget = active.filter((assignment) => assignment.targetSessionId === targetSessionId); + if (onTarget.length === 0) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub has no active durable delegation to stop on that Session', + ); + } + // One link is the answer whatever state its work is in. Whether that work + // finished, or was never WorkHub's to stop, is what the stop resolves to — + // `already_terminal` and `not_owned` are outcomes, not reasons to refuse + // the request before it is recorded. + if (onTarget.length === 1) return onTarget[0]!; + // Only several links need separating, and then the rule is the same one + // competition uses: a delegation whose work already finished is still + // linked but is no longer a stop target, so it cannot make a Session that + // was delegated to twice permanently unstoppable. + const holdingWork: WorkHubDelegationAssignedMessage[] = []; + for (const assignment of onTarget) { + if ((await this.#effects.readDelegationRetirement(assignment)) !== 'retired') { + holdingWork.push(assignment); + } + } + if (holdingWork.length !== 1) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop target does not identify one active durable delegation', + ); + } + return holdingWork[0]!; + } + /** * Whether any other delegation on the stop target's Session still holds work. * A retirement read that cannot see the owner yet fails the stop closed diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index c72d3d6f56..6e99ce1dd5 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -42,8 +42,6 @@ import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage import type { OperationOutcome, WorkHubCoordinationActInput, - WorkHubCoordinationDelegation, - WorkHubCoordinationDelegationsInput, WorkHubCoordinationAnswerInput, WorkHubCoordinationRecordInput, } from '../protocol/index.js'; @@ -91,6 +89,7 @@ type CoordinationStores = Pick< | 'createStableSession' | 'listHeaders' | 'claimWorkHubAction' + | 'readWorkHubActionClaim' | 'probeSessionRemoval' | 'probeStableSessionCreate' | 'readHeaderSnapshot' @@ -134,7 +133,6 @@ export class HostWorkHubCoordinationCoordinator { 'workhub.coordination.answer': (input, context) => this.#answer(input, context), 'workhub.coordination.record': (input) => this.#record(input), 'workhub.coordination.candidates': () => this.#candidates(), - 'workhub.coordination.delegations': (input) => this.#delegations(input), 'workhub.coordination.act': (input, context) => this.#act(input, context), }; @@ -166,6 +164,9 @@ export class HostWorkHubCoordinationCoordinator { this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, () => this.#stores.claimWorkHubAction(claim), ), + // Read without the admission lease: it is a durable point lookup by + // primary key, and the claim it finds was committed under that lease. + readActionClaim: (actionId) => this.#stores.readWorkHubActionClaim(actionId), probeTargetRemoval: async (sessionId) => (await this.#stores.probeSessionRemoval(sessionId)).kind, readAssignment: (actionId) => this.#stores.readWorkHubAssignment(actionId), @@ -460,34 +461,6 @@ export class HostWorkHubCoordinationCoordinator { ); } - /** - * The active delegation links, with the Host's own answer to whether each - * still holds work a stop could reach. A client projection of these links - * can be stale or not yet built, so a policy that would otherwise answer the - * user from its mirror asks here and gets the same judgement admission uses. - */ - async #delegations( - input: WorkHubCoordinationDelegationsInput, - ): Promise> { - try { - const active = await this.#listActiveAssignments(); - const scoped = input.targetSessionId - ? active.filter((assignment) => assignment.targetSessionId === input.targetSessionId) - : active; - const delegations: WorkHubCoordinationDelegation[] = []; - for (const assignment of scoped) { - delegations.push({ - actionId: assignment.actionId, - targetSessionId: assignment.targetSessionId, - stoppable: (await this.#readDelegationRetirement(assignment)) !== 'retired', - }); - } - return { ok: true, result: { delegations } }; - } catch { - return { ok: false, error: { code: 'internal_failure', message: 'WorkHub delegations' } }; - } - } - async #candidates(): Promise> { try { return { ok: true, result: await this.#actionGate.candidates() }; From 34a32f243fc099e094dc06bdcb62348e98dd9cba Mon Sep 17 00:00:00 2001 From: ChengBo Zhang Date: Thu, 3 Sep 2026 14:21:40 +0800 Subject: [PATCH 17/19] refactor(workhub): retire the stop proofs the resolver replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving delegation resolution into the Gate left both of its predecessors standing. A stop now derives its delegation from the active links, then proves the same rule twice more. The Gate's second pass is unreachable as a decision. Every path that gets there took `source` from `#stopSource`, which had just applied that rule to `listActiveAssignments()`; the replay branch returns before this line. So re-reading the transcript to check that `source` is active, and to ask whether anything competes with it, restates the answer the stop arrived with. `listSessions` and the supersession and replacement reads stay: those are facts `#stopSource` never established. Admission's set-equivalence check is the same duplication with a cost. It requires `admissionSessionIds` to name every Session holding an active delegation, so admitting one stop serializes delegation traffic across all of them, and a delegation to an unrelated Session arriving mid-admission fails a stop that cannot touch it. What decides is the narrowed proof under the lease — target visible, `source` still linked, competitors retired — and it reads only the target's own delegations. Together they took one stop from four reads of the append-only Coordination transcript to two: one to derive, one to reprove under the lease. The retirement rule now lives in `#stopSource` alone rather than in two places that must agree. Reported by Astro-Han in review of #4439. Generated-by: Claude Opus --- .../workhub-coordination-coordinator.test.ts | 176 ++++++++++++++++++ .../workhub-coordination-action-gate.ts | 52 +----- .../workhub-coordination-coordinator.ts | 21 +-- 3 files changed, 189 insertions(+), 60 deletions(-) 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 395b5b0dd5..f6ce2ce70b 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -1289,6 +1289,182 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('a stop holds only its target Session lane and the Coordination lane', async () => { + // Admission serializes per Session. A stop that held a lane for every + // Session with an active delegation would put unrelated delegation traffic + // behind it, and a delegation arriving elsewhere mid-admission would fail a + // stop it cannot affect. The proof under the lease needs neither. + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-lanes-')); + const store = createSessionStore(root); + try { + const targets: Array<{ id: string; name: string }> = []; + for (const name of ['Payments', 'Login']) { + targets.push( + await store.create({ + cwd: root, + name, + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }), + ); + } + const payments = targets.find((session) => session.name === 'Payments')!; + const login = targets.find((session) => session.name === 'Login')!; + const admission = new SessionAdmissionGate(); + const laneSets: string[][] = []; + const observed = new Proxy(admission, { + get(gate, property, receiver) { + if (property === 'runMany') { + return (sessionIds: readonly string[], operation: never) => { + laneSets.push([...sessionIds]); + return gate.runMany(sessionIds, operation); + }; + } + const value = Reflect.get(gate, property, receiver) as unknown; + return typeof value === 'function' ? value.bind(gate) : value; + }, + }) as SessionAdmissionGate; + const workhub = coordinator(root, store, () => undefined, undefined, undefined, observed, { + assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`), + retireDelegation: async () => ({ + outcome: 'stop_delivered' as const, + targetTurnId: 'source-action-turn', + }), + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + for (const [actionId, target, userText] of [ + ['source-action', payments, 'Fix payment retry'], + ['login-action', login, 'Fix the login redirect'], + ] as const) { + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId, + userText, + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!.candidateRef, + }, + }, + CONTEXT, + ) + ).ok, + true, + ); + } + + laneSets.length = 0; + const stopped = await workhub.handlers['workhub.coordination.act']( + { + actionId: 'stop-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', expects: { targetSessionId: payments.id } }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + + assert.equal(stopped.ok, true); + const stopLanes = laneSets.find((lanes) => lanes.includes(payments.id)); + assert.ok(stopLanes, 'the stop must take a lane on its own target'); + assert.deepEqual( + [...stopLanes].sort(), + [WORKHUB_COORDINATION_SESSION_ID, payments.id].sort(), + 'Login has an active delegation but this stop cannot change it', + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('one stop reads the Coordination transcript twice, not once per proof', async () => { + // The Gate derives the delegation from the active links, then admission + // reproves it under the lease. Those are the two reads that decide. Any + // further pass re-derives an answer the stop already holds, on a transcript + // that only grows. + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-scan-count-')); + const store = createSessionStore(root); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + let coordinationReads = 0; + let counting = false; + const stores = new Proxy(store, { + get(authority, property, receiver) { + if (property === 'readMessagesSnapshot') { + return async (sessionId: string) => { + if (counting && sessionId === WORKHUB_COORDINATION_SESSION_ID) coordinationReads += 1; + return authority.readMessagesSnapshot(sessionId); + }; + } + const value = Reflect.get(authority, property, receiver) as unknown; + return typeof value === 'function' ? value.bind(authority) : value; + }, + }) as SessionAuthorityStore; + const workhub = coordinator(root, stores, () => undefined, undefined, undefined, undefined, { + assign: (input) => persistTestAssignment(store, input, 'payments-turn'), + retireDelegation: async () => ({ + outcome: 'stop_delivered' as const, + targetTurnId: 'payments-turn', + }), + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!.candidateRef, + }, + }, + CONTEXT, + ) + ).ok, + true, + ); + + counting = true; + const stopped = await workhub.handlers['workhub.coordination.act']( + { + actionId: 'stop-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', expects: { targetSessionId: target.id } }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + + assert.equal(stopped.ok, true); + assert.equal(coordinationReads, 2, 'a stop derives once and reproves once'); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('refuses to merge a Turn identity shared across answer and record', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-turn-identity-')); const store = createSessionStore(root); 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 d890b81114..b8c8fd5d3f 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -382,40 +382,22 @@ export class WorkHubCoordinationActionGate { assertStopReplay(existing, input, source, stopFingerprint); return this.#stop(existing, source); } - const [sessions, activeAssignments] = await Promise.all([ - this.#effects.listSessions(), - this.#effects.listActiveAssignments(), - ]); + const sessions = await this.#effects.listSessions(); const sessionNameById = new Map(sessions.map((session) => [session.id, session.name])); // Only this delegation's target has to be visible. A delegation whose // Session the user deleted stays in the active set forever — nothing // retires it — so proving visibility over the whole set would let one // deleted Session block every stop in the system from then on. + // + // Sole active delegation is not reproved here. `#stopSource` derived this + // `source` from the active links a moment ago by that same rule, and the + // replay branch above returned before reaching this line, so a second + // pass would re-read the transcript to reach the answer it started from. + // The proof that decides is the coordinator's, under the admission lease. const currentTargetName = sessionNameById.get(source.targetSessionId); if (!currentTargetName) { throw new WorkHubActionGateFailure('action_conflict', 'WorkHub stop target is unavailable'); } - // Authority is the opaque delegation identity, never the display name the - // Resolver recalled it by. This is the advisory read; the coordinator - // reproves it from durable state under the admission lease. - // - // A delegation link ends only by supersession or a resolved stop, so a - // delegation whose work already finished is still linked. It is not a - // competing stop target though — there is nothing left in it to stop — - // and counting it would make a Session that was delegated to twice - // permanently unstoppable once the first delegation completed. - if (!activeAssignments.some((assignment) => assignment.actionId === source.actionId)) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub stop target is no longer an active durable delegation', - ); - } - if (await this.#hasCompetingWork(activeAssignments, source)) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub stop target does not identify one active durable delegation', - ); - } if (await this.#effects.readSupersession(source.delegationId)) { throw new WorkHubActionGateFailure( 'action_conflict', @@ -613,26 +595,6 @@ export class WorkHubCoordinationActionGate { return holdingWork[0]!; } - /** - * Whether any other delegation on the stop target's Session still holds work. - * A retirement read that cannot see the owner yet fails the stop closed - * rather than guessing that the other delegation is finished. - */ - async #hasCompetingWork( - activeAssignments: readonly WorkHubDelegationAssignedMessage[], - source: WorkHubDelegationAssignedMessage, - ): Promise { - const competitors = activeAssignments.filter( - (assignment) => - assignment.targetSessionId === source.targetSessionId && - assignment.delegationId !== source.delegationId, - ); - for (const competitor of competitors) { - if ((await this.#effects.readDelegationRetirement(competitor)) !== 'retired') return true; - } - return false; - } - async #stop( request: WorkHubDelegationStopRequestedMessage, source: WorkHubDelegationAssignedMessage, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 6e99ce1dd5..1f42f9900e 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -258,13 +258,14 @@ export class HostWorkHubCoordinationCoordinator { async #prepareStop( input: Parameters[0], ): Promise { - const initiallyActive = await this.#listActiveAssignments(); - const admittedTargetSessionIds = new Set( - initiallyActive.map((assignment) => assignment.targetSessionId), - ); const suffix = workHubDestructiveClaimIdentitySuffix(input.stopsDelegationId); return this.#commitCoordinationFact({ - admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, ...admittedTargetSessionIds], + // Only the two Sessions this stop can change: the one whose delegation + // ends, and the Coordination Session that records it. Holding a lane for + // every Session with an active delegation would serialize unrelated + // delegation traffic behind one stop, and the proof below needs no lane + // it does not already hold. + admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.targetSessionId], read: () => this.#stores.readWorkHubStopRequest(input.stopsDelegationId), build: (existing) => ({ type: 'workhub_coordination', @@ -299,16 +300,6 @@ export class HostWorkHubCoordinationCoordinator { } const visibleSessionIds = new Set(headers.map((header) => header.id)); const activeAssignments = activeWorkHubAssignments(messages); - if ( - activeAssignments.some( - (assignment) => !admittedTargetSessionIds.has(assignment.targetSessionId), - ) - ) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub active delegation set changed during stop admission', - ); - } // Held lanes make this the last moment the one-target proof can change. // It is proved from opaque delegation identity, so a concurrent rename // is harmless while a concurrent delegation to the same Session is not. From 217a8d91dfb7557337163119bae0c888418c9898 Mon Sep 17 00:00:00 2001 From: ChengBo Zhang Date: Thu, 3 Sep 2026 14:41:22 +0800 Subject: [PATCH 18/19] fix(workhub): name the refusal when a stop identity outlives its delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A claim with no request behind it resolves from the active links like a first attempt. That is only true while those links still name the delegation the claim bound itself to. If it left and another took its place, re-deriving picks the successor, the fingerprint no longer matches the claim, and `claimAction` refuses — permanently, because claims are never deleted, and without saying why. Nothing destructive happens either way and a fresh message carries a fresh action id, so this costs one refused attempt. Refuse it by name instead: the identity is spent, and it says so. Reported by Astro-Han in review of #4439. Generated-by: Claude Opus --- apps/desktop/renderer-architecture.json | 42 +------ .../workhub-coordination-coordinator.test.ts | 111 ++++++++++++++++++ .../workhub-coordination-action-gate.ts | 35 ++++-- 3 files changed, 143 insertions(+), 45 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 9dc6bc4eea..e54ddacfd2 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -121,7 +121,6 @@ "src/renderer/session-read-state.ts", "src/renderer/session-status-presentation.ts", "src/renderer/session-trace-refresh.ts", - "src/renderer/session-turn-request-composer.tsx", "src/renderer/session-workspace-actions.ts", "src/renderer/session-workspace-errors.ts", "src/renderer/settings/about-settings-page.tsx", @@ -762,7 +761,7 @@ "@maka/ui": 1 }, "importSpecifiers": 10, - "nonTriviaTokens": 654 + "nonTriviaTokens": 650 }, "src/renderer/app-shell-turn-view-model.ts": { "importDeclarations": 7, @@ -786,10 +785,10 @@ "react": 1 }, "importSpecifiers": 18, - "nonTriviaTokens": 1425 + "nonTriviaTokens": 1410 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 105, + "importDeclarations": 103, "bridgePaths": { "window.maka.app.installUpdate": 1, "window.maka.app.retryUpdateDownload": 1, @@ -922,7 +921,6 @@ "./live-content-seed": 1, "./live-turn-reconciler": 1, "./locales/conversation-copy": 1, - "./locales/session-collaboration-copy": 1, "./locales/shell-copy": 1, "./locales/shell-remaining-copy.js": 1, "./model-connection-errors": 1, @@ -932,7 +930,6 @@ "./plan-mode-panel": 1, "./scroll-motion-policy": 1, "./session-collaboration-dialog": 1, - "./session-turn-request-composer.js": 1, "./session-workspace-errors": 1, "./settings/provider-brand-marks": 1, "./settings/provider-display": 1, @@ -982,8 +979,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 186, - "nonTriviaTokens": 15725 + "importSpecifiers": 184, + "nonTriviaTokens": 15692 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, @@ -2385,8 +2382,8 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./features/session-collaboration": 1, "./locales/session-collaboration-copy.js": 1, - "./session-turn-request-composer.js": 1, "@astryxdesign/core": 1, "@astryxdesign/core/Dialog": 1, "@astryxdesign/core/Layout": 1, @@ -2501,33 +2498,6 @@ "@maka/core/events": 1 } }, - "src/renderer/session-turn-request-composer.tsx": { - "bridgePaths": { - "window.maka.sessionCollaboration.acknowledgeTurnRequest": 1, - "window.maka.sessionCollaboration.getTurnRequests": 2, - "window.maka.sessionCollaboration.requestTurn": 1 - }, - "environmentCapabilities": { - "window.clearTimeout": 1, - "window.setTimeout": 1 - }, - "hookCalls": { - "useEffect": 1, - "useRef": 2, - "useState": 6, - "useToast": 1, - "useUiLocale": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./locales/session-collaboration-copy.js": 1, - "@maka/runtime-host/protocol": 1, - "@maka/ui": 1, - "react": 1 - } - }, "src/renderer/session-workspace-actions.ts": { "bridgePaths": { "window.maka.sessions.queryCancelledMessages": 1 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 f6ce2ce70b..b4baea17fd 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -1201,6 +1201,117 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('a claimed stop refuses by name when its delegation was replaced', async () => { + // The claim survived a crash before its request. By the retry the link it + // bound itself to is gone and another has taken its place on the same + // Session, so re-deriving would silently bind this action to a delegation + // the user never named. The fingerprint would not match the claim either, + // and claims are never deleted, so the refusal is permanent — it should at + // least say which refusal it is. + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-claim-moved-')); + const store = createSessionStore(root); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`), + retireDelegation: async () => assert.fail('a spent stop identity must not retire work'), + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!.candidateRef, + }, + }, + CONTEXT, + ) + ).ok, + true, + ); + const assignment = await store.readWorkHubAssignment('source-action'); + assert.ok(assignment); + // The stop bound itself to that delegation, then crashed before its + // request was durable. + assert.equal( + await store.claimWorkHubAction({ + actionId: 'stop-action', + operation: 'stop', + actionFingerprint: `sha256:${'d'.repeat(64)}`, + subject: assignment.delegationId, + }), + 'claimed', + ); + // That delegation ends and a different one takes its place. + await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [ + { + type: 'workhub_coordination', + id: 'whs_replaced_probe', + turnId: 'replaced-probe-turn', + ts: Date.now(), + schemaVersion: WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, + kind: 'delegation_superseded', + actionId: 'supersede-probe-action', + actionFingerprint: `sha256:${'e'.repeat(64)}`, + coordinationTurnId: 'replaced-probe-turn', + supersededActionId: 'source-action', + supersededDelegationId: assignment.delegationId, + replacementDelegationId: 'whd_replacement_probe', + }, + ]); + await persistTestAssignment( + store, + { + actionId: 'successor-action', + actionFingerprint: `sha256:${'f'.repeat(64)}`, + targetSessionId: target.id, + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix payment retry again', + }, + 'successor-turn', + ); + + const refused = await workhub.handlers['workhub.coordination.act']( + { + actionId: 'stop-action', + userText: 'Stop Payments', + proposal: { disposition: 'stop_work', expects: { targetSessionId: target.id } }, + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + + assert.equal(refused.ok, false); + if (!refused.ok) { + assert.equal(refused.error.code, 'operation_conflict'); + assert.match(refused.error.message, /already bound to a different delegation/u); + } + const successor = await store.readWorkHubAssignment('successor-action'); + assert.ok(successor); + assert.equal(await store.readWorkHubStopRequest(successor.delegationId), undefined); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('a claimed stop whose delegation went terminal elsewhere conflicts', async () => { // Claim present, no request and no resolution to converge on, and the // delegation is gone from the active set because another path superseded 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 b8c8fd5d3f..bea4056209 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -550,7 +550,8 @@ export class WorkHubCoordinationActionGate { // The request records which delegation this action bound itself to. It is // written after the claim, so a crash between the two leaves a claim with // nothing to converge on — and nothing destructive happened either, so - // that case resolves from the active links below like a first attempt. + // that case resolves from the active links below, subject to the claim + // still naming what they resolve to. const requested = await this.#effects.readStopRequest(claim.subject); if (requested) { const claimed = await this.#effects.readAssignment(requested.stopsActionId); @@ -575,24 +576,40 @@ export class WorkHubCoordinationActionGate { // finished, or was never WorkHub's to stop, is what the stop resolves to — // `already_terminal` and `not_owned` are outcomes, not reasons to refuse // the request before it is recorded. - if (onTarget.length === 1) return onTarget[0]!; + // // Only several links need separating, and then the rule is the same one // competition uses: a delegation whose work already finished is still // linked but is no longer a stop target, so it cannot make a Session that // was delegated to twice permanently unstoppable. - const holdingWork: WorkHubDelegationAssignedMessage[] = []; - for (const assignment of onTarget) { - if ((await this.#effects.readDelegationRetirement(assignment)) !== 'retired') { - holdingWork.push(assignment); + let resolved = onTarget[0]!; + if (onTarget.length > 1) { + const holdingWork: WorkHubDelegationAssignedMessage[] = []; + for (const assignment of onTarget) { + if ((await this.#effects.readDelegationRetirement(assignment)) !== 'retired') { + holdingWork.push(assignment); + } + } + if (holdingWork.length !== 1) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop target does not identify one active durable delegation', + ); } + resolved = holdingWork[0]!; } - if (holdingWork.length !== 1) { + // A claim with no request behind it resolves from the active links like a + // first attempt, but only while those links still name the delegation it + // bound itself to. If that one left and another took its place, the + // fingerprint derived here would no longer match the claim, and since + // claims are never deleted the refusal would be permanent and unexplained. + // Say why instead: the identity is spent, and the retry needs a new one. + if (claim?.operation === 'stop' && resolved.delegationId !== claim.subject) { throw new WorkHubActionGateFailure( 'action_conflict', - 'WorkHub stop target does not identify one active durable delegation', + 'WorkHub stop identity is already bound to a different delegation', ); } - return holdingWork[0]!; + return resolved; } async #stop( From 8f723e072b6da8cd6b0e4fc92fa76879c254ab0e Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Thu, 3 Sep 2026 15:17:47 +0800 Subject: [PATCH 19/19] test(workhub): tighten direct stop coverage Pin a stop action to its original target Session, remove redundant tests, and leave target visibility to the gate while the coordinator owns the lease-held delegation proof. Generated-by: OpenAI Codex --- .../main/__tests__/workhub-controller.test.ts | 30 --- .../workhub-session-resolver-port.test.ts | 19 -- .../workhub-session-resolver.test.ts | 16 -- .../__tests__/execution-composition.test.ts | 22 -- .../workhub-coordination-action-gate.test.ts | 86 ++++++-- .../workhub-coordination-coordinator.test.ts | 192 ------------------ .../workhub-coordination-coordinator.ts | 6 +- 7 files changed, 68 insertions(+), 303 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index f011215107..13b46ce35c 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -420,36 +420,6 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou await handle.close(); }); -test('a stop answer never comes from an unfilled delegation mirror', async () => { - // A second window, a reload, or a reconnect: the coordination stream has not - // delivered anything yet, so the renderer's mirror is empty. Answering from - // it would tell the user there is nothing to stop while the work is running. - const sessions = port([session('payments', { sessionName: 'Payments' })]); - const actions: Array> = []; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => assert.fail('a direct stop must not read route candidates'), - act: async (input) => { - actions.push(input); - return { - disposition: 'stop_work', - outcome: 'cancelled_pending', - targetSessionId: 'payments', - }; - }, - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - const result = await controller.submit({ requestId: 'stop-1', text: 'Stop Payments' }); - assert.equal(result.kind, 'stop'); - assert.equal(actions[0]?.proposal.disposition, 'stop_work'); - await handle.close(); -}); - test('a named stop reports the Gate refusal instead of judging the target itself', async () => { // The renderer no longer decides whether a Session can be stopped, so it // submits and lets the Gate answer. Its refusal is the clarification, which diff --git a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts index 5f716a010e..6e9b2305df 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts @@ -89,25 +89,6 @@ test('stop resolves through the shared port rather than a stop-specific grammar' }); }); -test('a resolved reference submits instead of judging the Host state itself', () => { - const descriptions = new Map([['payments', 'payment timeout work']]); - const text = 'Stop the payment timeout work'; - - // Whether that Session still owns a single stoppable delegation is not asked - // here, and deliberately so: only the Host can answer it, and it re-proves it - // under the lease that performs the stop. A renderer that answered from its - // own view would contradict the Host in exactly the windows where its view is - // empty. A confidently resolved reference therefore becomes a target, and a - // Session with nothing to stop is refused by the Gate, not here. - assert.deepEqual( - createWorkHubRoutePolicy(describedResolver(descriptions)).resolveStop({ - text, - sessions: [routable('payments', 'Payments')], - }), - { kind: 'target', target: { sessionId: 'payments' } }, - ); -}); - test('an ambiguous recall never becomes a destructive target', async () => { const resolver = describedResolver( new Map([ diff --git a/packages/core/src/__tests__/workhub-session-resolver.test.ts b/packages/core/src/__tests__/workhub-session-resolver.test.ts index 30ca3666fb..5092f8a5fe 100644 --- a/packages/core/src/__tests__/workhub-session-resolver.test.ts +++ b/packages/core/src/__tests__/workhub-session-resolver.test.ts @@ -68,19 +68,3 @@ test('equal exact matches are ambiguity rather than an unjustified ranking', () }, ); }); - -test('resolution is bounded to the offered candidate set', () => { - // A reference that names real work outside the permitted candidate set - // recalls nothing. Retrieval cannot widen its own visibility, and it has no - // vocabulary for creating work either — that stays an Action Policy decision. - assert.deepEqual(resolveText('Stop Payments', [session('s2', 'Login')]), { kind: 'none' }); - const resolution = resolveText('Stop Payments', [ - session('s1', 'Payments'), - session('s2', 'Login'), - ]); - assert.equal(resolution.kind, 'ranked'); - const offered = new Set(['s1', 's2']); - assert.ok( - resolution.kind === 'ranked' && resolution.candidates.every(({ ref }) => offered.has(ref)), - ); -}); diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 78aad8a616..a21b24c4bf 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -200,28 +200,6 @@ test('WorkHub detects a manual Stop that wins after its active-root check', asyn assert.equal(outcome.outcome, 'already_terminal'); }); -test('WorkHub does not claim an unrelated manual Stop as its delivery', async () => { - const outcome = await stopOwnedWorkHubRoot( - { - readRootState: () => ({ kind: 'idle' }), - read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({ - ...identity, - status: 'cancelled', - terminalEventId: 'earlier-manual-stop', - abortSource: 'renderer.stop_button', - }), - stopRoot: async () => assert.fail('a terminal root must not be stopped again'), - } as unknown as Parameters[0], - { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' }, - 'workhub-stop-action', - ); - - assert.deepEqual(outcome, { - outcome: 'already_terminal', - targetTurnId: 'target-turn', - }); -}); - test('a replacement retirement never records direct-stop provenance', async () => { const stops: Array | undefined> = []; const outcome = await stopReplacedWorkHubRoot( 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 5c96997b4c..5d79ca924e 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 @@ -324,9 +324,8 @@ describe('WorkHub Coordination Action Gate', () => { * A stop proposal as the Action Policy produces it: opaque identities plus * the active-delegation state it resolved against, never a display name. */ - const stopProposal = (stopsActionId: string, targetSessionId: string) => ({ + const stopProposal = (targetSessionId: string) => ({ disposition: 'stop_work' as const, - stopsActionId, expects: { targetSessionId }, }); @@ -349,7 +348,7 @@ describe('WorkHub Coordination Action Gate', () => { const input = { actionId: 'stop-action', userText: 'Stop Payments', - proposal: stopProposal('source-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' as const }, }; @@ -401,7 +400,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-login', userText: 'Stop Login', - proposal: stopProposal('login-action', 'login'), + proposal: stopProposal('login'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -414,7 +413,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-payments', userText: 'Stop Payments', - proposal: stopProposal('pay-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -450,7 +449,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-live', userText: 'Stop Payments', - proposal: stopProposal('live-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -477,14 +476,15 @@ describe('WorkHub Coordination Action Gate', () => { ); } // Unreadable is not the same as finished, so it still blocks the proof. - effects.readDelegationRetirement = async () => 'recovering'; + effects.readDelegationRetirement = async (assignment) => + assignment.actionId === 'unreadable-action' ? 'recovering' : 'not_retired'; await assert.rejects( new WorkHubCoordinationActionGate(effects).act( { actionId: 'stop-unresolved-competitor', userText: 'Stop Payments', - proposal: stopProposal('live-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -520,7 +520,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-ambiguous-payments', userText: 'Stop Payments', - proposal: stopProposal('source-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -561,7 +561,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: `stop-${userText}`, userText, - proposal: stopProposal('source-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -577,7 +577,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-wrong-session', userText: 'Stop Payments', - proposal: stopProposal('source-action', 'login'), + proposal: stopProposal('login'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -612,7 +612,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-shared', userText: 'Stop Payments', - proposal: stopProposal('source-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -643,7 +643,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-shared', userText: 'Stop Payments', - proposal: stopProposal('source-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -683,7 +683,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'reused-stop', userText: 'Stop Payments', - proposal: stopProposal('source-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -700,7 +700,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'reused-stop', userText: 'Stop Login', - proposal: stopProposal('other-action', 'login'), + proposal: stopProposal('login'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -711,6 +711,52 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.stopResolutions.size, 0); }); + test('a committed stop identity cannot replay against another Session with the same name', async () => { + const effects = fakeEffects([ + session('payments-primary', { name: 'Payments' }), + session('payments-secondary', { name: 'Payments' }), + ]); + for (const [actionId, targetSessionId] of [ + ['primary-action', 'payments-primary'], + ['secondary-action', 'payments-secondary'], + ] as const) { + effects.assignmentRecords.set( + actionId, + assignmentRecord( + { + actionId, + actionFingerprint: `sha256:${(actionId === 'primary-action' ? '1' : '2').repeat(64)}`, + targetSessionId, + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix payment retry', + }, + `${actionId}-turn`, + ), + ); + } + effects.retireDelegation = async () => ({ outcome: 'recovering' as const }); + const stopInput = (targetSessionId: string) => ({ + actionId: 'reused-stop', + userText: 'Stop Payments', + proposal: stopProposal(targetSessionId), + confirmation: { kind: 'user_stop' as const }, + }); + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(stopInput('payments-primary'), CONTEXT), + (error) => + error instanceof WorkHubActionEffectFailure && error.code === 'operation_unavailable', + ); + assert.deepEqual([...effects.stopRequests.keys()], ['delegation-primary-action']); + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(stopInput('payments-secondary'), CONTEXT), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.deepEqual([...effects.stopRequests.keys()], ['delegation-primary-action']); + }); + test('a stop action identity cannot cross into a delegation assignment', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); effects.assignmentRecords.set( @@ -731,7 +777,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'crossing-action', userText: 'Stop Payments', - proposal: stopProposal('source-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -782,7 +828,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-first', userText: 'Stop Payments', - proposal: stopProposal('source-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -792,7 +838,7 @@ describe('WorkHub Coordination Action Gate', () => { { actionId: 'stop-second', userText: 'Stop Payments', - proposal: stopProposal('source-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' }, }, CONTEXT, @@ -823,7 +869,7 @@ describe('WorkHub Coordination Action Gate', () => { const input = { actionId: 'stop-removed-target', userText: 'Stop Payments', - proposal: stopProposal('source-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' as const }, }; const unresolved = (error: unknown) => @@ -879,7 +925,7 @@ describe('WorkHub Coordination Action Gate', () => { const input = { actionId: 'stop-renamed', userText: 'Stop Old Payments', - proposal: stopProposal('source-action', 'payments'), + proposal: stopProposal('payments'), confirmation: { kind: 'user_stop' as const }, }; assert.equal( 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 b4baea17fd..cb70f8d3b9 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -792,100 +792,6 @@ describe('Host WorkHub Coordination coordinator', () => { } }); - test('rejects a stop when its target is removed before stop admission', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-removed-target-')); - const store = createSessionStore(root); - try { - const target = await store.create({ - cwd: root, - name: 'Payments', - llmConnectionSlug: 'test-connection', - model: 'test-model', - permissionMode: 'ask', - }); - let removed = false; - const stores = new Proxy(store, { - get(authority, property, receiver) { - if (property === 'readMessagesSnapshot') { - return async (sessionId: string) => { - const messages = await authority.readMessagesSnapshot(sessionId); - if ( - !removed && - sessionId === WORKHUB_COORDINATION_SESSION_ID && - messages.some( - (message) => - message.type === 'workhub_coordination' && - message.kind === 'delegation_assigned' && - message.actionId === 'source-action', - ) - ) { - removed = true; - await authority.remove(target.id); - } - return messages; - }; - } - const value = Reflect.get(authority, property, receiver) as unknown; - return typeof value === 'function' ? value.bind(authority) : value; - }, - }) as SessionAuthorityStore; - let retireCalls = 0; - const workhub = coordinator(root, stores, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, 'source-turn'), - retireDelegation: async () => { - retireCalls += 1; - return { outcome: 'cancelled_pending' }; - }, - }); - assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); - const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); - assert.equal(candidates.ok, true); - if (!candidates.ok) return; - const candidate = candidates.result.candidates.find( - ({ sessionId }) => sessionId === target.id, - )!; - assert.equal( - ( - await workhub.handlers['workhub.coordination.act']( - { - actionId: 'source-action', - userText: 'Fix payment retry', - candidateSetId: candidates.result.candidateSetId, - proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef }, - }, - CONTEXT, - ) - ).ok, - true, - ); - - const stopped = await workhub.handlers['workhub.coordination.act']( - { - actionId: 'stop-removed-target-action', - userText: 'Stop Payments', - proposal: { - disposition: 'stop_work', - expects: { targetSessionId: target.id }, - }, - confirmation: { kind: 'user_stop' }, - }, - CONTEXT, - ); - assert.equal(stopped.ok, false); - if (!stopped.ok) assert.equal(stopped.error.code, 'operation_conflict'); - const source = await store.readWorkHubAssignment('source-action'); - assert.ok(source); - assert.equal( - source ? await store.readWorkHubStopRequest(source.delegationId) : undefined, - undefined, - ); - assert.equal(retireCalls, 0); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); - test('converges a committed stop after the target Session is removed and the Host restarts', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-removed-')); let store = createSessionStore(root); @@ -1002,104 +908,6 @@ describe('Host WorkHub Coordination coordinator', () => { } }); - test('keeps one durable action identity bound to one delegation across restart', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-workhub-action-claim-')); - let store = createSessionStore(root); - let loginSessionId = ''; - const stopLogin = () => ({ - actionId: 'reused-stop', - userText: 'Stop Login', - proposal: { - disposition: 'stop_work' as const, - expects: { targetSessionId: loginSessionId }, - }, - confirmation: { kind: 'user_stop' as const }, - }); - let loginDelegationId: string | undefined; - try { - const targets: Array<{ id: string; name: string }> = []; - for (const name of ['Payments', 'Login']) { - targets.push( - await store.create({ - cwd: root, - name, - llmConnectionSlug: 'test-connection', - model: 'test-model', - permissionMode: 'ask', - }), - ); - } - const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`), - retireDelegation: async () => ({ outcome: 'recovering' }), - }); - assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); - for (const [actionId, name, userText] of [ - ['source-action', 'Payments', 'Fix payment retry'], - ['login-action', 'Login', 'Fix the login redirect'], - ] as const) { - const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); - assert.equal(candidates.ok, true); - if (!candidates.ok) return; - const target = targets.find((session) => session.name === name)!; - assert.equal( - ( - await workhub.handlers['workhub.coordination.act']( - { - actionId, - userText, - candidateSetId: candidates.result.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: candidates.result.candidates.find( - ({ sessionId }) => sessionId === target.id, - )!.candidateRef, - }, - }, - CONTEXT, - ) - ).ok, - true, - ); - } - loginSessionId = targets.find((session) => session.name === 'Login')!.id; - loginDelegationId = (await store.readWorkHubAssignment('login-action'))?.delegationId; - const recovering = await workhub.handlers['workhub.coordination.act']( - { - actionId: 'reused-stop', - userText: 'Stop Payments', - proposal: { - disposition: 'stop_work', - expects: { - targetSessionId: targets.find((session) => session.name === 'Payments')!.id, - }, - }, - confirmation: { kind: 'user_stop' }, - }, - CONTEXT, - ); - assert.equal(recovering.ok, false); - if (!recovering.ok) assert.equal(recovering.error.code, 'operation_unavailable'); - } finally { - await store.close?.(); - } - - store = createSessionStore(root); - try { - const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - retireDelegation: async () => assert.fail('a reused action identity must not retire work'), - }); - const crossed = await restarted.handlers['workhub.coordination.act'](stopLogin(), CONTEXT); - assert.equal(crossed.ok, false); - if (!crossed.ok) assert.equal(crossed.error.code, 'operation_conflict'); - assert.ok(loginDelegationId); - assert.equal(await store.readWorkHubStopRequest(loginDelegationId), undefined); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); - test('a claim written before its stop request resolves like a first attempt', async () => { // The claim is committed before the request, so a crash between them leaves // an action that owns a stop with nothing to converge on. Nothing diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 1f42f9900e..80eaf04b2a 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -286,10 +286,9 @@ export class HostWorkHubCoordinationCoordinator { }), conflictMessage: 'WorkHub delegation already has a different stop claim', beforeAppend: async () => { - const [replacement, supersession, headers, messages] = await Promise.all([ + const [replacement, supersession, messages] = await Promise.all([ this.#stores.readWorkHubReplacement(input.stopsDelegationId), this.#stores.readWorkHubSupersession(input.stopsDelegationId), - this.#stores.listHeaders(), this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID), ]); if (replacement || supersession) { @@ -298,7 +297,6 @@ export class HostWorkHubCoordinationCoordinator { 'WorkHub delegation is already being replaced', ); } - const visibleSessionIds = new Set(headers.map((header) => header.id)); const activeAssignments = activeWorkHubAssignments(messages); // Held lanes make this the last moment the one-target proof can change. // It is proved from opaque delegation identity, so a concurrent rename @@ -311,7 +309,7 @@ export class HostWorkHubCoordinationCoordinator { assignment.actionId === input.stopsActionId && assignment.delegationId === input.stopsDelegationId, ); - if (!visibleSessionIds.has(input.targetSessionId) || !source) { + if (!source) { throw new WorkHubActionGateFailure( 'action_conflict', 'WorkHub stop target does not identify one active durable delegation',