1 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 fc41ad21aa Fix TOOL-12 follow-up: no line or verifier on interim stops
A dev that waits on its own background work (a 35-45 min test suite)
ends several turns before it hands back. Every turn end posted a new
"verifying on Opus..." line and started the verifier on an unfinished
tree (MAINT-28 T307: 5 lines; T276/T278 judged an uncommitted tree).

- After each turn end of a tracked agent, its loop status decides:
  completed, failed, killed, or already dropped by the engine is the
  hand-back; waiting, idle, running or pending is an interim stop.
- An interim stop posts nothing and starts no verifier. The router
  checks the status again every 15 s, up to 24 h, and runs the round
  once the run has ended. A newer turn end takes the watch over.
- The hand-back uses the last turn's model and end reason.
- 8 new tests, including the ticket's acceptance (3 interim stops: one
  line, one verdict, both after the hand-back); each new rule was also
  broken on purpose and caught, apart from one redundant guard.

Version 0.4.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdqPWvuDpbpRvDEdURoAwo
2026-11-12 21:36:04 +00:00
5 changed files with 193 additions and 8 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "model-router",
"version": "0.4.6",
"version": "0.4.7",
"description": "Routes each subagent to Haiku, Sonnet or Opus by how well-defined its brief is, verifies dev work below Opus in its own clone on a different model, sends failures back to the same agent then a stronger one, and estimates the savings against Opus.",
"author": {
"name": "Glenn Schrooyen"
+2 -2
View File
@@ -8,7 +8,7 @@ A Claude Code plugin that picks a model for every subagent, checks dev work on a
|---|---|
| Route | Dev agents run on **Sonnet** when the brief decides the approach (Expected / acceptance criteria), on **Opus** when the design is still open. Review and test agents keep the model they are given (Opus when none). |
| Verify | When a dev agent below Opus finishes a round, the router diffs its clone against the merge base, runs the test command in that clone, and has **Opus** judge the change. |
| Hand-back line | Every dev hand-back gets one line at once: `[model-router] Tn verifying on Opus...` (a verdict follows), or `[model-router] Tn no verification: dev ran on Opus. Go straight to review.` Decided by the model the run actually used, not only the routing choice; a difference is shown as `(routed to X, ran on Y)`. |
| Hand-back line | Every dev hand-back gets one line at once: `[model-router] Tn verifying on Opus...` (a verdict follows), or `[model-router] Tn no verification: dev ran on Opus. Go straight to review.` Only the real hand-back counts: an interim stop while the dev waits on its own background work (e.g. a long test suite) posts nothing and starts no verifier. Decided by the model the run actually used, not only the routing choice; a difference is shown as `(routed to X, ran on Y)`. |
| On FAIL | 1st: resume the same agent with the issues. 2nd: a new agent one model up, in the same clone. After that: handed back to the manager. |
| Not verified | A test timeout, a test command that cannot run, a missing clone or merge base, or a verdict that cannot be read even after asking once more: reported as NOT VERIFIED, never as a FAIL. |
| Savings | `/router savings` prices every run on the model it ran on and compares it with the same tokens on Opus 5.5. |
@@ -59,7 +59,7 @@ claude plugin validate .
claude plugin test .
```
62 tests. Every routing, verification, cost and timeout rule was also checked by breaking it on purpose (66 mutations in v0.4.4, plus 13 for the verdict reader in v0.4.5 and 12 for the hand-back line in v0.4.6; all caught by a test).
69 tests. Every routing, verification, cost and timeout rule was also checked by breaking it on purpose (66 mutations in v0.4.4, plus 13 for the verdict reader in v0.4.5 12 for the hand-back line in v0.4.6 and 10 for interim stops in v0.4.7; all caught by a test).
## Known limits
+50 -4
View File
@@ -21,6 +21,9 @@ import {
parseVerdict,
handBackLine,
ranTier,
turnEndKind,
WATCH_EVERY_MS,
WATCH_FOR_MS,
safetyHit,
skipReason,
validTimeout,
@@ -129,7 +132,7 @@ The model-router mod picks each subagent's model and verifies dev work before it
- Every dev, review and test brief carries one line \`Clone: <path>\` (add \`(Role: dev|review|test)\` if the path does not end in the role). The router reads the role from that line only; a brief naming several clones without it runs on Opus, unverified.
- Dev agents are routed: Sonnet when the brief decides the approach (Expected / acceptance criteria), Opus when the design is still open. Naming a model on a dev brief has no effect. Give each dev brief its clone path and base branch.
- Review and test agents keep the model you name (Opus when you name none).
- Every dev hand-back gets one "[model-router] Tn" line at once. "verifying on Opus...": wait for the verdict before advancing the PR. "no verification: dev ran on Opus. Go straight to review.": no verdict will come. Lines are decided by the model the dev actually ran on.
- Every dev hand-back gets one "[model-router] Tn" line at once; an interim stop while the dev waits on its own background work gets none. "verifying on Opus...": wait for the verdict before advancing the PR. "no verification: dev ran on Opus. Go straight to review.": no verdict will come. Lines are decided by the model the dev actually ran on.
- On "[model-router] ... FAIL ... resume agent", resume that same agent with SendMessage, passing the issues. On "start a new agent ... [router-rewrite Tn]", stop the old agent and call Agent with exactly that prompt. On "handed back", stop and show the issues to the person. Do not fix rejected work yourself.`
// ---------- store helpers (top-level, as the engine requires for `$`) ----------
@@ -523,6 +526,48 @@ async function runRound($: EngineInterface, agentId: string, rec: AgentRec, ende
else await verifyRound($, agentId, round, cfg)
}
// ---------- waiting for the real hand-back ----------
// The newest turn end per agent. A turn end that is only an interim stop (the
// agent waits on its own background work) must not post a line or start the
// verifier: only the run's end does. Module state: a reload drops the watches.
const lastTurn = new Map<string, { token: number; ended: string; reported?: string }>()
let tokens = 0
function pause($: EngineInterface, ms: number) {
return new Promise<void>(resolve => {
$.clock.after(ms, () => resolve())
})
}
async function statusOf($: EngineInterface, agentId: string) {
try {
return (await $.agent.list()).find(a => a.id === agentId)?.status
} catch {
return undefined
}
}
/**
* After a tracked agent's turn ends: runs the round once its run has ended,
* and not for an interim stop. A newer turn end of the same agent takes over.
*/
async function awaitHandBack($: EngineInterface, agentId: string, token: number, cfg: Settings) {
const started = await $.clock.now()
for (;;) {
if (lastTurn.get(agentId)?.token !== token) return // a newer turn end took over
if (turnEndKind(await statusOf($, agentId)) === 'handback') break
if ((await $.clock.now()) - started > WATCH_FOR_MS) return
await pause($, WATCH_EVERY_MS)
}
const turn = lastTurn.get(agentId)
if (!turn || turn.token !== token) return
lastTurn.delete(agentId)
// read the record now: an earlier round may have changed it meanwhile
const rec = (await getMap<AgentRec>($, S.agents))[agentId]
if (rec) await runRound($, agentId, rec, turn.ended, turn.reported, cfg)
}
// ---------- the hooks ----------
export const register: Register = (on, options) => {
@@ -668,9 +713,10 @@ export const register: Register = (on, options) => {
}
const rec = (await getMap<AgentRec>($, S.agents))[agentId]
if (!rec) return out
// Verification takes minutes: run it after this event has gone on.
const reported = e.usage?.model
$.clock.after(0, () => void runRound($, agentId, rec, e.reason, reported, cfg))
// Only the run's end is a hand-back; wait for it after this event has gone on.
const token = ++tokens
lastTurn.set(agentId, { token, ended: e.reason, reported: e.usage?.model })
$.clock.after(0, () => void awaitHandBack($, agentId, token, cfg))
return out
})
+19
View File
@@ -298,3 +298,22 @@ export const handBackLine = (args: {
if (skip === 'no-clone') return `${head} no verification: the brief names no clone path.${note}`
return undefined
}
// ---------- interim stops vs the hand-back (TOOL-12 follow-up) ----------
/** An agent's loop status, as `$.agent.list()` reports it. */
export type LoopStatus = 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
/**
* What a turn's end means for the agent, read from its status afterwards.
* - `handback`: its run ended (completed, failed, killed), or the engine
* already dropped it from the list: the report went back to its spawner.
* - `interim`: it is held on its own background work (`waiting`), between
* turns (`idle`), or already in its next turn (`running`, `pending`).
*/
export const turnEndKind = (status: LoopStatus | undefined): 'handback' | 'interim' =>
status === undefined || status === 'completed' || status === 'failed' || status === 'killed' ? 'handback' : 'interim'
/** How often an interim agent's status is checked again, and for how long at most. */
export const WATCH_EVERY_MS = 15_000
export const WATCH_FOR_MS = 24 * 60 * 60 * 1000
+121 -1
View File
@@ -13,6 +13,7 @@ import {
verifierFor,
handBackLine,
ranTier,
turnEndKind,
} from '../hooks/router'
const DEV = '/home/homelab/projects/goodwe/clones/SAFETY-95-dev'
@@ -218,6 +219,8 @@ type World = {
handBacks: string[]
/** Every message in the order it was sent. */
all: string[]
/** Each agent's loop status as $.agent.list() reports it; absent means completed. */
status: Record<string, string>
diffs: string[]
diffBases: string[]
testCwd: string[]
@@ -280,6 +283,12 @@ function world(on: On, w: World) {
return { model: e.model ?? 'inherit', agentId: `agent-${w.spawned.length}` }
})
on('turn.complete', async (_$, e) => ({ text: e.answer }))
on('agent.list', async () => ({
value: w.spawned
.map((_, i) => `agent-${i + 1}`)
.filter(id => w.status[id] !== 'gone') // 'gone': the engine dropped it
.map(id => ({ id, description: '', type: 'general-purpose', status: (w.status[id] ?? 'completed') as never })),
}))
on('prompt.submit', async (_$, e) => {
w.all.push(e.text)
if (/^\[model-router\] T\d+ (verifying on|no verification:)/.test(e.text)) w.handBacks.push(e.text)
@@ -289,7 +298,7 @@ function world(on: On, w: World) {
return clock
}
const fresh = (): World => ({ spawned: [], verifier: [], verdicts: [], told: [], handBacks: [], all: [], diffs: [], diffBases: [], testCwd: [], testArgv: [], testEnds: [], testFired: [], snap: 0 })
const fresh = (): World => ({ spawned: [], verifier: [], verdicts: [], told: [], handBacks: [], all: [], status: {}, diffs: [], diffBases: [], testCwd: [], testArgv: [], testEnds: [], testFired: [], snap: 0 })
async function finish($: Engine, clock: { advance: (ms: number) => Promise<void> }, agentId: string, ranModel?: string, reason = 'answer') {
const usage = ranModel
@@ -643,3 +652,114 @@ test('TOOL-12: review and test agents get no hand-back line', async ($, on) => {
await finish($, clock, r.agentId as string, 'claude-opus-5-5')
expect(w.all).toEqual([])
})
// ---------- interim stops (TOOL-12 follow-up) ----------
/** A turn end of the agent while it is still alive: its status says so. */
async function interimStop($: Engine, clock: { advance: (ms: number) => Promise<void> }, w: World, agentId: string, status = 'waiting') {
w.status[agentId] = status
await ($.turn as unknown as { complete: (e: unknown) => Promise<unknown> }).complete({
answer: 'Waiting for the test suite.',
durationMs: 1,
isAborted: false,
turnId: 't',
agentId,
reason: 'answer',
usage: { model: 'claude-sonnet-5-5', input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
})
await clock.advance(1)
}
test('follow-up acceptance: 3 interim stops give exactly one verifying line and one verdict, both after the hand-back', async ($, on) => {
const w = fresh()
w.diffs = ['diff']
const clock = world(on, w)
const r = await $.agent.spawn({ prompt: devBrief(), description: 'MAINT-28 dev' } as never)
const id = r.agentId as string
for (let i = 0; i < 3; i++) {
await interimStop($, clock, w, id)
await clock.advance(40 * 60 * 1000) // the test suite runs 35-45 min
expect(w.all).toEqual([])
expect(w.verifier).toEqual([])
expect(w.diffBases).toEqual([])
}
w.status[id] = 'completed'
await finish($, clock, id, 'claude-sonnet-5-5')
await clock.advance(60_000)
expect(w.handBacks).toEqual(['[model-router] T1 verifying on Opus...'])
expect(w.verifier).toEqual(['opus'])
expect(w.told.length).toBe(1)
expect(w.all[1]).toContain('PASS from verifier opus')
})
test('follow-up: an interim stop of an Opus dev posts nothing; its hand-back posts the skip line once', async ($, on) => {
const w = fresh()
const clock = world(on, w)
const r = await $.agent.spawn({ prompt: `SAFETY-135: decide how the staleness guard should recover. Clone: ${DEV}/`, description: 'dev' } as never)
const id = r.agentId as string
await interimStop($, clock, w, id)
await interimStop($, clock, w, id, 'idle')
await clock.advance(10 * 60 * 1000)
expect(w.all).toEqual([])
w.status[id] = 'completed'
await finish($, clock, id, 'claude-opus-5-5')
await clock.advance(60_000)
expect(w.all).toEqual(['[model-router] T1 no verification: dev ran on Opus. Go straight to review.'])
})
test('follow-up: a status that turns ended a moment after the turn is still caught', async ($, on) => {
const w = fresh()
w.diffs = ['diff']
const clock = world(on, w)
const r = await $.agent.spawn({ prompt: devBrief(), description: 'dev' } as never)
const id = r.agentId as string
w.status[id] = 'running' // not yet updated when the turn ends
await finish($, clock, id, 'claude-sonnet-5-5')
expect(w.all).toEqual([])
w.status[id] = 'completed'
await clock.advance(16_000)
expect(w.handBacks).toEqual(['[model-router] T1 verifying on Opus...'])
expect(w.verifier).toEqual(['opus'])
})
test('follow-up: an agent the engine already dropped counts as handed back', async ($, on) => {
const w = fresh()
w.diffs = ['diff']
const clock = world(on, w)
const r = await $.agent.spawn({ prompt: devBrief(), description: 'dev' } as never)
const id = r.agentId as string
w.status[id] = 'gone'
await finish($, clock, id, 'claude-sonnet-5-5')
await clock.advance(1)
expect(w.handBacks.length).toBe(1)
})
test('follow-up: a failed or killed run is a hand-back too', async () => {
for (const s of ['completed', 'failed', 'killed', undefined] as const) expect(turnEndKind(s)).toBe('handback')
for (const s of ['waiting', 'idle', 'running', 'pending'] as const) expect(turnEndKind(s)).toBe('interim')
})
test('follow-up: the hand-back uses the last turn: its model and how it ended', async ($, on) => {
const w = fresh()
w.diffs = ['diff']
const clock = world(on, w)
const r = await $.agent.spawn({ prompt: devBrief(), description: 'dev' } as never)
const id = r.agentId as string
await interimStop($, clock, w, id) // reported Sonnet
w.status[id] = 'completed'
await finish($, clock, id, 'claude-opus-5-5') // the last turn ran on Opus
await clock.advance(60_000)
expect(w.all).toEqual(['[model-router] T1 no verification: dev ran on Opus. Go straight to review. (routed to Sonnet, ran on Opus)'])
})
test('follow-up: each agent is judged by its own status, not by that of another agent', async ($, on) => {
const w = fresh()
w.diffs = ['diff']
const clock = world(on, w)
await $.agent.spawn({ prompt: devBrief(), description: 'finished dev' } as never) // agent-1: completed
const r = await $.agent.spawn({ prompt: devBrief(), description: 'busy dev' } as never)
await interimStop($, clock, w, r.agentId as string) // agent-2: waiting
await clock.advance(60_000)
expect(w.all).toEqual([])
expect(w.verifier).toEqual([])
})