fix(agents): refuse a retired agent at admission, not only at start

Executing the rules found what thirteen ticks of reading and typechecking did
not: `decideDispatch` returned `dispatch` for a retired agent. I added
retirement to `selectCandidates` and `claimRun`, which start runs, and missed
the one that books them.

So mentioning a retired agent booked a run that could never start. It sat
queued forever, held a slot against that agent's queue limit, made the thread
look like work was pending, and told the person nothing — the exact opposite
of what `isAgentAddressable`'s own comment promised.

Retirement gets its own skip reason rather than borrowing `agent_disabled`,
because the remedies differ: `explainSkip` tells a reader to enable a disabled
agent, and enabling a retired one is refused. The new copy says its posts stay
and the work should go to someone else.

The same gap was in both assign paths — `assignThread` and
`createAssignedThread` each checked only `enabled` — so a thread could be
assigned to a retired agent too. Both now refuse, `assignThread` with the same
`agent_retired` kind, and the route maps it to 409 assignee_retired.

The harness grows a sixth section covering all eleven admission outcomes, and
this bug is now one of them: removing the guard turns that assertion red with
`{"kind":"dispatch"}` printed, and restoring it returns 36/36.
This commit is contained in:
yiliang114 2026-09-08 07:19:07 +09:00
parent 0189e7ca15
commit 521b212cd0
6 changed files with 107 additions and 5 deletions

View file

@ -891,7 +891,9 @@ export function registerWorkspaceAgentRoutes(
? ([409, 'thread_done'] as const)
: result.kind === 'agent_unknown'
? ([400, 'assignee_unknown'] as const)
: ([409, 'assignee_disabled'] as const);
: result.kind === 'agent_retired'
? ([409, 'assignee_retired'] as const)
: ([409, 'assignee_disabled'] as const);
res.status(status).json({ error });
return;
}

View file

@ -51,6 +51,7 @@ export type DispatchDecision =
export type SkipReason =
| 'self_trigger'
| 'agent_disabled'
| 'agent_retired'
| 'agent_unknown'
| 'turn_budget_exhausted'
| 'token_budget_exhausted'
@ -101,6 +102,13 @@ export function decideDispatch(context: DispatchContext): DispatchDecision {
const { thread, message, target } = context;
if (!target) return { kind: 'skip', reason: 'agent_unknown' };
// Retired before disabled, and a reason of its own: the two are different
// refusals with different remedies. Booking a run for a retired agent used
// to succeed here — `selectCandidates` then refused to start it, so the run
// sat queued forever, held a queue slot, and told the person nothing.
if (target.retiredAt !== undefined) {
return { kind: 'skip', reason: 'agent_retired' };
}
if (!isAgentEnabled(target)) {
return { kind: 'skip', reason: 'agent_disabled' };
}

View file

@ -1267,8 +1267,10 @@ export function maxConcurrentRunsFor(agent: WorkspaceAgent): number {
*
* Retired and disabled are different refusals with the same answer here, and
* both are kept apart from "unknown": a retired agent's name still resolves, so
* a post that mentions it gets `agent_disabled` and a person is told the agent
* is gone rather than that they mistyped.
* a post that mentions it is refused with `agent_retired` and the person is
* told the agent is gone rather than that they mistyped. Retired has its own
* reason rather than borrowing `agent_disabled` because the remedies differ —
* enabling a retired agent is itself refused.
*/
export function isAgentAddressable(agent: WorkspaceAgent): boolean {
return agent.retiredAt === undefined && agent.enabled !== false;

View file

@ -395,7 +395,7 @@ export async function createAssignedThread(
return withAgentStoreTransaction(projectRoot, async (transaction) => {
const agents = await transaction.readAgents();
const assignee = agents.find((agent) => agent.id === input.assignee.id);
if (!assignee || assignee.enabled === false) {
if (!assignee || !isAgentAddressable(assignee)) {
throw new Error(`Agent "${input.assignee.name}" is no longer available.`);
}
const thread = await prepareThreadInTransaction(transaction, {
@ -433,7 +433,8 @@ export type AssignThreadResult =
| 'thread_not_found'
| 'thread_done'
| 'agent_unknown'
| 'agent_disabled';
| 'agent_disabled'
| 'agent_retired';
};
export async function assignThread(
@ -460,6 +461,9 @@ export async function assignThread(
(agent) => agent.name.toLowerCase() === assigneeName.toLowerCase(),
);
if (!assignee) return { kind: 'agent_unknown' };
// Same order and same reason as admission: retirement is not disablement,
// and the remedy differs.
if (assignee.retiredAt !== undefined) return { kind: 'agent_retired' };
if (assignee.enabled === false) return { kind: 'agent_disabled' };
if (thread.assigneeAgentId === assignee.id) {
return { kind: 'updated', thread };

View file

@ -263,6 +263,13 @@ export function explainSkip(
what: `${target} is disabled and cannot take work`,
fix: `Enable ${target} to let it take work again.`,
};
case 'agent_retired':
// Deliberately not the disabled copy: enabling a retired agent is
// refused, so telling someone to enable it sends them at a wall.
return {
what: `${target} is retired and takes no new work`,
fix: `Its posts stay on every thread. Hand this to another agent.`,
};
case 'no_target':
return {
what: 'your reply would reach nobody',

View file

@ -31,6 +31,7 @@ await fs.writeFile(
`export * from '${repo}/${src}/store.js';
export { selectCandidates } from '${repo}/${src}/dispatcher.js';
export { assembleAgentPrompt } from '${repo}/${src}/prompt.js';
export { decideDispatch, resolveTargets } from '${repo}/${src}/dispatch-policy.js';
export * from '${repo}/${src}/types.js';
export { Storage } from '${repo}/packages/core/src/config/storage.js';
`,
@ -264,6 +265,84 @@ ok(
);
ok('a thread with none says nothing', !env.text.includes('Done when:'));
console.log('\n6. admission');
const post = (over = {}) => ({
id: 'm1',
sequence: 1,
authorKind: 'human',
from: M.HUMAN_AUTHOR_ID,
authorNameSnapshot: 'you',
text: 'go',
mentions: [],
outcomes: [],
at: 1,
...over,
});
const decide = (over = {}) =>
M.decideDispatch({
thread: thr('th_a', undefined, []),
message: post(),
target: BOB,
budget: { autoTurnsUsed: 0, tokensUsed: 0 },
agentQueuedElsewhere: 0,
...over,
});
ok('an ordinary mention dispatches', decide().kind === 'dispatch');
ok(
'an unknown target is refused',
decide({ target: undefined }).reason === 'agent_unknown',
);
ok(
'a disabled agent is refused',
decide({ target: { ...BOB, enabled: false } }).reason === 'agent_disabled',
);
ok(
'a retired agent is refused, and not as merely disabled',
decide({ target: { ...BOB, retiredAt: 1 } }).reason === 'agent_retired',
JSON.stringify(decide({ target: { ...BOB, retiredAt: 1 } })),
);
ok(
'a done thread is refused',
decide({ thread: { ...thr('th_a', undefined, []), status: 'done' } })
.reason === 'thread_done',
);
ok(
"an agent's own post never wakes it",
decide({ message: post({ from: BOB.id, authorKind: 'agent' }) }).reason ===
'self_trigger',
);
ok(
'an agent-triggered turn stops at the turn budget',
decide({
message: post({ from: 'ag_other', authorKind: 'agent' }),
budget: { autoTurnsUsed: 999, tokensUsed: 0 },
}).reason === 'turn_budget_exhausted',
);
ok(
'a human post is not stopped by the turn budget',
decide({ budget: { autoTurnsUsed: 999, tokensUsed: 0 } }).kind === 'dispatch',
);
ok(
'the token budget stops even a human trigger',
decide({ budget: { autoTurnsUsed: 0, tokensUsed: 99_999_999 } }).reason ===
'token_budget_exhausted',
);
const queued = thr('th_a', undefined, [run('rq', 1)]);
ok(
'a queued run of its own coalesces',
JSON.stringify(decide({ thread: queued })) ===
'{"kind":"coalesce","runId":"rq","into":"queued"}',
);
const running = thr('th_a', undefined, [run('rr', 1, { status: 'running' })]);
ok(
'a running run of its own coalesces mid-turn',
decide({ thread: running }).into === 'running',
);
ok(
'a full queue is refused',
decide({ agentQueuedElsewhere: 99 }).reason === 'queue_full',
);
await fs.rm(tmp, { recursive: true, force: true });
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);