Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3bf54ad
feat(workhub): add direct stop coordination
ARE404 Sep 1, 2026
84af1c5
fix(desktop): keep WorkHub stop routing behind policy
ARE404 Sep 1, 2026
19eaa70
fix(workhub): harden direct stop recovery
ARE404 Sep 1, 2026
2ec065c
fix(workhub): claim prepared replacements before effects
ARE404 Sep 1, 2026
d668357
docs(workhub): record shared session resolution design
ARE404 Sep 2, 2026
89cc3a1
refactor(workhub): resolve stop targets through a shared Session Reso…
ARE404 Sep 2, 2026
84a7e51
fix(workhub): admit stop by resolved identity and expected state
ARE404 Sep 2, 2026
4649241
docs(workhub): record resolver-backed stop resolution and admission
ARE404 Sep 2, 2026
2a8c50d
fix(workhub): scope the stop visibility proof to one delegation
ARE404 Sep 2, 2026
6030c6f
refactor(workhub): single-source the Session name rule behind the port
ARE404 Sep 2, 2026
13ec555
refactor(workhub): let the Host alone prove sole delegation
ARE404 Sep 2, 2026
dc9511b
fix(workhub): never read an unrecovered root as already terminal
ARE404 Sep 2, 2026
e1fe629
fix(workhub): let finished delegations stop competing for stop
ARE404 Sep 2, 2026
789e92f
docs(workhub): fold the resolution design back into the ADR
ARE404 Sep 2, 2026
2022a4d
fix(workhub): ask the Host before answering a stop with a refusal
ARE404 Sep 2, 2026
b2c9612
fix(workhub): let the Host name the delegation a stop ends
ARE404 Sep 3, 2026
34a32f2
refactor(workhub): retire the stop proofs the resolver replaced
ARE404 Sep 3, 2026
217a8d9
fix(workhub): name the refusal when a stop identity outlives its dele…
ARE404 Sep 3, 2026
8f723e0
test(workhub): tighten direct stop coverage
ARE404 Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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([
Expand Down
51 changes: 51 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-session-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading