diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts
index 5301584e28..13b46ce35c 100644
--- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts
@@ -32,6 +32,7 @@ import {
createWorkHubRoutePolicy,
workHubNewSessionName,
} from '../../renderer/workhub-route-policy.js';
+import { WorkHubCoordinationFailure } from '../../renderer/workhub-coordination-port.js';
const appShellUrl = [
new URL('../../renderer/app-shell.tsx', import.meta.url),
@@ -185,6 +186,13 @@ function createWorkHubController({ sessions }: { sessions: TestSessionPort }) {
...(admitted.steered ? { steered: true as const } : {}),
};
}
+ if (input.proposal.disposition === 'stop_work') {
+ return {
+ disposition: 'stop_work',
+ outcome: 'cancelled_pending',
+ targetSessionId: input.proposal.expects.targetSessionId,
+ };
+ }
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);
@@ -324,6 +332,209 @@ test('conversation feedback never lets an older refresh overwrite newer target s
await handle.close();
});
+test('direct stop bypasses routing candidates and preserves a not_owned delegation link', async () => {
+ const sessions = port([session('payments', { sessionName: 'Payments' })]);
+ const actions: WorkHubCoordinationActInput[] = [];
+ let candidateReads = 0;
+ const controller = createGatedWorkHubController({
+ sessions,
+ coordination: {
+ open: async (handler) => {
+ handler([coordinationAssignmentTurn()], [{
+ actionId: 'action-1',
+ targetSessionId: 'payments',
+ sequence: 0,
+ }]);
+ return { close: async () => undefined };
+ },
+ record: async (input) => ({ turnId: input.turnId }),
+ candidates: async () => {
+ candidateReads += 1;
+ return { candidateSetId: `sha256:${'d'.repeat(64)}`, candidates: [] };
+ },
+ act: async (input) => {
+ actions.push(input);
+ return {
+ disposition: 'stop_work',
+ outcome: 'not_owned',
+ targetSessionId: 'payments',
+ targetTurnId: 'shared-turn',
+ };
+ },
+ },
+ });
+ const handle = await controller.openConversation(() => undefined, () => undefined);
+
+ const result = await controller.submit({ requestId: 'stop-1', text: 'Stop Payments' });
+ assert.deepEqual(result, {
+ kind: 'stop',
+ strategyId: WORKHUB_ROUTING_STRATEGY_ID,
+ requestId: 'stop-1',
+ target: { sessionId: 'payments' },
+ outcome: 'not_owned',
+ targetTurnId: 'shared-turn',
+ });
+ // The proposal carries only the Session the reference resolved to. No display
+ // name and no delegation identity reach the Action Gate: which link to end is
+ // the Host's to decide.
+ assert.deepEqual(actions, [{
+ actionId: 'stop-1',
+ userText: 'Stop Payments',
+ proposal: {
+ disposition: 'stop_work',
+ expects: { targetSessionId: 'payments' },
+ },
+ confirmation: { kind: 'user_stop' },
+ }]);
+ assert.equal(candidateReads, 0);
+
+ const retry = await controller.submit({ requestId: 'stop-2', text: 'Stop Payments' });
+ assert.equal(retry.kind, 'stop');
+ assert.equal(actions.length, 2);
+ await handle.close();
+});
+
+test('an anaphoric stop asks for a fresh named imperative without offering a route choice', async () => {
+ const sessions = port([session('payments', { sessionName: 'Payments' })]);
+ const controller = createGatedWorkHubController({
+ sessions,
+ coordination: {
+ open: async (handler) => {
+ handler([], [{ actionId: 'action-1', targetSessionId: 'payments', sequence: 0 }]);
+ return { close: async () => undefined };
+ },
+ record: async (input) => ({ turnId: input.turnId }),
+ candidates: async () => assert.fail('stop clarification must not read route candidates'),
+ act: async () => assert.fail('anaphoric stop must not reach the Action Gate'),
+ },
+ });
+ const handle = await controller.openConversation(() => undefined, () => undefined);
+ assert.deepEqual(await controller.submit({ requestId: 'stop-it', text: 'Stop it' }), {
+ kind: 'clarification',
+ strategyId: WORKHUB_ROUTING_STRATEGY_ID,
+ requestId: 'stop-it',
+ text: 'Stop it',
+ options: [],
+ reason: 'stop_target_required',
+ });
+ await handle.close();
+});
+
+test('a named stop reports the Gate refusal instead of judging the target itself', async () => {
+ // The renderer no longer decides whether a Session can be stopped, so it
+ // submits and lets the Gate answer. Its refusal is the clarification, which
+ // is the only version of this answer that cannot contradict the Host.
+ const sessions = port([session('payments', { sessionName: 'Payments' })]);
+ let submitted = 0;
+ const controller = createGatedWorkHubController({
+ sessions,
+ coordination: {
+ open: async (handler) => {
+ handler([], []);
+ return { close: async () => undefined };
+ },
+ record: async (input) => ({ turnId: input.turnId }),
+ candidates: async () => assert.fail('stop clarification must not read route candidates'),
+ act: async () => {
+ submitted += 1;
+ throw new WorkHubCoordinationFailure(
+ 'operation_conflict',
+ 'WorkHub has no active durable delegation to stop on that Session',
+ );
+ },
+ },
+ });
+ const handle = await controller.openConversation(() => undefined, () => undefined);
+
+ assert.deepEqual(await controller.submit({ requestId: 'stop-payments', text: 'Stop Payments' }), {
+ kind: 'clarification',
+ strategyId: WORKHUB_ROUTING_STRATEGY_ID,
+ requestId: 'stop-payments',
+ text: 'Stop Payments',
+ options: [],
+ reason: 'stop_target_unavailable',
+ });
+ assert.equal(submitted, 1, 'the Host is the one that decides, so it must be asked');
+ await handle.close();
+});
+
+test('a stop that fails for any other reason is a fault, not a clarification', async () => {
+ const sessions = port([session('payments', { sessionName: 'Payments' })]);
+ const controller = createGatedWorkHubController({
+ sessions,
+ coordination: {
+ open: async (handler) => {
+ handler([], []);
+ return { close: async () => undefined };
+ },
+ record: async (input) => ({ turnId: input.turnId }),
+ candidates: async () => assert.fail('stop clarification must not read route candidates'),
+ act: async () => {
+ throw new WorkHubCoordinationFailure('persistence_failed', 'WorkHub stop state is unavailable');
+ },
+ },
+ });
+ const handle = await controller.openConversation(() => undefined, () => undefined);
+
+ await assert.rejects(
+ () => controller.submit({ requestId: 'stop-payments', text: 'Stop Payments' }),
+ /WorkHub stop state is unavailable/,
+ );
+ await handle.close();
+});
+
+test('stop-shaped ordinary work routes normally instead of looping on clarification', async () => {
+ for (const [sessionName, text] of [
+ ['Payments', 'Stop using the deprecated API in Payments'],
+ ['支付任务', '停止使用支付任务里的旧接口'],
+ ] as const) {
+ const sessions = port([session('payments', { sessionName })]);
+ const actions: WorkHubCoordinationActInput[] = [];
+ const controller = createGatedWorkHubController({
+ sessions,
+ coordination: {
+ open: async (handler) => {
+ handler([], [{ actionId: 'action-1', targetSessionId: 'payments', sequence: 0 }]);
+ return { close: async () => undefined };
+ },
+ record: async (input) => ({ turnId: input.turnId }),
+ candidates: async () => ({
+ candidateSetId: `sha256:${'e'.repeat(64)}`,
+ candidates: [{
+ candidateRef: 'candidate-payments',
+ sessionId: 'payments',
+ sessionName,
+ workspace: {
+ target: { kind: 'host_path' as const, path: '/workspace/payments' },
+ hostCwd: '/workspace/payments',
+ },
+ state: 'active' as const,
+ updatedAt: 1,
+ }],
+ }),
+ act: async (input) => {
+ actions.push(input);
+ return {
+ disposition: 'delegate_existing',
+ targetSessionId: 'payments',
+ targetTurnId: 'payments-turn',
+ };
+ },
+ },
+ });
+ const handle = await controller.openConversation(() => undefined, () => undefined);
+
+ const result = await controller.submit({ requestId: `work-${sessionName}`, text });
+ assert.equal(result.kind, 'submitted', text);
+ assert.deepEqual(
+ actions.map((action) => action.proposal.disposition),
+ ['delegate_existing'],
+ text,
+ );
+ 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 b982a265d2..00ba4f9303 100644
--- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts
@@ -295,6 +295,57 @@ test('a durable replacement abort terminalizes the retired source linkage', () =
);
});
+test('direct-stop projection is retryable until resolved and preserves not_owned links', () => {
+ const assignment: StoredMessage = {
+ type: 'workhub_coordination', id: 'assignment', turnId: 'source-action', ts: 1,
+ schemaVersion: 1, kind: 'delegation_assigned', actionId: 'source-action',
+ actionFingerprint: `sha256:${'a'.repeat(64)}`, coordinationTurnId: 'source-action',
+ targetSessionId: 'payments', targetSessionName: 'Payments', targetTurnId: 'payments-turn',
+ targetMessageId: 'payments-message', delegationId: 'payments-delegation',
+ disposition: 'delegate_existing', userText: 'Fix payment retry',
+ };
+ const requested: StoredMessage = {
+ type: 'workhub_coordination', id: 'stop-request', turnId: 'stop-action', ts: 2,
+ schemaVersion: 3, kind: 'delegation_stop_requested', actionId: 'stop-action',
+ actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'stop-action',
+ stopsActionId: 'source-action', stopsDelegationId: 'payments-delegation',
+ targetSessionId: 'payments', targetMessageId: 'payments-message',
+ targetSessionName: 'Payments', userText: 'Stop Payments',
+ };
+ const notOwned: StoredMessage = {
+ type: 'workhub_coordination', id: 'stop-resolution', turnId: 'stop-action', ts: 3,
+ schemaVersion: 3, kind: 'delegation_stop_resolved', actionId: 'stop-action',
+ actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'stop-action',
+ stopsActionId: 'source-action', stopsDelegationId: 'payments-delegation',
+ targetSessionId: 'payments', targetTurnId: 'shared-turn', outcome: 'not_owned',
+ };
+
+ assert.equal(projectWorkHubCoordinationTurns([assignment, requested])[1]?.state, 'running');
+ const projected = projectWorkHubCoordinationTurns([assignment, requested, notOwned]);
+ assert.deepEqual(projected[1]?.stop, {
+ targetSessionId: 'payments',
+ targetSessionName: 'Payments',
+ outcome: 'not_owned',
+ });
+ assert.equal(projected[0]?.assignment?.linkState, 'active');
+ assert.deepEqual(projectWorkHubActiveDelegations([
+ { sequence: 0, message: assignment },
+ { sequence: 1, message: requested },
+ { sequence: 2, message: notOwned },
+ ]), [{ actionId: 'source-action', targetSessionId: 'payments', sequence: 0 }]);
+
+ const stopped = { ...notOwned, outcome: 'stop_delivered' as const };
+ assert.equal(
+ projectWorkHubCoordinationTurns([assignment, requested, stopped])[0]?.assignment?.linkState,
+ 'stopped',
+ );
+ assert.deepEqual(projectWorkHubActiveDelegations([
+ { sequence: 0, message: assignment },
+ { sequence: 1, message: requested },
+ { sequence: 2, message: stopped },
+ ]), []);
+});
+
test('durable supersession terminalizes only the replaced linkage', () => {
const source: StoredMessage = {
type: 'workhub_coordination',
diff --git a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts
new file mode 100644
index 0000000000..6e9b2305df
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type {
+ WorkHubSessionResolution,
+ WorkHubSessionResolver,
+} from '../../renderer/application/contracts/workhub-request-intent.js';
+import { createWorkHubRoutePolicy } from '../../renderer/workhub-route-policy.js';
+
+const routable = (sessionId: string, sessionName: string) => ({
+ target: { sessionId },
+ projectName: 'demo',
+ sessionName,
+ updatedAt: 1,
+});
+
+/** Stands in for the Host read the stop policy makes once a reference resolves. */
+/**
+ * A stand-in for a later ranked resolver. It recalls by remembered description
+ * rather than display name, which is exactly the recall the exact-name baseline
+ * cannot do, and it answers in the same contract.
+ */
+const describedResolver = (
+ descriptions: ReadonlyMap,
+): WorkHubSessionResolver => ({
+ resolve({ reference, sessions }): WorkHubSessionResolution {
+ const candidates = sessions
+ .filter((session) => descriptions.get(session.ref) === reference.text)
+ .map((session) => ({
+ ref: session.ref,
+ evidence: { kind: 'named' as const, remainder: '' },
+ }));
+ const [first, ...rest] = candidates;
+ if (!first) return { kind: 'none' };
+ if (rest.length > 0) return { kind: 'ambiguous', candidates };
+ return { kind: 'ranked', candidates: [first] };
+ },
+});
+
+test('stop resolves through the shared port rather than a stop-specific grammar', async () => {
+ const sessions = [routable('payments', 'Payments'), routable('login', 'Login')];
+
+ // Action Intent extracts the reference ("Stop the payment timeout work" ->
+ // "payment timeout work"); resolving it is the Resolver's business alone.
+ // The exact-name baseline recalls the display name and nothing else.
+ const baseline = createWorkHubRoutePolicy();
+ assert.deepEqual(baseline.resolveStop({ text: 'Stop Payments', sessions}), {
+ kind: 'target',
+ target: { sessionId: 'payments' },
+ });
+ assert.deepEqual(
+ baseline.resolveStop({
+ text: 'Stop the payment timeout work',
+ sessions,
+ }),
+ { kind: 'not_requested' },
+ );
+
+ // Swapping the resolver changes only recall. The decision the stop policy
+ // produces keeps the same opaque identities and the same durable protocol.
+ const ranked = createWorkHubRoutePolicy(
+ describedResolver(new Map([['payments', 'payment timeout work']])),
+ );
+ assert.deepEqual(
+ ranked.resolveStop({
+ text: 'Stop the payment timeout work',
+ sessions,
+ }), {
+ kind: 'target',
+ target: { sessionId: 'payments' },
+ });
+});
+
+test('an ambiguous recall never becomes a destructive target', async () => {
+ const resolver = describedResolver(
+ new Map([
+ ['payments', 'payment timeout work'],
+ ['payments-eu', 'payment timeout work'],
+ ]),
+ );
+ assert.deepEqual(
+ createWorkHubRoutePolicy(resolver).resolveStop({
+ text: 'Stop the payment timeout work',
+ sessions: [routable('payments', 'Payments'), routable('payments-eu', 'Payments EU')],
+ }),
+ { kind: 'clarification', reason: 'stop_target_ambiguous' },
+ );
+});
+
+test('a resolver cannot widen stop beyond the visible candidate set it was given', async () => {
+ const resolver: WorkHubSessionResolver = {
+ resolve: () => ({
+ kind: 'ranked',
+ candidates: [
+ { ref: 'never-offered', evidence: { kind: 'named', remainder: '' } },
+ ],
+ }),
+ };
+ assert.deepEqual(
+ createWorkHubRoutePolicy(resolver).resolveStop({
+ text: 'Stop Payments',
+ sessions: [routable('payments', 'Payments')],
+ }),
+ { kind: 'not_requested' },
+ );
+});
+
+test('a stop cue with no safe reference asks for one instead of resolving', async () => {
+ const resolver: WorkHubSessionResolver = {
+ resolve: () => assert.fail('an unsafe reference must not reach the Session Resolver'),
+ };
+ assert.deepEqual(
+ createWorkHubRoutePolicy(resolver).resolveStop({
+ text: 'Stop it',
+ sessions: [routable('payments', 'Payments')],
+ }),
+ { kind: 'clarification', reason: 'stop_target_required' },
+ );
+});
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 447b5969b5..076040c109 100644
--- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts
@@ -165,6 +165,7 @@ test('durable delegation renders terminal link state instead of stale execution
const terminalLinks = [
['superseded', 'Superseded link', '已被更正'],
['aborted', 'Aborted replacement', '更正已中止'],
+ ['stopped', 'Stopped link', '已停止关联'],
] as const;
for (const [linkState, english, chinese] of terminalLinks) {
const turn: WorkHubCoordinationTurn = {
@@ -657,6 +658,13 @@ test('real Session projection creates new guide topics and preserves origin ambi
targetTurnId: admitted.turnId,
};
}
+ if (input.proposal.disposition === 'stop_work') {
+ return {
+ disposition: 'stop_work',
+ outcome: 'cancelled_pending',
+ targetSessionId: input.proposal.expects.targetSessionId,
+ };
+ }
const targetSessionId = input.proposal.candidateRef.replace(/^candidate-/u, '');
const admitted = await send(targetSessionId, {
type: 'send',
diff --git a/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts b/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts
index ce7d1e8993..8164ee8e5c 100644
--- a/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts
+++ b/apps/desktop/src/renderer/application/contracts/workhub-request-intent.ts
@@ -19,6 +19,12 @@
export {
readWorkHubRequestIntent,
- workHubCorrectionTargetsSession,
+ workHubCorrectionAdmitsReference,
} from '@maka/core/workhub-creation-intent';
export type { WorkHubRequestIntent } from '@maka/core/workhub-creation-intent';
+export { createExactNameSessionResolver } from '@maka/core/workhub-session-resolver';
+export type {
+ WorkHubResolverSession,
+ WorkHubSessionResolution,
+ WorkHubSessionResolver,
+} from '@maka/core/workhub-session-resolver';
diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts
index b6d3eac025..f900300483 100644
--- a/apps/desktop/src/renderer/workhub-controller.ts
+++ b/apps/desktop/src/renderer/workhub-controller.ts
@@ -26,13 +26,30 @@
import {
createWorkHubRoutePolicy,
type WorkHubRouteEvidence,
+ type WorkHubStopClarificationReason,
} from './workhub-route-policy.js';
import type {
+ OperationError,
WorkHubCoordinationActInput,
WorkHubCoordinationActResult,
WorkHubCoordinationCandidatesResult,
} from '@maka/runtime-host/protocol';
+/**
+ * A Host operation the Coordination port could not complete. It lives beside
+ * the port interface rather than beside its Desktop implementation, so a
+ * caller can tell a refusal from a fault without depending on the adapter.
+ */
+export class WorkHubCoordinationFailure extends Error {
+ constructor(
+ readonly code: OperationError<'workhub.coordination.act'>['code'],
+ message: string,
+ ) {
+ super(message);
+ this.name = 'WorkHubCoordinationFailure';
+ }
+}
+
export interface WorkHubSessionTarget {
sessionId: string;
}
@@ -110,10 +127,15 @@ export interface WorkHubCoordinationTurn {
readonly linkState: WorkHubDelegationLinkState;
readonly createdNew?: true;
};
+ stop?: {
+ readonly targetSessionId: string;
+ readonly targetSessionName: string;
+ readonly outcome?: Extract['outcome'];
+ };
updatedAt: number;
}
-export type WorkHubDelegationLinkState = 'active' | 'superseded' | 'aborted';
+export type WorkHubDelegationLinkState = 'active' | 'superseded' | 'aborted' | 'stopped';
/** Unbounded, rebuildable linkage state kept separate from the bounded timeline. */
export interface WorkHubActiveDelegation {
@@ -172,7 +194,7 @@ export type WorkHubSubmission = (
requestId: string;
text: string;
options: Array>;
- reason?: 'ambiguous_command';
+ reason?: 'ambiguous_command' | WorkHubStopClarificationReason;
correction?: WorkHubCorrectionContext;
}
| {
@@ -186,6 +208,13 @@ export type WorkHubSubmission = (
text: string;
target: WorkHubSessionTarget;
}
+ | {
+ kind: 'stop';
+ requestId: string;
+ target: WorkHubSessionTarget;
+ outcome: Extract['outcome'];
+ targetTurnId?: string;
+ }
) & { strategyId: WorkHubRoutingStrategyId };
/**
@@ -259,9 +288,25 @@ export function createWorkHubController(deps: {
let routePolicy = createWorkHubRoutePolicy();
let focusReadVersion = 0;
let pendingFocusReadVersion: number | undefined;
- const activeActionIdBySessionId = new Map();
+ const activeActionIdsBySessionId = new Map();
+ const removeActiveAction = (sessionId: string, actionId: string) => {
+ const remaining = (activeActionIdsBySessionId.get(sessionId) ?? []).filter(
+ (candidate) => candidate !== actionId,
+ );
+ if (remaining.length === 0) {
+ activeActionIdsBySessionId.delete(sessionId);
+ return;
+ }
+ activeActionIdsBySessionId.set(sessionId, remaining);
+ };
+ const addActiveAction = (sessionId: string, actionId: string) => {
+ const active = activeActionIdsBySessionId.get(sessionId) ?? [];
+ if (!active.includes(actionId)) {
+ activeActionIdsBySessionId.set(sessionId, [...active, actionId]);
+ }
+ };
const correctionFor = (from: WorkHubSessionTarget): WorkHubCorrectionContext => {
- const sourceActionId = activeActionIdBySessionId.get(from.sessionId);
+ const sourceActionId = activeActionIdsBySessionId.get(from.sessionId)?.at(-1);
if (!sourceActionId) {
throw new Error('WorkHub linked correction requires an active durable delegation');
}
@@ -270,11 +315,11 @@ export function createWorkHubController(deps: {
const reconcileActiveDelegations = (
activeDelegations: readonly WorkHubActiveDelegation[],
) => {
- activeActionIdBySessionId.clear();
+ activeActionIdsBySessionId.clear();
for (const delegation of [...activeDelegations].sort(
(left, right) => left.sequence - right.sequence,
)) {
- activeActionIdBySessionId.set(delegation.targetSessionId, delegation.actionId);
+ addActiveAction(delegation.targetSessionId, delegation.actionId);
}
};
const reconcileFocus = (
@@ -297,13 +342,10 @@ export function createWorkHubController(deps: {
correction: WorkHubCorrectionContext | undefined,
): Extract => {
const target = { sessionId: admitted.targetSessionId };
- if (
- correction &&
- activeActionIdBySessionId.get(correction.from.sessionId) === correction.sourceActionId
- ) {
- activeActionIdBySessionId.delete(correction.from.sessionId);
+ if (correction) {
+ removeActiveAction(correction.from.sessionId, correction.sourceActionId);
}
- activeActionIdBySessionId.set(target.sessionId, input.requestId);
+ addActiveAction(target.sessionId, input.requestId);
policy.rememberTarget(target);
return {
kind: 'submitted',
@@ -448,6 +490,67 @@ export function createWorkHubController(deps: {
const sessions = await deps.sessions.list();
reconcileFocus(submissionPolicy, sessions);
const ordinary = sessions.filter((session) => session.kind === 'ordinary');
+ const stopDecision = submissionPolicy.resolveStop({
+ text: input.text,
+ sessions: ordinary,
+ });
+ if (stopDecision.kind !== 'not_requested') {
+ if (stopDecision.kind === 'clarification') {
+ return {
+ kind: 'clarification',
+ strategyId: WORKHUB_ROUTING_STRATEGY_ID,
+ requestId: input.requestId,
+ text: input.text,
+ options: [],
+ reason: stopDecision.reason,
+ };
+ }
+ const { target } = stopDecision;
+ let admitted;
+ try {
+ admitted = await coordination.act({
+ actionId: input.requestId,
+ userText: input.text,
+ proposal: {
+ disposition: 'stop_work',
+ // Only the Session the reference resolved to. Which delegation
+ // that Session still owns is the Host's to decide, under the
+ // lease that ends it.
+ expects: { targetSessionId: target.sessionId },
+ },
+ confirmation: { kind: 'user_stop' },
+ });
+ } catch (error) {
+ // The Gate refusing the stop is an answer, not a fault: it is the
+ // only party that can say the Session owns no single stoppable
+ // delegation. Anything else is a real failure and still throws.
+ if (
+ error instanceof WorkHubCoordinationFailure &&
+ error.code === 'operation_conflict'
+ ) {
+ return {
+ kind: 'clarification',
+ strategyId: WORKHUB_ROUTING_STRATEGY_ID,
+ requestId: input.requestId,
+ text: input.text,
+ options: [],
+ reason: 'stop_target_unavailable',
+ };
+ }
+ throw error;
+ }
+ if (admitted.disposition !== 'stop_work') {
+ throw new Error('WorkHub Action Gate returned an unexpected disposition');
+ }
+ return {
+ kind: 'stop',
+ strategyId: WORKHUB_ROUTING_STRATEGY_ID,
+ requestId: input.requestId,
+ target,
+ outcome: admitted.outcome,
+ ...(admitted.targetTurnId ? { targetTurnId: admitted.targetTurnId } : {}),
+ };
+ }
const candidateSet = await coordination.candidates();
const candidateBySessionId = new Map(
candidateSet.candidates.map((candidate) => [candidate.sessionId, candidate]),
diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts
index 1eb40fd3b7..114fed8532 100644
--- a/apps/desktop/src/renderer/workhub-coordination-port.ts
+++ b/apps/desktop/src/renderer/workhub-coordination-port.ts
@@ -37,21 +37,13 @@ import type {
OperationOutcome,
OperationError,
} from '@maka/runtime-host/protocol';
-import { boundedWorkHubTimelineText } from './workhub-controller.js';
+import { boundedWorkHubTimelineText, WorkHubCoordinationFailure } from './workhub-controller.js';
+
+export { WorkHubCoordinationFailure };
import type { WorkHubDesktopTranscriptBridge } from './workhub-session-port.js';
const WORKHUB_COORDINATION_TURN_LIMIT = 40;
-export class WorkHubCoordinationFailure extends Error {
- constructor(
- readonly code: OperationError<'workhub.coordination.act'>['code'],
- message: string,
- ) {
- super(message);
- this.name = 'WorkHubCoordinationFailure';
- }
-}
-
export function createDesktopWorkHubCoordinationPort(deps: {
sessionId: string;
transcripts: WorkHubDesktopTranscriptBridge;
@@ -201,13 +193,36 @@ export function projectWorkHubCoordinationTurns(
);
const turns: WorkHubCoordinationTurn[] = [];
const latestUserIndexByTurnId = new Map();
- const terminalLinkState = new Map();
+ const terminalLinkState = new Map();
+ const stopResolutionByDelegationId = new Map(
+ messages.flatMap((message) =>
+ message.type === 'workhub_coordination' && message.kind === 'delegation_stop_resolved'
+ ? [[message.stopsDelegationId, message] as const]
+ : [],
+ ),
+ );
for (const message of messages) {
const terminal = terminalDelegationLink(message);
if (terminal) terminalLinkState.set(terminal.delegationId, terminal.state);
}
for (const message of messages) {
+ if (message.type === 'workhub_coordination' && message.kind === 'delegation_stop_requested') {
+ const resolution = stopResolutionByDelegationId.get(message.stopsDelegationId);
+ turns.push({
+ messageId: message.id,
+ turnId: message.coordinationTurnId,
+ text: boundedWorkHubTimelineText(message.userText),
+ state: resolution ? 'completed' : 'running',
+ stop: {
+ targetSessionId: message.targetSessionId,
+ targetSessionName: message.targetSessionName,
+ ...(resolution ? { outcome: resolution.outcome } : {}),
+ },
+ updatedAt: resolution ? Math.max(message.ts, resolution.ts) : message.ts,
+ });
+ continue;
+ }
if (message.type === 'workhub_coordination' && message.kind === 'delegation_assigned') {
turns.push({
messageId: message.id,
@@ -262,7 +277,7 @@ export function projectWorkHubCoordinationTurns(
function terminalDelegationLink(
message: StoredMessage,
-): { readonly delegationId: string; readonly state: 'superseded' | 'aborted' } | undefined {
+): { readonly delegationId: string; readonly state: 'superseded' | 'aborted' | 'stopped' } | undefined {
if (message.type !== 'workhub_coordination') return undefined;
if (message.kind === 'delegation_superseded') {
return { delegationId: message.supersededDelegationId, state: 'superseded' };
@@ -270,6 +285,9 @@ function terminalDelegationLink(
if (message.kind === 'delegation_replacement_aborted') {
return { delegationId: message.abortedDelegationId, state: 'aborted' };
}
+ if (message.kind === 'delegation_stop_resolved' && message.outcome !== 'not_owned') {
+ return { delegationId: message.stopsDelegationId, state: 'stopped' };
+ }
return undefined;
}
diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts
index 170a9c4be6..af2960c94f 100644
--- a/apps/desktop/src/renderer/workhub-route-policy.ts
+++ b/apps/desktop/src/renderer/workhub-route-policy.ts
@@ -18,9 +18,12 @@
*/
import {
+ createExactNameSessionResolver,
readWorkHubRequestIntent,
- workHubCorrectionTargetsSession,
+ workHubCorrectionAdmitsReference,
type WorkHubRequestIntent,
+ type WorkHubResolverSession,
+ type WorkHubSessionResolver,
} from './application/contracts/workhub-request-intent.js';
interface WorkHubRouteTarget {
@@ -58,7 +61,41 @@ export type WorkHubRouteDecision =
| { kind: 'discussion' }
| { kind: 'new_session'; title: string; correctedFrom?: WorkHubRouteTarget };
+
+/**
+ * Both reasons are about the reference itself — what the user's words name —
+ * which is the only question this policy can answer on its own.
+ *
+ * Whether the named Session still owns work a stop can reach is not asked
+ * here. Only the Host knows that, it proves it under the admission lease
+ * anyway, and a renderer that answered from its own view would contradict the
+ * Host in exactly the windows where its view is empty: a second window, a
+ * reload, a reconnect. So a resolved reference submits, and a Session with
+ * nothing to stop is refused by the Gate.
+ */
+export type WorkHubStopClarificationReason =
+ /** The stop names no safe target of its own — a pronoun or a bare noun. */
+ | 'stop_target_required'
+ /** The stop names more than one existing Session. */
+ | 'stop_target_ambiguous'
+ /** The Host refused the stop; its conflict is the whole answer. */
+ | 'stop_target_unavailable';
+
+/**
+ * A stop clarification never offers route options. Choosing one re-sends the
+ * original text as work, and stop-shaped text is exactly what must not be
+ * delivered to a Session that way, so the reason carries the whole answer.
+ */
+export type WorkHubStopRouteDecision =
+ | { kind: 'not_requested' }
+ | { kind: 'clarification'; reason: WorkHubStopClarificationReason }
+ | { kind: 'target'; target: WorkHubRouteTarget };
+
export interface WorkHubRoutePolicy {
+ resolveStop(input: {
+ text: string;
+ sessions: WorkHubRoutableSession[];
+ }): WorkHubStopRouteDecision;
resolve(input: {
text: string;
sessions: WorkHubRoutableSession[];
@@ -101,15 +138,67 @@ const MAX_RELATED_CLARIFICATION_OPTIONS = 4;
* It owns only transient inference context. Session identity, transcript,
* execution state, and recovery continue to come from the Session port.
*/
-export function createWorkHubRoutePolicy(): WorkHubRoutePolicy {
- return createWorkHubRoutePolicyVisit();
+export function createWorkHubRoutePolicy(
+ sessionResolver: WorkHubSessionResolver = createExactNameSessionResolver(),
+): WorkHubRoutePolicy {
+ return createWorkHubRoutePolicyVisit(sessionResolver);
}
-function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy {
+function createWorkHubRoutePolicyVisit(
+ sessionResolver: WorkHubSessionResolver,
+): WorkHubRoutePolicy {
let currentFocus: WorkHubRouteTarget | undefined;
let previousFocus: WorkHubRouteTarget | undefined;
return {
+ // The stop Action Policy. Action Intent says only that the user issued a
+ // stop imperative and what work it refers to; the shared Session Resolver
+ // recalls which visible Sessions that reference names; this policy decides
+ // whether the resolution is sufficient for a destructive action.
+ //
+ // Direct stop is a narrow claim over WorkHub's own active delegations, not
+ // a filter over every sentence that begins with "stop". A reference that
+ // recalls no WorkHub identity — "Stop using the deprecated API" — is
+ // ordinary work and falls through to routing; an unsafe or anaphoric
+ // reference still fails closed, and a resolved Session that is not uniquely
+ // stoppable says why.
+ resolveStop({ text, sessions }) {
+ const intent = readWorkHubRequestIntent(text);
+ if (!intent.stop.cue) return { kind: 'not_requested' };
+ const reference = intent.stop.imperative ? intent.stop.target : undefined;
+ if (!reference) {
+ return { kind: 'clarification', reason: 'stop_target_required' };
+ }
+ const sessionByRef = new Map(
+ sessions.map((session) => [session.target.sessionId, session]),
+ );
+ const resolution = sessionResolver.resolve({
+ reference: { text: reference },
+ sessions: sessions.map(resolverSession),
+ });
+ if (resolution.kind === 'none') return { kind: 'not_requested' };
+ // Stop's own tail rule. The Resolver reports what the reference said
+ // after the name; a destructive command may add punctuation and nothing
+ // else, so `Stop Payments and Login` names no stoppable target here even
+ // though `Payments` matched.
+ const admissible = resolution.candidates.filter(
+ ({ evidence }) =>
+ evidence.kind === 'elided_name_punctuation' ||
+ /^[.!?。!?]*$/u.test(evidence.remainder),
+ );
+ if (admissible.length === 0) return { kind: 'not_requested' };
+ // Stop admits one candidate only. A ranked resolver may return several;
+ // this action never picks a winner from a ranking it cannot justify.
+ if (resolution.kind === 'ambiguous' || admissible.length > 1) {
+ return { kind: 'clarification', reason: 'stop_target_ambiguous' };
+ }
+ const resolved = sessionByRef.get(admissible[0]!.ref);
+ if (!resolved) return { kind: 'not_requested' };
+ // The reference resolved, which is everything this policy can prove.
+ // Which delegation to end, and whether there is one at all, is the
+ // Host's answer and is made under the lease that performs the stop.
+ return { kind: 'target', target: resolved.target };
+ },
resolve({ text, sessions, originPromptBySessionId, explicitTarget }) {
const intent = readWorkHubRequestIntent(text);
if (intent.execution === 'ambiguous') {
@@ -142,8 +231,20 @@ function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy {
}
const alternatives = sessions.filter((session) =>
session.target.sessionId !== correctedFrom.sessionId);
+ // Correction recalls its target through the same shared port as stop,
+ // then applies its own tail rule: a correction may name the Session and
+ // go on to say what to do with it.
+ const correctionResolution = sessionResolver.resolve({
+ reference: { text: correctionText },
+ sessions: alternatives.map(resolverSession),
+ });
+ const affirmed = new Set(
+ (correctionResolution.kind === 'none' ? [] : correctionResolution.candidates)
+ .filter(({ evidence }) => workHubCorrectionAdmitsReference(correctionText, evidence))
+ .map(({ ref }) => ref),
+ );
const affirmedCorrections = alternatives.filter((session) =>
- workHubCorrectionTargetsSession(intent, session.sessionName));
+ affirmed.has(session.target.sessionId));
if (affirmedCorrections.length === 1) {
return {
kind: 'target',
@@ -268,7 +369,7 @@ function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy {
}
},
newVisit() {
- return createWorkHubRoutePolicyVisit();
+ return createWorkHubRoutePolicyVisit(sessionResolver);
},
rememberTarget(target) {
if (currentFocus?.sessionId === target.sessionId) return;
@@ -278,6 +379,16 @@ function createWorkHubRoutePolicyVisit(): WorkHubRoutePolicy {
};
}
+/** Presents one routable Session to the Resolver as a bounded opaque candidate. */
+function resolverSession(session: WorkHubRoutableSession): WorkHubResolverSession {
+ return {
+ ref: session.target.sessionId,
+ sessionName: session.sessionName,
+ projectName: session.projectName,
+ updatedAt: session.updatedAt,
+ };
+}
+
function rankExactSessions(
text: string,
sessions: WorkHubRoutableSession[],
diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx
index 1713c6b9df..8f9bf0532a 100644
--- a/apps/desktop/src/renderer/workhub-surface.tsx
+++ b/apps/desktop/src/renderer/workhub-surface.tsx
@@ -134,14 +134,17 @@ export function visibleWorkHubConversation(
const localTurn = localByRequestId.get(turn.turnId);
return !localTurn ||
localTurn.outcome?.kind === 'discussion' ||
- localTurn.outcome?.kind === 'submitted';
+ localTurn.outcome?.kind === 'submitted' ||
+ localTurn.outcome?.kind === 'stop';
},
);
const coordinationTurnIds = new Set(coordination.map(({ turnId }) => turnId));
const visibleLocal = local.filter(
(turn) =>
!coordinationTurnIds.has(turn.requestId) ||
- (turn.outcome?.kind !== 'discussion' && turn.outcome?.kind !== 'submitted'),
+ (turn.outcome?.kind !== 'discussion' &&
+ turn.outcome?.kind !== 'submitted' &&
+ turn.outcome?.kind !== 'stop'),
);
return { coordination: visibleCoordination, local: visibleLocal };
}
@@ -172,7 +175,8 @@ export async function submitAndRecordWorkHubSurfaceInput(input: {
if (
result.kind === 'discussion' ||
result.kind === 'waiting' ||
- result.kind === 'submitted'
+ result.kind === 'submitted' ||
+ result.kind === 'stop'
) {
return result;
}
@@ -316,7 +320,7 @@ export function WorkHubSurface(props: {
? { ...turn, state: 'settled', outcome: result }
: turn,
));
- if (result.kind === 'submitted') await refresh();
+ if (result.kind === 'submitted' || result.kind === 'stop') await refresh();
return result;
} catch (error) {
if (isTerminalWorkHubSurfaceFailure(error)) {
@@ -564,16 +568,38 @@ export function WorkHubCoordinationTurnView(props: {
(candidate) => candidate.target.sessionId === assignment.targetSessionId,
)
: undefined;
+ const stoppedSession = props.turn.stop
+ ? props.projection.sessions.find(
+ (candidate) => candidate.target.sessionId === props.turn.stop!.targetSessionId,
+ )
+ : undefined;
return (
- {assignment ? (
+ {props.turn.stop ? (
+
+ ) : assignment ? (
['reason'],
+ copy: ReturnType,
+): string | undefined {
+ if (reason === 'ambiguous_command') return copy.confirmCommand;
+ if (reason === 'stop_target_required') return copy.stopTargetRequired;
+ if (reason === 'stop_target_ambiguous') return copy.stopTargetAmbiguous;
+ if (reason === 'stop_target_unavailable') return copy.stopTargetUnavailable;
+ return undefined;
+}
+
export function workHubCoordinationSummary(
result: Exclude,
projection: WorkHubProjection,
copy: ReturnType,
): string {
if (result.kind === 'clarification') {
- if (result.reason === 'ambiguous_command') return copy.confirmCommand;
+ const prompt = workHubClarificationPrompt(result.reason, copy);
+ if (prompt) {
+ return result.options.length > 0
+ ? `${prompt} ${result.options.map(({ sessionName }) => sessionName).join('、')}`
+ : prompt;
+ }
return `${copy.chooseWork} ${result.options.map(({ sessionName }) => sessionName).join('、')}`;
}
if (result.kind === 'waiting') {
return `${copy.waitingForDecision} ${copy.requestNotSent}`;
}
+ if (result.kind === 'stop') return copy.stopOutcomes[result.outcome];
const target = projection.sessions.find(
(session) => session.target.sessionId === result.target.sessionId,
);
@@ -633,6 +682,7 @@ function WorkHubTurnView(props: {
}) {
const { turn, copy } = props;
const submitted = turn.outcome?.kind === 'submitted' ? turn.outcome : undefined;
+ const stopped = turn.outcome?.kind === 'stop' ? turn.outcome : undefined;
const target = submitted
? props.projection.sessions.find((session) => session.target.sessionId === submitted.target.sessionId)
: undefined;
@@ -647,9 +697,7 @@ function WorkHubTurnView(props: {
) : turn.outcome?.kind === 'clarification' ? (
<>
- {turn.outcome.reason === 'ambiguous_command'
- ? copy.confirmCommand
- : copy.chooseWork}
+ {workHubClarificationPrompt(turn.outcome.reason, copy) ?? copy.chooseWork}
{turn.outcome.options.length > 0 ? (
{turn.outcome.options.map((option) => (
@@ -679,6 +727,18 @@ function WorkHubTurnView(props: {
{copy.waitingForDecision}
{copy.requestNotSent}
+ ) : stopped ? (
+ session.target.sessionId === stopped.target.sessionId,
+ )}
+ targetSessionId={stopped.target.sessionId}
+ heading={copy.stopOutcomes[stopped.outcome]}
+ state={stopped.outcome === 'not_owned' ? copy.openSessionToStop : copy.stopRecorded}
+ result={undefined}
+ copy={copy}
+ onOpenSession={props.onOpenSession}
+ />
) : submitted ? (
`${count} 项工作`, clarification: '选择工作',
chooseWork: '这条输入可能与多项工作有关,请选择目标:',
confirmCommand: workHubAmbiguousCommandPrompt(locale),
+ stopTargetRequired: '请明确说出要停止的工作名称,例如“停止 支付任务”。',
+ stopTargetAmbiguous: '这个名称对应多项工作;请打开具体的 Session 停止对应委托。',
+ stopTargetUnavailable: '这项工作现在没有可以停止的单个 WorkHub 委托;请打开该 Session 查看。',
discussionStayed: '这条内容暂时保留在 WorkHub,没有创建或改动 Session。',
discussionHint: '提出明确的执行目标后,我会把它交给对应的 Session。',
answering: '正在回答…',
choseWork: (name: string) => `选择“${name}”`,
sentTo: '已交给:', createdWork: '已创建新工作:', accepted: '已接收', sessionFallback: '普通 Session',
+ stoppingWork: '正在请求停止:', stopping: '正在处理', stopRecorded: '结果已记录',
+ openSessionToStop: '这个 Turn 不由该委托独占;请打开 Session 处理',
+ stopOutcomes: {
+ cancelled_pending: '已取消尚未开始的工作:',
+ stop_delivered: '已向运行中的工作发出停止请求:',
+ already_terminal: '这项工作已经结束:',
+ not_owned: '未停止共享或用户拥有的 Turn:',
+ },
waitingForDecision: '这项工作正在等待你的决定。',
requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。',
routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。',
@@ -810,6 +881,7 @@ function workHubCopy(locale: UiLocale) {
active: (execution: string) => `关联有效 · ${execution}`,
superseded: '已被更正',
aborted: '更正已中止',
+ stopped: '已停止关联',
},
turnStates: { running: '进行中', completed: '已完成', aborted: '已中止', failed: '失败' },
} as const;
@@ -824,11 +896,24 @@ function workHubCopy(locale: UiLocale) {
workCount: (count: number) => `${count} work item${count === 1 ? '' : 's'}`, clarification: 'Choose work',
chooseWork: 'This input may relate to more than one task. Choose a target:',
confirmCommand: workHubAmbiguousCommandPrompt(locale),
+ stopTargetRequired: 'Name the work explicitly, for example “Stop Payments”.',
+ stopTargetAmbiguous:
+ 'That name matches more than one work item. Open the exact Session to stop its delegation.',
+ stopTargetUnavailable:
+ 'This work has no single WorkHub delegation to stop right now. Open its Session to see what is running.',
discussionStayed: 'This stayed in WorkHub without creating or changing a Session.',
discussionHint: 'State an executable goal and I will hand it to the owning Session.',
answering: 'Answering…',
choseWork: (name: string) => `Choose “${name}”`,
sentTo: 'Sent to:', createdWork: 'Created new work:', accepted: 'Accepted', sessionFallback: 'Ordinary Session',
+ stoppingWork: 'Requesting stop:', stopping: 'Stopping', stopRecorded: 'Result recorded',
+ openSessionToStop: 'This Turn is shared or user-owned. Open the Session to stop it.',
+ stopOutcomes: {
+ cancelled_pending: 'Cancelled work that had not started:',
+ stop_delivered: 'Asked the running work to stop:',
+ already_terminal: 'This work had already ended:',
+ not_owned: 'Did not stop a shared or user-owned Turn:',
+ },
waitingForDecision: 'This work is waiting for your decision.',
requestNotSent: 'The new request was not sent. Resolve the interaction in its Session, then send again.',
routing: 'Choosing the right Session…', loadFailed: 'Could not read existing work.',
@@ -858,6 +943,7 @@ function workHubCopy(locale: UiLocale) {
active: (execution: string) => `Active link · ${execution}`,
superseded: 'Superseded link',
aborted: 'Aborted replacement',
+ stopped: 'Stopped link',
},
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 06ae83fd5e..93522fc3d4 100644
--- a/docs/architecture/workhub-coordination-session-adr.md
+++ b/docs/architecture/workhub-coordination-session-adr.md
@@ -184,6 +184,53 @@ waiting after the destructive retirement boundary, Coordination appends a
retired source from active linkage and makes later retries return the same terminal
outcome instead of displaying a stopped, unsuperseded link.
+Direct stop resolves its target through the shared Session Resolver, then asks the
+Host which of that Session's delegations still hold stoppable work before it
+answers the user or proposes anything. WorkHub projections are rebuildable and may
+be empty when a window opens, so a destructive answer is never given from one. The
+proposal then carries only what resolution produced: the opaque delegation identity
+and the Session it belongs to. Display names are retrieval evidence on the proposal side and never
+appear in admission, and the proposal asserts no proof of its own — the Host makes
+those from durable state. The Action Gate revalidates immediately before any
+effect: the assignment still exists, it still belongs to the proposed Session, and
+no other delegation on that Session still holds work that could be stopped. A
+delegation link ends only by supersession or a resolved stop, so finished work
+stays linked while ceasing to be a competing stop target; execution state that
+cannot be read counts as competing, never as finished. Visibility is proved for
+the stopped delegation alone, because nothing retires a delegation whose Session
+was deleted and proving it over the whole active set would let one deleted Session
+block every stop. A stale resolution therefore fails closed, while a rename between
+resolution and admission is correctly irrelevant. Trusted user text still has to
+carry a direct stop imperative, and the `user_stop` confirmation stays outside
+strategy output, so neither model output nor a display name can select what gets
+stopped.
+
+Direct stop persists a distinct `delegation_stop_requested` claim before
+retirement and a `delegation_stop_resolved` observation afterward. The pending
+cancellation tombstone retains the destructive action identity, preserving
+`cancelled_pending` across a crash between those two Coordination records. Its
+owning-root Stop uses an action-derived abort source on the exact target Turn,
+so recovery cannot mistake a normal Session stop for WorkHub delivery. Its
+admission holds the Coordination Session together with every active target
+Session lane while re-reading the active links. A concurrent assignment must
+therefore settle before the sole-delegation proof, wait until after the stop
+claim, or cause admission to fail closed. Only a confirmed direct stop records
+that provenance: a route correction retiring the same owning root carries its own
+cancellation claim but keeps the neutral Stop source, so replay cannot read a
+correction as a delivered stop.
+
+Every durable WorkHub record is keyed by what it is about — an assignment by its
+action, a stop or replacement by its delegation — so no single record can see an
+action identity that moved to a second delegation or a second disposition. A
+separate durable action claim, taken under the same Coordination admission
+before any effect, is that global owner. Exact replay converges on it; any other
+reuse of the identity fails closed before an effect, including after a rejected
+or still-recovering attempt and across Host restarts. The claim carries no
+Session foreign key, because a committed destructive claim has to outlive the
+removal of its target: when the target Session is gone, its removal tombstone —
+not the vanished Message proof, and never a merely unreadable target — is what
+lets the stop reach a terminal resolution.
+
## Consequences, costs, and reevaluation
- WorkHub gains persistent conversational continuity without adding another
@@ -206,8 +253,14 @@ outcome instead of displaying a stopped, unsuperseded link.
that transcript; target lifecycle projection and the hybrid first-response
contract are implemented as rebuildable reads. Linked correction, exact
target-owned pending cancellation/Turn Stop, atomic supersession, and retry-based
- replacement recovery are implemented. Broader stop/resume controls remain later
- work.
+ replacement recovery and direct stop are implemented. Direct
+ stop uses durable `delegation_stop_requested` / `delegation_stop_resolved`
+ facts, exact Message ownership, and first-claim-wins arbitration with
+ replacement. Its target comes from the shared Session Resolver port, whose
+ first implementation is a temporary exact-name baseline; replacing it changes
+ recall only, because admission revalidates opaque identity and expected state
+ rather than any display name. Pause, resume, and pronoun-based stop controls
+ remain later work.
Reevaluate the per-Host decision if supported workflows require one WorkHub
conversation to coordinate ordinary Sessions on multiple Runtime Hosts, or if Host
diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md
index a78f03655a..c0888ca291 100644
--- a/docs/workhub-domain-language.md
+++ b/docs/workhub-domain-language.md
@@ -67,9 +67,29 @@ bounded, valid ordinary Session; `create_new` creates an ordinary Session before
delegating and is visibly announced as new work; and `clarify` continues in the
Coordination Session without guessing or creating.
+**Action Intent**: A bounded interpretation of what the user is trying to do,
+such as discuss, delegate, inspect, continue, stop, or resume. It carries trusted
+user-input evidence but no selected Session and no execution authority.
+
+**Session Resolver**: The shared, replaceable capability that recalls and ranks
+visible existing ordinary Sessions for a user reference. It may return ranked
+candidates, no candidate, or ambiguity. It never returns `create_new`, chooses a
+final coordination outcome, or grants execution authority. Exact-name matching is
+only a temporary deterministic implementation; future ranked implementations use
+the same contract.
+
+**Action Policy**: Deterministic, action-specific rules that combine Action Intent,
+Session resolution, and current product constraints to propose an existing-target
+action, explicit creation, clarification, local discussion, or safe rejection.
+Creation is a policy decision rather than a retrieval result.
+
+**Action Proposal**: A closed typed request produced by an Action Policy. It uses
+opaque stable target identities and expected-state preconditions, but remains
+advisory until the Action Gate revalidates and admits it.
+
**delegation**: A bounded reference from a Coordination Turn to one target ordinary
Session and Turn, including only its identity, disposition, and coordination-owned
-link status (`active`, `superseded`, or `aborted`). A link is `aborted` only when a
+link status (`active`, `superseded`, `aborted`, or `stopped`). A link is `aborted` only when a
correction retired its source but the replacement target became unavailable or
started waiting before admission; it is not the target Turn's execution status.
Delegation links the separately authoritative transcripts; it does not copy the
@@ -105,6 +125,63 @@ the Coordination transcript records an auditable replacement-aborted terminal
fact and removes the retired source from active linkage.
Correction never replaces either Session's transcript authority.
+**Direct stop**: A user's explicit imperative to retire one active durable
+delegation. A delegation link ends only by supersession or a resolved stop, so a
+delegation whose work has finished is still linked; it is no longer a stop target,
+because there is nothing left in it to stop. Only work that could still be stopped
+makes a Session's stop target ambiguous, and execution state that cannot be read
+is never treated as finished. The initial deterministic implementation accepts exact display-name
+references behind the shared Session Resolver contract; exact-name syntax is not
+the long-term product boundary. Pronouns, pause/wait language, questions, advice,
+negation, unresolved or ambiguous targets, and model-supplied Session, Turn, Run,
+or Message identities grant no Stop authority. A future ranked resolver may recall
+a Session from other permitted evidence, but the Action Policy must still require a
+sufficiently resolved active WorkHub delegation and the Action Gate must revalidate
+its stable identity. The stop proposal therefore carries opaque identities and the
+expected active-delegation state the policy resolved against, never a display name;
+the Action Gate readmits it only while the assignment still belongs to that Session
+and that Session's active delegations are still exactly the one being stopped.
+A rename between resolution and admission is irrelevant, and a stale resolution
+fails closed. WorkHub first records
+`delegation_stop_requested`, resolves the source action to its durable
+delegation, and lets the target Session's Message authority observe one of four
+outcomes: `cancelled_pending`, `stop_delivered`, `already_terminal`, or `not_owned`.
+It then records the neutral `delegation_stop_resolved` fact. `stop_delivered` means
+the exact owning root accepted the Stop operation; the UI says that WorkHub asked
+it to stop rather than inventing an execution result. `not_owned` means the
+Message was consumed by a shared or user-owned Turn; WorkHub does not stop that
+Turn, preserves the active link, and navigates the user to the owning Session.
+A stop reference that recalls no existing WorkHub Session is ordinary work — `Stop
+using the deprecated API` is a task, not a destructive command — and routes
+normally. An ambiguous recall, a resolved Session that is not uniquely stoppable,
+and an unsafe or anaphoric reference each fail closed with the reason they failed
+rather than an unanswerable prompt. Whether a resolved Session is uniquely
+stoppable is asked of the Host once a reference resolves, never answered from a
+client's delegation projection: that projection is empty until the Coordination
+stream fills it, so a fresh window or a reconnect would otherwise state
+confidently that running work does not exist.
+An unresolved direct-stop claim and a replacement claim are mutually exclusive;
+the first durable destructive claim wins. A `not_owned` resolution releases that
+exclusion so a later explicit route correction can proceed, and because it leaves
+the delegation active, a later attempt under a fresh request identity converges on
+that same immutable `not_owned` outcome instead of colliding with the first claim.
+The pending-Message cancellation tombstone binds the durable stop action that
+created it, so a crash after cancellation but before resolution still replays
+`cancelled_pending` rather than degrading to `already_terminal`. Owning-root Stop
+likewise writes the direct-stop action identity into the exact root Turn's
+durable abort source. A retry recognizes only that matching proof; an earlier or
+concurrent manual Stop remains `already_terminal`. Root registration is in-memory,
+so between a Host restart and execution recovery a running root looks inactive.
+`already_terminal` is an immutable observation, so only a durably terminal target
+snapshot may claim it; an unrecovered target is still resolving instead. Stop admission holds the
+Coordination Session and every currently active target Session lane
+while it rechecks current target identities and active links; a concurrent new
+delegation therefore cannot invalidate the one-target proof after the request
+record commits. Removing the target Session destroys the Message proof a
+committed claim still needs; the removal tombstone outlives that Session and
+resolves the claim as `already_terminal`, while a target that is merely
+unreadable, or one that never existed here, stays unresolved.
+
**R2.4**: The deterministic context-continuity routing baseline. It remains useful
as an experiment baseline or target resolver behind WorkHub's coordination layer;
it is not the final architecture or authority boundary of WorkHub.
diff --git a/packages/core/package.json b/packages/core/package.json
index 8ef4b2638c..11e29d6f3b 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -78,6 +78,7 @@
"./daily-review": "./dist/daily-review.js",
"./work-board": "./dist/work-board.js",
"./workhub-creation-intent": "./dist/workhub-creation-intent.js",
+ "./workhub-session-resolver": "./dist/workhub-session-resolver.js",
"./deep-research": "./dist/deep-research.js",
"./session-start-mode": "./dist/session-start-mode.js",
"./long-term-memory": "./dist/long-term-memory.js",
diff --git a/packages/core/src/__tests__/workhub-coordination-record.test.ts b/packages/core/src/__tests__/workhub-coordination-record.test.ts
index ad167c6cc3..3d31480799 100644
--- a/packages/core/src/__tests__/workhub-coordination-record.test.ts
+++ b/packages/core/src/__tests__/workhub-coordination-record.test.ts
@@ -199,4 +199,66 @@ describe('WorkHub Coordination stored records', () => {
/Invalid stored message schema/u,
);
});
+
+ test('decodes exact direct-stop request and observed resolution records', () => {
+ const requested = {
+ type: 'workhub_coordination',
+ id: 'stop-request-id',
+ turnId: 'stop-action',
+ ts: 4,
+ schemaVersion: 3,
+ kind: 'delegation_stop_requested',
+ actionId: 'stop-action',
+ actionFingerprint: FINGERPRINT,
+ coordinationTurnId: 'stop-action',
+ stopsActionId: 'original-action',
+ stopsDelegationId: 'original-delegation',
+ targetSessionId: 'payments',
+ targetMessageId: 'payments-message',
+ targetSessionName: 'Payments',
+ userText: 'Stop Payments',
+ } as const;
+ const resolved = {
+ type: 'workhub_coordination',
+ id: 'stop-resolution-id',
+ turnId: 'stop-action',
+ ts: 5,
+ schemaVersion: 3,
+ kind: 'delegation_stop_resolved',
+ actionId: 'stop-action',
+ actionFingerprint: FINGERPRINT,
+ coordinationTurnId: 'stop-action',
+ stopsActionId: 'original-action',
+ stopsDelegationId: 'original-delegation',
+ targetSessionId: 'payments',
+ targetTurnId: 'payments-turn',
+ outcome: 'stop_delivered',
+ } as const;
+
+ assert.deepEqual(decodeCanonicalMessage(requested), requested);
+ assert.deepEqual(decodeCanonicalMessage(resolved), resolved);
+ for (const invalid of [
+ { ...requested, candidateRef: 'injected' },
+ { ...requested, schemaVersion: 2 },
+ { ...resolved, outcome: 'stopped' },
+ { ...resolved, runId: 'injected' },
+ { ...resolved, targetTurnId: '' },
+ { ...resolved, targetTurnId: undefined },
+ { ...resolved, outcome: 'cancelled_pending' },
+ ]) {
+ assert.throws(() => decodeCanonicalMessage(invalid), /Invalid stored message schema/u);
+ }
+ assert.deepEqual(
+ decodeCanonicalMessage({
+ ...resolved,
+ outcome: 'cancelled_pending',
+ targetTurnId: undefined,
+ }),
+ {
+ ...resolved,
+ outcome: 'cancelled_pending',
+ targetTurnId: undefined,
+ },
+ );
+ });
});
diff --git a/packages/core/src/__tests__/workhub-creation-intent.test.ts b/packages/core/src/__tests__/workhub-creation-intent.test.ts
index bce72071bf..5919ddedb8 100644
--- a/packages/core/src/__tests__/workhub-creation-intent.test.ts
+++ b/packages/core/src/__tests__/workhub-creation-intent.test.ts
@@ -23,9 +23,23 @@ import {
readWorkHubRequestIntent,
workHubCorrectionTargetsSession,
workHubCreationAuthorizesTitle,
+ matchWorkHubSessionName,
+ type WorkHubRequestIntent,
} from '../workhub-creation-intent.js';
const intentFor = readWorkHubRequestIntent;
+/**
+ * The stop Action Policy, reproduced here over the shared matcher: a stop
+ * reference may carry punctuation after the name and nothing else.
+ */
+const workHubStopTargetsSession = (intent: WorkHubRequestIntent, sessionName: string): boolean => {
+ if (!intent.stop.imperative || !intent.stop.target) return false;
+ const match = matchWorkHubSessionName(intent.stop.target, sessionName);
+ return (
+ match.kind === 'elided_name_punctuation' ||
+ (match.kind === 'named' && /^[.!?。!?]*$/u.test(match.remainder))
+ );
+};
const affirmativeWorkHubExistingCorrectionTarget = (value: string) =>
intentFor(value).correction.existingTarget;
const affirmativeWorkHubNamedCreationTitle = (value: string) => {
@@ -185,6 +199,21 @@ test('requires an affirmative target action for destructive corrections', () =>
assert.equal(isAffirmativeWorkHubCorrectionRequest(text), false, text);
assert.equal(isExplicitWorkHubCreationRequest(text), false, text);
}
+
+ for (const sessionName of ['U.S.', 'Dr.']) {
+ assert.equal(
+ workHubStopTargetsSession(readWorkHubRequestIntent(`Stop ${sessionName}`), sessionName),
+ true,
+ sessionName,
+ );
+ }
+ for (const text of ['Stop Payments, fix Login', 'Stop Payments and Login']) {
+ assert.equal(
+ workHubStopTargetsSession(readWorkHubRequestIntent(text), 'Payments'),
+ false,
+ text,
+ );
+ }
});
test('recognizes affirmative creation after an explicit contrast', () => {
@@ -997,3 +1026,43 @@ test('returns one bounded intent record for routing and admission', () => {
assert.equal(readWorkHubRequestIntent(text).execution, 'non_executable', text);
}
});
+
+test('requires a direct, explicitly named command for WorkHub stop authority', () => {
+ for (const [text, target] of [
+ ['Stop Payments', 'Payments'],
+ ['Please cancel the session Payments.', 'Payments'],
+ ['Terminate work "API migration"', 'API migration'],
+ ['停止支付任务', '支付任务'],
+ ['请取消这个会话 登录稳定性。', '登录稳定性'],
+ ] as const) {
+ const intent = readWorkHubRequestIntent(text);
+ assert.deepEqual(intent.stop, { cue: true, imperative: true, target }, text);
+ assert.equal(workHubStopTargetsSession(intent, target), true, text);
+ assert.equal(workHubStopTargetsSession(intent, `${target} extra`), false, text);
+ }
+
+ for (const text of [
+ 'Stop it',
+ 'Cancel this work',
+ '取消这个工作',
+ 'Pause Payments',
+ 'Wait on Payments',
+ 'How do I stop Payments?',
+ 'Can you stop Payments?',
+ 'Do not stop Payments',
+ "Don't cancel Payments",
+ '不要停止支付任务',
+ 'The literal text is "Stop Payments"',
+ '"Stop Payments"',
+ 'Stop "Payments',
+ ]) {
+ assert.deepEqual(
+ readWorkHubRequestIntent(text).stop,
+ {
+ cue: text === 'Stop it' || text === 'Cancel this work' || text === '取消这个工作',
+ imperative: false,
+ },
+ text,
+ );
+ }
+});
diff --git a/packages/core/src/__tests__/workhub-session-resolver.test.ts b/packages/core/src/__tests__/workhub-session-resolver.test.ts
new file mode 100644
index 0000000000..5092f8a5fe
--- /dev/null
+++ b/packages/core/src/__tests__/workhub-session-resolver.test.ts
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { readWorkHubRequestIntent } from '../workhub-creation-intent.js';
+import {
+ createExactNameSessionResolver,
+ type WorkHubResolverSession,
+} from '../workhub-session-resolver.js';
+
+const session = (ref: string, sessionName: string, updatedAt = 1): WorkHubResolverSession => ({
+ ref,
+ sessionName,
+ projectName: 'demo',
+ updatedAt,
+});
+
+const resolveText = (text: string, sessions: readonly WorkHubResolverSession[]) => {
+ const reference = readWorkHubRequestIntent(text).stop.target;
+ assert.ok(reference, text);
+ return createExactNameSessionResolver().resolve({ reference: { text: reference }, sessions });
+};
+
+test('the exact-name resolver recalls one visible Session by opaque reference', () => {
+ assert.deepEqual(
+ resolveText('Stop Payments', [session('s1', 'Payments'), session('s2', 'Login')]),
+ { kind: 'ranked', candidates: [{ ref: 's1', evidence: { kind: 'named', remainder: '' } }] },
+ );
+ assert.deepEqual(resolveText('停止支付任务', [session('s1', '支付任务')]), {
+ kind: 'ranked',
+ candidates: [{ ref: 's1', evidence: { kind: 'named', remainder: '' } }],
+ });
+});
+
+test('a reference that names nothing visible resolves to none', () => {
+ assert.deepEqual(resolveText('Stop using the deprecated API', [session('s1', 'Payments')]), {
+ kind: 'none',
+ });
+ assert.deepEqual(resolveText('Stop Payments', []), { kind: 'none' });
+});
+
+test('equal exact matches are ambiguity rather than an unjustified ranking', () => {
+ assert.deepEqual(
+ resolveText('Stop Payments', [session('s1', 'Payments'), session('s2', 'Payments')]),
+ {
+ kind: 'ambiguous',
+ candidates: [
+ { ref: 's1', evidence: { kind: 'named', remainder: '' } },
+ { ref: 's2', evidence: { kind: 'named', remainder: '' } },
+ ],
+ },
+ );
+});
diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts
index fb29abb545..4d7c85ea0a 100644
--- a/packages/core/src/session.ts
+++ b/packages/core/src/session.ts
@@ -935,6 +935,7 @@ export interface TurnStateMessage {
export const WORKHUB_COORDINATION_RECORD_SCHEMA_VERSION = 1 as const;
export const WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION = 2 as const;
+export const WORKHUB_COORDINATION_STOP_SCHEMA_VERSION = 3 as const;
export type WorkHubDelegationDisposition = 'delegate_existing' | 'create_new';
@@ -1028,11 +1029,93 @@ export interface WorkHubDelegationReplacementAbortedMessage {
reason: 'target_unavailable' | 'target_waiting_for_user';
}
+export type WorkHubDelegationStopOutcome =
+ | 'cancelled_pending'
+ | 'stop_delivered'
+ | 'already_terminal'
+ | 'not_owned';
+
+/** Durable destructive claim written before attempting to retire one delegation. */
+export interface WorkHubDelegationStopRequestedMessage {
+ type: 'workhub_coordination';
+ id: string;
+ turnId: string;
+ ts: number;
+ schemaVersion: typeof WORKHUB_COORDINATION_STOP_SCHEMA_VERSION;
+ kind: 'delegation_stop_requested';
+ actionId: string;
+ actionFingerprint: `sha256:${string}`;
+ coordinationTurnId: string;
+ stopsActionId: string;
+ stopsDelegationId: string;
+ targetSessionId: string;
+ targetMessageId: string;
+ targetSessionName: string;
+ userText: string;
+}
+
+/** Durable observed result of a direct-stop attempt. */
+export interface WorkHubDelegationStopResolvedMessage {
+ type: 'workhub_coordination';
+ id: string;
+ turnId: string;
+ ts: number;
+ schemaVersion: typeof WORKHUB_COORDINATION_STOP_SCHEMA_VERSION;
+ kind: 'delegation_stop_resolved';
+ actionId: string;
+ actionFingerprint: `sha256:${string}`;
+ coordinationTurnId: string;
+ stopsActionId: string;
+ stopsDelegationId: string;
+ targetSessionId: string;
+ outcome: WorkHubDelegationStopOutcome;
+ targetTurnId?: string;
+}
+
+/**
+ * The exact durable operation one WorkHub action identity is allowed to own.
+ *
+ * Per-record identity is keyed by the thing each record is about — an
+ * assignment by its action, a stop or replacement by its delegation — so no
+ * single record can reject an action id that crossed to another delegation or
+ * another disposition. This vocabulary names the one global owner that can.
+ */
+export type WorkHubActionOperation =
+ | 'answer_here'
+ | 'clarify'
+ | 'delegate_existing'
+ | 'create_new'
+ | 'replace'
+ | 'stop';
+
+/** Durable global binding from one action identity to one exact operation. */
+export interface WorkHubActionClaim {
+ readonly actionId: string;
+ readonly operation: WorkHubActionOperation;
+ readonly actionFingerprint: `sha256:${string}`;
+ /** The durable identity this action owns: a delegation or a Coordination Turn. */
+ readonly subject: string;
+}
+
+export type WorkHubActionClaimOutcome = 'claimed' | 'same_claim' | 'conflict';
+
export type WorkHubCoordinationMessage =
| WorkHubDelegationAssignedMessage
| WorkHubDelegationReplacementRequestedMessage
| WorkHubDelegationReplacementAbortedMessage
- | WorkHubDelegationSupersededMessage;
+ | WorkHubDelegationSupersededMessage
+ | WorkHubDelegationStopRequestedMessage
+ | WorkHubDelegationStopResolvedMessage;
+
+function isWorkHubDelegationStopResolution(
+ outcome: unknown,
+ targetTurnId: unknown,
+): outcome is WorkHubDelegationStopOutcome {
+ const hasTargetTurnId = typeof targetTurnId === 'string' && targetTurnId.length > 0;
+ if (outcome === 'stop_delivered' || outcome === 'not_owned') return hasTargetTurnId;
+ if (outcome === 'cancelled_pending') return targetTurnId === undefined;
+ return outcome === 'already_terminal' && (targetTurnId === undefined || hasTargetTurnId);
+}
export interface TurnRecord {
turnId: string;
@@ -1247,6 +1330,46 @@ const WORKHUB_DELEGATION_REPLACEMENT_ABORTED_MESSAGE_SHAPE =
],
[],
);
+const WORKHUB_DELEGATION_STOP_REQUESTED_MESSAGE_SHAPE =
+ defineObjectShape()(
+ [
+ 'type',
+ 'id',
+ 'turnId',
+ 'ts',
+ 'schemaVersion',
+ 'kind',
+ 'actionId',
+ 'actionFingerprint',
+ 'coordinationTurnId',
+ 'stopsActionId',
+ 'stopsDelegationId',
+ 'targetSessionId',
+ 'targetMessageId',
+ 'targetSessionName',
+ 'userText',
+ ],
+ [],
+ );
+const WORKHUB_DELEGATION_STOP_RESOLVED_MESSAGE_SHAPE =
+ defineObjectShape()(
+ [
+ 'type',
+ 'id',
+ 'turnId',
+ 'ts',
+ 'schemaVersion',
+ 'kind',
+ 'actionId',
+ 'actionFingerprint',
+ 'coordinationTurnId',
+ 'stopsActionId',
+ 'stopsDelegationId',
+ 'targetSessionId',
+ 'outcome',
+ ],
+ ['targetTurnId'],
+ );
const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()(
['title', 'workspace'],
[],
@@ -1432,6 +1555,41 @@ function decodeMessage(
}
function isWorkHubCoordinationMessage(message: Record): boolean {
+ if (message.kind === 'delegation_stop_requested') {
+ return (
+ hasMessageEnvelope(message, true) &&
+ hasExactShape(message, WORKHUB_DELEGATION_STOP_REQUESTED_MESSAGE_SHAPE) &&
+ message.schemaVersion === WORKHUB_COORDINATION_STOP_SCHEMA_VERSION &&
+ isWorkHubActionIdentity(message) &&
+ typeof message.stopsActionId === 'string' &&
+ message.stopsActionId.length > 0 &&
+ typeof message.stopsDelegationId === 'string' &&
+ message.stopsDelegationId.length > 0 &&
+ typeof message.targetSessionId === 'string' &&
+ message.targetSessionId.length > 0 &&
+ typeof message.targetMessageId === 'string' &&
+ message.targetMessageId.length > 0 &&
+ typeof message.targetSessionName === 'string' &&
+ message.targetSessionName.trim().length > 0 &&
+ typeof message.userText === 'string' &&
+ message.userText.trim().length > 0
+ );
+ }
+ if (message.kind === 'delegation_stop_resolved') {
+ return (
+ hasMessageEnvelope(message, true) &&
+ hasExactShape(message, WORKHUB_DELEGATION_STOP_RESOLVED_MESSAGE_SHAPE) &&
+ message.schemaVersion === WORKHUB_COORDINATION_STOP_SCHEMA_VERSION &&
+ isWorkHubActionIdentity(message) &&
+ typeof message.stopsActionId === 'string' &&
+ message.stopsActionId.length > 0 &&
+ typeof message.stopsDelegationId === 'string' &&
+ message.stopsDelegationId.length > 0 &&
+ typeof message.targetSessionId === 'string' &&
+ message.targetSessionId.length > 0 &&
+ isWorkHubDelegationStopResolution(message.outcome, message.targetTurnId)
+ );
+ }
if (message.kind === 'delegation_replacement_aborted') {
return (
hasMessageEnvelope(message, true) &&
@@ -1521,6 +1679,18 @@ function isWorkHubCoordinationMessage(message: Record): boolean
);
}
+function isWorkHubActionIdentity(message: Record): boolean {
+ return (
+ typeof message.actionId === 'string' &&
+ message.actionId.length > 0 &&
+ typeof message.actionFingerprint === 'string' &&
+ /^sha256:[a-f0-9]{64}$/u.test(message.actionFingerprint) &&
+ typeof message.coordinationTurnId === 'string' &&
+ message.coordinationTurnId.length > 0 &&
+ message.turnId === message.coordinationTurnId
+ );
+}
+
function isWorkHubDelegationCreateSpec(value: unknown): value is WorkHubDelegationCreateSpec {
if (
!isRecord(value) ||
diff --git a/packages/core/src/workhub-creation-intent.ts b/packages/core/src/workhub-creation-intent.ts
index 613afb6684..c767cf5442 100644
--- a/packages/core/src/workhub-creation-intent.ts
+++ b/packages/core/src/workhub-creation-intent.ts
@@ -99,6 +99,24 @@ const CREATION_REQUEST_PREFIX =
const NAMED_CREATION_TITLE_INTRODUCER =
/\b(?:new|brand[- ]new)\s+(?:session|work|task)[\s,,::-]+(?:(?:called|named|titled)|with\s+(?:the\s+)?title)\s+|(?:新的?|全新的?)?\s*(?:Session|会话|工作|任务)[\s,,::-]*(?:叫做?|名叫|名为|命名为|标题为|名称为|名字为)\s*/iu;
const LEADING_CORRECTION_SEPARATOR = /^[\s,.;:!?,。;:!?—–-]+/u;
+const DIRECT_STOP_REQUEST =
+ /^\s*(?:(?:please|kindly)\s+)?(?:stop|cancel|terminate|halt)\s+(?:(?:the|this)\s+)?(?:(?:session|work|task|job)\s+)?(.+?)\s*[.!。!]?\s*$/iu;
+const DIRECT_CHINESE_STOP_REQUEST =
+ /^\s*(?:(?:请|请帮我|帮我|麻烦你?)\s*)?(?:停止|取消|终止|中止)\s*(?:(?:这个|该)?(?:会话|工作|任务)\s*)?(.+?)\s*[。!]?\s*$/iu;
+const UNSAFE_STOP_TARGET =
+ /^(?:it|this|that|one|everything|all|current|session|work|task|job|(?:this|that|current)\s+(?:session|work|task|job)|它|这个|那个|全部|当前|会话|工作|任务|(?:这个|那个|当前)(?:会话|工作|任务))$/iu;
+
+/**
+ * Where a Session name matched inside a trusted reference, and what followed.
+ *
+ * `remainder` is neutral evidence, not a verdict: the action's policy decides
+ * whether that leftover text is acceptable for what it is about to do.
+ */
+export type WorkHubSessionNameMatch =
+ | { readonly kind: 'none' }
+ | { readonly kind: 'named'; readonly remainder: string }
+ /** The reference is the name with its own trailing punctuation dropped. */
+ | { readonly kind: 'elided_name_punctuation' };
/** How much authority trusted user text carries for starting work. */
export type WorkHubExecutionIntent = 'imperative' | 'ambiguous' | 'non_executable';
@@ -119,6 +137,13 @@ export interface WorkHubRequestIntent {
readonly cue: boolean;
readonly existingTarget?: string;
};
+ readonly stop: {
+ /** A direct stop speech act was present, but its target may still be unsafe. */
+ readonly cue: boolean;
+ /** True only for a direct, explicitly named stop command. */
+ readonly imperative: boolean;
+ readonly target?: string;
+ };
}
/**
@@ -276,6 +301,8 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent {
: { kind: 'unusable' };
const correctionCue = hasWorkHubCorrectionCue(source);
const existingTarget = affirmativeWorkHubExistingCorrectionTarget(source);
+ const stopCue = directWorkHubStopCue(source, literalMask.malformed);
+ const stopTarget = stopCue ? directWorkHubStopTarget(source, false) : undefined;
const actions = allMatches(masked, EXECUTION_ACTION);
const execution: WorkHubExecutionIntent =
literalMask.malformed || naming.kind === 'unusable' || hasDominatingDeliberation(masked)
@@ -292,6 +319,11 @@ export function readWorkHubRequestIntent(value: string): WorkHubRequestIntent {
cue: correctionCue,
...(existingTarget ? { existingTarget } : {}),
},
+ stop: {
+ cue: stopCue,
+ imperative: Boolean(stopTarget),
+ ...(stopTarget ? { target: stopTarget } : {}),
+ },
};
}
@@ -360,10 +392,22 @@ function affirmativeWorkHubExistingCorrectionTarget(value: string): string | und
return lastTarget;
}
-function correctionTargetMatchesSession(target: string, sessionName: string): boolean {
- const normalizedTarget = normalizeCorrectionIdentity(target);
+/**
+ * The one rule for reading a Session name out of a trusted reference.
+ *
+ * It is deliberately action-agnostic: it reports where the name matched and
+ * what text was left over, and says nothing about whether that leftover is
+ * acceptable. Each action's policy owns that question, because the answer
+ * genuinely differs — a stop reference may carry only punctuation after the
+ * name, while a correction may carry a further instruction.
+ */
+export function matchWorkHubSessionName(
+ reference: string,
+ sessionName: string,
+): WorkHubSessionNameMatch {
+ const normalizedTarget = normalizeCorrectionIdentity(reference);
const normalizedName = normalizeCorrectionIdentity(sessionName);
- if (!normalizedName) return false;
+ if (!normalizedName) return { kind: 'none' };
const quotedNames = [
`"${normalizedName}"`,
`“${normalizedName}”`,
@@ -377,14 +421,35 @@ function correctionTargetMatchesSession(target: string, sessionName: string): bo
normalizedTarget.startsWith(candidate) &&
!/[\p{L}\p{N}]/u.test(normalizedTarget[candidate.length] ?? ''),
);
- if (!matchedName) return false;
- if (matchedName === normalizedName && hasUnsafeUnquotedHardClauseBoundary(sessionName)) {
- return false;
+ if (!matchedName) {
+ // A name whose own trailing punctuation the reference dropped still names
+ // it, but nothing may follow: there is no boundary left to trust.
+ return /[.!。!]$/u.test(normalizedName) &&
+ normalizedTarget === normalizedName.replace(/[.!。!]+$/u, '').trim()
+ ? { kind: 'elided_name_punctuation' }
+ : { kind: 'none' };
}
- if (hasUnquotedTerminalWithdrawal(target)) {
- return false;
+ if (matchedName === normalizedName && hasUnsafeUnquotedHardClauseBoundary(sessionName)) {
+ return { kind: 'none' };
}
- const remainder = normalizedTarget.slice(matchedName.length).trim();
+ return { kind: 'named', remainder: normalizedTarget.slice(matchedName.length).trim() };
+}
+
+/**
+ * The correction policy's tail rule. A correction may name its target and then
+ * say what to do with it, but a withdrawal anywhere in the reference retracts
+ * the whole thing.
+ *
+ * It takes a match rather than a Session name so that a caller which already
+ * resolved candidates through the shared Session Resolver applies exactly this
+ * rule to exactly that recall, instead of matching names a second time.
+ */
+export function workHubCorrectionAdmitsReference(
+ reference: string,
+ match: WorkHubSessionNameMatch,
+): boolean {
+ if (match.kind !== 'named' || hasUnquotedTerminalWithdrawal(reference)) return false;
+ const { remainder } = match;
if (!remainder || /^(?:instead\s*)?[.!?。!?]?$/iu.test(remainder)) return true;
const supplemental = remainder.match(/^[,;,;]\s*(.+)$/u)?.[1]?.trim();
const supplementalBody = supplemental?.replace(/[.!?。!?]+\s*$/u, '').trim();
@@ -397,6 +462,36 @@ function correctionTargetMatchesSession(target: string, sessionName: string): bo
);
}
+function correctionTargetMatchesSession(target: string, sessionName: string): boolean {
+ return workHubCorrectionAdmitsReference(target, matchWorkHubSessionName(target, sessionName));
+}
+
+function directWorkHubStopTarget(value: string, malformedLiteral: boolean): string | undefined {
+ if (malformedLiteral || /[??]\s*$/u.test(value)) return undefined;
+ const match = DIRECT_STOP_REQUEST.exec(value) ?? DIRECT_CHINESE_STOP_REQUEST.exec(value);
+ const rawTarget = match?.[1]?.trim();
+ if (!rawTarget) return undefined;
+ const target = stripMatchingStopQuotes(rawTarget.replace(/[.!。!]+\s*$/u, '').trim());
+ if (!target || UNSAFE_STOP_TARGET.test(target)) return undefined;
+ return target;
+}
+
+function directWorkHubStopCue(value: string, malformedLiteral: boolean): boolean {
+ if (malformedLiteral || /[??]\s*$/u.test(value)) return false;
+ return Boolean(DIRECT_STOP_REQUEST.test(value) || DIRECT_CHINESE_STOP_REQUEST.test(value));
+}
+
+function stripMatchingStopQuotes(value: string): string {
+ const pairs = new Map([
+ ['"', '"'],
+ ["'", "'"],
+ ['“', '”'],
+ ['‘', '’'],
+ ]);
+ const closer = pairs.get(value[0] ?? '');
+ return closer && value.endsWith(closer) ? value.slice(1, -1).trim() : value;
+}
+
function normalizeCorrectionIdentity(value: string): string {
return value.normalize('NFKC').toLocaleLowerCase().replace(/\s+/gu, ' ').trim();
}
diff --git a/packages/core/src/workhub-session-resolver.ts b/packages/core/src/workhub-session-resolver.ts
new file mode 100644
index 0000000000..eb063f73fe
--- /dev/null
+++ b/packages/core/src/workhub-session-resolver.ts
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ matchWorkHubSessionName,
+ type WorkHubSessionNameMatch,
+} from './workhub-creation-intent.js';
+
+/**
+ * The shared Session Resolver port.
+ *
+ * Every WorkHub action that may refer to existing work asks this one capability
+ * which visible Sessions a trusted user reference recalls. It answers with
+ * ranked candidates, nothing, or ambiguity, and nothing else: it never chooses
+ * the action, never returns creation, and never grants execution authority.
+ * The action-specific policy decides whether a resolution is sufficient, and
+ * the Action Gate revalidates every identity immediately before an effect.
+ */
+export interface WorkHubSessionResolver {
+ resolve(input: WorkHubSessionResolverInput): WorkHubSessionResolution;
+}
+
+export interface WorkHubSessionResolverInput {
+ readonly reference: WorkHubSessionReference;
+ /** The bounded, visible candidate set the caller is permitted to resolve over. */
+ readonly sessions: readonly WorkHubResolverSession[];
+}
+
+/**
+ * A trusted user reference to existing work, carried by Action Intent. It is
+ * retrieval evidence only; display text never becomes execution authority.
+ */
+export interface WorkHubSessionReference {
+ readonly text: string;
+}
+
+/** One visible existing Session offered to the Resolver as a bounded candidate. */
+export interface WorkHubResolverSession {
+ /**
+ * Opaque Runtime-issued identity. Resolvers select among these references
+ * and never invent one from user or model text.
+ */
+ readonly ref: string;
+ readonly sessionName: string;
+ readonly projectName: string;
+ readonly updatedAt: number;
+}
+
+/**
+ * Why a candidate was recalled. Evidence explains a recall and authorizes
+ * nothing, but it must be rich enough for an action's policy to apply its own
+ * rules — so exact naming reports the reference text left over after the name,
+ * which stop and correction are each entitled to judge differently.
+ */
+export type WorkHubSessionResolutionEvidence = Exclude<
+ WorkHubSessionNameMatch,
+ { readonly kind: 'none' }
+>;
+
+export interface WorkHubSessionCandidate {
+ readonly ref: string;
+ readonly evidence: WorkHubSessionResolutionEvidence;
+}
+
+/**
+ * Resolution is total: nothing recalled, one ranked list a policy may act on,
+ * or an ambiguity a policy must clarify. `create_new` is deliberately absent —
+ * creation is a policy decision, never a retrieval result.
+ */
+export type WorkHubSessionResolution =
+ | { readonly kind: 'none' }
+ | {
+ readonly kind: 'ranked';
+ readonly candidates: readonly [WorkHubSessionCandidate, ...WorkHubSessionCandidate[]];
+ }
+ | { readonly kind: 'ambiguous'; readonly candidates: readonly WorkHubSessionCandidate[] };
+
+/**
+ * The temporary deterministic baseline: a reference resolves only when it names
+ * one visible Session exactly. Exact display names are conservative retrieval
+ * evidence, not the long-term product boundary, and this implementation exists
+ * to keep the port real while a ranked resolver is built behind it.
+ *
+ * It can be removed once every target-bearing WorkHub action resolves through
+ * this port, the replacement resolver passes the common routing evaluation, and
+ * its rollout retains a tested rollback path.
+ */
+export function createExactNameSessionResolver(): WorkHubSessionResolver {
+ return {
+ resolve({ reference, sessions }) {
+ const candidates: WorkHubSessionCandidate[] = [];
+ for (const session of sessions) {
+ const match = matchWorkHubSessionName(reference.text, session.sessionName);
+ if (match.kind === 'none') continue;
+ candidates.push({ ref: session.ref, evidence: match });
+ }
+ const [first, ...rest] = candidates;
+ if (!first) return { kind: 'none' };
+ // Exact naming has no score to separate equals by, so more than one match
+ // is ambiguity rather than a ranking a policy could safely act on.
+ if (rest.length > 0) return { kind: 'ambiguous', candidates };
+ return { kind: 'ranked', candidates: [first] };
+ },
+ };
+}
diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts
index 0f25236dc9..a21b24c4bf 100644
--- a/packages/runtime-host/src/__tests__/execution-composition.test.ts
+++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts
@@ -37,6 +37,7 @@ import {
} from '@maka/runtime/test-only/fake-backend';
import { LOCAL_READ_AGENT_DEFINITION } from '@maka/runtime/agent-catalog';
import { SessionManager } from '@maka/runtime/session-manager';
+import { workHubDirectStopAbortSource } from '@maka/runtime/session-manager';
import { fingerprintAgentGraphRunnableIntent } from '@maka/runtime/stream-graph-admission';
import type { AgentGraphRunnableIntent } from '@maka/runtime/stream-graph-readiness';
import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store';
@@ -58,6 +59,8 @@ import { HostResidencyRegistry } from '../server/host-residency-registry.js';
import {
createExecutionRuntimeHostComposition,
runtimeHostFilesystemWorkerRuntime,
+ stopOwnedWorkHubRoot,
+ stopReplacedWorkHubRoot,
} from '../server/execution-composition.js';
import { waitFor as pollFor } from '@maka/core/test-only/async-primitives';
@@ -70,6 +73,176 @@ test('filesystem worker follows the candidate executable runtime', () => {
assert.equal(runtimeHostFilesystemWorkerRuntime({}), 'node');
});
+test('WorkHub recovers a delivered root Stop from its durable cancelled Turn', async () => {
+ let stopCalls = 0;
+ const outcome = await stopOwnedWorkHubRoot(
+ {
+ readRootState: () => ({ kind: 'idle' }),
+ read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({
+ ...identity,
+ status: 'cancelled',
+ terminalEventId: 'terminal-workhub-stop',
+ abortSource: workHubDirectStopAbortSource('workhub-stop-action'),
+ }),
+ stopRoot: async () => {
+ stopCalls += 1;
+ },
+ } as unknown as Parameters[0],
+ { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' },
+ 'workhub-stop-action',
+ );
+
+ assert.deepEqual(outcome, {
+ outcome: 'stop_delivered',
+ targetTurnId: 'target-turn',
+ });
+ assert.equal(stopCalls, 0);
+});
+
+test('WorkHub never reports a still-running root as already terminal', async () => {
+ // The restart window: the execution is not registered in memory yet, so the
+ // root looks inactive while its durable snapshot is still running.
+ const outcome = await stopOwnedWorkHubRoot(
+ {
+ readRootState: () => ({ kind: 'idle' }),
+ read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({
+ ...identity,
+ status: 'running',
+ }),
+ stopRoot: async () => assert.fail('an unregistered root cannot be stopped'),
+ } as unknown as Parameters[0],
+ { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' },
+ 'workhub-stop-action',
+ );
+
+ assert.deepEqual(outcome, { outcome: 'recovering', targetTurnId: 'target-turn' });
+
+ // A durably terminal snapshot is still the proof `already_terminal` needs.
+ const settled = await stopOwnedWorkHubRoot(
+ {
+ readRootState: () => ({ kind: 'idle' }),
+ read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({
+ ...identity,
+ status: 'completed',
+ terminalEventId: 'terminal-complete',
+ }),
+ stopRoot: async () => assert.fail('a completed root cannot be stopped'),
+ } as unknown as Parameters[0],
+ { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' },
+ 'workhub-stop-action',
+ );
+
+ assert.deepEqual(settled, { outcome: 'already_terminal', targetTurnId: 'target-turn' });
+});
+
+test('WorkHub binds a fresh owning-root Stop to its action identity', async () => {
+ let source: string | undefined;
+ let actionId: string | undefined;
+ const outcome = await stopOwnedWorkHubRoot(
+ {
+ readRootState: () => ({
+ kind: 'active',
+ sessionId: 'target-session',
+ turnId: 'target-turn',
+ runId: 'target-run',
+ }),
+ read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({
+ ...identity,
+ status: 'cancelled',
+ terminalEventId: 'terminal-workhub-stop',
+ abortSource: workHubDirectStopAbortSource('workhub-stop-action'),
+ }),
+ stopRoot: async (
+ _identity: { sessionId: string; turnId: string; runId: string },
+ input: {
+ source?: 'stop_button' | 'graph_supervisor' | 'workhub_direct_stop';
+ workHubActionId?: string;
+ },
+ ) => {
+ source = input.source;
+ actionId = input.workHubActionId;
+ },
+ } as unknown as Parameters[0],
+ { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' },
+ 'workhub-stop-action',
+ );
+
+ assert.equal(source, 'workhub_direct_stop');
+ assert.equal(actionId, 'workhub-stop-action');
+ assert.equal(outcome.outcome, 'stop_delivered');
+});
+
+test('WorkHub detects a manual Stop that wins after its active-root check', async () => {
+ let stopCalls = 0;
+ const outcome = await stopOwnedWorkHubRoot(
+ {
+ readRootState: () => ({
+ kind: 'active',
+ sessionId: 'target-session',
+ turnId: 'target-turn',
+ runId: 'target-run',
+ }),
+ read: async (identity: { sessionId: string; turnId: string; runId: string }) => ({
+ ...identity,
+ status: 'cancelled',
+ terminalEventId: 'concurrent-manual-stop',
+ abortSource: 'renderer.stop_button',
+ }),
+ stopRoot: async () => {
+ stopCalls += 1;
+ },
+ } as unknown as Parameters[0],
+ { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' },
+ 'workhub-stop-action',
+ );
+
+ assert.equal(stopCalls, 1);
+ assert.equal(outcome.outcome, 'already_terminal');
+});
+
+test('a replacement retirement never records direct-stop provenance', async () => {
+ const stops: Array | undefined> = [];
+ const outcome = await stopReplacedWorkHubRoot(
+ {
+ readRootState: () => ({
+ kind: 'active',
+ sessionId: 'target-session',
+ turnId: 'target-turn',
+ runId: 'target-run',
+ }),
+ read: async () => assert.fail('replacement retirement must not re-read stop provenance'),
+ stopRoot: async (
+ _identity: { sessionId: string; turnId: string; runId: string },
+ input?: Record,
+ ) => {
+ stops.push(input);
+ },
+ } as unknown as Parameters[0],
+ { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' },
+ );
+
+ assert.deepEqual(stops, [undefined]);
+ assert.deepEqual(outcome, { outcome: 'stop_delivered', targetTurnId: 'target-turn' });
+});
+
+test('a replacement leaves a root it no longer owns alone', async () => {
+ const outcome = await stopReplacedWorkHubRoot(
+ {
+ readRootState: () => ({
+ kind: 'active',
+ sessionId: 'target-session',
+ turnId: 'other-turn',
+ runId: 'other-run',
+ }),
+ read: async () => assert.fail('replacement retirement must not re-read stop provenance'),
+ stopRoot: async () => assert.fail('a root owned by another Turn must not be stopped'),
+ } as unknown as Parameters[0],
+ { sessionId: 'target-session', turnId: 'target-turn', runId: 'target-run' },
+ );
+
+ assert.deepEqual(outcome, { outcome: 'already_terminal', targetTurnId: 'target-turn' });
+});
+
test('production composition owns the long-term memory database lifecycle', async () => {
await withCompositionRoot(async ({ root, owner }) => {
const databasePath = join(root, LONG_TERM_MEMORY_DATABASE_NAME);
@@ -590,6 +763,32 @@ test('WorkHub correction replaces its link without stopping a shared manual Turn
return proof.ok && proof.result.resolutions[0]?.state === 'owned';
});
+ const stopped = await composition.handlers['workhub.coordination.act'](
+ {
+ actionId: 'workhub-stop-shared-action',
+ userText: `Stop ${sourceCandidate.sessionName}`,
+ confirmation: { kind: 'user_stop' },
+ proposal: {
+ disposition: 'stop_work',
+ expects: { targetSessionId: source.id },
+ },
+ },
+ context,
+ );
+ assert.deepEqual(stopped, {
+ ok: true,
+ result: {
+ disposition: 'stop_work',
+ outcome: 'not_owned',
+ targetSessionId: source.id,
+ targetTurnId: 'manual-active-turn',
+ },
+ });
+ assert.equal(
+ (await stores.sessionStore.readWorkHubStopResolution(assignment.delegationId))?.outcome,
+ 'not_owned',
+ );
+
const unrelated = await composition.handlers['turn.message.submit'](
{
originHostEpoch: context.hostEpoch,
diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts
index ae6c2e168f..56e31728ff 100644
--- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts
@@ -489,15 +489,54 @@ test('exact pending cancellation removes only the linked Message', async () => {
await submit(fixture, 'unrelated-message', 'keep this queued', 'next_turn');
assert.deepEqual(
- await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'),
- { kind: 'cancelled' },
+ await fixture.coordinator.cancelMessageIfPending(
+ ROOT.sessionId,
+ 'linked-message',
+ 'first-claim',
+ ),
+ { kind: 'cancelled_pending' },
);
assert.deepEqual(
fixture.coordinator.projection(ROOT.sessionId).followup.map((entry) => entry.messageId),
['unrelated-message'],
);
assert.deepEqual(
- await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'),
+ await fixture.coordinator.cancelMessageIfPending(
+ ROOT.sessionId,
+ 'linked-message',
+ 'second-claim',
+ ),
+ { kind: 'cancelled' },
+ );
+});
+
+test('a durable cancellation claim preserves cancelled_pending across restart-style replay', async () => {
+ const fixture = createFixture();
+ fixture.coordinator.reserveRootTurn(ROOT);
+ await submit(fixture, 'linked-message', 'wrong delegation', 'next_turn');
+
+ assert.deepEqual(
+ await fixture.coordinator.cancelMessageIfPending(
+ ROOT.sessionId,
+ 'linked-message',
+ 'stop-claim',
+ ),
+ { kind: 'cancelled_pending' },
+ );
+ assert.deepEqual(
+ await fixture.coordinator.cancelMessageIfPending(
+ ROOT.sessionId,
+ 'linked-message',
+ 'stop-claim',
+ ),
+ { kind: 'cancelled_pending' },
+ );
+ assert.deepEqual(
+ await fixture.coordinator.cancelMessageIfPending(
+ ROOT.sessionId,
+ 'linked-message',
+ 'different-claim',
+ ),
{ kind: 'cancelled' },
);
});
@@ -507,7 +546,11 @@ test('a consumed steering Message cannot claim ownership of its pre-existing roo
fixture.events.push(steeringEvent('linked-message', 'wrong delegation'));
assert.deepEqual(
- await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'),
+ await fixture.coordinator.cancelMessageIfPending(
+ ROOT.sessionId,
+ 'linked-message',
+ 'stop-claim',
+ ),
{
kind: 'shared_turn',
turnId: ROOT.turnId,
@@ -524,7 +567,11 @@ test('a root source Message owns only the root Turn it created', async () => {
);
assert.deepEqual(
- await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, 'linked-message'),
+ await fixture.coordinator.cancelMessageIfPending(
+ ROOT.sessionId,
+ 'linked-message',
+ 'stop-claim',
+ ),
{
kind: 'owned_root',
turnId: 'durable-turn',
@@ -548,11 +595,14 @@ test('a recovered multi-source successor remains shared by every source Message'
});
for (const messageId of ['linked-message', 'other-message']) {
- assert.deepEqual(await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, messageId), {
- kind: 'shared_turn',
- turnId: 'durable-turn',
- runId: 'durable-run',
- });
+ assert.deepEqual(
+ await fixture.coordinator.cancelMessageIfPending(ROOT.sessionId, messageId, 'stop-claim'),
+ {
+ kind: 'shared_turn',
+ turnId: 'durable-turn',
+ runId: 'durable-run',
+ },
+ );
}
});
@@ -3804,6 +3854,7 @@ function memoryMessageAdmissionStore(
>,
onMessagesHandedOff?: (input: MarkMessagesHandedOffInput) => void,
): MessageAdmissionStore {
+ const cancellationClaims = new Map();
return {
commitMessageAdmission: async (admission) => {
const existing = admissions.get(admission.messageId);
@@ -3814,6 +3865,16 @@ function memoryMessageAdmissionStore(
readMessageAdmission: async (_sessionId, messageId) => admissions.get(messageId)?.admission,
hasCancelledMessageAdmission: async (_sessionId, messageId) =>
admissions.get(messageId)?.state === 'cancelled',
+ claimMessageAdmissionCancellation: async (_sessionId, messageId, claimId) => {
+ const existing = admissions.get(messageId);
+ if (existing?.state === 'cancelled') {
+ return cancellationClaims.get(messageId) === claimId ? 'same_claim' : 'already_cancelled';
+ }
+ if (!existing) throw new Error(`Missing admission ${messageId}`);
+ existing.state = 'cancelled';
+ cancellationClaims.set(messageId, claimId);
+ return 'cancelled_by_claim';
+ },
listMessageAdmissions: async (sessionId) =>
[...admissions.values()]
.filter(({ admission, state }) => admission.sessionId === sessionId && state === 'accepted')
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 8ba15639e0..c53171b3f8 100644
--- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
@@ -4204,6 +4204,74 @@ test('public turn.interrupt releases the Session lane while a queried Run is sti
}
});
+test('invalid WorkHub Stop provenance fails before the root fence mutates authority', async () => {
+ let backend: BlockingRootBackend | undefined;
+ const fixture = await createFailureFixture({
+ registerBackend: (backends) =>
+ backends.register('ai-sdk', (context) => {
+ backend = new BlockingRootBackend(context.sessionId);
+ return backend;
+ }),
+ });
+ try {
+ const started = await fixture.interactiveTurns.handlers['turn.start'](
+ {
+ sessionId: fixture.sessionId,
+ turnId: 'turn-invalid-workhub-stop',
+ content: { text: 'keep this root active' },
+ },
+ operationContext(fixture.hostEpoch, fixture.acquireResidency),
+ );
+ assert.equal(started.ok, true);
+ if (!started.ok) return;
+ assertStartedTurn(started);
+ await backend?.started.promise;
+
+ const queued = await fixture.messages.handlers['turn.message.submit'](
+ {
+ originHostEpoch: fixture.hostEpoch,
+ sessionId: fixture.sessionId,
+ messageId: 'queued-before-invalid-workhub-stop',
+ content: { text: 'preserve this follow-up' },
+ placement: 'next_turn',
+ },
+ operationContext(fixture.hostEpoch, fixture.acquireResidency),
+ );
+ assert.equal(queued.ok, true);
+ const before = fixture.messages.projection(fixture.sessionId);
+
+ const invalidInputs = [
+ { source: 'workhub_direct_stop' },
+ { source: 'workhub_direct_stop', workHubActionId: '' },
+ { source: 'stop_button', workHubActionId: 'wrong-source-action' },
+ ];
+ for (const input of invalidInputs) {
+ await assert.rejects(
+ async () =>
+ fixture.coordinator.stopRoot(
+ {
+ sessionId: fixture.sessionId,
+ turnId: 'turn-invalid-workhub-stop',
+ runId: started.result.turn.runId,
+ },
+ input as never,
+ ),
+ /WorkHub direct-stop/,
+ );
+ }
+
+ assert.deepEqual(fixture.messages.projection(fixture.sessionId), before);
+ assert.equal(fixture.coordinator.readRootState(fixture.sessionId).kind, 'active');
+ assert.equal(fixture.fallbackRunClosureClaims(), 0);
+ assert.equal(backend?.stopCount, 0);
+ } finally {
+ backend?.release();
+ await fixture.coordinator.close();
+ await fixture.messages.close();
+ await fixture.dispose();
+ }
+});
+
test('Runtime stop lets a running admission publish before its exact-Run closure', {
timeout: 20_000,
}, async () => {
@@ -5077,6 +5145,7 @@ async function createFailureFixture(options: {
let canonicalProjection: CanonicalSessionProjectionReader | undefined;
let messages!: HostMessageCoordinator;
let interactions: HostInteractionCoordinator | undefined;
+ let fallbackRunClosureClaims = 0;
const rootPort: HostMessageRootPort = {
readSessionHeader: (sessionId) => requireCoordinator(coordinator).readSessionHeader(sessionId),
readRootState: (sessionId) => requireCoordinator(coordinator).readRootState(sessionId),
@@ -5187,7 +5256,9 @@ async function createFailureFixture(options: {
admissionOwner,
interactions ?? {
assertTerminalFence: async () => undefined,
- claimRunClosure: async () => undefined,
+ claimRunClosure: async () => {
+ fallbackRunClosureClaims += 1;
+ },
},
messages,
requireContinuity(continuity),
@@ -5267,6 +5338,7 @@ async function createFailureFixture(options: {
},
liveResidencies: () => liveResidencies,
drainRequested: () => drainRequested,
+ fallbackRunClosureClaims: () => fallbackRunClosureClaims,
dispose: async () => {
requireContinuity(continuity).close();
artifacts?.close();
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 a1bed88c73..5d79ca924e 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
@@ -20,9 +20,13 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type {
+ WorkHubActionClaim,
+ WorkHubActionClaimOutcome,
WorkHubDelegationAssignedMessage,
WorkHubDelegationReplacementAbortedMessage,
WorkHubDelegationReplacementRequestedMessage,
+ WorkHubDelegationStopRequestedMessage,
+ WorkHubDelegationStopResolvedMessage,
WorkHubDelegationSupersededMessage,
} from '@maka/core/session';
import {
@@ -35,6 +39,10 @@ import {
type WorkHubDelegationAssignmentInput,
type WorkHubDelegationReplacementAbortInput,
type WorkHubDelegationReplacementInput,
+ type WorkHubDelegationRetirementClaim,
+ type WorkHubDelegationStopInput,
+ type WorkHubDelegationStopResolutionInput,
+ type WorkHubRetirementResult,
} from '../server/workhub-coordination-action-gate.js';
import type { ConnectionContext } from '../server/operation-dispatcher.js';
@@ -312,6 +320,633 @@ describe('WorkHub Coordination Action Gate', () => {
assert.equal(effects.assignments.length, 1);
});
+ /**
+ * A stop proposal as the Action 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,
+ expects: { targetSessionId },
+ });
+
+ test('stops exactly one named durable delegation and replays its observed outcome', async () => {
+ const effects = fakeEffects([session('payments', { name: 'Payments' })]);
+ effects.assignmentRecords.set(
+ 'source-action',
+ assignmentRecord(
+ {
+ actionId: 'source-action',
+ actionFingerprint: `sha256:${'a'.repeat(64)}`,
+ targetSessionId: 'payments',
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: 'Fix payment retry',
+ },
+ 'source-turn',
+ ),
+ );
+ const input = {
+ actionId: 'stop-action',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' as const },
+ };
+
+ const first = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT);
+ assert.deepEqual(first, {
+ disposition: 'stop_work',
+ outcome: 'cancelled_pending',
+ targetSessionId: 'payments',
+ });
+ assert.equal(effects.retirements.length, 1);
+ assert.equal(effects.stopRequests.size, 1);
+ assert.equal(effects.stopResolutions.size, 1);
+
+ const replay = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT);
+ assert.deepEqual(replay, first);
+ assert.equal(effects.retirements.length, 1);
+ });
+
+ test('a deleted delegation target does not disable stop for every other Session', async () => {
+ const effects = fakeEffects([
+ session('payments', { name: 'Payments' }),
+ session('login', { name: 'Login' }),
+ ]);
+ for (const [actionId, targetSessionId, name] of [
+ ['pay-action', 'payments', 'Payments'],
+ ['login-action', 'login', 'Login'],
+ ] as const) {
+ effects.assignmentRecords.set(
+ actionId,
+ assignmentRecord(
+ {
+ actionId,
+ actionFingerprint: `sha256:${(actionId === 'pay-action' ? '4' : '5').repeat(64)}`,
+ targetSessionId,
+ targetSessionName: name,
+ disposition: 'delegate_existing',
+ userText: `Work in ${name}`,
+ },
+ `${actionId}-turn`,
+ ),
+ );
+ }
+
+ // Nothing retires a delegation when its Session is deleted, so this one
+ // stays active forever. It must not be able to veto an unrelated stop.
+ effects.sessions = effects.sessions.filter(({ id }) => id !== 'payments');
+
+ const stopped = await new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'stop-login',
+ userText: 'Stop Login',
+ proposal: stopProposal('login'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+ assert.equal(stopped.disposition, 'stop_work');
+
+ // The dangling delegation itself still fails closed: its own target is gone.
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'stop-payments',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ ),
+ (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict',
+ );
+ });
+
+ test('a finished delegation stops competing for the sole-delegation proof', async () => {
+ const effects = fakeEffects([session('payments', { name: 'Payments' })]);
+ for (const actionId of ['finished-action', 'live-action']) {
+ effects.assignmentRecords.set(
+ actionId,
+ assignmentRecord(
+ {
+ actionId,
+ actionFingerprint: `sha256:${(actionId === 'live-action' ? '6' : '7').repeat(64)}`,
+ targetSessionId: 'payments',
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: `Work from ${actionId}`,
+ },
+ `${actionId}-turn`,
+ ),
+ );
+ }
+ // The link outlives the work, so the completed delegation is still active.
+ const settled = new Set(['delegation-finished-action']);
+ effects.readDelegationRetirement = async (assignment) =>
+ settled.has(assignment.delegationId) ? 'retired' : 'not_retired';
+
+ const stopped = await new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'stop-live',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+ assert.equal(stopped.disposition, 'stop_work');
+ });
+
+ test('a competitor the Host cannot resolve yet fails the stop closed', async () => {
+ const effects = fakeEffects([session('payments', { name: 'Payments' })]);
+ for (const actionId of ['unreadable-action', 'live-action']) {
+ effects.assignmentRecords.set(
+ actionId,
+ assignmentRecord(
+ {
+ actionId,
+ actionFingerprint: `sha256:${(actionId === 'live-action' ? '6' : '7').repeat(64)}`,
+ targetSessionId: 'payments',
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: `Work from ${actionId}`,
+ },
+ `${actionId}-turn`,
+ ),
+ );
+ }
+ // Unreadable is not the same as finished, so it still blocks the proof.
+ effects.readDelegationRetirement = async (assignment) =>
+ assignment.actionId === 'unreadable-action' ? 'recovering' : 'not_retired';
+
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'stop-unresolved-competitor',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ ),
+ (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict',
+ );
+ assert.equal(effects.stopRequests.size, 0);
+ });
+
+ test('rejects a stop that does not identify one active durable delegation', async () => {
+ const effects = fakeEffects([session('payments', { name: 'Payments' })]);
+ for (const actionId of ['source-action', 'other-action']) {
+ effects.assignmentRecords.set(
+ actionId,
+ assignmentRecord(
+ {
+ actionId,
+ actionFingerprint: `sha256:${(actionId === 'source-action' ? '1' : '2').repeat(64)}`,
+ targetSessionId: 'payments',
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: `Work from ${actionId}`,
+ },
+ `${actionId}-turn`,
+ ),
+ );
+ }
+
+ // Stop admits a sole active delegation, and the Host proves that from
+ // durable state — the proposal cannot assert its way past it.
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'stop-ambiguous-payments',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ ),
+ (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict',
+ );
+ assert.equal(effects.stopRequests.size, 0);
+ assert.equal(effects.retirements.length, 0);
+ });
+
+ test('rejects stop authority from confirmation alone or a stale precondition', async () => {
+ const effects = fakeEffects([session('payments', { name: 'Payments' })]);
+ effects.assignmentRecords.set(
+ 'source-action',
+ assignmentRecord(
+ {
+ actionId: 'source-action',
+ actionFingerprint: `sha256:${'b'.repeat(64)}`,
+ targetSessionId: 'payments',
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: 'Fix payment retry',
+ },
+ 'source-turn',
+ ),
+ );
+ // Action Intent still has to carry a direct stop imperative. It only says
+ // that the user asked to stop work; which work is the Resolver's answer and
+ // this Gate's revalidated precondition, so no text here selects a target.
+ for (const userText of [
+ 'Stop it',
+ 'Pause Payments',
+ 'How do I stop Payments?',
+ 'Do not stop Payments',
+ ]) {
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: `stop-${userText}`,
+ userText,
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ ),
+ (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict',
+ userText,
+ );
+ }
+ // A precondition that disagrees with durable state fails closed: this
+ // delegation does not belong to the Session the proposal resolved.
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'stop-wrong-session',
+ userText: 'Stop Payments',
+ proposal: stopProposal('login'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ ),
+ (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict',
+ );
+ assert.equal(effects.retirements.length, 0);
+ });
+
+ test('records not_owned without treating a shared user Turn as stopped', async () => {
+ const effects = fakeEffects([session('payments', { name: 'Payments' })]);
+ effects.assignmentRecords.set(
+ 'source-action',
+ assignmentRecord(
+ {
+ actionId: 'source-action',
+ actionFingerprint: `sha256:${'c'.repeat(64)}`,
+ targetSessionId: 'payments',
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: 'Fix payment retry',
+ },
+ 'source-turn',
+ ),
+ );
+ effects.retireDelegation = async () => ({
+ outcome: 'not_owned',
+ targetTurnId: 'shared-turn',
+ });
+
+ const result = await new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'stop-shared',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+ assert.deepEqual(result, {
+ disposition: 'stop_work',
+ outcome: 'not_owned',
+ targetSessionId: 'payments',
+ targetTurnId: 'shared-turn',
+ });
+ assert.equal(effects.supersessions.size, 0);
+ effects.supersessions.set('delegation-source-action', {
+ type: 'workhub_coordination',
+ id: 'later-supersession',
+ turnId: 'later-correction',
+ ts: 9,
+ schemaVersion: 2,
+ kind: 'delegation_superseded',
+ actionId: 'later-correction',
+ actionFingerprint: `sha256:${'d'.repeat(64)}`,
+ coordinationTurnId: 'later-correction',
+ supersededActionId: 'source-action',
+ supersededDelegationId: 'delegation-source-action',
+ replacementDelegationId: 'replacement-delegation',
+ });
+ assert.deepEqual(
+ await new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'stop-shared',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ ),
+ result,
+ );
+ });
+
+ test('a recovering stop keeps its action identity out of a second delegation', async () => {
+ const effects = fakeEffects([
+ session('payments', { name: 'Payments' }),
+ session('login', { name: 'Login' }),
+ ]);
+ for (const [actionId, targetSessionId, name] of [
+ ['source-action', 'payments', 'Payments'],
+ ['other-action', 'login', 'Login'],
+ ] as const) {
+ effects.assignmentRecords.set(
+ actionId,
+ assignmentRecord(
+ {
+ actionId,
+ actionFingerprint: `sha256:${(actionId === 'source-action' ? '1' : '2').repeat(64)}`,
+ targetSessionId,
+ targetSessionName: name,
+ disposition: 'delegate_existing',
+ userText: `Work in ${name}`,
+ },
+ `${actionId}-turn`,
+ ),
+ );
+ }
+ effects.retireDelegation = async () => ({ outcome: 'recovering' as const });
+
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'reused-stop',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ ),
+ (error) =>
+ error instanceof WorkHubActionEffectFailure && error.code === 'operation_unavailable',
+ );
+ assert.deepEqual([...effects.stopRequests.keys()], ['delegation-source-action']);
+
+ // A fresh gate is the Host after restart: only the durable action owner can
+ // refuse the second delegation this identity is now trying to claim.
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'reused-stop',
+ userText: 'Stop Login',
+ proposal: stopProposal('login'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ ),
+ (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict',
+ );
+ assert.deepEqual([...effects.stopRequests.keys()], ['delegation-source-action']);
+ assert.equal(effects.stopResolutions.size, 0);
+ });
+
+ test('a committed stop identity cannot replay against another Session with the same name', async () => {
+ const effects = fakeEffects([
+ session('payments-primary', { name: 'Payments' }),
+ session('payments-secondary', { name: 'Payments' }),
+ ]);
+ for (const [actionId, targetSessionId] of [
+ ['primary-action', 'payments-primary'],
+ ['secondary-action', 'payments-secondary'],
+ ] as const) {
+ effects.assignmentRecords.set(
+ actionId,
+ assignmentRecord(
+ {
+ actionId,
+ actionFingerprint: `sha256:${(actionId === 'primary-action' ? '1' : '2').repeat(64)}`,
+ targetSessionId,
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: 'Fix payment retry',
+ },
+ `${actionId}-turn`,
+ ),
+ );
+ }
+ effects.retireDelegation = async () => ({ outcome: 'recovering' as const });
+ const stopInput = (targetSessionId: string) => ({
+ actionId: 'reused-stop',
+ userText: 'Stop Payments',
+ proposal: stopProposal(targetSessionId),
+ confirmation: { kind: 'user_stop' as const },
+ });
+
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(stopInput('payments-primary'), CONTEXT),
+ (error) =>
+ error instanceof WorkHubActionEffectFailure && error.code === 'operation_unavailable',
+ );
+ assert.deepEqual([...effects.stopRequests.keys()], ['delegation-primary-action']);
+
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(stopInput('payments-secondary'), CONTEXT),
+ (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict',
+ );
+ assert.deepEqual([...effects.stopRequests.keys()], ['delegation-primary-action']);
+ });
+
+ test('a stop action identity cannot cross into a delegation assignment', async () => {
+ const effects = fakeEffects([session('payments', { name: 'Payments' })]);
+ effects.assignmentRecords.set(
+ 'source-action',
+ assignmentRecord(
+ {
+ actionId: 'source-action',
+ actionFingerprint: `sha256:${'7'.repeat(64)}`,
+ targetSessionId: 'payments',
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: 'Fix payment retry',
+ },
+ 'source-turn',
+ ),
+ );
+ await new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'crossing-action',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+
+ const gate = new WorkHubCoordinationActionGate(effects);
+ const snapshot = await gate.candidates();
+ await assert.rejects(
+ gate.act(
+ {
+ actionId: 'crossing-action',
+ userText: 'Fix the login redirect',
+ candidateSetId: snapshot.candidateSetId,
+ proposal: {
+ disposition: 'delegate_existing',
+ candidateRef: snapshot.candidates[0]!.candidateRef,
+ },
+ },
+ CONTEXT,
+ ),
+ (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict',
+ );
+ assert.equal(effects.assignments.length, 0);
+ });
+
+ test('a fresh attempt after not_owned converges instead of conflicting forever', async () => {
+ const effects = fakeEffects([session('payments', { name: 'Payments' })]);
+ effects.assignmentRecords.set(
+ 'source-action',
+ assignmentRecord(
+ {
+ actionId: 'source-action',
+ actionFingerprint: `sha256:${'8'.repeat(64)}`,
+ targetSessionId: 'payments',
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: 'Fix payment retry',
+ },
+ 'source-turn',
+ ),
+ );
+ let retirements = 0;
+ effects.retireDelegation = async () => {
+ retirements += 1;
+ return { outcome: 'not_owned' as const, targetTurnId: 'shared-turn' };
+ };
+ const first = await new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'stop-first',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+
+ const retried = await new WorkHubCoordinationActionGate(effects).act(
+ {
+ actionId: 'stop-second',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+
+ assert.deepEqual(retried, first);
+ assert.equal(retirements, 1);
+ assert.equal(effects.stopResolutions.size, 1);
+ });
+
+ test('a committed stop converges once its target Session is durably removed', async () => {
+ const effects = fakeEffects([session('payments', { name: 'Payments' })]);
+ effects.assignmentRecords.set(
+ 'source-action',
+ assignmentRecord(
+ {
+ actionId: 'source-action',
+ actionFingerprint: `sha256:${'9'.repeat(64)}`,
+ targetSessionId: 'payments',
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: 'Fix payment retry',
+ },
+ 'source-turn',
+ ),
+ );
+ effects.retireDelegation = async () => ({ outcome: 'recovering' as const });
+ const input = {
+ actionId: 'stop-removed-target',
+ userText: 'Stop Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' as const },
+ };
+ const unresolved = (error: unknown) =>
+ error instanceof WorkHubActionEffectFailure && error.code === 'operation_unavailable';
+
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(input, CONTEXT),
+ unresolved,
+ );
+ assert.equal(effects.stopRequests.size, 1);
+
+ // Unreadable is not proof. Only the removal tombstone resolves the claim.
+ effects.sessions = [];
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(input, CONTEXT),
+ unresolved,
+ );
+ assert.equal(effects.stopResolutions.size, 0);
+
+ effects.removedSessionIds.add('payments');
+ const resolved = await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT);
+ assert.deepEqual(resolved, {
+ disposition: 'stop_work',
+ outcome: 'already_terminal',
+ targetSessionId: 'payments',
+ });
+ assert.deepEqual(
+ await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT),
+ resolved,
+ );
+ assert.equal(effects.stopResolutions.size, 1);
+ });
+
+ test('keeps display names as stop evidence rather than admission authority', async () => {
+ const effects = fakeEffects([session('payments', { name: 'Renamed Payments' })]);
+ effects.assignmentRecords.set(
+ 'source-action',
+ assignmentRecord(
+ {
+ actionId: 'source-action',
+ actionFingerprint: `sha256:${'e'.repeat(64)}`,
+ targetSessionId: 'payments',
+ targetSessionName: 'Old Payments',
+ disposition: 'delegate_existing',
+ userText: 'Fix payment retry',
+ },
+ 'source-turn',
+ ),
+ );
+ // The reference the user typed is the Session's old name. Resolution is the
+ // Resolver's business; admission proves the opaque identity, so a rename
+ // between resolution and admission cannot invalidate the claim.
+ const input = {
+ actionId: 'stop-renamed',
+ userText: 'Stop Old Payments',
+ proposal: stopProposal('payments'),
+ confirmation: { kind: 'user_stop' as const },
+ };
+ assert.equal(
+ (await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT)).disposition,
+ 'stop_work',
+ );
+ assert.equal(
+ effects.stopRequests.get('delegation-source-action')?.targetSessionName,
+ 'Renamed Payments',
+ );
+ effects.sessions[0] = session('payments', { name: 'Renamed Again' });
+ assert.equal(
+ (await new WorkHubCoordinationActionGate(effects).act(input, CONTEXT)).disposition,
+ 'stop_work',
+ );
+ assert.equal(
+ effects.stopRequests.get('delegation-source-action')?.targetSessionName,
+ 'Renamed Payments',
+ );
+ });
+
test('rejects waiting targets independently of strategy behavior', async () => {
const effects = fakeEffects([session('waiting', { status: 'waiting_for_user' })]);
const gate = new WorkHubCoordinationActionGate(effects);
@@ -1547,6 +2182,66 @@ describe('WorkHub Coordination Action Gate', () => {
assert.equal(effects.supersessions.has('delegation-source-action'), true);
});
+ test('rejects a conflicting post-migration claim before replaying a prepared replacement', async () => {
+ const effects = fakeEffects([session('source'), session('destination')]);
+ effects.assignmentRecords.set(
+ 'source-action',
+ assignmentRecord(
+ {
+ actionId: 'source-action',
+ actionFingerprint: `sha256:${'c'.repeat(64)}`,
+ targetSessionId: 'source',
+ targetSessionName: 'source',
+ disposition: 'delegate_existing',
+ userText: 'Wrong target',
+ },
+ 'source-turn',
+ ),
+ );
+ const gate = new WorkHubCoordinationActionGate(effects);
+ const snapshot = await gate.candidates();
+ const input = {
+ actionId: 'migrated-prepared-replacement',
+ userText: 'No, send this to destination',
+ candidateSetId: snapshot.candidateSetId,
+ confirmation: { kind: 'user_correction' as const },
+ proposal: {
+ disposition: 'replace' as const,
+ replacesActionId: 'source-action',
+ target: {
+ disposition: 'delegate_existing' as const,
+ candidateRef: snapshot.candidates.find(
+ (candidate) => candidate.sessionId === 'destination',
+ )!.candidateRef,
+ },
+ },
+ };
+ const prepareReplacement = effects.prepareReplacement;
+ effects.prepareReplacement = async (replacement) => {
+ await prepareReplacement(replacement);
+ throw new WorkHubActionEffectFailure('internal_failure', 'simulated pre-retirement crash');
+ };
+
+ await assert.rejects(gate.act(input, CONTEXT));
+ assert.equal(effects.replacements.has('delegation-source-action'), true);
+ assert.equal(effects.retirements.length, 0);
+
+ effects.actionClaims.clear();
+ effects.actionClaims.set(input.actionId, {
+ actionId: input.actionId,
+ operation: 'answer_here',
+ actionFingerprint: `sha256:${'d'.repeat(64)}`,
+ subject: 'coordination-session',
+ });
+
+ await assert.rejects(
+ new WorkHubCoordinationActionGate(effects).act(input, CONTEXT),
+ (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict',
+ );
+ assert.equal(effects.retirements.length, 0);
+ assert.equal(effects.assignments.length, 0);
+ });
+
test('refreshes replacement target display identity after retiring the source', async () => {
const effects = fakeEffects([
session('source'),
@@ -1572,11 +2267,12 @@ describe('WorkHub Coordination Action Gate', () => {
(candidate) => candidate.sessionId === 'destination',
)!;
const retireDelegation = effects.retireDelegation;
- effects.retireDelegation = async (assignment) => {
- await retireDelegation.call(effects, assignment);
+ effects.retireDelegation = async (assignment, retirement) => {
+ const result = await retireDelegation.call(effects, assignment, retirement);
effects.sessions = effects.sessions.map((candidate) =>
candidate.id === 'destination' ? { ...candidate, name: 'Renamed destination' } : candidate,
);
+ return result;
};
const assign = effects.assign;
effects.assign = async (input) => {
@@ -1635,8 +2331,8 @@ describe('WorkHub Coordination Action Gate', () => {
(candidate) => candidate.sessionId === 'destination',
)!;
const retireDelegation = effects.retireDelegation;
- effects.retireDelegation = async (assignment) => {
- await retireDelegation.call(effects, assignment);
+ effects.retireDelegation = async (assignment, retirement) => {
+ const result = await retireDelegation.call(effects, assignment, retirement);
effects.sessions = effects.sessions.map((candidate) =>
candidate.id !== 'destination'
? candidate
@@ -1644,6 +2340,7 @@ describe('WorkHub Coordination Action Gate', () => {
? { ...candidate, isArchived: true }
: { ...candidate, status: 'waiting_for_user' },
);
+ return result;
};
const input = {
actionId: `target-became-${lifecycle}`,
@@ -1722,8 +2419,8 @@ describe('WorkHub Coordination Action Gate', () => {
},
};
const retireDelegation = effects.retireDelegation;
- effects.retireDelegation = async (assignment) => {
- await retireDelegation.call(effects, assignment);
+ effects.retireDelegation = async (assignment, retirement) => {
+ await retireDelegation.call(effects, assignment, retirement);
throw new Error('simulated process exit after retirement');
};
@@ -1829,8 +2526,13 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) {
const replacements = new Map();
const replacementAborts = new Map();
const supersessions = new Map();
+ const stopRequests = new Map();
+ const stopResolutions = new Map();
+ const actionClaims = new Map();
return {
sessions: [...initialSessions],
+ actionClaims,
+ removedSessionIds: new Set(),
answers: [] as Array<{ turnId: string; text: string }>,
clarifications: [] as Array<{
turnId: string;
@@ -1842,13 +2544,45 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) {
replacements,
replacementAborts,
supersessions,
+ stopRequests,
+ stopResolutions,
retirements: [] as WorkHubDelegationAssignedMessage[],
+ retirementClaims: [] as WorkHubDelegationRetirementClaim[],
async listSessions() {
return this.sessions;
},
+ async claimAction(claim: WorkHubActionClaim): Promise {
+ const existing = actionClaims.get(claim.actionId);
+ if (!existing) {
+ actionClaims.set(claim.actionId, claim);
+ return 'claimed';
+ }
+ return existing.operation === claim.operation &&
+ existing.actionFingerprint === claim.actionFingerprint &&
+ existing.subject === claim.subject
+ ? 'same_claim'
+ : 'conflict';
+ },
+ async readActionClaim(actionId: string) {
+ return actionClaims.get(actionId);
+ },
+ async probeTargetRemoval(sessionId: string) {
+ if (this.sessions.some((session) => session.id === sessionId)) return 'present' as const;
+ return this.removedSessionIds.has(sessionId) ? ('removed' as const) : ('absent' as const);
+ },
async readAssignment(actionId: string) {
return assignmentRecords.get(actionId);
},
+ async listActiveAssignments() {
+ return [...assignmentRecords.values()].filter((assignment) => {
+ const stopOutcome = stopResolutions.get(assignment.delegationId)?.outcome;
+ return (
+ !supersessions.has(assignment.delegationId) &&
+ !replacementAborts.has(assignment.delegationId) &&
+ (stopOutcome === undefined || stopOutcome === 'not_owned')
+ );
+ });
+ },
async readReplacement(delegationId: string) {
return replacements.get(delegationId);
},
@@ -1858,6 +2592,12 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) {
async readSupersession(delegationId: string) {
return supersessions.get(delegationId);
},
+ async readStopRequest(delegationId: string) {
+ return stopRequests.get(delegationId);
+ },
+ async readStopResolution(delegationId: string) {
+ return stopResolutions.get(delegationId);
+ },
async answer(input: { turnId: string; text: string }) {
this.answers.push(input);
},
@@ -1941,16 +2681,72 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) {
replacementAborts.set(replacement.replacesDelegationId, aborted);
return aborted;
},
- async readDelegationRetirement(assignment: WorkHubDelegationAssignedMessage) {
+ async prepareStop(input: WorkHubDelegationStopInput) {
+ const existing = stopRequests.get(input.stopsDelegationId);
+ if (existing) return existing;
+ const requested: WorkHubDelegationStopRequestedMessage = {
+ type: 'workhub_coordination',
+ id: `stop-${input.actionId}`,
+ turnId: input.actionId,
+ ts: 5,
+ schemaVersion: 3,
+ kind: 'delegation_stop_requested',
+ actionId: input.actionId,
+ actionFingerprint: input.actionFingerprint,
+ coordinationTurnId: input.actionId,
+ stopsActionId: input.stopsActionId,
+ stopsDelegationId: input.stopsDelegationId,
+ targetSessionId: input.targetSessionId,
+ targetMessageId: input.targetMessageId,
+ targetSessionName: input.targetSessionName,
+ userText: input.userText,
+ };
+ stopRequests.set(input.stopsDelegationId, requested);
+ return requested;
+ },
+ async resolveStop(input: WorkHubDelegationStopResolutionInput) {
+ const request = input.request;
+ const existing = stopResolutions.get(request.stopsDelegationId);
+ if (existing) return existing;
+ const resolved: WorkHubDelegationStopResolvedMessage = {
+ type: 'workhub_coordination',
+ id: `resolved-${request.actionId}`,
+ turnId: request.actionId,
+ ts: 6,
+ schemaVersion: 3,
+ kind: 'delegation_stop_resolved',
+ actionId: request.actionId,
+ actionFingerprint: request.actionFingerprint,
+ coordinationTurnId: request.coordinationTurnId,
+ stopsActionId: request.stopsActionId,
+ stopsDelegationId: request.stopsDelegationId,
+ targetSessionId: request.targetSessionId,
+ outcome: input.outcome,
+ ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}),
+ };
+ stopResolutions.set(request.stopsDelegationId, resolved);
+ return resolved;
+ },
+ async readDelegationRetirement(
+ assignment: WorkHubDelegationAssignedMessage,
+ ): Promise<'not_retired' | 'retired' | 'recovering'> {
return this.retirements.some((retired) => retired.delegationId === assignment.delegationId)
- ? ('retired' as const)
- : ('not_retired' as const);
+ ? 'retired'
+ : 'not_retired';
},
- async retireDelegation(assignment: WorkHubDelegationAssignedMessage) {
+ async retireDelegation(
+ assignment: WorkHubDelegationAssignedMessage,
+ retirement: WorkHubDelegationRetirementClaim,
+ ): Promise {
this.retirements.push(assignment);
+ this.retirementClaims.push(retirement);
+ return { outcome: 'cancelled_pending' as const };
},
} satisfies WorkHubActionGateEffects & {
sessions: WorkHubActionGateSession[];
+ actionClaims: Map;
+ removedSessionIds: Set;
+ retirementClaims: WorkHubDelegationRetirementClaim[];
answers: Array<{ turnId: string; text: string }>;
clarifications: Array<{ turnId: string; userText: string; assistantText: string }>;
assignments: WorkHubDelegationAssignmentInput[];
@@ -1958,6 +2754,8 @@ function fakeEffects(initialSessions: WorkHubActionGateSession[]) {
replacements: Map;
replacementAborts: Map;
supersessions: Map;
+ stopRequests: Map;
+ stopResolutions: Map;
retirements: WorkHubDelegationAssignedMessage[];
};
}
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 598f476da0..cb70f8d3b9 100644
--- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts
@@ -32,6 +32,8 @@ import {
import {
WORKHUB_COORDINATION_SESSION_ID,
WORKHUB_COORDINATION_SESSION_ROLE,
+ WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION,
+ type StoredMessage,
} from '@maka/core/session';
import { createSessionStore, type SessionAuthorityStore } from '@maka/storage/session-store';
import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store';
@@ -580,6 +582,808 @@ describe('Host WorkHub Coordination coordinator', () => {
}
});
+ test('persists direct-stop request and resolution before replaying after restart', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-'));
+ let store = createSessionStore(root);
+ let targetId = '';
+ try {
+ const target = await store.create({
+ cwd: root,
+ name: 'Payments',
+ llmConnectionSlug: 'test-connection',
+ model: 'test-model',
+ permissionMode: 'ask',
+ });
+ targetId = target.id;
+ let retireCalls = 0;
+ const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, {
+ assign: (input) => persistTestAssignment(store, input, 'payments-turn'),
+ retireDelegation: async () => {
+ retireCalls += 1;
+ return { outcome: 'stop_delivered', targetTurnId: 'payments-turn' };
+ },
+ });
+ assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true);
+ const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT);
+ assert.equal(candidates.ok, true);
+ if (!candidates.ok) return;
+ const candidate = candidates.result.candidates.find(
+ ({ sessionId }) => sessionId === target.id,
+ )!;
+ assert.equal(
+ (
+ await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'source-action',
+ userText: 'Fix payment retry',
+ candidateSetId: candidates.result.candidateSetId,
+ proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef },
+ },
+ CONTEXT,
+ )
+ ).ok,
+ true,
+ );
+ const stopped = await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'stop-action',
+ userText: 'Stop Payments',
+ proposal: {
+ disposition: 'stop_work',
+ expects: { targetSessionId: target.id },
+ },
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+ assert.deepEqual(stopped, {
+ ok: true,
+ result: {
+ disposition: 'stop_work',
+ outcome: 'stop_delivered',
+ targetSessionId: target.id,
+ targetTurnId: 'payments-turn',
+ },
+ });
+ assert.equal(retireCalls, 1);
+ const assignment = await store.readWorkHubAssignment('source-action');
+ assert.ok(assignment);
+ assert.equal(
+ (await store.readWorkHubStopRequest(assignment.delegationId))?.actionId,
+ 'stop-action',
+ );
+ assert.equal(
+ (await store.readWorkHubStopResolution(assignment.delegationId))?.outcome,
+ 'stop_delivered',
+ );
+ } finally {
+ await store.close?.();
+ }
+
+ store = createSessionStore(root);
+ try {
+ const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, {
+ retireDelegation: async () => assert.fail('durable stop replay must not retire twice'),
+ });
+ const replay = await restarted.handlers['workhub.coordination.act'](
+ {
+ actionId: 'stop-action',
+ userText: 'Stop Payments',
+ proposal: {
+ disposition: 'stop_work',
+ expects: { targetSessionId: targetId },
+ },
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+ assert.equal(replay.ok, true);
+ if (replay.ok && replay.result.disposition === 'stop_work') {
+ assert.equal(replay.result.outcome, 'stop_delivered');
+ }
+ } finally {
+ await store.close?.();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ test('rechecks sole-delegation stop preconditions after the advisory active-link read', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-race-'));
+ const store = createSessionStore(root);
+ try {
+ const target = await store.create({
+ cwd: root,
+ name: 'Payments',
+ llmConnectionSlug: 'test-connection',
+ model: 'test-model',
+ permissionMode: 'ask',
+ });
+ let injected = false;
+ const stores = new Proxy(store, {
+ get(authority, property, receiver) {
+ if (property === 'readMessagesSnapshot') {
+ return async (sessionId: string) => {
+ const messages = await authority.readMessagesSnapshot(sessionId);
+ if (
+ !injected &&
+ sessionId === WORKHUB_COORDINATION_SESSION_ID &&
+ messages.some(
+ (message) =>
+ message.type === 'workhub_coordination' &&
+ message.kind === 'delegation_assigned' &&
+ message.actionId === 'source-action',
+ )
+ ) {
+ injected = true;
+ await persistTestAssignment(
+ authority,
+ {
+ actionId: 'racing-action',
+ actionFingerprint: `sha256:${'8'.repeat(64)}`,
+ targetSessionId: target.id,
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: 'A second payment delegation',
+ },
+ 'racing-turn',
+ );
+ }
+ return messages;
+ };
+ }
+ const value = Reflect.get(authority, property, receiver) as unknown;
+ return typeof value === 'function' ? value.bind(authority) : value;
+ },
+ }) as SessionAuthorityStore;
+ let retireCalls = 0;
+ const workhub = coordinator(root, stores, () => undefined, undefined, undefined, undefined, {
+ assign: (input) => persistTestAssignment(store, input, 'source-turn'),
+ retireDelegation: async () => {
+ retireCalls += 1;
+ return { outcome: 'cancelled_pending' };
+ },
+ });
+ assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true);
+ const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT);
+ assert.equal(candidates.ok, true);
+ if (!candidates.ok) return;
+ const candidate = candidates.result.candidates.find(
+ ({ sessionId }) => sessionId === target.id,
+ )!;
+ assert.equal(
+ (
+ await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'source-action',
+ userText: 'Fix payment retry',
+ candidateSetId: candidates.result.candidateSetId,
+ proposal: { disposition: 'delegate_existing', candidateRef: candidate.candidateRef },
+ },
+ CONTEXT,
+ )
+ ).ok,
+ true,
+ );
+
+ const stopped = await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'stop-racing-action',
+ userText: 'Stop Payments',
+ proposal: {
+ disposition: 'stop_work',
+ expects: { targetSessionId: target.id },
+ },
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+ assert.equal(stopped.ok, false);
+ if (!stopped.ok) assert.equal(stopped.error.code, 'operation_conflict');
+ const source = await store.readWorkHubAssignment('source-action');
+ assert.ok(source);
+ assert.equal(
+ source ? await store.readWorkHubStopRequest(source.delegationId) : undefined,
+ undefined,
+ );
+ assert.equal(retireCalls, 0);
+ } finally {
+ await store.close?.();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ test('converges a committed stop after the target Session is removed and the Host restarts', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-removed-'));
+ let store = createSessionStore(root);
+ let targetId: string;
+ const stopInput = () => ({
+ actionId: 'stop-action',
+ userText: 'Stop Payments',
+ proposal: {
+ disposition: 'stop_work' as const,
+ expects: { targetSessionId: targetId },
+ },
+ confirmation: { kind: 'user_stop' as const },
+ });
+ try {
+ const target = await store.create({
+ cwd: root,
+ name: 'Payments',
+ llmConnectionSlug: 'test-connection',
+ model: 'test-model',
+ permissionMode: 'ask',
+ });
+ targetId = target.id;
+ // The exact crash seam: the pending cancellation succeeds and the durable
+ // resolution never lands.
+ const crashing = new Proxy(store, {
+ get(authority, property, receiver) {
+ if (property === 'appendMessages') {
+ return async (sessionId: string, messages: readonly { kind?: unknown }[]) => {
+ if (messages.some((message) => message.kind === 'delegation_stop_resolved')) {
+ throw new Error('simulated process exit before the stop resolution');
+ }
+ return authority.appendMessages(sessionId, messages as never);
+ };
+ }
+ const value = Reflect.get(authority, property, receiver) as unknown;
+ return typeof value === 'function' ? value.bind(authority) : value;
+ },
+ }) as SessionAuthorityStore;
+ const workhub = coordinator(
+ root,
+ crashing,
+ () => undefined,
+ undefined,
+ undefined,
+ undefined,
+ {
+ assign: (input) => persistTestAssignment(store, input, 'payments-turn'),
+ retireDelegation: async () => ({ outcome: 'cancelled_pending' }),
+ },
+ );
+ assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true);
+ const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT);
+ assert.equal(candidates.ok, true);
+ if (!candidates.ok) return;
+ assert.equal(
+ (
+ await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'source-action',
+ userText: 'Fix payment retry',
+ candidateSetId: candidates.result.candidateSetId,
+ proposal: {
+ disposition: 'delegate_existing',
+ candidateRef: candidates.result.candidates.find(
+ ({ sessionId }) => sessionId === target.id,
+ )!.candidateRef,
+ },
+ },
+ CONTEXT,
+ )
+ ).ok,
+ true,
+ );
+ const crashed = await workhub.handlers['workhub.coordination.act'](stopInput(), CONTEXT);
+ assert.equal(crashed.ok, false);
+ const assignment = await store.readWorkHubAssignment('source-action');
+ assert.ok(assignment);
+ assert.ok(await store.readWorkHubStopRequest(assignment.delegationId));
+ assert.equal(await store.readWorkHubStopResolution(assignment.delegationId), undefined);
+
+ await store.remove(target.id);
+ } finally {
+ await store.close?.();
+ }
+
+ store = createSessionStore(root);
+ try {
+ let retireCalls = 0;
+ const restarted = coordinator(root, store, () => undefined, undefined, undefined, undefined, {
+ // The removed Session took every Message-ownership proof with it.
+ retireDelegation: async () => {
+ retireCalls += 1;
+ return { outcome: 'recovering' };
+ },
+ });
+ const resolved = await restarted.handlers['workhub.coordination.act'](stopInput(), CONTEXT);
+ assert.deepEqual(resolved, {
+ ok: true,
+ result: {
+ disposition: 'stop_work',
+ outcome: 'already_terminal',
+ targetSessionId: targetId,
+ },
+ });
+ assert.equal(retireCalls, 1);
+ assert.deepEqual(
+ await restarted.handlers['workhub.coordination.act'](stopInput(), CONTEXT),
+ resolved,
+ );
+ assert.equal(retireCalls, 1);
+ } finally {
+ await store.close?.();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ test('a claim written before its stop request resolves like a first attempt', async () => {
+ // The claim is committed before the request, so a crash between them leaves
+ // an action that owns a stop with nothing to converge on. Nothing
+ // destructive happened either, so the delegation is still linked and the
+ // retry must resolve from the active links rather than refuse.
+ const root = await mkdtemp(join(tmpdir(), 'maka-workhub-claim-only-'));
+ const store = createSessionStore(root);
+ try {
+ const target = await store.create({
+ cwd: root,
+ name: 'Payments',
+ llmConnectionSlug: 'test-connection',
+ model: 'test-model',
+ permissionMode: 'ask',
+ });
+ let failStopRequest = true;
+ const stores = new Proxy(store, {
+ get(authority, property, receiver) {
+ if (property === 'appendMessages') {
+ return async (sessionId: string, messages: StoredMessage[]) => {
+ if (
+ failStopRequest &&
+ messages.some(
+ (message) =>
+ message.type === 'workhub_coordination' &&
+ message.kind === 'delegation_stop_requested',
+ )
+ ) {
+ failStopRequest = false;
+ throw new Error('crash before the stop request is durable');
+ }
+ return authority.appendMessages(sessionId, messages);
+ };
+ }
+ return Reflect.get(authority, property, receiver);
+ },
+ }) as SessionAuthorityStore;
+ const workhub = coordinator(root, stores, () => undefined, undefined, undefined, undefined, {
+ assign: (input) => persistTestAssignment(store, input, 'payments-turn'),
+ retireDelegation: async () => ({
+ outcome: 'stop_delivered' as const,
+ targetTurnId: 'payments-turn',
+ }),
+ });
+ assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true);
+ const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT);
+ assert.equal(candidates.ok, true);
+ if (!candidates.ok) return;
+ assert.equal(
+ (
+ await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'source-action',
+ userText: 'Fix payment retry',
+ candidateSetId: candidates.result.candidateSetId,
+ proposal: {
+ disposition: 'delegate_existing',
+ candidateRef: candidates.result.candidates.find(
+ ({ sessionId }) => sessionId === target.id,
+ )!.candidateRef,
+ },
+ },
+ CONTEXT,
+ )
+ ).ok,
+ true,
+ );
+ const assignment = await store.readWorkHubAssignment('source-action');
+ assert.ok(assignment);
+
+ const stop = () =>
+ workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'stop-action',
+ userText: 'Stop Payments',
+ proposal: { disposition: 'stop_work', expects: { targetSessionId: target.id } },
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+
+ assert.equal((await stop()).ok, false);
+ // Exactly the seam: the action owns a stop claim, and no request behind it.
+ assert.equal((await store.readWorkHubActionClaim('stop-action'))?.operation, 'stop');
+ assert.equal(await store.readWorkHubStopRequest(assignment.delegationId), undefined);
+
+ const retried = await stop();
+ assert.equal(retried.ok, true);
+ if (retried.ok && retried.result.disposition === 'stop_work') {
+ assert.equal(retried.result.outcome, 'stop_delivered');
+ }
+ assert.equal(
+ (await store.readWorkHubStopResolution(assignment.delegationId))?.outcome,
+ 'stop_delivered',
+ );
+ } finally {
+ await store.close?.();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ test('a claimed stop refuses by name when its delegation was replaced', async () => {
+ // The claim survived a crash before its request. By the retry the link it
+ // bound itself to is gone and another has taken its place on the same
+ // Session, so re-deriving would silently bind this action to a delegation
+ // the user never named. The fingerprint would not match the claim either,
+ // and claims are never deleted, so the refusal is permanent — it should at
+ // least say which refusal it is.
+ const root = await mkdtemp(join(tmpdir(), 'maka-workhub-claim-moved-'));
+ const store = createSessionStore(root);
+ try {
+ const target = await store.create({
+ cwd: root,
+ name: 'Payments',
+ llmConnectionSlug: 'test-connection',
+ model: 'test-model',
+ permissionMode: 'ask',
+ });
+ const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, {
+ assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`),
+ retireDelegation: async () => assert.fail('a spent stop identity must not retire work'),
+ });
+ assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true);
+ const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT);
+ assert.equal(candidates.ok, true);
+ if (!candidates.ok) return;
+ assert.equal(
+ (
+ await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'source-action',
+ userText: 'Fix payment retry',
+ candidateSetId: candidates.result.candidateSetId,
+ proposal: {
+ disposition: 'delegate_existing',
+ candidateRef: candidates.result.candidates.find(
+ ({ sessionId }) => sessionId === target.id,
+ )!.candidateRef,
+ },
+ },
+ CONTEXT,
+ )
+ ).ok,
+ true,
+ );
+ const assignment = await store.readWorkHubAssignment('source-action');
+ assert.ok(assignment);
+ // The stop bound itself to that delegation, then crashed before its
+ // request was durable.
+ assert.equal(
+ await store.claimWorkHubAction({
+ actionId: 'stop-action',
+ operation: 'stop',
+ actionFingerprint: `sha256:${'d'.repeat(64)}`,
+ subject: assignment.delegationId,
+ }),
+ 'claimed',
+ );
+ // That delegation ends and a different one takes its place.
+ await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [
+ {
+ type: 'workhub_coordination',
+ id: 'whs_replaced_probe',
+ turnId: 'replaced-probe-turn',
+ ts: Date.now(),
+ schemaVersion: WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION,
+ kind: 'delegation_superseded',
+ actionId: 'supersede-probe-action',
+ actionFingerprint: `sha256:${'e'.repeat(64)}`,
+ coordinationTurnId: 'replaced-probe-turn',
+ supersededActionId: 'source-action',
+ supersededDelegationId: assignment.delegationId,
+ replacementDelegationId: 'whd_replacement_probe',
+ },
+ ]);
+ await persistTestAssignment(
+ store,
+ {
+ actionId: 'successor-action',
+ actionFingerprint: `sha256:${'f'.repeat(64)}`,
+ targetSessionId: target.id,
+ targetSessionName: 'Payments',
+ disposition: 'delegate_existing',
+ userText: 'Fix payment retry again',
+ },
+ 'successor-turn',
+ );
+
+ const refused = await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'stop-action',
+ userText: 'Stop Payments',
+ proposal: { disposition: 'stop_work', expects: { targetSessionId: target.id } },
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+
+ assert.equal(refused.ok, false);
+ if (!refused.ok) {
+ assert.equal(refused.error.code, 'operation_conflict');
+ assert.match(refused.error.message, /already bound to a different delegation/u);
+ }
+ const successor = await store.readWorkHubAssignment('successor-action');
+ assert.ok(successor);
+ assert.equal(await store.readWorkHubStopRequest(successor.delegationId), undefined);
+ } finally {
+ await store.close?.();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ test('a claimed stop whose delegation went terminal elsewhere conflicts', async () => {
+ // Claim present, no request and no resolution to converge on, and the
+ // delegation is gone from the active set because another path superseded
+ // it. There is nothing left to resolve and nothing was destroyed, so this
+ // refuses exactly as it did before the claim became the replay key.
+ const root = await mkdtemp(join(tmpdir(), 'maka-workhub-claim-terminal-'));
+ const store = createSessionStore(root);
+ try {
+ const target = await store.create({
+ cwd: root,
+ name: 'Payments',
+ llmConnectionSlug: 'test-connection',
+ model: 'test-model',
+ permissionMode: 'ask',
+ });
+ const workhub = coordinator(root, store, () => undefined, undefined, undefined, undefined, {
+ assign: (input) => persistTestAssignment(store, input, 'payments-turn'),
+ retireDelegation: async () => assert.fail('a terminal delegation must not be retired'),
+ });
+ assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true);
+ const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT);
+ assert.equal(candidates.ok, true);
+ if (!candidates.ok) return;
+ assert.equal(
+ (
+ await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'source-action',
+ userText: 'Fix payment retry',
+ candidateSetId: candidates.result.candidateSetId,
+ proposal: {
+ disposition: 'delegate_existing',
+ candidateRef: candidates.result.candidates.find(
+ ({ sessionId }) => sessionId === target.id,
+ )!.candidateRef,
+ },
+ },
+ CONTEXT,
+ )
+ ).ok,
+ true,
+ );
+ const assignment = await store.readWorkHubAssignment('source-action');
+ assert.ok(assignment);
+ assert.equal(
+ await store.claimWorkHubAction({
+ actionId: 'stop-action',
+ operation: 'stop',
+ actionFingerprint: `sha256:${'b'.repeat(64)}`,
+ subject: assignment.delegationId,
+ }),
+ 'claimed',
+ );
+ await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [
+ {
+ type: 'workhub_coordination',
+ id: 'whs_terminal_probe',
+ turnId: 'terminal-probe-turn',
+ ts: Date.now(),
+ schemaVersion: WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION,
+ kind: 'delegation_superseded',
+ actionId: 'supersede-probe-action',
+ actionFingerprint: `sha256:${'c'.repeat(64)}`,
+ coordinationTurnId: 'terminal-probe-turn',
+ supersededActionId: 'source-action',
+ supersededDelegationId: assignment.delegationId,
+ replacementDelegationId: 'whd_replacement_probe',
+ },
+ ]);
+
+ const conflicted = await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'stop-action',
+ userText: 'Stop Payments',
+ proposal: { disposition: 'stop_work', expects: { targetSessionId: target.id } },
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+ assert.equal(conflicted.ok, false);
+ if (!conflicted.ok) assert.equal(conflicted.error.code, 'operation_conflict');
+ assert.equal(await store.readWorkHubStopResolution(assignment.delegationId), undefined);
+ } finally {
+ await store.close?.();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ test('a stop holds only its target Session lane and the Coordination lane', async () => {
+ // Admission serializes per Session. A stop that held a lane for every
+ // Session with an active delegation would put unrelated delegation traffic
+ // behind it, and a delegation arriving elsewhere mid-admission would fail a
+ // stop it cannot affect. The proof under the lease needs neither.
+ const root = await mkdtemp(join(tmpdir(), 'maka-workhub-lanes-'));
+ const store = createSessionStore(root);
+ try {
+ const targets: Array<{ id: string; name: string }> = [];
+ for (const name of ['Payments', 'Login']) {
+ targets.push(
+ await store.create({
+ cwd: root,
+ name,
+ llmConnectionSlug: 'test-connection',
+ model: 'test-model',
+ permissionMode: 'ask',
+ }),
+ );
+ }
+ const payments = targets.find((session) => session.name === 'Payments')!;
+ const login = targets.find((session) => session.name === 'Login')!;
+ const admission = new SessionAdmissionGate();
+ const laneSets: string[][] = [];
+ const observed = new Proxy(admission, {
+ get(gate, property, receiver) {
+ if (property === 'runMany') {
+ return (sessionIds: readonly string[], operation: never) => {
+ laneSets.push([...sessionIds]);
+ return gate.runMany(sessionIds, operation);
+ };
+ }
+ const value = Reflect.get(gate, property, receiver) as unknown;
+ return typeof value === 'function' ? value.bind(gate) : value;
+ },
+ }) as SessionAdmissionGate;
+ const workhub = coordinator(root, store, () => undefined, undefined, undefined, observed, {
+ assign: (input) => persistTestAssignment(store, input, `${input.actionId}-turn`),
+ retireDelegation: async () => ({
+ outcome: 'stop_delivered' as const,
+ targetTurnId: 'source-action-turn',
+ }),
+ });
+ assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true);
+ for (const [actionId, target, userText] of [
+ ['source-action', payments, 'Fix payment retry'],
+ ['login-action', login, 'Fix the login redirect'],
+ ] as const) {
+ const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT);
+ assert.equal(candidates.ok, true);
+ if (!candidates.ok) return;
+ assert.equal(
+ (
+ await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId,
+ userText,
+ candidateSetId: candidates.result.candidateSetId,
+ proposal: {
+ disposition: 'delegate_existing',
+ candidateRef: candidates.result.candidates.find(
+ ({ sessionId }) => sessionId === target.id,
+ )!.candidateRef,
+ },
+ },
+ CONTEXT,
+ )
+ ).ok,
+ true,
+ );
+ }
+
+ laneSets.length = 0;
+ const stopped = await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'stop-action',
+ userText: 'Stop Payments',
+ proposal: { disposition: 'stop_work', expects: { targetSessionId: payments.id } },
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+
+ assert.equal(stopped.ok, true);
+ const stopLanes = laneSets.find((lanes) => lanes.includes(payments.id));
+ assert.ok(stopLanes, 'the stop must take a lane on its own target');
+ assert.deepEqual(
+ [...stopLanes].sort(),
+ [WORKHUB_COORDINATION_SESSION_ID, payments.id].sort(),
+ 'Login has an active delegation but this stop cannot change it',
+ );
+ } finally {
+ await store.close?.();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ test('one stop reads the Coordination transcript twice, not once per proof', async () => {
+ // The Gate derives the delegation from the active links, then admission
+ // reproves it under the lease. Those are the two reads that decide. Any
+ // further pass re-derives an answer the stop already holds, on a transcript
+ // that only grows.
+ const root = await mkdtemp(join(tmpdir(), 'maka-workhub-scan-count-'));
+ const store = createSessionStore(root);
+ try {
+ const target = await store.create({
+ cwd: root,
+ name: 'Payments',
+ llmConnectionSlug: 'test-connection',
+ model: 'test-model',
+ permissionMode: 'ask',
+ });
+ let coordinationReads = 0;
+ let counting = false;
+ const stores = new Proxy(store, {
+ get(authority, property, receiver) {
+ if (property === 'readMessagesSnapshot') {
+ return async (sessionId: string) => {
+ if (counting && sessionId === WORKHUB_COORDINATION_SESSION_ID) coordinationReads += 1;
+ return authority.readMessagesSnapshot(sessionId);
+ };
+ }
+ const value = Reflect.get(authority, property, receiver) as unknown;
+ return typeof value === 'function' ? value.bind(authority) : value;
+ },
+ }) as SessionAuthorityStore;
+ const workhub = coordinator(root, stores, () => undefined, undefined, undefined, undefined, {
+ assign: (input) => persistTestAssignment(store, input, 'payments-turn'),
+ retireDelegation: async () => ({
+ outcome: 'stop_delivered' as const,
+ targetTurnId: 'payments-turn',
+ }),
+ });
+ assert.equal((await workhub.handlers['workhub.coordination.resolve']({}, CONTEXT)).ok, true);
+ const candidates = await workhub.handlers['workhub.coordination.candidates']({}, CONTEXT);
+ assert.equal(candidates.ok, true);
+ if (!candidates.ok) return;
+ assert.equal(
+ (
+ await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'source-action',
+ userText: 'Fix payment retry',
+ candidateSetId: candidates.result.candidateSetId,
+ proposal: {
+ disposition: 'delegate_existing',
+ candidateRef: candidates.result.candidates.find(
+ ({ sessionId }) => sessionId === target.id,
+ )!.candidateRef,
+ },
+ },
+ CONTEXT,
+ )
+ ).ok,
+ true,
+ );
+
+ counting = true;
+ const stopped = await workhub.handlers['workhub.coordination.act'](
+ {
+ actionId: 'stop-action',
+ userText: 'Stop Payments',
+ proposal: { disposition: 'stop_work', expects: { targetSessionId: target.id } },
+ confirmation: { kind: 'user_stop' },
+ },
+ CONTEXT,
+ );
+
+ assert.equal(stopped.ok, true);
+ assert.equal(coordinationReads, 2, 'a stop derives once and reproves once');
+ } finally {
+ await store.close?.();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
test('refuses to merge a Turn identity shared across answer and record', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-workhub-turn-identity-'));
const store = createSessionStore(root);
@@ -727,7 +1531,7 @@ function coordinator(
sessionActions: {
assign: async ({ targetSessionId }) => ({ turnId: `turn-${targetSessionId}` }),
readDelegationRetirement: async () => 'not_retired',
- retireDelegation: async () => undefined,
+ retireDelegation: async () => ({ outcome: 'cancelled_pending' }),
...sessionActions,
},
resolveCreateTarget:
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 b6cbfbf3fa..25cd512c4e 100644
--- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts
@@ -39,7 +39,7 @@ test('WorkHub Coordination resolve has a closed empty input and bounded identity
sessionId: 'coordination',
});
assert.equal(HOST_OPERATION_SPECS['workhub.coordination.resolve'].mode, 'command');
- assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 49);
+ assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 86);
assert.throws(
() => decodeWorkHubCoordinationResolveInput({ sessionId: 'caller-selected' }),
(error) => error instanceof RuntimeHostProtocolError,
@@ -85,6 +85,77 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', ()
target: { disposition: 'delegate_existing', candidateRef: 'candidate_login' },
},
);
+ assert.deepEqual(
+ decodeWorkHubCoordinationActInput({
+ actionId: 'action-stop',
+ userText: 'Stop Payments',
+ proposal: {
+ disposition: 'stop_work',
+ expects: { targetSessionId: 'payments' },
+ },
+ confirmation: { kind: 'user_stop' },
+ }),
+ {
+ actionId: 'action-stop',
+ userText: 'Stop Payments',
+ proposal: {
+ disposition: 'stop_work',
+ expects: { targetSessionId: 'payments' },
+ },
+ confirmation: { kind: 'user_stop' },
+ },
+ );
+ for (const invalid of [
+ {
+ actionId: 'action-stop-no-confirmation',
+ userText: 'Stop Payments',
+ proposal: {
+ disposition: 'stop_work',
+ expects: { targetSessionId: 'payments' },
+ },
+ },
+ {
+ actionId: 'action-stop-wrong-confirmation',
+ userText: 'Stop Payments',
+ proposal: {
+ disposition: 'stop_work',
+ expects: { targetSessionId: 'payments' },
+ },
+ confirmation: { kind: 'user_correction' },
+ },
+ {
+ actionId: 'action-stop-injected',
+ userText: 'Stop Payments',
+ proposal: {
+ disposition: 'stop_work',
+ expects: { targetSessionId: 'payments' },
+ targetSessionId: 'injected',
+ },
+ confirmation: { kind: 'user_stop' },
+ },
+ // Preconditions are part of the closed proposal shape, not an optional hint.
+ {
+ actionId: 'action-stop-missing-preconditions',
+ userText: 'Stop Payments',
+ proposal: { disposition: 'stop_work', stopsActionId: 'action-payments' },
+ confirmation: { kind: 'user_stop' },
+ },
+ // Preconditions are a closed shape: no room for a second, client-asserted proof.
+ {
+ actionId: 'action-stop-extra-precondition',
+ userText: 'Stop Payments',
+ proposal: {
+ disposition: 'stop_work',
+ expects: { targetSessionId: 'payments', activeActionIds: ['action-payments'] },
+ },
+ confirmation: { kind: 'user_stop' },
+ },
+ ]) {
+ assert.throws(
+ () => decodeWorkHubCoordinationActInput(invalid),
+ (error) => error instanceof RuntimeHostProtocolError,
+ );
+ }
assert.throws(
() =>
decodeWorkHubCoordinationActInput({
@@ -294,4 +365,50 @@ test('WorkHub Coordination action results preserve the admitted disposition', ()
targetTurnId: 'turn-login',
},
);
+ assert.deepEqual(
+ decodeWorkHubCoordinationActResult({
+ disposition: 'stop_work',
+ outcome: 'not_owned',
+ targetSessionId: 'payments',
+ targetTurnId: 'shared-turn',
+ }),
+ {
+ disposition: 'stop_work',
+ outcome: 'not_owned',
+ targetSessionId: 'payments',
+ targetTurnId: 'shared-turn',
+ },
+ );
+ assert.throws(
+ () =>
+ decodeWorkHubCoordinationActResult({
+ disposition: 'stop_work',
+ outcome: 'stopped',
+ targetSessionId: 'payments',
+ }),
+ (error) => error instanceof RuntimeHostProtocolError,
+ );
+ for (const invalid of [
+ {
+ disposition: 'stop_work',
+ outcome: 'stop_delivered',
+ targetSessionId: 'payments',
+ },
+ {
+ disposition: 'stop_work',
+ outcome: 'not_owned',
+ targetSessionId: 'payments',
+ },
+ {
+ disposition: 'stop_work',
+ outcome: 'cancelled_pending',
+ targetSessionId: 'payments',
+ targetTurnId: 'unexpected-turn',
+ },
+ ]) {
+ assert.throws(
+ () => decodeWorkHubCoordinationActResult(invalid),
+ (error) => error instanceof RuntimeHostProtocolError,
+ );
+ }
});
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 32d21d3f7b..90e7197a60 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -100,7 +100,10 @@ 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 = 103 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 104 as const;
+// 104: WorkHub Coordination actions add closed direct-stop proposals,
+// confirmations, expected-state preconditions, and outcomes. Older peers
+// reject these strict shapes.
// 103: `github-copilot` joins `OAUTH_LOGIN_PROVIDERS`, the Host answers the
// closed `oauth.enrollment.query`, and `connection.onboarding.save` admits
// canonical OAuth material with an empty enable-all-discovered selection.
diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts
index f3859f04b1..735c6a840e 100644
--- a/packages/runtime-host/src/protocol/workhub-coordination.ts
+++ b/packages/runtime-host/src/protocol/workhub-coordination.ts
@@ -132,13 +132,35 @@ export type WorkHubCoordinationProposal =
readonly target:
| { readonly disposition: 'delegate_existing'; readonly candidateRef: string }
| { readonly disposition: 'create_new'; readonly title: string };
+ }
+ | {
+ readonly disposition: 'stop_work';
+ /**
+ * The expected state the Action 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.
+ *
+ * Which delegation the stop ends is not stated here. A client cannot
+ * 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;
};
-export interface WorkHubCoordinationDestructiveConfirmation {
- /** Kept outside strategy output so a model proposal cannot authorize Stop. */
- readonly kind: 'user_correction';
+export interface WorkHubCoordinationStopPreconditions {
+ /**
+ * Session the resolved delegation was proposed against. Sole-active-delegation
+ * is proved by the Host from durable state under the admission lease, so the
+ * proposal states only what it resolved, never its own proof.
+ */
+ readonly targetSessionId: string;
}
+export type WorkHubCoordinationDestructiveConfirmation =
+ /** Kept outside strategy output so a model proposal cannot authorize Stop. */
+ { readonly kind: 'user_correction' } | { readonly kind: 'user_stop' };
+
export interface WorkHubCoordinationCreateContext {
/** Trusted desktop context. Model/strategy output never contains a workspace or identity. */
readonly workspace: WorkspaceTarget;
@@ -174,6 +196,12 @@ export type WorkHubCoordinationActResult =
readonly targetSessionId: string;
readonly targetTurnId: string;
readonly steered?: true;
+ }
+ | {
+ readonly disposition: 'stop_work';
+ readonly outcome: 'cancelled_pending' | 'stop_delivered' | 'already_terminal' | 'not_owned';
+ readonly targetSessionId: string;
+ readonly targetTurnId?: string;
};
export const WORKHUB_COORDINATION_OPERATION_SPECS = {
@@ -359,6 +387,9 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi
}
if (proposal.disposition === 'replace') {
const confirmation = decodeWorkHubCoordinationDestructiveConfirmation(input.confirmation);
+ if (confirmation.kind !== 'user_correction') {
+ throw invalidProtocolFrame('Invalid WorkHub replacement confirmation');
+ }
if (proposal.target.disposition === 'delegate_existing') {
if (input.candidateSetId === undefined || input.create !== undefined) {
throw invalidProtocolFrame('Invalid WorkHub replacement context');
@@ -378,6 +409,17 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi
confirmation,
};
}
+ if (proposal.disposition === 'stop_work') {
+ const confirmation = decodeWorkHubCoordinationDestructiveConfirmation(input.confirmation);
+ if (
+ confirmation.kind !== 'user_stop' ||
+ input.candidateSetId !== undefined ||
+ input.create !== undefined
+ ) {
+ throw invalidProtocolFrame('Invalid WorkHub stop context');
+ }
+ return { ...base, confirmation };
+ }
if (
input.candidateSetId !== undefined ||
input.create !== undefined ||
@@ -441,6 +483,39 @@ export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoord
...(exact.steered === true ? { steered: true as const } : {}),
};
}
+ if (result.disposition === 'stop_work') {
+ const exact = requireShapedRecord(
+ result,
+ 'WorkHub Coordination stop result',
+ ['disposition', 'outcome', 'targetSessionId'],
+ ['targetTurnId'],
+ );
+ if (
+ exact.outcome !== 'cancelled_pending' &&
+ exact.outcome !== 'stop_delivered' &&
+ exact.outcome !== 'already_terminal' &&
+ exact.outcome !== 'not_owned'
+ ) {
+ throw invalidProtocolFrame('Invalid WorkHub stop outcome');
+ }
+ if (
+ ((exact.outcome === 'stop_delivered' || exact.outcome === 'not_owned') &&
+ exact.targetTurnId === undefined) ||
+ (exact.outcome === 'cancelled_pending' && exact.targetTurnId !== undefined)
+ ) {
+ throw invalidProtocolFrame('Invalid WorkHub stop target Turn');
+ }
+ return {
+ disposition: 'stop_work',
+ outcome: exact.outcome,
+ targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'),
+ ...(exact.targetTurnId === undefined
+ ? {}
+ : {
+ targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'),
+ }),
+ };
+ }
throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition');
}
@@ -544,9 +619,25 @@ 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']);
+ return {
+ disposition: 'stop_work',
+ expects: decodeWorkHubCoordinationStopPreconditions(exact.expects),
+ };
+ }
throw invalidProtocolFrame('Invalid WorkHub Coordination proposal disposition');
}
+function decodeWorkHubCoordinationStopPreconditions(
+ value: unknown,
+): WorkHubCoordinationStopPreconditions {
+ const expects = requireExactRecord(value, 'WorkHub stop preconditions', ['targetSessionId']);
+ return {
+ targetSessionId: requireEntityId(expects.targetSessionId, 'WorkHub target Session id'),
+ };
+}
+
function decodeWorkHubCoordinationCreateContext(value: unknown): WorkHubCoordinationCreateContext {
const context = requireExactRecord(value, 'WorkHub creation context', ['workspace']);
return {
@@ -558,10 +649,10 @@ function decodeWorkHubCoordinationDestructiveConfirmation(
value: unknown,
): WorkHubCoordinationDestructiveConfirmation {
const confirmation = requireExactRecord(value, 'WorkHub destructive confirmation', ['kind']);
- if (confirmation.kind !== 'user_correction') {
+ if (confirmation.kind !== 'user_correction' && confirmation.kind !== 'user_stop') {
throw invalidProtocolFrame('Invalid WorkHub destructive confirmation');
}
- return { kind: 'user_correction' };
+ return { kind: confirmation.kind };
}
function candidateSetId(value: unknown): string {
diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index 6496697eff..27d3e69c4b 100644
--- a/packages/runtime-host/src/server/execution-composition.ts
+++ b/packages/runtime-host/src/server/execution-composition.ts
@@ -37,6 +37,7 @@ import { AgentGraphSupervisorWakeCoordinator } from '@maka/runtime/agent-graph-s
import {
BackendRegistry,
SessionManager,
+ workHubDirectStopAbortSource,
type BackendFactory,
type BackendPreparationContext,
} from '@maka/runtime/session-manager';
@@ -73,6 +74,7 @@ import {
} from '@maka/runtime/shell-detect';
import { type MakaTool } from '@maka/runtime/tool-runtime';
import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority';
+import { isHostedExecutionTerminal } from './hosted-execution-authority.js';
import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store';
import { createArtifactAttachmentResourceReader } from '@maka/storage/artifact-stores';
import { createReadImageSnapshotStore } from '@maka/storage/read-image-snapshot-store';
@@ -1357,40 +1359,47 @@ export async function createExecutionRuntimeHostComposition(
if (disposition.kind === 'cancelled' || disposition.kind === 'shared_turn') {
return 'retired';
}
- const rootState = coordinator.readRootState(assignment.targetSessionId);
- return rootState.kind === 'active' &&
- rootState.turnId === disposition.turnId &&
- rootState.runId === disposition.runId
- ? 'not_retired'
- : 'retired';
+ const identity = {
+ sessionId: assignment.targetSessionId,
+ turnId: disposition.turnId,
+ runId: disposition.runId,
+ };
+ if (isActiveWorkHubRoot(coordinator, identity)) return 'not_retired';
+ // The same restart window as `stopOwnedWorkHubRoot`: an unregistered
+ // root is not evidence that its work ended.
+ const snapshot = await coordinator.read(identity);
+ return isHostedExecutionTerminal(snapshot) ? 'retired' : 'recovering';
},
- retireDelegation: async (assignment) => {
+ retireDelegation: async (assignment, retirement) => {
const disposition = await messages.cancelMessageIfPending(
assignment.targetSessionId,
assignment.targetMessageId,
+ retirement.cancellationClaimId,
);
if (disposition.kind === 'recovering') {
- throw new WorkHubActionEffectFailure(
- 'operation_unavailable',
- 'WorkHub is still resolving the delegated Message owner',
- );
+ return { outcome: 'recovering' as const };
+ }
+ if (disposition.kind === 'cancelled') {
+ return { outcome: 'already_terminal' as const };
+ }
+ if (disposition.kind === 'cancelled_pending') {
+ return { outcome: 'cancelled_pending' as const };
+ }
+ if (disposition.kind === 'shared_turn') {
+ return { outcome: 'not_owned' as const, targetTurnId: disposition.turnId };
}
- if (disposition.kind === 'shared_turn') return;
if (disposition.kind === 'owned_root') {
- const rootState = coordinator.readRootState(assignment.targetSessionId);
- if (
- rootState.kind !== 'active' ||
- rootState.turnId !== disposition.turnId ||
- rootState.runId !== disposition.runId
- ) {
- return;
- }
- await coordinator.stopRoot({
+ const identity = {
sessionId: assignment.targetSessionId,
turnId: disposition.turnId,
runId: disposition.runId,
- });
+ };
+ return retirement.cause === 'direct_stop'
+ ? stopOwnedWorkHubRoot(coordinator, identity, retirement.cancellationClaimId)
+ : stopReplacedWorkHubRoot(coordinator, identity);
}
+ disposition satisfies never;
+ throw new Error('Unhandled WorkHub Message retirement disposition');
},
assign: async (input) => {
const durable = await stores.sessionStore.readWorkHubAssignment(input.actionId);
@@ -1991,6 +2000,74 @@ export async function createExecutionRuntimeHostComposition(
}
}
+/**
+ * Confirmed direct stop. The action-derived abort source is written onto the
+ * exact root Turn so a retry after a crash can tell WorkHub's own delivery
+ * apart from an earlier or concurrent manual Stop.
+ */
+export async function stopOwnedWorkHubRoot(
+ coordinator: Pick,
+ identity: { readonly sessionId: string; readonly turnId: string; readonly runId: string },
+ actionId: string,
+): Promise<{
+ readonly outcome: 'stop_delivered' | 'already_terminal' | 'recovering';
+ readonly targetTurnId: string;
+}> {
+ if (isActiveWorkHubRoot(coordinator, identity)) {
+ await coordinator.stopRoot(identity, {
+ source: 'workhub_direct_stop',
+ workHubActionId: actionId,
+ });
+ }
+ const terminal = await coordinator.read(identity);
+ if (
+ terminal.status === 'cancelled' &&
+ terminal.abortSource === workHubDirectStopAbortSource(actionId)
+ ) {
+ return { outcome: 'stop_delivered', targetTurnId: identity.turnId };
+ }
+ // Registration is in-memory, so between Host restart and execution recovery
+ // this root looks inactive while it is still running. `already_terminal` is
+ // committed as an immutable fact, so only a durably terminal snapshot may
+ // claim it; anything else is still resolving.
+ return isHostedExecutionTerminal(terminal)
+ ? { outcome: 'already_terminal', targetTurnId: identity.turnId }
+ : { outcome: 'recovering', targetTurnId: identity.turnId };
+}
+
+/**
+ * Route correction retiring the root it is replacing. It carries its own
+ * cancellation claim, but it is not a direct stop: recording direct-stop
+ * provenance here would let replay mistake a correction for one, so the
+ * retirement keeps the neutral Stop source ordinary supersession has always
+ * used.
+ */
+export async function stopReplacedWorkHubRoot(
+ coordinator: Pick,
+ identity: { readonly sessionId: string; readonly turnId: string; readonly runId: string },
+): Promise<{
+ readonly outcome: 'stop_delivered' | 'already_terminal';
+ readonly targetTurnId: string;
+}> {
+ if (!isActiveWorkHubRoot(coordinator, identity)) {
+ return { outcome: 'already_terminal', targetTurnId: identity.turnId };
+ }
+ await coordinator.stopRoot(identity);
+ return { outcome: 'stop_delivered', targetTurnId: identity.turnId };
+}
+
+function isActiveWorkHubRoot(
+ coordinator: Pick,
+ identity: { readonly sessionId: string; readonly turnId: string; readonly runId: string },
+): boolean {
+ const rootState = coordinator.readRootState(identity.sessionId);
+ return (
+ rootState.kind === 'active' &&
+ rootState.turnId === identity.turnId &&
+ rootState.runId === identity.runId
+ );
+}
+
function sessionExecutionConnectionRef(
header: Pick,
): ExecutionConnectionRef {
diff --git a/packages/runtime-host/src/server/hosted-execution-authority.ts b/packages/runtime-host/src/server/hosted-execution-authority.ts
index 152eeace91..41c23e5555 100644
--- a/packages/runtime-host/src/server/hosted-execution-authority.ts
+++ b/packages/runtime-host/src/server/hosted-execution-authority.ts
@@ -17,10 +17,10 @@
* under the License.
*/
-import type { BackendStopMode } from '@maka/core/backend-types';
import type { RootExecutionDescriptor } from '@maka/core/agent-run';
import type { MessageContent, SessionEvent } from '@maka/core/events';
import type { UserMessageInput } from '@maka/core/runtime-inputs';
+import type { StopSessionInput } from '@maka/runtime/session-manager';
import type { TurnSnapshot } from '../protocol/index.js';
export interface HostedExecutionRef {
@@ -80,11 +80,9 @@ export interface HostedExecutionObserver {
begin(input: HostedExecutionObservation): HostedExecutionCompletionObserver | undefined;
}
-export interface HostedExecutionStopInput {
+export type HostedExecutionStopInput = {
readonly execution: HostedExecutionRef;
- readonly source?: 'stop_button' | 'graph_supervisor';
- readonly mode?: BackendStopMode;
-}
+} & StopSessionInput;
export type HostedExecutionListener = (execution: HostedExecutionRef) => void;
@@ -120,7 +118,7 @@ export interface HostedExecutionAuthority {
input: {
readonly sessionId: string;
readonly abortSignal: AbortSignal;
- readonly stopSource?: HostedExecutionStopInput['source'];
+ readonly stopSource?: Exclude;
},
operation: () => Promise,
): Promise;
diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts
index 2f483a7ee1..0d5a6eec87 100644
--- a/packages/runtime-host/src/server/message-coordinator.ts
+++ b/packages/runtime-host/src/server/message-coordinator.ts
@@ -178,14 +178,18 @@ export interface HostMessageStopFence {
deliverStop(): Promise;
}
-export type HostMessageCancellationDisposition =
+type HostMessageResolvedDisposition =
| { readonly kind: 'cancelled' }
| { readonly kind: 'owned_root'; readonly turnId: string; readonly runId: string }
| { readonly kind: 'shared_turn'; readonly turnId: string; readonly runId: string }
| { readonly kind: 'recovering' };
+export type HostMessageCancellationDisposition =
+ | HostMessageResolvedDisposition
+ | { readonly kind: 'cancelled_pending' };
+
export type HostMessageExecutionDisposition =
- | HostMessageCancellationDisposition
+ | HostMessageResolvedDisposition
| { readonly kind: 'pending' };
/** Root execution operations that must share the message coordinator's Session gate. */
@@ -489,13 +493,29 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority {
* Cancels exactly one durable pending Message, or returns the Turn that has
* already consumed it. This is the target Session's ordinary Message
* authority; WorkHub never edits the queue or admission tables directly.
+ *
+ * The claim identity is required: the cancellation tombstone it writes is the
+ * only proof that distinguishes this caller's own cancellation from one that
+ * had already happened, which is what makes a crash between cancelling and
+ * recording the outcome recoverable.
*/
cancelMessageIfPending(
sessionId: string,
messageId: string,
+ cancellationClaimId: string,
): Promise {
return this.#sessionAdmission.run(sessionId, async () => {
const disposition = await this.#resolveMessageExecution(sessionId, messageId);
+ if (disposition.kind === 'cancelled') {
+ const outcome = await this.#admissions.claimMessageAdmissionCancellation(
+ sessionId,
+ messageId,
+ cancellationClaimId,
+ );
+ return outcome === 'same_claim'
+ ? { kind: 'cancelled_pending' as const }
+ : { kind: 'cancelled' as const };
+ }
if (disposition.kind !== 'pending') return disposition;
const state = this.#sessions.get(sessionId);
@@ -513,7 +533,11 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority {
return { kind: 'recovering' };
}
- await this.#admissions.cancelMessageAdmissions(sessionId, [messageId]);
+ const claimOutcome = await this.#admissions.claimMessageAdmissionCancellation(
+ sessionId,
+ messageId,
+ cancellationClaimId,
+ );
if (state && steeringIndex >= 0) {
const [entry] = state.steering.splice(steeringIndex, 1);
if (entry) this.#releaseEntry(entry);
@@ -527,7 +551,9 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority {
} else {
this.#onProjectionChanged(sessionId);
}
- return { kind: 'cancelled' };
+ return claimOutcome === 'already_cancelled'
+ ? { kind: 'cancelled' }
+ : { kind: 'cancelled_pending' };
});
}
diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts
index d246e0c4a6..f7442e572e 100644
--- a/packages/runtime-host/src/server/root-turn-coordinator.ts
+++ b/packages/runtime-host/src/server/root-turn-coordinator.ts
@@ -48,7 +48,12 @@ import {
RuntimeInteractionFailStopError,
RuntimeInteractionInvariantError,
} from '@maka/runtime/interaction-authority';
-import { RuntimeRegenerateTurnError, type SessionManager } from '@maka/runtime/session-manager';
+import {
+ normalizeStopSessionSource,
+ RuntimeRegenerateTurnError,
+ type SessionManager,
+ type StopSessionInput,
+} from '@maka/runtime/session-manager';
import { RuntimeOwnerCleanupError } from '@maka/runtime/runtime-kernel';
import {
parseSkillInvocationTokens,
@@ -565,7 +570,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority {
input: {
readonly sessionId: string;
readonly abortSignal: AbortSignal;
- readonly stopSource?: HostedExecutionStopInput['source'];
+ readonly stopSource?: Exclude;
},
operation: () => Promise,
): Promise {
@@ -893,8 +898,9 @@ export class RootTurnCoordinator implements HostedExecutionAuthority {
async requestStop(input: HostedExecutionStopInput): Promise {
await this.stopRoot(input.execution, {
...(input.source ? { source: input.source } : {}),
+ ...(input.workHubActionId !== undefined ? { workHubActionId: input.workHubActionId } : {}),
...(input.mode ? { mode: input.mode } : {}),
- });
+ } as StopSessionInput);
return await this.read(input.execution);
}
@@ -912,13 +918,8 @@ export class RootTurnCoordinator implements HostedExecutionAuthority {
);
}
- stopRoot(
- identity: RuntimeMessageRunIdentity,
- input: {
- source?: 'stop_button' | 'graph_supervisor';
- mode?: BackendStopMode;
- } = {},
- ): Promise {
+ stopRoot(identity: RuntimeMessageRunIdentity, input: StopSessionInput = {}): Promise {
+ normalizeStopSessionSource(input.source, input.workHubActionId);
return this.runCommand(async () => {
const declared = await this.sessionAdmission.run(identity.sessionId, (lease) =>
this.declareStopFence(
@@ -944,13 +945,8 @@ export class RootTurnCoordinator implements HostedExecutionAuthority {
});
}
- stopSession(
- sessionId: string,
- input: {
- source?: 'stop_button' | 'graph_supervisor';
- mode?: BackendStopMode;
- } = {},
- ): Promise {
+ stopSession(sessionId: string, input: StopSessionInput = {}): Promise {
+ normalizeStopSessionSource(input.source, input.workHubActionId);
return this.runCommand(async () => {
const declared = await this.sessionAdmission.run(sessionId, (lease) => {
const active = this.#executions.get(sessionId);
@@ -2062,10 +2058,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority {
input: Pick,
commitQueueFence: () => QueueFenceResult,
admission: SessionAdmissionLease,
- stopInput: {
- source?: 'stop_button' | 'graph_supervisor';
- mode?: BackendStopMode;
- } = {},
+ stopInput: StopSessionInput = {},
): Promise {
const active = this.#executions.get(input.sessionId);
if (!active || active.turnId !== input.turnId || active.runId !== input.runId) {
@@ -2656,10 +2649,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority {
private async deliverRuntimeStopIntent(
sessionId: string,
- input: {
- source?: 'stop_button' | 'graph_supervisor';
- mode?: BackendStopMode;
- } = { source: 'stop_button' },
+ input: StopSessionInput = { source: 'stop_button' },
): Promise {
await this.manager.deliverHostedRootStop(sessionId, input);
}
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 181f422d3c..bea4056209 100644
--- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts
+++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts
@@ -21,11 +21,17 @@ import { createHash } from 'node:crypto';
import type {
SessionHeader,
SessionStatus,
+ WorkHubActionClaim,
+ WorkHubActionClaimOutcome,
+ WorkHubActionOperation,
WorkHubDelegationAssignedMessage,
WorkHubDelegationCreateSpec,
WorkHubDelegationDisposition,
WorkHubDelegationReplacementAbortedMessage,
WorkHubDelegationReplacementRequestedMessage,
+ WorkHubDelegationStopRequestedMessage,
+ WorkHubDelegationStopResolvedMessage,
+ WorkHubDelegationStopOutcome,
WorkHubDelegationSupersededMessage,
} from '@maka/core/session';
import {
@@ -69,7 +75,31 @@ export type WorkHubActionGateSession = Pick<
export interface WorkHubActionGateEffects {
listSessions(): Promise;
+ /**
+ * Durably binds this action identity to one exact operation before any
+ * effect. Every other WorkHub record is keyed by the delegation or the
+ * assignment it describes, so this is the only owner that can reject an
+ * action id reused across delegations or across dispositions.
+ */
+ claimAction(claim: WorkHubActionClaim): Promise;
+ /**
+ * The operation this action identity already owns, if any.
+ *
+ * A stop names its target Session, not the delegation to end — the Host
+ * resolves that from its own active links. Resolving again on replay would
+ * fail, because a resolved stop takes its delegation out of the active set:
+ * the second attempt would find nothing where the first found one. The claim
+ * is the durable key that survives that, and it outlives removal of the
+ * target Session, so a committed destructive claim still converges.
+ */
+ readActionClaim(actionId: string): Promise;
+ /**
+ * Durable lifetime proof for a delegation target that is no longer readable.
+ * `removed` is a tombstone; `absent` is an identity that never existed here.
+ */
+ probeTargetRemoval(sessionId: string): Promise<'present' | 'removed' | 'absent'>;
readAssignment(actionId: string): Promise;
+ listActiveAssignments(): Promise;
readReplacement(
delegationId: string,
): Promise;
@@ -77,6 +107,10 @@ export interface WorkHubActionGateEffects {
delegationId: string,
): Promise;
readSupersession(delegationId: string): Promise;
+ readStopRequest(delegationId: string): Promise;
+ readStopResolution(
+ delegationId: string,
+ ): Promise;
answer(
input: { readonly turnId: string; readonly text: string },
context: ConnectionContext,
@@ -96,10 +130,34 @@ export interface WorkHubActionGateEffects {
abortReplacement(
input: WorkHubDelegationReplacementAbortInput,
): Promise;
+ prepareStop(input: WorkHubDelegationStopInput): Promise;
+ resolveStop(
+ input: WorkHubDelegationStopResolutionInput,
+ ): Promise;
readDelegationRetirement(
assignment: WorkHubDelegationAssignedMessage,
): Promise<'not_retired' | 'retired' | 'recovering'>;
- retireDelegation(assignment: WorkHubDelegationAssignedMessage): Promise;
+ retireDelegation(
+ assignment: WorkHubDelegationAssignedMessage,
+ retirement: WorkHubDelegationRetirementClaim,
+ ): Promise;
+}
+
+/**
+ * Cancellation claim identity and retirement cause are separate concerns.
+ *
+ * Both a direct stop and a route correction retire a delegation and both need a
+ * crash-safe pending-cancellation claim, but only a confirmed direct stop may
+ * record direct-stop provenance on the target Turn.
+ */
+export interface WorkHubDelegationRetirementClaim {
+ readonly cancellationClaimId: string;
+ readonly cause: 'direct_stop' | 'replacement';
+}
+
+export interface WorkHubRetirementResult {
+ readonly outcome: WorkHubDelegationStopOutcome | 'recovering';
+ readonly targetTurnId?: string;
}
export interface WorkHubDelegationAssignmentInput {
@@ -126,6 +184,23 @@ export interface WorkHubDelegationReplacementAbortInput {
readonly reason: WorkHubDelegationReplacementAbortedMessage['reason'];
}
+export interface WorkHubDelegationStopInput {
+ readonly actionId: string;
+ readonly actionFingerprint: `sha256:${string}`;
+ readonly stopsActionId: string;
+ readonly stopsDelegationId: string;
+ readonly targetSessionId: string;
+ readonly targetMessageId: string;
+ readonly targetSessionName: string;
+ readonly userText: string;
+}
+
+export interface WorkHubDelegationStopResolutionInput {
+ readonly request: WorkHubDelegationStopRequestedMessage;
+ readonly outcome: WorkHubDelegationStopOutcome;
+ readonly targetTurnId?: string;
+}
+
export type WorkHubActionEffectFailureCode =
| 'host_not_ready'
| 'host_draining'
@@ -223,8 +298,9 @@ export class WorkHubCoordinationActionGate {
const action = { requestFingerprint, result };
this.#actions.set(input.actionId, action);
// Successful actions remain a Host-lifetime fast path. Rejections release
- // the slot so a pre-assignment admission can retry; once assigned, SQLite
- // independently owns the durable action identity.
+ // the slot so a pre-assignment admission can retry; the durable action
+ // claim, not this map, is what owns the identity across that retry and
+ // across restarts.
void result.catch(() => {
if (this.#actions.get(input.actionId) === action) {
this.#actions.delete(input.actionId);
@@ -269,11 +345,13 @@ export class WorkHubCoordinationActionGate {
}
if (proposal.disposition === 'answer_here') {
const turnId = coordinationTurnId(input.actionId, 'answer');
+ await this.#claimAction(input.actionId, 'answer_here', fingerprint, turnId);
await this.#effects.answer({ turnId, text: input.userText }, context);
return { disposition: 'answer_here', coordinationTurnId: turnId };
}
if (proposal.disposition === 'clarify') {
const turnId = coordinationTurnId(input.actionId, 'clarify');
+ await this.#claimAction(input.actionId, 'clarify', fingerprint, turnId);
await this.#effects.clarify({
turnId,
userText: input.userText,
@@ -281,6 +359,69 @@ export class WorkHubCoordinationActionGate {
});
return { disposition: 'clarify', coordinationTurnId: turnId };
}
+ if (proposal.disposition === 'stop_work') {
+ if (input.confirmation?.kind !== 'user_stop' || !requestIntent.stop.imperative) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub stop requires an explicit named command in trusted user text',
+ );
+ }
+ const source = await this.#stopSource(input.actionId, proposal.expects.targetSessionId);
+ const stopFingerprint = stopActionFingerprint(input, source);
+ await this.#claimAction(input.actionId, 'stop', stopFingerprint, source.delegationId);
+ const existing = await this.#effects.readStopRequest(source.delegationId);
+ if (existing) {
+ if (existing.actionId !== input.actionId) {
+ // `not_owned` deliberately leaves the delegation active, so the user
+ // can and will try again with a fresh request. That later attempt has
+ // its own identity and must converge on the immutable non-destructive
+ // outcome instead of colliding with the first attempt's stop claim.
+ const resolved = await this.#effects.readStopResolution(source.delegationId);
+ if (resolved?.outcome === 'not_owned') return stopResult(resolved);
+ }
+ assertStopReplay(existing, input, source, stopFingerprint);
+ return this.#stop(existing, source);
+ }
+ const sessions = await this.#effects.listSessions();
+ const sessionNameById = new Map(sessions.map((session) => [session.id, session.name]));
+ // Only this delegation's target has to be visible. A delegation whose
+ // Session the user deleted stays in the active set forever — nothing
+ // retires it — so proving visibility over the whole set would let one
+ // deleted Session block every stop in the system from then on.
+ //
+ // Sole active delegation is not reproved here. `#stopSource` derived this
+ // `source` from the active links a moment ago by that same rule, and the
+ // replay branch above returned before reaching this line, so a second
+ // pass would re-read the transcript to reach the answer it started from.
+ // The proof that decides is the coordinator's, under the admission lease.
+ const currentTargetName = sessionNameById.get(source.targetSessionId);
+ if (!currentTargetName) {
+ throw new WorkHubActionGateFailure('action_conflict', 'WorkHub stop target is unavailable');
+ }
+ if (await this.#effects.readSupersession(source.delegationId)) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub delegation has already been superseded',
+ );
+ }
+ if (await this.#effects.readReplacement(source.delegationId)) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub delegation already has a replacement claim',
+ );
+ }
+ const requested = await this.#effects.prepareStop({
+ actionId: input.actionId,
+ actionFingerprint: stopFingerprint,
+ stopsActionId: source.actionId,
+ stopsDelegationId: source.delegationId,
+ targetSessionId: source.targetSessionId,
+ targetMessageId: source.targetMessageId,
+ targetSessionName: currentTargetName,
+ userText: input.userText,
+ });
+ return this.#stop(requested, source);
+ }
if (proposal.disposition === 'create_new') {
if (!input.create || !workHubCreationAuthorizesTitle(requestIntent, proposal.title)) {
throw new WorkHubActionGateFailure(
@@ -340,9 +481,21 @@ export class WorkHubCoordinationActionGate {
);
}
await this.#assertReplacementReplayTarget(input, prepared.targetSessionId);
+ await this.#claimAction(
+ prepared.actionId,
+ 'replace',
+ prepared.actionFingerprint,
+ prepared.replacesDelegationId,
+ );
return this.#replace(prepared, context);
}
const replacement = await this.#replacementAssignment(input, replaced);
+ await this.#claimAction(
+ replacement.actionId,
+ 'replace',
+ replacement.actionFingerprint,
+ replacement.replacesDelegationId,
+ );
const intent = await this.#effects.prepareReplacement(replacement);
return this.#replace(intent, context);
}
@@ -371,6 +524,157 @@ export class WorkHubCoordinationActionGate {
);
}
+ /**
+ * The delegation a stop names, by the only two keys that can name it.
+ *
+ * A stop carries its target Session and its own action identity; it never
+ * carries the delegation, because a client cannot prove which link is live.
+ *
+ * Replay reads the claim first. A resolved stop takes its delegation out of
+ * the active set, so re-deriving after one succeeded would find nothing and
+ * turn a converging replay into a conflict. The claim records the delegation
+ * this exact action already bound itself to, and it is written before any
+ * effect, so whatever the first attempt reached is reachable again.
+ *
+ * A first attempt has no claim and resolves from the active links: exactly
+ * one delegation on that Session must still hold work a stop could reach.
+ * Zero or several is the same refusal admission has always made, from the
+ * same durable state, rather than a client's guess about either.
+ */
+ async #stopSource(
+ actionId: string,
+ targetSessionId: string,
+ ): Promise {
+ const claim = await this.#effects.readActionClaim(actionId);
+ if (claim?.operation === 'stop') {
+ // The request records which delegation this action bound itself to. It is
+ // written after the claim, so a crash between the two leaves a claim with
+ // nothing to converge on — and nothing destructive happened either, so
+ // that case resolves from the active links below, subject to the claim
+ // still naming what they resolve to.
+ const requested = await this.#effects.readStopRequest(claim.subject);
+ if (requested) {
+ const claimed = await this.#effects.readAssignment(requested.stopsActionId);
+ if (!claimed || claimed.targetSessionId !== targetSessionId) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub stop identity is already bound to a different delegation',
+ );
+ }
+ return claimed;
+ }
+ }
+ const active = await this.#effects.listActiveAssignments();
+ const onTarget = active.filter((assignment) => assignment.targetSessionId === targetSessionId);
+ if (onTarget.length === 0) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub has no active durable delegation to stop on that Session',
+ );
+ }
+ // One link is the answer whatever state its work is in. Whether that work
+ // finished, or was never WorkHub's to stop, is what the stop resolves to —
+ // `already_terminal` and `not_owned` are outcomes, not reasons to refuse
+ // the request before it is recorded.
+ //
+ // Only several links need separating, and then the rule is the same one
+ // competition uses: a delegation whose work already finished is still
+ // linked but is no longer a stop target, so it cannot make a Session that
+ // was delegated to twice permanently unstoppable.
+ let resolved = onTarget[0]!;
+ if (onTarget.length > 1) {
+ const holdingWork: WorkHubDelegationAssignedMessage[] = [];
+ for (const assignment of onTarget) {
+ if ((await this.#effects.readDelegationRetirement(assignment)) !== 'retired') {
+ holdingWork.push(assignment);
+ }
+ }
+ if (holdingWork.length !== 1) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub stop target does not identify one active durable delegation',
+ );
+ }
+ resolved = holdingWork[0]!;
+ }
+ // A claim with no request behind it resolves from the active links like a
+ // first attempt, but only while those links still name the delegation it
+ // bound itself to. If that one left and another took its place, the
+ // fingerprint derived here would no longer match the claim, and since
+ // claims are never deleted the refusal would be permanent and unexplained.
+ // Say why instead: the identity is spent, and the retry needs a new one.
+ if (claim?.operation === 'stop' && resolved.delegationId !== claim.subject) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub stop identity is already bound to a different delegation',
+ );
+ }
+ return resolved;
+ }
+
+ async #stop(
+ request: WorkHubDelegationStopRequestedMessage,
+ source: WorkHubDelegationAssignedMessage,
+ ): Promise {
+ const resolved = await this.#effects.readStopResolution(source.delegationId);
+ if (resolved) return stopResultFromRecord(resolved, request);
+ const retirement = await this.#effects.retireDelegation(source, {
+ cancellationClaimId: request.actionId,
+ cause: 'direct_stop',
+ });
+ const outcome =
+ retirement.outcome === 'recovering'
+ ? await this.#removedTargetOutcome(source)
+ : retirement.outcome;
+ if (!outcome) {
+ throw new WorkHubActionEffectFailure(
+ 'operation_unavailable',
+ 'WorkHub is still resolving the delegated Message owner',
+ );
+ }
+ const targetTurnId = retirement.outcome === outcome ? retirement.targetTurnId : undefined;
+ const resolution = await this.#effects.resolveStop({
+ request,
+ outcome,
+ ...(targetTurnId ? { targetTurnId } : {}),
+ });
+ return stopResultFromRecord(resolution, request);
+ }
+
+ /**
+ * A removed target Session takes its Message-ownership proof with it, so a
+ * committed stop claim would otherwise recover forever. The removal tombstone
+ * outlives that Session and proves the delegated work ended; a target that is
+ * merely unreadable, or an identity that never existed here, stays unresolved
+ * rather than being reported as stopped.
+ */
+ async #removedTargetOutcome(
+ source: WorkHubDelegationAssignedMessage,
+ ): Promise<'already_terminal' | undefined> {
+ const lifetime = await this.#effects.probeTargetRemoval(source.targetSessionId);
+ return lifetime === 'removed' ? 'already_terminal' : undefined;
+ }
+
+ async #claimAction(
+ actionId: string,
+ operation: WorkHubActionOperation,
+ actionFingerprint: `sha256:${string}`,
+ subject: string,
+ ): Promise {
+ const outcome = await this.#effects.claimAction({
+ actionId,
+ operation,
+ actionFingerprint,
+ subject,
+ });
+ if (outcome === 'conflict') {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub action identity already owns a different operation',
+ );
+ }
+ }
+
async #replacementAssignment(
input: WorkHubCoordinationActInput,
replaced: WorkHubDelegationAssignedMessage,
@@ -502,7 +806,18 @@ export class WorkHubCoordinationActionGate {
if (retirement === 'not_retired' && replacement.disposition === 'delegate_existing') {
await this.#replacementTarget(replacement);
}
- if (retirement === 'not_retired') await this.#effects.retireDelegation(source);
+ if (retirement === 'not_retired') {
+ const result = await this.#effects.retireDelegation(source, {
+ cancellationClaimId: replacement.actionId,
+ cause: 'replacement',
+ });
+ if (result.outcome === 'recovering') {
+ throw new WorkHubActionEffectFailure(
+ 'operation_unavailable',
+ 'WorkHub is still resolving the delegated Message owner',
+ );
+ }
+ }
try {
if (replacement.disposition === 'delegate_existing') {
// Retirement can await cancellation or Stop long enough for display
@@ -597,6 +912,12 @@ export class WorkHubCoordinationActionGate {
assignment: WorkHubDelegationAssignmentInput,
context: ConnectionContext,
): Promise {
+ await this.#claimAction(
+ assignment.actionId,
+ assignment.replacesDelegationId ? 'replace' : assignment.disposition,
+ assignment.actionFingerprint,
+ assignment.replacesDelegationId ?? assignment.targetSessionId,
+ );
const admitted = await this.#effects.assign(assignment, context);
if (assignment.replacesDelegationId) {
return {
@@ -798,6 +1119,74 @@ function replacementActionFingerprint(
});
}
+function stopActionFingerprint(
+ input: WorkHubCoordinationActInput,
+ source: WorkHubDelegationAssignedMessage,
+): `sha256:${string}` {
+ if (input.proposal.disposition !== 'stop_work') {
+ throw new WorkHubActionGateFailure('action_conflict', 'Invalid WorkHub stop replay');
+ }
+ return digest({
+ userText: input.userText,
+ disposition: 'stop_work',
+ stopsActionId: source.actionId,
+ stopsDelegationId: source.delegationId,
+ targetSessionId: source.targetSessionId,
+ targetMessageId: source.targetMessageId,
+ });
+}
+
+function assertStopReplay(
+ request: WorkHubDelegationStopRequestedMessage,
+ input: WorkHubCoordinationActInput,
+ source: WorkHubDelegationAssignedMessage,
+ fingerprint: `sha256:${string}`,
+): void {
+ if (
+ request.actionId !== input.actionId ||
+ request.actionFingerprint !== fingerprint ||
+ request.stopsActionId !== source.actionId ||
+ request.stopsDelegationId !== source.delegationId ||
+ request.targetSessionId !== source.targetSessionId ||
+ request.targetMessageId !== source.targetMessageId
+ ) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub delegation already has a different stop claim',
+ );
+ }
+}
+
+function stopResult(
+ resolution: WorkHubDelegationStopResolvedMessage,
+): WorkHubCoordinationActResult {
+ return {
+ disposition: 'stop_work',
+ outcome: resolution.outcome,
+ targetSessionId: resolution.targetSessionId,
+ ...(resolution.targetTurnId ? { targetTurnId: resolution.targetTurnId } : {}),
+ };
+}
+
+function stopResultFromRecord(
+ resolution: WorkHubDelegationStopResolvedMessage,
+ request: WorkHubDelegationStopRequestedMessage,
+): WorkHubCoordinationActResult {
+ if (
+ resolution.actionId !== request.actionId ||
+ resolution.actionFingerprint !== request.actionFingerprint ||
+ resolution.stopsActionId !== request.stopsActionId ||
+ resolution.stopsDelegationId !== request.stopsDelegationId ||
+ resolution.targetSessionId !== request.targetSessionId
+ ) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub stop has a different durable resolution',
+ );
+ }
+ return stopResult(resolution);
+}
+
function assignmentInputFromRecord(
assignment: WorkHubDelegationAssignedMessage,
): WorkHubDelegationAssignmentInput {
diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts
index 8e66215953..80eaf04b2a 100644
--- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts
+++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts
@@ -27,12 +27,16 @@ import {
WORKHUB_COORDINATION_SESSION_ID,
WORKHUB_COORDINATION_SESSION_ROLE,
WORKHUB_COORDINATION_REPLACEMENT_SCHEMA_VERSION,
+ WORKHUB_COORDINATION_STOP_SCHEMA_VERSION,
isWorkHubCoordinationSession,
isWorkHubCoordinationSessionId,
type SessionHeader,
type StoredMessage,
+ type WorkHubDelegationAssignedMessage,
type WorkHubDelegationReplacementAbortedMessage,
type WorkHubDelegationReplacementRequestedMessage,
+ type WorkHubDelegationStopRequestedMessage,
+ type WorkHubDelegationStopResolvedMessage,
} from '@maka/core/session';
import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store';
import type {
@@ -84,12 +88,18 @@ type CoordinationStores = Pick<
| 'appendMessages'
| 'createStableSession'
| 'listHeaders'
+ | 'claimWorkHubAction'
+ | 'readWorkHubActionClaim'
+ | 'probeSessionRemoval'
| 'probeStableSessionCreate'
| 'readHeaderSnapshot'
+ | 'readMessagesSnapshot'
| 'readWorkHubAssignment'
| 'readWorkHubReplacement'
| 'readWorkHubReplacementAbort'
| 'readWorkHubSupersession'
+ | 'readWorkHubStopRequest'
+ | 'readWorkHubStopResolution'
| 'readTranscriptHighWaterSnapshot'
| 'readTranscriptMessagesSnapshot'
| 'updateHeaderVersioned'
@@ -134,10 +144,12 @@ export class HostWorkHubCoordinationCoordinator {
readonly #resolveCreateTarget: () => Promise;
readonly #requestDrain: () => void;
readonly #actionGate: WorkHubCoordinationActionGate;
+ readonly #readDelegationRetirement: HostWorkHubCoordinationCoordinatorOptions['sessionActions']['readDelegationRetirement'];
constructor(options: HostWorkHubCoordinationCoordinatorOptions) {
this.#coordinationCwd = join(options.stateRoot, COORDINATION_CWD_DIRECTORY);
this.#stores = options.stores;
+ this.#readDelegationRetirement = options.sessionActions.readDelegationRetirement;
this.#admission = options.admission;
this.#continuity = options.continuity;
this.#executions = options.executions;
@@ -145,11 +157,26 @@ export class HostWorkHubCoordinationCoordinator {
this.#requestDrain = options.requestDrain;
this.#actionGate = new WorkHubCoordinationActionGate({
listSessions: () => this.#stores.listHeaders(),
+ // The global action owner is committed under the same Coordination
+ // admission that serializes every durable Coordination fact, so a
+ // concurrent action cannot slip between the claim and the fact it owns.
+ claimAction: (claim) =>
+ this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, () =>
+ this.#stores.claimWorkHubAction(claim),
+ ),
+ // Read without the admission lease: it is a durable point lookup by
+ // primary key, and the claim it finds was committed under that lease.
+ readActionClaim: (actionId) => this.#stores.readWorkHubActionClaim(actionId),
+ probeTargetRemoval: async (sessionId) =>
+ (await this.#stores.probeSessionRemoval(sessionId)).kind,
readAssignment: (actionId) => this.#stores.readWorkHubAssignment(actionId),
+ listActiveAssignments: () => this.#listActiveAssignments(),
readReplacement: (delegationId) => this.#stores.readWorkHubReplacement(delegationId),
readReplacementAbort: (delegationId) =>
this.#stores.readWorkHubReplacementAbort(delegationId),
readSupersession: (delegationId) => this.#stores.readWorkHubSupersession(delegationId),
+ readStopRequest: (delegationId) => this.#stores.readWorkHubStopRequest(delegationId),
+ readStopResolution: (delegationId) => this.#stores.readWorkHubStopResolution(delegationId),
answer: async (input, context) => {
const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context);
if (!outcome.ok) {
@@ -169,6 +196,8 @@ export class HostWorkHubCoordinationCoordinator {
assign: options.sessionActions.assign,
prepareReplacement: (input) => this.#prepareReplacement(input),
abortReplacement: (input) => this.#abortReplacement(input),
+ prepareStop: (input) => this.#prepareStop(input),
+ resolveStop: (input) => this.#resolveStop(input),
readDelegationRetirement: options.sessionActions.readDelegationRetirement,
retireDelegation: options.sessionActions.retireDelegation,
});
@@ -177,8 +206,8 @@ export class HostWorkHubCoordinationCoordinator {
#prepareReplacement(
input: Parameters[0],
): Promise {
- const suffix = workHubReplacementIdentitySuffix(input.replacesDelegationId);
- return this.#commitReplacementFact({
+ const suffix = workHubDestructiveClaimIdentitySuffix(input.replacesDelegationId);
+ return this.#commitCoordinationFact({
read: () => this.#stores.readWorkHubReplacement(input.replacesDelegationId),
build: (existing) => ({
type: 'workhub_coordination',
@@ -202,6 +231,18 @@ export class HostWorkHubCoordinationCoordinator {
}),
conflictMessage: 'WorkHub action identity belongs to a different replacement',
beforeAppend: async () => {
+ const stopRequest = await this.#stores.readWorkHubStopRequest(input.replacesDelegationId);
+ if (stopRequest) {
+ const resolution = await this.#stores.readWorkHubStopResolution(
+ input.replacesDelegationId,
+ );
+ if (resolution?.outcome !== 'not_owned') {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub delegation already has a stop claim',
+ );
+ }
+ }
const header = await this.#stores.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID);
if (!validCoordinationHeader(header)) {
throw new WorkHubActionEffectFailure(
@@ -214,12 +255,132 @@ export class HostWorkHubCoordinationCoordinator {
});
}
+ async #prepareStop(
+ input: Parameters[0],
+ ): Promise {
+ const suffix = workHubDestructiveClaimIdentitySuffix(input.stopsDelegationId);
+ return this.#commitCoordinationFact({
+ // Only the two Sessions this stop can change: the one whose delegation
+ // ends, and the Coordination Session that records it. Holding a lane for
+ // every Session with an active delegation would serialize unrelated
+ // delegation traffic behind one stop, and the proof below needs no lane
+ // it does not already hold.
+ admissionSessionIds: [WORKHUB_COORDINATION_SESSION_ID, input.targetSessionId],
+ read: () => this.#stores.readWorkHubStopRequest(input.stopsDelegationId),
+ build: (existing) => ({
+ type: 'workhub_coordination',
+ id: `whq_${suffix}`,
+ turnId: input.actionId,
+ ts: existing?.ts ?? Date.now(),
+ schemaVersion: WORKHUB_COORDINATION_STOP_SCHEMA_VERSION,
+ kind: 'delegation_stop_requested',
+ actionId: input.actionId,
+ actionFingerprint: input.actionFingerprint,
+ coordinationTurnId: input.actionId,
+ stopsActionId: input.stopsActionId,
+ stopsDelegationId: input.stopsDelegationId,
+ targetSessionId: input.targetSessionId,
+ targetMessageId: input.targetMessageId,
+ targetSessionName: input.targetSessionName,
+ userText: input.userText,
+ }),
+ conflictMessage: 'WorkHub delegation already has a different stop claim',
+ beforeAppend: async () => {
+ const [replacement, supersession, messages] = await Promise.all([
+ this.#stores.readWorkHubReplacement(input.stopsDelegationId),
+ this.#stores.readWorkHubSupersession(input.stopsDelegationId),
+ this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID),
+ ]);
+ if (replacement || supersession) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub delegation is already being replaced',
+ );
+ }
+ const activeAssignments = activeWorkHubAssignments(messages);
+ // Held lanes make this the last moment the one-target proof can change.
+ // It is proved from opaque delegation identity, so a concurrent rename
+ // is harmless while a concurrent delegation to the same Session is not.
+ const targetActive = activeAssignments.filter(
+ (assignment) => assignment.targetSessionId === input.targetSessionId,
+ );
+ const source = targetActive.find(
+ (assignment) =>
+ assignment.actionId === input.stopsActionId &&
+ assignment.delegationId === input.stopsDelegationId,
+ );
+ if (!source) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub stop target does not identify one active durable delegation',
+ );
+ }
+ // A delegation whose work already finished stays linked but competes
+ // for nothing; only work that could still be stopped makes the target
+ // ambiguous.
+ for (const competitor of targetActive) {
+ if (competitor.delegationId === source.delegationId) continue;
+ if ((await this.#readDelegationRetirement(competitor)) !== 'retired') {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub stop target does not identify one active durable delegation',
+ );
+ }
+ }
+ },
+ unknownOutcomeMessage: 'WorkHub stop request outcome is unknown',
+ });
+ }
+
+ async #listActiveAssignments(): Promise {
+ return activeWorkHubAssignments(
+ await this.#stores.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID),
+ );
+ }
+
+ #resolveStop(
+ input: Parameters[0],
+ ): Promise {
+ const request = input.request;
+ const suffix = workHubDestructiveClaimIdentitySuffix(request.stopsDelegationId);
+ return this.#commitCoordinationFact({
+ read: () => this.#stores.readWorkHubStopResolution(request.stopsDelegationId),
+ build: (existing) => ({
+ type: 'workhub_coordination',
+ id: `whz_${suffix}`,
+ turnId: request.actionId,
+ ts: existing?.ts ?? Date.now(),
+ schemaVersion: WORKHUB_COORDINATION_STOP_SCHEMA_VERSION,
+ kind: 'delegation_stop_resolved',
+ actionId: request.actionId,
+ actionFingerprint: request.actionFingerprint,
+ coordinationTurnId: request.coordinationTurnId,
+ stopsActionId: request.stopsActionId,
+ stopsDelegationId: request.stopsDelegationId,
+ targetSessionId: request.targetSessionId,
+ outcome: input.outcome,
+ ...(input.targetTurnId ? { targetTurnId: input.targetTurnId } : {}),
+ }),
+ conflictMessage: 'WorkHub stop already has a different resolution',
+ beforeAppend: async () => {
+ const durable = await this.#stores.readWorkHubStopRequest(request.stopsDelegationId);
+ if (!durable || !isDeepStrictEqual(durable, request)) {
+ throw new WorkHubActionGateFailure(
+ 'action_conflict',
+ 'WorkHub stop request identity changed',
+ );
+ }
+ },
+ unknownOutcomeMessage: 'WorkHub stop resolution outcome is unknown',
+ });
+ }
+
#abortReplacement(
input: Parameters[0],
): Promise {
const replacement = input.replacement;
- const suffix = workHubReplacementIdentitySuffix(replacement.replacesDelegationId);
- return this.#commitReplacementFact({
+ const suffix = workHubDestructiveClaimIdentitySuffix(replacement.replacesDelegationId);
+ return this.#commitCoordinationFact({
read: () => this.#stores.readWorkHubReplacementAbort(replacement.replacesDelegationId),
build: (existing) => ({
type: 'workhub_coordination',
@@ -252,37 +413,41 @@ export class HostWorkHubCoordinationCoordinator {
});
}
- #commitReplacementFact(options: {
+ #commitCoordinationFact(options: {
+ readonly admissionSessionIds?: readonly string[];
readonly read: () => Promise;
readonly build: (existing: T | undefined) => T;
readonly conflictMessage: string;
readonly beforeAppend: () => Promise;
readonly unknownOutcomeMessage: string;
}): Promise {
- return this.#admission.run(WORKHUB_COORDINATION_SESSION_ID, async (lease) => {
- const existing = await options.read();
- const requested = options.build(existing);
- if (existing) {
- if (!isDeepStrictEqual(existing, requested)) {
- throw new WorkHubActionGateFailure('action_conflict', options.conflictMessage);
+ return this.#admission.runMany(
+ options.admissionSessionIds ?? [WORKHUB_COORDINATION_SESSION_ID],
+ async (lease) => {
+ const existing = await options.read();
+ const requested = options.build(existing);
+ if (existing) {
+ if (!isDeepStrictEqual(existing, requested)) {
+ throw new WorkHubActionGateFailure('action_conflict', options.conflictMessage);
+ }
+ return existing;
}
- return existing;
- }
- await options.beforeAppend();
- try {
- await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [requested]);
- await this.#continuity.refreshCanonical(WORKHUB_COORDINATION_SESSION_ID, lease);
- return requested;
- } catch {
- const replay = await options.read().catch(() => undefined);
- if (replay && isDeepStrictEqual(replay, requested)) return replay;
- this.#requestDrain();
- throw new WorkHubActionEffectFailure(
- 'commit_outcome_unknown',
- options.unknownOutcomeMessage,
- );
- }
- });
+ await options.beforeAppend();
+ try {
+ await this.#stores.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [requested]);
+ await this.#continuity.refreshCanonical(WORKHUB_COORDINATION_SESSION_ID, lease);
+ return requested;
+ } catch {
+ const replay = await options.read().catch(() => undefined);
+ if (replay && isDeepStrictEqual(replay, requested)) return replay;
+ this.#requestDrain();
+ throw new WorkHubActionEffectFailure(
+ 'commit_outcome_unknown',
+ options.unknownOutcomeMessage,
+ );
+ }
+ },
+ );
}
async #candidates(): Promise> {
@@ -627,7 +792,27 @@ function digest(value: unknown): `sha256:${string}` {
return `sha256:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`;
}
-function workHubReplacementIdentitySuffix(delegationId: string): string {
+function activeWorkHubAssignments(
+ messages: readonly StoredMessage[],
+): WorkHubDelegationAssignedMessage[] {
+ const terminalDelegationIds = new Set();
+ const assignments: WorkHubDelegationAssignedMessage[] = [];
+ for (const message of messages) {
+ if (message.type !== 'workhub_coordination') continue;
+ if (message.kind === 'delegation_assigned') {
+ assignments.push(message);
+ } else if (message.kind === 'delegation_superseded') {
+ terminalDelegationIds.add(message.supersededDelegationId);
+ } else if (message.kind === 'delegation_replacement_aborted') {
+ terminalDelegationIds.add(message.abortedDelegationId);
+ } else if (message.kind === 'delegation_stop_resolved' && message.outcome !== 'not_owned') {
+ terminalDelegationIds.add(message.stopsDelegationId);
+ }
+ }
+ return assignments.filter(({ delegationId }) => !terminalDelegationIds.has(delegationId));
+}
+
+function workHubDestructiveClaimIdentitySuffix(delegationId: string): string {
return createHash('sha256').update(delegationId, 'utf8').digest('hex').slice(0, 48);
}
diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts
index 156eae2614..f1c59932fb 100644
--- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts
+++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts
@@ -40,6 +40,7 @@ import { AgentRun } from '../agent-run.js';
import {
BackendRegistry,
SessionManager,
+ workHubDirectStopAbortSource,
type BackendFactoryContext,
type SessionStore,
} from '../session-manager.js';
@@ -282,6 +283,41 @@ describe('SessionManager terminal ledger invariants', () => {
assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.abortSource, 'renderer.stop_button');
});
+ test('stopSession persists a WorkHub action-bound abort source', async () => {
+ const store = new TinySessionStore();
+ const runStore = new TinyAgentRunStore();
+ const backends = new BackendRegistry();
+ backends.register('ai-sdk', (ctx) => new NeverEndingBackend(ctx));
+ const manager = new SessionManager({
+ store,
+ runStore,
+ runtimeEventStore: runStore,
+ backends,
+ newId: nextId(),
+ now: nextNow(20_500),
+ });
+ const session = await manager.createSession(makeInput());
+ const iterator = manager
+ .sendMessage(session.id, { turnId: 'turn-workhub-stop', text: 'hello' })
+ [Symbol.asyncIterator]();
+ assert.strictEqual((await iterator.next()).value?.type, 'text_delta');
+
+ await manager.stopSession(session.id, {
+ source: 'workhub_direct_stop',
+ workHubActionId: 'workhub-stop-action',
+ });
+
+ const expected = workHubDirectStopAbortSource('workhub-stop-action');
+ const [run] = await runStore.listSessionRuns(session.id);
+ if (!run) throw new Error('run was not recorded');
+ assert.strictEqual(run.status, 'cancelled');
+ assert.strictEqual(run.abortSource, expected);
+ const [terminal] = (await runStore.readRuntimeEvents(session.id, run.runId)).filter(
+ isTerminalRuntimeEvent,
+ );
+ assert.strictEqual(terminal?.actions?.stateDelta?.abortSource, expected);
+ });
+
test('stopSession commits a terminal fact when the backend stream never ends', async () => {
const store = new TinySessionStore();
const runStore = new TinyAgentRunStore();
diff --git a/packages/runtime/src/__tests__/session-projection-helpers.test.ts b/packages/runtime/src/__tests__/session-projection-helpers.test.ts
index 80790ee3ad..bacf2c9971 100644
--- a/packages/runtime/src/__tests__/session-projection-helpers.test.ts
+++ b/packages/runtime/src/__tests__/session-projection-helpers.test.ts
@@ -24,12 +24,33 @@ import {
buildStatusPatch,
buildTurnStateMessage,
isTerminalRunStatus,
+ normalizeStopSessionSource,
statusFromEvent,
turnStatusFromEvent,
turnHasRetainedOutput,
+ workHubDirectStopAbortSource,
} from '../session-projection-helpers.js';
describe('session projection helpers', () => {
+ test('binds WorkHub Stop provenance to one valid action identity', () => {
+ assert.equal(
+ normalizeStopSessionSource('workhub_direct_stop', 'stop-action'),
+ workHubDirectStopAbortSource('stop-action'),
+ );
+ assert.notEqual(
+ workHubDirectStopAbortSource('stop-action'),
+ workHubDirectStopAbortSource('different-action'),
+ );
+ assert.throws(
+ () => normalizeStopSessionSource('workhub_direct_stop'),
+ /Invalid WorkHub direct-stop action identity/,
+ );
+ assert.throws(
+ () => normalizeStopSessionSource('stop_button', 'stop-action'),
+ /requires its dedicated Stop source/,
+ );
+ });
+
test('buildStatusPatch normalizes blocked reasons and clears non-blocked reasons', () => {
assert.deepStrictEqual(buildStatusPatch('blocked', 100), {
status: 'blocked',
diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts
index cff8aaed16..a507862d0e 100644
--- a/packages/runtime/src/agent-run.ts
+++ b/packages/runtime/src/agent-run.ts
@@ -317,11 +317,15 @@ export class AgentRun {
};
}
- stop(source: StopSessionInput['source'] | undefined): boolean {
+ stop(
+ source: StopSessionInput['source'] | undefined,
+ workHubActionId?: StopSessionInput['workHubActionId'],
+ ): boolean {
+ const abortSource = normalizeStopSessionSource(source, workHubActionId);
if (this.terminalClaim) return false;
this.terminalClaim = { owner: 'stop' };
this.stopped = true;
- this.abortSource = normalizeStopSessionSource(source);
+ this.abortSource = abortSource;
return true;
}
diff --git a/packages/runtime/src/message-authority.ts b/packages/runtime/src/message-authority.ts
index 09cc562786..55498687f8 100644
--- a/packages/runtime/src/message-authority.ts
+++ b/packages/runtime/src/message-authority.ts
@@ -17,9 +17,10 @@
* under the License.
*/
-import type { BackendStopMode, SteeringLease } from '@maka/core/backend-types';
+import type { SteeringLease } from '@maka/core/backend-types';
import type { RootExecutionDescriptor } from '@maka/core/agent-run';
import type { MessageContent, SessionEvent } from '@maka/core/events';
+import type { StopSessionInput } from './session-manager.js';
export interface RuntimeMessageRunIdentity {
readonly sessionId: string;
@@ -62,20 +63,8 @@ export interface RuntimeHostedRootExecutionInput extends RuntimeMessageRunIdenti
/** Host-only root lifecycle capability. Embedded compositions must omit it. */
export interface RuntimeHostedRootAuthority extends RuntimeMessageAuthority {
executeRoot(input: RuntimeHostedRootExecutionInput): Promise;
- stopRoot(
- identity: RuntimeMessageRunIdentity,
- input?: {
- source?: 'stop_button' | 'graph_supervisor';
- mode?: BackendStopMode;
- },
- ): Promise;
- stopSession(
- sessionId: string,
- input?: {
- source?: 'stop_button' | 'graph_supervisor';
- mode?: BackendStopMode;
- },
- ): Promise;
+ stopRoot(identity: RuntimeMessageRunIdentity, input?: StopSessionInput): Promise;
+ stopSession(sessionId: string, input?: StopSessionInput): Promise;
}
export function isRuntimeHostedRootAuthority(
diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts
index 1da029c273..f9974c0a57 100644
--- a/packages/runtime/src/runtime-kernel.ts
+++ b/packages/runtime/src/runtime-kernel.ts
@@ -532,7 +532,9 @@ export class RuntimeKernel implements RuntimeKernelLike {
}
execution.run = run;
execution.phase = 'attached';
- if (execution.stopIntent) run.stop(execution.stopIntent.input.source);
+ if (execution.stopIntent) {
+ run.stop(execution.stopIntent.input.source, execution.stopIntent.input.workHubActionId);
+ }
}
private reserveExecutionClaim(
@@ -1712,6 +1714,7 @@ export class RuntimeKernel implements RuntimeKernelLike {
}
stopSession(sessionId: string, input: StopSessionInput = {}): Promise {
+ normalizeStopSessionSource(input.source, input.workHubActionId);
const existing = this.stopAttempts.get(sessionId);
if (existing) return existing;
const intent: SessionStopIntent = { input, claims: new Set() };
@@ -1721,7 +1724,9 @@ export class RuntimeKernel implements RuntimeKernelLike {
execution.stopIntent = intent;
intent.claims.add(execution);
}
- for (const execution of executions) execution.run?.stop(input.source);
+ for (const execution of executions) {
+ execution.run?.stop(input.source, input.workHubActionId);
+ }
for (const execution of executions) {
execution.abortController.abort(execution.cancellation);
}
@@ -1777,7 +1782,7 @@ export class RuntimeKernel implements RuntimeKernelLike {
active: BackendGeneration,
run: AgentRun,
): StopOperation | undefined {
- run.stop(input.source);
+ run.stop(input.source, input.workHubActionId);
if (!run.hasPendingStop()) return this.stopOperations.get(sessionId);
const existingOperation = this.stopOperations.get(sessionId);
const operation = existingOperation ?? this.buildStopOperation(input);
@@ -1820,7 +1825,7 @@ export class RuntimeKernel implements RuntimeKernelLike {
}
private buildStopOperation(input: StopSessionInput): StopOperation {
- const abortSource = normalizeStopSessionSource(input.source);
+ const abortSource = normalizeStopSessionSource(input.source, input.workHubActionId);
const ts = this.deps.now();
return {
abortSource,
diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts
index fe98e096f7..e602dba5c4 100644
--- a/packages/runtime/src/session-manager.ts
+++ b/packages/runtime/src/session-manager.ts
@@ -248,10 +248,22 @@ function runtimeCommitSinkFromEventStore(
: undefined;
}
-export interface StopSessionInput {
- source?: 'stop_button' | 'graph_supervisor';
- mode?: BackendStopMode;
-}
+export type StopSessionInput =
+ | {
+ source?: 'stop_button' | 'graph_supervisor';
+ workHubActionId?: never;
+ mode?: BackendStopMode;
+ }
+ | {
+ source: 'workhub_direct_stop';
+ workHubActionId: string;
+ mode?: BackendStopMode;
+ };
+
+export {
+ normalizeStopSessionSource,
+ workHubDirectStopAbortSource,
+} from './session-projection-helpers.js';
export type CompactSessionInput =
| {
diff --git a/packages/runtime/src/session-projection-helpers.ts b/packages/runtime/src/session-projection-helpers.ts
index 468005fc24..b57598339e 100644
--- a/packages/runtime/src/session-projection-helpers.ts
+++ b/packages/runtime/src/session-projection-helpers.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { createHash } from 'node:crypto';
import type { AgentRunHeader } from '@maka/core/agent-run';
import { failureClassFromCompleteStopReason, type SessionEvent } from '@maka/core/events';
import type {
@@ -95,18 +96,32 @@ export function turnHasRetainedOutput(messages: readonly StoredMessage[], turnId
}
export function normalizeStopSessionSource(
- source: 'stop_button' | 'graph_supervisor' | undefined,
+ source: 'stop_button' | 'graph_supervisor' | 'workhub_direct_stop' | undefined,
+ workHubActionId?: string,
): string | undefined {
+ if (source !== 'workhub_direct_stop' && workHubActionId !== undefined) {
+ throw new Error('WorkHub direct-stop identity requires its dedicated Stop source');
+ }
switch (source) {
case 'stop_button':
return 'renderer.stop_button';
case 'graph_supervisor':
return 'graph.supervisor';
+ case 'workhub_direct_stop':
+ return workHubDirectStopAbortSource(workHubActionId);
case undefined:
return undefined;
}
}
+export function workHubDirectStopAbortSource(actionId: string | undefined): string {
+ if (!actionId || !/^[A-Za-z0-9_-]{1,128}$/u.test(actionId)) {
+ throw new Error('Invalid WorkHub direct-stop action identity');
+ }
+ const suffix = createHash('sha256').update(actionId, 'utf8').digest('hex').slice(0, 48);
+ return `workhub.direct_stop.${suffix}`;
+}
+
export function isTerminalRunStatus(status: AgentRunHeader['status']): boolean {
return status === 'completed' || status === 'failed' || status === 'cancelled';
}
diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts
index 8aea6a22be..906c5d6ace 100644
--- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts
+++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts
@@ -501,6 +501,65 @@ describe('SqliteSessionMetadataStore', () => {
}
});
+ test('migrates a v36 cancellation tombstone without inventing a claim owner', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-message-cancellation-v36-'));
+ const path = join(root, 'state.sqlite');
+ try {
+ const setup = createSqliteSessionMetadataStore(path);
+ try {
+ await setup.create(fullHeader({ id: 'session-v36-cancellation' }));
+ const content = { text: 'cancelled before claim provenance existed' };
+ await setup.commitMessageAdmission({
+ sessionId: 'session-v36-cancellation',
+ turnId: 'turn-1',
+ runId: 'run-1',
+ messageId: 'message-1',
+ content,
+ submittedContentDigest: messageContentDigest(content),
+ submittedPlacement: 'next_turn',
+ placement: 'next_turn',
+ disposition: 'followup',
+ skillInvocation: { loaded: [], failed: [], receipts: [] },
+ admittedAt: 10,
+ });
+ await setup.cancelMessageAdmissions('session-v36-cancellation', ['message-1']);
+ } finally {
+ setup.close();
+ }
+
+ const legacy = new DatabaseSync(path);
+ try {
+ legacy.exec(`
+ ALTER TABLE cancelled_message_admissions DROP COLUMN cancellation_claim_id;
+ UPDATE session_metadata_schema SET version = 36 WHERE scope = 'session_metadata';
+ `);
+ } finally {
+ legacy.close();
+ }
+
+ const migrated = createSqliteSessionMetadataStore(path);
+ try {
+ assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION);
+ assert.equal(
+ await migrated.hasCancelledMessageAdmission('session-v36-cancellation', 'message-1'),
+ true,
+ );
+ assert.equal(
+ await migrated.claimMessageAdmissionCancellation(
+ 'session-v36-cancellation',
+ 'message-1',
+ 'later-workhub-claim',
+ ),
+ 'already_cancelled',
+ );
+ } finally {
+ migrated.close();
+ }
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
test('materializes a proven Root message when its admission is absent', async () => {
const store = createSqliteSessionMetadataStore(':memory:');
try {
@@ -1445,6 +1504,107 @@ describe('SqliteSessionMetadataStore', () => {
}
});
+ test('a WorkHub action identity owns one operation across store restarts', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-workhub-action-claim-'));
+ const path = join(root, 'state.sqlite');
+ const stopClaim = {
+ actionId: 'stop-action',
+ operation: 'stop' as const,
+ actionFingerprint: `sha256:${'a'.repeat(64)}` as const,
+ subject: 'whd_payments',
+ };
+ let store = createSqliteSessionMetadataStore(path);
+ try {
+ assert.equal(await store.claimWorkHubAction(stopClaim), 'claimed');
+ assert.equal(await store.claimWorkHubAction(stopClaim), 'same_claim');
+ } finally {
+ store.close();
+ }
+
+ store = createSqliteSessionMetadataStore(path);
+ try {
+ assert.deepEqual(await store.readWorkHubActionClaim('stop-action'), stopClaim);
+ assert.equal(await store.claimWorkHubAction(stopClaim), 'same_claim');
+ // A second delegation, a second disposition, and a changed payload are
+ // each a different operation for the same identity.
+ assert.equal(
+ await store.claimWorkHubAction({ ...stopClaim, subject: 'whd_login' }),
+ 'conflict',
+ );
+ assert.equal(
+ await store.claimWorkHubAction({ ...stopClaim, operation: 'delegate_existing' }),
+ 'conflict',
+ );
+ assert.equal(
+ await store.claimWorkHubAction({
+ ...stopClaim,
+ actionFingerprint: `sha256:${'b'.repeat(64)}`,
+ }),
+ 'conflict',
+ );
+ assert.equal(await store.readWorkHubActionClaim('unclaimed-action'), undefined);
+ } finally {
+ store.close();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
+ test('cancellation tombstones retain the durable claim that created them', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-message-cancellation-claim-'));
+ const path = join(root, 'state.sqlite');
+ let store = createSqliteSessionMetadataStore(path);
+ try {
+ await store.create(fullHeader({ id: 'session-claim' }));
+ const content = { text: 'cancel this pending work' };
+ await store.commitMessageAdmission({
+ sessionId: 'session-claim',
+ turnId: 'turn-claim',
+ runId: 'run-claim',
+ messageId: 'message-claim',
+ content,
+ submittedContentDigest: messageContentDigest(content),
+ submittedPlacement: 'next_turn',
+ placement: 'next_turn',
+ disposition: 'followup',
+ skillInvocation: { loaded: [], failed: [], receipts: [] },
+ admittedAt: 10,
+ });
+ assert.equal(
+ await store.claimMessageAdmissionCancellation(
+ 'session-claim',
+ 'message-claim',
+ 'stop-claim',
+ ),
+ 'cancelled_by_claim',
+ );
+ } finally {
+ store.close();
+ }
+
+ store = createSqliteSessionMetadataStore(path);
+ try {
+ assert.equal(
+ await store.claimMessageAdmissionCancellation(
+ 'session-claim',
+ 'message-claim',
+ 'stop-claim',
+ ),
+ 'same_claim',
+ );
+ assert.equal(
+ await store.claimMessageAdmissionCancellation(
+ 'session-claim',
+ 'message-claim',
+ 'other-claim',
+ ),
+ 'already_cancelled',
+ );
+ } finally {
+ store.close();
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+
test('materializes an accepted follow-up under its successor root', async () => {
const store = createSqliteSessionMetadataStore(':memory:');
try {
diff --git a/packages/storage/src/__tests__/workhub-message-assignment.test.ts b/packages/storage/src/__tests__/workhub-message-assignment.test.ts
index 27828855ab..983cfacefc 100644
--- a/packages/storage/src/__tests__/workhub-message-assignment.test.ts
+++ b/packages/storage/src/__tests__/workhub-message-assignment.test.ts
@@ -29,6 +29,8 @@ import {
WORKHUB_COORDINATION_SESSION_ROLE,
type WorkHubDelegationAssignedMessage,
type WorkHubDelegationReplacementAbortedMessage,
+ type WorkHubDelegationStopRequestedMessage,
+ type WorkHubDelegationStopResolvedMessage,
type WorkHubDelegationSupersededMessage,
} from '@maka/core/session';
import { createSessionStore, isSessionNotFoundError } from '../session-store.js';
@@ -332,6 +334,108 @@ test('an aborted replacement cannot later commit a supersession', async () => {
}
});
+test('an unresolved stop claim blocks replacement while not_owned releases the link', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-workhub-stop-arbitration-'));
+ const store = createSessionStore(root);
+ try {
+ await createCoordinationSession(store, root);
+ const source = await store.create({
+ cwd: root,
+ name: 'Payments',
+ llmConnectionSlug: 'test',
+ model: 'test',
+ permissionMode: 'ask',
+ });
+ const destination = await store.create({
+ cwd: root,
+ name: 'Login',
+ llmConnectionSlug: 'test',
+ model: 'test',
+ permissionMode: 'ask',
+ });
+ const original = assignmentRequest('stop-source', source.id, 'Payments', 'source-turn');
+ await store.assignWorkHubMessage(original);
+ const delegationSuffix = createHash('sha256')
+ .update(original.assignment.delegationId)
+ .digest('hex')
+ .slice(0, 48);
+ const request: WorkHubDelegationStopRequestedMessage = {
+ type: 'workhub_coordination',
+ id: `whq_${delegationSuffix}`,
+ turnId: 'stop-action',
+ ts: 11,
+ schemaVersion: 3,
+ kind: 'delegation_stop_requested',
+ actionId: 'stop-action',
+ actionFingerprint: `sha256:${'d'.repeat(64)}`,
+ coordinationTurnId: 'stop-action',
+ stopsActionId: original.assignment.actionId,
+ stopsDelegationId: original.assignment.delegationId,
+ targetSessionId: source.id,
+ targetMessageId: original.assignment.targetMessageId,
+ targetSessionName: 'Payments',
+ userText: 'Stop Payments',
+ };
+ await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [request]);
+ assert.deepEqual(await store.readWorkHubStopRequest(original.assignment.delegationId), request);
+
+ const base = assignmentRequest('after-stop', destination.id, 'Login', 'destination-turn');
+ const assignment: WorkHubDelegationAssignedMessage = {
+ ...base.assignment,
+ schemaVersion: 2,
+ replacesActionId: original.assignment.actionId,
+ replacesDelegationId: original.assignment.delegationId,
+ };
+ const supersession: WorkHubDelegationSupersededMessage = {
+ type: 'workhub_coordination',
+ id: `whx_${delegationSuffix}`,
+ turnId: assignment.actionId,
+ ts: assignment.ts,
+ schemaVersion: 2,
+ kind: 'delegation_superseded',
+ actionId: assignment.actionId,
+ actionFingerprint: assignment.actionFingerprint,
+ coordinationTurnId: assignment.coordinationTurnId,
+ supersededActionId: original.assignment.actionId,
+ supersededDelegationId: original.assignment.delegationId,
+ replacementDelegationId: assignment.delegationId,
+ };
+ await assert.rejects(
+ store.assignWorkHubMessage({ ...base, assignment, supersession }),
+ /stop claim/u,
+ );
+
+ const resolution: WorkHubDelegationStopResolvedMessage = {
+ type: 'workhub_coordination',
+ id: `whz_${delegationSuffix}`,
+ turnId: 'stop-action',
+ ts: 12,
+ schemaVersion: 3,
+ kind: 'delegation_stop_resolved',
+ actionId: 'stop-action',
+ actionFingerprint: request.actionFingerprint,
+ coordinationTurnId: 'stop-action',
+ stopsActionId: original.assignment.actionId,
+ stopsDelegationId: original.assignment.delegationId,
+ targetSessionId: source.id,
+ targetTurnId: 'shared-turn',
+ outcome: 'not_owned',
+ };
+ await store.appendMessages(WORKHUB_COORDINATION_SESSION_ID, [resolution]);
+ assert.deepEqual(
+ await store.readWorkHubStopResolution(original.assignment.delegationId),
+ resolution,
+ );
+ assert.equal(
+ (await store.assignWorkHubMessage({ ...base, assignment, supersession })).kind,
+ 'assigned',
+ );
+ } finally {
+ await store.close?.();
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
async function createCoordinationSession(
store: ReturnType,
root: string,
diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts
index dc62748e68..ed9714e275 100644
--- a/packages/storage/src/execution-stores.ts
+++ b/packages/storage/src/execution-stores.ts
@@ -369,6 +369,13 @@ async function createExecutionStoresForWrite sessionStore.readWorkHubReplacementAbort(delegationId)),
readWorkHubSupersession: (delegationId) =>
run(() => sessionStore.readWorkHubSupersession(delegationId)),
+ readWorkHubStopRequest: (delegationId) =>
+ run(() => sessionStore.readWorkHubStopRequest(delegationId)),
+ readWorkHubStopResolution: (delegationId) =>
+ run(() => sessionStore.readWorkHubStopResolution(delegationId)),
+ claimWorkHubAction: (claim) => run(() => sessionStore.claimWorkHubAction(claim)),
+ readWorkHubActionClaim: (actionId) =>
+ run(() => sessionStore.readWorkHubActionClaim(actionId)),
discardStableConversationCopy: (sessionId, requestFingerprint) =>
run(() => sessionStore.discardStableConversationCopy(sessionId, requestFingerprint)),
createSubagent: (input, initialBoundary) =>
@@ -437,6 +444,8 @@ async function createExecutionStoresForWrite sessionStore.readMessageAdmission(sessionId, messageId)),
hasCancelledMessageAdmission: (sessionId, messageId) =>
run(() => sessionStore.hasCancelledMessageAdmission(sessionId, messageId)),
+ claimMessageAdmissionCancellation: (sessionId, messageId, claimId) =>
+ run(() => sessionStore.claimMessageAdmissionCancellation(sessionId, messageId, claimId)),
listMessageAdmissions: (sessionId) =>
run(() => sessionStore.listMessageAdmissions(sessionId)),
markMessagesHandedOff: (input) => run(() => sessionStore.markMessagesHandedOff(input)),
diff --git a/packages/storage/src/message-admission-store.ts b/packages/storage/src/message-admission-store.ts
index c759c3703a..d284a04ebb 100644
--- a/packages/storage/src/message-admission-store.ts
+++ b/packages/storage/src/message-admission-store.ts
@@ -85,6 +85,11 @@ export interface MarkMessagesHandedOffInput {
readonly provenSteeringMessages?: readonly ProvenSteeringMessageHandoff[];
}
+export type MessageAdmissionCancellationClaimOutcome =
+ | 'cancelled_by_claim'
+ | 'same_claim'
+ | 'already_cancelled';
+
export interface MessageAdmissionStore {
commitMessageAdmission(admission: PendingMessageAdmission): Promise;
readMessageAdmission(
@@ -97,6 +102,11 @@ export interface MessageAdmissionStore {
* own columns never leave this layer.
*/
hasCancelledMessageAdmission(sessionId: string, messageId: string): Promise;
+ claimMessageAdmissionCancellation(
+ sessionId: string,
+ messageId: string,
+ claimId: string,
+ ): Promise;
listMessageAdmissions(sessionId: string): Promise;
markMessagesHandedOff(input: MarkMessagesHandedOffInput): Promise;
updateMessageAdmission(admission: PendingMessageAdmission): Promise;
diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts
index f51971958c..c745b72675 100644
--- a/packages/storage/src/session-store.ts
+++ b/packages/storage/src/session-store.ts
@@ -86,6 +86,10 @@ import {
type WorkHubDelegationAssignedMessage,
type WorkHubDelegationReplacementAbortedMessage,
type WorkHubDelegationReplacementRequestedMessage,
+ type WorkHubActionClaim,
+ type WorkHubActionClaimOutcome,
+ type WorkHubDelegationStopRequestedMessage,
+ type WorkHubDelegationStopResolvedMessage,
type WorkHubDelegationSupersededMessage,
} from '@maka/core/session';
import type {
@@ -430,6 +434,19 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto
readWorkHubSupersession(
delegationId: string,
): Promise;
+ readWorkHubStopRequest(
+ delegationId: string,
+ ): Promise;
+ readWorkHubStopResolution(
+ delegationId: string,
+ ): Promise;
+ /**
+ * Durably binds one action identity to one exact WorkHub operation before its
+ * effect. Survives removal of the target Session so a committed destructive
+ * claim can still converge afterwards.
+ */
+ claimWorkHubAction(claim: WorkHubActionClaim): Promise;
+ readWorkHubActionClaim(actionId: string): Promise;
discardStableConversationCopy(sessionId: string, requestFingerprint: string): Promise;
listCatalogPage(
filter: SessionListFilter | undefined,
@@ -717,6 +734,38 @@ class SqliteSessionStore implements SessionAuthorityStore {
: undefined;
}
+ async readWorkHubStopRequest(
+ delegationId: string,
+ ): Promise {
+ const message = await this.readWorkHubCoordinationMessage(
+ `whq_${workHubIdentitySuffix(delegationId)}`,
+ );
+ return message?.type === 'workhub_coordination' && message.kind === 'delegation_stop_requested'
+ ? message
+ : undefined;
+ }
+
+ async readWorkHubStopResolution(
+ delegationId: string,
+ ): Promise {
+ const message = await this.readWorkHubCoordinationMessage(
+ `whz_${workHubIdentitySuffix(delegationId)}`,
+ );
+ return message?.type === 'workhub_coordination' && message.kind === 'delegation_stop_resolved'
+ ? message
+ : undefined;
+ }
+
+ async claimWorkHubAction(claim: WorkHubActionClaim): Promise {
+ await this.ensureReady();
+ return this.metadata.claimWorkHubAction(claim);
+ }
+
+ async readWorkHubActionClaim(actionId: string): Promise {
+ await this.ensureReady();
+ return this.metadata.readWorkHubActionClaim(actionId);
+ }
+
private async readWorkHubCoordinationMessage(
messageId: string,
): Promise {
@@ -1059,6 +1108,11 @@ class SqliteSessionStore implements SessionAuthorityStore {
return this.metadata.hasCancelledMessageAdmission(sessionId, messageId);
}
+ async claimMessageAdmissionCancellation(sessionId: string, messageId: string, claimId: string) {
+ await this.ensureReady();
+ return this.metadata.claimMessageAdmissionCancellation(sessionId, messageId, claimId);
+ }
+
async listMessageAdmissions(sessionId: string): Promise {
await this.ensureReady();
return this.metadata.listMessageAdmissions(sessionId);
diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts
index b4e3c037a5..525934c7de 100644
--- a/packages/storage/src/sqlite-session-metadata-schema.ts
+++ b/packages/storage/src/sqlite-session-metadata-schema.ts
@@ -19,7 +19,7 @@
import type { DatabaseSync } from 'node:sqlite';
-export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 36;
+export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 38;
export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024;
export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}';
@@ -1242,6 +1242,32 @@ const MIGRATIONS: ReadonlyMap = new Map([
SELECT 1;
`,
],
+ [
+ 37,
+ `
+ ALTER TABLE cancelled_message_admissions
+ ADD COLUMN cancellation_claim_id TEXT;
+ `,
+ ],
+ [
+ 38,
+ `
+ -- The one global owner of a WorkHub action identity. It deliberately has no
+ -- Session foreign key: the claim must outlive removal of the target Session
+ -- so a committed destructive claim still converges after that removal.
+ CREATE TABLE IF NOT EXISTS workhub_action_claims (
+ action_id TEXT PRIMARY KEY,
+ operation TEXT NOT NULL CHECK (
+ operation IN (
+ 'answer_here', 'clarify', 'delegate_existing', 'create_new', 'replace', 'stop'
+ )
+ ),
+ action_fingerprint TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ claimed_at INTEGER NOT NULL CHECK (claimed_at >= 0)
+ );
+ `,
+ ],
]);
if (MIGRATIONS.size !== SQLITE_SESSION_METADATA_SCHEMA_VERSION) {
@@ -1299,13 +1325,14 @@ export function migrateSqliteSessionMetadataDatabase(
) {
const sql = MIGRATIONS.get(version);
if (!sql) throw new Error(`Missing SQLite session metadata migration ${version}`);
- // Versions 32 and 35 each add one column, and the post-merge convergence
+ // Versions 32, 35, and 37 each add one column, and the post-merge convergence
// path can replay them onto a database that already carries the current
// table shape. SQLite has no `ADD COLUMN IF NOT EXISTS`, so the guards
// live here.
const columnAlreadyPresent =
(version === 32 && hasColumn(db, 'message_admissions', 'submitted_intent_json')) ||
- (version === 35 && hasColumn(db, 'message_admissions', 'skill_invocation_json'));
+ (version === 35 && hasColumn(db, 'message_admissions', 'skill_invocation_json')) ||
+ (version === 37 && hasColumn(db, 'cancelled_message_admissions', 'cancellation_claim_id'));
if (!columnAlreadyPresent) {
db.exec(sql);
}
diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts
index 770147b5d3..c1a13de8d5 100644
--- a/packages/storage/src/sqlite-session-metadata-store.ts
+++ b/packages/storage/src/sqlite-session-metadata-store.ts
@@ -92,6 +92,9 @@ import {
type SessionHeaderPatch,
type StoredMessage,
type SubagentSessionParent,
+ type WorkHubActionClaim,
+ type WorkHubActionClaimOutcome,
+ type WorkHubActionOperation,
type WorkHubDelegationAssignedMessage,
type WorkHubDelegationSupersededMessage,
WORKHUB_COORDINATION_SESSION_ID,
@@ -106,6 +109,7 @@ import {
normalizeProvenSteeringMessageHandoff,
samePendingMessageAdmission,
type MarkMessagesHandedOffInput,
+ type MessageAdmissionCancellationClaimOutcome,
type PendingMessageAdmission,
type ProvenRootMessageHandoff,
type ProvenSteeringMessageHandoff,
@@ -1819,6 +1823,23 @@ export class SqliteSessionMetadataStore {
.update(assignment.replacesDelegationId)
.digest('hex')
.slice(0, 48);
+ const stopRequest = this.readMessageByIdSync(
+ WORKHUB_COORDINATION_SESSION_ID,
+ `whq_${abortSuffix}`,
+ );
+ if (stopRequest) {
+ const stopResolution = this.readMessageByIdSync(
+ WORKHUB_COORDINATION_SESSION_ID,
+ `whz_${abortSuffix}`,
+ );
+ if (
+ stopResolution?.type !== 'workhub_coordination' ||
+ stopResolution.kind !== 'delegation_stop_resolved' ||
+ stopResolution.outcome !== 'not_owned'
+ ) {
+ throw new SessionMetadataConflictError('WorkHub delegation already has a stop claim');
+ }
+ }
const existingAbort = this.readMessageByIdSync(
WORKHUB_COORDINATION_SESSION_ID,
`whb_${abortSuffix}`,
@@ -1955,6 +1976,137 @@ export class SqliteSessionMetadataStore {
});
}
+ /**
+ * Binds one WorkHub action identity to one exact operation, for good.
+ *
+ * Every other durable WorkHub record is keyed by what it is about, so none of
+ * them can see an action id that moved to a second delegation or a second
+ * disposition. This row is the global owner that rejects both, and it is
+ * written before the action's effect so a rejected or recovering attempt can
+ * never leak its identity into a different operation.
+ */
+ async claimWorkHubAction(claim: WorkHubActionClaim): Promise {
+ this.assertOpen();
+ assertSafeSessionId(claim.actionId);
+ assertSafeSessionId(claim.subject);
+ if (!/^sha256:[a-f0-9]{64}$/u.test(claim.actionFingerprint)) {
+ throw new SessionMetadataConflictError('Invalid WorkHub action fingerprint');
+ }
+ return this.transaction(() => {
+ const existing = this.readWorkHubActionClaimSync(claim.actionId);
+ if (existing) {
+ return existing.operation === claim.operation &&
+ existing.actionFingerprint === claim.actionFingerprint &&
+ existing.subject === claim.subject
+ ? 'same_claim'
+ : 'conflict';
+ }
+ this.db
+ .prepare(
+ `
+ INSERT INTO workhub_action_claims(
+ action_id, operation, action_fingerprint, subject, claimed_at
+ ) VALUES (?, ?, ?, ?, ?)
+ `,
+ )
+ .run(claim.actionId, claim.operation, claim.actionFingerprint, claim.subject, this.now());
+ return 'claimed';
+ });
+ }
+
+ async readWorkHubActionClaim(actionId: string): Promise {
+ this.assertOpen();
+ assertSafeSessionId(actionId);
+ return this.readTransaction(() => this.readWorkHubActionClaimSync(actionId));
+ }
+
+ private readWorkHubActionClaimSync(actionId: string): WorkHubActionClaim | undefined {
+ const row = this.db
+ .prepare(
+ 'SELECT operation, action_fingerprint, subject FROM workhub_action_claims WHERE action_id = ?',
+ )
+ .get(actionId) as
+ | { operation?: unknown; action_fingerprint?: unknown; subject?: unknown }
+ | undefined;
+ if (!row) return undefined;
+ if (
+ !isWorkHubActionOperation(row.operation) ||
+ typeof row.action_fingerprint !== 'string' ||
+ !/^sha256:[a-f0-9]{64}$/u.test(row.action_fingerprint) ||
+ typeof row.subject !== 'string'
+ ) {
+ throw new SessionMetadataConflictError('Invalid WorkHub action claim row');
+ }
+ return {
+ actionId,
+ operation: row.operation,
+ actionFingerprint: row.action_fingerprint as `sha256:${string}`,
+ subject: row.subject,
+ };
+ }
+
+ async claimMessageAdmissionCancellation(
+ sessionId: string,
+ messageId: string,
+ claimId: string,
+ ): Promise {
+ this.assertOpen();
+ assertSafeSessionId(sessionId);
+ assertSafeSessionId(messageId);
+ assertSafeSessionId(claimId);
+ return this.transaction(() => {
+ const cancelled = this.db
+ .prepare(
+ 'SELECT cancellation_claim_id FROM cancelled_message_admissions WHERE session_id = ? AND message_id = ?',
+ )
+ .get(sessionId, messageId) as { cancellation_claim_id?: unknown } | undefined;
+ if (cancelled) {
+ return cancelled.cancellation_claim_id === claimId ? 'same_claim' : 'already_cancelled';
+ }
+ const admission = this.db
+ .prepare(
+ `
+ SELECT submitted_content_digest, submitted_placement
+ FROM message_admissions
+ WHERE session_id = ? AND message_id = ?
+ `,
+ )
+ .get(sessionId, messageId) as
+ | { submitted_content_digest?: unknown; submitted_placement?: unknown }
+ | undefined;
+ if (
+ typeof admission?.submitted_content_digest !== 'string' ||
+ (admission.submitted_placement !== 'current_turn' &&
+ admission.submitted_placement !== 'next_turn')
+ ) {
+ throw new SessionMetadataConflictError('Message admission cancellation identity conflict');
+ }
+ this.db
+ .prepare(
+ `
+ INSERT INTO cancelled_message_admissions(
+ session_id, message_id, submitted_content_digest, submitted_placement,
+ cancellation_claim_id
+ ) VALUES (?, ?, ?, ?, ?)
+ `,
+ )
+ .run(
+ sessionId,
+ messageId,
+ admission.submitted_content_digest,
+ admission.submitted_placement,
+ claimId,
+ );
+ const deleted = this.db
+ .prepare('DELETE FROM message_admissions WHERE session_id = ? AND message_id = ?')
+ .run(sessionId, messageId);
+ if (deleted.changes !== 1) {
+ throw new SessionMetadataConflictError('Message admission cancellation identity conflict');
+ }
+ return 'cancelled_by_claim';
+ });
+ }
+
async listMessageAdmissions(sessionId: string): Promise {
this.assertOpen();
assertSafeSessionId(sessionId);
@@ -6900,6 +7052,17 @@ function readStoredMessageRecordJson(
return recordJson;
}
+function isWorkHubActionOperation(value: unknown): value is WorkHubActionOperation {
+ return (
+ value === 'answer_here' ||
+ value === 'clarify' ||
+ value === 'delegate_existing' ||
+ value === 'create_new' ||
+ value === 'replace' ||
+ value === 'stop'
+ );
+}
+
function sameWorkHubAssignmentRequest(
existing: WorkHubDelegationAssignedMessage,
requested: WorkHubDelegationAssignedMessage,