diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 5301584e28..13b46ce35c 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), @@ -185,6 +186,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.expects.targetSessionId, + }; + } 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 +332,209 @@ 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', + }); + // 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', + expects: { targetSessionId: 'payments' }, + }, + 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 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 }; + }, + 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: '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 }; + }, + 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 () => { + 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 () => { 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-session-resolver-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts new file mode 100644 index 0000000000..6e9b2305df --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts @@ -0,0 +1,137 @@ +/* + * 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 } from '../../renderer/workhub-route-policy.js'; + +const routable = (sessionId: string, sessionName: string) => ({ + target: { sessionId }, + projectName: 'demo', + sessionName, + updatedAt: 1, +}); + +/** Stands in for the Host read the stop policy makes once a reference resolves. */ +/** + * 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: { kind: 'named' as const, remainder: '' }, + })); + 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', async () => { + const sessions = [routable('payments', 'Payments'), routable('login', 'Login')]; + + // 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' }, + }); + 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' }, + }); +}); + +test('an ambiguous recall never becomes a destructive target', async () => { + 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: [routable('payments', 'Payments'), routable('payments-eu', 'Payments EU')], + }), + { kind: 'clarification', reason: 'stop_target_ambiguous' }, + ); +}); + +test('a resolver cannot widen stop beyond the visible candidate set it was given', async () => { + const resolver: WorkHubSessionResolver = { + resolve: () => ({ + kind: 'ranked', + candidates: [ + { ref: 'never-offered', evidence: { kind: 'named', remainder: '' } }, + ], + }), + }; + assert.deepEqual( + createWorkHubRoutePolicy(resolver).resolveStop({ + text: 'Stop Payments', + sessions: [routable('payments', 'Payments')], + }), + { kind: 'not_requested' }, + ); +}); + +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({ + text: 'Stop it', + sessions: [routable('payments', 'Payments')], + }), + { 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 447b5969b5..076040c109 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 = { @@ -657,6 +658,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.expects.targetSessionId, + }; + } 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..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,6 +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-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index b6d3eac025..f900300483 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -26,13 +26,30 @@ import { createWorkHubRoutePolicy, type WorkHubRouteEvidence, + type WorkHubStopClarificationReason, } from './workhub-route-policy.js'; import type { + OperationError, WorkHubCoordinationActInput, WorkHubCoordinationActResult, WorkHubCoordinationCandidatesResult, } 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; } @@ -110,10 +127,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 +194,7 @@ export type WorkHubSubmission = ( requestId: string; text: string; options: Array>; - reason?: 'ambiguous_command'; + reason?: 'ambiguous_command' | WorkHubStopClarificationReason; correction?: WorkHubCorrectionContext; } | { @@ -186,6 +208,13 @@ export type WorkHubSubmission = ( text: string; target: WorkHubSessionTarget; } + | { + kind: 'stop'; + requestId: string; + target: WorkHubSessionTarget; + outcome: Extract['outcome']; + targetTurnId?: string; + } ) & { strategyId: WorkHubRoutingStrategyId }; /** @@ -259,9 +288,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 +315,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 +342,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 +490,67 @@ 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({ + text: input.text, + sessions: ordinary, + }); + if (stopDecision.kind !== 'not_requested') { + if (stopDecision.kind === 'clarification') { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: stopDecision.reason, + }; + } + const { target } = stopDecision; + let admitted; + try { + admitted = await coordination.act({ + actionId: input.requestId, + userText: input.text, + proposal: { + disposition: 'stop_work', + // Only the Session the reference resolved to. Which delegation + // that Session still owns is the Host's to decide, under the + // lease that ends it. + expects: { targetSessionId: target.sessionId }, + }, + confirmation: { kind: 'user_stop' }, + }); + } catch (error) { + // The Gate refusing the stop is an answer, not a fault: it is the + // only party that can say the Session owns no single stoppable + // delegation. Anything else is a real failure and still throws. + if ( + error instanceof WorkHubCoordinationFailure && + error.code === 'operation_conflict' + ) { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: 'stop_target_unavailable', + }; + } + throw error; + } + if (admitted.disposition !== 'stop_work') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } + return { + kind: 'stop', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + outcome: admitted.outcome, + ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), + }; + } const 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..114fed8532 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -37,21 +37,13 @@ import type { 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; @@ -201,13 +193,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 +277,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 +285,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-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index 170a9c4be6..af2960c94f 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -18,9 +18,12 @@ */ import { + createExactNameSessionResolver, readWorkHubRequestIntent, - workHubCorrectionTargetsSession, + workHubCorrectionAdmitsReference, type WorkHubRequestIntent, + type WorkHubResolverSession, + type WorkHubSessionResolver, } from './application/contracts/workhub-request-intent.js'; interface WorkHubRouteTarget { @@ -58,7 +61,41 @@ export type WorkHubRouteDecision = | { kind: 'discussion' } | { 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 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 + * 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'; reason: WorkHubStopClarificationReason } + | { kind: 'target'; target: WorkHubRouteTarget }; + export interface WorkHubRoutePolicy { + resolveStop(input: { + text: string; + sessions: WorkHubRoutableSession[]; + }): WorkHubStopRouteDecision; resolve(input: { text: string; sessions: WorkHubRoutableSession[]; @@ -101,15 +138,67 @@ 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". 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' }; + const reference = intent.stop.imperative ? intent.stop.target : undefined; + if (!reference) { + return { kind: 'clarification', reason: 'stop_target_required' }; + } + const sessionByRef = new Map( + sessions.map((session) => [session.target.sessionId, session]), + ); + const resolution = sessionResolver.resolve({ + reference: { text: reference }, + sessions: sessions.map(resolverSession), + }); + if (resolution.kind === 'none') return { kind: 'not_requested' }; + // Stop's own tail rule. The Resolver reports what the reference said + // after the name; a destructive command may add punctuation and nothing + // else, so `Stop Payments and Login` names no stoppable target here even + // though `Payments` matched. + const admissible = resolution.candidates.filter( + ({ evidence }) => + evidence.kind === 'elided_name_punctuation' || + /^[.!?。!?]*$/u.test(evidence.remainder), + ); + if (admissible.length === 0) return { kind: 'not_requested' }; + // Stop admits one candidate only. A ranked resolver may return several; + // this action never picks a winner from a ranking it cannot justify. + if (resolution.kind === 'ambiguous' || admissible.length > 1) { + return { kind: 'clarification', reason: 'stop_target_ambiguous' }; + } + const resolved = sessionByRef.get(admissible[0]!.ref); + if (!resolved) return { kind: 'not_requested' }; + // The reference resolved, which is everything this policy can prove. + // Which delegation to end, and whether there is one at all, is the + // Host's answer and is made under the lease that performs the stop. + return { kind: 'target', target: resolved.target }; + }, resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { const intent = readWorkHubRequestIntent(text); if (intent.execution === 'ambiguous') { @@ -142,8 +231,20 @@ function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy { } 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', @@ -268,7 +369,7 @@ function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy { } }, newVisit() { - return createWorkHubRoutePolicyVisit(); + return createWorkHubRoutePolicyVisit(sessionResolver); }, rememberTarget(target) { if (currentFocus?.sessionId === target.sessionId) return; @@ -278,6 +379,16 @@ function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy { }; } +/** 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/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 1713c6b9df..8f9bf0532a 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 ? ( ['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_unavailable') return copy.stopTargetUnavailable; + return undefined; +} + export function workHubCoordinationSummary( result: Exclude, projection: WorkHubProjection, copy: ReturnType, ): string { if (result.kind === 'clarification') { - if (result.reason === 'ambiguous_command') return copy.confirmCommand; + 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') { 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 +682,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; @@ -647,9 +697,7 @@ function WorkHubTurnView(props: {

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

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

+

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

{turn.outcome.options.length > 0 ? (
{turn.outcome.options.map((option) => ( @@ -679,6 +727,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: '请明确说出要停止的工作名称,例如“停止 支付任务”。', + stopTargetAmbiguous: '这个名称对应多项工作;请打开具体的 Session 停止对应委托。', + stopTargetUnavailable: '这项工作现在没有可以停止的单个 WorkHub 委托;请打开该 Session 查看。', 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 +881,7 @@ function workHubCopy(locale: UiLocale) { active: (execution: string) => `关联有效 · ${execution}`, superseded: '已被更正', aborted: '更正已中止', + stopped: '已停止关联', }, turnStates: { running: '进行中', completed: '已完成', aborted: '已中止', failed: '失败' }, } as const; @@ -824,11 +896,24 @@ 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”.', + stopTargetAmbiguous: + 'That name matches more than one work item. Open the exact Session to stop its delegation.', + 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…', 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 +943,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..93522fc3d4 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -184,6 +184,53 @@ 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, 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 +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 +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. 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 +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 - WorkHub gains persistent conversational continuity without adding another @@ -206,8 +253,14 @@ 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 - work. + 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. 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 a78f03655a..c0888ca291 100644 --- a/docs/workhub-domain-language.md +++ b/docs/workhub-domain-language.md @@ -67,9 +67,29 @@ 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`, 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 +125,63 @@ 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 imperative to retire one active durable +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, +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. 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 +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. +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. 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 +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 +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`. 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 +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; it is not the final architecture or authority boundary of WorkHub. 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-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..5919ddedb8 100644 --- a/packages/core/src/__tests__/workhub-creation-intent.test.ts +++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts @@ -23,9 +23,23 @@ import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, workHubCreationAuthorizesTitle, + matchWorkHubSessionName, + type WorkHubRequestIntent, } from '../workhub-creation-intent.js'; const intentFor = readWorkHubRequestIntent; +/** + * 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) => { @@ -185,6 +199,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 +1026,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/__tests__/workhub-session-resolver.test.ts b/packages/core/src/__tests__/workhub-session-resolver.test.ts new file mode 100644 index 0000000000..5092f8a5fe --- /dev/null +++ b/packages/core/src/__tests__/workhub-session-resolver.test.ts @@ -0,0 +1,70 @@ +/* + * 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: { kind: 'named', remainder: '' } }] }, + ); + assert.deepEqual(resolveText('停止支付任务', [session('s1', '支付任务')]), { + kind: 'ranked', + candidates: [{ ref: 's1', evidence: { kind: 'named', remainder: '' } }], + }); +}); + +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: { kind: 'named', remainder: '' } }, + { ref: 's2', evidence: { kind: 'named', remainder: '' } }, + ], + }, + ); +}); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index fb29abb545..4d7c85ea0a 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,93 @@ 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; +} + +/** + * 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 | 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 +1330,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 +1555,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 +1679,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..c767cf5442 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -99,6 +99,24 @@ 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; + +/** + * 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'; @@ -119,6 +137,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 +301,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,6 +319,11 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { cue: correctionCue, ...(existingTarget ? { existingTarget } : {}), }, + stop: { + cue: stopCue, + imperative: Boolean(stopTarget), + ...(stopTarget ? { target: stopTarget } : {}), + }, }; } @@ -360,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}”`, @@ -377,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(); @@ -397,6 +462,36 @@ 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); + const rawTarget = match?.[1]?.trim(); + if (!rawTarget) return undefined; + const target = stripMatchingStopQuotes(rawTarget.replace(/[.!。!]+\s*$/u, '').trim()); + if (!target || UNSAFE_STOP_TARGET.test(target)) return undefined; + return target; +} + +function directWorkHubStopCue(value: string, malformedLiteral: boolean): boolean { + if (malformedLiteral || /[??]\s*$/u.test(value)) return false; + return Boolean(DIRECT_STOP_REQUEST.test(value) || DIRECT_CHINESE_STOP_REQUEST.test(value)); +} + +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/core/src/workhub-session-resolver.ts b/packages/core/src/workhub-session-resolver.ts new file mode 100644 index 0000000000..eb063f73fe --- /dev/null +++ b/packages/core/src/workhub-session-resolver.ts @@ -0,0 +1,121 @@ +/* + * 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 { + matchWorkHubSessionName, + type WorkHubSessionNameMatch, +} 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 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; + 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 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 + // 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] }; + }, + }; +} diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 0f25236dc9..a21b24c4bf 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,8 @@ import { HostResidencyRegistry } from '../server/host-residency-registry.js'; import { createExecutionRuntimeHostComposition, runtimeHostFilesystemWorkerRuntime, + stopOwnedWorkHubRoot, + stopReplacedWorkHubRoot, } from '../server/execution-composition.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; @@ -70,6 +73,176 @@ 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 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; + 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('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); @@ -590,6 +763,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', + expects: { targetSessionId: source.id }, + }, + }, + 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..56e31728ff 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -489,15 +489,54 @@ 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'), - { kind: 'cancelled' }, + await fixture.coordinator.cancelMessageIfPending( + ROOT.sessionId, + 'linked-message', + 'first-claim', + ), + { kind: 'cancelled_pending' }, ); assert.deepEqual( fixture.coordinator.projection(ROOT.sessionId).followup.map((entry) => entry.messageId), ['unrelated-message'], ); assert.deepEqual( - await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'), + await fixture.coordinator.cancelMessageIfPending( + ROOT.sessionId, + 'linked-message', + 'second-claim', + ), + { kind: 'cancelled' }, + ); +}); + +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' }, ); }); @@ -507,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, @@ -524,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', @@ -548,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', + }, + ); } }); @@ -3804,6 +3854,7 @@ function memoryMessageAdmissionStore( >, onMessagesHandedOff?: (input: MarkMessagesHandedOffInput) => void, ): MessageAdmissionStore { + const cancellationClaims = new Map(); return { commitMessageAdmission: async (admission) => { const existing = admissions.get(admission.messageId); @@ -3814,6 +3865,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..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 @@ -20,9 +20,13 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { + WorkHubActionClaim, + WorkHubActionClaimOutcome, WorkHubDelegationAssignedMessage, WorkHubDelegationReplacementAbortedMessage, WorkHubDelegationReplacementRequestedMessage, + WorkHubDelegationStopRequestedMessage, + WorkHubDelegationStopResolvedMessage, WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { @@ -35,6 +39,10 @@ import { type WorkHubDelegationAssignmentInput, type WorkHubDelegationReplacementAbortInput, type WorkHubDelegationReplacementInput, + type WorkHubDelegationRetirementClaim, + type WorkHubDelegationStopInput, + type WorkHubDelegationStopResolutionInput, + type WorkHubRetirementResult, } from '../server/workhub-coordination-action-gate.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; @@ -312,6 +320,633 @@ 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 = (targetSessionId: string) => ({ + disposition: 'stop_work' as const, + expects: { targetSessionId }, + }); + + 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: stopProposal('payments'), + 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('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'), + 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('payments'), + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + }); + + 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('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 (assignment) => + assignment.actionId === 'unreadable-action' ? 'recovering' : 'not_retired'; + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-unresolved-competitor', + userText: 'Stop Payments', + proposal: stopProposal('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']) { + 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`, + ), + ); + } + + // 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('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); + }); + + test('rejects stop authority from confirmation alone or a stale precondition', 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', + ), + ); + // 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', + ]) { + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: `stop-${userText}`, + userText, + proposal: stopProposal('payments'), + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + userText, + ); + } + // 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('login'), + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + 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: stopProposal('payments'), + 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: stopProposal('payments'), + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ), + result, + ); + }); + + 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: stopProposal('payments'), + 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: stopProposal('login'), + 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 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( + '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: stopProposal('payments'), + 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: stopProposal('payments'), + confirmation: { kind: 'user_stop' }, + }, + CONTEXT, + ); + + const retried = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'stop-second', + userText: 'Stop Payments', + proposal: stopProposal('payments'), + 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: stopProposal('payments'), + 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('keeps display names as stop evidence rather than admission authority', 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', + ), + ); + // 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 Old Payments', + proposal: stopProposal('payments'), + confirmation: { kind: 'user_stop' as const }, + }; + assert.equal( + (await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT)).disposition, + 'stop_work', + ); + 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', + ); + assert.equal( + effects.stopRequests.get('delegation-source-action')?.targetSessionName, + 'Renamed Payments', + ); + }); + test('rejects waiting targets independently of strategy behavior', async () => { const effects = fakeEffects([session('waiting', { status: 'waiting_for_user' })]); const gate = new WorkHubCoordinationActionGate(effects); @@ -1547,6 +2182,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'), @@ -1572,11 +2267,12 @@ describe('WorkHub Coordination Action Gate', () => { (candidate) => candidate.sessionId === 'destination', )!; const retireDelegation = effects.retireDelegation; - effects.retireDelegation = async (assignment) => { - 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, ); + return result; }; const assign = effects.assign; effects.assign = async (input) => { @@ -1635,8 +2331,8 @@ describe('WorkHub Coordination Action Gate', () => { (candidate) => candidate.sessionId === 'destination', )!; const retireDelegation = effects.retireDelegation; - effects.retireDelegation = async (assignment) => { - 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 @@ -1644,6 +2340,7 @@ describe('WorkHub Coordination Action Gate', () => { ? { ...candidate, isArchived: true } : { ...candidate, status: 'waiting_for_user' }, ); + return result; }; const input = { actionId: `target-became-${lifecycle}`, @@ -1722,8 +2419,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'); }; @@ -1829,8 +2526,13 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { const replacements = new Map(); const replacementAborts = new Map(); 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; @@ -1842,13 +2544,45 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { replacements, replacementAborts, supersessions, + 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 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); + }, 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 +2592,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,16 +2681,72 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { replacementAborts.set(replacement.replacesDelegationId, aborted); return aborted; }, - async readDelegationRetirement(assignment: WorkHubDelegationAssignedMessage) { + 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, + ): 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) { + 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[]; @@ -1958,6 +2754,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..cb70f8d3b9 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'; @@ -580,6 +582,808 @@ 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, + name: 'Payments', + llmConnectionSlug: 'test-connection', + 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'), + 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', + expects: { targetSessionId: target.id }, + }, + 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', + expects: { targetSessionId: targetId }, + }, + 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 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 { + 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', + 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); + let targetId: string; + const stopInput = () => ({ + actionId: 'stop-action', + userText: 'Stop Payments', + proposal: { + disposition: 'stop_work' as const, + expects: { targetSessionId: targetId }, + }, + confirmation: { kind: 'user_stop' as const }, + }); + 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('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 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 + // 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('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); @@ -727,7 +1531,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..25cd512c4e 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,77 @@ 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', + expects: { targetSessionId: 'payments' }, + }, + confirmation: { kind: 'user_stop' }, + }), + { + actionId: 'action-stop', + userText: 'Stop Payments', + proposal: { + disposition: 'stop_work', + expects: { targetSessionId: 'payments' }, + }, + confirmation: { kind: 'user_stop' }, + }, + ); + for (const invalid of [ + { + actionId: 'action-stop-no-confirmation', + userText: 'Stop Payments', + proposal: { + disposition: 'stop_work', + expects: { targetSessionId: 'payments' }, + }, + }, + { + actionId: 'action-stop-wrong-confirmation', + userText: 'Stop Payments', + proposal: { + disposition: 'stop_work', + expects: { targetSessionId: 'payments' }, + }, + confirmation: { kind: 'user_correction' }, + }, + { + actionId: 'action-stop-injected', + userText: 'Stop Payments', + proposal: { + disposition: 'stop_work', + expects: { targetSessionId: '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' }, + }, + // Preconditions are a closed shape: no room for a second, client-asserted proof. + { + actionId: 'action-stop-extra-precondition', + userText: 'Stop Payments', + proposal: { + disposition: 'stop_work', + expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] }, + }, + confirmation: { kind: 'user_stop' }, + }, + ]) { + assert.throws( + () => decodeWorkHubCoordinationActInput(invalid), + (error) => error instanceof RuntimeHostProtocolError, + ); + } assert.throws( () => decodeWorkHubCoordinationActInput({ @@ -294,4 +365,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..735c6a840e 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -132,13 +132,35 @@ export type WorkHubCoordinationProposal = readonly target: | { readonly disposition: 'delegate_existing'; readonly candidateRef: string } | { readonly disposition: 'create_new'; readonly title: string }; + } + | { + readonly disposition: 'stop_work'; + /** + * 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; }; -export interface WorkHubCoordinationDestructiveConfirmation { - /** Kept outside strategy output so a model proposal cannot authorize Stop. */ - readonly kind: 'user_correction'; +export interface WorkHubCoordinationStopPreconditions { + /** + * 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 targetSessionId: string; } +export type WorkHubCoordinationDestructiveConfirmation = + /** Kept outside strategy output so a model proposal cannot authorize Stop. */ + { readonly kind: 'user_correction' } | { readonly kind: 'user_stop' }; + export interface WorkHubCoordinationCreateContext { /** Trusted desktop context. Model/strategy output never contains a workspace or identity. */ readonly workspace: WorkspaceTarget; @@ -174,6 +196,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 +387,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 +409,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 +483,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,9 +619,25 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP } throw invalidProtocolFrame('Invalid WorkHub replacement target'); } + if (proposal.disposition === 'stop_work') { + const exact = requireExactRecord(proposal, 'WorkHub stop proposal', ['disposition', 'expects']); + return { + disposition: 'stop_work', + expects: decodeWorkHubCoordinationStopPreconditions(exact.expects), + }; + } throw invalidProtocolFrame('Invalid WorkHub Coordination proposal disposition'); } +function decodeWorkHubCoordinationStopPreconditions( + value: unknown, +): WorkHubCoordinationStopPreconditions { + const expects = requireExactRecord(value, 'WorkHub stop preconditions', ['targetSessionId']); + return { + targetSessionId: requireEntityId(expects.targetSessionId, 'WorkHub target Session id'), + }; +} + function decodeWorkHubCoordinationCreateContext(value: unknown): WorkHubCoordinationCreateContext { const context = requireExactRecord(value, 'WorkHub creation context', ['workspace']); return { @@ -558,10 +649,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..27d3e69c4b 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'; @@ -73,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'; @@ -1357,40 +1359,47 @@ 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) => { + retireDelegation: async (assignment, retirement) => { const disposition = await messages.cancelMessageIfPending( assignment.targetSessionId, assignment.targetMessageId, + retirement.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({ + 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'); }, assign: async (input) => { const durable = await stores.sessionStore.readWorkHubAssignment(input.actionId); @@ -1991,6 +2000,74 @@ 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 }, + actionId: string, +): Promise<{ + readonly outcome: 'stop_delivered' | 'already_terminal' | 'recovering'; + readonly targetTurnId: string; +}> { + if (isActiveWorkHubRoot(coordinator, identity)) { + await coordinator.stopRoot(identity, { + source: 'workhub_direct_stop', + workHubActionId: actionId, + }); + } + const terminal = await coordinator.read(identity); + if ( + terminal.status === 'cancelled' && + terminal.abortSource === workHubDirectStopAbortSource(actionId) + ) { + 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 }; +} + +/** + * 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/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..0d5a6eec87 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. */ @@ -489,13 +493,29 @@ 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, ): Promise { return this.#sessionAdmission.run(sessionId, async () => { const disposition = await this.#resolveMessageExecution(sessionId, messageId); + if (disposition.kind === 'cancelled') { + 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 +533,11 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return { kind: 'recovering' }; } - 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); @@ -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..bea4056209 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -21,11 +21,17 @@ import { createHash } from 'node:crypto'; import type { SessionHeader, SessionStatus, + WorkHubActionClaim, + WorkHubActionClaimOutcome, + WorkHubActionOperation, WorkHubDelegationAssignedMessage, WorkHubDelegationCreateSpec, WorkHubDelegationDisposition, WorkHubDelegationReplacementAbortedMessage, WorkHubDelegationReplacementRequestedMessage, + WorkHubDelegationStopRequestedMessage, + WorkHubDelegationStopResolvedMessage, + WorkHubDelegationStopOutcome, WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { @@ -69,7 +75,31 @@ 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; + /** + * 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. + */ + probeTargetRemoval(sessionId: string): Promise<'present' | 'removed' | 'absent'>; readAssignment(actionId: string): Promise; + listActiveAssignments(): Promise; readReplacement( delegationId: string, ): Promise; @@ -77,6 +107,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 +130,34 @@ 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, + 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; } export interface WorkHubDelegationAssignmentInput { @@ -126,6 +184,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' @@ -223,8 +298,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); @@ -269,11 +345,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, @@ -281,6 +359,69 @@ 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.#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); + if (existing) { + 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 = 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'); + } + 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: stopFingerprint, + 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( @@ -340,9 +481,21 @@ 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); + await this.#claimAction( + replacement.actionId, + 'replace', + replacement.actionFingerprint, + replacement.replacesDelegationId, + ); const intent = await this.#effects.prepareReplacement(replacement); return this.#replace(intent, context); } @@ -371,6 +524,157 @@ 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, 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); + 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. + // + // 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. + 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]!; + } + // A claim with no request behind it resolves from the active links like a + // first attempt, but only while those links still name the delegation it + // bound itself to. If that one left and another took its place, the + // fingerprint derived here would no longer match the claim, and since + // claims are never deleted the refusal would be permanent and unexplained. + // Say why instead: the identity is spent, and the retry needs a new one. + if (claim?.operation === 'stop' && resolved.delegationId !== claim.subject) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop identity is already bound to a different delegation', + ); + } + return resolved; + } + + async #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, { + 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, + ...(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, @@ -502,7 +806,18 @@ 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, { + cancellationClaimId: replacement.actionId, + cause: 'replacement', + }); + 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 @@ -597,6 +912,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 { @@ -798,6 +1119,74 @@ 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 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, +): 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 stopResult(resolution); +} + 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..80eaf04b2a 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -27,12 +27,16 @@ 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 type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; import type { @@ -84,12 +88,18 @@ type CoordinationStores = Pick< | 'appendMessages' | 'createStableSession' | 'listHeaders' + | 'claimWorkHubAction' + | 'readWorkHubActionClaim' + | 'probeSessionRemoval' | 'probeStableSessionCreate' | 'readHeaderSnapshot' + | 'readMessagesSnapshot' | 'readWorkHubAssignment' | 'readWorkHubReplacement' | 'readWorkHubReplacementAbort' | 'readWorkHubSupersession' + | 'readWorkHubStopRequest' + | 'readWorkHubStopResolution' | 'readTranscriptHighWaterSnapshot' | 'readTranscriptMessagesSnapshot' | 'updateHeaderVersioned' @@ -134,10 +144,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; @@ -145,11 +157,26 @@ 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), + ), + // 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), + 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 +196,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 +206,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 +231,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 +255,132 @@ export class HostWorkHubCoordinationCoordinator { }); } + async #prepareStop( + input: Parameters[0], + ): Promise { + const suffix = workHubDestructiveClaimIdentitySuffix(input.stopsDelegationId); + return this.#commitCoordinationFact({ + // 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', + 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, messages] = await Promise.all([ + this.#stores.readWorkHubReplacement(input.stopsDelegationId), + this.#stores.readWorkHubSupersession(input.stopsDelegationId), + this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID), + ]); + if (replacement || supersession) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub delegation is already being replaced', + ); + } + const activeAssignments = activeWorkHubAssignments(messages); + // Held lanes make this the last moment the one-target proof can change. + // It is proved from opaque delegation identity, so a concurrent rename + // is harmless while a concurrent delegation to the same Session is not. + const targetActive = activeAssignments.filter( + (assignment) => assignment.targetSessionId === input.targetSessionId, + ); + const source = targetActive.find( + (assignment) => + assignment.actionId === input.stopsActionId && + assignment.delegationId === input.stopsDelegationId, + ); + if (!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', + }); + } + + 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 +413,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 +792,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..906c5d6ace 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,107 @@ 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'); + 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..ed9714e275 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -369,6 +369,13 @@ async function createExecutionStoresForWrite sessionStore.readWorkHubReplacementAbort(delegationId)), readWorkHubSupersession: (delegationId) => run(() => sessionStore.readWorkHubSupersession(delegationId)), + readWorkHubStopRequest: (delegationId) => + run(() => 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) => @@ -437,6 +444,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..c745b72675 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -86,6 +86,10 @@ import { type WorkHubDelegationAssignedMessage, type WorkHubDelegationReplacementAbortedMessage, type WorkHubDelegationReplacementRequestedMessage, + type WorkHubActionClaim, + type WorkHubActionClaimOutcome, + type WorkHubDelegationStopRequestedMessage, + type WorkHubDelegationStopResolvedMessage, type WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import type { @@ -430,6 +434,19 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto readWorkHubSupersession( delegationId: string, ): Promise; + readWorkHubStopRequest( + delegationId: string, + ): Promise; + 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, @@ -717,6 +734,38 @@ 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; + } + + 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 { @@ -1059,6 +1108,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..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 = 36; +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"}'; @@ -1242,6 +1242,32 @@ const MIGRATIONS: ReadonlyMap = new Map([ SELECT 1; `, ], + [ + 37, + ` + ALTER TABLE cancelled_message_admissions + 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) { @@ -1299,13 +1325,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..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, @@ -106,6 +109,7 @@ import { normalizeProvenSteeringMessageHandoff, samePendingMessageAdmission, type MarkMessagesHandedOffInput, + type MessageAdmissionCancellationClaimOutcome, type PendingMessageAdmission, type ProvenRootMessageHandoff, type ProvenSteeringMessageHandoff, @@ -1819,6 +1823,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 +1976,137 @@ 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, + 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); @@ -6900,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,