diff --git a/apps/desktop/src/main/__tests__/workhub-runtime.test.ts b/apps/desktop/src/main/__tests__/workhub-runtime.test.ts index 32e10d2ba23..b5bde91bf14 100644 --- a/apps/desktop/src/main/__tests__/workhub-runtime.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-runtime.test.ts @@ -20,6 +20,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; +import { workHubTasksSchema } from '../../shared/workhub-tool-schema.js'; import { createWorkHubRuntime } from '../workhub-runtime.js'; const scope = { hostId: 'host', targetEpoch: 'epoch' }; @@ -59,6 +60,68 @@ test('task delegation binds the tool action to the Host turn and trusted creatio assert.deepEqual(f.changes, [[scope, 'created', 'target']]); }); +test('linked task operations remain operations at the Host protocol boundary', async () => { + const f = fixture(); + await f.runtime.actTasks(scope, 'turn', 'correct-action', { + operation: 'correct', + replacesActionId: 'old-action', + candidateSetId: 'set', + target: { disposition: 'delegate_existing', candidateRef: 'candidate' }, + text: 'Move the delegated work', + }); + await f.runtime.actTasks(scope, 'turn', 'stop-action', { + operation: 'stop', + targetSessionId: 'target', + }); + await f.runtime.actTasks(scope, 'turn', 'resume-action', { + operation: 'resume', + targetSessionId: 'target', + resumesActionId: 'stop-action', + }); + + assert.deepEqual(f.requests, [ + { + turnId: 'turn', + actionId: 'correct-action', + proposal: { + operation: 'correct', + replacesActionId: 'old-action', + target: { disposition: 'delegate_existing', candidateRef: 'candidate' }, + }, + delegationText: 'Move the delegated work', + candidateSetId: 'set', + }, + { + turnId: 'turn', + actionId: 'stop-action', + proposal: { operation: 'stop', expects: { targetSessionId: 'target' } }, + }, + { + turnId: 'turn', + actionId: 'resume-action', + proposal: { + operation: 'resume', + resumesActionId: 'stop-action', + expects: { targetSessionId: 'target' }, + }, + }, + ]); +}); + +test('the task tool exposes correction as a linked operation, not a disposition', () => { + const correction = { + operation: 'correct', + replacesActionId: 'old-action', + target: { disposition: 'create_new', title: 'Replacement' }, + text: 'Correct the earlier delegation', + }; + assert.deepEqual(workHubTasksSchema.parse(correction), correction); + assert.equal( + workHubTasksSchema.safeParse({ ...correction, operation: 'replace' }).success, + false, + ); +}); + test('a Host switch while resolving the workspace prevents delegation', async () => { const f = fixture(); const original = f.deps.createContext; diff --git a/apps/desktop/src/main/workhub-runtime.ts b/apps/desktop/src/main/workhub-runtime.ts index d211773f70e..df8005cbbce 100644 --- a/apps/desktop/src/main/workhub-runtime.ts +++ b/apps/desktop/src/main/workhub-runtime.ts @@ -63,12 +63,15 @@ export function createWorkHubRuntime(deps: WorkHubRuntimeDeps) { switch (input.operation) { case 'delegate_existing': proposal = { disposition: 'delegate_existing', candidateRef: input.candidateRef }; break; case 'create_new': proposal = { disposition: 'create_new', title: input.title }; break; - case 'replace': proposal = { disposition: 'replace', replacesActionId: input.replacesActionId, target: input.target }; break; - case 'stop': proposal = { disposition: 'stop_work', expects: { targetSessionId: input.targetSessionId } }; break; - case 'resume': proposal = { disposition: 'resume_work', resumesActionId: input.resumesActionId, expects: { targetSessionId: input.targetSessionId } }; break; + case 'correct': proposal = { operation: 'correct', replacesActionId: input.replacesActionId, target: input.target }; break; + case 'stop': proposal = { operation: 'stop', expects: { targetSessionId: input.targetSessionId } }; break; + case 'resume': proposal = { operation: 'resume', resumesActionId: input.resumesActionId, expects: { targetSessionId: input.targetSessionId } }; break; } - const createsTarget = proposal.disposition === 'create_new' || - (proposal.disposition === 'replace' && proposal.target.disposition === 'create_new'); + const createsTarget = + ('disposition' in proposal && proposal.disposition === 'create_new') || + ('operation' in proposal && + proposal.operation === 'correct' && + proposal.target.disposition === 'create_new'); const context = createsTarget ? await deps.createContext(scope) : undefined; requireCurrent(scope); const result = await client.actWorkHubCoordinationFromTurn({ diff --git a/apps/desktop/src/shared/workhub-tool-schema.ts b/apps/desktop/src/shared/workhub-tool-schema.ts index 89cd878e509..4016b9ad58b 100644 --- a/apps/desktop/src/shared/workhub-tool-schema.ts +++ b/apps/desktop/src/shared/workhub-tool-schema.ts @@ -187,7 +187,7 @@ export const workHubTasksSchema = z.discriminatedUnion("operation", [ ), z .object({ - operation: z.literal("replace"), + operation: z.literal("correct"), replacesActionId: z.string().min(1), candidateSetId: z.string().min(1).optional(), target: delegationTarget, diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 6ae51da940d..4e235d9c172 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -53,7 +53,7 @@ durable conversation and execution substrate. The role is provisioned lazily when WorkHub first needs it and resolves to the same Session after Runtime Host or application restarts. The Session role representation, lookup, recovery, and per-Host UI resolution enforce this lifecycle contract. The -coordination transcript and typed dispositions use that same Session substrate. +coordination transcript and typed action proposals use that same Session substrate. The per-Host boundary is intentional. A Coordination Session coordinates only the ordinary Sessions belonging to the same Runtime Host. Switching Runtime Hosts @@ -76,9 +76,9 @@ ordinary navigation and target discovery. The Coordination Session is authoritative only for the coordination conversation. It never acquires authority over an ordinary Session's execution or lifecycle. -## Dispositions and action admission +## Routing dispositions, linked operations, and admission -Every WorkHub input resolves to exactly one proposed **disposition**: +Every ordinary routing input resolves to exactly one proposed **routing disposition**: - `answer_here`: answer in the Coordination Session. - `delegate_existing`: delegate concrete work to one bounded, valid ordinary @@ -87,12 +87,34 @@ Every WorkHub input resolves to exactly one proposed **disposition**: - `clarify`: continue clarification in the Coordination Session without guessing a target or creating a Session. -Linked correction is a user-confirmed coordination operation over a prior durable -delegation, not a model disposition. Its replacement target is still restricted to -`delegate_existing` or explicit `create_new` admission. +Correction, stop, and resume are **linked operations** over a prior durable +delegation, not additional routing dispositions. Correction's replacement target +is still restricted to `delegate_existing` or explicit `create_new` admission. +Stop and resume follow the durable delegation-to-Session-to-Turn lineage rather +than inferring an operation target from a similarly named Session. + +The decision flow is: + +```text +user input + -> intent analysis + -> Session Resolver or linked-target resolution + -> Coordination policy + -> routing disposition or linked-operation proposal + -> deterministic Action Gate + -> owning Host / Session +``` + +Intent describes what the user wants; it does not select authority. The Session +Resolver returns bounded existing-Session evidence and never creates a Session. +Linked-target resolution starts from a bounded WorkHub-owned delegation and +follows its durable Message, Turn, and continuation lineage. Coordination policy +combines that evidence into an advisory proposal. Missing, stale, or ambiguous +linkage fails closed instead of falling back to name similarity. All model and routing output is advisory. Before any write, a deterministic -**Action Gate** admits or rejects the proposed disposition and operation. The gate +**Action Gate** admits or rejects the proposed routing disposition or linked +operation. The gate enforces Runtime Host and target validity, archive and waiting state, self-route exclusion, explicit `create_new`, and existing tool and permission ceilings. For a replacement, the gate additionally requires explicit correction evidence in the @@ -100,23 +122,19 @@ trusted user text, claims the source delegation in Coordination transcript order and rejects any later competing replacement intent. Neither a model nor a routing policy can directly authorize a write, Stop, or expansion of execution authority. -Routing experiments replace Action Intent classification and/or Session Resolver -recall behind the fixed Action Policy and unchanged Action Gate. A strategy names -one Intent component and one Resolver component; it has no proposal-producing -`resolve()` method and owns no visit focus. R2.4 pairs deterministic components; -R3-A pairs model-assisted intent with model-ranked recall; R3-B pairs model-assisted -intent with deterministic recall. These are experiment configurations, not separate -policy implementations or a production model rollout. - -Intent output contains no target. Resolver output contains only ranked or ambiguous -opaque candidate references, or no match; it cannot return creation or a disposition. -The controller shares one bounded candidate context across arms; deterministic -components retain full request text, while model adapters bound text at the model -call boundary. The controller passes validated evidence through the same Policy with the same trusted Session snapshot. -A model recall budget does not hide known Sessions from exact-name or correction -rules in that fixed Policy. Policy retains trusted-text creation, -ambiguity, correction and focus constraints. Model ranking alone cannot authorize -work, and every resulting proposal still goes through the Host-owned Gate. +The current production path performs intent analysis and Coordination policy through +the Coordination model's active-Turn task actions. Bounded candidate discovery is +the Session Resolver input to that decision; the transient proposal interface keeps +routing dispositions separate from linked operations. `answer_here` and `clarify` +remain transcript outcomes; only the side-effecting routing subset crosses the Host +proposal boundary. Historical R2.4/R3 experiment configurations remain evaluation +evidence, not a second production policy or a renderer-owned language authority. + +Intent output contains no target. Session Resolver output contains only bounded +opaque candidate references; it cannot return creation or a disposition. Linked +target evidence is likewise advisory and cannot prove ownership. Model ranking or +tool selection alone cannot authorize work, and every resulting proposal still goes +through the Host-owned Gate with the original trusted user request. ## Delegation links rather than copies transcripts diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md index a2d4efee3ff..bf20a43f10a 100644 --- a/docs/workhub-domain-language.md +++ b/docs/workhub-domain-language.md @@ -36,9 +36,32 @@ its state without copying its execution transcript or acquiring ownership of unrelated user work. **Coordination model** interprets the admitted user request. Local answers and -clarifications are normal model output. Task tools can propose `delegate_existing`, -`create_new`, `replace`, `stop_work`, or `resume_work`; there is no parallel -renderer classifier, exact-name resolver, or synthetic-summary writer. +clarifications are normal model output. Its task actions produce advisory routing +or linked-operation proposals; there is no parallel renderer classifier, +exact-name resolver, or synthetic-summary writer. + +**Routing intent** begins a new coordination decision, such as discussing, +executing, creating, or continuing work. _Avoid_: linked intent, disposition. + +**Linked intent** refers to a delegation WorkHub already owns and asks to correct, +stop, or resume it. _Avoid_: routing intent, disposition. + +**Routing disposition** is the closed outcome of a new routing decision: +`answer_here`, `delegate_existing`, `create_new`, or `clarify`. _Avoid_: stop, +resume, correction. + +**Linked operation** changes an existing delegation relationship: `correct`, +`stop`, or `resume`. It is not a routing disposition. + +**Session Resolver** recalls and ranks bounded existing ordinary Sessions for a +routing intent. It returns target evidence, never creation or execution authority. + +**Linked-target resolution** starts from a bounded WorkHub-owned delegation and +follows its Session, Message, Turn, and continuation lineage. It does not infer an +operation target from a similar display name. + +**Coordination policy** combines intent and resolved evidence into either a routing +disposition or a linked-operation proposal. Its output remains advisory. **Active-Turn action** names the currently executing coordination Turn. The Host checks its live execution, durable admission and coordination tool profile, then @@ -48,9 +71,9 @@ supply its own user-originated authority or attachment locators. Model-prepared **Action Gate** validates the resulting operation against durable ownership, candidate freshness, Session identity, archive/waiting state and existing claims. -The model selects an operation; the gate determines whether that exact operation -can be admitted. Creation workspace context comes from Desktop main, outside the -model proposal. Each target still executes under its own permission boundary. +The policy proposes an action; the gate determines whether that exact action can +be admitted. Creation workspace context comes from Desktop main, outside the model +proposal. Each target still executes under its own permission boundary. ## Delegation and recovery diff --git a/packages/core/src/workhub-action-result.ts b/packages/core/src/workhub-action-result.ts index b993249dec4..48736542af1 100644 --- a/packages/core/src/workhub-action-result.ts +++ b/packages/core/src/workhub-action-result.ts @@ -19,6 +19,11 @@ import { isRecord } from './record-schema.js'; +/** + * Durable coordination receipt schema. The linked-operation variants retain + * their historical `disposition` tags so existing records and replay + * fingerprints stay compatible; transient proposals use `operation` instead. + */ export type WorkHubActionResult = | { readonly disposition: 'answer_here'; readonly coordinationTurnId: string } | { readonly disposition: 'clarify'; readonly coordinationTurnId: string } diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 1ab5c3a394c..299590322b9 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -1001,7 +1001,7 @@ test('WorkHub creates new work through the production assignment composition', a actionId: 'workhub-create-stop-action', userText: 'Stop Login stability', proposal: { - disposition: 'stop_work', + operation: 'stop', expects: { targetSessionId }, }, }, @@ -1141,7 +1141,7 @@ test('WorkHub Resume and Stop follow logical lineage across repeated physical ha actionId: 'workhub-resume-stop-resume', userText: 'Resume Payments', proposal: { - disposition: 'resume_work', + operation: 'resume', resumesActionId: 'workhub-resume-stop-delegation', expects: { targetSessionId: target.id }, }, @@ -1185,7 +1185,7 @@ test('WorkHub Resume and Stop follow logical lineage across repeated physical ha actionId: 'workhub-resume-stop-resume', userText: 'Resume Payments', proposal: { - disposition: 'resume_work' as const, + operation: 'resume' as const, resumesActionId: 'workhub-resume-stop-delegation', expects: { targetSessionId: target.id }, }, @@ -1243,7 +1243,7 @@ test('WorkHub Resume and Stop follow logical lineage across repeated physical ha actionId: 'workhub-resume-stop-stop', userText: 'Stop Payments', proposal: { - disposition: 'stop_work', + operation: 'stop', expects: { targetSessionId: target.id }, }, }, @@ -1340,7 +1340,7 @@ test('WorkHub does not record resume while safe-boundary resume is disabled', as actionId, userText: 'Resume Payments', proposal: { - disposition: 'resume_work', + operation: 'resume', resumesActionId: 'workhub-disabled-resume-delegation', expects: { targetSessionId: target.id }, }, @@ -1459,7 +1459,7 @@ test('WorkHub correction replaces its link without stopping a shared manual Turn actionId: 'workhub-stop-shared-action', userText: `Stop ${sourceCandidate.sessionName}`, proposal: { - disposition: 'stop_work', + operation: 'stop', expects: { targetSessionId: source.id }, }, }, @@ -1510,7 +1510,7 @@ test('WorkHub correction replaces its link without stopping a shared manual Turn userText: `No, move this to ${correctionDestination.sessionName} instead`, candidateSetId: correctionCandidates.result.candidateSetId, proposal: { - disposition: 'replace', + operation: 'correct', replacesActionId: assignment.actionId, target: { disposition: 'delegate_existing', diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 057c1610193..a126e91887e 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -109,7 +109,7 @@ describe('WorkHub Coordination Action Gate', () => { candidateSetId: candidates.candidateSetId, proposal: { - disposition: 'replace' as const, + operation: 'correct' as const, replacesActionId: 'source-action', target: { disposition: 'delegate_existing' as const, @@ -227,17 +227,17 @@ describe('WorkHub Coordination Action Gate', () => { }); /** - * A stop proposal as the Action Policy produces it: opaque identities plus + * A stop proposal as Coordination policy produces it: opaque identities plus * the active-delegation state it resolved against, never a display name. */ const stopProposal = (targetSessionId: string) => ({ - disposition: 'stop_work' as const, + operation: 'stop' as const, expects: { targetSessionId }, }); const resumeProposal = (targetSessionId: string, resumesActionId = 'source-action') => ({ resumesActionId, - disposition: 'resume_work' as const, + operation: 'resume' as const, expects: { targetSessionId }, }); @@ -1119,7 +1119,7 @@ describe('WorkHub Coordination Action Gate', () => { userText: 'No, create a new Session for Payments instead', proposal: { - disposition: 'replace' as const, + operation: 'correct' as const, replacesActionId: 'source-action', target: { disposition: 'create_new' as const, title: 'Payments' }, }, @@ -1274,7 +1274,7 @@ describe('WorkHub Coordination Action Gate', () => { userText: 'No, send this to destination', candidateSetId: snapshot.candidateSetId, proposal: { - disposition: 'replace' as const, + operation: 'correct' as const, replacesActionId: 'source-action', target: { disposition: 'delegate_existing' as const, @@ -1323,7 +1323,7 @@ describe('WorkHub Coordination Action Gate', () => { candidateSetId: snapshot.candidateSetId, proposal: { - disposition: 'replace' as const, + operation: 'correct' as const, replacesActionId: 'source-action', target: { disposition: 'delegate_existing' as const, @@ -1415,7 +1415,7 @@ describe('WorkHub Coordination Action Gate', () => { candidateSetId: snapshot.candidateSetId, proposal: { - disposition: 'replace' as const, + operation: 'correct' as const, replacesActionId: 'source-action', target: { disposition: 'delegate_existing' as const, @@ -1502,7 +1502,7 @@ describe('WorkHub Coordination Action Gate', () => { candidateSetId: snapshot.candidateSetId, proposal: { - disposition: 'replace', + operation: 'correct', replacesActionId: 'source-action', target: { disposition: 'delegate_existing', @@ -1557,7 +1557,7 @@ describe('WorkHub Coordination Action Gate', () => { candidateSetId: snapshot.candidateSetId, proposal: { - disposition: 'replace' as const, + operation: 'correct' as const, replacesActionId: 'source-action', target: { disposition: 'delegate_existing' as const, @@ -1619,7 +1619,7 @@ describe('WorkHub Coordination Action Gate', () => { candidateSetId: snapshot.candidateSetId, proposal: { - disposition: 'replace' as const, + operation: 'correct' as const, replacesActionId: 'source-action', target: { disposition: 'delegate_existing' as const, @@ -1673,7 +1673,7 @@ describe('WorkHub Coordination Action Gate', () => { candidateSetId: snapshot.candidateSetId, proposal: { - disposition: 'replace' as const, + operation: 'correct' as const, replacesActionId: 'source-action', target: { disposition: 'delegate_existing' as const, diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index 63ea966241e..a13c2705638 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -154,7 +154,7 @@ describe('Host WorkHub Coordination coordinator', () => { const stop = { turnId: 'active-turn', actionId: 'model-stop-call', - proposal: { disposition: 'stop_work' as const, expects: { targetSessionId: target.id } }, + proposal: { operation: 'stop' as const, expects: { targetSessionId: target.id } }, }; canonical = { text: '可以把 Payments 停一下了' }; const stopped = await workhub.handlers['workhub.coordination.actFromTurn'](stop, CONTEXT); @@ -1050,7 +1050,7 @@ describe('Host WorkHub Coordination coordinator', () => { candidateSetId: staleCandidates.result.candidateSetId, proposal: { - disposition: 'replace', + operation: 'correct', replacesActionId: staleSource.actionId, target: { disposition: 'delegate_existing', @@ -1118,7 +1118,7 @@ describe('Host WorkHub Coordination coordinator', () => { actionId: 'stop-action', userText: 'Stop Payments', proposal: { - disposition: 'stop_work', + operation: 'stop', expects: { targetSessionId: target.id }, }, }, @@ -1158,7 +1158,7 @@ describe('Host WorkHub Coordination coordinator', () => { actionId: 'stop-action', userText: 'Stop Payments', proposal: { - disposition: 'stop_work', + operation: 'stop', expects: { targetSessionId: targetId }, }, }, @@ -1223,7 +1223,7 @@ describe('Host WorkHub Coordination coordinator', () => { actionId: 'stop-action', userText: 'Stop Payments', proposal: { - disposition: 'stop_work', + operation: 'stop', expects: { targetSessionId: target.id }, }, }, @@ -1251,7 +1251,7 @@ describe('Host WorkHub Coordination coordinator', () => { actionId: 'resume-action', userText: 'Resume Payments', proposal: { - disposition: 'resume_work' as const, + operation: 'resume' as const, resumesActionId: 'source-action', expects: { targetSessionId: targetId }, }, @@ -1371,7 +1371,7 @@ describe('Host WorkHub Coordination coordinator', () => { actionId: 'resume-after-recovery', userText: 'Resume Payments', proposal: { - disposition: 'resume_work' as const, + operation: 'resume' as const, resumesActionId: 'source-action', expects: { targetSessionId: target.id }, }, @@ -1488,7 +1488,7 @@ describe('Host WorkHub Coordination coordinator', () => { actionId: 'stop-racing-action', userText: 'Stop Payments', proposal: { - disposition: 'stop_work', + operation: 'stop', expects: { targetSessionId: target.id }, }, }, @@ -1517,7 +1517,7 @@ describe('Host WorkHub Coordination coordinator', () => { actionId: 'stop-action', userText: 'Stop Payments', proposal: { - disposition: 'stop_work' as const, + operation: 'stop' as const, expects: { targetSessionId: targetId }, }, }); @@ -1696,7 +1696,7 @@ describe('Host WorkHub Coordination coordinator', () => { { actionId: 'stop-action', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', expects: { targetSessionId: target.id } }, + proposal: { operation: 'stop', expects: { targetSessionId: target.id } }, }, CONTEXT, ); @@ -1819,7 +1819,7 @@ describe('Host WorkHub Coordination coordinator', () => { { actionId: 'stop-action', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', expects: { targetSessionId: target.id } }, + proposal: { operation: 'stop', expects: { targetSessionId: target.id } }, }, CONTEXT, ); @@ -1885,7 +1885,7 @@ describe('Host WorkHub Coordination coordinator', () => { const stopInput = { actionId: 'stop-action', userText: 'Stop Payments', - proposal: { disposition: 'stop_work' as const, expects: { targetSessionId: target.id } }, + proposal: { operation: 'stop' as const, expects: { targetSessionId: target.id } }, }; assert.equal( await store.claimWorkHubAction({ @@ -2014,7 +2014,7 @@ describe('Host WorkHub Coordination coordinator', () => { { actionId: 'stop-action', userText: 'Stop Payments', - proposal: { disposition: 'stop_work', expects: { targetSessionId: payments.id } }, + proposal: { operation: 'stop', expects: { targetSessionId: payments.id } }, }, CONTEXT, ); diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts index 066c019c8e2..e065170ecb6 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -127,14 +127,14 @@ test('delegation content is optional, bounded, and unavailable to stop or resume actionId: 'stop-content', turnId: 'active-turn', delegationText: 'Unrelated work', - proposal: { disposition: 'stop_work', expects: { targetSessionId: 'payments' } }, + proposal: { operation: 'stop', expects: { targetSessionId: 'payments' } }, }), RuntimeHostProtocolError, ); }); test('WorkHub Coordination candidates are bounded and carry opaque proposal identities', () => { - assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 110); + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH >= 136); const result = decodeWorkHubCoordinationCandidatesResult({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [ @@ -179,16 +179,16 @@ test('model actions retain closed task inputs and bounded answer content', () => }, { proposal: { - disposition: 'replace', + operation: 'correct', replacesActionId: 'source', target: { disposition: 'delegate_existing', candidateRef: 'candidate' }, }, candidateSetId, }, - { proposal: { disposition: 'stop_work', expects: { targetSessionId: 'target' } } }, + { proposal: { operation: 'stop', expects: { targetSessionId: 'target' } } }, { proposal: { - disposition: 'resume_work', + operation: 'resume', resumesActionId: 'source', expects: { targetSessionId: 'target' }, }, @@ -208,13 +208,13 @@ test('model actions retain closed task inputs and bounded answer content', () => }, { proposal: { - disposition: 'stop_work', + operation: 'stop', expects: { targetSessionId: 'target', activeActionIds: ['forged'] }, }, }, { proposal: { - disposition: 'resume_work', + operation: 'resume', resumesActionId: 'source', expects: { targetSessionId: 'target' }, }, @@ -222,6 +222,23 @@ test('model actions retain closed task inputs and bounded answer content', () => }, ]) assert.throws(() => decodeWorkHubCoordinationActFromTurnInput({ ...base, ...fields })); + for (const legacyProposal of [ + { + disposition: 'replace', + replacesActionId: 'source', + target: { disposition: 'create_new', title: 'Legacy' }, + }, + { disposition: 'stop_work', expects: { targetSessionId: 'target' } }, + { + disposition: 'resume_work', + resumesActionId: 'source', + expects: { targetSessionId: 'target' }, + }, + ]) + assert.throws( + () => decodeWorkHubCoordinationActFromTurnInput({ ...base, proposal: legacyProposal }), + RuntimeHostProtocolError, + ); const attachments = [ { name: 'brief.txt', diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 92b2583cafa..c95ce0b6697 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 135 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 136 as const; +// 136: WorkHub transient proposals distinguish routing dispositions from linked +// operations. Older peers expect replace/stop_work/resume_work dispositions. // 135: WorkHub model Turns replace direct action proposals with active-Turn task tools. // 134: Coordination actions own real Runtime Turns. Removes the synthetic record // operation, projects typed action receipts and admitted action identities, and diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 16cd2a4c1d3..b4e43c2513c 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -153,14 +153,16 @@ export interface WorkHubCoordinationCandidatesResult { readonly candidates: readonly WorkHubCoordinationCandidate[]; } -export type WorkHubCoordinationProposal = +export type WorkHubRoutingProposal = | { readonly disposition: 'delegate_existing'; readonly candidateRef: string; } - | { readonly disposition: 'create_new'; readonly title: string } + | { readonly disposition: 'create_new'; readonly title: string }; + +export type WorkHubLinkedOperationProposal = | { - readonly disposition: 'replace'; + readonly operation: 'correct'; /** Action identity of the exact durable delegation link being corrected. */ readonly replacesActionId: string; readonly target: @@ -168,9 +170,9 @@ export type WorkHubCoordinationProposal = | { readonly disposition: 'create_new'; readonly title: string }; } | { - readonly disposition: 'stop_work'; + readonly operation: 'stop'; /** - * The expected state the Action Policy resolved against. It carries no + * The expected state the Coordination policy resolved against. It carries no * authority of its own; the Action Gate revalidates it against current * durable facts, so a resolution that has gone stale fails closed instead * of stopping work the user never resolved. @@ -179,16 +181,22 @@ export type WorkHubCoordinationProposal = * prove which link is live, so the Gate resolves it from its own active * links, and on replay from the durable claim this action already owns. */ - readonly expects: WorkHubCoordinationStopPreconditions; + readonly expects: WorkHubCoordinationLinkedTargetPreconditions; } | { - readonly disposition: 'resume_work'; + readonly operation: 'resume'; /** Bound reference from candidate discovery; the Gate checks current ownership. */ readonly resumesActionId: string; - readonly expects: WorkHubCoordinationStopPreconditions; + readonly expects: WorkHubCoordinationLinkedTargetPreconditions; }; -export interface WorkHubCoordinationStopPreconditions { +/** + * A coordination proposal is either a routing decision or an operation over a + * durable delegation. Linked operations are deliberately not dispositions. + */ +export type WorkHubCoordinationProposal = WorkHubRoutingProposal | WorkHubLinkedOperationProposal; + +export interface WorkHubCoordinationLinkedTargetPreconditions { /** * Session the resolved delegation was proposed against. Sole-active-delegation * is proved by the Host from durable state under the admission lease, so the @@ -399,8 +407,10 @@ function decodeWorkHubCoordinationActionFields( input.newWorkDefaults !== undefined && (!isWorkHubCreateDefaults(input.newWorkDefaults) || !( - proposal.disposition === 'create_new' || - (proposal.disposition === 'replace' && proposal.target.disposition === 'create_new') + ('disposition' in proposal && proposal.disposition === 'create_new') || + ('operation' in proposal && + proposal.operation === 'correct' && + proposal.target.disposition === 'create_new') )) ) { throw invalidProtocolFrame('Invalid WorkHub creation defaults'); @@ -416,9 +426,7 @@ function decodeWorkHubCoordinationActionFields( if ( delegationText !== undefined && (!delegationText.trim() || - (proposal.disposition !== 'delegate_existing' && - proposal.disposition !== 'create_new' && - proposal.disposition !== 'replace')) + !('disposition' in proposal || ('operation' in proposal && proposal.operation === 'correct'))) ) { throw invalidProtocolFrame('Invalid WorkHub delegation text'); } @@ -430,19 +438,19 @@ function decodeWorkHubCoordinationActionFields( : {}), ...(delegationText === undefined ? {} : { delegationText }), }; - if (proposal.disposition === 'delegate_existing') { + if ('disposition' in proposal && proposal.disposition === 'delegate_existing') { if (input.create !== undefined || input.candidateSetId === undefined) { throw invalidProtocolFrame('Invalid WorkHub delegation context'); } return { ...base, candidateSetId: candidateSetId(input.candidateSetId) }; } - if (proposal.disposition === 'create_new') { + if ('disposition' in proposal && proposal.disposition === 'create_new') { if (input.candidateSetId !== undefined || input.create === undefined) { throw invalidProtocolFrame('Invalid WorkHub creation context'); } return { ...base, create: decodeWorkHubCoordinationCreateContext(input.create) }; } - if (proposal.disposition === 'replace') { + if ('operation' in proposal && proposal.operation === 'correct') { if (proposal.target.disposition === 'delegate_existing') { if (input.candidateSetId === undefined || input.create !== undefined) { throw invalidProtocolFrame('Invalid WorkHub replacement context'); @@ -527,9 +535,9 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP title: requireUtf8String(exact.title, 'WorkHub Session title', COORDINATION_TITLE_MAX_BYTES), }; } - if (proposal.disposition === 'replace') { + if (proposal.operation === 'correct') { const exact = requireExactRecord(proposal, 'WorkHub replacement proposal', [ - 'disposition', + 'operation', 'replacesActionId', 'target', ]); @@ -540,7 +548,7 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP 'candidateRef', ]); return { - disposition: 'replace', + operation: 'correct', replacesActionId: requireEntityId(exact.replacesActionId, 'WorkHub replaced action id'), target: { disposition: 'delegate_existing', @@ -554,7 +562,7 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP 'title', ]); return { - disposition: 'replace', + operation: 'correct', replacesActionId: requireEntityId(exact.replacesActionId, 'WorkHub replaced action id'), target: { disposition: 'create_new', @@ -568,32 +576,34 @@ function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationP } throw invalidProtocolFrame('Invalid WorkHub replacement target'); } - if (proposal.disposition === 'stop_work') { - const exact = requireExactRecord(proposal, 'WorkHub stop proposal', ['disposition', 'expects']); + if (proposal.operation === 'stop') { + const exact = requireExactRecord(proposal, 'WorkHub stop proposal', ['operation', 'expects']); return { - disposition: 'stop_work', - expects: decodeWorkHubCoordinationStopPreconditions(exact.expects), + operation: 'stop', + expects: decodeWorkHubCoordinationLinkedTargetPreconditions(exact.expects), }; } - if (proposal.disposition === 'resume_work') { + if (proposal.operation === 'resume') { const exact = requireExactRecord(proposal, 'WorkHub resume proposal', [ - 'disposition', + 'operation', 'expects', 'resumesActionId', ]); return { - disposition: 'resume_work', + operation: 'resume', resumesActionId: requireEntityId(exact.resumesActionId, 'WorkHub resume assignment'), - expects: decodeWorkHubCoordinationStopPreconditions(exact.expects), + expects: decodeWorkHubCoordinationLinkedTargetPreconditions(exact.expects), }; } - throw invalidProtocolFrame('Invalid WorkHub Coordination proposal disposition'); + throw invalidProtocolFrame('Invalid WorkHub Coordination proposal'); } -function decodeWorkHubCoordinationStopPreconditions( +function decodeWorkHubCoordinationLinkedTargetPreconditions( value: unknown, -): WorkHubCoordinationStopPreconditions { - const expects = requireExactRecord(value, 'WorkHub stop preconditions', ['targetSessionId']); +): WorkHubCoordinationLinkedTargetPreconditions { + const expects = requireExactRecord(value, 'WorkHub linked-target preconditions', [ + 'targetSessionId', + ]); return { targetSessionId: requireEntityId(expects.targetSessionId, 'WorkHub target Session id'), }; diff --git a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts index d1f4b1f3f34..46536717843 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -286,6 +286,7 @@ export class WorkHubCoordinationActionGate { context: ConnectionContext, admittedTurnId?: string, ): Promise { + const proposal = input.proposal; if (!input.userText.trim()) { return Promise.reject( new WorkHubActionGateFailure('action_conflict', 'WorkHub action text is empty'), @@ -294,15 +295,20 @@ export class WorkHubCoordinationActionGate { if ( input.delegationText !== undefined && (!input.delegationText.trim() || - (input.proposal.disposition !== 'delegate_existing' && - input.proposal.disposition !== 'create_new' && - input.proposal.disposition !== 'replace')) + !( + 'disposition' in proposal || + ('operation' in proposal && proposal.operation === 'correct') + )) ) { return Promise.reject( new WorkHubActionGateFailure('action_conflict', 'Invalid WorkHub delegation text'), ); } - if (input.proposal.disposition === 'create_new' && !input.proposal.title.trim()) { + if ( + 'disposition' in proposal && + proposal.disposition === 'create_new' && + !proposal.title.trim() + ) { return Promise.reject( new WorkHubActionGateFailure('action_conflict', 'WorkHub creation title is empty'), ); @@ -366,7 +372,7 @@ export class WorkHubCoordinationActionGate { return this.#assign(assignmentInputFromRecord(durable), context); } - if (proposal.disposition === 'stop_work') { + if ('operation' in proposal && proposal.operation === 'stop') { const source = await this.#stopSource(input.actionId, proposal.expects.targetSessionId); const stopFingerprint = stopActionFingerprint(input, source); await this.#claimAction(input.actionId, 'stop', stopFingerprint, source.delegationId); @@ -424,7 +430,7 @@ export class WorkHubCoordinationActionGate { }); return this.#stop(requested, source); } - if (proposal.disposition === 'resume_work') { + if ('operation' in proposal && proposal.operation === 'resume') { const source = await this.#effects.readAssignment(proposal.resumesActionId); if (!source || source.targetSessionId !== proposal.expects.targetSessionId) { throw new WorkHubActionGateFailure( @@ -462,7 +468,7 @@ export class WorkHubCoordinationActionGate { ); } - if (proposal.disposition === 'create_new') { + if ('disposition' in proposal && proposal.disposition === 'create_new') { if (!input.create) { throw new WorkHubActionGateFailure( 'action_conflict', @@ -476,7 +482,7 @@ export class WorkHubCoordinationActionGate { ); } - if (proposal.disposition === 'replace') { + if ('operation' in proposal && proposal.operation === 'correct') { const replaced = await this.#effects.readAssignment(proposal.replacesActionId); if (!replaced) { throw new WorkHubActionGateFailure( @@ -708,7 +714,7 @@ export class WorkHubCoordinationActionGate { input: AdmittedWorkHubAction, replaced: WorkHubDelegationAssignedMessage, ): Promise { - if (input.proposal.disposition !== 'replace') { + if (!('operation' in input.proposal) || input.proposal.operation !== 'correct') { throw new WorkHubActionGateFailure('action_conflict', 'Invalid WorkHub replacement'); } const target = input.proposal.target; @@ -877,7 +883,8 @@ export class WorkHubCoordinationActionGate { targetSessionId: string, ): Promise { if ( - input.proposal.disposition !== 'replace' || + !('operation' in input.proposal) || + input.proposal.operation !== 'correct' || input.proposal.target.disposition !== 'delegate_existing' || input.candidateSetId === undefined ) { @@ -1033,10 +1040,7 @@ function delegationAssignment( targetSessionName: string, ): WorkHubDelegationAssignmentInput { const create = input.create; - if ( - input.proposal.disposition !== 'delegate_existing' && - input.proposal.disposition !== 'create_new' - ) { + if (!('disposition' in input.proposal)) { throw new WorkHubActionGateFailure( 'action_conflict', 'WorkHub local action cannot create a delegation intent', @@ -1103,30 +1107,37 @@ function digest(value: unknown): `sha256:${string}` { } function actionFingerprint(input: WorkHubAdmittedAction): `sha256:${string}` { - return digest({ + const common = { userText: input.userText, ...(input.attachments ? { attachments: input.attachments } : {}), ...(input.newWorkDefaults ? { newWorkDefaults: input.newWorkDefaults } : {}), ...(input.delegationText === undefined ? {} : { delegationText: input.delegationText }), - disposition: input.proposal.disposition, - ...(input.proposal.disposition === 'delegate_existing' - ? { candidateRef: input.proposal.candidateRef } - : {}), - ...(input.proposal.disposition === 'create_new' - ? { - title: input.proposal.title, - workspace: input.create?.workspace, - } - : {}), - ...(input.proposal.disposition === 'replace' - ? { - replacesActionId: input.proposal.replacesActionId, - target: input.proposal.target, - ...(input.proposal.target.disposition === 'create_new' - ? { workspace: input.create?.workspace } - : {}), - } - : {}), + }; + const proposal = input.proposal; + if ('disposition' in proposal) { + return digest({ + ...common, + disposition: proposal.disposition, + ...(proposal.disposition === 'delegate_existing' + ? { candidateRef: proposal.candidateRef } + : { title: proposal.title, workspace: input.create?.workspace }), + }); + } + if (proposal.operation === 'correct') { + return digest({ + ...common, + disposition: 'replace', + replacesActionId: proposal.replacesActionId, + target: proposal.target, + ...(proposal.target.disposition === 'create_new' + ? { workspace: input.create?.workspace } + : {}), + }); + } + // Preserve durable action fingerprints across the transient proposal rename. + return digest({ + ...common, + disposition: proposal.operation === 'stop' ? 'stop_work' : 'resume_work', }); } @@ -1134,7 +1145,7 @@ function replacementActionFingerprint( input: WorkHubAdmittedAction, targetSessionId: string, ): `sha256:${string}` { - if (input.proposal.disposition !== 'replace') { + if (!('operation' in input.proposal) || input.proposal.operation !== 'correct') { throw new WorkHubActionGateFailure('action_conflict', 'Invalid WorkHub replacement replay'); } return digest({ @@ -1142,7 +1153,7 @@ function replacementActionFingerprint( ...(input.attachments ? { attachments: input.attachments } : {}), ...(input.newWorkDefaults ? { newWorkDefaults: input.newWorkDefaults } : {}), ...(input.delegationText === undefined ? {} : { delegationText: input.delegationText }), - disposition: input.proposal.disposition, + disposition: 'replace', replacesActionId: input.proposal.replacesActionId, target: { disposition: input.proposal.target.disposition, @@ -1158,7 +1169,7 @@ function stopActionFingerprint( input: WorkHubAdmittedAction, source: WorkHubDelegationAssignedMessage, ): `sha256:${string}` { - if (input.proposal.disposition !== 'stop_work') { + if (!('operation' in input.proposal) || input.proposal.operation !== 'stop') { throw new WorkHubActionGateFailure('action_conflict', 'Invalid WorkHub stop replay'); } return digest({ diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 5eda6c718da..82f34e17da3 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -551,9 +551,8 @@ export class HostWorkHubCoordinationCoordinator { } const { turnId: _turnId, ...action } = input; const carriesAttachments = - action.proposal.disposition === 'delegate_existing' || - action.proposal.disposition === 'create_new' || - action.proposal.disposition === 'replace'; + 'disposition' in action.proposal || + ('operation' in action.proposal && action.proposal.operation === 'correct'); try { return { ok: true,