diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 3b524129e7..8c9823f6b8 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -24,6 +24,7 @@ import type { WorkHubCoordinationActInput } from '@maka/runtime-host/protocol'; import { createWorkHubController as createGatedWorkHubController, WORKHUB_ROUTING_STRATEGY_ID, + WorkHubCoordinationFailure, type WorkHubSessionFacts, type WorkHubSessionPort, type WorkHubCoordinationTurn, @@ -32,7 +33,6 @@ import { createWorkHubRoutePolicy, workHubNewSessionName, } from '../../renderer/workhub-route-policy.js'; -import { WorkHubCoordinationFailure } from '../../renderer/workhub-coordination-port.js'; const appShellUrl = [ new URL('../../renderer/app-shell.tsx', import.meta.url), @@ -193,6 +193,14 @@ function createWorkHubController({ sessions }: { sessions: TestSessionPort }) { targetSessionId: input.proposal.expects.targetSessionId, }; } + if (input.proposal.disposition === 'resume_work') { + return { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: input.proposal.expects.targetSessionId, + targetTurnId: 'resumed-turn', + }; + } const target = candidateByRef.get(input.proposal.candidateRef); if (!target) throw new Error('unknown test candidate'); const admitted = await sessions.submit(target.target, input.userText, input.actionId); @@ -408,6 +416,205 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou await handle.close(); }); +test('a named resume submits and reports what the Host did', async () => { + const sessions = port([session('payments', { sessionName: 'Payments' })]); + const actions: WorkHubCoordinationActInput[] = []; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([]); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'e'.repeat(64)}`, + candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: 'payments', + targetTurnId: 'resumed-turn', + }; + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + const result = await controller.submit({ requestId: 'resume-1', text: 'Resume Payments' }); + + assert.deepEqual(result, { + kind: 'resume', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'resume-1', + target: { sessionId: 'payments' }, + outcome: 'resume_started', + }); + // The proposal names the Session and carries no confirmation: resume ends + // nothing, so it needs no authority a delegation did not already grant. + assert.deepEqual(actions, [{ + actionId: 'resume-1', + userText: 'Resume Payments', + proposal: { disposition: 'resume_work', resumesActionId: 'source-action', expects: { targetSessionId: 'payments' } }, + }]); + await handle.close(); +}); + +test('an anaphoric resume asks for a named work item', async () => { + const controller = createGatedWorkHubController({ + sessions: port([session('payments', { sessionName: 'Payments' })]), + coordination: { + open: async (handler) => { + handler([]); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('resume clarification must not read route candidates'), + act: async () => assert.fail('anaphoric resume must not reach the Action Gate'), + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + assert.deepEqual(await controller.submit({ requestId: 'resume-it', text: 'Resume it' }), { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'resume-it', + text: 'Resume it', + options: [], + reason: 'resume_target_required', + }); + await handle.close(); +}); + +test('a resume the Host will not admit becomes its clarification', async () => { + const sessions = port([session('payments', { sessionName: 'Payments' })]); + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([]); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'e'.repeat(64)}`, + candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], + }), + act: async () => { + throw new WorkHubCoordinationFailure( + 'operation_conflict', + 'WorkHub has no active durable delegation to resume on that Session', + ); + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + assert.deepEqual(await controller.submit({ requestId: 'resume-2', text: 'Resume Payments' }), { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'resume-2', + text: 'Resume Payments', + options: [], + reason: 'resume_target_unavailable', + }); + await handle.close(); +}); + +test('a resume identity conflict is not mislabeled as a missing target', async () => { + const conflict = new WorkHubCoordinationFailure( + 'operation_conflict', + 'WorkHub action identity already owns a different operation', + ); + const controller = createGatedWorkHubController({ + sessions: port([session('payments', { sessionName: 'Payments' })]), + coordination: { + open: async (handler) => { + handler([]); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'e'.repeat(64)}`, + candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], + }), + act: async () => { + throw conflict; + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + await assert.rejects( + controller.submit({ requestId: 'resume-conflict', text: 'Resume Payments' }), + (error) => error === conflict, + ); + await handle.close(); +}); + +test('a Runtime Host without safe-boundary resume explains why it cannot resume', async () => { + const controller = createGatedWorkHubController({ + sessions: port([session('payments', { sessionName: 'Payments' })]), + coordination: { + open: async (handler) => { + handler([]); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'e'.repeat(64)}`, + candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], + }), + act: async () => { + throw new WorkHubCoordinationFailure( + 'operation_unavailable', + 'Safe-boundary resume is disabled for this Runtime Host', + ); + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + assert.deepEqual(await controller.submit({ requestId: 'resume-disabled', text: 'Resume Payments' }), { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'resume-disabled', + text: 'Resume Payments', + options: [], + reason: 'resume_operation_unavailable', + }); + await handle.close(); +}); + +test('a recovering Runtime Host tells the user to retry resume', async () => { + const controller = createGatedWorkHubController({ + sessions: port([session('payments', { sessionName: 'Payments' })]), + coordination: { + open: async (handler) => { + handler([]); + return { close: async () => undefined }; + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'e'.repeat(64)}`, + candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], + }), + act: async () => { + throw new WorkHubCoordinationFailure('host_not_ready', 'Runtime Host is recovering'); + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + const result = await controller.submit({ requestId: 'resume-recovering', text: 'Resume Payments' }); + assert.equal(result.kind, 'clarification'); + if (result.kind === 'clarification') assert.equal(result.reason, 'resume_host_recovering'); + await handle.close(); +}); + test('a named stop reports the Gate refusal instead of judging the target itself', async () => { // The renderer no longer decides whether a Session can be stopped, so it // submits and lets the Gate answer. Its refusal is the clarification, which diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts index 8d9166794e..772fb55f39 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -350,6 +350,61 @@ test('surface replaces a submitted placeholder with its durable assignment state }); }); +test('surface replaces resume feedback with the ordinary coordination acknowledgement', () => { + const local = [ + { + requestId: 'stop-action', + text: 'Stop Payments', + state: 'settled' as const, + outcome: { + kind: 'stop' as const, + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'stop-action', + target: { sessionId: 'payments' }, + outcome: 'stop_delivered' as const, + }, + }, + { + requestId: 'resume-action', + text: 'Resume Payments', + state: 'settled' as const, + outcome: { + kind: 'resume' as const, + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'resume-action', + target: { sessionId: 'payments' }, + outcome: 'resume_started' as const, + }, + }, + ]; + const durable: WorkHubCoordinationTurn[] = [ + { + messageId: 'stop-record', + turnId: 'stop-action', + text: 'Stop Payments', + state: 'completed', + stop: { + targetSessionId: 'payments', + targetSessionName: 'Payments', + outcome: 'stop_delivered', + }, + updatedAt: 10, + }, + { + messageId: 'resume-record', + turnId: 'resume-action', + text: 'Resume Payments', + state: 'completed', + updatedAt: 20, + }, + ]; + + assert.deepEqual(visibleWorkHubConversation(durable, local), { + coordination: durable, + local: [], + }); +}); + test('surface keeps clarification and successful routing in WorkHub', async () => { const submissions: WorkHubSubmitInput[] = []; const controller: WorkHubController = { @@ -665,6 +720,14 @@ test('real Session projection creates new guide topics and preserves origin ambi targetSessionId: input.proposal.expects.targetSessionId, }; } + if (input.proposal.disposition === 'resume_work') { + return { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: input.proposal.expects.targetSessionId, + targetTurnId: 'resumed-turn', + }; + } const targetSessionId = input.proposal.candidateRef.replace(/^candidate-/u, ''); const admitted = await send(targetSessionId, { type: 'send', @@ -775,6 +838,28 @@ test('successful delegated submission needs no renderer summary write', async () assert.equal(records, 0); }); +test('resume records ordinary conversation text without persisting execution fields', async () => { + const records: unknown[] = []; + const controller = fakeController({ + submit: async (input) => ({ + kind: 'resume', strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, target: { sessionId: 'payments' }, outcome: 'resume_started', + }), + record: async (input) => { records.push(input); return { turnId: input.turnId }; }, + }); + await submitAndRecordWorkHubSurfaceInput({ + controller, + request: { requestId: 'resume-1', text: 'Resume Payments' }, + recordedUserText: 'Resume Payments', + summary: () => 'Resume requested. See the target Session for current progress.', + onSummaryError: () => assert.fail('conversation write must succeed'), + }); + assert.deepEqual(records, [{ + turnId: 'resume-1', userText: 'Resume Payments', + assistantText: 'Resume requested. See the target Session for current progress.', disposition: 'summary', + }]); +}); + test('lease retires only after an acknowledged submission', async () => { const { storage } = memoryStorage(); let sends = 0; diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 959381ae30..c259514837 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -27,6 +27,7 @@ import { createWorkHubRoutePolicy, type WorkHubRouteEvidence, type WorkHubStopClarificationReason, + type WorkHubNamedActionRouteDecision, } from './workhub-route-policy.js'; import type { OperationError, @@ -208,6 +209,12 @@ export type WorkHubSubmission = ( outcome: Extract['outcome']; targetTurnId?: string; } + | { + kind: 'resume'; + requestId: string; + target: WorkHubSessionTarget; + outcome: Extract['outcome']; + } ) & { strategyId: WorkHubRoutingStrategyId }; /** @@ -320,6 +327,106 @@ export function createWorkHubController(deps: { ...(correction ? { correctedFrom: correction.from } : {}), }; }; + const submitNamedDelegationAction = async ( + input: WorkHubSubmitInput, + decision: WorkHubNamedActionRouteDecision, + kind: 'resume' | 'stop', + ): Promise | undefined> => { + if (decision.kind === 'not_requested') return undefined; + if (decision.kind === 'clarification') { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: decision.reason, + }; + } + const { target } = decision; + try { + const candidates = kind === 'resume' ? await coordination.candidates() : undefined; + const resumesActionId = candidates?.candidates.find( + (candidate) => candidate.sessionId === target.sessionId, + )?.latestDelegationActionId; + if (kind === 'resume' && !resumesActionId) { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: 'resume_target_unavailable', + }; + } + const admitted = await coordination.act({ + actionId: input.requestId, + userText: input.text, + proposal: kind === 'resume' + ? { + disposition: 'resume_work', + expects: { targetSessionId: target.sessionId }, + resumesActionId: resumesActionId!, + } + : { disposition: 'stop_work', expects: { targetSessionId: target.sessionId } }, + ...(kind === 'stop' ? { confirmation: { kind: 'user_stop' as const } } : {}), + }); + const result = { + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + }; + if (kind === 'resume' && admitted.disposition === 'resume_work') { + return { + ...result, + kind: 'resume', + outcome: admitted.outcome, + }; + } + if (kind === 'stop' && admitted.disposition === 'stop_work') { + return { + ...result, + kind: 'stop', + outcome: admitted.outcome, + ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), + }; + } + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } catch (error) { + if ( + kind === 'resume' && + error instanceof WorkHubCoordinationFailure && + (error.code === 'operation_unavailable' || error.code === 'host_not_ready') + ) { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: error.code === 'host_not_ready' + ? 'resume_host_recovering' + : 'resume_operation_unavailable', + }; + } + if (error instanceof WorkHubCoordinationFailure && error.code === 'operation_conflict') { + if (!/no active durable delegation|does not identify one active durable delegation/iu.test( + error.message, + )) { + throw error; + } + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: kind === 'resume' ? 'resume_target_unavailable' : 'stop_target_unavailable', + }; + } + throw error; + } + }; return { async openConversation(handler, onError) { let disposed = false; @@ -451,67 +558,18 @@ export function createWorkHubController(deps: { const sessions = await deps.sessions.list(); reconcileFocus(submissionPolicy, sessions); const ordinary = sessions.filter((session) => session.kind === 'ordinary'); + const resumeDecision = submissionPolicy.resolveResume({ + text: input.text, + sessions: ordinary, + }); + const resume = await submitNamedDelegationAction(input, resumeDecision, 'resume'); + if (resume) return resume; const stopDecision = submissionPolicy.resolveStop({ text: input.text, sessions: ordinary, }); - if (stopDecision.kind !== 'not_requested') { - if (stopDecision.kind === 'clarification') { - return { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - text: input.text, - options: [], - reason: stopDecision.reason, - }; - } - const { target } = stopDecision; - let admitted; - try { - admitted = await coordination.act({ - actionId: input.requestId, - userText: input.text, - proposal: { - disposition: 'stop_work', - // Only the Session the reference resolved to. Which delegation - // that Session still owns is the Host's to decide, under the - // lease that ends it. - expects: { targetSessionId: target.sessionId }, - }, - confirmation: { kind: 'user_stop' }, - }); - } catch (error) { - // The Gate refusing the stop is an answer, not a fault: it is the - // only party that can say the Session owns no single stoppable - // delegation. Anything else is a real failure and still throws. - if ( - error instanceof WorkHubCoordinationFailure && - error.code === 'operation_conflict' - ) { - return { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - text: input.text, - options: [], - reason: 'stop_target_unavailable', - }; - } - throw error; - } - if (admitted.disposition !== 'stop_work') { - throw new Error('WorkHub Action Gate returned an unexpected disposition'); - } - return { - kind: 'stop', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - target, - outcome: admitted.outcome, - ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), - }; - } + const stop = await submitNamedDelegationAction(input, stopDecision, 'stop'); + if (stop) return stop; const candidateSet = await coordination.candidates(); const candidateBySessionId = new Map( candidateSet.candidates.map((candidate) => [candidate.sessionId, candidate]), diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index af2960c94f..90c855b7ef 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -79,14 +79,24 @@ export type WorkHubStopClarificationReason = /** The stop names more than one existing Session. */ | 'stop_target_ambiguous' /** The Host refused the stop; its conflict is the whole answer. */ - | 'stop_target_unavailable'; + | 'stop_target_unavailable' + /** The resume names more than one existing Session. */ + | 'resume_target_ambiguous' + /** The resume names no safe target of its own. */ + | 'resume_target_required' + /** The Host refused the resume; its conflict is the whole answer. */ + | 'resume_target_unavailable' + /** This Host does not expose safe-boundary resume. */ + | 'resume_operation_unavailable' + /** The Host is still recovering; retry may succeed. */ + | 'resume_host_recovering'; /** * A stop clarification never offers route options. Choosing one re-sends the * original text as work, and stop-shaped text is exactly what must not be * delivered to a Session that way, so the reason carries the whole answer. */ -export type WorkHubStopRouteDecision = +export type WorkHubNamedActionRouteDecision = | { kind: 'not_requested' } | { kind: 'clarification'; reason: WorkHubStopClarificationReason } | { kind: 'target'; target: WorkHubRouteTarget }; @@ -95,7 +105,11 @@ export interface WorkHubRoutePolicy { resolveStop(input: { text: string; sessions: WorkHubRoutableSession[]; - }): WorkHubStopRouteDecision; + }): WorkHubNamedActionRouteDecision; + resolveResume(input: { + text: string; + sessions: WorkHubRoutableSession[]; + }): WorkHubNamedActionRouteDecision; resolve(input: { text: string; sessions: WorkHubRoutableSession[]; @@ -132,6 +146,38 @@ const MIN_STRONG_SINGLE_LATIN_LENGTH = 8; const MAX_UNCERTAINTY_OPTIONS = 5; const MAX_RELATED_CLARIFICATION_OPTIONS = 4; +function resolveNamedDelegationAction( + sessionResolver: WorkHubSessionResolver, + reference: string, + sessions: WorkHubRoutableSession[], + ambiguousReason: WorkHubStopClarificationReason, +): WorkHubNamedActionRouteDecision { + const sessionByRef = new Map(sessions.map((session) => [session.target.sessionId, session])); + const resolution = sessionResolver.resolve({ + reference: { text: reference }, + sessions: sessions.map(resolverSession), + }); + if (resolution.kind === 'none') return { kind: 'not_requested' }; + // The tail rule. The Resolver reports what the reference said after the name; + // one of these commands may add punctuation and nothing else, so + // `Stop Payments and Login` names no target here even though `Payments` + // matched. + const admissible = resolution.candidates.filter( + ({ evidence }) => + evidence.kind === 'elided_name_punctuation' || + /^[.!?。!?]*$/u.test(evidence.remainder), + ); + if (admissible.length === 0) return { kind: 'not_requested' }; + // One candidate only. A ranked resolver may return several; neither action + // picks a winner from a ranking it cannot justify. + if (resolution.kind === 'ambiguous' || admissible.length > 1) { + return { kind: 'clarification', reason: ambiguousReason }; + } + const resolved = sessionByRef.get(admissible[0]!.ref); + if (!resolved) return { kind: 'not_requested' }; + return { kind: 'target', target: resolved.target }; +} + /** * Deep routing module for R2.4. * @@ -163,41 +209,30 @@ function createWorkHubRoutePolicyVisit( // reference still fails closed, and a resolved Session that is not uniquely // stoppable says why. resolveStop({ text, sessions }) { - const intent = readWorkHubRequestIntent(text); - if (!intent.stop.cue) return { kind: 'not_requested' }; - const reference = intent.stop.imperative ? intent.stop.target : undefined; - if (!reference) { + const action = readWorkHubRequestIntent(text).stop; + if (!action.cue) return { kind: 'not_requested' }; + if (!action.imperative || !action.target) { return { kind: 'clarification', reason: 'stop_target_required' }; } - const sessionByRef = new Map( - sessions.map((session) => [session.target.sessionId, session]), + return resolveNamedDelegationAction( + sessionResolver, + action.target, + sessions, + 'stop_target_ambiguous', ); - const resolution = sessionResolver.resolve({ - reference: { text: reference }, - sessions: sessions.map(resolverSession), - }); - if (resolution.kind === 'none') return { kind: 'not_requested' }; - // Stop's own tail rule. The Resolver reports what the reference said - // after the name; a destructive command may add punctuation and nothing - // else, so `Stop Payments and Login` names no stoppable target here even - // though `Payments` matched. - const admissible = resolution.candidates.filter( - ({ evidence }) => - evidence.kind === 'elided_name_punctuation' || - /^[.!?。!?]*$/u.test(evidence.remainder), - ); - 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' }; + }, + resolveResume({ text, sessions }) { + const action = readWorkHubRequestIntent(text).resume; + if (!action.cue) return { kind: 'not_requested' }; + if (!action.imperative || !action.target) { + return { kind: 'clarification', reason: 'resume_target_required' }; } - 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 }; + return resolveNamedDelegationAction( + sessionResolver, + action.target, + sessions, + 'resume_target_ambiguous', + ); }, resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { const intent = readWorkHubRequestIntent(text); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 8a7cf93ad2..d6e2ad54b6 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -27,20 +27,20 @@ import { import { Button } from '@astryxdesign/core/Button'; import type { UiLocale } from '@maka/core/ui-locale'; import { ChatSurfaceLayout, Composer } from '@maka/ui'; -import type { - WorkHubController, - WorkHubCoordinationTurn, - WorkHubDelegationLinkState, - WorkHubProjection, - WorkHubSessionSummary, - WorkHubSubmission, - WorkHubSubmitInput, +import { + type WorkHubController, + type WorkHubCoordinationTurn, + type WorkHubDelegationLinkState, + type WorkHubProjection, + type WorkHubSessionSummary, + type WorkHubSubmission, + type WorkHubSubmitInput, } from './workhub-controller.js'; +import { WorkHubCoordinationFailure } from './workhub-coordination-port.js'; import { WorkHubSendLease, type WorkHubSendAttempt, } from './workhub-send-lease.js'; -import { WorkHubCoordinationFailure } from './workhub-coordination-port.js'; export interface WorkHubConversationTurn { requestId: string; @@ -135,7 +135,7 @@ export function visibleWorkHubConversation( return !localTurn || localTurn.outcome?.kind === 'discussion' || localTurn.outcome?.kind === 'submitted' || - localTurn.outcome?.kind === 'stop'; + localTurn.outcome?.kind === 'stop' || localTurn.outcome?.kind === 'resume'; }, ); const coordinationTurnIds = new Set(coordination.map(({ turnId }) => turnId)); @@ -144,7 +144,7 @@ export function visibleWorkHubConversation( !coordinationTurnIds.has(turn.requestId) || (turn.outcome?.kind !== 'discussion' && turn.outcome?.kind !== 'submitted' && - turn.outcome?.kind !== 'stop'), + turn.outcome?.kind !== 'stop' && turn.outcome?.kind !== 'resume'), ); return { coordination: visibleCoordination, local: visibleLocal }; } @@ -320,7 +320,8 @@ export function WorkHubSurface(props: { ? { ...turn, state: 'settled', outcome: result } : turn, )); - if (result.kind === 'submitted' || result.kind === 'stop') await refresh(); + if (result.kind === 'submitted' || result.kind === 'stop' || result.kind === 'resume') + await refresh(); return result; } catch (error) { if (isTerminalWorkHubSurfaceFailure(error)) { @@ -639,6 +640,11 @@ function workHubClarificationPrompt( if (reason === 'stop_target_required') return copy.stopTargetRequired; if (reason === 'stop_target_ambiguous') return copy.stopTargetAmbiguous; if (reason === 'stop_target_unavailable') return copy.stopTargetUnavailable; + if (reason === 'resume_target_required') return copy.resumeTargetRequired; + if (reason === 'resume_target_ambiguous') return copy.resumeTargetAmbiguous; + if (reason === 'resume_target_unavailable') return copy.resumeTargetUnavailable; + if (reason === 'resume_operation_unavailable') return copy.resumeOperationUnavailable; + if (reason === 'resume_host_recovering') return copy.resumeHostRecovering; return undefined; } @@ -660,6 +666,7 @@ export function workHubCoordinationSummary( return `${copy.waitingForDecision} ${copy.requestNotSent}`; } if (result.kind === 'stop') return copy.stopOutcomes[result.outcome]; + if (result.kind === 'resume') return copy.resumeRequested; const target = projection.sessions.find( (session) => session.target.sessionId === result.target.sessionId, ); @@ -683,6 +690,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 resumed = turn.outcome?.kind === 'resume' ? turn.outcome : undefined; const target = submitted ? props.projection.sessions.find((session) => session.target.sessionId === submitted.target.sessionId) : undefined; @@ -739,6 +747,18 @@ function WorkHubTurnView(props: { copy={copy} onOpenSession={props.onOpenSession} /> + ) : resumed ? ( + session.target.sessionId === resumed.target.sessionId, + )} + targetSessionId={resumed.target.sessionId} + heading={copy.resumeOutcomes[resumed.outcome]} + state="" + result={undefined} + copy={copy} + onOpenSession={props.onOpenSession} + /> ) : submitted ? ( { + // Resume asks the Host to carry on work an interruption left unfinished, so + // it is admitted on the same terms as a stop: a direct speech act naming one + // existing Session, in either language. + for (const [text, target] of [ + ['Resume Payments', 'Payments'], + ['恢复支付任务', '支付任务'], + ['接着跑支付任务', '支付任务'], + ['恢復支付任務', '支付任務'], + ['接著跑支付任務', '支付任務'], + ] as const) { + assert.deepEqual( + readWorkHubRequestIntent(text).resume, + { cue: true, imperative: true, target }, + text, + ); + } + + for (const text of ['Resume it', '恢复它']) { + assert.deepEqual(readWorkHubRequestIntent(text).resume, { cue: true, imperative: false }, text); + } + + // Ambiguous verbs remain ordinary Session instructions rather than being + // consumed as WorkHub resume commands. + for (const text of [ + 'Continue Payments', + 'Restart Payments', + '继续支付任务', + '请继续支付任务', + '重新开始支付任务', + 'Should I resume Payments?', + 'Do not resume Payments', + 'Resume "Payments', + ]) { + assert.equal(readWorkHubRequestIntent(text).resume.imperative, false, text); + } + + // Stop and resume are separate speech acts; neither reads as the other, and + // ordinary work is neither. + assert.equal(readWorkHubRequestIntent('Stop Payments').resume.imperative, false); + assert.equal(readWorkHubRequestIntent('Resume Payments').stop.cue, false); + assert.equal(readWorkHubRequestIntent('Fix the login bug').resume.imperative, false); +}); diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index f55679b311..d6b0dfa5df 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -110,7 +110,11 @@ const DIRECT_STOP_REQUEST = // equivalent either. const DIRECT_CHINESE_STOP_REQUEST = /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:停止|停掉|停下|取消|终止|中止)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; -const UNSAFE_STOP_TARGET = +const DIRECT_RESUME_REQUEST = + /^\s*(?:(?:please|kindly)\s+)?resume\s+(?:(?:the|this)\s+)?(?:(?:session|work|task|job)\s+)?(.+?)\s*[.!。!]?\s*$/iu; +const DIRECT_CHINESE_RESUME_REQUEST = + /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:恢复|恢復|接着跑|接著跑)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; +const UNSAFE_NAMED_ACTION_TARGET = /^(?:it|this|that|one|everything|all|current|session|work|task|job|(?:this|that|current)\s+(?:session|work|task|job)|它|这个|那个|全部|当前|会话|工作|任务|(?:这个|那个|当前)(?:会话|工作|任务))$/iu; /** @@ -151,6 +155,13 @@ export interface WorkHubRequestIntent { readonly imperative: boolean; readonly target?: string; }; + readonly resume: { + /** A direct resume speech act was present, but its target may still be unsafe. */ + readonly cue: boolean; + /** True only for a direct, explicitly named resume command. */ + readonly imperative: boolean; + readonly target?: string; + }; } /** @@ -316,8 +327,14 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { : { kind: 'unusable' }; const correctionCue = hasWorkHubCorrectionCue(source); const existingTarget = affirmativeWorkHubExistingCorrectionTarget(source); - const stopCue = directWorkHubStopCue(source, literalMask.malformed); - const stopTarget = stopCue ? directWorkHubStopTarget(source, false) : undefined; + const stop = directWorkHubNamedAction(source, literalMask.malformed, [ + DIRECT_STOP_REQUEST, + DIRECT_CHINESE_STOP_REQUEST, + ]); + const resume = directWorkHubNamedAction(source, literalMask.malformed, [ + DIRECT_RESUME_REQUEST, + DIRECT_CHINESE_RESUME_REQUEST, + ]); const actions = allMatches(masked, EXECUTION_ACTION); const execution: WorkHubExecutionIntent = literalMask.malformed || naming.kind === 'unusable' || hasDominatingDeliberation(masked) @@ -335,9 +352,14 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { ...(existingTarget ? { existingTarget } : {}), }, stop: { - cue: stopCue, - imperative: Boolean(stopTarget), - ...(stopTarget ? { target: stopTarget } : {}), + cue: stop.cue, + imperative: Boolean(stop.target), + ...(stop.target ? { target: stop.target } : {}), + }, + resume: { + cue: resume.cue, + imperative: Boolean(resume.target), + ...(resume.target ? { target: resume.target } : {}), }, }; } @@ -450,6 +472,19 @@ export function matchWorkHubSessionName( return { kind: 'named', remainder: normalizedTarget.slice(matchedName.length).trim() }; } +/** Whether a direct stop/resume reference names exactly this Session. */ +export function workHubNamedDelegationActionTargetsSession( + action: { readonly imperative: boolean; readonly target?: string }, + sessionName: string, +): boolean { + if (!action.imperative || !action.target) return false; + const match = matchWorkHubSessionName(action.target, sessionName); + return ( + match.kind === 'elided_name_punctuation' || + (match.kind === 'named' && /^[.!?。!?]*$/u.test(match.remainder)) + ); +} + /** * The correction policy's tail rule. A correction may name its target and then * say what to do with it, but a withdrawal anywhere in the reference retracts @@ -481,22 +516,20 @@ function correctionTargetMatchesSession(target: string, sessionName: string): bo return workHubCorrectionAdmitsReference(target, matchWorkHubSessionName(target, sessionName)); } -function directWorkHubStopTarget(value: string, malformedLiteral: boolean): string | undefined { - if (malformedLiteral || /[??]\s*$/u.test(value)) return undefined; - const match = DIRECT_STOP_REQUEST.exec(value) ?? DIRECT_CHINESE_STOP_REQUEST.exec(value); +function directWorkHubNamedAction( + value: string, + malformedLiteral: boolean, + patterns: readonly [RegExp, RegExp], +): { readonly cue: boolean; readonly target?: string } { + if (malformedLiteral || /[??]\s*$/u.test(value)) return { cue: false }; + const match = patterns[0].exec(value) ?? patterns[1].exec(value); const rawTarget = match?.[1]?.trim(); - if (!rawTarget) return undefined; - const target = stripMatchingStopQuotes(rawTarget.replace(/[.!。!]+\s*$/u, '').trim()); - if (!target || UNSAFE_STOP_TARGET.test(target)) return undefined; - return target; -} - -function 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)); + if (!rawTarget) return { cue: Boolean(match) }; + const target = stripMatchingActionQuotes(rawTarget.replace(/[.!。!]+\s*$/u, '').trim()); + return !target || UNSAFE_NAMED_ACTION_TARGET.test(target) ? { cue: true } : { cue: true, target }; } -function stripMatchingStopQuotes(value: string): string { +function stripMatchingActionQuotes(value: string): string { const pairs = new Map([ ['"', '"'], ["'", "'"], diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index dd8c16dc9b..c6b436436c 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -583,6 +583,7 @@ function createMessages( stores: ExecutionStoresWriter<'interactive'>, ): HostMessageCoordinator { const root: HostMessageRootPort = { + readLatestRootTurnLineage: async (identity) => identity, readSessionHeader: async () => ({ isArchived: false }), readRootState: () => ({ kind: 'active', sessionId, turnId: 'turn-1', runId: 'run-1' }), claimStopFence: async () => ({ diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 3e5de76f15..4c7cc83f84 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -709,6 +709,289 @@ test('WorkHub creates new work through the production assignment composition', a }); }); +test('WorkHub Stop retires the running continuation after Resume', async () => { + await withCompositionRoot(async ({ root, owner }) => { + const connectionId = await configureFakeDefaultTarget(owner); + let { composition, manager } = await createCapturedExecutionComposition(owner, { + safeBoundaryResume: true, + }); + const context = { + hostEpoch: 'execution-composition-test', + connectionId: 'workhub-resume-stop-client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + let closed = false; + let restartedOwner: InteractiveRootOwner | undefined; + let continuation: { turnId: string; runId: string } | undefined; + let targetSessionId: string | undefined; + try { + const target = await manager.createSession({ + cwd: root, + llmConnectionId: connectionId, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + name: 'Payments', + }); + targetSessionId = target.id; + await composition.handlers['workhub.coordination.resolve']({}, context); + const candidates = await composition.handlers['workhub.coordination.candidates']({}, context); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + ); + assert.ok(candidate); + if (!candidate) return; + + const delegated = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-resume-stop-delegation', + userText: FAKE_HOLD_OPEN_PROMPT, + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidate.candidateRef, + }, + }, + context, + ); + assert.equal(delegated.ok, true, JSON.stringify(delegated)); + if (!delegated.ok || delegated.result.disposition !== 'delegate_existing') return; + const original = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: delegated.result.targetTurnId }, + context, + ); + assert.equal(original.ok, true); + if (!original.ok) return; + await composition.handlers['turn.stop']( + { sessionId: target.id, turnId: original.result.turnId, runId: original.result.runId }, + context, + ); + + const resumed = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-resume-stop-resume', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + resumesActionId: 'workhub-resume-stop-delegation', + expects: { targetSessionId: target.id }, + }, + }, + context, + ); + assert.equal(resumed.ok, true, JSON.stringify(resumed)); + if ( + !resumed.ok || + resumed.result.disposition !== 'resume_work' || + !resumed.result.targetTurnId + ) + return; + const resumedTurn = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: resumed.result.targetTurnId }, + context, + ); + assert.equal(resumedTurn.ok, true); + if (!resumedTurn.ok) return; + continuation = { turnId: resumedTurn.result.turnId, runId: resumedTurn.result.runId }; + assert.equal(resumedTurn.result.status, 'running'); + + // Lose the response, interrupt the continuation, then discard all + // in-memory Gate replay state by reopening the production composition. + await composition.handlers['turn.stop']({ sessionId: target.id, ...continuation }, context); + await composition.close(); + closed = true; + await owner.close(); + restartedOwner = await tryAcquireInteractiveRootOwner( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + assert.ok(restartedOwner); + owner = restartedOwner; + ({ composition } = await createCapturedExecutionComposition(owner, { + safeBoundaryResume: true, + })); + const retry = { + actionId: 'workhub-resume-stop-resume', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work' as const, + resumesActionId: 'workhub-resume-stop-delegation', + expects: { targetSessionId: target.id }, + }, + }; + const replayed = await composition.handlers['workhub.coordination.act'](retry, context); + assert.equal(replayed.ok, false, JSON.stringify(replayed)); + if (!replayed.ok) assert.equal(replayed.error.code, 'operation_conflict'); + const fresh = await composition.handlers['workhub.coordination.act']( + { ...retry, actionId: 'workhub-resume-again' }, + context, + ); + assert.equal(fresh.ok, true, JSON.stringify(fresh)); + if (!fresh.ok || fresh.result.disposition !== 'resume_work' || !fresh.result.targetTurnId) + return; + const freshTurn = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: fresh.result.targetTurnId }, + context, + ); + assert.equal(freshTurn.ok, true); + if (!freshTurn.ok) return; + assert.equal(freshTurn.result.status, 'running'); + assert.notEqual(freshTurn.result.turnId, continuation.turnId); + continuation = { turnId: freshTurn.result.turnId, runId: freshTurn.result.runId }; + + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const assignment = await stores.sessionStore.readWorkHubAssignment( + 'workhub-resume-stop-delegation', + ); + assert.ok(assignment); + // The existing Desktop card query must resolve the resumed execution, + // rather than keep projecting the original interrupted Turn. + const feedback = await composition.handlers['turn.message.execution.query']( + { + sessionId: target.id, + messageIds: [assignment.targetMessageId], + }, + context, + ); + assert.deepEqual(feedback, { + ok: true, + result: { + resolutions: [ + { + messageId: assignment.targetMessageId, + state: 'owned', + ...continuation, + }, + ], + }, + }); + + const stopped = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-resume-stop-stop', + userText: 'Stop Payments', + confirmation: { kind: 'user_stop' }, + proposal: { + disposition: 'stop_work', + expects: { targetSessionId: target.id }, + }, + }, + context, + ); + assert.deepEqual(stopped, { + ok: true, + result: { + disposition: 'stop_work', + outcome: 'stop_delivered', + targetSessionId: target.id, + targetTurnId: continuation.turnId, + }, + }); + const terminal = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: continuation.turnId }, + context, + ); + assert.equal(terminal.ok, true); + if (terminal.ok) assert.equal(terminal.result.status, 'cancelled'); + } finally { + if (!closed && continuation && targetSessionId) { + await composition.handlers['turn.stop']( + { sessionId: targetSessionId, ...continuation }, + context, + ); + } + if (!closed) await composition.close(); + await restartedOwner?.close(); + } + }); +}); + +test('WorkHub does not record resume while safe-boundary resume is disabled', async () => { + await withCompositionRoot(async ({ root, owner }) => { + const connectionId = await configureFakeDefaultTarget(owner); + const { composition, manager } = await createCapturedExecutionComposition(owner, { + safeBoundaryResume: false, + }); + const context = { + hostEpoch: 'execution-composition-test', + connectionId: 'workhub-disabled-resume-client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + try { + const target = await manager.createSession({ + cwd: root, + llmConnectionId: connectionId, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + name: 'Payments', + }); + await composition.handlers['workhub.coordination.resolve']({}, context); + const candidates = await composition.handlers['workhub.coordination.candidates']({}, context); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + ); + assert.ok(candidate); + if (!candidate) return; + const delegated = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-disabled-resume-delegation', + userText: FAKE_HOLD_OPEN_PROMPT, + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidate.candidateRef, + }, + }, + context, + ); + assert.equal(delegated.ok, true, JSON.stringify(delegated)); + if (!delegated.ok || delegated.result.disposition !== 'delegate_existing') return; + const original = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: delegated.result.targetTurnId }, + context, + ); + assert.equal(original.ok, true); + if (!original.ok) return; + await composition.handlers['turn.stop']( + { sessionId: target.id, turnId: original.result.turnId, runId: original.result.runId }, + context, + ); + + const actionId = 'workhub-disabled-resume'; + const resumed = await composition.handlers['workhub.coordination.act']( + { + actionId, + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + resumesActionId: 'workhub-disabled-resume-delegation', + expects: { targetSessionId: target.id }, + }, + }, + context, + ); + assert.deepEqual(resumed, { + ok: false, + error: { + code: 'operation_unavailable', + message: 'Safe-boundary resume is disabled for this Runtime Host', + }, + }); + await composition.close(); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + assert.equal(await stores.sessionStore.readWorkHubActionClaim(actionId), undefined); + } finally { + await composition.close(); + } + }); +}); + test('WorkHub correction replaces its link without stopping a shared manual Turn', async () => { await withCompositionRoot(async ({ root, owner }) => { const connectionId = await configureFakeDefaultTarget(owner); @@ -1578,17 +1861,23 @@ async function seedLegacyFakeBackendSession( return sessionId; } -async function createCapturedExecutionComposition(owner: InteractiveRootOwner): Promise<{ +async function createCapturedExecutionComposition( + owner: InteractiveRootOwner, + options: { readonly safeBoundaryResume?: boolean } = {}, +): Promise<{ composition: Awaited>; manager: SessionManager; }> { const originalRecover = SessionManager.prototype.recoverInterruptedSessionsStrict; + const originalSafeBoundaryResume = process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME; let manager: SessionManager | undefined; SessionManager.prototype.recoverInterruptedSessionsStrict = async function (stores) { manager = this; return originalRecover.call(this, stores); }; try { + if (options.safeBoundaryResume === true) process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = '1'; + if (options.safeBoundaryResume === false) delete process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME; // The production composition no longer registers a test backend of its // own; the deterministic one arrives through the same `primaryBackendFactory` // seam the Desktop E2E run uses. @@ -1601,6 +1890,11 @@ async function createCapturedExecutionComposition(owner: InteractiveRootOwner): if (!manager) throw new Error('Production execution composition did not construct Runtime'); return { composition, manager }; } finally { + if (originalSafeBoundaryResume === undefined) { + delete process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME; + } else { + process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = originalSafeBoundaryResume; + } SessionManager.prototype.recoverInterruptedSessionsStrict = originalRecover; } } diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index c6e89e8376..abd2ddd8e5 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -559,6 +559,7 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro let requestedDrain = false; const goalChangeListeners = new Set<() => void>(); const rootPort: HostMessageRootPort = { + readLatestRootTurnLineage: async (identity) => identity, readSessionHeader: (sessionId) => requireCoordinator(coordinator).readSessionHeader(sessionId), readRootState: (sessionId) => requireCoordinator(coordinator).readRootState(sessionId), claimStopFence: (input, commitQueueFence, lease) => diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index a16d721c79..190bdeb598 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -3706,6 +3706,7 @@ function createFixture( const terminal = deferred(); let coordinator: HostMessageCoordinator; const root: HostMessageRootPort = { + readLatestRootTurnLineage: async (identity) => identity, readSessionHeader: async () => { return { isArchived: false }; }, diff --git a/packages/runtime-host/src/__tests__/root-admission-owner.test.ts b/packages/runtime-host/src/__tests__/root-admission-owner.test.ts index bb0c443909..c513d8b598 100644 --- a/packages/runtime-host/src/__tests__/root-admission-owner.test.ts +++ b/packages/runtime-host/src/__tests__/root-admission-owner.test.ts @@ -45,6 +45,8 @@ test('poisons a Session after an ambiguous durable admission failure', async () }, readRootTurnAdmission: (sessionId, turnId) => durableStore.readRootTurnAdmission(sessionId, turnId), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + durableStore.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), readRootTurnSourceMessageReceipt: (sessionId, sourceMessageId) => durableStore.readRootTurnSourceMessageReceipt(sessionId, sourceMessageId), listRootTurnAdmissionsForRecovery: (sessionId) => @@ -98,6 +100,26 @@ test('recovery installs the validated tip and the successor extends it', async ( }); }); +test('a competing continuation is a classified conflict and does not poison the Session', async () => { + await withStore(async (store) => { + const owner = new RootAdmissionOwner(store); + await owner.recoverSession('session'); + const source = await owner.admitRootTurn(admitInput('session', 'source-turn', 10)); + const continuation = await owner.admitRootTurn( + continuationAdmitInput('session', 'continuation-turn', source.admission, 20), + ); + + const competing = await owner.admitRootTurn( + continuationAdmitInput('session', 'competing-turn', source.admission, 30), + ); + assert.deepEqual(competing, { kind: 'conflict', admission: continuation.admission }); + + const successor = await owner.admitRootTurn(admitInput('session', 'successor-turn', 40)); + assert.equal(successor.kind, 'admitted'); + assert.equal(successor.admission.previousRootTurnId, continuation.admission.turnId); + }); +}); + test('recovers the original submitted placement for a promoted source after SQLite reopen', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-root-admission-placement-')); try { @@ -284,6 +306,7 @@ test('snapshots recovered admissions without retaining mutable caller references const store: RootTurnAdmissionStore = { admitRootTurn: async () => ({ kind: 'admitted', admission }), readRootTurnAdmission: async () => admission, + readRootTurnContinuationAdmission: async () => undefined, readRootTurnSourceMessageReceipt: async () => undefined, listRootTurnAdmissionsForRecovery: async () => [admission], }; @@ -366,6 +389,7 @@ test('returns an owned admission instead of retaining the mutable store result', const store: RootTurnAdmissionStore = { admitRootTurn: async () => ({ kind: 'admitted', admission: durableAdmission }), readRootTurnAdmission: async () => durableAdmission, + readRootTurnContinuationAdmission: async () => undefined, readRootTurnSourceMessageReceipt: async () => undefined, listRootTurnAdmissionsForRecovery: async () => [], }; @@ -402,6 +426,35 @@ function admitInput(sessionId: string, turnId: string, admittedAt: number) { }; } +function continuationAdmitInput( + sessionId: string, + turnId: string, + source: RootTurnAdmission, + admittedAt: number, +) { + return { + sessionId, + turnId, + proposedRunId: `run-${turnId}`, + proposedUserMessageId: null, + execution: { + kind: 'safe_boundary_continuation' as const, + sourceInvocationId: `invocation-${source.turnId}`, + sourceRunId: source.runId, + sourceTurnId: source.turnId, + sourceRuntimeEventHighWater: 7, + claimId: `claim-${turnId}`, + boundaryDigest: `sha256:${'a'.repeat(64)}` as const, + providerReplayDigest: `sha256:${'b'.repeat(64)}` as const, + safetyDigest: `sha256:${'c'.repeat(64)}` as const, + targetInvocationId: `invocation-${turnId}`, + }, + normalizedInput: null, + sourceMessages: [], + admittedAt, + }; +} + function multiSourceAdmitInput(sessionId: string, turnId: string, admittedAt: number) { const attachment = { kind: 'image' as const, 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 7897dde2ea..d2c5e1e8c6 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -1268,6 +1268,8 @@ test('idle Skill admission persists a canonical draft without history before roo throw new Error('injected root admission failure'); }, readRootTurnAdmission: (sessionId, turnId) => store.readRootTurnAdmission(sessionId, turnId), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + store.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), readRootTurnSourceMessageReceipt: (sessionId, messageId) => store.readRootTurnSourceMessageReceipt(sessionId, messageId), listRootTurnAdmissionsForRecovery: (sessionId) => @@ -2657,6 +2659,7 @@ test('hosted linked child roots share admission, message, terminal, and stop aut let drainRequested = false; let stopClosureSignal: ReturnType> | undefined; const rootPort: HostMessageRootPort = { + readLatestRootTurnLineage: async (identity) => identity, readSessionHeader: (sessionId) => requireCoordinator(coordinator).readSessionHeader(sessionId), readRootState: (sessionId) => requireCoordinator(coordinator).readRootState(sessionId), @@ -3195,6 +3198,8 @@ test('successor admission failure retains the terminal transition and its confir return store.admitRootTurn(input); }, readRootTurnAdmission: (sessionId, turnId) => store.readRootTurnAdmission(sessionId, turnId), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + store.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), readRootTurnSourceMessageReceipt: (sessionId, messageId) => store.readRootTurnSourceMessageReceipt(sessionId, messageId), listRootTurnAdmissionsForRecovery: (sessionId) => @@ -3303,6 +3308,8 @@ test('shutdown contains a successor backend start rejected by Interaction drain' return store.admitRootTurn(input); }, readRootTurnAdmission: (sessionId, turnId) => store.readRootTurnAdmission(sessionId, turnId), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + store.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), readRootTurnSourceMessageReceipt: (sessionId, messageId) => store.readRootTurnSourceMessageReceipt(sessionId, messageId), listRootTurnAdmissionsForRecovery: (sessionId) => @@ -5224,6 +5231,7 @@ async function createFailureFixture(options: { let interactions: HostInteractionCoordinator | undefined; let fallbackRunClosureClaims = 0; const rootPort: HostMessageRootPort = { + readLatestRootTurnLineage: async (identity) => identity, readSessionHeader: (sessionId) => requireCoordinator(coordinator).readSessionHeader(sessionId), readRootState: (sessionId) => requireCoordinator(coordinator).readRootState(sessionId), claimStopFence: (input, commitQueueFence, admission) => 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 b7c9d598b2..22d7fd17b4 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 @@ -39,6 +39,7 @@ import { type WorkHubDelegationAssignmentInput, type WorkHubDelegationReplacementAbortInput, type WorkHubDelegationReplacementInput, + type WorkHubDelegationResumeInput, type WorkHubDelegationRetirementClaim, type WorkHubDelegationStopInput, type WorkHubDelegationStopResolutionInput, @@ -329,6 +330,231 @@ describe('WorkHub Coordination Action Gate', () => { expects: { targetSessionId }, }); + const resumeProposal = (targetSessionId: string, resumesActionId = 'source-action') => ({ + resumesActionId, + disposition: 'resume_work' as const, + expects: { targetSessionId }, + }); + + const delegatedTo = (effects: ReturnType, sessionId: string) => { + effects.assignmentRecords.set( + 'source-action', + assignmentRecord( + { + actionId: 'source-action', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + targetSessionId: sessionId, + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix payment retry', + }, + 'source-turn', + ), + ); + }; + + test('resumes the one delegation the named Session owns', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'resume-action', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ); + + assert.deepEqual(result, { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: 'payments', + targetTurnId: 'resumed-turn', + }); + assert.equal(effects.resumeCalls.length, 1); + const resumeCall = effects.resumeCalls[0]; + assert.ok(resumeCall); + assert.equal(resumeCall.source.actionId, 'source-action'); + assert.equal(effects.actionClaims.has('resume-action'), false); + }); + + test('resume binds the trusted named target to the proposed Session', async () => { + const effects = fakeEffects([ + session('payments', { name: 'Payments' }), + session('login', { name: 'Login' }), + ]); + delegatedTo(effects, 'payments'); + + await assert.rejects( + () => + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'resume-wrong-target', + userText: 'Resume Login', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.resumeCalls.length, 0); + }); + + test('resume needs a named command and carries no destructive confirmation', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + const gate = new WorkHubCoordinationActionGate(effects); + + // Anaphora names nothing, so it never reaches the delegation. + await assert.rejects( + () => + gate.act( + { + actionId: 'resume-anaphora', + userText: 'Resume it', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ), + /explicit named command/u, + ); + // A question is not a command. + await assert.rejects( + () => + gate.act( + { + actionId: 'resume-question', + userText: 'Should I resume Payments?', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ), + /explicit named command/u, + ); + assert.equal(effects.resumeCalls.length, 0); + }); + + test('resume refuses a Session that does not own exactly one delegation', async () => { + const none = fakeEffects([session('payments', { name: 'Payments' })]); + await assert.rejects( + () => + new WorkHubCoordinationActionGate(none).act( + { + actionId: 'resume-none', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ), + /no active durable delegation to resume/u, + ); + + const several = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(several, 'payments'); + several.assignmentRecords.set( + 'second-action', + assignmentRecord( + { + actionId: 'second-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Also fix the receipts', + }, + 'second-turn', + ), + ); + await assert.rejects( + () => + new WorkHubCoordinationActionGate(several).act( + { + actionId: 'resume-many', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ), + /does not identify one active durable delegation/u, + ); + assert.equal(several.resumeCalls.length, 0); + }); + + test('resume ignores a retired link when one delegation still holds work', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + const retired = effects.assignmentRecords.get('source-action')!; + effects.assignmentRecords.set( + 'second-action', + assignmentRecord( + { + actionId: 'second-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix the interrupted receipt retry', + }, + 'second-turn', + ), + ); + effects.retirements.push(retired); + + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'resume-one-live', + userText: 'Resume Payments', + proposal: resumeProposal('payments', 'second-action'), + }, + CONTEXT, + ); + + assert.equal(result.disposition, 'resume_work'); + const call = effects.resumeCalls[0]; + assert.ok(call); + assert.equal(call.source.actionId, 'second-action'); + }); + + test('resume retries cannot move an explicitly named assignment to another delegation', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'resume-retry', + userText: 'Resume Payments', + proposal: resumeProposal('payments', 'retired-assignment'), + }, + CONTEXT, + ), + /resume target delegation changed/u, + ); + assert.equal(effects.resumeCalls.length, 0); + }); + + test('resume reports when the delegated work is already running', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + effects.resumeOutcome = { outcome: 'already_running' }; + + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'resume-already-running', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ); + + assert.deepEqual(result, { + disposition: 'resume_work', + outcome: 'already_running', + targetSessionId: 'payments', + }); + }); + test('stops exactly one named durable delegation and replays its observed outcome', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); effects.assignmentRecords.set( @@ -2563,6 +2789,25 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { ? 'same_claim' : 'conflict'; }, + resumeCalls: [] as WorkHubDelegationResumeInput[], + resumeOutcome: { + outcome: 'resume_started' as const, + targetTurnId: 'resumed-turn', + } as { + outcome: 'resume_started' | 'already_running'; + targetTurnId?: string; + }, + async resume(input: WorkHubDelegationResumeInput) { + this.resumeCalls.push(input); + return { + disposition: 'resume_work' as const, + outcome: this.resumeOutcome.outcome, + targetSessionId: input.source.targetSessionId, + ...(this.resumeOutcome.targetTurnId + ? { targetTurnId: this.resumeOutcome.targetTurnId } + : {}), + }; + }, async readActionClaim(actionId: string) { return actionClaims.get(actionId); }, @@ -2758,6 +3003,11 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { stopRequests: Map; stopResolutions: Map; retirements: WorkHubDelegationAssignedMessage[]; + resumeCalls: WorkHubDelegationResumeInput[]; + resumeOutcome: { + outcome: 'resume_started' | 'already_running'; + targetTurnId?: string; + }; }; } diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index f18b8c1556..f6ae747120 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -47,7 +47,10 @@ import type { ConnectionContext } from '../server/operation-dispatcher.js'; import type { RootTurnCoordinator } from '../server/root-turn-coordinator.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; import { SessionOperationFailure } from '../server/session-catalog-coordinator.js'; -import type { WorkHubActionGateEffects } from '../server/workhub-coordination-action-gate.js'; +import { + WorkHubActionEffectFailure, + type WorkHubActionGateEffects, +} from '../server/workhub-coordination-action-gate.js'; import { HostWorkHubCoordinationCoordinator, type CoordinationCreateTarget, @@ -590,6 +593,51 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('preserves unauthorized action failures for the client', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-unauthorized-')); + const store = createSessionStore(root); + try { + 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: async () => { + throw new WorkHubActionEffectFailure('unauthorized', 'Target permission denied'); + }, + }); + 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.deepEqual( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'permission-rejected', + userText: 'Continue payments', + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidates.result.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ), + { + ok: false, + error: { code: 'unauthorized', message: 'Target permission denied' }, + }, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('reads current linkage only through the bounded candidate target', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-active-ledger-')); const store = createSessionStore(root); @@ -1075,6 +1123,160 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('returns the Host resume result without a durable resume record', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-resume-')); + let store = createSessionStore(root); + let targetId = ''; + const resumeInput = () => ({ + actionId: 'resume-action', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work' as const, + resumesActionId: 'source-action', + expects: { targetSessionId: targetId }, + }, + }); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + targetId = target.id; + let resumeCalls = 0; + const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + assign: persistTestAssignmentAction(store, 'payments-turn'), + resumeDelegation: async () => { + resumeCalls += 1; + return { + outcome: 'resume_started', + targetTurnId: 'resumed-turn', + }; + }, + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + 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 transcript = await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID); + const resumed = await workhub.handlers['workhub.coordination.act'](resumeInput(), CONTEXT); + assert.deepEqual(resumed, { + ok: true, + result: { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: target.id, + targetTurnId: 'resumed-turn', + }, + }); + assert.equal(resumeCalls, 1); + assert.equal(await store.readWorkHubActionClaim('resume-action'), undefined); + assert.deepEqual( + await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID), + transcript, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('reports recovery without durably parking a resume action', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-resume-recovering-')); + const store = createSessionStore(root); + try { + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + let recovering = true; + const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + assign: persistTestAssignmentAction(store, 'payments-turn'), + resumeDelegation: async () => { + if (recovering) { + throw new WorkHubActionEffectFailure( + 'host_not_ready', + 'WorkHub is still recovering the delegated execution', + ); + } + return { outcome: 'resume_started', targetTurnId: 'resumed-turn' }; + }, + }); + assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + )!; + assert.equal( + ( + await workhub.handlers['workhub.coordination.act']( + { + actionId: 'source-action', + userText: 'Fix payment retry', + candidateSetId: candidates.result.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef }, + }, + CONTEXT, + ) + ).ok, + true, + ); + + const input = { + actionId: 'resume-after-recovery', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work' as const, + resumesActionId: 'source-action', + expects: { targetSessionId: target.id }, + }, + }; + assert.deepEqual(await workhub.handlers['workhub.coordination.act'](input, CONTEXT), { + ok: false, + error: { + code: 'host_not_ready', + message: 'WorkHub is still recovering the delegated execution', + }, + }); + assert.equal(await store.readWorkHubActionClaim(input.actionId), undefined); + + recovering = false; + const retried = await workhub.handlers['workhub.coordination.act'](input, CONTEXT); + assert.equal(retried.ok, true); + if (retried.ok && retried.result.disposition === 'resume_work') { + assert.equal(retried.result.outcome, 'resume_started'); + } + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('rechecks sole-delegation stop preconditions after the advisory active-link read', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-race-')); const store = createSessionStore(root); @@ -1869,6 +2071,10 @@ function coordinator( executions, sessionActions: { readDelegationRetirement: async () => 'not_retired', + resumeDelegation: async () => ({ + outcome: 'resume_started' as const, + targetTurnId: 'resumed-turn', + }), retireDelegation: async () => ({ outcome: 'cancelled_pending' }), ...sessionActions, assign, diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts index 5a957aae82..04a51c1043 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -189,6 +189,98 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () ); }); +test('WorkHub Coordination resume has closed input and outcome shapes', () => { + assert.deepEqual( + decodeWorkHubCoordinationActInput({ + actionId: 'action-resume', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + resumesActionId: 'source-action', + expects: { targetSessionId: 'payments' }, + }, + }), + { + actionId: 'action-resume', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + resumesActionId: 'source-action', + expects: { targetSessionId: 'payments' }, + }, + }, + ); + + for (const invalid of [ + { + actionId: 'action-resume-confirmed', + userText: 'Resume Payments', + proposal: { disposition: 'resume_work', expects: { targetSessionId: 'payments' } }, + confirmation: { kind: 'user_stop' }, + }, + { + actionId: 'action-resume-missing-target', + userText: 'Resume Payments', + proposal: { disposition: 'resume_work' }, + }, + { + actionId: 'action-resume-injected', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + resumesActionId: 'source-action', + expects: { targetSessionId: 'payments' }, + targetSessionId: 'injected', + }, + }, + ]) { + assert.throws( + () => decodeWorkHubCoordinationActInput(invalid), + (error) => error instanceof RuntimeHostProtocolError, + ); + } + + for (const result of [ + { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: 'payments', + targetTurnId: 'turn-2', + }, + { + disposition: 'resume_work', + outcome: 'already_running', + targetSessionId: 'payments', + }, + ]) { + assert.deepEqual(decodeWorkHubCoordinationActResult(result), result); + } + + for (const invalid of [ + { + disposition: 'resume_work', + outcome: 'parked', + targetSessionId: 'payments', + }, + { + disposition: 'resume_work', + outcome: 'already_running', + targetSessionId: 'payments', + parkReason: 'safety_check_failed', + }, + { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: 'payments', + }, + ]) { + assert.throws( + () => decodeWorkHubCoordinationActResult(invalid), + (error) => error instanceof RuntimeHostProtocolError, + ); + } +}); + test('WorkHub Coordination candidates are bounded and carry opaque proposal identities', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 110); const result = decodeWorkHubCoordinationCandidatesResult({ diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 62845e8fb8..6e38c7504b 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 119 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 120 as const; +// 120: WorkHub admits named resume proposals with an explicit resumesActionId +// and returns a transient resume outcome. Older peers cannot decode this action. // 119: Session Guest principals expose optional display names and an owner-only // rename command. Older peers reject named principal projections. // 118: External-session import publishes distinct `model_unavailable` and diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 1f38312344..ebbd1092ef 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -148,6 +148,12 @@ export type WorkHubCoordinationProposal = * links, and on replay from the durable claim this action already owns. */ readonly expects: WorkHubCoordinationStopPreconditions; + } + | { + readonly disposition: 'resume_work'; + /** Bound reference from candidate discovery; the Gate checks current ownership. */ + readonly resumesActionId: string; + readonly expects: WorkHubCoordinationStopPreconditions; }; export interface WorkHubCoordinationStopPreconditions { @@ -204,6 +210,12 @@ export type WorkHubCoordinationActResult = readonly outcome: 'cancelled_pending' | 'stop_delivered' | 'already_terminal' | 'not_owned'; readonly targetSessionId: string; readonly targetTurnId?: string; + } + | { + readonly disposition: 'resume_work'; + readonly outcome: 'resume_started' | 'already_running'; + readonly targetSessionId: string; + readonly targetTurnId?: string; }; export const WORKHUB_COORDINATION_OPERATION_SPECS = { @@ -518,6 +530,32 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord }), }; } + if (result.disposition === 'resume_work') { + const exact = requireShapedRecord( + result, + 'WorkHub Coordination resume result', + ['disposition', 'outcome', 'targetSessionId'], + ['targetTurnId'], + ); + if (exact.outcome !== 'resume_started' && exact.outcome !== 'already_running') { + throw invalidProtocolFrame('Invalid WorkHub resume outcome'); + } + // Only a started continuation names a Turn: the Host has one to name, and + // the other two outcomes changed nothing that could carry an identity. + if ((exact.outcome === 'resume_started') !== (exact.targetTurnId !== undefined)) { + throw invalidProtocolFrame('Invalid WorkHub resume target Turn'); + } + return { + disposition: 'resume_work', + outcome: exact.outcome, + targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), + ...(exact.targetTurnId === undefined + ? {} + : { + targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), + }), + }; + } throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); } @@ -634,6 +672,18 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP expects: decodeWorkHubCoordinationStopPreconditions(exact.expects), }; } + if (proposal.disposition === 'resume_work') { + const exact = requireExactRecord(proposal, 'WorkHub resume proposal', [ + 'disposition', + 'expects', + 'resumesActionId', + ]); + return { + disposition: 'resume_work', + resumesActionId: requireEntityId(exact.resumesActionId, 'WorkHub resume assignment'), + expects: decodeWorkHubCoordinationStopPreconditions(exact.expects), + }; + } throw invalidProtocolFrame('Invalid WorkHub Coordination proposal disposition'); } diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index b3a1c107e4..c0e503699c 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -181,7 +181,10 @@ import type { TurnOperationHandlerMap } from './operation-dispatcher.js'; import { HostUsagePricingCoordinator } from './usage-pricing-coordinator.js'; import { HostWebSearchCoordinator } from './web-search-coordinator.js'; import { HostWorkHubCoordinationCoordinator } from './workhub-coordination-coordinator.js'; -import { WorkHubActionEffectFailure } from './workhub-coordination-action-gate.js'; +import { + WorkHubActionEffectFailure, + workHubResumedTurnId, +} from './workhub-coordination-action-gate.js'; type ExecutionConnectionRef = Parameters< RuntimePolicyStoresWriter['operations']['resolveExecutionConnection'] @@ -565,6 +568,8 @@ export async function createExecutionRuntimeHostComposition( let deepResearch: HostDeepResearchCoordinator | undefined; let dailyReview: HostDailyReviewCoordinator | undefined; const rootPort: HostMessageRootPort = { + readLatestRootTurnLineage: (identity) => + requireRootCoordinator(rootCoordinator).readLatestRootTurnLineage(identity), readSessionHeader: (sessionId) => requireRootCoordinator(rootCoordinator).readSessionHeader(sessionId), readRootState: (sessionId) => @@ -1380,12 +1385,106 @@ export async function createExecutionRuntimeHostComposition( turnId: disposition.turnId, runId: disposition.runId, }; - if (isActiveWorkHubRoot(coordinator, identity)) return 'not_retired'; + const latest = await coordinator.readLatestRootTurnLineage(identity); + if (isActiveWorkHubRoot(coordinator, latest)) return 'not_retired'; // The same restart window as `stopOwnedWorkHubRoot`: an unregistered // root is not evidence that its work ended. - const snapshot = await coordinator.read(identity); + const snapshot = await coordinator.read(latest); return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; }, + // Resolve and resume only the execution lineage owned by this + // delegation. A Session-wide latest-failure query could otherwise + // continue unrelated work started directly in the same Session. + resumeDelegation: async (assignment, context, actionId) => { + const disposition = await messages.readMessageExecutionDisposition( + assignment.targetSessionId, + assignment.targetMessageId, + ); + if (disposition.kind === 'recovering') { + throw new WorkHubActionEffectFailure( + 'host_not_ready', + 'WorkHub is still recovering the delegated execution', + ); + } + if (disposition.kind !== 'owned_root') { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub delegated execution is not resumable', + ); + } + const source = await coordinator.readLatestRootTurnLineage({ + sessionId: assignment.targetSessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }); + if (isActiveWorkHubRoot(coordinator, source)) { + return { outcome: 'already_running' as const }; + } + const snapshot = await coordinator.read(source); + if (!isHostedExecutionTerminal(snapshot)) { + throw new WorkHubActionEffectFailure( + 'host_not_ready', + 'WorkHub is still recovering the delegated execution', + ); + } + if (snapshot.status !== 'failed' && snapshot.status !== 'cancelled') { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub delegated execution is not resumable', + ); + } + const plan = await coordinator.handlers['turn.resume.query']( + { sessionId: assignment.targetSessionId, sourceRunId: source.runId }, + context, + ); + if (!plan.ok) throw new WorkHubActionEffectFailure(plan.error.code, plan.error.message); + if (plan.result.disposition === 'parked') { + throw new WorkHubActionEffectFailure( + plan.result.reason === 'resume_feature_disabled' + ? 'operation_unavailable' + : 'operation_conflict', + plan.result.reason === 'resume_feature_disabled' + ? 'Safe-boundary resume is disabled for this Runtime Host' + : 'WorkHub delegated execution is not resumable', + ); + } + if ( + plan.result.sourceRunId !== source.runId || + plan.result.sourceTurnId !== source.turnId + ) { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub resume source lineage changed during planning', + ); + } + const targetTurnId = workHubResumedTurnId(actionId); + const started = await coordinator.handlers['turn.resume.start']( + { + sessionId: assignment.targetSessionId, + turnId: targetTurnId, + sourceRunId: plan.result.sourceRunId, + sourceRuntimeEventHighWater: plan.result.sourceRuntimeEventHighWater, + }, + context, + ); + if (!started.ok) { + throw new WorkHubActionEffectFailure(started.error.code, started.error.message); + } + if (started.result.kind === 'parked') { + throw new WorkHubActionEffectFailure( + started.result.plan.reason === 'resume_feature_disabled' + ? 'operation_unavailable' + : 'operation_conflict', + started.result.plan.reason === 'resume_feature_disabled' + ? 'Safe-boundary resume is disabled for this Runtime Host' + : 'WorkHub delegated execution is not resumable', + ); + } + return { + outcome: 'resume_started' as const, + targetTurnId: started.result.turn.turnId, + }; + }, retireDelegation: async (assignment, retirement) => { const disposition = await messages.cancelMessageIfPending( assignment.targetSessionId, @@ -1405,11 +1504,11 @@ export async function createExecutionRuntimeHostComposition( return { outcome: 'not_owned' as const, targetTurnId: disposition.turnId }; } if (disposition.kind === 'owned_root') { - const identity = { + const identity = await coordinator.readLatestRootTurnLineage({ sessionId: assignment.targetSessionId, turnId: disposition.turnId, runId: disposition.runId, - }; + }); return retirement.cause === 'direct_stop' ? stopOwnedWorkHubRoot(coordinator, identity, retirement.cancellationClaimId) : stopReplacedWorkHubRoot(coordinator, identity); diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 4694dc7919..0bad3a0a4d 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -194,6 +194,11 @@ export type HostMessageExecutionDisposition = /** Root execution operations that must share the message coordinator's Session gate. */ export interface HostMessageRootPort { + readLatestRootTurnLineage(identity: { + sessionId: string; + turnId: string; + runId: string; + }): Promise<{ turnId: string; runId: string }>; readSessionHeader(sessionId: string): Promise; readRootState(sessionId: string): Promise | HostMessageRootState; claimStopFence( @@ -470,11 +475,18 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { for (const messageId of input.messageIds) { const disposition = await this.#resolveMessageExecution(input.sessionId, messageId); if (disposition.kind === 'owned_root' || disposition.kind === 'shared_turn') { + // This read projects current execution, including safe-boundary + // continuations. The Message's durable admission ownership is unchanged. + const latest = await this.#root.readLatestRootTurnLineage({ + sessionId: input.sessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }); resolutions.push({ messageId, state: 'owned', - turnId: disposition.turnId, - runId: disposition.runId, + turnId: latest.turnId, + runId: latest.runId, }); continue; } diff --git a/packages/runtime-host/src/server/root-admission-owner.ts b/packages/runtime-host/src/server/root-admission-owner.ts index f811c44f70..7cda7d7c35 100644 --- a/packages/runtime-host/src/server/root-admission-owner.ts +++ b/packages/runtime-host/src/server/root-admission-owner.ts @@ -86,6 +86,13 @@ export class RootAdmissionOwner { previousRootTurnId: current?.turnId ?? null, }); const admission = result.admission; + if (result.kind === 'conflict') { + const known = this.#admissionsBySession.get(input.sessionId)?.get(admission.turnId); + if (!known || !sameRootAdmission(known, admission)) { + throw new Error('Durable Root Turn conflict is outside the owned chain'); + } + return Object.freeze({ kind: 'conflict', admission: known }); + } if ( admission.sessionId !== input.sessionId || admission.turnId !== input.turnId || diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index f5db2de586..c8eb4958db 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -519,6 +519,30 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { return this.#admissions.has(sessionId) ? { kind: 'reserved' } : { kind: 'idle' }; } + /** Returns the newest Host-admitted continuation descended from one root execution. */ + async readLatestRootTurnLineage(identity: HostedExecutionRef): Promise { + const origin = await this.stores.agentRunStore.readRootTurnAdmission( + identity.sessionId, + identity.turnId, + ); + if (!origin || origin.runId !== identity.runId) { + throw new RuntimeMessageAuthorityInvariantError( + `Root execution ${identity.turnId}/${identity.runId} has no durable admission`, + ); + } + let latest = origin; + while (true) { + const continuation = await this.stores.agentRunStore.readRootTurnContinuationAdmission( + identity.sessionId, + latest.turnId, + latest.runId, + ); + if (!continuation) break; + latest = continuation; + } + return { sessionId: latest.sessionId, turnId: latest.turnId, runId: latest.runId }; + } + startHostedExternalTransition( input: HostedExternalTurnTransitionInput, context: ConnectionContext, @@ -1966,6 +1990,14 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { sourceMessages: [], admittedAt: Date.now(), }); + if (admitted.kind === 'conflict') { + return { + kind: 'complete', + outcome: operationConflict( + 'This root execution already has a different continuation Turn', + ), + }; + } if ( admitted.admission.runId !== continuation.runId || !isDeepStrictEqual(admitted.admission.execution, execution) 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 a270e319a2..8d701e46d9 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -42,6 +42,7 @@ import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, workHubCreationAuthorizesTitle, + workHubNamedDelegationActionTargetsSession, } from '@maka/core/workhub-creation-intent'; import type { WorkHubCoordinationActInput, @@ -145,6 +146,10 @@ export interface WorkHubActionGateEffects { assignment: WorkHubDelegationAssignedMessage, retirement: WorkHubDelegationRetirementClaim, ): Promise; + resume( + input: WorkHubDelegationResumeInput, + context: ConnectionContext, + ): Promise>; } /** @@ -159,6 +164,11 @@ export interface WorkHubDelegationRetirementClaim { readonly cause: 'direct_stop' | 'replacement'; } +export interface WorkHubDelegationResumeInput { + readonly actionId: string; + readonly source: WorkHubDelegationAssignedMessage; +} + export interface WorkHubRetirementResult { readonly outcome: WorkHubDelegationStopOutcome | 'recovering'; readonly targetTurnId?: string; @@ -302,9 +312,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; the durable action - // claim, not this map, is what owns the identity across that retry and - // across restarts. + // the slot so admission can retry. Durable identity belongs to the + // owning operation: action claims for coordination writes, Host Turn + // admission for resume. void result.catch(() => { if (this.#actions.get(input.actionId) === action) { this.#actions.delete(input.actionId); @@ -426,6 +436,43 @@ export class WorkHubCoordinationActionGate { }); return this.#stop(requested, source); } + if (proposal.disposition === 'resume_work') { + if (!requestIntent.resume.imperative) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume requires an explicit named command in trusted user text', + ); + } + const candidates = await this.candidates(); + const target = candidates.candidates.find( + (candidate) => candidate.sessionId === proposal.expects.targetSessionId, + ); + if (!target) + throw new WorkHubActionGateFailure( + 'candidate_unavailable', + 'WorkHub resume target is unavailable', + ); + this.#assertTarget(target); + const source = await this.#soleWorkingDelegation(target.sessionId, 'resume'); + if (source.actionId !== proposal.resumesActionId) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume target delegation changed', + ); + } + const currentTargetName = target.sessionName; + if ( + !currentTargetName || + !workHubNamedDelegationActionTargetsSession(requestIntent.resume, currentTargetName) + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume target is not affirmed in trusted user text', + ); + } + return this.#effects.resume({ actionId: input.actionId, source }, context); + } + if (proposal.disposition === 'create_new') { if (!input.create || !workHubCreationAuthorizesTitle(requestIntent, proposal.title)) { throw new WorkHubActionGateFailure( @@ -568,11 +615,31 @@ export class WorkHubCoordinationActionGate { return claimed; } } + const resolved = await this.#soleWorkingDelegation(targetSessionId, 'stop'); + // A claim with no request behind it resolves from the active links like a + // first attempt, but only while those links still name the delegation it + // bound itself to. If that one left and another took its place, the + // fingerprint derived here would no longer match the claim, and since + // claims are never deleted the refusal would be permanent and unexplained. + // Say why instead: the identity is spent, and the retry needs a new one. + if (claim?.operation === 'stop' && resolved.delegationId !== claim.subject) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub stop identity is already bound to a different delegation', + ); + } + return resolved; + } + + async #soleWorkingDelegation( + targetSessionId: string, + operation: 'resume' | 'stop', + ): Promise { const onTarget = await this.#effects.listActiveAssignments(targetSessionId); if (onTarget.length === 0) { throw new WorkHubActionGateFailure( 'action_conflict', - 'WorkHub has no active durable delegation to stop on that Session', + `WorkHub has no active durable delegation to ${operation} on that Session`, ); } // One link is the answer whatever state its work is in. Whether that work @@ -595,23 +662,11 @@ export class WorkHubCoordinationActionGate { if (holdingWork.length !== 1) { throw new WorkHubActionGateFailure( 'action_conflict', - 'WorkHub stop target does not identify one active durable delegation', + `WorkHub ${operation} target does not identify one active durable delegation`, ); } resolved = holdingWork[0]!; } - // A claim with no request behind it resolves from the active links like a - // first attempt, but only while those links still name the delegation it - // bound itself to. If that one left and another took its place, the - // fingerprint derived here would no longer match the claim, and since - // claims are never deleted the refusal would be permanent and unexplained. - // Say why instead: the identity is spent, and the retry needs a new one. - if (claim?.operation === 'stop' && resolved.delegationId !== claim.subject) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub stop identity is already bound to a different delegation', - ); - } return resolved; } @@ -1051,6 +1106,12 @@ function workHubCreatedSessionId(actionId: string): string { return `whs_${hash(`create\0${actionId}`).slice(0, 48)}`; } +// One request cannot resume a later interruption after a lost response and restart. +// Host admission rejects reuse of this Turn id for a different source boundary. +export function workHubResumedTurnId(actionId: string): string { + return `wht_${hash(`resume\0${actionId}`).slice(0, 48)}`; +} + function workspaceProjection(session: WorkHubActionGateSession): WorkspaceProjection { return { target: diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 5f14a3ffc7..1a33b3095c 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -41,6 +41,7 @@ import { import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; import type { OperationOutcome, + WorkHubCoordinationActResult, WorkHubCoordinationActInput, WorkHubCoordinationAnswerInput, WorkHubCoordinationRecordInput, @@ -110,6 +111,24 @@ type CoordinationExecutions = Pick< 'startWorkHubCoordinationMessage' | 'hasRootTurnAdmission' >; +type WorkHubResumeResult = + | { + readonly outcome: 'resume_started'; + readonly targetTurnId: string; + } + | { readonly outcome: 'already_running' }; + +type CoordinationSessionActions = Pick< + WorkHubActionGateEffects, + 'assign' | 'readDelegationRetirement' | 'retireDelegation' +> & { + resumeDelegation( + assignment: WorkHubDelegationAssignedMessage, + context: ConnectionContext, + actionId: string, + ): Promise; +}; + export type CoordinationCreateTarget = Omit; export interface HostWorkHubCoordinationCoordinatorOptions { @@ -118,10 +137,7 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly admission: SessionAdmissionGate; readonly continuity: Pick; readonly executions: CoordinationExecutions; - readonly sessionActions: Pick< - WorkHubActionGateEffects, - 'assign' | 'readDelegationRetirement' | 'retireDelegation' - >; + readonly sessionActions: CoordinationSessionActions; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; } @@ -203,6 +219,11 @@ export class HostWorkHubCoordinationCoordinator { resolveStop: (input) => this.#resolveStop(input), readDelegationRetirement: options.sessionActions.readDelegationRetirement, retireDelegation: options.sessionActions.retireDelegation, + resume: async (input, context) => ({ + disposition: 'resume_work', + targetSessionId: input.source.targetSessionId, + ...(await options.sessionActions.resumeDelegation(input.source, context, input.actionId)), + }), }); } @@ -506,7 +527,7 @@ export class HostWorkHubCoordinationCoordinator { return { ok: false, error: { - code: error.code === 'unauthorized' ? 'operation_unavailable' : error.code, + code: error.code, message: error.message, }, }; diff --git a/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts b/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts new file mode 100644 index 0000000000..9d0fa6ed6c --- /dev/null +++ b/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { test } from 'node:test'; +import { createSqliteAgentRunStore } from '../agent-run-store.js'; +import { migrateSqliteCoreExecutionDatabase } from '../sqlite-core-execution-schema.js'; + +test('core execution migration preserves databases with historical continuation forks', () => { + const database = new DatabaseSync(':memory:'); + try { + database.exec(` + CREATE TABLE core_root_turn_admissions ( + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + admitted_at INTEGER NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (session_id, turn_id) + ); + `); + const insert = database.prepare(` + INSERT INTO core_root_turn_admissions(session_id, turn_id, admitted_at, record_json) + VALUES (?, ?, ?, ?) + `); + for (const [turnId, admittedAt] of [ + ['continuation-a', 20], + ['continuation-b', 30], + ] as const) { + insert.run( + 'session', + turnId, + admittedAt, + JSON.stringify({ + sessionId: 'session', + turnId, + execution: { + kind: 'safe_boundary_continuation', + sourceTurnId: 'source-turn', + sourceRunId: 'source-run', + }, + }), + ); + } + + assert.doesNotThrow(() => migrateSqliteCoreExecutionDatabase(database)); + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM core_root_turn_admissions').get()?.count, + 2, + ); + } finally { + database.close(); + } +}); + +test('safe-boundary continuation admission is indexed by its source execution', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-continuation-admission-')); + try { + const store = createSqliteAgentRunStore(root); + const origin = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'source-turn', + proposedRunId: 'source-run', + proposedUserMessageId: 'source-message', + execution: { kind: 'external_message' }, + previousRootTurnId: null, + normalizedInput: { text: 'Start work' }, + sourceMessages: [], + admittedAt: 10, + }); + assert.equal(origin.kind, 'admitted'); + const continuation = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'continuation-turn', + proposedRunId: 'continuation-run', + proposedUserMessageId: null, + execution: { + kind: 'safe_boundary_continuation', + sourceInvocationId: 'source-invocation', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 7, + claimId: 'continuation-claim', + boundaryDigest: `sha256:${'a'.repeat(64)}`, + providerReplayDigest: `sha256:${'b'.repeat(64)}`, + safetyDigest: `sha256:${'c'.repeat(64)}`, + targetInvocationId: 'continuation-invocation', + }, + previousRootTurnId: 'source-turn', + normalizedInput: null, + sourceMessages: [], + admittedAt: 20, + }); + assert.equal(continuation.kind, 'admitted'); + const competing = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'competing-continuation-turn', + proposedRunId: 'competing-continuation-run', + proposedUserMessageId: null, + execution: { + kind: 'safe_boundary_continuation', + sourceInvocationId: 'source-invocation', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 7, + claimId: 'competing-continuation-claim', + boundaryDigest: `sha256:${'d'.repeat(64)}`, + providerReplayDigest: `sha256:${'e'.repeat(64)}`, + safetyDigest: `sha256:${'f'.repeat(64)}`, + targetInvocationId: 'competing-continuation-invocation', + }, + previousRootTurnId: 'source-turn', + normalizedInput: null, + sourceMessages: [], + admittedAt: 30, + }); + assert.deepEqual(competing, { kind: 'conflict', admission: continuation.admission }); + + assert.deepEqual( + await store.readRootTurnContinuationAdmission('session', 'source-turn', 'source-run'), + continuation.admission, + ); + assert.equal( + await store.readRootTurnContinuationAdmission('session', 'source-turn', 'other-run'), + undefined, + ); + store.close?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('historical continuation forks resolve to the earliest durable admission', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-continuation-fork-')); + try { + const store = createSqliteAgentRunStore(root); + const source = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'source-turn', + proposedRunId: 'source-run', + proposedUserMessageId: 'source-message', + execution: { kind: 'external_message' }, + previousRootTurnId: null, + normalizedInput: { text: 'Start work' }, + sourceMessages: [], + admittedAt: 10, + }); + const first = await store.admitRootTurn({ + sessionId: 'session', + turnId: 'continuation-a', + proposedRunId: 'continuation-run-a', + proposedUserMessageId: null, + execution: { + kind: 'safe_boundary_continuation', + sourceInvocationId: 'source-invocation', + sourceRunId: source.admission.runId, + sourceTurnId: source.admission.turnId, + sourceRuntimeEventHighWater: 7, + claimId: 'continuation-claim-a', + boundaryDigest: `sha256:${'a'.repeat(64)}`, + providerReplayDigest: `sha256:${'b'.repeat(64)}`, + safetyDigest: `sha256:${'c'.repeat(64)}`, + targetInvocationId: 'continuation-invocation-a', + }, + previousRootTurnId: source.admission.turnId, + normalizedInput: null, + sourceMessages: [], + admittedAt: 20, + }); + store.close?.(); + + const database = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + const fork = { + ...first.admission, + turnId: 'continuation-b', + runId: 'continuation-run-b', + admittedAt: 30, + execution: { + ...first.admission.execution, + claimId: 'continuation-claim-b', + targetInvocationId: 'continuation-invocation-b', + }, + }; + database + .prepare(` + INSERT INTO core_root_turn_admissions(session_id, turn_id, admitted_at, record_json) + VALUES (?, ?, ?, ?) + `) + .run(fork.sessionId, fork.turnId, fork.admittedAt, JSON.stringify(fork)); + } finally { + database.close(); + } + + const reopened = createSqliteAgentRunStore(root); + try { + assert.deepEqual( + await reopened.readRootTurnContinuationAdmission('session', 'source-turn', 'source-run'), + first.admission, + ); + } finally { + reopened.close?.(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index dd855c9b3f..58aa12a584 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -202,6 +202,11 @@ export type AdmitRootTurnResult = export interface RootTurnAdmissionStore { admitRootTurn(input: AdmitRootTurnInput): Promise; readRootTurnAdmission(sessionId: string, turnId: string): Promise; + readRootTurnContinuationAdmission( + sessionId: string, + sourceTurnId: string, + sourceRunId: string, + ): Promise; readRootTurnSourceMessageReceipt( sessionId: string, sourceMessageId: string, @@ -588,6 +593,33 @@ class SqliteAgentRunStore implements DurableAgentRunStore { ) { throw new Error('Root Turn identity is already rejected'); } + if (admission.execution.kind === 'safe_boundary_continuation') { + const sourceOwner = this.#lease.database + .prepare(` + SELECT turn_id + FROM core_root_turn_admissions + WHERE session_id = ? + AND json_extract(record_json, '$.execution.sourceTurnId') = ? + AND json_extract(record_json, '$.execution.sourceRunId') = ? + AND json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation' + ORDER BY admitted_at, turn_id + LIMIT 1 + `) + .get( + admission.sessionId, + admission.execution.sourceTurnId, + admission.execution.sourceRunId, + ) as { turn_id?: unknown } | undefined; + if (typeof sourceOwner?.turn_id === 'string') { + const owner = readSqliteRootTurnAdmission( + this.#lease.database, + admission.sessionId, + sourceOwner.turn_id, + ); + if (!owner) throw new Error('Root continuation index has no durable admission'); + return { kind: 'conflict', admission: owner }; + } + } for (const source of admission.sourceMessages) { const proof = this.#lease.database .prepare(` @@ -635,6 +667,43 @@ class SqliteAgentRunStore implements DurableAgentRunStore { return readSqliteRootTurnAdmission(this.#lease.database, sessionId, turnId); } + async readRootTurnContinuationAdmission( + sessionId: string, + sourceTurnId: string, + sourceRunId: string, + ): Promise { + assertSafeId(sessionId, 'Invalid session id'); + assertSafeId(sourceTurnId, 'Invalid source turn id'); + assertSafeId(sourceRunId, 'Invalid source run id'); + const row = this.#lease.database + .prepare(` + SELECT turn_id, record_json + FROM core_root_turn_admissions + WHERE session_id = ? + AND json_extract(record_json, '$.execution.sourceTurnId') = ? + AND json_extract(record_json, '$.execution.sourceRunId') = ? + AND json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation' + ORDER BY admitted_at, turn_id + LIMIT 1 + `) + .get(sessionId, sourceTurnId, sourceRunId) as + | { + turn_id?: unknown; + record_json?: unknown; + } + | undefined; + if (!row) return undefined; + if (typeof row.turn_id !== 'string' || typeof row.record_json !== 'string') { + throw new Error('Invalid SQLite root turn continuation admission row'); + } + const admission = normalizeRootTurnAdmission( + JSON.parse(row.record_json), + sessionId, + row.turn_id, + ); + return admission; + } + async readRootTurnStartRejection( sessionId: string, turnId: string, diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 27c8e3541f..5b066c5d4a 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -193,6 +193,11 @@ export interface ExecutionAgentRunReader { type: AgentRunProjectionKey, ): Promise; readRootTurnAdmission(sessionId: string, turnId: string): Promise; + readRootTurnContinuationAdmission( + sessionId: string, + sourceTurnId: string, + sourceRunId: string, + ): Promise; readRootTurnSourceMessageReceipt( sessionId: string, sourceMessageId: string, @@ -527,6 +532,10 @@ async function createExecutionStoresForWrite agentRunStore.admitRootTurn(input)), readRootTurnAdmission: (sessionId, turnId) => run(() => agentRunStore.readRootTurnAdmission(sessionId, turnId)), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + run(() => + agentRunStore.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), + ), readRootTurnStartRejection: (sessionId, turnId) => run(() => agentRunStore.readRootTurnStartRejection(sessionId, turnId)), commitRootTurnStartRejection: (input: CommitRootTurnStartRejectionInput) => @@ -663,6 +672,10 @@ async function openExecutionStoresForRead agentRunStore.readEventProjection(sessionId, type)), readRootTurnAdmission: (sessionId, turnId) => run(() => agentRunStore.readRootTurnAdmission(sessionId, turnId)), + readRootTurnContinuationAdmission: (sessionId, sourceTurnId, sourceRunId) => + run(() => + agentRunStore.readRootTurnContinuationAdmission(sessionId, sourceTurnId, sourceRunId), + ), readRootTurnSourceMessageReceipt: (sessionId, sourceMessageId) => run(() => agentRunStore.readRootTurnSourceMessageReceipt(sessionId, sourceMessageId)), }, diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index caefc73fca..eff59d38f7 100644 --- a/packages/storage/src/sqlite-core-execution-schema.ts +++ b/packages/storage/src/sqlite-core-execution-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_CORE_EXECUTION_SCHEMA_VERSION = 7; +export const SQLITE_CORE_EXECUTION_SCHEMA_VERSION = 8; export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { db.exec(` @@ -172,6 +172,16 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { ON core_agent_runs(session_id, latest_model_call_sequence, run_id) WHERE latest_model_call_sequence IS NOT NULL; + DROP INDEX IF EXISTS core_root_turn_continuation_source; + + CREATE INDEX IF NOT EXISTS core_root_turn_continuation_source_v2 + ON core_root_turn_admissions( + session_id, + json_extract(record_json, '$.execution.sourceTurnId'), + json_extract(record_json, '$.execution.sourceRunId') + ) + WHERE json_extract(record_json, '$.execution.kind') = 'safe_boundary_continuation'; + DROP INDEX IF EXISTS core_agent_runs_identity; DROP TABLE IF EXISTS core_message_receipts;