From 7c10296fded17a88e1aeaaf6ca9c4d96ec92e48b Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 29 Aug 2026 08:06:21 +0800 Subject: [PATCH 1/4] feat(workhub): project delegated execution status Generated-by: Codex --- .../e2e/workhub-reconstruction.spec.ts | 6 +- .../main/__tests__/workhub-controller.test.ts | 118 ++++++++++++++++++ .../__tests__/workhub-session-port.test.ts | 73 +++++++++++ .../__tests__/workhub-surface-flow.test.ts | 48 +++++++ .../src/renderer/workhub-controller.ts | 99 ++++++++++++++- .../src/renderer/workhub-coordination-port.ts | 3 + .../src/renderer/workhub-session-port.ts | 81 +++++++++++- apps/desktop/src/renderer/workhub-surface.tsx | 69 ++++++++-- .../workhub-coordination-session-adr.md | 13 +- .../src/__tests__/message-coordinator.test.ts | 45 +++++++ .../__tests__/root-turn-coordinator.test.ts | 58 ++++++++- .../src/server/message-coordinator.ts | 31 +++++ .../src/server/root-turn-coordinator.ts | 11 +- 13 files changed, 633 insertions(+), 22 deletions(-) diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index 938df54eb7..0b29525c03 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -19,7 +19,7 @@ import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures'; -test('WorkHub rebuilds Session conversation after navigating away and back', async ({ +test('WorkHub rebuilds delegated execution feedback after navigating away and back', async ({ window: page, }) => { const initialPrompt = '检查支付回调重复投递时的幂等性'; @@ -64,6 +64,10 @@ test('WorkHub rebuilds Session conversation after navigating away and back', asy hasText: routedPrompt, }), ).toBeVisible(); + await expect( + page.locator('.workhub-projected-turn', { hasText: routedPrompt }) + .locator('.workhub-submitted-state'), + ).toHaveText('进行中'); }); test('WorkHub defers destructive correction until linked delegation exists', async ({ diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index ff438b0540..147974f1f2 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -27,6 +27,7 @@ import { WORKHUB_ROUTING_STRATEGY_ID, type WorkHubSessionFacts, type WorkHubSessionPort, + type WorkHubCoordinationTurn, } from '../../renderer/workhub-controller.js'; const appShellUrl = [ @@ -76,6 +77,8 @@ function port(sessions: WorkHubSessionFacts[]): WorkHubSessionPort { return { list: async () => sessions, recentTurns: async () => [], + delegationFeedback: async (references) => + references.map(({ delegationId }) => ({ delegationId, state: 'accepted' })), routingEvidence: async () => [], create: async () => { throw new Error('create is not used by this read test'); @@ -90,6 +93,121 @@ function port(sessions: WorkHubSessionFacts[]): WorkHubSessionPort { }; } +function coordinationAssignmentTurn(): WorkHubCoordinationTurn { + return { + messageId: 'assignment-1', + turnId: 'action-1', + text: 'Continue payments', + state: 'completed', + assignment: { + delegationId: 'delegation-1', + targetSessionId: 'payment', + targetSessionName: 'Payments', + targetTurnId: 'payment-turn', + feedbackState: 'accepted', + }, + updatedAt: 10, + }; +} + +test('conversation acknowledges a durable assignment before projecting target execution', async () => { + const sessions = port([session('payment')]); + let onSessionChanged: (() => void) | undefined; + let feedbackState: 'completed' | 'waiting_for_user' = 'completed'; + sessions.subscribe = (handler) => { + onSessionChanged = handler; + return () => { + onSessionChanged = undefined; + }; + }; + sessions.delegationFeedback = async (references) => + references.map(({ delegationId }) => ({ delegationId, state: feedbackState })); + const assignment = coordinationAssignmentTurn(); + const snapshots: string[] = []; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([assignment]); + return { close: async () => undefined }; + }, + answer: async (input) => ({ turnId: input.turnId }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }), + act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), + }, + }); + + const handle = await controller.openConversation((turns) => { + snapshots.push(turns[0]?.assignment?.feedbackState ?? 'missing'); + }, () => undefined); + await Promise.resolve(); + + assert.deepEqual(snapshots.slice(0, 2), ['accepted', 'completed']); + + feedbackState = 'waiting_for_user'; + onSessionChanged?.(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(snapshots.at(-1), 'waiting_for_user'); + + await handle.close(); +}); + +test('conversation feedback never lets an older refresh overwrite newer target state', async () => { + const sessions = port([session('payment')]); + let onSessionChanged: (() => void) | undefined; + sessions.subscribe = (handler) => { + onSessionChanged = handler; + return () => undefined; + }; + type Feedback = Awaited>; + const pending: Array<{ + references: Parameters[0]; + resolve(feedback: Feedback): void; + }> = []; + sessions.delegationFeedback = (references) => + new Promise((resolve) => pending.push({ references, resolve })); + const snapshots: string[] = []; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async (handler) => { + handler([coordinationAssignmentTurn()]); + return { close: async () => undefined }; + }, + answer: async (input) => ({ turnId: input.turnId }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, candidates: [] }), + act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), + }, + }); + + const handle = await controller.openConversation((turns) => { + snapshots.push(turns[0]?.assignment?.feedbackState ?? 'missing'); + }, () => undefined); + assert.equal(pending.length, 1); + onSessionChanged?.(); + assert.equal(pending.length, 2); + + pending[1]!.resolve(pending[1]!.references.map(({ delegationId }) => ({ + delegationId, + state: 'completed', + }))); + await Promise.resolve(); + await Promise.resolve(); + pending[0]!.resolve(pending[0]!.references.map(({ delegationId }) => ({ + delegationId, + state: 'failed', + }))); + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(snapshots.at(-1), 'completed'); + assert.equal(snapshots.includes('failed'), false); + await handle.close(); +}); + test('read exposes existing ordinary Sessions as factual Work summaries', async () => { const controller = createWorkHubController({ sessions: port([ 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 8f3eb7d0c2..9a3cf0d67c 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -148,8 +148,11 @@ test('projects the durable Coordination transcript into the WorkHub conversation text: 'Continue payments', state: 'completed', assignment: { + delegationId: 'payments-delegation', targetSessionId: 'payments', targetSessionName: 'Payments', + targetTurnId: 'payments-turn', + feedbackState: 'accepted', }, updatedAt: 20, }]); @@ -506,6 +509,76 @@ test('desktop adapter projects Session catalog facts without owning copies', asy ]); }); +test('desktop adapter rebuilds delegation feedback from the exact authoritative Turn', async () => { + const sessions = [ + desktopSession('accepted'), + desktopSession('running', { status: 'running', runningTurnIds: ['turn-running'] }), + desktopSession('waiting', { + status: 'waiting_for_user', + runningTurnIds: ['turn-waiting'], + }), + desktopSession('completed', { + status: 'waiting_for_user', + runningTurnIds: ['later-turn'], + }), + desktopSession('failed'), + desktopSession('aborted'), + desktopSession('recovering'), + ]; + const turns = new Map>([ + ['running', [{ turnId: 'turn-running', status: 'running', statusSource: 'recorded' }]], + ['waiting', [{ turnId: 'turn-waiting', status: 'running', statusSource: 'recorded' }]], + ['completed', [{ turnId: 'turn-completed', status: 'completed', statusSource: 'recorded' }]], + ['failed', [{ turnId: 'turn-failed', status: 'failed', statusSource: 'recorded' }]], + ['aborted', [{ turnId: 'turn-aborted', status: 'aborted', statusSource: 'recorded' }]], + ]); + const adapter = createDesktopWorkHubSessionPort({ + transcripts: unusedTranscripts, + sessions: { + list: async () => sessions, + listTurns: async (sessionId) => { + if (sessionId === 'recovering') throw new Error('Host is recovering'); + return turns.get(sessionId) ?? []; + }, + create: async () => { throw new Error('not used'); }, + send: async () => { throw new Error('not used'); }, + stop: async () => {}, + subscribeChanges: () => () => {}, + }, + projectName: () => 'Maka', + newTurnId: () => 'unused', + }); + const references = [ + ['accepted', 'turn-accepted'], + ['running', 'turn-running'], + ['waiting', 'turn-waiting'], + ['completed', 'turn-completed'], + ['failed', 'turn-failed'], + ['aborted', 'turn-aborted'], + ['recovering', 'turn-recovering'], + ].map(([targetSessionId, targetTurnId]) => ({ + delegationId: `delegation-${targetSessionId}`, + targetSessionId: targetSessionId!, + targetTurnId: targetTurnId!, + })); + + const feedback = await adapter.delegationFeedback(references); + + assert.deepEqual(feedback.map(({ delegationId, state }) => ({ delegationId, state })), [ + { delegationId: 'delegation-accepted', state: 'accepted' }, + { delegationId: 'delegation-running', state: 'running' }, + { delegationId: 'delegation-waiting', state: 'waiting_for_user' }, + { delegationId: 'delegation-completed', state: 'completed' }, + { delegationId: 'delegation-failed', state: 'failed' }, + { delegationId: 'delegation-aborted', state: 'aborted' }, + { delegationId: 'delegation-recovering', state: 'recovering' }, + ]); +}); + test('desktop adapter preserves per-Host catalog coverage for ownership reconciliation', async () => { const localSessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'local' }); const adapter = createDesktopWorkHubSessionPort({ 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 b6f1eb03ec..b9dafbae7b 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -24,6 +24,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; import { WorkHubCoordinationStatus, + WorkHubCoordinationTurnView, WorkHubProjectionRefreshGate, WorkHubSurfaceRouteGate, submitAndRecordWorkHubSurfaceInput, @@ -37,6 +38,8 @@ import { createLegacyWorkHubControllerForTests as createWorkHubController, WORKHUB_ROUTING_STRATEGY_ID, type WorkHubController, + type WorkHubCoordinationTurn, + type WorkHubDelegationExecutionState, type WorkHubSubmitInput, } from '../../renderer/workhub-controller.js'; import { WorkHubSendLease } from '../../renderer/workhub-send-lease.js'; @@ -107,6 +110,51 @@ test('Coordination lifecycle keeps a visible loading state and exposes failure r assert.match(failed, />Retry { + const states: Array<[WorkHubDelegationExecutionState, string]> = [ + ['accepted', 'Accepted'], + ['running', 'Running'], + ['waiting_for_user', 'Waiting for you'], + ['completed', 'Completed'], + ['failed', 'Failed'], + ['aborted', 'Aborted'], + ['recovering', 'Recovering'], + ]; + for (const [state, label] of states) { + const turn: WorkHubCoordinationTurn = { + messageId: 'assignment-1', + turnId: 'action-1', + text: 'Continue payments', + state: 'completed', + assignment: { + delegationId: 'delegation-1', + targetSessionId: 'payment', + targetSessionName: 'Payments', + targetTurnId: 'payment-turn', + feedbackState: state, + }, + updatedAt: 10, + }; + const markup = renderToStaticMarkup( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(WorkHubCoordinationTurnView, { + turn, + projection: { sessions: [], turns: [] }, + locale: 'en', + onOpenSession: () => undefined, + }), + }), + }), + ); + assert.match(markup, / @@ -747,6 +772,15 @@ function workHubCopy(locale: UiLocale) { delivery_failed: '输入未能送达,请重试。', }, scrollToBottom: '滚动到底部', archived: '已归档', states: { active: '活跃', running: '进行中', waiting_for_user: '等待你', blocked: '受阻', aborted: '已中止' }, + delegationStates: { + accepted: '已接收', + running: '进行中', + waiting_for_user: '等待你', + completed: '已完成', + failed: '失败', + aborted: '已中止', + recovering: '正在恢复', + }, turnStates: { running: '进行中', completed: '已完成', aborted: '已中止', failed: '失败' }, } as const; } @@ -780,6 +814,15 @@ function workHubCopy(locale: UiLocale) { delivery_failed: 'The input could not be delivered. Try again.', }, scrollToBottom: 'Scroll to bottom', archived: 'Archived', states: { active: 'Active', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Blocked', aborted: 'Aborted' }, + delegationStates: { + accepted: 'Accepted', + running: 'Running', + waiting_for_user: 'Waiting for you', + completed: 'Completed', + failed: 'Failed', + aborted: 'Aborted', + recovering: 'Recovering', + }, turnStates: { running: 'Running', completed: 'Completed', aborted: 'Aborted', failed: 'Failed' }, } as const; } diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index eade3fbfb9..cd4c98ca1f 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -131,6 +131,16 @@ so WorkHub does not own a second recovery state machine or compensation chain. The `delegation_assigned` record itself projects the visible WorkHub turn; the renderer does not append a second summary. +The first-response contract is hybrid. The atomic `delegation_assigned` record is +an immediate durable acknowledgement, so WorkHub confirms acceptance without +waiting for target execution. It then joins that link to the exact target Turn's +recorded lifecycle and the target Session's exact live-Turn membership to project +`running`, `waiting_for_user`, `completed`, `failed`, and `aborted`. If the target +authority is temporarily unreadable, WorkHub projects `recovering` rather than +inventing a terminal result. These execution states are never appended as mutable +Coordination records; Session change notifications invalidate the projection and +opening WorkHub after restart rebuilds it from the same link and target facts. + The renderer persists only a Host-scoped action id until acknowledgement. Composer draft text uses a separate storage key and lifecycle. A reload therefore preserves idempotency without freezing old text or coupling draft edits to Host authority. @@ -156,7 +166,8 @@ been committed. - Coordination Session role representation, lazy creation, durable lookup, recovery, per-Host UI resolution, persistent transcript, closed dispositions, and the Action Gate are implemented. Durable delegation linkage is encoded in - that transcript; target lifecycle projection, linked correction, and destructive + that transcript; target lifecycle projection and the hybrid first-response + contract are implemented as rebuildable reads. Linked correction and destructive replacement/Stop recovery remain later work. Reevaluate the per-Host decision if supported workflows require one WorkHub diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 48f24ad74a..42d5cb5857 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -130,6 +130,51 @@ test('consumes an atomically committed active-target admission exactly once', as assert.equal(fixture.drainRequests(), 0); }); +test('idle recovery preserves the exact root identity chosen with a durable admission', async () => { + const fixture = createFixture(); + fixture.setRootState({ kind: 'idle' }); + const content = { text: 'recover the linked WorkHub assignment' }; + await fixture.admissions.commitMessageAdmission({ + ...ROOT, + messageId: 'workhub-linked-message', + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + admittedAt: 10, + }); + + await fixture.coordinator.consumePendingAdmissions([ROOT.sessionId]); + + assert.equal(fixture.recoveredBatches.length, 1); + assert.deepEqual(fixture.recoveredBatches[0]?.rootIdentity, { + turnId: ROOT.turnId, + runId: ROOT.runId, + }); +}); + +test('idle recovery does not reuse a predecessor identity for its queued successor', async () => { + const fixture = createFixture(); + fixture.setRootState({ kind: 'idle' }); + const content = { text: 'recover the queued successor' }; + await fixture.admissions.commitMessageAdmission({ + ...ROOT, + messageId: 'queued-successor-message', + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'next_turn', + placement: 'next_turn', + disposition: 'followup', + admittedAt: 10, + }); + + await fixture.coordinator.consumePendingAdmissions([ROOT.sessionId]); + + assert.equal(fixture.recoveredBatches.length, 1); + assert.equal(fixture.recoveredBatches[0]?.rootIdentity, undefined); +}); + test('idle submit starts exactly one root Turn and retry identity is connection-independent', async () => { const fixture = createFixture(); fixture.setRootState({ kind: 'idle' }); diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 08eb929d6c..65abd93b96 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -55,7 +55,7 @@ import type { BackendCompactHistoryInput, BackendSendInput, } from '@maka/core/backend-types'; -import type { SessionEvent } from '@maka/core/events'; +import { messageContentDigest, type SessionEvent } from '@maka/core/events'; import { WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_SESSION_ROLE, @@ -179,6 +179,62 @@ test('turn.start rejects the reserved WorkHub Coordination Session identity', as } }); +test('recovered Messages retain their durably assigned root identity', async () => { + const fixture = await createFailureFixture({ + registerBackend: (backends) => + backends.register('ai-sdk', (context) => new FakeBackend(context)), + }); + const turnId = 'workhub-linked-turn'; + const runId = 'workhub-linked-run'; + const messageId = 'workhub-linked-message'; + const content = { text: 'continue the linked assignment' }; + const source = { + messageId, + content, + submittedContentDigest: messageContentDigest(content), + placement: 'current_turn' as const, + disposition: 'steering' as const, + }; + try { + await fixture.stores.sessionStore.commitMessageAdmission({ + sessionId: fixture.sessionId, + turnId, + runId, + messageId, + content, + submittedContentDigest: source.submittedContentDigest, + submittedPlacement: 'current_turn', + placement: source.placement, + disposition: source.disposition, + admittedAt: 1, + }); + + const outcome = await fixture.sessionAdmission.run(fixture.sessionId, (lease) => + fixture.coordinator.startRecoveredMessages( + { + sessionId: fixture.sessionId, + content, + submittedContent: content, + sources: [source], + rootIdentity: { turnId, runId }, + }, + lease, + ), + ); + + assert.deepEqual(outcome, { turnId }); + const admission = await fixture.stores.agentRunStore.readRootTurnAdmission( + fixture.sessionId, + turnId, + ); + assert.equal(admission?.runId, runId); + } finally { + await fixture.coordinator.close(); + await fixture.messages.close(); + await fixture.dispose(); + } +}); + test('turn.start rejects a corrupt Coordination role on an ordinary identity', async () => { const fixture = await createFailureFixture({ registerBackend: (backends) => diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 1db629328d..f452ca0035 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -128,6 +128,17 @@ export interface HostMessageRecoveryBatch { readonly content: MessageContent; readonly submittedContent: MessageContent; readonly sources: readonly RootTurnSourceMessage[]; + /** + * A root identity durably chosen with the pending Messages. Recovery must + * preserve it so immutable links to the exact Turn keep naming the execution + * that is actually admitted. A batch only carries one when every pending + * current-Turn steering Message names the same root; next-Turn follow-ups + * name their predecessor and must receive a new successor identity. + */ + readonly rootIdentity?: { + readonly turnId: string; + readonly runId: string; + }; /** * What the recovered Message asked of its Turn. Only a lone Message can * carry one — exact-Turn intent needs an idle Session and opens its own root @@ -711,12 +722,14 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { 'Message recovery authority is unavailable', ); } + const rootIdentity = sharedPendingRootIdentity(pending); const started = await this.#root.startRecoveredMessages( { sessionId, content: aggregateMessageContents(pending.map((entry) => entry.content)), submittedContent: aggregateMessageContents(pending.map((entry) => entry.content)), sources: pending.map(pendingMessageSource), + ...(rootIdentity ? { rootIdentity } : {}), ...(pending.length === 1 && pending[0]!.submittedIntent ? { submittedIntent: pending[0]!.submittedIntent } : {}), @@ -2217,6 +2230,24 @@ function pendingMessageSource(admission: PendingMessageAdmission): RootTurnSourc }; } +function sharedPendingRootIdentity( + admissions: readonly PendingMessageAdmission[], +): HostMessageRecoveryBatch['rootIdentity'] { + const first = admissions[0]; + if (!first || first.placement !== 'current_turn' || first.disposition !== 'steering') { + return undefined; + } + return admissions.every( + (admission) => + admission.placement === 'current_turn' && + admission.disposition === 'steering' && + admission.turnId === first.turnId && + admission.runId === first.runId, + ) + ? { turnId: first.turnId, runId: first.runId } + : undefined; +} + function submittedProjectionContent(content: MessageContent): MessageContent { const normalized = normalizeMessageContent(content); const text = normalized.displayText ?? normalized.text; diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index d631a5d990..90cca17a08 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1170,7 +1170,14 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const reservation = this.reserveRootTurn(input.sessionId); if (!reservation) return { error: 'Another root Turn is being admitted' }; try { - const turnId = randomUUID(); + // A steering admission can preassign a future root while the Session + // is idle. An identity already in the owned chain instead names the + // predecessor that accepted the Message and must not become its own + // successor. + const latestAdmission = this.rootAdmissionOwner.latestAdmission(input.sessionId); + const rootIdentity = + input.rootIdentity?.turnId === latestAdmission?.turnId ? undefined : input.rootIdentity; + const turnId = rootIdentity?.turnId ?? randomUUID(); // The recovered Message asked for this mode before the Host stopped; // admitting without it would run a different Turn than was requested. const turnOrchestration = input.submittedIntent?.turnOrchestration; @@ -1178,7 +1185,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const admitted = await this.rootAdmissionOwner.admitRootTurn({ sessionId: input.sessionId, turnId, - proposedRunId: randomUUID(), + proposedRunId: rootIdentity?.runId ?? randomUUID(), proposedUserMessageId: input.sources.length === 1 ? input.sources[0]!.messageId : null, execution: { kind: 'external_message', From 4fc134b22b6ced31641063a9a15db5c096e5346b Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 29 Aug 2026 11:13:40 +0800 Subject: [PATCH 2/4] fix(workhub): follow delegated message ownership Generated-by: Codex --- ...me-host-session-execution-ipc-main.test.ts | 32 +++++ .../main/__tests__/workhub-controller.test.ts | 1 + .../workhub-coordination-host-scope.test.ts | 1 + .../__tests__/workhub-session-port.test.ts | 90 ++++++++++++- .../__tests__/workhub-surface-flow.test.ts | 2 + apps/desktop/src/main/runtime-host-client.ts | 6 + ...runtime-host-session-execution-ipc-main.ts | 9 ++ apps/desktop/src/preload/bridge-contract.d.ts | 4 + apps/desktop/src/preload/preload.ts | 3 + .../src/renderer/workhub-controller.ts | 4 + .../workhub-coordination-host-scope.ts | 4 + .../src/renderer/workhub-coordination-port.ts | 1 + .../src/renderer/workhub-session-port.ts | 64 ++++++++-- .../workhub-coordination-session-adr.md | 20 +-- .../src/__tests__/message-coordinator.test.ts | 119 ++++++++++++++++-- .../src/__tests__/protocol.test.ts | 38 ++++++ packages/runtime-host/src/protocol/index.ts | 4 +- packages/runtime-host/src/protocol/message.ts | 68 ++++++++++ .../runtime-host/src/protocol/operations.ts | 1 + .../src/server/message-coordinator.ts | 60 +++++++++ .../src/server/operation-dispatcher.ts | 1 + 21 files changed, 502 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index b95338d162..e6ebbbce39 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -630,6 +630,37 @@ test('returns Host-owned cancellation proof to the renderer', async () => { ); }); +test('returns Host-owned Message execution proof to the renderer', async () => { + const ipc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + queryMessageExecutions: async (input) => ({ + resolutions: input.messageIds.map((messageId) => ({ + messageId, + state: 'owned' as const, + turnId: 'successor-turn', + runId: 'successor-run', + })), + }), + }), + }, + ipc, + ); + + assert.deepEqual( + await ipc.invoke('sessions:queryMessageExecutions', 'session-1', ['message-delegated']), + { + resolutions: [{ + messageId: 'message-delegated', + state: 'owned', + turnId: 'successor-turn', + runId: 'successor-run', + }], + }, + ); +}); + test('submits a slash Skill message and reports the Host Skill outcome', async () => { const submits: unknown[] = []; const ipc = ipcHarness(); @@ -1502,6 +1533,7 @@ function executionClient(overrides: Partial): ExecutionClient { interruptTurn: unavailable, listSessionTurnLandmarks: unavailable, listSessionTurns: unavailable, + queryMessageExecutions: unavailable, queryMessages: unavailable, queryTurnResume: unavailable, readExecutionBoundary: unavailable, diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 147974f1f2..943516eaa0 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -103,6 +103,7 @@ function coordinationAssignmentTurn(): WorkHubCoordinationTurn { delegationId: 'delegation-1', targetSessionId: 'payment', targetSessionName: 'Payments', + targetMessageId: 'payment-message', targetTurnId: 'payment-turn', feedbackState: 'accepted', }, diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts index 0a4e014fe2..5b3e18ac37 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts @@ -44,6 +44,7 @@ test('WorkHub candidates follow the resolved Coordination Session Host only', as completeHostIds: ['host-a', 'host-b'], }), listTurns: async () => [], + queryMessageExecutions: async () => ({ resolutions: [] }), create: async () => { throw new Error('unscoped create must not be used'); }, 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 9a3cf0d67c..e22c14f045 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -56,6 +56,13 @@ const unusedTranscripts = { }, }; +const noMessageExecutions = async () => ({ + resolutions: [] as Array< + | { messageId: string; state: 'pending' } + | { messageId: string; state: 'owned'; turnId: string; runId: string } + >, +}); + function transcriptsWith(messages: readonly StoredMessage[]) { return { open: async (sessionId: string, handler: (batch: DesktopTranscriptBatch) => void) => { @@ -151,6 +158,7 @@ test('projects the durable Coordination transcript into the WorkHub conversation delegationId: 'payments-delegation', targetSessionId: 'payments', targetSessionName: 'Payments', + targetMessageId: 'payments-message', targetTurnId: 'payments-turn', feedbackState: 'accepted', }, @@ -294,6 +302,7 @@ test('desktop adapter rebuilds recent turns from the Session transcript and clos sessions: { list: async () => [], listTurns: async () => [], + queryMessageExecutions: noMessageExecutions, create: async () => { throw new Error('not used'); }, @@ -369,6 +378,7 @@ test('desktop adapter cancels an unavailable transcript without hiding ready Ses sessions: { list: async () => [], listTurns: async () => [], + queryMessageExecutions: noMessageExecutions, create: async () => { throw new Error('not used'); }, send: async () => { throw new Error('not used'); }, stop: async () => {}, @@ -451,6 +461,7 @@ test('desktop adapter projects Session catalog facts without owning copies', asy sessions: { list: async () => source, listTurns: async () => [], + queryMessageExecutions: noMessageExecutions, create: async () => { throw new Error('not used'); }, @@ -509,7 +520,7 @@ test('desktop adapter projects Session catalog facts without owning copies', asy ]); }); -test('desktop adapter rebuilds delegation feedback from the exact authoritative Turn', async () => { +test('desktop adapter rebuilds delegation feedback from the Message-owned execution Turn', async () => { const sessions = [ desktopSession('accepted'), desktopSession('running', { status: 'running', runningTurnIds: ['turn-running'] }), @@ -544,6 +555,18 @@ test('desktop adapter rebuilds delegation feedback from the exact authoritative if (sessionId === 'recovering') throw new Error('Host is recovering'); return turns.get(sessionId) ?? []; }, + queryMessageExecutions: async (sessionId, messageIds) => ({ + resolutions: sessionId === 'accepted' + ? messageIds.map((messageId) => ({ messageId, state: 'pending' as const })) + : sessionId === 'recovering' + ? [] + : messageIds.map((messageId) => ({ + messageId, + state: 'owned' as const, + turnId: `turn-${sessionId}`, + runId: `run-${sessionId}`, + })), + }), create: async () => { throw new Error('not used'); }, send: async () => { throw new Error('not used'); }, stop: async () => {}, @@ -563,6 +586,7 @@ test('desktop adapter rebuilds delegation feedback from the exact authoritative ].map(([targetSessionId, targetTurnId]) => ({ delegationId: `delegation-${targetSessionId}`, targetSessionId: targetSessionId!, + targetMessageId: `message-${targetSessionId}`, targetTurnId: targetTurnId!, })); @@ -579,6 +603,63 @@ test('desktop adapter rebuilds delegation feedback from the exact authoritative ]); }); +test('desktop adapter follows a delegated Message into its successor Turn', async () => { + const targetSessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'payments' }); + const adapter = createDesktopWorkHubSessionPort({ + transcripts: transcriptsWith([{ + type: 'user', + id: 'payment-message', + turnId: 'successor-turn', + ts: 2, + text: 'Continue payment recovery', + steeringEventId: 'payment-message', + }]), + sessions: { + list: async () => [desktopSession(targetSessionId, { + status: 'running', + runningTurnIds: ['successor-turn'], + })], + listTurns: async () => [ + { + turnId: 'admission-turn', + status: 'completed', + statusSource: 'recorded', + }, + { + turnId: 'successor-turn', + status: 'running', + statusSource: 'recorded', + }, + ], + queryMessageExecutions: async (_sessionId, messageIds) => ({ + resolutions: messageIds.map((messageId) => ({ + messageId, + state: 'owned' as const, + turnId: 'successor-turn', + runId: 'successor-run', + })), + }), + create: async () => { throw new Error('not used'); }, + send: async () => { throw new Error('not used'); }, + stop: async () => {}, + subscribeChanges: () => () => {}, + }, + projectName: () => 'Maka', + newTurnId: () => 'unused', + }); + + const references = [{ + delegationId: 'payment-delegation', + targetSessionId, + targetTurnId: 'admission-turn', + targetMessageId: 'payment-message', + }]; + assert.deepEqual(await adapter.delegationFeedback(references), [{ + delegationId: 'payment-delegation', + state: 'running', + }]); +}); + test('desktop adapter preserves per-Host catalog coverage for ownership reconciliation', async () => { const localSessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'local' }); const adapter = createDesktopWorkHubSessionPort({ @@ -590,6 +671,7 @@ test('desktop adapter preserves per-Host catalog coverage for ownership reconcil completeHostIds: ['local-host'], }), listTurns: async () => [], + queryMessageExecutions: noMessageExecutions, create: async () => { throw new Error('not used'); }, send: async () => { throw new Error('not used'); }, stop: async () => {}, @@ -619,6 +701,7 @@ test('desktop adapter delegates create, send, and invalidation to Session APIs', runningTurnIds: ['turn-new'], })], listTurns: async () => [], + queryMessageExecutions: noMessageExecutions, create: async (input) => { calls.push(['create', input]); return desktopSession('created', { name: input.name }); @@ -667,6 +750,7 @@ test('desktop adapter preserves when Session delivery steered an existing root T sessions: { list: async () => [], listTurns: async () => [], + queryMessageExecutions: noMessageExecutions, create: async () => { throw new Error('not used'); }, @@ -699,6 +783,7 @@ test('desktop adapter distinguishes definite rejection from an unknown delivery sessions: { list: async () => [], listTurns: async () => [], + queryMessageExecutions: noMessageExecutions, create: async () => { throw new Error('not used'); }, send: async () => { if (outcome === 'throw') throw new Error('transport disconnected'); @@ -792,6 +877,7 @@ test('desktop adapter reconciles lost replies from authoritative transcript iden sessions: { list: async () => [], listTurns: async () => [], + queryMessageExecutions: noMessageExecutions, create: async () => { throw new Error('not used'); }, send: async () => { throw new Error('not used'); }, stop: async () => {}, @@ -818,6 +904,7 @@ test('desktop adapter binds stop to the root Turn owned by the WorkHub submissio sessions: { list: async () => [], listTurns: async () => [], + queryMessageExecutions: noMessageExecutions, create: async () => { throw new Error('not used'); }, @@ -855,6 +942,7 @@ test('desktop adapter derives stable origin evidence from the existing Session l { userPromptPreview: '把风险按高、中、低分组' }, ]; }, + queryMessageExecutions: noMessageExecutions, create: async () => { throw new Error('not used'); }, 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 b9dafbae7b..a254f12be4 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -130,6 +130,7 @@ test('durable delegation renders every projected target state as a navigable res delegationId: 'delegation-1', targetSessionId: 'payment', targetSessionName: 'Payments', + targetMessageId: 'payment-message', targetTurnId: 'payment-turn', feedbackState: state, }, @@ -334,6 +335,7 @@ test('real Session projection creates new guide topics and preserves origin ambi list: async () => sessions, listTurns: async (sessionId) => (prompts.get(sessionId) ?? []).map((userPromptPreview) => ({ userPromptPreview })), + queryMessageExecutions: async () => ({ resolutions: [] }), create: async ({ name }) => { const id = name.includes('支付回调') ? 'payment' : 'layout'; const session: WorkHubDesktopSession = { diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 81e9775ec0..22aaf6b55a 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1084,6 +1084,12 @@ export class DesktopRuntimeHostClient { return this.request('turn.message.query', input); } + queryMessageExecutions( + input: OperationInput<'turn.message.execution.query'>, + ): Promise> { + return this.request('turn.message.execution.query', input); + } + retractQueueEntry( input: Omit, ): Promise { diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index d281173ac0..51e656253e 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -95,6 +95,7 @@ type RuntimeHostSessionExecutionClient = Pick< | "interruptTurn" | 'listSessionTurns' | 'listSessionTurnLandmarks' + | 'queryMessageExecutions' | 'queryMessages' | "queryTurnResume" | "readExecutionBoundary" @@ -211,6 +212,14 @@ export function registerRuntimeHostSessionExecutionIpc( }, ); + ipcMain.handle( + 'sessions:queryMessageExecutions', + async (_event, sessionId: string, messageIds: unknown) => { + if (!Array.isArray(messageIds)) throw new Error('Invalid Message identities'); + return deps.client.queryMessageExecutions({ sessionId, messageIds }); + }, + ); + handleReconnectableRead( ipcMain, "sessions:observe", diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 92b170ecc2..94dcc8db3b 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1018,6 +1018,10 @@ export interface MakaBridge { sessionId: string, messageIds: readonly string[], ): Promise; + queryMessageExecutions( + sessionId: string, + messageIds: readonly string[], + ): Promise; retractQueueEntry(sessionId: string, entryId: string): Promise; promoteQueueEntry(sessionId: string, entryId: string): Promise; updateQueueEntry( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f7b98cefc0..0ad27fe096 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1790,6 +1790,9 @@ const makaBridge = { queryCancelledMessages(sessionId, messageIds) { return invokeSessionRuntimeHost('sessions:queryCancelledMessages', sessionId, messageIds); }, + queryMessageExecutions(sessionId, messageIds) { + return invokeSessionRuntimeHost('sessions:queryMessageExecutions', sessionId, messageIds); + }, retractQueueEntry(sessionId: string, entryId: string): Promise { return invokeSessionRuntimeHost('sessions:retractQueueEntry', sessionId, entryId); }, diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index ec500f38ed..8aba296633 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -74,6 +74,8 @@ export type WorkHubDelegationExecutionState = export interface WorkHubDelegationReference { readonly delegationId: string; readonly targetSessionId: string; + /** Stable delegated work identity; targetTurnId is only its admission location. */ + readonly targetMessageId: string; readonly targetTurnId: string; } @@ -102,6 +104,7 @@ export interface WorkHubCoordinationTurn { readonly delegationId: string; readonly targetSessionId: string; readonly targetSessionName: string; + readonly targetMessageId: string; readonly targetTurnId: string; readonly feedbackState: WorkHubDelegationExecutionState; }; @@ -648,6 +651,7 @@ function createWorkHubControllerImplementation(deps: { ? [{ delegationId: turn.assignment.delegationId, targetSessionId: turn.assignment.targetSessionId, + targetMessageId: turn.assignment.targetMessageId, targetTurnId: turn.assignment.targetTurnId, }] : [], diff --git a/apps/desktop/src/renderer/workhub-coordination-host-scope.ts b/apps/desktop/src/renderer/workhub-coordination-host-scope.ts index 55ebdb1c9e..9c89f537a6 100644 --- a/apps/desktop/src/renderer/workhub-coordination-host-scope.ts +++ b/apps/desktop/src/renderer/workhub-coordination-host-scope.ts @@ -74,6 +74,10 @@ export function scopeWorkHubSessionsToCoordinationHost( requireTargetHost(sessionId); return await sessions.listTurns(sessionId); }, + async queryMessageExecutions(sessionId: string, messageIds: readonly string[]) { + requireTargetHost(sessionId); + return await sessions.queryMessageExecutions(sessionId, messageIds); + }, async create(input: { name: string }) { requireHost(); return await createOnCoordinationHost(coordinationSessionId!, input); diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 341653dfd4..97f15864d1 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -131,6 +131,7 @@ export function projectWorkHubCoordinationTurns( delegationId: message.delegationId, targetSessionId: message.targetSessionId, targetSessionName: message.targetSessionName, + targetMessageId: message.targetMessageId, targetTurnId: message.targetTurnId, feedbackState: 'accepted', }, diff --git a/apps/desktop/src/renderer/workhub-session-port.ts b/apps/desktop/src/renderer/workhub-session-port.ts index e6efa3c1ad..9d647d476f 100644 --- a/apps/desktop/src/renderer/workhub-session-port.ts +++ b/apps/desktop/src/renderer/workhub-session-port.ts @@ -66,6 +66,15 @@ export interface WorkHubDesktopSessionBridge { listTurns( sessionId: string, ): Promise>[]>; + queryMessageExecutions( + sessionId: string, + messageIds: readonly string[], + ): Promise<{ + readonly resolutions: readonly ( + | { messageId: string; state: 'pending' } + | { messageId: string; state: 'owned'; turnId: string; runId: string } + )[]; + }>; create(input: { name: string }): Promise; send( sessionId: string, @@ -206,19 +215,44 @@ export function createDesktopWorkHubSessionPort(deps: { } catch { turnReadFailed = true; } + let executionReadFailed = false; + let resolutions: readonly ( + | { messageId: string; state: 'pending' } + | { messageId: string; state: 'owned'; turnId: string; runId: string } + )[] = []; + try { + const result = await deps.sessions.queryMessageExecutions( + sessionId, + grouped.map(({ targetMessageId }) => targetMessageId), + ); + resolutions = result.resolutions; + } catch { + executionReadFailed = true; + } const turnById = new Map( turns.flatMap((turn) => turn.turnId ? [[turn.turnId, turn] as const] : []), ); + const resolutionByMessageId = new Map( + resolutions.map((resolution) => [resolution.messageId, resolution]), + ); const session = sessionById.get(sessionId); - return grouped.map((reference): WorkHubDelegationFeedback => ({ - delegationId: reference.delegationId, - state: projectDelegationExecutionState({ - reference, - session, - turn: turnById.get(reference.targetTurnId), - turnReadFailed, - }), - })); + return grouped.map((reference): WorkHubDelegationFeedback => { + const resolution = resolutionByMessageId.get(reference.targetMessageId); + const executionTurnId = resolution?.state === 'owned' + ? resolution.turnId + : undefined; + return { + delegationId: reference.delegationId, + state: projectDelegationExecutionState({ + resolutionState: resolution?.state, + executionTurnId, + session, + turn: executionTurnId ? turnById.get(executionTurnId) : undefined, + turnReadFailed, + executionReadFailed, + }), + }; + }); }), ); const feedbackByDelegationId = new Map( @@ -413,16 +447,22 @@ function projectState(session: WorkHubDesktopSession): WorkHubSessionState { } function projectDelegationExecutionState(input: { - reference: WorkHubDelegationReference; + resolutionState: 'pending' | 'owned' | undefined; + executionTurnId: string | undefined; session: WorkHubSessionFacts | undefined; turn: Partial> | undefined; turnReadFailed: boolean; + executionReadFailed: boolean; }): WorkHubDelegationFeedback['state'] { - const { reference, session, turn } = input; + const { executionTurnId, session, turn } = input; + if (input.executionReadFailed) return 'recovering'; + if (!input.resolutionState) return 'recovering'; + if (input.resolutionState === 'pending') return 'accepted'; + if (!executionTurnId) return 'recovering'; if (turn?.statusSource === 'recorded' && turn.status && turn.status !== 'running') { return turn.status; } - const ownsLiveTurn = session?.runningTurnIds?.includes(reference.targetTurnId) === true; + const ownsLiveTurn = session?.runningTurnIds?.includes(executionTurnId) === true; if (ownsLiveTurn && session?.state === 'waiting_for_user') return 'waiting_for_user'; if (ownsLiveTurn || (turn?.statusSource === 'recorded' && turn.status === 'running')) { return 'running'; diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index cd4c98ca1f..1f1748a599 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -98,6 +98,7 @@ transcripts, such as: delegationId coordinationTurnId targetSessionId +targetMessageId targetTurnId disposition ``` @@ -133,13 +134,18 @@ renderer does not append a second summary. The first-response contract is hybrid. The atomic `delegation_assigned` record is an immediate durable acknowledgement, so WorkHub confirms acceptance without -waiting for target execution. It then joins that link to the exact target Turn's -recorded lifecycle and the target Session's exact live-Turn membership to project -`running`, `waiting_for_user`, `completed`, `failed`, and `aborted`. If the target -authority is temporarily unreadable, WorkHub projects `recovering` rather than -inventing a terminal result. These execution states are never appended as mutable -Coordination records; Session change notifications invalidate the projection and -opening WorkHub after restart rebuilds it from the same link and target facts. +waiting for target execution. The target Message is the stable delegation +identity; `targetTurnId` records only its admission location. WorkHub asks the +target Message authority which Turn durably consumed or admitted that Message, +then joins the resolved Turn's recorded lifecycle and the target Session's exact +live-Turn membership to project `running`, `waiting_for_user`, `completed`, +`failed`, and `aborted`. This remains correct when an unconsumed steering Message +is folded into a successor Turn or recovery aggregates several pending Messages +under one new Turn. If the target authority is temporarily unreadable, WorkHub +projects `recovering` rather than inventing a terminal result. These execution +states are never appended as mutable Coordination records; Session change +notifications invalidate the projection and opening WorkHub after restart +rebuilds it from the same link and target facts. The renderer persists only a Host-scoped action id until acknowledgement. Composer draft text uses a separate storage key and lifecycle. A reload therefore preserves diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 42d5cb5857..ea9af3f341 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -175,6 +175,59 @@ test('idle recovery does not reuse a predecessor identity for its queued success assert.equal(fixture.recoveredBatches[0]?.rootIdentity, undefined); }); +test('idle recovery resolves differently preassigned Messages to their shared successor Turn', async () => { + const fixture = createFixture(); + fixture.setRootState({ kind: 'idle' }); + for (const [messageId, turnId, runId] of [ + ['workhub-message-a', 'preassigned-turn-a', 'preassigned-run-a'], + ['workhub-message-b', 'preassigned-turn-b', 'preassigned-run-b'], + ] as const) { + const content = { text: `recover ${messageId}` }; + await fixture.admissions.commitMessageAdmission({ + sessionId: ROOT.sessionId, + turnId, + runId, + messageId, + content, + submittedContentDigest: messageContentDigest(content), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + admittedAt: 10, + }); + } + + await fixture.coordinator.consumePendingAdmissions([ROOT.sessionId]); + const resolved = await fixture.coordinator.handlers['turn.message.execution.query']( + { + sessionId: ROOT.sessionId, + messageIds: ['workhub-message-a', 'workhub-message-b'], + }, + operationContext(), + ); + + assert.equal(fixture.recoveredBatches[0]?.rootIdentity, undefined); + assert.deepEqual(resolved, { + ok: true, + result: { + resolutions: [ + { + messageId: 'workhub-message-a', + state: 'owned', + turnId: 'recovered-turn', + runId: 'durable-run', + }, + { + messageId: 'workhub-message-b', + state: 'owned', + turnId: 'recovered-turn', + runId: 'durable-run', + }, + ], + }, + }); +}); + test('idle submit starts exactly one root Turn and retry identity is connection-independent', async () => { const fixture = createFixture(); fixture.setRootState({ kind: 'idle' }); @@ -247,27 +300,75 @@ test('message query reports only durable cancellation proof', async () => { await submit(fixture, 'cancelled-message', 'discard me', 'next_turn'); await submit(fixture, 'accepted-message', 'waiting', 'next_turn'); await fixture.coordinator.cancelMessages(ROOT.sessionId, ['cancelled-message']); + const result = await fixture.coordinator.handlers['turn.message.query']( + { + sessionId: ROOT.sessionId, + messageIds: ['cancelled-message', 'accepted-message', 'unknown-message'], + }, + operationContext(), + ); + + assert.deepEqual(result, { + ok: true, + result: { cancelledMessageIds: ['cancelled-message'] }, + }); +}); + +test('message execution query reports the Turn that durably owns each Message', async () => { + const fixture = createFixture(); + const pendingContent = { text: 'not handed off yet' }; + await fixture.admissions.commitMessageAdmission({ + ...ROOT, + messageId: 'pending-message', + content: pendingContent, + submittedContentDigest: messageContentDigest(pendingContent), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + admittedAt: 10, + }); fixture.receipts.set( 'handed-off-message', - sourceReceipt('handed-off-message', 'delivered', 'current_turn', 'steering'), + sourceReceipt( + 'handed-off-message', + 'delivered by successor', + 'current_turn', + 'steering', + 'successor-turn', + ), ); + fixture.events.push(steeringEvent('steered-message', 'consumed by admission Turn')); - const result = await fixture.coordinator.handlers['turn.message.query']( + const result = await fixture.coordinator.handlers['turn.message.execution.query']( { sessionId: ROOT.sessionId, - messageIds: [ - 'cancelled-message', - 'accepted-message', - 'handed-off-message', - 'unknown-message', - ], + messageIds: ['pending-message', 'handed-off-message', 'steered-message', 'unknown-message'], }, operationContext(), ); assert.deepEqual(result, { ok: true, - result: { cancelledMessageIds: ['cancelled-message'] }, + result: { + resolutions: [ + { + messageId: 'pending-message', + state: 'pending', + }, + { + messageId: 'handed-off-message', + state: 'owned', + turnId: 'successor-turn', + runId: 'durable-run', + }, + { + messageId: 'steered-message', + state: 'owned', + turnId: ROOT.turnId, + runId: ROOT.runId, + }, + ], + }, }); }); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 8c4b8e3f2b..c401ca96db 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -333,6 +333,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 50); }); + test('publishes a new compatibility epoch for Message execution ownership', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 61); + }); + test('publishes a new compatibility epoch for exact Session Connection identity', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 56); }); @@ -1188,6 +1192,11 @@ describe('Runtime Host bootstrap protocol', () => { messageIds: ['message-1', 'message-2'], }, }; + const executionQuery = { + requestId: 'execution-query-request-1', + operation: 'turn.message.execution.query' as const, + input: query.input, + }; const submit = { requestId: 'submit-request-1', operation: 'turn.message.submit' as const, @@ -1216,6 +1225,35 @@ describe('Runtime Host bootstrap protocol', () => { }, }; assert.deepEqual(decodeClientFrame(query), query); + assert.deepEqual(decodeClientFrame(executionQuery), executionQuery); + const queried = { + requestId: executionQuery.requestId, + operation: executionQuery.operation, + ok: true as const, + result: { + resolutions: [ + { messageId: 'message-1', state: 'pending' as const }, + { + messageId: 'message-2', + state: 'owned' as const, + turnId: 'turn-2', + runId: 'run-2', + }, + ], + }, + }; + assert.deepEqual(decodeHostFrame(queried), queried); + assert.throws( + () => + decodeHostFrame({ + ...queried, + result: { + ...queried.result, + resolutions: [...queried.result.resolutions, ...queried.result.resolutions], + }, + }), + isInvalidFrame, + ); assert.deepEqual(decodeClientFrame(submit), submit); assert.deepEqual(decodeClientFrame(retract), retract); assert.deepEqual(decodeClientFrame(interrupt), interrupt); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 253c42fb20..85f087efef 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -94,7 +94,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 = 66 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 67 as const; +// 67: Message lifecycle queries expose durable execution ownership. Older +// peers cannot decode or provide the closed execution proof list. // 66: Peer Mesh queries expose one canonical transit selection and runtime metrics. // 65: live `tool_start` frames may carry optional `intent` / `argsPreview` // keys. Older Clients decode the event with a strict allowed-key list and tear diff --git a/packages/runtime-host/src/protocol/message.ts b/packages/runtime-host/src/protocol/message.ts index ea4df35d27..05048890db 100644 --- a/packages/runtime-host/src/protocol/message.ts +++ b/packages/runtime-host/src/protocol/message.ts @@ -123,6 +123,24 @@ export interface TurnMessageQueryResult { readonly cancelledMessageIds: readonly string[]; } +export interface TurnMessageExecutionQueryInput { + readonly sessionId: string; + readonly messageIds: readonly string[]; +} + +export interface TurnMessageExecutionQueryResult { + readonly resolutions: readonly TurnMessageExecutionResolution[]; +} + +export type TurnMessageExecutionResolution = + | { readonly messageId: string; readonly state: 'pending' } + | { + readonly messageId: string; + readonly state: 'owned'; + readonly turnId: string; + readonly runId: string; + }; + export interface QueueRetractInput { readonly originHostEpoch: string; readonly sessionId: string; @@ -202,6 +220,13 @@ export const MESSAGE_OPERATION_SPECS = { decodeInput: decodeTurnMessageQueryInput, decodeOutput: decodeTurnMessageQueryResult, }), + 'turn.message.execution.query': defineOperation({ + mode: 'query', + availability: 'ready', + errors: MESSAGE_OPERATION_ERRORS, + decodeInput: decodeTurnMessageExecutionQueryInput, + decodeOutput: decodeTurnMessageExecutionQueryResult, + }), 'turn.message.submit': defineOperation({ mode: 'command', availability: 'ready', @@ -341,6 +366,49 @@ function decodeTurnMessageQueryResult(value: unknown): TurnMessageQueryResult { return { cancelledMessageIds }; } +function decodeTurnMessageExecutionQueryInput(value: unknown): TurnMessageExecutionQueryInput { + return decodeTurnMessageQueryInput(value); +} + +function decodeTurnMessageExecutionQueryResult(value: unknown): TurnMessageExecutionQueryResult { + const record = requireExactRecord(value, 'turn.message.execution.query result', ['resolutions']); + if (!Array.isArray(record.resolutions) || record.resolutions.length > MESSAGE_QUEUE_MAX_ENTRIES) { + throw invalidProtocolFrame('Invalid turn.message.execution.query resolutions'); + } + const resolutions = record.resolutions.map((value): TurnMessageExecutionResolution => { + const resolution = requireRecord(value, 'turn.message.execution.query resolution'); + if (resolution.state === 'pending') { + assertExactKeys(resolution, 'turn.message.execution.query pending resolution', [ + 'messageId', + 'state', + ]); + return { + messageId: requireEntityId(resolution.messageId, 'messageId'), + state: 'pending', + }; + } + if (resolution.state === 'owned') { + assertExactKeys(resolution, 'turn.message.execution.query owned resolution', [ + 'messageId', + 'state', + 'turnId', + 'runId', + ]); + return { + messageId: requireEntityId(resolution.messageId, 'messageId'), + state: 'owned', + turnId: requireEntityId(resolution.turnId, 'turnId'), + runId: requireEntityId(resolution.runId, 'runId'), + }; + } + throw invalidProtocolFrame('Invalid turn.message.execution.query resolution state'); + }); + if (new Set(resolutions.map(({ messageId }) => messageId)).size !== resolutions.length) { + throw invalidProtocolFrame('Duplicate turn.message.execution.query messageId'); + } + return { resolutions }; +} + function decodeTurnMessageSubmitResult(value: unknown): TurnMessageSubmitResult { const record = requireRecord(value, 'turn.message.submit result'); if (record.disposition === 'turn_started') { diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index d9176c2657..392347d4f7 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -317,6 +317,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'subscription.open', 'task.ledger.query', 'turn.interrupt', + 'turn.message.execution.query', 'turn.message.query', 'turn.message.submit', 'turn.query', diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index f452ca0035..197924e61b 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -349,6 +349,7 @@ const HOST_EPOCH_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u; export class HostMessageCoordinator implements RuntimeMessageAuthority { readonly handlers: MessageOperationHandlerMap = { 'turn.message.query': (input) => this.queryMessages(input), + 'turn.message.execution.query': (input) => this.queryMessageExecutions(input), 'turn.message.submit': (input, context) => this.submit(input, context), 'queue.retract': (input) => this.retract(input), 'queue.entry.retract': (input) => this.retractQueuedEntry(input), @@ -422,6 +423,65 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { return success({ cancelledMessageIds }); } + async queryMessageExecutions(input: { + sessionId: string; + messageIds: readonly string[]; + }): Promise< + MessageOutcome<{ + resolutions: Array< + | { messageId: string; state: 'pending' } + | { messageId: string; state: 'owned'; turnId: string; runId: string } + >; + }> + > { + const resolutions: Array< + | { messageId: string; state: 'pending' } + | { messageId: string; state: 'owned'; turnId: string; runId: string } + > = []; + for (const messageId of input.messageIds) { + const receipt = await this.#durableProof.readRootTurnSourceMessageReceipt( + input.sessionId, + messageId, + ); + if ( + receipt?.admission.sessionId === input.sessionId && + receipt.sourceMessage.messageId === messageId + ) { + // A root source receipt is the latest durable ownership proof and + // therefore outranks the steering location from which a Message may + // have been folded into this successor. + resolutions.push({ + messageId, + state: 'owned', + turnId: receipt.admission.turnId, + runId: receipt.admission.runId, + }); + continue; + } + const steering = await this.#durableProof.readImmutableSteeringMessageProof( + input.sessionId, + messageId, + ); + if ( + steering?.event.sessionId === input.sessionId && + steering.event.refs?.providerEventId === messageId + ) { + resolutions.push({ + messageId, + state: 'owned', + turnId: steering.event.turnId, + runId: steering.event.runId, + }); + continue; + } + const pending = await this.#admissions.readMessageAdmission(input.sessionId, messageId); + if (pending?.sessionId === input.sessionId && pending.messageId === messageId) { + resolutions.push({ messageId, state: 'pending' }); + } + } + return success({ resolutions }); + } + retireSessions(sessionIds: readonly string[]): void { for (const sessionId of new Set(sessionIds)) { const state = this.#sessions.get(sessionId); diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 8208c5ad08..77af9e17a5 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -90,6 +90,7 @@ export type ConnectionEffectOperationKey = Extract< export type MessageOperationKey = Extract< OperationKey, | 'turn.message.query' + | 'turn.message.execution.query' | 'turn.message.submit' | 'queue.retract' | 'queue.entry.retract' From 59cce98b97267de2891dd03eea24ddb2a983705f Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 29 Aug 2026 16:26:03 +0800 Subject: [PATCH 3/4] fix(workhub): project cancelled delegations Generated-by: Codex --- ...me-host-session-execution-ipc-main.test.ts | 36 +++++++++++-------- .../__tests__/workhub-session-port.test.ts | 6 ++++ .../src/renderer/workhub-session-port.ts | 5 ++- .../workhub-coordination-session-adr.md | 11 +++--- .../src/__tests__/message-coordinator.test.ts | 12 +++++++ .../src/__tests__/protocol.test.ts | 3 +- packages/runtime-host/src/protocol/index.ts | 4 +-- packages/runtime-host/src/protocol/message.ts | 11 ++++++ .../src/server/message-coordinator.ts | 6 ++++ 9 files changed, 71 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index e6ebbbce39..8bdf462bed 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -630,18 +630,20 @@ test('returns Host-owned cancellation proof to the renderer', async () => { ); }); -test('returns Host-owned Message execution proof to the renderer', async () => { +test('returns Host-owned Message execution resolutions to the renderer', async () => { const ipc = ipcHarness(); registerExecutionIpc( { client: executionClient({ queryMessageExecutions: async (input) => ({ - resolutions: input.messageIds.map((messageId) => ({ - messageId, - state: 'owned' as const, - turnId: 'successor-turn', - runId: 'successor-run', - })), + resolutions: input.messageIds.map((messageId) => messageId === 'message-cancelled' + ? { messageId, state: 'cancelled' as const } + : { + messageId, + state: 'owned' as const, + turnId: 'successor-turn', + runId: 'successor-run', + }), }), }), }, @@ -649,14 +651,20 @@ test('returns Host-owned Message execution proof to the renderer', async () => { ); assert.deepEqual( - await ipc.invoke('sessions:queryMessageExecutions', 'session-1', ['message-delegated']), + await ipc.invoke('sessions:queryMessageExecutions', 'session-1', [ + 'message-delegated', + 'message-cancelled', + ]), { - resolutions: [{ - messageId: 'message-delegated', - state: 'owned', - turnId: 'successor-turn', - runId: 'successor-run', - }], + resolutions: [ + { + messageId: 'message-delegated', + state: 'owned', + turnId: 'successor-turn', + runId: 'successor-run', + }, + { messageId: 'message-cancelled', state: 'cancelled' }, + ], }, ); }); 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 e22c14f045..a92dc154c0 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -59,6 +59,7 @@ const unusedTranscripts = { const noMessageExecutions = async () => ({ resolutions: [] as Array< | { messageId: string; state: 'pending' } + | { messageId: string; state: 'cancelled' } | { messageId: string; state: 'owned'; turnId: string; runId: string } >, }); @@ -534,6 +535,7 @@ test('desktop adapter rebuilds delegation feedback from the Message-owned execut }), desktopSession('failed'), desktopSession('aborted'), + desktopSession('cancelled'), desktopSession('recovering'), ]; const turns = new Map ({ resolutions: sessionId === 'accepted' ? messageIds.map((messageId) => ({ messageId, state: 'pending' as const })) + : sessionId === 'cancelled' + ? messageIds.map((messageId) => ({ messageId, state: 'cancelled' as const })) : sessionId === 'recovering' ? [] : messageIds.map((messageId) => ({ @@ -582,6 +586,7 @@ test('desktop adapter rebuilds delegation feedback from the Message-owned execut ['completed', 'turn-completed'], ['failed', 'turn-failed'], ['aborted', 'turn-aborted'], + ['cancelled', 'turn-cancelled'], ['recovering', 'turn-recovering'], ].map(([targetSessionId, targetTurnId]) => ({ delegationId: `delegation-${targetSessionId}`, @@ -599,6 +604,7 @@ test('desktop adapter rebuilds delegation feedback from the Message-owned execut { delegationId: 'delegation-completed', state: 'completed' }, { delegationId: 'delegation-failed', state: 'failed' }, { delegationId: 'delegation-aborted', state: 'aborted' }, + { delegationId: 'delegation-cancelled', state: 'aborted' }, { delegationId: 'delegation-recovering', state: 'recovering' }, ]); }); diff --git a/apps/desktop/src/renderer/workhub-session-port.ts b/apps/desktop/src/renderer/workhub-session-port.ts index 9d647d476f..b954007e5d 100644 --- a/apps/desktop/src/renderer/workhub-session-port.ts +++ b/apps/desktop/src/renderer/workhub-session-port.ts @@ -72,6 +72,7 @@ export interface WorkHubDesktopSessionBridge { ): Promise<{ readonly resolutions: readonly ( | { messageId: string; state: 'pending' } + | { messageId: string; state: 'cancelled' } | { messageId: string; state: 'owned'; turnId: string; runId: string } )[]; }>; @@ -218,6 +219,7 @@ export function createDesktopWorkHubSessionPort(deps: { let executionReadFailed = false; let resolutions: readonly ( | { messageId: string; state: 'pending' } + | { messageId: string; state: 'cancelled' } | { messageId: string; state: 'owned'; turnId: string; runId: string } )[] = []; try { @@ -447,7 +449,7 @@ function projectState(session: WorkHubDesktopSession): WorkHubSessionState { } function projectDelegationExecutionState(input: { - resolutionState: 'pending' | 'owned' | undefined; + resolutionState: 'pending' | 'cancelled' | 'owned' | undefined; executionTurnId: string | undefined; session: WorkHubSessionFacts | undefined; turn: Partial> | undefined; @@ -457,6 +459,7 @@ function projectDelegationExecutionState(input: { const { executionTurnId, session, turn } = input; if (input.executionReadFailed) return 'recovering'; if (!input.resolutionState) return 'recovering'; + if (input.resolutionState === 'cancelled') return 'aborted'; if (input.resolutionState === 'pending') return 'accepted'; if (!executionTurnId) return 'recovering'; if (turn?.statusSource === 'recorded' && turn.status && turn.status !== 'running') { diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 1f1748a599..7b49723565 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -141,11 +141,12 @@ then joins the resolved Turn's recorded lifecycle and the target Session's exact live-Turn membership to project `running`, `waiting_for_user`, `completed`, `failed`, and `aborted`. This remains correct when an unconsumed steering Message is folded into a successor Turn or recovery aggregates several pending Messages -under one new Turn. If the target authority is temporarily unreadable, WorkHub -projects `recovering` rather than inventing a terminal result. These execution -states are never appended as mutable Coordination records; Session change -notifications invalidate the projection and opening WorkHub after restart -rebuilds it from the same link and target facts. +under one new Turn. A durable cancellation tombstone for a retracted queued +Message resolves the delegation to `aborted`. If the target authority is +temporarily unreadable, WorkHub projects `recovering` rather than inventing a +terminal result. These execution states are never appended as mutable Coordination +records; Session change notifications invalidate the projection and opening +WorkHub after restart rebuilds it from the same link and target facts. The renderer persists only a Host-scoped action id until acknowledgement. Composer draft text uses a separate storage key and lifecycle. A reload therefore preserves diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index ea9af3f341..4f31f1431f 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -889,6 +889,18 @@ test('entry retract removes one queued entry, replays its outcome, and rejects s fixture.coordinator.projection(ROOT.sessionId).steering.map((entry) => entry.messageId), ['steer-1'], ); + assert.deepEqual( + await fixture.coordinator.handlers['turn.message.execution.query']( + { sessionId: ROOT.sessionId, messageIds: ['follow-1'] }, + operationContext(), + ), + { + ok: true, + result: { + resolutions: [{ messageId: 'follow-1', state: 'cancelled' }], + }, + }, + ); const retry = await fixture.coordinator.handlers['queue.entry.retract']( { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index c401ca96db..1c5241a5e9 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1189,7 +1189,7 @@ describe('Runtime Host bootstrap protocol', () => { operation: 'turn.message.query' as const, input: { sessionId: 'session-1', - messageIds: ['message-1', 'message-2'], + messageIds: ['message-1', 'message-2', 'message-3'], }, }; const executionQuery = { @@ -1239,6 +1239,7 @@ describe('Runtime Host bootstrap protocol', () => { turnId: 'turn-2', runId: 'run-2', }, + { messageId: 'message-3', state: 'cancelled' as const }, ], }, }; diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 85f087efef..a359bcdefd 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,8 +95,8 @@ 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 = 67 as const; -// 67: Message lifecycle queries expose durable execution ownership. Older -// peers cannot decode or provide the closed execution proof list. +// 67: Message lifecycle queries expose durable execution ownership and +// cancellation. Older peers cannot decode or provide the closed proof list. // 66: Peer Mesh queries expose one canonical transit selection and runtime metrics. // 65: live `tool_start` frames may carry optional `intent` / `argsPreview` // keys. Older Clients decode the event with a strict allowed-key list and tear diff --git a/packages/runtime-host/src/protocol/message.ts b/packages/runtime-host/src/protocol/message.ts index 05048890db..c30546427c 100644 --- a/packages/runtime-host/src/protocol/message.ts +++ b/packages/runtime-host/src/protocol/message.ts @@ -134,6 +134,7 @@ export interface TurnMessageExecutionQueryResult { export type TurnMessageExecutionResolution = | { readonly messageId: string; readonly state: 'pending' } + | { readonly messageId: string; readonly state: 'cancelled' } | { readonly messageId: string; readonly state: 'owned'; @@ -387,6 +388,16 @@ function decodeTurnMessageExecutionQueryResult(value: unknown): TurnMessageExecu state: 'pending', }; } + if (resolution.state === 'cancelled') { + assertExactKeys(resolution, 'turn.message.execution.query cancelled resolution', [ + 'messageId', + 'state', + ]); + return { + messageId: requireEntityId(resolution.messageId, 'messageId'), + state: 'cancelled', + }; + } if (resolution.state === 'owned') { assertExactKeys(resolution, 'turn.message.execution.query owned resolution', [ 'messageId', diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 197924e61b..11a953a119 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -430,12 +430,14 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { MessageOutcome<{ resolutions: Array< | { messageId: string; state: 'pending' } + | { messageId: string; state: 'cancelled' } | { messageId: string; state: 'owned'; turnId: string; runId: string } >; }> > { const resolutions: Array< | { messageId: string; state: 'pending' } + | { messageId: string; state: 'cancelled' } | { messageId: string; state: 'owned'; turnId: string; runId: string } > = []; for (const messageId of input.messageIds) { @@ -474,6 +476,10 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { }); continue; } + if (await this.#admissions.hasCancelledMessageAdmission(input.sessionId, messageId)) { + resolutions.push({ messageId, state: 'cancelled' }); + continue; + } const pending = await this.#admissions.readMessageAdmission(input.sessionId, messageId); if (pending?.sessionId === input.sessionId && pending.messageId === messageId) { resolutions.push({ messageId, state: 'pending' }); From 3a1208ef036c16f66c89abe74987677ea1c35f7c Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sat, 29 Aug 2026 20:22:41 +0800 Subject: [PATCH 4/4] fix(workhub): converge execution authority Generated-by: Codex --- .../main/__tests__/workhub-controller.test.ts | 1603 ++--------------- .../workhub-coordination-host-scope.test.ts | 30 +- .../__tests__/workhub-session-port.test.ts | 292 +-- .../__tests__/workhub-surface-flow.test.ts | 112 +- apps/desktop/src/renderer/app-shell.tsx | 5 - .../src/renderer/workhub-controller.ts | 575 +----- .../workhub-coordination-host-scope.ts | 23 +- .../src/renderer/workhub-coordination-port.ts | 2 - .../src/renderer/workhub-route-policy.ts | 62 +- .../src/renderer/workhub-session-port.ts | 103 +- .../src/__tests__/message-coordinator.test.ts | 46 - .../__tests__/root-turn-coordinator.test.ts | 56 - .../src/server/message-coordinator.ts | 31 - .../src/server/root-turn-coordinator.ts | 11 +- 14 files changed, 332 insertions(+), 2619 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 943516eaa0..81e7538696 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -21,9 +21,7 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; import { - createLegacyWorkHubControllerForTests as createWorkHubController, createWorkHubController as createGatedWorkHubController, - WorkHubSessionSubmitError, WORKHUB_ROUTING_STRATEGY_ID, type WorkHubSessionFacts, type WorkHubSessionPort, @@ -72,7 +70,16 @@ function session( }; } -function port(sessions: WorkHubSessionFacts[]): WorkHubSessionPort { +interface TestSessionPort extends WorkHubSessionPort { + create(input: { name: string }): Promise; + submit( + target: { sessionId: string }, + text: string, + turnId: string, + ): Promise<{ turnId: string; steered?: true }>; +} + +function port(sessions: WorkHubSessionFacts[]): TestSessionPort { let nextTurnId = 0; return { list: async () => sessions, @@ -83,16 +90,83 @@ function port(sessions: WorkHubSessionFacts[]): WorkHubSessionPort { create: async () => { throw new Error('create is not used by this read test'); }, - reserveTurnId: () => `reserved-turn-${++nextTurnId}`, - submit: async () => { - throw new Error('submit is not used by this read test'); - }, - reconcileSubmission: async () => ({ kind: 'unknown' }), - stop: async () => {}, + submit: async (_target, _text, turnId) => ({ + turnId: turnId || `reserved-turn-${++nextTurnId}`, + }), subscribe: () => () => {}, }; } +function createWorkHubController({ sessions }: { sessions: TestSessionPort }) { + let candidateByRef = new Map(); + return createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => { + const candidates = (await sessions.list()) + .filter((entry) => entry.kind === 'ordinary' && !entry.archived) + .map((entry) => ({ + candidateRef: `candidate-${entry.target.sessionId}`, + sessionId: entry.target.sessionId, + sessionName: entry.sessionName, + workspace: { + target: { kind: 'host_path' as const, path: `/workspace/${entry.target.sessionId}` }, + hostCwd: `/workspace/${entry.target.sessionId}`, + }, + state: entry.state, + updatedAt: entry.updatedAt, + })); + const byId = new Map( + (await sessions.list()).map((entry) => [entry.target.sessionId, entry]), + ); + candidateByRef = new Map(candidates.flatMap((candidate) => { + const entry = byId.get(candidate.sessionId); + return entry ? [[candidate.candidateRef, entry] as const] : []; + })); + return { + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates, + }; + }, + act: async (input) => { + if (input.proposal.disposition === 'answer_here') { + return { + disposition: 'answer_here', + coordinationTurnId: input.actionId, + }; + } + if (input.proposal.disposition === 'clarify') { + return { + disposition: 'clarify', + coordinationTurnId: input.actionId, + }; + } + if (input.proposal.disposition === 'create_new') { + const created = await sessions.create({ name: input.proposal.title }); + const admitted = await sessions.submit(created.target, input.userText, input.actionId); + return { + disposition: 'create_new', + targetSessionId: created.target.sessionId, + targetTurnId: admitted.turnId, + ...(admitted.steered ? { steered: true as const } : {}), + }; + } + const target = candidateByRef.get(input.proposal.candidateRef); + if (!target) throw new Error('unknown test candidate'); + const admitted = await sessions.submit(target.target, input.userText, input.actionId); + return { + disposition: 'delegate_existing', + targetSessionId: target.target.sessionId, + targetTurnId: admitted.turnId, + ...(admitted.steered ? { steered: true as const } : {}), + }; + }, + }, + }); +} + function coordinationAssignmentTurn(): WorkHubCoordinationTurn { return { messageId: 'assignment-1', @@ -132,7 +206,6 @@ test('conversation acknowledges a durable assignment before projecting target ex handler([assignment]); return { close: async () => undefined }; }, - answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), @@ -177,7 +250,6 @@ test('conversation feedback never lets an older refresh overwrite newer target s handler([coordinationAssignmentTurn()]); return { close: async () => undefined }; }, - answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, candidates: [] }), act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), @@ -1112,1466 +1184,145 @@ test('English core evidence requires a distinctive word or multiple whole-word m assert.deepEqual(submitted, ['parser']); }); -test('route correction stops the wrong Session and teaches a similar request', async () => { - const submitted: string[] = []; - const stopped: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性' }), - session('payment', { sessionName: '支付稳定性' }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - sessions.stop = async (target) => { - stopped.push(target.sessionId); - }; - const controller = createWorkHubController({ sessions }); - await controller.submit({ - requestId: 'request-focus-payment', - text: '先看支付', - explicitTarget: { sessionId: 'payment' }, - }); - - const wrong = await controller.submit({ - requestId: 'request-alias', - text: '继续白鹭点,列出验收项。', - }); - assert.deepEqual(wrong.kind === 'submitted' ? wrong.target : undefined, { - sessionId: 'payment', - }); - - const corrected = await controller.submit({ - requestId: 'request-alias', - text: '继续白鹭点,列出验收项。', - explicitTarget: { sessionId: 'login' }, - correction: { from: { sessionId: 'payment' }, turnId: 'turn-2' }, - }); - assert.equal(corrected.kind, 'submitted'); - assert.equal(corrected.kind === 'submitted' ? corrected.evidence : undefined, 'route_correction'); - assert.deepEqual(corrected.kind === 'submitted' ? corrected.correctedFrom : undefined, { - sessionId: 'payment', - }); - - const learned = await controller.submit({ - requestId: 'request-alias-similar', - text: '继续白鹭点,补充失败判定。', - }); - assert.deepEqual(learned.kind === 'submitted' ? learned.target : undefined, { - sessionId: 'login', - }); - assert.equal(learned.kind === 'submitted' ? learned.evidence : undefined, 'route_correction'); - assert.deepEqual(stopped, ['payment']); - assert.deepEqual(submitted, ['payment', 'payment', 'login', 'login']); -}); - -test('route correction never stops a root Turn that WorkHub only steered into', async () => { - const stopped: string[] = []; - let submissionCount = 0; - const sessions = port([ - session('login', { sessionName: '登录稳定性' }), - session('payment', { sessionName: '支付稳定性', state: 'running' }), - ]); - sessions.submit = async () => { - submissionCount += 1; - return submissionCount === 1 - ? { turnId: 'turn-existing', steered: true } - : { turnId: 'turn-login' }; - }; - sessions.stop = async (target) => { - stopped.push(target.sessionId); - }; - const controller = createWorkHubController({ sessions }); - - const wrong = await controller.submit({ - requestId: 'request-steered', - text: '继续补充支付验收项', - explicitTarget: { sessionId: 'payment' }, - }); - assert.equal(wrong.kind === 'submitted' ? wrong.steered : undefined, true); - - const correction = { - from: { sessionId: 'payment' }, - turnId: 'turn-existing', - steered: true as const, - }; - const corrected = await controller.submit({ - requestId: 'request-steered', - text: '不是支付,应该补充登录验收项', - explicitTarget: { sessionId: 'login' }, - correction, - }); - - assert.equal(corrected.kind, 'submitted'); - assert.deepEqual(stopped, []); -}); - -test('first natural-language correction reroutes and stops the wrong WorkHub-owned Turn', async () => { - const submitted: string[] = []; - const stopped: Array<[string, string]> = []; +test('waiting Session rejects a second root request without calling submit', async () => { + let submitted = false; const sessions = port([ session('login', { - sessionName: '登录稳定性', - latestResult: '刷新令牌过期导致重复登录', - updatedAt: 20, - }), - session('payment', { - sessionName: '支付稳定性', - latestResult: '支付回调重复投递', - updatedAt: 30, - }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - await controller.read(); - await controller.submit({ - requestId: 'request-wrong-payment', - text: '继续这个工作,补充验收项', - }); - - const corrected = await controller.submit({ - requestId: 'request-natural-correction', - text: '不是这个,换成登录那个,补充刷新令牌失败判定', - }); - - assert.equal(corrected.kind, 'submitted'); - assert.deepEqual(corrected.kind === 'submitted' ? corrected.target : undefined, { - sessionId: 'login', - }); - assert.equal( - corrected.kind === 'submitted' ? corrected.evidence : undefined, - 'route_correction', - ); - assert.deepEqual(corrected.kind === 'submitted' ? corrected.correctedFrom : undefined, { - sessionId: 'payment', - }); - assert.deepEqual(stopped, [['payment', 'turn-1']]); - assert.deepEqual(submitted, ['payment', 'login']); -}); - -test('content-level replacement instructions stay inside the focused Session', async () => { - const submitted: string[] = []; - const stopped: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), - session('database', { - sessionName: '数据库迁移', - latestResult: 'Postgres schema migration', - updatedAt: 10, + sessionName: '排查令牌过期重复登录问题', + state: 'waiting_for_user', }), ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - sessions.stop = async (target) => { - stopped.push(target.sessionId); + sessions.submit = async () => { + submitted = true; + return { turnId: 'unexpected' }; }; const controller = createWorkHubController({ sessions }); - await controller.read(); - await controller.submit({ - requestId: 'request-before-content-change', - text: '继续这个工作', - }); const result = await controller.submit({ - requestId: 'request-content-change', - text: '继续这个工作,Redis 配置不对,改成 Postgres', + requestId: 'request-waiting', + text: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', }); - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'payment', + assert.deepEqual(result, { + kind: 'waiting', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'request-waiting', + text: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', + target: { sessionId: 'login' }, }); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'recent_focus'); - assert.deepEqual(stopped, []); + assert.equal(submitted, false); }); -test('steering the same WorkHub-owned root preserves ownership for a later correction', async () => { +test('submit returns to the previous focused Session', async () => { const submitted: string[] = []; - const stopped: Array<[string, string]> = []; const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), + session('login', { sessionName: '登录刷新令牌' }), + session('payment', { sessionName: '支付回调幂等性' }), ]); - let paymentSubmissions = 0; sessions.submit = async (target) => { submitted.push(target.sessionId); - if (target.sessionId === 'payment') { - paymentSubmissions += 1; - return paymentSubmissions === 1 - ? { turnId: 'turn-payment-root' } - : { turnId: 'turn-payment-steering-command', steered: true }; - } - return { turnId: 'turn-login-' + submitted.length }; - }; - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); + return { turnId: `turn-${submitted.length}` }; }; const controller = createWorkHubController({ sessions }); - await controller.read(); await controller.submit({ - requestId: 'request-owned-root', - text: '继续这个工作', - }); - await controller.submit({ - requestId: 'request-other-owned-root', - text: '先检查登录稳定性', + requestId: 'request-login', + text: '先看登录', explicitTarget: { sessionId: 'login' }, }); await controller.submit({ - requestId: 'request-steer-owned-root', - text: '继续这个工作,补充测试点', + requestId: 'request-payment', + text: '再看支付', explicitTarget: { sessionId: 'payment' }, }); - const corrected = await controller.submit({ - requestId: 'request-correct-owned-root', - text: '不是这个工作,换成登录稳定性', + const result = await controller.submit({ + requestId: 'request-previous', + text: '回到上一个工作', }); - assert.deepEqual(corrected.kind === 'submitted' ? corrected.target : undefined, { - sessionId: 'login', - }); - assert.deepEqual(stopped, [['payment', 'turn-payment-root']]); + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { sessionId: 'login' }); + assert.deepEqual(submitted, ['login', 'payment', 'login']); }); -test('a late root completion cannot overwrite newer ownership after remount', async () => { - const stopped: Array<[string, string]> = []; +test('submit lets strong foreign core evidence override a vague focus word', async () => { + const submitted: string[] = []; const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), + session('login', { + sessionName: '登录稳定性', + latestResult: '处理刷新令牌过期导致的重复登录', + }), + session('payment', { + sessionName: '支付稳定性', + latestResult: '处理支付回调重复投递', + }), ]); - let signalOlderStarted!: () => void; - const olderStarted = new Promise((resolve) => { - signalOlderStarted = resolve; - }); - let finishOlder!: (value: { turnId: string }) => void; - const olderTurn = new Promise<{ turnId: string }>((resolve) => { - finishOlder = resolve; - }); - let paymentSubmissions = 0; sessions.submit = async (target) => { - if (target.sessionId === 'payment') { - paymentSubmissions += 1; - if (paymentSubmissions === 1) { - signalOlderStarted(); - return olderTurn; - } - return { turnId: 'turn-payment-new' }; - } - return { turnId: 'turn-login' }; - }; - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); + submitted.push(target.sessionId); + return { turnId: `turn-${submitted.length}` }; }; const controller = createWorkHubController({ sessions }); - - const olderSubmission = controller.submit({ - requestId: 'request-payment-old', - text: '先继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }); - await olderStarted; - controller.resetVisitContext(); await controller.submit({ - requestId: 'request-payment-new', - text: '重新继续支付稳定性', + requestId: 'request-payment-focus', + text: '先看支付', explicitTarget: { sessionId: 'payment' }, }); - finishOlder({ turnId: 'turn-payment-old' }); - await olderSubmission; - const corrected = await controller.submit({ - requestId: 'request-correct-after-late-root', - text: '不是这个工作,换成登录稳定性', + const result = await controller.submit({ + requestId: 'request-foreign-core', + text: '继续处理刷新令牌过期', }); - assert.deepEqual(corrected.kind === 'submitted' ? corrected.target : undefined, { - sessionId: 'login', - }); - assert.deepEqual(stopped, [['payment', 'turn-payment-new']]); + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { sessionId: 'login' }); + assert.deepEqual(submitted, ['payment', 'login']); }); -test('a correction after remount stops a root whose admission is still pending', async () => { - const stopped: Array<[string, string]> = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), - ]); - let signalPaymentStarted!: () => void; - const paymentStarted = new Promise((resolve) => { - signalPaymentStarted = resolve; - }); - let finishPayment!: (value: { turnId: string }) => void; - const paymentTurn = new Promise<{ turnId: string }>((resolve) => { - finishPayment = resolve; - }); - let nextReservedTurnId = 0; - sessions.reserveTurnId = () => `turn-reserved-${++nextReservedTurnId}`; - sessions.submit = async (target, _text, turnId) => { - if (target.sessionId === 'payment') { - assert.equal(turnId, 'turn-reserved-1'); - signalPaymentStarted(); - return paymentTurn; - } - return { turnId }; - }; - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); +test('submit keeps unmatched non-executable conversation in WorkHub', async () => { + let created = false; + const actions: unknown[] = []; + const sessions = port([]); + sessions.create = async () => { + created = true; + return session('unexpected'); }; - const controller = createWorkHubController({ sessions }); - - const pendingSubmission = controller.submit({ - requestId: 'request-payment-pending', - text: '继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'answer_here', + coordinationTurnId: 'coordination-turn', + }; + }, + }, }); - await paymentStarted; - controller.resetVisitContext(); - await controller.read({ focus: { sessionId: 'payment' } }); - const corrected = await controller.submit({ - requestId: 'request-correct-pending-root', - text: '不是这个工作,换成登录稳定性', + const result = await controller.submit({ + requestId: 'request-discussion', + text: '你觉得统一入口最重要的价值是什么?', }); - assert.deepEqual(corrected.kind === 'submitted' ? corrected.target : undefined, { - sessionId: 'login', + assert.deepEqual(result, { + kind: 'discussion', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: 'request-discussion', + text: '你觉得统一入口最重要的价值是什么?', }); - assert.deepEqual(stopped, [['payment', 'turn-reserved-1']]); - finishPayment({ turnId: 'turn-payment-host-rebound' }); - await pendingSubmission; - assert.deepEqual(stopped, [ - ['payment', 'turn-reserved-1'], - ['payment', 'turn-payment-host-rebound'], - ]); -}); - -test('a correction stops an uncertain root under the Turn identity the Host minted', async () => { - const stopped: Array<[string, string]> = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), - ]); - sessions.reserveTurnId = () => 'reserved-payment'; - // The Host admitted the Message and opened a Turn under its own identity, - // then the answer was lost. Only the transcript can tie the reserved Message - // identity back to that Turn. - sessions.submit = async (target, _text, turnId) => { - if (target.sessionId !== 'payment') return { turnId }; - throw new WorkHubSessionSubmitError('delivery outcome is unknown', 'unknown'); - }; - // The transcript has not caught up at delivery time, so the candidate stays - // uncertain; the correction is the next chance to resolve it. - let transcriptCaughtUp = false; - sessions.reconcileSubmission = async (_target, reservedTurnId) => { - if (reservedTurnId !== 'reserved-payment' || !transcriptCaughtUp) { - transcriptCaughtUp = true; - return { kind: 'unknown' }; - } - return { kind: 'root', turnId: 'turn-payment-host' }; - }; - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - - await assert.rejects(controller.submit({ - requestId: 'request-payment-uncertain', - text: '继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - })); - - const corrected = await controller.submit({ - requestId: 'request-correct-uncertain-root', - text: '不是这个工作,换成登录稳定性', - }); - - assert.deepEqual(corrected.kind === 'submitted' ? corrected.target : undefined, { - sessionId: 'login', - }); - assert.deepEqual(stopped, [['payment', 'turn-payment-host']]); -}); - -test('a correction retries Stop when the same reserved root is admitted before Stop settles', async () => { - const stopped: Array<[string, string]> = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), - ]); - let signalPaymentStarted!: () => void; - const paymentStarted = new Promise((resolve) => { - signalPaymentStarted = resolve; - }); - let finishPayment!: (value: { turnId: string }) => void; - const paymentTurn = new Promise<{ turnId: string }>((resolve) => { - finishPayment = resolve; - }); - sessions.reserveTurnId = () => 'turn-reserved-1'; - sessions.submit = async (target, _text, turnId) => { - if (target.sessionId === 'payment') { - assert.equal(turnId, 'turn-reserved-1'); - signalPaymentStarted(); - return paymentTurn; - } - return { turnId }; - }; - let signalFirstStopStarted!: () => void; - const firstStopStarted = new Promise((resolve) => { - signalFirstStopStarted = resolve; - }); - let finishFirstStop!: () => void; - const firstStop = new Promise((resolve) => { - finishFirstStop = resolve; - }); - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - if (stopped.length === 1) { - signalFirstStopStarted(); - await firstStop; - } - }; - const controller = createWorkHubController({ sessions }); - - const pendingSubmission = controller.submit({ - requestId: 'request-payment-same-id-pending', - text: '继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }); - await paymentStarted; - controller.resetVisitContext(); - await controller.read({ focus: { sessionId: 'payment' } }); - - const correction = controller.submit({ - requestId: 'request-correct-same-id-pending-root', - text: '不是这个工作,换成登录稳定性', - }); - await firstStopStarted; - assert.deepEqual(stopped, [['payment', 'turn-reserved-1']]); - - finishPayment({ turnId: 'turn-reserved-1' }); - finishFirstStop(); - await Promise.all([pendingSubmission, correction]); - - assert.deepEqual(stopped, [ - ['payment', 'turn-reserved-1'], - ['payment', 'turn-reserved-1'], - ]); -}); - -test('a stopped ownership tombstone blocks an older root completion', async () => { - const stopped: Array<[string, string]> = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), - ]); - let signalStaleStarted!: () => void; - const staleStarted = new Promise((resolve) => { - signalStaleStarted = resolve; - }); - let finishStale!: (value: { turnId: string }) => void; - const staleTurn = new Promise<{ turnId: string }>((resolve) => { - finishStale = resolve; - }); - let paymentSubmissions = 0; - sessions.submit = async (target) => { - if (target.sessionId !== 'payment') return { turnId: 'turn-login' }; - paymentSubmissions += 1; - if (paymentSubmissions === 1) return { turnId: 'turn-payment-root' }; - signalStaleStarted(); - return staleTurn; - }; - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - await controller.submit({ - requestId: 'request-payment-owned', - text: '继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }); - const staleSubmission = controller.submit({ - requestId: 'request-payment-stale', - text: '再继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }); - await staleStarted; - - await controller.submit({ - requestId: 'request-stop-before-stale-finishes', - text: '不是这个工作,换成登录稳定性', - }); - finishStale({ turnId: 'turn-payment-stale' }); - await staleSubmission; - controller.resetVisitContext(); - await controller.read({ focus: { sessionId: 'payment' } }); - await controller.submit({ - requestId: 'request-correct-after-stale-finishes', - text: '不是这个工作,换成登录稳定性', - }); - - assert.deepEqual(stopped, [ - ['payment', 'turn-payment-root'], - ['payment', 'reserved-turn-2'], - ['payment', 'turn-payment-stale'], - ]); -}); - -test('tombstone retention never evicts live ownership for another Session', async () => { - const stopped: Array<[string, string]> = []; - const fillers = Array.from({ length: 32 }, (_, index) => - session(`filler-${index}`, { sessionName: `填充工作 ${index}` })); - const sessions = port([ - session('long-running', { sessionName: '长期工作', updatedAt: 100 }), - session('sink', { sessionName: '收件箱工作', updatedAt: 90 }), - ...fillers, - ]); - sessions.submit = async (target, _text, turnId) => target.sessionId === 'sink' - ? { turnId, steered: true } - : { turnId }; - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - - const live = await controller.submit({ - requestId: 'request-live-root', - text: '开始长期工作', - explicitTarget: { sessionId: 'long-running' }, - }); - assert.equal(live.kind, 'submitted'); - - for (const [index, filler] of fillers.entries()) { - const owned = await controller.submit({ - requestId: `request-filler-${index}`, - text: `开始填充工作 ${index}`, - explicitTarget: filler.target, - }); - assert.equal(owned.kind, 'submitted'); - if (owned.kind !== 'submitted') continue; - await controller.submit({ - requestId: `request-stop-filler-${index}`, - text: `填充工作 ${index} 路由错了`, - explicitTarget: { sessionId: 'sink' }, - correction: { - from: filler.target, - turnId: owned.turnId, - }, - }); - } - - stopped.length = 0; - controller.resetVisitContext(); - await controller.read({ focus: { sessionId: 'long-running' } }); - await controller.submit({ - requestId: 'request-correct-live-root', - text: '不是这个工作,换成收件箱工作', - }); - - assert.equal(stopped.length, 1); - assert.equal(stopped[0]?.[0], 'long-running'); - assert.equal(stopped[0]?.[1], live.kind === 'submitted' ? live.turnId : undefined); -}); - -test('correction barrier rejects a new root while Stop is pending', async () => { - const stopped: Array<[string, string]> = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), - ]); - let signalStopStarted!: () => void; - const stopStarted = new Promise((resolve) => { - signalStopStarted = resolve; - }); - let finishFirstStop!: () => void; - const firstStop = new Promise((resolve) => { - finishFirstStop = resolve; - }); - let stopCalls = 0; - sessions.submit = async (_target, _text, turnId) => ({ turnId }); - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - stopCalls += 1; - if (stopCalls === 1) { - signalStopStarted(); - await firstStop; - } - }; - const controller = createWorkHubController({ sessions }); - const original = await controller.submit({ - requestId: 'request-original-payment', - text: '继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }); - assert.equal(original.kind, 'submitted'); - - const correction = controller.submit({ - requestId: 'request-correct-original-payment', - text: '不是这个工作,换成登录稳定性', - }); - await stopStarted; - await assert.rejects(controller.submit({ - requestId: 'request-overlapping-payment', - text: '重新处理支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }), /still reconciling/u); - finishFirstStop(); - await correction; - - const newer = await controller.submit({ - requestId: 'request-newer-payment', - text: '重新处理支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }); - assert.equal(newer.kind, 'submitted'); - - controller.resetVisitContext(); - await controller.read({ focus: { sessionId: 'payment' } }); - await controller.submit({ - requestId: 'request-correct-newer-payment', - text: '不是这个工作,换成登录稳定性', - }); - - assert.deepEqual(stopped, [ - ['payment', original.kind === 'submitted' ? original.turnId : ''], - ['payment', newer.kind === 'submitted' ? newer.turnId : ''], - ]); -}); - -test('a partially failed correction records only successful Stops and keeps failures reachable', async () => { - const stopAttempts: Array<[string, string]> = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), - ]); - let finishPending!: (value: { turnId: string }) => void; - const pendingTurn = new Promise<{ turnId: string }>((resolve) => { - finishPending = resolve; - }); - let paymentSubmissions = 0; - sessions.reserveTurnId = (() => { - let next = 0; - return () => `reserved-${++next}`; - })(); - sessions.submit = async (target, _text, turnId) => { - if (target.sessionId !== 'payment') return { turnId }; - paymentSubmissions += 1; - return paymentSubmissions === 1 ? { turnId: 'payment-root' } : pendingTurn; - }; - let failPaymentRoot = true; - sessions.stop = async (target, turnId) => { - stopAttempts.push([target.sessionId, turnId]); - if (turnId === 'payment-root' && failPaymentRoot) { - failPaymentRoot = false; - throw new Error('Host rejected the first Stop'); - } - }; - const controller = createWorkHubController({ sessions }); - const confirmed = await controller.submit({ - requestId: 'confirmed-payment-root', - text: '继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }); - assert.equal(confirmed.kind, 'submitted'); - const pending = controller.submit({ - requestId: 'pending-payment-root', - text: '再次处理支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }); - await Promise.resolve(); - await Promise.resolve(); - - await assert.rejects(controller.submit({ - requestId: 'partially-failed-correction', - text: '不是支付,改成登录', - explicitTarget: { sessionId: 'login' }, - correction: { - from: { sessionId: 'payment' }, - turnId: 'payment-root', - }, - }), /Host rejected the first Stop/u); - - assert.equal(stopAttempts.some(([, turnId]) => turnId === 'reserved-2'), true); - finishPending({ turnId: 'payment-host-rebound' }); - await pending; - assert.equal(stopAttempts.some(([, turnId]) => turnId === 'payment-host-rebound'), true); - - await controller.submit({ - requestId: 'retry-failed-correction', - text: '不是支付,改成登录', - explicitTarget: { sessionId: 'login' }, - correction: { - from: { sessionId: 'payment' }, - turnId: 'payment-root', - }, - }); - assert.equal( - stopAttempts.filter(([, turnId]) => turnId === 'payment-root').length, - 2, - ); -}); - -test('the 33rd unresolved root is back-pressured before Host admission', async () => { - const facts = Array.from({ length: 33 }, (_, index) => - session(`work-${index}`, { runningTurnIds: [] })); - const sessions = port(facts); - const finishes = new Map void>(); - let admitted = 0; - let signalThirtyTwo!: () => void; - const thirtyTwoAdmitted = new Promise((resolve) => { - signalThirtyTwo = resolve; - }); - sessions.submit = async (target, _text, turnId) => { - admitted += 1; - if (admitted === 32) signalThirtyTwo(); - return await new Promise<{ turnId: string }>((resolve) => { - finishes.set(target.sessionId, resolve); - }); - }; - const controller = createWorkHubController({ sessions }); - const inFlight = facts.slice(0, 32).map((fact, index) => controller.submit({ - requestId: `root-${index}`, - text: `开始工作 ${index}`, - explicitTarget: fact.target, - })); - await thirtyTwoAdmitted; - - await assert.rejects(controller.submit({ - requestId: 'root-33', - text: '开始工作 33', - explicitTarget: facts[32]!.target, - }), /too many unresolved root submissions/u); - assert.equal(admitted, 32); - - finishes.get('work-0')?.({ turnId: 'settled-work-0' }); - await inFlight[0]; - const thirtyThird = controller.submit({ - requestId: 'root-33-after-capacity', - text: '开始工作 33', - explicitTarget: facts[32]!.target, - }); - await Promise.resolve(); - await Promise.resolve(); - assert.equal(admitted, 33); - finishes.get('work-32')?.({ turnId: 'settled-work-32' }); - await thirtyThird; -}); - -test('an old correction barrier survives more than 32 newer corrections', async () => { - const stopped: Array<[string, string]> = []; - const fillers = Array.from({ length: 32 }, (_, index) => - session(`barrier-filler-${index}`, { sessionName: `屏障填充 ${index}` })); - const sessions = port([ - session('old-pending', { sessionName: '旧的待定工作', updatedAt: 100 }), - session('sink', { sessionName: '安全收件箱', updatedAt: 90 }), - ...fillers, - ]); - let finishOld!: (value: { turnId: string }) => void; - const oldTurn = new Promise<{ turnId: string }>((resolve) => { - finishOld = resolve; - }); - sessions.submit = async (target, _text, turnId) => { - if (target.sessionId === 'old-pending') return oldTurn; - if (target.sessionId === 'sink') return { turnId, steered: true }; - return { turnId }; - }; - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - const oldSubmission = controller.submit({ - requestId: 'old-pending-root', - text: '开始旧的待定工作', - explicitTarget: { sessionId: 'old-pending' }, - }); - await Promise.resolve(); - await Promise.resolve(); - await controller.submit({ - requestId: 'correct-old-pending', - text: '旧工作路由错了', - explicitTarget: { sessionId: 'sink' }, - correction: { - from: { sessionId: 'old-pending' }, - turnId: 'reserved-turn-1', - }, - }); - - for (const [index, filler] of fillers.entries()) { - const owned = await controller.submit({ - requestId: `newer-barrier-root-${index}`, - text: `开始屏障填充 ${index}`, - explicitTarget: filler.target, - }); - assert.equal(owned.kind, 'submitted'); - if (owned.kind !== 'submitted') continue; - await controller.submit({ - requestId: `newer-barrier-correction-${index}`, - text: `屏障填充 ${index} 路由错了`, - explicitTarget: { sessionId: 'sink' }, - correction: { from: filler.target, turnId: owned.turnId }, - }); - } - - finishOld({ turnId: 'old-host-rebound' }); - await oldSubmission; - assert.equal( - stopped.some(([sessionId, turnId]) => - sessionId === 'old-pending' && turnId === 'old-host-rebound'), - true, - ); -}); - -test('a definite Host rejection releases only its own pending admission', async () => { - const stopped: Array<[string, string]> = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性' }), - session('payment', { sessionName: '支付稳定性' }), - ]); - let shouldReject = true; - sessions.submit = async (_target, _text, turnId) => { - if (shouldReject) { - shouldReject = false; - throw new WorkHubSessionSubmitError('not admitted', 'rejected'); - } - return { turnId }; - }; - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - - await assert.rejects(controller.submit({ - requestId: 'definitely-rejected', - text: '继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }), /not admitted/u); - const admitted = await controller.submit({ - requestId: 'later-admitted', - text: '继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }); - assert.equal(admitted.kind, 'submitted'); - await controller.submit({ - requestId: 'correct-later-admitted', - text: '不是支付,改成登录', - explicitTarget: { sessionId: 'login' }, - correction: { from: { sessionId: 'payment' } }, - }); - - assert.deepEqual(stopped, [[ - 'payment', - admitted.kind === 'submitted' ? admitted.turnId : '', - ]]); -}); - -test('a lost delivery reply is reconciled to its authoritative root ownership', async () => { - const stopped: Array<[string, string]> = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性' }), - session('payment', { sessionName: '支付稳定性' }), - ]); - sessions.submit = async () => { - throw new WorkHubSessionSubmitError('reply lost', 'unknown'); - }; - sessions.reconcileSubmission = async () => ({ - kind: 'root', - turnId: 'authoritative-payment-root', - }); - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - - await assert.rejects(controller.submit({ - requestId: 'reply-lost', - text: '继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }), /reply lost/u); - sessions.submit = async (_target, _text, turnId) => ({ turnId }); - await controller.submit({ - requestId: 'correct-reconciled-root', - text: '不是支付,改成登录', - explicitTarget: { sessionId: 'login' }, - correction: { from: { sessionId: 'payment' } }, - }); - - assert.deepEqual(stopped, [['payment', 'authoritative-payment-root']]); -}); - -test('an unknown delivery remains pending until a later authoritative reconciliation', async () => { - const stopped: Array<[string, string]> = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性' }), - session('payment', { sessionName: '支付稳定性' }), - ]); - sessions.submit = async () => { - throw new WorkHubSessionSubmitError('reply lost', 'unknown'); - }; - let reconciliation: Awaited> = { - kind: 'unknown', - }; - sessions.reconcileSubmission = async () => reconciliation; - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - - await assert.rejects(controller.submit({ - requestId: 'unknown-delivery', - text: '继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }), /reply lost/u); - reconciliation = { kind: 'root', turnId: 'later-authoritative-root' }; - await controller.read(); - sessions.submit = async (_target, _text, turnId) => ({ turnId }); - await controller.submit({ - requestId: 'correct-later-reconciled-root', - text: '不是支付,改成登录', - explicitTarget: { sessionId: 'login' }, - correction: { from: { sessionId: 'payment' } }, - }); - - assert.deepEqual(stopped, [['payment', 'later-authoritative-root']]); -}); - -test('a partial multi-Host catalog never erases confirmed ownership', async () => { - const stopped: Array<[string, string]> = []; - const allSessions = [ - session('login', { sessionName: '登录稳定性' }), - session('remote-payment', { sessionName: '远端支付稳定性' }), - ]; - const sessions = port(allSessions); - let visible = allSessions; - let paymentCatalogComplete = true; - sessions.listCatalog = async () => ({ - sessions: visible, - isCompleteFor: (target) => - target.sessionId !== 'remote-payment' || paymentCatalogComplete, - }); - sessions.submit = async (_target, _text, turnId) => ({ turnId }); - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - const owned = await controller.submit({ - requestId: 'remote-root', - text: '继续远端支付', - explicitTarget: { sessionId: 'remote-payment' }, - }); - assert.equal(owned.kind, 'submitted'); - - visible = [allSessions[0]!]; - paymentCatalogComplete = false; - await controller.submit({ - requestId: 'correct-after-partial-catalog', - text: '不是支付,改成登录', - explicitTarget: { sessionId: 'login' }, - correction: { from: { sessionId: 'remote-payment' } }, - }); - - assert.deepEqual(stopped, [[ - 'remote-payment', - owned.kind === 'submitted' ? owned.turnId : '', - ]]); -}); - -test('a stale complete catalog never erases newer confirmed ownership', async () => { - const stopped: Array<[string, string]> = []; - const allSessions = [ - session('login', { sessionName: '登录稳定性' }), - session('payment', { sessionName: '支付稳定性' }), - ]; - const sessions = port(allSessions); - let signalStaleReadStarted!: () => void; - const staleReadStarted = new Promise((resolve) => { - signalStaleReadStarted = resolve; - }); - let finishStaleRead!: (value: { - sessions: WorkHubSessionFacts[]; - isCompleteFor(target: { sessionId: string }): boolean; - }) => void; - const staleCatalog = new Promise<{ - sessions: WorkHubSessionFacts[]; - isCompleteFor(target: { sessionId: string }): boolean; - }>((resolve) => { - finishStaleRead = resolve; - }); - let catalogReads = 0; - sessions.listCatalog = async () => { - catalogReads += 1; - if (catalogReads === 1) { - signalStaleReadStarted(); - return staleCatalog; - } - return { - sessions: allSessions, - isCompleteFor: () => true, - }; - }; - sessions.submit = async (_target, _text, turnId) => ({ turnId }); - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - - const staleRead = controller.read(); - await staleReadStarted; - const owned = await controller.submit({ - requestId: 'payment-root-after-stale-read-started', - text: '继续支付稳定性', - explicitTarget: { sessionId: 'payment' }, - }); - assert.equal(owned.kind, 'submitted'); - - finishStaleRead({ - sessions: [allSessions[0]!], - isCompleteFor: () => true, - }); - await staleRead; - await controller.submit({ - requestId: 'correct-after-stale-complete-catalog', - text: '不是支付,改成登录', - explicitTarget: { sessionId: 'login' }, - correction: { from: { sessionId: 'payment' } }, - }); - - assert.deepEqual(stopped, [[ - 'payment', - owned.kind === 'submitted' ? owned.turnId : '', - ]]); -}); - -test('authoritative Session removal releases uncertain admissions before global backpressure', async () => { - const stale = Array.from({ length: 32 }, (_, index) => - session(`removed-${index}`, { sessionName: `已删除工作 ${index}` })); - const fresh = session('fresh', { sessionName: '新工作' }); - const sessions = port(stale); - let visible = stale; - let removedCatalogComplete = false; - sessions.listCatalog = async () => ({ - sessions: visible, - isCompleteFor: (target) => - target.sessionId.startsWith('removed-') && removedCatalogComplete, - }); - sessions.submit = async () => { - throw new WorkHubSessionSubmitError('reply lost', 'unknown'); - }; - sessions.reconcileSubmission = async () => ({ kind: 'unknown' }); - const controller = createWorkHubController({ sessions }); - - for (const [index, fact] of stale.entries()) { - await assert.rejects(controller.submit({ - requestId: `uncertain-${index}`, - text: `开始已删除工作 ${index}`, - explicitTarget: fact.target, - }), /reply lost/u); - } - - visible = [fresh]; - removedCatalogComplete = true; - sessions.submit = async (_target, _text, turnId) => ({ turnId }); - const admitted = await controller.submit({ - requestId: 'after-authoritative-removal', - text: '开始新工作', - explicitTarget: fresh.target, - }); - - assert.equal(admitted.kind, 'submitted'); - assert.deepEqual(admitted.kind === 'submitted' ? admitted.target : undefined, fresh.target); -}); - -test('a lost reply reconciled as steering never claims the pre-existing root', async () => { - const stopped: Array<[string, string]> = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性' }), - session('payment', { sessionName: '支付稳定性' }), - ]); - sessions.submit = async () => { - throw new WorkHubSessionSubmitError('steering reply lost', 'unknown'); - }; - sessions.reconcileSubmission = async () => ({ kind: 'steered' }); - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - - await assert.rejects(controller.submit({ - requestId: 'steering-reply-lost', - text: '补充支付测试', - explicitTarget: { sessionId: 'payment' }, - }), /steering reply lost/u); - sessions.submit = async (_target, _text, turnId) => ({ turnId }); - await controller.submit({ - requestId: 'correct-after-steering-reply-loss', - text: '不是支付,改成登录', - explicitTarget: { sessionId: 'login' }, - correction: { from: { sessionId: 'payment' } }, - }); - - assert.deepEqual(stopped, []); -}); - -test('WorkHub-owned root remains stoppable after navigating away and back', async () => { - const stopped: Array<[string, string]> = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), - ]); - sessions.submit = async (target) => ({ turnId: 'turn-' + target.sessionId }); - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - await controller.read(); - await controller.submit({ - requestId: 'request-owned-before-navigation', - text: '继续这个工作', - }); - controller.resetVisitContext(); - await controller.read({ focus: { sessionId: 'payment' } }); - - const corrected = await controller.submit({ - requestId: 'request-correction-after-return', - text: '不是这个工作,换成登录稳定性', - }); - - assert.deepEqual(corrected.kind === 'submitted' ? corrected.target : undefined, { - sessionId: 'login', - }); - assert.deepEqual(stopped, [['payment', 'turn-payment']]); -}); - -test('natural-language correction never stops a pre-existing focused Session', async () => { - const stopped: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', state: 'running', updatedAt: 30 }), - ]); - sessions.submit = async (target) => ({ turnId: `turn-${target.sessionId}` }); - sessions.stop = async (target) => { - stopped.push(target.sessionId); - }; - const controller = createWorkHubController({ sessions }); - await controller.read(); - - const corrected = await controller.submit({ - requestId: 'request-safe-natural-correction', - text: '不是这个,用登录那个', - }); - - assert.deepEqual(corrected.kind === 'submitted' ? corrected.target : undefined, { - sessionId: 'login', - }); - assert.deepEqual(corrected.kind === 'submitted' ? corrected.correctedFrom : undefined, { - sessionId: 'payment', - }); - assert.deepEqual(stopped, []); -}); - -test('English natural-language correction names the replacement Session', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { sessionName: 'Login Reliability', updatedAt: 20 }), - session('payment', { sessionName: 'Payment Webhooks', updatedAt: 30 }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - const controller = createWorkHubController({ sessions }); - await controller.read(); - - const corrected = await controller.submit({ - requestId: 'request-english-natural-correction', - text: 'Not that work; switch to Login Reliability and add the retry checks', - }); - - assert.deepEqual(corrected.kind === 'submitted' ? corrected.target : undefined, { - sessionId: 'login', - }); - assert.equal( - corrected.kind === 'submitted' ? corrected.evidence : undefined, - 'route_correction', - ); -}); - -test('ambiguous natural-language correction preserves correction context through clarification', async () => { - const submitted: string[] = []; - const stopped: Array<[string, string]> = []; - const sessions = port([ - session('login-api', { sessionName: '登录 API 稳定性', updatedAt: 20 }), - session('login-ui', { sessionName: '登录 UI 稳定性', updatedAt: 10 }), - session('payment', { sessionName: '支付回调幂等性', updatedAt: 30 }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - sessions.stop = async (target, turnId) => { - stopped.push([target.sessionId, turnId]); - }; - const controller = createWorkHubController({ sessions }); - await controller.read(); - await controller.submit({ - requestId: 'request-payment-before-clarification', - text: '继续这个工作', - }); - - const clarification = await controller.submit({ - requestId: 'request-natural-clarification', - text: '不是这个,换成登录那个', - }); - assert.equal(clarification.kind, 'clarification'); - if (clarification.kind !== 'clarification') return; - assert.deepEqual( - clarification.options.map((option) => option.target.sessionId), - ['login-api', 'login-ui'], - ); - assert.deepEqual(clarification.correction, { - from: { sessionId: 'payment' }, - turnId: 'turn-1', - }); - - const corrected = await controller.submit({ - requestId: clarification.requestId, - text: clarification.text, - explicitTarget: { sessionId: 'login-api' }, - correction: clarification.correction, - }); - assert.equal(corrected.kind, 'submitted'); - assert.deepEqual(stopped, [['payment', 'turn-1']]); - assert.deepEqual(submitted, ['payment', 'login-api']); -}); - -test('latest route correction wins for the same expression family', async () => { - const sessions = port([ - session('login', { sessionName: '登录稳定性' }), - session('payment', { sessionName: '支付稳定性' }), - ]); - sessions.submit = async (_target) => ({ turnId: 'turn' }); - const controller = createWorkHubController({ sessions }); - - await controller.submit({ - requestId: 'correction-login', - text: '继续白鹭点,列出验收项。', - explicitTarget: { sessionId: 'login' }, - correction: { from: { sessionId: 'payment' }, turnId: 'turn' }, - }); - await controller.submit({ - requestId: 'correction-payment', - text: '继续白鹭点,列出异常项。', - explicitTarget: { sessionId: 'payment' }, - correction: { from: { sessionId: 'login' }, turnId: 'turn' }, - }); - - const result = await controller.submit({ - requestId: 'correction-latest', - text: '继续白鹭点,补充回滚条件。', - }); - - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'payment', - }); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'route_correction'); -}); - -test('user correction order wins when overlapping submissions finish out of order', async () => { - const sessions = port([ - session('login', { sessionName: '登录稳定性' }), - session('payment', { sessionName: '支付稳定性' }), - ]); - let signalOlderStarted!: () => void; - const olderStarted = new Promise((resolve) => { - signalOlderStarted = resolve; - }); - let finishOlder!: (value: { turnId: string }) => void; - const olderTurn = new Promise<{ turnId: string }>((resolve) => { - finishOlder = resolve; - }); - sessions.submit = async (target) => { - if (target.sessionId === 'login') { - signalOlderStarted(); - return olderTurn; - } - return { turnId: 'turn-payment' }; - }; - const controller = createWorkHubController({ sessions }); - - const olderCorrection = controller.submit({ - requestId: 'correction-older-login', - text: '继续白鹭点,列出验收项。', - explicitTarget: { sessionId: 'login' }, - correction: { from: { sessionId: 'payment' } }, - }); - await olderStarted; - controller.resetVisitContext(); - await controller.submit({ - requestId: 'correction-newer-payment', - text: '继续白鹭点,列出异常项。', - explicitTarget: { sessionId: 'payment' }, - correction: { from: { sessionId: 'login' } }, - }); - finishOlder({ turnId: 'turn-login' }); - await olderCorrection; - - const result = await controller.submit({ - requestId: 'correction-after-overlap', - text: '继续白鹭点,补充回滚条件。', - }); - - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'payment', - }); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'route_correction'); -}); - -test('waiting Session rejects a second root request without calling submit', async () => { - let submitted = false; - const sessions = port([ - session('login', { - sessionName: '排查令牌过期重复登录问题', - state: 'waiting_for_user', - }), - ]); - sessions.submit = async () => { - submitted = true; - return { turnId: 'unexpected' }; - }; - const controller = createWorkHubController({ sessions }); - - const result = await controller.submit({ - requestId: 'request-waiting', - text: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', - }); - - assert.deepEqual(result, { - kind: 'waiting', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'request-waiting', - text: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', - target: { sessionId: 'login' }, - }); - assert.equal(submitted, false); -}); - -test('submit returns to the previous focused Session', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录刷新令牌' }), - session('payment', { sessionName: '支付回调幂等性' }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - const controller = createWorkHubController({ sessions }); - await controller.submit({ - requestId: 'request-login', - text: '先看登录', - explicitTarget: { sessionId: 'login' }, - }); - await controller.submit({ - requestId: 'request-payment', - text: '再看支付', - explicitTarget: { sessionId: 'payment' }, - }); - - const result = await controller.submit({ - requestId: 'request-previous', - text: '回到上一个工作', - }); - - assert.equal(result.kind, 'submitted'); - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { sessionId: 'login' }); - assert.deepEqual(submitted, ['login', 'payment', 'login']); -}); - -test('submit lets strong foreign core evidence override a vague focus word', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { - sessionName: '登录稳定性', - latestResult: '处理刷新令牌过期导致的重复登录', - }), - session('payment', { - sessionName: '支付稳定性', - latestResult: '处理支付回调重复投递', - }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - const controller = createWorkHubController({ sessions }); - await controller.submit({ - requestId: 'request-payment-focus', - text: '先看支付', - explicitTarget: { sessionId: 'payment' }, - }); - - const result = await controller.submit({ - requestId: 'request-foreign-core', - text: '继续处理刷新令牌过期', - }); - - assert.equal(result.kind, 'submitted'); - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { sessionId: 'login' }); - assert.deepEqual(submitted, ['payment', 'login']); -}); - -test('submit keeps unmatched non-executable conversation in WorkHub', async () => { - let created = false; - const actions: unknown[] = []; - const sessions = port([]); - sessions.create = async () => { - created = true; - return session('unexpected'); - }; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async () => ({ close: async () => undefined }), - answer: async (input) => ({ turnId: input.turnId }), - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'a'.repeat(64)}`, - candidates: [], - }), - act: async (input) => { - actions.push(input); - return { - disposition: 'answer_here', - coordinationTurnId: 'coordination-turn', - }; - }, - }, - }); - - const result = await controller.submit({ - requestId: 'request-discussion', - text: '你觉得统一入口最重要的价值是什么?', - }); - - assert.deepEqual(result, { - kind: 'discussion', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'request-discussion', - text: '你觉得统一入口最重要的价值是什么?', - }); - assert.equal(created, false); - assert.deepEqual(actions, [ - { - actionId: 'request-discussion', - userText: '你觉得统一入口最重要的价值是什么?', - proposal: { disposition: 'answer_here' }, - }, + assert.equal(created, false); + assert.deepEqual(actions, [ + { + actionId: 'request-discussion', + userText: '你觉得统一入口最重要的价值是什么?', + proposal: { disposition: 'answer_here' }, + }, ]); }); @@ -2581,14 +1332,10 @@ test('production submission delegates only through the Runtime-owned candidate r sessions.submit = async () => { throw new Error('renderer direct submit must not be used'); }; - sessions.stop = async () => { - throw new Error('renderer direct stop must not be used'); - }; const controller = createGatedWorkHubController({ sessions, coordination: { open: async () => ({ close: async () => undefined }), - answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, @@ -2643,7 +1390,6 @@ test('production retry reaches durable Action Gate replay while target is waitin sessions, coordination: { open: async () => ({ close: async () => undefined }), - answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, @@ -2689,7 +1435,6 @@ test('production defers destructive correction until persistent delegation exist sessions, coordination: { open: async () => ({ close: async () => undefined }), - answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), candidates: async () => ({ candidateSetId: `sha256:${'d'.repeat(64)}`, @@ -2795,7 +1540,6 @@ test('production natural-language correction fails closed before a second delega sessions, coordination: { open: async () => ({ close: async () => undefined }), - answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), candidates: async () => ({ candidateSetId, candidates }), act: async (input) => { @@ -2851,7 +1595,6 @@ test('production correction-shaped creation stays create_new without an existing sessions: port([]), coordination: { open: async () => ({ close: async () => undefined }), - answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, @@ -2883,7 +1626,6 @@ test('production clarification is persisted through the typed Action Gate dispos sessions: port([]), coordination: { open: async () => ({ close: async () => undefined }), - answer: async (input) => ({ turnId: input.turnId }), record: async () => { throw new Error('legacy summary recording must not persist clarification'); }, @@ -2927,7 +1669,6 @@ test('production creation leaves Session identity and workspace authority to mai sessions, coordination: { open: async () => ({ close: async () => undefined }), - answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), candidates: async () => ({ candidateSetId: `sha256:${'c'.repeat(64)}`, diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts index 5b3e18ac37..5feca3844c 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts @@ -19,10 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { - desktopSessionKey, - parseDesktopSessionKey, -} from '../../shared/runtime-host-identity.js'; +import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; import { scopeWorkHubSessionsToCoordinationHost } from '../../renderer/workhub-coordination-host-scope.js'; import { startWorkHubCoordinationLifecycle, @@ -30,7 +27,7 @@ import { } from '../../renderer/workhub-coordination-lifecycle.js'; import type { WorkHubDesktopSessionBridge } from '../../renderer/workhub-session-port.js'; -test('WorkHub candidates follow the resolved Coordination Session Host only', async () => { +test('WorkHub projections follow the resolved Coordination Session Host only', async () => { const sessionA = desktopSessionKey({ hostId: 'host-a', sessionId: 'ordinary-a' }); const sessionB = desktopSessionKey({ hostId: 'host-b', sessionId: 'ordinary-b' }); let coordinationSessionId: string | undefined; @@ -45,19 +42,8 @@ test('WorkHub candidates follow the resolved Coordination Session Host only', as }), listTurns: async () => [], queryMessageExecutions: async () => ({ resolutions: [] }), - create: async () => { - throw new Error('unscoped create must not be used'); - }, - send: async () => ({ ok: true, turnId: 'turn' }), - stop: async () => undefined, subscribeChanges: () => () => undefined, }; - const createdFor: string[] = []; - const createOnCoordinationHost = async (coordinationId: string) => { - createdFor.push(coordinationId); - const { hostId } = parseDesktopSessionKey(coordinationId); - return ordinarySession(hostId === 'host-a' ? sessionA : sessionB); - }; const scopeSessions = () => { const generation = coordinationGeneration; return scopeWorkHubSessionsToCoordinationHost( @@ -66,7 +52,6 @@ test('WorkHub candidates follow the resolved Coordination Session Host only', as sessionId: coordinationSessionId, isCurrent: () => generation === coordinationGeneration, }, - createOnCoordinationHost, ); }; let sessions = scopeSessions(); @@ -94,13 +79,11 @@ test('WorkHub candidates follow the resolved Coordination Session Host only', as }); const unresolvedList = sessions.list(); - const unresolvedCreate = assert.rejects(sessions.create({ name: 'unsafe' }), /unresolved/); assert.deepEqual(await unresolvedList, []); - await unresolvedCreate; await Promise.resolve(); assert.deepEqual((await sessions.list()).map((session) => session.id), [sessionA]); await assert.rejects( - () => sessions.send(sessionB, { type: 'send', turnId: 'turn', text: 'wrong Host' }), + () => sessions.listTurns(sessionB), /another Runtime Host/, ); assert.deepEqual(await sessions.listWithCoverage?.(), { @@ -114,12 +97,7 @@ test('WorkHub candidates follow the resolved Coordination Session Host only', as assert.deepEqual(await sessions.list(), []); await Promise.resolve(); assert.deepEqual((await sessions.list()).map((session) => session.id), [sessionB]); - await assert.rejects(staleHostAScope.create({ name: 'stale A' }), /scope is revoked/); - assert.equal((await sessions.create({ name: 'current B' })).id, sessionB); - assert.deepEqual( - createdFor.map((sessionId) => parseDesktopSessionKey(sessionId).hostId), - ['host-b'], - ); + await assert.rejects(staleHostAScope.listTurns(sessionA), /scope is revoked/); stop(); }); 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 a92dc154c0..fbf60f1c43 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -31,7 +31,6 @@ import { createDesktopWorkHubCoordinationPort, projectWorkHubCoordinationTurns, } from '../../renderer/workhub-coordination-port.js'; -import { WorkHubSessionSubmitError } from '../../renderer/workhub-controller.js'; function desktopSession( id: string, @@ -201,7 +200,6 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and }; }, }, - answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, @@ -355,7 +353,6 @@ test('desktop adapter rebuilds recent turns from the Session transcript and clos }, }, projectName: () => 'Maka', - newTurnId: () => 'unused', }); assert.deepEqual(await adapter.recentTurns([{ sessionId }]), [{ @@ -417,7 +414,6 @@ test('desktop adapter cancels an unavailable transcript without hiding ready Ses }, }, projectName: () => 'Maka', - newTurnId: () => 'unused', }); const turns = adapter.recentTurns([ @@ -473,7 +469,6 @@ test('desktop adapter projects Session catalog facts without owning copies', asy subscribeChanges: () => () => {}, }, projectName: (projectId) => projectId === 'project-maka' ? 'Maka' : undefined, - newTurnId: () => 'unused', }); assert.deepEqual(await adapter.list(), [ @@ -525,6 +520,8 @@ test('desktop adapter rebuilds delegation feedback from the Message-owned execut const sessions = [ desktopSession('accepted'), desktopSession('running', { status: 'running', runningTurnIds: ['turn-running'] }), + desktopSession('stale-running'), + desktopSession('recorded-running-only', { runningTurnIds: undefined }), desktopSession('waiting', { status: 'waiting_for_user', runningTurnIds: ['turn-waiting'], @@ -544,6 +541,16 @@ test('desktop adapter rebuilds delegation feedback from the Message-owned execut statusSource: 'recorded'; }>>([ ['running', [{ turnId: 'turn-running', status: 'running', statusSource: 'recorded' }]], + ['stale-running', [{ + turnId: 'turn-stale-running', + status: 'running', + statusSource: 'recorded', + }]], + ['recorded-running-only', [{ + turnId: 'turn-recorded-running-only', + status: 'running', + statusSource: 'recorded', + }]], ['waiting', [{ turnId: 'turn-waiting', status: 'running', statusSource: 'recorded' }]], ['completed', [{ turnId: 'turn-completed', status: 'completed', statusSource: 'recorded' }]], ['failed', [{ turnId: 'turn-failed', status: 'failed', statusSource: 'recorded' }]], @@ -577,11 +584,12 @@ test('desktop adapter rebuilds delegation feedback from the Message-owned execut subscribeChanges: () => () => {}, }, projectName: () => 'Maka', - newTurnId: () => 'unused', }); const references = [ ['accepted', 'turn-accepted'], ['running', 'turn-running'], + ['stale-running', 'turn-stale-running'], + ['recorded-running-only', 'turn-recorded-running-only'], ['waiting', 'turn-waiting'], ['completed', 'turn-completed'], ['failed', 'turn-failed'], @@ -600,6 +608,8 @@ test('desktop adapter rebuilds delegation feedback from the Message-owned execut assert.deepEqual(feedback.map(({ delegationId, state }) => ({ delegationId, state })), [ { delegationId: 'delegation-accepted', state: 'accepted' }, { delegationId: 'delegation-running', state: 'running' }, + { delegationId: 'delegation-stale-running', state: 'accepted' }, + { delegationId: 'delegation-recorded-running-only', state: 'running' }, { delegationId: 'delegation-waiting', state: 'waiting_for_user' }, { delegationId: 'delegation-completed', state: 'completed' }, { delegationId: 'delegation-failed', state: 'failed' }, @@ -651,7 +661,6 @@ test('desktop adapter follows a delegated Message into its successor Turn', asyn subscribeChanges: () => () => {}, }, projectName: () => 'Maka', - newTurnId: () => 'unused', }); const references = [{ @@ -666,274 +675,6 @@ test('desktop adapter follows a delegated Message into its successor Turn', asyn }]); }); -test('desktop adapter preserves per-Host catalog coverage for ownership reconciliation', async () => { - const localSessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'local' }); - const adapter = createDesktopWorkHubSessionPort({ - transcripts: unusedTranscripts, - sessions: { - list: async () => [], - listWithCoverage: async () => ({ - sessions: [desktopSession(localSessionId)], - completeHostIds: ['local-host'], - }), - listTurns: async () => [], - queryMessageExecutions: noMessageExecutions, - create: async () => { throw new Error('not used'); }, - send: async () => { throw new Error('not used'); }, - stop: async () => {}, - subscribeChanges: () => () => {}, - }, - projectName: () => 'Maka', - newTurnId: () => 'unused', - }); - - const catalog = await adapter.listCatalog?.(); - assert.ok(catalog); - assert.equal(catalog.sessions[0]?.target.sessionId, localSessionId); - assert.equal(catalog.isCompleteFor({ sessionId: localSessionId }), true); - assert.equal(catalog.isCompleteFor({ - sessionId: desktopSessionKey({ hostId: 'remote-host', sessionId: 'remote' }), - }), false); -}); - -test('desktop adapter delegates create, send, and invalidation to Session APIs', async () => { - const calls: unknown[] = []; - let onChanged: (() => void) | undefined; - const adapter = createDesktopWorkHubSessionPort({ - transcripts: unusedTranscripts, - sessions: { - list: async () => [desktopSession('created', { - status: 'running', - runningTurnIds: ['turn-new'], - })], - listTurns: async () => [], - queryMessageExecutions: noMessageExecutions, - create: async (input) => { - calls.push(['create', input]); - return desktopSession('created', { name: input.name }); - }, - send: async (sessionId, command) => { - calls.push(['send', sessionId, command]); - return { ok: true, turnId: command.turnId }; - }, - stop: async (sessionId, input) => { - calls.push(['stop', sessionId, input]); - }, - subscribeChanges: (handler) => { - onChanged = handler; - return () => calls.push(['unsubscribe']); - }, - }, - projectName: () => 'Maka', - newTurnId: () => 'turn-new', - }); - - const created = await adapter.create({ name: '实现导出发票 PDF 功能' }); - const turnId = adapter.reserveTurnId(); - const turn = await adapter.submit(created.target, '实现导出发票 PDF 功能', turnId); - await adapter.stop(created.target, 'turn-new'); - let invalidations = 0; - const unsubscribe = adapter.subscribe(() => { - invalidations += 1; - }); - onChanged?.(); - unsubscribe(); - - assert.equal(created.kind, 'ordinary'); - assert.deepEqual(turn, { turnId: 'turn-new' }); - assert.equal(invalidations, 1); - assert.deepEqual(calls, [ - ['create', { name: '实现导出发票 PDF 功能' }], - ['send', 'created', { type: 'send', turnId: 'turn-new', text: '实现导出发票 PDF 功能' }], - ['stop', 'created', { source: 'stop_button', expectedTurnId: 'turn-new' }], - ['unsubscribe'], - ]); -}); - -test('desktop adapter preserves when Session delivery steered an existing root Turn', async () => { - const adapter = createDesktopWorkHubSessionPort({ - transcripts: unusedTranscripts, - sessions: { - list: async () => [], - listTurns: async () => [], - queryMessageExecutions: noMessageExecutions, - create: async () => { - throw new Error('not used'); - }, - send: async (_sessionId, command) => ({ - ok: true, - turnId: command.turnId, - steered: true, - }), - stop: async () => {}, - subscribeChanges: () => () => {}, - }, - projectName: () => 'Maka', - newTurnId: () => 'turn-steered', - }); - - assert.deepEqual( - await adapter.submit( - { sessionId: 'busy' }, - '补充已有执行流', - adapter.reserveTurnId(), - ), - { turnId: 'turn-steered', steered: true }, - ); -}); - -test('desktop adapter distinguishes definite rejection from an unknown delivery outcome', async () => { - let outcome: 'throw' | 'unknown' | 'reject' = 'throw'; - const adapter = createDesktopWorkHubSessionPort({ - transcripts: unusedTranscripts, - sessions: { - list: async () => [], - listTurns: async () => [], - queryMessageExecutions: noMessageExecutions, - create: async () => { throw new Error('not used'); }, - send: async () => { - if (outcome === 'throw') throw new Error('transport disconnected'); - if (outcome === 'unknown') { - return { - ok: false as const, - reason: 'outcome_unknown' as const, - messageId: 'reserved-turn', - skillInvocation: { loaded: [], failed: [], receipts: [] }, - }; - } - return { - ok: false as const, - reason: 'skill_invocation_failed' as const, - skillInvocation: { - loaded: [], - failed: [{ request: 'missing', reason: 'not_found' as const }], - receipts: [], - }, - }; - }, - stop: async () => {}, - subscribeChanges: () => () => {}, - }, - projectName: () => 'Maka', - newTurnId: () => 'reserved-turn', - }); - - await assert.rejects( - adapter.submit({ sessionId: 'payment' }, '继续支付', 'reserved-turn'), - (error) => error instanceof WorkHubSessionSubmitError && error.admission === 'unknown', - ); - // The Host declining to prove the outcome must stay reconcilable; only a - // Host-owned refusal releases the reserved root. - outcome = 'unknown'; - await assert.rejects( - adapter.submit({ sessionId: 'payment' }, '继续支付', 'reserved-turn'), - (error) => error instanceof WorkHubSessionSubmitError && error.admission === 'unknown', - ); - outcome = 'reject'; - await assert.rejects( - adapter.submit({ sessionId: 'payment' }, '继续支付', 'reserved-turn'), - (error) => error instanceof WorkHubSessionSubmitError && error.admission === 'rejected', - ); -}); - -test('desktop adapter reconciles lost replies from authoritative transcript identity', async () => { - const cases: Array<{ - name: string; - message: StoredMessage; - expected: { kind: 'root'; turnId: string } | { kind: 'steered' } | { kind: 'unknown' }; - }> = [ - { - name: 'direct root', - message: { - type: 'user', id: 'user-root', turnId: 'reserved-turn', ts: 1, text: '开始支付', - }, - expected: { kind: 'root', turnId: 'reserved-turn' }, - }, - { - name: 'busy-race root', - message: { - type: 'user', id: 'reserved-turn', turnId: 'host-root', ts: 1, text: '开始支付', - }, - expected: { kind: 'root', turnId: 'host-root' }, - }, - { - name: 'steering', - message: { - type: 'user', - id: 'reserved-turn', - turnId: 'pre-existing-root', - steeringEventId: 'steering-event', - ts: 1, - text: '补充支付测试', - }, - expected: { kind: 'steered' }, - }, - { - name: 'unrelated message', - message: { - type: 'user', id: 'other-message', turnId: 'other-root', ts: 1, text: '其他工作', - }, - expected: { kind: 'unknown' }, - }, - ]; - - for (const fixture of cases) { - const adapter = createDesktopWorkHubSessionPort({ - transcripts: transcriptsWith([fixture.message]), - sessions: { - list: async () => [], - listTurns: async () => [], - queryMessageExecutions: noMessageExecutions, - create: async () => { throw new Error('not used'); }, - send: async () => { throw new Error('not used'); }, - stop: async () => {}, - subscribeChanges: () => () => {}, - }, - projectName: () => 'Maka', - newTurnId: () => 'reserved-turn', - }); - - assert.deepEqual( - await adapter.reconcileSubmission({ - sessionId: desktopSessionKey({ hostId: 'local-host', sessionId: fixture.name }), - }, 'reserved-turn'), - fixture.expected, - fixture.name, - ); - } -}); - -test('desktop adapter binds stop to the root Turn owned by the WorkHub submission', async () => { - const stopped: unknown[] = []; - const adapter = createDesktopWorkHubSessionPort({ - transcripts: unusedTranscripts, - sessions: { - list: async () => [], - listTurns: async () => [], - queryMessageExecutions: noMessageExecutions, - create: async () => { - throw new Error('not used'); - }, - send: async () => { - throw new Error('not used'); - }, - stop: async (sessionId, input) => { - stopped.push([sessionId, input]); - }, - subscribeChanges: () => () => {}, - }, - projectName: () => 'Maka', - newTurnId: () => 'unused', - }); - - await adapter.stop({ sessionId: 'payment' }, 'turn-workhub'); - - assert.deepEqual(stopped, [[ - 'payment', - { source: 'stop_button', expectedTurnId: 'turn-workhub' }, - ]]); -}); - test('desktop adapter derives stable origin evidence from the existing Session log', async () => { let reads = 0; const adapter = createDesktopWorkHubSessionPort({ @@ -959,7 +700,6 @@ test('desktop adapter derives stable origin evidence from the existing Session l subscribeChanges: () => () => {}, }, projectName: () => 'Maka', - newTurnId: () => 'unused', }); const first = await adapter.routingEvidence([{ sessionId: 'payment' }]); 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 a254f12be4..98af7045e5 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -35,7 +35,7 @@ import { workHubSubmissionClearsDraft, } from '../../renderer/workhub-surface.js'; import { - createLegacyWorkHubControllerForTests as createWorkHubController, + createWorkHubController, WORKHUB_ROUTING_STRATEGY_ID, type WorkHubController, type WorkHubCoordinationTurn, @@ -325,6 +325,34 @@ test('real Session projection creates new guide topics and preserves origin ambi ], ]); const created: string[] = []; + const createSession = async ({ name }: { name: string }) => { + const id = name.includes('支付回调') ? 'payment' : 'layout'; + const createdSession: WorkHubDesktopSession = { + id, + name: id === 'payment' ? '支付回调幂等性' : '移动端窄屏布局', + labels: [], + isArchived: false, + status: 'active', + projectId: 'project-maka', + lastMessageAt: ++clock, + }; + created.push(id); + sessions.push(createdSession); + prompts.set(id, []); + return createdSession; + }; + const send = async ( + sessionId: string, + command: { type: 'send'; turnId: string; text: string }, + ) => { + prompts.get(sessionId)?.push(command.text); + const target = sessions.find((candidate) => candidate.id === sessionId); + if (target) { + target.lastMessageAt = ++clock; + target.lastMessagePreview = command.text; + } + return { ok: true as const, turnId: command.turnId }; + }; const port = createDesktopWorkHubSessionPort({ transcripts: { open: async () => { @@ -336,39 +364,67 @@ test('real Session projection creates new guide topics and preserves origin ambi listTurns: async (sessionId) => (prompts.get(sessionId) ?? []).map((userPromptPreview) => ({ userPromptPreview })), queryMessageExecutions: async () => ({ resolutions: [] }), - create: async ({ name }) => { - const id = name.includes('支付回调') ? 'payment' : 'layout'; - const session: WorkHubDesktopSession = { - id, - name: id === 'payment' ? '支付回调幂等性' : '移动端窄屏布局', - labels: [], - isArchived: false, - status: 'active', - projectId: 'project-maka', - lastMessageAt: ++clock, - }; - created.push(id); - sessions.push(session); - prompts.set(id, []); - return session; - }, - send: async (sessionId, command) => { - prompts.get(sessionId)?.push(command.text); - const session = sessions.find((candidate) => candidate.id === sessionId); - if (session) { - session.lastMessageAt = ++clock; - session.lastMessagePreview = command.text; - } - return { ok: true, turnId: command.turnId }; - }, + create: createSession, + send, stop: async () => {}, subscribeChanges: () => () => {}, }, projectName: (projectId) => projectId === 'project-router' ? 'maka-workhub-session-router' : 'maka-agent', - newTurnId: () => `turn-${clock + 1}`, }); - const controller = createWorkHubController({ sessions: port }); + const controller = createWorkHubController({ + 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) => ({ + candidateRef: `candidate-${entry.id}`, + sessionId: entry.id, + sessionName: entry.name, + workspace: { + target: { kind: 'host_path' as const, path: `/workspace/${entry.id}` }, + hostCwd: `/workspace/${entry.id}`, + }, + state: entry.status, + updatedAt: entry.lastMessageAt ?? 0, + })), + }), + act: async (input) => { + if (input.proposal.disposition === 'answer_here') { + return { disposition: 'answer_here', coordinationTurnId: input.actionId }; + } + if (input.proposal.disposition === 'clarify') { + return { disposition: 'clarify', coordinationTurnId: input.actionId }; + } + if (input.proposal.disposition === 'create_new') { + const target = await createSession({ name: input.proposal.title }); + const admitted = await send(target.id, { + type: 'send', + turnId: input.actionId, + text: input.userText, + }); + return { + disposition: 'create_new', + targetSessionId: target.id, + targetTurnId: admitted.turnId, + }; + } + const targetSessionId = input.proposal.candidateRef.replace(/^candidate-/u, ''); + const admitted = await send(targetSessionId, { + type: 'send', + turnId: input.actionId, + text: input.userText, + }); + return { + disposition: 'delegate_existing', + targetSessionId, + targetTurnId: admitted.turnId, + }; + }, + }, + }); const payment = await controller.submit({ requestId: 'setup-payment', diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 67a61a1ea2..e390ab2775 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1492,8 +1492,6 @@ function AppShellContent({ coordination: createDesktopWorkHubCoordinationPort({ sessionId: workHubCoordinationSessionId ?? 'workhub-coordination-unresolved', transcripts: window.maka.transcripts, - answer: (input) => - window.maka.workHub.answer(workHubCoordinationSessionId!, input), record: (input) => window.maka.workHub.record(workHubCoordinationSessionId!, input), candidates: () => @@ -1510,13 +1508,10 @@ function AppShellContent({ workHubCoordinationGenerationRef.current === workHubCoordinationGeneration && workHubCoordinationSessionIdRef.current === workHubCoordinationSessionId, }, - (coordinationSessionId, input) => - window.maka.workHub.createSession(coordinationSessionId, input), ), transcripts: window.maka.transcripts, projectName: (projectId) => workHubProjectsRef.current.find((project) => project.id === projectId)?.name, - newTurnId: () => crypto.randomUUID(), }), }), [workHubCoordinationGeneration, workHubCoordinationSessionId], diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 8aba296633..46a1b89a75 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -183,14 +183,6 @@ export type WorkHubSubmission = ( */ export interface WorkHubSessionPort { list(): Promise; - /** - * Lists Sessions with per-target catalog coverage. A target missing from a - * partial multi-Host list is not authoritatively absent. - */ - listCatalog?(): Promise<{ - sessions: WorkHubSessionFacts[]; - isCompleteFor(target: WorkHubSessionTarget): boolean; - }>; /** * Rebuilds a bounded recent conversation from the authoritative Session * transcripts. Missing transcripts are omitted rather than copied elsewhere. @@ -211,22 +203,6 @@ export interface WorkHubSessionPort { routingEvidence( targets: readonly WorkHubSessionTarget[], ): Promise>; - create(input: { name: string }): Promise; - reserveTurnId(): string; - submit( - target: WorkHubSessionTarget, - text: string, - turnId: string, - ): Promise<{ turnId: string; steered?: true }>; - reconcileSubmission( - target: WorkHubSessionTarget, - reservedTurnId: string, - ): Promise< - | { kind: 'root'; turnId: string } - | { kind: 'steered' } - | { kind: 'unknown' } - >; - stop(target: WorkHubSessionTarget, expectedTurnId: string): Promise; subscribe(handler: () => void): () => void; } @@ -235,7 +211,6 @@ export interface WorkHubCoordinationPort { handler: (turns: readonly WorkHubCoordinationTurn[]) => void, onError: (error: unknown) => void, ): Promise<{ close(): Promise }>; - answer(input: { turnId: string; text: string }): Promise<{ turnId: string }>; record(input: { turnId: string; userText: string; @@ -245,17 +220,6 @@ export interface WorkHubCoordinationPort { act(input: Omit): Promise; } -export class WorkHubSessionSubmitError extends Error { - constructor( - message: string, - readonly admission: 'rejected' | 'unknown', - options?: ErrorOptions, - ) { - super(message, options); - this.name = 'WorkHubSessionSubmitError'; - } -} - export interface WorkHubController { read(input?: WorkHubReadInput): Promise; submit(input: WorkHubSubmitInput): Promise; @@ -273,50 +237,14 @@ export interface WorkHubController { resetVisitContext(): void; } -const MAX_TRACKED_WORKHUB_ROOTS = 32; - -interface WorkHubRootOwnership { - order: number; - turnId: string; -} - -interface WorkHubPendingAdmission extends WorkHubRootOwnership { - state: 'in_flight' | 'uncertain'; -} - -interface WorkHubOwnershipTombstone { - order: number; - stoppedTurnIds: Set; -} - export function createWorkHubController(deps: { sessions: WorkHubSessionPort; coordination: WorkHubCoordinationPort; }): WorkHubController { - return createWorkHubControllerImplementation(deps); -} - -/** @internal Transitional R2.4 regression harness; application code must use the Action Gate. */ -export function createLegacyWorkHubControllerForTests(deps: { - sessions: WorkHubSessionPort; -}): WorkHubController { - return createWorkHubControllerImplementation(deps); -} - -function createWorkHubControllerImplementation(deps: { - sessions: WorkHubSessionPort; - coordination?: WorkHubCoordinationPort; -}): WorkHubController { - const coordination = deps.coordination ?? legacyTestCoordinationPort(); + const { coordination } = deps; let routePolicy = createWorkHubRoutePolicy(); let focusReadVersion = 0; let pendingFocusReadVersion: number | undefined; - const confirmedOwnershipBySessionId = new Map(); - const pendingAdmissionsBySessionId = new Map(); - const ownershipTombstoneBySessionId = new Map(); - const stopAttemptByTurn = new Map>(); - const stopOperationCountBySessionId = new Map(); - let ownershipRevision = 0; const reconcileFocus = ( policy: ReturnType, sessions: readonly WorkHubSessionFacts[], @@ -326,317 +254,6 @@ function createWorkHubControllerImplementation(deps: { .sort((left, right) => right.updatedAt - left.updatedAt) .map((session) => session.target)); }; - const correctionFor = (from: WorkHubSessionTarget): WorkHubCorrectionContext => { - const confirmed = confirmedOwnershipBySessionId.get(from.sessionId); - const pending = pendingAdmissionsBySessionId.get(from.sessionId); - const turnId = confirmed?.turnId ?? pending?.at(-1)?.turnId; - if (!turnId) return { from }; - return { - from, - turnId, - }; - }; - const pendingAdmissions = (sessionId: string): WorkHubPendingAdmission[] => - pendingAdmissionsBySessionId.get(sessionId) ?? []; - const setPendingAdmissions = ( - sessionId: string, - pending: WorkHubPendingAdmission[], - ) => { - ownershipRevision += 1; - if (pending.length === 0) { - pendingAdmissionsBySessionId.delete(sessionId); - return; - } - pendingAdmissionsBySessionId.set( - sessionId, - [...pending].sort((left, right) => left.order - right.order), - ); - }; - const trackedRootCount = () => { - let pendingCount = 0; - for (const pending of pendingAdmissionsBySessionId.values()) { - pendingCount += pending.length; - } - return confirmedOwnershipBySessionId.size + pendingCount; - }; - const maybeRetireTombstone = (sessionId: string) => { - const tombstone = ownershipTombstoneBySessionId.get(sessionId); - if (!tombstone) return; - if ((stopOperationCountBySessionId.get(sessionId) ?? 0) > 0) return; - if (pendingAdmissions(sessionId).some((candidate) => candidate.order <= tombstone.order)) { - return; - } - ownershipTombstoneBySessionId.delete(sessionId); - }; - const readCatalog = async () => { - const revisionAtStart = ownershipRevision; - const catalog = deps.sessions.listCatalog - ? await deps.sessions.listCatalog() - : { - sessions: await deps.sessions.list(), - isCompleteFor: () => false, - }; - return { - catalog, - // A catalog request that overlapped an ownership mutation may describe - // the state before that mutation. It remains useful for projection, but - // it must not authoritatively prune newer ownership or admissions. - allowAuthoritativePruning: revisionAtStart === ownershipRevision, - }; - }; - const reconcileConfirmedOwnership = (catalog: { - sessions: readonly WorkHubSessionFacts[]; - isCompleteFor(target: WorkHubSessionTarget): boolean; - }, allowAuthoritativePruning: boolean) => { - if (!allowAuthoritativePruning) return; - const { sessions } = catalog; - const sessionById = new Map(sessions.map((session) => [session.target.sessionId, session])); - for (const [sessionId, ownership] of confirmedOwnershipBySessionId) { - const session = sessionById.get(sessionId); - if ( - (!session && catalog.isCompleteFor({ sessionId })) || - session?.archived || - (session?.runningTurnIds !== undefined && - !session.runningTurnIds.includes(ownership.turnId)) - ) { - if (confirmedOwnershipBySessionId.delete(sessionId)) { - ownershipRevision += 1; - } - } - } - }; - const storeOwnershipTombstone = ( - sessionId: string, - order: number, - stoppedTurnIds: Iterable = [], - ) => { - const existing = ownershipTombstoneBySessionId.get(sessionId); - if (existing && existing.order > order) return; - const stopped = new Set(existing?.order === order ? existing.stoppedTurnIds : []); - for (const turnId of stoppedTurnIds) stopped.add(turnId); - ownershipTombstoneBySessionId.set(sessionId, { - order, - stoppedTurnIds: stopped, - }); - }; - const reserveOwnedRoot = ( - target: WorkHubSessionTarget, - turnId: string, - order: number, - ) => { - if (trackedRootCount() >= MAX_TRACKED_WORKHUB_ROOTS) { - throw new Error('WorkHub has too many unresolved root submissions'); - } - setPendingAdmissions(target.sessionId, [ - ...pendingAdmissions(target.sessionId), - { order, turnId, state: 'in_flight' }, - ]); - }; - const removePendingRoot = ( - target: WorkHubSessionTarget, - reservedTurnId: string, - order: number, - ) => { - setPendingAdmissions( - target.sessionId, - pendingAdmissions(target.sessionId).filter((candidate) => - candidate.order !== order || candidate.turnId !== reservedTurnId), - ); - }; - const markPendingRootUncertain = ( - target: WorkHubSessionTarget, - reservedTurnId: string, - order: number, - ) => { - setPendingAdmissions( - target.sessionId, - pendingAdmissions(target.sessionId).map((candidate) => - candidate.order === order && candidate.turnId === reservedTurnId - ? { ...candidate, state: 'uncertain' } - : candidate), - ); - }; - const attemptStop = ( - target: WorkHubSessionTarget, - turnId: string, - ): Promise => { - const key = `${target.sessionId}\0${turnId}`; - const existing = stopAttemptByTurn.get(key); - if (existing) return existing; - stopOperationCountBySessionId.set( - target.sessionId, - (stopOperationCountBySessionId.get(target.sessionId) ?? 0) + 1, - ); - const stopping = deps.sessions.stop(target, turnId).finally(() => { - stopAttemptByTurn.delete(key); - const remaining = (stopOperationCountBySessionId.get(target.sessionId) ?? 1) - 1; - if (remaining === 0) { - stopOperationCountBySessionId.delete(target.sessionId); - } else { - stopOperationCountBySessionId.set(target.sessionId, remaining); - } - }); - stopAttemptByTurn.set(key, stopping); - return stopping; - }; - const settleOwnedRoot = async ( - target: WorkHubSessionTarget, - reservedTurnId: string, - turn: { turnId: string; steered?: true }, - order: number, - ) => { - const tombstone = ownershipTombstoneBySessionId.get(target.sessionId); - let stopped = false; - let stopFailure: unknown; - if (!turn.steered && tombstone && tombstone.order >= order) { - stopped = tombstone.stoppedTurnIds.has(turn.turnId); - if (!stopped) { - try { - const priorStopAttempt = stopAttemptByTurn.get( - `${target.sessionId}\0${turn.turnId}`, - ); - if (priorStopAttempt) { - try { - await priorStopAttempt; - } catch { - // Admission is new evidence. Retry against the admitted root even - // when the earlier pre-admission Stop failed or observed nothing. - } - } - await attemptStop(target, turn.turnId); - const currentBarrier = ownershipTombstoneBySessionId.get(target.sessionId); - if (currentBarrier && currentBarrier.order >= order) { - storeOwnershipTombstone(target.sessionId, currentBarrier.order, [turn.turnId]); - } - stopped = true; - } catch (error) { - stopFailure = error; - } - } - } - removePendingRoot(target, reservedTurnId, order); - if (!turn.steered && !stopped) { - const confirmed = confirmedOwnershipBySessionId.get(target.sessionId); - if (!confirmed || confirmed.order <= order) { - confirmedOwnershipBySessionId.set(target.sessionId, { - order, - turnId: turn.turnId, - }); - ownershipRevision += 1; - } - } - maybeRetireTombstone(target.sessionId); - if (stopFailure) throw stopFailure; - }; - const releasePendingRoot = ( - target: WorkHubSessionTarget, - reservedTurnId: string, - order: number, - ) => { - removePendingRoot(target, reservedTurnId, order); - maybeRetireTombstone(target.sessionId); - }; - const reconcilePendingRoot = async ( - target: WorkHubSessionTarget, - reservedTurnId: string, - order: number, - ): Promise => { - const reconciliation = await deps.sessions.reconcileSubmission(target, reservedTurnId); - if (reconciliation.kind === 'unknown') return false; - await settleOwnedRoot( - target, - reservedTurnId, - reconciliation.kind === 'steered' - ? { turnId: reservedTurnId, steered: true } - : { turnId: reconciliation.turnId }, - order, - ); - return true; - }; - const reconcileUncertainAdmissions = (catalog: { - sessions: readonly WorkHubSessionFacts[]; - isCompleteFor(target: WorkHubSessionTarget): boolean; - }, allowAuthoritativePruning: boolean): Promise | undefined => { - const sessionById = new Map( - catalog.sessions.map((session) => [session.target.sessionId, session]), - ); - const uncertain = [...pendingAdmissionsBySessionId.entries()] - .flatMap(([sessionId, pending]) => pending - .filter((candidate) => candidate.state === 'uncertain') - .map((candidate) => ({ - target: { sessionId }, - ...candidate, - }))); - if (uncertain.length === 0) return undefined; - return Promise.all(uncertain.map(async ({ target, turnId, order }) => { - const session = sessionById.get(target.sessionId); - if ( - allowAuthoritativePruning && - (session?.archived || (!session && catalog.isCompleteFor(target))) - ) { - releasePendingRoot(target, turnId, order); - return; - } - try { - await reconcilePendingRoot(target, turnId, order); - } catch { - // Failed reconciliation preserves the pending or confirmed ownership. - } - })).then(() => undefined); - }; - const assertSubmissionBarrierOpen = (target: WorkHubSessionTarget) => { - maybeRetireTombstone(target.sessionId); - const tombstone = ownershipTombstoneBySessionId.get(target.sessionId); - const stopCount = stopOperationCountBySessionId.get(target.sessionId) ?? 0; - const pendingBarrier = tombstone && pendingAdmissions(target.sessionId) - .some((candidate) => candidate.order <= tombstone.order); - if (stopCount > 0 || pendingBarrier) { - throw new Error('WorkHub is still reconciling a correction for this Session'); - } - }; - const stopOwnedRoots = async ( - correction: WorkHubCorrectionContext, - order: number, - ) => { - if (correction.steered) return; - const confirmed = confirmedOwnershipBySessionId.get(correction.from.sessionId); - const pending = pendingAdmissions(correction.from.sessionId); - const turnIds = new Set(); - const unconfirmedTurnIds = new Set(); - if (correction.turnId) turnIds.add(correction.turnId); - if (confirmed && confirmed.order < order) { - turnIds.add(confirmed.turnId); - } - for (const candidate of pending) { - if (candidate.order < order) { - turnIds.add(candidate.turnId); - unconfirmedTurnIds.add(candidate.turnId); - } - } - if (turnIds.size === 0) return; - // Publish only the order barrier before awaiting Host acknowledgements. - // Individual IDs become tombstoned only after their Stop succeeds. - storeOwnershipTombstone(correction.from.sessionId, order); - const failures: unknown[] = []; - await Promise.all([...turnIds].map(async (turnId) => { - try { - await attemptStop(correction.from, turnId); - const barrier = ownershipTombstoneBySessionId.get(correction.from.sessionId); - if (barrier && barrier.order >= order && !unconfirmedTurnIds.has(turnId)) { - storeOwnershipTombstone(correction.from.sessionId, barrier.order, [turnId]); - } - const owned = confirmedOwnershipBySessionId.get(correction.from.sessionId); - if (owned && owned.order < order && owned.turnId === turnId) { - confirmedOwnershipBySessionId.delete(correction.from.sessionId); - ownershipRevision += 1; - } - } catch (error) { - failures.push(error); - } - })); - maybeRetireTombstone(correction.from.sessionId); - if (failures.length > 0) throw failures[0]; - }; return { async openConversation(handler, onError) { let disposed = false; @@ -707,7 +324,7 @@ function createWorkHubControllerImplementation(deps: { }; }, async recordConversationTurn(input) { - if (deps.coordination && input.disposition === 'clarify') { + if (input.disposition === 'clarify') { const result = await coordination.act({ actionId: input.turnId, userText: input.userText, @@ -739,15 +356,7 @@ function createWorkHubControllerImplementation(deps: { readPolicy.rememberTarget(input.focus); } try { - const { catalog, allowAuthoritativePruning } = - await readCatalog(); - reconcileConfirmedOwnership(catalog, allowAuthoritativePruning); - const reconciliation = reconcileUncertainAdmissions( - catalog, - allowAuthoritativePruning, - ); - if (reconciliation) await reconciliation; - const facts = catalog.sessions; + const facts = await deps.sessions.list(); const ordinary = facts .filter((session) => session.kind === 'ordinary') .sort((left, right) => right.updatedAt - left.updatedAt); @@ -773,31 +382,17 @@ function createWorkHubControllerImplementation(deps: { }, async submit(input) { const submissionPolicy = routePolicy; - // Reserve the order synchronously, before any await. Corrections are - // learned only after successful delivery, but their precedence follows - // user submission order rather than network completion order. - const submissionOrder = submissionPolicy.reserveSubmissionOrder(); - if (deps.coordination && input.correction) { + if (input.correction) { throw new Error( 'WorkHub linked correction requires persistent delegation support', ); } - const { catalog, allowAuthoritativePruning } = - await readCatalog(); - reconcileConfirmedOwnership(catalog, allowAuthoritativePruning); - const reconciliation = reconcileUncertainAdmissions( - catalog, - allowAuthoritativePruning, - ); - if (reconciliation) await reconciliation; - const sessions = catalog.sessions; + const sessions = await deps.sessions.list(); reconcileFocus(submissionPolicy, sessions); const ordinary = sessions.filter((session) => session.kind === 'ordinary'); - const candidateSet = deps.coordination - ? await coordination.candidates() - : undefined; + const candidateSet = await coordination.candidates(); const candidateBySessionId = new Map( - candidateSet?.candidates.map((candidate) => [candidate.sessionId, candidate]), + candidateSet.candidates.map((candidate) => [candidate.sessionId, candidate]), ); // Archived Sessions remain visible as historical work, but Runtime Host // rejects new root Turns for them. In production the Runtime-owned @@ -805,7 +400,7 @@ function createWorkHubControllerImplementation(deps: { const routable = ordinary.filter( (session) => !session.archived && - (!candidateSet || candidateBySessionId.has(session.target.sessionId)), + candidateBySessionId.has(session.target.sessionId), ); const routingEvidence = input.explicitTarget ? [] @@ -819,14 +414,11 @@ function createWorkHubControllerImplementation(deps: { ...(input.explicitTarget ? { explicitTarget: input.explicitTarget } : {}), }); if (decision.kind === 'clarification') { - if (deps.coordination && decision.correctedFrom) { + if (decision.correctedFrom) { throw new Error( 'WorkHub linked correction requires persistent delegation support', ); } - const correction = decision.correctedFrom - ? correctionFor(decision.correctedFrom) - : undefined; return { kind: 'clarification', strategyId: WORKHUB_ROUTING_STRATEGY_ID, @@ -837,22 +429,14 @@ function createWorkHubControllerImplementation(deps: { projectName: session.projectName, sessionName: session.sessionName, })), - ...(correction ? { correction } : {}), }; } if (decision.kind === 'discussion') { - if (candidateSet) { - await coordination.act({ - actionId: input.requestId, - userText: input.text, - proposal: { disposition: 'answer_here' }, - }); - } else { - await coordination.answer({ - turnId: input.requestId, - text: input.text, - }); - } + await coordination.act({ + actionId: input.requestId, + userText: input.text, + proposal: { disposition: 'answer_here' }, + }); return { kind: 'discussion', strategyId: WORKHUB_ROUTING_STRATEGY_ID, @@ -860,17 +444,12 @@ function createWorkHubControllerImplementation(deps: { text: input.text, }; } - let target: WorkHubSessionTarget; - let evidence: Extract['evidence']; - const correction = input.correction ?? (decision.kind === 'target' && decision.correctedFrom - ? correctionFor(decision.correctedFrom) - : undefined); - if (deps.coordination && correction) { + if (decision.kind === 'target' && decision.correctedFrom) { throw new Error( 'WorkHub linked correction requires persistent delegation support', ); } - if (candidateSet && decision.kind === 'new_session') { + if (decision.kind === 'new_session') { const admitted = await coordination.act({ actionId: input.requestId, userText: input.text, @@ -882,7 +461,7 @@ function createWorkHubControllerImplementation(deps: { if (admitted.disposition !== 'create_new') { throw new Error('WorkHub Action Gate returned an unexpected disposition'); } - target = { sessionId: admitted.targetSessionId }; + const target = { sessionId: admitted.targetSessionId }; submissionPolicy.rememberTarget(target); return { kind: 'submitted', @@ -894,21 +473,11 @@ function createWorkHubControllerImplementation(deps: { evidence: 'new_session', }; } - if (decision.kind === 'new_session') { - const created = await deps.sessions.create({ name: workHubNewSessionName(input.text) }); - if (created.kind !== 'ordinary') { - throw new Error('WorkHub can only create ordinary Sessions'); - } - target = created.target; - evidence = 'new_session'; - } else { - target = decision.target; - evidence = correction ? 'route_correction' : decision.evidence; - } + const target = decision.target; const targetSession = routable.find( (session) => session.target.sessionId === target.sessionId, ); - if (!targetSession && evidence !== 'new_session') { + if (!targetSession) { throw new Error('WorkHub target Session is unavailable'); } if (targetSession?.state === 'waiting_for_user' && !input.retryAction) { @@ -920,76 +489,33 @@ function createWorkHubControllerImplementation(deps: { target, }; } - if (candidateSet) { - const candidate = candidateBySessionId.get(target.sessionId); - if (!candidate) { - throw new Error('WorkHub target Session is unavailable'); - } - const action: WorkHubCoordinationActInput = { - actionId: input.requestId, - userText: input.text, - candidateSetId: candidateSet.candidateSetId, - proposal: { - disposition: 'delegate_existing', - candidateRef: candidate.candidateRef, - }, - }; - const admitted = await coordination.act(action); - if (admitted.disposition !== 'delegate_existing') { - throw new Error('WorkHub Action Gate returned an unexpected disposition'); - } - target = { sessionId: admitted.targetSessionId }; - submissionPolicy.rememberTarget(target); - return { - kind: 'submitted', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: input.requestId, - target, - turnId: admitted.targetTurnId, - ...(admitted.steered ? { steered: true as const } : {}), - evidence, - }; - } - if (correction) { - await stopOwnedRoots(correction, submissionOrder); - } - assertSubmissionBarrierOpen(target); - const reservedTurnId = deps.sessions.reserveTurnId(); - reserveOwnedRoot(target, reservedTurnId, submissionOrder); - let turn: { turnId: string; steered?: true }; - try { - turn = await deps.sessions.submit(target, input.text, reservedTurnId); - } catch (error) { - if ( - error instanceof WorkHubSessionSubmitError && - error.admission === 'rejected' - ) { - releasePendingRoot(target, reservedTurnId, submissionOrder); - } else { - markPendingRootUncertain(target, reservedTurnId, submissionOrder); - try { - await reconcilePendingRoot(target, reservedTurnId, submissionOrder); - } catch { - // The original delivery error remains primary. Reconciliation keeps - // any unresolved admission reachable for a later read/correction. - } - } - throw error; + const candidate = candidateBySessionId.get(target.sessionId); + if (!candidate) { + throw new Error('WorkHub target Session is unavailable'); } - await settleOwnedRoot(target, reservedTurnId, turn, submissionOrder); - submissionPolicy.rememberTarget(target); - if (correction) { - submissionPolicy.rememberCorrection(input.text, target, submissionOrder); + const action: WorkHubCoordinationActInput = { + actionId: input.requestId, + userText: input.text, + candidateSetId: candidateSet.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidate.candidateRef, + }, + }; + const admitted = await coordination.act(action); + if (admitted.disposition !== 'delegate_existing') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); } + const admittedTarget = { sessionId: admitted.targetSessionId }; + submissionPolicy.rememberTarget(admittedTarget); return { kind: 'submitted', strategyId: WORKHUB_ROUTING_STRATEGY_ID, requestId: input.requestId, - target, - turnId: turn.turnId, - ...(turn.steered ? { steered: true as const } : {}), - evidence, - ...(correction ? { correctedFrom: correction.from } : {}), + target: admittedTarget, + turnId: admitted.targetTurnId, + ...(admitted.steered ? { steered: true as const } : {}), + evidence: decision.evidence, }; }, resetVisitContext() { @@ -999,24 +525,3 @@ function createWorkHubControllerImplementation(deps: { }, }; } - -function legacyTestCoordinationPort(): WorkHubCoordinationPort { - return { - async open(handler) { - handler([]); - return { close: async () => undefined }; - }, - async answer(input) { - return { turnId: input.turnId }; - }, - async record(input) { - return { turnId: input.turnId }; - }, - async candidates() { - throw new Error('The legacy WorkHub test adapter does not expose Action Gate candidates'); - }, - async act() { - throw new Error('The legacy WorkHub test adapter does not expose Action Gate actions'); - }, - }; -} diff --git a/apps/desktop/src/renderer/workhub-coordination-host-scope.ts b/apps/desktop/src/renderer/workhub-coordination-host-scope.ts index 9c89f537a6..e6277688ab 100644 --- a/apps/desktop/src/renderer/workhub-coordination-host-scope.ts +++ b/apps/desktop/src/renderer/workhub-coordination-host-scope.ts @@ -18,10 +18,7 @@ */ import { parseDesktopSessionKey } from '../shared/runtime-host-identity.js'; -import type { - WorkHubCoordinationHostSessionCreator, - WorkHubDesktopSessionBridge, -} from './workhub-session-port.js'; +import type { WorkHubDesktopSessionBridge } from './workhub-session-port.js'; export interface WorkHubCoordinationHostAuthority { readonly sessionId: string | undefined; @@ -29,11 +26,10 @@ export interface WorkHubCoordinationHostAuthority { readonly isCurrent: () => boolean; } -/** Restricts the transitional WorkHub router to the Coordination Session's Host. */ +/** Restricts WorkHub's read-only Session projection to the Coordination Session's Host. */ export function scopeWorkHubSessionsToCoordinationHost( sessions: WorkHubDesktopSessionBridge, coordination: WorkHubCoordinationHostAuthority, - createOnCoordinationHost: WorkHubCoordinationHostSessionCreator, ): WorkHubDesktopSessionBridge { const coordinationSessionId = coordination.sessionId; const hostId = (() => { @@ -78,21 +74,6 @@ export function scopeWorkHubSessionsToCoordinationHost( requireTargetHost(sessionId); return await sessions.queryMessageExecutions(sessionId, messageIds); }, - async create(input: { name: string }) { - requireHost(); - return await createOnCoordinationHost(coordinationSessionId!, input); - }, - async send(sessionId: string, command: { type: 'send'; turnId: string; text: string }) { - requireTargetHost(sessionId); - return sessions.send(sessionId, command); - }, - async stop( - sessionId: string, - input?: { source?: 'stop_button'; expectedTurnId?: string }, - ) { - requireTargetHost(sessionId); - await sessions.stop(sessionId, input); - }, subscribeChanges: (handler: () => void) => sessions.subscribeChanges(handler), } satisfies WorkHubDesktopSessionBridge; const listWithCoverage = sessions.listWithCoverage; diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 97f15864d1..7869470c76 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -54,7 +54,6 @@ export class WorkHubCoordinationFailure extends Error { export function createDesktopWorkHubCoordinationPort(deps: { sessionId: string; transcripts: WorkHubDesktopTranscriptBridge; - answer(input: { turnId: string; text: string }): Promise<{ turnId: string }>; record(input: { turnId: string; userText: string; @@ -66,7 +65,6 @@ export function createDesktopWorkHubCoordinationPort(deps: { ): Promise>; }): WorkHubCoordinationPort { return { - answer: deps.answer, record: deps.record, candidates: deps.candidates, async act(input) { diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index 907892e730..4dd073bf7a 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -54,8 +54,6 @@ export interface WorkHubRoutePolicy { initializeFocus(targets: readonly WorkHubSessionTarget[]): void; newVisit(): WorkHubRoutePolicy; rememberTarget(target: WorkHubSessionTarget): void; - reserveSubmissionOrder(): number; - rememberCorrection(text: string, target: WorkHubSessionTarget, order: number): void; } export function workHubNewSessionName(text: string): string { @@ -75,20 +73,7 @@ export function workHubNewSessionName(text: string): string { return firstClause?.slice(0, 48) || '新工作'; } -interface RouteCorrection { - text: string; - target: WorkHubSessionTarget; - sequence: number; -} - -interface RouteCorrectionMemory { - corrections: RouteCorrection[]; - sequence: number; -} - -const MAX_ROUTE_CORRECTIONS = 32; const MIN_EXACT_SESSION_NAME_LENGTH = 2; -const MIN_CORRECTION_TERM_LENGTH = 3; // One four-character Han phrase is usually a meaningful entity rather than // grammar; Latin needs either two whole-word matches or one distinctive word. const MIN_STRONG_HAN_MATCH_LENGTH = 4; @@ -104,15 +89,12 @@ const MAX_RELATED_CLARIFICATION_OPTIONS = 4; * execution state, and recovery continue to come from the Session port. */ export function createWorkHubRoutePolicy(): WorkHubRoutePolicy { - return createWorkHubRoutePolicyVisit({ corrections: [], sequence: 0 }); + return createWorkHubRoutePolicyVisit(); } -function createWorkHubRoutePolicyVisit( - correctionMemory: RouteCorrectionMemory, -): WorkHubRoutePolicy { +function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy { let currentFocus: WorkHubSessionTarget | undefined; let previousFocus: WorkHubSessionTarget | undefined; - const corrections = correctionMemory.corrections; return { resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { @@ -201,11 +183,6 @@ function createWorkHubRoutePolicyVisit( return { kind: 'clarification', options: options.slice(0, MAX_UNCERTAINTY_OPTIONS) }; } - const corrected = correctedTarget(text, sessions, corrections); - if (corrected) { - return { kind: 'target', target: corrected, evidence: 'route_correction' }; - } - const previousReference = looksLikePreviousFocus(text); const currentReference = !previousReference && looksLikeRecentFocus(text); const focusCandidate = previousReference @@ -280,22 +257,13 @@ function createWorkHubRoutePolicyVisit( } }, newVisit() { - return createWorkHubRoutePolicyVisit(correctionMemory); + return createWorkHubRoutePolicyVisit(); }, rememberTarget(target) { if (currentFocus?.sessionId === target.sessionId) return; previousFocus = currentFocus; currentFocus = target; }, - reserveSubmissionOrder() { - correctionMemory.sequence += 1; - return correctionMemory.sequence; - }, - rememberCorrection(text, target, order) { - corrections.push({ text, target, sequence: order }); - corrections.sort((left, right) => right.sequence - left.sequence); - corrections.splice(MAX_ROUTE_CORRECTIONS); - }, }; } @@ -316,30 +284,6 @@ function rankExactSessions( .sort((left, right) => right.matchLength - left.matchLength); } -function correctedTarget( - text: string, - sessions: WorkHubSessionFacts[], - corrections: readonly RouteCorrection[], -): WorkHubSessionTarget | undefined { - const queryTerms = new Set(routingTerms(text)); - if (queryTerms.size === 0) return undefined; - const available = new Set(sessions.map((session) => session.target.sessionId)); - const ranked = corrections - .filter((correction) => available.has(correction.target.sessionId)) - .map((correction) => { - const matches = routingTerms(correction.text).filter((term) => queryTerms.has(term)); - return { - correction, - score: matches.reduce((total, term) => total + term.length, 0), - longestMatch: matches.reduce((longest, term) => Math.max(longest, term.length), 0), - }; - }) - .filter(({ longestMatch }) => longestMatch >= MIN_CORRECTION_TERM_LENGTH) - .sort((left, right) => - right.score - left.score || right.correction.sequence - left.correction.sequence); - return ranked[0]?.correction.target; -} - function normalizeIdentityText(value: string): string { return value.toLocaleLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ''); } diff --git a/apps/desktop/src/renderer/workhub-session-port.ts b/apps/desktop/src/renderer/workhub-session-port.ts index b954007e5d..9bf586d3bd 100644 --- a/apps/desktop/src/renderer/workhub-session-port.ts +++ b/apps/desktop/src/renderer/workhub-session-port.ts @@ -37,10 +37,7 @@ import type { WorkHubSessionState, WorkHubSessionTarget, } from './workhub-controller.js'; -import { - boundedWorkHubTimelineText, - WorkHubSessionSubmitError, -} from './workhub-controller.js'; +import { boundedWorkHubTimelineText } from './workhub-controller.js'; export interface WorkHubDesktopSession { id: string; @@ -76,25 +73,9 @@ export interface WorkHubDesktopSessionBridge { | { messageId: string; state: 'owned'; turnId: string; runId: string } )[]; }>; - create(input: { name: string }): Promise; - send( - sessionId: string, - command: { type: 'send'; turnId: string; text: string }, - ): Promise< - { ok: true; turnId: string; steered?: true } | { ok: false; reason: string } - >; - stop( - sessionId: string, - input?: { source?: 'stop_button'; expectedTurnId?: string }, - ): Promise; subscribeChanges(handler: () => void): () => void; } -export type WorkHubCoordinationHostSessionCreator = ( - coordinationSessionId: string, - input: { name: string }, -) => Promise; - export interface WorkHubDesktopTranscriptBridge { open( sessionId: string, @@ -107,11 +88,12 @@ const WORKHUB_TIMELINE_SESSION_LIMIT = 10; const WORKHUB_TIMELINE_TURN_LIMIT = 40; const WORKHUB_TRANSCRIPT_READY_TIMEOUT_MS = 5_000; -export function createDesktopWorkHubSessionPort(deps: { - sessions: WorkHubDesktopSessionBridge; +export function createDesktopWorkHubSessionPort< + Sessions extends WorkHubDesktopSessionBridge, +>(deps: { + sessions: Sessions; transcripts: WorkHubDesktopTranscriptBridge; projectName(projectId: string): string | undefined; - newTurnId(): string; }): WorkHubSessionPort { // The first prompt is immutable Session-log evidence. This cache is only a // rebuildable read optimization; it is never an authority or a write path. @@ -164,9 +146,6 @@ export function createDesktopWorkHubSessionPort(deps: { async list() { return (await projectCatalog()).sessions; }, - listCatalog() { - return projectCatalog(); - }, async recentTurns(targets) { const turnsBySession = await Promise.all( targets.slice(0, WORKHUB_TIMELINE_SESSION_LIMIT).map(async (target) => { @@ -283,72 +262,6 @@ export function createDesktopWorkHubSessionPort(deps: { } })); }, - async create({ name }) { - return projectSession(await deps.sessions.create({ name })); - }, - reserveTurnId() { - return deps.newTurnId(); - }, - async submit(target: WorkHubSessionTarget, text: string, turnId: string) { - let result: Awaited>; - try { - result = await deps.sessions.send(target.sessionId, { - type: 'send', - turnId, - text, - }); - } catch (cause) { - throw new WorkHubSessionSubmitError( - 'WorkHub Session delivery outcome is unknown', - 'unknown', - { cause }, - ); - } - if (!result.ok) { - // `outcome_unknown` is the Host declining to prove what happened, not a - // refusal: the Message may already be running, so it stays reachable - // for reconciliation rather than being released. - throw new WorkHubSessionSubmitError( - `WorkHub Session send failed: ${result.reason}`, - result.reason === 'outcome_unknown' ? 'unknown' : 'rejected', - ); - } - return { - turnId: result.turnId, - ...(result.steered ? { steered: true as const } : {}), - }; - }, - async reconcileSubmission(target, reservedTurnId) { - try { - const messages = await readWorkHubSessionMessages(deps.transcripts, target); - let message: Extract | undefined; - for (let index = messages.length - 1; index >= 0; index -= 1) { - const entry = messages[index]; - if ( - entry?.type === 'user' && - (entry.turnId === reservedTurnId || entry.id === reservedTurnId) - ) { - message = entry; - break; - } - } - if (!message) return { kind: 'unknown' }; - if (message.turnId === reservedTurnId) { - return { kind: 'root', turnId: message.turnId }; - } - return message.steeringEventId - ? { kind: 'steered' } - : { kind: 'root', turnId: message.turnId }; - } catch { - return { kind: 'unknown' }; - } - }, - async stop(target, expectedTurnId) { - await deps.sessions.stop(target.sessionId, { - source: 'stop_button', - expectedTurnId, - }); - }, subscribe(handler) { return deps.sessions.subscribeChanges(handler); }, @@ -465,9 +378,11 @@ function projectDelegationExecutionState(input: { if (turn?.statusSource === 'recorded' && turn.status && turn.status !== 'running') { return turn.status; } - const ownsLiveTurn = session?.runningTurnIds?.includes(executionTurnId) === true; + const liveTurnIds = session?.runningTurnIds; + const ownsLiveTurn = liveTurnIds?.includes(executionTurnId) === true; if (ownsLiveTurn && session?.state === 'waiting_for_user') return 'waiting_for_user'; - if (ownsLiveTurn || (turn?.statusSource === 'recorded' && turn.status === 'running')) { + if (ownsLiveTurn) return 'running'; + if (liveTurnIds === undefined && turn?.statusSource === 'recorded' && turn.status === 'running') { return 'running'; } if (input.turnReadFailed || !session) return 'recovering'; diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 4f31f1431f..9b30146285 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -130,51 +130,6 @@ test('consumes an atomically committed active-target admission exactly once', as assert.equal(fixture.drainRequests(), 0); }); -test('idle recovery preserves the exact root identity chosen with a durable admission', async () => { - const fixture = createFixture(); - fixture.setRootState({ kind: 'idle' }); - const content = { text: 'recover the linked WorkHub assignment' }; - await fixture.admissions.commitMessageAdmission({ - ...ROOT, - messageId: 'workhub-linked-message', - content, - submittedContentDigest: messageContentDigest(content), - submittedPlacement: 'current_turn', - placement: 'current_turn', - disposition: 'steering', - admittedAt: 10, - }); - - await fixture.coordinator.consumePendingAdmissions([ROOT.sessionId]); - - assert.equal(fixture.recoveredBatches.length, 1); - assert.deepEqual(fixture.recoveredBatches[0]?.rootIdentity, { - turnId: ROOT.turnId, - runId: ROOT.runId, - }); -}); - -test('idle recovery does not reuse a predecessor identity for its queued successor', async () => { - const fixture = createFixture(); - fixture.setRootState({ kind: 'idle' }); - const content = { text: 'recover the queued successor' }; - await fixture.admissions.commitMessageAdmission({ - ...ROOT, - messageId: 'queued-successor-message', - content, - submittedContentDigest: messageContentDigest(content), - submittedPlacement: 'next_turn', - placement: 'next_turn', - disposition: 'followup', - admittedAt: 10, - }); - - await fixture.coordinator.consumePendingAdmissions([ROOT.sessionId]); - - assert.equal(fixture.recoveredBatches.length, 1); - assert.equal(fixture.recoveredBatches[0]?.rootIdentity, undefined); -}); - test('idle recovery resolves differently preassigned Messages to their shared successor Turn', async () => { const fixture = createFixture(); fixture.setRootState({ kind: 'idle' }); @@ -206,7 +161,6 @@ test('idle recovery resolves differently preassigned Messages to their shared su operationContext(), ); - assert.equal(fixture.recoveredBatches[0]?.rootIdentity, undefined); assert.deepEqual(resolved, { ok: true, result: { diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 65abd93b96..a0f2f62842 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -179,62 +179,6 @@ test('turn.start rejects the reserved WorkHub Coordination Session identity', as } }); -test('recovered Messages retain their durably assigned root identity', async () => { - const fixture = await createFailureFixture({ - registerBackend: (backends) => - backends.register('ai-sdk', (context) => new FakeBackend(context)), - }); - const turnId = 'workhub-linked-turn'; - const runId = 'workhub-linked-run'; - const messageId = 'workhub-linked-message'; - const content = { text: 'continue the linked assignment' }; - const source = { - messageId, - content, - submittedContentDigest: messageContentDigest(content), - placement: 'current_turn' as const, - disposition: 'steering' as const, - }; - try { - await fixture.stores.sessionStore.commitMessageAdmission({ - sessionId: fixture.sessionId, - turnId, - runId, - messageId, - content, - submittedContentDigest: source.submittedContentDigest, - submittedPlacement: 'current_turn', - placement: source.placement, - disposition: source.disposition, - admittedAt: 1, - }); - - const outcome = await fixture.sessionAdmission.run(fixture.sessionId, (lease) => - fixture.coordinator.startRecoveredMessages( - { - sessionId: fixture.sessionId, - content, - submittedContent: content, - sources: [source], - rootIdentity: { turnId, runId }, - }, - lease, - ), - ); - - assert.deepEqual(outcome, { turnId }); - const admission = await fixture.stores.agentRunStore.readRootTurnAdmission( - fixture.sessionId, - turnId, - ); - assert.equal(admission?.runId, runId); - } finally { - await fixture.coordinator.close(); - await fixture.messages.close(); - await fixture.dispose(); - } -}); - test('turn.start rejects a corrupt Coordination role on an ordinary identity', async () => { const fixture = await createFailureFixture({ registerBackend: (backends) => diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 11a953a119..0f3ec3f083 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -128,17 +128,6 @@ export interface HostMessageRecoveryBatch { readonly content: MessageContent; readonly submittedContent: MessageContent; readonly sources: readonly RootTurnSourceMessage[]; - /** - * A root identity durably chosen with the pending Messages. Recovery must - * preserve it so immutable links to the exact Turn keep naming the execution - * that is actually admitted. A batch only carries one when every pending - * current-Turn steering Message names the same root; next-Turn follow-ups - * name their predecessor and must receive a new successor identity. - */ - readonly rootIdentity?: { - readonly turnId: string; - readonly runId: string; - }; /** * What the recovered Message asked of its Turn. Only a lone Message can * carry one — exact-Turn intent needs an idle Session and opens its own root @@ -788,14 +777,12 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { 'Message recovery authority is unavailable', ); } - const rootIdentity = sharedPendingRootIdentity(pending); const started = await this.#root.startRecoveredMessages( { sessionId, content: aggregateMessageContents(pending.map((entry) => entry.content)), submittedContent: aggregateMessageContents(pending.map((entry) => entry.content)), sources: pending.map(pendingMessageSource), - ...(rootIdentity ? { rootIdentity } : {}), ...(pending.length === 1 && pending[0]!.submittedIntent ? { submittedIntent: pending[0]!.submittedIntent } : {}), @@ -2296,24 +2283,6 @@ function pendingMessageSource(admission: PendingMessageAdmission): RootTurnSourc }; } -function sharedPendingRootIdentity( - admissions: readonly PendingMessageAdmission[], -): HostMessageRecoveryBatch['rootIdentity'] { - const first = admissions[0]; - if (!first || first.placement !== 'current_turn' || first.disposition !== 'steering') { - return undefined; - } - return admissions.every( - (admission) => - admission.placement === 'current_turn' && - admission.disposition === 'steering' && - admission.turnId === first.turnId && - admission.runId === first.runId, - ) - ? { turnId: first.turnId, runId: first.runId } - : undefined; -} - function submittedProjectionContent(content: MessageContent): MessageContent { const normalized = normalizeMessageContent(content); const text = normalized.displayText ?? normalized.text; diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 90cca17a08..d631a5d990 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1170,14 +1170,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const reservation = this.reserveRootTurn(input.sessionId); if (!reservation) return { error: 'Another root Turn is being admitted' }; try { - // A steering admission can preassign a future root while the Session - // is idle. An identity already in the owned chain instead names the - // predecessor that accepted the Message and must not become its own - // successor. - const latestAdmission = this.rootAdmissionOwner.latestAdmission(input.sessionId); - const rootIdentity = - input.rootIdentity?.turnId === latestAdmission?.turnId ? undefined : input.rootIdentity; - const turnId = rootIdentity?.turnId ?? randomUUID(); + const turnId = randomUUID(); // The recovered Message asked for this mode before the Host stopped; // admitting without it would run a different Turn than was requested. const turnOrchestration = input.submittedIntent?.turnOrchestration; @@ -1185,7 +1178,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const admitted = await this.rootAdmissionOwner.admitRootTurn({ sessionId: input.sessionId, turnId, - proposedRunId: rootIdentity?.runId ?? randomUUID(), + proposedRunId: randomUUID(), proposedUserMessageId: input.sources.length === 1 ? input.sources[0]!.messageId : null, execution: { kind: 'external_message',