From b253eb59821452eb49cecec6e4f42e1beb10b3d8 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Mon, 7 Sep 2026 23:33:09 +0800 Subject: [PATCH 1/9] fix(workhub): admit coordination actions through Runtime turns Generated-by: Codex --- .../workhub-turn-admission/receipts-dark.png | Bin 0 -> 178075 bytes .../workhub-turn-admission/receipts-light.png | Bin 0 -> 65665 bytes .../runtime-host-client-operations.test.ts | 18 +- .../runtime-host-workhub-ipc-main.test.ts | 23 +- .../__tests__/workhub-controller-fixture.ts | 2 +- .../main/__tests__/workhub-controller.test.ts | 47 ++-- .../__tests__/workhub-session-port.test.ts | 31 ++- .../__tests__/workhub-surface-flow.test.ts | 20 +- apps/desktop/src/main/runtime-host-client.ts | 6 - .../src/main/runtime-host-workhub-ipc-main.ts | 4 - apps/desktop/src/preload/bridge-contract.d.ts | 5 - apps/desktop/src/preload/preload.ts | 10 - apps/desktop/src/renderer/app-shell.tsx | 2 - .../src/renderer/workhub-controller.ts | 36 +--- .../src/renderer/workhub-coordination-port.ts | 36 +++- apps/desktop/src/renderer/workhub-surface.tsx | 21 +- .../src/shared/desktop-session-projection.ts | 11 + apps/desktop/stories/workhub.stories.tsx | 18 +- .../workhub-coordination-session-adr.md | 29 ++- packages/core/package.json | 3 +- .../core/src/__tests__/runtime-event.test.ts | 28 +++ packages/core/src/runtime-event.ts | 5 + packages/core/src/runtime-invocation.ts | 1 + packages/core/src/session.ts | 23 ++ packages/core/src/workhub-action-result.ts | 115 ++++++++++ .../__tests__/execution-composition.test.ts | 196 ++++++++++++++++- .../workhub-coordination-coordinator.test.ts | 161 +------------- .../workhub-coordination-protocol.test.ts | 24 --- packages/runtime-host/src/protocol/index.ts | 4 +- .../runtime-host/src/protocol/operations.ts | 1 - .../src/protocol/workhub-coordination.ts | 76 +------ .../src/server/execution-composition.ts | 8 +- .../src/server/hosted-execution-recovery.ts | 8 +- .../src/server/root-turn-coordinator.ts | 97 ++++++++- .../workhub-coordination-action-gate.ts | 59 ++++- .../workhub-coordination-coordinator.ts | 201 +++++------------- .../runtime-event-read-model.test.ts | 37 ++++ .../src/__tests__/session-manager.test.ts | 9 + .../runtime/src/runtime-event-read-model.ts | 29 ++- packages/runtime/src/runtime-kernel.ts | 117 ++++++++++ packages/runtime/src/session-manager.ts | 16 ++ packages/storage/src/agent-run-store.ts | 12 +- 42 files changed, 980 insertions(+), 569 deletions(-) create mode 100644 .github/assets/workhub-turn-admission/receipts-dark.png create mode 100644 .github/assets/workhub-turn-admission/receipts-light.png create mode 100644 packages/core/src/workhub-action-result.ts diff --git a/.github/assets/workhub-turn-admission/receipts-dark.png b/.github/assets/workhub-turn-admission/receipts-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..2d6fb9594ae043b5643e6e7aa17272e9afe30583 GIT binary patch literal 178075 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�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

{copy.answering}

) : (

- {copy.turnStates[props.turn.state]} + {props.turn.coordinationActionId + ? copy.actionConfirmationIncomplete : copy.turnStates[props.turn.state]}

)} diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index f381358556..8e6373290c 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -106,11 +106,23 @@ The shared transcript reader derives receipts directly from RuntimeEvents and retains legacy atomic linkage facts and released history. A rebuildable SQLite index holds only `(source, sourceSequence)` references in stable page order; it contains no message bodies, action results, or execution authority. Initial -backfill and incremental refresh consume bounded source batches; normal pages +backfill and incremental refresh commit at most 64 references per foreground +request. An unfinished catch-up returns `transcript_preparing`, including the +committed index position; it publishes no incomplete snapshot or empty-history +claim. Subscription clients yield between resumable requests and retain their +loading state within the open deadline. Reader recreation resumes the committed +source positions. There is no detached maintenance worker or second task lifecycle. +Once caught up, normal pages seek the index and project bounded source batches/Turns. Wall-clock regressions and later appends cannot renumber existing pages. No receipt is written back into the legacy message store. The WorkHub view groups receipt retries by action identity rather than exposing each physical attempt as a new conversation card. +Persisted user inputs and Run terminal states always remain readable, including +a failed attempt whose receipt was never committed. The projection carries the +admitted action identity alongside the physical Turn identity. Only a visible +receipt or atomic link suppresses its input rows; a bounded page without that +replacement still shows the failed inputs. Missing acknowledgement is presented +as incomplete confirmation, without claiming the target effect failed. Host-only Turns retain execution ownership without activating a model provider. An interrupted Host action is closed by Runtime recovery and never replayed as a model answer. Target-owned diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index 28a52cd72c..d6c635c7d2 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -242,6 +242,8 @@ export type RootExecutionDescriptor = /** Tool-free conversational execution admitted only by WorkHub authority. */ kind: 'workhub_coordination'; operation?: 'action'; + /** Stable request identity shared by physical action retries. */ + actionId?: string; inputDigest: `sha256:${string}`; } | { kind: 'regenerate'; sourceTurnId: string } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 661e6955f4..5e3276cafa 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -765,6 +765,8 @@ export type StoredMessage = | SystemNoteMessage; export interface UserMessage extends MessageContent { + /** Derived from the admitted WorkHub action; does not change physical Turn identity. */ + coordinationActionId?: string; type: 'user'; id: string; turnId: string; @@ -1237,6 +1239,7 @@ const USER_MESSAGE_SHAPE = defineObjectShape()( 'quotes', 'inlineReferences', 'steeringEventId', + 'coordinationActionId', 'origin', ], ); @@ -1487,7 +1490,10 @@ function decodeMessage( if ( hasExactShape(message, USER_MESSAGE_SHAPE) && hasMessageEnvelope(message, true) && - (message.origin === undefined || decodeTurnOrigin(message.origin) !== undefined) + (message.origin === undefined || decodeTurnOrigin(message.origin) !== undefined) && + (message.coordinationActionId === undefined || + (typeof message.coordinationActionId === 'string' && + message.coordinationActionId.length > 0)) ) { const { displayText, diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 937a30b7b9..04a31e6d8e 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -1567,6 +1567,27 @@ for (const missingReceipt of [false, true]) expects: { targetSessionId: target.id }, }, }; + if (missingReceipt) { + const failed = await readProductionTranscript(composition, context); + assert.ok( + failed.some( + (message) => + message.type === 'user' && + message.turnId === retry.actionId && + message.coordinationActionId === retry.actionId && + message.text === retry.userText, + ), + 'failed admitted input must remain visible after reopen, before retry', + ); + assert.ok( + failed.some( + (message) => + message.type === 'turn_state' && + message.turnId === retry.actionId && + message.status === 'failed', + ), + ); + } const replayed = await composition.handlers['workhub.coordination.act'](retry, context); // Re-delivery acknowledges the original Coordination Run; it must never // resume a later interruption under the same action identity. diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts index a33f361175..510e0ea115 100644 --- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts +++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts @@ -406,6 +406,64 @@ test('loads a canonical transcript while live frames continue on the same connec ); }); +test('resumes bounded index preparation before publishing the canonical transcript', async () => { + const message = { + type: 'assistant' as const, + id: 'message-1', + turnId: 'turn-1', + ts: 1, + text: 'snapshot text', + modelId: 'test-model', + }; + await withProtocolPeer( + async (transport, hostEpoch, rootId) => { + let openRequest = await acceptConnectionAndReadOpen(transport, hostEpoch, rootId); + for (let batch = 0; batch < 3; batch++) { + await writeProtocolFrame(transport, { + requestId: openRequest.requestId, + operation: 'subscription.open', + ok: false, + error: { code: 'transcript_preparing', message: `indexed through ${batch * 64}` }, + }); + const next = decodeClientFrame(await transport.read(1_000)); + assert.ok(!('kind' in next) && next.operation === 'subscription.open'); + assert.deepEqual(next.input, openRequest.input); + openRequest = next; + } + const opened = openResult( + hostEpoch, + 'subscription-transcript', + transcriptBootstrap(Buffer.from(JSON.stringify(message), 'utf8')), + ); + await writeRawLocalIpc( + transport, + Buffer.concat([ + encodeLocalIpcTestFrame({ + requestId: openRequest.requestId, + operation: 'subscription.open', + ok: true, + result: opened, + }), + encodeLocalIpcTestFrame(deltaFrame(hostEpoch, opened.subscriptionId, 1)), + ]), + ); + await answerClose(transport, opened.subscriptionId); + }, + async (connection) => { + const subscription = await connection.openSessionSubscription({ + sessionId: 'session-1', + transcript: { kind: 'tail', maxBytes: 16 * 1024 }, + }); + assert.deepEqual(await subscription.loadTranscript(decodeStoredMessage), [message]); + assert.deepEqual(await subscription[Symbol.asyncIterator]().next(), { + done: false, + value: deltaFrame(connection.hostEpoch, subscription.subscriptionId, 1), + }); + await subscription.close(); + }, + ); +}); + test('reassembles a large message from bounded backward pages', async () => { const message = { type: 'user' as const, diff --git a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts index 00fc141529..b760b7b414 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts @@ -986,3 +986,18 @@ function decodeBootstrap( JSON.parse(Buffer.from(fragment.data, 'base64').toString('utf8')), ); } + +test('shared transcript preserves admitted action identity alongside its physical Turn', () => { + const message = { + type: 'user' as const, + id: 'input', + turnId: 'physical-retry', + ts: 1, + text: 'Resume Payments', + coordinationActionId: 'resume-action', + }; + assert.deepEqual( + projectSharedSessionTranscriptMessage(message, 'maka_workhub_coordination'), + message, + ); +}); diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts index 7e6ebb0c95..60659b2c56 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -775,6 +775,97 @@ test('reads the WorkHub Coordination transcript from its own rows', async () => ); }); +for (const historySize of [257, 10000]) { + test(`Coordination small-page foreground work is bounded (${historySize} rows)`, async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-coordination-growth-')); + const root = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(root); + assert.ok(owner); + try { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const sessionId = WORKHUB_COORDINATION_SESSION_ID; + await stores.sessionStore.createStableSession({ + sessionId, + requestFingerprint: `sha256:${'0'.repeat(64)}`, + input: { + role: 'workhub_coordination', + cwd: root.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }, + }); + for (let i = 0; i < historySize; i++) + await stores.sessionStore.appendMessage(sessionId, { + type: 'user', + id: `u-${i}`, + turnId: `t-${i}`, + ts: i, + text: `message ${i}`, + }); + let reads = 0, + decoded = 0, + writes = 0; + const makeReader = () => + createSessionTranscriptReader({ + stores: { + ...stores, + sessionStore: { + ...stores.sessionStore, + async readMessagesAfter(...args) { + reads++; + const page = await stores.sessionStore.readMessagesAfter(...args); + decoded += page.records.length; + return page; + }, + async appendCoordinationTranscriptIndex(...args) { + writes++; + return stores.sessionStore.appendCoordinationTranscriptIndex(...args); + }, + }, + }, + canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, + }); + let batches = 0; + let previousThrough = -1; + for (;;) { + reads = decoded = writes = 0; + let complete = false; + try { + const page = await makeReader().readDurablePage(sessionId, { + direction: 'older', + maxBytes: 128, + maxMessages: 1, + }); + assert.ok( + page.fragments.length > 0, + 'catch-up must not report an empty complete history', + ); + assert.equal( + JSON.parse(Buffer.concat(page.fragments.map((f) => f.data)).toString()).id, + `u-${historySize - 1}`, + ); + complete = true; + } catch (error) { + assert.equal((error as Error).name, 'CoordinationTranscriptIndexPending'); + const through = (error as { indexedThrough: number }).indexedThrough; + assert.ok(through > previousThrough, 'recreated reader resumes committed progress'); + previousThrough = through; + } + assert.ok(reads <= 3, `foreground source reads: ${reads}`); + assert.ok(decoded <= 192, `foreground decoded rows: ${decoded}`); + assert.ok(writes <= 1, `foreground index writes: ${writes}`); + assert.ok(++batches <= Math.ceil(historySize / 64)); + if (complete) break; + } + } finally { + await owner.close(); + await rm(base, { recursive: true, force: true }); + } + }); +} + test('Coordination page positions survive regressing clocks, late appends and reader recreation', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-coordination-index-')); const root = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index 56de724cee..5a7b63dc71 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -589,7 +589,32 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { return status; } - openSessionSubscription( + async openSessionSubscription( + input: SubscriptionOpenInput, + timeoutMs?: number, + ): Promise { + const deadline = + Date.now() + (timeoutMs === undefined ? 30_000 : requireTimeout(timeoutMs, 'timeoutMs')); + for (;;) { + try { + return await this.#openSessionSubscription(input, Math.max(1, deadline - Date.now())); + } catch (error) { + if ( + !(error instanceof RuntimeHostOperationError) || + error.code !== 'transcript_preparing' || + input.transcript.kind !== 'tail' || + this.#terminalError || + Date.now() >= deadline + ) + throw error; + // Each refusal committed a bounded, resumable index batch. Keep the + // caller in its loading state and yield before requesting more work. + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + } + + #openSessionSubscription( input: SubscriptionOpenInput, timeoutMs?: number, ): Promise { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index eb48961f97..021024a461 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -103,7 +103,8 @@ export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // interoperability. Mismatches are rejected before domain commands are admitted. export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 134 as const; // 134: Coordination actions own real Runtime Turns. Removes the synthetic record -// operation, projects typed action receipts, and distinguishes stale candidate refusals. +// operation, projects typed action receipts and admitted action identities, and +// distinguishes stale candidate refusals and resumable transcript preparation. // 133: WorkHub actions carry attachments and new-Work model/permission defaults. // Epoch-132 peers reject these additional fields on strict action shapes. // 132: new Tool Result archives use versioned ledger references, not Artifact payloads. diff --git a/packages/runtime-host/src/protocol/operation-spec.ts b/packages/runtime-host/src/protocol/operation-spec.ts index f16b48a90f..24505ff9d9 100644 --- a/packages/runtime-host/src/protocol/operation-spec.ts +++ b/packages/runtime-host/src/protocol/operation-spec.ts @@ -28,6 +28,7 @@ export type HostOperationErrorCode = | 'not_found' | 'session_archived' | 'session_busy' + | 'transcript_preparing' | 'candidate_set_stale' | 'operation_conflict' | 'capability_unavailable' diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index ae600aa5de..9a839c4c69 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -314,6 +314,7 @@ export type OrderedSubscriptionFrame = Exclude< >; const SUBSCRIPTION_OPEN_ERRORS = [ + 'transcript_preparing', 'host_not_ready', 'host_draining', 'operation_unavailable', diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index b9cc0f1d01..6a04ef8f7a 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1707,7 +1707,13 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { turnId, ); if (!admission) break; - if (!isDeepStrictEqual(admission.execution, request.execution)) + const expectedExecution = { ...request.execution }; + if ( + admission.execution.kind === 'workhub_coordination' && + admission.execution.actionId === undefined + ) + delete expectedExecution.actionId; + if (!isDeepStrictEqual(admission.execution, expectedExecution)) return operationConflict('Coordination action identity belongs to different content'); const run = await this.readRunIfPresent(request.sessionId, admission.runId); if (!run) break; @@ -1728,10 +1734,17 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { turnId = `whretry_${createHash('sha256').update(admission.runId).digest('hex').slice(0, 48)}`; } const receiptToReplay = recoveredReceipt; + const existingAttempt = await this.stores.agentRunStore.readRootTurnAdmission( + request.sessionId, + turnId, + ); const started = await this.startWorkHubCoordinationMessage( { ...request, turnId, + ...(existingAttempt?.execution.kind === 'workhub_coordination' + ? { execution: existingAttempt.execution } + : {}), ...(receiptToReplay ? { operation: async () => receiptToReplay } : {}), }, context, diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index ae70ff3d27..2d632b7f9f 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -17,6 +17,7 @@ * under the License. */ +import { CoordinationTranscriptIndexPending } from './session-transcript-reader.js'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { SessionEvent, ShellRunUpdate } from '@maka/core/events'; @@ -866,7 +867,12 @@ export class SessionContinuityCoordinator implements SessionContinuityService { | { ok: true; value: SubscriptionOpenResult } | { ok: false; - code: 'not_found' | 'operation_conflict' | 'operation_unavailable' | 'persistence_failed'; + code: + | 'not_found' + | 'operation_conflict' + | 'operation_unavailable' + | 'persistence_failed' + | 'transcript_preparing'; message: string; } > { @@ -977,6 +983,12 @@ export class SessionContinuityCoordinator implements SessionContinuityService { transcriptBootstrap = created.bootstrap; } catch (error) { if (error instanceof TranscriptOverlayPreparationRequired) throw error; + if (error instanceof CoordinationTranscriptIndexPending) + return { + ok: false as const, + code: 'transcript_preparing' as const, + message: error.message, + }; return { ok: false as const, code: diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts index f7ae4e0e5a..090a38b714 100644 --- a/packages/runtime-host/src/server/session-transcript-reader.ts +++ b/packages/runtime-host/src/server/session-transcript-reader.ts @@ -79,6 +79,16 @@ const TRANSCRIPT_LOOKUP_MAX_TURNS = 2; const COORDINATION_TRANSCRIPT_SCAN_LIMIT = 64; const COORDINATION_TRANSCRIPT_SCAN_MAX_BYTES = DURABLE_TRANSCRIPT_TURN_MAX_BYTES; +/** A bounded index batch committed; no complete snapshot is available yet. */ +export class CoordinationTranscriptIndexPending extends Error { + readonly name = 'CoordinationTranscriptIndexPending'; + constructor(readonly indexedThrough: number | null) { + super( + `Coordination history is preparing (indexed through ${indexedThrough ?? 'none'}); retry to continue`, + ); + } +} + export function createSessionTranscriptReader(input: { stores: ExecutionStoresWriter<'interactive'>; canonicalPermissionOutcomes: CanonicalPermissionOutcomeReader; @@ -246,31 +256,37 @@ function createDurableLedgerTranscriptReader(input: { if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) { throw new Error('Durable RuntimeEvent transcript projection is incomplete'); } - const action = + const admission = turn.invocation.sessionId === WORKHUB_COORDINATION_SESSION_ID ? await input.stores.agentRunStore.readRootTurnAdmission( turn.invocation.sessionId, turn.invocation.turnId, ) : undefined; - const hostAction = - action?.execution.kind === 'workhub_coordination' && action.execution.operation === 'action'; + const actionId = + admission?.execution.kind === 'workhub_coordination' + ? admission.execution.actionId + : undefined; const ordinals = new Map(turn.events.map((entry) => [entry.event.id, entry.ordinal])); const emitted = new Map(); - return projected.messages - .map((message, index) => { - const ordinal = ordinals.get(projected.sourceEventIds[index]!); - if (ordinal === undefined) { - throw new Error('Durable transcript message has no source RuntimeEvent'); - } - const offset = emitted.get(ordinal) ?? 0; - if (offset >= EVENT_SEQUENCE_STRIDE) { - throw new Error('RuntimeEvent exceeds its transcript sequence stride'); - } - emitted.set(ordinal, offset + 1); - return { sequence: ordinal * EVENT_SEQUENCE_STRIDE + offset, message }; - }) - .filter(({ message }) => !hostAction || message.type !== 'user'); + return projected.messages.map((message, index) => { + const ordinal = ordinals.get(projected.sourceEventIds[index]!); + if (ordinal === undefined) { + throw new Error('Durable transcript message has no source RuntimeEvent'); + } + const offset = emitted.get(ordinal) ?? 0; + if (offset >= EVENT_SEQUENCE_STRIDE) { + throw new Error('RuntimeEvent exceeds its transcript sequence stride'); + } + emitted.set(ordinal, offset + 1); + return { + sequence: ordinal * EVENT_SEQUENCE_STRIDE + offset, + message: + message.type === 'user' && actionId + ? { ...message, coordinationActionId: actionId } + : message, + }; + }); }; const readTurns = async ( @@ -709,7 +725,10 @@ function mergeTranscriptSources( try { const heads = await Promise.all(walks.map((walk) => walk.next())); let batch: CoordinationTranscriptReference[] = []; - while (heads.some((head) => !head.done)) { + while ( + heads.some((head) => !head.done) && + batch.length < COORDINATION_TRANSCRIPT_SCAN_LIMIT + ) { // Time is only a presentation hint for newly observed facts, never a // cursor or a reason to move an already indexed record. const lane = heads[0]!.done @@ -720,14 +739,12 @@ function mergeTranscriptSources( ? 0 : 1; batch.push({ source: lanes[lane]!, sourceSequence: heads[lane]!.value!.sequence }); - if (batch.length === COORDINATION_TRANSCRIPT_SCAN_LIMIT) { - await store.appendCoordinationTranscriptIndex(batch); - batch = []; - } heads[lane] = await walks[lane]!.next(); } if (batch.length) await store.appendCoordinationTranscriptIndex(batch); - return (await store.readCoordinationTranscriptIndexState()).highWater; + const highWater = (await store.readCoordinationTranscriptIndexState()).highWater; + if (heads.some((head) => !head.done)) throw new CoordinationTranscriptIndexPending(highWater); + return highWater; } finally { await Promise.all(walks.map((walk) => walk.return(undefined))); } diff --git a/packages/runtime-host/src/server/shared-session-transcript.ts b/packages/runtime-host/src/server/shared-session-transcript.ts index 0995206c7e..243512859f 100644 --- a/packages/runtime-host/src/server/shared-session-transcript.ts +++ b/packages/runtime-host/src/server/shared-session-transcript.ts @@ -61,6 +61,9 @@ export function projectSharedSessionTranscriptMessage( ? {} : { steeringEventId: message.steeringEventId }), ...(message.origin === undefined ? {} : { origin: message.origin }), + ...(message.coordinationActionId === undefined + ? {} + : { coordinationActionId: message.coordinationActionId }), }; } case 'assistant': diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index d46d1779e9..15ed951b72 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -529,6 +529,7 @@ export class HostWorkHubCoordinationCoordinator { execution: { kind: 'workhub_coordination', operation: 'action', + actionId: input.actionId, inputDigest: await this.#actionGate.coordinationInputDigest(input), }, archivedMessage: 'WorkHub Coordination Session is unavailable', diff --git a/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts b/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts index 7eca36f5a6..c818f28e4f 100644 --- a/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts +++ b/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts @@ -25,50 +25,56 @@ import { test } from 'node:test'; import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import { createSqliteAgentRunStore } from '../agent-run-store.js'; -test('WorkHub Coordination admission preserves its bounded content identity across restart', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-workhub-admission-')); - const inputDigest = `sha256:${'a'.repeat(64)}` as const; - try { - const store = createSqliteAgentRunStore(root); - const admitted = await store.admitRootTurn({ - sessionId: 'coordination-session', - turnId: 'coordination-turn', - proposedRunId: 'coordination-run', - proposedUserMessageId: 'coordination-message', - execution: { kind: 'workhub_coordination', inputDigest }, - previousRootTurnId: null, - normalizedInput: { text: 'What should happen next?' }, - sourceMessages: [], - admittedAt: 50, - }); - assert.equal(admitted.kind, 'admitted'); - store.close?.(); +for (const actionId of [undefined, 'stable-action']) { + test(`WorkHub Coordination admission preserves its bounded content identity across restart (${actionId ?? 'legacy'})`, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workhub-admission-')); + const inputDigest = `sha256:${'a'.repeat(64)}` as const; + try { + const store = createSqliteAgentRunStore(root); + const admitted = await store.admitRootTurn({ + sessionId: 'coordination-session', + turnId: 'coordination-turn', + proposedRunId: 'coordination-run', + proposedUserMessageId: 'coordination-message', + execution: { + kind: 'workhub_coordination', + inputDigest, + ...(actionId ? { operation: 'action' as const, actionId } : {}), + }, + previousRootTurnId: null, + normalizedInput: { text: 'What should happen next?' }, + sourceMessages: [], + admittedAt: 50, + }); + assert.equal(admitted.kind, 'admitted'); + store.close?.(); - const reopened = createSqliteAgentRunStore(root); - assert.deepEqual( - await reopened.readRootTurnAdmission('coordination-session', 'coordination-turn'), - admitted.admission, - ); - await assert.rejects( - () => - reopened.admitRootTurn({ - sessionId: 'coordination-session', - turnId: 'invalid-coordination-turn', - proposedRunId: 'invalid-coordination-run', - proposedUserMessageId: 'invalid-coordination-message', - execution: { - kind: 'workhub_coordination', - inputDigest: 'sha256:not-a-digest', - } as RootExecutionDescriptor, - previousRootTurnId: 'coordination-turn', - normalizedInput: { text: 'Invalid identity' }, - sourceMessages: [], - admittedAt: 60, - }), - /Invalid root execution descriptor/u, - ); - reopened.close?.(); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); + const reopened = createSqliteAgentRunStore(root); + assert.deepEqual( + await reopened.readRootTurnAdmission('coordination-session', 'coordination-turn'), + admitted.admission, + ); + await assert.rejects( + () => + reopened.admitRootTurn({ + sessionId: 'coordination-session', + turnId: 'invalid-coordination-turn', + proposedRunId: 'invalid-coordination-run', + proposedUserMessageId: 'invalid-coordination-message', + execution: { + kind: 'workhub_coordination', + inputDigest: 'sha256:not-a-digest', + } as RootExecutionDescriptor, + previousRootTurnId: 'coordination-turn', + normalizedInput: { text: 'Invalid identity' }, + sourceMessages: [], + admittedAt: 60, + }), + /Invalid root execution descriptor/u, + ); + reopened.close?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 3a657e0a20..8497b706b4 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -2034,9 +2034,16 @@ function normalizeRootExecutionDescriptor(value: unknown): RootExecutionDescript value, value.operation === undefined ? ['kind', 'inputDigest'] - : ['kind', 'inputDigest', 'operation'], + : [ + 'kind', + 'inputDigest', + 'operation', + ...(value.actionId === undefined ? [] : ['actionId']), + ], ) || (value.operation !== undefined && value.operation !== 'action') || + (value.actionId !== undefined && + (typeof value.actionId !== 'string' || !isSafeId(value.actionId))) || !isSha256Digest(value.inputDigest) ) { throw new Error('Invalid root execution descriptor'); @@ -2045,6 +2052,7 @@ function normalizeRootExecutionDescriptor(value: unknown): RootExecutionDescript kind: 'workhub_coordination', ...(value.operation === 'action' ? { operation: 'action' as const } : {}), inputDigest: value.inputDigest, + ...(typeof value.actionId === 'string' ? { actionId: value.actionId } : {}), }); } if (value.kind === 'regenerate') { From 69c83c7264daffd5a31e26d0e7384d53c2910fd7 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 21:24:55 +0800 Subject: [PATCH 9/9] fix(workhub): validate concurrent replay and preserve request budgets --- .../workhub-coordination-session-adr.md | 9 +- .../__tests__/execution-composition.test.ts | 83 ++++++++++++++++ .../session-subscription-client.test.ts | 95 +++++++++++++++++++ .../runtime-host/src/client/connection.ts | 15 ++- .../src/server/root-turn-coordinator.ts | 35 +++---- 5 files changed, 214 insertions(+), 23 deletions(-) diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 8e6373290c..0e0db33acf 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -95,7 +95,10 @@ Routing is not rerun; a missing or renamed target, another refusal, or continued snapshot churn stops the attempt. The Host still validates every refreshed proposal. Re-delivery of a completed request returns that receipt, including -after restart, without repeating the effect. Failed attempts remain terminal; +after restart, without repeating the effect. Incoming execution content is validated +against the admitted descriptor even when another request wins admission concurrently; +legacy compatibility ignores only an absent action identity field, never the input digest. +Failed attempts remain terminal; a same-action retry gets a subsequent admitted Turn. If the failed attempt already committed a receipt, the new Turn reuses that result without repeating the effect. When target resume admission committed before a missing receipt, retry first @@ -110,7 +113,9 @@ backfill and incremental refresh commit at most 64 references per foreground request. An unfinished catch-up returns `transcript_preparing`, including the committed index position; it publishes no incomplete snapshot or empty-history claim. Subscription clients yield between resumable requests and retain their -loading state within the open deadline. Reader recreation resumes the committed +loading state within the open deadline. Later page and overlay-release requests +retain their independent per-request timeout, not the remaining preparation time. +Reader recreation resumes the committed source positions. There is no detached maintenance worker or second task lifecycle. Once caught up, normal pages seek the index and project bounded source batches/Turns. Wall-clock regressions diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 04a31e6d8e..650170a4ed 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -17,6 +17,7 @@ * under the License. */ +import { RootTurnCoordinator } from '../server/root-turn-coordinator.js'; import { WorkHubCoordinationActionGate, workHubCoordinationTurnId, @@ -997,6 +998,88 @@ test('WorkHub creates new work through the production assignment composition', a context, ); assert.equal(changed.ok, false); + for (const legacy of [false, true]) + for (const changePayload of [false, true]) { + const missingRead = deferred(); + const releaseRead = deferred(); + const originalOperation = RootTurnCoordinator.prototype.runWorkHubCoordinationOperation; + let restoreRead: (() => void) | undefined; + let intercept = true; + RootTurnCoordinator.prototype.runWorkHubCoordinationOperation = function (request, ctx) { + const isRacingRequest = intercept; + if (intercept) { + intercept = false; + const coordinator = this as unknown as { stores: typeof coordinationStores }; + const originalStores = coordinator.stores; + let paused = false; + coordinator.stores = { + ...originalStores, + agentRunStore: { + ...originalStores.agentRunStore, + readRootTurnAdmission: async (...args) => { + const admission = await originalStores.agentRunStore.readRootTurnAdmission( + ...args, + ); + if (!paused && args[1] === request.turnId && !admission) { + paused = true; + missingRead.resolve(); + await releaseRead.promise; + } + return admission; + }, + }, + }; + restoreRead = () => { + coordinator.stores = originalStores; + }; + } + if (legacy && !isRacingRequest) { + const { actionId: _actionId, ...execution } = request.execution; + return originalOperation.call(this, { ...request, execution }, ctx); + } + return originalOperation.call(this, request, ctx); + }; + const concurrentInput = { + ...clarifyInput, + actionId: `concurrent-clarification-${legacy}-${changePayload}`, + }; + const pendingChanged = composition.handlers['workhub.coordination.act']( + { + ...concurrentInput, + proposal: changePayload + ? { disposition: 'clarify', assistantText: 'Changed concurrent content' } + : concurrentInput.proposal, + }, + context, + ); + try { + await Promise.race([ + missingRead.promise, + pendingChanged.then((value) => { + throw new Error(`Concurrent probe did not pause: ${JSON.stringify(value)}`); + }), + ]); + const accepted = await composition.handlers['workhub.coordination.act']( + concurrentInput, + context, + ); + assert.ok(accepted.ok, JSON.stringify(accepted)); + releaseRead.resolve(); + const rejected = await pendingChanged; + assert.equal( + rejected.ok, + !changePayload, + 'concurrent replay must validate incoming content', + ); + if (!changePayload) assert.deepEqual(rejected, accepted); + if (!rejected.ok) assert.equal(rejected.error.code, 'operation_conflict'); + } finally { + releaseRead.resolve(); + await pendingChanged; + restoreRead?.(); + RootTurnCoordinator.prototype.runWorkHubCoordinationOperation = originalOperation; + } + } assert.equal(coordinationModelCalls, 0, 'Actions must not start a model answer in WorkHub'); const targetSessionId = created.result.targetSessionId; const session = (await manager.listSessions()).find(({ id }) => id === targetSessionId); diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts index 510e0ea115..c5efadcb9d 100644 --- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts +++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts @@ -545,6 +545,101 @@ test('reassembles a large message from bounded backward pages', async () => { ); }); +test('keeps page timeout independent of index preparation time', async () => { + const message = { + type: 'user' as const, + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'hello', + }; + const encoded = Buffer.from(JSON.stringify(message), 'utf8'); + const splitAt = Math.floor(encoded.byteLength / 2); + await withProtocolPeer( + async (transport, hostEpoch, rootId) => { + let openRequest = await acceptConnectionAndReadOpen(transport, hostEpoch, rootId); + await new Promise((resolve) => setTimeout(resolve, 700)); + await writeProtocolFrame(transport, { + requestId: openRequest.requestId, + operation: 'subscription.open', + ok: false, + error: { code: 'transcript_preparing', message: 'Preparing history' }, + }); + const next = decodeClientFrame(await transport.read(1_000)); + assert.ok(!('kind' in next) && next.operation === 'subscription.open'); + openRequest = next; + const opened = openResult(hostEpoch, 'subscription-fragmented', { + throughSequence: 0, + overlayMessageCount: 0, + durable: transcriptPage({ + rawBytes: encoded.byteLength - splitAt, + fragments: [ + { + kind: 'durable', + sequence: 0, + byteOffset: splitAt, + totalBytes: encoded.byteLength, + payloadDigest: null, + data: encoded.subarray(splitAt).toString('base64'), + }, + ], + nextCursor: 'cursor-1', + }), + overlay: transcriptPage({ source: 'overlay' }), + }); + await writeProtocolFrame(transport, { + requestId: openRequest.requestId, + operation: 'subscription.open', + ok: true, + result: opened, + }); + const continuationRequest = decodeClientFrame(await transport.read(1_000)); + assert.ok(!('kind' in continuationRequest)); + assert.equal(continuationRequest.operation, 'session.transcript.page'); + assert.deepEqual(continuationRequest.input, { + subscriptionId: opened.subscriptionId, + source: 'durable', + direction: 'older', + throughSequence: 0, + cursor: 'cursor-1', + anchorSequence: null, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + await writeProtocolFrame(transport, { + requestId: continuationRequest.requestId, + operation: 'session.transcript.page', + ok: true, + result: transcriptPage({ + rawBytes: splitAt, + fragments: [ + { + kind: 'durable', + sequence: 0, + byteOffset: 0, + totalBytes: encoded.byteLength, + payloadDigest: null, + data: encoded.subarray(0, splitAt).toString('base64'), + }, + ], + }), + }); + await answerClose(transport, opened.subscriptionId); + }, + async (connection) => { + const subscription = await connection.openSessionSubscription( + { + sessionId: 'session-1', + transcript: { kind: 'tail', maxBytes: 16 * 1024 }, + }, + 1_000, + ); + assert.deepEqual(await subscription.loadTranscript(decodeStoredMessage), [message]); + await subscription.close(); + }, + ); +}); + test('decodes one bounded page without walking the remaining transcript', async () => { const message = { type: 'user' as const, diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index 5a7b63dc71..2e53ba2654 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -597,7 +597,11 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { Date.now() + (timeoutMs === undefined ? 30_000 : requireTimeout(timeoutMs, 'timeoutMs')); for (;;) { try { - return await this.#openSessionSubscription(input, Math.max(1, deadline - Date.now())); + return await this.#openSessionSubscription( + input, + Math.max(1, deadline - Date.now()), + timeoutMs, + ); } catch (error) { if ( !(error instanceof RuntimeHostOperationError) || @@ -616,13 +620,14 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { #openSessionSubscription( input: SubscriptionOpenInput, - timeoutMs?: number, + openTimeoutMs: number, + requestTimeoutMs?: number, ): Promise { const expectedSessionId = input.sessionId; return this.#requestOperation( 'subscription.open', input, - timeoutMs, + openTimeoutMs, (result) => { if (result.hostEpoch !== this.hostEpoch) { throw new RuntimeHostSubscriptionError( @@ -645,13 +650,13 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { const subscription = new ClientSessionSubscription( result, () => this.#closeSessionSubscription(result.subscriptionId), - (query) => this.request('session.transcript.page', query, timeoutMs), + (query) => this.request('session.transcript.page', query, requestTimeoutMs), async () => { try { await this.request( 'session.transcript.overlay.release', { subscriptionId: result.subscriptionId }, - timeoutMs, + requestTimeoutMs, ); } catch (error) { this.#fail(asError(error)); diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 6a04ef8f7a..8e19acf6d3 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1707,13 +1707,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { turnId, ); if (!admission) break; - const expectedExecution = { ...request.execution }; - if ( - admission.execution.kind === 'workhub_coordination' && - admission.execution.actionId === undefined - ) - delete expectedExecution.actionId; - if (!isDeepStrictEqual(admission.execution, expectedExecution)) + if (!rootExecutionMatches(admission.execution, request.execution)) return operationConflict('Coordination action identity belongs to different content'); const run = await this.readRunIfPresent(request.sessionId, admission.runId); if (!run) break; @@ -1734,17 +1728,10 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { turnId = `whretry_${createHash('sha256').update(admission.runId).digest('hex').slice(0, 48)}`; } const receiptToReplay = recoveredReceipt; - const existingAttempt = await this.stores.agentRunStore.readRootTurnAdmission( - request.sessionId, - turnId, - ); const started = await this.startWorkHubCoordinationMessage( { ...request, turnId, - ...(existingAttempt?.execution.kind === 'workhub_coordination' - ? { execution: existingAttempt.execution } - : {}), ...(receiptToReplay ? { operation: async () => receiptToReplay } : {}), }, context, @@ -1821,7 +1808,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { operationConflict('Turn identity belongs to a different execution kind'), ); } - if (!isDeepStrictEqual(existing.execution, request.execution)) { + if (!rootExecutionMatches(existing.execution, request.execution)) { return completedStart( operationConflict('Turn identity belongs to a different execution payload'), ); @@ -3235,6 +3222,22 @@ function throwHostedStopError( } } +/** Compatibility may omit only the action identity absent from an older admission. */ +function rootExecutionMatches( + stored: RootExecutionDescriptor, + incoming: RootExecutionDescriptor, +): boolean { + if ( + stored.kind === 'workhub_coordination' && + incoming.kind === 'workhub_coordination' && + stored.actionId === undefined + ) { + const { actionId: _actionId, ...legacyIncoming } = incoming; + return isDeepStrictEqual(stored, legacyIncoming); + } + return isDeepStrictEqual(stored, incoming); +} + function rootMessageAdmissionMatches( admission: RootTurnAdmission, request: RootMessageStartRequest, @@ -3242,7 +3245,7 @@ function rootMessageAdmissionMatches( authorization: ConnectionContext['turnAdmissionAuthorization'], ): boolean { return ( - isDeepStrictEqual(admission.execution, request.execution) && + rootExecutionMatches(admission.execution, request.execution) && (request.execution.kind === 'external_message' && request.execution.inputDigest ? true : messageContentsEqual(requireHostedExecutionMessageContent(admission), content)) &&

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 literal 0 HcmV?d00001 diff --git a/.github/assets/workhub-turn-admission/receipts-light.png b/.github/assets/workhub-turn-admission/receipts-light.png new file mode 100644 index 0000000000000000000000000000000000000000..7042dcdb209fcd4847b18c79769f8c701b730637 GIT binary patch literal 65665 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 { const handlers = new Map unknown>(); let resolveCalls = 0; - const records: unknown[] = []; const actions: unknown[] = []; const changes: unknown[] = []; const createdSessionId = 'runtime-created-session'; @@ -35,14 +34,6 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' resolveCalls += 1; return { sessionId: 'maka_workhub_coordination' }; }, - recordWorkHubCoordination: async (input: { - turnId: string; - userText: string; - assistantText: string; - }) => { - records.push(input); - return { turnId: input.turnId }; - }, listWorkHubCoordinationCandidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], @@ -74,19 +65,7 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' assert.ok(handler); assert.deepEqual(await handler({}), { sessionId: 'maka_workhub_coordination' }); assert.equal(resolveCalls, 1); - assert.deepEqual( - await handlers.get('workhub:record')?.({}, { - turnId: 'record', - userText: 'Request', - assistantText: 'Summary', - }), - { turnId: 'record' }, - ); - assert.deepEqual(records, [{ - turnId: 'record', - userText: 'Request', - assistantText: 'Summary', - }]); + assert.equal(handlers.has('workhub:record'), false); assert.deepEqual(await handlers.get('workhub:candidates')?.({}), { candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], diff --git a/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts b/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts index e0dcbb3c1a..d2f701052f 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts @@ -83,7 +83,7 @@ export function createWorkHubController({ ...(routingStrategy ? { routingStrategy } : {}), coordination: { open: async (handler) => { handler(transcript); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => { const candidates = (await sessions.list()) .filter((entry) => entry.kind === 'ordinary' && !entry.archived) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 98b68f833d..4a20ed0f33 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -112,7 +112,7 @@ test('conversation acknowledges a durable assignment before projecting target ex handler([assignment]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), }, @@ -159,7 +159,7 @@ test('conversation feedback never lets an older refresh overwrite newer target s handler([assignment]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), }, @@ -201,7 +201,7 @@ test('direct stop bypasses routing candidates and preserves a not_owned delegati handler([coordinationAssignmentTurn()]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => { candidateReads += 1; return { candidateSetId: `sha256:${'d'.repeat(64)}`, candidates: [] }; @@ -257,7 +257,7 @@ test('an anaphoric stop asks for a fresh named imperative without offering a rou handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => assert.fail('anaphoric stop must not reach the Action Gate'), }, @@ -284,7 +284,7 @@ test('a named resume submits and reports what the Host did', async () => { handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -329,7 +329,7 @@ test('an anaphoric resume asks for a named work item', async () => { handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('resume clarification must not read route candidates'), act: async () => assert.fail('anaphoric resume must not reach the Action Gate'), }, @@ -356,7 +356,7 @@ test('a resume the Host will not admit becomes its clarification', async () => { handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -394,7 +394,7 @@ test('a resume identity conflict is not mislabeled as a missing target', async ( handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -421,7 +421,7 @@ test('a Runtime Host without safe-boundary resume explains why it cannot resume' handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -455,7 +455,7 @@ test('a recovering Runtime Host tells the user to retry resume', async () => { handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], @@ -486,7 +486,7 @@ test('a named stop reports the Gate refusal instead of judging the target itself handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => { submitted += 1; @@ -520,7 +520,7 @@ test('a stop that fails for any other reason is a fault, not a clarification', a handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('stop clarification must not read route candidates'), act: async () => { throw new WorkHubCoordinationFailure('persistence_failed', 'WorkHub stop state is unavailable'); @@ -550,7 +550,7 @@ test('stop-shaped ordinary work routes normally instead of looping on clarificat handler([]); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ @@ -1669,7 +1669,7 @@ test('submit keeps unmatched non-executable conversation in WorkHub', async () = sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], @@ -1716,7 +1716,7 @@ test('production submission delegates only through the Runtime-owned candidate r sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, candidates: [{ @@ -1771,7 +1771,7 @@ test('production retry reaches durable Action Gate replay while target is waitin sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [{ @@ -1817,7 +1817,7 @@ test('production sends an explicit correction as a linked replacement', async () sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'d'.repeat(64)}`, candidates: [ @@ -1943,7 +1943,7 @@ test('production natural-language corrections retain the prior delegation link', sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId, candidates: candidates.map((candidate) => { @@ -2043,7 +2043,7 @@ test('production correction-shaped creation stays create_new without an existing sessions: port([]), coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [], @@ -2074,9 +2074,6 @@ test('production clarification is persisted through the typed Action Gate dispos sessions: port([]), coordination: { open: async () => ({ close: async () => undefined }), - record: async () => { - throw new Error('legacy summary recording must not persist clarification'); - }, candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [], @@ -2091,7 +2088,7 @@ test('production clarification is persisted through the typed Action Gate dispos }, }); - assert.deepEqual(await controller.recordConversationTurn({ + assert.deepEqual(await controller.requestClarification({ turnId: 'clarification-action', userText: '继续稳定性问题', assistantText: '请选择目标 Session', @@ -2117,7 +2114,7 @@ test('production creation leaves Session identity and workspace authority to mai sessions, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, candidates: [], @@ -3281,7 +3278,7 @@ for (const createStrategy of [createWorkHubR24RoutingStrategy, () => createWorkH routingStrategy, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'payments-ref', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }] }), act: async (input) => { assert.equal(input.proposal.disposition, 'resume_work'); 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 e5b668ca78..bb3c9177eb 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -448,7 +448,7 @@ test('Coordination transcript adapter never replays history and completes only t }; }, }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('conversation open must not read route candidates'), act: async () => ({ ok: true, @@ -532,7 +532,7 @@ test('Coordination transcript adapter retries latest-record completion in the sa }; }, }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('conversation open must not read route candidates'), act: async () => ({ ok: true, @@ -630,7 +630,7 @@ test('Coordination transcript adapter ignores a stale latest-record failure afte }; }, }, - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('conversation open must not read route candidates'), act: async () => ({ ok: true, @@ -1158,3 +1158,28 @@ test('desktop adapter derives stable origin evidence from the existing Session l assert.deepEqual(second, first); assert.equal(reads, 1); }); + + +test('admitted clarification and resume project receipts without assistant messages', () => { + const turns = projectWorkHubCoordinationTurns([ + { type: 'user', id: 'failed-user', turnId: 'request', ts: 0, text: 'Which task?' }, + { type: 'turn_state', id: 'failed-state', turnId: 'request', ts: 1, status: 'failed' }, + { type: 'user', id: 'u', turnId: 'retry-turn', ts: 1, text: 'Which task?' }, + { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'receipt', turnId: 'retry-turn', ts: 2, + receipt: { actionId: 'request', userText: 'Which task?', clarification: 'Please name a task.', + result: { disposition: 'clarify', coordinationTurnId: 'retry-turn' } } }, + { type: 'turn_state', id: 'done', turnId: 'retry-turn', ts: 3, status: 'completed' }, + { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'resume-receipt', turnId: 'resume-turn', ts: 4, + receipt: { actionId: 'resume', userText: 'Resume Payments', + result: { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'target-turn' } } }, + { type: 'turn_state', id: 'resume-done', turnId: 'resume-turn', ts: 5, status: 'completed' }, + ]); + assert.equal(turns.length, 2); + assert.equal(turns[0]?.turnId, 'request'); + assert.equal(turns[0]?.result, 'Please name a task.'); + assert.equal(turns[0]?.state, 'completed'); + assert.equal(turns[1]?.result, undefined); + assert.deepEqual(turns[1]?.resume, { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'target-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 0644ad6553..f43e1e83cc 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -449,7 +449,7 @@ test('surface keeps clarification and successful routing in WorkHub', async () = handler([]); return { close: async () => undefined }; }, - recordConversationTurn: async ({ turnId }) => ({ turnId }), + requestClarification: async ({ turnId }) => ({ turnId }), resetVisitContext: () => {}, subscribe: () => () => {}, submit: async (input) => { @@ -511,7 +511,7 @@ test('ambiguous creation is durably clarified before a fresh imperative creates }, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], @@ -593,7 +593,7 @@ test('surface leaves discussion in WorkHub instead of creating a task view', asy handler([]); return { close: async () => undefined }; }, - recordConversationTurn: async ({ turnId }) => ({ turnId }), + requestClarification: async ({ turnId }) => ({ turnId }), resetVisitContext: () => {}, subscribe: () => () => {}, submit: async (input) => ({ @@ -684,7 +684,7 @@ test('real Session projection creates new guide topics and preserves origin ambi sessions: port, coordination: { open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: sessions.map((entry) => ({ @@ -879,7 +879,7 @@ test('successful delegated submission needs no renderer summary write', async () assert.equal(records, 0); }); -test('resume records ordinary conversation text without persisting execution fields', async () => { +test('resume relies on the admitted Host receipt without a second conversation write', async () => { const records: unknown[] = []; const controller = fakeController({ submit: async (input) => ({ @@ -895,11 +895,7 @@ test('resume records ordinary conversation text without persisting execution fie summary: () => 'Resume requested. See the target Session for current progress.', onSummaryError: () => assert.fail('conversation write must succeed'), }); - assert.deepEqual(records, [{ - turnId: 'resume-1', userText: 'Resume Payments', - assistantText: 'Resume requested. See the target Session for current progress.', disposition: 'summary', - }]); -}); + assert.deepEqual(records, []);}); test('lease retires only after an acknowledged submission', async () => { const { storage } = memoryStorage(); @@ -969,13 +965,13 @@ function memoryStorage() { function fakeController(input: { submit: WorkHubController['submit']; - record: WorkHubController['recordConversationTurn']; + record: WorkHubController['requestClarification']; }): WorkHubController { return { read: async () => ({ sessions: [], turns: [] }), submit: input.submit, openConversation: async () => ({ close: async () => undefined }), - recordConversationTurn: input.record, + requestClarification: input.record, subscribe: () => () => undefined, resetVisitContext: () => undefined, }; diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 966bc58334..b36fee7732 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -987,12 +987,6 @@ export class DesktopRuntimeHostClient { - recordWorkHubCoordination( - input: OperationInput<"workhub.coordination.record">, - ): Promise> { - return this.request("workhub.coordination.record", input); - } - listExternalSessionSources(): Promise { return this.request("external-session.source.query", {}); } 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 7bb4283f0a..7f5bd56b4f 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -35,7 +35,6 @@ type RuntimeHostWorkHubClient = Pick< | 'ingestAttachment' | 'actWorkHubCoordination' | 'listWorkHubCoordinationCandidates' - | 'recordWorkHubCoordination' | 'resolveWorkHubCoordinationSession' >; @@ -56,9 +55,6 @@ export function registerRuntimeHostWorkHubIpc( ipcMain.handle('workhub:resolveCoordinationSession', () => client.resolveWorkHubCoordinationSession(), ); - ipcMain.handle('workhub:record', (_event, input) => - client.recordWorkHubCoordination(input), - ); ipcMain.handle('workhub:candidates', () => client.listWorkHubCoordinationCandidates()); ipcMain.handle('workhub:prepareAttachments', async (event, items: unknown) => { if (!options.attachmentIngest) throw new Error('WorkHub attachments are unavailable'); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 68465dac92..46d52f4f33 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1011,11 +1011,6 @@ export interface MakaBridge { prepareAttachments(coordinationSessionId: string, items: RendererIngestInput[]): Promise; /** Resolve the active Runtime Host's stable coordination conversation. */ resolveCoordinationSession(): Promise; - /** Persist one deterministic clarification or routing summary. */ - record( - coordinationSessionId: string, - input: { turnId: string; userText: string; assistantText: string }, - ): Promise<{ turnId: string }>; /** Read one bounded, Host-issued candidate set for a coordination action. */ candidates( coordinationSessionId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 23164f1de7..fead146cef 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2016,16 +2016,6 @@ const makaBridge = { (scope) => ipcRenderer.invoke('workhub:resolveCoordinationSession', scope), ); }, - async record( - coordinationSessionId: string, - input: { turnId: string; userText: string; assistantText: string }, - ): Promise<{ turnId: string }> { - const scope = await resolveDesktopWorkHubCoordinationCreateScope( - coordinationSessionId, - runtimeHostSessionRef, - ); - return ipcRenderer.invoke('workhub:record', scope, input) as Promise<{ turnId: string }>; - }, async candidates( coordinationSessionId: string, ): Promise> { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index cdd11d6866..63a7300f0e 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1283,8 +1283,6 @@ function AppShellContent({ coordination: createDesktopWorkHubCoordinationPort({ sessionId: workHubCoordinationSessionId ?? 'workhub-coordination-unresolved', transcripts: window.maka.transcripts, - record: (input) => - window.maka.workHub.record(workHubCoordinationSessionId!, input), candidates: () => window.maka.workHub.candidates(workHubCoordinationSessionId!), act: (input) => diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 5c00022927..e05ee41f3b 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -144,6 +144,7 @@ export interface WorkHubCoordinationTurn { readonly targetSessionName: string; readonly outcome?: Extract['outcome']; }; + resume?: Extract; updatedAt: number; } @@ -264,11 +265,6 @@ export interface WorkHubCoordinationPort { handler: (turns: readonly WorkHubCoordinationTurn[]) => void, onError: (error: unknown) => void, ): Promise<{ close(): Promise }>; - record(input: { - turnId: string; - userText: string; - assistantText: string; - }): Promise<{ turnId: string }>; candidates(): Promise; act(input: Omit): Promise; } @@ -282,11 +278,11 @@ export interface WorkHubController { ) => void, onError: (error: unknown) => void, ): Promise<{ close(): Promise }>; - recordConversationTurn(input: { + requestClarification(input: { turnId: string; userText: string; assistantText: string; - disposition?: 'clarify' | 'summary'; + disposition: 'clarify'; }): Promise<{ turnId: string }>; subscribe(handler: () => void): () => void; resetVisitContext(): void; @@ -527,26 +523,16 @@ export function createWorkHubController(deps: { }, }; }, - async recordConversationTurn(input) { - if (input.disposition === 'clarify') { - const result = await coordination.act({ - actionId: input.turnId, - userText: input.userText, - proposal: { - disposition: 'clarify', - assistantText: input.assistantText, - }, - }); - if (result.disposition !== 'clarify') { - throw new Error('WorkHub Action Gate returned an unexpected disposition'); - } - return { turnId: result.coordinationTurnId }; - } - return coordination.record({ - turnId: input.turnId, + async requestClarification(input) { + const result = await coordination.act({ + actionId: input.turnId, userText: input.userText, - assistantText: input.assistantText, + proposal: { disposition: 'clarify', assistantText: input.assistantText }, }); + if (result.disposition !== 'clarify') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } + return { turnId: result.coordinationTurnId }; }, subscribe(handler) { return deps.sessions.subscribe(handler); diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index a157a60b3f..0b1ef4090a 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -50,18 +50,12 @@ const WORKHUB_COORDINATION_LATEST_RECORD_MAX_BYTES = 512 * 1024; export function createDesktopWorkHubCoordinationPort(deps: { sessionId: string; transcripts: WorkHubDesktopTranscriptBridge; - record(input: { - turnId: string; - userText: string; - assistantText: string; - }): Promise<{ turnId: string }>; candidates(): Promise; act( input: Omit, ): Promise>; }): WorkHubCoordinationPort { return { - record: deps.record, candidates: deps.candidates, async act(input) { const outcome = await deps.act(input); @@ -164,6 +158,15 @@ export function projectWorkHubCoordinationTurns( const stateByTurnId = new Map( deriveTurnRecords(messages).map((turn) => [turn.turnId, projectState(turn.status)]), ); + const factualTurnIds = new Set(messages.flatMap((message) => { + if (message.type !== 'workhub_coordination') return []; + if (message.kind === 'action_receipt' && message.receipt.result.disposition === 'clarify') { + return [message.receipt.actionId]; + } + return message.kind === 'delegation_assigned' || message.kind === 'delegation_stop_requested' + ? [message.coordinationTurnId] + : []; + })); const turns: WorkHubCoordinationTurn[] = []; const latestUserIndexByTurnId = new Map(); const terminalLinkState = new Map(); @@ -180,6 +183,26 @@ export function projectWorkHubCoordinationTurns( } for (const message of messages) { + if (message.type === 'workhub_coordination' && message.kind === 'action_receipt') { + const { receipt } = message; + if (receipt.result.disposition === 'clarify' || receipt.result.disposition === 'resume_work') { + const earlier = latestUserIndexByTurnId.get(message.turnId); + const row = { + messageId: message.id, + turnId: receipt.actionId, + text: boundedWorkHubTimelineText(receipt.userText), + ...(receipt.result.disposition === 'resume_work' ? { resume: receipt.result } : {}), + ...(receipt.clarification + ? { result: boundedWorkHubTimelineText(receipt.clarification) } + : {}), + state: stateByTurnId.get(message.turnId) ?? 'running', + updatedAt: message.ts, + }; + if (earlier !== undefined) turns[earlier] = row; + else turns.push(row); + } + continue; + } if (message.type === 'workhub_coordination' && message.kind === 'delegation_stop_requested') { const resolution = stopResolutionByDelegationId.get(message.stopsDelegationId); turns.push({ @@ -219,6 +242,7 @@ export function projectWorkHubCoordinationTurns( continue; } if (message.type === 'user') { + if (factualTurnIds.has(message.turnId)) continue; const text = boundedWorkHubTimelineText(userFacingText(message)); if (!text) continue; turns.push({ diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index a215fab538..9d1f07b9e7 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -173,21 +173,22 @@ export async function submitAndRecordWorkHubSurfaceInput(input: { // accepted and must not consume the immutable Coordination summary owned by // this action identity. A later same-identity retry may still be admitted. // Delegations project directly from the Host's atomic delegation_assigned - // record. Only local clarification still needs the generic summary path. + // record. Local clarification is submitted as its own admitted Host action. if ( result.kind === 'discussion' || result.kind === 'waiting' || result.kind === 'submitted' || - result.kind === 'stop' + result.kind === 'stop' || + result.kind === 'resume' ) { return result; } try { - await input.controller.recordConversationTurn({ + await input.controller.requestClarification({ turnId: input.request.requestId, userText: input.recordedUserText, assistantText: input.summary(result), - disposition: result.kind === 'clarification' ? 'clarify' : 'summary', + disposition: 'clarify', }); } catch (error) { input.onSummaryError(); @@ -662,6 +663,18 @@ export function WorkHubCoordinationTurnView(props: { copy={copy} onOpenSession={props.onOpenSession} /> + ) : props.turn.resume ? ( + candidate.target.sessionId === props.turn.resume!.targetSessionId, + )} + targetSessionId={props.turn.resume.targetSessionId} + heading={copy.resumeOutcomes[props.turn.resume.outcome]} + state={copy.resumeRequested} + result={undefined} + copy={copy} + onOpenSession={props.onOpenSession} + /> ) : assignment ? ( {} }; }, - recordConversationTurn: async ({ turnId }) => ({ turnId }), + requestClarification: async ({ turnId }) => ({ turnId }), subscribe: () => () => {}, resetVisitContext: () => {}, }; @@ -466,3 +466,19 @@ export const ComposerRetainsFailedAttachment: Story = { canvasElement.dataset.workhubComposerVerified = 'true'; }, }; + +// Real path: a completed Coordination Run projects host action receipts through +// the shared transcript, with clarification text and a navigable resume target. +export const CoordinationActionReceipts: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('你是指支付回调幂等性任务吗?')).toBeVisible(); + await expect(await canvas.findByText('已让中断的工作继续:')).toBeVisible(); + }, +}; diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 8b9a848019..e0e5686c78 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -48,7 +48,7 @@ durable conversation and execution substrate. The role is provisioned lazily when WorkHub first needs it and resolves to the same Session after Runtime Host or application restarts. The Session role representation, lookup, recovery, and per-Host UI resolution enforce this lifecycle contract. The -coordination transcript and disposition semantics remain separate later work. +coordination transcript and typed dispositions use that same Session substrate. The per-Host boundary is intentional. A Coordination Session coordinates only the ordinary Sessions belonging to the same Runtime Host. Switching Runtime Hosts @@ -73,6 +73,29 @@ It never acquires authority over an ordinary Session's execution or lifecycle. ## Dispositions and action admission +An admitted Coordination request owns a real root Turn and Run in the reserved +WorkHub Session. `answer_here` executes the existing model answer path. Action +Turns execute the Host operation through the same Runtime admission, execution +ownership, terminal commit, and recovery machinery; admission does not require an +extra model call. Intent, Resolver, and clarification can later invoke models +inside this coordination execution without changing target Session authority. + +A successful Action Run writes a host-authored, model-hidden +`RuntimeEvent.actions.coordination` receipt. The transcript projects it as an +`action_receipt`, not an invented assistant response. Clarification carries its +prompt; resume carries the target reference and admission acknowledgement. +The synthetic `workhub.coordination.record` operation is removed. Released history +remains readable without inventing admissions for old summary rows. + +A receipt acknowledges what the operation accepted; it is not the target's current +execution state. Re-delivery of a completed request returns that receipt, including +after restart, without repeating the effect. Failed attempts remain terminal; +a same-action retry gets a subsequent admitted Turn. An interrupted Host action is +closed by Runtime recovery and never replayed as a model answer. Target-owned +claims, assignment atomicity, and resume source-boundary checks still decide +whether an unfinished effect can continue. Transactional delegation/Stop facts +remain authoritative for their existing ownership and linkage projections. + Every WorkHub input resolves to exactly one proposed **disposition**: - `answer_here`: answer in the Coordination Session. @@ -277,8 +300,8 @@ lets the stop reach a terminal resolution. replacement. Its target comes from the shared Session Resolver port, whose first implementation is a temporary exact-name baseline; replacing it changes recall only, because admission revalidates opaque identity and expected state - rather than any display name. Pause, resume, and pronoun-based stop controls - remain later work. + rather than any display name. Named resume uses ordinary Session continuation admission. Pause and + pronoun-based stop controls remain later work. Reevaluate the per-Host decision if supported workflows require one WorkHub conversation to coordinate ordinary Sessions on multiple Runtime Hosts, or if Host diff --git a/packages/core/package.json b/packages/core/package.json index df82465aa7..a5570ae15e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -158,7 +158,8 @@ "./unified-diff": "./dist/unified-diff.js", "./dev-single-instance": "./dist/dev-single-instance.js", "./maka-wordmark": "./dist/maka-wordmark.js", - "./test-only/async-primitives": "./dist/test-only/async-primitives.js" + "./test-only/async-primitives": "./dist/test-only/async-primitives.js", + "./workhub-action-result": "./dist/workhub-action-result.js" }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo src/model-metadata.generated.ts", diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 3bfb380177..0476d7dd74 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -893,3 +893,31 @@ describe('RuntimeEvent reference validation', () => { ); }); }); + +test('Coordination Runtime receipts survive decoding and reject unrecognized results', () => { + const coordination = { + actionId: 'action', + userText: 'Which task?', + clarification: 'Name a task.', + result: { disposition: 'clarify' as const, coordinationTurnId: 'turn-1' }, + }; + const event = baseEvent({ + role: 'system', + author: 'host', + modelVisibility: 'hidden', + actions: { coordination }, + }); + assert.deepEqual(decodeRuntimeEvent(event).actions?.coordination, coordination); + assert.throws(() => + decodeRuntimeEvent({ + ...event, + actions: { coordination: { ...coordination, result: { disposition: 'execute_anything' } } }, + }), + ); + assert.throws(() => + decodeRuntimeEvent({ + ...event, + actions: { coordination: { ...coordination, executionStatus: 'completed' } }, + }), + ); +}); diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 6c7d37d226..28d89273bd 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -33,6 +33,7 @@ * projection, or ledger logic lives here. Those arrive in later nodes. */ +import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js'; import { isModelRetryDecision, type ModelRetryDecision } from './model-failure.js'; import { @@ -536,6 +537,8 @@ export interface RuntimeEventPermissionClosureAccepted { * event without `actions.endInvocation` MUST assert a terminal `status`. */ export interface RuntimeEventActions { + /** Host coordination receipt linked to this admitted Run. */ + coordination?: WorkHubActionReceipt; /** Durable physical pause; does not complete or cancel the owning logical Turn. */ handoffPause?: RuntimeHandoffPause; /** Patch applied to invocation-scoped runtime state. */ @@ -838,6 +841,7 @@ const RUNTIME_ACTIONS_SHAPE = defineObjectShape()( [], [ 'handoffPause', + 'coordination', 'stateDelta', 'artifactDelta', 'permissionRequest', @@ -1272,6 +1276,7 @@ function isRuntimeEventActions(value: unknown): value is RuntimeEventActions { } return ( (value.handoffPause === undefined || isRuntimeHandoffPause(value.handoffPause)) && + (value.coordination === undefined || isWorkHubActionReceipt(value.coordination)) && (value.stateDelta === undefined || isRecord(value.stateDelta)) && (value.artifactDelta === undefined || (isRecord(value.artifactDelta) && diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index 9108319565..28a52cd72c 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -241,6 +241,7 @@ export type RootExecutionDescriptor = | { /** Tool-free conversational execution admitted only by WorkHub authority. */ kind: 'workhub_coordination'; + operation?: 'action'; inputDigest: `sha256:${string}`; } | { kind: 'regenerate'; sourceTurnId: string } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e06eddfe00..661e6955f4 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -17,6 +17,8 @@ * under the License. */ +import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js'; + import { MODEL_FAILURE_MESSAGE_MAX_BYTES, isModelRetryDecision, @@ -1118,7 +1120,18 @@ export interface WorkHubActionClaim { export type WorkHubActionClaimOutcome = 'claimed' | 'same_claim' | 'conflict'; +export interface WorkHubCoordinationActionMessage { + type: 'workhub_coordination'; + kind: 'action_receipt'; + schemaVersion: 1; + id: string; + turnId: string; + ts: number; + receipt: WorkHubActionReceipt; +} + export type WorkHubCoordinationMessage = + | WorkHubCoordinationActionMessage | WorkHubDelegationAssignedMessage | WorkHubDelegationReplacementRequestedMessage | WorkHubDelegationReplacementAbortedMessage @@ -1608,6 +1621,16 @@ function decodeMessage( } function isWorkHubCoordinationMessage(message: Record): boolean { + if (message.kind === 'action_receipt') + return ( + hasMessageEnvelope(message, true) && + message.schemaVersion === 1 && + Object.keys(message).every((k) => + ['type', 'kind', 'schemaVersion', 'id', 'turnId', 'ts', 'receipt'].includes(k), + ) && + isWorkHubActionReceipt(message.receipt) + ); + if (message.kind === 'delegation_stop_requested') { return ( hasMessageEnvelope(message, true) && diff --git a/packages/core/src/workhub-action-result.ts b/packages/core/src/workhub-action-result.ts new file mode 100644 index 0000000000..feafef0426 --- /dev/null +++ b/packages/core/src/workhub-action-result.ts @@ -0,0 +1,115 @@ +/* + * 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 { isRecord } from './record-schema.js'; + +export type WorkHubActionResult = + | { readonly disposition: 'answer_here'; readonly coordinationTurnId: string } + | { readonly disposition: 'clarify'; readonly coordinationTurnId: string } + | { + readonly disposition: 'delegate_existing'; + readonly targetSessionId: string; + readonly targetTurnId: string; + readonly steered?: true; + } + | { + readonly disposition: 'create_new'; + readonly targetSessionId: string; + readonly targetTurnId: string; + readonly steered?: true; + } + | { + readonly disposition: 'replace'; + readonly replacementDisposition: 'delegate_existing' | 'create_new'; + readonly targetSessionId: string; + readonly targetTurnId: string; + readonly steered?: true; + } + | { + readonly disposition: 'stop_work'; + readonly outcome: 'cancelled_pending' | 'stop_delivered' | 'already_terminal' | 'not_owned'; + readonly targetSessionId: string; + readonly targetTurnId?: string; + } + | { + readonly disposition: 'resume_work'; + readonly outcome: 'resume_started' | 'already_running'; + readonly targetSessionId: string; + readonly targetTurnId?: string; + }; + +/** Coordination receipt, not a copy of target execution state. */ +export interface WorkHubActionReceipt { + actionId: string; + userText: string; + result: WorkHubActionResult; + clarification?: string; +} + +export function isWorkHubActionReceipt(value: unknown): value is WorkHubActionReceipt { + if ( + !isRecord(value) || + Object.keys(value).some( + (k) => !['actionId', 'userText', 'result', 'clarification'].includes(k), + ) || + typeof value.actionId !== 'string' || + !value.actionId || + typeof value.userText !== 'string' || + !value.userText.trim() || + (value.clarification !== undefined && typeof value.clarification !== 'string') + ) + return false; + const r = value.result; + if (!isRecord(r)) return false; + const text = (k: string) => typeof r[k] === 'string' && r[k] !== ''; + const keys = (allowed: string[]) => Object.keys(r).every((k) => allowed.includes(k)); + if (r.disposition === 'answer_here' || r.disposition === 'clarify') + return keys(['disposition', 'coordinationTurnId']) && text('coordinationTurnId'); + if ( + r.disposition === 'delegate_existing' || + r.disposition === 'create_new' || + r.disposition === 'replace' + ) + return ( + keys([ + 'disposition', + 'targetSessionId', + 'targetTurnId', + 'steered', + ...(r.disposition === 'replace' ? ['replacementDisposition'] : []), + ]) && + text('targetSessionId') && + text('targetTurnId') && + (r.steered === undefined || r.steered === true) && + (r.disposition !== 'replace' || + r.replacementDisposition === 'delegate_existing' || + r.replacementDisposition === 'create_new') + ); + if (r.disposition === 'stop_work' || r.disposition === 'resume_work') + return ( + keys(['disposition', 'outcome', 'targetSessionId', 'targetTurnId']) && + text('targetSessionId') && + (r.targetTurnId === undefined || text('targetTurnId')) && + (r.disposition === 'stop_work' + ? ['cancelled_pending', 'stop_delivered', 'already_terminal', 'not_owned'] + : ['resume_started', 'already_running'] + ).includes(String(r.outcome)) + ); + return false; +} diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index fef1a5020a..1ed87afa40 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -17,7 +17,9 @@ * under the License. */ +import { workHubCoordinationTurnId } from '../server/workhub-coordination-action-gate.js'; import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; import { createRunCompositionSnapshot } from '@maka/core/run-composition'; import type { BackendSendInput } from '@maka/core/backend-types'; @@ -832,7 +834,17 @@ test('production composition commits automatic titles through Host-owned Session test('WorkHub creates new work through the production assignment composition', async () => { await withCompositionRoot(async ({ root, owner }) => { const connectionId = await configureFakeDefaultTarget(owner); - const { composition, manager } = await createCapturedExecutionComposition(owner); + let coordinationModelCalls = 0; + const { composition, manager } = await createCapturedExecutionComposition(owner, { + primaryBackendFactory: (backendContext) => + new (class extends FakeBackend { + override async *send(input: BackendSendInput): AsyncIterable { + if (backendContext.header.id === 'maka_workhub_coordination') + coordinationModelCalls += 1; + yield* super.send(input); + } + })(backendContext), + }); const context = { hostEpoch: 'execution-composition-test', connectionId: 'workhub-create-client', @@ -854,6 +866,76 @@ test('WorkHub creates new work through the production assignment composition', a assert.equal(created.ok, true, JSON.stringify(created)); if (!created.ok || created.result.disposition !== 'create_new') return; + const coordinationStores = await openInteractiveExecutionStoresForWrite(owner.lease); + const admission = await coordinationStores.agentRunStore.readRootTurnAdmission( + 'maka_workhub_coordination', + 'workhub-create-action', + ); + assert.ok(admission, 'Delegation must belong to an admitted Coordination Turn'); + assert.equal(admission.execution.kind, 'workhub_coordination'); + const events = await coordinationStores.runtimeEventStore.readImmutableRuntimeEvents( + admission.sessionId, + admission.runId, + ); + assert.equal( + events.find((event) => event.actions?.coordination)?.actions?.coordination?.result + .disposition, + 'create_new', + ); + assert.ok(events.some((event) => event.status === 'completed')); + assert.equal( + events.some((event) => event.role === 'model'), + false, + ); + const transcript = await coordinationStores.sessionStore.readMessages(admission.sessionId); + assert.ok( + transcript.some( + (message) => message.type === 'workhub_coordination' && message.kind === 'action_receipt', + ), + 'The shared transcript must expose the Runtime receipt to Desktop', + ); + const replayed = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-create-action', + userText: 'Fix login stability', + proposal: { disposition: 'create_new', title: 'Login stability' }, + create: { workspace: { kind: 'host_path', path: root } }, + }, + context, + ); + assert.deepEqual(replayed, created); + const clarifyInput = { + actionId: 'workhub-clarify', + userText: 'Which task?', + proposal: { disposition: 'clarify' as const, assistantText: 'Please name the task.' }, + }; + const clarified = await composition.handlers['workhub.coordination.act']( + clarifyInput, + context, + ); + assert.ok(clarified.ok, JSON.stringify(clarified)); + assert.equal(clarified.result.disposition, 'clarify'); + if (clarified.result.disposition === 'clarify') { + assert.ok( + await coordinationStores.agentRunStore.readRootTurnAdmission( + admission.sessionId, + clarified.result.coordinationTurnId, + ), + ); + } + assert.deepEqual( + await composition.handlers['workhub.coordination.act'](clarifyInput, context), + clarified, + ); + const changed = await composition.handlers['workhub.coordination.act']( + { + ...clarifyInput, + proposal: { disposition: 'clarify', assistantText: 'Different content' }, + }, + context, + ); + assert.equal(changed.ok, false); + assert.equal(coordinationModelCalls, 0, 'Actions must not start a model answer in WorkHub'); const targetSessionId = created.result.targetSessionId; const session = (await manager.listSessions()).find(({ id }) => id === targetSessionId); assert.equal(session?.name, 'Login stability'); @@ -887,6 +969,93 @@ test('WorkHub creates new work through the production assignment composition', a }); }); +test('interrupted Coordination admission recovers without a model and retries in a new Turn', async () => { + await withCompositionRoot(async ({ root, owner }) => { + await configureFakeDefaultTarget(owner); + const context = { + hostEpoch: 'coordination-recovery', + connectionId: 'client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + const first = await createCapturedExecutionComposition(owner); + await first.composition.handlers['workhub.coordination.resolve']({}, context); + await first.composition.close(); + const request = { + actionId: 'orphaned-action', + userText: 'Which task?', + proposal: { disposition: 'clarify' as const, assistantText: 'Please name a task.' }, + }; + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + await stores.agentRunStore.admitRootTurn({ + sessionId: 'maka_workhub_coordination', + turnId: workHubCoordinationTurnId(request.actionId, 'clarify'), + proposedRunId: 'orphaned-run', + proposedUserMessageId: 'orphaned-user', + execution: { + kind: 'workhub_coordination', + operation: 'action', + inputDigest: `sha256:${createHash('sha256').update(JSON.stringify(request)).digest('hex')}`, + }, + previousRootTurnId: null, + normalizedInput: { text: request.userText }, + sourceMessages: [], + admittedAt: 1, + }); + await owner.close(); + const reopened = await tryAcquireInteractiveRootOwner( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + assert.ok(reopened); + let modelCalls = 0; + try { + const recovered = await createCapturedExecutionComposition(reopened, { + primaryBackendFactory: (backendContext) => + new (class extends FakeBackend { + override async *send(input: BackendSendInput): AsyncIterable { + modelCalls += 1; + yield* super.send(input); + } + })(backendContext), + }); + try { + const reopenedStores = await openInteractiveExecutionStoresForWrite(reopened.lease); + const oldRun = ( + await reopenedStores.runtimeEventStore.listSessionInvocations('maka_workhub_coordination') + ).find((run) => run.runId === 'orphaned-run'); + assert.ok(oldRun); + assert.equal(runtimeInvocationOutcome(oldRun), 'failed'); + const retried = await recovered.composition.handlers['workhub.coordination.act']( + request, + context, + ); + assert.ok(retried.ok, JSON.stringify(retried)); + assert.equal(retried.result.disposition, 'clarify'); + if (retried.result.disposition !== 'clarify') return; + assert.notEqual( + retried.result.coordinationTurnId, + workHubCoordinationTurnId(request.actionId, 'clarify'), + ); + const retryAdmission = await reopenedStores.agentRunStore.readRootTurnAdmission( + 'maka_workhub_coordination', + retried.result.coordinationTurnId, + ); + assert.ok(retryAdmission); + assert.notEqual(retryAdmission.runId, 'orphaned-run'); + assert.deepEqual( + await recovered.composition.handlers['workhub.coordination.act'](request, context), + retried, + ); + assert.equal(modelCalls, 0); + } finally { + await recovered.composition.close(); + } + } finally { + await reopened.close(); + } + }); +}); + test('WorkHub Resume and Stop follow logical lineage across repeated physical handoffs', async () => { await withCompositionRoot(async ({ root, owner }) => { const connectionId = await configureFakeDefaultTarget(owner); @@ -1059,8 +1228,15 @@ test('WorkHub Resume and Stop follow logical lineage across repeated physical ha }, }; const replayed = await composition.handlers['workhub.coordination.act'](retry, context); - assert.equal(replayed.ok, false, JSON.stringify(replayed)); - if (!replayed.ok) assert.equal(replayed.error.code, 'operation_conflict'); + // Re-delivery acknowledges the original Coordination Run; it must never + // resume a later interruption under the same action identity. + assert.equal(replayed.ok, true, JSON.stringify(replayed)); + const stillInterrupted = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: continuation.turnId }, + context, + ); + assert.ok(stillInterrupted.ok); + assert.notEqual(stillInterrupted.result.status, 'running'); const fresh = await composition.handlers['workhub.coordination.act']( { ...retry, actionId: 'workhub-resume-again' }, context, @@ -1220,9 +1396,21 @@ test('WorkHub does not record resume while safe-boundary resume is disabled', as message: 'Safe-boundary resume is disabled for this Runtime Host', }, }); - await composition.close(); const stores = await openInteractiveExecutionStoresForWrite(owner.lease); assert.equal(await stores.sessionStore.readWorkHubActionClaim(actionId), undefined); + const clarified = await composition.handlers['workhub.coordination.act']( + { + actionId, + userText: 'Resume Payments', + proposal: { + disposition: 'clarify', + assistantText: 'Resume is unavailable on this Host.', + }, + }, + context, + ); + assert.ok(clarified.ok, JSON.stringify(clarified)); + assert.equal(clarified.result.disposition, 'clarify'); } finally { await composition.close(); } 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 f6ae747120..06fddd5e77 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -39,10 +39,6 @@ import { } from '@maka/core/session'; import { createSessionStore, type SessionAuthorityStore } from '@maka/storage/session-store'; import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store'; -import { - WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, - WORKHUB_COORDINATION_TEXT_MAX_BYTES, -} from '../protocol/index.js'; 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'; @@ -430,70 +426,6 @@ describe('Host WorkHub Coordination coordinator', () => { } }); - test('records synthetic coordination summaries durably and retries idempotently', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-workhub-record-')); - const store = createSessionStore(root); - try { - const workhub = coordinator(root, store); - assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); - const input = { - turnId: 'summary-turn', - userText: 'Continue payment work', - assistantText: 'Submitted to Payment', - }; - assert.deepEqual(await workhub.handlers['workhub.coordination.record'](input, CONTEXT), { - ok: true, - result: { turnId: 'summary-turn' }, - }); - assert.deepEqual(await workhub.handlers['workhub.coordination.record'](input, CONTEXT), { - ok: true, - result: { turnId: 'summary-turn' }, - }); - const maximumInput = { - turnId: 'maximum-summary-turn', - // Each NUL is one UTF-8 input byte but six bytes once JSON-escaped in - // the durable transcript record. Retry lookup must budget for that - // worst case, not only the decoded text sizes. - userText: '\0'.repeat(WORKHUB_COORDINATION_TEXT_MAX_BYTES), - assistantText: '\0'.repeat(WORKHUB_COORDINATION_SUMMARY_MAX_BYTES), - }; - assert.deepEqual( - await workhub.handlers['workhub.coordination.record'](maximumInput, CONTEXT), - { ok: true, result: { turnId: 'maximum-summary-turn' } }, - ); - assert.deepEqual( - await workhub.handlers['workhub.coordination.record'](maximumInput, CONTEXT), - { ok: true, result: { turnId: 'maximum-summary-turn' } }, - ); - const messages = await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID); - assert.equal(messages.length, 6); - assert.deepEqual( - messages.slice(0, 3).map(({ type, turnId }) => ({ type, turnId })), - [ - { type: 'user', turnId: 'summary-turn' }, - { type: 'assistant', turnId: 'summary-turn' }, - { type: 'turn_state', turnId: 'summary-turn' }, - ], - ); - const conflict = await workhub.handlers['workhub.coordination.record']( - { ...input, assistantText: 'Different summary' }, - CONTEXT, - ); - assert.equal(conflict.ok, false); - if (!conflict.ok) assert.equal(conflict.error.code, 'operation_conflict'); - assert.equal((await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).length, 6); - const empty = await workhub.handlers['workhub.coordination.record']( - { ...input, turnId: 'empty-summary', assistantText: ' ' }, - CONTEXT, - ); - assert.equal(empty.ok, false); - if (!empty.ok) assert.equal(empty.error.code, 'operation_conflict'); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); - 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); @@ -1921,90 +1853,11 @@ describe('Host WorkHub Coordination coordinator', () => { 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); - const admission = new SessionAdmissionGate(); - const { executions } = coordinationExecutions(admission); - try { - const workhub = coordinator(root, store, () => undefined, undefined, executions, admission); - assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true); - - // An answered Turn is owned by the root admission ledger. - assert.equal( - ( - await workhub.handlers['workhub.coordination.answer']( - { turnId: 'shared-turn', text: 'What is left on payments?' }, - CONTEXT, - ) - ).ok, - true, - ); - const recordAfterAnswer = await workhub.handlers['workhub.coordination.record']( - { turnId: 'shared-turn', userText: 'Continue payments', assistantText: 'Sent to Payments' }, - CONTEXT, - ); - assert.deepEqual(recordAfterAnswer, { - ok: false, - error: { - code: 'operation_conflict', - message: 'WorkHub Coordination Turn identity belongs to a different operation', - }, - }); - assert.deepEqual(await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID), []); - - // A recorded Turn is owned by the durable summary triplet. - assert.equal( - ( - await workhub.handlers['workhub.coordination.record']( - { - turnId: 'recorded-turn', - userText: 'Continue payments', - assistantText: 'Sent to Payments', - }, - CONTEXT, - ) - ).ok, - true, - ); - const answerAfterRecord = await workhub.handlers['workhub.coordination.answer']( - { turnId: 'recorded-turn', text: 'What is left on payments?' }, - CONTEXT, - ); - assert.deepEqual(answerAfterRecord, { - ok: false, - error: { - code: 'operation_conflict', - message: 'WorkHub Coordination Turn identity belongs to a different operation', - }, - }); - assert.deepEqual( - (await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).map( - ({ type, turnId }) => ({ type, turnId }), - ), - [ - { type: 'user', turnId: 'recorded-turn' }, - { type: 'assistant', turnId: 'recorded-turn' }, - { type: 'turn_state', turnId: 'recorded-turn' }, - ], - ); - assert.deepEqual( - (await store.listTurnsSnapshot(WORKHUB_COORDINATION_SESSION_ID)).map( - ({ turnId }) => turnId, - ), - ['recorded-turn'], - ); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); }); type CoordinationExecutions = Pick< RootTurnCoordinator, - 'startWorkHubCoordinationMessage' | 'hasRootTurnAdmission' + 'startWorkHubCoordinationMessage' | 'runWorkHubCoordinationOperation' >; /** @@ -2013,7 +1866,6 @@ type CoordinationExecutions = Pick< * reproduce the ordering the real ledger enforces. */ function coordinationExecutions(admission: SessionAdmissionGate) { - const admitted = new Set(); const starts: Parameters[0][] = []; const prepared: MessageContent[] = []; const executions: CoordinationExecutions = { @@ -2023,7 +1875,6 @@ function coordinationExecutions(admission: SessionAdmissionGate) { const content = await request.prepareFreshContent(lease); if (content.kind === 'rejected') return content.outcome; prepared.push(content.content); - admitted.add(request.turnId); return { ok: true, result: { @@ -2035,7 +1886,10 @@ function coordinationExecutions(admission: SessionAdmissionGate) { }; }); }, - hasRootTurnAdmission: async (_sessionId, turnId) => admitted.has(turnId), + runWorkHubCoordinationOperation: async (request) => { + if (!request.operation) throw new Error('Missing operation'); + return { ok: true, result: await request.operation(request.turnId) }; + }, }; return { executions, starts, prepared }; } @@ -2053,7 +1907,10 @@ function coordinator( message: 'WorkHub test execution is not configured', }, }), - hasRootTurnAdmission: async () => false, + runWorkHubCoordinationOperation: async (request) => { + if (!request.operation) throw new Error('Missing operation'); + return { ok: true, result: await request.operation(request.turnId) }; + }, }, admission: SessionAdmissionGate = new SessionAdmissionGate(), sessionActions: Partial = {}, diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts index 30c0f9b40e..e826224dae 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -25,7 +25,6 @@ import { decodeWorkHubCoordinationActResult, decodeWorkHubCoordinationAnswerInput, decodeWorkHubCoordinationCandidatesResult, - decodeWorkHubCoordinationRecordInput, decodeWorkHubCoordinationResolveInput, decodeWorkHubCoordinationResolveResult, HOST_OPERATION_SPECS, @@ -55,18 +54,6 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () decodeWorkHubCoordinationAnswerInput({ turnId: 'answer-turn', text: 'What changed?' }), { turnId: 'answer-turn', text: 'What changed?' }, ); - assert.deepEqual( - decodeWorkHubCoordinationRecordInput({ - turnId: 'summary-turn', - userText: 'Continue payment work', - assistantText: 'Submitted to Payment', - }), - { - turnId: 'summary-turn', - userText: 'Continue payment work', - assistantText: 'Submitted to Payment', - }, - ); assert.deepEqual( decodeWorkHubCoordinationActInput({ actionId: 'action-correction', @@ -171,22 +158,11 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () (error) => error instanceof RuntimeHostProtocolError, ); assert.equal(HOST_OPERATION_SPECS['workhub.coordination.answer'].mode, 'command'); - assert.equal(HOST_OPERATION_SPECS['workhub.coordination.record'].mode, 'command'); assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.answer'), true); - assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.record'), true); assert.throws( () => decodeWorkHubCoordinationAnswerInput({ turnId: 'turn', text: 'answer', extra: true }), (error) => error instanceof RuntimeHostProtocolError, ); - assert.throws( - () => - decodeWorkHubCoordinationRecordInput({ - turnId: 'turn', - userText: 'user', - assistantText: 'x'.repeat(8 * 1024 + 1), - }), - (error) => error instanceof RuntimeHostProtocolError, - ); }); test('WorkHub Coordination resume has closed input and outcome shapes', () => { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 3078a049b2..a55555db45 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 133 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 134 as const; +// 134: Coordination actions own real Runtime Turns. Removes the synthetic record +// operation and projects typed action receipts; older peers cannot decode them. // 133: WorkHub actions carry attachments and new-Work model/permission defaults. // Epoch-132 peers reject these additional fields on strict action shapes. // 132: new Tool Result archives use versioned ledger references, not Artifact payloads. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 1134fdb917..cbc856d960 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -356,7 +356,6 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'workhub.coordination.answer', 'workhub.coordination.act', 'workhub.coordination.candidates', - 'workhub.coordination.record', 'workhub.coordination.resolve', ] as const satisfies readonly OperationKey[]); diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index fb9b6a3e5a..86f81d9c62 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -86,12 +86,6 @@ export interface WorkHubCoordinationAnswerInput { readonly attachments?: AttachmentRef[]; } -export interface WorkHubCoordinationRecordInput { - readonly turnId: string; - readonly userText: string; - readonly assistantText: string; -} - export interface WorkHubCoordinationTurnResult { readonly turnId: string; } @@ -189,40 +183,8 @@ export interface WorkHubCoordinationActInput { readonly confirmation?: WorkHubCoordinationDestructiveConfirmation; } -export type WorkHubCoordinationActResult = - | { readonly disposition: 'answer_here'; readonly coordinationTurnId: string } - | { readonly disposition: 'clarify'; readonly coordinationTurnId: string } - | { - readonly disposition: 'delegate_existing'; - readonly targetSessionId: string; - readonly targetTurnId: string; - readonly steered?: true; - } - | { - readonly disposition: 'create_new'; - readonly targetSessionId: string; - readonly targetTurnId: string; - readonly steered?: true; - } - | { - readonly disposition: 'replace'; - readonly replacementDisposition: 'delegate_existing' | 'create_new'; - readonly targetSessionId: string; - readonly targetTurnId: string; - readonly steered?: true; - } - | { - readonly disposition: 'stop_work'; - readonly outcome: 'cancelled_pending' | 'stop_delivered' | 'already_terminal' | 'not_owned'; - readonly targetSessionId: string; - readonly targetTurnId?: string; - } - | { - readonly disposition: 'resume_work'; - readonly outcome: 'resume_started' | 'already_running'; - readonly targetSessionId: string; - readonly targetTurnId?: string; - }; +export type { WorkHubActionResult as WorkHubCoordinationActResult } from '@maka/core/workhub-action-result'; +import type { WorkHubActionResult as WorkHubCoordinationActResult } from '@maka/core/workhub-action-result'; export const WORKHUB_COORDINATION_OPERATION_SPECS = { 'workhub.coordination.resolve': defineOperation< @@ -247,17 +209,6 @@ export const WORKHUB_COORDINATION_OPERATION_SPECS = { decodeInput: decodeWorkHubCoordinationAnswerInput, decodeOutput: decodeWorkHubCoordinationTurnResult, }), - 'workhub.coordination.record': defineOperation< - WorkHubCoordinationRecordInput, - WorkHubCoordinationTurnResult, - (typeof TURN_ERRORS)[number] - >({ - mode: 'command', - availability: 'ready', - errors: TURN_ERRORS, - decodeInput: decodeWorkHubCoordinationRecordInput, - decodeOutput: decodeWorkHubCoordinationTurnResult, - }), 'workhub.coordination.candidates': defineOperation< WorkHubCoordinationCandidatesInput, WorkHubCoordinationCandidatesResult, @@ -323,29 +274,6 @@ export function decodeWorkHubCoordinationAnswerInput( }; } -export function decodeWorkHubCoordinationRecordInput( - value: unknown, -): WorkHubCoordinationRecordInput { - const input = requireExactRecord(value, 'WorkHub Coordination record input', [ - 'turnId', - 'userText', - 'assistantText', - ]); - return { - turnId: requireEntityId(input.turnId, 'WorkHub Coordination Turn id'), - userText: requireUtf8String( - input.userText, - 'WorkHub Coordination user text', - WORKHUB_COORDINATION_TEXT_MAX_BYTES, - ), - assistantText: requireUtf8String( - input.assistantText, - 'WorkHub Coordination assistant text', - WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, - ), - }; -} - export function decodeWorkHubCoordinationTurnResult(value: unknown): WorkHubCoordinationTurnResult { const result = requireExactRecord(value, 'WorkHub Coordination Turn result', ['turnId']); return { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index f098c3ab81..4dfa090c97 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1619,13 +1619,13 @@ export async function createExecutionRuntimeHostComposition( .update(input.replacesDelegationId, 'utf8') .digest('hex') .slice(0, 48)}`, - turnId: input.actionId, + turnId: input.coordinationTurnId ?? input.actionId, ts: assignedAt, schemaVersion: WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, kind: 'delegation_superseded' as const, actionId: input.actionId, actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, + coordinationTurnId: input.coordinationTurnId ?? input.actionId, supersededActionId: input.replacesActionId, supersededDelegationId: input.replacesDelegationId, replacementDelegationId: delegationId, @@ -1635,7 +1635,7 @@ export async function createExecutionRuntimeHostComposition( assignment: { type: 'workhub_coordination', id: `wha_${suffix}`, - turnId: input.actionId, + turnId: input.coordinationTurnId ?? input.actionId, ts: assignedAt, schemaVersion: supersession ? WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION @@ -1643,7 +1643,7 @@ export async function createExecutionRuntimeHostComposition( kind: 'delegation_assigned', actionId: input.actionId, actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, + coordinationTurnId: input.coordinationTurnId ?? input.actionId, targetSessionId: input.targetSessionId, targetSessionName: input.targetSessionName, targetTurnId: turnId, diff --git a/packages/runtime-host/src/server/hosted-execution-recovery.ts b/packages/runtime-host/src/server/hosted-execution-recovery.ts index 9c595bbe6d..7423a3d928 100644 --- a/packages/runtime-host/src/server/hosted-execution-recovery.ts +++ b/packages/runtime-host/src/server/hosted-execution-recovery.ts @@ -454,7 +454,11 @@ function recoveryExecutionContract(execution: RootExecutionDescriptor): Recovery case 'external_message': return contract(true, true, 'root_replay'); case 'workhub_coordination': - return contract(false, true, 'root_replay'); + return contract( + false, + true, + execution.operation === 'action' ? 'host_recovery_closure' : 'root_replay', + ); case 'regenerate': return contract(false, true, 'root_replay'); case 'context_compact': @@ -491,6 +495,7 @@ function usesHostRecoveryClosure(execution: RootExecutionDescriptor): execution RootExecutionDescriptor, { kind: + | 'workhub_coordination' | 'goal' | 'legacy_automation' | 'agent_graph_supervisor_wake' @@ -501,6 +506,7 @@ function usesHostRecoveryClosure(execution: RootExecutionDescriptor): execution } > { return ( + (execution.kind === 'workhub_coordination' && execution.operation === 'action') || execution.kind === 'legacy_automation' || execution.kind === 'goal' || execution.kind === 'agent_graph_supervisor_wake' || diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 76a6208b09..ae5bb91922 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { WorkHubActionReceipt } from '@maka/core/workhub-action-result'; import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { BackendStopMode } from '@maka/core/backend-types'; @@ -222,6 +223,7 @@ export type RootMessageStartRequest = }) | (RootMessageStartRequestBase & { readonly execution: Extract; + readonly operation?: (turnId: string) => Promise; readonly turnOrchestration?: undefined; prepareFreshContent(lease: SessionAdmissionLease): Promise; }); @@ -1678,16 +1680,93 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { operationUnavailable('WorkHub Coordination execution requires its reserved Session'), ); } + if ((request.execution.operation === 'action') !== (request.operation !== undefined)) { + return Promise.resolve( + operationUnavailable('Coordination execution mode does not match its runner'), + ); + } return this.startRootMessage(request, context); } - /** - * Whether a durable root Turn already owns this identity. WorkHub also writes - * Coordination Turns outside this coordinator, and must not append a second - * triplet into a Turn this admission ledger already owns. - */ - async hasRootTurnAdmission(sessionId: string, turnId: string): Promise { - return (await this.stores.agentRunStore.readRootTurnAdmission(sessionId, turnId)) !== undefined; + async runWorkHubCoordinationOperation( + request: Extract, + context: ConnectionContext, + ): Promise< + { ok: true; result: WorkHubActionReceipt } | Extract + > { + if (request.execution.operation !== 'action' || !request.operation) { + return operationUnavailable('Coordination action requires a Host operation'); + } + let turnId = request.turnId; + // A retry preserves the action identity, but never reopens a terminal Run. + // The existing admission chain and terminal facts identify prior attempts. + for (;;) { + const admission = await this.stores.agentRunStore.readRootTurnAdmission( + request.sessionId, + turnId, + ); + if (!admission) break; + if (!isDeepStrictEqual(admission.execution, request.execution)) + return operationConflict('Coordination action identity belongs to different content'); + const run = await this.readRunIfPresent(request.sessionId, admission.runId); + if (!run) break; + const snapshot = await this.readCanonicalSnapshot( + request.sessionId, + turnId, + admission.runId, + run, + ); + if (!isTerminalSnapshot(snapshot) || snapshot.status === 'completed') break; + turnId = `whretry_${createHash('sha256').update(admission.runId).digest('hex').slice(0, 48)}`; + } + const started = await this.startWorkHubCoordinationMessage({ ...request, turnId }, context); + if (!started.ok) return started; + const active = this.#executions.get(request.sessionId); + if (active?.turnId === turnId) await active.done; + const snapshot = await this.readCanonicalSnapshot( + request.sessionId, + turnId, + started.result.runId, + ); + if (snapshot.status !== 'completed') + return operationUnavailable('Coordination operation did not complete'); + const events = await this.stores.runtimeEventStore.readImmutableRuntimeEvents( + request.sessionId, + started.result.runId, + ); + const receipt = events.find((event) => event.actions?.coordination)?.actions?.coordination; + return receipt + ? { ok: true, result: receipt } + : operationUnavailable('Coordination receipt is unavailable'); + } + + private coordinationOperation( + request: RootMessageStartRequest, + admission: RootTurnAdmission, + ): HostedExecutionAdmission | undefined { + if ( + request.execution.kind !== 'workhub_coordination' || + !('operation' in request) || + !request.operation + ) + return undefined; + const execute = request.operation; + const content = requireHostedExecutionMessageContent(admission); + return { + sessionId: admission.sessionId, + turnId: admission.turnId, + runId: admission.runId, + userMessageId: admission.userMessageId, + execution: admission.execution, + content, + start: ({ runId, userMessageId, onRunStarted }) => + this.manager.runCoordinationOperation( + admission.sessionId, + { turnId: admission.turnId, ...content }, + { runId, userMessageId, onRunStarted }, + () => execute(admission.turnId), + ), + }; } private startRootMessage( @@ -1759,7 +1838,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { context.acquireResidency, lease, undefined, - undefined, + this.coordinationOperation(request, existing), reservation, ); } @@ -1872,7 +1951,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { context.acquireResidency, lease, undefined, - undefined, + this.coordinationOperation(request, admitted.admission), reservation, ); }); 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 b9c9e88019..ee1905265b 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -179,7 +179,10 @@ export interface WorkHubRetirementResult { readonly targetTurnId?: string; } +type AdmittedWorkHubAction = WorkHubCoordinationActInput & { readonly coordinationTurnId?: string }; + export interface WorkHubDelegationAssignmentInput { + readonly coordinationTurnId?: string; readonly actionId: string; readonly actionFingerprint: `sha256:${string}`; readonly targetSessionId: string; @@ -205,6 +208,7 @@ export interface WorkHubDelegationReplacementAbortInput { } export interface WorkHubDelegationStopInput { + readonly coordinationTurnId?: string; readonly actionId: string; readonly actionFingerprint: `sha256:${string}`; readonly stopsActionId: string; @@ -285,9 +289,32 @@ export class WorkHubCoordinationActionGate { return candidateSet(await this.#effects.listSessions()); } + /** Bind admitted retries to the same stable replacement destination as the Gate. */ + async coordinationInputDigest(input: WorkHubCoordinationActInput): Promise<`sha256:${string}`> { + if (input.proposal.disposition !== 'replace') + return digest({ ...input, candidateSetId: undefined }); + const replaced = await this.#effects.readAssignment(input.proposal.replacesActionId); + if (!replaced) + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub replacement source is unavailable', + ); + const prepared = await this.#effects.readReplacement(replaced.delegationId); + const assigned = await this.#effects.readAssignment(input.actionId); + const destination = assigned?.targetSessionId ?? prepared?.targetSessionId; + if (destination) await this.#assertReplacementReplayTarget(input, destination); + const targetSessionId = + destination ?? (await this.#replacementAssignment(input, replaced)).targetSessionId; + return digest({ + fingerprint: replacementActionFingerprint(input, targetSessionId), + confirmation: input.confirmation, + }); + } + act( input: WorkHubCoordinationActInput, context: ConnectionContext, + admittedTurnId?: string, ): Promise { if (!input.userText.trim()) { return Promise.reject( @@ -314,7 +341,11 @@ export class WorkHubCoordinationActionGate { return replay.result; } - const result = this.#act(input, fingerprint, context); + const result = this.#act( + { ...input, ...(admittedTurnId ? { coordinationTurnId: admittedTurnId } : {}) }, + fingerprint, + context, + ); const action = { requestFingerprint, result }; this.#actions.set(input.actionId, action); // Successful actions remain a Host-lifetime fast path. Rejections release @@ -331,7 +362,7 @@ export class WorkHubCoordinationActionGate { } async #act( - input: WorkHubCoordinationActInput, + input: AdmittedWorkHubAction, fingerprint: `sha256:${string}`, context: ConnectionContext, ): Promise { @@ -364,7 +395,7 @@ export class WorkHubCoordinationActionGate { return this.#assign(assignmentInputFromRecord(durable), context); } if (proposal.disposition === 'answer_here') { - const turnId = coordinationTurnId(input.actionId, 'answer'); + const turnId = workHubCoordinationTurnId(input.actionId, 'answer'); await this.#claimAction(input.actionId, 'answer_here', fingerprint, turnId); await this.#effects.answer( { @@ -377,8 +408,14 @@ export class WorkHubCoordinationActionGate { return { disposition: 'answer_here', coordinationTurnId: turnId }; } if (proposal.disposition === 'clarify') { - const turnId = coordinationTurnId(input.actionId, 'clarify'); - await this.#claimAction(input.actionId, 'clarify', fingerprint, turnId); + const turnId = + input.coordinationTurnId ?? workHubCoordinationTurnId(input.actionId, 'clarify'); + await this.#claimAction( + input.actionId, + 'clarify', + fingerprint, + workHubCoordinationTurnId(input.actionId, 'clarify'), + ); await this.#effects.clarify({ turnId, userText: input.userText, @@ -439,6 +476,7 @@ export class WorkHubCoordinationActionGate { } const requested = await this.#effects.prepareStop({ actionId: input.actionId, + ...(input.coordinationTurnId ? { coordinationTurnId: input.coordinationTurnId } : {}), actionFingerprint: stopFingerprint, stopsActionId: source.actionId, stopsDelegationId: source.delegationId, @@ -747,7 +785,7 @@ export class WorkHubCoordinationActionGate { } async #replacementAssignment( - input: WorkHubCoordinationActInput, + input: AdmittedWorkHubAction, replaced: WorkHubDelegationAssignedMessage, ): Promise { if (input.proposal.disposition !== 'replace') { @@ -770,6 +808,7 @@ export class WorkHubCoordinationActionGate { const targetSessionId = workHubCreatedSessionId(input.actionId); return { actionId: input.actionId, + ...(input.coordinationTurnId ? { coordinationTurnId: input.coordinationTurnId } : {}), actionFingerprint: replacementActionFingerprint(input, targetSessionId), targetSessionId, targetSessionName: target.title, @@ -820,6 +859,7 @@ export class WorkHubCoordinationActionGate { } return { actionId: input.actionId, + ...(input.coordinationTurnId ? { coordinationTurnId: input.coordinationTurnId } : {}), actionFingerprint: replacementActionFingerprint(input, destination.sessionId), targetSessionId: destination.sessionId, targetSessionName: destination.sessionName, @@ -1077,12 +1117,12 @@ function candidateRef(candidateSetId: string, sessionId: string): string { return `whc_${hash(`${candidateSetId}\0${sessionId}`).slice(0, 48)}`; } -function coordinationTurnId(actionId: string, kind: 'answer' | 'clarify'): string { +export function workHubCoordinationTurnId(actionId: string, kind: 'answer' | 'clarify'): string { return `wha_${hash(`${actionId}\0${kind}`).slice(0, 48)}`; } function delegationAssignment( - input: WorkHubCoordinationActInput, + input: AdmittedWorkHubAction, actionFingerprint: `sha256:${string}`, targetSessionId: string, targetSessionName: string, @@ -1099,6 +1139,7 @@ function delegationAssignment( } const base = { actionId: input.actionId, + coordinationTurnId: input.coordinationTurnId ?? input.actionId, actionFingerprint, targetSessionId, targetSessionName, @@ -1281,6 +1322,7 @@ function assignmentInputFromRecord( ): WorkHubDelegationAssignmentInput { return { actionId: assignment.actionId, + coordinationTurnId: assignment.coordinationTurnId, actionFingerprint: assignment.actionFingerprint, targetSessionId: assignment.targetSessionId, targetSessionName: assignment.targetSessionName, @@ -1302,6 +1344,7 @@ function assignmentInputFromReplacement( ): WorkHubDelegationAssignmentInput { return { actionId: replacement.actionId, + coordinationTurnId: replacement.coordinationTurnId, actionFingerprint: replacement.actionFingerprint, targetSessionId: replacement.targetSessionId, targetSessionName: replacement.targetSessionName, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 1084180500..58fd130d37 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -44,7 +44,6 @@ import type { WorkHubCoordinationActResult, WorkHubCoordinationActInput, WorkHubCoordinationAnswerInput, - WorkHubCoordinationRecordInput, } from '../protocol/index.js'; import { WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, @@ -63,6 +62,7 @@ import { WorkHubActionGateFailure, WorkHubCoordinationActionGate, type WorkHubActionGateEffects, + workHubCoordinationTurnId, } from './workhub-coordination-action-gate.js'; const CREATE_FINGERPRINT = `sha256:${createHash('sha256') @@ -70,7 +70,6 @@ const CREATE_FINGERPRINT = `sha256:${createHash('sha256') .digest('hex')}`; const COORDINATION_CWD_DIRECTORY = 'workhub-coordination'; const COORDINATION_TOOL_PROFILE = 'workhub-coordination-v1' as const; -const SYNTHETIC_COORDINATION_MODEL_ID = 'maka-workhub-coordination'; const COORDINATION_PERMISSION_MODE = 'explore' as const; const COORDINATION_COLLABORATION_MODE = 'agent' as const; const COORDINATION_ORCHESTRATION_MODE = 'default' as const; @@ -108,7 +107,7 @@ type CoordinationStores = Pick< type CoordinationExecutions = Pick< RootTurnCoordinator, - 'startWorkHubCoordinationMessage' | 'hasRootTurnAdmission' + 'startWorkHubCoordinationMessage' | 'runWorkHubCoordinationOperation' >; type WorkHubResumeResult = @@ -147,7 +146,6 @@ export class HostWorkHubCoordinationCoordinator { readonly handlers: WorkHubCoordinationOperationHandlerMap = { 'workhub.coordination.resolve': () => this.#resolve(), 'workhub.coordination.answer': (input, context) => this.#answer(input, context), - 'workhub.coordination.record': (input) => this.#record(input), 'workhub.coordination.candidates': () => this.#candidates(), 'workhub.coordination.act': (input, context) => this.#act(input, context), }; @@ -202,16 +200,8 @@ export class HostWorkHubCoordinationCoordinator { throw new WorkHubActionEffectFailure(outcome.error.code, outcome.error.message); } }, - clarify: async (input) => { - const outcome = await this.#record({ - turnId: input.turnId, - userText: input.userText, - assistantText: input.assistantText, - }); - if (!outcome.ok) { - throw new WorkHubActionEffectFailure(outcome.error.code, outcome.error.message); - } - }, + // Clarification is recorded by the admitted Run as a host receipt. + clarify: async () => undefined, assign: options.sessionActions.assign, prepareReplacement: (input) => this.#prepareReplacement(input), abortReplacement: (input) => this.#abortReplacement(input), @@ -237,13 +227,14 @@ export class HostWorkHubCoordinationCoordinator { build: (existing) => ({ type: 'workhub_coordination', id: `whp_${suffix}`, - turnId: input.actionId, + turnId: existing?.turnId ?? input.coordinationTurnId ?? input.actionId, ts: existing?.ts ?? Date.now(), schemaVersion: WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION, kind: 'delegation_replacement_requested', actionId: input.actionId, actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, + coordinationTurnId: + existing?.coordinationTurnId ?? input.coordinationTurnId ?? input.actionId, targetSessionId: input.targetSessionId, targetSessionName: input.targetSessionName, disposition: input.disposition, @@ -311,13 +302,14 @@ export class HostWorkHubCoordinationCoordinator { build: (existing) => ({ type: 'workhub_coordination', id: `whq_${suffix}`, - turnId: input.actionId, + turnId: existing?.turnId ?? input.coordinationTurnId ?? input.actionId, ts: existing?.ts ?? Date.now(), schemaVersion: WORKHUB_COORDINATION_STOP_SCHEMA_VERSION, kind: 'delegation_stop_requested', actionId: input.actionId, actionFingerprint: input.actionFingerprint, - coordinationTurnId: input.actionId, + coordinationTurnId: + existing?.coordinationTurnId ?? input.coordinationTurnId ?? input.actionId, stopsActionId: input.stopsActionId, stopsDelegationId: input.stopsDelegationId, targetSessionId: input.targetSessionId, @@ -522,7 +514,51 @@ export class HostWorkHubCoordinationCoordinator { context: ConnectionContext, ): Promise> { try { - return { ok: true, result: await this.#actionGate.act(input, context) }; + if (input.proposal.disposition === 'answer_here') { + return { ok: true, result: await this.#actionGate.act(input, context) }; + } + const coordinationTurnId = + input.proposal.disposition === 'clarify' + ? workHubCoordinationTurnId(input.actionId, 'clarify') + : input.actionId; + let result: WorkHubCoordinationActResult | undefined; + let failure: unknown; + const outcome = await this.#executions.runWorkHubCoordinationOperation( + { + sessionId: WORKHUB_COORDINATION_SESSION_ID, + turnId: coordinationTurnId, + execution: { + kind: 'workhub_coordination', + operation: 'action', + inputDigest: await this.#actionGate.coordinationInputDigest(input), + }, + archivedMessage: 'WorkHub Coordination Session is unavailable', + prepareFreshContent: async () => + (await this.#readSummaryMessages(coordinationTurnId)).length > 0 + ? { kind: 'rejected', outcome: turnIdentityConflict() } + : { kind: 'ready', content: normalizeMessageContent({ text: input.userText }) }, + operation: async (turnId) => { + try { + result = await this.#actionGate.act(input, context, turnId); + return { + actionId: input.actionId, + userText: input.userText, + result, + ...(input.proposal.disposition === 'clarify' + ? { clarification: input.proposal.assistantText } + : {}), + }; + } catch (error) { + failure = error; + throw error; + } + }, + }, + context, + ); + if (failure) throw failure; + if (!outcome.ok) return outcome; + return { ok: true, result: outcome.result.result }; } catch (error) { if (error instanceof WorkHubActionEffectFailure) { return { @@ -649,9 +685,8 @@ export class HostWorkHubCoordinationCoordinator { }), }, archivedMessage: 'WorkHub Coordination Session is unavailable', - // A recorded summary owns its Turn identity durably but is admitted - // outside this ledger, so the probe runs under the admission lease: a - // concurrent `record` cannot slip a second triplet into this Turn. + // Released summaries have no admission row. Keep their Turn identities + // reserved when admitting a new Runtime-owned answer. prepareFreshContent: async () => { let recorded: readonly StoredMessage[]; try { @@ -680,76 +715,7 @@ export class HostWorkHubCoordinationCoordinator { return outcome.ok ? { ok: true, result: { turnId: input.turnId } } : outcome; } - #record( - input: WorkHubCoordinationRecordInput, - ): Promise> { - if (!input.userText.trim() || !input.assistantText.trim()) { - return Promise.resolve( - turnFailure( - 'workhub.coordination.record', - 'operation_conflict', - 'WorkHub Coordination summary text is empty', - ), - ); - } - return this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, async (lease) => { - let header: SessionHeader; - try { - header = await this.#stores.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID); - } catch { - return turnFailure( - 'workhub.coordination.record', - 'persistence_failed', - 'WorkHub Coordination Session state is unavailable', - ); - } - if (!validCoordinationHeader(header)) { - return turnFailure( - 'workhub.coordination.record', - 'operation_conflict', - 'WorkHub Coordination Session identity is unavailable', - ); - } - - const messages = coordinationSummaryMessages(input); - try { - const existing = await this.#readSummaryMessages(input.turnId); - if (existing.length > 0) { - return coordinationSummaryMatches(existing, input) - ? { ok: true, result: { turnId: input.turnId } } - : turnFailure( - 'workhub.coordination.record', - 'operation_conflict', - 'WorkHub Coordination Turn identity belongs to different content', - ); - } - // An answer owns its Turn identity in the root admission ledger. Both - // operations take the same Session admission, so this probe settles the - // race in one direction and the answer's own probe settles the other. - if ( - await this.#executions.hasRootTurnAdmission(WORKHUB_COORDINATION_SESSION_ID, input.turnId) - ) { - return turnFailure( - 'workhub.coordination.record', - 'operation_conflict', - TURN_IDENTITY_CONFLICT_MESSAGE, - ); - } - await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, messages); - await this.#continuity.refreshCanonical(WORKHUB_COORDINATION_SESSION_ID, lease); - return { ok: true, result: { turnId: input.turnId } }; - } catch { - this.#requestDrain(); - return turnFailure( - 'workhub.coordination.record', - 'commit_outcome_unknown', - 'WorkHub Coordination summary outcome is unknown', - ); - } - }); - } - - /** Reads the durable summary triplet a `record` would own for this Turn. */ + /** Reads released synthetic summaries solely to reject identity collisions. */ async #readSummaryMessages(turnId: string): Promise { const throughSequence = await this.#stores.readTranscriptHighWaterSnapshot( WORKHUB_COORDINATION_SESSION_ID, @@ -868,55 +834,6 @@ function coordinationSummaryMessageId( .slice(0, 48)}`; } -function coordinationSummaryMessages(input: WorkHubCoordinationRecordInput): StoredMessage[] { - const ts = Date.now(); - const messageId = (kind: (typeof COORDINATION_SUMMARY_MESSAGE_KINDS)[number]) => - coordinationSummaryMessageId(input.turnId, kind); - return [ - { - type: 'user', - id: messageId('user'), - turnId: input.turnId, - ts, - text: input.userText, - }, - { - type: 'assistant', - id: messageId('assistant'), - turnId: input.turnId, - ts: ts + 1, - text: input.assistantText, - modelId: SYNTHETIC_COORDINATION_MODEL_ID, - }, - { - type: 'turn_state', - id: messageId('state'), - turnId: input.turnId, - ts: ts + 2, - status: 'completed', - }, - ]; -} - -function coordinationSummaryMatches( - existing: readonly StoredMessage[], - input: WorkHubCoordinationRecordInput, -): boolean { - if (existing.length !== 3) return false; - const user = existing.find((message) => message.type === 'user'); - const assistant = existing.find((message) => message.type === 'assistant'); - const state = existing.find((message) => message.type === 'turn_state'); - return ( - user?.turnId === input.turnId && - user.text === input.userText && - assistant?.turnId === input.turnId && - assistant.text === input.assistantText && - assistant.modelId === SYNTHETIC_COORDINATION_MODEL_ID && - state?.turnId === input.turnId && - state.status === 'completed' - ); -} - function success(): OperationOutcome<'workhub.coordination.resolve'> { return { ok: true, @@ -950,7 +867,7 @@ function operationUnavailable(message: string) { return { ok: false, error: { code: 'operation_unavailable', message } } as const; } -function turnFailure( +function turnFailure( _operation: K, code: Extract, { readonly ok: false }>['error']['code'], message: string, diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index aea8ca2ef5..6e9cb120bc 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1883,6 +1883,14 @@ type ActionCoverageSamples = { }; const ACTION_COVERAGE_SAMPLES: ActionCoverageSamples = { + coordination: { + action: { + actionId: 'clarify', + userText: 'Which task?', + result: { disposition: 'clarify', coordinationTurnId: 'turn-1' }, + clarification: 'Please name a task.', + }, + }, handoffPause: { action: { protocol: 'runtime_handoff_pause_v1', @@ -2405,3 +2413,32 @@ function makeHeader(id: string): SessionHeader { schemaVersion: 1, }; } + +test('Coordination receipts materialize as host facts, never assistant output', () => { + const receipt = { + actionId: 'clarify', + userText: 'Which task?', + clarification: 'Please name a task.', + result: { disposition: 'clarify' as const, coordinationTurnId: turnId }, + }; + const out = projectRuntimeEventsToStoredMessages( + [ + ev({ + id: 'coordination', + author: 'host', + modelVisibility: 'hidden', + actions: { coordination: receipt }, + }), + ], + { invocations: [invocation] }, + ); + assert.ok( + out.messages.some( + (message) => message.type === 'workhub_coordination' && message.kind === 'action_receipt', + ), + ); + assert.equal( + out.messages.some((message) => message.type === 'assistant'), + false, + ); +}); diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 124d79ff28..4a5c547872 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -11922,6 +11922,15 @@ class GatedSteeringBackend implements AgentBackend { } class DelegatingRuntimeKernel implements RuntimeKernelLike { + async *runCoordinationOperation( + _sessionId: string, + _input: Parameters[1], + _options: unknown, + execute: Parameters[3], + ): AsyncIterable { + await execute(); + } + readonly starts: Array<{ sessionId: string; input: Parameters[1]; diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 10a79aa96c..f65410d420 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -20,7 +20,12 @@ import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; -import type { AssistantStepContentKind, StoredMessage, TurnStatus } from '@maka/core/session'; +import type { + AssistantStepContentKind, + StoredMessage, + TurnStatus, + WorkHubCoordinationActionMessage, +} from '@maka/core/session'; import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; import type { ToolActivityKind, ToolResultContent } from '@maka/core/events'; import { markPersisted } from '@maka/core/persisted-value'; @@ -99,6 +104,21 @@ export function isContinuationStartRuntimeEvent(event: RuntimeEvent): boolean { ); } +export function projectRuntimeEventCoordinationReceipt( + event: RuntimeEvent, +): WorkHubCoordinationActionMessage | undefined { + if (!event.actions?.coordination) return undefined; + return { + type: 'workhub_coordination', + kind: 'action_receipt', + schemaVersion: 1, + id: event.id, + turnId: event.turnId, + ts: event.ts, + receipt: event.actions.coordination, + }; +} + /** * Whether the event can affect the StoredMessage projection or the state needed * to construct one. Pure control-plane facts are intentionally absent so a @@ -106,6 +126,7 @@ export function isContinuationStartRuntimeEvent(event: RuntimeEvent): boolean { */ export function affectsRuntimeEventStoredMessageProjection(event: RuntimeEvent): boolean { return ( + event.actions?.coordination !== undefined || event.content !== undefined || isTerminalRuntimeEvent(event) || event.actions?.permissionRequest !== undefined || @@ -284,6 +305,12 @@ export function projectRuntimeEventsToStoredMessages( } } + const coordinationReceipt = projectRuntimeEventCoordinationReceipt(event); + if (coordinationReceipt) { + messages.push(coordinationReceipt); + projected = true; + } + if (event.actions?.permissionRequest) { const request = event.actions.permissionRequest; state.permissionRequestById.set(request.requestId, { diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index f061a9eb33..97b4c93174 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { WorkHubActionReceipt } from '@maka/core/workhub-action-result'; import type { AgentRunStore } from '@maka/core/agent-run'; import { agentRunCompositionFromEvents } from '@maka/core/agent-run'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; @@ -171,6 +172,12 @@ export interface RuntimeKernelLike { continuation: RuntimeContinuation, options?: ResumeContinuationOptions, ): AsyncIterable; + runCoordinationOperation( + sessionId: string, + input: UserMessageInput, + options: TurnStartOptions, + execute: () => Promise, + ): AsyncIterable; compactSession(sessionId: string, input?: CompactSessionInput): AsyncIterable; preflightContextCompaction(sessionId: string): Promise; stopSession(sessionId: string, input?: StopSessionInput): Promise; @@ -956,6 +963,116 @@ export class RuntimeKernel implements RuntimeKernelLike { ); } + /** Host coordination uses the same Run owner and terminal authority without a provider send. */ + async *runCoordinationOperation( + sessionId: string, + input: UserMessageInput, + options: TurnStartOptions, + execute: () => Promise, + ): AsyncIterable { + const execution = this.takeExecutionClaim(sessionId); + try { + await this.enterExecutionClaim(execution); + const header = await this.deps.store.readHeader(sessionId); + const run = new AgentRun({ + sessionId, + header, + userInput: input, + runId: options.runId, + userMessageId: options.userMessageId, + durability: 'required', + store: this.deps.store, + runStore: this.deps.runStore, + runtimeEventStore: this.deps.runtimeEventStore, + newId: this.deps.newId, + now: this.deps.now, + effectiveOrchestration: resolveEffectiveOrchestration('default', undefined), + hooks: { + reserveRun: async (id, nextHeader, activeRun) => { + const active = await this.reserveParentRun(id, nextHeader, activeRun, execution); + this.reserveExecutionClaim(execution, active, activeRun); + return active; + }, + unregisterRun: (active, activeRun) => this.unregisterParentRun(active, activeRun), + updateHeader: (id, patch) => this.updateHeader(id, patch), + updateStatus: (id, status, reason, ts) => this.updateStatus(id, status, reason, ts), + appendTurnState: (id, turnId, status, lineage, stateOptions) => + this.appendTurnState(id, turnId, status, lineage, stateOptions), + }, + }); + this.attachExecutionClaim(execution, run); + const owners = this.createRunOwnerScope(run, execution); + try { + owners.bindMessage(this.deps.messageAuthority, { + sessionId, + turnId: input.turnId, + runId: run.runId, + }); + await this.runBackendActivation(async () => { + run.bindProviderStateIdentity( + await this.prepareBackendForExecution(sessionId, header, execution), + ); + await run.begin(); + }); + await options.onRunStarted?.(run.runId, header); + this.settleReservedExecutionClaim(execution, run, { ok: true }); + } catch (error) { + await this.finalizeFailedRunStart(owners, run, execution, error); + return; + } + try { + if (run.isStopped()) return; + const receipt = await execute(); + const receiptEvent: RuntimeEvent = { + id: this.deps.newId(), + sessionId, + turnId: input.turnId, + runId: run.runId, + invocationId: run.runId, + ts: this.deps.now(), + partial: false, + role: 'system', + author: 'host', + modelVisibility: 'hidden', + actions: { coordination: receipt }, + }; + await run.recordRuntimeEvents([receiptEvent], { requireDurableWrite: true }); + if (run.isStopped()) return; + const complete: CompleteEvent = { + type: 'complete', + id: this.deps.newId(), + turnId: input.turnId, + ts: this.deps.now(), + stopReason: 'end_turn', + }; + await run.acceptMappedEvent( + complete, + mapSessionEventToRuntimeEvent( + complete, + this.runtimeEventMapContext({ + sessionId, + invocationId: run.runId, + runId: run.runId, + turnId: input.turnId, + }), + ), + { requireTerminalWrite: true }, + ); + yield complete; + } catch (error) { + await run.recordFailure(error); + throw error; + } finally { + const failures = new FailureCollector(); + await failures.capture(() => owners.finalize()); + await failures.capture(() => owners.releaseMessage()); + failures.throwIfAny(`Coordination cleanup failed for ${run.runId}`); + } + } finally { + this.releaseExecutionClaim(execution); + } + } + async *compactSession( sessionId: string, input: CompactSessionInput = {}, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 40000d95d0..abfa95e8ed 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -29,6 +29,7 @@ * persistence and same-session serialization semantics. */ +import type { WorkHubActionReceipt } from '@maka/core/workhub-action-result'; import { createHash } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import { setTimeout as delay } from 'node:timers/promises'; @@ -2342,6 +2343,15 @@ export class SessionManager { } } + runCoordinationOperation( + sessionId: string, + input: UserMessageInput, + options: TurnStartOptions, + execute: () => Promise, + ): AsyncIterable { + return this.runtimeKernel.runCoordinationOperation(sessionId, input, options, execute); + } + async *compactSession( sessionId: string, input: CompactSessionInput = {}, @@ -3694,6 +3704,12 @@ export class SessionManager { executionKind: input.execution.kind, goalId: input.execution.goalId, }; + } else if ( + input.execution.kind === 'workhub_coordination' && + input.execution.operation === 'action' + ) { + recoveryReason = 'coordination_action_admission_without_run'; + diagnostic = { executionKind: input.execution.kind, operation: input.execution.operation }; } else if (input.execution.kind === 'legacy_automation') { root = { kind: 'legacy_automation', legacyAutomationId: input.execution.automationId }; recoveryReason = 'legacy_automation_authority_removed'; diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 58aa12a584..3a657e0a20 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -2029,11 +2029,21 @@ function normalizeRootExecutionDescriptor(value: unknown): RootExecutionDescript }); } if (value.kind === 'workhub_coordination') { - if (!hasExactKeys(value, ['kind', 'inputDigest']) || !isSha256Digest(value.inputDigest)) { + if ( + !hasExactKeys( + value, + value.operation === undefined + ? ['kind', 'inputDigest'] + : ['kind', 'inputDigest', 'operation'], + ) || + (value.operation !== undefined && value.operation !== 'action') || + !isSha256Digest(value.inputDigest) + ) { throw new Error('Invalid root execution descriptor'); } return Object.freeze({ kind: 'workhub_coordination', + ...(value.operation === 'action' ? { operation: 'action' as const } : {}), inputDigest: value.inputDigest, }); } From 8e5d017359d6c3d7304e0eece7e0f2ad780c99ac Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 07:34:29 +0800 Subject: [PATCH 2/9] refactor(workhub): share action result validation and remove redundant inputs Generated-by: Codex --- apps/desktop/renderer-architecture.json | 3 +- .../main/__tests__/workhub-controller.test.ts | 1 - .../src/renderer/workhub-controller.ts | 1 - apps/desktop/src/renderer/workhub-surface.tsx | 1 - .../core/src/__tests__/runtime-event.test.ts | 25 ++++ packages/core/src/workhub-action-result.ts | 13 +- .../src/protocol/workhub-coordination.ts | 118 ++---------------- .../workhub-coordination-coordinator.ts | 3 +- 8 files changed, 45 insertions(+), 120 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 4712d5e0c1..098cfae5ac 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -747,7 +747,6 @@ "window.maka.transcripts": 2, "window.maka.workHub.act": 1, "window.maka.workHub.candidates": 1, - "window.maka.workHub.record": 1, "window.maka.workHub.resolveCoordinationSession": 1 }, "environmentCapabilities": { @@ -895,7 +894,7 @@ "react": 1 }, "importSpecifiers": 116, - "nonTriviaTokens": 14338 + "nonTriviaTokens": 14318 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 4a20ed0f33..3abe2937dd 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -2092,7 +2092,6 @@ test('production clarification is persisted through the typed Action Gate dispos turnId: 'clarification-action', userText: '继续稳定性问题', assistantText: '请选择目标 Session', - disposition: 'clarify', }), { turnId: 'clarification-turn' }); assert.deepEqual(actions, [{ actionId: 'clarification-action', diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index e05ee41f3b..641c4957f9 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -282,7 +282,6 @@ export interface WorkHubController { turnId: string; userText: string; assistantText: string; - disposition: 'clarify'; }): Promise<{ turnId: string }>; subscribe(handler: () => void): () => void; resetVisitContext(): void; diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 9d1f07b9e7..4089093854 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -188,7 +188,6 @@ export async function submitAndRecordWorkHubSurfaceInput(input: { turnId: input.request.requestId, userText: input.recordedUserText, assistantText: input.summary(result), - disposition: 'clarify', }); } catch (error) { input.onSummaryError(); diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 0476d7dd74..a4e8e7ea25 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -908,6 +908,31 @@ test('Coordination Runtime receipts survive decoding and reject unrecognized res actions: { coordination }, }); assert.deepEqual(decodeRuntimeEvent(event).actions?.coordination, coordination); + for (const result of [ + { disposition: 'clarify', coordinationTurnId: '../invalid' }, + { disposition: 'stop_work', outcome: 'stop_delivered', targetSessionId: 'target' }, + { + disposition: 'stop_work', + outcome: 'cancelled_pending', + targetSessionId: 'target', + targetTurnId: 'turn', + }, + { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'target' }, + { + disposition: 'resume_work', + outcome: 'already_running', + targetSessionId: 'target', + targetTurnId: 'turn', + }, + ]) { + assert.throws(() => + decodeRuntimeEvent({ + ...event, + actions: { coordination: { ...coordination, result } }, + }), + ); + } + assert.throws(() => decodeRuntimeEvent({ ...event, diff --git a/packages/core/src/workhub-action-result.ts b/packages/core/src/workhub-action-result.ts index feafef0426..b993249dec 100644 --- a/packages/core/src/workhub-action-result.ts +++ b/packages/core/src/workhub-action-result.ts @@ -75,9 +75,13 @@ export function isWorkHubActionReceipt(value: unknown): value is WorkHubActionRe (value.clarification !== undefined && typeof value.clarification !== 'string') ) return false; - const r = value.result; + return isWorkHubActionResult(value.result); +} + +/** Shared closed result contract for Runtime receipts and Host protocol replies. */ +export function isWorkHubActionResult(r: unknown): r is WorkHubActionResult { if (!isRecord(r)) return false; - const text = (k: string) => typeof r[k] === 'string' && r[k] !== ''; + const text = (k: string) => typeof r[k] === 'string' && /^[A-Za-z0-9_-]{1,128}$/u.test(r[k]); const keys = (allowed: string[]) => Object.keys(r).every((k) => allowed.includes(k)); if (r.disposition === 'answer_here' || r.disposition === 'clarify') return keys(['disposition', 'coordinationTurnId']) && text('coordinationTurnId'); @@ -103,6 +107,11 @@ export function isWorkHubActionReceipt(value: unknown): value is WorkHubActionRe ); if (r.disposition === 'stop_work' || r.disposition === 'resume_work') return ( + (r.disposition === 'stop_work' + ? ((r.outcome !== 'stop_delivered' && r.outcome !== 'not_owned') || + r.targetTurnId !== undefined) && + (r.outcome !== 'cancelled_pending' || r.targetTurnId === undefined) + : (r.outcome === 'resume_started') === (r.targetTurnId !== undefined)) && keys(['disposition', 'outcome', 'targetSessionId', 'targetTurnId']) && text('targetSessionId') && (r.targetTurnId === undefined || text('targetTurnId')) && diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 86f81d9c62..3ba85ae126 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -20,6 +20,8 @@ import type { AttachmentRef } from '@maka/core/events'; import { decodeMessageContent } from './turn.js'; import { isWorkHubCreateDefaults, type WorkHubCreateDefaults } from '@maka/core/session'; +import { isWorkHubActionResult } from '@maka/core/workhub-action-result'; + import { requireCount, requireEntityId, @@ -417,118 +419,12 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi } export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoordinationActResult { - const result = requireRecord(value, 'WorkHub Coordination action result'); - if (result.disposition === 'answer_here' || result.disposition === 'clarify') { - const exact = requireExactRecord(result, 'WorkHub Coordination local action result', [ - 'disposition', - 'coordinationTurnId', - ]); - return { - disposition: result.disposition, - coordinationTurnId: requireEntityId(exact.coordinationTurnId, 'WorkHub Coordination Turn id'), - }; - } - if (result.disposition === 'delegate_existing' || result.disposition === 'create_new') { - const exact = requireShapedRecord( - result, - 'WorkHub Coordination execution action result', - ['disposition', 'targetSessionId', 'targetTurnId'], - ['steered'], - ); - if (exact.steered !== undefined && exact.steered !== true) { - throw invalidProtocolFrame('Invalid WorkHub Coordination steering result'); - } - return { - disposition: result.disposition, - targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), - targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), - ...(exact.steered === true ? { steered: true as const } : {}), - }; - } - if (result.disposition === 'replace') { - const exact = requireShapedRecord( - result, - 'WorkHub Coordination replacement result', - ['disposition', 'replacementDisposition', 'targetSessionId', 'targetTurnId'], - ['steered'], - ); - if ( - exact.replacementDisposition !== 'delegate_existing' && - exact.replacementDisposition !== 'create_new' - ) { - throw invalidProtocolFrame('Invalid WorkHub replacement disposition'); - } - if (exact.steered !== undefined && exact.steered !== true) { - throw invalidProtocolFrame('Invalid WorkHub Coordination steering result'); - } - return { - disposition: 'replace', - replacementDisposition: exact.replacementDisposition, - targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), - targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), - ...(exact.steered === true ? { steered: true as const } : {}), - }; - } - if (result.disposition === 'stop_work') { - const exact = requireShapedRecord( - result, - 'WorkHub Coordination stop result', - ['disposition', 'outcome', 'targetSessionId'], - ['targetTurnId'], - ); - if ( - exact.outcome !== 'cancelled_pending' && - exact.outcome !== 'stop_delivered' && - exact.outcome !== 'already_terminal' && - exact.outcome !== 'not_owned' - ) { - throw invalidProtocolFrame('Invalid WorkHub stop outcome'); - } - if ( - ((exact.outcome === 'stop_delivered' || exact.outcome === 'not_owned') && - exact.targetTurnId === undefined) || - (exact.outcome === 'cancelled_pending' && exact.targetTurnId !== undefined) - ) { - throw invalidProtocolFrame('Invalid WorkHub stop target Turn'); - } - return { - disposition: 'stop_work', - outcome: exact.outcome, - targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), - ...(exact.targetTurnId === undefined - ? {} - : { - targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), - }), - }; - } - if (result.disposition === 'resume_work') { - const exact = requireShapedRecord( - result, - 'WorkHub Coordination resume result', - ['disposition', 'outcome', 'targetSessionId'], - ['targetTurnId'], - ); - if (exact.outcome !== 'resume_started' && exact.outcome !== 'already_running') { - throw invalidProtocolFrame('Invalid WorkHub resume outcome'); - } - // Only a started continuation names a Turn: the Host has one to name, and - // the other two outcomes changed nothing that could carry an identity. - if ((exact.outcome === 'resume_started') !== (exact.targetTurnId !== undefined)) { - throw invalidProtocolFrame('Invalid WorkHub resume target Turn'); - } - return { - disposition: 'resume_work', - outcome: exact.outcome, - targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), - ...(exact.targetTurnId === undefined - ? {} - : { - targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), - }), - }; + if (!isWorkHubActionResult(value)) { + throw invalidProtocolFrame('Invalid WorkHub Coordination action result'); } - throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); + return Object.fromEntries( + Object.entries(value).filter(([, field]) => field !== undefined), + ) as WorkHubCoordinationActResult; } function decodeWorkHubCoordinationCandidate(value: unknown): WorkHubCoordinationCandidate { diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 58fd130d37..daed3dff5c 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -521,7 +521,6 @@ export class HostWorkHubCoordinationCoordinator { input.proposal.disposition === 'clarify' ? workHubCoordinationTurnId(input.actionId, 'clarify') : input.actionId; - let result: WorkHubCoordinationActResult | undefined; let failure: unknown; const outcome = await this.#executions.runWorkHubCoordinationOperation( { @@ -539,7 +538,7 @@ export class HostWorkHubCoordinationCoordinator { : { kind: 'ready', content: normalizeMessageContent({ text: input.userText }) }, operation: async (turnId) => { try { - result = await this.#actionGate.act(input, context, turnId); + const result = await this.#actionGate.act(input, context, turnId); return { actionId: input.actionId, userText: input.userText, From 02b54c73a0595aa23c85b8384df70661eaa7636c Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 08:14:41 +0800 Subject: [PATCH 3/9] fix(workhub): preserve Host ownership and durable action outcomes Generated-by: Codex --- .../workhub-coordination-session-adr.md | 6 +- .../__tests__/execution-composition.test.ts | 292 +++++++++++++++++- .../src/server/root-turn-coordinator.ts | 18 +- packages/runtime/src/agent-run.ts | 13 +- packages/runtime/src/runtime-kernel.ts | 50 +-- 5 files changed, 347 insertions(+), 32 deletions(-) diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index e0e5686c78..4dfb7b59de 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -90,7 +90,11 @@ remains readable without inventing admissions for old summary rows. A receipt acknowledges what the operation accepted; it is not the target's current execution state. Re-delivery of a completed request returns that receipt, including after restart, without repeating the effect. Failed attempts remain terminal; -a same-action retry gets a subsequent admitted Turn. An interrupted Host action is +a same-action retry gets a subsequent admitted Turn. If the failed attempt already +committed a receipt, the new Turn reuses that result without repeating the effect. +Transcript projection failure is repaired separately and does not fail the operation. +Host-only Turns retain execution ownership without activating a model provider. +An interrupted Host action is closed by Runtime recovery and never replayed as a model answer. Target-owned claims, assignment atomicity, and resume source-boundary checks still decide whether an unfinished effect can continue. Transactional delegation/Stop facts diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 1ed87afa40..e01145c73d 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -17,13 +17,18 @@ * under the License. */ -import { workHubCoordinationTurnId } from '../server/workhub-coordination-action-gate.js'; +import { + WorkHubCoordinationActionGate, + workHubCoordinationTurnId, +} from '../server/workhub-coordination-action-gate.js'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; import { createRunCompositionSnapshot } from '@maka/core/run-composition'; import type { BackendSendInput } from '@maka/core/backend-types'; import type { SessionEvent } from '@maka/core/events'; +import type { SessionStore } from '@maka/runtime/session-manager'; +import type { RuntimeKernelLike } from '@maka/runtime/runtime-kernel'; import { runtimeHandoffPause } from '@maka/core/runtime-handoff'; import { deferred } from '@maka/core/test-only/async-primitives'; import { runtimeInvocationFailureClass } from '@maka/runtime/runtime-event-read-model'; @@ -836,14 +841,13 @@ test('WorkHub creates new work through the production assignment composition', a const connectionId = await configureFakeDefaultTarget(owner); let coordinationModelCalls = 0; const { composition, manager } = await createCapturedExecutionComposition(owner, { - primaryBackendFactory: (backendContext) => - new (class extends FakeBackend { - override async *send(input: BackendSendInput): AsyncIterable { - if (backendContext.header.id === 'maka_workhub_coordination') - coordinationModelCalls += 1; - yield* super.send(input); - } - })(backendContext), + primaryBackendFactory: (backendContext) => { + if (backendContext.header.id === 'maka_workhub_coordination') { + coordinationModelCalls += 1; + throw new Error('Coordination provider is unavailable'); + } + return new FakeBackend(backendContext); + }, }); const context = { hostEpoch: 'execution-composition-test', @@ -969,6 +973,276 @@ test('WorkHub creates new work through the production assignment composition', a }); }); +test('Coordination Host ownership survives Stop and deferred backend invalidation', { + timeout: 10_000, +}, async () => { + await withCompositionRoot(async ({ owner }) => { + await configureFakeDefaultTarget(owner); + let disposed = 0; + const { composition, manager } = await createCapturedExecutionComposition(owner, { + primaryBackendFactory: (backendContext) => + new (class extends FakeBackend { + override async dispose(): Promise { + if (backendContext.sessionId === 'maka_workhub_coordination') disposed += 1; + await super.dispose(); + } + })(backendContext), + }); + const context = { + hostEpoch: 'coordination-lifecycle', + connectionId: 'client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + const sessionId = 'maka_workhub_coordination'; + const kernel = (manager as unknown as { runtimeKernel: RuntimeKernelLike }).runtimeKernel; + const entered = deferred(); + const release = deferred(); + const originalRunner = manager.runCoordinationOperation; + let stopped = false; + let invalidated = false; + try { + assert.ok((await composition.handlers['workhub.coordination.resolve']({}, context)).ok); + assert.ok( + ( + await composition.handlers['workhub.coordination.answer']( + { + turnId: 'cached-model-answer', + text: 'Say hello', + }, + context, + ) + ).ok, + ); + await waitFor(async () => { + const state = await composition.handlers['turn.query']( + { sessionId, turnId: 'cached-model-answer' }, + context, + ); + return state.ok && state.result.status === 'completed'; + }); + manager.runCoordinationOperation = function (id, input, options, execute) { + return originalRunner.call(this, id, input, options, async () => { + entered.resolve(); + await release.promise; + return execute(); + }); + }; + const request = { + actionId: 'lifecycle-action', + userText: 'Which task?', + proposal: { disposition: 'clarify' as const, assistantText: 'Please name a task.' }, + }; + const action = composition.handlers['workhub.coordination.act'](request, context); + await entered.promise; + const turnId = workHubCoordinationTurnId(request.actionId, 'clarify'); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const admission = await stores.agentRunStore.readRootTurnAdmission(sessionId, turnId); + assert.ok(admission); + assert.equal(kernel.hasActiveRun?.(sessionId, admission.runId, turnId), true); + assert.deepEqual(manager.runningTurnIds(sessionId), [turnId]); + const invalidation = kernel.invalidateCachedBackends().then(() => { + invalidated = true; + }); + const stop = kernel.stopSession(sessionId).then(() => { + stopped = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(stopped, false, 'Stop must await the admitted Host callback'); + assert.equal(invalidated, false, 'Cached backend invalidation must wait for Host ownership'); + assert.equal(disposed, 0); + release.resolve(); + const [result] = await Promise.all([action, stop, invalidation]); + assert.equal(result.ok, false, 'A stopped Coordination Run must not report completed'); + assert.equal(kernel.hasActiveRun?.(sessionId, admission.runId, turnId), false); + assert.deepEqual(manager.runningTurnIds(sessionId), []); + assert.equal(disposed, 1); + const runs = await stores.runtimeEventStore.listSessionInvocations(sessionId); + assert.equal( + runtimeInvocationOutcome(runs.find((run) => run.runId === admission.runId)!), + 'cancelled', + ); + } finally { + release.resolve(); + manager.runCoordinationOperation = originalRunner; + await composition.close(); + } + }); +}); + +test('Coordination receipt projection failure preserves successful action and replay', async () => { + await withCompositionRoot(async ({ owner }) => { + await configureFakeDefaultTarget(owner); + const { composition, manager } = await createCapturedExecutionComposition(owner); + const context = { + hostEpoch: 'coordination-projection-cut', + connectionId: 'client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + const kernel = ( + manager as unknown as { + runtimeKernel: { deps: { store: SessionStore; newId: () => string } }; + } + ).runtimeKernel; + const originalStore = kernel.deps.store; + let cuts = 0; + kernel.deps.store = { + ...originalStore, + async appendMessage(sessionId, message) { + if ( + message.type === 'workhub_coordination' && + message.kind === 'action_receipt' && + cuts === 0 + ) { + cuts += 1; + throw new Error('Injected receipt projection failure'); + } + return originalStore.appendMessage(sessionId, message); + }, + }; + try { + assert.ok((await composition.handlers['workhub.coordination.resolve']({}, context)).ok); + const request = { + actionId: 'projection-cut-action', + userText: 'Which task?', + proposal: { disposition: 'clarify' as const, assistantText: 'Please name a task.' }, + }; + const first = await composition.handlers['workhub.coordination.act'](request, context); + assert.ok(first.ok, JSON.stringify(first)); + assert.equal(cuts, 1, 'The test must cut the receipt projection write'); + assert.deepEqual( + await composition.handlers['workhub.coordination.act'](request, context), + first, + ); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const invocations = await stores.runtimeEventStore.listSessionInvocations( + 'maka_workhub_coordination', + ); + assert.equal(invocations.length, 1, 'Projection failure must not create a retry Run'); + assert.equal(runtimeInvocationOutcome(invocations[0]!), 'completed'); + const events = await stores.runtimeEventStore.readSessionRuntimeEvents( + 'maka_workhub_coordination', + ); + assert.equal(events.filter((event) => event.actions?.coordination).length, 1); + } finally { + kernel.deps.store = originalStore; + await composition.close(); + } + }); +}); + +test('Coordination retries a durable receipt without repeating its Host action', async () => { + await withCompositionRoot(async ({ owner }) => { + await configureFakeDefaultTarget(owner); + const { composition, manager } = await createCapturedExecutionComposition(owner); + const context = { + hostEpoch: 'coordination-terminal-cut', + connectionId: 'client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + const kernel = ( + manager as unknown as { + runtimeKernel: { deps: { store: SessionStore; newId: () => string } }; + } + ).runtimeKernel; + const originalStore = kernel.deps.store; + const originalNewId = kernel.deps.newId; + const originalAct = WorkHubCoordinationActionGate.prototype.act; + let actions = 0; + let cutNextId = false; + let cuts = 0; + WorkHubCoordinationActionGate.prototype.act = function (...args) { + actions += 1; + return originalAct.apply(this, args); + }; + kernel.deps.store = { + ...originalStore, + async appendMessage(sessionId, message) { + await originalStore.appendMessage(sessionId, message); + if ( + message.type === 'workhub_coordination' && + message.kind === 'action_receipt' && + cuts === 0 + ) { + cutNextId = true; + } + }, + }; + kernel.deps.newId = () => { + if (cutNextId) { + cutNextId = false; + cuts += 1; + throw new Error('Injected crash cut after receipt and before terminal'); + } + return originalNewId(); + }; + try { + assert.ok((await composition.handlers['workhub.coordination.resolve']({}, context)).ok); + const request = { + actionId: 'terminal-cut-action', + userText: 'Which task?', + proposal: { disposition: 'clarify' as const, assistantText: 'Please name a task.' }, + }; + const first = await composition.handlers['workhub.coordination.act'](request, context); + assert.equal(first.ok, false); + assert.equal(cuts, 1); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const oldTurnId = workHubCoordinationTurnId(request.actionId, 'clarify'); + const oldAdmission = await stores.agentRunStore.readRootTurnAdmission( + 'maka_workhub_coordination', + oldTurnId, + ); + assert.ok(oldAdmission); + const before = await stores.runtimeEventStore.readImmutableRuntimeEvents( + oldAdmission.sessionId, + oldAdmission.runId, + ); + assert.ok(before.some((event) => event.actions?.coordination)); + assert.ok(before.some((event) => event.status === 'failed')); + const replay = await composition.handlers['workhub.coordination.act'](request, context); + assert.ok(replay.ok, JSON.stringify(replay)); + assert.equal(replay.result.disposition, 'clarify'); + if (replay.result.disposition !== 'clarify') return; + assert.notEqual(replay.result.coordinationTurnId, oldTurnId); + const retryAdmission = await stores.agentRunStore.readRootTurnAdmission( + oldAdmission.sessionId, + replay.result.coordinationTurnId, + ); + assert.ok(retryAdmission); + assert.notEqual(retryAdmission.runId, oldAdmission.runId); + assert.equal(actions, 1, 'A durable receipt must bypass the Host action on retry'); + assert.deepEqual( + await stores.runtimeEventStore.readImmutableRuntimeEvents( + oldAdmission.sessionId, + oldAdmission.runId, + ), + before, + ); + const events = await stores.runtimeEventStore.readImmutableRuntimeEvents( + retryAdmission.sessionId, + retryAdmission.runId, + ); + assert.ok(events.some((event) => event.status === 'completed')); + assert.deepEqual( + events.find((event) => event.actions?.coordination)?.actions?.coordination?.result, + replay.result, + ); + assert.deepEqual( + await composition.handlers['workhub.coordination.act'](request, context), + replay, + ); + assert.equal(actions, 1); + } finally { + kernel.deps.store = originalStore; + kernel.deps.newId = originalNewId; + WorkHubCoordinationActionGate.prototype.act = originalAct; + await composition.close(); + } + }); +}); + test('interrupted Coordination admission recovers without a model and retries in a new Turn', async () => { await withCompositionRoot(async ({ root, owner }) => { await configureFakeDefaultTarget(owner); diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index ae5bb91922..b9cc0f1d01 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1698,6 +1698,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { return operationUnavailable('Coordination action requires a Host operation'); } let turnId = request.turnId; + let recoveredReceipt: WorkHubActionReceipt | undefined; // A retry preserves the action identity, but never reopens a terminal Run. // The existing admission chain and terminal facts identify prior attempts. for (;;) { @@ -1717,9 +1718,24 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { run, ); if (!isTerminalSnapshot(snapshot) || snapshot.status === 'completed') break; + // A crash after the receipt does not erase a completed Host effect. + const events = await this.stores.runtimeEventStore.readImmutableRuntimeEvents( + request.sessionId, + admission.runId, + ); + recoveredReceipt ??= events.find((event) => event.actions?.coordination)?.actions + ?.coordination; turnId = `whretry_${createHash('sha256').update(admission.runId).digest('hex').slice(0, 48)}`; } - const started = await this.startWorkHubCoordinationMessage({ ...request, turnId }, context); + const receiptToReplay = recoveredReceipt; + const started = await this.startWorkHubCoordinationMessage( + { + ...request, + turnId, + ...(receiptToReplay ? { operation: async () => receiptToReplay } : {}), + }, + context, + ); if (!started.ok) return started; const active = this.#executions.get(request.sessionId); if (active?.turnId === turnId) await active.done; diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 2f59c35986..6ad82960db 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -944,7 +944,7 @@ export class AgentRun { }; } - async begin(): Promise { + private async beginUserTurn(): Promise { // Owed from here, not from after the opening: `openInvocation` can leave the // invocation open and still throw, and `finalize` reopens what it can. this.initialRuntimeEventPending = true; @@ -953,6 +953,17 @@ export class AgentRun { this.lastTs = this.input.now(); const initialRuntimeEvent = await this.recordInitialRuntimeEvent(this.lastTs); + return initialRuntimeEvent; + } + + /** Host actions share Turn facts and finalization without activating a provider. */ + async beginCoordination(): Promise { + await this.beginUserTurn(); + await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, this.lastTs); + } + + async begin(): Promise { + const initialRuntimeEvent = await this.beginUserTurn(); this.active = await this.input.hooks.reserveRun(this.sessionId, this.header, this); await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, this.lastTs); diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 97b4c93174..7c459208f2 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -354,6 +354,7 @@ interface PendingExecutionClaim { rejectSettled(error: unknown): void; phase: 'pending' | 'attached' | 'reserved' | 'released' | 'failed'; run?: AgentRun; + hostOperation?: true; backendPreparation?: PreparedBackendActivation; stopIntent?: SessionStopIntent; finalization?: ExecutionClaimOutcome; @@ -971,6 +972,7 @@ export class RuntimeKernel implements RuntimeKernelLike { execute: () => Promise, ): AsyncIterable { const execution = this.takeExecutionClaim(sessionId); + execution.hostOperation = true; try { await this.enterExecutionClaim(execution); const header = await this.deps.store.readHeader(sessionId); @@ -1008,21 +1010,24 @@ export class RuntimeKernel implements RuntimeKernelLike { turnId: input.turnId, runId: run.runId, }); - await this.runBackendActivation(async () => { - run.bindProviderStateIdentity( - await this.prepareBackendForExecution(sessionId, header, execution), - ); - await run.begin(); - }); + // Keep the execution claim attached until finalization. Stop/drain can + // therefore cancel and await this Run without a provider generation. + await run.beginCoordination(); await options.onRunStarted?.(run.runId, header); - this.settleReservedExecutionClaim(execution, run, { ok: true }); } catch (error) { await this.finalizeFailedRunStart(owners, run, execution, error); return; } try { if (run.isStopped()) return; - const receipt = await execute(); + const executed = await execute(); + const receipt: WorkHubActionReceipt = { + ...executed, + result: + executed.result.disposition === 'clarify' + ? { ...executed.result, coordinationTurnId: input.turnId } + : executed.result, + }; const receiptEvent: RuntimeEvent = { id: this.deps.newId(), sessionId, @@ -1070,6 +1075,7 @@ export class RuntimeKernel implements RuntimeKernelLike { } } finally { this.releaseExecutionClaim(execution); + await this.flushBackendInvalidation(sessionId); } } @@ -2183,25 +2189,29 @@ export class RuntimeKernel implements RuntimeKernelLike { ); } + private activeRunsFor(sessionId: string): AgentRun[] { + const runs = new Set(); + for (const active of this.backendGenerationsFor(sessionId)) { + for (const run of active.activeRuns.values()) runs.add(run); + } + for (const claim of this.executionClaims.get(sessionId) ?? []) { + if (claim.hostOperation && claim.run) runs.add(claim.run); + } + return [...runs]; + } + hasActiveRuns(sessionId: string): boolean { - return this.backendGenerationsFor(sessionId).some((active) => active.activeRuns.size > 0); + return this.activeRunsFor(sessionId).length > 0; } runningTurnIds(sessionId: string): string[] { - const turnIds: string[] = []; - for (const active of this.backendGenerationsFor(sessionId)) { - for (const run of active.activeRuns.values()) { - if (!turnIds.includes(run.turnId)) turnIds.push(run.turnId); - } - } - return turnIds; + return [...new Set(this.activeRunsFor(sessionId).map((run) => run.turnId))]; } hasActiveRun(sessionId: string, runId: string, turnId?: string): boolean { - return this.backendGenerationsFor(sessionId).some((active) => { - const run = active.activeRuns.get(runId); - return run !== undefined && (turnId === undefined || run.turnId === turnId); - }); + return this.activeRunsFor(sessionId).some( + (run) => run.runId === runId && (turnId === undefined || run.turnId === turnId), + ); } requestRunHandoff( From 5582f150b2ae26be625554856dc3fb0e1a6b2a60 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 09:55:58 +0800 Subject: [PATCH 4/9] fix(workhub): align admission with RuntimeEvent transcript authority Generated-by: Codex --- .../workhub-coordination-session-adr.md | 3 +- .../__tests__/execution-composition.test.ts | 92 +++---------------- packages/runtime/src/runtime-kernel.ts | 4 +- 3 files changed, 17 insertions(+), 82 deletions(-) diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 4dfb7b59de..45a50e67da 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -92,7 +92,8 @@ execution state. Re-delivery of a completed request returns that receipt, includ after restart, without repeating the effect. Failed attempts remain terminal; a same-action retry gets a subsequent admitted Turn. If the failed attempt already committed a receipt, the new Turn reuses that result without repeating the effect. -Transcript projection failure is repaired separately and does not fail the operation. +The shared transcript reader derives receipts directly from RuntimeEvents; no +receipt is written back into the legacy message store. Host-only Turns retain execution ownership without activating a model provider. An interrupted Host action is closed by Runtime recovery and never replayed as a model answer. Target-owned diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index e01145c73d..61cd881e9b 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -27,7 +27,7 @@ import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; import { createRunCompositionSnapshot } from '@maka/core/run-composition'; import type { BackendSendInput } from '@maka/core/backend-types'; import type { SessionEvent } from '@maka/core/events'; -import type { SessionStore } from '@maka/runtime/session-manager'; +import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import type { RuntimeKernelLike } from '@maka/runtime/runtime-kernel'; import { runtimeHandoffPause } from '@maka/core/runtime-handoff'; import { deferred } from '@maka/core/test-only/async-primitives'; @@ -891,7 +891,10 @@ test('WorkHub creates new work through the production assignment composition', a events.some((event) => event.role === 'model'), false, ); - const transcript = await coordinationStores.sessionStore.readMessages(admission.sessionId); + const transcript = await readLedgerMessages( + coordinationStores.runtimeEventStore, + admission.sessionId, + ); assert.ok( transcript.some( (message) => message.type === 'workhub_coordination' && message.kind === 'action_receipt', @@ -1070,68 +1073,6 @@ test('Coordination Host ownership survives Stop and deferred backend invalidatio }); }); -test('Coordination receipt projection failure preserves successful action and replay', async () => { - await withCompositionRoot(async ({ owner }) => { - await configureFakeDefaultTarget(owner); - const { composition, manager } = await createCapturedExecutionComposition(owner); - const context = { - hostEpoch: 'coordination-projection-cut', - connectionId: 'client', - principal: 'local_os_user' as const, - acquireResidency: () => ({ release() {} }), - }; - const kernel = ( - manager as unknown as { - runtimeKernel: { deps: { store: SessionStore; newId: () => string } }; - } - ).runtimeKernel; - const originalStore = kernel.deps.store; - let cuts = 0; - kernel.deps.store = { - ...originalStore, - async appendMessage(sessionId, message) { - if ( - message.type === 'workhub_coordination' && - message.kind === 'action_receipt' && - cuts === 0 - ) { - cuts += 1; - throw new Error('Injected receipt projection failure'); - } - return originalStore.appendMessage(sessionId, message); - }, - }; - try { - assert.ok((await composition.handlers['workhub.coordination.resolve']({}, context)).ok); - const request = { - actionId: 'projection-cut-action', - userText: 'Which task?', - proposal: { disposition: 'clarify' as const, assistantText: 'Please name a task.' }, - }; - const first = await composition.handlers['workhub.coordination.act'](request, context); - assert.ok(first.ok, JSON.stringify(first)); - assert.equal(cuts, 1, 'The test must cut the receipt projection write'); - assert.deepEqual( - await composition.handlers['workhub.coordination.act'](request, context), - first, - ); - const stores = await openInteractiveExecutionStoresForWrite(owner.lease); - const invocations = await stores.runtimeEventStore.listSessionInvocations( - 'maka_workhub_coordination', - ); - assert.equal(invocations.length, 1, 'Projection failure must not create a retry Run'); - assert.equal(runtimeInvocationOutcome(invocations[0]!), 'completed'); - const events = await stores.runtimeEventStore.readSessionRuntimeEvents( - 'maka_workhub_coordination', - ); - assert.equal(events.filter((event) => event.actions?.coordination).length, 1); - } finally { - kernel.deps.store = originalStore; - await composition.close(); - } - }); -}); - test('Coordination retries a durable receipt without repeating its Host action', async () => { await withCompositionRoot(async ({ owner }) => { await configureFakeDefaultTarget(owner); @@ -1144,10 +1085,11 @@ test('Coordination retries a durable receipt without repeating its Host action', }; const kernel = ( manager as unknown as { - runtimeKernel: { deps: { store: SessionStore; newId: () => string } }; + runtimeKernel: { deps: { runtimeEventStore: RuntimeEventStore; newId: () => string } }; } ).runtimeKernel; - const originalStore = kernel.deps.store; + const eventStore = kernel.deps.runtimeEventStore; + const originalAppend = eventStore.appendRuntimeEvent; const originalNewId = kernel.deps.newId; const originalAct = WorkHubCoordinationActionGate.prototype.act; let actions = 0; @@ -1157,17 +1099,11 @@ test('Coordination retries a durable receipt without repeating its Host action', actions += 1; return originalAct.apply(this, args); }; - kernel.deps.store = { - ...originalStore, - async appendMessage(sessionId, message) { - await originalStore.appendMessage(sessionId, message); - if ( - message.type === 'workhub_coordination' && - message.kind === 'action_receipt' && - cuts === 0 - ) { - cutNextId = true; - } + kernel.deps.runtimeEventStore = { + ...eventStore, + async appendRuntimeEvent(sessionId, runId, event, options) { + await originalAppend.call(eventStore, sessionId, runId, event, options); + if (event.actions?.coordination && cuts === 0) cutNextId = true; }, }; kernel.deps.newId = () => { @@ -1235,7 +1171,7 @@ test('Coordination retries a durable receipt without repeating its Host action', ); assert.equal(actions, 1); } finally { - kernel.deps.store = originalStore; + kernel.deps.runtimeEventStore = eventStore; kernel.deps.newId = originalNewId; WorkHubCoordinationActionGate.prototype.act = originalAct; await composition.close(); diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 7c459208f2..dfe788e54f 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -983,7 +983,6 @@ export class RuntimeKernel implements RuntimeKernelLike { runId: options.runId, userMessageId: options.userMessageId, durability: 'required', - store: this.deps.store, runStore: this.deps.runStore, runtimeEventStore: this.deps.runtimeEventStore, newId: this.deps.newId, @@ -998,8 +997,7 @@ export class RuntimeKernel implements RuntimeKernelLike { unregisterRun: (active, activeRun) => this.unregisterParentRun(active, activeRun), updateHeader: (id, patch) => this.updateHeader(id, patch), updateStatus: (id, status, reason, ts) => this.updateStatus(id, status, reason, ts), - appendTurnState: (id, turnId, status, lineage, stateOptions) => - this.appendTurnState(id, turnId, status, lineage, stateOptions), + ...this.messageProjectionHook(), }, }); this.attachExecutionClaim(execution, run); From a1e2da29175acec979a4dcbab4a0184b247c632a Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 11:21:47 +0800 Subject: [PATCH 5/9] fix(workhub): preserve durable receipts and recover accepted resumes Generated-by: Codex --- .../__tests__/workhub-session-port.test.ts | 6 + .../src/renderer/workhub-coordination-port.ts | 8 +- .../workhub-coordination-session-adr.md | 15 +- .../access-credential-grant-migration.test.ts | 16 + .../__tests__/execution-composition.test.ts | 672 +++++++++++------- .../session-transcript-reader.test.ts | 224 +++++- .../src/server/access-credential-store.ts | 2 + .../src/server/execution-composition.ts | 15 +- .../src/server/session-transcript-reader.ts | 207 ++++-- .../sqlite-session-metadata-store.test.ts | 65 ++ packages/storage/src/execution-stores.ts | 7 + packages/storage/src/session-store.ts | 46 ++ .../src/sqlite-session-metadata-schema.ts | 13 +- .../src/sqlite-session-metadata-store.ts | 57 ++ 14 files changed, 1053 insertions(+), 300 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index bb3c9177eb..2e9286594f 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -1175,6 +1175,12 @@ test('admitted clarification and resume project receipts without assistant messa receipt: { actionId: 'resume', userText: 'Resume Payments', result: { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'target-turn' } } }, { type: 'turn_state', id: 'resume-done', turnId: 'resume-turn', ts: 5, status: 'completed' }, + { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'resume-retry-receipt', turnId: 'resume-retry-turn', ts: 6, + receipt: { actionId: 'resume', userText: 'Resume Payments', + result: { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'target-turn' } } }, + { type: 'turn_state', id: 'resume-retry-done', turnId: 'resume-retry-turn', ts: 7, status: 'completed' }, + ]); assert.equal(turns.length, 2); assert.equal(turns[0]?.turnId, 'request'); diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 0b1ef4090a..4c1afa436a 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -169,6 +169,7 @@ export function projectWorkHubCoordinationTurns( })); const turns: WorkHubCoordinationTurn[] = []; const latestUserIndexByTurnId = new Map(); + const receiptIndexByActionId = new Map(); const terminalLinkState = new Map(); const stopResolutionByDelegationId = new Map( messages.flatMap((message) => @@ -186,7 +187,7 @@ export function projectWorkHubCoordinationTurns( if (message.type === 'workhub_coordination' && message.kind === 'action_receipt') { const { receipt } = message; if (receipt.result.disposition === 'clarify' || receipt.result.disposition === 'resume_work') { - const earlier = latestUserIndexByTurnId.get(message.turnId); + const earlier = receiptIndexByActionId.get(receipt.actionId) ?? latestUserIndexByTurnId.get(message.turnId); const row = { messageId: message.id, turnId: receipt.actionId, @@ -198,8 +199,9 @@ export function projectWorkHubCoordinationTurns( state: stateByTurnId.get(message.turnId) ?? 'running', updatedAt: message.ts, }; - if (earlier !== undefined) turns[earlier] = row; - else turns.push(row); + const index = earlier ?? turns.length; + turns[index] = row; + receiptIndexByActionId.set(receipt.actionId, index); } continue; } diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 45a50e67da..225850a322 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -92,8 +92,19 @@ execution state. Re-delivery of a completed request returns that receipt, includ after restart, without repeating the effect. Failed attempts remain terminal; a same-action retry gets a subsequent admitted Turn. If the failed attempt already committed a receipt, the new Turn reuses that result without repeating the effect. -The shared transcript reader derives receipts directly from RuntimeEvents; no -receipt is written back into the legacy message store. +When target resume admission committed before a missing receipt, retry first +consults the deterministic target Turn admission and acknowledges that original +Turn. It does not plan another continuation from the newer target lineage. + +The shared transcript reader derives receipts directly from RuntimeEvents and +retains legacy atomic linkage facts and released history. A rebuildable SQLite +index holds only `(source, sourceSequence)` references in stable page order; it +contains no message bodies, action results, or execution authority. Initial +backfill and incremental refresh consume bounded source batches; normal pages +seek the index and project bounded source batches/Turns. Wall-clock regressions +and later appends cannot renumber existing pages. No receipt is written back into +the legacy message store. The WorkHub view groups receipt retries by action +identity rather than exposing each physical attempt as a new conversation card. Host-only Turns retain execution ownership without activating a model provider. An interrupted Host action is closed by Runtime recovery and never replayed as a model answer. Target-owned diff --git a/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts b/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts index aac8e62468..d36a3124e0 100644 --- a/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts +++ b/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts @@ -135,6 +135,22 @@ test('a released operation is dropped from the record and reported as accounted assert.deepEqual(unresolvedPersistedGrants(file), []); }); +test('retired Coordination record grant is released through read and rewrite', async () => { + const path = await writeAccessFile({ + schemaVersion: 3, + credentials: [storedCredential(['host.status', 'workhub.coordination.record'])], + sessionGrants: [], + turnAccessRequests: [], + }); + const file = await readAccessCredentialFile(path); + assert.deepEqual(file.credentials[0]?.grants, ['host.status']); + assert.deepEqual(unresolvedPersistedGrants(file), []); + await writeAccessCredentialFile(path, file); + assert.deepEqual(JSON.parse(await readFile(path, 'utf8')).credentials[0].operationGrants, [ + 'host.status', + ]); +}); + test('a Session Guest holds the current guest policy, not what its record says', async () => { const path = await writeAccessFile({ schemaVersion: 3, diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 61cd881e9b..1458074b8d 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -20,6 +20,7 @@ import { WorkHubCoordinationActionGate, workHubCoordinationTurnId, + workHubResumedTurnId, } from '../server/workhub-coordination-action-gate.js'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; @@ -51,6 +52,10 @@ import { } from '@maka/runtime/test-only/fake-backend'; import { LOCAL_READ_AGENT_DEFINITION } from '@maka/runtime/agent-catalog'; import { SessionManager, type BackendFactory } from '@maka/runtime/session-manager'; +import { createSessionTranscriptReader } from '../server/session-transcript-reader.js'; +import { ClientSessionSubscription } from '../client/session-subscription.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; +import type { StoredMessage } from '@maka/core/session'; import { workHubDirectStopAbortSource } from '@maka/runtime/session-manager'; import { fingerprintAgentGraphRunnableIntent } from '@maka/runtime/stream-graph-admission'; import type { AgentGraphRunnableIntent } from '@maka/runtime/stream-graph-readiness'; @@ -920,6 +925,56 @@ test('WorkHub creates new work through the production assignment composition', a clarifyInput, context, ); + const durable = await readProductionTranscript(composition, context); + assert.ok( + durable.some( + (message) => + message.type === 'workhub_coordination' && + message.kind === 'action_receipt' && + message.receipt.actionId === clarifyInput.actionId, + ), + 'Production reader lost clarification receipt', + ); + assert.ok( + durable.some( + (message) => + message.type === 'workhub_coordination' && message.kind === 'delegation_assigned', + ), + 'Production reader lost atomic assignment', + ); + const reader = createSessionTranscriptReader({ + stores: coordinationStores, + canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, + }); + const all = await reader.readDurableRecords(admission.sessionId, { + direction: 'newer', + maxMessages: 100, + maxStoredBytes: 1024 * 1024, + }); + const landmarks = await reader.readDurableTurnLandmarks(admission.sessionId, 100); + for (const landmark of landmarks.landmarks) { + assert.ok( + all.records.some(({ message }) => message.turnId === landmark.turnId), + 'Landmarks must refer to physical transcript Turns, not logical action IDs', + ); + } + for (const direction of ['newer', 'older'] as const) { + const records = []; + let position: number | undefined; + do { + const page = await reader.readDurableRecords(admission.sessionId, { + direction, + throughSequence: all.throughSequence, + position, + maxMessages: 1, + maxStoredBytes: 1024 * 1024, + }); + records.push(...page.records); + position = page.nextPosition ?? undefined; + assert.ok(records.length <= all.records.length, 'Pagination must advance'); + } while (position !== undefined); + assert.deepEqual(direction === 'older' ? records.reverse() : records, all.records); + } assert.ok(clarified.ok, JSON.stringify(clarified)); assert.equal(clarified.result.disposition, 'clarify'); if (clarified.result.disposition === 'clarify') { @@ -976,6 +1031,49 @@ test('WorkHub creates new work through the production assignment composition', a }); }); +test('Coordination clarification survives a production transcript reopen', async () => { + await withCompositionRoot(async ({ root, owner }) => { + await configureFakeDefaultTarget(owner); + let { composition } = await createCapturedExecutionComposition(owner); + const context = { + hostEpoch: 'clarify-reopen', + connectionId: 'client', + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), + }; + await composition.handlers['workhub.coordination.resolve']({}, context); + const request = { + actionId: 'clarify-reopen', + userText: 'Which task?', + proposal: { disposition: 'clarify' as const, assistantText: 'Please choose a task.' }, + }; + assert.ok((await composition.handlers['workhub.coordination.act'](request, context)).ok); + const before = await readProductionTranscript(composition, context); + await composition.close(); + await owner.close(); + const reopened = await tryAcquireInteractiveRootOwner( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + assert.ok(reopened); + try { + ({ composition } = await createCapturedExecutionComposition(reopened)); + const after = await readProductionTranscript(composition, context); + assert.deepEqual(after, before); + assert.equal( + after.filter( + (message) => message.type === 'workhub_coordination' && message.kind === 'action_receipt', + ).length, + 1, + ); + assert.ok((await composition.handlers['workhub.coordination.act'](request, context)).ok); + assert.deepEqual(await readProductionTranscript(composition, context), before); + } finally { + await composition.close(); + await reopened.close(); + } + }); +}); + test('Coordination Host ownership survives Stop and deferred backend invalidation', { timeout: 10_000, }, async () => { @@ -1266,270 +1364,325 @@ test('interrupted Coordination admission recovers without a model and retries in }); }); -test('WorkHub Resume and Stop follow logical lineage across repeated physical handoffs', async () => { - await withCompositionRoot(async ({ root, owner }) => { - const connectionId = await configureFakeDefaultTarget(owner); - let pauseNext = true; - let boundary = deferred(); - const primaryBackendFactory: BackendFactory = (backendContext) => - new (class extends FakeBackend { - async prepareRunComposition(input: { runId: string; turnId: string }): Promise { - await backendContext.recordRunComposition!(input.runId, HANDOFF_TEST_COMPOSITION); - } +for (const missingReceipt of [false, true]) + test(`WorkHub Resume and Stop across restart (missing receipt: ${missingReceipt})`, async () => { + await withCompositionRoot(async ({ root, owner }) => { + const connectionId = await configureFakeDefaultTarget(owner); + let pauseNext = true; + let boundary = deferred(); + const primaryBackendFactory: BackendFactory = (backendContext) => + new (class extends FakeBackend { + async prepareRunComposition(input: { runId: string; turnId: string }): Promise { + await backendContext.recordRunComposition!(input.runId, HANDOFF_TEST_COMPOSITION); + } - override async *send(input: BackendSendInput): AsyncIterable { - assert.ok(input.runId); - await this.prepareRunComposition({ runId: input.runId, turnId: input.turnId }); - if (backendContext.header.name === 'Payments' && pauseNext) { - pauseNext = false; - await boundary.promise; - assert.equal(await input.handoffBoundary!(new AbortController().signal, null), 'pause'); - return; + override async *send(input: BackendSendInput): AsyncIterable { + assert.ok(input.runId); + await this.prepareRunComposition({ runId: input.runId, turnId: input.turnId }); + if (backendContext.header.name === 'Payments' && pauseNext) { + pauseNext = false; + await boundary.promise; + assert.equal( + await input.handoffBoundary!(new AbortController().signal, null), + 'pause', + ); + return; + } + yield* super.send(input); } - yield* super.send(input); - } - })(backendContext); - let { composition, manager } = await createCapturedExecutionComposition(owner, { - safeBoundaryResume: true, - primaryBackendFactory, - }); - const context = { - hostEpoch: 'execution-composition-test', - connectionId: 'workhub-resume-stop-client', - principal: 'local_os_user' as const, - acquireResidency: () => ({ release() {} }), - }; - let closed = false; - let restartedOwner: InteractiveRootOwner | undefined; - let continuation: { turnId: string; runId: string } | undefined; - let targetSessionId: string | undefined; - const handoffAndReopen = async () => { - const requested = deferred(); - const request = manager.requestRunHandoff.bind(manager); - manager.requestRunHandoff = (...args) => { - const result = request(...args); - requested.resolve(); - return result; - }; - const preparing = composition.prepareHandoff!( - context.hostEpoch, - new AbortController().signal, - ); - await requested.promise; - boundary.resolve(); - const preparation = await preparing; - assert.ok(preparation); - assert.equal(await preparation.seal(), true); - assert.ok(await preparation.residencies()); - await preparation.detach(); - composition.beginDrain(); - await composition.close(); - closed = true; - await owner.close(); - restartedOwner = await tryAcquireInteractiveRootOwner( - await resolveStorageRoot({ path: root, kind: 'interactive' }), - ); - assert.ok(restartedOwner); - owner = restartedOwner; - ({ composition, manager } = await createCapturedExecutionComposition(owner, { + })(backendContext); + let { composition, manager } = await createCapturedExecutionComposition(owner, { safeBoundaryResume: true, primaryBackendFactory, - })); - closed = false; - }; - try { - const target = await manager.createSession({ - cwd: root, - llmConnectionId: connectionId, - llmConnectionSlug: 'fake', - model: 'fake-model', - permissionMode: 'ask', - name: 'Payments', }); - targetSessionId = target.id; - await composition.handlers['workhub.coordination.resolve']({}, context); - const candidates = await composition.handlers['workhub.coordination.candidates']({}, context); - assert.equal(candidates.ok, true); - if (!candidates.ok) return; - const candidate = candidates.result.candidates.find( - ({ sessionId }) => sessionId === target.id, - ); - assert.ok(candidate); - if (!candidate) return; + const context = { + hostEpoch: 'execution-composition-test', + connectionId: 'workhub-resume-stop-client', + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), + }; + let closed = false; + let restartedOwner: InteractiveRootOwner | undefined; + let continuation: { turnId: string; runId: string } | undefined; + let targetSessionId: string | undefined; + const handoffAndReopen = async () => { + await waitFor(async () => !pauseNext); + const requested = deferred(); + const request = manager.requestRunHandoff.bind(manager); + manager.requestRunHandoff = (...args) => { + const result = request(...args); + requested.resolve(); + return result; + }; + const preparing = composition.prepareHandoff!( + context.hostEpoch, + new AbortController().signal, + ); + await requested.promise; + boundary.resolve(); + const preparation = await preparing; + assert.ok(preparation); + assert.equal(await preparation.seal(), true); + assert.ok(await preparation.residencies()); + await preparation.detach(); + composition.beginDrain(); + await composition.close(); + closed = true; + await owner.close(); + restartedOwner = await tryAcquireInteractiveRootOwner( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + assert.ok(restartedOwner); + owner = restartedOwner; + ({ composition, manager } = await createCapturedExecutionComposition(owner, { + safeBoundaryResume: true, + primaryBackendFactory, + })); + closed = false; + }; + try { + const target = await manager.createSession({ + cwd: root, + llmConnectionId: connectionId, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + name: 'Payments', + }); + targetSessionId = target.id; + await composition.handlers['workhub.coordination.resolve']({}, context); + const candidates = await composition.handlers['workhub.coordination.candidates']( + {}, + context, + ); + assert.equal(candidates.ok, true); + if (!candidates.ok) return; + const candidate = candidates.result.candidates.find( + ({ sessionId }) => sessionId === target.id, + ); + assert.ok(candidate); + if (!candidate) return; - const delegated = await composition.handlers['workhub.coordination.act']( - { - actionId: 'workhub-resume-stop-delegation', - userText: FAKE_HOLD_OPEN_PROMPT, - candidateSetId: candidates.result.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: candidate.candidateRef, + const delegated = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-resume-stop-delegation', + userText: FAKE_HOLD_OPEN_PROMPT, + candidateSetId: candidates.result.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidate.candidateRef, + }, }, - }, - context, - ); - assert.equal(delegated.ok, true, JSON.stringify(delegated)); - if (!delegated.ok || delegated.result.disposition !== 'delegate_existing') return; - const original = await composition.handlers['turn.query']( - { sessionId: target.id, turnId: delegated.result.targetTurnId }, - context, - ); - assert.equal(original.ok, true); - if (!original.ok) return; - await handoffAndReopen(); - await composition.handlers['turn.stop']( - { sessionId: target.id, turnId: original.result.turnId, runId: original.result.runId }, - context, - ); + context, + ); + assert.equal(delegated.ok, true, JSON.stringify(delegated)); + if (!delegated.ok || delegated.result.disposition !== 'delegate_existing') return; + const original = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: delegated.result.targetTurnId }, + context, + ); + assert.equal(original.ok, true); + if (!original.ok) return; + await handoffAndReopen(); + await composition.handlers['turn.stop']( + { sessionId: target.id, turnId: original.result.turnId, runId: original.result.runId }, + context, + ); - pauseNext = true; - boundary = deferred(); - const resumed = await composition.handlers['workhub.coordination.act']( - { + pauseNext = !missingReceipt; + boundary = deferred(); + const kernel = ( + manager as unknown as { + runtimeKernel: { deps: { runtimeEventStore: RuntimeEventStore } }; + } + ).runtimeKernel; + const originalEvents = kernel.deps.runtimeEventStore; + let cut = false; + if (missingReceipt) + kernel.deps.runtimeEventStore = { + ...originalEvents, + async appendRuntimeEvent(sessionId, runId, event, options) { + if (!cut && event.actions?.coordination?.result.disposition === 'resume_work') { + cut = true; + throw new Error('Crash after target admission before Coordination receipt'); + } + return originalEvents.appendRuntimeEvent(sessionId, runId, event, options); + }, + }; + const resumed = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-resume-stop-resume', + userText: 'Resume Payments', + proposal: { + disposition: 'resume_work', + resumesActionId: 'workhub-resume-stop-delegation', + expects: { targetSessionId: target.id }, + }, + }, + context, + ); + kernel.deps.runtimeEventStore = originalEvents; + assert.equal(resumed.ok, !missingReceipt, JSON.stringify(resumed)); + assert.equal(cut, missingReceipt); + const resumedTurn = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: workHubResumedTurnId('workhub-resume-stop-resume') }, + context, + ); + assert.equal(resumedTurn.ok, true); + if (!resumedTurn.ok) return; + continuation = { turnId: resumedTurn.result.turnId, runId: resumedTurn.result.runId }; + assert.equal(resumedTurn.result.status, 'running'); + if (!missingReceipt) await handoffAndReopen(); + + // Lose the response, interrupt the continuation, then discard all + // in-memory Gate replay state by reopening the production composition. + await composition.handlers['turn.stop']({ sessionId: target.id, ...continuation }, context); + await composition.close().catch((error: unknown) => { + const messages = (failure: unknown): string => + failure instanceof AggregateError + ? failure.errors.map(messages).join('\n') + : String(failure); + if ( + !missingReceipt || + !messages(error).includes('Crash after target admission before Coordination receipt') + ) + throw error; + }); + closed = true; + await owner.close(); + restartedOwner = await tryAcquireInteractiveRootOwner( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + assert.ok(restartedOwner); + owner = restartedOwner; + ({ composition } = await createCapturedExecutionComposition(owner, { + safeBoundaryResume: true, + })); + const retry = { actionId: 'workhub-resume-stop-resume', userText: 'Resume Payments', proposal: { - disposition: 'resume_work', + disposition: 'resume_work' as const, resumesActionId: 'workhub-resume-stop-delegation', expects: { targetSessionId: target.id }, }, - }, - context, - ); - assert.equal(resumed.ok, true, JSON.stringify(resumed)); - if ( - !resumed.ok || - resumed.result.disposition !== 'resume_work' || - !resumed.result.targetTurnId - ) - return; - const resumedTurn = await composition.handlers['turn.query']( - { sessionId: target.id, turnId: resumed.result.targetTurnId }, - context, - ); - assert.equal(resumedTurn.ok, true); - if (!resumedTurn.ok) return; - continuation = { turnId: resumedTurn.result.turnId, runId: resumedTurn.result.runId }; - assert.equal(resumedTurn.result.status, 'running'); - await handoffAndReopen(); + }; + const replayed = await composition.handlers['workhub.coordination.act'](retry, context); + // Re-delivery acknowledges the original Coordination Run; it must never + // resume a later interruption under the same action identity. + assert.equal(replayed.ok, true, JSON.stringify(replayed)); + assert.deepEqual(replayed.result, { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: target.id, + targetTurnId: continuation.turnId, + }); + const transcript = await readProductionTranscript(composition, context); + assert.equal( + transcript.filter( + (message) => + message.type === 'workhub_coordination' && + message.kind === 'action_receipt' && + message.receipt.actionId === retry.actionId, + ).length, + 1, + ); + assert.ok( + transcript.some( + (message) => + message.type === 'workhub_coordination' && message.kind === 'delegation_assigned', + ), + ); - // Lose the response, interrupt the continuation, then discard all - // in-memory Gate replay state by reopening the production composition. - await composition.handlers['turn.stop']({ sessionId: target.id, ...continuation }, context); - await composition.close(); - closed = true; - await owner.close(); - restartedOwner = await tryAcquireInteractiveRootOwner( - await resolveStorageRoot({ path: root, kind: 'interactive' }), - ); - assert.ok(restartedOwner); - owner = restartedOwner; - ({ composition } = await createCapturedExecutionComposition(owner, { - safeBoundaryResume: true, - })); - const retry = { - actionId: 'workhub-resume-stop-resume', - userText: 'Resume Payments', - proposal: { - disposition: 'resume_work' as const, - resumesActionId: 'workhub-resume-stop-delegation', - expects: { targetSessionId: target.id }, - }, - }; - const replayed = await composition.handlers['workhub.coordination.act'](retry, context); - // Re-delivery acknowledges the original Coordination Run; it must never - // resume a later interruption under the same action identity. - assert.equal(replayed.ok, true, JSON.stringify(replayed)); - const stillInterrupted = await composition.handlers['turn.query']( - { sessionId: target.id, turnId: continuation.turnId }, - context, - ); - assert.ok(stillInterrupted.ok); - assert.notEqual(stillInterrupted.result.status, 'running'); - const fresh = await composition.handlers['workhub.coordination.act']( - { ...retry, actionId: 'workhub-resume-again' }, - context, - ); - assert.equal(fresh.ok, true, JSON.stringify(fresh)); - if (!fresh.ok || fresh.result.disposition !== 'resume_work' || !fresh.result.targetTurnId) - return; - const freshTurn = await composition.handlers['turn.query']( - { sessionId: target.id, turnId: fresh.result.targetTurnId }, - context, - ); - assert.equal(freshTurn.ok, true); - if (!freshTurn.ok) return; - assert.equal(freshTurn.result.status, 'running'); - assert.notEqual(freshTurn.result.turnId, continuation.turnId); - continuation = { turnId: freshTurn.result.turnId, runId: freshTurn.result.runId }; + const stillInterrupted = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: continuation.turnId }, + context, + ); + assert.ok(stillInterrupted.ok); + assert.notEqual(stillInterrupted.result.status, 'running'); + const fresh = await composition.handlers['workhub.coordination.act']( + { ...retry, actionId: 'workhub-resume-again' }, + context, + ); + assert.equal(fresh.ok, true, JSON.stringify(fresh)); + if (!fresh.ok || fresh.result.disposition !== 'resume_work' || !fresh.result.targetTurnId) + return; + const freshTurn = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: fresh.result.targetTurnId }, + context, + ); + assert.equal(freshTurn.ok, true); + if (!freshTurn.ok) return; + assert.equal(freshTurn.result.status, 'running'); + assert.notEqual(freshTurn.result.turnId, continuation.turnId); + continuation = { turnId: freshTurn.result.turnId, runId: freshTurn.result.runId }; + + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const assignment = await stores.sessionStore.readWorkHubAssignment( + 'workhub-resume-stop-delegation', + ); + assert.ok(assignment); + // The existing Desktop card query must resolve the resumed execution, + // rather than keep projecting the original interrupted Turn. + const feedback = await composition.handlers['turn.message.execution.query']( + { + sessionId: target.id, + messageIds: [assignment.targetMessageId], + }, + context, + ); + assert.deepEqual(feedback, { + ok: true, + result: { + resolutions: [ + { + messageId: assignment.targetMessageId, + state: 'owned', + ...continuation, + }, + ], + }, + }); - const stores = await openInteractiveExecutionStoresForWrite(owner.lease); - const assignment = await stores.sessionStore.readWorkHubAssignment( - 'workhub-resume-stop-delegation', - ); - assert.ok(assignment); - // The existing Desktop card query must resolve the resumed execution, - // rather than keep projecting the original interrupted Turn. - const feedback = await composition.handlers['turn.message.execution.query']( - { - sessionId: target.id, - messageIds: [assignment.targetMessageId], - }, - context, - ); - assert.deepEqual(feedback, { - ok: true, - result: { - resolutions: [ - { - messageId: assignment.targetMessageId, - state: 'owned', - ...continuation, + const stopped = await composition.handlers['workhub.coordination.act']( + { + actionId: 'workhub-resume-stop-stop', + userText: 'Stop Payments', + confirmation: { kind: 'user_stop' }, + proposal: { + disposition: 'stop_work', + expects: { targetSessionId: target.id }, }, - ], - }, - }); - - const stopped = await composition.handlers['workhub.coordination.act']( - { - actionId: 'workhub-resume-stop-stop', - userText: 'Stop Payments', - confirmation: { kind: 'user_stop' }, - proposal: { + }, + context, + ); + assert.deepEqual(stopped, { + ok: true, + result: { disposition: 'stop_work', - expects: { targetSessionId: target.id }, + outcome: 'stop_delivered', + targetSessionId: target.id, + targetTurnId: continuation.turnId, }, - }, - context, - ); - assert.deepEqual(stopped, { - ok: true, - result: { - disposition: 'stop_work', - outcome: 'stop_delivered', - targetSessionId: target.id, - targetTurnId: continuation.turnId, - }, - }); - const terminal = await composition.handlers['turn.query']( - { sessionId: target.id, turnId: continuation.turnId }, - context, - ); - assert.equal(terminal.ok, true); - if (terminal.ok) assert.equal(terminal.result.status, 'cancelled'); - } finally { - if (!closed && continuation && targetSessionId) { - await composition.handlers['turn.stop']( - { sessionId: targetSessionId, ...continuation }, + }); + const terminal = await composition.handlers['turn.query']( + { sessionId: target.id, turnId: continuation.turnId }, context, ); + assert.equal(terminal.ok, true); + if (terminal.ok) assert.equal(terminal.result.status, 'cancelled'); + } finally { + if (!closed && continuation && targetSessionId) { + await composition.handlers['turn.stop']( + { sessionId: targetSessionId, ...continuation }, + context, + ).catch(() => {}); + } + if (!closed) await composition.close().catch(() => {}); + await restartedOwner?.close(); } - if (!closed) await composition.close(); - await restartedOwner?.close(); - } + }); }); -}); test('WorkHub does not record resume while safe-boundary resume is disabled', async () => { await withCompositionRoot(async ({ root, owner }) => { @@ -2496,6 +2649,43 @@ async function seedLegacyFakeBackendSession( return sessionId; } +async function readProductionTranscript( + composition: Awaited>, + context: ConnectionContext, +): Promise { + assert.ok(composition.continuity); + context = { ...context, principalKind: 'local_owner' }; + const connection = composition.continuity.attachConnection(context.connectionId, { + send: async () => {}, + }); + let pages = 0; + try { + const opened = await composition.handlers['subscription.open']( + { + sessionId: 'maka_workhub_coordination', + transcript: { kind: 'tail', maxBytes: 128 }, + }, + context, + ); + assert.ok(opened.ok, JSON.stringify(opened)); + const client = new ClientSessionSubscription( + opened.result, + async () => undefined, + async (input) => { + pages += 1; + const page = await composition.handlers['session.transcript.page'](input, context); + assert.ok(page.ok, JSON.stringify(page)); + return page.result; + }, + ); + const messages = await client.loadTranscript((value) => value as StoredMessage); + assert.ok(pages > 0, 'Must exercise production pagination, not only bootstrap'); + return messages; + } finally { + connection.close(); + } +} + async function createCapturedExecutionComposition( owner: InteractiveRootOwner, options: { diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts index b288e834ec..7e6ebb0c95 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -696,21 +696,47 @@ test('reads the WorkHub Coordination transcript from its own rows', async () => }, }, ]; + const indexed: Array<{ sequence: number; source: 'legacy' | 'runtime'; sourceSequence: number }> = + []; let ledgerReads = 0; const stores = { agentRunStore: {}, - // Any ledger read is the defect: this Session's Turns are never admitted, - // so nothing ever converts these rows and a ledger read returns nothing. - runtimeEventStore: new Proxy( - {}, - { - get: () => () => { - ledgerReads += 1; - return Promise.resolve([]); - }, + runtimeEventStore: { + readTranscriptHighWater: async () => { + ledgerReads += 1; + return null; }, - ), + readTranscriptInvocations: async () => [], + }, sessionStore: { + readCoordinationTranscriptIndexState: async () => ({ + highWater: indexed.at(-1)?.sequence ?? null, + legacy: indexed.at(-1)?.sourceSequence ?? null, + runtime: null, + }), + appendCoordinationTranscriptIndex: async ( + refs: Array<{ source: 'legacy' | 'runtime'; sourceSequence: number }>, + ) => { + for (const ref of refs) indexed.push({ ...ref, sequence: indexed.length }); + }, + readCoordinationTranscriptIndex: async (request: { + direction: string; + throughSequence: number; + position: number; + limit: number; + }) => { + const selected = indexed.filter( + (ref) => + ref.sequence <= request.throughSequence && + (request.direction === 'older' + ? ref.sequence <= request.position + : ref.sequence >= request.position), + ); + return (request.direction === 'older' ? selected.reverse() : selected).slice( + 0, + request.limit, + ); + }, readTranscriptHighWaterSnapshot: async () => rows.at(-1)!.sequence, readMessagesAfter: async ( _sessionId: string, @@ -740,7 +766,7 @@ test('reads the WorkHub Coordination transcript from its own rows', async () => page.records.map(({ message }) => message.id), ['wha_1-user', 'wha_1'], ); - assert.equal(ledgerReads, 0); + assert.ok(ledgerReads > 0); assert.deepEqual( (await read.readDurableTurnLandmarks(WORKHUB_COORDINATION_SESSION_ID, 4)).landmarks.map( ({ turnId }) => turnId, @@ -749,6 +775,182 @@ test('reads the WorkHub Coordination transcript from its own rows', async () => ); }); +test('Coordination page positions survive regressing clocks, late appends and reader recreation', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-coordination-index-')); + const root = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(root); + assert.ok(owner); + try { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const sessionId = WORKHUB_COORDINATION_SESSION_ID; + await stores.sessionStore.createStableSession({ + sessionId, + requestFingerprint: `sha256:${'0'.repeat(64)}`, + input: { + role: 'workhub_coordination', + cwd: root.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }, + }); + for (const [id, ts] of [ + ['legacy-1', 100], + ['legacy-2', 1], + ] as const) { + await stores.sessionStore.appendMessage(sessionId, { + type: 'user', + id, + turnId: id, + ts, + text: id, + }); + } + await seedInvocation(stores.runtimeEventStore, { + sessionId, + runId: 'index-run', + turnId: 'index-turn', + openedAt: 20, + }); + for (const [id, ts] of [ + ['runtime-1', 50], + ['runtime-2', 2], + ] as const) { + await stores.runtimeEventStore.appendRuntimeEvent( + sessionId, + 'index-run', + runtimeEvent(sessionId, { + id, + invocationId: 'index-run', + runId: 'index-run', + turnId: 'index-turn', + ts, + role: 'user', + author: 'user', + content: { kind: 'text', text: id }, + refs: { storedMessageId: id }, + }), + ); + } + await stores.runtimeEventStore.appendRuntimeEvent( + sessionId, + 'index-run', + runtimeEvent(sessionId, { + id: 'runtime-terminal', + invocationId: 'index-run', + runId: 'index-run', + turnId: 'index-turn', + ts: 3, + status: 'completed', + actions: { endInvocation: true }, + }), + ); + const makeReader = () => + createSessionTranscriptReader({ + stores, + canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, + }); + const reader = makeReader(); + const before = await reader.readDurableRecords(sessionId, { + direction: 'newer', + maxMessages: 64, + maxStoredBytes: 65536, + }); + assert.equal(before.records.length, 5); + // A new record can predate every displayed timestamp, in either store. + await stores.sessionStore.appendMessage(sessionId, { + type: 'user', + id: 'late-legacy', + turnId: 'late', + ts: 0, + text: 'late', + }); + await seedInvocation(stores.runtimeEventStore, { + sessionId, + runId: 'late-run', + turnId: 'late-turn', + openedAt: 0, + }); + await stores.runtimeEventStore.appendRuntimeEvent( + sessionId, + 'late-run', + runtimeEvent(sessionId, { + id: 'late-runtime', + invocationId: 'late-run', + runId: 'late-run', + turnId: 'late-turn', + ts: 0, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'late runtime' }, + refs: { storedMessageId: 'late-runtime' }, + }), + ); + await stores.runtimeEventStore.appendRuntimeEvent( + sessionId, + 'late-run', + runtimeEvent(sessionId, { + id: 'late-terminal', + invocationId: 'late-run', + runId: 'late-run', + turnId: 'late-turn', + ts: 0, + status: 'completed', + actions: { endInvocation: true }, + }), + ); + const recreated = makeReader(); + const after = await recreated.readDurableRecords(sessionId, { + direction: 'newer', + maxMessages: 64, + maxStoredBytes: 65536, + }); + assert.deepEqual(after.records.slice(0, before.records.length), before.records); + assert.ok(after.records.some(({ message }) => message.id === 'late-legacy')); + assert.ok(after.records.some(({ message }) => message.id === 'late-runtime')); + assert.deepEqual( + await recreated.readDurableRecords(sessionId, { + direction: 'newer', + throughSequence: before.throughSequence, + maxMessages: 64, + maxStoredBytes: 65536, + }), + before, + ); + for (const direction of ['newer', 'older'] as const) { + const records: (typeof after.records)[number][] = []; + let position: number | undefined; + do { + const page = await recreated.readDurableRecords(sessionId, { + direction, + throughSequence: after.throughSequence, + position, + maxMessages: 1, + maxStoredBytes: 65536, + }); + records.push(...page.records); + position = page.nextPosition ?? undefined; + assert.ok(records.length <= after.records.length); + } while (position !== undefined); + assert.deepEqual(direction === 'older' ? records.reverse() : records, after.records); + } + // Anchor +/- 1 is part of the public pager contract. + const anchor = after.records[2]!.sequence; + const preceding = await recreated.readDurableRecords(sessionId, { + direction: 'older', + position: anchor - 1, + throughSequence: after.throughSequence, + maxMessages: 64, + maxStoredBytes: 65536, + }); + assert.deepEqual(preceding.records, after.records.slice(0, 2).reverse()); + } finally { + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + test('pages a nested Turn the same way a single sweep reads it', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-nested-paging-')); const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts index b655573352..840749b816 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -84,6 +84,8 @@ const PERSISTED_GRANT_MIGRATIONS: ReadonlyMap = // Retired with the second execution-inspection contract; no shipped surface // called execution.inspect.resolve. ['execution.inspect.resolve', { kind: 'release' }], + // Synthetic Coordination recording was retired, not widened into action authority. + ['workhub.coordination.record', { kind: 'release' }], ]); export const ACCESS_FILE_NAME = 'runtime-host-access.json'; diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 4dfa090c97..5442b3197d 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1431,6 +1431,20 @@ export async function createExecutionRuntimeHostComposition( // delegation. A Session-wide latest-failure query could otherwise // continue unrelated work started directly in the same Session. resumeDelegation: async (assignment, context, actionId) => { + const targetTurnId = workHubResumedTurnId(actionId); + const admitted = await stores.agentRunStore.readRootTurnAdmission( + assignment.targetSessionId, + targetTurnId, + ); + if (admitted) { + if (admitted.execution.kind !== 'safe_boundary_continuation') { + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub resume identity is not a continuation', + ); + } + return { outcome: 'resume_started' as const, targetTurnId }; + } const disposition = await messages.readMessageExecutionDisposition( assignment.targetSessionId, assignment.targetMessageId, @@ -1491,7 +1505,6 @@ export async function createExecutionRuntimeHostComposition( 'WorkHub resume source lineage changed during planning', ); } - const targetTurnId = workHubResumedTurnId(actionId); const started = await coordinator.handlers['turn.resume.start']( { sessionId: assignment.targetSessionId, diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts index 5977acf25b..f7ae4e0e5a 100644 --- a/packages/runtime-host/src/server/session-transcript-reader.ts +++ b/packages/runtime-host/src/server/session-transcript-reader.ts @@ -36,6 +36,7 @@ import { } from '@maka/runtime/interaction-authority'; import type { ExecutionStoresWriter, + CoordinationTranscriptReference, SessionTranscriptMessageLookupRequest, SessionTranscriptPageRequest, SessionTranscriptRecordScanPage, @@ -89,11 +90,11 @@ export function createSessionTranscriptReader(input: { ensureTranscriptLedger?: (sessionId: string) => Promise; }): SessionTranscriptReader { const ledger = createDurableLedgerTranscriptReader(input); - const coordination = createCoordinationTranscriptReader(input.stores); + const coordination = createCoordinationTranscriptReader(input.stores, ledger.source); const isCoordination = (sessionId: string): boolean => sessionId === WORKHUB_COORDINATION_SESSION_ID; - // Only a ledger-backed Session has a conversion; the Coordination Session's - // rows are the transcript, not something a run left behind. + // Coordination retains atomic link facts and released history in the legacy + // source, while admitted Run output comes from the same ledger reader. const prepared = async (sessionId: string): Promise => { if (isCoordination(sessionId)) return coordination; await input.ensureTranscriptLedger?.(sessionId); @@ -245,20 +246,31 @@ function createDurableLedgerTranscriptReader(input: { if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) { throw new Error('Durable RuntimeEvent transcript projection is incomplete'); } + const action = + turn.invocation.sessionId === WORKHUB_COORDINATION_SESSION_ID + ? await input.stores.agentRunStore.readRootTurnAdmission( + turn.invocation.sessionId, + turn.invocation.turnId, + ) + : undefined; + const hostAction = + action?.execution.kind === 'workhub_coordination' && action.execution.operation === 'action'; const ordinals = new Map(turn.events.map((entry) => [entry.event.id, entry.ordinal])); const emitted = new Map(); - return projected.messages.map((message, index) => { - const ordinal = ordinals.get(projected.sourceEventIds[index]!); - if (ordinal === undefined) { - throw new Error('Durable transcript message has no source RuntimeEvent'); - } - const offset = emitted.get(ordinal) ?? 0; - if (offset >= EVENT_SEQUENCE_STRIDE) { - throw new Error('RuntimeEvent exceeds its transcript sequence stride'); - } - emitted.set(ordinal, offset + 1); - return { sequence: ordinal * EVENT_SEQUENCE_STRIDE + offset, message }; - }); + return projected.messages + .map((message, index) => { + const ordinal = ordinals.get(projected.sourceEventIds[index]!); + if (ordinal === undefined) { + throw new Error('Durable transcript message has no source RuntimeEvent'); + } + const offset = emitted.get(ordinal) ?? 0; + if (offset >= EVENT_SEQUENCE_STRIDE) { + throw new Error('RuntimeEvent exceeds its transcript sequence stride'); + } + emitted.set(ordinal, offset + 1); + return { sequence: ordinal * EVENT_SEQUENCE_STRIDE + offset, message }; + }) + .filter(({ message }) => !hostAction || message.type !== 'user'); }; const readTurns = async ( @@ -337,10 +349,12 @@ function createDurableLedgerTranscriptReader(input: { } }; + const source: TranscriptRecordSource = { readHighWater: highWater, scan }; return { + source, readHighWater: highWater, - ...pagedTranscriptReads({ readHighWater: highWater, scan }), + ...pagedTranscriptReads(source), /** One row per Turn, folded from the Turn's own projected messages. */ async readTurnContributions( @@ -552,28 +566,16 @@ function pagedTranscriptReads(source: TranscriptRecordSource) { }; } -/** - * The WorkHub Coordination Session's transcript, read from the rows the WorkHub - * writes. - * - * Every other Session's transcript is what its runs did, so the ledger holds - * all of it. The Coordination Session's is not: a delegation, a stop and a - * routing summary are appended under a Turn id that no root Turn admission ever - * minted, so there is no invocation for the ledger to hang them on and no - * conversion that could lift them. `workhub.coordination.answer` is the one - * path that would admit a real Turn and nothing in the renderer calls it. - * - * Delete this source once the WorkHub admits a Coordination Turn for every - * action, which its own ADR already requires (#3492, - * `docs/architecture/workhub-coordination-session-adr.md`): the Session then - * reads like any other and this reader has nothing left to do. - */ -function createCoordinationTranscriptReader(stores: ExecutionStoresWriter<'interactive'>) { +/** Legacy atomic facts and Runtime output share one bounded cursor projection. */ +function createCoordinationTranscriptReader( + stores: ExecutionStoresWriter<'interactive'>, + ledger: TranscriptRecordSource, +) { const store = stores.sessionStore; - const highWater = (sessionId: string): Promise => + const legacyHighWater = (sessionId: string): Promise => store.readTranscriptHighWaterSnapshot(sessionId); - const scan = async function* ( + const legacyScan = async function* ( sessionId: string, request: { direction: 'older' | 'newer'; @@ -582,7 +584,9 @@ function createCoordinationTranscriptReader(stores: ExecutionStoresWriter<'inter }, ): AsyncGenerator<{ sequence: number; message: StoredMessage }> { const throughSequence = - request.throughSequence === undefined ? await highWater(sessionId) : request.throughSequence; + request.throughSequence === undefined + ? await legacyHighWater(sessionId) + : request.throughSequence; if (throughSequence === null) return; const older = request.direction === 'older'; const position = request.position ?? (older ? throughSequence : 0); @@ -602,8 +606,15 @@ function createCoordinationTranscriptReader(stores: ExecutionStoresWriter<'inter } }; - const source: TranscriptRecordSource = { readHighWater: highWater, scan }; + const source = mergeTranscriptSources( + { readHighWater: legacyHighWater, scan: legacyScan }, + ledger, + store, + ); + const highWater = source.readHighWater; + const scan = source.scan; return { + source, readHighWater: highWater, ...pagedTranscriptReads(source), @@ -645,14 +656,24 @@ function createCoordinationTranscriptReader(stores: ExecutionStoresWriter<'inter const throughSequence = await highWater(sessionId); if (throughSequence === null) return { throughSequence: null, landmarks: [] }; const landmarks: SessionTurnLandmark[] = []; + const seen = new Set(); for await (const { sequence, message } of scan(sessionId, { direction: 'newer', throughSequence, })) { - if (message.type !== 'user' || message.turnId === undefined) continue; - const label = (message.displayText ?? message.text ?? '').trim(); - if (!label) continue; - landmarks.push({ turnId: message.turnId, sequence, label }); + const receipt = + message.type === 'workhub_coordination' && message.kind === 'action_receipt' + ? message.receipt + : undefined; + const turnId = message.turnId; + const identity = receipt?.actionId ?? turnId; + const label = ( + receipt?.userText ?? + (message.type === 'user' ? (message.displayText ?? message.text) : '') + ).trim(); + if (!turnId || !identity || !label || seen.has(identity)) continue; + seen.add(identity); + landmarks.push({ turnId, sequence, label }); if (landmarks.length === maxLandmarks) break; } return { throughSequence, landmarks }; @@ -660,6 +681,110 @@ function createCoordinationTranscriptReader(stores: ExecutionStoresWriter<'inter }; } +/** + * The stores have independent append orders, and event timestamps can regress. + * Retain only source references in a rebuildable index, allocating stable page + * positions once. Bodies and execution facts remain in their original store. + * Refresh drains bounded batches; pages seek the index and project one source + * Turn/batch at a time, including after a Host restart. + */ +function mergeTranscriptSources( + legacy: TranscriptRecordSource, + ledger: TranscriptRecordSource, + store: ExecutionStoresWriter<'interactive'>['sessionStore'], +): TranscriptRecordSource { + const sources = { legacy, runtime: ledger }; + let refreshing: Promise | undefined; + const refresh = async (sessionId: string): Promise => { + const state = await store.readCoordinationTranscriptIndexState(); + const lanes = ['legacy', 'runtime'] as const; + const limits = await Promise.all(lanes.map((lane) => sources[lane].readHighWater(sessionId))); + const walks = lanes.map((lane, index) => + sources[lane].scan(sessionId, { + direction: 'newer', + throughSequence: limits[index]!, + position: (state[lane] ?? -1) + 1, + }), + ); + try { + const heads = await Promise.all(walks.map((walk) => walk.next())); + let batch: CoordinationTranscriptReference[] = []; + while (heads.some((head) => !head.done)) { + // Time is only a presentation hint for newly observed facts, never a + // cursor or a reason to move an already indexed record. + const lane = heads[0]!.done + ? 1 + : heads[1]!.done + ? 0 + : heads[0]!.value.message.ts <= heads[1]!.value.message.ts + ? 0 + : 1; + batch.push({ source: lanes[lane]!, sourceSequence: heads[lane]!.value!.sequence }); + if (batch.length === COORDINATION_TRANSCRIPT_SCAN_LIMIT) { + await store.appendCoordinationTranscriptIndex(batch); + batch = []; + } + heads[lane] = await walks[lane]!.next(); + } + if (batch.length) await store.appendCoordinationTranscriptIndex(batch); + return (await store.readCoordinationTranscriptIndexState()).highWater; + } finally { + await Promise.all(walks.map((walk) => walk.return(undefined))); + } + }; + const readHighWater = (sessionId: string): Promise => { + refreshing ??= refresh(sessionId).finally(() => { + refreshing = undefined; + }); + return refreshing; + }; + const scan: TranscriptRecordSource['scan'] = async function* (sessionId, request) { + const watermark = + request.throughSequence === undefined + ? await readHighWater(sessionId) + : request.throughSequence; + if (watermark === null) return; + const state = await store.readCoordinationTranscriptIndexState(); + const older = request.direction === 'older'; + let position = request.position ?? (older ? watermark : 0); + let batches = 0; + for (;;) { + if (request.maxTurns !== undefined && batches++ >= request.maxTurns) return; + const refs = await store.readCoordinationTranscriptIndex({ + direction: request.direction, + throughSequence: watermark, + position, + limit: COORDINATION_TRANSCRIPT_SCAN_LIMIT, + }); + if (!refs.length) return; + const walks: Partial< + Record< + CoordinationTranscriptReference['source'], + ReturnType + > + > = {}; + try { + for (const ref of refs) { + const walk = (walks[ref.source] ??= sources[ref.source].scan(sessionId, { + direction: request.direction, + throughSequence: state[ref.source], + position: ref.sourceSequence, + })); + const record = await walk.next(); + if (record.done || record.value.sequence !== ref.sourceSequence) { + throw new Error('Coordination transcript source reference is missing'); + } + yield { sequence: ref.sequence, message: record.value.message }; + } + } finally { + await Promise.all(Object.values(walks).map((walk) => walk.return(undefined))); + } + position = refs.at(-1)!.sequence + (older ? -1 : 1); + } + }; + return { readHighWater, scan }; +} + function ordinalOf(sequence: number): number { return Math.floor(sequence / EVENT_SEQUENCE_STRIDE); } diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 078183b55f..4f408bc856 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -58,6 +58,71 @@ import { import { SQLITE_AGENT_GRAPH_CONTROL_TABLES } from '../sqlite-session-metadata-schema.js'; describe('SqliteSessionMetadataStore', () => { + test('migrates version 38 and resumes the body-free Coordination index idempotently', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-coordination-index-migration-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path); + setup.close(); + const baseline = new DatabaseSync(path); + baseline.exec( + "DROP TABLE coordination_transcript_index; UPDATE session_metadata_schema SET version = 38 WHERE scope = 'session_metadata'", + ); + baseline.close(); + const migrated = createSqliteSessionMetadataStore(path); + await migrated.appendCoordinationTranscriptIndex([ + { source: 'legacy', sourceSequence: 0 }, + { source: 'runtime', sourceSequence: 8 }, + ]); + migrated.close(); + const reopened = createSqliteSessionMetadataStore(path); + try { + await reopened.appendCoordinationTranscriptIndex([ + { source: 'runtime', sourceSequence: 8 }, + { source: 'legacy', sourceSequence: 1 }, + ]); + assert.deepEqual( + { ...(await reopened.readCoordinationTranscriptIndexState()) }, + { highWater: 2, legacy: 1, runtime: 8 }, + ); + const records = await reopened.readCoordinationTranscriptIndex({ + direction: 'older', + throughSequence: 1, + position: 1, + limit: 64, + }); + assert.deepEqual( + records.map((record) => ({ ...record })), + [ + { sequence: 1, source: 'runtime', sourceSequence: 8 }, + { sequence: 0, source: 'legacy', sourceSequence: 0 }, + ], + ); + await assert.rejects( + () => + reopened.appendCoordinationTranscriptIndex( + Array.from({ length: 65 }, () => ({ source: 'legacy' as const, sourceSequence: 2 })), + ), + /batch exceeds limit/, + ); + assert.equal((await reopened.readCoordinationTranscriptIndexState()).highWater, 2); + } finally { + reopened.close(); + } + const inspect = new DatabaseSync(path); + assert.deepEqual( + inspect + .prepare('PRAGMA table_info(coordination_transcript_index)') + .all() + .map((column) => column.name), + ['sequence', 'source', 'source_sequence'], + ); + inspect.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + for (const version30Shape of ['admissions-only', 'coordination-only', 'complete'] as const) { test(`converges the ${version30Shape} version-30 schema after the merge`, async () => { const root = await mkdtemp(join(tmpdir(), `maka-session-v30-${version30Shape}-`)); diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 9d5bc3542b..a85b6d23c5 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -125,6 +125,7 @@ export type { SessionHeaderSnapshot, SessionTranscriptMessageLookupRequest, SessionTranscriptPageRequest, + CoordinationTranscriptReference, SessionTranscriptRecordScanPage, SessionTranscriptRecordScanRequest, SessionTranscriptStoragePage, @@ -443,6 +444,12 @@ async function createExecutionStoresForWrite run(() => sessionStore.readMessagesSnapshot(sessionId)), readTranscriptMessagesSnapshot: (sessionId, request) => run(() => sessionStore.readTranscriptMessagesSnapshot(sessionId, request)), + readCoordinationTranscriptIndexState: () => + run(() => sessionStore.readCoordinationTranscriptIndexState()), + appendCoordinationTranscriptIndex: (records) => + run(() => sessionStore.appendCoordinationTranscriptIndex(records)), + readCoordinationTranscriptIndex: (request) => + run(() => sessionStore.readCoordinationTranscriptIndex(request)), readTranscriptHighWaterSnapshot: (sessionId) => run(() => sessionStore.readTranscriptHighWaterSnapshot(sessionId)), listTurnsSnapshot: (sessionId) => run(() => sessionStore.listTurnsSnapshot(sessionId)), diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 04809ad117..45810704c0 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -355,7 +355,31 @@ export interface SessionStore { close?(): Promise; } +/** Rebuildable ordering only; the message body remains in its original store. */ +export interface CoordinationTranscriptReference { + readonly source: 'legacy' | 'runtime'; + readonly sourceSequence: number; +} +export interface CoordinationTranscriptIndexRecord extends CoordinationTranscriptReference { + readonly sequence: number; +} +export interface CoordinationTranscriptIndexState { + readonly highWater: number | null; + readonly legacy: number | null; + readonly runtime: number | null; +} + export interface SessionAuthorityStore extends SessionStore, MessageAdmissionStore { + readCoordinationTranscriptIndexState(): Promise; + appendCoordinationTranscriptIndex( + records: readonly CoordinationTranscriptReference[], + ): Promise; + readCoordinationTranscriptIndex(request: { + direction: 'older' | 'newer'; + throughSequence: number; + position: number; + limit: number; + }): Promise; /** Read a bounded set of durable messages at an inclusive transcript watermark. */ readTranscriptMessagesSnapshot( sessionId: string, @@ -986,6 +1010,28 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.readTranscriptHighWater(sessionId); } + async readCoordinationTranscriptIndexState(): Promise { + await this.ensureReady(); + return this.metadata.readCoordinationTranscriptIndexState(); + } + + async appendCoordinationTranscriptIndex( + records: readonly CoordinationTranscriptReference[], + ): Promise { + await this.ensureReady(); + return this.metadata.appendCoordinationTranscriptIndex(records); + } + + async readCoordinationTranscriptIndex(request: { + direction: 'older' | 'newer'; + throughSequence: number; + position: number; + limit: number; + }): Promise { + await this.ensureReady(); + return this.metadata.readCoordinationTranscriptIndex(request); + } + async listTurnsSnapshot(sessionId: string): Promise { return deriveTurnRecords(await this.readMessagesSnapshot(sessionId)); } diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 525934c7de..3c843f5302 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 38; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 39; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -37,6 +37,17 @@ export const SQLITE_AGENT_GRAPH_CONTROL_TABLES = [ ] as const; const MIGRATIONS: ReadonlyMap = new Map([ + [ + 39, + ` + CREATE TABLE IF NOT EXISTS coordination_transcript_index ( + sequence INTEGER PRIMARY KEY, + source TEXT NOT NULL CHECK (source IN ('legacy', 'runtime')), + source_sequence INTEGER NOT NULL CHECK (source_sequence >= 0), + UNIQUE (source, source_sequence) + ); + `, + ], [ 1, ` diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index f40d62b19f..5b31404958 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -142,6 +142,9 @@ import { normalizeSessionHeader, SessionNotFoundError, type ExternalSessionImportLookupResult, + type CoordinationTranscriptReference, + type CoordinationTranscriptIndexRecord, + type CoordinationTranscriptIndexState, type SessionMessageScanPage, type SessionMessageScanRecord, type SessionMessageScanRequest, @@ -2585,6 +2588,60 @@ export class SqliteSessionMetadataStore { }); } + async readCoordinationTranscriptIndexState(): Promise { + this.assertOpen(); + return this.db + .prepare(`SELECT (SELECT MAX(sequence) FROM coordination_transcript_index) AS highWater, + (SELECT MAX(source_sequence) FROM coordination_transcript_index WHERE source = 'legacy') AS legacy, + (SELECT MAX(source_sequence) FROM coordination_transcript_index WHERE source = 'runtime') AS runtime`) + .get() as unknown as CoordinationTranscriptIndexState; + } + + async appendCoordinationTranscriptIndex( + records: readonly CoordinationTranscriptReference[], + ): Promise { + this.assertOpen(); + if (records.length > 64) throw new Error('Coordination transcript index batch exceeds limit'); + this.transaction(() => { + let sequence = + ( + this.db + .prepare('SELECT MAX(sequence) AS value FROM coordination_transcript_index') + .get() as { value: number | null } + ).value ?? -1; + const insert = this.db.prepare(`INSERT INTO coordination_transcript_index + (sequence, source, source_sequence) VALUES (?, ?, ?) + ON CONFLICT(source, source_sequence) DO NOTHING`); + for (const record of records) { + if (!Number.isSafeInteger(record.sourceSequence) || record.sourceSequence < 0) + throw new Error('Invalid Coordination source sequence'); + const result = insert.run(sequence + 1, record.source, record.sourceSequence); + if (result.changes) sequence++; + } + }); + } + + async readCoordinationTranscriptIndex(request: { + direction: 'older' | 'newer'; + throughSequence: number; + position: number; + limit: number; + }): Promise { + this.assertOpen(); + if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 64) + throw new Error('Invalid Coordination transcript index limit'); + const older = request.direction === 'older'; + return this.db + .prepare(`SELECT sequence, source, source_sequence AS sourceSequence + FROM coordination_transcript_index WHERE sequence <= ? AND sequence ${older ? '<=' : '>='} ? + ORDER BY sequence ${older ? 'DESC' : 'ASC'} LIMIT ?`) + .all( + request.throughSequence, + request.position, + request.limit, + ) as unknown as CoordinationTranscriptIndexRecord[]; + } + async readMessages(sessionId: string): Promise { return this.readMessagesWith(sessionId, decodeStoredMessage); } From d13d888487999533d297ec8c05efaa19c4f0209b Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 15:37:11 +0800 Subject: [PATCH 6/9] fix(workhub): refresh expired replacement candidates without rerouting Preserve the typed stale-candidate refusal through Host and IPC, and retry only the same action and selected Session with fresh opaque references. Bound retries and retain fail-closed behavior for changed targets and other failures. Stabilize E2E terminal and title setup, and align the preload fixture with removed synthetic recording. Generated-by: Codex --- apps/desktop/e2e/new-task-reload.spec.ts | 3 ++ .../e2e/workhub-reconstruction.spec.ts | 11 +++-- .../runtime-host-workhub-ipc-main.test.ts | 8 ++-- .../main/__tests__/workhub-controller.test.ts | 42 +++++++++++++++++++ ...ub-coordination-transcript-preload.test.ts | 1 - .../src/main/runtime-host-workhub-ipc-main.ts | 1 + .../src/renderer/workhub-controller.ts | 29 ++++++++++++- apps/desktop/src/renderer/workhub-surface.tsx | 2 +- .../workhub-coordination-session-adr.md | 8 +++- .../__tests__/execution-composition.test.ts | 33 +++++++++------ packages/runtime-host/src/protocol/index.ts | 2 +- .../src/protocol/operation-spec.ts | 1 + .../src/protocol/workhub-coordination.ts | 4 +- .../workhub-coordination-coordinator.ts | 7 +++- 14 files changed, 123 insertions(+), 29 deletions(-) diff --git a/apps/desktop/e2e/new-task-reload.spec.ts b/apps/desktop/e2e/new-task-reload.spec.ts index b35056981d..a6c54cac76 100644 --- a/apps/desktop/e2e/new-task-reload.spec.ts +++ b/apps/desktop/e2e/new-task-reload.spec.ts @@ -28,6 +28,9 @@ test('archived-only history boots into a usable new task', async ({ window: page await composer.press('Enter'); await expect(reply).toBeVisible({ timeout: 20_000 }); + // Visible streaming text is not proof that the Host has released the Turn. + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1); + // Prove bootstrap can restore this history before archiving it. await page.reload(); await expect(reply).toBeVisible(); diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index 3acf19c471..f1cc999677 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -36,10 +36,13 @@ test('WorkHub rebuilds delegated execution feedback after navigating away and ba timeout: 20_000, }); - const sessionName = await page.evaluate(async () => - (await window.maka.sessions.list())[0]?.name, - ); - expect(sessionName).toBeTruthy(); + // This test owns navigation identity, not asynchronous title generation. + const sessionName = initialPrompt; + await page.evaluate(async (name) => { + const session = (await window.maka.sessions.list())[0]; + if (!session) throw new Error('Source Session was not found'); + await window.maka.sessions.rename(session.id, name); + }, sessionName); await page.evaluate(async () => { await window.maka.settings.updateClient({ workHub: { enabled: true } }); }); 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 2760618e04..bd15684858 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 @@ -102,14 +102,15 @@ 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 () => { +for (const code of ['operation_conflict', 'candidate_set_stale'] as const) { +test(`serializes typed WorkHub action failures across Electron IPC (${code})`, async () => { const handlers = new Map unknown>(); registerRuntimeHostWorkHubIpc( { actWorkHubCoordination: async () => { throw new RuntimeHostOperationError( 'workhub.coordination.act', - 'operation_conflict', + code, 'WorkHub action is permanently abandoned', ); }, @@ -135,9 +136,10 @@ test('serializes typed WorkHub action failures across Electron IPC', async () => { ok: false, error: { - code: 'operation_conflict', + code, message: 'WorkHub action is permanently abandoned', }, }, ); }); +} diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 3abe2937dd..b00f5f0d2c 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -3358,3 +3358,45 @@ test('composer defaults apply only to creation while attachments follow explicit assert.deepEqual(actions[1]?.newWorkDefaults, newWorkDefaults); assert.deepEqual(actions[1]?.attachments, attachments); }); + +for (const mode of ['refresh', 'missing', 'renamed', 'churn', 'conflict'] as const) { + test(`replacement candidate refresh preserves the chosen Session (${mode})`, async () => { + const actions: WorkHubCoordinationActInput[] = []; + let reads = 0; + const controller = createGatedWorkHubController({ + sessions: port([session('source'), session('target')]), + coordination: { + open: async () => ({ close: async () => undefined }), + candidates: async () => { + const version = reads++; + return { + candidateSetId: `sha256:${String(version).repeat(64)}`, + candidates: ['other', 'target', 'source'].filter((id) => !(mode === 'missing' && version > 0 && id === 'target')).map((id) => ({ + candidateRef: `${id}-${version}`, sessionId: id, + sessionName: mode === 'renamed' && version > 0 && id === 'target' ? 'different work' : id, + workspace: { target: { kind: 'host_path' as const, path: `/workspace/${id}` }, hostCwd: `/workspace/${id}` }, + state: 'active' as const, updatedAt: version, + })), + }; + }, + act: async (input) => { + actions.push(input); + if (actions.length === 1 || mode === 'churn') throw new WorkHubCoordinationFailure( + mode === 'conflict' ? 'operation_conflict' : 'candidate_set_stale', 'Snapshot changed'); + return { disposition: 'replace', replacementDisposition: 'delegate_existing', targetSessionId: 'target', targetTurnId: 'replacement-turn' }; + }, + }, + }); + const submit = () => controller.submit({ requestId: 'same-action', text: 'No, use target instead', explicitTarget: { sessionId: 'target' }, correction: { from: { sessionId: 'source' }, sourceActionId: 'source-action' } }); + if (mode === 'refresh') { + assert.equal((await submit()).kind, 'submitted'); + assert.equal(actions.length, 2); + assert.deepEqual(actions.map((action) => action.actionId), ['same-action', 'same-action']); + assert.deepEqual(actions.map((action) => action.proposal), [0, 1].map((version) => ({ disposition: 'replace', replacesActionId: 'source-action', target: { disposition: 'delegate_existing', candidateRef: `target-${version}` } }))); + assert.notEqual(actions[0]!.candidateSetId, actions[1]!.candidateSetId); + } else { + await assert.rejects(submit, WorkHubCoordinationFailure); + assert.equal(actions.length, mode === 'churn' ? 3 : 1); + } + }); +} diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index 8d3220cfb8..3cba8f91ee 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -138,7 +138,6 @@ test('Coordination tail recovery converges through the preload with a fragmented return bridge!.transcripts.open(requestedSessionId, handler, registerCancellation); }, }, - record: async (input) => ({ turnId: input.turnId }), candidates: async () => assert.fail('unused'), act: async () => assert.fail('unused'), }); 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 7f5bd56b4f..b89f428d18 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -135,6 +135,7 @@ function workHubActError( case 'not_found': case 'session_archived': case 'session_busy': + case 'candidate_set_stale': case 'operation_conflict': case 'persistence_failed': case 'commit_outcome_unknown': diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 641c4957f9..e4b12f7a91 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -730,7 +730,7 @@ export function createWorkHubController(deps: { if (!candidate) { throw new ExpectedOperationError('candidates_changed'); } - const action: WorkHubCoordinationActInput = correction + let action: WorkHubCoordinationActInput = correction ? { actionId: input.requestId, userText: input.text, @@ -756,7 +756,32 @@ export function createWorkHubController(deps: { candidateRef: candidate.candidateRef, }, }; - const admitted = await coordination.act(action); + let admitted: WorkHubCoordinationActResult; + for (let attempt = 0; ; attempt++) { + try { + admitted = await coordination.act(action); + break; + } catch (error) { + if ( + !(error instanceof WorkHubCoordinationFailure) || error.code !== 'candidate_set_stale' || + attempt >= 2 || action.proposal.disposition !== 'replace' || + action.proposal.target.disposition !== 'delegate_existing' + ) throw error; + // Refresh only the opaque reference for the already chosen Session. + // Do not rerun routing or change action/source identity on this retry. + const refreshed = await coordination.candidates(); + const sameTarget = refreshed.candidates.find((item) => item.sessionId === target.sessionId); + if (!sameTarget || sameTarget.sessionName !== candidate.sessionName) throw error; + action = { + ...action, + candidateSetId: refreshed.candidateSetId, + proposal: { + ...action.proposal, + target: { disposition: 'delegate_existing', candidateRef: sameTarget.candidateRef }, + }, + }; + } + } if ( (!correction && admitted.disposition !== 'delegate_existing') || (correction && diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 4089093854..c16b6ff999 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -113,7 +113,7 @@ 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') { + if (error.code === 'candidate_set_stale' || error.code === 'not_found' || error.code === 'session_archived') { return 'candidates_changed'; } if (error.code === 'session_busy') return 'target_waiting'; diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 225850a322..f381358556 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -88,7 +88,13 @@ The synthetic `workhub.coordination.record` operation is removed. Released histo remains readable without inventing admissions for old summary rows. A receipt acknowledges what the operation accepted; it is not the target's current -execution state. Re-delivery of a completed request returns that receipt, including +execution state. Candidate-snapshot expiry is a distinct refusal. For a replacement +of an existing Session, the client may refresh its opaque candidate reference at +most twice while preserving the action, source delegation, and chosen target. +Routing is not rerun; a missing or renamed target, another refusal, or continued +snapshot churn stops the attempt. The Host still validates every refreshed proposal. + +Re-delivery of a completed request returns that receipt, including after restart, without repeating the effect. Failed attempts remain terminal; a same-action retry gets a subsequent admitted Turn. If the failed attempt already committed a receipt, the new Turn reuses that result without repeating the effect. diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 1458074b8d..937a30b7b9 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -1921,21 +1921,28 @@ test('WorkHub correction replaces its link without stopping a shared manual Turn assert.ok(correctionDestination); if (!correctionDestination) return; - const correction = await composition.handlers['workhub.coordination.act']( - { - actionId: 'workhub-correction-action', - userText: `No, move this to ${correctionDestination.sessionName} instead`, - candidateSetId: correctionCandidates.result.candidateSetId, - confirmation: { kind: 'user_correction' }, - proposal: { - disposition: 'replace', - replacesActionId: assignment.actionId, - target: { - disposition: 'delegate_existing', - candidateRef: correctionDestination.candidateRef, - }, + const correctionInput = { + actionId: 'workhub-correction-action', + userText: `No, move this to ${correctionDestination.sessionName} instead`, + candidateSetId: correctionCandidates.result.candidateSetId, + confirmation: { kind: 'user_correction' }, + proposal: { + disposition: 'replace', + replacesActionId: assignment.actionId, + target: { + disposition: 'delegate_existing', + candidateRef: correctionDestination.candidateRef, }, }, + } as const; + const stale = await composition.handlers['workhub.coordination.act']( + { ...correctionInput, candidateSetId: `sha256:${'0'.repeat(64)}` }, + context, + ); + assert.equal(stale.ok, false); + if (!stale.ok) assert.equal(stale.error.code, 'candidate_set_stale'); + const correction = await composition.handlers['workhub.coordination.act']( + correctionInput, context, ); assert.equal(correction.ok, true, JSON.stringify(correction)); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index a55555db45..eb48961f97 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -103,7 +103,7 @@ export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // interoperability. Mismatches are rejected before domain commands are admitted. export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 134 as const; // 134: Coordination actions own real Runtime Turns. Removes the synthetic record -// operation and projects typed action receipts; older peers cannot decode them. +// operation, projects typed action receipts, and distinguishes stale candidate refusals. // 133: WorkHub actions carry attachments and new-Work model/permission defaults. // Epoch-132 peers reject these additional fields on strict action shapes. // 132: new Tool Result archives use versioned ledger references, not Artifact payloads. diff --git a/packages/runtime-host/src/protocol/operation-spec.ts b/packages/runtime-host/src/protocol/operation-spec.ts index cbdd9a6768..f16b48a90f 100644 --- a/packages/runtime-host/src/protocol/operation-spec.ts +++ b/packages/runtime-host/src/protocol/operation-spec.ts @@ -28,6 +28,7 @@ export type HostOperationErrorCode = | 'not_found' | 'session_archived' | 'session_busy' + | 'candidate_set_stale' | 'operation_conflict' | 'capability_unavailable' | 'invalid_request' diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 3ba85ae126..b767ac7bcd 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -225,11 +225,11 @@ export const WORKHUB_COORDINATION_OPERATION_SPECS = { 'workhub.coordination.act': defineOperation< WorkHubCoordinationActInput, WorkHubCoordinationActResult, - (typeof TURN_ERRORS)[number] + (typeof TURN_ERRORS)[number] | 'candidate_set_stale' >({ mode: 'command', availability: 'ready', - errors: TURN_ERRORS, + errors: [...TURN_ERRORS, 'candidate_set_stale'], decodeInput: decodeWorkHubCoordinationActInput, decodeOutput: decodeWorkHubCoordinationActResult, }), diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index daed3dff5c..d46d1779e9 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -572,7 +572,12 @@ export class HostWorkHubCoordinationCoordinator { return { ok: false, error: { - code: error.code === 'target_waiting_for_user' ? 'session_busy' : 'operation_conflict', + code: + error.code === 'target_waiting_for_user' + ? 'session_busy' + : error.code === 'candidate_set_stale' + ? 'candidate_set_stale' + : 'operation_conflict', message: error.message, }, }; From e6567a69f1cfc44f811d48f04dc9a476a1824d7a Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 15:49:49 +0800 Subject: [PATCH 7/9] fix(workhub): reconcile retried actions by logical identity --- .../__tests__/workhub-session-port.test.ts | 128 ++++++++++-------- .../src/renderer/workhub-coordination-port.ts | 4 +- 2 files changed, 70 insertions(+), 62 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index 2e9286594f..34cde5dee9 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -106,68 +106,71 @@ function transcriptsWith(messages: readonly StoredMessage[]) { }; } -test('projects the durable Coordination transcript into the WorkHub conversation', () => { - const messages: StoredMessage[] = [ - { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 10, text: 'What is next?' }, - { - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 11, - text: 'Slice 3 is next.', - modelId: 'test-model', - }, - { - type: 'turn_state', - id: 'state-1', +for (const physicalTurnId of ['action-1', 'action-1-retry']) { + test(`projects the durable Coordination transcript into the WorkHub conversation (${physicalTurnId})`, () => { + const messages: StoredMessage[] = [ + { type: 'user', id: 'action-user', turnId: physicalTurnId, ts: 19, text: 'Continue payments' }, + { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 10, text: 'What is next?' }, + { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 11, + text: 'Slice 3 is next.', + modelId: 'test-model', + }, + { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 12, + status: 'completed', + }, + { + type: 'workhub_coordination', + id: 'assignment-1', + turnId: physicalTurnId, + ts: 20, + schemaVersion: 1, + kind: 'delegation_assigned', + actionId: 'action-1', + actionFingerprint: `sha256:${'a'.repeat(64)}`, + coordinationTurnId: physicalTurnId, + targetSessionId: 'payments', + targetSessionName: 'Payments', + targetTurnId: 'payments-turn', + targetMessageId: 'payments-message', + delegationId: 'payments-delegation', + disposition: 'delegate_existing', + userText: 'Continue payments', + }, + ]; + assert.deepEqual(projectWorkHubCoordinationTurns(messages), [{ + messageId: 'user-1', turnId: 'turn-1', - ts: 12, - status: 'completed', - }, - { - type: 'workhub_coordination', - id: 'assignment-1', + text: 'What is next?', + result: 'Slice 3 is next.', + state: 'completed', + updatedAt: 11, + }, { + messageId: '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', - }, - ]; - assert.deepEqual(projectWorkHubCoordinationTurns(messages), [{ - messageId: 'user-1', - turnId: 'turn-1', - text: 'What is next?', - result: 'Slice 3 is next.', - state: 'completed', - updatedAt: 11, - }, { - messageId: 'assignment-1', - turnId: 'action-1', - text: 'Continue payments', - state: 'completed', - assignment: { - actionId: 'action-1', - delegationId: 'payments-delegation', - targetSessionId: 'payments', - targetSessionName: 'Payments', - targetMessageId: 'payments-message', - targetTurnId: 'payments-turn', - feedbackState: 'accepted', - linkState: 'active', - }, - updatedAt: 20, - }]); -}); + text: 'Continue payments', + state: 'completed', + assignment: { + actionId: 'action-1', + delegationId: 'payments-delegation', + targetSessionId: 'payments', + targetSessionName: 'Payments', + targetMessageId: 'payments-message', + targetTurnId: 'payments-turn', + feedbackState: 'accepted', + linkState: 'active', + }, + updatedAt: 20, + }]); + }); +} test('bounds the visible timeline independently of old delegation linkage', () => { const assignment: StoredMessage = { @@ -309,6 +312,11 @@ test('direct-stop projection is retryable until resolved and preserves not_owned outcome: 'not_owned', }); assert.equal(projected[0]?.assignment?.linkState, 'active'); + const retriedStop = { ...requested, turnId: 'stop-retry', coordinationTurnId: 'stop-retry' }; + assert.equal( + projectWorkHubCoordinationTurns([assignment, retriedStop])[1]?.turnId, + 'stop-action', + ); const stopped = { ...notOwned, outcome: 'stop_delivered' as const }; assert.equal( diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 4c1afa436a..067db6a306 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -209,7 +209,7 @@ export function projectWorkHubCoordinationTurns( const resolution = stopResolutionByDelegationId.get(message.stopsDelegationId); turns.push({ messageId: message.id, - turnId: message.coordinationTurnId, + turnId: message.actionId, text: boundedWorkHubTimelineText(message.userText), state: resolution ? 'completed' : 'running', stop: { @@ -224,7 +224,7 @@ export function projectWorkHubCoordinationTurns( if (message.type === 'workhub_coordination' && message.kind === 'delegation_assigned') { turns.push({ messageId: message.id, - turnId: message.coordinationTurnId, + turnId: message.actionId, text: boundedWorkHubTimelineText(message.userText), ...(message.attachments ? { attachments: message.attachments } : {}), state: 'completed', From a637d1a583c081336cf5413420eabe1a1f3e0849 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 20:01:54 +0800 Subject: [PATCH 8/9] fix(workhub): bound transcript preparation and retain failed actions --- .../main/__tests__/workhub-controller.test.ts | 2 +- .../__tests__/workhub-session-port.test.ts | 21 ++++ .../src/renderer/locales/workhub-copy.ts | 4 + .../src/renderer/workhub-controller.ts | 1 + .../src/renderer/workhub-coordination-port.ts | 10 +- apps/desktop/src/renderer/workhub-surface.tsx | 3 +- .../workhub-coordination-session-adr.md | 14 ++- packages/core/src/runtime-invocation.ts | 2 + packages/core/src/session.ts | 8 +- .../__tests__/execution-composition.test.ts | 21 ++++ .../session-subscription-client.test.ts | 58 +++++++++++ .../session-transcript-pager.test.ts | 15 +++ .../session-transcript-reader.test.ts | 91 +++++++++++++++++ .../runtime-host/src/client/connection.ts | 27 ++++- packages/runtime-host/src/protocol/index.ts | 3 +- .../src/protocol/operation-spec.ts | 1 + .../src/protocol/session-continuity.ts | 1 + .../src/server/root-turn-coordinator.ts | 15 ++- .../server/session-continuity-coordinator.ts | 14 ++- .../src/server/session-transcript-reader.ts | 63 +++++++----- .../src/server/shared-session-transcript.ts | 3 + .../workhub-coordination-coordinator.ts | 1 + ...orkhub-coordination-root-admission.test.ts | 98 ++++++++++--------- packages/storage/src/agent-run-store.ts | 10 +- 24 files changed, 404 insertions(+), 82 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index b00f5f0d2c..04e259ca0a 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -3387,7 +3387,7 @@ for (const mode of ['refresh', 'missing', 'renamed', 'churn', 'conflict'] as con }, }, }); - const submit = () => controller.submit({ requestId: 'same-action', text: 'No, use target instead', explicitTarget: { sessionId: 'target' }, correction: { from: { sessionId: 'source' }, sourceActionId: 'source-action' } }); + const submit = () => controller.submit({ newSessionFallbackTitle: 'New task', requestId: 'same-action', text: 'No, use target instead', explicitTarget: { sessionId: 'target' }, correction: { from: { sessionId: 'source' }, sourceActionId: 'source-action' } }); if (mode === 'refresh') { assert.equal((await submit()).kind, 'submitted'); assert.equal(actions.length, 2); 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 34cde5dee9..c4568225bb 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -1197,3 +1197,24 @@ test('admitted clarification and resume project receipts without assistant messa assert.equal(turns[1]?.result, undefined); assert.deepEqual(turns[1]?.resume, { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'target-turn' }); }); + + +test('failed action inputs remain visible until a visible receipt replaces every physical retry', () => { + const failed: StoredMessage[] = ['resume', 'retry-one', 'retry-two'].flatMap((turnId, index) => [ + { type: 'user' as const, id: `u-${turnId}`, turnId, ts: index * 2, + text: 'Resume Payments', coordinationActionId: 'resume' }, + { type: 'turn_state' as const, id: `s-${turnId}`, turnId, ts: index * 2 + 1, status: 'failed' as const }, + ]); + const receipt: StoredMessage = { type: 'workhub_coordination', kind: 'action_receipt', schemaVersion: 1, + id: 'resumed', turnId: 'retry-three', ts: 10, + receipt: { actionId: 'resume', userText: 'Resume Payments', result: { + disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'original-target', + } } }; + assert.deepEqual(projectWorkHubCoordinationTurns(failed).map((row) => [row.text, row.state, row.coordinationActionId]), + Array.from({ length: 3 }, () => ['Resume Payments', 'failed', 'resume'])); + const visible = projectWorkHubCoordinationTurns([...failed, receipt]); + assert.equal(visible.length, 1); + assert.equal(visible[0]?.resume?.targetTurnId, 'original-target'); + // A bounded older page with no visible receipt must still reconstruct its inputs. + assert.equal(projectWorkHubCoordinationTurns(failed).length, 3); +}); diff --git a/apps/desktop/src/renderer/locales/workhub-copy.ts b/apps/desktop/src/renderer/locales/workhub-copy.ts index 86f67eedfa..dfc67ceddd 100644 --- a/apps/desktop/src/renderer/locales/workhub-copy.ts +++ b/apps/desktop/src/renderer/locales/workhub-copy.ts @@ -213,6 +213,7 @@ export interface WorkHubCopy { readonly aborted: string; readonly stopped: string; }; + readonly actionConfirmationIncomplete: string; readonly turnStates: Record<'running' | 'completed' | 'aborted' | 'failed', string>; } @@ -286,6 +287,7 @@ const WORKHUB_COPY = { aborted: '更正已中止', stopped: '已停止关联', }, + actionConfirmationIncomplete: '操作确认未完成;请查看目标任务状态后重试。', turnStates: { running: '进行中', completed: '已完成', aborted: '已中止', failed: '失败' }, }, 'zh-TW': { @@ -357,6 +359,7 @@ const WORKHUB_COPY = { aborted: '更正已中止', stopped: '已停止關聯', }, + actionConfirmationIncomplete: '操作確認未完成;請查看目標任務狀態後重試。', turnStates: { running: '進行中', completed: '已完成', aborted: '已中止', failed: '失敗' }, }, en: { @@ -433,6 +436,7 @@ const WORKHUB_COPY = { aborted: 'Aborted replacement', stopped: 'Stopped link', }, + actionConfirmationIncomplete: 'Action confirmation is incomplete. Check the target task before retrying.', turnStates: { running: 'Running', completed: 'Completed', aborted: 'Aborted', failed: 'Failed' }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index e4b12f7a91..397ca48771 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -122,6 +122,7 @@ export interface WorkHubProjectedTurn { } export interface WorkHubCoordinationTurn { + coordinationActionId?: string; attachments?: WorkHubCoordinationActInput['attachments']; messageId: string; turnId: string; diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 067db6a306..574434c997 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -160,11 +160,12 @@ export function projectWorkHubCoordinationTurns( ); const factualTurnIds = new Set(messages.flatMap((message) => { if (message.type !== 'workhub_coordination') return []; - if (message.kind === 'action_receipt' && message.receipt.result.disposition === 'clarify') { - return [message.receipt.actionId]; + if (message.kind === 'action_receipt' && + (message.receipt.result.disposition === 'clarify' || message.receipt.result.disposition === 'resume_work')) { + return [message.receipt.actionId, message.turnId]; } return message.kind === 'delegation_assigned' || message.kind === 'delegation_stop_requested' - ? [message.coordinationTurnId] + ? [message.coordinationTurnId, message.actionId] : []; })); const turns: WorkHubCoordinationTurn[] = []; @@ -244,7 +245,7 @@ export function projectWorkHubCoordinationTurns( continue; } if (message.type === 'user') { - if (factualTurnIds.has(message.turnId)) continue; + if (factualTurnIds.has(message.coordinationActionId ?? message.turnId)) continue; const text = boundedWorkHubTimelineText(userFacingText(message)); if (!text) continue; turns.push({ @@ -252,6 +253,7 @@ export function projectWorkHubCoordinationTurns( turnId: message.turnId, text, ...(message.attachments ? { attachments: message.attachments } : {}), + ...(message.coordinationActionId ? { coordinationActionId: message.coordinationActionId } : {}), state: stateByTurnId.get(message.turnId) ?? 'running', updatedAt: message.ts, }); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index c16b6ff999..837418655c 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -693,7 +693,8 @@ export function WorkHubCoordinationTurnView(props: {