From b52636241cd01e9318ef68ccf985b00edea0f672 Mon Sep 17 00:00:00 2001 From: ChengBo Zhang Date: Fri, 4 Sep 2026 04:31:18 +0800 Subject: [PATCH 01/10] feat(workhub): read a resume request from trusted user text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resume asks the Host to carry on work an interruption left unfinished. It is admitted on the same terms as a stop — a direct speech act naming one existing Session — so it reuses the stop reader's rules: a question is not a command, a malformed literal is refused, and an anaphoric target carries the cue without claiming a target so the surface can ask which work rather than guess. English covers resume / continue / restart and Chinese 继续 / 恢复 / 接着跑 / 重新开始, the same colloquial range each language already has for stop. A cue alone resumes nothing: the reference must still resolve to a Session, which is what keeps `continue with the refactor` ordinary work. This is the reader only. The disposition that consumes it lands with the Gate admission in the same change. Generated-by: Claude Opus --- .../__tests__/workhub-creation-intent.test.ts | 38 ++++++++++++++++ packages/core/src/workhub-creation-intent.ts | 44 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/core/src/__tests__/workhub-creation-intent.test.ts b/packages/core/src/__tests__/workhub-creation-intent.test.ts index 04cc0f8bb6..0151cd8870 100644 --- a/packages/core/src/__tests__/workhub-creation-intent.test.ts +++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts @@ -1126,3 +1126,41 @@ test('a spoken Chinese stop is a stop, in the same range English already covers' assert.equal(readWorkHubRequestIntent(text).stop.cue, false, text); } }); + +test('a resume names one Session, and reads like a stop everywhere else', () => { + // 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'], + ['Continue Payments', 'Payments'], + ['Restart Payments', 'Payments'], + ['继续支付任务', '支付任务'], + ['恢复支付任务', '支付任务'], + ['请继续支付任务', '支付任务'], + ['接着跑支付任务', '支付任务'], + ] as const) { + assert.deepEqual( + readWorkHubRequestIntent(text).resume, + { cue: true, imperative: true, target }, + text, + ); + } + + // Anaphora carries the cue and claims no target, so the surface asks which + // work instead of guessing — the same shape `Stop it` produces. + for (const text of ['Resume it', '继续它']) { + assert.deepEqual(readWorkHubRequestIntent(text).resume, { cue: true, imperative: false }, text); + } + + // A question, a negation and an unterminated quote are not commands. + for (const text of ['Should I resume Payments?', 'Do not resume Payments', 'Resume "Payments']) { + assert.equal(readWorkHubRequestIntent(text).resume.cue, 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.cue, false); + assert.equal(readWorkHubRequestIntent('Resume Payments').stop.cue, false); + assert.equal(readWorkHubRequestIntent('Fix the login bug').resume.cue, false); +}); diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index f55679b311..427ee79e00 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -110,6 +110,15 @@ const DIRECT_STOP_REQUEST = // equivalent either. const DIRECT_CHINESE_STOP_REQUEST = /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:停止|停掉|停下|取消|终止|中止)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; +// Resume asks the Host to carry on work that was interrupted, so it reads the +// same shape as a stop: a direct speech act naming one existing Session. It is +// deliberately narrower than the everyday senses of these words — `continue` +// and `继续` also introduce ordinary instructions ("continue with the refactor"), +// which is why a resume that names nothing resolvable stays ordinary work. +const DIRECT_RESUME_REQUEST = + /^\s*(?:(?:please|kindly)\s+)?(?:resume|continue|restart)\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_STOP_TARGET = /^(?:it|this|that|one|everything|all|current|session|work|task|job|(?:this|that|current)\s+(?:session|work|task|job)|它|这个|那个|全部|当前|会话|工作|任务|(?:这个|那个|当前)(?:会话|工作|任务))$/iu; @@ -151,6 +160,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; + }; } /** @@ -317,6 +333,8 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { const correctionCue = hasWorkHubCorrectionCue(source); const existingTarget = affirmativeWorkHubExistingCorrectionTarget(source); const stopCue = directWorkHubStopCue(source, literalMask.malformed); + const resumeCue = directWorkHubResumeCue(source, literalMask.malformed); + const resumeTarget = resumeCue ? directWorkHubResumeTarget(source, false) : undefined; const stopTarget = stopCue ? directWorkHubStopTarget(source, false) : undefined; const actions = allMatches(masked, EXECUTION_ACTION); const execution: WorkHubExecutionIntent = @@ -339,6 +357,11 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { imperative: Boolean(stopTarget), ...(stopTarget ? { target: stopTarget } : {}), }, + resume: { + cue: resumeCue, + imperative: Boolean(resumeTarget), + ...(resumeTarget ? { target: resumeTarget } : {}), + }, }; } @@ -496,6 +519,27 @@ function directWorkHubStopCue(value: string, malformedLiteral: boolean): boolean return Boolean(DIRECT_STOP_REQUEST.test(value) || DIRECT_CHINESE_STOP_REQUEST.test(value)); } +/** + * Resume reuses the stop reader's rules: a question is not a command, a + * malformed literal is refused outright, and an anaphoric target — `it`, `它`, + * `这个工作` — reads the cue without claiming a target, so the surface can ask + * which work rather than guess at it. + */ +function directWorkHubResumeTarget(value: string, malformedLiteral: boolean): string | undefined { + if (malformedLiteral || /[??]\s*$/u.test(value)) return undefined; + const match = DIRECT_RESUME_REQUEST.exec(value) ?? DIRECT_CHINESE_RESUME_REQUEST.exec(value); + const rawTarget = match?.[1]?.trim(); + if (!rawTarget) return undefined; + const target = stripMatchingStopQuotes(rawTarget.replace(/[.!。!]+\s*$/u, '').trim()); + if (!target || UNSAFE_STOP_TARGET.test(target)) return undefined; + return target; +} + +function directWorkHubResumeCue(value: string, malformedLiteral: boolean): boolean { + if (malformedLiteral || /[??]\s*$/u.test(value)) return false; + return Boolean(DIRECT_RESUME_REQUEST.test(value) || DIRECT_CHINESE_RESUME_REQUEST.test(value)); +} + function stripMatchingStopQuotes(value: string): string { const pairs = new Map([ ['"', '"'], From b5e1472b846356cdf9750e050895575b1a204a9a Mon Sep 17 00:00:00 2001 From: ChengBo Zhang Date: Fri, 4 Sep 2026 04:43:32 +0800 Subject: [PATCH 02/10] feat(workhub): admit a resume through the Action Gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop was the only way WorkHub could change delegated execution, so work an interruption left unfinished could only be restarted by leaving the conversation, opening the Session and using its own banner. Slice 5 asks for resume from the coordination transcript, and the capability to do it already exists. Resume composes the two operations that banner uses — ask the Host whether this Session has a continuation to make, then make it — and adds no recovery machinery of its own. A repeat is safe because the Host parks a continuation that already exists rather than forking a second one, so the Turn identity is derived from the delegation and the source run rather than minted, and two attempts name the same Turn. It goes through the Gate for the same reason every other disposition does: it changes execution state, and the Gate is where that is admitted. It carries no confirmation, because it destroys nothing and grants no authority a delegation did not already grant — it proves only that the words named one Session and that the Session still owns one link. `WorkHubActionOperation` gains `resume` so one action identity still means one operation. That is a durable vocabulary change with no migration: the column already exists, and only a Host that predates this value would refuse to read a claim carrying it. Refs #3492 Generated-by: Claude Opus --- .../main/__tests__/workhub-controller.test.ts | 8 + .../__tests__/workhub-surface-flow.test.ts | 8 + packages/core/src/session.ts | 7 +- .../workhub-coordination-action-gate.test.ts | 168 ++++++++++++++++++ .../workhub-coordination-coordinator.test.ts | 4 + packages/runtime-host/src/protocol/index.ts | 5 +- .../src/protocol/workhub-coordination.ts | 62 +++++++ .../src/server/execution-composition.ts | 40 ++++- .../workhub-coordination-action-gate.ts | 88 +++++++++ .../workhub-coordination-coordinator.ts | 3 +- .../src/sqlite-session-metadata-store.ts | 3 +- 11 files changed, 391 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 3b524129e7..4724a7909d 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -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); 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..e88af5b222 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -665,6 +665,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', diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index dc03aea7be..a58c5b1d39 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1091,7 +1091,12 @@ export type WorkHubActionOperation = | 'delegate_existing' | 'create_new' | 'replace' - | 'stop'; + | 'stop' + // Resume claims like every other disposition, so one action identity still + // means one operation. A Host that predates this value refuses to read a + // claim carrying it, which is a downgrade hazard and not a wire one: the + // table is local, and the row only exists once a resume has been admitted. + | 'resume'; /** Durable global binding from one action identity to one exact operation. */ export interface WorkHubActionClaim { 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..76efe332df 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 @@ -329,6 +329,160 @@ describe('WorkHub Coordination Action Gate', () => { expects: { targetSessionId }, }); + const resumeProposal = (targetSessionId: string) => ({ + 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); + assert.equal(effects.resumeCalls[0]?.actionId, 'source-action'); + // Resume claims like every other disposition, so the identity is spent. + assert.equal(effects.actionClaims.get('resume-action')?.operation, 'resume'); + }); + + 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 reports the Host answer it was given, including a park', async () => { + for (const [outcome, targetTurnId] of [ + ['already_running', undefined], + ['parked', undefined], + ] as const) { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + effects.resumeOutcome = { outcome }; + + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: `resume-${outcome}`, + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ); + + assert.deepEqual(result, { + disposition: 'resume_work', + outcome, + targetSessionId: 'payments', + ...(targetTurnId ? { targetTurnId } : {}), + }); + } + }); + 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 +2717,15 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { ? 'same_claim' : 'conflict'; }, + resumeCalls: [] as WorkHubDelegationAssignedMessage[], + resumeOutcome: { outcome: 'resume_started' as const, targetTurnId: 'resumed-turn' } as { + outcome: 'resume_started' | 'already_running' | 'parked'; + targetTurnId?: string; + }, + async resumeDelegation(assignment: WorkHubDelegationAssignedMessage) { + this.resumeCalls.push(assignment); + return this.resumeOutcome; + }, async readActionClaim(actionId: string) { return actionClaims.get(actionId); }, @@ -2758,6 +2921,11 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { stopRequests: Map; stopResolutions: Map; retirements: WorkHubDelegationAssignedMessage[]; + resumeCalls: WorkHubDelegationAssignedMessage[]; + resumeOutcome: { + outcome: 'resume_started' | 'already_running' | 'parked'; + 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..379e8fc362 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -1869,6 +1869,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/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index c3d9ed2d8a..b4e2832a92 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 117 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 118 as const; +// 118: WorkHub Coordination admits a `resume_work` proposal and answers with a +// `resume_work` outcome. An older peer's closed decoder rejects both the +// disposition it does not know and the result kind it cannot read. // 117: WorkHub exposes only one correction linkage per bounded candidate and // no longer returns the Host's complete active-link set. // 116: User deletion rejects workflow-owned Artifacts with operation_conflict. diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 1f38312344..fb0fa08195 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -148,6 +148,16 @@ export type WorkHubCoordinationProposal = * links, and on replay from the durable claim this action already owns. */ readonly expects: WorkHubCoordinationStopPreconditions; + } + | { + readonly disposition: 'resume_work'; + /** + * Resume names the Session it resolved and nothing else, for the same + * reason a stop does: which delegation is live is the Host's to know. + * The Gate revalidates it, so a resolution that has gone stale fails + * closed instead of restarting work the user never named. + */ + readonly expects: WorkHubCoordinationStopPreconditions; }; export interface WorkHubCoordinationStopPreconditions { @@ -204,6 +214,18 @@ export type WorkHubCoordinationActResult = readonly outcome: 'cancelled_pending' | 'stop_delivered' | 'already_terminal' | 'not_owned'; readonly targetSessionId: string; readonly targetTurnId?: string; + } + | { + readonly disposition: 'resume_work'; + /** + * `parked` is the Host declining to continue — the reason is its own and + * is not restated here, because nothing the client can do changes it. + * `already_running` means the work never stopped, which is an answer, not + * a failure. + */ + readonly outcome: 'resume_started' | 'already_running' | 'parked'; + readonly targetSessionId: string; + readonly targetTurnId?: string; }; export const WORKHUB_COORDINATION_OPERATION_SPECS = { @@ -518,6 +540,36 @@ 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' && + exact.outcome !== 'parked' + ) { + 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 +686,16 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP expects: decodeWorkHubCoordinationStopPreconditions(exact.expects), }; } + if (proposal.disposition === 'resume_work') { + const exact = requireExactRecord(proposal, 'WorkHub resume proposal', [ + 'disposition', + 'expects', + ]); + return { + disposition: 'resume_work', + 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 7c0f8f90e0..c7e998672a 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'] @@ -1386,6 +1389,41 @@ export async function createExecutionRuntimeHostComposition( const snapshot = await coordinator.read(identity); return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; }, + // Resume is the same two steps the Desktop banner takes: ask the Host + // whether this Session has a continuation to make, then make it. The + // Host owns both answers, so a repeat parks rather than forking, and + // WorkHub records only which of the three things happened. + resumeDelegation: async (assignment, context) => { + const plan = await coordinator.handlers['turn.resume.query']( + { sessionId: assignment.targetSessionId }, + context, + ); + if (!plan.ok) return { outcome: 'parked' as const }; + if (plan.result.disposition === 'parked') { + return { + outcome: + plan.result.reason === 'resume_candidate_missing' + ? ('already_running' as const) + : ('parked' as const), + }; + } + const started = await coordinator.handlers['turn.resume.start']( + { + sessionId: assignment.targetSessionId, + turnId: workHubResumedTurnId(assignment.delegationId, plan.result.sourceRunId), + sourceRunId: plan.result.sourceRunId, + sourceRuntimeEventHighWater: plan.result.sourceRuntimeEventHighWater, + }, + context, + ); + if (!started.ok || started.result.kind === 'parked') { + return { outcome: 'parked' as const }; + } + return { + outcome: 'resume_started' as const, + targetTurnId: started.result.turn.turnId, + }; + }, retireDelegation: async (assignment, retirement) => { const disposition = await messages.cancelMessageIfPending( assignment.targetSessionId, 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..19b7a99e6d 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -145,6 +145,15 @@ export interface WorkHubActionGateEffects { assignment: WorkHubDelegationAssignedMessage, retirement: WorkHubDelegationRetirementClaim, ): Promise; + /** + * Ask the target Session to carry on the work this delegation left + * unfinished. The Host owns whether that is possible; a repeat is safe + * because a continuation that already exists parks rather than forks. + */ + resumeDelegation( + assignment: WorkHubDelegationAssignedMessage, + context: ConnectionContext, + ): Promise; } /** @@ -159,6 +168,11 @@ export interface WorkHubDelegationRetirementClaim { readonly cause: 'direct_stop' | 'replacement'; } +export interface WorkHubResumeResult { + readonly outcome: 'resume_started' | 'already_running' | 'parked'; + readonly targetTurnId?: string; +} + export interface WorkHubRetirementResult { readonly outcome: WorkHubDelegationStopOutcome | 'recovering'; readonly targetTurnId?: string; @@ -426,6 +440,29 @@ export class WorkHubCoordinationActionGate { }); return this.#stop(requested, source); } + if (proposal.disposition === 'resume_work') { + // Resume carries no confirmation. It starts work that was already + // delegated and already interrupted, so it destroys nothing and needs no + // authority a delegation did not already grant — only proof that the + // words asked for it and that the Session named still owns one link. + if (!requestIntent.resume.imperative) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume requires an explicit named command in trusted user text', + ); + } + const source = await this.#resumeSource(proposal.expects.targetSessionId); + const resumeFingerprint = resumeActionFingerprint(input, source); + await this.#claimAction(input.actionId, 'resume', resumeFingerprint, source.delegationId); + const resumed = await this.#effects.resumeDelegation(source, context); + return { + disposition: 'resume_work', + outcome: resumed.outcome, + targetSessionId: source.targetSessionId, + ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), + }; + } + if (proposal.disposition === 'create_new') { if (!input.create || !workHubCreationAuthorizesTitle(requestIntent, proposal.title)) { throw new WorkHubActionGateFailure( @@ -528,6 +565,29 @@ export class WorkHubCoordinationActionGate { ); } + /** + * The delegation a resume names. + * + * Unlike a stop this needs no claim to find its way back: resume changes no + * durable link, so the delegation it names is still in the active set on the + * next attempt exactly as it was on the first. One link on the Session is the + * answer; several is the same ambiguity a stop refuses, and none means there + * is nothing here to carry on. + */ + async #resumeSource(targetSessionId: string): Promise { + const active = await this.#effects.listActiveAssignments(); + const onTarget = active.filter((assignment) => assignment.targetSessionId === targetSessionId); + if (onTarget.length !== 1) { + throw new WorkHubActionGateFailure( + 'action_conflict', + onTarget.length === 0 + ? 'WorkHub has no active durable delegation to resume on that Session' + : 'WorkHub resume target does not identify one active durable delegation', + ); + } + return onTarget[0]!; + } + /** * The delegation a stop names, by the only two keys that can name it. * @@ -1051,6 +1111,17 @@ function workHubCreatedSessionId(actionId: string): string { return `whs_${hash(`create\0${actionId}`).slice(0, 48)}`; } +/** + * The continuation identity a resume would start, derived rather than minted. + * + * Two attempts at the same interrupted run must name the same Turn, or the + * second would ask the Host to start a second continuation instead of finding + * the first already there. + */ +export function workHubResumedTurnId(delegationId: string, sourceRunId: string): string { + return `wht_${hash(`resume\0${delegationId}\0${sourceRunId}`).slice(0, 48)}`; +} + function workspaceProjection(session: WorkHubActionGateSession): WorkspaceProjection { return { target: @@ -1122,6 +1193,23 @@ function replacementActionFingerprint( }); } +function resumeActionFingerprint( + input: WorkHubCoordinationActInput, + source: WorkHubDelegationAssignedMessage, +): `sha256:${string}` { + if (input.proposal.disposition !== 'resume_work') { + throw new WorkHubActionGateFailure('action_conflict', 'Invalid WorkHub resume replay'); + } + return digest({ + userText: input.userText, + disposition: 'resume_work', + resumesActionId: source.actionId, + resumesDelegationId: source.delegationId, + targetSessionId: source.targetSessionId, + targetMessageId: source.targetMessageId, + }); +} + function stopActionFingerprint( input: WorkHubCoordinationActInput, source: WorkHubDelegationAssignedMessage, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 5f14a3ffc7..a2e0c9aa98 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -120,7 +120,7 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly executions: CoordinationExecutions; readonly sessionActions: Pick< WorkHubActionGateEffects, - 'assign' | 'readDelegationRetirement' | 'retireDelegation' + 'assign' | 'readDelegationRetirement' | 'retireDelegation' | 'resumeDelegation' >; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; @@ -203,6 +203,7 @@ export class HostWorkHubCoordinationCoordinator { resolveStop: (input) => this.#resolveStop(input), readDelegationRetirement: options.sessionActions.readDelegationRetirement, retireDelegation: options.sessionActions.retireDelegation, + resumeDelegation: options.sessionActions.resumeDelegation, }); } diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index a9bbe0e089..16c156c7d2 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -7199,7 +7199,8 @@ function isWorkHubActionOperation(value: unknown): value is WorkHubActionOperati value === 'delegate_existing' || value === 'create_new' || value === 'replace' || - value === 'stop' + value === 'stop' || + value === 'resume' ); } From 9de38e139a33edaabe07265782bd8fc04a73e5d7 Mon Sep 17 00:00:00 2001 From: ChengBo Zhang Date: Fri, 4 Sep 2026 10:41:42 +0800 Subject: [PATCH 03/10] feat(workhub): resume delegated work from the coordination conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Host could resume interrupted work, but only its own Session could ask: the user had to leave WorkHub, open the Session and use its banner. This wires the conversation to the disposition the Gate already admits. Stop and resume ask the same question of the same words — which visible Session does this reference name — so they now ask it with one matcher. Two copies would be two chances for `Resume Payments` and `Stop Payments` to disagree about which Session they mean. They diverge on what an unnamed reference means, and that asymmetry is the point. A stop must answer it: stopping is destructive, so `Stop it` has to be met with a question rather than a guess. A resume must not: `继续这个工作` is how someone carries on with the Session they are already in, and answering it would take an ordinary instruction away from ordinary routing. Resuming nothing costs nothing, so an unnamed resume falls through and only a named one becomes an action. The three answers the Host can give are all reported as themselves. `already_running` and `parked` are outcomes, not failures: the first says the work never stopped, and the second is the Host declining, whose reason is its own. Refs #3492 Generated-by: Claude Opus --- .../main/__tests__/workhub-controller.test.ts | 126 ++++++++++++++++++ .../src/renderer/workhub-controller.ts | 60 +++++++++ .../src/renderer/workhub-route-policy.ts | 124 ++++++++++++----- apps/desktop/src/renderer/workhub-surface.tsx | 25 +++- 4 files changed, 299 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 4724a7909d..0432084dbb 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -416,6 +416,132 @@ 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 () => assert.fail('a resume must not read route candidates'), + 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', + targetTurnId: 'resumed-turn', + }); + // 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', expects: { targetSessionId: 'payments' } }, + }]); + 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 () => assert.fail('a resume must not read route candidates'), + 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('resume-shaped ordinary work routes normally instead of resuming', async () => { + // `Continue` is an ordinary English verb. A resume that recalls no WorkHub + // identity is work to do, not a command over a delegation. + 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:${'c'.repeat(64)}`, + candidates: [{ + candidateRef: 'ref-payments', + sessionId: 'payments', + sessionName: 'Payments', + workspace: { + target: { kind: 'host_path' as const, path: '/workspace/payments' }, + hostCwd: '/workspace/payments', + }, + state: 'active' as const, + updatedAt: 1, + }], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'delegate_existing', + targetSessionId: 'payments', + targetTurnId: 'delegated-turn', + }; + }, + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + const result = await controller.submit({ + requestId: 'ordinary-1', + text: 'Continue the refactor in Payments', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(actions[0]?.proposal.disposition, 'delegate_existing'); + 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/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 959381ae30..1a857c0c2d 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -208,6 +208,13 @@ export type WorkHubSubmission = ( outcome: Extract['outcome']; targetTurnId?: string; } + | { + kind: 'resume'; + requestId: string; + target: WorkHubSessionTarget; + outcome: Extract['outcome']; + targetTurnId?: string; + } ) & { strategyId: WorkHubRoutingStrategyId }; /** @@ -451,6 +458,59 @@ 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, + }); + if (resumeDecision.kind !== 'not_requested') { + if (resumeDecision.kind === 'clarification') { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: resumeDecision.reason, + }; + } + const { target } = resumeDecision; + let resumed; + try { + resumed = await coordination.act({ + actionId: input.requestId, + userText: input.text, + // No confirmation: resume ends nothing. Which delegation it carries + // on, and whether the Host can, is decided where the stop is. + proposal: { + disposition: 'resume_work', + expects: { targetSessionId: target.sessionId }, + }, + }); + } catch (error) { + if (error instanceof WorkHubCoordinationFailure && error.code === 'operation_conflict') { + return { + kind: 'clarification', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + options: [], + reason: 'resume_target_unavailable', + }; + } + throw error; + } + if (resumed.disposition !== 'resume_work') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } + return { + kind: 'resume', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + outcome: resumed.outcome, + ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), + }; + } const stopDecision = submissionPolicy.resolveStop({ text: input.text, sessions: ordinary, diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index af2960c94f..f6929338d2 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -79,7 +79,11 @@ 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 Host refused the resume; its conflict is the whole answer. */ + | 'resume_target_unavailable'; /** * A stop clarification never offers route options. Choosing one re-sends the @@ -96,6 +100,15 @@ export interface WorkHubRoutePolicy { text: string; sessions: WorkHubRoutableSession[]; }): WorkHubStopRouteDecision; + /** + * Resume reads the same way a stop does and refuses on the same terms. The + * two share one resolver and one tail rule so `Resume Payments` and + * `Stop Payments` cannot disagree about which Session they mean. + */ + resolveResume(input: { + text: string; + sessions: WorkHubRoutableSession[]; + }): WorkHubStopRouteDecision; resolve(input: { text: string; sessions: WorkHubRoutableSession[]; @@ -132,6 +145,67 @@ const MIN_STRONG_SINGLE_LATIN_LENGTH = 8; const MAX_UNCERTAINTY_OPTIONS = 5; const MAX_RELATED_CLARIFICATION_OPTIONS = 4; +/** + * The reference half of a direct action over one existing delegation. + * + * Action Intent says only that the user issued this imperative and what work it + * refers to; the shared Session Resolver recalls which visible Sessions that + * reference names; this decides whether the resolution is sufficient to submit. + * + * Stop and resume ask exactly this, so they share it. What the Host then does + * with the named Session — end its delegation, or carry it on — is the Host's, + * and neither action claims to know whether there is one to act on. + */ +function resolveNamedDelegationAction( + sessionResolver: WorkHubSessionResolver, + action: { readonly cue: boolean; readonly imperative: boolean; readonly target?: string }, + sessions: WorkHubRoutableSession[], + reasons: { + /** + * What an unnamed reference means. A stop must say so: it is destructive, + * and `Stop it` has to be answered rather than delivered as work. A resume + * must not: `继续这个工作` is how a user carries on with the Session they + * are already in, and answering it would take an ordinary instruction away + * from ordinary routing. Resuming nothing costs nothing, so it falls + * through and only an explicitly named resume becomes an action. + */ + readonly unnamed: WorkHubStopClarificationReason | 'not_requested'; + readonly ambiguous: WorkHubStopClarificationReason; + }, +): WorkHubStopRouteDecision { + if (!action.cue) return { kind: 'not_requested' }; + const reference = action.imperative ? action.target : undefined; + if (!reference) { + return reasons.unnamed === 'not_requested' + ? { kind: 'not_requested' } + : { kind: 'clarification', reason: reasons.unnamed }; + } + 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: reasons.ambiguous }; + } + 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 +237,23 @@ 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) { - return { kind: 'clarification', reason: 'stop_target_required' }; - } - const sessionByRef = new Map( - sessions.map((session) => [session.target.sessionId, session]), + return resolveNamedDelegationAction( + sessionResolver, + readWorkHubRequestIntent(text).stop, + sessions, + { unnamed: 'stop_target_required', ambiguous: '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), + }, + // Resume asks the same question of the same words, so it asks it with the + // same code. Two copies of this would be two chances for `Resume Payments` + // and `Stop Payments` to disagree about which Session they name. + resolveResume({ text, sessions }) { + return resolveNamedDelegationAction( + sessionResolver, + readWorkHubRequestIntent(text).resume, + sessions, + { unnamed: 'not_requested', ambiguous: 'resume_target_ambiguous' }, ); - if (admissible.length === 0) return { kind: 'not_requested' }; - // Stop admits one candidate only. A ranked resolver may return several; - // this action never picks a winner from a ranking it cannot justify. - if (resolution.kind === 'ambiguous' || admissible.length > 1) { - return { kind: 'clarification', reason: 'stop_target_ambiguous' }; - } - const resolved = sessionByRef.get(admissible[0]!.ref); - if (!resolved) return { kind: 'not_requested' }; - // The reference resolved, which is everything this policy can prove. - // Which delegation to end, and whether there is one at all, is the - // Host's answer and is made under the lease that performs the stop. - return { kind: 'target', target: resolved.target }; }, resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { const intent = readWorkHubRequestIntent(text); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 8a7cf93ad2..99526ebef3 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -176,7 +176,8 @@ export async function submitAndRecordWorkHubSurfaceInput(input: { result.kind === 'discussion' || result.kind === 'waiting' || result.kind === 'submitted' || - result.kind === 'stop' + result.kind === 'stop' || + result.kind === 'resume' ) { return result; } @@ -320,7 +321,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 +641,8 @@ 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_ambiguous') return copy.resumeTargetAmbiguous; + if (reason === 'resume_target_unavailable') return copy.resumeTargetUnavailable; return undefined; } @@ -660,6 +664,7 @@ export function workHubCoordinationSummary( return `${copy.waitingForDecision} ${copy.requestNotSent}`; } if (result.kind === 'stop') return copy.stopOutcomes[result.outcome]; + if (result.kind === 'resume') return copy.resumeOutcomes[result.outcome]; const target = projection.sessions.find( (session) => session.target.sessionId === result.target.sessionId, ); @@ -856,6 +861,13 @@ function workHubCopy(locale: UiLocale) { already_terminal: '这项工作已经结束:', not_owned: '未停止共享或用户拥有的 Turn:', }, + resumeOutcomes: { + resume_started: '已让中断的工作继续:', + already_running: '这项工作还在跑,不需要恢复:', + parked: '无法继续这项工作;请打开该 Session 查看原因:', + }, + resumeTargetAmbiguous: '这个名称对应多项工作;请打开具体的 Session 继续它。', + resumeTargetUnavailable: '这项工作现在没有可以继续的单个 WorkHub 委派;请打开该 Session 查看。', waitingForDecision: '这项工作正在等待你的决定。', requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', @@ -980,6 +992,15 @@ function workHubCopy(locale: UiLocale) { already_terminal: 'This work had already ended:', not_owned: 'Did not stop a shared or user-owned Turn:', }, + resumeOutcomes: { + resume_started: 'Carried on the interrupted work:', + already_running: 'This work is still running, so there was nothing to resume:', + parked: 'Could not carry this work on. Open its Session to see why:', + }, + resumeTargetAmbiguous: + 'That name matches more than one work item. Open the exact Session to resume it.', + resumeTargetUnavailable: + 'This work has no single WorkHub delegation to resume right now. Open its Session to see what is running.', waitingForDecision: 'This work is waiting for your decision.', requestNotSent: 'The new request was not sent. Resolve the interaction in its Session, then send again.', routing: 'Choosing the right Session…', loadFailed: 'Could not read existing work.', From 586afbe3647e9698ca327bb7ba6c9d7fa39396d9 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 00:52:09 +0800 Subject: [PATCH 04/10] fix(workhub): make resume durable and lineage-bound --- .../__tests__/workhub-session-port.test.ts | 36 ++++ .../src/renderer/workhub-controller.ts | 5 + .../src/renderer/workhub-coordination-port.ts | 23 +++ apps/desktop/src/renderer/workhub-surface.tsx | 41 +++++ .../workhub-coordination-record.test.ts | 62 +++++++ packages/core/src/session.ts | 149 +++++++++++++++- packages/core/src/workhub-creation-intent.ts | 13 ++ .../workhub-coordination-action-gate.test.ts | 162 +++++++++++++++++- .../workhub-coordination-coordinator.test.ts | 111 ++++++++++++ .../src/server/execution-composition.ts | 93 ++++++++-- .../workhub-coordination-action-gate.ts | 138 ++++++++++++++- .../workhub-coordination-coordinator.ts | 113 +++++++++++- .../sqlite-session-metadata-store.test.ts | 8 + packages/storage/src/execution-stores.ts | 4 + packages/storage/src/session-store.ts | 31 ++++ .../src/sqlite-session-metadata-schema.ts | 40 ++++- 16 files changed, 994 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index ed02dcdf78..ae743fc9ff 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -317,6 +317,42 @@ test('direct-stop projection is retryable until resolved and preserves not_owned ); }); +test('resume projection survives reload and exposes its durable outcome', () => { + const requested: StoredMessage = { + type: 'workhub_coordination', id: 'resume-request', turnId: 'resume-action', ts: 2, + schemaVersion: 4, kind: 'delegation_resume_requested', actionId: 'resume-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'resume-action', + resumesActionId: 'source-action', resumesDelegationId: 'payments-delegation', + targetSessionId: 'payments', targetMessageId: 'payments-message', + targetSessionName: 'Payments', userText: 'Resume Payments', plan: 'ready', + sourceTurnId: 'failed-turn', sourceRunId: 'failed-run', + sourceRuntimeEventHighWater: 4, targetTurnId: 'resumed-turn', + }; + const resolved: StoredMessage = { + type: 'workhub_coordination', id: 'resume-resolution', turnId: 'resume-action', ts: 3, + schemaVersion: 4, kind: 'delegation_resume_resolved', actionId: 'resume-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'resume-action', + resumesActionId: 'source-action', resumesDelegationId: 'payments-delegation', + targetSessionId: 'payments', outcome: 'resume_started', + targetTurnId: 'resumed-turn', targetRunId: 'resumed-run', + }; + + assert.deepEqual(projectWorkHubCoordinationTurns([requested]), [{ + messageId: 'resume-request', turnId: 'resume-action', text: 'Resume Payments', + state: 'running', + resume: { targetSessionId: 'payments', targetSessionName: 'Payments' }, + updatedAt: 2, + }]); + assert.deepEqual(projectWorkHubCoordinationTurns([requested, resolved]), [{ + messageId: 'resume-request', turnId: 'resume-action', text: 'Resume Payments', + state: 'completed', + resume: { + targetSessionId: 'payments', targetSessionName: 'Payments', outcome: 'resume_started', + }, + updatedAt: 3, + }]); +}); + test('durable supersession terminalizes only the replaced linkage', () => { const source: StoredMessage = { type: 'workhub_coordination', diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 1a857c0c2d..7c8de20b85 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -132,6 +132,11 @@ export interface WorkHubCoordinationTurn { readonly targetSessionName: string; readonly outcome?: Extract['outcome']; }; + resume?: { + readonly targetSessionId: string; + readonly targetSessionName: string; + readonly outcome?: Extract['outcome']; + }; updatedAt: number; } diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 954e68286b..79304d493e 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -167,12 +167,35 @@ export function projectWorkHubCoordinationTurns( : [], ), ); + const resumeResolutionByActionId = new Map( + messages.flatMap((message) => + message.type === 'workhub_coordination' && message.kind === 'delegation_resume_resolved' + ? [[message.actionId, message] as const] + : [], + ), + ); for (const message of messages) { const terminal = terminalDelegationLink(message); if (terminal) terminalLinkState.set(terminal.delegationId, terminal.state); } for (const message of messages) { + if (message.type === 'workhub_coordination' && message.kind === 'delegation_resume_requested') { + const resolution = resumeResolutionByActionId.get(message.actionId); + turns.push({ + messageId: message.id, + turnId: message.coordinationTurnId, + text: boundedWorkHubTimelineText(message.userText), + state: resolution ? 'completed' : 'running', + resume: { + targetSessionId: message.targetSessionId, + targetSessionName: message.targetSessionName, + ...(resolution ? { outcome: resolution.outcome } : {}), + }, + updatedAt: resolution ? Math.max(message.ts, resolution.ts) : message.ts, + }); + continue; + } if (message.type === 'workhub_coordination' && message.kind === 'delegation_stop_requested') { const resolution = stopResolutionByDelegationId.get(message.stopsDelegationId); turns.push({ diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 99526ebef3..6ffdcf9c2a 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -575,6 +575,11 @@ export function WorkHubCoordinationTurnView(props: { (candidate) => candidate.target.sessionId === props.turn.stop!.targetSessionId, ) : undefined; + const resumedSession = props.turn.resume + ? props.projection.sessions.find( + (candidate) => candidate.target.sessionId === props.turn.resume!.targetSessionId, + ) + : undefined; return ( + ) : props.turn.resume ? ( + ) : assignment ? ( session.target.sessionId === submitted.target.sessionId) : undefined; @@ -744,6 +763,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={copy.resumeRecorded} + result={undefined} + copy={copy} + onOpenSession={props.onOpenSession} + /> ) : submitted ? ( { }, ); }); + + test('decodes exact resume plan and observed resolution records', () => { + const requested = { + type: 'workhub_coordination', + id: 'resume-request-id', + turnId: 'resume-action', + ts: 6, + schemaVersion: 4, + kind: 'delegation_resume_requested', + actionId: 'resume-action', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'resume-action', + resumesActionId: 'original-action', + resumesDelegationId: 'original-delegation', + targetSessionId: 'payments', + targetMessageId: 'payments-message', + targetSessionName: 'Payments', + userText: 'Resume Payments', + plan: 'ready', + sourceTurnId: 'failed-turn', + sourceRunId: 'failed-run', + sourceRuntimeEventHighWater: 12, + targetTurnId: 'resumed-turn', + } as const; + const resolved = { + type: 'workhub_coordination', + id: 'resume-resolution-id', + turnId: 'resume-action', + ts: 7, + schemaVersion: 4, + kind: 'delegation_resume_resolved', + actionId: 'resume-action', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'resume-action', + resumesActionId: 'original-action', + resumesDelegationId: 'original-delegation', + targetSessionId: 'payments', + outcome: 'resume_started', + targetTurnId: 'resumed-turn', + targetRunId: 'resumed-run', + } as const; + + assert.deepEqual(decodeCanonicalMessage(requested), requested); + assert.deepEqual(decodeCanonicalMessage(resolved), resolved); + for (const invalid of [ + { ...requested, sourceRunId: undefined }, + { ...requested, sourceRuntimeEventHighWater: -1 }, + { ...requested, plan: 'parked' }, + { ...resolved, targetRunId: undefined }, + { ...resolved, outcome: 'parked' }, + { ...resolved, sourceRunId: 'injected' }, + ]) { + assert.throws(() => decodeCanonicalMessage(invalid), /Invalid stored message schema/u); + } + const parked = { + ...resolved, + outcome: 'parked' as const, + targetTurnId: undefined, + targetRunId: undefined, + }; + assert.deepEqual(decodeCanonicalMessage(parked), parked); + }); }); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index a58c5b1d39..26817df577 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -941,6 +941,7 @@ export interface TurnStateMessage { export const WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION = 1 as const; export const WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION = 2 as const; export const WORKHUB_COORDINATION_STOP_SCHEMA_VERSION = 3 as const; +export const WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION = 4 as const; export type WorkHubDelegationDisposition = 'delegate_existing' | 'create_new'; @@ -1077,6 +1078,51 @@ export interface WorkHubDelegationStopResolvedMessage { targetTurnId?: string; } +export type WorkHubDelegationResumeOutcome = 'resume_started' | 'already_running' | 'parked'; + +/** Durable resume plan written before attempting a continuation. */ +export interface WorkHubDelegationResumeRequestedMessage { + type: 'workhub_coordination'; + id: string; + turnId: string; + ts: number; + schemaVersion: typeof WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION; + kind: 'delegation_resume_requested'; + actionId: string; + actionFingerprint: `sha256:${string}`; + coordinationTurnId: string; + resumesActionId: string; + resumesDelegationId: string; + targetSessionId: string; + targetMessageId: string; + targetSessionName: string; + userText: string; + plan: 'ready' | 'already_running' | 'parked'; + sourceTurnId?: string; + sourceRunId?: string; + sourceRuntimeEventHighWater?: number; + targetTurnId?: string; +} + +/** Durable observed result of a resume attempt. */ +export interface WorkHubDelegationResumeResolvedMessage { + type: 'workhub_coordination'; + id: string; + turnId: string; + ts: number; + schemaVersion: typeof WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION; + kind: 'delegation_resume_resolved'; + actionId: string; + actionFingerprint: `sha256:${string}`; + coordinationTurnId: string; + resumesActionId: string; + resumesDelegationId: string; + targetSessionId: string; + outcome: WorkHubDelegationResumeOutcome; + targetTurnId?: string; + targetRunId?: string; +} + /** * The exact durable operation one WorkHub action identity is allowed to own. * @@ -1115,7 +1161,9 @@ export type WorkHubCoordinationMessage = | WorkHubDelegationReplacementAbortedMessage | WorkHubDelegationSupersededMessage | WorkHubDelegationStopRequestedMessage - | WorkHubDelegationStopResolvedMessage; + | WorkHubDelegationStopResolvedMessage + | WorkHubDelegationResumeRequestedMessage + | WorkHubDelegationResumeResolvedMessage; function isWorkHubDelegationStopResolution( outcome: unknown, @@ -1385,6 +1433,47 @@ const WORKHUB_DELEGATION_STOP_RESOLVED_MESSAGE_SHAPE = ], ['targetTurnId'], ); +const WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE = + defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'resumesActionId', + 'resumesDelegationId', + 'targetSessionId', + 'targetMessageId', + 'targetSessionName', + 'userText', + 'plan', + ], + ['sourceTurnId', 'sourceRunId', 'sourceRuntimeEventHighWater', 'targetTurnId'], + ); +const WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE = + defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'resumesActionId', + 'resumesDelegationId', + 'targetSessionId', + 'outcome', + ], + ['targetTurnId', 'targetRunId'], + ); const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( ['title', 'workspace'], [], @@ -1575,6 +1664,64 @@ function decodeMessage( } function isWorkHubCoordinationMessage(message: Record): boolean { + if (message.kind === 'delegation_resume_requested') { + const ready = message.plan === 'ready'; + return ( + hasMessageEnvelope(message, true) && + hasExactShape(message, WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE) && + message.schemaVersion === WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION && + isWorkHubActionIdentity(message) && + typeof message.resumesActionId === 'string' && + message.resumesActionId.length > 0 && + typeof message.resumesDelegationId === 'string' && + message.resumesDelegationId.length > 0 && + typeof message.targetSessionId === 'string' && + message.targetSessionId.length > 0 && + typeof message.targetMessageId === 'string' && + message.targetMessageId.length > 0 && + typeof message.targetSessionName === 'string' && + message.targetSessionName.trim().length > 0 && + typeof message.userText === 'string' && + message.userText.trim().length > 0 && + (ready || message.plan === 'already_running' || message.plan === 'parked') && + (ready + ? typeof message.sourceTurnId === 'string' && + message.sourceTurnId.length > 0 && + typeof message.sourceRunId === 'string' && + message.sourceRunId.length > 0 && + typeof message.sourceRuntimeEventHighWater === 'number' && + Number.isSafeInteger(message.sourceRuntimeEventHighWater) && + message.sourceRuntimeEventHighWater >= 0 && + typeof message.targetTurnId === 'string' && + message.targetTurnId.length > 0 + : message.sourceTurnId === undefined && + message.sourceRunId === undefined && + message.sourceRuntimeEventHighWater === undefined && + message.targetTurnId === undefined) + ); + } + if (message.kind === 'delegation_resume_resolved') { + const started = message.outcome === 'resume_started'; + return ( + hasMessageEnvelope(message, true) && + hasExactShape(message, WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE) && + message.schemaVersion === WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION && + isWorkHubActionIdentity(message) && + typeof message.resumesActionId === 'string' && + message.resumesActionId.length > 0 && + typeof message.resumesDelegationId === 'string' && + message.resumesDelegationId.length > 0 && + typeof message.targetSessionId === 'string' && + message.targetSessionId.length > 0 && + (started || message.outcome === 'already_running' || message.outcome === 'parked') && + (started + ? typeof message.targetTurnId === 'string' && + message.targetTurnId.length > 0 && + typeof message.targetRunId === 'string' && + message.targetRunId.length > 0 + : message.targetTurnId === undefined && message.targetRunId === undefined) + ); + } if (message.kind === 'delegation_stop_requested') { return ( hasMessageEnvelope(message, true) && diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index 427ee79e00..cad6aeb79c 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -473,6 +473,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 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 76efe332df..9fc8636075 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 @@ -25,6 +25,8 @@ import type { WorkHubDelegationAssignedMessage, WorkHubDelegationReplacementAbortedMessage, WorkHubDelegationReplacementRequestedMessage, + WorkHubDelegationResumeRequestedMessage, + WorkHubDelegationResumeResolvedMessage, WorkHubDelegationStopRequestedMessage, WorkHubDelegationStopResolvedMessage, WorkHubDelegationSupersededMessage, @@ -39,10 +41,13 @@ import { type WorkHubDelegationAssignmentInput, type WorkHubDelegationReplacementAbortInput, type WorkHubDelegationReplacementInput, + type WorkHubDelegationResumeInput, + type WorkHubDelegationResumeResolutionInput, type WorkHubDelegationRetirementClaim, type WorkHubDelegationStopInput, type WorkHubDelegationStopResolutionInput, type WorkHubRetirementResult, + type WorkHubResumePlan, } from '../server/workhub-coordination-action-gate.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; @@ -371,11 +376,55 @@ describe('WorkHub Coordination Action Gate', () => { targetTurnId: 'resumed-turn', }); assert.equal(effects.resumeCalls.length, 1); - assert.equal(effects.resumeCalls[0]?.actionId, 'source-action'); + assert.equal(effects.resumeCalls[0]?.resumesActionId, 'source-action'); // Resume claims like every other disposition, so the identity is spent. assert.equal(effects.actionClaims.get('resume-action')?.operation, 'resume'); }); + 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 replays one durable result without planning or starting twice', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + delegatedTo(effects, 'payments'); + const gate = new WorkHubCoordinationActionGate(effects); + const input = { + actionId: 'resume-replay', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }; + + const first = await gate.act(input, CONTEXT); + effects.resumeOutcome = { outcome: 'parked' }; + effects.resumePlan = { kind: 'parked' }; + const replay = await gate.act(input, CONTEXT); + + assert.deepEqual(replay, first); + assert.equal(effects.planResumeCalls.length, 1); + assert.equal(effects.resumeCalls.length, 1); + assert.equal(effects.resumeRequests.size, 1); + assert.equal(effects.resumeResolutions.size, 1); + }); + test('resume needs a named command and carries no destructive confirmation', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); delegatedTo(effects, 'payments'); @@ -2682,6 +2731,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { const supersessions = new Map(); const stopRequests = new Map(); const stopResolutions = new Map(); + const resumeRequests = new Map(); + const resumeResolutions = new Map(); const actionClaims = new Map(); return { sessions: [...initialSessions], @@ -2700,6 +2751,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { supersessions, stopRequests, stopResolutions, + resumeRequests, + resumeResolutions, retirements: [] as WorkHubDelegationAssignedMessage[], retirementClaims: [] as WorkHubDelegationRetirementClaim[], async listSessions() { @@ -2717,15 +2770,104 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { ? 'same_claim' : 'conflict'; }, - resumeCalls: [] as WorkHubDelegationAssignedMessage[], - resumeOutcome: { outcome: 'resume_started' as const, targetTurnId: 'resumed-turn' } as { + resumeCalls: [] as WorkHubDelegationResumeRequestedMessage[], + planResumeCalls: [] as Array<{ + assignment: WorkHubDelegationAssignedMessage; + previous: WorkHubDelegationResumeResolvedMessage | undefined; + }>, + resumePlan: { + kind: 'ready', + sourceTurnId: 'source-turn', + sourceRunId: 'source-run', + sourceRuntimeEventHighWater: 1, + targetTurnId: 'resumed-turn', + } as WorkHubResumePlan, + resumeOutcome: { + outcome: 'resume_started' as const, + targetTurnId: 'resumed-turn', + targetRunId: 'resumed-run', + } as { outcome: 'resume_started' | 'already_running' | 'parked'; targetTurnId?: string; + targetRunId?: string; + }, + async readResumeRequest(actionId: string) { + return resumeRequests.get(actionId); + }, + async readResumeResolution(actionId: string) { + return resumeResolutions.get(actionId); + }, + async listResumeResolutions(delegationId: string) { + return [...resumeResolutions.values()].filter( + (resolution) => resolution.resumesDelegationId === delegationId, + ); + }, + async planResume( + assignment: WorkHubDelegationAssignedMessage, + previous: WorkHubDelegationResumeResolvedMessage | undefined, + ) { + this.planResumeCalls.push({ assignment, previous }); + return this.resumePlan; }, - async resumeDelegation(assignment: WorkHubDelegationAssignedMessage) { - this.resumeCalls.push(assignment); + async prepareResume(input: WorkHubDelegationResumeInput) { + const existing = resumeRequests.get(input.actionId); + if (existing) return existing; + const requested: WorkHubDelegationResumeRequestedMessage = { + type: 'workhub_coordination', + id: `resume-${input.actionId}`, + turnId: input.actionId, + ts: 7, + schemaVersion: 4, + kind: 'delegation_resume_requested', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + resumesActionId: input.resumesActionId, + resumesDelegationId: input.resumesDelegationId, + targetSessionId: input.targetSessionId, + targetMessageId: input.targetMessageId, + targetSessionName: input.targetSessionName, + userText: input.userText, + plan: input.plan.kind, + ...(input.plan.kind === 'ready' + ? { + sourceTurnId: input.plan.sourceTurnId, + sourceRunId: input.plan.sourceRunId, + sourceRuntimeEventHighWater: input.plan.sourceRuntimeEventHighWater, + targetTurnId: input.plan.targetTurnId, + } + : {}), + }; + resumeRequests.set(input.actionId, requested); + return requested; + }, + async resumeDelegation(request: WorkHubDelegationResumeRequestedMessage) { + this.resumeCalls.push(request); return this.resumeOutcome; }, + async resolveResume(input: WorkHubDelegationResumeResolutionInput) { + const existing = resumeResolutions.get(input.request.actionId); + if (existing) return existing; + const resolved: WorkHubDelegationResumeResolvedMessage = { + type: 'workhub_coordination', + id: `resume-resolved-${input.request.actionId}`, + turnId: input.request.actionId, + ts: 8, + schemaVersion: 4, + kind: 'delegation_resume_resolved', + actionId: input.request.actionId, + actionFingerprint: input.request.actionFingerprint, + coordinationTurnId: input.request.coordinationTurnId, + resumesActionId: input.request.resumesActionId, + resumesDelegationId: input.request.resumesDelegationId, + targetSessionId: input.request.targetSessionId, + outcome: input.outcome, + ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}), + ...(input.targetRunId ? { targetRunId: input.targetRunId } : {}), + }; + resumeResolutions.set(input.request.actionId, resolved); + return resolved; + }, async readActionClaim(actionId: string) { return actionClaims.get(actionId); }, @@ -2920,11 +3062,19 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { supersessions: Map; stopRequests: Map; stopResolutions: Map; + resumeRequests: Map; + resumeResolutions: Map; retirements: WorkHubDelegationAssignedMessage[]; - resumeCalls: WorkHubDelegationAssignedMessage[]; + resumeCalls: WorkHubDelegationResumeRequestedMessage[]; + planResumeCalls: Array<{ + assignment: WorkHubDelegationAssignedMessage; + previous: WorkHubDelegationResumeResolvedMessage | undefined; + }>; + resumePlan: WorkHubResumePlan; resumeOutcome: { outcome: 'resume_started' | 'already_running' | 'parked'; targetTurnId?: string; + targetRunId?: 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 379e8fc362..dd159af3f8 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -1075,6 +1075,109 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('persists a resume plan and result before replaying after Host restart', 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, + 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'), + planResume: async () => ({ + kind: 'ready', + sourceTurnId: 'payments-turn', + sourceRunId: 'payments-run', + sourceRuntimeEventHighWater: 9, + targetTurnId: 'resumed-turn', + }), + resumeDelegation: async () => { + resumeCalls += 1; + return { + outcome: 'resume_started', + targetTurnId: 'resumed-turn', + targetRunId: 'resumed-run', + }; + }, + }); + 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 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.readWorkHubResumeRequest('resume-action'))?.sourceRunId, + 'payments-run', + ); + assert.equal( + (await store.readWorkHubResumeResolution('resume-action'))?.targetRunId, + 'resumed-run', + ); + } finally { + await store.close?.(); + } + + store = createSessionStore(root); + try { + const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + planResume: async () => assert.fail('durable resume replay must not replan'), + resumeDelegation: async () => assert.fail('durable resume replay must not restart'), + }); + const replay = await restarted.handlers['workhub.coordination.act'](resumeInput(), CONTEXT); + assert.equal(replay.ok, true); + if (replay.ok && replay.result.disposition === 'resume_work') { + assert.equal(replay.result.outcome, 'resume_started'); + assert.equal(replay.result.targetTurnId, 'resumed-turn'); + } + } 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,9 +1972,17 @@ function coordinator( executions, sessionActions: { readDelegationRetirement: async () => 'not_retired', + planResume: async () => ({ + kind: 'ready' as const, + sourceTurnId: 'source-turn', + sourceRunId: 'source-run', + sourceRuntimeEventHighWater: 1, + targetTurnId: 'resumed-turn', + }), resumeDelegation: async () => ({ outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', + targetRunId: 'resumed-run', }), retireDelegation: async () => ({ outcome: 'cancelled_pending' }), ...sessionActions, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index c7e998672a..e6976b7e74 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1389,30 +1389,84 @@ export async function createExecutionRuntimeHostComposition( const snapshot = await coordinator.read(identity); return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; }, - // Resume is the same two steps the Desktop banner takes: ask the Host - // whether this Session has a continuation to make, then make it. The - // Host owns both answers, so a repeat parks rather than forking, and - // WorkHub records only which of the three things happened. - resumeDelegation: async (assignment, context) => { + // Resolve 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. + planResume: async (assignment, previous, context) => { + let source: + | { readonly sessionId: string; readonly turnId: string; readonly runId: string } + | undefined; + if ( + previous?.outcome === 'resume_started' && + previous.targetTurnId && + previous.targetRunId + ) { + source = { + sessionId: assignment.targetSessionId, + turnId: previous.targetTurnId, + runId: previous.targetRunId, + }; + } else { + const disposition = await messages.readMessageExecutionDisposition( + assignment.targetSessionId, + assignment.targetMessageId, + ); + if (disposition.kind !== 'owned_root') return { kind: 'parked' as const }; + source = { + sessionId: assignment.targetSessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }; + } + let snapshot; + try { + snapshot = await coordinator.read(source); + } catch { + return { kind: 'parked' as const }; + } + if ( + snapshot.status === 'admitted' || + snapshot.status === 'created' || + snapshot.status === 'running' + ) { + return { kind: 'already_running' as const }; + } + if (snapshot.status !== 'failed' && snapshot.status !== 'cancelled') { + return { kind: 'parked' as const }; + } const plan = await coordinator.handlers['turn.resume.query']( - { sessionId: assignment.targetSessionId }, + { sessionId: assignment.targetSessionId, sourceRunId: source.runId }, context, ); - if (!plan.ok) return { outcome: 'parked' as const }; - if (plan.result.disposition === 'parked') { - return { - outcome: - plan.result.reason === 'resume_candidate_missing' - ? ('already_running' as const) - : ('parked' as const), - }; - } + if ( + !plan.ok || + plan.result.disposition === 'parked' || + plan.result.sourceRunId !== source.runId || + plan.result.sourceTurnId !== source.turnId + ) + return { kind: 'parked' as const }; + return { + kind: 'ready' as const, + sourceTurnId: plan.result.sourceTurnId, + sourceRunId: plan.result.sourceRunId, + sourceRuntimeEventHighWater: plan.result.sourceRuntimeEventHighWater, + targetTurnId: workHubResumedTurnId(assignment.delegationId, plan.result.sourceRunId), + }; + }, + resumeDelegation: async (request, context) => { + if ( + request.plan !== 'ready' || + !request.sourceRunId || + request.sourceRuntimeEventHighWater === undefined || + !request.targetTurnId + ) + return { outcome: 'parked' as const }; const started = await coordinator.handlers['turn.resume.start']( { - sessionId: assignment.targetSessionId, - turnId: workHubResumedTurnId(assignment.delegationId, plan.result.sourceRunId), - sourceRunId: plan.result.sourceRunId, - sourceRuntimeEventHighWater: plan.result.sourceRuntimeEventHighWater, + sessionId: request.targetSessionId, + turnId: request.targetTurnId, + sourceRunId: request.sourceRunId, + sourceRuntimeEventHighWater: request.sourceRuntimeEventHighWater, }, context, ); @@ -1422,6 +1476,7 @@ export async function createExecutionRuntimeHostComposition( return { outcome: 'resume_started' as const, targetTurnId: started.result.turn.turnId, + targetRunId: started.result.turn.runId, }; }, retireDelegation: async (assignment, retirement) => { 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 19b7a99e6d..cdda3b53f0 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -32,6 +32,8 @@ import type { WorkHubDelegationStopRequestedMessage, WorkHubDelegationStopResolvedMessage, WorkHubDelegationStopOutcome, + WorkHubDelegationResumeRequestedMessage, + WorkHubDelegationResumeResolvedMessage, WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { @@ -42,6 +44,7 @@ import { readWorkHubRequestIntent, workHubCorrectionTargetsSession, workHubCreationAuthorizesTitle, + workHubNamedDelegationActionTargetsSession, } from '@maka/core/workhub-creation-intent'; import type { WorkHubCoordinationActInput, @@ -114,6 +117,13 @@ export interface WorkHubActionGateEffects { readStopResolution( delegationId: string, ): Promise; + readResumeRequest(actionId: string): Promise; + readResumeResolution( + actionId: string, + ): Promise; + listResumeResolutions( + delegationId: string, + ): Promise; answer( input: { readonly turnId: string; readonly text: string }, context: ConnectionContext, @@ -150,10 +160,21 @@ export interface WorkHubActionGateEffects { * unfinished. The Host owns whether that is possible; a repeat is safe * because a continuation that already exists parks rather than forks. */ - resumeDelegation( + planResume( assignment: WorkHubDelegationAssignedMessage, + previous: WorkHubDelegationResumeResolvedMessage | undefined, + context: ConnectionContext, + ): Promise; + prepareResume( + input: WorkHubDelegationResumeInput, + ): Promise; + resumeDelegation( + request: WorkHubDelegationResumeRequestedMessage, context: ConnectionContext, ): Promise; + resolveResume( + input: WorkHubDelegationResumeResolutionInput, + ): Promise; } /** @@ -171,6 +192,36 @@ export interface WorkHubDelegationRetirementClaim { export interface WorkHubResumeResult { readonly outcome: 'resume_started' | 'already_running' | 'parked'; readonly targetTurnId?: string; + readonly targetRunId?: string; +} + +export type WorkHubResumePlan = + | { readonly kind: 'already_running' | 'parked' } + | { + readonly kind: 'ready'; + readonly sourceTurnId: string; + readonly sourceRunId: string; + readonly sourceRuntimeEventHighWater: number; + readonly targetTurnId: string; + }; + +export interface WorkHubDelegationResumeInput { + readonly actionId: string; + readonly actionFingerprint: `sha256:${string}`; + readonly resumesActionId: string; + readonly resumesDelegationId: string; + readonly targetSessionId: string; + readonly targetMessageId: string; + readonly targetSessionName: string; + readonly userText: string; + readonly plan: WorkHubResumePlan; +} + +export interface WorkHubDelegationResumeResolutionInput { + readonly request: WorkHubDelegationResumeRequestedMessage; + readonly outcome: 'resume_started' | 'already_running' | 'parked'; + readonly targetTurnId?: string; + readonly targetRunId?: string; } export interface WorkHubRetirementResult { @@ -451,16 +502,59 @@ export class WorkHubCoordinationActionGate { 'WorkHub resume requires an explicit named command in trusted user text', ); } + const replay = await this.#effects.readResumeRequest(input.actionId); + if (replay) { + if ( + replay.userText !== input.userText || + replay.targetSessionId !== proposal.expects.targetSessionId || + !workHubNamedDelegationActionTargetsSession( + requestIntent.resume, + replay.targetSessionName, + ) + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume identity belongs to a different request', + ); + } + await this.#claimAction( + input.actionId, + 'resume', + replay.actionFingerprint, + replay.resumesDelegationId, + ); + return this.#resume(replay, context); + } const source = await this.#resumeSource(proposal.expects.targetSessionId); + const sessions = await this.#effects.listSessions(); + const currentTargetName = sessions.find(({ id }) => id === source.targetSessionId)?.name; + if ( + !currentTargetName || + !workHubNamedDelegationActionTargetsSession(requestIntent.resume, currentTargetName) + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume target is not affirmed in trusted user text', + ); + } const resumeFingerprint = resumeActionFingerprint(input, source); await this.#claimAction(input.actionId, 'resume', resumeFingerprint, source.delegationId); - const resumed = await this.#effects.resumeDelegation(source, context); - return { - disposition: 'resume_work', - outcome: resumed.outcome, + const previous = (await this.#effects.listResumeResolutions(source.delegationId)) + .filter(({ outcome }) => outcome === 'resume_started') + .at(-1); + const plan = await this.#effects.planResume(source, previous, context); + const request = await this.#effects.prepareResume({ + actionId: input.actionId, + actionFingerprint: resumeFingerprint, + resumesActionId: source.actionId, + resumesDelegationId: source.delegationId, targetSessionId: source.targetSessionId, - ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), - }; + targetMessageId: source.targetMessageId, + targetSessionName: currentTargetName, + userText: input.userText, + plan, + }); + return this.#resume(request, context); } if (proposal.disposition === 'create_new') { @@ -588,6 +682,25 @@ export class WorkHubCoordinationActionGate { return onTarget[0]!; } + async #resume( + request: WorkHubDelegationResumeRequestedMessage, + context: ConnectionContext, + ): Promise> { + const existing = await this.#effects.readResumeResolution(request.actionId); + if (existing) return resumeResult(existing); + const resumed: WorkHubResumeResult = + request.plan === 'ready' + ? await this.#effects.resumeDelegation(request, context) + : { outcome: request.plan }; + const resolution = await this.#effects.resolveResume({ + request, + outcome: resumed.outcome, + ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), + ...(resumed.targetRunId ? { targetRunId: resumed.targetRunId } : {}), + }); + return resumeResult(resolution); + } + /** * The delegation a stop names, by the only two keys that can name it. * @@ -1259,6 +1372,17 @@ function stopResult( }; } +function resumeResult( + resolution: WorkHubDelegationResumeResolvedMessage, +): Extract { + return { + disposition: 'resume_work', + outcome: resolution.outcome, + targetSessionId: resolution.targetSessionId, + ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), + }; +} + function stopResultFromRecord( resolution: WorkHubDelegationStopResolvedMessage, request: WorkHubDelegationStopRequestedMessage, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index a2e0c9aa98..326a6a2373 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -28,6 +28,7 @@ import { WORKHUB_COORDINATION_SESSION_ROLE, WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, WORKHUB_COORDINATION_STOP_SCHEMA_VERSION, + WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, isWorkHubCoordinationSession, isWorkHubCoordinationSessionId, type SessionHeader, @@ -37,6 +38,8 @@ import { type WorkHubDelegationReplacementRequestedMessage, type WorkHubDelegationStopRequestedMessage, type WorkHubDelegationStopResolvedMessage, + type WorkHubDelegationResumeRequestedMessage, + type WorkHubDelegationResumeResolvedMessage, } from '@maka/core/session'; import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; import type { @@ -100,6 +103,8 @@ type CoordinationStores = Pick< | 'readWorkHubSupersession' | 'readWorkHubStopRequest' | 'readWorkHubStopResolution' + | 'readWorkHubResumeRequest' + | 'readWorkHubResumeResolution' | 'readTranscriptHighWaterSnapshot' | 'readTranscriptMessagesSnapshot' | 'updateHeaderVersioned' @@ -120,7 +125,11 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly executions: CoordinationExecutions; readonly sessionActions: Pick< WorkHubActionGateEffects, - 'assign' | 'readDelegationRetirement' | 'retireDelegation' | 'resumeDelegation' + | 'assign' + | 'readDelegationRetirement' + | 'retireDelegation' + | 'planResume' + | 'resumeDelegation' >; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; @@ -180,6 +189,9 @@ export class HostWorkHubCoordinationCoordinator { readSupersession: (delegationId) => this.#stores.readWorkHubSupersession(delegationId), readStopRequest: (delegationId) => this.#stores.readWorkHubStopRequest(delegationId), readStopResolution: (delegationId) => this.#stores.readWorkHubStopResolution(delegationId), + readResumeRequest: (actionId) => this.#stores.readWorkHubResumeRequest(actionId), + readResumeResolution: (actionId) => this.#stores.readWorkHubResumeResolution(actionId), + listResumeResolutions: (delegationId) => this.#listResumeResolutions(delegationId), answer: async (input, context) => { const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); if (!outcome.ok) { @@ -201,8 +213,11 @@ export class HostWorkHubCoordinationCoordinator { abortReplacement: (input) => this.#abortReplacement(input), prepareStop: (input) => this.#prepareStop(input), resolveStop: (input) => this.#resolveStop(input), + prepareResume: (input) => this.#prepareResume(input), + resolveResume: (input) => this.#resolveResume(input), readDelegationRetirement: options.sessionActions.readDelegationRetirement, retireDelegation: options.sessionActions.retireDelegation, + planResume: options.sessionActions.planResume, resumeDelegation: options.sessionActions.resumeDelegation, }); } @@ -385,6 +400,102 @@ export class HostWorkHubCoordinationCoordinator { }); } + #prepareResume( + input: Parameters[0], + ): Promise { + const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); + return this.#commitCoordinationFact({ + admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.targetSessionId], + read: () => this.#stores.readWorkHubResumeRequest(input.actionId), + build: (existing) => ({ + type: 'workhub_coordination', + id: `whr_${suffix}`, + turnId: input.actionId, + ts: existing?.ts ?? Date.now(), + schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, + kind: 'delegation_resume_requested', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + resumesActionId: input.resumesActionId, + resumesDelegationId: input.resumesDelegationId, + targetSessionId: input.targetSessionId, + targetMessageId: input.targetMessageId, + targetSessionName: input.targetSessionName, + userText: input.userText, + plan: input.plan.kind, + ...(input.plan.kind === 'ready' + ? { + sourceTurnId: input.plan.sourceTurnId, + sourceRunId: input.plan.sourceRunId, + sourceRuntimeEventHighWater: input.plan.sourceRuntimeEventHighWater, + targetTurnId: input.plan.targetTurnId, + } + : {}), + }), + conflictMessage: 'WorkHub resume identity belongs to a different plan', + beforeAppend: async () => { + const source = (await this.#listActiveAssignments()).find( + ({ actionId, delegationId }) => + actionId === input.resumesActionId && delegationId === input.resumesDelegationId, + ); + if (!source || source.targetSessionId !== input.targetSessionId) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume source is no longer active', + ); + } + }, + unknownOutcomeMessage: 'WorkHub resume plan outcome is unknown', + }); + } + + #resolveResume( + input: Parameters[0], + ): Promise { + const request = input.request; + const suffix = createHash('sha256').update(request.actionId, 'utf8').digest('hex').slice(0, 48); + return this.#commitCoordinationFact({ + read: () => this.#stores.readWorkHubResumeResolution(request.actionId), + build: (existing) => ({ + type: 'workhub_coordination', + id: `whn_${suffix}`, + turnId: request.actionId, + ts: existing?.ts ?? Date.now(), + schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, + kind: 'delegation_resume_resolved', + actionId: request.actionId, + actionFingerprint: request.actionFingerprint, + coordinationTurnId: request.coordinationTurnId, + resumesActionId: request.resumesActionId, + resumesDelegationId: request.resumesDelegationId, + targetSessionId: request.targetSessionId, + outcome: input.outcome, + ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}), + ...(input.targetRunId ? { targetRunId: input.targetRunId } : {}), + }), + conflictMessage: 'WorkHub resume already has a different resolution', + beforeAppend: async () => { + const durable = await this.#stores.readWorkHubResumeRequest(request.actionId); + if (!durable || !isDeepStrictEqual(durable, request)) { + throw new WorkHubActionGateFailure('action_conflict', 'WorkHub resume plan changed'); + } + }, + unknownOutcomeMessage: 'WorkHub resume resolution outcome is unknown', + }); + } + + async #listResumeResolutions( + delegationId: string, + ): Promise { + return (await this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).filter( + (message): message is WorkHubDelegationResumeResolvedMessage => + message.type === 'workhub_coordination' && + message.kind === 'delegation_resume_resolved' && + message.resumesDelegationId === delegationId, + ); + } + #abortReplacement( input: Parameters[0], ): Promise { diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 906c5d6ace..4a809c22d2 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -1513,10 +1513,17 @@ describe('SqliteSessionMetadataStore', () => { actionFingerprint: `sha256:${'a'.repeat(64)}` as const, subject: 'whd_payments', }; + const resumeClaim = { + actionId: 'resume-action', + operation: 'resume' as const, + actionFingerprint: `sha256:${'b'.repeat(64)}` as const, + subject: 'whd_payments', + }; let store = createSqliteSessionMetadataStore(path); try { assert.equal(await store.claimWorkHubAction(stopClaim), 'claimed'); assert.equal(await store.claimWorkHubAction(stopClaim), 'same_claim'); + assert.equal(await store.claimWorkHubAction(resumeClaim), 'claimed'); } finally { store.close(); } @@ -1524,6 +1531,7 @@ describe('SqliteSessionMetadataStore', () => { store = createSqliteSessionMetadataStore(path); try { assert.deepEqual(await store.readWorkHubActionClaim('stop-action'), stopClaim); + assert.deepEqual(await store.readWorkHubActionClaim('resume-action'), resumeClaim); assert.equal(await store.claimWorkHubAction(stopClaim), 'same_claim'); // A second delegation, a second disposition, and a changed payload are // each a different operation for the same identity. diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 27c8e3541f..16542d87ad 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -387,6 +387,10 @@ async function createExecutionStoresForWrite sessionStore.readWorkHubStopRequest(delegationId)), readWorkHubStopResolution: (delegationId) => run(() => sessionStore.readWorkHubStopResolution(delegationId)), + readWorkHubResumeRequest: (actionId) => + run(() => sessionStore.readWorkHubResumeRequest(actionId)), + readWorkHubResumeResolution: (actionId) => + run(() => sessionStore.readWorkHubResumeResolution(actionId)), claimWorkHubAction: (claim) => run(() => sessionStore.claimWorkHubAction(claim)), readWorkHubActionClaim: (actionId) => run(() => sessionStore.readWorkHubActionClaim(actionId)), diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index eb05f3fb1f..822ef99d7b 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -90,6 +90,8 @@ import { type WorkHubActionClaimOutcome, type WorkHubDelegationStopRequestedMessage, type WorkHubDelegationStopResolvedMessage, + type WorkHubDelegationResumeRequestedMessage, + type WorkHubDelegationResumeResolvedMessage, type WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import type { @@ -445,6 +447,12 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto readWorkHubStopResolution( delegationId: string, ): Promise; + readWorkHubResumeRequest( + actionId: string, + ): Promise; + readWorkHubResumeResolution( + actionId: string, + ): Promise; /** * Durably binds one action identity to one exact WorkHub operation before its * effect. Survives removal of the target Session so a committed destructive @@ -772,6 +780,29 @@ class SqliteSessionStore implements SessionAuthorityStore { : undefined; } + async readWorkHubResumeRequest( + actionId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whr_${workHubIdentitySuffix(actionId)}`, + ); + return message?.type === 'workhub_coordination' && + message.kind === 'delegation_resume_requested' + ? message + : undefined; + } + + async readWorkHubResumeResolution( + actionId: string, + ): Promise { + const message = await this.readWorkHubCoordinationMessage( + `whn_${workHubIdentitySuffix(actionId)}`, + ); + return message?.type === 'workhub_coordination' && message.kind === 'delegation_resume_resolved' + ? message + : undefined; + } + async claimWorkHubAction(claim: WorkHubActionClaim): Promise { await this.ensureReady(); return this.metadata.claimWorkHubAction(claim); diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 525934c7de..af375aaa44 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 38; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 39; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -1268,6 +1268,44 @@ const MIGRATIONS: ReadonlyMap = new Map([ ); `, ], + [ + 39, + ` + -- SQLite cannot widen a CHECK constraint in place. Rebuild the action + -- claim table so resume receives the same durable one-action/one-operation + -- ownership as every other WorkHub action. + ALTER TABLE workhub_action_claims RENAME TO workhub_action_claims_v38; + + CREATE TABLE workhub_action_claims ( + action_id TEXT PRIMARY KEY, + operation TEXT NOT NULL CHECK ( + operation IN ( + 'answer_here', 'clarify', 'delegate_existing', 'create_new', 'replace', 'stop', 'resume' + ) + ), + action_fingerprint TEXT NOT NULL, + subject TEXT NOT NULL, + claimed_at INTEGER NOT NULL CHECK (claimed_at >= 0) + ); + + INSERT INTO workhub_action_claims( + action_id, + operation, + action_fingerprint, + subject, + claimed_at + ) + SELECT + action_id, + operation, + action_fingerprint, + subject, + claimed_at + FROM workhub_action_claims_v38; + + DROP TABLE workhub_action_claims_v38; + `, + ], ]); if (MIGRATIONS.size !== SQLITE_SESSION_METADATA_SCHEMA_VERSION) { From 249b91cda0e3d83d18841447edbf1a32aecc47b6 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 10:53:29 +0800 Subject: [PATCH 05/10] refactor(workhub): deepen resume coordination boundary --- .../workhub-coordination-action-gate.test.ts | 145 ++++++------ .../workhub-coordination-action-gate.ts | 119 ++-------- .../workhub-coordination-coordinator.ts | 206 +++++++++++------- 3 files changed, 204 insertions(+), 266 deletions(-) diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 9fc8636075..37c7686267 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 @@ -42,12 +42,10 @@ import { type WorkHubDelegationReplacementAbortInput, type WorkHubDelegationReplacementInput, type WorkHubDelegationResumeInput, - type WorkHubDelegationResumeResolutionInput, type WorkHubDelegationRetirementClaim, type WorkHubDelegationStopInput, type WorkHubDelegationStopResolutionInput, type WorkHubRetirementResult, - type WorkHubResumePlan, } from '../server/workhub-coordination-action-gate.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; @@ -376,7 +374,9 @@ describe('WorkHub Coordination Action Gate', () => { targetTurnId: 'resumed-turn', }); assert.equal(effects.resumeCalls.length, 1); - assert.equal(effects.resumeCalls[0]?.resumesActionId, 'source-action'); + const resumeCall = effects.resumeCalls[0]; + assert.ok(resumeCall && !('request' in resumeCall)); + assert.equal(resumeCall.source.actionId, 'source-action'); // Resume claims like every other disposition, so the identity is spent. assert.equal(effects.actionClaims.get('resume-action')?.operation, 'resume'); }); @@ -403,7 +403,7 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.resumeCalls.length, 0); }); - test('resume replays one durable result without planning or starting twice', async () => { + test('resume replays the durable request through the same deep effect', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); delegatedTo(effects, 'payments'); const gate = new WorkHubCoordinationActionGate(effects); @@ -415,12 +415,11 @@ describe('WorkHub Coordination Action Gate', () => { const first = await gate.act(input, CONTEXT); effects.resumeOutcome = { outcome: 'parked' }; - effects.resumePlan = { kind: 'parked' }; - const replay = await gate.act(input, CONTEXT); + const replay = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); assert.deepEqual(replay, first); - assert.equal(effects.planResumeCalls.length, 1); - assert.equal(effects.resumeCalls.length, 1); + assert.equal(effects.resumeCalls.length, 2); + assert.ok('request' in effects.resumeCalls[1]!); assert.equal(effects.resumeRequests.size, 1); assert.equal(effects.resumeResolutions.size, 1); }); @@ -2770,18 +2769,7 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { ? 'same_claim' : 'conflict'; }, - resumeCalls: [] as WorkHubDelegationResumeRequestedMessage[], - planResumeCalls: [] as Array<{ - assignment: WorkHubDelegationAssignedMessage; - previous: WorkHubDelegationResumeResolvedMessage | undefined; - }>, - resumePlan: { - kind: 'ready', - sourceTurnId: 'source-turn', - sourceRunId: 'source-run', - sourceRuntimeEventHighWater: 1, - targetTurnId: 'resumed-turn', - } as WorkHubResumePlan, + resumeCalls: [] as WorkHubDelegationResumeInput[], resumeOutcome: { outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', @@ -2794,79 +2782,73 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { async readResumeRequest(actionId: string) { return resumeRequests.get(actionId); }, - async readResumeResolution(actionId: string) { - return resumeResolutions.get(actionId); - }, - async listResumeResolutions(delegationId: string) { - return [...resumeResolutions.values()].filter( - (resolution) => resolution.resumesDelegationId === delegationId, - ); - }, - async planResume( - assignment: WorkHubDelegationAssignedMessage, - previous: WorkHubDelegationResumeResolvedMessage | undefined, - ) { - this.planResumeCalls.push({ assignment, previous }); - return this.resumePlan; - }, - async prepareResume(input: WorkHubDelegationResumeInput) { - const existing = resumeRequests.get(input.actionId); - if (existing) return existing; + async resume(input: WorkHubDelegationResumeInput) { + this.resumeCalls.push(input); + const actionId = 'request' in input ? input.request.actionId : input.actionId; + const existingResolution = resumeResolutions.get(actionId); + if (existingResolution) { + return { + disposition: 'resume_work' as const, + outcome: existingResolution.outcome, + targetSessionId: existingResolution.targetSessionId, + ...(existingResolution.targetTurnId + ? { targetTurnId: existingResolution.targetTurnId } + : {}), + }; + } + if ('request' in input) { + throw new Error('missing durable fake resume resolution'); + } + const source = input.source; const requested: WorkHubDelegationResumeRequestedMessage = { type: 'workhub_coordination', - id: `resume-${input.actionId}`, - turnId: input.actionId, + id: `resume-${actionId}`, + turnId: actionId, ts: 7, schemaVersion: 4, kind: 'delegation_resume_requested', - actionId: input.actionId, + actionId, actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, - resumesActionId: input.resumesActionId, - resumesDelegationId: input.resumesDelegationId, - targetSessionId: input.targetSessionId, - targetMessageId: input.targetMessageId, + coordinationTurnId: actionId, + resumesActionId: source.actionId, + resumesDelegationId: source.delegationId, + targetSessionId: source.targetSessionId, + targetMessageId: source.targetMessageId, targetSessionName: input.targetSessionName, userText: input.userText, - plan: input.plan.kind, - ...(input.plan.kind === 'ready' - ? { - sourceTurnId: input.plan.sourceTurnId, - sourceRunId: input.plan.sourceRunId, - sourceRuntimeEventHighWater: input.plan.sourceRuntimeEventHighWater, - targetTurnId: input.plan.targetTurnId, - } - : {}), + plan: 'ready', + sourceTurnId: 'source-turn', + sourceRunId: 'source-run', + sourceRuntimeEventHighWater: 1, + targetTurnId: 'resumed-turn', }; - resumeRequests.set(input.actionId, requested); - return requested; - }, - async resumeDelegation(request: WorkHubDelegationResumeRequestedMessage) { - this.resumeCalls.push(request); - return this.resumeOutcome; - }, - async resolveResume(input: WorkHubDelegationResumeResolutionInput) { - const existing = resumeResolutions.get(input.request.actionId); - if (existing) return existing; + resumeRequests.set(actionId, requested); const resolved: WorkHubDelegationResumeResolvedMessage = { type: 'workhub_coordination', - id: `resume-resolved-${input.request.actionId}`, - turnId: input.request.actionId, + id: `resume-resolved-${actionId}`, + turnId: actionId, ts: 8, schemaVersion: 4, kind: 'delegation_resume_resolved', - actionId: input.request.actionId, - actionFingerprint: input.request.actionFingerprint, - coordinationTurnId: input.request.coordinationTurnId, - resumesActionId: input.request.resumesActionId, - resumesDelegationId: input.request.resumesDelegationId, - targetSessionId: input.request.targetSessionId, - outcome: input.outcome, - ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}), - ...(input.targetRunId ? { targetRunId: input.targetRunId } : {}), + actionId, + actionFingerprint: requested.actionFingerprint, + coordinationTurnId: requested.coordinationTurnId, + resumesActionId: requested.resumesActionId, + resumesDelegationId: requested.resumesDelegationId, + targetSessionId: requested.targetSessionId, + outcome: this.resumeOutcome.outcome, + ...(this.resumeOutcome.targetTurnId + ? { targetTurnId: this.resumeOutcome.targetTurnId } + : {}), + ...(this.resumeOutcome.targetRunId ? { targetRunId: this.resumeOutcome.targetRunId } : {}), + }; + resumeResolutions.set(actionId, resolved); + return { + disposition: 'resume_work' as const, + outcome: resolved.outcome, + targetSessionId: resolved.targetSessionId, + ...(resolved.targetTurnId ? { targetTurnId: resolved.targetTurnId } : {}), }; - resumeResolutions.set(input.request.actionId, resolved); - return resolved; }, async readActionClaim(actionId: string) { return actionClaims.get(actionId); @@ -3065,12 +3047,7 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { resumeRequests: Map; resumeResolutions: Map; retirements: WorkHubDelegationAssignedMessage[]; - resumeCalls: WorkHubDelegationResumeRequestedMessage[]; - planResumeCalls: Array<{ - assignment: WorkHubDelegationAssignedMessage; - previous: WorkHubDelegationResumeResolvedMessage | undefined; - }>; - resumePlan: WorkHubResumePlan; + resumeCalls: WorkHubDelegationResumeInput[]; resumeOutcome: { outcome: 'resume_started' | 'already_running' | 'parked'; targetTurnId?: string; 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 cdda3b53f0..277616d8d9 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -33,7 +33,6 @@ import type { WorkHubDelegationStopResolvedMessage, WorkHubDelegationStopOutcome, WorkHubDelegationResumeRequestedMessage, - WorkHubDelegationResumeResolvedMessage, WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { @@ -118,12 +117,6 @@ export interface WorkHubActionGateEffects { delegationId: string, ): Promise; readResumeRequest(actionId: string): Promise; - readResumeResolution( - actionId: string, - ): Promise; - listResumeResolutions( - delegationId: string, - ): Promise; answer( input: { readonly turnId: string; readonly text: string }, context: ConnectionContext, @@ -160,21 +153,10 @@ export interface WorkHubActionGateEffects { * unfinished. The Host owns whether that is possible; a repeat is safe * because a continuation that already exists parks rather than forks. */ - planResume( - assignment: WorkHubDelegationAssignedMessage, - previous: WorkHubDelegationResumeResolvedMessage | undefined, - context: ConnectionContext, - ): Promise; - prepareResume( + resume( input: WorkHubDelegationResumeInput, - ): Promise; - resumeDelegation( - request: WorkHubDelegationResumeRequestedMessage, context: ConnectionContext, - ): Promise; - resolveResume( - input: WorkHubDelegationResumeResolutionInput, - ): Promise; + ): Promise>; } /** @@ -189,41 +171,16 @@ export interface WorkHubDelegationRetirementClaim { readonly cause: 'direct_stop' | 'replacement'; } -export interface WorkHubResumeResult { - readonly outcome: 'resume_started' | 'already_running' | 'parked'; - readonly targetTurnId?: string; - readonly targetRunId?: string; -} - -export type WorkHubResumePlan = - | { readonly kind: 'already_running' | 'parked' } +export type WorkHubDelegationResumeInput = + | { readonly request: WorkHubDelegationResumeRequestedMessage } | { - readonly kind: 'ready'; - readonly sourceTurnId: string; - readonly sourceRunId: string; - readonly sourceRuntimeEventHighWater: number; - readonly targetTurnId: string; + readonly actionId: string; + readonly actionFingerprint: `sha256:${string}`; + readonly source: WorkHubDelegationAssignedMessage; + readonly targetSessionName: string; + readonly userText: string; }; -export interface WorkHubDelegationResumeInput { - readonly actionId: string; - readonly actionFingerprint: `sha256:${string}`; - readonly resumesActionId: string; - readonly resumesDelegationId: string; - readonly targetSessionId: string; - readonly targetMessageId: string; - readonly targetSessionName: string; - readonly userText: string; - readonly plan: WorkHubResumePlan; -} - -export interface WorkHubDelegationResumeResolutionInput { - readonly request: WorkHubDelegationResumeRequestedMessage; - readonly outcome: 'resume_started' | 'already_running' | 'parked'; - readonly targetTurnId?: string; - readonly targetRunId?: string; -} - export interface WorkHubRetirementResult { readonly outcome: WorkHubDelegationStopOutcome | 'recovering'; readonly targetTurnId?: string; @@ -523,7 +480,7 @@ export class WorkHubCoordinationActionGate { replay.actionFingerprint, replay.resumesDelegationId, ); - return this.#resume(replay, context); + return this.#effects.resume({ request: replay }, context); } const source = await this.#resumeSource(proposal.expects.targetSessionId); const sessions = await this.#effects.listSessions(); @@ -539,22 +496,16 @@ export class WorkHubCoordinationActionGate { } const resumeFingerprint = resumeActionFingerprint(input, source); await this.#claimAction(input.actionId, 'resume', resumeFingerprint, source.delegationId); - const previous = (await this.#effects.listResumeResolutions(source.delegationId)) - .filter(({ outcome }) => outcome === 'resume_started') - .at(-1); - const plan = await this.#effects.planResume(source, previous, context); - const request = await this.#effects.prepareResume({ - actionId: input.actionId, - actionFingerprint: resumeFingerprint, - resumesActionId: source.actionId, - resumesDelegationId: source.delegationId, - targetSessionId: source.targetSessionId, - targetMessageId: source.targetMessageId, - targetSessionName: currentTargetName, - userText: input.userText, - plan, - }); - return this.#resume(request, context); + return this.#effects.resume( + { + actionId: input.actionId, + actionFingerprint: resumeFingerprint, + source, + targetSessionName: currentTargetName, + userText: input.userText, + }, + context, + ); } if (proposal.disposition === 'create_new') { @@ -682,25 +633,6 @@ export class WorkHubCoordinationActionGate { return onTarget[0]!; } - async #resume( - request: WorkHubDelegationResumeRequestedMessage, - context: ConnectionContext, - ): Promise> { - const existing = await this.#effects.readResumeResolution(request.actionId); - if (existing) return resumeResult(existing); - const resumed: WorkHubResumeResult = - request.plan === 'ready' - ? await this.#effects.resumeDelegation(request, context) - : { outcome: request.plan }; - const resolution = await this.#effects.resolveResume({ - request, - outcome: resumed.outcome, - ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), - ...(resumed.targetRunId ? { targetRunId: resumed.targetRunId } : {}), - }); - return resumeResult(resolution); - } - /** * The delegation a stop names, by the only two keys that can name it. * @@ -1372,17 +1304,6 @@ function stopResult( }; } -function resumeResult( - resolution: WorkHubDelegationResumeResolvedMessage, -): Extract { - return { - disposition: 'resume_work', - outcome: resolution.outcome, - targetSessionId: resolution.targetSessionId, - ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), - }; -} - function stopResultFromRecord( resolution: WorkHubDelegationStopResolvedMessage, request: WorkHubDelegationStopRequestedMessage, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 326a6a2373..892eff27b4 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -44,6 +44,7 @@ import { import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; import type { OperationOutcome, + WorkHubCoordinationActResult, WorkHubCoordinationActInput, WorkHubCoordinationAnswerInput, WorkHubCoordinationRecordInput, @@ -65,6 +66,7 @@ import { WorkHubActionGateFailure, WorkHubCoordinationActionGate, type WorkHubActionGateEffects, + type WorkHubDelegationResumeInput, } from './workhub-coordination-action-gate.js'; const CREATE_FINGERPRINT = `sha256:${createHash('sha256') @@ -115,6 +117,37 @@ type CoordinationExecutions = Pick< 'startWorkHubCoordinationMessage' | 'hasRootTurnAdmission' >; +type WorkHubResumePlan = + | { readonly kind: 'already_running' | 'parked' } + | { + readonly kind: 'ready'; + readonly sourceTurnId: string; + readonly sourceRunId: string; + readonly sourceRuntimeEventHighWater: number; + readonly targetTurnId: string; + }; + +interface WorkHubResumeResult { + readonly outcome: 'resume_started' | 'already_running' | 'parked'; + readonly targetTurnId?: string; + readonly targetRunId?: string; +} + +type CoordinationSessionActions = Pick< + WorkHubActionGateEffects, + 'assign' | 'readDelegationRetirement' | 'retireDelegation' +> & { + planResume( + assignment: WorkHubDelegationAssignedMessage, + previous: WorkHubDelegationResumeResolvedMessage | undefined, + context: ConnectionContext, + ): Promise; + resumeDelegation( + request: WorkHubDelegationResumeRequestedMessage, + context: ConnectionContext, + ): Promise; +}; + export type CoordinationCreateTarget = Omit; export interface HostWorkHubCoordinationCoordinatorOptions { @@ -123,14 +156,7 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly admission: SessionAdmissionGate; readonly continuity: Pick; readonly executions: CoordinationExecutions; - readonly sessionActions: Pick< - WorkHubActionGateEffects, - | 'assign' - | 'readDelegationRetirement' - | 'retireDelegation' - | 'planResume' - | 'resumeDelegation' - >; + readonly sessionActions: CoordinationSessionActions; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; } @@ -190,8 +216,6 @@ export class HostWorkHubCoordinationCoordinator { readStopRequest: (delegationId) => this.#stores.readWorkHubStopRequest(delegationId), readStopResolution: (delegationId) => this.#stores.readWorkHubStopResolution(delegationId), readResumeRequest: (actionId) => this.#stores.readWorkHubResumeRequest(actionId), - readResumeResolution: (actionId) => this.#stores.readWorkHubResumeResolution(actionId), - listResumeResolutions: (delegationId) => this.#listResumeResolutions(delegationId), answer: async (input, context) => { const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); if (!outcome.ok) { @@ -213,12 +237,9 @@ export class HostWorkHubCoordinationCoordinator { abortReplacement: (input) => this.#abortReplacement(input), prepareStop: (input) => this.#prepareStop(input), resolveStop: (input) => this.#resolveStop(input), - prepareResume: (input) => this.#prepareResume(input), - resolveResume: (input) => this.#resolveResume(input), readDelegationRetirement: options.sessionActions.readDelegationRetirement, retireDelegation: options.sessionActions.retireDelegation, - planResume: options.sessionActions.planResume, - resumeDelegation: options.sessionActions.resumeDelegation, + resume: (input, context) => this.#resume(input, context, options.sessionActions), }); } @@ -400,68 +421,86 @@ export class HostWorkHubCoordinationCoordinator { }); } - #prepareResume( - input: Parameters[0], - ): Promise { - const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); - return this.#commitCoordinationFact({ - admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.targetSessionId], - read: () => this.#stores.readWorkHubResumeRequest(input.actionId), - build: (existing) => ({ - type: 'workhub_coordination', - id: `whr_${suffix}`, - turnId: input.actionId, - ts: existing?.ts ?? Date.now(), - schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, - kind: 'delegation_resume_requested', - actionId: input.actionId, - actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, - resumesActionId: input.resumesActionId, - resumesDelegationId: input.resumesDelegationId, - targetSessionId: input.targetSessionId, - targetMessageId: input.targetMessageId, - targetSessionName: input.targetSessionName, - userText: input.userText, - plan: input.plan.kind, - ...(input.plan.kind === 'ready' - ? { - sourceTurnId: input.plan.sourceTurnId, - sourceRunId: input.plan.sourceRunId, - sourceRuntimeEventHighWater: input.plan.sourceRuntimeEventHighWater, - targetTurnId: input.plan.targetTurnId, - } - : {}), - }), - conflictMessage: 'WorkHub resume identity belongs to a different plan', - beforeAppend: async () => { - const source = (await this.#listActiveAssignments()).find( - ({ actionId, delegationId }) => - actionId === input.resumesActionId && delegationId === input.resumesDelegationId, - ); - if (!source || source.targetSessionId !== input.targetSessionId) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub resume source is no longer active', + async #resume( + input: WorkHubDelegationResumeInput, + context: ConnectionContext, + actions: CoordinationSessionActions, + ): Promise> { + let request: WorkHubDelegationResumeRequestedMessage; + if ('request' in input) { + request = input.request; + } else { + const previous = (await this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)) + .filter( + (message): message is WorkHubDelegationResumeResolvedMessage => + message.type === 'workhub_coordination' && + message.kind === 'delegation_resume_resolved' && + message.resumesDelegationId === input.source.delegationId && + message.outcome === 'resume_started', + ) + .at(-1); + const plan = await actions.planResume(input.source, previous, context); + const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); + request = await this.#commitCoordinationFact({ + admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.source.targetSessionId], + read: () => this.#stores.readWorkHubResumeRequest(input.actionId), + build: (existing) => ({ + type: 'workhub_coordination', + id: `whr_${suffix}`, + turnId: input.actionId, + ts: existing?.ts ?? Date.now(), + schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, + kind: 'delegation_resume_requested', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + resumesActionId: input.source.actionId, + resumesDelegationId: input.source.delegationId, + targetSessionId: input.source.targetSessionId, + targetMessageId: input.source.targetMessageId, + targetSessionName: input.targetSessionName, + userText: input.userText, + plan: plan.kind, + ...(plan.kind === 'ready' + ? { + sourceTurnId: plan.sourceTurnId, + sourceRunId: plan.sourceRunId, + sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, + targetTurnId: plan.targetTurnId, + } + : {}), + }), + conflictMessage: 'WorkHub resume identity belongs to a different plan', + beforeAppend: async () => { + const source = (await this.#listActiveAssignments()).find( + ({ actionId, delegationId }) => + actionId === input.source.actionId && delegationId === input.source.delegationId, ); - } - }, - unknownOutcomeMessage: 'WorkHub resume plan outcome is unknown', - }); - } + if (!source || source.targetSessionId !== input.source.targetSessionId) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume source is no longer active', + ); + } + }, + unknownOutcomeMessage: 'WorkHub resume plan outcome is unknown', + }); + } - #resolveResume( - input: Parameters[0], - ): Promise { - const request = input.request; + const existing = await this.#stores.readWorkHubResumeResolution(request.actionId); + if (existing) return coordinationResumeResult(existing); + const resumed: WorkHubResumeResult = + request.plan === 'ready' + ? await actions.resumeDelegation(request, context) + : { outcome: request.plan }; const suffix = createHash('sha256').update(request.actionId, 'utf8').digest('hex').slice(0, 48); - return this.#commitCoordinationFact({ + const resolution = await this.#commitCoordinationFact({ read: () => this.#stores.readWorkHubResumeResolution(request.actionId), - build: (existing) => ({ + build: (durable) => ({ type: 'workhub_coordination', id: `whn_${suffix}`, turnId: request.actionId, - ts: existing?.ts ?? Date.now(), + ts: durable?.ts ?? Date.now(), schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, kind: 'delegation_resume_resolved', actionId: request.actionId, @@ -470,9 +509,9 @@ export class HostWorkHubCoordinationCoordinator { resumesActionId: request.resumesActionId, resumesDelegationId: request.resumesDelegationId, targetSessionId: request.targetSessionId, - outcome: input.outcome, - ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}), - ...(input.targetRunId ? { targetRunId: input.targetRunId } : {}), + outcome: resumed.outcome, + ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), + ...(resumed.targetRunId ? { targetRunId: resumed.targetRunId } : {}), }), conflictMessage: 'WorkHub resume already has a different resolution', beforeAppend: async () => { @@ -483,17 +522,7 @@ export class HostWorkHubCoordinationCoordinator { }, unknownOutcomeMessage: 'WorkHub resume resolution outcome is unknown', }); - } - - async #listResumeResolutions( - delegationId: string, - ): Promise { - return (await this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).filter( - (message): message is WorkHubDelegationResumeResolvedMessage => - message.type === 'workhub_coordination' && - message.kind === 'delegation_resume_resolved' && - message.resumesDelegationId === delegationId, - ); + return coordinationResumeResult(resolution); } #abortReplacement( @@ -935,6 +964,17 @@ function digest(value: unknown): `sha256:${string}` { return `sha256:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`; } +function coordinationResumeResult( + resolution: WorkHubDelegationResumeResolvedMessage, +): Extract { + return { + disposition: 'resume_work', + outcome: resolution.outcome, + targetSessionId: resolution.targetSessionId, + ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), + }; +} + function workHubDestructiveClaimIdentitySuffix(delegationId: string): string { return createHash('sha256').update(delegationId, 'utf8').digest('hex').slice(0, 48); } From 8bab0aba61c766bdb6a393e3ae0672b27959280b Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 15:34:13 +0800 Subject: [PATCH 06/10] fix(workhub): preserve resumed execution ownership --- .../main/__tests__/workhub-controller.test.ts | 33 +++- .../__tests__/workhub-session-port.test.ts | 2 +- .../__tests__/workhub-surface-flow.test.ts | 31 +++ .../src/renderer/workhub-controller.ts | 178 +++++++----------- .../src/renderer/workhub-coordination-port.ts | 7 +- .../src/renderer/workhub-route-policy.ts | 47 ++--- apps/desktop/src/renderer/workhub-surface.tsx | 8 +- .../workhub-coordination-record.test.ts | 9 +- .../__tests__/workhub-creation-intent.test.ts | 36 ++-- packages/core/src/runtime-invocation.ts | 15 ++ packages/core/src/session.ts | 29 ++- packages/core/src/workhub-creation-intent.ts | 77 +++----- .../__tests__/execution-composition.test.ts | 137 +++++++++++++- .../workhub-coordination-action-gate.test.ts | 17 +- .../workhub-coordination-coordinator.test.ts | 86 ++++++++- .../workhub-coordination-protocol.test.ts | 101 ++++++++++ packages/runtime-host/src/protocol/turn.ts | 21 +-- .../src/protocol/workhub-coordination.ts | 18 +- .../src/server/execution-composition.ts | 93 +++++---- .../src/server/root-turn-coordinator.ts | 27 +++ .../workhub-coordination-coordinator.ts | 48 ++--- packages/storage/src/session-store.ts | 2 +- 22 files changed, 699 insertions(+), 323 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 0432084dbb..a82050ebc8 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), @@ -461,6 +461,37 @@ test('a named resume submits and reports what the Host did', async () => { await handle.close(); }); +test('a parked resume preserves the Host park reason', 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('a resume must not read route candidates'), + act: async () => ({ + disposition: 'resume_work', + outcome: 'parked', + targetSessionId: 'payments', + parkReason: 'safety_check_failed', + }), + }, + }); + const handle = await controller.openConversation(() => undefined, () => undefined); + + assert.deepEqual(await controller.submit({ requestId: 'resume-parked', text: 'Resume Payments' }), { + kind: 'resume', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'resume-parked', + target: { sessionId: 'payments' }, + outcome: 'parked', + parkReason: 'safety_check_failed', + }); + 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({ diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index ae743fc9ff..0f70c41df5 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -334,7 +334,7 @@ test('resume projection survives reload and exposes its durable outcome', () => actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'resume-action', resumesActionId: 'source-action', resumesDelegationId: 'payments-delegation', targetSessionId: 'payments', outcome: 'resume_started', - targetTurnId: 'resumed-turn', targetRunId: 'resumed-run', + targetTurnId: 'resumed-turn', }; assert.deepEqual(projectWorkHubCoordinationTurns([requested]), [{ 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 e88af5b222..fcb62fe77a 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -210,6 +210,37 @@ test('durable delegation renders terminal link state instead of stale execution } }); +test('a parked resume owns the durable frame state', () => { + const turn: WorkHubCoordinationTurn = { + messageId: 'resume-parked', + turnId: 'resume-parked', + text: 'Resume Payments', + state: 'completed', + resume: { + targetSessionId: 'payments', + targetSessionName: 'Payments', + outcome: 'parked', + }, + updatedAt: 10, + }; + const markup = renderToStaticMarkup( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(WorkHubCoordinationTurnView, { + turn, + projection: { sessions: [], turns: [] }, + locale: 'en', + onOpenSession: () => undefined, + }), + }), + }), + ); + + assert.match(markup, /data-state="parked"/u); + assert.doesNotMatch(markup, /data-state="completed"/u); +}); + test('durable creation explicitly announces the new work', () => { const turn: WorkHubCoordinationTurn = { messageId: 'created-assignment', diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 7c8de20b85..e392c4c93d 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, @@ -219,6 +220,10 @@ export type WorkHubSubmission = ( target: WorkHubSessionTarget; outcome: Extract['outcome']; targetTurnId?: string; + parkReason?: Extract< + WorkHubCoordinationActResult, + { disposition: 'resume_work' } + >['parkReason']; } ) & { strategyId: WorkHubRoutingStrategyId }; @@ -332,6 +337,69 @@ 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 admitted = await coordination.act({ + actionId: input.requestId, + userText: input.text, + proposal: { + disposition: kind === 'resume' ? 'resume_work' : 'stop_work', + expects: { targetSessionId: target.sessionId }, + }, + ...(kind === 'stop' ? { confirmation: { kind: 'user_stop' as const } } : {}), + }); + if (kind === 'resume' && admitted.disposition === 'resume_work') { + return { + kind, + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + outcome: admitted.outcome, + ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), + ...(admitted.parkReason ? { parkReason: admitted.parkReason } : {}), + }; + } + if (kind === 'stop' && admitted.disposition === 'stop_work') { + return { + kind, + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + outcome: admitted.outcome, + ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), + }; + } + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } catch (error) { + if (error instanceof WorkHubCoordinationFailure && error.code === 'operation_conflict') { + 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; @@ -467,116 +535,14 @@ export function createWorkHubController(deps: { text: input.text, sessions: ordinary, }); - if (resumeDecision.kind !== 'not_requested') { - if (resumeDecision.kind === 'clarification') { - return { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - text: input.text, - options: [], - reason: resumeDecision.reason, - }; - } - const { target } = resumeDecision; - let resumed; - try { - resumed = await coordination.act({ - actionId: input.requestId, - userText: input.text, - // No confirmation: resume ends nothing. Which delegation it carries - // on, and whether the Host can, is decided where the stop is. - proposal: { - disposition: 'resume_work', - expects: { targetSessionId: target.sessionId }, - }, - }); - } catch (error) { - if (error instanceof WorkHubCoordinationFailure && error.code === 'operation_conflict') { - return { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - text: input.text, - options: [], - reason: 'resume_target_unavailable', - }; - } - throw error; - } - if (resumed.disposition !== 'resume_work') { - throw new Error('WorkHub Action Gate returned an unexpected disposition'); - } - return { - kind: 'resume', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - target, - outcome: resumed.outcome, - ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), - }; - } + 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-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 79304d493e..c9d2cdc0aa 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -38,7 +38,12 @@ import type { } from '@maka/runtime-host/protocol'; import { boundedWorkHubTimelineText, WorkHubCoordinationFailure } from './workhub-controller.js'; -export { WorkHubCoordinationFailure }; +export function isWorkHubCoordinationFailure( + error: unknown, +): error is WorkHubCoordinationFailure { + return error instanceof WorkHubCoordinationFailure; +} + import type { WorkHubDesktopTranscriptBridge } from './workhub-session-port.js'; const WORKHUB_COORDINATION_TURN_LIMIT = 40; diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index f6929338d2..68733abb80 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -90,7 +90,7 @@ export type WorkHubStopClarificationReason = * 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 }; @@ -99,7 +99,7 @@ export interface WorkHubRoutePolicy { resolveStop(input: { text: string; sessions: WorkHubRoutableSession[]; - }): WorkHubStopRouteDecision; + }): WorkHubNamedActionRouteDecision; /** * Resume reads the same way a stop does and refuses on the same terms. The * two share one resolver and one tail rule so `Resume Payments` and @@ -108,7 +108,7 @@ export interface WorkHubRoutePolicy { resolveResume(input: { text: string; sessions: WorkHubRoutableSession[]; - }): WorkHubStopRouteDecision; + }): WorkHubNamedActionRouteDecision; resolve(input: { text: string; sessions: WorkHubRoutableSession[]; @@ -158,27 +158,17 @@ const MAX_RELATED_CLARIFICATION_OPTIONS = 4; */ function resolveNamedDelegationAction( sessionResolver: WorkHubSessionResolver, - action: { readonly cue: boolean; readonly imperative: boolean; readonly target?: string }, + action: { readonly requested: boolean; readonly target?: string }, sessions: WorkHubRoutableSession[], - reasons: { - /** - * What an unnamed reference means. A stop must say so: it is destructive, - * and `Stop it` has to be answered rather than delivered as work. A resume - * must not: `继续这个工作` is how a user carries on with the Session they - * are already in, and answering it would take an ordinary instruction away - * from ordinary routing. Resuming nothing costs nothing, so it falls - * through and only an explicitly named resume becomes an action. - */ - readonly unnamed: WorkHubStopClarificationReason | 'not_requested'; - readonly ambiguous: WorkHubStopClarificationReason; - }, -): WorkHubStopRouteDecision { - if (!action.cue) return { kind: 'not_requested' }; - const reference = action.imperative ? action.target : undefined; + ambiguousReason: WorkHubStopClarificationReason, + unnamedReason?: WorkHubStopClarificationReason, +): WorkHubNamedActionRouteDecision { + if (!action.requested) return { kind: 'not_requested' }; + const reference = action.target; if (!reference) { - return reasons.unnamed === 'not_requested' - ? { kind: 'not_requested' } - : { kind: 'clarification', reason: reasons.unnamed }; + return unnamedReason + ? { kind: 'clarification', reason: unnamedReason } + : { kind: 'not_requested' }; } const sessionByRef = new Map(sessions.map((session) => [session.target.sessionId, session])); const resolution = sessionResolver.resolve({ @@ -199,7 +189,7 @@ function resolveNamedDelegationAction( // 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: reasons.ambiguous }; + return { kind: 'clarification', reason: ambiguousReason }; } const resolved = sessionByRef.get(admissible[0]!.ref); if (!resolved) return { kind: 'not_requested' }; @@ -237,22 +227,25 @@ function createWorkHubRoutePolicyVisit( // reference still fails closed, and a resolved Session that is not uniquely // stoppable says why. resolveStop({ text, sessions }) { + const action = readWorkHubRequestIntent(text).stop; return resolveNamedDelegationAction( sessionResolver, - readWorkHubRequestIntent(text).stop, + { requested: action.cue, ...(action.imperative ? { target: action.target } : {}) }, sessions, - { unnamed: 'stop_target_required', ambiguous: 'stop_target_ambiguous' }, + 'stop_target_ambiguous', + 'stop_target_required', ); }, // Resume asks the same question of the same words, so it asks it with the // same code. Two copies of this would be two chances for `Resume Payments` // and `Stop Payments` to disagree about which Session they name. resolveResume({ text, sessions }) { + const action = readWorkHubRequestIntent(text).resume; return resolveNamedDelegationAction( sessionResolver, - readWorkHubRequestIntent(text).resume, + { requested: action.imperative, ...(action.target ? { target: action.target } : {}) }, sessions, - { unnamed: 'not_requested', ambiguous: 'resume_target_ambiguous' }, + 'resume_target_ambiguous', ); }, resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 6ffdcf9c2a..05f0316278 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -40,7 +40,7 @@ import { WorkHubSendLease, type WorkHubSendAttempt, } from './workhub-send-lease.js'; -import { WorkHubCoordinationFailure } from './workhub-coordination-port.js'; +import { isWorkHubCoordinationFailure } from './workhub-coordination-port.js'; export interface WorkHubConversationTurn { requestId: string; @@ -95,7 +95,7 @@ export function workHubSubmissionClearsDraft( } export function workHubSurfaceFailure(error: unknown): WorkHubSurfaceFailure { - if (error instanceof WorkHubCoordinationFailure) { + if (isWorkHubCoordinationFailure(error)) { if (error.code === 'operation_conflict') return 'action_changed'; if (error.code === 'not_found' || error.code === 'session_archived') { return 'candidates_changed'; @@ -467,7 +467,7 @@ export function WorkHubSurface(props: { function isTerminalWorkHubSurfaceFailure(error: unknown): boolean { return ( - error instanceof WorkHubCoordinationFailure && + isWorkHubCoordinationFailure(error) && (error.code === 'operation_conflict' || error.code === 'not_found' || error.code === 'session_archived' || @@ -583,7 +583,7 @@ export function WorkHubCoordinationTurnView(props: { return ( { targetSessionId: 'payments', outcome: 'resume_started', targetTurnId: 'resumed-turn', - targetRunId: 'resumed-run', } as const; assert.deepEqual(decodeCanonicalMessage(requested), requested); @@ -308,9 +307,9 @@ describe('WorkHub Coordination stored records', () => { for (const invalid of [ { ...requested, sourceRunId: undefined }, { ...requested, sourceRuntimeEventHighWater: -1 }, - { ...requested, plan: 'parked' }, - { ...resolved, targetRunId: undefined }, - { ...resolved, outcome: 'parked' }, + { ...requested, plan: 'parked', parkReason: undefined }, + { ...resolved, targetTurnId: undefined }, + { ...resolved, outcome: 'parked', parkReason: undefined }, { ...resolved, sourceRunId: 'injected' }, ]) { assert.throws(() => decodeCanonicalMessage(invalid), /Invalid stored message schema/u); @@ -318,8 +317,8 @@ describe('WorkHub Coordination stored records', () => { const parked = { ...resolved, outcome: 'parked' as const, + parkReason: 'safety_check_failed' as const, targetTurnId: undefined, - targetRunId: undefined, }; assert.deepEqual(decodeCanonicalMessage(parked), parked); }); diff --git a/packages/core/src/__tests__/workhub-creation-intent.test.ts b/packages/core/src/__tests__/workhub-creation-intent.test.ts index 0151cd8870..6e96a18cc7 100644 --- a/packages/core/src/__tests__/workhub-creation-intent.test.ts +++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts @@ -1133,34 +1133,34 @@ test('a resume names one Session, and reads like a stop everywhere else', () => // existing Session, in either language. for (const [text, target] of [ ['Resume Payments', 'Payments'], - ['Continue Payments', 'Payments'], - ['Restart Payments', 'Payments'], - ['继续支付任务', '支付任务'], ['恢复支付任务', '支付任务'], - ['请继续支付任务', '支付任务'], ['接着跑支付任务', '支付任务'], ] as const) { - assert.deepEqual( - readWorkHubRequestIntent(text).resume, - { cue: true, imperative: true, target }, - text, - ); + assert.deepEqual(readWorkHubRequestIntent(text).resume, { imperative: true, target }, text); } - // Anaphora carries the cue and claims no target, so the surface asks which - // work instead of guessing — the same shape `Stop it` produces. - for (const text of ['Resume it', '继续它']) { - assert.deepEqual(readWorkHubRequestIntent(text).resume, { cue: true, imperative: false }, text); + for (const text of ['Resume it', '恢复它']) { + assert.deepEqual(readWorkHubRequestIntent(text).resume, { imperative: false }, text); } - // A question, a negation and an unterminated quote are not commands. - for (const text of ['Should I resume Payments?', 'Do not resume Payments', 'Resume "Payments']) { - assert.equal(readWorkHubRequestIntent(text).resume.cue, 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.cue, false); + assert.equal(readWorkHubRequestIntent('Stop Payments').resume.imperative, false); assert.equal(readWorkHubRequestIntent('Resume Payments').stop.cue, false); - assert.equal(readWorkHubRequestIntent('Fix the login bug').resume.cue, false); + assert.equal(readWorkHubRequestIntent('Fix the login bug').resume.imperative, false); }); diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index e020d7f14f..a8050d7752 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -34,6 +34,21 @@ import type { } from './runtime-event.js'; import { isTerminalRuntimeEvent } from './runtime-event.js'; +export const SAFE_BOUNDARY_RESUME_PARK_REASONS = [ + 'resume_candidate_missing', + 'source_run_unreadable', + 'safety_check_failed', + 'continuation_already_exists', + 'continuation_repair_required', + 'continuation_started_indeterminate', + 'resume_feature_disabled', + 'continuation_authority_unavailable', + 'safety_observation_unavailable', + 'session_busy', +] as const; + +export type SafeBoundaryResumeParkReason = (typeof SAFE_BOUNDARY_RESUME_PARK_REASONS)[number]; + export interface RuntimeInvocationRecord { sessionId: string; invocationId: string; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 26817df577..07ea5a08b9 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -49,6 +49,10 @@ import { import { markPersisted, type PersistedValue } from './persisted-value.js'; import type { SubagentWorkspaceBinding } from './subagent-workspace.js'; import { decodeTurnOrigin, type TurnOrigin } from './turn-origin.js'; +import { + SAFE_BOUNDARY_RESUME_PARK_REASONS, + type SafeBoundaryResumeParkReason, +} from './runtime-invocation.js'; export { DEEP_RESEARCH_SESSION_LABEL, isDeepResearchSession } from './deep-research.js'; @@ -1078,8 +1082,6 @@ export interface WorkHubDelegationStopResolvedMessage { targetTurnId?: string; } -export type WorkHubDelegationResumeOutcome = 'resume_started' | 'already_running' | 'parked'; - /** Durable resume plan written before attempting a continuation. */ export interface WorkHubDelegationResumeRequestedMessage { type: 'workhub_coordination'; @@ -1098,6 +1100,7 @@ export interface WorkHubDelegationResumeRequestedMessage { targetSessionName: string; userText: string; plan: 'ready' | 'already_running' | 'parked'; + parkReason?: SafeBoundaryResumeParkReason; sourceTurnId?: string; sourceRunId?: string; sourceRuntimeEventHighWater?: number; @@ -1118,9 +1121,9 @@ export interface WorkHubDelegationResumeResolvedMessage { resumesActionId: string; resumesDelegationId: string; targetSessionId: string; - outcome: WorkHubDelegationResumeOutcome; + outcome: 'resume_started' | 'already_running' | 'parked'; + parkReason?: SafeBoundaryResumeParkReason; targetTurnId?: string; - targetRunId?: string; } /** @@ -1453,7 +1456,7 @@ const WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE = 'userText', 'plan', ], - ['sourceTurnId', 'sourceRunId', 'sourceRuntimeEventHighWater', 'targetTurnId'], + ['parkReason', 'sourceTurnId', 'sourceRunId', 'sourceRuntimeEventHighWater', 'targetTurnId'], ); const WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE = defineObjectShape()( @@ -1472,7 +1475,7 @@ const WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE = 'targetSessionId', 'outcome', ], - ['targetTurnId', 'targetRunId'], + ['parkReason', 'targetTurnId'], ); const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( ['title', 'workspace'], @@ -1666,6 +1669,7 @@ function decodeMessage( function isWorkHubCoordinationMessage(message: Record): boolean { if (message.kind === 'delegation_resume_requested') { const ready = message.plan === 'ready'; + const parked = message.plan === 'parked'; return ( hasMessageEnvelope(message, true) && hasExactShape(message, WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE) && @@ -1684,6 +1688,9 @@ function isWorkHubCoordinationMessage(message: Record): boolean typeof message.userText === 'string' && message.userText.trim().length > 0 && (ready || message.plan === 'already_running' || message.plan === 'parked') && + (parked + ? (SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(message.parkReason) + : message.parkReason === undefined) && (ready ? typeof message.sourceTurnId === 'string' && message.sourceTurnId.length > 0 && @@ -1702,6 +1709,7 @@ function isWorkHubCoordinationMessage(message: Record): boolean } if (message.kind === 'delegation_resume_resolved') { const started = message.outcome === 'resume_started'; + const parked = message.outcome === 'parked'; return ( hasMessageEnvelope(message, true) && hasExactShape(message, WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE) && @@ -1714,12 +1722,13 @@ function isWorkHubCoordinationMessage(message: Record): boolean typeof message.targetSessionId === 'string' && message.targetSessionId.length > 0 && (started || message.outcome === 'already_running' || message.outcome === 'parked') && + (parked + ? (SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(message.parkReason) + : message.parkReason === undefined) && (started ? typeof message.targetTurnId === 'string' && - message.targetTurnId.length > 0 && - typeof message.targetRunId === 'string' && - message.targetRunId.length > 0 - : message.targetTurnId === undefined && message.targetRunId === undefined) + message.targetTurnId.length > 0 + : message.targetTurnId === undefined) ); } if (message.kind === 'delegation_stop_requested') { diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index cad6aeb79c..7982a66f57 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -112,13 +112,10 @@ const DIRECT_CHINESE_STOP_REQUEST = /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:停止|停掉|停下|取消|终止|中止)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; // Resume asks the Host to carry on work that was interrupted, so it reads the // same shape as a stop: a direct speech act naming one existing Session. It is -// deliberately narrower than the everyday senses of these words — `continue` -// and `继续` also introduce ordinary instructions ("continue with the refactor"), -// which is why a resume that names nothing resolvable stays ordinary work. const DIRECT_RESUME_REQUEST = - /^\s*(?:(?:please|kindly)\s+)?(?:resume|continue|restart)\s+(?:(?:the|this)\s+)?(?:(?:session|work|task|job)\s+)?(.+?)\s*[.!。!]?\s*$/iu; + /^\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; + /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:恢复|接着跑)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; const UNSAFE_STOP_TARGET = /^(?:it|this|that|one|everything|all|current|session|work|task|job|(?:this|that|current)\s+(?:session|work|task|job)|它|这个|那个|全部|当前|会话|工作|任务|(?:这个|那个|当前)(?:会话|工作|任务))$/iu; @@ -161,8 +158,6 @@ export interface WorkHubRequestIntent { 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; @@ -332,10 +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 resumeCue = directWorkHubResumeCue(source, literalMask.malformed); - const resumeTarget = resumeCue ? directWorkHubResumeTarget(source, false) : undefined; - 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) @@ -353,14 +352,13 @@ 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: resumeCue, - imperative: Boolean(resumeTarget), - ...(resumeTarget ? { target: resumeTarget } : {}), + imperative: Boolean(resume.target), + ...(resume.target ? { target: resume.target } : {}), }, }; } @@ -517,43 +515,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); - 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)); -} - -/** - * Resume reuses the stop reader's rules: a question is not a command, a - * malformed literal is refused outright, and an anaphoric target — `it`, `它`, - * `这个工作` — reads the cue without claiming a target, so the surface can ask - * which work rather than guess at it. - */ -function directWorkHubResumeTarget(value: string, malformedLiteral: boolean): string | undefined { - if (malformedLiteral || /[??]\s*$/u.test(value)) return undefined; - const match = DIRECT_RESUME_REQUEST.exec(value) ?? DIRECT_CHINESE_RESUME_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 directWorkHubResumeCue(value: string, malformedLiteral: boolean): boolean { - if (malformedLiteral || /[??]\s*$/u.test(value)) return false; - return Boolean(DIRECT_RESUME_REQUEST.test(value) || DIRECT_CHINESE_RESUME_REQUEST.test(value)); + if (!rawTarget) return { cue: Boolean(match) }; + const target = stripMatchingActionQuotes(rawTarget.replace(/[.!。!]+\s*$/u, '').trim()); + return !target || UNSAFE_STOP_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__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 3e5de76f15..6fdf0ec45b 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -709,6 +709,131 @@ 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); + const { 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 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', + 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'); + + 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 (continuation && targetSessionId) { + await composition.handlers['turn.stop']( + { sessionId: targetSessionId, ...continuation }, + context, + ); + } + 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 +1703,22 @@ 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) process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = '1'; // 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 +1731,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__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 37c7686267..4d67f91aab 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 @@ -414,7 +414,7 @@ describe('WorkHub Coordination Action Gate', () => { }; const first = await gate.act(input, CONTEXT); - effects.resumeOutcome = { outcome: 'parked' }; + effects.resumeOutcome = { outcome: 'parked', parkReason: 'safety_check_failed' }; const replay = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); assert.deepEqual(replay, first); @@ -511,7 +511,8 @@ describe('WorkHub Coordination Action Gate', () => { ] as const) { const effects = fakeEffects([session('payments', { name: 'Payments' })]); delegatedTo(effects, 'payments'); - effects.resumeOutcome = { outcome }; + effects.resumeOutcome = + outcome === 'parked' ? { outcome, parkReason: 'safety_check_failed' } : { outcome }; const result = await new WorkHubCoordinationActionGate(effects).act( { @@ -526,6 +527,7 @@ describe('WorkHub Coordination Action Gate', () => { disposition: 'resume_work', outcome, targetSessionId: 'payments', + ...(outcome === 'parked' ? { parkReason: 'safety_check_failed' } : {}), ...(targetTurnId ? { targetTurnId } : {}), }); } @@ -2773,11 +2775,10 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { resumeOutcome: { outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', - targetRunId: 'resumed-run', } as { outcome: 'resume_started' | 'already_running' | 'parked'; + parkReason?: 'safety_check_failed'; targetTurnId?: string; - targetRunId?: string; }, async readResumeRequest(actionId: string) { return resumeRequests.get(actionId); @@ -2794,6 +2795,7 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { ...(existingResolution.targetTurnId ? { targetTurnId: existingResolution.targetTurnId } : {}), + ...(existingResolution.parkReason ? { parkReason: existingResolution.parkReason } : {}), }; } if ('request' in input) { @@ -2837,10 +2839,12 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { resumesDelegationId: requested.resumesDelegationId, targetSessionId: requested.targetSessionId, outcome: this.resumeOutcome.outcome, + ...(this.resumeOutcome.outcome === 'parked' + ? { parkReason: this.resumeOutcome.parkReason ?? 'safety_check_failed' } + : {}), ...(this.resumeOutcome.targetTurnId ? { targetTurnId: this.resumeOutcome.targetTurnId } : {}), - ...(this.resumeOutcome.targetRunId ? { targetRunId: this.resumeOutcome.targetRunId } : {}), }; resumeResolutions.set(actionId, resolved); return { @@ -2848,6 +2852,7 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { outcome: resolved.outcome, targetSessionId: resolved.targetSessionId, ...(resolved.targetTurnId ? { targetTurnId: resolved.targetTurnId } : {}), + ...(resolved.parkReason ? { parkReason: resolved.parkReason } : {}), }; }, async readActionClaim(actionId: string) { @@ -3050,8 +3055,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { resumeCalls: WorkHubDelegationResumeInput[]; resumeOutcome: { outcome: 'resume_started' | 'already_running' | 'parked'; + parkReason?: 'safety_check_failed'; targetTurnId?: string; - targetRunId?: 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 dd159af3f8..3e9aec3a7e 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -1111,7 +1111,6 @@ describe('Host WorkHub Coordination coordinator', () => { return { outcome: 'resume_started', targetTurnId: 'resumed-turn', - targetRunId: 'resumed-run', }; }, }); @@ -1153,8 +1152,8 @@ describe('Host WorkHub Coordination coordinator', () => { 'payments-run', ); assert.equal( - (await store.readWorkHubResumeResolution('resume-action'))?.targetRunId, - 'resumed-run', + (await store.readWorkHubResumeResolution('resume-action'))?.targetTurnId, + 'resumed-turn', ); } finally { await store.close?.(); @@ -1178,6 +1177,86 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + 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: (input) => persistTestAssignment(store, input, 'payments-turn'), + planResume: async () => + recovering + ? { kind: 'recovering' } + : { + kind: 'ready', + sourceTurnId: 'payments-turn', + sourceRunId: 'payments-run', + sourceRuntimeEventHighWater: 9, + targetTurnId: 'resumed-turn', + }, + resumeDelegation: async () => ({ + 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, + expects: { targetSessionId: target.id }, + }, + }; + assert.deepEqual(await workhub.handlers['workhub.coordination.act'](input, CONTEXT), { + ok: false, + error: { + code: 'operation_unavailable', + message: 'WorkHub is still recovering the delegated execution', + }, + }); + assert.equal(await store.readWorkHubResumeRequest(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); @@ -1982,7 +2061,6 @@ function coordinator( resumeDelegation: async () => ({ outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', - targetRunId: 'resumed-run', }), retireDelegation: async () => ({ outcome: 'cancelled_pending' }), ...sessionActions, 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..c175e134a9 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,107 @@ 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', + expects: { targetSessionId: 'payments' }, + }, + }), + { + actionId: 'action-resume', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + 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', + 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', + }, + { + disposition: 'resume_work', + outcome: 'parked', + targetSessionId: 'payments', + parkReason: 'safety_check_failed', + }, + ]) { + assert.deepEqual(decodeWorkHubCoordinationActResult(result), result); + } + + for (const invalid of [ + { + disposition: 'resume_work', + outcome: 'parked', + targetSessionId: 'payments', + }, + { + disposition: 'resume_work', + outcome: 'parked', + targetSessionId: 'payments', + parkReason: 'invented', + }, + { + 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/turn.ts b/packages/runtime-host/src/protocol/turn.ts index dafa428649..5f3dc33cda 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -35,6 +35,10 @@ import { decodeSkillInvocationResult, type SkillInvocationResult, } from '@maka/core/skill-invocation'; +import { + SAFE_BOUNDARY_RESUME_PARK_REASONS, + type SafeBoundaryResumeParkReason, +} from '@maka/core/runtime-invocation'; import { invalidProtocolFrame } from './errors.js'; import { assertExactKeys, @@ -113,20 +117,9 @@ export interface TurnResumeStartInput { sourceRuntimeEventHighWater: number; } -export const TURN_RESUME_PARK_REASONS = [ - 'resume_candidate_missing', - 'source_run_unreadable', - 'safety_check_failed', - 'continuation_already_exists', - 'continuation_repair_required', - 'continuation_started_indeterminate', - 'resume_feature_disabled', - 'continuation_authority_unavailable', - 'safety_observation_unavailable', - 'session_busy', -] as const; - -export type TurnResumeParkReason = (typeof TURN_RESUME_PARK_REASONS)[number]; +export const TURN_RESUME_PARK_REASONS = SAFE_BOUNDARY_RESUME_PARK_REASONS; + +export type TurnResumeParkReason = SafeBoundaryResumeParkReason; export type TurnResumePlan = | { diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index fb0fa08195..04ac2dcfb0 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -17,6 +17,11 @@ * under the License. */ +import { + SAFE_BOUNDARY_RESUME_PARK_REASONS, + type SafeBoundaryResumeParkReason, +} from '@maka/core/runtime-invocation'; + import { requireCount, requireEntityId, @@ -226,6 +231,7 @@ export type WorkHubCoordinationActResult = readonly outcome: 'resume_started' | 'already_running' | 'parked'; readonly targetSessionId: string; readonly targetTurnId?: string; + readonly parkReason?: SafeBoundaryResumeParkReason; }; export const WORKHUB_COORDINATION_OPERATION_SPECS = { @@ -545,7 +551,7 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord result, 'WorkHub Coordination resume result', ['disposition', 'outcome', 'targetSessionId'], - ['targetTurnId'], + ['targetTurnId', 'parkReason'], ); if ( exact.outcome !== 'resume_started' && @@ -559,6 +565,13 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord if ((exact.outcome === 'resume_started') !== (exact.targetTurnId !== undefined)) { throw invalidProtocolFrame('Invalid WorkHub resume target Turn'); } + if ( + exact.outcome === 'parked' + ? !(SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(exact.parkReason) + : exact.parkReason !== undefined + ) { + throw invalidProtocolFrame('Invalid WorkHub resume park reason'); + } return { disposition: 'resume_work', outcome: exact.outcome, @@ -568,6 +581,9 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord : { targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), }), + ...(exact.parkReason === undefined + ? {} + : { parkReason: exact.parkReason as SafeBoundaryResumeParkReason }), }; } throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index e6976b7e74..95038e4464 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1383,68 +1383,55 @@ 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 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. - planResume: async (assignment, previous, context) => { - let source: - | { readonly sessionId: string; readonly turnId: string; readonly runId: string } - | undefined; - if ( - previous?.outcome === 'resume_started' && - previous.targetTurnId && - previous.targetRunId - ) { - source = { - sessionId: assignment.targetSessionId, - turnId: previous.targetTurnId, - runId: previous.targetRunId, - }; - } else { - const disposition = await messages.readMessageExecutionDisposition( - assignment.targetSessionId, - assignment.targetMessageId, - ); - if (disposition.kind !== 'owned_root') return { kind: 'parked' as const }; - source = { - sessionId: assignment.targetSessionId, - turnId: disposition.turnId, - runId: disposition.runId, - }; - } - let snapshot; - try { - snapshot = await coordinator.read(source); - } catch { - return { kind: 'parked' as const }; + planResume: async (assignment, context) => { + const disposition = await messages.readMessageExecutionDisposition( + assignment.targetSessionId, + assignment.targetMessageId, + ); + if (disposition.kind === 'recovering') return { kind: 'recovering' as const }; + if (disposition.kind !== 'owned_root') { + return { kind: 'parked' as const, parkReason: 'resume_candidate_missing' as const }; } - if ( - snapshot.status === 'admitted' || - snapshot.status === 'created' || - snapshot.status === 'running' - ) { + const source = await coordinator.readLatestRootTurnLineage({ + sessionId: assignment.targetSessionId, + turnId: disposition.turnId, + runId: disposition.runId, + }); + if (isActiveWorkHubRoot(coordinator, source)) { return { kind: 'already_running' as const }; } + const snapshot = await coordinator.read(source); + if (!isHostedExecutionTerminal(snapshot)) return { kind: 'recovering' as const }; if (snapshot.status !== 'failed' && snapshot.status !== 'cancelled') { - return { kind: 'parked' as const }; + return { kind: 'parked' as const, parkReason: 'resume_candidate_missing' as const }; } 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') { + return { kind: 'parked' as const, parkReason: plan.result.reason }; + } if ( - !plan.ok || - plan.result.disposition === 'parked' || plan.result.sourceRunId !== source.runId || plan.result.sourceTurnId !== source.turnId - ) - return { kind: 'parked' as const }; + ) { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub resume source lineage changed during planning', + ); + } return { kind: 'ready' as const, sourceTurnId: plan.result.sourceTurnId, @@ -1459,8 +1446,12 @@ export async function createExecutionRuntimeHostComposition( !request.sourceRunId || request.sourceRuntimeEventHighWater === undefined || !request.targetTurnId - ) - return { outcome: 'parked' as const }; + ) { + throw new WorkHubActionEffectFailure( + 'persistence_failed', + 'WorkHub durable resume plan is incomplete', + ); + } const started = await coordinator.handlers['turn.resume.start']( { sessionId: request.targetSessionId, @@ -1470,13 +1461,15 @@ export async function createExecutionRuntimeHostComposition( }, context, ); - if (!started.ok || started.result.kind === 'parked') { - return { outcome: 'parked' as const }; + if (!started.ok) { + throw new WorkHubActionEffectFailure(started.error.code, started.error.message); + } + if (started.result.kind === 'parked') { + return { outcome: 'parked' as const, parkReason: started.result.plan.reason }; } return { outcome: 'resume_started' as const, targetTurnId: started.result.turn.turnId, - targetRunId: started.result.turn.runId, }; }, retireDelegation: async (assignment, retirement) => { @@ -1498,11 +1491,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/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index f5db2de586..60ed63b2a8 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -519,6 +519,33 @@ 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 admissions = await this.stores.agentRunStore.listRootTurnAdmissionsForRecovery( + identity.sessionId, + ); + const originIndex = admissions.findIndex( + ({ turnId, runId }) => turnId === identity.turnId && runId === identity.runId, + ); + if (originIndex === -1) { + throw new RuntimeMessageAuthorityInvariantError( + `Root execution ${identity.turnId}/${identity.runId} has no durable admission`, + ); + } + let latest = admissions[originIndex]!; + for (const admission of admissions.slice(originIndex + 1)) { + const execution = admission.execution; + if ( + execution.kind === 'safe_boundary_continuation' && + execution.sourceTurnId === latest.turnId && + execution.sourceRunId === latest.runId + ) { + latest = admission; + } + } + return { sessionId: latest.sessionId, turnId: latest.turnId, runId: latest.runId }; + } + startHostedExternalTransition( input: HostedExternalTurnTransitionInput, context: ConnectionContext, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 892eff27b4..2ef0bf870f 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -44,6 +44,7 @@ import { import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; import type { OperationOutcome, + TurnResumeParkReason, WorkHubCoordinationActResult, WorkHubCoordinationActInput, WorkHubCoordinationAnswerInput, @@ -118,7 +119,9 @@ type CoordinationExecutions = Pick< >; type WorkHubResumePlan = - | { readonly kind: 'already_running' | 'parked' } + | { readonly kind: 'already_running' } + | { readonly kind: 'recovering' } + | { readonly kind: 'parked'; readonly parkReason: TurnResumeParkReason } | { readonly kind: 'ready'; readonly sourceTurnId: string; @@ -127,11 +130,12 @@ type WorkHubResumePlan = readonly targetTurnId: string; }; -interface WorkHubResumeResult { - readonly outcome: 'resume_started' | 'already_running' | 'parked'; - readonly targetTurnId?: string; - readonly targetRunId?: string; -} +type WorkHubResumeResult = + | { + readonly outcome: 'resume_started'; + readonly targetTurnId: string; + } + | { readonly outcome: 'parked'; readonly parkReason: TurnResumeParkReason }; type CoordinationSessionActions = Pick< WorkHubActionGateEffects, @@ -139,7 +143,6 @@ type CoordinationSessionActions = Pick< > & { planResume( assignment: WorkHubDelegationAssignedMessage, - previous: WorkHubDelegationResumeResolvedMessage | undefined, context: ConnectionContext, ): Promise; resumeDelegation( @@ -430,23 +433,20 @@ export class HostWorkHubCoordinationCoordinator { if ('request' in input) { request = input.request; } else { - const previous = (await this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)) - .filter( - (message): message is WorkHubDelegationResumeResolvedMessage => - message.type === 'workhub_coordination' && - message.kind === 'delegation_resume_resolved' && - message.resumesDelegationId === input.source.delegationId && - message.outcome === 'resume_started', - ) - .at(-1); - const plan = await actions.planResume(input.source, previous, context); + const plan = await actions.planResume(input.source, context); + if (plan.kind === 'recovering') { + throw new WorkHubActionEffectFailure( + 'operation_unavailable', + 'WorkHub is still recovering the delegated execution', + ); + } const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); request = await this.#commitCoordinationFact({ admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.source.targetSessionId], read: () => this.#stores.readWorkHubResumeRequest(input.actionId), build: (existing) => ({ type: 'workhub_coordination', - id: `whr_${suffix}`, + id: `whu_${suffix}`, turnId: input.actionId, ts: existing?.ts ?? Date.now(), schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, @@ -461,6 +461,7 @@ export class HostWorkHubCoordinationCoordinator { targetSessionName: input.targetSessionName, userText: input.userText, plan: plan.kind, + ...(plan.kind === 'parked' ? { parkReason: plan.parkReason } : {}), ...(plan.kind === 'ready' ? { sourceTurnId: plan.sourceTurnId, @@ -489,10 +490,12 @@ export class HostWorkHubCoordinationCoordinator { const existing = await this.#stores.readWorkHubResumeResolution(request.actionId); if (existing) return coordinationResumeResult(existing); - const resumed: WorkHubResumeResult = + const resumed = request.plan === 'ready' ? await actions.resumeDelegation(request, context) - : { outcome: request.plan }; + : request.plan === 'parked' + ? { outcome: 'parked' as const, parkReason: request.parkReason! } + : { outcome: 'already_running' as const }; const suffix = createHash('sha256').update(request.actionId, 'utf8').digest('hex').slice(0, 48); const resolution = await this.#commitCoordinationFact({ read: () => this.#stores.readWorkHubResumeResolution(request.actionId), @@ -510,8 +513,8 @@ export class HostWorkHubCoordinationCoordinator { resumesDelegationId: request.resumesDelegationId, targetSessionId: request.targetSessionId, outcome: resumed.outcome, - ...(resumed.targetTurnId ? { targetTurnId: resumed.targetTurnId } : {}), - ...(resumed.targetRunId ? { targetRunId: resumed.targetRunId } : {}), + ...(resumed.outcome === 'parked' ? { parkReason: resumed.parkReason } : {}), + ...(resumed.outcome === 'resume_started' ? { targetTurnId: resumed.targetTurnId } : {}), }), conflictMessage: 'WorkHub resume already has a different resolution', beforeAppend: async () => { @@ -972,6 +975,7 @@ function coordinationResumeResult( outcome: resolution.outcome, targetSessionId: resolution.targetSessionId, ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), + ...(resolution.parkReason ? { parkReason: resolution.parkReason } : {}), }; } diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 822ef99d7b..7d259d5b14 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -784,7 +784,7 @@ class SqliteSessionStore implements SessionAuthorityStore { actionId: string, ): Promise { const message = await this.readWorkHubCoordinationMessage( - `whr_${workHubIdentitySuffix(actionId)}`, + `whu_${workHubIdentitySuffix(actionId)}`, ); return message?.type === 'workhub_coordination' && message.kind === 'delegation_resume_requested' From 3616bcb300e52c7f369a19bc18835c40025f3c1e Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 15:56:34 +0800 Subject: [PATCH 07/10] style(core): format resume record validation --- packages/core/src/session.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 07ea5a08b9..38287f7d37 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1726,8 +1726,7 @@ function isWorkHubCoordinationMessage(message: Record): boolean ? (SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(message.parkReason) : message.parkReason === undefined) && (started - ? typeof message.targetTurnId === 'string' && - message.targetTurnId.length > 0 + ? typeof message.targetTurnId === 'string' && message.targetTurnId.length > 0 : message.targetTurnId === undefined) ); } From 2f1984b504865bc741c6081499b0101fdfb146c9 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 17:46:19 +0800 Subject: [PATCH 08/10] refactor(workhub): collapse resume coordination --- .../main/__tests__/workhub-controller.test.ts | 79 ++------ .../__tests__/workhub-session-port.test.ts | 29 +-- .../__tests__/workhub-surface-flow.test.ts | 31 --- .../src/renderer/workhub-controller.ts | 38 ++-- .../src/renderer/workhub-coordination-port.ts | 22 +-- .../src/renderer/workhub-route-policy.ts | 39 +--- apps/desktop/src/renderer/workhub-surface.tsx | 43 ++--- .../workhub-coordination-record.test.ts | 49 ++--- packages/core/src/runtime-invocation.ts | 15 -- packages/core/src/session.ts | 148 +++----------- packages/core/src/workhub-creation-intent.ts | 2 - .../__tests__/execution-composition.test.ts | 86 ++++++++- .../__tests__/root-admission-owner.test.ts | 4 + .../__tests__/root-turn-coordinator.test.ts | 6 + .../workhub-coordination-action-gate.test.ts | 182 ++++++------------ .../workhub-coordination-coordinator.test.ts | 80 +++----- .../workhub-coordination-protocol.test.ts | 12 -- packages/runtime-host/src/protocol/turn.ts | 21 +- .../src/protocol/workhub-coordination.ts | 32 +-- .../src/server/execution-composition.ts | 84 ++++---- .../src/server/root-turn-coordinator.ts | 27 ++- .../workhub-coordination-action-gate.ts | 114 +++-------- .../workhub-coordination-coordinator.ts | 135 +++---------- ...fe-boundary-continuation-admission.test.ts | 79 ++++++++ packages/storage/src/agent-run-store.ts | 44 +++++ packages/storage/src/execution-stores.ts | 18 +- packages/storage/src/session-store.ts | 28 +-- .../src/sqlite-core-execution-schema.ts | 10 +- 28 files changed, 580 insertions(+), 877 deletions(-) create mode 100644 packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index a82050ebc8..4b30d129b5 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -461,37 +461,6 @@ test('a named resume submits and reports what the Host did', async () => { await handle.close(); }); -test('a parked resume preserves the Host park reason', 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('a resume must not read route candidates'), - act: async () => ({ - disposition: 'resume_work', - outcome: 'parked', - targetSessionId: 'payments', - parkReason: 'safety_check_failed', - }), - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - assert.deepEqual(await controller.submit({ requestId: 'resume-parked', text: 'Resume Payments' }), { - kind: 'resume', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'resume-parked', - target: { sessionId: 'payments' }, - outcome: 'parked', - parkReason: 'safety_check_failed', - }); - 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({ @@ -524,52 +493,34 @@ test('a resume the Host will not admit becomes its clarification', async () => { await handle.close(); }); -test('resume-shaped ordinary work routes normally instead of resuming', async () => { - // `Continue` is an ordinary English verb. A resume that recalls no WorkHub - // identity is work to do, not a command over a delegation. - const sessions = port([session('payments', { sessionName: 'Payments' })]); - const actions: WorkHubCoordinationActInput[] = []; +test('a Runtime Host without safe-boundary resume explains why it cannot resume', async () => { const controller = createGatedWorkHubController({ - sessions, + 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:${'c'.repeat(64)}`, - candidates: [{ - candidateRef: 'ref-payments', - sessionId: 'payments', - sessionName: 'Payments', - workspace: { - target: { kind: 'host_path' as const, path: '/workspace/payments' }, - hostCwd: '/workspace/payments', - }, - state: 'active' as const, - updatedAt: 1, - }], - }), - act: async (input) => { - actions.push(input); - return { - disposition: 'delegate_existing', - targetSessionId: 'payments', - targetTurnId: 'delegated-turn', - }; + candidates: async () => assert.fail('a resume must not read route candidates'), + act: async () => { + throw new WorkHubCoordinationFailure( + 'operation_unavailable', + 'Safe-boundary resume is disabled for this Runtime Host', + ); }, }, }); const handle = await controller.openConversation(() => undefined, () => undefined); - const result = await controller.submit({ - requestId: 'ordinary-1', - text: 'Continue the refactor in Payments', + 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_target_unavailable', }); - - assert.equal(result.kind, 'submitted'); - assert.equal(actions[0]?.proposal.disposition, 'delegate_existing'); await handle.close(); }); diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index 0f70c41df5..7a4e704d29 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -318,33 +318,18 @@ test('direct-stop projection is retryable until resolved and preserves not_owned }); test('resume projection survives reload and exposes its durable outcome', () => { - const requested: StoredMessage = { - type: 'workhub_coordination', id: 'resume-request', turnId: 'resume-action', ts: 2, - schemaVersion: 4, kind: 'delegation_resume_requested', actionId: 'resume-action', - actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'resume-action', - resumesActionId: 'source-action', resumesDelegationId: 'payments-delegation', - targetSessionId: 'payments', targetMessageId: 'payments-message', - targetSessionName: 'Payments', userText: 'Resume Payments', plan: 'ready', - sourceTurnId: 'failed-turn', sourceRunId: 'failed-run', - sourceRuntimeEventHighWater: 4, targetTurnId: 'resumed-turn', - }; - const resolved: StoredMessage = { - type: 'workhub_coordination', id: 'resume-resolution', turnId: 'resume-action', ts: 3, - schemaVersion: 4, kind: 'delegation_resume_resolved', actionId: 'resume-action', + const resumed: StoredMessage = { + type: 'workhub_coordination', id: 'resume', turnId: 'resume-action', ts: 3, + schemaVersion: 4, kind: 'delegation_resume', actionId: 'resume-action', actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'resume-action', resumesActionId: 'source-action', resumesDelegationId: 'payments-delegation', - targetSessionId: 'payments', outcome: 'resume_started', + targetSessionId: 'payments', + targetSessionName: 'Payments', userText: 'Resume Payments', outcome: 'resume_started', targetTurnId: 'resumed-turn', }; - assert.deepEqual(projectWorkHubCoordinationTurns([requested]), [{ - messageId: 'resume-request', turnId: 'resume-action', text: 'Resume Payments', - state: 'running', - resume: { targetSessionId: 'payments', targetSessionName: 'Payments' }, - updatedAt: 2, - }]); - assert.deepEqual(projectWorkHubCoordinationTurns([requested, resolved]), [{ - messageId: 'resume-request', turnId: 'resume-action', text: 'Resume Payments', + assert.deepEqual(projectWorkHubCoordinationTurns([resumed]), [{ + messageId: 'resume', turnId: 'resume-action', text: 'Resume Payments', state: 'completed', resume: { targetSessionId: 'payments', targetSessionName: 'Payments', outcome: 'resume_started', 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 fcb62fe77a..e88af5b222 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -210,37 +210,6 @@ test('durable delegation renders terminal link state instead of stale execution } }); -test('a parked resume owns the durable frame state', () => { - const turn: WorkHubCoordinationTurn = { - messageId: 'resume-parked', - turnId: 'resume-parked', - text: 'Resume Payments', - state: 'completed', - resume: { - targetSessionId: 'payments', - targetSessionName: 'Payments', - outcome: 'parked', - }, - updatedAt: 10, - }; - const markup = renderToStaticMarkup( - createElement(LocaleProvider, { - locale: 'en', - children: createElement(AstryxLocaleProvider, { - children: createElement(WorkHubCoordinationTurnView, { - turn, - projection: { sessions: [], turns: [] }, - locale: 'en', - onOpenSession: () => undefined, - }), - }), - }), - ); - - assert.match(markup, /data-state="parked"/u); - assert.doesNotMatch(markup, /data-state="completed"/u); -}); - test('durable creation explicitly announces the new work', () => { const turn: WorkHubCoordinationTurn = { messageId: 'created-assignment', diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index e392c4c93d..24c8dc6868 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -136,7 +136,7 @@ export interface WorkHubCoordinationTurn { resume?: { readonly targetSessionId: string; readonly targetSessionName: string; - readonly outcome?: Extract['outcome']; + readonly outcome: Extract['outcome']; }; updatedAt: number; } @@ -220,10 +220,6 @@ export type WorkHubSubmission = ( target: WorkHubSessionTarget; outcome: Extract['outcome']; targetTurnId?: string; - parkReason?: Extract< - WorkHubCoordinationActResult, - { disposition: 'resume_work' } - >['parkReason']; } ) & { strategyId: WorkHubRoutingStrategyId }; @@ -364,29 +360,43 @@ export function createWorkHubController(deps: { }, ...(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 { - kind, - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - target, + ...result, + kind: 'resume', outcome: admitted.outcome, ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}), - ...(admitted.parkReason ? { parkReason: admitted.parkReason } : {}), }; } if (kind === 'stop' && admitted.disposition === 'stop_work') { return { - kind, - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - target, + ...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: 'resume_target_unavailable', + }; + } if (error instanceof WorkHubCoordinationFailure && error.code === 'operation_conflict') { return { kind: 'clarification', diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index c9d2cdc0aa..3d266556c2 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -38,12 +38,6 @@ import type { } from '@maka/runtime-host/protocol'; import { boundedWorkHubTimelineText, WorkHubCoordinationFailure } from './workhub-controller.js'; -export function isWorkHubCoordinationFailure( - error: unknown, -): error is WorkHubCoordinationFailure { - return error instanceof WorkHubCoordinationFailure; -} - import type { WorkHubDesktopTranscriptBridge } from './workhub-session-port.js'; const WORKHUB_COORDINATION_TURN_LIMIT = 40; @@ -172,32 +166,24 @@ export function projectWorkHubCoordinationTurns( : [], ), ); - const resumeResolutionByActionId = new Map( - messages.flatMap((message) => - message.type === 'workhub_coordination' && message.kind === 'delegation_resume_resolved' - ? [[message.actionId, message] as const] - : [], - ), - ); for (const message of messages) { const terminal = terminalDelegationLink(message); if (terminal) terminalLinkState.set(terminal.delegationId, terminal.state); } for (const message of messages) { - if (message.type === 'workhub_coordination' && message.kind === 'delegation_resume_requested') { - const resolution = resumeResolutionByActionId.get(message.actionId); + if (message.type === 'workhub_coordination' && message.kind === 'delegation_resume') { turns.push({ messageId: message.id, turnId: message.coordinationTurnId, text: boundedWorkHubTimelineText(message.userText), - state: resolution ? 'completed' : 'running', + state: 'completed', resume: { targetSessionId: message.targetSessionId, targetSessionName: message.targetSessionName, - ...(resolution ? { outcome: resolution.outcome } : {}), + outcome: message.outcome, }, - updatedAt: resolution ? Math.max(message.ts, resolution.ts) : message.ts, + updatedAt: message.ts, }); continue; } diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index 68733abb80..8a47325efe 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -100,11 +100,6 @@ export interface WorkHubRoutePolicy { text: string; sessions: WorkHubRoutableSession[]; }): WorkHubNamedActionRouteDecision; - /** - * Resume reads the same way a stop does and refuses on the same terms. The - * two share one resolver and one tail rule so `Resume Payments` and - * `Stop Payments` cannot disagree about which Session they mean. - */ resolveResume(input: { text: string; sessions: WorkHubRoutableSession[]; @@ -145,31 +140,12 @@ const MIN_STRONG_SINGLE_LATIN_LENGTH = 8; const MAX_UNCERTAINTY_OPTIONS = 5; const MAX_RELATED_CLARIFICATION_OPTIONS = 4; -/** - * The reference half of a direct action over one existing delegation. - * - * Action Intent says only that the user issued this imperative and what work it - * refers to; the shared Session Resolver recalls which visible Sessions that - * reference names; this decides whether the resolution is sufficient to submit. - * - * Stop and resume ask exactly this, so they share it. What the Host then does - * with the named Session — end its delegation, or carry it on — is the Host's, - * and neither action claims to know whether there is one to act on. - */ function resolveNamedDelegationAction( sessionResolver: WorkHubSessionResolver, - action: { readonly requested: boolean; readonly target?: string }, + reference: string, sessions: WorkHubRoutableSession[], ambiguousReason: WorkHubStopClarificationReason, - unnamedReason?: WorkHubStopClarificationReason, ): WorkHubNamedActionRouteDecision { - if (!action.requested) return { kind: 'not_requested' }; - const reference = action.target; - if (!reference) { - return unnamedReason - ? { kind: 'clarification', reason: unnamedReason } - : { kind: 'not_requested' }; - } const sessionByRef = new Map(sessions.map((session) => [session.target.sessionId, session])); const resolution = sessionResolver.resolve({ reference: { text: reference }, @@ -228,22 +204,23 @@ function createWorkHubRoutePolicyVisit( // stoppable says why. resolveStop({ text, sessions }) { const action = readWorkHubRequestIntent(text).stop; + if (!action.cue) return { kind: 'not_requested' }; + if (!action.imperative || !action.target) { + return { kind: 'clarification', reason: 'stop_target_required' }; + } return resolveNamedDelegationAction( sessionResolver, - { requested: action.cue, ...(action.imperative ? { target: action.target } : {}) }, + action.target, sessions, 'stop_target_ambiguous', - 'stop_target_required', ); }, - // Resume asks the same question of the same words, so it asks it with the - // same code. Two copies of this would be two chances for `Resume Payments` - // and `Stop Payments` to disagree about which Session they name. resolveResume({ text, sessions }) { const action = readWorkHubRequestIntent(text).resume; + if (!action.imperative || !action.target) return { kind: 'not_requested' }; return resolveNamedDelegationAction( sessionResolver, - { requested: action.imperative, ...(action.target ? { target: action.target } : {}) }, + action.target, sessions, 'resume_target_ambiguous', ); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 05f0316278..1a2960cb4c 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 { + WorkHubCoordinationFailure, + type WorkHubController, + type WorkHubCoordinationTurn, + type WorkHubDelegationLinkState, + type WorkHubProjection, + type WorkHubSessionSummary, + type WorkHubSubmission, + type WorkHubSubmitInput, } from './workhub-controller.js'; import { WorkHubSendLease, type WorkHubSendAttempt, } from './workhub-send-lease.js'; -import { isWorkHubCoordinationFailure } from './workhub-coordination-port.js'; export interface WorkHubConversationTurn { requestId: string; @@ -95,7 +95,7 @@ export function workHubSubmissionClearsDraft( } export function workHubSurfaceFailure(error: unknown): WorkHubSurfaceFailure { - if (isWorkHubCoordinationFailure(error)) { + if (error instanceof WorkHubCoordinationFailure) { if (error.code === 'operation_conflict') return 'action_changed'; if (error.code === 'not_found' || error.code === 'session_archived') { return 'candidates_changed'; @@ -467,7 +467,7 @@ export function WorkHubSurface(props: { function isTerminalWorkHubSurfaceFailure(error: unknown): boolean { return ( - isWorkHubCoordinationFailure(error) && + error instanceof WorkHubCoordinationFailure && (error.code === 'operation_conflict' || error.code === 'not_found' || error.code === 'session_archived' || @@ -611,10 +611,8 @@ export function WorkHubCoordinationTurnView(props: { session={resumedSession} targetSessionId={props.turn.resume.targetSessionId} fallbackName={props.turn.resume.targetSessionName} - heading={props.turn.resume.outcome - ? copy.resumeOutcomes[props.turn.resume.outcome] - : copy.resumingWork} - state={props.turn.resume.outcome ? copy.resumeRecorded : copy.resuming} + heading={copy.resumeOutcomes[props.turn.resume.outcome]} + state={copy.resumeRecorded} result={undefined} copy={copy} onOpenSession={props.onOpenSession} @@ -895,11 +893,10 @@ function workHubCopy(locale: UiLocale) { resumeOutcomes: { resume_started: '已让中断的工作继续:', already_running: '这项工作还在跑,不需要恢复:', - parked: '无法继续这项工作;请打开该 Session 查看原因:', }, - resumingWork: '正在请求继续:', resuming: '正在处理', resumeRecorded: '结果已记录', + resumeRecorded: '结果已记录', resumeTargetAmbiguous: '这个名称对应多项工作;请打开具体的 Session 继续它。', - resumeTargetUnavailable: '这项工作现在没有可以继续的单个 WorkHub 委派;请打开该 Session 查看。', + resumeTargetUnavailable: '当前 Runtime Host 无法继续这项工作;安全边界恢复可能未启用,或 Host 仍在恢复。', waitingForDecision: '这项工作正在等待你的决定。', requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', @@ -972,11 +969,10 @@ function workHubCopy(locale: UiLocale) { resumeOutcomes: { resume_started: '已讓中斷的工作繼續:', already_running: '這項工作仍在執行,不需要恢復:', - parked: '無法繼續這項工作;請開啟該 Session 檢視原因:', }, - resumingWork: '正在請求繼續:', resuming: '正在處理', resumeRecorded: '結果已記錄', + resumeRecorded: '結果已記錄', resumeTargetAmbiguous: '這個名稱對應多項工作;請開啟具體的 Session 繼續它。', - resumeTargetUnavailable: '這項工作現在沒有可以繼續的單一 WorkHub 委派;請開啟該 Session 檢視。', + resumeTargetUnavailable: '目前 Runtime Host 無法繼續這項工作;安全邊界恢復可能未啟用,或 Host 仍在恢復。', submitFailures: { candidates_changed: '工作清單已變更,請重新傳送以使用最新目標。', linked_correction_unavailable: '跨 Session 更正將於持久委派關聯完成後開放;請先開啟原 Session 並停止目前工作。', @@ -1035,13 +1031,12 @@ function workHubCopy(locale: UiLocale) { resumeOutcomes: { resume_started: 'Carried on the interrupted work:', already_running: 'This work is still running, so there was nothing to resume:', - parked: 'Could not carry this work on. Open its Session to see why:', }, - resumingWork: 'Requesting resume:', resuming: 'Resuming', resumeRecorded: 'Result recorded', + resumeRecorded: 'Result recorded', resumeTargetAmbiguous: 'That name matches more than one work item. Open the exact Session to resume it.', resumeTargetUnavailable: - 'This work has no single WorkHub delegation to resume right now. Open its Session to see what is running.', + 'The current Runtime Host cannot resume this work. Safe-boundary resume may be disabled, or the Host may still be recovering.', waitingForDecision: 'This work is waiting for your decision.', requestNotSent: 'The new request was not sent. Resolve the interaction in its Session, then send again.', routing: 'Choosing the right Session…', loadFailed: 'Could not read existing work.', diff --git a/packages/core/src/__tests__/workhub-coordination-record.test.ts b/packages/core/src/__tests__/workhub-coordination-record.test.ts index 04037f7e01..9a9ee1e51a 100644 --- a/packages/core/src/__tests__/workhub-coordination-record.test.ts +++ b/packages/core/src/__tests__/workhub-coordination-record.test.ts @@ -262,64 +262,39 @@ describe('WorkHub Coordination stored records', () => { ); }); - test('decodes exact resume plan and observed resolution records', () => { - const requested = { + test('decodes one exact observed resume record', () => { + const resumed = { type: 'workhub_coordination', - id: 'resume-request-id', + id: 'resume-id', turnId: 'resume-action', ts: 6, schemaVersion: 4, - kind: 'delegation_resume_requested', + kind: 'delegation_resume', actionId: 'resume-action', actionFingerprint: FINGERPRINT, coordinationTurnId: 'resume-action', resumesActionId: 'original-action', resumesDelegationId: 'original-delegation', targetSessionId: 'payments', - targetMessageId: 'payments-message', targetSessionName: 'Payments', userText: 'Resume Payments', - plan: 'ready', - sourceTurnId: 'failed-turn', - sourceRunId: 'failed-run', - sourceRuntimeEventHighWater: 12, - targetTurnId: 'resumed-turn', - } as const; - const resolved = { - type: 'workhub_coordination', - id: 'resume-resolution-id', - turnId: 'resume-action', - ts: 7, - schemaVersion: 4, - kind: 'delegation_resume_resolved', - actionId: 'resume-action', - actionFingerprint: FINGERPRINT, - coordinationTurnId: 'resume-action', - resumesActionId: 'original-action', - resumesDelegationId: 'original-delegation', - targetSessionId: 'payments', outcome: 'resume_started', targetTurnId: 'resumed-turn', } as const; - assert.deepEqual(decodeCanonicalMessage(requested), requested); - assert.deepEqual(decodeCanonicalMessage(resolved), resolved); + assert.deepEqual(decodeCanonicalMessage(resumed), resumed); for (const invalid of [ - { ...requested, sourceRunId: undefined }, - { ...requested, sourceRuntimeEventHighWater: -1 }, - { ...requested, plan: 'parked', parkReason: undefined }, - { ...resolved, targetTurnId: undefined }, - { ...resolved, outcome: 'parked', parkReason: undefined }, - { ...resolved, sourceRunId: 'injected' }, + { ...resumed, targetTurnId: undefined }, + { ...resumed, outcome: 'parked' }, + { ...resumed, sourceRunId: 'injected' }, ]) { assert.throws(() => decodeCanonicalMessage(invalid), /Invalid stored message schema/u); } - const parked = { - ...resolved, - outcome: 'parked' as const, - parkReason: 'safety_check_failed' as const, + const alreadyRunning = { + ...resumed, + outcome: 'already_running' as const, targetTurnId: undefined, }; - assert.deepEqual(decodeCanonicalMessage(parked), parked); + assert.deepEqual(decodeCanonicalMessage(alreadyRunning), alreadyRunning); }); }); diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index a8050d7752..e020d7f14f 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -34,21 +34,6 @@ import type { } from './runtime-event.js'; import { isTerminalRuntimeEvent } from './runtime-event.js'; -export const SAFE_BOUNDARY_RESUME_PARK_REASONS = [ - 'resume_candidate_missing', - 'source_run_unreadable', - 'safety_check_failed', - 'continuation_already_exists', - 'continuation_repair_required', - 'continuation_started_indeterminate', - 'resume_feature_disabled', - 'continuation_authority_unavailable', - 'safety_observation_unavailable', - 'session_busy', -] as const; - -export type SafeBoundaryResumeParkReason = (typeof SAFE_BOUNDARY_RESUME_PARK_REASONS)[number]; - export interface RuntimeInvocationRecord { sessionId: string; invocationId: string; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 38287f7d37..4e4e7044bd 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -49,10 +49,6 @@ import { import { markPersisted, type PersistedValue } from './persisted-value.js'; import type { SubagentWorkspaceBinding } from './subagent-workspace.js'; import { decodeTurnOrigin, type TurnOrigin } from './turn-origin.js'; -import { - SAFE_BOUNDARY_RESUME_PARK_REASONS, - type SafeBoundaryResumeParkReason, -} from './runtime-invocation.js'; export { DEEP_RESEARCH_SESSION_LABEL, isDeepResearchSession } from './deep-research.js'; @@ -1082,47 +1078,23 @@ export interface WorkHubDelegationStopResolvedMessage { targetTurnId?: string; } -/** Durable resume plan written before attempting a continuation. */ -export interface WorkHubDelegationResumeRequestedMessage { +/** Durable observed result of a resume attempt. */ +export interface WorkHubDelegationResumeMessage { type: 'workhub_coordination'; id: string; turnId: string; ts: number; schemaVersion: typeof WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION; - kind: 'delegation_resume_requested'; + kind: 'delegation_resume'; actionId: string; actionFingerprint: `sha256:${string}`; coordinationTurnId: string; resumesActionId: string; resumesDelegationId: string; targetSessionId: string; - targetMessageId: string; targetSessionName: string; userText: string; - plan: 'ready' | 'already_running' | 'parked'; - parkReason?: SafeBoundaryResumeParkReason; - sourceTurnId?: string; - sourceRunId?: string; - sourceRuntimeEventHighWater?: number; - targetTurnId?: string; -} - -/** Durable observed result of a resume attempt. */ -export interface WorkHubDelegationResumeResolvedMessage { - type: 'workhub_coordination'; - id: string; - turnId: string; - ts: number; - schemaVersion: typeof WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION; - kind: 'delegation_resume_resolved'; - actionId: string; - actionFingerprint: `sha256:${string}`; - coordinationTurnId: string; - resumesActionId: string; - resumesDelegationId: string; - targetSessionId: string; - outcome: 'resume_started' | 'already_running' | 'parked'; - parkReason?: SafeBoundaryResumeParkReason; + outcome: 'resume_started' | 'already_running'; targetTurnId?: string; } @@ -1165,8 +1137,7 @@ export type WorkHubCoordinationMessage = | WorkHubDelegationSupersededMessage | WorkHubDelegationStopRequestedMessage | WorkHubDelegationStopResolvedMessage - | WorkHubDelegationResumeRequestedMessage - | WorkHubDelegationResumeResolvedMessage; + | WorkHubDelegationResumeMessage; function isWorkHubDelegationStopResolution( outcome: unknown, @@ -1436,47 +1407,26 @@ const WORKHUB_DELEGATION_STOP_RESOLVED_MESSAGE_SHAPE = ], ['targetTurnId'], ); -const WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE = - defineObjectShape()( - [ - 'type', - 'id', - 'turnId', - 'ts', - 'schemaVersion', - 'kind', - 'actionId', - 'actionFingerprint', - 'coordinationTurnId', - 'resumesActionId', - 'resumesDelegationId', - 'targetSessionId', - 'targetMessageId', - 'targetSessionName', - 'userText', - 'plan', - ], - ['parkReason', 'sourceTurnId', 'sourceRunId', 'sourceRuntimeEventHighWater', 'targetTurnId'], - ); -const WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE = - defineObjectShape()( - [ - 'type', - 'id', - 'turnId', - 'ts', - 'schemaVersion', - 'kind', - 'actionId', - 'actionFingerprint', - 'coordinationTurnId', - 'resumesActionId', - 'resumesDelegationId', - 'targetSessionId', - 'outcome', - ], - ['parkReason', 'targetTurnId'], - ); +const WORKHUB_DELEGATION_RESUME_MESSAGE_SHAPE = defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'resumesActionId', + 'resumesDelegationId', + 'targetSessionId', + 'targetSessionName', + 'userText', + 'outcome', + ], + ['targetTurnId'], +); const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( ['title', 'workspace'], [], @@ -1667,12 +1617,11 @@ function decodeMessage( } function isWorkHubCoordinationMessage(message: Record): boolean { - if (message.kind === 'delegation_resume_requested') { - const ready = message.plan === 'ready'; - const parked = message.plan === 'parked'; + if (message.kind === 'delegation_resume') { + const started = message.outcome === 'resume_started'; return ( hasMessageEnvelope(message, true) && - hasExactShape(message, WORKHUB_DELEGATION_RESUME_REQUESTED_MESSAGE_SHAPE) && + hasExactShape(message, WORKHUB_DELEGATION_RESUME_MESSAGE_SHAPE) && message.schemaVersion === WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION && isWorkHubActionIdentity(message) && typeof message.resumesActionId === 'string' && @@ -1681,50 +1630,11 @@ function isWorkHubCoordinationMessage(message: Record): boolean message.resumesDelegationId.length > 0 && typeof message.targetSessionId === 'string' && message.targetSessionId.length > 0 && - typeof message.targetMessageId === 'string' && - message.targetMessageId.length > 0 && typeof message.targetSessionName === 'string' && message.targetSessionName.trim().length > 0 && typeof message.userText === 'string' && message.userText.trim().length > 0 && - (ready || message.plan === 'already_running' || message.plan === 'parked') && - (parked - ? (SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(message.parkReason) - : message.parkReason === undefined) && - (ready - ? typeof message.sourceTurnId === 'string' && - message.sourceTurnId.length > 0 && - typeof message.sourceRunId === 'string' && - message.sourceRunId.length > 0 && - typeof message.sourceRuntimeEventHighWater === 'number' && - Number.isSafeInteger(message.sourceRuntimeEventHighWater) && - message.sourceRuntimeEventHighWater >= 0 && - typeof message.targetTurnId === 'string' && - message.targetTurnId.length > 0 - : message.sourceTurnId === undefined && - message.sourceRunId === undefined && - message.sourceRuntimeEventHighWater === undefined && - message.targetTurnId === undefined) - ); - } - if (message.kind === 'delegation_resume_resolved') { - const started = message.outcome === 'resume_started'; - const parked = message.outcome === 'parked'; - return ( - hasMessageEnvelope(message, true) && - hasExactShape(message, WORKHUB_DELEGATION_RESUME_RESOLVED_MESSAGE_SHAPE) && - message.schemaVersion === WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION && - isWorkHubActionIdentity(message) && - typeof message.resumesActionId === 'string' && - message.resumesActionId.length > 0 && - typeof message.resumesDelegationId === 'string' && - message.resumesDelegationId.length > 0 && - typeof message.targetSessionId === 'string' && - message.targetSessionId.length > 0 && - (started || message.outcome === 'already_running' || message.outcome === 'parked') && - (parked - ? (SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(message.parkReason) - : message.parkReason === undefined) && + (started || message.outcome === 'already_running') && (started ? typeof message.targetTurnId === 'string' && message.targetTurnId.length > 0 : message.targetTurnId === undefined) diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index 7982a66f57..2c2130e2b1 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -110,8 +110,6 @@ const DIRECT_STOP_REQUEST = // equivalent either. const DIRECT_CHINESE_STOP_REQUEST = /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:停止|停掉|停下|取消|终止|中止)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu; -// Resume asks the Host to carry on work that was interrupted, so it reads the -// same shape as a stop: a direct speech act naming one existing Session. It is 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 = diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 6fdf0ec45b..f01707a5a7 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -834,6 +834,89 @@ test('WorkHub Stop retires the running continuation after Resume', async () => { }); }); +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', + 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.readWorkHubResume(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); @@ -1718,7 +1801,8 @@ async function createCapturedExecutionComposition( return originalRecover.call(this, stores); }; try { - if (options.safeBoundaryResume) process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = '1'; + 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. 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..8fd25edd13 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) => @@ -284,6 +286,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 +369,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 () => [], }; 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..2f0d006510 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) => @@ -3195,6 +3197,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 +3307,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) => 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 4d67f91aab..f26a68238d 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 @@ -25,8 +25,6 @@ import type { WorkHubDelegationAssignedMessage, WorkHubDelegationReplacementAbortedMessage, WorkHubDelegationReplacementRequestedMessage, - WorkHubDelegationResumeRequestedMessage, - WorkHubDelegationResumeResolvedMessage, WorkHubDelegationStopRequestedMessage, WorkHubDelegationStopResolvedMessage, WorkHubDelegationSupersededMessage, @@ -375,7 +373,7 @@ describe('WorkHub Coordination Action Gate', () => { }); assert.equal(effects.resumeCalls.length, 1); const resumeCall = effects.resumeCalls[0]; - assert.ok(resumeCall && !('request' in resumeCall)); + assert.ok(resumeCall); assert.equal(resumeCall.source.actionId, 'source-action'); // Resume claims like every other disposition, so the identity is spent. assert.equal(effects.actionClaims.get('resume-action')?.operation, 'resume'); @@ -403,27 +401,6 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.resumeCalls.length, 0); }); - test('resume replays the durable request through the same deep effect', async () => { - const effects = fakeEffects([session('payments', { name: 'Payments' })]); - delegatedTo(effects, 'payments'); - const gate = new WorkHubCoordinationActionGate(effects); - const input = { - actionId: 'resume-replay', - userText: 'Resume Payments', - proposal: resumeProposal('payments'), - }; - - const first = await gate.act(input, CONTEXT); - effects.resumeOutcome = { outcome: 'parked', parkReason: 'safety_check_failed' }; - const replay = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); - - assert.deepEqual(replay, first); - assert.equal(effects.resumeCalls.length, 2); - assert.ok('request' in effects.resumeCalls[1]!); - assert.equal(effects.resumeRequests.size, 1); - assert.equal(effects.resumeResolutions.size, 1); - }); - test('resume needs a named command and carries no destructive confirmation', async () => { const effects = fakeEffects([session('payments', { name: 'Payments' })]); delegatedTo(effects, 'payments'); @@ -504,33 +481,60 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(several.resumeCalls.length, 0); }); - test('resume reports the Host answer it was given, including a park', async () => { - for (const [outcome, targetTurnId] of [ - ['already_running', undefined], - ['parked', undefined], - ] as const) { - const effects = fakeEffects([session('payments', { name: 'Payments' })]); - delegatedTo(effects, 'payments'); - effects.resumeOutcome = - outcome === 'parked' ? { outcome, parkReason: 'safety_check_failed' } : { outcome }; - - const result = await new WorkHubCoordinationActionGate(effects).act( + 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: `resume-${outcome}`, - userText: 'Resume Payments', - proposal: resumeProposal('payments'), + actionId: 'second-action', + actionFingerprint: `sha256:${'b'.repeat(64)}`, + targetSessionId: 'payments', + targetSessionName: 'Payments', + disposition: 'delegate_existing', + userText: 'Fix the interrupted receipt retry', }, - CONTEXT, - ); + 'second-turn', + ), + ); + effects.retirements.push(retired); - assert.deepEqual(result, { - disposition: 'resume_work', - outcome, - targetSessionId: 'payments', - ...(outcome === 'parked' ? { parkReason: 'safety_check_failed' } : {}), - ...(targetTurnId ? { targetTurnId } : {}), - }); - } + const result = await new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'resume-one-live', + userText: 'Resume Payments', + proposal: resumeProposal('payments'), + }, + CONTEXT, + ); + + assert.equal(result.disposition, 'resume_work'); + const call = effects.resumeCalls[0]; + assert.ok(call); + assert.equal(call.source.actionId, 'second-action'); + }); + + 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 () => { @@ -2732,8 +2736,6 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { const supersessions = new Map(); const stopRequests = new Map(); const stopResolutions = new Map(); - const resumeRequests = new Map(); - const resumeResolutions = new Map(); const actionClaims = new Map(); return { sessions: [...initialSessions], @@ -2752,8 +2754,6 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { supersessions, stopRequests, stopResolutions, - resumeRequests, - resumeResolutions, retirements: [] as WorkHubDelegationAssignedMessage[], retirementClaims: [] as WorkHubDelegationRetirementClaim[], async listSessions() { @@ -2776,84 +2776,19 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', } as { - outcome: 'resume_started' | 'already_running' | 'parked'; - parkReason?: 'safety_check_failed'; + outcome: 'resume_started' | 'already_running'; targetTurnId?: string; }, - async readResumeRequest(actionId: string) { - return resumeRequests.get(actionId); - }, async resume(input: WorkHubDelegationResumeInput) { this.resumeCalls.push(input); - const actionId = 'request' in input ? input.request.actionId : input.actionId; - const existingResolution = resumeResolutions.get(actionId); - if (existingResolution) { - return { - disposition: 'resume_work' as const, - outcome: existingResolution.outcome, - targetSessionId: existingResolution.targetSessionId, - ...(existingResolution.targetTurnId - ? { targetTurnId: existingResolution.targetTurnId } - : {}), - ...(existingResolution.parkReason ? { parkReason: existingResolution.parkReason } : {}), - }; - } - if ('request' in input) { - throw new Error('missing durable fake resume resolution'); - } - const source = input.source; - const requested: WorkHubDelegationResumeRequestedMessage = { - type: 'workhub_coordination', - id: `resume-${actionId}`, - turnId: actionId, - ts: 7, - schemaVersion: 4, - kind: 'delegation_resume_requested', - actionId, - actionFingerprint: input.actionFingerprint, - coordinationTurnId: actionId, - resumesActionId: source.actionId, - resumesDelegationId: source.delegationId, - targetSessionId: source.targetSessionId, - targetMessageId: source.targetMessageId, - targetSessionName: input.targetSessionName, - userText: input.userText, - plan: 'ready', - sourceTurnId: 'source-turn', - sourceRunId: 'source-run', - sourceRuntimeEventHighWater: 1, - targetTurnId: 'resumed-turn', - }; - resumeRequests.set(actionId, requested); - const resolved: WorkHubDelegationResumeResolvedMessage = { - type: 'workhub_coordination', - id: `resume-resolved-${actionId}`, - turnId: actionId, - ts: 8, - schemaVersion: 4, - kind: 'delegation_resume_resolved', - actionId, - actionFingerprint: requested.actionFingerprint, - coordinationTurnId: requested.coordinationTurnId, - resumesActionId: requested.resumesActionId, - resumesDelegationId: requested.resumesDelegationId, - targetSessionId: requested.targetSessionId, + return { + disposition: 'resume_work' as const, outcome: this.resumeOutcome.outcome, - ...(this.resumeOutcome.outcome === 'parked' - ? { parkReason: this.resumeOutcome.parkReason ?? 'safety_check_failed' } - : {}), + targetSessionId: input.source.targetSessionId, ...(this.resumeOutcome.targetTurnId ? { targetTurnId: this.resumeOutcome.targetTurnId } : {}), }; - resumeResolutions.set(actionId, resolved); - return { - disposition: 'resume_work' as const, - outcome: resolved.outcome, - targetSessionId: resolved.targetSessionId, - ...(resolved.targetTurnId ? { targetTurnId: resolved.targetTurnId } : {}), - ...(resolved.parkReason ? { parkReason: resolved.parkReason } : {}), - }; }, async readActionClaim(actionId: string) { return actionClaims.get(actionId); @@ -3049,13 +2984,10 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { supersessions: Map; stopRequests: Map; stopResolutions: Map; - resumeRequests: Map; - resumeResolutions: Map; retirements: WorkHubDelegationAssignedMessage[]; resumeCalls: WorkHubDelegationResumeInput[]; resumeOutcome: { - outcome: 'resume_started' | 'already_running' | 'parked'; - parkReason?: 'safety_check_failed'; + 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 3e9aec3a7e..2398bd202c 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, @@ -1075,7 +1078,7 @@ describe('Host WorkHub Coordination coordinator', () => { } }); - test('persists a resume plan and result before replaying after Host restart', async () => { + test('persists one resume result after the Host starts the continuation', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-resume-')); let store = createSessionStore(root); let targetId = ''; @@ -1099,13 +1102,6 @@ describe('Host WorkHub Coordination coordinator', () => { let resumeCalls = 0; const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { assign: persistTestAssignmentAction(store, 'payments-turn'), - planResume: async () => ({ - kind: 'ready', - sourceTurnId: 'payments-turn', - sourceRunId: 'payments-run', - sourceRuntimeEventHighWater: 9, - targetTurnId: 'resumed-turn', - }), resumeDelegation: async () => { resumeCalls += 1; return { @@ -1147,30 +1143,12 @@ describe('Host WorkHub Coordination coordinator', () => { }, }); assert.equal(resumeCalls, 1); - assert.equal( - (await store.readWorkHubResumeRequest('resume-action'))?.sourceRunId, - 'payments-run', - ); - assert.equal( - (await store.readWorkHubResumeResolution('resume-action'))?.targetTurnId, - 'resumed-turn', - ); - } finally { - await store.close?.(); - } - - store = createSessionStore(root); - try { - const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - planResume: async () => assert.fail('durable resume replay must not replan'), - resumeDelegation: async () => assert.fail('durable resume replay must not restart'), - }); - const replay = await restarted.handlers['workhub.coordination.act'](resumeInput(), CONTEXT); - assert.equal(replay.ok, true); - if (replay.ok && replay.result.disposition === 'resume_work') { - assert.equal(replay.result.outcome, 'resume_started'); - assert.equal(replay.result.targetTurnId, 'resumed-turn'); - } + const durable = await store.readWorkHubResume('resume-action'); + assert.equal(durable?.kind, 'delegation_resume'); + assert.equal(durable?.resumesActionId, 'source-action'); + assert.equal(durable?.targetSessionId, target.id); + assert.equal(durable?.outcome, 'resume_started'); + assert.equal(durable?.targetTurnId, 'resumed-turn'); } finally { await store.close?.(); await rm(root, { recursive: true, force: true }); @@ -1190,21 +1168,16 @@ describe('Host WorkHub Coordination coordinator', () => { }); let recovering = true; const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - assign: (input) => persistTestAssignment(store, input, 'payments-turn'), - planResume: async () => - recovering - ? { kind: 'recovering' } - : { - kind: 'ready', - sourceTurnId: 'payments-turn', - sourceRunId: 'payments-run', - sourceRuntimeEventHighWater: 9, - targetTurnId: 'resumed-turn', - }, - resumeDelegation: async () => ({ - outcome: 'resume_started', - targetTurnId: 'resumed-turn', - }), + 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); @@ -1239,11 +1212,11 @@ describe('Host WorkHub Coordination coordinator', () => { assert.deepEqual(await workhub.handlers['workhub.coordination.act'](input, CONTEXT), { ok: false, error: { - code: 'operation_unavailable', + code: 'host_not_ready', message: 'WorkHub is still recovering the delegated execution', }, }); - assert.equal(await store.readWorkHubResumeRequest(input.actionId), undefined); + assert.equal(await store.readWorkHubResume(input.actionId), undefined); recovering = false; const retried = await workhub.handlers['workhub.coordination.act'](input, CONTEXT); @@ -2051,13 +2024,6 @@ function coordinator( executions, sessionActions: { readDelegationRetirement: async () => 'not_retired', - planResume: async () => ({ - kind: 'ready' as const, - sourceTurnId: 'source-turn', - sourceRunId: 'source-run', - sourceRuntimeEventHighWater: 1, - targetTurnId: 'resumed-turn', - }), resumeDelegation: async () => ({ outcome: 'resume_started' as const, targetTurnId: 'resumed-turn', 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 c175e134a9..c7b8407895 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -249,12 +249,6 @@ test('WorkHub Coordination resume has closed input and outcome shapes', () => { outcome: 'already_running', targetSessionId: 'payments', }, - { - disposition: 'resume_work', - outcome: 'parked', - targetSessionId: 'payments', - parkReason: 'safety_check_failed', - }, ]) { assert.deepEqual(decodeWorkHubCoordinationActResult(result), result); } @@ -265,12 +259,6 @@ test('WorkHub Coordination resume has closed input and outcome shapes', () => { outcome: 'parked', targetSessionId: 'payments', }, - { - disposition: 'resume_work', - outcome: 'parked', - targetSessionId: 'payments', - parkReason: 'invented', - }, { disposition: 'resume_work', outcome: 'already_running', diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 5f3dc33cda..dafa428649 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -35,10 +35,6 @@ import { decodeSkillInvocationResult, type SkillInvocationResult, } from '@maka/core/skill-invocation'; -import { - SAFE_BOUNDARY_RESUME_PARK_REASONS, - type SafeBoundaryResumeParkReason, -} from '@maka/core/runtime-invocation'; import { invalidProtocolFrame } from './errors.js'; import { assertExactKeys, @@ -117,9 +113,20 @@ export interface TurnResumeStartInput { sourceRuntimeEventHighWater: number; } -export const TURN_RESUME_PARK_REASONS = SAFE_BOUNDARY_RESUME_PARK_REASONS; - -export type TurnResumeParkReason = SafeBoundaryResumeParkReason; +export const TURN_RESUME_PARK_REASONS = [ + 'resume_candidate_missing', + 'source_run_unreadable', + 'safety_check_failed', + 'continuation_already_exists', + 'continuation_repair_required', + 'continuation_started_indeterminate', + 'resume_feature_disabled', + 'continuation_authority_unavailable', + 'safety_observation_unavailable', + 'session_busy', +] as const; + +export type TurnResumeParkReason = (typeof TURN_RESUME_PARK_REASONS)[number]; export type TurnResumePlan = | { diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 04ac2dcfb0..4300aece15 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -17,11 +17,6 @@ * under the License. */ -import { - SAFE_BOUNDARY_RESUME_PARK_REASONS, - type SafeBoundaryResumeParkReason, -} from '@maka/core/runtime-invocation'; - import { requireCount, requireEntityId, @@ -222,16 +217,9 @@ export type WorkHubCoordinationActResult = } | { readonly disposition: 'resume_work'; - /** - * `parked` is the Host declining to continue — the reason is its own and - * is not restated here, because nothing the client can do changes it. - * `already_running` means the work never stopped, which is an answer, not - * a failure. - */ - readonly outcome: 'resume_started' | 'already_running' | 'parked'; + readonly outcome: 'resume_started' | 'already_running'; readonly targetSessionId: string; readonly targetTurnId?: string; - readonly parkReason?: SafeBoundaryResumeParkReason; }; export const WORKHUB_COORDINATION_OPERATION_SPECS = { @@ -551,13 +539,9 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord result, 'WorkHub Coordination resume result', ['disposition', 'outcome', 'targetSessionId'], - ['targetTurnId', 'parkReason'], + ['targetTurnId'], ); - if ( - exact.outcome !== 'resume_started' && - exact.outcome !== 'already_running' && - exact.outcome !== 'parked' - ) { + 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 @@ -565,13 +549,6 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord if ((exact.outcome === 'resume_started') !== (exact.targetTurnId !== undefined)) { throw invalidProtocolFrame('Invalid WorkHub resume target Turn'); } - if ( - exact.outcome === 'parked' - ? !(SAFE_BOUNDARY_RESUME_PARK_REASONS as readonly unknown[]).includes(exact.parkReason) - : exact.parkReason !== undefined - ) { - throw invalidProtocolFrame('Invalid WorkHub resume park reason'); - } return { disposition: 'resume_work', outcome: exact.outcome, @@ -581,9 +558,6 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord : { targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), }), - ...(exact.parkReason === undefined - ? {} - : { parkReason: exact.parkReason as SafeBoundaryResumeParkReason }), }; } throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 95038e4464..efe40bb55e 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1390,17 +1390,25 @@ export async function createExecutionRuntimeHostComposition( const snapshot = await coordinator.read(latest); return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering'; }, - // Resolve 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. - planResume: async (assignment, context) => { + // 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) => { const disposition = await messages.readMessageExecutionDisposition( assignment.targetSessionId, assignment.targetMessageId, ); - if (disposition.kind === 'recovering') return { kind: 'recovering' as const }; + if (disposition.kind === 'recovering') { + throw new WorkHubActionEffectFailure( + 'host_not_ready', + 'WorkHub is still recovering the delegated execution', + ); + } if (disposition.kind !== 'owned_root') { - return { kind: 'parked' as const, parkReason: 'resume_candidate_missing' as const }; + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub delegated execution is not resumable', + ); } const source = await coordinator.readLatestRootTurnLineage({ sessionId: assignment.targetSessionId, @@ -1408,12 +1416,20 @@ export async function createExecutionRuntimeHostComposition( runId: disposition.runId, }); if (isActiveWorkHubRoot(coordinator, source)) { - return { kind: 'already_running' as const }; + return { outcome: 'already_running' as const }; } const snapshot = await coordinator.read(source); - if (!isHostedExecutionTerminal(snapshot)) return { kind: 'recovering' as const }; + if (!isHostedExecutionTerminal(snapshot)) { + throw new WorkHubActionEffectFailure( + 'host_not_ready', + 'WorkHub is still recovering the delegated execution', + ); + } if (snapshot.status !== 'failed' && snapshot.status !== 'cancelled') { - return { kind: 'parked' as const, parkReason: 'resume_candidate_missing' as const }; + 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 }, @@ -1421,7 +1437,14 @@ export async function createExecutionRuntimeHostComposition( ); if (!plan.ok) throw new WorkHubActionEffectFailure(plan.error.code, plan.error.message); if (plan.result.disposition === 'parked') { - return { kind: 'parked' as const, parkReason: plan.result.reason }; + 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 || @@ -1432,32 +1455,16 @@ export async function createExecutionRuntimeHostComposition( 'WorkHub resume source lineage changed during planning', ); } - return { - kind: 'ready' as const, - sourceTurnId: plan.result.sourceTurnId, - sourceRunId: plan.result.sourceRunId, - sourceRuntimeEventHighWater: plan.result.sourceRuntimeEventHighWater, - targetTurnId: workHubResumedTurnId(assignment.delegationId, plan.result.sourceRunId), - }; - }, - resumeDelegation: async (request, context) => { - if ( - request.plan !== 'ready' || - !request.sourceRunId || - request.sourceRuntimeEventHighWater === undefined || - !request.targetTurnId - ) { - throw new WorkHubActionEffectFailure( - 'persistence_failed', - 'WorkHub durable resume plan is incomplete', - ); - } + const targetTurnId = workHubResumedTurnId( + assignment.delegationId, + plan.result.sourceRunId, + ); const started = await coordinator.handlers['turn.resume.start']( { - sessionId: request.targetSessionId, - turnId: request.targetTurnId, - sourceRunId: request.sourceRunId, - sourceRuntimeEventHighWater: request.sourceRuntimeEventHighWater, + sessionId: assignment.targetSessionId, + turnId: targetTurnId, + sourceRunId: plan.result.sourceRunId, + sourceRuntimeEventHighWater: plan.result.sourceRuntimeEventHighWater, }, context, ); @@ -1465,7 +1472,14 @@ export async function createExecutionRuntimeHostComposition( throw new WorkHubActionEffectFailure(started.error.code, started.error.message); } if (started.result.kind === 'parked') { - return { outcome: 'parked' as const, parkReason: started.result.plan.reason }; + 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, diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 60ed63b2a8..6d2a53ef8b 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -521,27 +521,24 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { /** Returns the newest Host-admitted continuation descended from one root execution. */ async readLatestRootTurnLineage(identity: HostedExecutionRef): Promise { - const admissions = await this.stores.agentRunStore.listRootTurnAdmissionsForRecovery( + const origin = await this.stores.agentRunStore.readRootTurnAdmission( identity.sessionId, + identity.turnId, ); - const originIndex = admissions.findIndex( - ({ turnId, runId }) => turnId === identity.turnId && runId === identity.runId, - ); - if (originIndex === -1) { + if (!origin || origin.runId !== identity.runId) { throw new RuntimeMessageAuthorityInvariantError( `Root execution ${identity.turnId}/${identity.runId} has no durable admission`, ); } - let latest = admissions[originIndex]!; - for (const admission of admissions.slice(originIndex + 1)) { - const execution = admission.execution; - if ( - execution.kind === 'safe_boundary_continuation' && - execution.sourceTurnId === latest.turnId && - execution.sourceRunId === latest.runId - ) { - latest = 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 }; } 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 277616d8d9..c5a8c03b66 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -32,7 +32,6 @@ import type { WorkHubDelegationStopRequestedMessage, WorkHubDelegationStopResolvedMessage, WorkHubDelegationStopOutcome, - WorkHubDelegationResumeRequestedMessage, WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import { @@ -116,7 +115,6 @@ export interface WorkHubActionGateEffects { readStopResolution( delegationId: string, ): Promise; - readResumeRequest(actionId: string): Promise; answer( input: { readonly turnId: string; readonly text: string }, context: ConnectionContext, @@ -148,11 +146,6 @@ export interface WorkHubActionGateEffects { assignment: WorkHubDelegationAssignedMessage, retirement: WorkHubDelegationRetirementClaim, ): Promise; - /** - * Ask the target Session to carry on the work this delegation left - * unfinished. The Host owns whether that is possible; a repeat is safe - * because a continuation that already exists parks rather than forks. - */ resume( input: WorkHubDelegationResumeInput, context: ConnectionContext, @@ -171,15 +164,13 @@ export interface WorkHubDelegationRetirementClaim { readonly cause: 'direct_stop' | 'replacement'; } -export type WorkHubDelegationResumeInput = - | { readonly request: WorkHubDelegationResumeRequestedMessage } - | { - readonly actionId: string; - readonly actionFingerprint: `sha256:${string}`; - readonly source: WorkHubDelegationAssignedMessage; - readonly targetSessionName: string; - readonly userText: string; - }; +export interface WorkHubDelegationResumeInput { + readonly actionId: string; + readonly actionFingerprint: `sha256:${string}`; + readonly source: WorkHubDelegationAssignedMessage; + readonly targetSessionName: string; + readonly userText: string; +} export interface WorkHubRetirementResult { readonly outcome: WorkHubDelegationStopOutcome | 'recovering'; @@ -449,39 +440,12 @@ export class WorkHubCoordinationActionGate { return this.#stop(requested, source); } if (proposal.disposition === 'resume_work') { - // Resume carries no confirmation. It starts work that was already - // delegated and already interrupted, so it destroys nothing and needs no - // authority a delegation did not already grant — only proof that the - // words asked for it and that the Session named still owns one link. if (!requestIntent.resume.imperative) { throw new WorkHubActionGateFailure( 'action_conflict', 'WorkHub resume requires an explicit named command in trusted user text', ); } - const replay = await this.#effects.readResumeRequest(input.actionId); - if (replay) { - if ( - replay.userText !== input.userText || - replay.targetSessionId !== proposal.expects.targetSessionId || - !workHubNamedDelegationActionTargetsSession( - requestIntent.resume, - replay.targetSessionName, - ) - ) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub resume identity belongs to a different request', - ); - } - await this.#claimAction( - input.actionId, - 'resume', - replay.actionFingerprint, - replay.resumesDelegationId, - ); - return this.#effects.resume({ request: replay }, context); - } const source = await this.#resumeSource(proposal.expects.targetSessionId); const sessions = await this.#effects.listSessions(); const currentTargetName = sessions.find(({ id }) => id === source.targetSessionId)?.name; @@ -610,27 +574,8 @@ export class WorkHubCoordinationActionGate { ); } - /** - * The delegation a resume names. - * - * Unlike a stop this needs no claim to find its way back: resume changes no - * durable link, so the delegation it names is still in the active set on the - * next attempt exactly as it was on the first. One link on the Session is the - * answer; several is the same ambiguity a stop refuses, and none means there - * is nothing here to carry on. - */ async #resumeSource(targetSessionId: string): Promise { - const active = await this.#effects.listActiveAssignments(); - const onTarget = active.filter((assignment) => assignment.targetSessionId === targetSessionId); - if (onTarget.length !== 1) { - throw new WorkHubActionGateFailure( - 'action_conflict', - onTarget.length === 0 - ? 'WorkHub has no active durable delegation to resume on that Session' - : 'WorkHub resume target does not identify one active durable delegation', - ); - } - return onTarget[0]!; + return this.#soleWorkingDelegation(targetSessionId, 'resume'); } /** @@ -673,11 +618,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 @@ -700,23 +665,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; } @@ -1156,13 +1109,6 @@ function workHubCreatedSessionId(actionId: string): string { return `whs_${hash(`create\0${actionId}`).slice(0, 48)}`; } -/** - * The continuation identity a resume would start, derived rather than minted. - * - * Two attempts at the same interrupted run must name the same Turn, or the - * second would ask the Host to start a second continuation instead of finding - * the first already there. - */ export function workHubResumedTurnId(delegationId: string, sourceRunId: string): string { return `wht_${hash(`resume\0${delegationId}\0${sourceRunId}`).slice(0, 48)}`; } diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 2ef0bf870f..b3597a6bda 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -38,13 +38,11 @@ import { type WorkHubDelegationReplacementRequestedMessage, type WorkHubDelegationStopRequestedMessage, type WorkHubDelegationStopResolvedMessage, - type WorkHubDelegationResumeRequestedMessage, - type WorkHubDelegationResumeResolvedMessage, + type WorkHubDelegationResumeMessage, } from '@maka/core/session'; import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; import type { OperationOutcome, - TurnResumeParkReason, WorkHubCoordinationActResult, WorkHubCoordinationActInput, WorkHubCoordinationAnswerInput, @@ -106,8 +104,7 @@ type CoordinationStores = Pick< | 'readWorkHubSupersession' | 'readWorkHubStopRequest' | 'readWorkHubStopResolution' - | 'readWorkHubResumeRequest' - | 'readWorkHubResumeResolution' + | 'readWorkHubResume' | 'readTranscriptHighWaterSnapshot' | 'readTranscriptMessagesSnapshot' | 'updateHeaderVersioned' @@ -118,35 +115,19 @@ type CoordinationExecutions = Pick< 'startWorkHubCoordinationMessage' | 'hasRootTurnAdmission' >; -type WorkHubResumePlan = - | { readonly kind: 'already_running' } - | { readonly kind: 'recovering' } - | { readonly kind: 'parked'; readonly parkReason: TurnResumeParkReason } - | { - readonly kind: 'ready'; - readonly sourceTurnId: string; - readonly sourceRunId: string; - readonly sourceRuntimeEventHighWater: number; - readonly targetTurnId: string; - }; - type WorkHubResumeResult = | { readonly outcome: 'resume_started'; readonly targetTurnId: string; } - | { readonly outcome: 'parked'; readonly parkReason: TurnResumeParkReason }; + | { readonly outcome: 'already_running' }; type CoordinationSessionActions = Pick< WorkHubActionGateEffects, 'assign' | 'readDelegationRetirement' | 'retireDelegation' > & { - planResume( - assignment: WorkHubDelegationAssignedMessage, - context: ConnectionContext, - ): Promise; resumeDelegation( - request: WorkHubDelegationResumeRequestedMessage, + assignment: WorkHubDelegationAssignedMessage, context: ConnectionContext, ): Promise; }; @@ -218,7 +199,6 @@ export class HostWorkHubCoordinationCoordinator { readSupersession: (delegationId) => this.#stores.readWorkHubSupersession(delegationId), readStopRequest: (delegationId) => this.#stores.readWorkHubStopRequest(delegationId), readStopResolution: (delegationId) => this.#stores.readWorkHubStopResolution(delegationId), - readResumeRequest: (actionId) => this.#stores.readWorkHubResumeRequest(actionId), answer: async (input, context) => { const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); if (!outcome.ok) { @@ -429,98 +409,42 @@ export class HostWorkHubCoordinationCoordinator { context: ConnectionContext, actions: CoordinationSessionActions, ): Promise> { - let request: WorkHubDelegationResumeRequestedMessage; - if ('request' in input) { - request = input.request; - } else { - const plan = await actions.planResume(input.source, context); - if (plan.kind === 'recovering') { - throw new WorkHubActionEffectFailure( - 'operation_unavailable', - 'WorkHub is still recovering the delegated execution', - ); - } - const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); - request = await this.#commitCoordinationFact({ - admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.source.targetSessionId], - read: () => this.#stores.readWorkHubResumeRequest(input.actionId), - build: (existing) => ({ - type: 'workhub_coordination', - id: `whu_${suffix}`, - turnId: input.actionId, - ts: existing?.ts ?? Date.now(), - schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, - kind: 'delegation_resume_requested', - actionId: input.actionId, - actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, - resumesActionId: input.source.actionId, - resumesDelegationId: input.source.delegationId, - targetSessionId: input.source.targetSessionId, - targetMessageId: input.source.targetMessageId, - targetSessionName: input.targetSessionName, - userText: input.userText, - plan: plan.kind, - ...(plan.kind === 'parked' ? { parkReason: plan.parkReason } : {}), - ...(plan.kind === 'ready' - ? { - sourceTurnId: plan.sourceTurnId, - sourceRunId: plan.sourceRunId, - sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, - targetTurnId: plan.targetTurnId, - } - : {}), - }), - conflictMessage: 'WorkHub resume identity belongs to a different plan', - beforeAppend: async () => { - const source = (await this.#listActiveAssignments()).find( - ({ actionId, delegationId }) => - actionId === input.source.actionId && delegationId === input.source.delegationId, - ); - if (!source || source.targetSessionId !== input.source.targetSessionId) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub resume source is no longer active', - ); - } - }, - unknownOutcomeMessage: 'WorkHub resume plan outcome is unknown', - }); - } - - const existing = await this.#stores.readWorkHubResumeResolution(request.actionId); + const existing = await this.#stores.readWorkHubResume(input.actionId); if (existing) return coordinationResumeResult(existing); - const resumed = - request.plan === 'ready' - ? await actions.resumeDelegation(request, context) - : request.plan === 'parked' - ? { outcome: 'parked' as const, parkReason: request.parkReason! } - : { outcome: 'already_running' as const }; - const suffix = createHash('sha256').update(request.actionId, 'utf8').digest('hex').slice(0, 48); + const resumed = await actions.resumeDelegation(input.source, context); + const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); const resolution = await this.#commitCoordinationFact({ - read: () => this.#stores.readWorkHubResumeResolution(request.actionId), + admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.source.targetSessionId], + read: () => this.#stores.readWorkHubResume(input.actionId), build: (durable) => ({ type: 'workhub_coordination', id: `whn_${suffix}`, - turnId: request.actionId, + turnId: input.actionId, ts: durable?.ts ?? Date.now(), schemaVersion: WORKHUB_COORDINATION_RESUME_SCHEMA_VERSION, - kind: 'delegation_resume_resolved', - actionId: request.actionId, - actionFingerprint: request.actionFingerprint, - coordinationTurnId: request.coordinationTurnId, - resumesActionId: request.resumesActionId, - resumesDelegationId: request.resumesDelegationId, - targetSessionId: request.targetSessionId, + kind: 'delegation_resume', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + resumesActionId: input.source.actionId, + resumesDelegationId: input.source.delegationId, + targetSessionId: input.source.targetSessionId, + targetSessionName: input.targetSessionName, + userText: input.userText, outcome: resumed.outcome, - ...(resumed.outcome === 'parked' ? { parkReason: resumed.parkReason } : {}), ...(resumed.outcome === 'resume_started' ? { targetTurnId: resumed.targetTurnId } : {}), }), conflictMessage: 'WorkHub resume already has a different resolution', beforeAppend: async () => { - const durable = await this.#stores.readWorkHubResumeRequest(request.actionId); - if (!durable || !isDeepStrictEqual(durable, request)) { - throw new WorkHubActionGateFailure('action_conflict', 'WorkHub resume plan changed'); + const source = (await this.#listActiveAssignments()).find( + ({ actionId, delegationId }) => + actionId === input.source.actionId && delegationId === input.source.delegationId, + ); + if (!source || source.targetSessionId !== input.source.targetSessionId) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub resume source is no longer active', + ); } }, unknownOutcomeMessage: 'WorkHub resume resolution outcome is unknown', @@ -968,14 +892,13 @@ function digest(value: unknown): `sha256:${string}` { } function coordinationResumeResult( - resolution: WorkHubDelegationResumeResolvedMessage, + resolution: WorkHubDelegationResumeMessage, ): Extract { return { disposition: 'resume_work', outcome: resolution.outcome, targetSessionId: resolution.targetSessionId, ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}), - ...(resolution.parkReason ? { parkReason: resolution.parkReason } : {}), }; } 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..2a0fed2289 --- /dev/null +++ b/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { test } from 'node:test'; +import { createSqliteAgentRunStore } from '../agent-run-store.js'; + +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'); + + 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 }); + } +}); diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index dd855c9b3f..b1ae6f9f25 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, @@ -635,6 +640,45 @@ 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' + `) + .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, + ); + if ( + admission.execution.kind !== 'safe_boundary_continuation' || + admission.execution.sourceTurnId !== sourceTurnId || + admission.execution.sourceRunId !== sourceRunId + ) { + throw new Error('Root turn continuation index disagrees with its durable admission'); + } + 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 16542d87ad..cac089abbf 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, @@ -387,10 +392,7 @@ async function createExecutionStoresForWrite sessionStore.readWorkHubStopRequest(delegationId)), readWorkHubStopResolution: (delegationId) => run(() => sessionStore.readWorkHubStopResolution(delegationId)), - readWorkHubResumeRequest: (actionId) => - run(() => sessionStore.readWorkHubResumeRequest(actionId)), - readWorkHubResumeResolution: (actionId) => - run(() => sessionStore.readWorkHubResumeResolution(actionId)), + readWorkHubResume: (actionId) => run(() => sessionStore.readWorkHubResume(actionId)), claimWorkHubAction: (claim) => run(() => sessionStore.claimWorkHubAction(claim)), readWorkHubActionClaim: (actionId) => run(() => sessionStore.readWorkHubActionClaim(actionId)), @@ -531,6 +533,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) => @@ -667,6 +673,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/session-store.ts b/packages/storage/src/session-store.ts index 7d259d5b14..517adb7860 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -90,8 +90,7 @@ import { type WorkHubActionClaimOutcome, type WorkHubDelegationStopRequestedMessage, type WorkHubDelegationStopResolvedMessage, - type WorkHubDelegationResumeRequestedMessage, - type WorkHubDelegationResumeResolvedMessage, + type WorkHubDelegationResumeMessage, type WorkHubDelegationSupersededMessage, } from '@maka/core/session'; import type { @@ -447,12 +446,7 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto readWorkHubStopResolution( delegationId: string, ): Promise; - readWorkHubResumeRequest( - actionId: string, - ): Promise; - readWorkHubResumeResolution( - actionId: string, - ): Promise; + readWorkHubResume(actionId: string): Promise; /** * Durably binds one action identity to one exact WorkHub operation before its * effect. Survives removal of the target Session so a committed destructive @@ -780,25 +774,11 @@ class SqliteSessionStore implements SessionAuthorityStore { : undefined; } - async readWorkHubResumeRequest( - actionId: string, - ): Promise { - const message = await this.readWorkHubCoordinationMessage( - `whu_${workHubIdentitySuffix(actionId)}`, - ); - return message?.type === 'workhub_coordination' && - message.kind === 'delegation_resume_requested' - ? message - : undefined; - } - - async readWorkHubResumeResolution( - actionId: string, - ): Promise { + async readWorkHubResume(actionId: string): Promise { const message = await this.readWorkHubCoordinationMessage( `whn_${workHubIdentitySuffix(actionId)}`, ); - return message?.type === 'workhub_coordination' && message.kind === 'delegation_resume_resolved' + return message?.type === 'workhub_coordination' && message.kind === 'delegation_resume' ? message : undefined; } diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index caefc73fca..b51416532b 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,14 @@ 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; + CREATE UNIQUE INDEX IF NOT EXISTS core_root_turn_continuation_source + 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; From a7786df5a3a2b5582ec22a2474370caed5dbd5fb Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 18:12:08 +0800 Subject: [PATCH 09/10] fix(desktop): preserve WorkHub renderer boundary --- apps/desktop/src/renderer/workhub-coordination-port.ts | 2 ++ apps/desktop/src/renderer/workhub-surface.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 3d266556c2..ca5062aea7 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -38,6 +38,8 @@ import type { } from '@maka/runtime-host/protocol'; import { boundedWorkHubTimelineText, WorkHubCoordinationFailure } from './workhub-controller.js'; +export { WorkHubCoordinationFailure }; + import type { WorkHubDesktopTranscriptBridge } from './workhub-session-port.js'; const WORKHUB_COORDINATION_TURN_LIMIT = 40; diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 1a2960cb4c..c5f3ad0de3 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -28,7 +28,6 @@ import { Button } from '@astryxdesign/core/Button'; import type { UiLocale } from '@maka/core/ui-locale'; import { ChatSurfaceLayout, Composer } from '@maka/ui'; import { - WorkHubCoordinationFailure, type WorkHubController, type WorkHubCoordinationTurn, type WorkHubDelegationLinkState, @@ -37,6 +36,7 @@ import { type WorkHubSubmission, type WorkHubSubmitInput, } from './workhub-controller.js'; +import { WorkHubCoordinationFailure } from './workhub-coordination-port.js'; import { WorkHubSendLease, type WorkHubSendAttempt, From 200e8583f2a2dcdaf2e1073ba2367c0c412d7499 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 5 Sep 2026 21:21:13 +0800 Subject: [PATCH 10/10] fix(workhub): address current-head review --- .../main/__tests__/workhub-controller.test.ts | 57 ++++++++++++++- .../src/renderer/workhub-controller.ts | 4 +- .../src/renderer/workhub-route-policy.ts | 13 +++- apps/desktop/src/renderer/workhub-surface.tsx | 19 ++++- .../__tests__/workhub-creation-intent.test.ts | 8 +- packages/core/src/workhub-creation-intent.ts | 3 + .../workhub-coordination-coordinator.ts | 4 +- ...fe-boundary-continuation-admission.test.ts | 73 +++++++++++++++++++ packages/storage/src/agent-run-store.ts | 37 ++++++++-- .../src/sqlite-core-execution-schema.ts | 4 +- 10 files changed, 203 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 4b30d129b5..65fb716ed0 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -423,7 +423,7 @@ test('a named resume submits and reports what the Host did', async () => { sessions, coordination: { open: async (handler) => { - handler([], []); + handler([]); return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), @@ -461,13 +461,39 @@ test('a named resume submits and reports what the Host did', async () => { 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([], []); + handler([]); return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), @@ -498,7 +524,7 @@ test('a Runtime Host without safe-boundary resume explains why it cannot resume' sessions: port([session('payments', { sessionName: 'Payments' })]), coordination: { open: async (handler) => { - handler([], []); + handler([]); return { close: async () => undefined }; }, record: async (input) => ({ turnId: input.turnId }), @@ -519,11 +545,34 @@ test('a Runtime Host without safe-boundary resume explains why it cannot resume' requestId: 'resume-disabled', text: 'Resume Payments', options: [], - reason: 'resume_target_unavailable', + 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 () => assert.fail('a resume must not read route candidates'), + 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/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 24c8dc6868..559b316494 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -394,7 +394,9 @@ export function createWorkHubController(deps: { requestId: input.requestId, text: input.text, options: [], - reason: 'resume_target_unavailable', + reason: error.code === 'host_not_ready' + ? 'resume_host_recovering' + : 'resume_operation_unavailable', }; } if (error instanceof WorkHubCoordinationFailure && error.code === 'operation_conflict') { diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index 8a47325efe..90c855b7ef 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -82,8 +82,14 @@ export type WorkHubStopClarificationReason = | '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'; + | '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 @@ -217,7 +223,10 @@ function createWorkHubRoutePolicyVisit( }, resolveResume({ text, sessions }) { const action = readWorkHubRequestIntent(text).resume; - if (!action.imperative || !action.target) return { kind: 'not_requested' }; + if (!action.cue) return { kind: 'not_requested' }; + if (!action.imperative || !action.target) { + return { kind: 'clarification', reason: 'resume_target_required' }; + } return resolveNamedDelegationAction( sessionResolver, action.target, diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index c5f3ad0de3..b944edd7ff 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -657,8 +657,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; } @@ -895,8 +898,11 @@ function workHubCopy(locale: UiLocale) { already_running: '这项工作还在跑,不需要恢复:', }, resumeRecorded: '结果已记录', + resumeTargetRequired: '请明确说出要继续的工作名称,例如“恢复 支付任务”。', resumeTargetAmbiguous: '这个名称对应多项工作;请打开具体的 Session 继续它。', - resumeTargetUnavailable: '当前 Runtime Host 无法继续这项工作;安全边界恢复可能未启用,或 Host 仍在恢复。', + resumeTargetUnavailable: '这项工作当前没有可恢复的单个 WorkHub 委派。', + resumeOperationUnavailable: '当前 Runtime Host 未启用安全边界恢复;请打开原 Session 继续处理。', + resumeHostRecovering: 'Runtime Host 仍在恢复;请稍后重试。', waitingForDecision: '这项工作正在等待你的决定。', requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', @@ -971,8 +977,11 @@ function workHubCopy(locale: UiLocale) { already_running: '這項工作仍在執行,不需要恢復:', }, resumeRecorded: '結果已記錄', + resumeTargetRequired: '請明確說出要繼續的工作名稱,例如「恢復 支付任務」。', resumeTargetAmbiguous: '這個名稱對應多項工作;請開啟具體的 Session 繼續它。', - resumeTargetUnavailable: '目前 Runtime Host 無法繼續這項工作;安全邊界恢復可能未啟用,或 Host 仍在恢復。', + resumeTargetUnavailable: '這項工作目前沒有可恢復的單一 WorkHub 委派。', + resumeOperationUnavailable: '目前 Runtime Host 未啟用安全邊界恢復;請開啟原 Session 繼續處理。', + resumeHostRecovering: 'Runtime Host 仍在恢復;請稍後重試。', submitFailures: { candidates_changed: '工作清單已變更,請重新傳送以使用最新目標。', linked_correction_unavailable: '跨 Session 更正將於持久委派關聯完成後開放;請先開啟原 Session 並停止目前工作。', @@ -1033,10 +1042,14 @@ function workHubCopy(locale: UiLocale) { already_running: 'This work is still running, so there was nothing to resume:', }, resumeRecorded: 'Result recorded', + resumeTargetRequired: 'Name the work explicitly, for example “Resume Payments”.', resumeTargetAmbiguous: 'That name matches more than one work item. Open the exact Session to resume it.', resumeTargetUnavailable: - 'The current Runtime Host cannot resume this work. Safe-boundary resume may be disabled, or the Host may still be recovering.', + 'This work has no single WorkHub delegation that can be resumed.', + resumeOperationUnavailable: + 'Safe-boundary resume is not enabled on this Runtime Host. Open the original Session to continue.', + resumeHostRecovering: 'The Runtime Host is still recovering. Try again shortly.', waitingForDecision: 'This work is waiting for your decision.', requestNotSent: 'The new request was not sent. Resolve the interaction in its Session, then send again.', routing: 'Choosing the right Session…', loadFailed: 'Could not read existing work.', diff --git a/packages/core/src/__tests__/workhub-creation-intent.test.ts b/packages/core/src/__tests__/workhub-creation-intent.test.ts index 6e96a18cc7..4a1107f119 100644 --- a/packages/core/src/__tests__/workhub-creation-intent.test.ts +++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts @@ -1136,11 +1136,15 @@ test('a resume names one Session, and reads like a stop everywhere else', () => ['恢复支付任务', '支付任务'], ['接着跑支付任务', '支付任务'], ] as const) { - assert.deepEqual(readWorkHubRequestIntent(text).resume, { imperative: true, target }, text); + assert.deepEqual( + readWorkHubRequestIntent(text).resume, + { cue: true, imperative: true, target }, + text, + ); } for (const text of ['Resume it', '恢复它']) { - assert.deepEqual(readWorkHubRequestIntent(text).resume, { imperative: false }, text); + assert.deepEqual(readWorkHubRequestIntent(text).resume, { cue: true, imperative: false }, text); } // Ambiguous verbs remain ordinary Session instructions rather than being diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts index 2c2130e2b1..640bcee31d 100644 --- a/packages/core/src/workhub-creation-intent.ts +++ b/packages/core/src/workhub-creation-intent.ts @@ -156,6 +156,8 @@ export interface WorkHubRequestIntent { 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; @@ -355,6 +357,7 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent { ...(stop.target ? { target: stop.target } : {}), }, resume: { + cue: resume.cue, imperative: Boolean(resume.target), ...(resume.target ? { target: resume.target } : {}), }, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index b3597a6bda..b81e38ed90 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -436,7 +436,9 @@ export class HostWorkHubCoordinationCoordinator { }), conflictMessage: 'WorkHub resume already has a different resolution', beforeAppend: async () => { - const source = (await this.#listActiveAssignments()).find( + const source = ( + await this.#stores.readActiveWorkHubAssignmentsByTarget([input.source.targetSessionId]) + ).find( ({ actionId, delegationId }) => actionId === input.source.actionId && delegationId === input.source.delegationId, ); diff --git a/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts b/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts index 2a0fed2289..cac49621f3 100644 --- a/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts +++ b/packages/storage/src/__tests__/safe-boundary-continuation-admission.test.ts @@ -21,8 +21,56 @@ 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-')); @@ -63,6 +111,31 @@ test('safe-boundary continuation admission is indexed by its source execution', admittedAt: 20, }); assert.equal(continuation.kind, 'admitted'); + await assert.rejects( + 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, + }), + /already has continuation continuation-turn/, + ); assert.deepEqual( await store.readRootTurnContinuationAdmission('session', 'source-turn', 'source-run'), diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index b1ae6f9f25..283778133b 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -593,6 +593,26 @@ 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' + LIMIT 1 + `) + .get( + admission.sessionId, + admission.execution.sourceTurnId, + admission.execution.sourceRunId, + ) as { turn_id?: unknown } | undefined; + if (sourceOwner) { + throw new Error(`Root execution already has continuation ${String(sourceOwner.turn_id)}`); + } + } for (const source of admission.sourceMessages) { const proof = this.#lease.database .prepare(` @@ -648,7 +668,7 @@ class SqliteAgentRunStore implements DurableAgentRunStore { assertSafeId(sessionId, 'Invalid session id'); assertSafeId(sourceTurnId, 'Invalid source turn id'); assertSafeId(sourceRunId, 'Invalid source run id'); - const row = this.#lease.database + const rows = this.#lease.database .prepare(` SELECT turn_id, record_json FROM core_root_turn_admissions @@ -656,11 +676,18 @@ class SqliteAgentRunStore implements DurableAgentRunStore { 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 2 `) - .get(sessionId, sourceTurnId, sourceRunId) as - | { turn_id?: unknown; record_json?: unknown } - | undefined; - if (!row) return undefined; + .all(sessionId, sourceTurnId, sourceRunId) as Array<{ + turn_id?: unknown; + record_json?: unknown; + }>; + if (rows.length === 0) return undefined; + if (rows.length > 1) { + throw new Error('Root execution has multiple durable continuation admissions'); + } + const row = rows[0]!; if (typeof row.turn_id !== 'string' || typeof row.record_json !== 'string') { throw new Error('Invalid SQLite root turn continuation admission row'); } diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index b51416532b..d094dfe63a 100644 --- a/packages/storage/src/sqlite-core-execution-schema.ts +++ b/packages/storage/src/sqlite-core-execution-schema.ts @@ -172,7 +172,9 @@ 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; - CREATE UNIQUE INDEX IF NOT EXISTS core_root_turn_continuation_source + DROP INDEX IF EXISTS core_root_turn_continuation_source; + + CREATE INDEX core_root_turn_continuation_source ON core_root_turn_admissions( session_id, json_extract(record_json, '$.execution.sourceTurnId'),