From 646962c5fe2fe97a553e2cd938e1d4f43422cc9d Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 26 Aug 2026 22:13:52 +0800 Subject: [PATCH 1/5] feat(workhub): persist delegation linkage Generated-by: Codex --- .../desktop-session-projection.test.ts | 27 ++ .../src/shared/desktop-session-projection.ts | 5 + .../workhub-coordination-session-adr.md | 16 +- .../workhub-coordination-record.test.ts | 81 +++++ packages/core/src/session.ts | 100 ++++++ packages/core/src/thread-search.ts | 4 + .../workhub-coordination-action-gate.test.ts | 138 +++++++- .../workhub-coordination-coordinator.test.ts | 111 ++++++- .../src/server/execution-composition.ts | 68 +++- .../workhub-coordination-action-gate.ts | 172 +++++++--- .../workhub-coordination-coordinator.ts | 17 +- .../src/server/workhub-delegation-journal.ts | 300 ++++++++++++++++++ 12 files changed, 983 insertions(+), 56 deletions(-) create mode 100644 packages/core/src/__tests__/workhub-coordination-record.test.ts create mode 100644 packages/runtime-host/src/server/workhub-delegation-journal.ts diff --git a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts index 2ab121f450..400d7ca1c4 100644 --- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts @@ -26,6 +26,7 @@ import { EMPTY_USAGE_PROVENANCE } from '@maka/core/usage-ledger-merge'; import { projectDesktopSessionEvent, projectDesktopSessionSummary, + projectDesktopStoredMessage, projectDesktopTurnRecord, projectDesktopUsageStats, } from '../../shared/desktop-session-projection.js'; @@ -199,6 +200,32 @@ test('projects only present Usage Session ids into the Desktop host namespace', assert.equal(projected.logs[1]?.sessionId, undefined); }); +test('projects durable WorkHub delegation targets into the Desktop host namespace', () => { + const projected = projectDesktopStoredMessage( + { hostId: 'remote-root' }, + { + type: 'workhub_coordination', + id: 'delegation-commit-message', + turnId: 'coordination-turn', + ts: 2, + schemaVersion: 1, + kind: 'delegation_committed', + actionId: 'action-id', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + coordinationTurnId: 'coordination-turn', + targetSessionId: 'payments', + disposition: 'delegate_existing', + delegationId: 'delegation-id', + targetTurnId: 'payments-turn', + }, + ); + + assert.equal(projected.type, 'workhub_coordination'); + if (projected.type === 'workhub_coordination') { + assert.equal(projected.targetSessionId, JSON.stringify(['remote-root', 'payments'])); + } +}); + function summary(id: string): SessionSummary { return { id, diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index daf09d2b1e..f0ef38cddd 100644 --- a/apps/desktop/src/shared/desktop-session-projection.ts +++ b/apps/desktop/src/shared/desktop-session-projection.ts @@ -124,6 +124,11 @@ export function projectDesktopStoredMessage( return message.parentSessionId ? { ...message, parentSessionId: projectSessionId(host, message.parentSessionId) } : message; + case 'workhub_coordination': + return { + ...message, + targetSessionId: projectSessionId(host, message.targetSessionId), + }; default: return message; } diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 18db78e671..2ed812375a 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -114,6 +114,15 @@ authoritative result. WorkHub may display a bounded projection or record a coordination summary, but it does not copy the ordinary Session's complete transcript into the Coordination Session. +Delegation linkage uses closed, typed `workhub_coordination` records in the +existing Coordination Session transcript. An immutable `delegation_intent` is +appended before the target Session effect so an opaque candidate remains +recoverable after the candidate set changes or the Runtime Host restarts. A +`delegation_committed` record then binds that intent to the accepted target Turn +and acts as the durable action-replay result. The records carry an action +fingerprint to reject conflicting reuse of an action identity. They do not form a +general workflow state machine and do not persist target execution lifecycle. + ## Consequences, costs, and reevaluation - WorkHub gains persistent conversational continuity without adding another @@ -129,9 +138,10 @@ transcript into the Coordination Session. entity remains unresolved. - Cross-Runtime-Host coordination remains deferred. - Coordination Session role representation, lazy creation, durable lookup, - recovery, and per-Host UI resolution are implemented. Coordination transcript, - disposition, delegation-link, and Action Gate behavior remain later work; this - ADR defines their authority boundaries without implementing them. + recovery, per-Host UI resolution, persistent transcript, closed dispositions, + and the Action Gate are implemented. Durable delegation linkage is encoded in + that transcript; target lifecycle projection and destructive replacement/Stop + recovery remain later work. Reevaluate the per-Host decision if supported workflows require one WorkHub conversation to coordinate ordinary Sessions on multiple Runtime Hosts, or if Host diff --git a/packages/core/src/__tests__/workhub-coordination-record.test.ts b/packages/core/src/__tests__/workhub-coordination-record.test.ts new file mode 100644 index 0000000000..522c0af041 --- /dev/null +++ b/packages/core/src/__tests__/workhub-coordination-record.test.ts @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { decodeCanonicalMessage } from '../session.js'; + +const FINGERPRINT = `sha256:${'a'.repeat(64)}`; + +describe('WorkHub Coordination stored records', () => { + test('decodes exact delegation intent and commit records', () => { + const intent = { + type: 'workhub_coordination', + id: 'intent-id', + turnId: 'coordination-turn', + ts: 1, + schemaVersion: 1, + kind: 'delegation_intent', + actionId: 'action-id', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'coordination-turn', + targetSessionId: 'payments', + disposition: 'delegate_existing', + } as const; + const committed = { + ...intent, + id: 'commit-id', + ts: 2, + kind: 'delegation_committed', + delegationId: 'delegation-id', + targetTurnId: 'target-turn', + steered: true, + } as const; + + assert.deepEqual(decodeCanonicalMessage(intent), intent); + assert.deepEqual(decodeCanonicalMessage(committed), committed); + }); + + test('rejects malformed or widened coordination records', () => { + const base = { + type: 'workhub_coordination', + id: 'intent-id', + turnId: 'coordination-turn', + ts: 1, + schemaVersion: 1, + kind: 'delegation_intent', + actionId: 'action-id', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'coordination-turn', + targetSessionId: 'payments', + disposition: 'delegate_existing', + } as const; + + for (const invalid of [ + { ...base, coordinationTurnId: 'different-turn' }, + { ...base, actionFingerprint: 'not-a-digest' }, + { ...base, disposition: 'replace' }, + { ...base, sourceSessionId: 'injected' }, + { ...base, kind: 'delegation_committed' }, + { ...base, schemaVersion: 2 }, + ]) { + assert.throws(() => decodeCanonicalMessage(invalid), /Invalid stored message schema/u); + } + }); +}); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e9896dc2e2..f7f19986d1 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -756,6 +756,7 @@ export type StoredMessage = | PermissionDecisionMessage | TokenUsageMessage | TurnStateMessage + | WorkHubCoordinationMessage | SystemNoteMessage; export interface UserMessage extends MessageContent { @@ -908,6 +909,41 @@ export interface TurnStateMessage { partialOutputRetained: boolean; } +export const WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION = 1 as const; + +export type WorkHubDelegationDisposition = 'delegate_existing' | 'create_new'; + +interface WorkHubCoordinationMessageEnvelope { + type: 'workhub_coordination'; + id: string; + /** The Coordination Turn that owns this action. */ + turnId: string; + ts: number; + schemaVersion: typeof WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION; + actionId: string; + actionFingerprint: `sha256:${string}`; + coordinationTurnId: string; + targetSessionId: string; + disposition: WorkHubDelegationDisposition; +} + +/** Durable target choice written before a delegated Session effect is attempted. */ +export interface WorkHubDelegationIntentMessage extends WorkHubCoordinationMessageEnvelope { + kind: 'delegation_intent'; +} + +/** Durable proof that one Coordination action owns one accepted target Turn. */ +export interface WorkHubDelegationCommittedMessage extends WorkHubCoordinationMessageEnvelope { + kind: 'delegation_committed'; + delegationId: string; + targetTurnId: string; + steered?: true; +} + +export type WorkHubCoordinationMessage = + | WorkHubDelegationIntentMessage + | WorkHubDelegationCommittedMessage; + export interface TurnRecord { turnId: string; firstSequence?: number; @@ -1030,6 +1066,41 @@ const TURN_STATE_MESSAGE_SHAPE = defineObjectShape()( 'errorClass', ], ); +const WORKHUB_DELEGATION_INTENT_MESSAGE_SHAPE = defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'targetSessionId', + 'disposition', + ], + [], +); +const WORKHUB_DELEGATION_COMMITTED_MESSAGE_SHAPE = + defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'targetSessionId', + 'disposition', + 'delegationId', + 'targetTurnId', + ], + ['steered'], + ); const SYSTEM_NOTE_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'ts', 'kind'], ['turnId', 'data'], @@ -1177,6 +1248,11 @@ function decodeMessage( ) return message as unknown as TurnStateMessage; break; + case 'workhub_coordination': + if (isWorkHubCoordinationMessage(message)) { + return message as unknown as WorkHubCoordinationMessage; + } + break; case 'system_note': if ( hasExactShape(message, SYSTEM_NOTE_MESSAGE_SHAPE) && @@ -1190,6 +1266,30 @@ function decodeMessage( throw new Error('Invalid stored message schema'); } +function isWorkHubCoordinationMessage(message: Record): boolean { + const common = + hasMessageEnvelope(message, true) && + message.schemaVersion === WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION && + typeof message.actionId === 'string' && + typeof message.actionFingerprint === 'string' && + /^sha256:[a-f0-9]{64}$/u.test(message.actionFingerprint) && + typeof message.coordinationTurnId === 'string' && + message.turnId === message.coordinationTurnId && + typeof message.targetSessionId === 'string' && + (message.disposition === 'delegate_existing' || message.disposition === 'create_new'); + if (!common) return false; + if (message.kind === 'delegation_intent') { + return hasExactShape(message, WORKHUB_DELEGATION_INTENT_MESSAGE_SHAPE); + } + return ( + message.kind === 'delegation_committed' && + hasExactShape(message, WORKHUB_DELEGATION_COMMITTED_MESSAGE_SHAPE) && + typeof message.delegationId === 'string' && + typeof message.targetTurnId === 'string' && + (message.steered === undefined || message.steered === true) + ); +} + function decodeStoredMessageContent( value: unknown, decodeToolResultContent: (content: unknown) => ToolResultContent, diff --git a/packages/core/src/thread-search.ts b/packages/core/src/thread-search.ts index 881124d3e5..f09df85a39 100644 --- a/packages/core/src/thread-search.ts +++ b/packages/core/src/thread-search.ts @@ -465,6 +465,7 @@ export function threadSearchMatchKind(message: StoredMessage): ThreadSearchMatch case 'permission_decision': case 'token_usage': case 'turn_state': + case 'workhub_coordination': case 'system_note': throw new Error(`Message type ${message.type} is not searchable`); } @@ -488,6 +489,8 @@ export function formatSearchResultSummary(message: StoredMessage): string { return '用量记录'; case 'turn_state': return '回合状态'; + case 'workhub_coordination': + return 'WorkHub 协调记录'; case 'system_note': return '系统记录'; } @@ -543,6 +546,7 @@ export function collectSearchableText(message: StoredMessage): string | undefine case 'permission_decision': case 'token_usage': case 'turn_state': + case 'workhub_coordination': case 'system_note': // Excluded — not user-typed / not user-visible content. return undefined; 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 9d36dae540..b50996e300 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,9 @@ import { WorkHubCoordinationActionGate, type WorkHubActionGateEffects, type WorkHubActionGateSession, + type WorkHubDelegationCommit, + type WorkHubDelegationIntent, + type WorkHubDelegationRecord, } from '../server/workhub-coordination-action-gate.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; @@ -202,8 +205,10 @@ describe('WorkHub Coordination Action Gate', () => { const first = await gate.act(input, CONTEXT); const replay = await gate.act(input, CONTEXT); + const restartedReplay = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); assert.deepEqual(replay, first); + assert.deepEqual(restartedReplay, first); assert.equal(effects.creations.length, 1); assert.equal(effects.submissions.length, 1); assert.match(effects.creations[0]?.sessionId ?? '', /^whs_[a-f0-9]{48}$/u); @@ -223,7 +228,7 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.creations.length, 1); }); - test('effect rejection grants no root ownership and releases the action identity', async () => { + test('effect rejection grants no root ownership and lets the durable intent retry', async () => { const effects = fakeEffects([session('ordinary')]); const gate = new WorkHubCoordinationActionGate(effects); const snapshot = await gate.candidates(); @@ -249,7 +254,31 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal((await gate.act(input, CONTEXT)).disposition, 'delegate_existing'); }); - test('replays an ordinary delegation without submitting twice', async () => { + test('commits a delegation after recovering an unknown submit outcome', async () => { + const effects = fakeEffects([session('ordinary')]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + effects.submitUnknownAfterAdmission = true; + + const result = await gate.act( + { + actionId: 'unknown-submit-action', + userText: 'Continue ordinary work', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ); + + assert.equal(result.disposition, 'delegate_existing'); + assert.equal(effects.submissions.length, 1); + assert.equal(effects.delegations.get('unknown-submit-action')?.kind, 'delegation_committed'); + }); + + test('replays an ordinary delegation durably across Action Gate restart', async () => { const effects = fakeEffects([session('ordinary')]); const gate = new WorkHubCoordinationActionGate(effects); const snapshot = await gate.candidates(); @@ -264,11 +293,52 @@ describe('WorkHub Coordination Action Gate', () => { }; const first = await gate.act(input, CONTEXT); - const replay = await gate.act(input, CONTEXT); + const replay = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); assert.deepEqual(replay, first); assert.equal(effects.submissions.length, 1); assert.equal(effects.submissions[0]?.sessionId, 'ordinary'); + assert.equal(effects.delegations.get(input.actionId)?.kind, 'delegation_committed'); + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { ...input, userText: 'Different work' }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.submissions.length, 1); + }); + + test('resumes a durable intent after restart without re-admitting stale candidates', async () => { + const effects = fakeEffects([session('ordinary')]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const input = { + actionId: 'interrupted-delegate-action', + userText: 'Continue ordinary work', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing' as const, + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }; + effects.commitFailuresRemaining = 1; + + await assert.rejects( + gate.act(input, CONTEXT), + (error) => + error instanceof WorkHubActionEffectFailure && error.code === 'commit_outcome_unknown', + ); + assert.equal(effects.delegations.get(input.actionId)?.kind, 'delegation_intent'); + assert.equal(effects.submissions.length, 1); + + effects.sessions[0] = session('ordinary', { statusUpdatedAt: 99 }); + const recovered = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); + + assert.equal(recovered.disposition, 'delegate_existing'); + assert.equal(effects.submissions.length, 1); + assert.equal(effects.delegations.get(input.actionId)?.kind, 'delegation_committed'); }); }); @@ -290,6 +360,13 @@ function session( } function fakeEffects(initialSessions: WorkHubActionGateSession[]) { + const submitted = new Map< + string, + { + readonly input: { sessionId: string; messageId: string; text: string }; + readonly turnId: string; + } + >(); const state = { sessions: [...initialSessions], answers: [] as Array<{ turnId: string; text: string }>, @@ -304,6 +381,9 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { title: string; }>, submissions: [] as Array<{ sessionId: string; messageId: string; text: string }>, + delegations: new Map(), + commitFailuresRemaining: 0, + submitUnknownAfterAdmission: false as boolean, async listSessions() { return this.sessions; }, @@ -318,11 +398,56 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { workspace: { kind: 'project'; projectId: string } | { kind: 'host_path'; path: string }; title: string; }) { - this.creations.push(input); + if (!this.creations.some(({ sessionId }) => sessionId === input.sessionId)) { + this.creations.push(input); + } }, async submit(input: { sessionId: string; messageId: string; text: string }) { + const existing = submitted.get(input.messageId); + if (existing) { + assert.deepEqual(existing.input, input); + return { turnId: existing.turnId }; + } this.submissions.push(input); - return { turnId: `turn-${input.sessionId}` }; + const turnId = `turn-${input.sessionId}`; + submitted.set(input.messageId, { input, turnId }); + if (this.submitUnknownAfterAdmission) { + this.submitUnknownAfterAdmission = false; + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + 'Target submit outcome is unknown', + ); + } + return { turnId }; + }, + async recoverSubmission(input: { sessionId: string; messageId: string; text: string }) { + const existing = submitted.get(input.messageId); + if (!existing) return undefined; + assert.deepEqual(existing.input, input); + return { turnId: existing.turnId }; + }, + async readDelegation(actionId: string) { + return this.delegations.get(actionId); + }, + async prepareDelegation(intent: WorkHubDelegationIntent) { + const existing = this.delegations.get(intent.actionId); + if (existing) { + assert.deepEqual(existing, intent); + return; + } + this.delegations.set(intent.actionId, intent); + }, + async commitDelegation(commit: WorkHubDelegationCommit) { + const existing = this.delegations.get(commit.actionId); + assert.equal(existing?.kind, 'delegation_intent'); + if (this.commitFailuresRemaining > 0) { + this.commitFailuresRemaining -= 1; + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + 'Delegation commit outcome is unknown', + ); + } + this.delegations.set(commit.actionId, commit); }, } satisfies WorkHubActionGateEffects & { sessions: WorkHubActionGateSession[]; @@ -334,6 +459,9 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { title: string; }>; submissions: Array<{ sessionId: string; messageId: string; text: string }>; + delegations: Map; + commitFailuresRemaining: number; + submitUnknownAfterAdmission: boolean; }; return state; } diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index d78d325d19..02f7c21020 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -38,6 +38,7 @@ 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 { HostWorkHubCoordinationCoordinator, type CoordinationCreateTarget, @@ -480,6 +481,106 @@ describe('Host WorkHub Coordination coordinator', () => { } }); + test('persists delegated action ownership and replays it after Host restart', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-delegation-')); + let store = createSessionStore(root); + try { + await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test-connection', + model: 'test-model', + permissionMode: 'ask', + }); + const submissions: Array<{ sessionId: string; messageId: string; text: string }> = []; + const first = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + create: async () => undefined, + submit: async (input) => { + submissions.push(input); + return { turnId: 'payments-turn' }; + }, + recoverSubmission: async () => undefined, + }); + assert.equal((await first.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); + const candidates = await first.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const input = { + actionId: 'payments-action', + userText: 'Continue payment work', + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing' as const, + candidateRef: candidates.result.candidates[0]!.candidateRef, + }, + }; + const admitted = await first.handlers['workhub.coordination.act'](input, CONTEXT); + assert.deepEqual(admitted, { + ok: true, + result: { + disposition: 'delegate_existing', + targetSessionId: candidates.result.candidates[0]!.sessionId, + targetTurnId: 'payments-turn', + }, + }); + assert.deepEqual( + (await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)) + .filter((message) => message.type === 'workhub_coordination') + .map(({ kind, actionId, targetSessionId }) => ({ kind, actionId, targetSessionId })), + [ + { + kind: 'delegation_intent', + actionId: 'payments-action', + targetSessionId: candidates.result.candidates[0]!.sessionId, + }, + { + kind: 'delegation_committed', + actionId: 'payments-action', + targetSessionId: candidates.result.candidates[0]!.sessionId, + }, + ], + ); + assert.equal(submissions.length, 1); + } finally { + await store.close?.(); + } + + store = createSessionStore(root); + try { + const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { + create: async () => assert.fail('durable replay must not create a Session'), + submit: async () => assert.fail('durable replay must not submit another Turn'), + recoverSubmission: async () => + assert.fail('durable replay must not recover an already committed Turn'), + }); + const candidates = await restarted.handlers['workhub.coordination.candidates']({}, CONTEXT); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const replayed = await restarted.handlers['workhub.coordination.act']( + { + actionId: 'payments-action', + userText: 'Continue payment work', + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidates.result.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ); + assert.equal(replayed.ok, true); + if (replayed.ok) { + assert.equal(replayed.result.disposition, 'delegate_existing'); + if (replayed.result.disposition === 'delegate_existing') { + assert.equal(replayed.result.targetTurnId, 'payments-turn'); + } + } + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('refuses to merge a Turn identity shared across answer and record', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-turn-identity-')); const store = createSessionStore(root); @@ -614,6 +715,11 @@ function coordinator( hasRootTurnAdmission: async () => false, }, admission: SessionAdmissionGate = new SessionAdmissionGate(), + sessionActions: Pick = { + create: async () => undefined, + submit: async ({ sessionId }) => ({ turnId: `turn-${sessionId}` }), + recoverSubmission: async () => undefined, + }, ) { return new HostWorkHubCoordinationCoordinator({ stateRoot: root, @@ -621,10 +727,7 @@ function coordinator( admission, continuity: { refreshCanonical: async () => undefined }, executions, - sessionActions: { - create: async () => undefined, - submit: async ({ sessionId }) => ({ turnId: `turn-${sessionId}` }), - }, + sessionActions, resolveCreateTarget: resolveCreateTarget ?? (async () => ({ diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index cf46c22b9c..f29ea7830f 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -18,7 +18,7 @@ */ import { createHash, randomUUID } from 'node:crypto'; -import { normalizeMessageContent } from '@maka/core/events'; +import { messageContentDigest, normalizeMessageContent } from '@maka/core/events'; import { describeChatConfigurationReason, NO_REAL_CONNECTION_CODE, @@ -1264,9 +1264,69 @@ export async function createExecutionRuntimeHostComposition( outcome.error.message, ); } - return outcome.result.disposition === 'turn_started' - ? { turnId: outcome.result.turnId } - : { turnId: input.messageId, steered: true as const }; + if (outcome.result.disposition === 'turn_started') { + return { turnId: outcome.result.turnId }; + } + try { + const admission = await stores.sessionStore.readMessageAdmission( + input.sessionId, + input.messageId, + ); + if (admission) return { turnId: admission.turnId, steered: true as const }; + } catch { + // The submit already settled; losing its exact Turn identity makes + // the WorkHub linkage outcome uncertain rather than retryable. + } + context.requestDrain(); + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + 'WorkHub target Turn identity could not be proven', + ); + }, + recoverSubmission: async (input) => { + const expectedDigest = messageContentDigest( + normalizeMessageContent({ text: input.text }), + ); + const receipt = await stores.agentRunStore.readRootTurnSourceMessageReceipt( + input.sessionId, + input.messageId, + ); + if (receipt) { + const source = receipt.sourceMessage; + const actualDigest = + source.submittedContentDigest ?? messageContentDigest(source.content); + if (source.placement !== 'current_turn' || actualDigest !== expectedDigest) { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub target Message identity belongs to different content', + ); + } + return source.disposition === 'turn_started' + ? { turnId: receipt.admission.turnId } + : source.disposition === 'steering' + ? { turnId: receipt.admission.turnId, steered: true as const } + : undefined; + } + const admission = await stores.sessionStore.readMessageAdmission( + input.sessionId, + input.messageId, + ); + if (!admission) return undefined; + if ( + admission.submittedPlacement !== 'current_turn' || + admission.submittedContentDigest !== expectedDigest + ) { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub target Message admission belongs to different content', + ); + } + const root = await stores.agentRunStore.readRootTurnAdmission( + input.sessionId, + admission.turnId, + ); + if (!root || root.runId !== admission.runId) return undefined; + return { turnId: admission.turnId, steered: true as const }; }, }, resolveCreateTarget: async () => { 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 bf95b82ae8..1dd75ec489 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -77,8 +77,34 @@ export interface WorkHubActionGateEffects { }, context: ConnectionContext, ): Promise<{ readonly turnId: string; readonly steered?: true }>; + recoverSubmission(input: { + readonly sessionId: string; + readonly messageId: string; + readonly text: string; + }): Promise<{ readonly turnId: string; readonly steered?: true } | undefined>; + readDelegation(actionId: string): Promise; + prepareDelegation(intent: WorkHubDelegationIntent): Promise; + commitDelegation(commit: WorkHubDelegationCommit): Promise; +} + +export interface WorkHubDelegationIntent { + readonly kind: 'delegation_intent'; + readonly actionId: string; + readonly actionFingerprint: `sha256:${string}`; + readonly coordinationTurnId: string; + readonly targetSessionId: string; + readonly disposition: 'delegate_existing' | 'create_new'; +} + +export interface WorkHubDelegationCommit extends Omit { + readonly kind: 'delegation_committed'; + readonly delegationId: string; + readonly targetTurnId: string; + readonly steered?: true; } +export type WorkHubDelegationRecord = WorkHubDelegationIntent | WorkHubDelegationCommit; + export type WorkHubActionEffectFailureCode = | 'host_not_ready' | 'host_draining' @@ -161,12 +187,12 @@ export class WorkHubCoordinationActionGate { return replay.result; } - const result = this.#act(input, context); + const result = this.#act(input, fingerprint, context); const action = { fingerprint, result }; this.#actions.set(input.actionId, action); - // Successful actions remain replayable. A rejected admission does not own - // the action identity forever: callers must be able to refresh stale - // candidates or satisfy an actionable precondition and retry safely. + // Successful actions remain a Host-lifetime fast path. Rejections leave the + // in-memory slot so a pre-intent admission can retry; once an intent is + // durable, the journal independently keeps that action identity owned. void result.catch(() => { if (this.#actions.get(input.actionId) === action) { this.#actions.delete(input.actionId); @@ -178,6 +204,7 @@ export class WorkHubCoordinationActionGate { async #act( input: WorkHubCoordinationActInput, + fingerprint: `sha256:${string}`, context: ConnectionContext, ): Promise { const proposal = input.proposal; @@ -195,6 +222,20 @@ export class WorkHubCoordinationActionGate { }); return { disposition: 'clarify', coordinationTurnId: turnId }; } + const durable = await this.#effects.readDelegation(input.actionId); + if (durable) { + if (durable.actionFingerprint !== fingerprint) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub action identity belongs to a different proposal', + ); + } + if (durable.kind === 'delegation_committed') { + return committedResult(durable); + } + return this.#executeDelegation(input, durable, context); + } + if (proposal.disposition === 'create_new') { if (!input.create) { throw new WorkHubActionGateFailure( @@ -203,20 +244,9 @@ export class WorkHubCoordinationActionGate { ); } const sessionId = workHubCreatedSessionId(input.actionId); - await this.#effects.create({ - sessionId, - workspace: input.create.workspace, - title: proposal.title, - }); - const submitted = await this.#effects.submit( - { - sessionId, - messageId: actionMessageId(input.actionId), - text: input.userText, - }, - context, - ); - return executionResult('create_new', sessionId, submitted); + const intent = delegationIntent(input, fingerprint, sessionId); + await this.#effects.prepareDelegation(intent); + return this.#executeDelegation(input, intent, context); } const candidates = await this.candidates(); @@ -237,23 +267,63 @@ export class WorkHubCoordinationActionGate { } this.#assertTarget(target); - return this.#submitExisting(input, target, context); + const intent = delegationIntent(input, fingerprint, target.sessionId); + await this.#effects.prepareDelegation(intent); + return this.#executeDelegation(input, intent, context); } - async #submitExisting( + async #executeDelegation( input: WorkHubCoordinationActInput, - target: WorkHubCoordinationCandidate, + intent: WorkHubDelegationIntent, context: ConnectionContext, ): Promise { - const submitted = await this.#effects.submit( - { - sessionId: target.sessionId, - messageId: actionMessageId(input.actionId), - text: input.userText, - }, - context, - ); - return executionResult('delegate_existing', target.sessionId, submitted); + if (intent.disposition === 'create_new') { + if (input.proposal.disposition !== 'create_new' || !input.create) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub durable creation intent does not match the requested action', + ); + } + await this.#effects.create({ + sessionId: intent.targetSessionId, + workspace: input.create.workspace, + title: input.proposal.title, + }); + } else if (input.proposal.disposition !== 'delegate_existing') { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub durable delegation intent does not match the requested action', + ); + } + + const message = { + sessionId: intent.targetSessionId, + messageId: actionMessageId(input.actionId), + text: input.userText, + }; + let submitted: { readonly turnId: string; readonly steered?: true }; + try { + submitted = await this.#effects.submit(message, context); + } catch (error) { + if ( + !(error instanceof WorkHubActionEffectFailure) || + error.code !== 'commit_outcome_unknown' + ) { + throw error; + } + const recovered = await this.#effects.recoverSubmission(message); + if (!recovered) throw error; + submitted = recovered; + } + const commit: WorkHubDelegationCommit = { + ...intent, + kind: 'delegation_committed', + delegationId: delegationId(input.actionId), + targetTurnId: submitted.turnId, + ...(submitted.steered ? { steered: true as const } : {}), + }; + await this.#effects.commitDelegation(commit); + return committedResult(commit); } #assertTarget(target: WorkHubCoordinationCandidate): void { @@ -328,6 +398,34 @@ function actionMessageId(actionId: string): string { return `whm_${hash(actionId).slice(0, 48)}`; } +function delegationId(actionId: string): string { + return `whd_${hash(`delegation\0${actionId}`).slice(0, 48)}`; +} + +function delegationIntent( + input: WorkHubCoordinationActInput, + actionFingerprint: `sha256:${string}`, + targetSessionId: string, +): WorkHubDelegationIntent { + if ( + input.proposal.disposition !== 'delegate_existing' && + input.proposal.disposition !== 'create_new' + ) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub local action cannot create a delegation intent', + ); + } + return { + kind: 'delegation_intent', + actionId: input.actionId, + actionFingerprint, + coordinationTurnId: input.actionId, + targetSessionId, + disposition: input.proposal.disposition, + }; +} + function workHubCreatedSessionId(actionId: string): string { return `whs_${hash(`create\0${actionId}`).slice(0, 48)}`; } @@ -350,16 +448,12 @@ function updatedAt(session: WorkHubActionGateSession): number { return session.lastMessageAt ?? session.statusUpdatedAt ?? session.createdAt; } -function executionResult( - disposition: 'delegate_existing' | 'create_new', - sessionId: string, - submitted: { readonly turnId: string; readonly steered?: true }, -): WorkHubCoordinationActResult { +function committedResult(commit: WorkHubDelegationCommit): WorkHubCoordinationActResult { return { - disposition, - targetSessionId: sessionId, - targetTurnId: submitted.turnId, - ...(submitted.steered ? { steered: true as const } : {}), + disposition: commit.disposition, + targetSessionId: commit.targetSessionId, + targetTurnId: commit.targetTurnId, + ...(commit.steered ? { steered: true as const } : {}), } as WorkHubCoordinationActResult; } diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index a90e8449b9..8af2c61439 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -55,6 +55,7 @@ import { WorkHubCoordinationActionGate, type WorkHubActionGateEffects, } from './workhub-coordination-action-gate.js'; +import { WorkHubDelegationJournal } from './workhub-delegation-journal.js'; const CREATE_FINGERPRINT = `sha256:${createHash('sha256') .update('maka:workhub-coordination-session:v1', 'utf8') @@ -100,7 +101,10 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly admission: SessionAdmissionGate; readonly continuity: Pick; readonly executions: CoordinationExecutions; - readonly sessionActions: Pick; + readonly sessionActions: Pick< + WorkHubActionGateEffects, + 'create' | 'submit' | 'recoverSubmission' + >; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; } @@ -123,6 +127,7 @@ export class HostWorkHubCoordinationCoordinator { readonly #resolveCreateTarget: () => Promise; readonly #requestDrain: () => void; readonly #actionGate: WorkHubCoordinationActionGate; + readonly #delegations: WorkHubDelegationJournal; constructor(options: HostWorkHubCoordinationCoordinatorOptions) { this.#coordinationCwd = join(options.stateRoot, COORDINATION_CWD_DIRECTORY); @@ -132,6 +137,12 @@ export class HostWorkHubCoordinationCoordinator { this.#executions = options.executions; this.#resolveCreateTarget = options.resolveCreateTarget; this.#requestDrain = options.requestDrain; + this.#delegations = new WorkHubDelegationJournal({ + stores: options.stores, + admission: options.admission, + continuity: options.continuity, + requestDrain: options.requestDrain, + }); this.#actionGate = new WorkHubCoordinationActionGate({ listSessions: () => this.#stores.listHeaders(), answer: async (input, context) => { @@ -152,6 +163,10 @@ export class HostWorkHubCoordinationCoordinator { }, create: options.sessionActions.create, submit: options.sessionActions.submit, + recoverSubmission: options.sessionActions.recoverSubmission, + readDelegation: (actionId) => this.#delegations.read(actionId), + prepareDelegation: (intent) => this.#delegations.prepare(intent), + commitDelegation: (commit) => this.#delegations.commit(commit), }); } diff --git a/packages/runtime-host/src/server/workhub-delegation-journal.ts b/packages/runtime-host/src/server/workhub-delegation-journal.ts new file mode 100644 index 0000000000..9d7937702b --- /dev/null +++ b/packages/runtime-host/src/server/workhub-delegation-journal.ts @@ -0,0 +1,300 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { + WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION, + WORKHUB_COORDINATION_SESSION_ID, + isWorkHubCoordinationSession, + type StoredMessage, + type WorkHubDelegationCommittedMessage, + type WorkHubDelegationIntentMessage, +} from '@maka/core/session'; +import type { SessionAuthorityStore } from '@maka/storage/session-store'; +import type { SessionContinuityCoordinator } from './session-continuity-coordinator.js'; +import { + WorkHubActionEffectFailure, + type WorkHubDelegationCommit, + type WorkHubDelegationIntent, + type WorkHubDelegationRecord, +} from './workhub-coordination-action-gate.js'; +import type { SessionAdmissionGate } from './session-admission-gate.js'; + +const RECORD_KINDS = ['delegation_intent', 'delegation_committed'] as const; +const RECORD_READ_MAX_BYTES = 16 * 1024; + +type JournalStores = Pick< + SessionAuthorityStore, + | 'appendMessages' + | 'readHeaderSnapshot' + | 'readTranscriptHighWaterSnapshot' + | 'readTranscriptMessagesSnapshot' +>; + +export interface WorkHubDelegationJournalOptions { + readonly stores: JournalStores; + readonly admission: SessionAdmissionGate; + readonly continuity: Pick; + readonly requestDrain: () => void; +} + +/** + * Append-only authority for WorkHub action intent and committed delegation links. + * + * The interface exposes domain records only. Message identities, transcript + * snapshots, exact replay checks, and commit-outcome handling stay local here. + */ +export class WorkHubDelegationJournal { + readonly #stores: JournalStores; + readonly #admission: SessionAdmissionGate; + readonly #continuity: Pick; + readonly #requestDrain: () => void; + + constructor(options: WorkHubDelegationJournalOptions) { + this.#stores = options.stores; + this.#admission = options.admission; + this.#continuity = options.continuity; + this.#requestDrain = options.requestDrain; + } + + async read(actionId: string): Promise { + await this.#assertCoordinationSession(); + const messages = await this.#readMessages(actionId); + return this.#projectRecord(actionId, messages); + } + + prepare(intent: WorkHubDelegationIntent): Promise { + return this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, async (lease) => { + await this.#assertCoordinationSession(); + const existing = this.#projectRecord( + intent.actionId, + await this.#readMessages(intent.actionId), + ); + if (existing) { + if (!sameIntent(existing, intent)) throw actionConflict(); + return; + } + try { + await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [intentMessage(intent)]); + await this.#continuity.refreshCanonical(WORKHUB_COORDINATION_SESSION_ID, lease); + } catch (error) { + if (error instanceof WorkHubActionEffectFailure) throw error; + this.#requestDrain(); + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + 'WorkHub delegation intent outcome is unknown', + ); + } + }); + } + + commit(commit: WorkHubDelegationCommit): Promise { + return this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, async (lease) => { + await this.#assertCoordinationSession(); + const existing = this.#projectRecord( + commit.actionId, + await this.#readMessages(commit.actionId), + ); + if (existing?.kind === 'delegation_committed') { + if (!sameCommit(existing, commit)) throw actionConflict(); + return; + } + if (!existing || !sameIntent(existing, commit)) throw actionConflict(); + try { + await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [ + committedMessage(commit), + ]); + await this.#continuity.refreshCanonical(WORKHUB_COORDINATION_SESSION_ID, lease); + } catch (error) { + if (error instanceof WorkHubActionEffectFailure) throw error; + this.#requestDrain(); + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + 'WorkHub delegation commit outcome is unknown', + ); + } + }); + } + + async #assertCoordinationSession(): Promise { + try { + const header = await this.#stores.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID); + if (isWorkHubCoordinationSession(header) && !header.isArchived) return; + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub Coordination Session identity is unavailable', + ); + } catch (error) { + if (error instanceof WorkHubActionEffectFailure) throw error; + throw new WorkHubActionEffectFailure( + 'persistence_failed', + 'WorkHub Coordination Session state is unavailable', + ); + } + } + + async #readMessages(actionId: string): Promise { + try { + const throughSequence = await this.#stores.readTranscriptHighWaterSnapshot( + WORKHUB_COORDINATION_SESSION_ID, + ); + if (throughSequence === null) return []; + return await this.#stores.readTranscriptMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID, { + messageIds: RECORD_KINDS.map((kind) => recordMessageId(actionId, kind)), + throughSequence, + maxBytes: RECORD_READ_MAX_BYTES, + maxMessages: RECORD_KINDS.length, + }); + } catch (error) { + if (error instanceof WorkHubActionEffectFailure) throw error; + throw new WorkHubActionEffectFailure( + 'persistence_failed', + 'WorkHub delegation records are unavailable', + ); + } + } + + #projectRecord( + actionId: string, + messages: readonly StoredMessage[], + ): WorkHubDelegationRecord | undefined { + try { + return projectRecord(actionId, messages); + } catch (error) { + this.#requestDrain(); + throw error; + } + } +} + +function projectRecord( + actionId: string, + messages: readonly StoredMessage[], +): WorkHubDelegationRecord | undefined { + if (messages.length === 0) return undefined; + const intent = messages.find( + (message): message is WorkHubDelegationIntentMessage => + message.type === 'workhub_coordination' && message.kind === 'delegation_intent', + ); + const committed = messages.find( + (message): message is WorkHubDelegationCommittedMessage => + message.type === 'workhub_coordination' && message.kind === 'delegation_committed', + ); + if ( + messages.length !== Number(intent !== undefined) + Number(committed !== undefined) || + intent?.actionId !== actionId || + (committed !== undefined && (!intent || !sameMessageIntent(intent, committed))) + ) { + throw new WorkHubActionEffectFailure( + 'persistence_failed', + 'WorkHub delegation record chain is invalid', + ); + } + return committed ? commitRecord(committed) : intent ? intentRecord(intent) : undefined; +} + +function intentMessage(intent: WorkHubDelegationIntent): WorkHubDelegationIntentMessage { + return { + type: 'workhub_coordination', + id: recordMessageId(intent.actionId, 'delegation_intent'), + turnId: intent.coordinationTurnId, + ts: Date.now(), + schemaVersion: WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION, + ...intent, + }; +} + +function committedMessage(commit: WorkHubDelegationCommit): WorkHubDelegationCommittedMessage { + return { + type: 'workhub_coordination', + id: recordMessageId(commit.actionId, 'delegation_committed'), + turnId: commit.coordinationTurnId, + ts: Date.now(), + schemaVersion: WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION, + ...commit, + }; +} + +function intentRecord(message: WorkHubDelegationIntentMessage): WorkHubDelegationIntent { + return { + kind: message.kind, + actionId: message.actionId, + actionFingerprint: message.actionFingerprint, + coordinationTurnId: message.coordinationTurnId, + targetSessionId: message.targetSessionId, + disposition: message.disposition, + }; +} + +function commitRecord(message: WorkHubDelegationCommittedMessage): WorkHubDelegationCommit { + return { + kind: 'delegation_committed', + actionId: message.actionId, + actionFingerprint: message.actionFingerprint, + coordinationTurnId: message.coordinationTurnId, + targetSessionId: message.targetSessionId, + disposition: message.disposition, + delegationId: message.delegationId, + targetTurnId: message.targetTurnId, + ...(message.steered ? { steered: true as const } : {}), + }; +} + +function sameMessageIntent( + intent: WorkHubDelegationIntentMessage, + committed: WorkHubDelegationCommittedMessage, +): boolean { + return sameIntent(intentRecord(intent), commitRecord(committed)); +} + +function sameIntent( + left: WorkHubDelegationRecord, + right: Omit, +): boolean { + return ( + left.actionId === right.actionId && + left.actionFingerprint === right.actionFingerprint && + left.coordinationTurnId === right.coordinationTurnId && + left.targetSessionId === right.targetSessionId && + left.disposition === right.disposition + ); +} + +function sameCommit(left: WorkHubDelegationCommit, right: WorkHubDelegationCommit): boolean { + return ( + sameIntent(left, right) && + left.delegationId === right.delegationId && + left.targetTurnId === right.targetTurnId && + left.steered === right.steered + ); +} + +function recordMessageId(actionId: string, kind: (typeof RECORD_KINDS)[number]): string { + return `whj_${createHash('sha256') + .update(`${actionId}\0${kind}`, 'utf8') + .digest('hex') + .slice(0, 48)}`; +} + +function actionConflict(): WorkHubActionEffectFailure { + return new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub action identity belongs to different durable delegation content', + ); +} From 0b2178512df30055b4a5657abd73a5e7ed01fcd2 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 26 Aug 2026 23:12:14 +0800 Subject: [PATCH 2/5] fix(workhub): preserve retry action identity Generated-by: Codex --- .../desktop-session-projection.test.ts | 1 + .../__tests__/workhub-surface-flow.test.ts | 72 ++++++++++ .../src/renderer/workhub-send-lease.ts | 131 ++++++++++++++++++ apps/desktop/src/renderer/workhub-surface.tsx | 64 ++++++--- .../workhub-coordination-session-adr.md | 10 ++ .../workhub-coordination-record.test.ts | 33 +++++ packages/core/src/session.ts | 58 +++++++- .../workhub-coordination-action-gate.test.ts | 79 ++++++++++- .../workhub-coordination-coordinator.test.ts | 5 +- .../workhub-coordination-action-gate.ts | 111 +++++++++------ .../src/server/workhub-delegation-journal.ts | 13 +- 11 files changed, 504 insertions(+), 73 deletions(-) create mode 100644 apps/desktop/src/renderer/workhub-send-lease.ts diff --git a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts index 400d7ca1c4..f04847c419 100644 --- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts @@ -215,6 +215,7 @@ test('projects durable WorkHub delegation targets into the Desktop host namespac coordinationTurnId: 'coordination-turn', targetSessionId: 'payments', disposition: 'delegate_existing', + userText: 'Continue payment work', delegationId: 'delegation-id', targetTurnId: 'payments-turn', }, 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 3440e0b854..72248f5444 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -26,6 +26,7 @@ import { WorkHubCoordinationStatus, WorkHubProjectionRefreshGate, WorkHubSurfaceRouteGate, + submitAndRecordWorkHubSurfaceInput, submitWorkHubSurfaceInput, visibleWorkHubConversation, workHubSurfaceFailure, @@ -41,6 +42,77 @@ import { createDesktopWorkHubSessionPort, type WorkHubDesktopSession, } from '../../renderer/workhub-session-port.js'; +import { WorkHubSendLease } from '../../renderer/workhub-send-lease.js'; + +test('production retry keeps one action identity across failure and renderer reload', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + const ids = ['action-1', 'action-2']; + const first = new WorkHubSendLease(storage, () => ids.shift()!); + + assert.equal(first.acquire('Continue payment work'), 'action-1'); + + const restarted = new WorkHubSendLease(storage, () => ids.shift()!); + assert.equal(restarted.acquire('Continue payment work'), 'action-1'); + restarted.complete('action-1'); + assert.equal(restarted.acquire('Continue payment work'), 'action-2'); +}); + +test('summary failure keeps the target action retryable under the same production identity', async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + const actionIds: string[] = []; + let summaries = 0; + const controller: WorkHubController = { + read: async () => ({ sessions: [], turns: [] }), + openConversation: async () => ({ close: async () => undefined }), + recordConversationTurn: async ({ turnId }) => { + summaries += 1; + if (summaries === 1) throw new Error('summary outcome unknown'); + return { turnId }; + }, + resetVisitContext: () => {}, + subscribe: () => () => {}, + submit: async (input) => { + actionIds.push(input.requestId); + return { + kind: 'submitted', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target: { sessionId: 'payment' }, + turnId: 'payment-turn', + evidence: 'explicit_target', + }; + }, + }; + const send = (requestId: string) => submitAndRecordWorkHubSurfaceInput({ + controller, + request: { requestId, text: 'Continue payment work' }, + recordedUserText: 'Continue payment work', + summary: () => 'Sent to Payments.', + onSummaryError: () => undefined, + }); + const first = new WorkHubSendLease(storage, () => 'action-1'); + const requestId = first.acquire('Continue payment work'); + + await assert.rejects(send(requestId), /summary outcome unknown/u); + + const restarted = new WorkHubSendLease(storage, () => 'action-2'); + const retriedId = restarted.acquire('Continue payment work'); + await send(retriedId); + restarted.complete(retriedId); + + assert.deepEqual(actionIds, ['action-1', 'action-1']); + assert.equal(summaries, 2); +}); test('surface turns Action Gate rejections into safe actionable failures', () => { assert.equal( diff --git a/apps/desktop/src/renderer/workhub-send-lease.ts b/apps/desktop/src/renderer/workhub-send-lease.ts new file mode 100644 index 0000000000..089f302b20 --- /dev/null +++ b/apps/desktop/src/renderer/workhub-send-lease.ts @@ -0,0 +1,131 @@ +/* + * 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. + */ + +const WORKHUB_SEND_LEASE_KEY = 'maka-workhub-send-lease-v1'; +const WORKHUB_DRAFT_KEY = 'workhub'; +const MAX_DRAFT_CHARS = 120_000; +const SAFE_REQUEST_ID = /^[A-Za-z0-9_-]{1,128}$/u; + +type WorkHubSendLeaseStorage = Pick; + +interface WorkHubSendLeaseState { + readonly version: 1; + readonly draft: string; + readonly requestId?: string; +} + +/** + * Couples the reload-safe Composer draft to the Action Gate identity that owns + * its delivery. A failed send keeps both; a fully settled send retires only the + * identity and lets Composer decide whether the text itself should clear. + */ +export class WorkHubSendLease { + #memory: WorkHubSendLeaseState | undefined; + + constructor( + private readonly storage: WorkHubSendLeaseStorage | undefined = rendererSessionStorage(), + private readonly createId: () => string = () => crypto.randomUUID(), + ) {} + + acquire(text: string): string { + const existing = this.#read(); + if (existing?.draft === text && existing.requestId) return existing.requestId; + const requestId = this.createId(); + this.#write({ version: 1, draft: text, requestId }); + return requestId; + } + + complete(requestId: string): void { + const existing = this.#read(); + if (existing?.requestId !== requestId) return; + this.#write({ version: 1, draft: existing.draft }); + } + + read(key: string | undefined): string | undefined { + return key === WORKHUB_DRAFT_KEY ? this.#read()?.draft : undefined; + } + + write(key: string | undefined, draft: string): void { + if (key !== WORKHUB_DRAFT_KEY) return; + if (!draft) { + this.#remove(); + return; + } + const existing = this.#read(); + this.#write({ + version: 1, + draft, + ...(existing?.draft === draft && existing.requestId + ? { requestId: existing.requestId } + : {}), + }); + } + + #read(): WorkHubSendLeaseState | undefined { + try { + const raw = this.storage?.getItem(WORKHUB_SEND_LEASE_KEY); + if (!raw) return this.#memory; + const value = JSON.parse(raw) as Partial; + if ( + value.version !== 1 || + typeof value.draft !== 'string' || + value.draft.length > MAX_DRAFT_CHARS || + (value.requestId !== undefined && + (typeof value.requestId !== 'string' || !SAFE_REQUEST_ID.test(value.requestId))) + ) { + return undefined; + } + const decoded = { + version: 1, + draft: value.draft, + ...(value.requestId ? { requestId: value.requestId } : {}), + } satisfies WorkHubSendLeaseState; + this.#memory = decoded; + return decoded; + } catch { + return this.#memory; + } + } + + #write(value: WorkHubSendLeaseState): void { + this.#memory = value; + try { + this.storage?.setItem(WORKHUB_SEND_LEASE_KEY, JSON.stringify(value)); + } catch { + // Restricted renderer contexts may not expose web storage. + } + } + + #remove(): void { + this.#memory = undefined; + try { + this.storage?.removeItem(WORKHUB_SEND_LEASE_KEY); + } catch { + // Restricted renderer contexts may not expose web storage. + } + } +} + +function rendererSessionStorage(): WorkHubSendLeaseStorage | undefined { + try { + return typeof window === 'undefined' ? undefined : window.sessionStorage; + } catch { + return undefined; + } +} diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 71f0179a6d..1334b7126a 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -35,6 +35,7 @@ import type { WorkHubSubmission, WorkHubSubmitInput, } from './workhub-controller.js'; +import { WorkHubSendLease } from './workhub-send-lease.js'; export interface WorkHubConversationTurn { requestId: string; @@ -134,6 +135,32 @@ export async function submitWorkHubSurfaceInput(input: { return input.controller.submit(input.input); } +export async function submitAndRecordWorkHubSurfaceInput(input: { + controller: WorkHubController; + request: WorkHubSubmitInput; + recordedUserText: string; + summary(result: Exclude): string; + onSummaryError(): void; +}): Promise { + const result = await submitWorkHubSurfaceInput({ + controller: input.controller, + input: input.request, + }); + if (result.kind === 'discussion') return result; + try { + await input.controller.recordConversationTurn({ + turnId: input.request.requestId, + userText: input.recordedUserText, + assistantText: input.summary(result), + disposition: result.kind === 'clarification' ? 'clarify' : 'summary', + }); + } catch (error) { + input.onSummaryError(); + throw error; + } + return result; +} + /** * The persistent Coordination Session transcript is the primary conversation. * Ordinary Sessions remain a read-only status/routing projection. @@ -155,6 +182,7 @@ export function WorkHubSurface(props: { // a rerender can disable Composer and clarification controls. const routeGate = useRef(new WorkHubSurfaceRouteGate()).current; const refreshGate = useRef(new WorkHubProjectionRefreshGate()).current; + const sendLease = useRef(new WorkHubSendLease()).current; const [loadError, setLoadError] = useState(false); const [conversationError, setConversationError] = useState(false); const refresh = useCallback(async (focusSessionId?: string) => { @@ -224,24 +252,16 @@ export function WorkHubSurface(props: { : turn, )); try { - const result = await submitWorkHubSurfaceInput({ + const result = await submitAndRecordWorkHubSurfaceInput({ controller: props.controller, - input, + request: input, + recordedUserText, + summary: (result) => workHubCoordinationSummary(result, projection, copy), + // The ordinary Session admission may already have settled. A failed + // Coordination summary keeps this send incomplete so the retry + // reuses its durable Action Gate identity before filling the gap. + onSummaryError: () => setConversationError(true), }); - if (result.kind !== 'discussion') { - try { - await props.controller.recordConversationTurn({ - turnId: input.requestId, - userText: recordedUserText, - assistantText: workHubCoordinationSummary(result, projection, copy), - disposition: result.kind === 'clarification' ? 'clarify' : 'summary', - }); - } catch { - // The ordinary Session admission has already settled. A failed - // Coordination summary must not make retry duplicate that work. - setConversationError(true); - } - } setTurns((current) => current.map((turn) => turn.requestId === localRequestId ? { ...turn, state: 'settled', outcome: result } @@ -270,13 +290,18 @@ export function WorkHubSurface(props: { const send = useCallback(async (value: string) => { const text = value.trim(); if (!text || !initialLoadSettled || !conversationReady || routeGate.pending) return false; - const requestId = crypto.randomUUID(); - setTurns((current) => [...current, { requestId, text, state: 'routing' }]); + const requestId = sendLease.acquire(text); + setTurns((current) => current.some((turn) => turn.requestId === requestId) + ? current.map((turn) => turn.requestId === requestId + ? { requestId, text, state: 'routing' } + : turn) + : [...current, { requestId, text, state: 'routing' }]); const result = await route({ requestId, text }); + if (result) sendLease.complete(requestId); // Composer clears only accepted drafts. Waiting, delivery failures, and a // ref-blocked duplicate keep the exact text available for retry. return workHubSubmissionClearsDraft(result); - }, [conversationReady, initialLoadSettled, route, routeGate]); + }, [conversationReady, initialLoadSettled, route, routeGate, sendLease]); const visible = visibleWorkHubConversation(coordinationTurns, turns); const visibleCoordinationTurns = visible.coordination; const visibleLocalTurns = visible.local; @@ -290,6 +315,7 @@ export function WorkHubSurface(props: { composer={( {}} sendBlocked={pending || !surfaceReady} diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 2ed812375a..5a9e29d49d 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -123,6 +123,16 @@ and acts as the durable action-replay result. The records carry an action fingerprint to reject conflicting reuse of an action identity. They do not form a general workflow state machine and do not persist target execution lifecycle. +The renderer couples one reload-safe Composer draft to one action identity until +both target admission and its Coordination summary settle. Retry therefore reuses +the same identity instead of treating the retained draft as new work. The durable +fingerprint covers stable user intent, not snapshot-scoped candidate ids; once +prepared, the intent owns the resolved target, exact user text, and any +`create_new` title/workspace context. Recovery is deliberately driven by that +explicit caller retry rather than an autonomous startup scan: the latter would +execute user work without a live request context and turn this journal into a +background workflow engine. + ## Consequences, costs, and reevaluation - WorkHub gains persistent conversational continuity without adding another diff --git a/packages/core/src/__tests__/workhub-coordination-record.test.ts b/packages/core/src/__tests__/workhub-coordination-record.test.ts index 522c0af041..7cf1b72270 100644 --- a/packages/core/src/__tests__/workhub-coordination-record.test.ts +++ b/packages/core/src/__tests__/workhub-coordination-record.test.ts @@ -37,6 +37,7 @@ describe('WorkHub Coordination stored records', () => { coordinationTurnId: 'coordination-turn', targetSessionId: 'payments', disposition: 'delegate_existing', + userText: 'Continue payment work', } as const; const committed = { ...intent, @@ -71,6 +72,7 @@ describe('WorkHub Coordination stored records', () => { { ...base, coordinationTurnId: 'different-turn' }, { ...base, actionFingerprint: 'not-a-digest' }, { ...base, disposition: 'replace' }, + { ...base, userText: undefined }, { ...base, sourceSessionId: 'injected' }, { ...base, kind: 'delegation_committed' }, { ...base, schemaVersion: 2 }, @@ -78,4 +80,35 @@ describe('WorkHub Coordination stored records', () => { assert.throws(() => decodeCanonicalMessage(invalid), /Invalid stored message schema/u); } }); + + test('requires a complete create payload only for create_new intents', () => { + const create = { + type: 'workhub_coordination', + id: 'create-intent-id', + turnId: 'coordination-turn', + ts: 1, + schemaVersion: 1, + kind: 'delegation_intent', + actionId: 'action-id', + actionFingerprint: FINGERPRINT, + coordinationTurnId: 'coordination-turn', + targetSessionId: 'created-session', + disposition: 'create_new', + userText: 'Create a login audit', + create: { + title: 'Login audit', + workspace: { kind: 'project', projectId: 'project-maka' }, + }, + } as const; + + assert.deepEqual(decodeCanonicalMessage(create), create); + assert.throws( + () => decodeCanonicalMessage({ ...create, create: undefined }), + /Invalid stored message schema/u, + ); + assert.throws( + () => decodeCanonicalMessage({ ...create, disposition: 'delegate_existing' }), + /Invalid stored message schema/u, + ); + }); }); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index f7f19986d1..84564b26c6 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -913,6 +913,15 @@ export const WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION = 1 as const; export type WorkHubDelegationDisposition = 'delegate_existing' | 'create_new'; +export type WorkHubDelegationWorkspace = + | { readonly kind: 'project'; readonly projectId: string } + | { readonly kind: 'host_path'; readonly path: string }; + +export interface WorkHubDelegationCreateSpec { + readonly title: string; + readonly workspace: WorkHubDelegationWorkspace; +} + interface WorkHubCoordinationMessageEnvelope { type: 'workhub_coordination'; id: string; @@ -925,6 +934,10 @@ interface WorkHubCoordinationMessageEnvelope { coordinationTurnId: string; targetSessionId: string; disposition: WorkHubDelegationDisposition; + /** Exact target payload; retained so retry does not depend on renderer memory. */ + userText: string; + /** Present exactly for create_new. */ + create?: WorkHubDelegationCreateSpec; } /** Durable target choice written before a delegated Session effect is attempted. */ @@ -1079,8 +1092,9 @@ const WORKHUB_DELEGATION_INTENT_MESSAGE_SHAPE = defineObjectShape()( @@ -1096,11 +1110,22 @@ const WORKHUB_DELEGATION_COMMITTED_MESSAGE_SHAPE = 'coordinationTurnId', 'targetSessionId', 'disposition', + 'userText', 'delegationId', 'targetTurnId', ], - ['steered'], + ['create', 'steered'], ); +const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( + ['title', 'workspace'], + [], +); +const WORKHUB_DELEGATION_PROJECT_WORKSPACE_SHAPE = defineObjectShape< + Extract +>()(['kind', 'projectId'], []); +const WORKHUB_DELEGATION_HOST_PATH_WORKSPACE_SHAPE = defineObjectShape< + Extract +>()(['kind', 'path'], []); const SYSTEM_NOTE_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'ts', 'kind'], ['turnId', 'data'], @@ -1276,6 +1301,10 @@ function isWorkHubCoordinationMessage(message: Record): boolean typeof message.coordinationTurnId === 'string' && message.turnId === message.coordinationTurnId && typeof message.targetSessionId === 'string' && + typeof message.userText === 'string' && + message.userText.trim().length > 0 && + ((message.disposition === 'delegate_existing' && message.create === undefined) || + (message.disposition === 'create_new' && isWorkHubDelegationCreateSpec(message.create))) && (message.disposition === 'delegate_existing' || message.disposition === 'create_new'); if (!common) return false; if (message.kind === 'delegation_intent') { @@ -1290,6 +1319,31 @@ function isWorkHubCoordinationMessage(message: Record): boolean ); } +function isWorkHubDelegationCreateSpec(value: unknown): value is WorkHubDelegationCreateSpec { + if ( + !isRecord(value) || + !hasExactShape(value, WORKHUB_DELEGATION_CREATE_SHAPE) || + typeof value.title !== 'string' || + value.title.trim().length === 0 || + !isRecord(value.workspace) + ) { + return false; + } + if (value.workspace.kind === 'project') { + return ( + hasExactShape(value.workspace, WORKHUB_DELEGATION_PROJECT_WORKSPACE_SHAPE) && + typeof value.workspace.projectId === 'string' && + value.workspace.projectId.length > 0 + ); + } + return ( + value.workspace.kind === 'host_path' && + hasExactShape(value.workspace, WORKHUB_DELEGATION_HOST_PATH_WORKSPACE_SHAPE) && + typeof value.workspace.path === 'string' && + value.workspace.path.length > 0 + ); +} + function decodeStoredMessageContent( value: unknown, decodeToolResultContent: (content: unknown) => ToolResultContent, 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 b50996e300..5d6a913b6a 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 @@ -221,8 +221,19 @@ describe('WorkHub Coordination Action Gate', () => { 'title', 'workspace', ]); + assert.deepEqual( + await new WorkHubCoordinationActionGate(effects).act( + { + ...input, + proposal: { disposition: 'create_new', title: 'Recomputed title' }, + create: { workspace: { kind: 'project', projectId: 'new-current-project' } }, + }, + CONTEXT, + ), + first, + ); await assert.rejects( - gate.act({ ...input, proposal: { disposition: 'create_new', title: 'Different' } }, CONTEXT), + gate.act({ ...input, userText: 'Create different work' }, CONTEXT), (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', ); assert.equal(effects.creations.length, 1); @@ -334,12 +345,70 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.submissions.length, 1); effects.sessions[0] = session('ordinary', { statusUpdatedAt: 99 }); - const recovered = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); + const restarted = new WorkHubCoordinationActionGate(effects); + const refreshed = await restarted.candidates(); + await assert.rejects( + restarted.act( + { + actionId: input.actionId, + userText: input.userText, + proposal: { disposition: 'answer_here' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + const recovered = await restarted.act( + { + ...input, + candidateSetId: refreshed.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: refreshed.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ); assert.equal(recovered.disposition, 'delegate_existing'); assert.equal(effects.submissions.length, 1); assert.equal(effects.delegations.get(input.actionId)?.kind, 'delegation_committed'); }); + + test('resumes create_new from the durable payload instead of recomputed caller context', async () => { + const effects = fakeEffects([session('ordinary')]); + const input = { + actionId: 'interrupted-create-action', + userText: 'Create a login audit', + proposal: { disposition: 'create_new' as const, title: 'Login audit' }, + create: { workspace: { kind: 'project' as const, projectId: 'original-project' } }, + }; + effects.commitFailuresRemaining = 1; + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + (error) => + error instanceof WorkHubActionEffectFailure && error.code === 'commit_outcome_unknown', + ); + const recovered = await new WorkHubCoordinationActionGate(effects).act( + { + ...input, + proposal: { disposition: 'create_new', title: 'Recomputed title' }, + create: { workspace: { kind: 'project', projectId: 'new-current-project' } }, + }, + CONTEXT, + ); + + assert.equal(recovered.disposition, 'create_new'); + assert.deepEqual(effects.creations, [ + { + sessionId: effects.creations[0]!.sessionId, + workspace: { kind: 'project', projectId: 'original-project' }, + title: 'Login audit', + }, + ]); + assert.equal(effects.submissions.length, 1); + }); }); function session( @@ -398,9 +467,9 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { workspace: { kind: 'project'; projectId: string } | { kind: 'host_path'; path: string }; title: string; }) { - if (!this.creations.some(({ sessionId }) => sessionId === input.sessionId)) { - this.creations.push(input); - } + const existing = this.creations.find(({ sessionId }) => sessionId === input.sessionId); + if (existing) assert.deepEqual(existing, input); + else this.creations.push(input); }, async submit(input: { sessionId: string; messageId: string; text: string }) { const existing = submitted.get(input.messageId); 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 02f7c21020..f73f803b25 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -483,6 +483,7 @@ describe('Host WorkHub Coordination coordinator', () => { test('persists delegated action ownership and replays it after Host restart', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-delegation-')); + const userText = 'Continue payment work. '.repeat(900); let store = createSessionStore(root); try { await store.create({ @@ -507,7 +508,7 @@ describe('Host WorkHub Coordination coordinator', () => { if (!candidates.ok) return; const input = { actionId: 'payments-action', - userText: 'Continue payment work', + userText, candidateSetId: candidates.result.candidateSetId, proposal: { disposition: 'delegate_existing' as const, @@ -559,7 +560,7 @@ describe('Host WorkHub Coordination coordinator', () => { const replayed = await restarted.handlers['workhub.coordination.act']( { actionId: 'payments-action', - userText: 'Continue payment work', + userText, candidateSetId: candidates.result.candidateSetId, proposal: { disposition: 'delegate_existing', 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 1dd75ec489..7d6f2bbb86 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -18,7 +18,12 @@ */ import { createHash } from 'node:crypto'; -import type { SessionHeader, SessionStatus } from '@maka/core/session'; +import type { + SessionHeader, + SessionStatus, + WorkHubDelegationCommittedMessage, + WorkHubDelegationIntentMessage, +} from '@maka/core/session'; import { WORKHUB_COORDINATION_SESSION_ID, isWorkHubCoordinationSessionTarget, @@ -87,21 +92,17 @@ export interface WorkHubActionGateEffects { commitDelegation(commit: WorkHubDelegationCommit): Promise; } -export interface WorkHubDelegationIntent { - readonly kind: 'delegation_intent'; - readonly actionId: string; - readonly actionFingerprint: `sha256:${string}`; - readonly coordinationTurnId: string; - readonly targetSessionId: string; - readonly disposition: 'delegate_existing' | 'create_new'; -} +type StoredDelegationEnvelopeKeys = 'type' | 'id' | 'turnId' | 'ts' | 'schemaVersion'; -export interface WorkHubDelegationCommit extends Omit { - readonly kind: 'delegation_committed'; - readonly delegationId: string; - readonly targetTurnId: string; - readonly steered?: true; -} +export type WorkHubDelegationIntent = Omit< + WorkHubDelegationIntentMessage, + StoredDelegationEnvelopeKeys +>; + +export type WorkHubDelegationCommit = Omit< + WorkHubDelegationCommittedMessage, + StoredDelegationEnvelopeKeys +>; export type WorkHubDelegationRecord = WorkHubDelegationIntent | WorkHubDelegationCommit; @@ -173,7 +174,7 @@ export class WorkHubCoordinationActionGate { input: WorkHubCoordinationActInput, context: ConnectionContext, ): Promise { - const fingerprint = digest(input); + const fingerprint = actionFingerprint(input); const replay = this.#actions.get(input.actionId); if (replay) { if (replay.fingerprint !== fingerprint) { @@ -208,6 +209,19 @@ export class WorkHubCoordinationActionGate { context: ConnectionContext, ): Promise { const proposal = input.proposal; + const durable = await this.#effects.readDelegation(input.actionId); + if (durable) { + if (durable.actionFingerprint !== fingerprint) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub action identity belongs to a different proposal', + ); + } + if (durable.kind === 'delegation_committed') { + return committedResult(durable); + } + return this.#executeDelegation(durable, context); + } if (proposal.disposition === 'answer_here') { const turnId = coordinationTurnId(input.actionId, 'answer'); await this.#effects.answer({ turnId, text: input.userText }, context); @@ -222,20 +236,6 @@ export class WorkHubCoordinationActionGate { }); return { disposition: 'clarify', coordinationTurnId: turnId }; } - const durable = await this.#effects.readDelegation(input.actionId); - if (durable) { - if (durable.actionFingerprint !== fingerprint) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub action identity belongs to a different proposal', - ); - } - if (durable.kind === 'delegation_committed') { - return committedResult(durable); - } - return this.#executeDelegation(input, durable, context); - } - if (proposal.disposition === 'create_new') { if (!input.create) { throw new WorkHubActionGateFailure( @@ -246,7 +246,7 @@ export class WorkHubCoordinationActionGate { const sessionId = workHubCreatedSessionId(input.actionId); const intent = delegationIntent(input, fingerprint, sessionId); await this.#effects.prepareDelegation(intent); - return this.#executeDelegation(input, intent, context); + return this.#executeDelegation(intent, context); } const candidates = await this.candidates(); @@ -269,37 +269,36 @@ export class WorkHubCoordinationActionGate { const intent = delegationIntent(input, fingerprint, target.sessionId); await this.#effects.prepareDelegation(intent); - return this.#executeDelegation(input, intent, context); + return this.#executeDelegation(intent, context); } async #executeDelegation( - input: WorkHubCoordinationActInput, intent: WorkHubDelegationIntent, context: ConnectionContext, ): Promise { if (intent.disposition === 'create_new') { - if (input.proposal.disposition !== 'create_new' || !input.create) { + if (!intent.create) { throw new WorkHubActionGateFailure( 'action_conflict', - 'WorkHub durable creation intent does not match the requested action', + 'WorkHub durable creation intent is incomplete', ); } await this.#effects.create({ sessionId: intent.targetSessionId, - workspace: input.create.workspace, - title: input.proposal.title, + workspace: intent.create.workspace, + title: intent.create.title, }); - } else if (input.proposal.disposition !== 'delegate_existing') { + } else if (intent.create) { throw new WorkHubActionGateFailure( 'action_conflict', - 'WorkHub durable delegation intent does not match the requested action', + 'WorkHub durable delegation intent contains creation context', ); } const message = { sessionId: intent.targetSessionId, - messageId: actionMessageId(input.actionId), - text: input.userText, + messageId: actionMessageId(intent.actionId), + text: intent.userText, }; let submitted: { readonly turnId: string; readonly steered?: true }; try { @@ -318,7 +317,7 @@ export class WorkHubCoordinationActionGate { const commit: WorkHubDelegationCommit = { ...intent, kind: 'delegation_committed', - delegationId: delegationId(input.actionId), + delegationId: delegationId(intent.actionId), targetTurnId: submitted.turnId, ...(submitted.steered ? { steered: true as const } : {}), }; @@ -407,6 +406,7 @@ function delegationIntent( actionFingerprint: `sha256:${string}`, targetSessionId: string, ): WorkHubDelegationIntent { + const create = input.create; if ( input.proposal.disposition !== 'delegate_existing' && input.proposal.disposition !== 'create_new' @@ -416,13 +416,28 @@ function delegationIntent( 'WorkHub local action cannot create a delegation intent', ); } - return { + const base = { kind: 'delegation_intent', actionId: input.actionId, actionFingerprint, coordinationTurnId: input.actionId, targetSessionId, disposition: input.proposal.disposition, + userText: input.userText, + } as const; + if (input.proposal.disposition === 'delegate_existing') return base; + if (!create) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub creation context is unavailable', + ); + } + return { + ...base, + create: { + title: input.proposal.title, + workspace: create.workspace, + }, }; } @@ -461,6 +476,16 @@ function digest(value: unknown): `sha256:${string}` { return `sha256:${hash(JSON.stringify(value))}`; } +function actionFingerprint(input: WorkHubCoordinationActInput): `sha256:${string}` { + return digest({ + userText: input.userText, + disposition: input.proposal.disposition, + ...(input.proposal.disposition === 'clarify' + ? { assistantText: input.proposal.assistantText } + : {}), + }); +} + function hash(value: string): string { return createHash('sha256').update(value, 'utf8').digest('hex'); } diff --git a/packages/runtime-host/src/server/workhub-delegation-journal.ts b/packages/runtime-host/src/server/workhub-delegation-journal.ts index 9d7937702b..4b982754d8 100644 --- a/packages/runtime-host/src/server/workhub-delegation-journal.ts +++ b/packages/runtime-host/src/server/workhub-delegation-journal.ts @@ -18,6 +18,7 @@ */ import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; import { WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION, WORKHUB_COORDINATION_SESSION_ID, @@ -37,7 +38,9 @@ import { import type { SessionAdmissionGate } from './session-admission-gate.js'; const RECORD_KINDS = ['delegation_intent', 'delegation_committed'] as const; -const RECORD_READ_MAX_BYTES = 16 * 1024; +// Two records may each repeat the bounded 48 KiB request plus create context; +// JSON escaping can expand one input byte to six encoded bytes. +const RECORD_READ_MAX_BYTES = 768 * 1024; type JournalStores = Pick< SessionAuthorityStore, @@ -239,6 +242,8 @@ function intentRecord(message: WorkHubDelegationIntentMessage): WorkHubDelegatio coordinationTurnId: message.coordinationTurnId, targetSessionId: message.targetSessionId, disposition: message.disposition, + userText: message.userText, + ...(message.create ? { create: message.create } : {}), }; } @@ -250,6 +255,8 @@ function commitRecord(message: WorkHubDelegationCommittedMessage): WorkHubDelega coordinationTurnId: message.coordinationTurnId, targetSessionId: message.targetSessionId, disposition: message.disposition, + userText: message.userText, + ...(message.create ? { create: message.create } : {}), delegationId: message.delegationId, targetTurnId: message.targetTurnId, ...(message.steered ? { steered: true as const } : {}), @@ -272,7 +279,9 @@ function sameIntent( left.actionFingerprint === right.actionFingerprint && left.coordinationTurnId === right.coordinationTurnId && left.targetSessionId === right.targetSessionId && - left.disposition === right.disposition + left.disposition === right.disposition && + left.userText === right.userText && + isDeepStrictEqual(left.create, right.create) ); } From 4fd576fa2c042d7c47bec9d58e2426b00f6a046b Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Thu, 27 Aug 2026 00:29:12 +0800 Subject: [PATCH 3/5] fix(workhub): close durable retry gaps Generated-by: Codex --- .../main/__tests__/workhub-controller.test.ts | 48 +++++++ .../__tests__/workhub-surface-flow.test.ts | 105 ++++++++++++++- apps/desktop/src/renderer/app-shell.tsx | 1 + .../src/renderer/workhub-controller.ts | 3 +- .../src/renderer/workhub-send-lease.ts | 81 +++++++++-- apps/desktop/src/renderer/workhub-surface.tsx | 19 ++- .../workhub-coordination-session-adr.md | 25 ++-- .../session-catalog-coordinator.test.ts | 29 ++++ .../workhub-coordination-action-gate.test.ts | 67 ++++++++++ .../workhub-coordination-coordinator.test.ts | 12 +- ...workhub-target-submission-recovery.test.ts | 56 ++++++++ .../src/server/execution-composition.ts | 91 ++++++------- .../src/server/session-catalog-coordinator.ts | 17 ++- .../workhub-coordination-action-gate.ts | 28 +++- .../workhub-coordination-coordinator.ts | 3 +- .../workhub-target-submission-recovery.ts | 126 ++++++++++++++++++ 16 files changed, 621 insertions(+), 90 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/workhub-target-submission-recovery.test.ts create mode 100644 packages/runtime-host/src/server/workhub-target-submission-recovery.ts diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 467fb567c6..a7812f1319 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -2469,6 +2469,54 @@ test('production submission delegates only through the Runtime-owned candidate r }]); }); +test('production retry reaches durable Action Gate replay while target is waiting', async () => { + const actions: unknown[] = []; + const sessions = port([ + session('payment', { state: 'waiting_for_user' }), + ]); + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + answer: async (input) => ({ turnId: input.turnId }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'c'.repeat(64)}`, + candidates: [{ + candidateRef: 'candidate-payment', + sessionId: 'payment', + sessionName: 'payment', + workspace: { + target: { kind: 'host_path', path: '/workspace/payment' }, + hostCwd: '/workspace/payment', + }, + state: 'waiting_for_user', + updatedAt: 2, + }], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'delegate_existing', + targetSessionId: 'payment', + targetTurnId: 'already-committed-turn', + }; + }, + }, + }); + + const result = await controller.submit({ + requestId: 'summary-recovery-action', + text: '继续支付工作', + explicitTarget: { sessionId: 'payment' }, + retryAction: true, + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.turnId : undefined, 'already-committed-turn'); + assert.equal(actions.length, 1); +}); + test('production defers destructive correction until persistent delegation exists', async () => { const actions: unknown[] = []; const sessions = port([session('source'), session('target')]); 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 72248f5444..c058a58a49 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -52,16 +52,113 @@ test('production retry keeps one action identity across failure and renderer rel removeItem: (key: string) => values.delete(key), }; const ids = ['action-1', 'action-2']; - const first = new WorkHubSendLease(storage, () => ids.shift()!); + const first = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => ids.shift()!, + }); assert.equal(first.acquire('Continue payment work'), 'action-1'); - const restarted = new WorkHubSendLease(storage, () => ids.shift()!); + const restarted = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => ids.shift()!, + }); assert.equal(restarted.acquire('Continue payment work'), 'action-1'); restarted.complete('action-1'); assert.equal(restarted.acquire('Continue payment work'), 'action-2'); }); +test('production retry identity is isolated by Runtime Host scope', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + const hostA = new WorkHubSendLease({ + scope: '["host-a","workhub_coordination"]', + storage, + createId: () => 'action-A', + }); + const hostB = new WorkHubSendLease({ + scope: '["host-b","workhub_coordination"]', + storage, + createId: () => 'action-B', + }); + + assert.equal(hostA.acquire('Continue payment work'), 'action-A'); + assert.equal(hostB.acquire('Continue payment work'), 'action-B'); + hostB.complete('action-B'); + assert.equal( + new WorkHubSendLease({ + scope: '["host-a","workhub_coordination"]', + storage, + createId: () => 'action-A-new', + }).acquire('Continue payment work'), + 'action-A', + ); +}); + +test('waiting keeps the action identity that may own an unrecorded summary', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + const first = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => 'action-1', + }); + const requestId = first.acquire('Continue payment work'); + + first.settle(requestId, workHubSubmissionClearsDraft({ + kind: 'waiting', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId, + text: 'Continue payment work', + target: { sessionId: 'payment' }, + })); + + assert.equal( + new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => 'action-2', + }).acquire('Continue payment work'), + 'action-1', + ); +}); + +test('summary retry reuses the text first bound to the action identity', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + const first = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => 'action-1', + }); + const requestId = first.acquire('Continue payment work'); + assert.equal(first.summary(requestId, () => 'Sent to Payments · running'), 'Sent to Payments · running'); + + const restarted = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => 'action-2', + }); + assert.equal( + restarted.summary(requestId, () => 'Sent to Payment archive · completed'), + 'Sent to Payments · running', + ); +}); + test('summary failure keeps the target action retryable under the same production identity', async () => { const values = new Map(); const storage = { @@ -100,12 +197,12 @@ test('summary failure keeps the target action retryable under the same productio summary: () => 'Sent to Payments.', onSummaryError: () => undefined, }); - const first = new WorkHubSendLease(storage, () => 'action-1'); + const first = new WorkHubSendLease({ scope: 'host-a', storage, createId: () => 'action-1' }); const requestId = first.acquire('Continue payment work'); await assert.rejects(send(requestId), /summary outcome unknown/u); - const restarted = new WorkHubSendLease(storage, () => 'action-2'); + const restarted = new WorkHubSendLease({ scope: 'host-a', storage, createId: () => 'action-2' }); const retriedId = restarted.acquire('Continue payment work'); await send(retriedId); restarted.complete(retriedId); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 7a8fc2e9db..aabbca1a66 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2819,6 +2819,7 @@ function AppShellContent({ ; @@ -28,6 +30,18 @@ interface WorkHubSendLeaseState { readonly version: 1; readonly draft: string; readonly requestId?: string; + readonly summary?: string; +} + +export interface WorkHubSendLeaseOptions { + readonly scope: string; + readonly storage?: WorkHubSendLeaseStorage; + readonly createId?: () => string; +} + +export interface WorkHubSendAttempt { + readonly requestId: string; + readonly retrying: boolean; } /** @@ -37,18 +51,33 @@ interface WorkHubSendLeaseState { */ export class WorkHubSendLease { #memory: WorkHubSendLeaseState | undefined; + readonly #scope: string; + readonly #storage: WorkHubSendLeaseStorage | undefined; + readonly #createId: () => string; + readonly #storageKey: string; - constructor( - private readonly storage: WorkHubSendLeaseStorage | undefined = rendererSessionStorage(), - private readonly createId: () => string = () => crypto.randomUUID(), - ) {} + constructor(options: WorkHubSendLeaseOptions) { + if (!options.scope || options.scope.length > MAX_SCOPE_CHARS) { + throw new TypeError('WorkHub send lease requires a bounded Runtime Host scope'); + } + this.#scope = options.scope; + this.#storage = options.storage ?? rendererSessionStorage(); + this.#createId = options.createId ?? (() => crypto.randomUUID()); + this.#storageKey = `${WORKHUB_SEND_LEASE_KEY}:${encodeURIComponent(this.#scope)}`; + } acquire(text: string): string { + return this.acquireAttempt(text).requestId; + } + + acquireAttempt(text: string): WorkHubSendAttempt { const existing = this.#read(); - if (existing?.draft === text && existing.requestId) return existing.requestId; - const requestId = this.createId(); + if (existing?.draft === text && existing.requestId) { + return { requestId: existing.requestId, retrying: true }; + } + const requestId = this.#createId(); this.#write({ version: 1, draft: text, requestId }); - return requestId; + return { requestId, retrying: false }; } complete(requestId: string): void { @@ -57,6 +86,24 @@ export class WorkHubSendLease { this.#write({ version: 1, draft: existing.draft }); } + settle(requestId: string, _clearsDraft: boolean): void { + if (_clearsDraft) this.complete(requestId); + } + + summary(requestId: string, create: () => string): string { + const existing = this.#read(); + if (existing?.requestId !== requestId) { + throw new Error('WorkHub summary identity does not own the active send lease'); + } + if (existing.summary) return existing.summary; + const summary = create(); + if (!summary || summary.length > MAX_SUMMARY_CHARS) { + throw new Error('WorkHub coordination summary is invalid'); + } + this.#write({ ...existing, summary }); + return summary; + } + read(key: string | undefined): string | undefined { return key === WORKHUB_DRAFT_KEY ? this.#read()?.draft : undefined; } @@ -68,18 +115,18 @@ export class WorkHubSendLease { return; } const existing = this.#read(); + const preservesIdentity = existing?.draft === draft && existing.requestId; this.#write({ version: 1, draft, - ...(existing?.draft === draft && existing.requestId - ? { requestId: existing.requestId } - : {}), + ...(preservesIdentity ? { requestId: existing.requestId } : {}), + ...(preservesIdentity && existing.summary ? { summary: existing.summary } : {}), }); } #read(): WorkHubSendLeaseState | undefined { try { - const raw = this.storage?.getItem(WORKHUB_SEND_LEASE_KEY); + const raw = this.#storage?.getItem(this.#storageKey); if (!raw) return this.#memory; const value = JSON.parse(raw) as Partial; if ( @@ -87,7 +134,12 @@ export class WorkHubSendLease { typeof value.draft !== 'string' || value.draft.length > MAX_DRAFT_CHARS || (value.requestId !== undefined && - (typeof value.requestId !== 'string' || !SAFE_REQUEST_ID.test(value.requestId))) + (typeof value.requestId !== 'string' || !SAFE_REQUEST_ID.test(value.requestId))) || + (value.summary !== undefined && + (typeof value.summary !== 'string' || + !value.summary || + value.summary.length > MAX_SUMMARY_CHARS || + value.requestId === undefined)) ) { return undefined; } @@ -95,6 +147,7 @@ export class WorkHubSendLease { version: 1, draft: value.draft, ...(value.requestId ? { requestId: value.requestId } : {}), + ...(value.summary ? { summary: value.summary } : {}), } satisfies WorkHubSendLeaseState; this.#memory = decoded; return decoded; @@ -106,7 +159,7 @@ export class WorkHubSendLease { #write(value: WorkHubSendLeaseState): void { this.#memory = value; try { - this.storage?.setItem(WORKHUB_SEND_LEASE_KEY, JSON.stringify(value)); + this.#storage?.setItem(this.#storageKey, JSON.stringify(value)); } catch { // Restricted renderer contexts may not expose web storage. } @@ -115,7 +168,7 @@ export class WorkHubSendLease { #remove(): void { this.#memory = undefined; try { - this.storage?.removeItem(WORKHUB_SEND_LEASE_KEY); + this.#storage?.removeItem(this.#storageKey); } catch { // Restricted renderer contexts may not expose web storage. } diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 1334b7126a..af3dc58639 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -167,6 +167,7 @@ export async function submitAndRecordWorkHubSurfaceInput(input: { */ export function WorkHubSurface(props: { controller: WorkHubController; + leaseScope: string; locale: UiLocale; initialFocusSessionId?: string; onOpenSession(sessionId: string): void; @@ -182,7 +183,7 @@ export function WorkHubSurface(props: { // a rerender can disable Composer and clarification controls. const routeGate = useRef(new WorkHubSurfaceRouteGate()).current; const refreshGate = useRef(new WorkHubProjectionRefreshGate()).current; - const sendLease = useRef(new WorkHubSendLease()).current; + const sendLease = useRef(new WorkHubSendLease({ scope: props.leaseScope })).current; const [loadError, setLoadError] = useState(false); const [conversationError, setConversationError] = useState(false); const refresh = useCallback(async (focusSessionId?: string) => { @@ -256,7 +257,10 @@ export function WorkHubSurface(props: { controller: props.controller, request: input, recordedUserText, - summary: (result) => workHubCoordinationSummary(result, projection, copy), + summary: (result) => sendLease.summary( + input.requestId, + () => workHubCoordinationSummary(result, projection, copy), + ), // The ordinary Session admission may already have settled. A failed // Coordination summary keeps this send incomplete so the retry // reuses its durable Action Gate identity before filling the gap. @@ -290,14 +294,19 @@ export function WorkHubSurface(props: { const send = useCallback(async (value: string) => { const text = value.trim(); if (!text || !initialLoadSettled || !conversationReady || routeGate.pending) return false; - const requestId = sendLease.acquire(text); + const attempt = sendLease.acquireAttempt(text); + const { requestId } = attempt; setTurns((current) => current.some((turn) => turn.requestId === requestId) ? current.map((turn) => turn.requestId === requestId ? { requestId, text, state: 'routing' } : turn) : [...current, { requestId, text, state: 'routing' }]); - const result = await route({ requestId, text }); - if (result) sendLease.complete(requestId); + const result = await route({ + requestId, + text, + ...(attempt.retrying ? { retryAction: true as const } : {}), + }); + if (result) sendLease.settle(requestId, workHubSubmissionClearsDraft(result)); // Composer clears only accepted drafts. Waiting, delivery failures, and a // ref-blocked duplicate keep the exact text available for retry. return workHubSubmissionClearsDraft(result); diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 5a9e29d49d..889ee9f50c 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -124,14 +124,23 @@ fingerprint to reject conflicting reuse of an action identity. They do not form general workflow state machine and do not persist target execution lifecycle. The renderer couples one reload-safe Composer draft to one action identity until -both target admission and its Coordination summary settle. Retry therefore reuses -the same identity instead of treating the retained draft as new work. The durable -fingerprint covers stable user intent, not snapshot-scoped candidate ids; once -prepared, the intent owns the resolved target, exact user text, and any -`create_new` title/workspace context. Recovery is deliberately driven by that -explicit caller retry rather than an autonomous startup scan: the latter would -execute user work without a live request context and turn this journal into a -background workflow engine. +both target admission and its Coordination summary settle. The lease is scoped by +Coordination Session, so switching Runtime Hosts cannot move or retire another +Host's action identity. Retry therefore reuses the same identity instead of +treating the retained draft as new work; `waiting_for_user` does not retire that +identity, and the first generated Coordination summary is immutable across retry. +The durable fingerprint covers stable user intent, not snapshot-scoped candidate +ids; once prepared, the intent owns the resolved target, exact user text, and any +`create_new` title/workspace context. + +Recovery accepts the target Session's existing root receipt, pending admission, or +immutable steering proof as durable evidence. A definitive first-submit rejection +after `create_new` compensates only a Session created by that exact attempt through +the ordinary Session-retirement authority; an unknown submit outcome never removes +a possibly admitted Session. Recovery is deliberately driven by explicit caller +retry rather than an autonomous startup scan: the latter would execute user work +without a live request context and turn this journal into a background workflow +engine. ## Consequences, costs, and reevaluation diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index f0c3d9d372..337f572470 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -478,6 +478,35 @@ test('ordinary creation rejects the reserved WorkHub Coordination Session identi assert.equal(fixture.drainRequests(), 0); }); +test('WorkHub creation reports only the revision created by this exact attempt', async () => { + let creates = 0; + const header = sessionHeader('session-1', []); + const fixture = createFixture({ + stores: { + createStableSession: async () => { + creates += 1; + return creates === 1 + ? { kind: 'created', record: headerSnapshot(header, 7) } + : { kind: 'existing', record: headerSnapshot(header, 7) }; + }, + readCatalogRecord: async () => catalogRecord(header, 7), + }, + }); + const input = { + sessionId: fixture.sessionId, + workspace: { kind: 'host_path' as const, path: process.cwd() }, + modelTarget: { kind: 'default' as const }, + }; + + const created = await fixture.coordinator.createForWorkHub(input); + const replayed = await fixture.coordinator.createForWorkHub(input); + + assert.equal(created.outcome.ok, true); + assert.equal(created.createdRevision, 7); + assert.equal(replayed.outcome.ok, true); + assert.equal(replayed.createdRevision, undefined); +}); + test('ordinary configuration rejects the WorkHub Coordination Session identity', async () => { let reads = 0; const fixture = createFixture({ 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 5d6a913b6a..31fdc055b6 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 @@ -409,6 +409,54 @@ describe('WorkHub Coordination Action Gate', () => { ]); assert.equal(effects.submissions.length, 1); }); + + test('definitive create_new submit rejection retires the empty created Session', async () => { + const effects = fakeEffects([session('ordinary')]); + effects.submitFailure = new WorkHubActionEffectFailure( + 'operation_conflict', + 'Target submit was definitively rejected', + ); + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'rejected-create-action', + userText: 'Create a login audit', + proposal: { disposition: 'create_new', title: 'Login audit' }, + create: { workspace: { kind: 'project', projectId: 'project-1' } }, + }, + CONTEXT, + ), + /definitively rejected/u, + ); + + assert.equal(effects.discardedCreatedSessionIds.length, 1); + assert.match(effects.discardedCreatedSessionIds[0] ?? '', /^whs_[a-f0-9]{48}$/u); + assert.deepEqual(effects.creations, []); + }); + + test('unknown create_new submit outcome never retires a possibly admitted Session', async () => { + const effects = fakeEffects([session('ordinary')]); + effects.submitUnknownAfterAdmission = true; + effects.recoverSubmissionMiss = true; + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'unknown-create-action', + userText: 'Create a login audit', + proposal: { disposition: 'create_new', title: 'Login audit' }, + create: { workspace: { kind: 'project', projectId: 'project-1' } }, + }, + CONTEXT, + ), + (error) => + error instanceof WorkHubActionEffectFailure && error.code === 'commit_outcome_unknown', + ); + + assert.equal(effects.creations.length, 1); + assert.deepEqual(effects.discardedCreatedSessionIds, []); + }); }); function session( @@ -453,6 +501,9 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { delegations: new Map(), commitFailuresRemaining: 0, submitUnknownAfterAdmission: false as boolean, + submitFailure: undefined as WorkHubActionEffectFailure | undefined, + recoverSubmissionMiss: false as boolean, + discardedCreatedSessionIds: [] as string[], async listSessions() { return this.sessions; }, @@ -470,8 +521,14 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { const existing = this.creations.find(({ sessionId }) => sessionId === input.sessionId); if (existing) assert.deepEqual(existing, input); else this.creations.push(input); + return existing ? {} : { createdRevision: 1 }; }, async submit(input: { sessionId: string; messageId: string; text: string }) { + if (this.submitFailure) { + const error = this.submitFailure; + this.submitFailure = undefined; + throw error; + } const existing = submitted.get(input.messageId); if (existing) { assert.deepEqual(existing.input, input); @@ -489,7 +546,14 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { } return { turnId }; }, + async discardCreated(input: { sessionId: string; expectedRevision: number }) { + assert.equal(input.expectedRevision, 1); + this.discardedCreatedSessionIds.push(input.sessionId); + const index = this.creations.findIndex(({ sessionId }) => sessionId === input.sessionId); + if (index >= 0) this.creations.splice(index, 1); + }, async recoverSubmission(input: { sessionId: string; messageId: string; text: string }) { + if (this.recoverSubmissionMiss) return undefined; const existing = submitted.get(input.messageId); if (!existing) return undefined; assert.deepEqual(existing.input, input); @@ -531,6 +595,9 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { delegations: Map; commitFailuresRemaining: number; submitUnknownAfterAdmission: boolean; + submitFailure: WorkHubActionEffectFailure | undefined; + recoverSubmissionMiss: boolean; + discardedCreatedSessionIds: string[]; }; return state; } diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index f73f803b25..0dec746e48 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -495,7 +495,8 @@ describe('Host WorkHub Coordination coordinator', () => { }); const submissions: Array<{ sessionId: string; messageId: string; text: string }> = []; const first = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - create: async () => undefined, + create: async () => ({}), + discardCreated: async () => undefined, submit: async (input) => { submissions.push(input); return { turnId: 'payments-turn' }; @@ -550,6 +551,7 @@ describe('Host WorkHub Coordination coordinator', () => { try { const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, { create: async () => assert.fail('durable replay must not create a Session'), + discardCreated: async () => assert.fail('durable replay must not discard a Session'), submit: async () => assert.fail('durable replay must not submit another Turn'), recoverSubmission: async () => assert.fail('durable replay must not recover an already committed Turn'), @@ -716,8 +718,12 @@ function coordinator( hasRootTurnAdmission: async () => false, }, admission: SessionAdmissionGate = new SessionAdmissionGate(), - sessionActions: Pick = { - create: async () => undefined, + sessionActions: Pick< + WorkHubActionGateEffects, + 'create' | 'discardCreated' | 'submit' | 'recoverSubmission' + > = { + create: async () => ({}), + discardCreated: async () => undefined, submit: async ({ sessionId }) => ({ turnId: `turn-${sessionId}` }), recoverSubmission: async () => undefined, }, diff --git a/packages/runtime-host/src/__tests__/workhub-target-submission-recovery.test.ts b/packages/runtime-host/src/__tests__/workhub-target-submission-recovery.test.ts new file mode 100644 index 0000000000..b0eae8174d --- /dev/null +++ b/packages/runtime-host/src/__tests__/workhub-target-submission-recovery.test.ts @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { WorkHubSubmissionRecoveryStores } from '../server/workhub-target-submission-recovery.js'; +import { recoverWorkHubTargetSubmission } from '../server/workhub-target-submission-recovery.js'; + +test('recovers a handed-off steering submission from its immutable proof', async () => { + const text = 'Continue payment work'; + const event = { + id: 'steering-event', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'payment', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text, steering: true }, + refs: { providerEventId: 'message-1' }, + } satisfies RuntimeEvent; + const stores = { + readRootTurnSourceMessageReceipt: async () => undefined, + readMessageAdmission: async () => undefined, + readRootTurnAdmission: async () => undefined, + readImmutableSteeringMessageProof: async () => ({ event }), + } satisfies WorkHubSubmissionRecoveryStores; + + assert.deepEqual( + await recoverWorkHubTargetSubmission(stores, { + sessionId: 'payment', + messageId: 'message-1', + text, + }), + { turnId: 'turn-1', steered: true }, + ); +}); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index f29ea7830f..1b76c71866 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -18,7 +18,7 @@ */ import { createHash, randomUUID } from 'node:crypto'; -import { messageContentDigest, normalizeMessageContent } from '@maka/core/events'; +import { normalizeMessageContent } from '@maka/core/events'; import { describeChatConfigurationReason, NO_REAL_CONNECTION_CODE, @@ -88,6 +88,7 @@ import { createHostChildAgentToolComposition, } from './child-agent-composition.js'; import { HostCanonicalPermissionOutcomeReader } from './canonical-permission-outcome-reader.js'; +import { recoverWorkHubTargetSubmission } from './workhub-target-submission-recovery.js'; import { HostArtifactCoordinator } from './artifact-coordinator.js'; import { HostAgentGraphCoordinator } from './agent-graph-coordinator.js'; import { HostAgentGraphExecutionCoordinator } from './agent-graph-execution-coordinator.js'; @@ -468,6 +469,7 @@ export async function createExecutionRuntimeHostComposition( let goal: HostGoalCoordinator | undefined; let deepResearch: HostDeepResearchCoordinator | undefined; let dailyReview: HostDailyReviewCoordinator | undefined; + let sessionRetirement: HostSessionRetirementCoordinator | undefined; const rootPort: HostMessageRootPort = { readSessionHeader: (sessionId) => requireRootCoordinator(rootCoordinator).readSessionHeader(sessionId), @@ -1230,7 +1232,7 @@ export async function createExecutionRuntimeHostComposition( executions: coordinator, sessionActions: { create: async (input) => { - const outcome = await sessionCatalog.createForWorkHub({ + const created = await sessionCatalog.createForWorkHub({ sessionId: input.sessionId, workspace: input.workspace, name: input.title, @@ -1238,12 +1240,34 @@ export async function createExecutionRuntimeHostComposition( collaborationMode: 'agent', orchestrationMode: 'default', }); + const { outcome } = created; if (!outcome.ok) { throw new WorkHubActionEffectFailure( outcome.error.code === 'invalid_request' ? 'operation_conflict' : outcome.error.code, outcome.error.message, ); } + return created.createdRevision === undefined + ? {} + : { createdRevision: created.createdRevision }; + }, + discardCreated: async (input, connection) => { + const outcome = await requireSessionRetirement(sessionRetirement).handlers[ + 'session.remove' + ]( + { + sessionId: input.sessionId, + expectedRevision: input.expectedRevision, + }, + connection, + ); + if (!outcome.ok || outcome.result.kind !== 'removed') { + context.requestDrain(); + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + 'WorkHub empty created Session retirement outcome is unknown', + ); + } }, submit: async (input, connection) => { const outcome = await messages.handlers['turn.message.submit']( @@ -1284,49 +1308,19 @@ export async function createExecutionRuntimeHostComposition( ); }, recoverSubmission: async (input) => { - const expectedDigest = messageContentDigest( - normalizeMessageContent({ text: input.text }), - ); - const receipt = await stores.agentRunStore.readRootTurnSourceMessageReceipt( - input.sessionId, - input.messageId, - ); - if (receipt) { - const source = receipt.sourceMessage; - const actualDigest = - source.submittedContentDigest ?? messageContentDigest(source.content); - if (source.placement !== 'current_turn' || actualDigest !== expectedDigest) { - throw new WorkHubActionEffectFailure( - 'operation_conflict', - 'WorkHub target Message identity belongs to different content', - ); - } - return source.disposition === 'turn_started' - ? { turnId: receipt.admission.turnId } - : source.disposition === 'steering' - ? { turnId: receipt.admission.turnId, steered: true as const } - : undefined; - } - const admission = await stores.sessionStore.readMessageAdmission( - input.sessionId, - input.messageId, - ); - if (!admission) return undefined; - if ( - admission.submittedPlacement !== 'current_turn' || - admission.submittedContentDigest !== expectedDigest - ) { - throw new WorkHubActionEffectFailure( - 'operation_conflict', - 'WorkHub target Message admission belongs to different content', - ); - } - const root = await stores.agentRunStore.readRootTurnAdmission( - input.sessionId, - admission.turnId, + return recoverWorkHubTargetSubmission( + { + readRootTurnSourceMessageReceipt: (sessionId, messageId) => + stores.agentRunStore.readRootTurnSourceMessageReceipt(sessionId, messageId), + readMessageAdmission: (sessionId, messageId) => + stores.sessionStore.readMessageAdmission(sessionId, messageId), + readRootTurnAdmission: (sessionId, turnId) => + stores.agentRunStore.readRootTurnAdmission(sessionId, turnId), + readImmutableSteeringMessageProof: (sessionId, messageId) => + stores.runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId), + }, + input, ); - if (!root || root.runId !== admission.runId) return undefined; - return { turnId: admission.turnId, steered: true as const }; }, }, resolveCreateTarget: async () => { @@ -1399,7 +1393,7 @@ export async function createExecutionRuntimeHostComposition( isSessionActive: (sessionId) => coordinator.readRootState(sessionId).kind !== 'idle', requestDrain: context.requestDrain, }); - const sessionRetirement = new HostSessionRetirementCoordinator({ + sessionRetirement = new HostSessionRetirementCoordinator({ stores: stores.sessionStore, admission: sessionAdmission, root: coordinator, @@ -1782,6 +1776,13 @@ function requireRootCoordinator(coordinator: RootTurnCoordinator | undefined): R return coordinator; } +function requireSessionRetirement( + coordinator: HostSessionRetirementCoordinator | undefined, +): HostSessionRetirementCoordinator { + if (!coordinator) throw new Error('Session retirement authority is unavailable'); + return coordinator; +} + function requireWorkspaceExecution( composition: RuntimeHostWorkspaceExecutionComposition | undefined, ): RuntimeHostWorkspaceExecutionComposition { diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 01f7556259..6524cbc387 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -200,8 +200,15 @@ export class HostSessionCatalogCoordinator { } /** WorkHub Action Gate path; callers cannot bypass the typed operation outcome. */ - createForWorkHub(input: SessionCreateInput): Promise> { - return this.#create(input); + async createForWorkHub(input: SessionCreateInput): Promise<{ + readonly outcome: OperationOutcome<'session.create'>; + readonly createdRevision?: number; + }> { + let createdRevision: number | undefined; + const outcome = await this.#create(input, (revision) => { + createdRevision = revision; + }); + return { outcome, ...(createdRevision === undefined ? {} : { createdRevision }) }; } async #query( @@ -336,7 +343,10 @@ export class HostSessionCatalogCoordinator { } } - async #create(input: SessionCreateInput): Promise> { + async #create( + input: SessionCreateInput, + onCreated?: (revision: number) => void, + ): Promise> { if (isWorkHubCoordinationSessionId(input.sessionId)) { return createFailure( 'operation_conflict', @@ -401,6 +411,7 @@ export class HostSessionCatalogCoordinator { 'Session identity belongs to a different create request', ); } + if (result.kind === 'created') onCreated?.(result.record.revision); await this.#continuity.refreshCanonical(input.sessionId, lease); return createSuccess( projectSessionCatalogRecord(await this.#stores.readCatalogRecord(input.sessionId)), 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 7d6f2bbb86..ea70bb76b1 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -73,7 +73,14 @@ export interface WorkHubActionGateEffects { readonly sessionId: string; readonly workspace: WorkspaceTarget; readonly title: string; - }): Promise; + }): Promise<{ readonly createdRevision?: number }>; + discardCreated( + input: { + readonly sessionId: string; + readonly expectedRevision: number; + }, + context: ConnectionContext, + ): Promise; submit( input: { readonly sessionId: string; @@ -276,6 +283,7 @@ export class WorkHubCoordinationActionGate { intent: WorkHubDelegationIntent, context: ConnectionContext, ): Promise { + let createdRevision: number | undefined; if (intent.disposition === 'create_new') { if (!intent.create) { throw new WorkHubActionGateFailure( @@ -283,11 +291,12 @@ export class WorkHubCoordinationActionGate { 'WorkHub durable creation intent is incomplete', ); } - await this.#effects.create({ + const created = await this.#effects.create({ sessionId: intent.targetSessionId, workspace: intent.create.workspace, title: intent.create.title, }); + createdRevision = created.createdRevision; } else if (intent.create) { throw new WorkHubActionGateFailure( 'action_conflict', @@ -304,10 +313,17 @@ export class WorkHubCoordinationActionGate { try { submitted = await this.#effects.submit(message, context); } catch (error) { - if ( - !(error instanceof WorkHubActionEffectFailure) || - error.code !== 'commit_outcome_unknown' - ) { + if (!(error instanceof WorkHubActionEffectFailure)) throw error; + if (error.code !== 'commit_outcome_unknown') { + if (createdRevision !== undefined) { + await this.#effects.discardCreated( + { + sessionId: intent.targetSessionId, + expectedRevision: createdRevision, + }, + context, + ); + } throw error; } const recovered = await this.#effects.recoverSubmission(message); diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 8af2c61439..3a7daeaad1 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -103,7 +103,7 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly executions: CoordinationExecutions; readonly sessionActions: Pick< WorkHubActionGateEffects, - 'create' | 'submit' | 'recoverSubmission' + 'create' | 'discardCreated' | 'submit' | 'recoverSubmission' >; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; @@ -162,6 +162,7 @@ export class HostWorkHubCoordinationCoordinator { } }, create: options.sessionActions.create, + discardCreated: options.sessionActions.discardCreated, submit: options.sessionActions.submit, recoverSubmission: options.sessionActions.recoverSubmission, readDelegation: (actionId) => this.#delegations.read(actionId), diff --git a/packages/runtime-host/src/server/workhub-target-submission-recovery.ts b/packages/runtime-host/src/server/workhub-target-submission-recovery.ts new file mode 100644 index 0000000000..1f5ec24f09 --- /dev/null +++ b/packages/runtime-host/src/server/workhub-target-submission-recovery.ts @@ -0,0 +1,126 @@ +/* + * 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 { + messageContentDigest, + normalizeMessageContent, + type MessageContent, +} from '@maka/core/events'; +import type { + ImmutableSteeringMessageProof, + RootTurnAdmission, + RootTurnSourceMessageReceipt, +} from '@maka/storage/agent-run-store'; +import { WorkHubActionEffectFailure } from './workhub-coordination-action-gate.js'; + +interface WorkHubPendingMessageAdmission { + readonly turnId: string; + readonly runId: string; + readonly submittedPlacement: 'current_turn' | 'next_turn'; + readonly submittedContentDigest: `sha256:${string}`; +} + +export interface WorkHubSubmissionRecoveryStores { + readRootTurnSourceMessageReceipt( + sessionId: string, + messageId: string, + ): Promise; + readMessageAdmission( + sessionId: string, + messageId: string, + ): Promise; + readRootTurnAdmission(sessionId: string, turnId: string): Promise; + readImmutableSteeringMessageProof( + sessionId: string, + messageId: string, + ): Promise; +} + +export async function recoverWorkHubTargetSubmission( + stores: WorkHubSubmissionRecoveryStores, + input: { readonly sessionId: string; readonly messageId: string; readonly text: string }, +): Promise<{ readonly turnId: string; readonly steered?: true } | undefined> { + const content = normalizeMessageContent({ text: input.text }); + const expectedDigest = messageContentDigest(content); + const receipt = await stores.readRootTurnSourceMessageReceipt(input.sessionId, input.messageId); + if (receipt) { + const source = receipt.sourceMessage; + const actualDigest = source.submittedContentDigest ?? messageContentDigest(source.content); + assertMatchingSubmission(source.placement, actualDigest, expectedDigest); + return source.disposition === 'turn_started' + ? { turnId: receipt.admission.turnId } + : source.disposition === 'steering' + ? { turnId: receipt.admission.turnId, steered: true } + : undefined; + } + + const steeringProof = await stores.readImmutableSteeringMessageProof( + input.sessionId, + input.messageId, + ); + if (steeringProof) { + const proofContent = workHubSteeringProofContent(steeringProof); + const proofDigest = + steeringProof.event.refs?.sourceMessageDigest ?? + (proofContent ? messageContentDigest(proofContent) : undefined); + if ( + steeringProof.event.content?.kind !== 'text' || + steeringProof.event.content.steering !== true || + proofDigest !== expectedDigest + ) { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub target steering identity belongs to different content', + ); + } + return { turnId: steeringProof.event.turnId, steered: true }; + } + + const admission = await stores.readMessageAdmission(input.sessionId, input.messageId); + if (!admission) return undefined; + assertMatchingSubmission( + admission.submittedPlacement, + admission.submittedContentDigest, + expectedDigest, + ); + const root = await stores.readRootTurnAdmission(input.sessionId, admission.turnId); + if (!root || root.runId !== admission.runId) return undefined; + return { turnId: admission.turnId, steered: true }; +} + +function assertMatchingSubmission( + placement: 'current_turn' | 'next_turn', + actualDigest: string, + expectedDigest: string, +): void { + if (placement !== 'current_turn' || actualDigest !== expectedDigest) { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub target Message identity belongs to different content', + ); + } +} + +export function workHubSteeringProofContent( + proof: ImmutableSteeringMessageProof, +): MessageContent | undefined { + return proof.event.content?.kind === 'text' + ? normalizeMessageContent(proof.event.content) + : undefined; +} From c4dea07bdca0dfe9948c4f25c4856ab89df5dd34 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Thu, 27 Aug 2026 10:48:52 +0800 Subject: [PATCH 4/5] fix(workhub): close durable retry edge cases Recover accepted submissions before retrying, retry pristine create cleanup after unknown outcomes, and keep waiting responses from consuming the final coordination summary. Add focused coverage and a renderer reload E2E for the durable action identity flow. Generated-by: Codex --- .../e2e/workhub-reconstruction.spec.ts | 68 +++++++++++++++++ .../__tests__/workhub-surface-flow.test.ts | 73 +++++++++++++++++++ apps/desktop/src/preload/preload.ts | 21 +++++- apps/desktop/src/renderer/workhub-surface.tsx | 5 +- .../workhub-coordination-session-adr.md | 18 +++-- .../session-catalog-coordinator.test.ts | 15 ++-- .../workhub-coordination-action-gate.test.ts | 48 +++++++++++- .../src/server/execution-composition.ts | 4 +- .../src/server/session-catalog-coordinator.ts | 27 +++++-- .../workhub-coordination-action-gate.ts | 44 +++++------ 10 files changed, 278 insertions(+), 45 deletions(-) diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index bda08351cc..bc332ee7c2 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -19,6 +19,14 @@ import { expect, test, COMPOSER_INPUT } from './fixtures'; +type WorkHubEvidenceWindow = Window & { + makaE2eLatch?: { + arm(key: 'workHub.record', options?: { oneShot?: boolean }): void; + reject(key: 'workHub.record', message: string): void; + waitForCall(key: 'workHub.record'): Promise; + }; +}; + test('WorkHub rebuilds Session conversation after navigating away and back', async ({ window: page, }) => { @@ -65,6 +73,66 @@ test('WorkHub rebuilds Session conversation after navigating away and back', asy ).toBeVisible(); }); +test('WorkHub retries one accepted action after summary failure and renderer reload', async ({ + window: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('检查支付回调重复投递时的幂等性'); + await composer.press('Enter'); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { + timeout: 20_000, + }); + await page.evaluate(async () => { + await window.maka.settings.updateClient({ workHub: { enabled: true } }); + }); + await expect(page.getByRole('main', { name: 'WorkHub' })).toBeVisible(); + + const latchInstalled = await page.evaluate(() => { + const e2e = window as WorkHubEvidenceWindow; + if (!e2e.makaE2eLatch) return false; + e2e.makaE2eLatch.arm('workHub.record', { oneShot: true }); + return true; + }); + expect(latchInstalled, 'the isolated E2E summary latch is installed').toBe(true); + + const routedPrompt = '继续这个工作,补充重复投递测试点。'; + const workHubComposer = page.locator( + '.workhub-surface .maka-composer-editor [contenteditable="true"]', + ); + await workHubComposer.fill(routedPrompt); + const recordReached = page.evaluate(() => + (window as WorkHubEvidenceWindow).makaE2eLatch?.waitForCall('workHub.record'), + ); + await workHubComposer.press('Enter'); + await recordReached; + await page.evaluate(() => { + (window as WorkHubEvidenceWindow).makaE2eLatch?.reject( + 'workHub.record', + 'forced WorkHub summary failure', + ); + }); + + const failed = page.locator('.workhub-turn', { hasText: routedPrompt }); + await expect(failed.locator('.workhub-error')).toContainText('输入未能送达'); + await expect(workHubComposer).toHaveText(routedPrompt); + + await page.reload(); + + await expect(page.getByRole('main', { name: 'WorkHub' })).toBeVisible(); + const reloadedComposer = page.locator( + '.workhub-surface .maka-composer-editor [contenteditable="true"]', + ); + await expect(reloadedComposer).toHaveText(routedPrompt); + await reloadedComposer.press('Enter'); + + await expect(page.locator('.workhub-submitted').last()).toBeVisible(); + await expect( + page.locator('.workhub-user-bubble > p', { + hasText: routedPrompt, + }), + ).toHaveCount(1); +}); + test('WorkHub defers destructive correction until linked delegation exists', async ({ window: page, }) => { 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 c058a58a49..aa37d6a276 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -133,6 +133,79 @@ test('waiting keeps the action identity that may own an unrecorded summary', () ); }); +test('waiting does not bind the final summary before the same action is accepted', async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + const recorded: string[] = []; + let attempts = 0; + const controller: WorkHubController = { + read: async () => ({ sessions: [], turns: [] }), + openConversation: async () => ({ close: async () => undefined }), + recordConversationTurn: async ({ turnId, assistantText }) => { + recorded.push(assistantText); + return { turnId }; + }, + resetVisitContext: () => {}, + subscribe: () => () => {}, + submit: async (input) => { + attempts += 1; + return attempts === 1 + ? { + kind: 'waiting' as const, + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + text: input.text, + target: { sessionId: 'payment' }, + } + : { + kind: 'submitted' as const, + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target: { sessionId: 'payment' }, + turnId: 'payment-turn', + evidence: 'explicit_target' as const, + }; + }, + }; + const first = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => 'action-1', + }); + const requestId = first.acquire('Continue payment work'); + const send = (lease: WorkHubSendLease, retrying: boolean) => + submitAndRecordWorkHubSurfaceInput({ + controller, + request: { + requestId, + text: 'Continue payment work', + ...(retrying ? { retryAction: true as const } : {}), + }, + recordedUserText: 'Continue payment work', + summary: (result) => lease.summary( + requestId, + () => result.kind === 'waiting' ? 'Request not sent.' : 'Accepted by Payments.', + ), + onSummaryError: () => undefined, + }); + + const waiting = await send(first, false); + first.settle(requestId, workHubSubmissionClearsDraft(waiting)); + const restarted = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => 'action-2', + }); + assert.equal(restarted.acquire('Continue payment work'), requestId); + await send(restarted, true); + + assert.deepEqual(recorded, ['Accepted by Payments.']); +}); + test('summary retry reuses the text first bound to the action identity', () => { const values = new Map(); const storage = { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 217bcd3281..4f5746757f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3289,14 +3289,22 @@ const makaBridge = { // exposeInMainWorld: the bridge is cloned into the main world at expose time, // and the exposed clone is sealed against later patching. if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { - type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list' | 'settings.chunk'; + type LatchKey = + | 'newTasks.listInvocableSkills' + | 'sessions.list' + | 'settings.chunk' + | 'workHub.record'; const gates = new Map; oneShot: boolean }>(); const releases = new Map void; reject: (error: Error) => void }>(); + const callWaiters = new Map void>>(); const invocableSkillsWaiters = new Map void>>(); const waitForLatch = async (key: LatchKey): Promise => { const gate = gates.get(key); if (!gate) return; if (gate.oneShot) gates.delete(key); + const waiter = callWaiters.get(key)?.shift(); + if (callWaiters.get(key)?.length === 0) callWaiters.delete(key); + waiter?.(); await gate.promise; }; const wrapLatched = ( @@ -3314,6 +3322,10 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { makaBridge.sessions.list.bind(makaBridge.sessions), 'sessions.list', ); + makaBridge.workHub.record = wrapLatched( + makaBridge.workHub.record.bind(makaBridge.workHub), + 'workHub.record', + ); const listInvocableSkills = makaBridge.skills.listInvocable.bind(makaBridge.skills); makaBridge.skills.listInvocable = async (...args) => { try { @@ -3346,6 +3358,13 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { wait(key: 'settings.chunk') { return waitForLatch(key); }, + waitForCall(key: LatchKey) { + return new Promise((resolve) => { + const waiters = callWaiters.get(key) ?? []; + waiters.push(resolve); + callWaiters.set(key, waiters); + }); + }, waitForInvocableSkillsCall(sessionId: string) { return new Promise((resolve) => { const waiters = invocableSkillsWaiters.get(sessionId) ?? []; diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index af3dc58639..04f76abba4 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -146,7 +146,10 @@ export async function submitAndRecordWorkHubSurfaceInput(input: { controller: input.controller, input: input.request, }); - if (result.kind === 'discussion') return result; + // Waiting is a local, retryable admission result: the request has not been + // accepted and must not consume the immutable Coordination summary owned by + // this action identity. A later same-identity retry may still be admitted. + if (result.kind === 'discussion' || result.kind === 'waiting') return result; try { await input.controller.recordConversationTurn({ turnId: input.request.requestId, diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 889ee9f50c..0243fc9c09 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -134,13 +134,17 @@ ids; once prepared, the intent owns the resolved target, exact user text, and an `create_new` title/workspace context. Recovery accepts the target Session's existing root receipt, pending admission, or -immutable steering proof as durable evidence. A definitive first-submit rejection -after `create_new` compensates only a Session created by that exact attempt through -the ordinary Session-retirement authority; an unknown submit outcome never removes -a possibly admitted Session. Recovery is deliberately driven by explicit caller -retry rather than an autonomous startup scan: the latter would execute user work -without a live request context and turn this journal into a background workflow -engine. +immutable steering proof as durable evidence, and checks that evidence before a +retry submits again. A waiting result is local and retryable: it neither consumes +the action's immutable Coordination summary nor records a false acceptance. A +definitive `create_new` submit rejection compensates through the ordinary +Session-retirement authority. The exact stable create may expose its revision again +only while the Session remains at the initial revision, so an uncertain retirement +can be retried without granting cleanup authority over a subsequently mutated +Session. An unknown submit outcome never removes a possibly admitted Session. +Recovery is deliberately driven by explicit caller retry rather than an autonomous +startup scan: the latter would execute user work without a live request context and +turn this journal into a background workflow engine. ## Consequences, costs, and reevaluation diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 337f572470..337aea7265 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -478,7 +478,7 @@ test('ordinary creation rejects the reserved WorkHub Coordination Session identi assert.equal(fixture.drainRequests(), 0); }); -test('WorkHub creation reports only the revision created by this exact attempt', async () => { +test('WorkHub creation reports a discard revision for creation and a pristine replay', async () => { let creates = 0; const header = sessionHeader('session-1', []); const fixture = createFixture({ @@ -486,10 +486,10 @@ test('WorkHub creation reports only the revision created by this exact attempt', createStableSession: async () => { creates += 1; return creates === 1 - ? { kind: 'created', record: headerSnapshot(header, 7) } - : { kind: 'existing', record: headerSnapshot(header, 7) }; + ? { kind: 'created', record: headerSnapshot(header, 1) } + : { kind: 'existing', record: headerSnapshot(header, creates === 2 ? 1 : 2) }; }, - readCatalogRecord: async () => catalogRecord(header, 7), + readCatalogRecord: async () => catalogRecord(header, 1), }, }); const input = { @@ -500,11 +500,14 @@ test('WorkHub creation reports only the revision created by this exact attempt', const created = await fixture.coordinator.createForWorkHub(input); const replayed = await fixture.coordinator.createForWorkHub(input); + const mutated = await fixture.coordinator.createForWorkHub(input); assert.equal(created.outcome.ok, true); - assert.equal(created.createdRevision, 7); + assert.equal(created.discardRevision, 1); assert.equal(replayed.outcome.ok, true); - assert.equal(replayed.createdRevision, undefined); + assert.equal(replayed.discardRevision, 1); + assert.equal(mutated.outcome.ok, true); + assert.equal(mutated.discardRevision, undefined); }); test('ordinary configuration rejects the WorkHub Coordination Session identity', async () => { 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 31fdc055b6..d2504ad2e6 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 @@ -457,6 +457,40 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.creations.length, 1); assert.deepEqual(effects.discardedCreatedSessionIds, []); }); + + test('retries definitive create_new cleanup after the first discard outcome is unknown', async () => { + const effects = fakeEffects([session('ordinary')]); + const input = { + actionId: 'retry-create-cleanup-action', + userText: 'Create a login audit', + proposal: { disposition: 'create_new' as const, title: 'Login audit' }, + create: { workspace: { kind: 'project' as const, projectId: 'project-1' } }, + }; + effects.submitFailure = new WorkHubActionEffectFailure( + 'operation_conflict', + 'Target submit was definitively rejected', + ); + effects.discardFailuresRemaining = 1; + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + (error) => + error instanceof WorkHubActionEffectFailure && error.code === 'commit_outcome_unknown', + ); + assert.equal(effects.creations.length, 1); + + effects.submitFailure = new WorkHubActionEffectFailure( + 'operation_conflict', + 'Target submit was definitively rejected again', + ); + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + /definitively rejected again/u, + ); + + assert.equal(effects.discardAttempts, 2); + assert.deepEqual(effects.creations, []); + }); }); function session( @@ -503,6 +537,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { submitUnknownAfterAdmission: false as boolean, submitFailure: undefined as WorkHubActionEffectFailure | undefined, recoverSubmissionMiss: false as boolean, + discardAttempts: 0, + discardFailuresRemaining: 0, discardedCreatedSessionIds: [] as string[], async listSessions() { return this.sessions; @@ -521,7 +557,7 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { const existing = this.creations.find(({ sessionId }) => sessionId === input.sessionId); if (existing) assert.deepEqual(existing, input); else this.creations.push(input); - return existing ? {} : { createdRevision: 1 }; + return { discardRevision: 1 }; }, async submit(input: { sessionId: string; messageId: string; text: string }) { if (this.submitFailure) { @@ -548,6 +584,14 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { }, async discardCreated(input: { sessionId: string; expectedRevision: number }) { assert.equal(input.expectedRevision, 1); + this.discardAttempts += 1; + if (this.discardFailuresRemaining > 0) { + this.discardFailuresRemaining -= 1; + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + 'Created Session retirement outcome is unknown', + ); + } this.discardedCreatedSessionIds.push(input.sessionId); const index = this.creations.findIndex(({ sessionId }) => sessionId === input.sessionId); if (index >= 0) this.creations.splice(index, 1); @@ -597,6 +641,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { submitUnknownAfterAdmission: boolean; submitFailure: WorkHubActionEffectFailure | undefined; recoverSubmissionMiss: boolean; + discardAttempts: number; + discardFailuresRemaining: number; discardedCreatedSessionIds: string[]; }; return state; diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 1b76c71866..3f85a02435 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1247,9 +1247,9 @@ export async function createExecutionRuntimeHostComposition( outcome.error.message, ); } - return created.createdRevision === undefined + return created.discardRevision === undefined ? {} - : { createdRevision: created.createdRevision }; + : { discardRevision: created.discardRevision }; }, discardCreated: async (input, connection) => { const outcome = await requireSessionRetirement(sessionRetirement).handlers[ diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 6524cbc387..55829c34e6 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -148,6 +148,10 @@ interface ResolvedSessionModel { readonly model: string; } +// Stable Session creation owns revision 1; any later metadata or execution +// mutation advances it and therefore revokes WorkHub's empty-Session cleanup. +const STABLE_SESSION_INITIAL_REVISION = 1; + /** Host-owned Session catalog, creation, and configuration authority. */ export class HostSessionCatalogCoordinator { readonly handlers: SessionCatalogOperationHandlerMap = { @@ -202,13 +206,14 @@ export class HostSessionCatalogCoordinator { /** WorkHub Action Gate path; callers cannot bypass the typed operation outcome. */ async createForWorkHub(input: SessionCreateInput): Promise<{ readonly outcome: OperationOutcome<'session.create'>; - readonly createdRevision?: number; + readonly discardRevision?: number; }> { - let createdRevision: number | undefined; - const outcome = await this.#create(input, (revision) => { - createdRevision = revision; - }); - return { outcome, ...(createdRevision === undefined ? {} : { createdRevision }) }; + let discardRevision: number | undefined; + const rememberDiscardRevision = (revision: number) => { + discardRevision = revision; + }; + const outcome = await this.#create(input, rememberDiscardRevision, rememberDiscardRevision); + return { outcome, ...(discardRevision === undefined ? {} : { discardRevision }) }; } async #query( @@ -346,6 +351,7 @@ export class HostSessionCatalogCoordinator { async #create( input: SessionCreateInput, onCreated?: (revision: number) => void, + onPristineReplay?: (revision: number) => void, ): Promise> { if (isWorkHubCoordinationSessionId(input.sessionId)) { return createFailure( @@ -369,6 +375,9 @@ export class HostSessionCatalogCoordinator { requestFingerprint, ); if (probe.kind === 'existing') { + if (probe.record.revision === STABLE_SESSION_INITIAL_REVISION) { + onPristineReplay?.(probe.record.revision); + } return createSuccess( projectSessionCatalogRecord(await this.#stores.readCatalogRecord(input.sessionId)), ); @@ -412,6 +421,12 @@ export class HostSessionCatalogCoordinator { ); } if (result.kind === 'created') onCreated?.(result.record.revision); + else if ( + result.kind === 'existing' && + result.record.revision === STABLE_SESSION_INITIAL_REVISION + ) { + onPristineReplay?.(result.record.revision); + } await this.#continuity.refreshCanonical(input.sessionId, lease); return createSuccess( projectSessionCatalogRecord(await this.#stores.readCatalogRecord(input.sessionId)), 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 ea70bb76b1..6d68987f21 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -73,7 +73,7 @@ export interface WorkHubActionGateEffects { readonly sessionId: string; readonly workspace: WorkspaceTarget; readonly title: string; - }): Promise<{ readonly createdRevision?: number }>; + }): Promise<{ readonly discardRevision?: number }>; discardCreated( input: { readonly sessionId: string; @@ -283,7 +283,7 @@ export class WorkHubCoordinationActionGate { intent: WorkHubDelegationIntent, context: ConnectionContext, ): Promise { - let createdRevision: number | undefined; + let discardRevision: number | undefined; if (intent.disposition === 'create_new') { if (!intent.create) { throw new WorkHubActionGateFailure( @@ -296,7 +296,7 @@ export class WorkHubCoordinationActionGate { workspace: intent.create.workspace, title: intent.create.title, }); - createdRevision = created.createdRevision; + discardRevision = created.discardRevision; } else if (intent.create) { throw new WorkHubActionGateFailure( 'action_conflict', @@ -309,26 +309,28 @@ export class WorkHubCoordinationActionGate { messageId: actionMessageId(intent.actionId), text: intent.userText, }; - let submitted: { readonly turnId: string; readonly steered?: true }; - try { - submitted = await this.#effects.submit(message, context); - } catch (error) { - if (!(error instanceof WorkHubActionEffectFailure)) throw error; - if (error.code !== 'commit_outcome_unknown') { - if (createdRevision !== undefined) { - await this.#effects.discardCreated( - { - sessionId: intent.targetSessionId, - expectedRevision: createdRevision, - }, - context, - ); + let submitted = await this.#effects.recoverSubmission(message); + if (!submitted) { + try { + submitted = await this.#effects.submit(message, context); + } catch (error) { + if (!(error instanceof WorkHubActionEffectFailure)) throw error; + if (error.code !== 'commit_outcome_unknown') { + if (discardRevision !== undefined) { + await this.#effects.discardCreated( + { + sessionId: intent.targetSessionId, + expectedRevision: discardRevision, + }, + context, + ); + } + throw error; } - throw error; + const recovered = await this.#effects.recoverSubmission(message); + if (!recovered) throw error; + submitted = recovered; } - const recovered = await this.#effects.recoverSubmission(message); - if (!recovered) throw error; - submitted = recovered; } const commit: WorkHubDelegationCommit = { ...intent, From dc25b432050a328e0573818960fb7565160c15e8 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Thu, 27 Aug 2026 14:35:06 +0800 Subject: [PATCH 5/5] fix(workhub): harden durable delegation recovery Persist definitive abandonment and deterministic cleanup state, preserve retry identity across renderer drafts and Host scopes, and recover accepted steering from durable proof. Carry typed failures across Desktop IPC, avoid unnecessary Host drains, and extend regression coverage for crash and retry seams. Generated-by: Codex --- .../runtime-host-workhub-ipc-main.test.ts | 50 +++++- .../__tests__/workhub-session-port.test.ts | 7 +- .../__tests__/workhub-surface-flow.test.ts | 140 ++++++++++++++++ .../src/main/runtime-host-workhub-ipc-main.ts | 86 ++++++---- apps/desktop/src/preload/bridge-contract.d.ts | 3 +- apps/desktop/src/preload/preload.ts | 22 ++- .../src/renderer/workhub-coordination-port.ts | 24 ++- .../src/renderer/workhub-send-lease.ts | 151 +++++++++++++----- apps/desktop/src/renderer/workhub-surface.tsx | 101 +++++++++--- .../workhub-coordination-session-adr.md | 38 +++-- .../workhub-coordination-record.test.ts | 10 +- packages/core/src/session.ts | 34 +++- .../session-catalog-coordinator.test.ts | 19 +++ .../workhub-coordination-action-gate.test.ts | 90 ++++++++++- .../workhub-coordination-coordinator.test.ts | 4 +- ...workhub-target-submission-recovery.test.ts | 52 ++++++ packages/runtime-host/src/protocol/index.ts | 4 +- .../src/server/execution-composition.ts | 16 +- .../src/server/session-catalog-coordinator.ts | 20 ++- .../workhub-coordination-action-gate.ts | 87 ++++++++-- .../workhub-coordination-coordinator.ts | 1 + .../src/server/workhub-delegation-journal.ts | 104 ++++++++++-- .../workhub-target-submission-recovery.ts | 13 +- 23 files changed, 910 insertions(+), 166 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts index 95e6f40d21..2aa190fb92 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import { registerRuntimeHostWorkHubIpc } from '../runtime-host-workhub-ipc-main.js'; test('projects WorkHub coordination resolution through its dedicated IPC domain', async () => { @@ -111,9 +112,12 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' }, }), { - disposition: 'create_new', - targetSessionId: createdSessionId, - targetTurnId: 'created-turn', + ok: true, + result: { + disposition: 'create_new', + targetSessionId: createdSessionId, + targetTurnId: 'created-turn', + }, }, ); assert.deepEqual(actions, [{ @@ -126,3 +130,43 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' }]); assert.deepEqual(changes, [{ reason: 'created', sessionId: createdSessionId }]); }); + +test('serializes typed WorkHub action failures across Electron IPC', async () => { + const handlers = new Map unknown>(); + registerRuntimeHostWorkHubIpc( + { + actWorkHubCoordination: async () => { + throw new RuntimeHostOperationError( + 'workhub.coordination.act', + 'operation_conflict', + 'WorkHub action is permanently abandoned', + ); + }, + } as never, + { + handle: (channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler); + }, + } as never, + { + resolveCreateProject: async () => ({ kind: 'host_path', path: '/workspace' }), + emitSessionsChanged: () => undefined, + }, + ); + + assert.deepEqual( + await handlers.get('workhub:act')?.({}, { + actionId: 'abandoned-action', + userText: 'Continue payment work', + candidateSetId: `sha256:${'a'.repeat(64)}`, + proposal: { disposition: 'delegate_existing', candidateRef: 'candidate' }, + }), + { + ok: false, + error: { + code: 'operation_conflict', + message: 'WorkHub action is permanently abandoned', + }, + }, + ); +}); 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 af3d8d48d7..135a8c0e82 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -168,8 +168,11 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and candidates: [], }), act: async () => ({ - disposition: 'answer_here', - coordinationTurnId: 'coordination-turn', + ok: true, + result: { + disposition: 'answer_here', + coordinationTurnId: 'coordination-turn', + }, }), }); 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 aa37d6a276..105545caf4 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -27,6 +27,7 @@ import { WorkHubProjectionRefreshGate, WorkHubSurfaceRouteGate, submitAndRecordWorkHubSurfaceInput, + submitLeasedWorkHubSurfaceInput, submitWorkHubSurfaceInput, visibleWorkHubConversation, workHubSurfaceFailure, @@ -43,6 +44,7 @@ import { type WorkHubDesktopSession, } from '../../renderer/workhub-session-port.js'; import { WorkHubSendLease } from '../../renderer/workhub-send-lease.js'; +import { WorkHubCoordinationFailure } from '../../renderer/workhub-coordination-port.js'; test('production retry keeps one action identity across failure and renderer reload', () => { const values = new Map(); @@ -70,6 +72,135 @@ test('production retry keeps one action identity across failure and renderer rel assert.equal(restarted.acquire('Continue payment work'), 'action-2'); }); +test('a failed storage retirement cannot resurrect a settled action in memory', () => { + const values = new Map(); + let rejectWrites = false; + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + if (rejectWrites) throw new Error('storage unavailable'); + values.set(key, value); + }, + removeItem: (key: string) => { + if (rejectWrites) throw new Error('storage unavailable'); + values.delete(key); + }, + }; + const ids = ['action-1', 'action-2']; + const lease = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => ids.shift()!, + }); + + const first = lease.acquire('Continue payment work'); + rejectWrites = true; + lease.complete(first); + + assert.equal(lease.acquire('Continue payment work'), 'action-2'); +}); + +test('typing the next draft while an action is in flight cannot revoke its summary identity', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + const ids = ['action-in-flight', 'action-next']; + const lease = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => ids.shift()!, + }); + const attempt = lease.acquireAttempt('Continue payment work'); + + lease.write('workhub', 'Start the next message'); + + assert.equal( + lease.summary(attempt.requestId, () => 'Accepted by Payments.'), + 'Accepted by Payments.', + ); + assert.deepEqual(lease.acquireAttempt('Start the next message'), { + requestId: 'action-in-flight', + text: 'Continue payment work', + retrying: true, + }); + assert.equal(lease.settle(attempt.requestId, true), false); + assert.equal(lease.read('workhub'), 'Start the next message'); + assert.deepEqual(lease.acquireAttempt('Start the next message'), { + requestId: 'action-next', + text: 'Start the next message', + retrying: false, + }); +}); + +test('a new send normalizes surrounding whitespace without retaining the sent draft', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + const lease = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => 'trimmed-action', + }); + lease.write('workhub', ' Continue payment work '); + + const attempt = lease.acquireAttempt('Continue payment work'); + + assert.equal(lease.read('workhub'), 'Continue payment work'); + assert.equal(lease.settle(attempt.requestId, true), true); +}); + +test('clarification choice retries through the same leased action identity', async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + const ids = ['choice-action-1', 'choice-action-2']; + const lease = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => ids.shift()!, + }); + const clarificationRequestId = lease.acquire('Continue the login work'); + assert.equal(lease.settle(clarificationRequestId, true), true); + lease.write('workhub', ''); + const submittedIds: string[] = []; + let failSummary = true; + const choose = () => submitLeasedWorkHubSurfaceInput({ + lease, + text: 'Continue the login work', + preserveDraft: true, + submit: async (attempt) => { + submittedIds.push(attempt.requestId); + if (failSummary) { + failSummary = false; + return undefined; + } + return { + kind: 'submitted', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: attempt.requestId, + target: { sessionId: 'login' }, + turnId: 'login-turn', + evidence: 'explicit_target', + }; + }, + }); + + assert.equal(await choose(), false); + assert.equal(lease.read('workhub'), 'Continue the login work'); + assert.equal(await choose(), false); + assert.equal(lease.read('workhub'), undefined); + assert.deepEqual(submittedIds, ['choice-action-2', 'choice-action-2']); +}); + test('production retry identity is isolated by Runtime Host scope', () => { const values = new Map(); const storage = { @@ -285,6 +416,15 @@ test('summary failure keeps the target action retryable under the same productio }); test('surface turns Action Gate rejections into safe actionable failures', () => { + assert.equal( + workHubSurfaceFailure( + new WorkHubCoordinationFailure( + 'operation_conflict', + 'WorkHub action is permanently abandoned', + ), + ), + 'action_changed', + ); assert.equal( workHubSurfaceFailure( new Error('WorkHub Session candidates changed; refresh before delegating'), diff --git a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts index 621bc5a570..c16a816054 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -18,10 +18,13 @@ */ import type { + OperationError, + OperationOutcome, WorkHubCoordinationActInput, WorkHubCoordinationActResult, WorkspaceTarget, } from '@maka/runtime-host/protocol'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import type { ReconnectableReadIpcMain } from './ipc-reconnect-policy.js'; @@ -58,33 +61,62 @@ export function registerRuntimeHostWorkHubIpc( ); ipcMain.handle('workhub:candidates', () => client.listWorkHubCoordinationCandidates()); ipcMain.handle('workhub:act', async (_event, rawInput: RendererWorkHubActionInput) => { - const proposal = rawInput?.proposal; - const base = { - actionId: rawInput?.actionId, - userText: rawInput?.userText, - proposal, - } as Pick; - let result: WorkHubCoordinationActResult; - if (proposal?.disposition === 'create_new') { - result = await client.actWorkHubCoordination({ - ...base, - create: { - workspace: await options.resolveCreateProject(), - }, - }); - } else { - result = await client.actWorkHubCoordination({ - ...base, - ...(rawInput?.candidateSetId === undefined - ? {} - : { candidateSetId: rawInput.candidateSetId }), - }); + try { + const proposal = rawInput?.proposal; + const base = { + actionId: rawInput?.actionId, + userText: rawInput?.userText, + proposal, + } as Pick; + let result: WorkHubCoordinationActResult; + if (proposal?.disposition === 'create_new') { + result = await client.actWorkHubCoordination({ + ...base, + create: { + workspace: await options.resolveCreateProject(), + }, + }); + } else { + result = await client.actWorkHubCoordination({ + ...base, + ...(rawInput?.candidateSetId === undefined + ? {} + : { candidateSetId: rawInput.candidateSetId }), + }); + } + if (result.disposition === 'create_new') { + options.emitSessionsChanged('created', result.targetSessionId); + } else if (result.disposition === 'delegate_existing') { + options.emitSessionsChanged('status-change', result.targetSessionId); + } + return { ok: true, result } satisfies OperationOutcome<'workhub.coordination.act'>; + } catch (error) { + if (!(error instanceof RuntimeHostOperationError)) throw error; + return { + ok: false, + error: workHubActError(error), + } satisfies OperationOutcome<'workhub.coordination.act'>; } - if (result.disposition === 'create_new') { - options.emitSessionsChanged('created', result.targetSessionId); - } else if (result.disposition === 'delegate_existing') { - options.emitSessionsChanged('status-change', result.targetSessionId); - } - return result; }); } + +function workHubActError( + error: RuntimeHostOperationError, +): OperationError<'workhub.coordination.act'> { + switch (error.code) { + case 'host_not_ready': + case 'host_draining': + case 'unauthorized': + case 'operation_unavailable': + case 'not_found': + case 'session_archived': + case 'session_busy': + case 'operation_conflict': + case 'persistence_failed': + case 'commit_outcome_unknown': + case 'internal_failure': + return { code: error.code, message: error.message }; + default: + return { code: 'internal_failure', message: 'WorkHub action failed' }; + } +} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 94129cd1f7..c44a49ae9d 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -105,6 +105,7 @@ import type { WorkBoardItem, WorkBoardListQuery, WorkBoardPage } from '@maka/cor import type { WorkBoardMutationOptions } from '@maka/storage/work-board-store'; import type { OperationInput, + OperationOutcome, OperationOutput, } from '@maka/runtime-host/protocol'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; @@ -816,7 +817,7 @@ export interface MakaBridge { act( coordinationSessionId: string, input: Omit, 'create'>, - ): Promise>; + ): Promise>; /** Create an ordinary Session on the exact Host owning the resolved conversation. */ createSession( coordinationSessionId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 4f5746757f..66de255e2c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -216,6 +216,7 @@ import type { OnboardingMilestoneId } from '@maka/core/onboarding'; import { SCHEDULED_TASK_CATALOG_MAX_ITEMS, type OperationInput, + type OperationOutcome, type OperationOutput, } from '@maka/runtime-host/protocol'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; @@ -1614,7 +1615,7 @@ const makaBridge = { async act( coordinationSessionId: string, input: Omit, 'create'>, - ): Promise> { + ): Promise> { const scope = await resolveDesktopWorkHubCoordinationCreateScope( coordinationSessionId, runtimeHostSessionRef, @@ -1623,14 +1624,21 @@ const makaBridge = { 'workhub:act', scope, input, - ) as OperationOutput<'workhub.coordination.act'>; - if (result.disposition === 'answer_here' || result.disposition === 'clarify') return result; + ) as OperationOutcome<'workhub.coordination.act'>; + if (!result.ok) return result; + if ( + result.result.disposition === 'answer_here' || + result.result.disposition === 'clarify' + ) return result; return { - ...result, - targetSessionId: desktopSessionKey({ + ok: true, + result: { + ...result.result, + targetSessionId: desktopSessionKey({ hostId: scope.hostId, - sessionId: result.targetSessionId, - }), + sessionId: result.result.targetSessionId, + }), + }, }; }, async createSession( diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index e2581cd67f..2ef6710550 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -33,12 +33,24 @@ import type { WorkHubCoordinationActInput, WorkHubCoordinationActResult, WorkHubCoordinationCandidatesResult, + OperationOutcome, + OperationError, } from '@maka/runtime-host/protocol'; import { boundedWorkHubTimelineText } from './workhub-controller.js'; import type { WorkHubDesktopTranscriptBridge } from './workhub-session-port.js'; const WORKHUB_COORDINATION_TURN_LIMIT = 40; +export class WorkHubCoordinationFailure extends Error { + constructor( + readonly code: OperationError<'workhub.coordination.act'>['code'], + message: string, + ) { + super(message); + this.name = 'WorkHubCoordinationFailure'; + } +} + export function createDesktopWorkHubCoordinationPort(deps: { sessionId: string; transcripts: WorkHubDesktopTranscriptBridge; @@ -49,13 +61,21 @@ export function createDesktopWorkHubCoordinationPort(deps: { assistantText: string; }): Promise<{ turnId: string }>; candidates(): Promise; - act(input: Omit): Promise; + act( + input: Omit, + ): Promise>; }): WorkHubCoordinationPort { return { answer: deps.answer, record: deps.record, candidates: deps.candidates, - act: deps.act, + async act(input) { + const outcome = await deps.act(input); + if (!outcome.ok) { + throw new WorkHubCoordinationFailure(outcome.error.code, outcome.error.message); + } + return outcome.result; + }, async open(handler, onError) { const store = new DesktopTranscriptRangeStore(deps.sessionId); let disposed = false; diff --git a/apps/desktop/src/renderer/workhub-send-lease.ts b/apps/desktop/src/renderer/workhub-send-lease.ts index 3a93b445ac..8f50d21aec 100644 --- a/apps/desktop/src/renderer/workhub-send-lease.ts +++ b/apps/desktop/src/renderer/workhub-send-lease.ts @@ -17,7 +17,7 @@ * under the License. */ -const WORKHUB_SEND_LEASE_KEY = 'maka-workhub-send-lease-v1'; +const WORKHUB_SEND_LEASE_KEY = 'maka-workhub-send-lease-v2'; const WORKHUB_DRAFT_KEY = 'workhub'; const MAX_DRAFT_CHARS = 120_000; const MAX_SUMMARY_CHARS = 4_000; @@ -27,10 +27,14 @@ const SAFE_REQUEST_ID = /^[A-Za-z0-9_-]{1,128}$/u; type WorkHubSendLeaseStorage = Pick; interface WorkHubSendLeaseState { - readonly version: 1; + readonly version: 2; readonly draft: string; - readonly requestId?: string; - readonly summary?: string; + readonly action?: { + readonly requestId: string; + readonly text: string; + readonly state: 'active' | 'settled'; + readonly summary?: string; + }; } export interface WorkHubSendLeaseOptions { @@ -41,13 +45,14 @@ export interface WorkHubSendLeaseOptions { export interface WorkHubSendAttempt { readonly requestId: string; + readonly text: string; readonly retrying: boolean; } /** - * Couples the reload-safe Composer draft to the Action Gate identity that owns - * its delivery. A failed send keeps both; a fully settled send retires only the - * identity and lets Composer decide whether the text itself should clear. + * Persists a Composer draft beside, but independently from, the Action Gate + * identity that owns an in-flight delivery. This lets a user type the next + * draft without revoking or overwriting recovery for the previous action. */ export class WorkHubSendLease { #memory: WorkHubSendLeaseState | undefined; @@ -55,13 +60,14 @@ export class WorkHubSendLease { readonly #storage: WorkHubSendLeaseStorage | undefined; readonly #createId: () => string; readonly #storageKey: string; + #storageHealthy = true; constructor(options: WorkHubSendLeaseOptions) { if (!options.scope || options.scope.length > MAX_SCOPE_CHARS) { throw new TypeError('WorkHub send lease requires a bounded Runtime Host scope'); } this.#scope = options.scope; - this.#storage = options.storage ?? rendererSessionStorage(); + this.#storage = options.storage ?? rendererPersistentStorage(); this.#createId = options.createId ?? (() => crypto.randomUUID()); this.#storageKey = `${WORKHUB_SEND_LEASE_KEY}:${encodeURIComponent(this.#scope)}`; } @@ -70,37 +76,78 @@ export class WorkHubSendLease { return this.acquireAttempt(text).requestId; } - acquireAttempt(text: string): WorkHubSendAttempt { + acquireAttempt( + text: string, + options: { readonly preserveDraft?: boolean } = {}, + ): WorkHubSendAttempt { const existing = this.#read(); - if (existing?.draft === text && existing.requestId) { - return { requestId: existing.requestId, retrying: true }; + if (existing?.action?.state === 'active') { + return { + requestId: existing.action.requestId, + text: existing.action.text, + retrying: true, + }; + } + if ( + !options.preserveDraft && + existing?.action?.state === 'settled' && + existing.draft === text && + existing.action.text === text + ) { + return { + requestId: existing.action.requestId, + text: existing.action.text, + retrying: true, + }; } const requestId = this.#createId(); - this.#write({ version: 1, draft: text, requestId }); - return { requestId, retrying: false }; + this.#write({ + version: 2, + draft: options.preserveDraft ? existing?.draft ?? text : text, + action: { requestId, text, state: 'active' }, + }); + return { requestId, text, retrying: false }; } complete(requestId: string): void { const existing = this.#read(); - if (existing?.requestId !== requestId) return; - this.#write({ version: 1, draft: existing.draft }); + if (existing?.action?.requestId !== requestId) return; + this.#write({ version: 2, draft: existing.draft }); + } + + settle(requestId: string, clearsDraft: boolean): boolean { + const existing = this.#read(); + if (!clearsDraft || existing?.action?.requestId !== requestId) return false; + const draftUnchanged = existing.draft === existing.action.text; + if (!existing.draft) { + this.#write({ version: 2, draft: '' }); + } else { + this.#write({ + ...existing, + action: { ...existing.action, state: 'settled' }, + }); + } + return draftUnchanged; } - settle(requestId: string, _clearsDraft: boolean): void { - if (_clearsDraft) this.complete(requestId); + abandon(requestId: string): void { + this.complete(requestId); } summary(requestId: string, create: () => string): string { const existing = this.#read(); - if (existing?.requestId !== requestId) { + if (existing?.action?.requestId !== requestId) { throw new Error('WorkHub summary identity does not own the active send lease'); } - if (existing.summary) return existing.summary; + if (existing.action.summary) return existing.action.summary; const summary = create(); if (!summary || summary.length > MAX_SUMMARY_CHARS) { throw new Error('WorkHub coordination summary is invalid'); } - this.#write({ ...existing, summary }); + this.#write({ + ...existing, + action: { ...existing.action, summary }, + }); return summary; } @@ -111,73 +158,97 @@ export class WorkHubSendLease { write(key: string | undefined, draft: string): void { if (key !== WORKHUB_DRAFT_KEY) return; if (!draft) { - this.#remove(); + const existing = this.#read(); + if (existing?.action?.state === 'active') { + this.#write({ ...existing, draft: '' }); + } else { + this.#remove(); + } return; } const existing = this.#read(); - const preservesIdentity = existing?.draft === draft && existing.requestId; + // Composer permits the user to type the next draft while the current send + // is still settling. Draft edits therefore cannot revoke the identity that + // owns an already-admitted target effect or its Coordination summary. this.#write({ - version: 1, + version: 2, draft, - ...(preservesIdentity ? { requestId: existing.requestId } : {}), - ...(preservesIdentity && existing.summary ? { summary: existing.summary } : {}), + ...(existing?.action ? { action: existing.action } : {}), }); } #read(): WorkHubSendLeaseState | undefined { + if (!this.#storageHealthy) return this.#memory; try { const raw = this.#storage?.getItem(this.#storageKey); if (!raw) return this.#memory; const value = JSON.parse(raw) as Partial; if ( - value.version !== 1 || + value.version !== 2 || typeof value.draft !== 'string' || value.draft.length > MAX_DRAFT_CHARS || - (value.requestId !== undefined && - (typeof value.requestId !== 'string' || !SAFE_REQUEST_ID.test(value.requestId))) || - (value.summary !== undefined && - (typeof value.summary !== 'string' || - !value.summary || - value.summary.length > MAX_SUMMARY_CHARS || - value.requestId === undefined)) + (value.action !== undefined && !isWorkHubSendAction(value.action)) ) { return undefined; } const decoded = { - version: 1, + version: 2, draft: value.draft, - ...(value.requestId ? { requestId: value.requestId } : {}), - ...(value.summary ? { summary: value.summary } : {}), + ...(value.action ? { action: value.action } : {}), } satisfies WorkHubSendLeaseState; this.#memory = decoded; return decoded; } catch { + this.#storageHealthy = false; return this.#memory; } } #write(value: WorkHubSendLeaseState): void { this.#memory = value; + if (!this.#storageHealthy) return; try { this.#storage?.setItem(this.#storageKey, JSON.stringify(value)); } catch { - // Restricted renderer contexts may not expose web storage. + this.#storageHealthy = false; } } #remove(): void { this.#memory = undefined; + if (!this.#storageHealthy) return; try { this.#storage?.removeItem(this.#storageKey); } catch { - // Restricted renderer contexts may not expose web storage. + this.#storageHealthy = false; } } } -function rendererSessionStorage(): WorkHubSendLeaseStorage | undefined { +function isWorkHubSendAction( + value: unknown, +): value is NonNullable { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial>; + return ( + typeof candidate.requestId === 'string' && + SAFE_REQUEST_ID.test(candidate.requestId) && + typeof candidate.text === 'string' && + candidate.text.length > 0 && + candidate.text.length <= MAX_DRAFT_CHARS && + (candidate.state === 'active' || candidate.state === 'settled') && + (candidate.summary === undefined || + (typeof candidate.summary === 'string' && + candidate.summary.length > 0 && + candidate.summary.length <= MAX_SUMMARY_CHARS)) + ); +} + +function rendererPersistentStorage(): WorkHubSendLeaseStorage | undefined { try { - return typeof window === 'undefined' ? undefined : window.sessionStorage; + return typeof window === 'undefined' || typeof document === 'undefined' + ? undefined + : window.localStorage; } catch { return undefined; } diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 04f76abba4..55b88ed21c 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -35,7 +35,11 @@ import type { WorkHubSubmission, WorkHubSubmitInput, } from './workhub-controller.js'; -import { WorkHubSendLease } from './workhub-send-lease.js'; +import { + WorkHubSendLease, + type WorkHubSendAttempt, +} from './workhub-send-lease.js'; +import { WorkHubCoordinationFailure } from './workhub-coordination-port.js'; export interface WorkHubConversationTurn { requestId: string; @@ -90,6 +94,14 @@ export function workHubSubmissionClearsDraft( } export function workHubSurfaceFailure(error: unknown): WorkHubSurfaceFailure { + if (error instanceof WorkHubCoordinationFailure) { + if (error.code === 'operation_conflict') return 'action_changed'; + if (error.code === 'not_found' || error.code === 'session_archived') { + return 'candidates_changed'; + } + if (error.code === 'session_busy') return 'target_waiting'; + return 'delivery_failed'; + } const message = error instanceof Error ? error.message : ''; if ( /candidates changed|not in the admitted candidate set|source or target is not in/iu.test( @@ -164,6 +176,29 @@ export async function submitAndRecordWorkHubSurfaceInput(input: { return result; } +export async function submitLeasedWorkHubSurfaceInput(input: { + lease: WorkHubSendLease; + text: string; + preserveDraft?: boolean; + submit(attempt: WorkHubSendAttempt): Promise; +}): Promise { + const attempt = input.lease.acquireAttempt(input.text, { + preserveDraft: input.preserveDraft, + }); + if (input.preserveDraft && attempt.text !== input.text) return false; + const result = await input.submit(attempt); + if (!result) return false; + const clearsDraft = input.lease.settle( + attempt.requestId, + workHubSubmissionClearsDraft(result), + ); + if (input.preserveDraft && clearsDraft) { + input.lease.write('workhub', ''); + return false; + } + return clearsDraft; +} + /** * The persistent Coordination Session transcript is the primary conversation. * Ordinary Sessions remain a read-only status/routing projection. @@ -277,6 +312,9 @@ export function WorkHubSurface(props: { if (result.kind === 'submitted') await refresh(); return result; } catch (error) { + if (isTerminalWorkHubSurfaceFailure(error)) { + sendLease.abandon(input.requestId); + } setTurns((current) => current.map((turn) => turn.requestId === localRequestId ? { @@ -297,22 +335,23 @@ export function WorkHubSurface(props: { const send = useCallback(async (value: string) => { const text = value.trim(); if (!text || !initialLoadSettled || !conversationReady || routeGate.pending) return false; - const attempt = sendLease.acquireAttempt(text); - const { requestId } = attempt; - setTurns((current) => current.some((turn) => turn.requestId === requestId) - ? current.map((turn) => turn.requestId === requestId - ? { requestId, text, state: 'routing' } - : turn) - : [...current, { requestId, text, state: 'routing' }]); - const result = await route({ - requestId, + return submitLeasedWorkHubSurfaceInput({ + lease: sendLease, text, - ...(attempt.retrying ? { retryAction: true as const } : {}), + submit: async (attempt) => { + const { requestId } = attempt; + setTurns((current) => current.some((turn) => turn.requestId === requestId) + ? current.map((turn) => turn.requestId === requestId + ? { requestId, text: attempt.text, state: 'routing' } + : turn) + : [...current, { requestId, text: attempt.text, state: 'routing' }]); + return route({ + requestId, + text: attempt.text, + ...(attempt.retrying ? { retryAction: true as const } : {}), + }); + }, }); - if (result) sendLease.settle(requestId, workHubSubmissionClearsDraft(result)); - // Composer clears only accepted drafts. Waiting, delivery failures, and a - // ref-blocked duplicate keep the exact text available for retry. - return workHubSubmissionClearsDraft(result); }, [conversationReady, initialLoadSettled, route, routeGate, sendLease]); const visible = visibleWorkHubConversation(coordinationTurns, turns); const visibleCoordinationTurns = visible.coordination; @@ -383,14 +422,22 @@ export function WorkHubSurface(props: { const selected = projection.sessions.find( (session) => session.target.sessionId === target.sessionId, ); - void route({ - requestId: crypto.randomUUID(), + void submitLeasedWorkHubSurfaceInput({ + lease: sendLease, text: turn.text, - explicitTarget: target, - ...(turn.outcome?.kind === 'clarification' && turn.outcome.correction - ? { correction: turn.outcome.correction } - : {}), - }, turn.requestId, copy.choseWork(selected?.sessionName ?? copy.sessionFallback)); + preserveDraft: true, + submit: (attempt) => route({ + requestId: attempt.requestId, + text: attempt.text, + explicitTarget: target, + ...(attempt.retrying ? { retryAction: true as const } : {}), + ...(turn.outcome?.kind === 'clarification' && turn.outcome.correction + ? { correction: turn.outcome.correction } + : {}), + }, turn.requestId, copy.choseWork( + selected?.sessionName ?? copy.sessionFallback, + )), + }); }} onOpenSession={props.onOpenSession} /> @@ -404,6 +451,16 @@ export function WorkHubSurface(props: { ); } +function isTerminalWorkHubSurfaceFailure(error: unknown): boolean { + return ( + error instanceof WorkHubCoordinationFailure && + (error.code === 'operation_conflict' || + error.code === 'not_found' || + error.code === 'session_archived' || + error.code === 'unauthorized') + ); +} + /** Visible lifecycle state while the active Host's Coordination Session is unavailable. */ export function WorkHubCoordinationStatus(props: { locale: UiLocale; diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 0243fc9c09..1a7e4a3b8d 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -119,16 +119,21 @@ existing Coordination Session transcript. An immutable `delegation_intent` is appended before the target Session effect so an opaque candidate remains recoverable after the candidate set changes or the Runtime Host restarts. A `delegation_committed` record then binds that intent to the accepted target Turn -and acts as the durable action-replay result. The records carry an action -fingerprint to reject conflicting reuse of an action identity. They do not form a -general workflow state machine and do not persist target execution lifecycle. - -The renderer couples one reload-safe Composer draft to one action identity until -both target admission and its Coordination summary settle. The lease is scoped by -Coordination Session, so switching Runtime Hosts cannot move or retire another -Host's action identity. Retry therefore reuses the same identity instead of -treating the retained draft as new work; `waiting_for_user` does not retire that -identity, and the first generated Coordination summary is immutable across retry. +and acts as the durable action-replay result. A mutually exclusive +`delegation_abandoned` record spends an identity whose target effect was +definitively rejected; for `create_new`, it also closes a retired deterministic +Session id. The records carry an action fingerprint to reject conflicting reuse +of an action identity. They do not form a general workflow state machine and do +not persist target execution lifecycle. + +The renderer persists the Composer draft and its in-flight action as separate +fields in local storage until both target admission and its Coordination summary +settle. Draft edits cannot revoke an admitted action, and the identity survives a +renderer reload or full application relaunch. The lease is scoped by Coordination +Session, so switching Runtime Hosts cannot move or retire another Host's action +identity. Retry therefore reuses the same identity instead of treating retained +text as new work; `waiting_for_user` does not retire that identity, and the first +generated Coordination summary remains bound to the action across draft changes. The durable fingerprint covers stable user intent, not snapshot-scoped candidate ids; once prepared, the intent owns the resolved target, exact user text, and any `create_new` title/workspace context. @@ -141,7 +146,10 @@ definitive `create_new` submit rejection compensates through the ordinary Session-retirement authority. The exact stable create may expose its revision again only while the Session remains at the initial revision, so an uncertain retirement can be retried without granting cleanup authority over a subsequently mutated -Session. An unknown submit outcome never removes a possibly admitted Session. +Session. `not_found` is successful cleanup, while revision, busy, and other +definitive cleanup failures stay local to the action; only an already-uncertain +commit path may request Runtime Host drain. An unknown submit outcome never removes +a possibly admitted Session. Recovery is deliberately driven by explicit caller retry rather than an autonomous startup scan: the latter would execute user work without a live request context and turn this journal into a background workflow engine. @@ -157,14 +165,18 @@ turn this journal into a background workflow engine. other Host's Sessions. - The special Session role adds provisioning, lookup, recovery, retention, and UI obligations even though it deliberately reuses the existing Session substrate. +- Every delegated Coordination turn adds two invisible journal messages (intent + plus committed or abandoned) beside its three visible transcript messages. + Message-count page limits therefore retain up to roughly 40% fewer visible + delegated turns than an answer-only Coordination history. - Whether Work is 1:1 with Session, 1:N over Sessions, or an independent durable entity remains unresolved. - Cross-Runtime-Host coordination remains deferred. - Coordination Session role representation, lazy creation, durable lookup, recovery, per-Host UI resolution, persistent transcript, closed dispositions, and the Action Gate are implemented. Durable delegation linkage is encoded in - that transcript; target lifecycle projection and destructive replacement/Stop - recovery remain later work. + that transcript; target lifecycle projection, linked correction, and destructive + replacement/Stop recovery remain later work. Reevaluate the per-Host decision if supported workflows require one WorkHub conversation to coordinate ordinary Sessions on multiple Runtime Hosts, or if Host diff --git a/packages/core/src/__tests__/workhub-coordination-record.test.ts b/packages/core/src/__tests__/workhub-coordination-record.test.ts index 7cf1b72270..8159dbd8b9 100644 --- a/packages/core/src/__tests__/workhub-coordination-record.test.ts +++ b/packages/core/src/__tests__/workhub-coordination-record.test.ts @@ -24,7 +24,7 @@ import { decodeCanonicalMessage } from '../session.js'; const FINGERPRINT = `sha256:${'a'.repeat(64)}`; describe('WorkHub Coordination stored records', () => { - test('decodes exact delegation intent and commit records', () => { + test('decodes exact delegation intent, commit, and abandonment records', () => { const intent = { type: 'workhub_coordination', id: 'intent-id', @@ -48,9 +48,17 @@ describe('WorkHub Coordination stored records', () => { targetTurnId: 'target-turn', steered: true, } as const; + const abandoned = { + ...intent, + id: 'abandoned-id', + ts: 3, + kind: 'delegation_abandoned', + reason: 'target_rejected', + } as const; assert.deepEqual(decodeCanonicalMessage(intent), intent); assert.deepEqual(decodeCanonicalMessage(committed), committed); + assert.deepEqual(decodeCanonicalMessage(abandoned), abandoned); }); test('rejects malformed or widened coordination records', () => { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 84564b26c6..2724652180 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -953,9 +953,16 @@ export interface WorkHubDelegationCommittedMessage extends WorkHubCoordinationMe steered?: true; } +/** Durable terminal proof that an action cannot be executed or retried. */ +export interface WorkHubDelegationAbandonedMessage extends WorkHubCoordinationMessageEnvelope { + kind: 'delegation_abandoned'; + reason: 'target_rejected' | 'created_session_retired'; +} + export type WorkHubCoordinationMessage = | WorkHubDelegationIntentMessage - | WorkHubDelegationCommittedMessage; + | WorkHubDelegationCommittedMessage + | WorkHubDelegationAbandonedMessage; export interface TurnRecord { turnId: string; @@ -1116,6 +1123,25 @@ const WORKHUB_DELEGATION_COMMITTED_MESSAGE_SHAPE = ], ['create', 'steered'], ); +const WORKHUB_DELEGATION_ABANDONED_MESSAGE_SHAPE = + defineObjectShape()( + [ + 'type', + 'id', + 'turnId', + 'ts', + 'schemaVersion', + 'kind', + 'actionId', + 'actionFingerprint', + 'coordinationTurnId', + 'targetSessionId', + 'disposition', + 'userText', + 'reason', + ], + ['create'], + ); const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( ['title', 'workspace'], [], @@ -1310,6 +1336,12 @@ function isWorkHubCoordinationMessage(message: Record): boolean if (message.kind === 'delegation_intent') { return hasExactShape(message, WORKHUB_DELEGATION_INTENT_MESSAGE_SHAPE); } + if (message.kind === 'delegation_abandoned') { + return ( + hasExactShape(message, WORKHUB_DELEGATION_ABANDONED_MESSAGE_SHAPE) && + (message.reason === 'target_rejected' || message.reason === 'created_session_retired') + ); + } return ( message.kind === 'delegation_committed' && hasExactShape(message, WORKHUB_DELEGATION_COMMITTED_MESSAGE_SHAPE) && diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 337aea7265..3e3da205bd 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -510,6 +510,25 @@ test('WorkHub creation reports a discard revision for creation and a pristine re assert.equal(mutated.discardRevision, undefined); }); +test('WorkHub creation distinguishes a retired deterministic Session identity', async () => { + const fixture = createFixture({ + stores: { + probeStableSessionCreate: async () => ({ kind: 'conflict', reason: 'removed' }), + }, + }); + + const retired = await fixture.coordinator.createForWorkHub({ + sessionId: fixture.sessionId, + workspace: { kind: 'host_path', path: process.cwd() }, + modelTarget: { kind: 'default' }, + }); + + assert.equal(retired.outcome.ok, false); + assert.equal(retired.retired, true); + assert.equal(retired.discardRevision, undefined); + assert.equal(fixture.drainRequests(), 0); +}); + test('ordinary configuration rejects the WorkHub Coordination Session identity', async () => { let reads = 0; const fixture = createFixture({ 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 d2504ad2e6..d5b87ba6ba 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,7 @@ import { WorkHubCoordinationActionGate, type WorkHubActionGateEffects, type WorkHubActionGateSession, + type WorkHubDelegationAbandoned, type WorkHubDelegationCommit, type WorkHubDelegationIntent, type WorkHubDelegationRecord, @@ -239,11 +240,10 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.creations.length, 1); }); - test('effect rejection grants no root ownership and lets the durable intent retry', async () => { + test('definitive effect rejection durably abandons the action identity', async () => { const effects = fakeEffects([session('ordinary')]); const gate = new WorkHubCoordinationActionGate(effects); const snapshot = await gate.candidates(); - const submit = effects.submit; effects.submit = async () => { throw new WorkHubActionEffectFailure('unauthorized', 'Target permission denied'); }; @@ -261,8 +261,12 @@ describe('WorkHub Coordination Action Gate', () => { gate.act(input, CONTEXT), (error) => error instanceof WorkHubActionEffectFailure && error.code === 'unauthorized', ); - effects.submit = submit; - assert.equal((await gate.act(input, CONTEXT)).disposition, 'delegate_existing'); + assert.equal(effects.delegations.get(input.actionId)?.kind as string, 'delegation_abandoned'); + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.deepEqual(effects.submissions, []); }); test('commits a delegation after recovering an unknown submit outcome', async () => { @@ -433,6 +437,25 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.discardedCreatedSessionIds.length, 1); assert.match(effects.discardedCreatedSessionIds[0] ?? '', /^whs_[a-f0-9]{48}$/u); assert.deepEqual(effects.creations, []); + assert.equal( + effects.delegations.get('rejected-create-action')?.kind as string, + 'delegation_abandoned', + ); + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act( + { + actionId: 'rejected-create-action', + userText: 'Create a login audit', + proposal: { disposition: 'create_new', title: 'Login audit' }, + create: { workspace: { kind: 'project', projectId: 'project-1' } }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.createAttempts, 1); + assert.equal(effects.submissions.length, 0); }); test('unknown create_new submit outcome never retires a possibly admitted Session', async () => { @@ -491,6 +514,36 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(effects.discardAttempts, 2); assert.deepEqual(effects.creations, []); }); + + test('retires an action when an unknown cleanup actually left a Session tombstone', async () => { + const effects = fakeEffects([session('ordinary')]); + const input = { + actionId: 'tombstoned-cleanup-action', + userText: 'Create a login audit', + proposal: { disposition: 'create_new' as const, title: 'Login audit' }, + create: { workspace: { kind: 'project' as const, projectId: 'project-1' } }, + }; + effects.submitFailure = new WorkHubActionEffectFailure( + 'operation_conflict', + 'Target submit was definitively rejected', + ); + effects.discardUnknownAfterRemoval = true; + + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + (error) => + error instanceof WorkHubActionEffectFailure && error.code === 'commit_outcome_unknown', + ); + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + + assert.equal(effects.delegations.get(input.actionId)?.kind, 'delegation_abandoned'); + assert.equal(effects.createAttempts, 2); + assert.equal(effects.discardAttempts, 1); + assert.deepEqual(effects.creations, []); + }); }); function session( @@ -539,7 +592,10 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { recoverSubmissionMiss: false as boolean, discardAttempts: 0, discardFailuresRemaining: 0, + discardUnknownAfterRemoval: false as boolean, discardedCreatedSessionIds: [] as string[], + retiredCreatedSessionIds: new Set(), + createAttempts: 0, async listSessions() { return this.sessions; }, @@ -554,10 +610,14 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { workspace: { kind: 'project'; projectId: string } | { kind: 'host_path'; path: string }; title: string; }) { + this.createAttempts += 1; + if (this.retiredCreatedSessionIds.has(input.sessionId)) { + return { kind: 'retired' as const }; + } const existing = this.creations.find(({ sessionId }) => sessionId === input.sessionId); if (existing) assert.deepEqual(existing, input); else this.creations.push(input); - return { discardRevision: 1 }; + return { kind: 'available' as const, discardRevision: 1 }; }, async submit(input: { sessionId: string; messageId: string; text: string }) { if (this.submitFailure) { @@ -585,6 +645,17 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { async discardCreated(input: { sessionId: string; expectedRevision: number }) { assert.equal(input.expectedRevision, 1); this.discardAttempts += 1; + if (this.discardUnknownAfterRemoval) { + this.discardUnknownAfterRemoval = false; + this.discardedCreatedSessionIds.push(input.sessionId); + this.retiredCreatedSessionIds.add(input.sessionId); + const index = this.creations.findIndex(({ sessionId }) => sessionId === input.sessionId); + if (index >= 0) this.creations.splice(index, 1); + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + 'Created Session retirement outcome is unknown', + ); + } if (this.discardFailuresRemaining > 0) { this.discardFailuresRemaining -= 1; throw new WorkHubActionEffectFailure( @@ -593,6 +664,7 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { ); } this.discardedCreatedSessionIds.push(input.sessionId); + this.retiredCreatedSessionIds.add(input.sessionId); const index = this.creations.findIndex(({ sessionId }) => sessionId === input.sessionId); if (index >= 0) this.creations.splice(index, 1); }, @@ -626,6 +698,11 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { } this.delegations.set(commit.actionId, commit); }, + async abandonDelegation(abandoned: WorkHubDelegationAbandoned) { + const existing = this.delegations.get(abandoned.actionId); + assert.equal(existing?.kind, 'delegation_intent'); + this.delegations.set(abandoned.actionId, abandoned); + }, } satisfies WorkHubActionGateEffects & { sessions: WorkHubActionGateSession[]; answers: Array<{ turnId: string; text: string }>; @@ -643,7 +720,10 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { recoverSubmissionMiss: boolean; discardAttempts: number; discardFailuresRemaining: number; + discardUnknownAfterRemoval: boolean; discardedCreatedSessionIds: string[]; + retiredCreatedSessionIds: Set; + createAttempts: number; }; return state; } diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index 0dec746e48..c609ae9d18 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -495,7 +495,7 @@ describe('Host WorkHub Coordination coordinator', () => { }); const submissions: Array<{ sessionId: string; messageId: string; text: string }> = []; const first = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - create: async () => ({}), + create: async () => ({ kind: 'available' }), discardCreated: async () => undefined, submit: async (input) => { submissions.push(input); @@ -722,7 +722,7 @@ function coordinator( WorkHubActionGateEffects, 'create' | 'discardCreated' | 'submit' | 'recoverSubmission' > = { - create: async () => ({}), + create: async () => ({ kind: 'available' }), discardCreated: async () => undefined, submit: async ({ sessionId }) => ({ turnId: `turn-${sessionId}` }), recoverSubmission: async () => undefined, diff --git a/packages/runtime-host/src/__tests__/workhub-target-submission-recovery.test.ts b/packages/runtime-host/src/__tests__/workhub-target-submission-recovery.test.ts index b0eae8174d..5cfe00820a 100644 --- a/packages/runtime-host/src/__tests__/workhub-target-submission-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-target-submission-recovery.test.ts @@ -19,7 +19,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { messageContentDigest, normalizeMessageContent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { ROOT_TURN_ADMISSION_SCHEMA_VERSION } from '@maka/storage/agent-run-store'; import type { WorkHubSubmissionRecoveryStores } from '../server/workhub-target-submission-recovery.js'; import { recoverWorkHubTargetSubmission } from '../server/workhub-target-submission-recovery.js'; @@ -54,3 +56,53 @@ test('recovers a handed-off steering submission from its immutable proof', async { turnId: 'turn-1', steered: true }, ); }); + +test('recovers only a matching pending steering admission', async () => { + const text = 'Continue payment work'; + const content = normalizeMessageContent({ text }); + const admission = { + sessionId: 'payment', + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-1', + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn' as const, + placement: 'current_turn' as const, + disposition: 'steering' as const, + admittedAt: 1, + }; + const stores = { + readRootTurnSourceMessageReceipt: async () => undefined, + readMessageAdmission: async () => admission, + readRootTurnAdmission: async () => ({ + schemaVersion: ROOT_TURN_ADMISSION_SCHEMA_VERSION, + sessionId: 'payment', + turnId: 'turn-1', + runId: 'run-1', + userMessageId: 'message-1', + execution: { kind: 'external_message' }, + previousRootTurnId: null, + normalizedInput: content, + sourceMessages: [], + admittedAt: 1, + }), + readImmutableSteeringMessageProof: async () => undefined, + } satisfies WorkHubSubmissionRecoveryStores; + + assert.deepEqual( + await recoverWorkHubTargetSubmission(stores, { + sessionId: 'payment', + messageId: 'message-1', + text, + }), + { turnId: 'turn-1', steered: true }, + ); + assert.equal( + await recoverWorkHubTargetSubmission( + { ...stores, readMessageAdmission: async () => ({ ...admission, disposition: 'followup' }) }, + { sessionId: 'payment', messageId: 'message-1', text }, + ), + undefined, + ); +}); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 51b9dfccbf..b260405db6 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,7 +92,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 53 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 54 as const; +// 54: WorkHub stores strict durable delegation intent, commit, and abandonment +// records. Older peers cannot decode these messages during transcript recovery. // 53: Message admission answers `turn.message.submit` with an explicit // disposition, and queued Messages can be proven cancelled. Older peers read the // answer as a bare acknowledgement and cannot reconcile their own projection. diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 3f85a02435..8e7d512b9d 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1241,6 +1241,7 @@ export async function createExecutionRuntimeHostComposition( orchestrationMode: 'default', }); const { outcome } = created; + if (created.retired) return { kind: 'retired' as const }; if (!outcome.ok) { throw new WorkHubActionEffectFailure( outcome.error.code === 'invalid_request' ? 'operation_conflict' : outcome.error.code, @@ -1248,8 +1249,8 @@ export async function createExecutionRuntimeHostComposition( ); } return created.discardRevision === undefined - ? {} - : { discardRevision: created.discardRevision }; + ? { kind: 'available' as const } + : { kind: 'available' as const, discardRevision: created.discardRevision }; }, discardCreated: async (input, connection) => { const outcome = await requireSessionRetirement(sessionRetirement).handlers[ @@ -1261,11 +1262,14 @@ export async function createExecutionRuntimeHostComposition( }, connection, ); - if (!outcome.ok || outcome.result.kind !== 'removed') { - context.requestDrain(); + if (!outcome.ok) { + if (outcome.error.code === 'not_found') return; + throw new WorkHubActionEffectFailure(outcome.error.code, outcome.error.message); + } + if (outcome.result.kind !== 'removed') { throw new WorkHubActionEffectFailure( - 'commit_outcome_unknown', - 'WorkHub empty created Session retirement outcome is unknown', + 'operation_conflict', + 'WorkHub empty created Session changed before retirement', ); } }, diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 55829c34e6..88fea1b3c4 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -207,13 +207,26 @@ export class HostSessionCatalogCoordinator { async createForWorkHub(input: SessionCreateInput): Promise<{ readonly outcome: OperationOutcome<'session.create'>; readonly discardRevision?: number; + readonly retired?: true; }> { let discardRevision: number | undefined; + let retired: true | undefined; const rememberDiscardRevision = (revision: number) => { discardRevision = revision; }; - const outcome = await this.#create(input, rememberDiscardRevision, rememberDiscardRevision); - return { outcome, ...(discardRevision === undefined ? {} : { discardRevision }) }; + const outcome = await this.#create( + input, + rememberDiscardRevision, + rememberDiscardRevision, + () => { + retired = true; + }, + ); + return { + outcome, + ...(discardRevision === undefined ? {} : { discardRevision }), + ...(retired ? { retired } : {}), + }; } async #query( @@ -352,6 +365,7 @@ export class HostSessionCatalogCoordinator { input: SessionCreateInput, onCreated?: (revision: number) => void, onPristineReplay?: (revision: number) => void, + onRetiredReplay?: () => void, ): Promise> { if (isWorkHubCoordinationSessionId(input.sessionId)) { return createFailure( @@ -383,6 +397,7 @@ export class HostSessionCatalogCoordinator { ); } if (probe.kind === 'conflict') { + if (probe.reason === 'removed') onRetiredReplay?.(); return createFailure( 'operation_conflict', 'Session identity belongs to a different create request', @@ -415,6 +430,7 @@ export class HostSessionCatalogCoordinator { input: createInput, }); if (result.kind === 'conflict') { + if (result.reason === 'removed') onRetiredReplay?.(); return createFailure( 'operation_conflict', 'Session identity belongs to a different create request', 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 6d68987f21..8f8446e53d 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -21,6 +21,7 @@ import { createHash } from 'node:crypto'; import type { SessionHeader, SessionStatus, + WorkHubDelegationAbandonedMessage, WorkHubDelegationCommittedMessage, WorkHubDelegationIntentMessage, } from '@maka/core/session'; @@ -73,7 +74,9 @@ export interface WorkHubActionGateEffects { readonly sessionId: string; readonly workspace: WorkspaceTarget; readonly title: string; - }): Promise<{ readonly discardRevision?: number }>; + }): Promise< + { readonly kind: 'available'; readonly discardRevision?: number } | { readonly kind: 'retired' } + >; discardCreated( input: { readonly sessionId: string; @@ -97,6 +100,7 @@ export interface WorkHubActionGateEffects { readDelegation(actionId: string): Promise; prepareDelegation(intent: WorkHubDelegationIntent): Promise; commitDelegation(commit: WorkHubDelegationCommit): Promise; + abandonDelegation(abandoned: WorkHubDelegationAbandoned): Promise; } type StoredDelegationEnvelopeKeys = 'type' | 'id' | 'turnId' | 'ts' | 'schemaVersion'; @@ -111,7 +115,15 @@ export type WorkHubDelegationCommit = Omit< StoredDelegationEnvelopeKeys >; -export type WorkHubDelegationRecord = WorkHubDelegationIntent | WorkHubDelegationCommit; +export type WorkHubDelegationAbandoned = Omit< + WorkHubDelegationAbandonedMessage, + StoredDelegationEnvelopeKeys +>; + +export type WorkHubDelegationRecord = + | WorkHubDelegationIntent + | WorkHubDelegationCommit + | WorkHubDelegationAbandoned; export type WorkHubActionEffectFailureCode = | 'host_not_ready' @@ -181,6 +193,16 @@ export class WorkHubCoordinationActionGate { input: WorkHubCoordinationActInput, context: ConnectionContext, ): Promise { + if (!input.userText.trim()) { + return Promise.reject( + new WorkHubActionGateFailure('action_conflict', 'WorkHub action text is empty'), + ); + } + if (input.proposal.disposition === 'create_new' && !input.proposal.title.trim()) { + return Promise.reject( + new WorkHubActionGateFailure('action_conflict', 'WorkHub creation title is empty'), + ); + } const fingerprint = actionFingerprint(input); const replay = this.#actions.get(input.actionId); if (replay) { @@ -227,6 +249,9 @@ export class WorkHubCoordinationActionGate { if (durable.kind === 'delegation_committed') { return committedResult(durable); } + if (durable.kind === 'delegation_abandoned') { + throw abandonedAction(); + } return this.#executeDelegation(durable, context); } if (proposal.disposition === 'answer_here') { @@ -296,6 +321,10 @@ export class WorkHubCoordinationActionGate { workspace: intent.create.workspace, title: intent.create.title, }); + if (created.kind === 'retired') { + await this.#abandonDelegation(intent, 'created_session_retired'); + throw abandonedAction(); + } discardRevision = created.discardRevision; } else if (intent.create) { throw new WorkHubActionGateFailure( @@ -315,18 +344,32 @@ export class WorkHubCoordinationActionGate { submitted = await this.#effects.submit(message, context); } catch (error) { if (!(error instanceof WorkHubActionEffectFailure)) throw error; - if (error.code !== 'commit_outcome_unknown') { + if (isDefinitiveSubmissionFailure(error.code)) { if (discardRevision !== undefined) { - await this.#effects.discardCreated( - { - sessionId: intent.targetSessionId, - expectedRevision: discardRevision, - }, - context, - ); + try { + await this.#effects.discardCreated( + { + sessionId: intent.targetSessionId, + expectedRevision: discardRevision, + }, + context, + ); + } catch (cleanupError) { + if ( + !(cleanupError instanceof WorkHubActionEffectFailure) || + cleanupError.code === 'commit_outcome_unknown' + ) { + throw cleanupError; + } + // The target effect was definitively rejected. Cleanup may be + // unnecessary or conflict with later user changes, but that + // must not leave the action executable again. + } } + await this.#abandonDelegation(intent, 'target_rejected'); throw error; } + if (error.code !== 'commit_outcome_unknown') throw error; const recovered = await this.#effects.recoverSubmission(message); if (!recovered) throw error; submitted = recovered; @@ -343,6 +386,17 @@ export class WorkHubCoordinationActionGate { return committedResult(commit); } + async #abandonDelegation( + intent: WorkHubDelegationIntent, + reason: WorkHubDelegationAbandoned['reason'], + ): Promise { + await this.#effects.abandonDelegation({ + ...intent, + kind: 'delegation_abandoned', + reason, + }); + } + #assertTarget(target: WorkHubCoordinationCandidate): void { if (target.sessionId === WORKHUB_COORDINATION_SESSION_ID) { throw new WorkHubActionGateFailure('self_route', 'WorkHub cannot delegate to itself'); @@ -364,6 +418,19 @@ export class WorkHubCoordinationActionGate { } } +function abandonedAction(): WorkHubActionGateFailure { + return new WorkHubActionGateFailure('action_conflict', 'WorkHub action is permanently abandoned'); +} + +function isDefinitiveSubmissionFailure(code: WorkHubActionEffectFailureCode): boolean { + return ( + code === 'not_found' || + code === 'session_archived' || + code === 'operation_conflict' || + code === 'unauthorized' + ); +} + export function candidateSet( sessions: readonly WorkHubActionGateSession[], ): WorkHubCoordinationCandidatesResult { diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 3a7daeaad1..72886effa9 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -168,6 +168,7 @@ export class HostWorkHubCoordinationCoordinator { readDelegation: (actionId) => this.#delegations.read(actionId), prepareDelegation: (intent) => this.#delegations.prepare(intent), commitDelegation: (commit) => this.#delegations.commit(commit), + abandonDelegation: (abandoned) => this.#delegations.abandon(abandoned), }); } diff --git a/packages/runtime-host/src/server/workhub-delegation-journal.ts b/packages/runtime-host/src/server/workhub-delegation-journal.ts index 4b982754d8..7db3985648 100644 --- a/packages/runtime-host/src/server/workhub-delegation-journal.ts +++ b/packages/runtime-host/src/server/workhub-delegation-journal.ts @@ -24,6 +24,7 @@ import { WORKHUB_COORDINATION_SESSION_ID, isWorkHubCoordinationSession, type StoredMessage, + type WorkHubDelegationAbandonedMessage, type WorkHubDelegationCommittedMessage, type WorkHubDelegationIntentMessage, } from '@maka/core/session'; @@ -31,13 +32,14 @@ import type { SessionAuthorityStore } from '@maka/storage/session-store'; import type { SessionContinuityCoordinator } from './session-continuity-coordinator.js'; import { WorkHubActionEffectFailure, + type WorkHubDelegationAbandoned, type WorkHubDelegationCommit, type WorkHubDelegationIntent, type WorkHubDelegationRecord, } from './workhub-coordination-action-gate.js'; import type { SessionAdmissionGate } from './session-admission-gate.js'; -const RECORD_KINDS = ['delegation_intent', 'delegation_committed'] as const; +const RECORD_KINDS = ['delegation_intent', 'delegation_committed', 'delegation_abandoned'] as const; // Two records may each repeat the bounded 48 KiB request plus create context; // JSON escaping can expand one input byte to six encoded bytes. const RECORD_READ_MAX_BYTES = 768 * 1024; @@ -118,6 +120,7 @@ export class WorkHubDelegationJournal { if (!sameCommit(existing, commit)) throw actionConflict(); return; } + if (existing?.kind === 'delegation_abandoned') throw actionConflict(); if (!existing || !sameIntent(existing, commit)) throw actionConflict(); try { await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [ @@ -135,6 +138,36 @@ export class WorkHubDelegationJournal { }); } + abandon(abandoned: WorkHubDelegationAbandoned): Promise { + return this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, async (lease) => { + await this.#assertCoordinationSession(); + const existing = this.#projectRecord( + abandoned.actionId, + await this.#readMessages(abandoned.actionId), + ); + if (existing?.kind === 'delegation_abandoned') { + if (!sameAbandoned(existing, abandoned)) throw actionConflict(); + return; + } + if (!existing || existing.kind !== 'delegation_intent' || !sameIntent(existing, abandoned)) { + throw actionConflict(); + } + try { + await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [ + abandonedMessage(abandoned), + ]); + await this.#continuity.refreshCanonical(WORKHUB_COORDINATION_SESSION_ID, lease); + } catch (error) { + if (error instanceof WorkHubActionEffectFailure) throw error; + this.#requestDrain(); + throw new WorkHubActionEffectFailure( + 'commit_outcome_unknown', + 'WorkHub delegation abandonment outcome is unknown', + ); + } + }); + } + async #assertCoordinationSession(): Promise { try { const header = await this.#stores.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID); @@ -177,12 +210,7 @@ export class WorkHubDelegationJournal { actionId: string, messages: readonly StoredMessage[], ): WorkHubDelegationRecord | undefined { - try { - return projectRecord(actionId, messages); - } catch (error) { - this.#requestDrain(); - throw error; - } + return projectRecord(actionId, messages); } } @@ -199,17 +227,32 @@ function projectRecord( (message): message is WorkHubDelegationCommittedMessage => message.type === 'workhub_coordination' && message.kind === 'delegation_committed', ); + const abandoned = messages.find( + (message): message is WorkHubDelegationAbandonedMessage => + message.type === 'workhub_coordination' && message.kind === 'delegation_abandoned', + ); if ( - messages.length !== Number(intent !== undefined) + Number(committed !== undefined) || + messages.length !== + Number(intent !== undefined) + + Number(committed !== undefined) + + Number(abandoned !== undefined) || intent?.actionId !== actionId || - (committed !== undefined && (!intent || !sameMessageIntent(intent, committed))) + (committed !== undefined && (!intent || !sameMessageIntent(intent, committed))) || + (abandoned !== undefined && (!intent || !sameMessageIntent(intent, abandoned))) || + (committed !== undefined && abandoned !== undefined) ) { throw new WorkHubActionEffectFailure( 'persistence_failed', 'WorkHub delegation record chain is invalid', ); } - return committed ? commitRecord(committed) : intent ? intentRecord(intent) : undefined; + return committed + ? commitRecord(committed) + : abandoned + ? abandonedRecord(abandoned) + : intent + ? intentRecord(intent) + : undefined; } function intentMessage(intent: WorkHubDelegationIntent): WorkHubDelegationIntentMessage { @@ -234,6 +277,19 @@ function committedMessage(commit: WorkHubDelegationCommit): WorkHubDelegationCom }; } +function abandonedMessage( + abandoned: WorkHubDelegationAbandoned, +): WorkHubDelegationAbandonedMessage { + return { + type: 'workhub_coordination', + id: recordMessageId(abandoned.actionId, 'delegation_abandoned'), + turnId: abandoned.coordinationTurnId, + ts: Date.now(), + schemaVersion: WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION, + ...abandoned, + }; +} + function intentRecord(message: WorkHubDelegationIntentMessage): WorkHubDelegationIntent { return { kind: message.kind, @@ -263,11 +319,28 @@ function commitRecord(message: WorkHubDelegationCommittedMessage): WorkHubDelega }; } +function abandonedRecord(message: WorkHubDelegationAbandonedMessage): WorkHubDelegationAbandoned { + return { + kind: 'delegation_abandoned', + actionId: message.actionId, + actionFingerprint: message.actionFingerprint, + coordinationTurnId: message.coordinationTurnId, + targetSessionId: message.targetSessionId, + disposition: message.disposition, + userText: message.userText, + ...(message.create ? { create: message.create } : {}), + reason: message.reason, + }; +} + function sameMessageIntent( intent: WorkHubDelegationIntentMessage, - committed: WorkHubDelegationCommittedMessage, + terminal: WorkHubDelegationCommittedMessage | WorkHubDelegationAbandonedMessage, ): boolean { - return sameIntent(intentRecord(intent), commitRecord(committed)); + return sameIntent( + intentRecord(intent), + terminal.kind === 'delegation_committed' ? commitRecord(terminal) : abandonedRecord(terminal), + ); } function sameIntent( @@ -294,6 +367,13 @@ function sameCommit(left: WorkHubDelegationCommit, right: WorkHubDelegationCommi ); } +function sameAbandoned( + left: WorkHubDelegationAbandoned, + right: WorkHubDelegationAbandoned, +): boolean { + return left.reason === right.reason && sameIntent(left, right); +} + function recordMessageId(actionId: string, kind: (typeof RECORD_KINDS)[number]): string { return `whj_${createHash('sha256') .update(`${actionId}\0${kind}`, 'utf8') diff --git a/packages/runtime-host/src/server/workhub-target-submission-recovery.ts b/packages/runtime-host/src/server/workhub-target-submission-recovery.ts index 1f5ec24f09..14fe692897 100644 --- a/packages/runtime-host/src/server/workhub-target-submission-recovery.ts +++ b/packages/runtime-host/src/server/workhub-target-submission-recovery.ts @@ -27,15 +27,9 @@ import type { RootTurnAdmission, RootTurnSourceMessageReceipt, } from '@maka/storage/agent-run-store'; +import type { PendingMessageAdmission } from '@maka/storage/execution-stores'; import { WorkHubActionEffectFailure } from './workhub-coordination-action-gate.js'; -interface WorkHubPendingMessageAdmission { - readonly turnId: string; - readonly runId: string; - readonly submittedPlacement: 'current_turn' | 'next_turn'; - readonly submittedContentDigest: `sha256:${string}`; -} - export interface WorkHubSubmissionRecoveryStores { readRootTurnSourceMessageReceipt( sessionId: string, @@ -44,7 +38,7 @@ export interface WorkHubSubmissionRecoveryStores { readMessageAdmission( sessionId: string, messageId: string, - ): Promise; + ): Promise; readRootTurnAdmission(sessionId: string, turnId: string): Promise; readImmutableSteeringMessageProof( sessionId: string, @@ -100,7 +94,8 @@ export async function recoverWorkHubTargetSubmission( expectedDigest, ); const root = await stores.readRootTurnAdmission(input.sessionId, admission.turnId); - if (!root || root.runId !== admission.runId) return undefined; + if (admission.disposition !== 'steering' || !root || root.runId !== admission.runId) + return undefined; return { turnId: admission.turnId, steered: true }; }