Public: anyone can read this; the crew can change it.
Escalate to Opus by need, not by agent: P0s, planning, and any pull sent back twice run on Opus; everything else on the agent's own model #7576
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7570-escalate-to-opus-by-need-not-by-agent-p0 → main 4 commits fork 1 thread · 1 unresolved
14 files
changed +396
−7
README.md +1
| @@ -37,6 +37,7 @@ The terminal's scorecard also ends each agent's row with `spend $ 5h · today · | ||
| 37 | 37 | its cost over the last 5 hours, since 00:00 UTC and the last 7 days, as |
| 38 | 38 | `/settings/spend` reads it; `—` means nothing was spent in that span (`spend —`, |
| 39 | 39 | in all three). |
| 40 | Runs on P0, `opus`-labelled, planner and twice-bounced work use Opus (`docs/features/model-escalation.md`); the "Per model per day" spend and landed table is on Settings → Crew, in the terminal's "Crew scorecard" list, and in `GET /api/v1/crew/scorecard`. | |
| 40 | 41 | Settings → Crew also shows, each only when it has something, a "Handed to the |
| 41 | 42 | planner" card (issues the crew parked twice: repo, issue link, when last |
| 42 | 43 | parked, why) and a "Restore drill" card (the last drill's pass or fail and |
docs/features/model-escalation.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # Escalate to Opus by need | |
| 2 | ||
| 3 | A run picks its model from the work, not only the agent (`src/crew/model_choice.ts`): | |
| 4 | ||
| 5 | - an issue serving the P0 pillar, or labelled `opus` → `opus` | |
| 6 | - a planner's run → `opus` | |
| 7 | - the next round on a pull that has two or more changes-requested reviews → `opus` (not for the reviewer's own run) | |
| 8 | - anything else → the agent's own model | |
| 9 | ||
| 10 | The run row records `model` and `model_reason` (`agent`, `p0`, `label:opus`, `planner`, `bounced:N`). | |
| 11 | Settings → Crew (`/settings/crew`, "Per model per day"), the terminal's "Crew scorecard" list (admin only) and `GET /api/v1/crew/scorecard` (`modelDays`) show runs, spend and landed pulls per model per day. |
src/actions/scorecard.ts +3 −1
| @@ -11,6 +11,7 @@ import { | ||
| 11 | 11 | type Scorecard, |
| 12 | 12 | type SplitScorecard, |
| 13 | 13 | } from "../core/scorecard.ts"; |
| 14 | import { modelDays, type ModelDay } from "../core/model_days.ts"; | |
| 14 | 15 | import { listAgents } from "../core/agents.ts"; |
| 15 | 16 | import type { User } from "../core/users.ts"; |
| 16 | 17 | |
| @@ -40,7 +41,7 @@ const figures = (c: Scorecard): Scorecard => ({ | ||
| 40 | 41 | export function readScorecards( |
| 41 | 42 | app: App, |
| 42 | 43 | actor: User | undefined, |
| 43 | ): { agents: AgentScorecard[] } | undefined { | |
| 44 | ): { agents: AgentScorecard[]; modelDays: ModelDay[] } | undefined { | |
| 44 | 45 | if (actor?.isAdmin !== true) return undefined; |
| 45 | 46 | const window = recentWindow(); |
| 46 | 47 | const all = crewScorecards(app.db); |
| @@ -49,6 +50,7 @@ export function readScorecards( | ||
| 49 | 50 | const recentSplits = crewScorecardSplits(app.db, window); |
| 50 | 51 | const key = (p: SplitScorecard) => `${p.model}\u0000${p.promptVersion}`; |
| 51 | 52 | return { |
| 53 | modelDays: modelDays(app.db), | |
| 52 | 54 | agents: listAgents(app.db).map((agent) => { |
| 53 | 55 | const id = agent.user.id; |
| 54 | 56 | const lately = new Map((recentSplits.get(id) ?? []).map((p) => [key(p), p])); |
src/core/migration-pins/20261027140000-run-model-reason.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 41f6145d061298b139c990f4a88b43ab131b26c816b297ca3152e635d38e98d1 |
src/core/migrations/20261027140000-run-model-reason.ts added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | // Why an agent run ran on the model it did: its agent's own, or an escalation | |
| 2 | // to Opus by need (#7570). | |
| 3 | export default ` | |
| 4 | ALTER TABLE run ADD COLUMN model_reason TEXT NOT NULL DEFAULT ''; | |
| 5 | `; |
src/core/model_days.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * Spend and landed pulls per model per day (#7570), so the switch from one | |
| 3 | * model to another is measured. A run counts on the day it was created, a | |
| 4 | * pull on the day it merged, under the model of the latest agent run on it. | |
| 5 | * An unrecorded model is the empty string. | |
| 6 | */ | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | ||
| 9 | export interface ModelDay { | |
| 10 | /** UTC date, `2026-10-27`. */ | |
| 11 | readonly day: string; | |
| 12 | readonly model: string; | |
| 13 | readonly runs: number; | |
| 14 | readonly spentUsd: number; | |
| 15 | readonly landed: number; | |
| 16 | } | |
| 17 | ||
| 18 | /** The last `days` days, newest day first, then the model by name. */ | |
| 19 | export function modelDays(db: Database, days = 7, now = Date.now()): ModelDay[] { | |
| 20 | const since = new Date(now - days * 86_400_000).toISOString(); | |
| 21 | const rows = new Map<string, { day: string; model: string; runs: number; spentUsd: number; landed: number }>(); | |
| 22 | const at = (day: string, model: string) => { | |
| 23 | const key = `${day}\0${model}`; | |
| 24 | let row = rows.get(key); | |
| 25 | if (row === undefined) rows.set(key, (row = { day, model, runs: 0, spentUsd: 0, landed: 0 })); | |
| 26 | return row; | |
| 27 | }; | |
| 28 | for (const r of db | |
| 29 | .query<{ day: string; model: string; runs: number; spent: number }, [string]>( | |
| 30 | `SELECT substr(created_at, 1, 10) AS day, model, COUNT(*) AS runs, | |
| 31 | COALESCE(SUM(cost_usd), 0) AS spent | |
| 32 | FROM run WHERE kind = 'agent' AND created_at >= ?1 GROUP BY day, model`, | |
| 33 | ) | |
| 34 | .all(since)) { | |
| 35 | const row = at(r.day, r.model); | |
| 36 | row.runs = r.runs; | |
| 37 | row.spentUsd = r.spent; | |
| 38 | } | |
| 39 | for (const r of db | |
| 40 | .query<{ day: string; model: string; landed: number }, [string]>( | |
| 41 | `SELECT substr(pull.merged_at, 1, 10) AS day, | |
| 42 | COALESCE((SELECT run.model FROM run | |
| 43 | WHERE run.issue_id = issue.id AND run.kind = 'agent' | |
| 44 | ORDER BY run.id DESC LIMIT 1), '') AS model, | |
| 45 | COUNT(*) AS landed | |
| 46 | FROM pull JOIN issue ON issue.id = pull.issue_id | |
| 47 | WHERE pull.merged_at >= ?1 GROUP BY day, model`, | |
| 48 | ) | |
| 49 | .all(since)) | |
| 50 | at(r.day, r.model).landed = r.landed; | |
| 51 | return [...rows.values()].sort( | |
| 52 | (a, b) => b.day.localeCompare(a.day) || a.model.localeCompare(b.model), | |
| 53 | ); | |
| 54 | } |
src/core/runs.ts +5 −1
| @@ -400,6 +400,8 @@ export function noteRunProgress( | ||
| 400 | 400 | node?: string | undefined; |
| 401 | 401 | /** The model an agent run ran on (empty: the runner's default), and its prompt version. */ |
| 402 | 402 | model?: string | undefined; |
| 403 | /** Why that model: the agent's own, or what escalated the run to Opus. */ | |
| 404 | modelReason?: string | undefined; | |
| 403 | 405 | promptVersion?: string | undefined; |
| 404 | 406 | }, |
| 405 | 407 | ): void { |
| @@ -411,7 +413,8 @@ export function noteRunProgress( | ||
| 411 | 413 | placement = COALESCE(?8, placement), |
| 412 | 414 | node = COALESCE(?9, node), |
| 413 | 415 | model = COALESCE(?10, model), |
| 414 | prompt_version = COALESCE(?11, prompt_version) | |
| 416 | prompt_version = COALESCE(?11, prompt_version), | |
| 417 | model_reason = COALESCE(?12, model_reason) | |
| 415 | 418 | WHERE id = ?1`, |
| 416 | 419 | ).run( |
| 417 | 420 | runId, |
| @@ -425,6 +428,7 @@ export function noteRunProgress( | ||
| 425 | 428 | progress.node?.slice(0, 253) ?? null, |
| 426 | 429 | progress.model?.slice(0, 100) ?? null, |
| 427 | 430 | progress.promptVersion?.slice(0, 40) ?? null, |
| 431 | progress.modelReason?.slice(0, 60) ?? null, | |
| 428 | 432 | ); |
| 429 | 433 | } |
| 430 | 434 |
src/crew/model_choice.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | /** | |
| 2 | * Which model a run uses (#7570). The agent's own model is the default; | |
| 3 | * work that costs more to get wrong escalates to Opus: a P0 issue or one | |
| 4 | * labelled `opus`, a planner's run, and the next round of a pull that has | |
| 5 | * been sent back for changes twice. The reason is recorded on the run row. | |
| 6 | */ | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import type { Repo } from "../core/repos.ts"; | |
| 9 | import { labelsFor, type Issue } from "../core/issues.ts"; | |
| 10 | import { REVIEW_ROUNDS } from "../core/reviews.ts"; | |
| 11 | import { pillarsOf, servedBy } from "../core/pillars.ts"; | |
| 12 | ||
| 13 | /** The model escalated runs ask Claude Code for. */ | |
| 14 | export const OPUS_MODEL = "opus"; | |
| 15 | /** Changes-requested reviews after which a pull's next round runs on Opus. */ | |
| 16 | export const BOUNCED_ROUNDS = REVIEW_ROUNDS; | |
| 17 | /** The label that asks for Opus by hand. */ | |
| 18 | export const OPUS_LABEL = "opus"; | |
| 19 | ||
| 20 | export interface ModelFacts { | |
| 21 | readonly agentModel: string; | |
| 22 | readonly role: string | undefined; | |
| 23 | readonly p0: boolean; | |
| 24 | readonly labelled: boolean; | |
| 25 | readonly bouncedRounds: number; | |
| 26 | } | |
| 27 | ||
| 28 | export interface ModelChoice { | |
| 29 | /** Empty: the runner's default. */ | |
| 30 | readonly model: string; | |
| 31 | /** `agent`, or what escalated: `p0`, `label:opus`, `planner`, `bounced:N`. */ | |
| 32 | readonly reason: string; | |
| 33 | } | |
| 34 | ||
| 35 | /** Pure: the facts of the work in, the model and why out. */ | |
| 36 | export function chooseModel(facts: ModelFacts): ModelChoice { | |
| 37 | if (facts.p0) return { model: OPUS_MODEL, reason: "p0" }; | |
| 38 | if (facts.labelled) return { model: OPUS_MODEL, reason: `label:${OPUS_LABEL}` }; | |
| 39 | if (facts.role === "planner") return { model: OPUS_MODEL, reason: "planner" }; | |
| 40 | if (facts.bouncedRounds >= BOUNCED_ROUNDS) | |
| 41 | return { model: OPUS_MODEL, reason: `bounced:${facts.bouncedRounds}` }; | |
| 42 | return { model: facts.agentModel, reason: "agent" }; | |
| 43 | } | |
| 44 | ||
| 45 | /** Reads the facts about the issues a run is for (the pull, and the issue it closes). */ | |
| 46 | export function modelFacts( | |
| 47 | db: Database, | |
| 48 | repo: Repo, | |
| 49 | issues: readonly Issue[], | |
| 50 | agentModel: string, | |
| 51 | role: string | undefined, | |
| 52 | reviewing = false, | |
| 53 | ): ModelFacts { | |
| 54 | const ids = issues.map((i) => i.id); | |
| 55 | const serving = servedBy(db, pillarsOf(db, repo)); | |
| 56 | const p0 = ids.some((id) => /^P0\b/.test(serving.get(id) ?? "")); | |
| 57 | const labelled = ids.some((id) => | |
| 58 | labelsFor(db, id).some((l) => l.name.toLowerCase() === OPUS_LABEL), | |
| 59 | ); | |
| 60 | // A reviewer reads a bounced pull; it is not the next round of the work. | |
| 61 | let bouncedRounds = 0; | |
| 62 | for (const issue of reviewing ? [] : issues) { | |
| 63 | if (issue.kind !== "pull") continue; | |
| 64 | bouncedRounds = db | |
| 65 | .query<{ n: number }, [string]>( | |
| 66 | "SELECT COUNT(*) AS n FROM review WHERE issue_id = ?1 AND state = 'changes_requested'", | |
| 67 | ) | |
| 68 | .get(issue.id)!.n; | |
| 69 | } | |
| 70 | return { agentModel, role, p0, labelled, bouncedRounds }; | |
| 71 | } |
src/crew/runner.ts +14 −2
| @@ -32,6 +32,7 @@ import { announceRun } from "../actions/runs.ts"; | ||
| 32 | 32 | import { Stopwatch } from "../apps/timing.ts"; |
| 33 | 33 | import { previewEnv } from "../apps/data.ts"; |
| 34 | 34 | import { appsRuntime } from "../apps/runtime.ts"; |
| 35 | import { chooseModel, modelFacts } from "./model_choice.ts"; | |
| 35 | 36 | import { ownApp, ownAppUrls } from "./app_hosts.ts"; |
| 36 | 37 | import { crewRole, getAgent, type Agent } from "../core/agents.ts"; |
| 37 | 38 | import { getAppByRepo, type AppRecord } from "../core/apps.ts"; |
| @@ -1327,6 +1328,16 @@ async function work( | ||
| 1327 | 1328 | // One deadline for the run, whatever it takes to get there: the token |
| 1328 | 1329 | // dies ten minutes after it, so a second invocation gets what is left. |
| 1329 | 1330 | const deadline = Date.now() + options.runTimeoutMs; |
| 1331 | // The agent's own model, unless the work asks for Opus (#7570). | |
| 1332 | const facts = modelFacts( | |
| 1333 | app.db, | |
| 1334 | repo, | |
| 1335 | taskIssue === undefined ? [issue] : [issue, taskIssue], | |
| 1336 | agent.model, | |
| 1337 | brief.role, | |
| 1338 | reviewing, | |
| 1339 | ); | |
| 1340 | const chosen = chooseModel(facts); | |
| 1330 | 1341 | const invoke = async ( |
| 1331 | 1342 | prompt: string, |
| 1332 | 1343 | budgetUsd: number, |
| @@ -1338,7 +1349,7 @@ async function work( | ||
| 1338 | 1349 | cwd: dir, |
| 1339 | 1350 | prompt, |
| 1340 | 1351 | appendSystemPrompt: systemPrompt(brief), |
| 1341 | model: agent.model || undefined, | |
| 1352 | model: chosen.model || undefined, | |
| 1342 | 1353 | budgetUsd, |
| 1343 | 1354 | resume, |
| 1344 | 1355 | env, |
| @@ -1372,7 +1383,8 @@ async function work( | ||
| 1372 | 1383 | `claude: budget $${budget.toFixed(2)}, $${reserve.toFixed(2)} of it kept for the wrap-up${brief.resuming ? `, resuming ${previous?.sessionId}` : ""}`, |
| 1373 | 1384 | ); |
| 1374 | 1385 | noteRunProgress(app.db, run.id, { |
| 1375 | model: agent.model, | |
| 1386 | model: chosen.model, | |
| 1387 | modelReason: chosen.reason, | |
| 1376 | 1388 | promptVersion: promptVersion(agent, brief.role), |
| 1377 | 1389 | sandbox: { |
| 1378 | 1390 | enforced: sandbox.enforced, |
src/tui/screens/crewsplits.ts +12 −2
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The crew scorecard in the terminal, a forge admin's (#2682): agent runs |
| 3 | * cut by the model they ran on and by prompt version, as Settings → Crew | |
| 3 | * cut by the model they ran on, by prompt version and by model per day, as Settings → Crew | |
| 4 | 4 | * has them (`agentSplits`). The gate is the page's: an admin. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { modelDays, type ModelDay } from "../../core/model_days.ts"; | |
| 7 | 8 | import { agentSplits, type SplitTotals } from "../../core/runs.ts"; |
| 8 | 9 | import type { Ctx, Screen } from "../app/app.ts"; |
| 9 | 10 | import { binding } from "../app/keys.ts"; |
| @@ -25,6 +26,14 @@ export function splitRows( | ||
| 25 | 26 | })); |
| 26 | 27 | } |
| 27 | 28 | |
| 29 | export function modelDayRows(days: readonly ModelDay[]): ListRow[] { | |
| 30 | return days.map((d) => ({ | |
| 31 | title: `day ${d.day}: ${d.model === "" ? "default" : d.model}`, | |
| 32 | secondary: `${d.runs} runs`, | |
| 33 | meta: `${d.landed} landed $${d.spentUsd.toFixed(2)} spent`, | |
| 34 | })); | |
| 35 | } | |
| 36 | ||
| 28 | 37 | export function crewSplitsScreen(f: Session, ctx: Ctx): Screen { |
| 29 | 38 | const state = new ListState(); |
| 30 | 39 | const allowed = f.user?.isAdmin === true; |
| @@ -33,6 +42,7 @@ export function crewSplitsScreen(f: Session, ctx: Ctx): Screen { | ||
| 33 | 42 | ? [ |
| 34 | 43 | ...splitRows("model", "default", agentSplits(f.db, "model")), |
| 35 | 44 | ...splitRows("prompt", "not recorded", agentSplits(f.db, "prompt_version")), |
| 45 | ...modelDayRows(modelDays(f.db)), | |
| 36 | 46 | ] |
| 37 | 47 | : []; |
| 38 | 48 | return { |
| @@ -45,7 +55,7 @@ export function crewSplitsScreen(f: Session, ctx: Ctx): Screen { | ||
| 45 | 55 | ? "The crew scorecard is read by a forge admin." |
| 46 | 56 | : rows.length === 0 |
| 47 | 57 | ? "No agent runs yet." |
| 48 | : "Agent runs per model, then per prompt version."; | |
| 58 | : "Agent runs per model, then per prompt version, then per model per day."; | |
| 49 | 59 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 50 | 60 | drawList(s, list!, c, state, rows); |
| 51 | 61 | }, |
src/web/pages/crew.ts +36 −1
| @@ -35,6 +35,7 @@ import { | ||
| 35 | 35 | type Scorecard, |
| 36 | 36 | type SplitScorecard, |
| 37 | 37 | } from "../../core/scorecard.ts"; |
| 38 | import { modelDays, type ModelDay } from "../../core/model_days.ts"; | |
| 38 | 39 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 39 | 40 | import { listTeams, type Team } from "../../core/teams.ts"; |
| 40 | 41 | import { crewFor } from "../../crew/dispatch.ts"; |
| @@ -436,6 +437,7 @@ function loadScorecards(ctx: Ctx) { | ||
| 436 | 437 | recentSplitScorecards: crewScorecardSplits(ctx.app.db, recentWindow()), |
| 437 | 438 | byModel: agentSplits(ctx.app.db, "model"), |
| 438 | 439 | byPrompt: agentSplits(ctx.app.db, "prompt_version"), |
| 440 | byModelDay: modelDays(ctx.app.db), | |
| 439 | 441 | }; |
| 440 | 442 | } |
| 441 | 443 | |
| @@ -473,7 +475,7 @@ function crewView( | ||
| 473 | 475 | splitScorecards, |
| 474 | 476 | recentSplitScorecards, |
| 475 | 477 | } = data.scorecards; |
| 476 | const { scorecards, byModel, byPrompt } = data.scorecards; | |
| 478 | const { scorecards, byModel, byPrompt, byModelDay } = data.scorecards; | |
| 477 | 479 | const { spending } = data; |
| 478 | 480 | const usernameError = |
| 479 | 481 | error !== undefined && /username/i.test(error) ? error : undefined; |
| @@ -584,6 +586,7 @@ function crewView( | ||
| 584 | 586 | byModel, |
| 585 | 587 | () => html`<span class="muted">default</span>`, |
| 586 | 588 | )} |
| 589 | ${modelDaysCard(byModelDay)} | |
| 587 | 590 | ${splitsCard( |
| 588 | 591 | "Per prompt version", |
| 589 | 592 | "Prompt version", |
| @@ -672,6 +675,38 @@ function drillCard(drill: CrewHandover["drill"]): Html { | ||
| 672 | 675 | ); |
| 673 | 676 | } |
| 674 | 677 | |
| 678 | /** Spend and landed pulls per model per day: the escalation, measured. */ | |
| 679 | function modelDaysCard(rows: readonly ModelDay[]): Html { | |
| 680 | if (rows.length === 0) return html``; | |
| 681 | return card( | |
| 682 | html`<div class="table-scroll"> | |
| 683 | <table class="table" id="crew-model-days"> | |
| 684 | <thead> | |
| 685 | <tr> | |
| 686 | <th>Day</th> | |
| 687 | <th>Model</th> | |
| 688 | <th>Runs</th> | |
| 689 | <th>Spent</th> | |
| 690 | <th>Landed</th> | |
| 691 | </tr> | |
| 692 | </thead> | |
| 693 | <tbody> | |
| 694 | ${rows.map( | |
| 695 | (r) => html`<tr> | |
| 696 | <td class="mono">${r.day}</td> | |
| 697 | <td class="mono">${r.model === "" ? html`<span class="muted">default</span>` : r.model}</td> | |
| 698 | <td class="num">${r.runs}</td> | |
| 699 | <td class="num">$${r.spentUsd.toFixed(2)}</td> | |
| 700 | <td class="num">${r.landed}</td> | |
| 701 | </tr>`, | |
| 702 | )} | |
| 703 | </tbody> | |
| 704 | </table> | |
| 705 | </div>`, | |
| 706 | { pad: false, head: html`<h2>Per model per day</h2>` }, | |
| 707 | ); | |
| 708 | } | |
| 709 | ||
| 675 | 710 | /** The scorecard cut by one thing the runs ran on; nothing before a first run. */ |
| 676 | 711 | function splitsCard( |
| 677 | 712 | title: string, |
test/crew-scorecard.test.ts +15
| @@ -9,6 +9,7 @@ import { | ||
| 9 | 9 | claimRun, |
| 10 | 10 | createRun, |
| 11 | 11 | finishRun, |
| 12 | noteRunProgress, | |
| 12 | 13 | } from "../src/core/runs.ts"; |
| 13 | 14 | import { |
| 14 | 15 | crewScorecards, |
| @@ -95,6 +96,20 @@ describe("a scorecard per crew member", () => { | ||
| 95 | 96 | await rm(dataDir, { recursive: true, force: true }); |
| 96 | 97 | }); |
| 97 | 98 | |
| 99 | test("the page has no model-per-day table before a run, and has one for an admin after", async () => { | |
| 100 | const get = () => | |
| 101 | fetch(`${base}/settings/crew`, { headers: { cookie } }).then((r) => r.text()); | |
| 102 | expect(await get()).not.toContain(`id="crew-model-days"`); | |
| 103 | const run = createRun(app.db, { repoId, kind: "agent", actorId: agentId }); | |
| 104 | claimRun(app.db, run.id); | |
| 105 | noteRunProgress(app.db, run.id, { model: "opus", modelReason: "p0", costUsd: 0.5 }); | |
| 106 | finishRun(app.db, run.id, { state: "ok", summary: "" }); | |
| 107 | const page = await get(); | |
| 108 | expect(page).toContain(`id="crew-model-days"`); | |
| 109 | expect(page).toContain("Per model per day"); | |
| 110 | app.db.query("DELETE FROM run WHERE id = ?1").run(run.id); | |
| 111 | }); | |
| 112 | ||
| 98 | 113 | test("an agent with no pull is a row of dashes, not zeros", async () => { |
| 99 | 114 | expect(crewScorecards(app.db).get(agentId)).toBeUndefined(); |
| 100 | 115 | const page = await fetch(`${base}/settings/crew`, { |
test/model-choice.test.ts added +166
| @@ -0,0 +1,166 @@ | ||
| 1 | /** | |
| 2 | * A run picks its model from the work (#7570): P0 or `opus`-labelled work, | |
| 3 | * a planner's run and a pull sent back twice run on Opus; the rest on the | |
| 4 | * agent's own model. The run row records which and why, and the scorecard | |
| 5 | * reads spend and landed pulls per model per day. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { chooseModel, modelFacts } from "../src/crew/model_choice.ts"; | |
| 14 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 15 | import { createIssue, createLabel, setIssueLabels, type Issue } from "../src/core/issues.ts"; | |
| 16 | import { modelDays } from "../src/core/model_days.ts"; | |
| 17 | import { submitReview } from "../src/core/reviews.ts"; | |
| 18 | import { claimRun, createRun, finishRun, noteRunProgress } from "../src/core/runs.ts"; | |
| 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 20 | import type { User } from "../src/core/users.ts"; | |
| 21 | import { readScorecards } from "../src/actions/scorecard.ts"; | |
| 22 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | const plain = { | |
| 28 | agentModel: "sonnet", | |
| 29 | role: "builder", | |
| 30 | p0: false, | |
| 31 | labelled: false, | |
| 32 | bouncedRounds: 0, | |
| 33 | }; | |
| 34 | ||
| 35 | describe("which model a run uses", () => { | |
| 36 | test("an ordinary run uses the agent's own model", () => { | |
| 37 | expect(chooseModel(plain)).toEqual({ model: "sonnet", reason: "agent" }); | |
| 38 | expect(chooseModel({ ...plain, agentModel: "" })).toEqual({ model: "", reason: "agent" }); | |
| 39 | expect(chooseModel({ ...plain, bouncedRounds: 1 }).reason).toBe("agent"); | |
| 40 | }); | |
| 41 | ||
| 42 | test("P0, the opus label, the planner and a twice-bounced pull use opus", () => { | |
| 43 | expect(chooseModel({ ...plain, p0: true })).toEqual({ model: "opus", reason: "p0" }); | |
| 44 | expect(chooseModel({ ...plain, labelled: true }).reason).toBe("label:opus"); | |
| 45 | expect(chooseModel({ ...plain, role: "planner" }).reason).toBe("planner"); | |
| 46 | expect(chooseModel({ ...plain, bouncedRounds: 2 })).toEqual({ | |
| 47 | model: "opus", | |
| 48 | reason: "bounced:2", | |
| 49 | }); | |
| 50 | }); | |
| 51 | }); | |
| 52 | ||
| 53 | describe("the facts of the work", () => { | |
| 54 | let dataDir: string; | |
| 55 | let app: App; | |
| 56 | let repo: Repo; | |
| 57 | let admin: User; | |
| 58 | ||
| 59 | const open = (title: string, kind: "issue" | "pull" = "issue"): Issue => { | |
| 60 | const made = createIssue(app.db, { repoId: repo.id, authorId: admin.id, title, kind }); | |
| 61 | if (!made.ok) throw new Error(made.error); | |
| 62 | return made.value; | |
| 63 | }; | |
| 64 | ||
| 65 | beforeAll(async () => { | |
| 66 | dataDir = await mkdtemp(join(tmpdir(), "forge-model-choice-")); | |
| 67 | app = createApp({ dataDir, port: 0 }); | |
| 68 | const ada = await seedAdmin(app, { username: "ada", email: "[email protected]" }); | |
| 69 | if (!ada.ok) throw new Error(ada.error); | |
| 70 | admin = ada.value; | |
| 71 | const made = await createRepo(app, admin, { name: "hello", visibility: "public" }); | |
| 72 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 73 | repo = getRepoById(app.db, made.value.id)!; | |
| 74 | }); | |
| 75 | ||
| 76 | afterAll(async () => { | |
| 77 | app.close(); | |
| 78 | await rm(dataDir, { recursive: true, force: true }); | |
| 79 | }); | |
| 80 | ||
| 81 | test("an issue serving P0 picks opus, one serving P1 does not", () => { | |
| 82 | const trust = open("P0 Trust: contained"); | |
| 83 | const feed = open("P1 Feed: front door"); | |
| 84 | const urgent = open("Contain the cell"); | |
| 85 | const calm = open("Tidy the feed"); | |
| 86 | addDependency(app.db, trust, urgent, admin.id); | |
| 87 | addDependency(app.db, feed, calm, admin.id); | |
| 88 | const pick = (i: Issue) => | |
| 89 | chooseModel(modelFacts(app.db, repo, [i], "sonnet", "builder")); | |
| 90 | expect(pick(urgent)).toEqual({ model: "opus", reason: "p0" }); | |
| 91 | expect(pick(calm)).toEqual({ model: "sonnet", reason: "agent" }); | |
| 92 | }); | |
| 93 | ||
| 94 | test("the opus label picks opus", () => { | |
| 95 | const labelled = open("Hard one"); | |
| 96 | const label = createLabel(app.db, repo.id, "opus", "7c3aed"); | |
| 97 | if (!label.ok) throw new Error(label.error); | |
| 98 | setIssueLabels(app.db, labelled.id, [label.value.id]); | |
| 99 | expect( | |
| 100 | chooseModel(modelFacts(app.db, repo, [labelled], "sonnet", "builder")).reason, | |
| 101 | ).toBe("label:opus"); | |
| 102 | }); | |
| 103 | ||
| 104 | test("a pull sent back twice picks opus for its next round", () => { | |
| 105 | const pull = open("A change", "pull"); | |
| 106 | const review = (state: "changes_requested" | "approved") => | |
| 107 | submitReview(app.db, { issueId: pull.id, authorId: admin.id, state, commitOid: "a" }); | |
| 108 | const pick = () => chooseModel(modelFacts(app.db, repo, [pull], "sonnet", "builder")); | |
| 109 | review("changes_requested"); | |
| 110 | expect(pick().reason).toBe("agent"); | |
| 111 | review("changes_requested"); | |
| 112 | expect(pick()).toEqual({ model: "opus", reason: "bounced:2" }); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a reviewer on a twice-bounced pull gets the agent's own model", () => { | |
| 116 | const pull = open("Reviewed", "pull"); | |
| 117 | for (let n = 0; n < 2; n++) | |
| 118 | submitReview(app.db, { issueId: pull.id, authorId: admin.id, state: "changes_requested", commitOid: "a" }); | |
| 119 | const pick = (reviewing: boolean) => | |
| 120 | chooseModel(modelFacts(app.db, repo, [pull], "sonnet", "reviewer", reviewing)); | |
| 121 | expect(pick(false).reason).toBe("bounced:2"); | |
| 122 | expect(pick(true)).toEqual({ model: "sonnet", reason: "agent" }); | |
| 123 | }); | |
| 124 | ||
| 125 | test("a landed pull counts on its merge day under the latest run's model", () => { | |
| 126 | const pull = open("Landed", "pull"); | |
| 127 | for (const model of ["sonnet", "opus"]) { | |
| 128 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: pull.id, actorId: admin.id }); | |
| 129 | claimRun(app.db, run.id); | |
| 130 | noteRunProgress(app.db, run.id, { model }); | |
| 131 | finishRun(app.db, run.id, { state: "ok", summary: "" }); | |
| 132 | } | |
| 133 | const merged = new Date(Date.now() - 86_400_000).toISOString(); | |
| 134 | app.db | |
| 135 | .query( | |
| 136 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) | |
| 137 | VALUES (?1, 'main', 'landed', 'a', 'b', ?2)`, | |
| 138 | ) | |
| 139 | .run(pull.id, merged); | |
| 140 | const days = modelDays(app.db, 7); | |
| 141 | const landed = days.filter((d) => d.landed > 0); | |
| 142 | expect(landed).toEqual([ | |
| 143 | expect.objectContaining({ day: merged.slice(0, 10), model: "opus", landed: 1 }), | |
| 144 | ]); | |
| 145 | }); | |
| 146 | ||
| 147 | test("the run records the model and why; the scorecard reads spend and landed per model per day", () => { | |
| 148 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: open("Work").id, actorId: admin.id }); | |
| 149 | claimRun(app.db, run.id); | |
| 150 | noteRunProgress(app.db, run.id, { model: "opus", modelReason: "p0", costUsd: 1.5 }); | |
| 151 | finishRun(app.db, run.id, { state: "ok", summary: "done" }); | |
| 152 | const row = app.db | |
| 153 | .query<{ model: string; model_reason: string }, [string]>( | |
| 154 | "SELECT model, model_reason FROM run WHERE id = ?1", | |
| 155 | ) | |
| 156 | .get(run.id)!; | |
| 157 | expect(row).toEqual({ model: "opus", model_reason: "p0" }); | |
| 158 | const days = modelDays(app.db); | |
| 159 | const today = new Date().toISOString().slice(0, 10); | |
| 160 | expect(days.find((d) => d.day === today && d.model === "opus")).toMatchObject({ | |
| 161 | runs: 2, | |
| 162 | spentUsd: 1.5, | |
| 163 | }); | |
| 164 | expect(readScorecards(app, admin)?.modelDays).toEqual(days); | |
| 165 | }); | |
| 166 | }); |
test/tui-crew-splits.test.ts +2
| @@ -76,6 +76,8 @@ test("an admin's terminal shows the cuts, with the empty key as default / not re | ||
| 76 | 76 | expect(text).toContain("model: default"); |
| 77 | 77 | expect(text).toContain("prompt: abc123"); |
| 78 | 78 | expect(text).toContain("prompt: not recorded"); |
| 79 | expect(text).toContain(`day ${new Date().toISOString().slice(0, 10)}: claude-opus-5-5`); | |
| 80 | expect(text).toContain("1 runs"); | |
| 79 | 81 | h.stop(); |
| 80 | 82 | }); |
| 81 | 83 |
new above
offline
This read model reaches the page and the API but not
src/tui/screens/. Add a terminal view that readsmodelDays, gated to admins asreadScorecardsis.