From 8b8a97d0e5669269f467002f331b67e864be9480 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 26 Aug 2026 22:13:52 +0800 Subject: [PATCH 1/8] 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 403e7af4c8..fbd17671d3 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, @@ -1265,9 +1265,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 ee265f696ccae180711cc1d11e45854c7d8f2427 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 26 Aug 2026 23:12:14 +0800 Subject: [PATCH 2/8] 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 11cb4c028e..db62ec6e31 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 c8d2ac0c0566a9dbc22fd179edfc7bd9a798d3f2 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Thu, 27 Aug 2026 00:29:12 +0800 Subject: [PATCH 3/8] 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 6ed05e7962..ff438b0540 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -2515,6 +2515,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 2c61228a42..458df497d9 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2810,6 +2810,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 db62ec6e31..ae52e9b8ee 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 fbd17671d3..d4e53355a1 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), @@ -1231,7 +1233,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, @@ -1239,12 +1241,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']( @@ -1285,49 +1309,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 () => { @@ -1400,7 +1394,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, @@ -1783,6 +1777,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 e055122ceba60c92fd08ff01f50758c7627aeb35 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Thu, 27 Aug 2026 10:48:52 +0800 Subject: [PATCH 4/8] 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 938df54eb7..148c0d6dff 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -19,6 +19,14 @@ import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } 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, }) => { @@ -66,6 +74,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 e5550f015e..953dad8b94 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3317,15 +3317,23 @@ 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 }>(); let nextSessionObservationError: Error | undefined; + 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 = ( @@ -3370,6 +3378,10 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { disposed = true; }; }; + 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 { @@ -3402,6 +3414,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 ae52e9b8ee..5ca9f2c33d 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 d4e53355a1..42eb467364 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1248,9 +1248,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 834f877c7af6829edf12d40252b405a31693d230 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Thu, 27 Aug 2026 14:35:06 +0800 Subject: [PATCH 5/8] 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 | 5 +- .../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(+), 167 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 50e4ec5afd..63cade7b76 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 877c9b65a5..c92e2d20ea 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'; @@ -858,7 +859,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 953dad8b94..ed1e4e3a8d 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'; @@ -1642,7 +1643,7 @@ const makaBridge = { async act( coordinationSessionId: string, input: Omit, 'create'>, - ): Promise> { + ): Promise> { const scope = await resolveDesktopWorkHubCoordinationCreateScope( coordinationSessionId, runtimeHostSessionRef, @@ -1651,14 +1652,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 5ca9f2c33d..173474919b 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 a02f619545..80475c2ff9 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -110,8 +110,9 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 59 as const; // 55: Local owners can atomically revoke every credential for one access // principal, closing pairing-finalize races that credential-by-ID revocation cannot. // 54: Client-bound pairing candidates restrict pre-claim authority and bind -// their durable credential to the claiming Client identity; it is also reserved -// by concurrent protocol changes in #3390 and #3935. +// their durable credential to the claiming Client identity. WorkHub also stores +// strict durable delegation intent, commit, and abandonment records that older +// peers cannot decode 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 42eb467364..dc5fe3f9da 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1242,6 +1242,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, @@ -1249,8 +1250,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[ @@ -1262,11 +1263,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 }; } From be005243ac00c5805886dcb91822e30ae22d7d7b Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Fri, 28 Aug 2026 15:49:44 +0800 Subject: [PATCH 6/8] refactor(workhub): make delegation assignment atomic Replace the delegation saga with one canonical assignment record committed atomically with target message admission and optional Session creation. Reuse normal pending-message recovery for post-commit consumption and keep renderer retry identity separate from draft storage. Generated-by: Codex --- .../e2e/workhub-reconstruction.spec.ts | 68 -- .../desktop-session-projection.test.ts | 6 +- .../__tests__/workhub-session-port.test.ts | 28 + .../__tests__/workhub-surface-flow.test.ts | 678 ++++++------------ apps/desktop/src/preload/preload.ts | 21 +- .../src/renderer/workhub-controller.ts | 4 + .../src/renderer/workhub-coordination-port.ts | 14 + .../src/renderer/workhub-send-lease.ts | 202 ++---- apps/desktop/src/renderer/workhub-surface.tsx | 33 +- .../workhub-coordination-session-adr.md | 85 +-- .../workhub-coordination-record.test.ts | 50 +- packages/core/src/session.ts | 86 +-- packages/core/src/thread-search.ts | 4 +- .../src/__tests__/message-coordinator.test.ts | 40 +- .../session-catalog-coordinator.test.ts | 51 -- .../workhub-coordination-action-gate.test.ts | 584 +++------------ .../workhub-coordination-coordinator.test.ts | 86 ++- ...workhub-target-submission-recovery.test.ts | 108 --- packages/runtime-host/src/protocol/index.ts | 9 +- .../src/server/execution-composition.ts | 231 +++--- .../src/server/message-coordinator.ts | 33 +- .../src/server/session-catalog-coordinator.ts | 77 +- .../workhub-coordination-action-gate.ts | 261 ++----- .../workhub-coordination-coordinator.ts | 24 +- .../src/server/workhub-delegation-journal.ts | 389 ---------- .../workhub-target-submission-recovery.ts | 121 ---- .../workhub-message-assignment.test.ts | 198 +++++ packages/storage/src/execution-stores.ts | 2 + .../storage/src/session-message-projection.ts | 12 +- packages/storage/src/session-store.ts | 78 ++ .../src/sqlite-session-metadata-schema.ts | 11 +- .../src/sqlite-session-metadata-store.ts | 315 ++++++-- 32 files changed, 1474 insertions(+), 2435 deletions(-) delete mode 100644 packages/runtime-host/src/__tests__/workhub-target-submission-recovery.test.ts delete mode 100644 packages/runtime-host/src/server/workhub-delegation-journal.ts delete mode 100644 packages/runtime-host/src/server/workhub-target-submission-recovery.ts create mode 100644 packages/storage/src/__tests__/workhub-message-assignment.test.ts diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index 148c0d6dff..938df54eb7 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -19,14 +19,6 @@ import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } 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, }) => { @@ -74,66 +66,6 @@ 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__/desktop-session-projection.test.ts b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts index f04847c419..0a0f5e3a70 100644 --- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts @@ -205,11 +205,11 @@ test('projects durable WorkHub delegation targets into the Desktop host namespac { hostId: 'remote-root' }, { type: 'workhub_coordination', - id: 'delegation-commit-message', + id: 'delegation-assignment-message', turnId: 'coordination-turn', ts: 2, schemaVersion: 1, - kind: 'delegation_committed', + kind: 'delegation_assigned', actionId: 'action-id', actionFingerprint: `sha256:${'a'.repeat(64)}`, coordinationTurnId: 'coordination-turn', @@ -218,6 +218,8 @@ test('projects durable WorkHub delegation targets into the Desktop host namespac userText: 'Continue payment work', delegationId: 'delegation-id', targetTurnId: 'payments-turn', + targetMessageId: 'payments-message', + targetSessionName: 'Payments', }, ); 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 63cade7b76..8f3eb7d0c2 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -117,6 +117,24 @@ test('projects the durable Coordination transcript into the WorkHub conversation status: 'completed', partialOutputRetained: true, }, + { + type: 'workhub_coordination', + id: 'assignment-1', + turnId: 'action-1', + ts: 20, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId: 'action-1', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + coordinationTurnId: 'action-1', + targetSessionId: 'payments', + targetSessionName: 'Payments', + targetTurnId: 'payments-turn', + targetMessageId: 'payments-message', + delegationId: 'payments-delegation', + disposition: 'delegate_existing', + userText: 'Continue payments', + }, ]), [{ messageId: 'user-1', turnId: 'turn-1', @@ -124,6 +142,16 @@ test('projects the durable Coordination transcript into the WorkHub conversation result: 'Slice 3 is next.', state: 'completed', updatedAt: 11, + }, { + messageId: 'assignment-1', + turnId: 'action-1', + text: 'Continue payments', + state: 'completed', + assignment: { + targetSessionId: 'payments', + targetSessionName: 'Payments', + }, + updatedAt: 20, }]); }); 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 105545caf4..b6f1eb03ec 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -39,392 +39,13 @@ import { type WorkHubController, type WorkHubSubmitInput, } from '../../renderer/workhub-controller.js'; +import { WorkHubSendLease } from '../../renderer/workhub-send-lease.js'; import { createDesktopWorkHubSessionPort, 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(); - 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({ - scope: 'host-a', - storage, - createId: () => ids.shift()!, - }); - - assert.equal(first.acquire('Continue payment work'), 'action-1'); - - 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('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 = { - 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('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 = { - 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 = { - 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({ 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({ scope: 'host-a', storage, createId: () => '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( - workHubSurfaceFailure( - new WorkHubCoordinationFailure( - 'operation_conflict', - 'WorkHub action is permanently abandoned', - ), - ), - 'action_changed', - ); assert.equal( workHubSurfaceFailure( new Error('WorkHub Session candidates changed; refresh before delegating'), @@ -463,18 +84,19 @@ test('surface route gate rejects same-frame duplicate operations and reopens aft }); test('Coordination lifecycle keeps a visible loading state and exposes failure recovery', () => { - const renderStatus = (state: 'resolving' | 'failed') => renderToStaticMarkup( - createElement(LocaleProvider, { - locale: 'en', - children: createElement(AstryxLocaleProvider, { - children: createElement(WorkHubCoordinationStatus, { - locale: 'en', - state, - onRetry: () => undefined, + const renderStatus = (state: 'resolving' | 'failed') => + renderToStaticMarkup( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(WorkHubCoordinationStatus, { + locale: 'en', + state, + onRetry: () => undefined, + }), }), }), - }), - ); + ); const resolving = renderStatus('resolving'); const failed = renderStatus('failed'); @@ -498,41 +120,51 @@ test('surface projection refresh gate rejects older reads after a newer refresh test('surface keeps the Composer draft when routing fails or the target is waiting', () => { assert.equal(workHubSubmissionClearsDraft(undefined), false); - assert.equal(workHubSubmissionClearsDraft({ - kind: 'waiting', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'waiting', - text: '继续处理', - target: { sessionId: 'payment' }, - }), false); - assert.equal(workHubSubmissionClearsDraft({ - kind: 'discussion', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'discussion', - text: '先讨论方向', - }), true); + assert.equal( + workHubSubmissionClearsDraft({ + kind: 'waiting', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'waiting', + text: '继续处理', + target: { sessionId: 'payment' }, + }), + false, + ); + assert.equal( + workHubSubmissionClearsDraft({ + kind: 'discussion', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'discussion', + text: '先讨论方向', + }), + true, + ); }); test('surface replaces a local discussion placeholder with its durable model answer', () => { - const local = [{ - requestId: 'discussion-turn', - text: 'What is next?', - state: 'settled' as const, - outcome: { - kind: 'discussion' as const, - strategyId: WORKHUB_ROUTING_STRATEGY_ID, + const local = [ + { requestId: 'discussion-turn', text: 'What is next?', + state: 'settled' as const, + outcome: { + kind: 'discussion' as const, + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'discussion-turn', + text: 'What is next?', + }, + }, + ]; + const durable = [ + { + messageId: 'user-message', + turnId: 'discussion-turn', + text: 'What is next?', + result: 'Slice 3 is next.', + state: 'completed' as const, + updatedAt: 10, }, - }]; - const durable = [{ - messageId: 'user-message', - turnId: 'discussion-turn', - text: 'What is next?', - result: 'Slice 3 is next.', - state: 'completed' as const, - updatedAt: 10, - }]; + ]; assert.deepEqual(visibleWorkHubConversation(durable, local), { coordination: durable, @@ -559,11 +191,13 @@ test('surface keeps clarification and successful routing in WorkHub', async () = strategyId: WORKHUB_ROUTING_STRATEGY_ID, requestId: input.requestId, text: input.text, - options: [{ - target: { sessionId: 'payment' }, - projectName: 'billing', - sessionName: '支付回调幂等性', - }], + options: [ + { + target: { sessionId: 'payment' }, + projectName: 'billing', + sessionName: '支付回调幂等性', + }, + ], }; } return { @@ -623,20 +257,24 @@ test('surface leaves discussion in WorkHub instead of creating a task view', asy test('real Session projection creates new guide topics and preserves origin ambiguity', async () => { let clock = 10; - const sessions: WorkHubDesktopSession[] = [{ - id: 'login', - name: '刷新令牌过期致重复登录的排查计划', - labels: [], - isArchived: false, - status: 'active', - projectId: 'project-router', - lastMessageAt: clock, - lastMessagePreview: '已经整理为检查清单', - }]; - const prompts = new Map([[ - 'login', - ['排查登录刷新令牌过期导致重复登录的问题,先只分析并列出计划,不修改文件。'], - ]]); + const sessions: WorkHubDesktopSession[] = [ + { + id: 'login', + name: '刷新令牌过期致重复登录的排查计划', + labels: [], + isArchived: false, + status: 'active', + projectId: 'project-router', + lastMessageAt: clock, + lastMessagePreview: '已经整理为检查清单', + }, + ]; + const prompts = new Map([ + [ + 'login', + ['排查登录刷新令牌过期导致重复登录的问题,先只分析并列出计划,不修改文件。'], + ], + ]); const created: string[] = []; const port = createDesktopWorkHubSessionPort({ transcripts: { @@ -646,8 +284,8 @@ test('real Session projection creates new guide topics and preserves origin ambi }, sessions: { list: async () => sessions, - listTurns: async (sessionId) => (prompts.get(sessionId) ?? []) - .map((userPromptPreview) => ({ userPromptPreview })), + listTurns: async (sessionId) => + (prompts.get(sessionId) ?? []).map((userPromptPreview) => ({ userPromptPreview })), create: async ({ name }) => { const id = name.includes('支付回调') ? 'payment' : 'layout'; const session: WorkHubDesktopSession = { @@ -676,9 +314,8 @@ test('real Session projection creates new guide topics and preserves origin ambi stop: async () => {}, subscribeChanges: () => () => {}, }, - projectName: (projectId) => projectId === 'project-router' - ? 'maka-workhub-session-router' - : 'maka-agent', + projectName: (projectId) => + projectId === 'project-router' ? 'maka-workhub-session-router' : 'maka-agent', newTurnId: () => `turn-${clock + 1}`, }); const controller = createWorkHubController({ sessions: port }); @@ -704,7 +341,156 @@ test('real Session projection creates new guide topics and preserves origin ambi assert.equal(layout.kind === 'submitted' ? layout.evidence : undefined, 'new_session'); assert.deepEqual(created, ['payment', 'layout']); assert.equal(ambiguous.kind, 'clarification'); - assert.deepEqual(ambiguous.kind === 'clarification' - ? ambiguous.options.map((option) => option.target.sessionId) - : [], ['login', 'payment']); + assert.deepEqual( + ambiguous.kind === 'clarification' + ? ambiguous.options.map((option) => option.target.sessionId) + : [], + ['login', 'payment'], + ); }); + +test('action identity survives reload while edited text remains current', () => { + const { storage } = memoryStorage(); + const first = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => 'action-1', + }); + assert.deepEqual(first.acquireAttempt('Continue payments'), { + requestId: 'action-1', + text: 'Continue payments', + retrying: false, + }); + + const restarted = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => 'action-2', + }); + assert.deepEqual(restarted.acquireAttempt('Investigate login instead'), { + requestId: 'action-1', + text: 'Investigate login instead', + retrying: true, + }); +}); + +test('draft and action identity have independent Host-scoped lifecycles', () => { + const { storage } = memoryStorage(); + const hostA = new WorkHubSendLease({ scope: 'host-a', storage, createId: () => 'action-a' }); + const hostB = new WorkHubSendLease({ scope: 'host-b', storage, createId: () => 'action-b' }); + hostA.write('workhub', 'A draft'); + hostB.write('workhub', 'B draft'); + assert.equal(hostA.acquire('A send'), 'action-a'); + assert.equal(hostB.acquire('B send'), 'action-b'); + hostA.complete('action-a'); + assert.equal(hostA.read('workhub'), 'A draft'); + assert.equal(hostB.read('workhub'), 'B draft'); + assert.equal(hostB.acquire('B retry'), 'action-b'); +}); + +test('successful delegated submission needs no renderer summary write', async () => { + let records = 0; + const controller = fakeController({ + submit: async (input) => ({ + kind: 'submitted', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target: { sessionId: 'payments' }, + turnId: 'payments-turn', + evidence: 'explicit_target', + }), + record: async ({ turnId }) => { + records += 1; + return { turnId }; + }, + }); + const result = await submitAndRecordWorkHubSurfaceInput({ + controller, + request: { requestId: 'action-1', text: 'Continue payments' }, + recordedUserText: 'Continue payments', + summary: () => 'Sent to Payments', + onSummaryError: () => assert.fail('no summary write is expected'), + }); + assert.equal(result.kind, 'submitted'); + assert.equal(records, 0); +}); + +test('lease retires only after an acknowledged submission', async () => { + const { storage } = memoryStorage(); + let sends = 0; + const lease = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => `action-${sends + 1}`, + }); + lease.write('workhub', 'Continue payments'); + const cleared = await submitLeasedWorkHubSurfaceInput({ + lease, + text: 'Continue payments', + submit: async (attempt) => { + sends += 1; + return { + kind: 'submitted', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: attempt.requestId, + target: { sessionId: 'payments' }, + turnId: 'payments-turn', + evidence: 'explicit_target', + }; + }, + }); + assert.equal(cleared, true); + assert.equal(lease.acquire('Next work'), 'action-2'); +}); + +test('clarification choice retires its action without clearing the Composer draft', async () => { + const { storage } = memoryStorage(); + const lease = new WorkHubSendLease({ + scope: 'host-a', + storage, + createId: () => 'action-choice', + }); + lease.write('workhub', 'Unrelated draft'); + + const clearsComposer = await submitLeasedWorkHubSurfaceInput({ + lease, + text: 'Unrelated draft', + preserveDraft: true, + submit: async (attempt) => ({ + kind: 'submitted', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: attempt.requestId, + target: { sessionId: 'payments' }, + turnId: 'payments-turn', + evidence: 'explicit_target', + }), + }); + + assert.equal(clearsComposer, false); + assert.equal(lease.read('workhub'), 'Unrelated draft'); +}); + +function memoryStorage() { + const values = new Map(); + return { + storage: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, + }; +} + +function fakeController(input: { + submit: WorkHubController['submit']; + record: WorkHubController['recordConversationTurn']; +}): WorkHubController { + return { + read: async () => ({ sessions: [], turns: [] }), + submit: input.submit, + openConversation: async () => ({ close: async () => undefined }), + recordConversationTurn: input.record, + subscribe: () => () => undefined, + resetVisitContext: () => undefined, + }; +} diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index ed1e4e3a8d..f1a8ce589b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3325,23 +3325,15 @@ 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' - | 'workHub.record'; + type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list' | 'settings.chunk'; const gates = new Map; oneShot: boolean }>(); const releases = new Map void; reject: (error: Error) => void }>(); let nextSessionObservationError: Error | undefined; - 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 = ( @@ -3386,10 +3378,6 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { disposed = true; }; }; - 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 { @@ -3422,13 +3410,6 @@ 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-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index bc0a3f0336..a024b2df29 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -78,6 +78,10 @@ export interface WorkHubCoordinationTurn { text: string; state: WorkHubProjectedTurnState; result?: string; + assignment?: { + readonly targetSessionId: string; + readonly targetSessionName: string; + }; updatedAt: number; } diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 2ef6710550..05e4a1c421 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -121,6 +121,20 @@ export function projectWorkHubCoordinationTurns( const latestUserIndexByTurnId = new Map(); for (const message of messages) { + if (message.type === 'workhub_coordination' && message.kind === 'delegation_assigned') { + turns.push({ + messageId: message.id, + turnId: message.coordinationTurnId, + text: boundedWorkHubTimelineText(message.userText), + state: 'completed', + assignment: { + targetSessionId: message.targetSessionId, + targetSessionName: message.targetSessionName, + }, + updatedAt: message.ts, + }); + continue; + } if (message.type === 'user') { const text = boundedWorkHubTimelineText(userFacingText(message)); if (!text) continue; diff --git a/apps/desktop/src/renderer/workhub-send-lease.ts b/apps/desktop/src/renderer/workhub-send-lease.ts index 8f50d21aec..f8ce076624 100644 --- a/apps/desktop/src/renderer/workhub-send-lease.ts +++ b/apps/desktop/src/renderer/workhub-send-lease.ts @@ -17,26 +17,15 @@ * under the License. */ -const WORKHUB_SEND_LEASE_KEY = 'maka-workhub-send-lease-v2'; +const WORKHUB_ACTION_KEY = 'maka-workhub-action-v1'; +const WORKHUB_DRAFT_STORAGE_KEY = 'maka-workhub-draft-v1'; const WORKHUB_DRAFT_KEY = 'workhub'; const MAX_DRAFT_CHARS = 120_000; -const MAX_SUMMARY_CHARS = 4_000; const MAX_SCOPE_CHARS = 1_024; const SAFE_REQUEST_ID = /^[A-Za-z0-9_-]{1,128}$/u; type WorkHubSendLeaseStorage = Pick; -interface WorkHubSendLeaseState { - readonly version: 2; - readonly draft: string; - readonly action?: { - readonly requestId: string; - readonly text: string; - readonly state: 'active' | 'settled'; - readonly summary?: string; - }; -} - export interface WorkHubSendLeaseOptions { readonly scope: string; readonly storage?: WorkHubSendLeaseStorage; @@ -50,83 +39,53 @@ export interface WorkHubSendAttempt { } /** - * 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. + * Keeps only the Host-scoped idempotency key until the Host acknowledges it. + * Composer draft text has its own storage key and lifecycle. */ export class WorkHubSendLease { - #memory: WorkHubSendLeaseState | undefined; - readonly #scope: string; readonly #storage: WorkHubSendLeaseStorage | undefined; readonly #createId: () => string; - readonly #storageKey: string; + readonly #actionKey: string; + readonly #draftKey: string; + #memoryRequestId: string | undefined; + #memoryDraft: string | undefined; #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; + const scope = encodeURIComponent(options.scope); this.#storage = options.storage ?? rendererPersistentStorage(); this.#createId = options.createId ?? (() => crypto.randomUUID()); - this.#storageKey = `${WORKHUB_SEND_LEASE_KEY}:${encodeURIComponent(this.#scope)}`; + this.#actionKey = `${WORKHUB_ACTION_KEY}:${scope}`; + this.#draftKey = `${WORKHUB_DRAFT_STORAGE_KEY}:${scope}`; } acquire(text: string): string { return this.acquireAttempt(text).requestId; } - acquireAttempt( - text: string, - options: { readonly preserveDraft?: boolean } = {}, - ): WorkHubSendAttempt { - const existing = this.#read(); - 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, - }; - } + acquireAttempt(text: string): WorkHubSendAttempt { + const existing = this.#readRequestId(); + if (existing) return { requestId: existing, text, retrying: true }; const requestId = this.#createId(); - this.#write({ - version: 2, - draft: options.preserveDraft ? existing?.draft ?? text : text, - action: { requestId, text, state: 'active' }, - }); + if (!SAFE_REQUEST_ID.test(requestId)) { + throw new Error('WorkHub action identity is invalid'); + } + this.#writeRequestId(requestId); return { requestId, text, retrying: false }; } complete(requestId: string): void { - const existing = this.#read(); - if (existing?.action?.requestId !== requestId) return; - this.#write({ version: 2, draft: existing.draft }); + if (this.#readRequestId() !== requestId) return; + this.#removeRequestId(); } - 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' }, - }); - } + settle(requestId: string, text: string, clearsDraft: boolean): boolean { + if (!clearsDraft || this.#readRequestId() !== requestId) return false; + const draftUnchanged = this.read(WORKHUB_DRAFT_KEY) === text; + this.#removeRequestId(); return draftUnchanged; } @@ -134,116 +93,67 @@ export class WorkHubSendLease { this.complete(requestId); } - summary(requestId: string, create: () => string): string { - const existing = this.#read(); - if (existing?.action?.requestId !== requestId) { - throw new Error('WorkHub summary identity does not own the active send lease'); - } - 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, - action: { ...existing.action, summary }, - }); - return summary; - } - read(key: string | undefined): string | undefined { - return key === WORKHUB_DRAFT_KEY ? this.#read()?.draft : undefined; + if (key !== WORKHUB_DRAFT_KEY) return undefined; + if (!this.#storageHealthy) return this.#memoryDraft; + try { + const draft = this.#storage?.getItem(this.#draftKey) ?? undefined; + if (draft === undefined || draft.length > MAX_DRAFT_CHARS) return this.#memoryDraft; + this.#memoryDraft = draft; + return draft; + } catch { + this.#storageHealthy = false; + return this.#memoryDraft; + } } write(key: string | undefined, draft: string): void { if (key !== WORKHUB_DRAFT_KEY) return; - if (!draft) { - const existing = this.#read(); - if (existing?.action?.state === 'active') { - this.#write({ ...existing, draft: '' }); - } else { - this.#remove(); - } - return; + if (draft.length > MAX_DRAFT_CHARS) return; + this.#memoryDraft = draft || undefined; + if (!this.#storageHealthy) return; + try { + if (draft) this.#storage?.setItem(this.#draftKey, draft); + else this.#storage?.removeItem(this.#draftKey); + } catch { + this.#storageHealthy = false; } - const existing = this.#read(); - // 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: 2, - draft, - ...(existing?.action ? { action: existing.action } : {}), - }); } - #read(): WorkHubSendLeaseState | undefined { - if (!this.#storageHealthy) return this.#memory; + #readRequestId(): string | undefined { + if (!this.#storageHealthy) return this.#memoryRequestId; try { - const raw = this.#storage?.getItem(this.#storageKey); - if (!raw) return this.#memory; - const value = JSON.parse(raw) as Partial; - if ( - value.version !== 2 || - typeof value.draft !== 'string' || - value.draft.length > MAX_DRAFT_CHARS || - (value.action !== undefined && !isWorkHubSendAction(value.action)) - ) { - return undefined; - } - const decoded = { - version: 2, - draft: value.draft, - ...(value.action ? { action: value.action } : {}), - } satisfies WorkHubSendLeaseState; - this.#memory = decoded; - return decoded; + const requestId = this.#storage?.getItem(this.#actionKey) ?? undefined; + if (!requestId || !SAFE_REQUEST_ID.test(requestId)) return this.#memoryRequestId; + this.#memoryRequestId = requestId; + return requestId; } catch { this.#storageHealthy = false; - return this.#memory; + return this.#memoryRequestId; } } - #write(value: WorkHubSendLeaseState): void { - this.#memory = value; + #writeRequestId(requestId: string): void { + this.#memoryRequestId = requestId; if (!this.#storageHealthy) return; try { - this.#storage?.setItem(this.#storageKey, JSON.stringify(value)); + this.#storage?.setItem(this.#actionKey, requestId); } catch { this.#storageHealthy = false; } } - #remove(): void { - this.#memory = undefined; + #removeRequestId(): void { + this.#memoryRequestId = undefined; if (!this.#storageHealthy) return; try { - this.#storage?.removeItem(this.#storageKey); + this.#storage?.removeItem(this.#actionKey); } catch { this.#storageHealthy = false; } } } -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' || typeof document === 'undefined' diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 173474919b..49ca281aea 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -161,7 +161,15 @@ export async function submitAndRecordWorkHubSurfaceInput(input: { // 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; + // Delegations project directly from the Host's atomic delegation_assigned + // record. Only local clarification still needs the generic summary path. + if ( + result.kind === 'discussion' || + result.kind === 'waiting' || + result.kind === 'submitted' + ) { + return result; + } try { await input.controller.recordConversationTurn({ turnId: input.request.requestId, @@ -182,18 +190,15 @@ export async function submitLeasedWorkHubSurfaceInput(input: { 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 attempt = input.lease.acquireAttempt(input.text); const result = await input.submit(attempt); if (!result) return false; const clearsDraft = input.lease.settle( attempt.requestId, + attempt.text, workHubSubmissionClearsDraft(result), ); if (input.preserveDraft && clearsDraft) { - input.lease.write('workhub', ''); return false; } return clearsDraft; @@ -295,13 +300,9 @@ export function WorkHubSurface(props: { controller: props.controller, request: input, recordedUserText, - 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. + summary: (result) => workHubCoordinationSummary(result, projection, copy), + // Clarification remains a local transcript write; delegated sends + // are projected directly from the Host-owned assignment record. onSummaryError: () => setConversationError(true), }); setTurns((current) => current.map((turn) => @@ -549,7 +550,11 @@ function CoordinationTurnView(props: { }) { return ( - {props.turn.result ? ( + {props.turn.assignment ? ( +

+ {props.copy.sentTo} {props.turn.assignment.targetSessionName} · {props.copy.accepted} +

+ ) : props.turn.result ? (

{props.turn.result}

) : props.turn.state === 'running' ? (

{props.copy.answering}

diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 1a7e4a3b8d..eade3fbfb9 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -85,9 +85,9 @@ Every WorkHub input resolves to exactly one proposed **disposition**: All model and routing output is advisory. Before any write, a deterministic **Action Gate** admits or rejects the proposed disposition and operation. The gate enforces Runtime Host and target validity, archive and waiting state, self-route -exclusion, explicit `create_new`, expected-Turn ownership for Stop, confirmation -requirements, and existing tool and permission ceilings. Neither a model nor a -routing policy can directly authorize a write or expand execution authority. +exclusion, explicit `create_new`, and existing tool and permission ceilings. +Replacement, supersession, and Stop ownership remain deferred. Neither a model +nor a routing policy can directly authorize a write or expand execution authority. ## Delegation links rather than copies transcripts @@ -100,59 +100,42 @@ coordinationTurnId targetSessionId targetTurnId disposition -status ``` -`status` describes only whether the coordination-owned link is `active` or -`superseded`; it never mirrors the target Turn's execution lifecycle. Target -acceptance, running, waiting, completion, failure, abort, and recovery state remain -ordinary Session facts. WorkHub derives those states as read-only projections and -does not persist them as independent Coordination Session truth. +The initial assignment link does not mirror the target Turn's execution lifecycle. +Target acceptance, running, waiting, completion, failure, abort, and recovery state +remain ordinary Session facts. WorkHub derives those states as read-only +projections and does not persist them as independent Coordination Session truth. +Future replacement support may add coordination-owned `active` / `superseded` +linkage without turning target execution status into WorkHub-owned state. The ordinary Session records the delegated request, tools, side effects, and 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. 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. - -Recovery accepts the target Session's existing root receipt, pending admission, or -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. `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. +Delegation linkage uses one closed, typed `delegation_assigned` record in the +existing Coordination Session transcript. Under the Coordination and target +Session admission authorities, one `runtime.sqlite` transaction commits that +record together with the target pending-message admission. For `create_new`, the +target Session metadata is created in the same transaction. The record carries the +exact user text, resolved target and target Message/Turn identities, creation +context, and stable display name. Its action fingerprint rejects conflicting reuse +of an action identity. + +The transaction is the user-visible assignment boundary. Before commit neither +Session observes the work; after commit both the WorkHub linkage and target input +exist. Waking or continuing the in-memory executor happens only after commit. A +Host crash between commit and wake is handled by ordinary pending-message recovery, +so WorkHub does not own a second recovery state machine or compensation chain. +The `delegation_assigned` record itself projects the visible WorkHub turn; the +renderer does not append a second summary. + +The renderer persists only a Host-scoped action id until acknowledgement. Composer +draft text uses a separate storage key and lifecycle. A reload therefore preserves +idempotency without freezing old text or coupling draft edits to Host authority. +`waiting_for_user` remains a local, retryable result because no assignment has yet +been committed. ## Consequences, costs, and reevaluation @@ -165,10 +148,8 @@ 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. +- Every delegated Coordination turn adds one typed assignment record, which is + also its visible timeline source. - Whether Work is 1:1 with Session, 1:N over Sessions, or an independent durable entity remains unresolved. - Cross-Runtime-Host coordination remains deferred. diff --git a/packages/core/src/__tests__/workhub-coordination-record.test.ts b/packages/core/src/__tests__/workhub-coordination-record.test.ts index 8159dbd8b9..9856a12d54 100644 --- a/packages/core/src/__tests__/workhub-coordination-record.test.ts +++ b/packages/core/src/__tests__/workhub-coordination-record.test.ts @@ -24,79 +24,71 @@ import { decodeCanonicalMessage } from '../session.js'; const FINGERPRINT = `sha256:${'a'.repeat(64)}`; describe('WorkHub Coordination stored records', () => { - test('decodes exact delegation intent, commit, and abandonment records', () => { - const intent = { + test('decodes one exact atomic delegation assignment', () => { + const assigned = { type: 'workhub_coordination', - id: 'intent-id', + id: 'assignment-id', turnId: 'coordination-turn', ts: 1, schemaVersion: 1, - kind: 'delegation_intent', + kind: 'delegation_assigned', actionId: 'action-id', actionFingerprint: FINGERPRINT, coordinationTurnId: 'coordination-turn', targetSessionId: 'payments', disposition: 'delegate_existing', userText: 'Continue payment work', - } as const; - const committed = { - ...intent, - id: 'commit-id', - ts: 2, - kind: 'delegation_committed', delegationId: 'delegation-id', targetTurnId: 'target-turn', + targetMessageId: 'target-message', + targetSessionName: 'Payments', 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); + assert.deepEqual(decodeCanonicalMessage(assigned), assigned); }); test('rejects malformed or widened coordination records', () => { const base = { type: 'workhub_coordination', - id: 'intent-id', + id: 'assignment-id', turnId: 'coordination-turn', ts: 1, schemaVersion: 1, - kind: 'delegation_intent', + kind: 'delegation_assigned', actionId: 'action-id', actionFingerprint: FINGERPRINT, coordinationTurnId: 'coordination-turn', targetSessionId: 'payments', disposition: 'delegate_existing', + userText: 'Continue payment work', + delegationId: 'delegation-id', + targetTurnId: 'target-turn', + targetMessageId: 'target-message', + targetSessionName: 'Payments', } as const; for (const invalid of [ { ...base, coordinationTurnId: 'different-turn' }, { ...base, actionFingerprint: 'not-a-digest' }, { ...base, disposition: 'replace' }, - { ...base, userText: undefined }, + { ...base, targetMessageId: undefined }, { ...base, sourceSessionId: 'injected' }, - { ...base, kind: 'delegation_committed' }, + { ...base, kind: 'delegation_intent' }, { ...base, schemaVersion: 2 }, ]) { assert.throws(() => decodeCanonicalMessage(invalid), /Invalid stored message schema/u); } }); - test('requires a complete create payload only for create_new intents', () => { + test('requires a complete create payload only for create_new assignments', () => { const create = { type: 'workhub_coordination', - id: 'create-intent-id', + id: 'create-assignment-id', turnId: 'coordination-turn', ts: 1, schemaVersion: 1, - kind: 'delegation_intent', + kind: 'delegation_assigned', actionId: 'action-id', actionFingerprint: FINGERPRINT, coordinationTurnId: 'coordination-turn', @@ -107,6 +99,10 @@ describe('WorkHub Coordination stored records', () => { title: 'Login audit', workspace: { kind: 'project', projectId: 'project-maka' }, }, + delegationId: 'delegation-id', + targetTurnId: 'target-turn', + targetMessageId: 'target-message', + targetSessionName: 'Login audit', } as const; assert.deepEqual(decodeCanonicalMessage(create), create); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2724652180..61f1e4a6e3 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -940,29 +940,19 @@ interface WorkHubCoordinationMessageEnvelope { create?: WorkHubDelegationCreateSpec; } -/** 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'; +/** + * Atomic proof that one Coordination action and one target Message admission + * were committed together by the Runtime Host. + */ +export interface WorkHubDelegationAssignedMessage extends WorkHubCoordinationMessageEnvelope { + kind: 'delegation_assigned'; delegationId: string; targetTurnId: string; + targetMessageId: string; + targetSessionName: string; 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 - | WorkHubDelegationAbandonedMessage; +export type WorkHubCoordinationMessage = WorkHubDelegationAssignedMessage; export interface TurnRecord { turnId: string; @@ -1086,25 +1076,8 @@ 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', - 'userText', - ], - ['create'], -); -const WORKHUB_DELEGATION_COMMITTED_MESSAGE_SHAPE = - defineObjectShape()( +const WORKHUB_DELEGATION_ASSIGNED_MESSAGE_SHAPE = + defineObjectShape()( [ 'type', 'id', @@ -1120,28 +1093,11 @@ const WORKHUB_DELEGATION_COMMITTED_MESSAGE_SHAPE = 'userText', 'delegationId', 'targetTurnId', + 'targetMessageId', + 'targetSessionName', ], ['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'], [], @@ -1333,20 +1289,14 @@ function isWorkHubCoordinationMessage(message: Record): boolean (message.disposition === 'create_new' && isWorkHubDelegationCreateSpec(message.create))) && (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); - } - 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) && + message.kind === 'delegation_assigned' && + hasExactShape(message, WORKHUB_DELEGATION_ASSIGNED_MESSAGE_SHAPE) && typeof message.delegationId === 'string' && typeof message.targetTurnId === 'string' && + typeof message.targetMessageId === 'string' && + typeof message.targetSessionName === 'string' && + message.targetSessionName.trim().length > 0 && (message.steered === undefined || message.steered === true) ); } diff --git a/packages/core/src/thread-search.ts b/packages/core/src/thread-search.ts index f09df85a39..7e6086e8d5 100644 --- a/packages/core/src/thread-search.ts +++ b/packages/core/src/thread-search.ts @@ -548,7 +548,9 @@ export function collectSearchableText(message: StoredMessage): string | undefine case 'turn_state': case 'workhub_coordination': case 'system_note': - // Excluded — not user-typed / not user-visible content. + // Coordination records are rendered by WorkHub, but the reserved + // Coordination Session is intentionally outside general thread search. + // The remaining cases are not user-typed / not user-visible content. return undefined; } } diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 6ff54ecd0d..871e42bcfb 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -44,6 +44,42 @@ import { SessionAdmissionGate } from '../server/session-admission-gate.js'; const ROOT = { sessionId: 'session-1', turnId: 'turn-1', runId: 'run-1' } as const; +test('a caller-owned admission can atomically commit and wake steering', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + fixture.coordinator.bindRun(ROOT); + const content = { text: 'atomic WorkHub assignment' }; + await fixture.admissions.commitMessageAdmission({ + ...ROOT, + messageId: 'workhub-message', + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + admittedAt: 10, + }); + + const outcome = await fixture.sessionAdmission.runMany( + ['maka_workhub_coordination', ROOT.sessionId], + (lease) => + fixture.coordinator.submitWithAdmissionLease( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + messageId: 'workhub-message', + content, + placement: 'current_turn', + }, + operationContext(), + lease, + ), + ); + + assert.equal(outcome.ok, true); + assert.equal(fixture.coordinator.projection(ROOT.sessionId).steering.length, 1); +}); + test('idle submit starts exactly one root Turn and retry identity is connection-independent', async () => { const fixture = createFixture(); fixture.setRootState({ kind: 'idle' }); @@ -2278,6 +2314,7 @@ function createFixture( } >(); const admissions = memoryMessageAdmissionStore(messageAdmissions); + const sessionAdmission = new SessionAdmissionGate(); const stopClaimed = deferred(); const terminal = deferred(); let coordinator: HostMessageCoordinator; @@ -2384,7 +2421,7 @@ function createFixture( }, }, admissions, - sessionAdmission: new SessionAdmissionGate(), + sessionAdmission, acquireResidency: () => { liveResidencies += 1; let released = false; @@ -2407,6 +2444,7 @@ function createFixture( return { coordinator, admissions, + sessionAdmission, setRootState: (state: HostMessageRootState) => { rootState = state; }, 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 3e3da205bd..f0c3d9d372 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -478,57 +478,6 @@ test('ordinary creation rejects the reserved WorkHub Coordination Session identi assert.equal(fixture.drainRequests(), 0); }); -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({ - stores: { - createStableSession: async () => { - creates += 1; - return creates === 1 - ? { kind: 'created', record: headerSnapshot(header, 1) } - : { kind: 'existing', record: headerSnapshot(header, creates === 2 ? 1 : 2) }; - }, - readCatalogRecord: async () => catalogRecord(header, 1), - }, - }); - 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); - const mutated = await fixture.coordinator.createForWorkHub(input); - - assert.equal(created.outcome.ok, true); - assert.equal(created.discardRevision, 1); - assert.equal(replayed.outcome.ok, true); - assert.equal(replayed.discardRevision, 1); - assert.equal(mutated.outcome.ok, true); - 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 d5b87ba6ba..095bea5ffd 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,10 +25,7 @@ import { WorkHubCoordinationActionGate, type WorkHubActionGateEffects, type WorkHubActionGateSession, - type WorkHubDelegationAbandoned, - type WorkHubDelegationCommit, - type WorkHubDelegationIntent, - type WorkHubDelegationRecord, + type WorkHubDelegationAssignmentInput, } from '../server/workhub-coordination-action-gate.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; @@ -40,7 +37,7 @@ const CONTEXT: ConnectionContext = { }; describe('WorkHub Coordination Action Gate', () => { - test('exposes only bounded ordinary candidates and proposals use opaque refs', async () => { + test('exposes only bounded ordinary candidates and opaque refs', async () => { const effects = fakeEffects([ session('ordinary'), session('archived', { isArchived: true }), @@ -57,7 +54,6 @@ describe('WorkHub Coordination Action Gate', () => { session('maka_workhub_coordination', { role: 'workhub_coordination' }), ]); const result = await new WorkHubCoordinationActionGate(effects).candidates(); - assert.deepEqual( result.candidates.map(({ sessionId }) => sessionId), ['ordinary', 'waiting'], @@ -71,16 +67,15 @@ describe('WorkHub Coordination Action Gate', () => { assert.equal(bounded.candidates.length, 32); }); - test('rejects stale and invented candidates before any Session effect', async () => { + test('rejects stale candidates before assignment', async () => { const effects = fakeEffects([session('payments')]); const gate = new WorkHubCoordinationActionGate(effects); const snapshot = await gate.candidates(); - effects.sessions[0] = session('payments', { statusUpdatedAt: 9 }); - + effects.sessions[0] = session('payments', { lastMessageAt: 9 }); await assert.rejects( gate.act( { - actionId: 'stale-action', + actionId: 'stale', userText: 'Continue payments', candidateSetId: snapshot.candidateSetId, proposal: { @@ -92,12 +87,12 @@ describe('WorkHub Coordination Action Gate', () => { ), (error) => error instanceof WorkHubActionGateFailure && error.code === 'candidate_set_stale', ); - assert.deepEqual(effects.submissions, []); + assert.equal(effects.assignments.length, 0); const refreshed = await gate.candidates(); const retried = await gate.act( { - actionId: 'stale-action', + actionId: 'stale', userText: 'Continue payments', candidateSetId: refreshed.candidateSetId, proposal: { @@ -108,13 +103,12 @@ describe('WorkHub Coordination Action Gate', () => { CONTEXT, ); assert.equal(retried.disposition, 'delegate_existing'); - effects.submissions.length = 0; const current = await gate.candidates(); await assert.rejects( gate.act( { - actionId: 'invented-action', + actionId: 'invented', userText: 'Continue payments', candidateSetId: current.candidateSetId, proposal: { disposition: 'delegate_existing', candidateRef: 'invented_candidate' }, @@ -124,19 +118,18 @@ describe('WorkHub Coordination Action Gate', () => { (error) => error instanceof WorkHubActionGateFailure && error.code === 'candidate_unavailable', ); - assert.deepEqual(effects.submissions, []); + assert.equal(effects.assignments.length, 1); }); test('rejects waiting targets independently of strategy behavior', async () => { const effects = fakeEffects([session('waiting', { status: 'waiting_for_user' })]); const gate = new WorkHubCoordinationActionGate(effects); const snapshot = await gate.candidates(); - await assert.rejects( gate.act( { - actionId: 'waiting-action', - userText: 'Do another thing', + actionId: 'waiting', + userText: 'Continue', candidateSetId: snapshot.candidateSetId, proposal: { disposition: 'delegate_existing', @@ -148,40 +141,55 @@ describe('WorkHub Coordination Action Gate', () => { (error) => error instanceof WorkHubActionGateFailure && error.code === 'target_waiting_for_user', ); - assert.deepEqual(effects.submissions, []); }); - test('answers and clarifies only through the Coordination transcript effects', async () => { + test('answers and clarifies only through Coordination effects', async () => { const effects = fakeEffects([session('ordinary')]); const gate = new WorkHubCoordinationActionGate(effects); - - const answered = await gate.act( - { - actionId: 'answer-action', - userText: 'What is useMemo?', - proposal: { disposition: 'answer_here' }, - }, + await gate.act( + { actionId: 'answer', userText: 'Summarize', proposal: { disposition: 'answer_here' } }, CONTEXT, ); - const clarified = await gate.act( + await gate.act( { - actionId: 'clarify-action', - userText: 'Continue that task', - proposal: { disposition: 'clarify', assistantText: 'Which task do you mean?' }, + actionId: 'clarify', + userText: 'Which one?', + proposal: { disposition: 'clarify', assistantText: 'Choose a Session' }, }, CONTEXT, ); - - assert.equal(answered.disposition, 'answer_here'); - assert.equal(clarified.disposition, 'clarify'); assert.equal(effects.answers.length, 1); assert.equal(effects.clarifications.length, 1); - assert.deepEqual(effects.submissions, []); - assert.equal(effects.creations.length, 0); + assert.equal(effects.assignments.length, 0); }); - test('only create_new creates and retries the exact action idempotently', async () => { - const effects = fakeEffects([session('ordinary')]); + test('delegates through one assignment effect', async () => { + const effects = fakeEffects([session('payments', { name: 'Payments' })]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const result = await gate.act( + { + actionId: 'delegate', + userText: 'Continue payments', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ); + assert.deepEqual(result, { + disposition: 'delegate_existing', + targetSessionId: 'payments', + targetTurnId: 'turn-delegate', + }); + assert.equal(effects.assignments[0]!.targetSessionName, 'Payments'); + assert.equal(effects.assignments[0]!.userText, 'Continue payments'); + }); + + test('create_new carries creation context into the same assignment', async () => { + const effects = fakeEffects([]); const gate = new WorkHubCoordinationActionGate(effects); await assert.rejects( gate.act( @@ -194,355 +202,109 @@ describe('WorkHub Coordination Action Gate', () => { ), (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', ); - assert.equal(effects.creations.length, 0); const input = { - actionId: 'create-action', + actionId: 'create', userText: 'Create an accessibility audit', proposal: { disposition: 'create_new' as const, title: 'Accessibility audit' }, - create: { - workspace: { kind: 'host_path' as const, path: '/workspace' }, - }, + create: { workspace: { kind: 'host_path' as const, path: '/workspace' } }, }; - 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.equal(effects.assignments.length, 1); + const restartedReplay = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT); 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); - assert.equal(first.disposition, 'create_new'); - if (first.disposition === 'create_new') { - assert.equal(first.targetSessionId, effects.creations[0]?.sessionId); - } - assert.deepEqual(Object.keys(effects.creations[0]!).sort(), [ - 'sessionId', - '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, - ); + assert.equal(effects.assignments.length, 2); + assert.deepEqual(effects.assignments[0], effects.assignments[1]); + assert.match(effects.assignments[0]!.targetSessionId, /^whs_[a-f0-9]{48}$/u); + assert.deepEqual(effects.assignments[0]!.create, { + title: 'Accessibility audit', + workspace: input.create.workspace, + }); await assert.rejects( - gate.act({ ...input, userText: 'Create different work' }, CONTEXT), + gate.act({ ...input, proposal: { disposition: 'create_new', title: 'Different' } }, CONTEXT), (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', ); - assert.equal(effects.creations.length, 1); + assert.equal(effects.assignments.length, 2); }); - test('definitive effect rejection durably abandons the action identity', async () => { - const effects = fakeEffects([session('ordinary')]); + test('one in-memory action identity cannot change payload', async () => { + const effects = fakeEffects([session('payments'), session('login', { lastMessageAt: 1 })]); const gate = new WorkHubCoordinationActionGate(effects); const snapshot = await gate.candidates(); - effects.submit = async () => { - throw new WorkHubActionEffectFailure('unauthorized', 'Target permission denied'); - }; const input = { - actionId: 'permission-rejected-action', - userText: 'Continue ordinary work', + actionId: 'same-action', + userText: 'Continue payments', candidateSetId: snapshot.candidateSetId, proposal: { disposition: 'delegate_existing' as const, candidateRef: snapshot.candidates[0]!.candidateRef, }, }; - + await gate.act(input, CONTEXT); await assert.rejects( - gate.act(input, CONTEXT), - (error) => error instanceof WorkHubActionEffectFailure && error.code === 'unauthorized', - ); - assert.equal(effects.delegations.get(input.actionId)?.kind as string, 'delegation_abandoned'); - await assert.rejects( - new WorkHubCoordinationActionGate(effects).act(input, CONTEXT), + gate.act({ ...input, userText: 'Different work' }, CONTEXT), (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', ); - assert.deepEqual(effects.submissions, []); - }); - - 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, + await assert.rejects( + gate.act( + { + ...input, + proposal: { + disposition: 'delegate_existing', + candidateRef: snapshot.candidates[1]!.candidateRef, + }, }, - }, - CONTEXT, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', ); - - 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')]); + test('an assignment rejection releases the action identity for retry', async () => { + const effects = fakeEffects([session('payments')]); const gate = new WorkHubCoordinationActionGate(effects); const snapshot = await gate.candidates(); const input = { - actionId: 'delegate-action', - userText: 'Continue ordinary work', + actionId: 'permission-rejected', + userText: 'Continue payments', candidateSetId: snapshot.candidateSetId, proposal: { disposition: 'delegate_existing' as const, candidateRef: snapshot.candidates[0]!.candidateRef, }, }; - - const first = 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'); - + const assign = effects.assign; + effects.assign = async () => { + throw new WorkHubActionEffectFailure('unauthorized', 'Target permission denied'); + }; await assert.rejects( - new WorkHubCoordinationActionGate(effects).act( - { ...input, userText: 'Different work' }, - CONTEXT, - ), - (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + gate.act(input, CONTEXT), + (error) => error instanceof WorkHubActionEffectFailure && error.code === 'unauthorized', ); - assert.equal(effects.submissions.length, 1); + effects.assign = assign; + assert.equal((await gate.act(input, CONTEXT)).disposition, 'delegate_existing'); }); - test('resumes a durable intent after restart without re-admitting stale candidates', async () => { - const effects = fakeEffects([session('ordinary')]); + test('replays an ordinary delegation without assigning twice', async () => { + const effects = fakeEffects([session('payments')]); const gate = new WorkHubCoordinationActionGate(effects); const snapshot = await gate.candidates(); const input = { - actionId: 'interrupted-delegate-action', - userText: 'Continue ordinary work', + actionId: 'delegate-replay', + userText: 'Continue payments', 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 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); - }); - - 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, []); - 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 () => { - 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, []); - }); - - 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, []); - }); - - 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', - ); + const first = await gate.act(input, CONTEXT); + const replay = await gate.act(input, CONTEXT); - assert.equal(effects.delegations.get(input.actionId)?.kind, 'delegation_abandoned'); - assert.equal(effects.createAttempts, 2); - assert.equal(effects.discardAttempts, 1); - assert.deepEqual(effects.creations, []); + assert.deepEqual(replay, first); + assert.equal(effects.assignments.length, 1); }); }); @@ -552,26 +314,25 @@ function session( ): WorkHubActionGateSession { return { id, - cwd: `/workspace/${id}`, + cwd: '/workspace', projectId: null, createdAt: 1, + lastMessageAt: 2, name: id, labels: [], isArchived: false, status: 'active', + statusUpdatedAt: 2, ...patch, }; } function fakeEffects(initialSessions: WorkHubActionGateSession[]) { - const submitted = new Map< + const durable = new Map< string, - { - readonly input: { sessionId: string; messageId: string; text: string }; - readonly turnId: string; - } + { input: WorkHubDelegationAssignmentInput; result: { turnId: string } } >(); - const state = { + return { sessions: [...initialSessions], answers: [] as Array<{ turnId: string; text: string }>, clarifications: [] as Array<{ @@ -579,151 +340,34 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) { userText: string; assistantText: string; }>, - creations: [] as Array<{ - sessionId: string; - workspace: { kind: 'project'; projectId: string } | { kind: 'host_path'; path: string }; - title: string; - }>, - submissions: [] as Array<{ sessionId: string; messageId: string; text: string }>, - delegations: new Map(), - commitFailuresRemaining: 0, - submitUnknownAfterAdmission: false as boolean, - submitFailure: undefined as WorkHubActionEffectFailure | undefined, - recoverSubmissionMiss: false as boolean, - discardAttempts: 0, - discardFailuresRemaining: 0, - discardUnknownAfterRemoval: false as boolean, - discardedCreatedSessionIds: [] as string[], - retiredCreatedSessionIds: new Set(), - createAttempts: 0, + assignments: [] as WorkHubDelegationAssignmentInput[], async listSessions() { return this.sessions; }, + async readAssignment() { + return undefined; + }, async answer(input: { turnId: string; text: string }) { this.answers.push(input); }, async clarify(input: { turnId: string; userText: string; assistantText: string }) { this.clarifications.push(input); }, - async create(input: { - sessionId: string; - 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 { kind: 'available' as const, discardRevision: 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); + async assign(input: WorkHubDelegationAssignmentInput) { + this.assignments.push(input); + const existing = durable.get(input.actionId); if (existing) { assert.deepEqual(existing.input, input); - return { turnId: existing.turnId }; + return existing.result; } - this.submissions.push(input); - 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 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( - 'commit_outcome_unknown', - 'Created Session retirement outcome is unknown', - ); - } - 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); - }, - 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); - 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); - }, - async abandonDelegation(abandoned: WorkHubDelegationAbandoned) { - const existing = this.delegations.get(abandoned.actionId); - assert.equal(existing?.kind, 'delegation_intent'); - this.delegations.set(abandoned.actionId, abandoned); + const result = { turnId: `turn-${input.actionId}` }; + durable.set(input.actionId, { input, result }); + return result; }, } satisfies WorkHubActionGateEffects & { sessions: WorkHubActionGateSession[]; answers: Array<{ turnId: string; text: string }>; clarifications: Array<{ turnId: string; userText: string; assistantText: string }>; - creations: Array<{ - sessionId: string; - workspace: { kind: 'project'; projectId: string } | { kind: 'host_path'; path: string }; - title: string; - }>; - submissions: Array<{ sessionId: string; messageId: string; text: string }>; - delegations: Map; - commitFailuresRemaining: number; - submitUnknownAfterAdmission: boolean; - submitFailure: WorkHubActionEffectFailure | undefined; - recoverSubmissionMiss: boolean; - discardAttempts: number; - discardFailuresRemaining: number; - discardUnknownAfterRemoval: boolean; - discardedCreatedSessionIds: string[]; - retiredCreatedSessionIds: Set; - createAttempts: number; + assignments: WorkHubDelegationAssignmentInput[]; }; - 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 c609ae9d18..bde8ce31fe 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -18,12 +18,17 @@ */ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { mkdtemp, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { describe, test } from 'node:test'; -import type { MessageContent } from '@maka/core/events'; +import { + messageContentDigest, + normalizeMessageContent, + type MessageContent, +} from '@maka/core/events'; import { WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_SESSION_ROLE, @@ -493,15 +498,12 @@ describe('Host WorkHub Coordination coordinator', () => { model: 'test-model', permissionMode: 'ask', }); - const submissions: Array<{ sessionId: string; messageId: string; text: string }> = []; + const assignments: string[] = []; const first = coordinator(root, store, () => undefined, undefined, undefined, undefined, { - create: async () => ({ kind: 'available' }), - discardCreated: async () => undefined, - submit: async (input) => { - submissions.push(input); - return { turnId: 'payments-turn' }; + assign: async (input) => { + assignments.push(input.actionId); + return persistTestAssignment(store, input, '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); @@ -531,18 +533,13 @@ describe('Host WorkHub Coordination coordinator', () => { .map(({ kind, actionId, targetSessionId }) => ({ kind, actionId, targetSessionId })), [ { - kind: 'delegation_intent', - actionId: 'payments-action', - targetSessionId: candidates.result.candidates[0]!.sessionId, - }, - { - kind: 'delegation_committed', + kind: 'delegation_assigned', actionId: 'payments-action', targetSessionId: candidates.result.candidates[0]!.sessionId, }, ], ); - assert.equal(submissions.length, 1); + assert.equal(assignments.length, 1); } finally { await store.close?.(); } @@ -550,11 +547,7 @@ describe('Host WorkHub Coordination coordinator', () => { store = createSessionStore(root); 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'), + assign: (input) => persistTestAssignment(store, input, 'payments-turn'), }); const candidates = await restarted.handlers['workhub.coordination.candidates']({}, CONTEXT); assert.equal(candidates.ok, true); @@ -718,14 +711,8 @@ function coordinator( hasRootTurnAdmission: async () => false, }, admission: SessionAdmissionGate = new SessionAdmissionGate(), - sessionActions: Pick< - WorkHubActionGateEffects, - 'create' | 'discardCreated' | 'submit' | 'recoverSubmission' - > = { - create: async () => ({ kind: 'available' }), - discardCreated: async () => undefined, - submit: async ({ sessionId }) => ({ turnId: `turn-${sessionId}` }), - recoverSubmission: async () => undefined, + sessionActions: Pick = { + assign: async ({ targetSessionId }) => ({ turnId: `turn-${targetSessionId}` }), }, ) { return new HostWorkHubCoordinationCoordinator({ @@ -747,3 +734,46 @@ function coordinator( requestDrain, }); } + +async function persistTestAssignment( + store: SessionAuthorityStore, + input: Parameters[0], + targetTurnId: string, +): Promise<{ turnId: string }> { + const suffix = createHash('sha256').update(input.actionId, 'utf8').digest('hex').slice(0, 48); + const content = normalizeMessageContent({ text: input.userText }); + const result = await store.assignWorkHubMessage({ + assignment: { + type: 'workhub_coordination', + id: `wha_${suffix}`, + turnId: input.actionId, + ts: Date.now(), + schemaVersion: 1, + kind: 'delegation_assigned', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + targetSessionId: input.targetSessionId, + targetSessionName: input.targetSessionName, + targetTurnId, + targetMessageId: `whm_${suffix}`, + delegationId: `whd_${suffix}`, + disposition: input.disposition, + userText: input.userText, + ...(input.create ? { create: input.create } : {}), + }, + admission: { + sessionId: input.targetSessionId, + turnId: targetTurnId, + runId: `whr_${suffix}`, + messageId: `whm_${suffix}`, + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + admittedAt: Date.now(), + }, + }); + return { turnId: result.assignment.targetTurnId }; +} 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 deleted file mode 100644 index 5cfe00820a..0000000000 --- a/packages/runtime-host/src/__tests__/workhub-target-submission-recovery.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* - * 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 { 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'; - -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 }, - ); -}); - -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 80475c2ff9..9999886345 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -93,7 +93,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 = 59 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 60 as const; +// 60: WorkHub stores a canonical delegation assignment record. Older peers +// cannot decode this message during transcript recovery. // 59: Scheduled Turn provider-retry frames may carry an optional host-clock // `ts`, letting a mid-wait re-projection recompute the authoritative // remaining duration. Older peers decode the frame with an exact key list @@ -110,9 +112,8 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 59 as const; // 55: Local owners can atomically revoke every credential for one access // principal, closing pairing-finalize races that credential-by-ID revocation cannot. // 54: Client-bound pairing candidates restrict pre-claim authority and bind -// their durable credential to the claiming Client identity. WorkHub also stores -// strict durable delegation intent, commit, and abandonment records that older -// peers cannot decode during transcript recovery. +// their durable credential to the claiming Client identity; it is also reserved +// by concurrent protocol changes in #3390. // 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 dc5fe3f9da..0199956811 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, @@ -27,7 +27,7 @@ import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { generalizedErrorMessage } from '@maka/core/redaction'; import { emptyPlanSessionState } from '@maka/core/plan'; import type { PermissionMode } from '@maka/core/permission'; -import { isDeepResearchSession } from '@maka/core/session'; +import { isDeepResearchSession, WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; import { filterModelVisibleTaskLedgerTasks } from '@maka/core/task-ledger'; import { AgentGraphCoordinator } from '@maka/runtime/stream-graph-coordinator'; import { AgentGraphSupervisorWakeCoordinator } from '@maka/runtime/agent-graph-supervisor-wake'; @@ -88,7 +88,6 @@ 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'; @@ -469,7 +468,6 @@ 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), @@ -1232,100 +1230,132 @@ export async function createExecutionRuntimeHostComposition( continuity: continuityCoordinator, executions: coordinator, sessionActions: { - create: async (input) => { - const created = await sessionCatalog.createForWorkHub({ - sessionId: input.sessionId, - workspace: input.workspace, - name: input.title, - modelTarget: { kind: 'default' }, - collaborationMode: 'agent', - 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, - outcome.error.message, - ); - } - return created.discardRevision === undefined - ? { kind: 'available' as const } - : { kind: 'available' as const, discardRevision: created.discardRevision }; - }, - discardCreated: async (input, connection) => { - const outcome = await requireSessionRetirement(sessionRetirement).handlers[ - 'session.remove' - ]( - { - sessionId: input.sessionId, - expectedRevision: input.expectedRevision, - }, - connection, - ); - 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( - 'operation_conflict', - 'WorkHub empty created Session changed before retirement', - ); - } - }, - submit: async (input, connection) => { - const outcome = await messages.handlers['turn.message.submit']( - { - originHostEpoch: connection.hostEpoch, - sessionId: input.sessionId, - messageId: input.messageId, - content: normalizeMessageContent({ text: input.text }), - placement: 'current_turn', - }, - connection, - ); - if (!outcome.ok) { - throw new WorkHubActionEffectFailure( - outcome.error.code === 'outcome_unknown' - ? 'commit_outcome_unknown' - : outcome.error.code, - outcome.error.message, - ); - } - if (outcome.result.disposition === 'turn_started') { - return { turnId: outcome.result.turnId }; + assign: async (input, connection) => { + const durable = await stores.sessionStore.readWorkHubAssignment(input.actionId); + const create = + !durable && input.create + ? await sessionCatalog.prepareWorkHubCreate({ + sessionId: input.targetSessionId, + workspace: input.create.workspace, + name: input.create.title, + modelTarget: { kind: 'default' }, + collaborationMode: 'agent', + orchestrationMode: 'default', + }) + : undefined; + const suffix = createHash('sha256') + .update(input.actionId, 'utf8') + .digest('hex') + .slice(0, 48); + const messageId = `whm_${suffix}`; + const content = normalizeMessageContent({ text: input.userText }); + let wakeFailed = false; + const persisted = + durable ?? + (await sessionAdmission.runMany( + [WORKHUB_COORDINATION_SESSION_ID, input.targetSessionId], + async (lease) => { + const rootState = coordinator.readRootState(input.targetSessionId); + if (!create && rootState.kind === 'reserved') { + throw new WorkHubActionEffectFailure( + 'session_busy', + 'A target root Turn is being admitted', + ); + } + const steered = rootState.kind === 'active'; + const turnId = steered ? rootState.turnId : `wht_${suffix}`; + const runId = steered ? rootState.runId : `whr_${suffix}`; + const assignedAt = Date.now(); + const result = await stores.sessionStore.assignWorkHubMessage({ + assignment: { + type: 'workhub_coordination', + id: `wha_${suffix}`, + turnId: input.actionId, + ts: assignedAt, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId: input.actionId, + actionFingerprint: input.actionFingerprint, + coordinationTurnId: input.actionId, + targetSessionId: input.targetSessionId, + targetSessionName: input.targetSessionName, + targetTurnId: turnId, + targetMessageId: messageId, + delegationId: `whd_${suffix}`, + disposition: input.disposition, + userText: input.userText, + ...(steered ? { steered: true as const } : {}), + ...(input.create ? { create: input.create } : {}), + }, + admission: { + sessionId: input.targetSessionId, + turnId, + runId, + messageId, + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + admittedAt: assignedAt, + }, + ...(create ? { create } : {}), + }); + try { + await continuityCoordinator.refreshCanonical( + WORKHUB_COORDINATION_SESSION_ID, + lease, + ); + await continuityCoordinator.refreshCanonical(input.targetSessionId, lease); + const outcome = await messages.submitWithAdmissionLease( + { + originHostEpoch: connection.hostEpoch, + sessionId: result.assignment.targetSessionId, + messageId: result.assignment.targetMessageId, + content: normalizeMessageContent({ text: result.assignment.userText }), + placement: 'current_turn', + }, + connection, + lease, + ); + wakeFailed = !outcome.ok; + } catch { + // The atomic assignment is already committed. Do not turn a + // post-commit projection or wake failure into a rejected + // WorkHub action; normal inbox recovery owns consumption. + wakeFailed = true; + } + return result.assignment; + }, + )); + + if (durable) { + try { + const outcome = await messages.handlers['turn.message.submit']( + { + originHostEpoch: connection.hostEpoch, + sessionId: persisted.targetSessionId, + messageId: persisted.targetMessageId, + content: normalizeMessageContent({ text: persisted.userText }), + placement: 'current_turn', + }, + connection, + ); + wakeFailed = !outcome.ok; + } catch { + wakeFailed = true; + } } - 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. + if (wakeFailed) { + // Assignment is the acknowledged WorkHub outcome. The pending + // admission remains the target Session's durable inbox and normal + // Host recovery owns another consumption attempt. + context.requestDrain(); } - context.requestDrain(); - throw new WorkHubActionEffectFailure( - 'commit_outcome_unknown', - 'WorkHub target Turn identity could not be proven', - ); - }, - recoverSubmission: async (input) => { - 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, - ); + return { + turnId: persisted.targetTurnId, + ...(persisted.steered ? { steered: true as const } : {}), + }; }, }, resolveCreateTarget: async () => { @@ -1398,7 +1428,7 @@ export async function createExecutionRuntimeHostComposition( isSessionActive: (sessionId) => coordinator.readRootState(sessionId).kind !== 'idle', requestDrain: context.requestDrain, }); - sessionRetirement = new HostSessionRetirementCoordinator({ + const sessionRetirement = new HostSessionRetirementCoordinator({ stores: stores.sessionStore, admission: sessionAdmission, root: coordinator, @@ -1781,13 +1811,6 @@ 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/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 0ba526392f..f001283fd4 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -347,6 +347,20 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { 'turn.interrupt': (input) => this.interrupt(input), }; + /** + * Submit while reusing a caller-owned Session admission. WorkHub uses this + * after atomically committing its assignment and target admission so the + * active root cannot turn over between the durable decision and its first + * in-memory wake attempt. + */ + submitWithAdmissionLease( + input: TurnMessageSubmitInput, + context: ConnectionContext, + admission: SessionAdmissionLease, + ): Promise> { + return this.submit(input, context, admission); + } + readonly #hostEpoch: string; readonly #root: HostMessageRootPort; readonly #durableProof: HostMessageDurableProofReader; @@ -774,6 +788,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { private submit( input: TurnMessageSubmitInput, context: ConnectionContext, + admission?: SessionAdmissionLease, ): Promise> { const payload = canonicalSubmitPayload(input); const isCurrentEpoch = input.originHostEpoch === this.#hostEpoch; @@ -790,9 +805,11 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { if (this.#failStopped) { return Promise.resolve(failure('host_draining', 'Runtime Host message authority has failed')); } - if (!isCurrentEpoch) return this.#submitAdmitted(input, payload, context.connectionId); + if (!isCurrentEpoch) { + return this.#submitAdmitted(input, payload, context.connectionId, admission); + } const key = operationKey(input.sessionId, input.messageId); - const result = this.#submitAdmitted(input, payload, context.connectionId); + const result = this.#submitAdmitted(input, payload, context.connectionId, admission); this.#pendingSubmits.set(key, { payload, result }); void result.then( () => this.#deletePendingSubmit(key, result), @@ -805,8 +822,11 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { input: TurnMessageSubmitInput, payload: CanonicalSubmitPayload, initiatingConnectionId: string, + admittedLease?: SessionAdmissionLease, ): Promise> { - return this.#sessionAdmission.run(input.sessionId, async (admission) => { + const execute = async ( + admission: SessionAdmissionLease, + ): Promise> => { if (this.#failStopped) { return failure('host_draining', 'Runtime Host message authority has failed'); } @@ -1074,7 +1094,12 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { ); return success(result); } - }); + }; + return admittedLease + ? this.#sessionAdmission.runAdmitted(input.sessionId, admittedLease, () => + execute(admittedLease), + ) + : this.#sessionAdmission.run(input.sessionId, execute); } private retract(input: QueueRetractInput): Promise> { diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 88fea1b3c4..7264c27a88 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -49,6 +49,7 @@ import { type SessionHeaderSnapshot, type ExecutionStoresWriter, } from '@maka/storage/execution-stores'; +import type { CreateStableSessionRequest } from '@maka/storage/session-store'; import type { RuntimePolicyStoresWriter } from '@maka/storage/runtime-policy-stores'; import { SessionConfigurationRevisionConflictError, @@ -148,10 +149,6 @@ 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 = { @@ -204,29 +201,36 @@ 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 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, - () => { - retired = true; - }, - ); - return { - outcome, - ...(discardRevision === undefined ? {} : { discardRevision }), - ...(retired ? { retired } : {}), - }; + createForWorkHub(input: SessionCreateInput): Promise> { + return this.#create(input); + } + + /** Prepare external facts before WorkHub commits create + assignment atomically. */ + async prepareWorkHubCreate(input: SessionCreateInput): Promise { + const prepared = await prepareCreate(input); + return this.#workspaceResolver.runWithUsageRecorded(input.workspace, async (workspace) => { + const [model, policy] = await Promise.all([ + this.#resolveModel(input.modelTarget, input.thinkingLevel), + this.#readRuntimePolicy(), + ]); + return { + sessionId: input.sessionId, + requestFingerprint: createRequestFingerprint(input, prepared), + input: { + cwd: workspace.cwd, + ...(workspace.projectId === null ? {} : { projectId: workspace.projectId }), + name: prepared.name, + labels: [...prepared.labels], + llmConnectionSlug: model.connectionSlug, + model: model.model, + ...(input.thinkingLevel === undefined ? {} : { thinkingLevel: input.thinkingLevel }), + ...(input.toolProfile === undefined ? {} : { toolProfile: input.toolProfile }), + permissionMode: prepared.permissionMode ?? policy.policy.chatDefaults.permissionMode, + collaborationMode: input.collaborationMode ?? 'agent', + orchestrationMode: input.orchestrationMode ?? 'default', + }, + }; + }); } async #query( @@ -361,12 +365,7 @@ export class HostSessionCatalogCoordinator { } } - async #create( - input: SessionCreateInput, - onCreated?: (revision: number) => void, - onPristineReplay?: (revision: number) => void, - onRetiredReplay?: () => void, - ): Promise> { + async #create(input: SessionCreateInput): Promise> { if (isWorkHubCoordinationSessionId(input.sessionId)) { return createFailure( 'operation_conflict', @@ -389,15 +388,11 @@ 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)), ); } if (probe.kind === 'conflict') { - if (probe.reason === 'removed') onRetiredReplay?.(); return createFailure( 'operation_conflict', 'Session identity belongs to a different create request', @@ -430,19 +425,11 @@ 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', ); } - 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 8f8446e53d..dfab6e260a 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -21,9 +21,9 @@ import { createHash } from 'node:crypto'; import type { SessionHeader, SessionStatus, - WorkHubDelegationAbandonedMessage, - WorkHubDelegationCommittedMessage, - WorkHubDelegationIntentMessage, + WorkHubDelegationAssignedMessage, + WorkHubDelegationCreateSpec, + WorkHubDelegationDisposition, } from '@maka/core/session'; import { WORKHUB_COORDINATION_SESSION_ID, @@ -61,6 +61,7 @@ export type WorkHubActionGateSession = Pick< export interface WorkHubActionGateEffects { listSessions(): Promise; + readAssignment(actionId: string): Promise; answer( input: { readonly turnId: string; readonly text: string }, context: ConnectionContext, @@ -70,60 +71,21 @@ export interface WorkHubActionGateEffects { readonly userText: string; readonly assistantText: string; }): Promise; - create(input: { - readonly sessionId: string; - readonly workspace: WorkspaceTarget; - readonly title: string; - }): Promise< - { readonly kind: 'available'; readonly discardRevision?: number } | { readonly kind: 'retired' } - >; - discardCreated( - input: { - readonly sessionId: string; - readonly expectedRevision: number; - }, - context: ConnectionContext, - ): Promise; - submit( - input: { - readonly sessionId: string; - readonly messageId: string; - readonly text: string; - }, + assign( + input: WorkHubDelegationAssignmentInput, 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; - abandonDelegation(abandoned: WorkHubDelegationAbandoned): Promise; } -type StoredDelegationEnvelopeKeys = 'type' | 'id' | 'turnId' | 'ts' | 'schemaVersion'; - -export type WorkHubDelegationIntent = Omit< - WorkHubDelegationIntentMessage, - StoredDelegationEnvelopeKeys ->; - -export type WorkHubDelegationCommit = Omit< - WorkHubDelegationCommittedMessage, - StoredDelegationEnvelopeKeys ->; - -export type WorkHubDelegationAbandoned = Omit< - WorkHubDelegationAbandonedMessage, - StoredDelegationEnvelopeKeys ->; - -export type WorkHubDelegationRecord = - | WorkHubDelegationIntent - | WorkHubDelegationCommit - | WorkHubDelegationAbandoned; +export interface WorkHubDelegationAssignmentInput { + readonly actionId: string; + readonly actionFingerprint: `sha256:${string}`; + readonly targetSessionId: string; + readonly targetSessionName: string; + readonly disposition: WorkHubDelegationDisposition; + readonly userText: string; + readonly create?: WorkHubDelegationCreateSpec; +} export type WorkHubActionEffectFailureCode = | 'host_not_ready' @@ -166,7 +128,7 @@ export class WorkHubActionGateFailure extends Error { } interface ActionReplay { - readonly fingerprint: string; + readonly requestFingerprint: string; readonly result: Promise; } @@ -204,9 +166,10 @@ export class WorkHubCoordinationActionGate { ); } const fingerprint = actionFingerprint(input); + const requestFingerprint = digest(input); const replay = this.#actions.get(input.actionId); if (replay) { - if (replay.fingerprint !== fingerprint) { + if (replay.requestFingerprint !== requestFingerprint) { return Promise.reject( new WorkHubActionGateFailure( 'action_conflict', @@ -218,11 +181,11 @@ export class WorkHubCoordinationActionGate { } const result = this.#act(input, fingerprint, context); - const action = { fingerprint, result }; + const action = { requestFingerprint, result }; this.#actions.set(input.actionId, action); - // 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. + // Successful actions remain a Host-lifetime fast path. Rejections release + // the slot so a pre-assignment admission can retry; once assigned, SQLite + // independently owns the durable action identity. void result.catch(() => { if (this.#actions.get(input.actionId) === action) { this.#actions.delete(input.actionId); @@ -238,7 +201,7 @@ export class WorkHubCoordinationActionGate { context: ConnectionContext, ): Promise { const proposal = input.proposal; - const durable = await this.#effects.readDelegation(input.actionId); + const durable = await this.#effects.readAssignment(input.actionId); if (durable) { if (durable.actionFingerprint !== fingerprint) { throw new WorkHubActionGateFailure( @@ -246,13 +209,7 @@ export class WorkHubCoordinationActionGate { 'WorkHub action identity belongs to a different proposal', ); } - if (durable.kind === 'delegation_committed') { - return committedResult(durable); - } - if (durable.kind === 'delegation_abandoned') { - throw abandonedAction(); - } - return this.#executeDelegation(durable, context); + return this.#assign(assignmentInputFromRecord(durable), context); } if (proposal.disposition === 'answer_here') { const turnId = coordinationTurnId(input.actionId, 'answer'); @@ -276,9 +233,10 @@ export class WorkHubCoordinationActionGate { ); } const sessionId = workHubCreatedSessionId(input.actionId); - const intent = delegationIntent(input, fingerprint, sessionId); - await this.#effects.prepareDelegation(intent); - return this.#executeDelegation(intent, context); + return this.#assign( + delegationAssignment(input, fingerprint, sessionId, proposal.title), + context, + ); } const candidates = await this.candidates(); @@ -299,102 +257,23 @@ export class WorkHubCoordinationActionGate { } this.#assertTarget(target); - const intent = delegationIntent(input, fingerprint, target.sessionId); - await this.#effects.prepareDelegation(intent); - return this.#executeDelegation(intent, context); + return this.#assign( + delegationAssignment(input, fingerprint, target.sessionId, target.sessionName), + context, + ); } - async #executeDelegation( - intent: WorkHubDelegationIntent, + async #assign( + assignment: WorkHubDelegationAssignmentInput, context: ConnectionContext, ): Promise { - let discardRevision: number | undefined; - if (intent.disposition === 'create_new') { - if (!intent.create) { - throw new WorkHubActionGateFailure( - 'action_conflict', - 'WorkHub durable creation intent is incomplete', - ); - } - const created = await this.#effects.create({ - sessionId: intent.targetSessionId, - 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( - 'action_conflict', - 'WorkHub durable delegation intent contains creation context', - ); - } - - const message = { - sessionId: intent.targetSessionId, - messageId: actionMessageId(intent.actionId), - text: intent.userText, - }; - 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 (isDefinitiveSubmissionFailure(error.code)) { - if (discardRevision !== undefined) { - 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; - } - } - const commit: WorkHubDelegationCommit = { - ...intent, - kind: 'delegation_committed', - delegationId: delegationId(intent.actionId), - targetTurnId: submitted.turnId, - ...(submitted.steered ? { steered: true as const } : {}), - }; - await this.#effects.commitDelegation(commit); - return committedResult(commit); - } - - async #abandonDelegation( - intent: WorkHubDelegationIntent, - reason: WorkHubDelegationAbandoned['reason'], - ): Promise { - await this.#effects.abandonDelegation({ - ...intent, - kind: 'delegation_abandoned', - reason, - }); + const admitted = await this.#effects.assign(assignment, context); + return { + disposition: assignment.disposition, + targetSessionId: assignment.targetSessionId, + targetTurnId: admitted.turnId, + ...(admitted.steered ? { steered: true as const } : {}), + } as WorkHubCoordinationActResult; } #assertTarget(target: WorkHubCoordinationCandidate): void { @@ -418,19 +297,6 @@ 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 { @@ -478,19 +344,12 @@ function coordinationTurnId(actionId: string, kind: 'answer' | 'clarify'): strin return `wha_${hash(`${actionId}\0${kind}`).slice(0, 48)}`; } -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( +function delegationAssignment( input: WorkHubCoordinationActInput, actionFingerprint: `sha256:${string}`, targetSessionId: string, -): WorkHubDelegationIntent { + targetSessionName: string, +): WorkHubDelegationAssignmentInput { const create = input.create; if ( input.proposal.disposition !== 'delegate_existing' && @@ -502,11 +361,10 @@ function delegationIntent( ); } const base = { - kind: 'delegation_intent', actionId: input.actionId, actionFingerprint, - coordinationTurnId: input.actionId, targetSessionId, + targetSessionName, disposition: input.proposal.disposition, userText: input.userText, } as const; @@ -548,15 +406,6 @@ function updatedAt(session: WorkHubActionGateSession): number { return session.lastMessageAt ?? session.statusUpdatedAt ?? session.createdAt; } -function committedResult(commit: WorkHubDelegationCommit): WorkHubCoordinationActResult { - return { - disposition: commit.disposition, - targetSessionId: commit.targetSessionId, - targetTurnId: commit.targetTurnId, - ...(commit.steered ? { steered: true as const } : {}), - } as WorkHubCoordinationActResult; -} - function digest(value: unknown): `sha256:${string}` { return `sha256:${hash(JSON.stringify(value))}`; } @@ -565,12 +414,32 @@ function actionFingerprint(input: WorkHubCoordinationActInput): `sha256:${string return digest({ userText: input.userText, disposition: input.proposal.disposition, + ...(input.proposal.disposition === 'create_new' + ? { + title: input.proposal.title, + workspace: input.create?.workspace, + } + : {}), ...(input.proposal.disposition === 'clarify' ? { assistantText: input.proposal.assistantText } : {}), }); } +function assignmentInputFromRecord( + assignment: WorkHubDelegationAssignedMessage, +): WorkHubDelegationAssignmentInput { + return { + actionId: assignment.actionId, + actionFingerprint: assignment.actionFingerprint, + targetSessionId: assignment.targetSessionId, + targetSessionName: assignment.targetSessionName, + disposition: assignment.disposition, + userText: assignment.userText, + ...(assignment.create ? { create: assignment.create } : {}), + }; +} + function hash(value: string): string { return createHash('sha256').update(value, 'utf8').digest('hex'); } diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 72886effa9..065b1806bb 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -55,7 +55,6 @@ 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') @@ -83,6 +82,7 @@ type CoordinationStores = Pick< | 'listHeaders' | 'probeStableSessionCreate' | 'readHeaderSnapshot' + | 'readWorkHubAssignment' | 'readTranscriptHighWaterSnapshot' | 'readTranscriptMessagesSnapshot' | 'updateHeaderVersioned' @@ -101,10 +101,7 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly admission: SessionAdmissionGate; readonly continuity: Pick; readonly executions: CoordinationExecutions; - readonly sessionActions: Pick< - WorkHubActionGateEffects, - 'create' | 'discardCreated' | 'submit' | 'recoverSubmission' - >; + readonly sessionActions: Pick; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; } @@ -127,7 +124,6 @@ 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); @@ -137,14 +133,9 @@ 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(), + readAssignment: (actionId) => this.#stores.readWorkHubAssignment(actionId), answer: async (input, context) => { const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); if (!outcome.ok) { @@ -161,14 +152,7 @@ export class HostWorkHubCoordinationCoordinator { throw new WorkHubActionEffectFailure(outcome.error.code, outcome.error.message); } }, - create: options.sessionActions.create, - discardCreated: options.sessionActions.discardCreated, - 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), - abandonDelegation: (abandoned) => this.#delegations.abandon(abandoned), + assign: options.sessionActions.assign, }); } diff --git a/packages/runtime-host/src/server/workhub-delegation-journal.ts b/packages/runtime-host/src/server/workhub-delegation-journal.ts deleted file mode 100644 index 7db3985648..0000000000 --- a/packages/runtime-host/src/server/workhub-delegation-journal.ts +++ /dev/null @@ -1,389 +0,0 @@ -/* - * 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 { isDeepStrictEqual } from 'node:util'; -import { - WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION, - WORKHUB_COORDINATION_SESSION_ID, - isWorkHubCoordinationSession, - type StoredMessage, - type WorkHubDelegationAbandonedMessage, - 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 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', '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; - -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?.kind === 'delegation_abandoned') throw actionConflict(); - 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', - ); - } - }); - } - - 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); - 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 { - return projectRecord(actionId, messages); - } -} - -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', - ); - 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) + - Number(abandoned !== undefined) || - intent?.actionId !== actionId || - (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) - : abandoned - ? abandonedRecord(abandoned) - : 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 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, - actionId: message.actionId, - actionFingerprint: message.actionFingerprint, - coordinationTurnId: message.coordinationTurnId, - targetSessionId: message.targetSessionId, - disposition: message.disposition, - userText: message.userText, - ...(message.create ? { create: message.create } : {}), - }; -} - -function commitRecord(message: WorkHubDelegationCommittedMessage): WorkHubDelegationCommit { - return { - kind: 'delegation_committed', - actionId: message.actionId, - actionFingerprint: message.actionFingerprint, - 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 } : {}), - }; -} - -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, - terminal: WorkHubDelegationCommittedMessage | WorkHubDelegationAbandonedMessage, -): boolean { - return sameIntent( - intentRecord(intent), - terminal.kind === 'delegation_committed' ? commitRecord(terminal) : abandonedRecord(terminal), - ); -} - -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 && - left.userText === right.userText && - isDeepStrictEqual(left.create, right.create) - ); -} - -function sameCommit(left: WorkHubDelegationCommit, right: WorkHubDelegationCommit): boolean { - return ( - sameIntent(left, right) && - left.delegationId === right.delegationId && - left.targetTurnId === right.targetTurnId && - left.steered === right.steered - ); -} - -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') - .digest('hex') - .slice(0, 48)}`; -} - -function actionConflict(): WorkHubActionEffectFailure { - return new WorkHubActionEffectFailure( - 'operation_conflict', - 'WorkHub action identity belongs to different durable delegation content', - ); -} diff --git a/packages/runtime-host/src/server/workhub-target-submission-recovery.ts b/packages/runtime-host/src/server/workhub-target-submission-recovery.ts deleted file mode 100644 index 14fe692897..0000000000 --- a/packages/runtime-host/src/server/workhub-target-submission-recovery.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * 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 type { PendingMessageAdmission } from '@maka/storage/execution-stores'; -import { WorkHubActionEffectFailure } from './workhub-coordination-action-gate.js'; - -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 (admission.disposition !== 'steering' || !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; -} diff --git a/packages/storage/src/__tests__/workhub-message-assignment.test.ts b/packages/storage/src/__tests__/workhub-message-assignment.test.ts new file mode 100644 index 0000000000..a6fef6e413 --- /dev/null +++ b/packages/storage/src/__tests__/workhub-message-assignment.test.ts @@ -0,0 +1,198 @@ +/* + * 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 { createHash } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { messageContentDigest, normalizeMessageContent } from '@maka/core/events'; +import { + WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_SESSION_ROLE, + type WorkHubDelegationAssignedMessage, +} from '@maka/core/session'; +import { createSessionStore, isSessionNotFoundError } from '../session-store.js'; + +test('atomically commits one WorkHub assignment and target admission', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-assignment-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const target = await store.create({ + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }); + const request = assignmentRequest('action-one', target.id, 'Payments', 'target-turn'); + const first = await store.assignWorkHubMessage(request); + const replay = await store.assignWorkHubMessage({ + ...request, + assignment: { + ...request.assignment, + ts: request.assignment.ts + 10, + targetTurnId: 'recomputed-turn', + targetSessionName: 'Recomputed name', + }, + admission: { + ...request.admission, + turnId: 'recomputed-turn', + runId: 'recomputed-run', + }, + }); + + assert.equal(first.kind, 'assigned'); + assert.equal(replay.kind, 'existing'); + assert.equal(replay.assignment.targetTurnId, 'target-turn'); + assert.deepEqual( + await store.readMessageAdmission(target.id, request.admission.messageId), + request.admission, + ); + assert.deepEqual( + (await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).filter( + (message) => message.type === 'workhub_coordination', + ), + [request.assignment], + ); + const coordination = await store.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID); + assert.equal(coordination.lastMessageAt, request.assignment.ts); + await store.markMessagesHandedOff({ + sessionId: target.id, + messageIds: [request.admission.messageId], + turnId: request.admission.turnId, + }); + const replayAfterConsumption = await store.assignWorkHubMessage(request); + assert.equal(replayAfterConsumption.kind, 'existing'); + assert.deepEqual(replayAfterConsumption.assignment, request.assignment); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('rolls create_new Session back when assignment validation fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-create-assignment-')); + const store = createSessionStore(root); + try { + await createCoordinationSession(store, root); + const request = assignmentRequest( + 'create-action', + 'created-target', + 'Wrong name', + 'target-turn', + ); + await assert.rejects( + store.assignWorkHubMessage({ + ...request, + assignment: { + ...request.assignment, + disposition: 'create_new', + create: { + title: 'Actual name', + workspace: { kind: 'host_path', path: root }, + }, + }, + create: { + sessionId: 'created-target', + requestFingerprint: `sha256:${'b'.repeat(64)}`, + input: { + cwd: root, + name: 'Actual name', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }, + }, + }), + /display identity changed/u, + ); + await assert.rejects(store.readHeaderSnapshot('created-target'), (error) => + isSessionNotFoundError(error), + ); + assert.deepEqual(await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID), []); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +async function createCoordinationSession( + store: ReturnType, + root: string, +): Promise { + await store.createStableSession({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + requestFingerprint: `sha256:${'a'.repeat(64)}`, + input: { + cwd: root, + name: 'WorkHub', + role: WORKHUB_COORDINATION_SESSION_ROLE, + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'explore', + toolProfile: 'workhub-coordination-v1', + }, + }); +} + +function assignmentRequest( + actionId: string, + targetSessionId: string, + targetSessionName: string, + targetTurnId: string, +) { + const suffix = createHash('sha256').update(actionId, 'utf8').digest('hex').slice(0, 48); + const content = normalizeMessageContent({ text: 'Continue payment work' }); + const assignment: WorkHubDelegationAssignedMessage = { + type: 'workhub_coordination', + id: `wha_${suffix}`, + turnId: actionId, + ts: 10, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId, + actionFingerprint: `sha256:${'c'.repeat(64)}`, + coordinationTurnId: actionId, + targetSessionId, + targetSessionName, + targetTurnId, + targetMessageId: `whm_${suffix}`, + delegationId: `whd_${suffix}`, + disposition: 'delegate_existing', + userText: content.text, + }; + return { + assignment, + admission: { + sessionId: targetSessionId, + turnId: targetTurnId, + runId: `whr_${suffix}`, + messageId: assignment.targetMessageId, + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn' as const, + placement: 'current_turn' as const, + disposition: 'steering' as const, + admittedAt: 10, + }, + }; +} diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 14462e5363..9dab9563d2 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -353,6 +353,8 @@ async function createExecutionStoresForWrite sessionStore.probeStableSessionCreate(sessionId, requestFingerprint)), createStableSession: (request, initialBoundary) => run(() => sessionStore.createStableSession(request, initialBoundary)), + assignWorkHubMessage: (request) => run(() => sessionStore.assignWorkHubMessage(request)), + readWorkHubAssignment: (actionId) => run(() => sessionStore.readWorkHubAssignment(actionId)), discardStableConversationCopy: (sessionId, requestFingerprint) => run(() => sessionStore.discardStableConversationCopy(sessionId, requestFingerprint)), createSubagent: (input, initialBoundary) => diff --git a/packages/storage/src/session-message-projection.ts b/packages/storage/src/session-message-projection.ts index 11ba43fde8..581ba6cc75 100644 --- a/packages/storage/src/session-message-projection.ts +++ b/packages/storage/src/session-message-projection.ts @@ -40,7 +40,13 @@ export function catalogPreviewForUserMessage(message: UserMessage): string | und export function latestVisibleMessageAt(messages: readonly StoredMessage[]): number | undefined { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]!; - if (message.type === 'user' || message.type === 'assistant') return message.ts; + if ( + message.type === 'user' || + message.type === 'assistant' || + message.type === 'workhub_coordination' + ) { + return message.ts; + } } return undefined; } @@ -64,6 +70,10 @@ export function lastMessagePreviewForMessages( const text = normalizePreviewText(message.text); if (text) return truncatePreview(text); } + if (message.type === 'workhub_coordination') { + const text = normalizePreviewText(message.userText); + if (text) return truncatePreview(text); + } } return undefined; } diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index e77d5dc093..f70f4504b0 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -47,6 +47,7 @@ import { isSessionStatus, isWorkHubCoordinationSessionId, subagentSessionRuntimeSummary, + WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_SESSION_ROLE, } from '@maka/core/session'; import { isCollaborationMode } from '@maka/core/collaboration'; @@ -82,6 +83,7 @@ import { type TurnRecord, type TurnStateMessage, type UserMessage, + type WorkHubDelegationAssignedMessage, } from '@maka/core/session'; import type { MessageAdmissionStore, PendingMessageAdmission } from './message-admission-store.js'; import { @@ -175,6 +177,19 @@ export interface CreateStableSessionRequest { readonly input: StableSessionCreateInput; } +export interface WorkHubMessageAssignmentRequest { + readonly assignment: WorkHubDelegationAssignedMessage; + readonly admission: PendingMessageAdmission; + /** Present exactly when the assignment creates its target Session. */ + readonly create?: CreateStableSessionRequest; +} + +export interface WorkHubMessageAssignmentResult { + readonly kind: 'assigned' | 'existing'; + readonly targetCreated: boolean; + readonly assignment: WorkHubDelegationAssignedMessage; +} + export type StableSessionCreateInput = CreateSessionInput & { readonly conversationCopy?: SessionConversationCopy; readonly role?: SessionRole; @@ -373,6 +388,11 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto request: CreateStableSessionRequest, initialBoundary?: ExecutionBoundary, ): Promise; + /** Atomically persist a WorkHub linkage and the target Message admission. */ + assignWorkHubMessage( + request: WorkHubMessageAssignmentRequest, + ): Promise; + readWorkHubAssignment(actionId: string): Promise; discardStableConversationCopy(sessionId: string, requestFingerprint: string): Promise; listCatalogPage( filter: SessionListFilter | undefined, @@ -576,6 +596,64 @@ class SqliteSessionStore implements SessionAuthorityStore { : result; } + async assignWorkHubMessage( + request: WorkHubMessageAssignmentRequest, + ): Promise { + await this.ensureReady(); + const create = request.create; + if (create) { + assertCoordinationIdentityPairing(create.sessionId, create.input.role); + if (create.sessionId !== request.assignment.targetSessionId) { + throw new Error('WorkHub assignment create identity does not match its target'); + } + } + const result = await this.metadata.assignWorkHubMessage({ + assignment: request.assignment, + admission: request.admission, + projection: projectSessionCatalogMessages([request.assignment]), + ...(create + ? { + create: { + header: buildSessionHeader( + this.workspaceRoot, + create.input, + create.sessionId, + create.input.conversationCopy, + ), + requestFingerprint: create.requestFingerprint, + }, + } + : {}), + }); + if (result.kind === 'assigned') { + for (const listener of this.transcriptChangeListeners) { + listener(WORKHUB_COORDINATION_SESSION_ID); + } + } + return result; + } + + async readWorkHubAssignment( + actionId: string, + ): Promise { + await this.ensureReady(); + const suffix = createHash('sha256').update(actionId, 'utf8').digest('hex').slice(0, 48); + const throughSequence = await this.metadata.readTranscriptHighWater( + WORKHUB_COORDINATION_SESSION_ID, + ); + if (throughSequence === null) return undefined; + const messages = await this.metadata.readTranscriptMessages(WORKHUB_COORDINATION_SESSION_ID, { + messageIds: [`wha_${suffix}`], + throughSequence, + maxMessages: 1, + maxBytes: 768 * 1024, + }); + const message = messages[0]; + return message?.type === 'workhub_coordination' && message.kind === 'delegation_assigned' + ? message + : undefined; + } + async discardStableConversationCopy( sessionId: string, requestFingerprint: string, diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 9a39e05a65..da49f651a7 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 33; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 34; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -1216,6 +1216,15 @@ const MIGRATIONS: ReadonlyMap = new Map([ AND json_extract(payload_json, '$.subagentParent') IS NOT NULL; `, ], + [ + 34, + ` + -- WorkHub delegation_assigned records are decoded by schema-aware builds. + -- Advancing the profile schema prevents an older build from opening a + -- transcript containing this new canonical message type. + SELECT 1; + `, + ], ]); if (MIGRATIONS.size !== SQLITE_SESSION_METADATA_SCHEMA_VERSION) { diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 59b34eae88..dd053e6b99 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -92,6 +92,9 @@ import { type SessionHeaderPatch, type StoredMessage, type SubagentSessionParent, + type WorkHubDelegationAssignedMessage, + WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_SESSION_ROLE, decodeCanonicalMessage, decodeStoredMessage as decodePersistedStoredMessage, } from '@maka/core/session'; @@ -102,7 +105,11 @@ import { type PendingMessageAdmission, } from './message-admission-store.js'; import { normalizeSubmittedTurnIntent } from './submitted-turn-intent.js'; -import { messageContentsEqual, normalizeMessageContent } from '@maka/core/events'; +import { + messageContentDigest, + messageContentsEqual, + normalizeMessageContent, +} from '@maka/core/events'; import { type AgentGraphIntentAdmissionSnapshot, type AgentGraphTimelineMetadataSnapshot, @@ -208,6 +215,22 @@ export interface SqliteSessionMetadataStoreOptions { databaseLease?: OperationalStateDatabaseLease; } +export interface SqliteWorkHubMessageAssignmentRequest { + readonly assignment: WorkHubDelegationAssignedMessage; + readonly admission: PendingMessageAdmission; + readonly projection: SessionCatalogMessageProjection; + readonly create?: { + readonly header: SessionHeader; + readonly requestFingerprint: string; + }; +} + +export interface SqliteWorkHubMessageAssignmentResult { + readonly kind: 'assigned' | 'existing'; + readonly targetCreated: boolean; + readonly assignment: WorkHubDelegationAssignedMessage; +} + export interface SessionMetadataRecord { header: SessionHeader; metadataVersion: number; @@ -1575,70 +1598,225 @@ export class SqliteSessionMetadataStore { const stored = normalizePendingMessageAdmission(admission); return this.transaction(() => { if (!this.readRecordSync(stored.sessionId)) throw new SessionNotFoundError(stored.sessionId); - const existingRow = this.db - .prepare( - ` - SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, - submitted_placement, placement, disposition, queue_order, admitted_at, - submitted_intent_json - FROM message_admissions - WHERE session_id = ? AND message_id = ? - `, - ) - .get(stored.sessionId, stored.messageId) as MessageAdmissionRow | undefined; - if (existingRow) { - const existing = decodeMessageAdmissionRow(stored.sessionId, existingRow); + const existing = this.readMessageAdmissionSync(stored.sessionId, stored.messageId); + if (existing) { if (!samePendingMessageAdmission(existing, stored)) { throw new SessionMetadataConflictError('Message admission identity conflict'); } return existing; } - const cancelled = this.db - .prepare( - 'SELECT 1 AS present FROM cancelled_message_admissions WHERE session_id = ? AND message_id = ?', - ) - .get(stored.sessionId, stored.messageId); - if (cancelled) { - throw new SessionMetadataConflictError('Message admission identity is already cancelled'); - } - const orderRow = this.db - .prepare( - ` + this.insertMessageAdmissionSync(stored); + return stored; + }); + } + + private readMessageAdmissionSync( + sessionId: string, + messageId: string, + ): PendingMessageAdmission | undefined { + const row = this.db + .prepare( + ` + SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, + submitted_placement, placement, disposition, queue_order, admitted_at, + submitted_intent_json + FROM message_admissions + WHERE session_id = ? AND message_id = ? + `, + ) + .get(sessionId, messageId) as MessageAdmissionRow | undefined; + return row ? decodeMessageAdmissionRow(sessionId, row) : undefined; + } + + private insertMessageAdmissionSync(stored: PendingMessageAdmission): void { + const cancelled = this.db + .prepare( + 'SELECT 1 AS present FROM cancelled_message_admissions WHERE session_id = ? AND message_id = ?', + ) + .get(stored.sessionId, stored.messageId); + if (cancelled) { + throw new SessionMetadataConflictError('Message admission identity is already cancelled'); + } + const orderRow = this.db + .prepare( + ` SELECT COALESCE(MAX(queue_order), -1) + 1 AS next_order FROM message_admissions WHERE session_id = ? `, - ) - .get(stored.sessionId) as { next_order?: unknown }; - if (typeof orderRow.next_order !== 'number' || !Number.isSafeInteger(orderRow.next_order)) { - throw new SessionMetadataConflictError('Invalid message admission order'); - } - this.db - .prepare( - ` + ) + .get(stored.sessionId) as { next_order?: unknown }; + if (typeof orderRow.next_order !== 'number' || !Number.isSafeInteger(orderRow.next_order)) { + throw new SessionMetadataConflictError('Invalid message admission order'); + } + this.db + .prepare( + ` INSERT INTO message_admissions( session_id, turn_id, run_id, message_id, content_json, submitted_content_digest, submitted_placement, placement, disposition, queue_order, admitted_at, submitted_intent_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - ) - .run( - stored.sessionId, - stored.turnId, - stored.runId, - stored.messageId, - JSON.stringify(stored.content), - stored.submittedContentDigest, - stored.submittedPlacement, - stored.placement, - stored.disposition, - orderRow.next_order, - stored.admittedAt, - stored.submittedIntent ? JSON.stringify(stored.submittedIntent) : null, + ) + .run( + stored.sessionId, + stored.turnId, + stored.runId, + stored.messageId, + JSON.stringify(stored.content), + stored.submittedContentDigest, + stored.submittedPlacement, + stored.placement, + stored.disposition, + orderRow.next_order, + stored.admittedAt, + stored.submittedIntent ? JSON.stringify(stored.submittedIntent) : null, + ); + } + + async assignWorkHubMessage( + request: SqliteWorkHubMessageAssignmentRequest, + ): Promise { + const assignmentJson = JSON.stringify(request.assignment); + const assignment = decodeCanonicalMessage(JSON.parse(assignmentJson) as unknown); + const admission = normalizePendingMessageAdmission(request.admission); + const suffix = createHash('sha256') + .update(request.assignment.actionId) + .digest('hex') + .slice(0, 48); + if ( + assignment.type !== 'workhub_coordination' || + assignment.kind !== 'delegation_assigned' || + assignment.targetSessionId !== admission.sessionId || + assignment.targetTurnId !== admission.turnId || + assignment.targetMessageId !== admission.messageId || + assignment.id !== `wha_${suffix}` || + assignment.targetMessageId !== `whm_${suffix}` || + assignment.delegationId !== `whd_${suffix}` || + !messageContentsEqual( + admission.content, + normalizeMessageContent({ text: assignment.userText }), + ) || + admission.submittedContentDigest !== messageContentDigest(admission.content) || + admission.submittedPlacement !== 'current_turn' || + admission.placement !== 'current_turn' || + admission.disposition !== 'steering' + ) { + throw new SessionMetadataConflictError('Invalid WorkHub assignment identity'); + } + const create = request.create + ? { + header: normalizeSessionHeader(request.create.header), + requestFingerprint: request.create.requestFingerprint, + } + : undefined; + if (create) { + assertSessionCreateFingerprint(create.requestFingerprint); + if (create.header.id !== assignment.targetSessionId) { + throw new SessionMetadataConflictError('WorkHub create identity does not match target'); + } + } + assertCatalogMessageProjection(request.projection); + if ((assignment.disposition === 'create_new') !== Boolean(create)) { + throw new SessionMetadataConflictError( + 'WorkHub create request does not match assignment disposition', + ); + } + + return this.transaction(() => { + const coordination = this.readRecordSync(WORKHUB_COORDINATION_SESSION_ID); + if ( + !coordination || + coordination.header.role !== WORKHUB_COORDINATION_SESSION_ROLE || + coordination.header.isArchived + ) { + throw new SessionMetadataConflictError('WorkHub Coordination Session is unavailable'); + } + + const existingAssignment = this.readMessageByIdSync( + WORKHUB_COORDINATION_SESSION_ID, + assignment.id, + ); + if (existingAssignment) { + if ( + existingAssignment.type !== 'workhub_coordination' || + existingAssignment.kind !== 'delegation_assigned' || + !sameWorkHubAssignmentRequest(existingAssignment, assignment) + ) { + throw new SessionMetadataConflictError( + 'WorkHub action identity belongs to a different assignment', + ); + } + return { + kind: 'existing' as const, + targetCreated: false, + assignment: existingAssignment, + }; + } + + let targetCreated = false; + if (create) { + const probe = this.probeStableSessionCreateSync( + create.header.id, + create.requestFingerprint, ); + if (probe.kind === 'conflict') { + throw new SessionMetadataConflictError( + 'WorkHub target Session identity belongs to a different create request', + ); + } + if (probe.kind === 'absent') { + const committedAt = this.now(); + this.db + .prepare( + ` + INSERT INTO session_create_claims(session_id, request_fingerprint, claimed_at) + VALUES (?, ?, ?) + `, + ) + .run(create.header.id, create.requestFingerprint, committedAt); + this.insertHeader(create.header, 1, committedAt); + targetCreated = true; + } + } - return stored; + const target = this.readRecordSync(assignment.targetSessionId); + if (!target || target.header.isArchived) { + throw new SessionMetadataConflictError('WorkHub target Session is unavailable'); + } + if (target.header.status === 'waiting_for_user') { + throw new SessionMetadataConflictError('WorkHub target Session is waiting for user input'); + } + if (target.header.name !== assignment.targetSessionName) { + throw new SessionMetadataConflictError('WorkHub target display identity changed'); + } + if (this.readMessageAdmissionSync(admission.sessionId, admission.messageId)) { + throw new SessionMetadataConflictError( + 'WorkHub target Message identity belongs to another admission', + ); + } + + this.insertMessageAdmissionSync(admission); + const sequenceRow = this.db + .prepare( + 'SELECT COALESCE(MAX(sequence), -1) AS last_sequence FROM session_messages WHERE session_id = ?', + ) + .get(WORKHUB_COORDINATION_SESSION_ID) as { last_sequence?: unknown }; + if ( + typeof sequenceRow.last_sequence !== 'number' || + !Number.isSafeInteger(sequenceRow.last_sequence) || + sequenceRow.last_sequence < -1 + ) { + throw new SessionMetadataConflictError('Invalid WorkHub transcript sequence'); + } + this.insertSessionMessagesSync( + WORKHUB_COORDINATION_SESSION_ID, + sequenceRow.last_sequence + 1, + [{ message: assignment, json: assignmentJson }], + ); + this.updateCatalogProjectionSync(WORKHUB_COORDINATION_SESSION_ID, request.projection, false); + return { kind: 'assigned' as const, targetCreated, assignment }; }); } @@ -4634,6 +4812,21 @@ export class SqliteSessionMetadataStore { return row ? decodeRecord(row) : undefined; } + private readMessageByIdSync(sessionId: string, messageId: string): StoredMessage | undefined { + const row = this.db + .prepare( + ` + SELECT message.sequence, message.record_json, payload.record_bytes, payload.sha256 + FROM session_messages AS message + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE message.session_id = ? AND message.message_id = ? + `, + ) + .get(sessionId, messageId) as StoredSessionMessagePayloadRow | undefined; + return row ? decodeStoredMessageRecordRow(this.db, sessionId, row) : undefined; + } + private insertSessionMessagesSync( sessionId: string, firstSequence: number, @@ -6260,6 +6453,32 @@ function readStoredMessageRecordJson( return recordJson; } +function sameWorkHubAssignmentRequest( + existing: WorkHubDelegationAssignedMessage, + requested: WorkHubDelegationAssignedMessage, +): boolean { + return isDeepStrictEqual( + { + actionId: existing.actionId, + actionFingerprint: existing.actionFingerprint, + coordinationTurnId: existing.coordinationTurnId, + targetSessionId: existing.targetSessionId, + disposition: existing.disposition, + userText: existing.userText, + create: existing.create, + }, + { + actionId: requested.actionId, + actionFingerprint: requested.actionFingerprint, + coordinationTurnId: requested.coordinationTurnId, + targetSessionId: requested.targetSessionId, + disposition: requested.disposition, + userText: requested.userText, + create: requested.create, + }, + ); +} + function foldTurnContribution( current: SessionTurnContribution | undefined, turnId: string, From f4f59b594d6620c8a7de059d6010ffa567cd5a6e Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Fri, 28 Aug 2026 17:15:28 +0800 Subject: [PATCH 7/8] fix(workhub): consume durable admissions exactly once --- .../src/__tests__/message-coordinator.test.ts | 107 +++++++--- .../src/server/execution-composition.ts | 50 +---- .../src/server/message-coordinator.ts | 194 +++++++++--------- 3 files changed, 184 insertions(+), 167 deletions(-) diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 871e42bcfb..48f24ad74a 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -18,14 +18,23 @@ */ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { test } from 'node:test'; import { messageContentDigest, type MessageContent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { + WORKHUB_COORDINATION_SESSION_ID, + WORKHUB_COORDINATION_SESSION_ROLE, +} from '@maka/core/session'; import type { MessageAdmissionStore, PendingMessageAdmission, RootTurnSourceMessageReceipt, } from '@maka/storage/execution-stores'; +import { createSessionStore } from '@maka/storage/session-store'; import { MESSAGE_OPERATION_RESULT_MAX_BYTES, MESSAGE_QUEUE_PROJECTION_MAX_BYTES, @@ -44,40 +53,81 @@ import { SessionAdmissionGate } from '../server/session-admission-gate.js'; const ROOT = { sessionId: 'session-1', turnId: 'turn-1', runId: 'run-1' } as const; -test('a caller-owned admission can atomically commit and wake steering', async () => { - const fixture = createFixture(); +test('consumes an atomically committed active-target admission exactly once', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-active-consume-')); + const store = createSessionStore(root); + t.after(async () => { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + }); + await store.createStableSession({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + requestFingerprint: `sha256:${'a'.repeat(64)}`, + input: { + cwd: root, + name: 'WorkHub', + role: WORKHUB_COORDINATION_SESSION_ROLE, + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'explore', + toolProfile: 'workhub-coordination-v1', + }, + }); + await store.createStableSession({ + sessionId: ROOT.sessionId, + requestFingerprint: `sha256:${'b'.repeat(64)}`, + input: { + cwd: root, + name: 'Payments', + llmConnectionSlug: 'test', + model: 'test', + permissionMode: 'ask', + }, + }); + const fixture = createFixture(undefined, () => true, store); fixture.coordinator.reserveRootTurn(ROOT); fixture.coordinator.bindRun(ROOT); const content = { text: 'atomic WorkHub assignment' }; - await fixture.admissions.commitMessageAdmission({ - ...ROOT, - messageId: 'workhub-message', - content, - submittedContentDigest: messageContentDigest(content), - submittedPlacement: 'current_turn', - placement: 'current_turn', - disposition: 'steering', - admittedAt: 10, + const actionId = 'action-active-target'; + const suffix = createHash('sha256').update(actionId, 'utf8').digest('hex').slice(0, 48); + const messageId = `whm_${suffix}`; + await store.assignWorkHubMessage({ + assignment: { + type: 'workhub_coordination', + id: `wha_${suffix}`, + turnId: actionId, + ts: 10, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId, + actionFingerprint: `sha256:${'c'.repeat(64)}`, + coordinationTurnId: actionId, + targetSessionId: ROOT.sessionId, + targetSessionName: 'Payments', + targetTurnId: ROOT.turnId, + targetMessageId: messageId, + delegationId: `whd_${suffix}`, + disposition: 'delegate_existing', + userText: content.text, + steered: true, + }, + admission: { + ...ROOT, + messageId, + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + admittedAt: 10, + }, }); - const outcome = await fixture.sessionAdmission.runMany( - ['maka_workhub_coordination', ROOT.sessionId], - (lease) => - fixture.coordinator.submitWithAdmissionLease( - { - originHostEpoch: 'epoch-1', - sessionId: ROOT.sessionId, - messageId: 'workhub-message', - content, - placement: 'current_turn', - }, - operationContext(), - lease, - ), - ); + await fixture.coordinator.consumePendingAdmissions([ROOT.sessionId]); + await fixture.coordinator.consumePendingAdmissions([ROOT.sessionId]); - assert.equal(outcome.ok, true); assert.equal(fixture.coordinator.projection(ROOT.sessionId).steering.length, 1); + assert.equal(fixture.drainRequests(), 0); }); test('idle submit starts exactly one root Turn and retry identity is connection-independent', async () => { @@ -2286,6 +2336,7 @@ test('canonical retry omits redundant display text and empty ordered refs', asyn function createFixture( onProjectionChanged?: (sessionId: string) => void, preflightSessionSnapshot: HostMessageCoordinatorOptions['preflightSessionSnapshot'] = () => true, + admissionsOverride?: MessageAdmissionStore, ) { let nextId = 1; let liveResidencies = 0; @@ -2313,7 +2364,7 @@ function createFixture( state: 'accepted' | 'handed_off' | 'executed' | 'cancelled'; } >(); - const admissions = memoryMessageAdmissionStore(messageAdmissions); + const admissions = admissionsOverride ?? memoryMessageAdmissionStore(messageAdmissions); const sessionAdmission = new SessionAdmissionGate(); const stopClaimed = deferred(); const terminal = deferred(); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 0199956811..46619ff9d8 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1230,7 +1230,7 @@ export async function createExecutionRuntimeHostComposition( continuity: continuityCoordinator, executions: coordinator, sessionActions: { - assign: async (input, connection) => { + assign: async (input) => { const durable = await stores.sessionStore.readWorkHubAssignment(input.actionId); const create = !durable && input.create @@ -1249,7 +1249,6 @@ export async function createExecutionRuntimeHostComposition( .slice(0, 48); const messageId = `whm_${suffix}`; const content = normalizeMessageContent({ text: input.userText }); - let wakeFailed = false; const persisted = durable ?? (await sessionAdmission.runMany( @@ -1307,51 +1306,20 @@ export async function createExecutionRuntimeHostComposition( lease, ); await continuityCoordinator.refreshCanonical(input.targetSessionId, lease); - const outcome = await messages.submitWithAdmissionLease( - { - originHostEpoch: connection.hostEpoch, - sessionId: result.assignment.targetSessionId, - messageId: result.assignment.targetMessageId, - content: normalizeMessageContent({ text: result.assignment.userText }), - placement: 'current_turn', - }, - connection, - lease, - ); - wakeFailed = !outcome.ok; } catch { - // The atomic assignment is already committed. Do not turn a - // post-commit projection or wake failure into a rejected - // WorkHub action; normal inbox recovery owns consumption. - wakeFailed = true; + // The atomic assignment is already committed. Projection + // refresh is rebuildable and must not reject the action. } return result.assignment; }, )); - if (durable) { - try { - const outcome = await messages.handlers['turn.message.submit']( - { - originHostEpoch: connection.hostEpoch, - sessionId: persisted.targetSessionId, - messageId: persisted.targetMessageId, - content: normalizeMessageContent({ text: persisted.userText }), - placement: 'current_turn', - }, - connection, - ); - wakeFailed = !outcome.ok; - } catch { - wakeFailed = true; - } - } - if (wakeFailed) { - // Assignment is the acknowledged WorkHub outcome. The pending - // admission remains the target Session's durable inbox and normal - // Host recovery owns another consumption attempt. - context.requestDrain(); - } + // Assignment is already the acknowledged durable outcome. Consume + // the exact stored admission after releasing the assignment leases; + // failure leaves it pending for the same normal recovery consumer. + void messages + .consumePendingAdmissions([persisted.targetSessionId]) + .catch(() => undefined); return { turnId: persisted.targetTurnId, ...(persisted.steered ? { steered: true as const } : {}), diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index f001283fd4..1db629328d 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -347,20 +347,6 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { 'turn.interrupt': (input) => this.interrupt(input), }; - /** - * Submit while reusing a caller-owned Session admission. WorkHub uses this - * after atomically committing its assignment and target admission so the - * active root cannot turn over between the durable decision and its first - * in-memory wake attempt. - */ - submitWithAdmissionLease( - input: TurnMessageSubmitInput, - context: ConnectionContext, - admission: SessionAdmissionLease, - ): Promise> { - return this.submit(input, context, admission); - } - readonly #hostEpoch: string; readonly #root: HostMessageRootPort; readonly #durableProof: HostMessageDurableProofReader; @@ -661,106 +647,118 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } async recoverPendingAfterHostRestart(sessionIds: readonly string[]): Promise { - for (const sessionId of sessionIds) { - const admissions = await this.#admissions.listMessageAdmissions(sessionId); - if (admissions.length === 0) continue; - const pending = [] as PendingMessageAdmission[]; - for (const admission of admissions) { - const source = await this.#durableProof.readRootTurnSourceMessageReceipt( + await this.consumePendingAdmissions(sessionIds); + } + + /** Consume canonical pending admissions without creating a second admission. */ + async consumePendingAdmissions(sessionIds: readonly string[]): Promise { + for (const sessionId of new Set(sessionIds)) { + await this.#sessionAdmission.run(sessionId, (admission) => + this.#consumePendingAdmissions(sessionId, admission), + ); + } + } + + async #consumePendingAdmissions( + sessionId: string, + admissionLease: SessionAdmissionLease, + ): Promise { + const admissions = await this.#admissions.listMessageAdmissions(sessionId); + if (admissions.length === 0) return; + const pending = [] as PendingMessageAdmission[]; + for (const admission of admissions) { + const source = await this.#durableProof.readRootTurnSourceMessageReceipt( + sessionId, + admission.messageId, + ); + if ( + source?.admission.turnId === admission.turnId && + source.admission.runId === admission.runId && + source.sourceMessage.messageId === admission.messageId + ) { + await this.materializeMessageHandoffsForRun({ + sessionId, + turnId: source.admission.turnId, + runId: source.admission.runId, + messageIds: [admission.messageId], + }); + } else { + const steering = await this.#durableProof.readImmutableSteeringMessageProof( sessionId, admission.messageId, ); if ( - source?.admission.turnId === admission.turnId && - source.admission.runId === admission.runId && - source.sourceMessage.messageId === admission.messageId + steering?.event.turnId === admission.turnId && + steering.event.runId === admission.runId ) { await this.materializeMessageHandoffsForRun({ sessionId, - turnId: source.admission.turnId, - runId: source.admission.runId, + turnId: admission.turnId, + runId: admission.runId, messageIds: [admission.messageId], }); } else { - const steering = await this.#durableProof.readImmutableSteeringMessageProof( - sessionId, - admission.messageId, - ); - if ( - steering?.event.turnId === admission.turnId && - steering.event.runId === admission.runId - ) { - await this.materializeMessageHandoffsForRun({ - sessionId, - turnId: admission.turnId, - runId: admission.runId, - messageIds: [admission.messageId], - }); - } else { - pending.push(admission); - } + pending.push(admission); } } - if (pending.length === 0) continue; - const rootState = await this.#root.readRootState(sessionId); - if (rootState.kind !== 'active') { - if (rootState.kind !== 'idle') continue; - if (!this.#root.startRecoveredMessages) { - throw new RuntimeMessageAuthorityInvariantError( - 'Message recovery authority is unavailable', - ); - } - const started = await this.#sessionAdmission.run(sessionId, (admission) => - this.#root.startRecoveredMessages!( - { - sessionId, - content: aggregateMessageContents(pending.map((entry) => entry.content)), - submittedContent: aggregateMessageContents(pending.map((entry) => entry.content)), - sources: pending.map(pendingMessageSource), - ...(pending.length === 1 && pending[0]!.submittedIntent - ? { submittedIntent: pending[0]!.submittedIntent } - : {}), - }, - admission, - ), + } + if (pending.length === 0) return; + const rootState = await this.#root.readRootState(sessionId); + if (rootState.kind !== 'active') { + if (rootState.kind !== 'idle') return; + if (!this.#root.startRecoveredMessages) { + throw new RuntimeMessageAuthorityInvariantError( + 'Message recovery authority is unavailable', ); - if ('error' in started) { - throw new RuntimeMessageAuthorityInvariantError( - `Durable Message recovery failed: ${started.error}`, - ); - } - continue; } - if (!this.#sessions.has(sessionId)) this.#state(sessionId); - const state = this.#requireState(sessionId); - if (!state.reservedRoot) this.reserveRootTurn(rootState); - if (!sameRun(state.reservedRoot!, rootState)) continue; - for (const admission of pending) { - if (admission.turnId !== rootState.turnId || admission.runId !== rootState.runId) continue; - const existing = allLiveEntries(state).find( - (entry) => entry.messageId === admission.messageId, + const started = await this.#root.startRecoveredMessages( + { + sessionId, + content: aggregateMessageContents(pending.map((entry) => entry.content)), + submittedContent: aggregateMessageContents(pending.map((entry) => entry.content)), + sources: pending.map(pendingMessageSource), + ...(pending.length === 1 && pending[0]!.submittedIntent + ? { submittedIntent: pending[0]!.submittedIntent } + : {}), + }, + admissionLease, + ); + if ('error' in started) { + throw new RuntimeMessageAuthorityInvariantError( + `Durable Message recovery failed: ${started.error}`, ); - if (existing) continue; - const residency = this.#acquireResidency(); - const entry: LiveEntry = { - entryId: this.#createId(), - messageId: admission.messageId, - turnId: admission.turnId, - runId: admission.runId, - admittedAt: admission.admittedAt, - content: submittedProjectionContent(admission.content), - modelContent: admission.content, - submittedContentDigest: admission.submittedContentDigest, - placement: admission.placement, - disposition: admission.disposition, - generation: state.generation, - residency, - state: 'queued', - }; - if (entry.disposition === 'steering') state.steering.push(entry); - else state.followup.push(entry); - this.#mutated(state); } + return; + } + if (!this.#sessions.has(sessionId)) this.#state(sessionId); + const state = this.#requireState(sessionId); + if (!state.reservedRoot) this.reserveRootTurn(rootState); + if (!sameRun(state.reservedRoot!, rootState)) return; + for (const admission of pending) { + if (admission.turnId !== rootState.turnId || admission.runId !== rootState.runId) continue; + const existing = allLiveEntries(state).find( + (entry) => entry.messageId === admission.messageId, + ); + if (existing) continue; + const residency = this.#acquireResidency(); + const entry: LiveEntry = { + entryId: this.#createId(), + messageId: admission.messageId, + turnId: admission.turnId, + runId: admission.runId, + admittedAt: admission.admittedAt, + content: submittedProjectionContent(admission.content), + modelContent: admission.content, + submittedContentDigest: admission.submittedContentDigest, + placement: admission.placement, + disposition: admission.disposition, + generation: state.generation, + residency, + state: 'queued', + }; + if (entry.disposition === 'steering') state.steering.push(entry); + else state.followup.push(entry); + this.#mutated(state); } } From 139e0233bf59158df575b571450fe40250385405 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Fri, 28 Aug 2026 17:16:07 +0800 Subject: [PATCH 8/8] fix(workhub): mark settings toggle unavailable --- .../desktop/src/renderer/locales/settings-preferences-copy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts index c7a7131a21..770e7e9953 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -331,7 +331,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { removeErrors: { invalid_id: '宠物 ID 无效。', remove_failed: '无法删除本地宠物包。' }, }, general: { - incognito: '隐身模式', incognitoHelp: '开启后暂停本地记忆读写、联网搜索和定时任务触发。', enableIncognito: '启用隐身模式', incognitoFailed: '隐身模式切换失败', notifications: '完成时发送系统通知', notificationsHelp: '窗口不在前台时,在回答完成或出错后发送桌面通知。', notificationsFailed: '通知设置切换失败', workspaceInstructions: '遵循项目指令', workspaceInstructionsHelp: '自动读取每个项目中已有的 AGENTS.md、CLAUDE.md 或 GEMINI.md;文件仍由各自项目管理。', workspaceInstructionsFailed: '项目指令设置切换失败', workHub: '启用 WorkHub', workHubHelp: '在一个入口查看已有工作,并将新输入保守地送往普通任务。', workHubFailed: 'WorkHub 设置切换失败', updateFailed: '设置未生效,请稍后重试。', + incognito: '隐身模式', incognitoHelp: '开启后暂停本地记忆读写、联网搜索和定时任务触发。', enableIncognito: '启用隐身模式', incognitoFailed: '隐身模式切换失败', notifications: '完成时发送系统通知', notificationsHelp: '窗口不在前台时,在回答完成或出错后发送桌面通知。', notificationsFailed: '通知设置切换失败', workspaceInstructions: '遵循项目指令', workspaceInstructionsHelp: '自动读取每个项目中已有的 AGENTS.md、CLAUDE.md 或 GEMINI.md;文件仍由各自项目管理。', workspaceInstructionsFailed: '项目指令设置切换失败', workHub: '启用 WorkHub', workHubHelp: 'WorkHub 目前仍不可用。此开关仅供开发测试,开启后也不能保证正常使用。', workHubFailed: 'WorkHub 设置切换失败', updateFailed: '设置未生效,请稍后重试。', defaultModel: '默认模型', defaultModelHelp: '新任务默认使用的模型。', notSet: '未设置', saveDefaultModelFailed: '保存默认模型失败', defaultPermission: '默认权限模式', defaultPermissionHelp: '新任务默认使用的权限模式;可在任务内随时切换。', saveDefaultPermissionFailed: '保存默认权限模式失败', defaultThinking: '默认思考级别', defaultThinkingHelp: '新任务的思考级别;当前模型不支持所选级别时用模型默认。', followModelDefault: '跟随模型默认', saveDefaultThinkingFailed: '保存默认思考级别失败', shellPreference: 'Bash 工具 shell', shellPreferenceHelp: '自动模式保持 Windows 的 PowerShell 优先规则;Git Bash 是仅对当前 Runtime Host 生效的显式覆盖。', shellAuto: '自动(推荐)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash 可执行文件', shellExecutableHelp: '填写 Runtime Host 所在 Windows 机器上 bash.exe 的绝对路径。也支持该机器上的旧版 System32 WSL Bash;保存时会验证 GNU Bash。', saveShell: '保存 shell 设置', savingShell: '正在保存…', shellSaved: '已保存', saveShellFailed: '保存 shell 设置失败', shellExecutableRejected: '当前 Runtime Host 无法把该路径作为 GNU Bash 运行。请检查 Host 是否为 Windows、路径是否存在,并确认文件名为 bash.exe。', proxy: '代理服务器', proxyHelp: '为 AI 模型请求配置网络代理', enableProxy: '启用代理服务器', saveNetworkFailed: '保存网络设置失败', proxyProtocol: '代理协议', serverAddress: '服务器地址', port: '端口', proxyAuth: '代理认证', proxyAuthHelp: '需要用户名和密码时开启。', enableProxyAuth: '启用代理认证', username: '用户名', password: '密码', bypassList: '代理白名单', bypassHelp: '这些域名将绕过代理直连,多个用逗号分隔。', autoBypass: (count) => `已自动添加 ${count} 个域名。代理仅作用于 AI 模型请求。`, testing: '测试中…', testCurrent: '测试当前配置', proxyReachable: '代理可达', proxyTestFailed: '代理测试失败', proxyTestError: '代理测试出错', @@ -386,7 +386,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { removeErrors: { invalid_id: 'The pet ID is invalid.', remove_failed: 'The local pet pack could not be removed.' }, }, general: { - incognito: 'Incognito mode', incognitoHelp: 'Pause local memory, web search, and scheduled task triggers.', enableIncognito: 'Enable incognito mode', incognitoFailed: 'Could not change incognito mode', notifications: 'Send a system notification when finished', notificationsHelp: 'Notify when a response finishes or fails while the window is in the background.', notificationsFailed: 'Could not change notification settings', workspaceInstructions: 'Follow project instructions', workspaceInstructionsHelp: 'Automatically read existing AGENTS.md, CLAUDE.md, or GEMINI.md files in each project. Manage the files in their respective projects.', workspaceInstructionsFailed: 'Could not change project instruction settings', workHub: 'Enable WorkHub', workHubHelp: 'See existing work in one entry and conservatively route new input to ordinary tasks.', workHubFailed: 'Could not change WorkHub setting', updateFailed: 'The setting was not applied. Try again later.', defaultModel: 'Default model', defaultModelHelp: 'Model used by new tasks.', notSet: 'Not set', saveDefaultModelFailed: 'Could not save the default model', defaultPermission: 'Default permission mode', defaultPermissionHelp: 'Initial permission mode for new tasks; it can be changed at any time.', saveDefaultPermissionFailed: 'Could not save the default permission mode', defaultThinking: 'Default thinking level', defaultThinkingHelp: 'Thinking level for new tasks; models that do not offer the chosen level use their own default.', followModelDefault: 'Follow model default', saveDefaultThinkingFailed: 'Could not save the default thinking level', proxy: 'Proxy server', proxyHelp: 'Configure a network proxy for AI model requests', enableProxy: 'Enable proxy server', saveNetworkFailed: 'Could not save network settings', proxyProtocol: 'Proxy protocol', serverAddress: 'Server address', port: 'Port', proxyAuth: 'Proxy authentication', proxyAuthHelp: 'Enable this when a username and password are required.', enableProxyAuth: 'Enable proxy authentication', username: 'Username', password: 'Password', bypassList: 'Proxy bypass list', bypassHelp: 'These domains connect directly. Separate multiple domains with commas.', autoBypass: (count) => `${count} ${count === 1 ? 'domain was' : 'domains were'} added automatically. The proxy applies to AI model requests only.`, testing: 'Testing…', testCurrent: 'Test current configuration', proxyReachable: 'Proxy is reachable', proxyTestFailed: 'Proxy test failed', proxyTestError: 'Could not test proxy', + incognito: 'Incognito mode', incognitoHelp: 'Pause local memory, web search, and scheduled task triggers.', enableIncognito: 'Enable incognito mode', incognitoFailed: 'Could not change incognito mode', notifications: 'Send a system notification when finished', notificationsHelp: 'Notify when a response finishes or fails while the window is in the background.', notificationsFailed: 'Could not change notification settings', workspaceInstructions: 'Follow project instructions', workspaceInstructionsHelp: 'Automatically read existing AGENTS.md, CLAUDE.md, or GEMINI.md files in each project. Manage the files in their respective projects.', workspaceInstructionsFailed: 'Could not change project instruction settings', workHub: 'Enable WorkHub', workHubHelp: 'WorkHub is not available yet. This toggle is for development testing and does not enable a usable feature.', workHubFailed: 'Could not change WorkHub setting', updateFailed: 'The setting was not applied. Try again later.', defaultModel: 'Default model', defaultModelHelp: 'Model used by new tasks.', notSet: 'Not set', saveDefaultModelFailed: 'Could not save the default model', defaultPermission: 'Default permission mode', defaultPermissionHelp: 'Initial permission mode for new tasks; it can be changed at any time.', saveDefaultPermissionFailed: 'Could not save the default permission mode', defaultThinking: 'Default thinking level', defaultThinkingHelp: 'Thinking level for new tasks; models that do not offer the chosen level use their own default.', followModelDefault: 'Follow model default', saveDefaultThinkingFailed: 'Could not save the default thinking level', proxy: 'Proxy server', proxyHelp: 'Configure a network proxy for AI model requests', enableProxy: 'Enable proxy server', saveNetworkFailed: 'Could not save network settings', proxyProtocol: 'Proxy protocol', serverAddress: 'Server address', port: 'Port', proxyAuth: 'Proxy authentication', proxyAuthHelp: 'Enable this when a username and password are required.', enableProxyAuth: 'Enable proxy authentication', username: 'Username', password: 'Password', bypassList: 'Proxy bypass list', bypassHelp: 'These domains connect directly. Separate multiple domains with commas.', autoBypass: (count) => `${count} ${count === 1 ? 'domain was' : 'domains were'} added automatically. The proxy applies to AI model requests only.`, testing: 'Testing…', testCurrent: 'Test current configuration', proxyReachable: 'Proxy is reachable', proxyTestFailed: 'Proxy test failed', proxyTestError: 'Could not test proxy', shellPreference: 'Bash tool shell', shellPreferenceHelp: 'Automatic keeps the PowerShell-first Windows default. Git Bash is an explicit override for the current Runtime Host.', shellAuto: 'Automatic (recommended)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash executable', shellExecutableHelp: 'Enter the absolute path to bash.exe on the Windows machine running the Runtime Host. The legacy System32 WSL Bash shim is also recognized; Maka verifies GNU Bash before saving.', saveShell: 'Save shell setting', savingShell: 'Saving…', shellSaved: 'Saved', saveShellFailed: 'Could not save shell setting', shellExecutableRejected: 'The current Runtime Host could not run that path as GNU Bash. Check that the Host runs Windows, the path exists, and the file is named bash.exe.', }, about: {