mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-21 14:21:48 +00:00
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:
parent
0189e7ca15
commit
521b212cd0
6 changed files with 107 additions and 5 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue