Public: anyone can read this; the crew can change it.
The crew does not build the same thing twice #7825
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7792-the-crew-does-not-build-the-same-thing-t → main 3 commits fork 3 threads · 3 unresolved
13 files
changed +440
−5
docs/features/twin-runs.md added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | # The crew does not build the same thing twice (#7792) | |
| 2 | ||
| 3 | **Guard.** A build run (`assigned`) on an issue is refused while a *twin* holds the work (`src/crew/twin.ts`): another open issue with the same title (case and edge spaces aside) that has a queued or running agent run; an open or merged pull on that twin's branch (`<agent>/<number>[-slug]`); or any open pull titled the same, or a pull titled the same that merged after this issue was opened (so a title that comes round again, like "Fix typo", can be built again). When the guard refuses, the agent posts one comment on the issue naming the twin and its standing (`#N already holds this work (running|open|landed)`). The check runs in the same write transaction as the run is created, beside the 10-minute fresh-pull guard. Closed, unmerged pulls hold nothing, so a failed attempt can be retried. Not covered: near-duplicate titles (different words, same idea). | |
| 4 | ||
| 5 | **Scorecard.** Settings → Crew shows "Wasted runs per day", and `GET /api/v1/crew/scorecard` / the MCP `scorecard` tool carry `wastedDays`: agent runs ending "pull closed", "no changes pushed" or "already done", per UTC day, with their cost (`src/core/wasted_days.ts`). Retried outages are not counted. The terminal's "Crew scorecard" screen lists the same days (`wasted <day>`). |
src/actions/scorecard.ts +3 −1
| @@ -12,6 +12,7 @@ import { | ||
| 12 | 12 | type SplitScorecard, |
| 13 | 13 | } from "../core/scorecard.ts"; |
| 14 | 14 | import { modelDays, type ModelDay } from "../core/model_days.ts"; |
| 15 | import { wastedDays, type WastedDay } from "../core/wasted_days.ts"; | |
| 15 | 16 | import { listAgents } from "../core/agents.ts"; |
| 16 | 17 | import type { User } from "../core/users.ts"; |
| 17 | 18 | |
| @@ -41,7 +42,7 @@ const figures = (c: Scorecard): Scorecard => ({ | ||
| 41 | 42 | export function readScorecards( |
| 42 | 43 | app: App, |
| 43 | 44 | actor: User | undefined, |
| 44 | ): { agents: AgentScorecard[]; modelDays: ModelDay[] } | undefined { | |
| 45 | ): { agents: AgentScorecard[]; modelDays: ModelDay[]; wastedDays: WastedDay[] } | undefined { | |
| 45 | 46 | if (actor?.isAdmin !== true) return undefined; |
| 46 | 47 | const window = recentWindow(); |
| 47 | 48 | const all = crewScorecards(app.db); |
| @@ -51,6 +52,7 @@ export function readScorecards( | ||
| 51 | 52 | const key = (p: SplitScorecard) => `${p.model}\u0000${p.promptVersion}`; |
| 52 | 53 | return { |
| 53 | 54 | modelDays: modelDays(app.db), |
| 55 | wastedDays: wastedDays(app.db), | |
| 54 | 56 | agents: listAgents(app.db).map((agent) => { |
| 55 | 57 | const id = agent.user.id; |
| 56 | 58 | const lately = new Map((recentSplits.get(id) ?? []).map((p) => [key(p), p])); |
src/contract/openapi/routes/getApiV1CrewScorecard.ts +1 −1
| @@ -3,7 +3,7 @@ import type { Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "get", |
| 5 | 5 | path: "/api/v1/crew/scorecard", |
| 6 | summary: "Read the crew's scorecard (a forge admin's, any token scope): { agents: [{ username, all, recent, splits }] }, where all and recent are { opened, landed, reviewRounds, redCheckRate, reverts, meanLandMs } or null, and each split adds model, prompt and its own recent", | |
| 6 | summary: "Read the crew's scorecard (a forge admin's, any token scope): { agents: [{ username, all, recent, splits }] }, where all and recent are { opened, landed, reviewRounds, redCheckRate, reverts, meanLandMs } or null, and each split adds model, prompt and its own recent; also modelDays (runs, landed and spend per model per day) and wastedDays: [{ day, wasted, runs, spentUsd }], the runs that ended on work not theirs to do", | |
| 7 | 7 | auth: true, |
| 8 | 8 | refusals: { unauthorized: "no valid credentials were sent", forbidden: "the caller is not a forge admin" }, |
| 9 | 9 | }; |
src/core/wasted_days.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * Wasted agent runs per day (#7792): runs that ended on work that was not | |
| 3 | * theirs to do, "the pull closed" under them, "no changes pushed", or | |
| 4 | * "already done". Counted on the day the run was created, with what they | |
| 5 | * cost. An outage the runner retried ("no changes pushed: ECONNREFUSED") is | |
| 6 | * the network's, not the crew's, and is left out. | |
| 7 | */ | |
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { isOutageSummary } from "../crew/network.ts"; | |
| 10 | ||
| 11 | export interface WastedDay { | |
| 12 | /** UTC date, `2026-10-27`. */ | |
| 13 | readonly day: string; | |
| 14 | readonly wasted: number; | |
| 15 | /** Agent runs created that day, wasted or not. */ | |
| 16 | readonly runs: number; | |
| 17 | readonly spentUsd: number; | |
| 18 | } | |
| 19 | ||
| 20 | /** Whether a run's summary is one of the three ways a run is wasted. */ | |
| 21 | export const isWasted = (summary: string): boolean => | |
| 22 | (/^(the )?pull closed\b/i.test(summary) || | |
| 23 | /^no changes pushed\b/i.test(summary) || | |
| 24 | /^already done\b/i.test(summary)) && | |
| 25 | !isOutageSummary(summary); | |
| 26 | ||
| 27 | /** The last `days` days, newest first; a day with no agent run is absent. */ | |
| 28 | export function wastedDays(db: Database, days = 7, now = Date.now()): WastedDay[] { | |
| 29 | const since = new Date(now - days * 86_400_000).toISOString(); | |
| 30 | const byDay = new Map<string, { wasted: number; runs: number; spentUsd: number }>(); | |
| 31 | for (const r of db | |
| 32 | .query<{ day: string; summary: string; cost: number }, [string]>( | |
| 33 | `SELECT substr(created_at, 1, 10) AS day, summary, COALESCE(cost_usd, 0) AS cost | |
| 34 | FROM run WHERE kind = 'agent' AND state IN ('ok','failed','cancelled') | |
| 35 | AND created_at >= ?1`, | |
| 36 | ) | |
| 37 | .all(since)) { | |
| 38 | const d = byDay.get(r.day) ?? { wasted: 0, runs: 0, spentUsd: 0 }; | |
| 39 | d.runs++; | |
| 40 | if (isWasted(r.summary)) { | |
| 41 | d.wasted++; | |
| 42 | d.spentUsd += r.cost; | |
| 43 | } | |
| 44 | byDay.set(r.day, d); | |
| 45 | } | |
| 46 | return [...byDay.entries()] | |
| 47 | .map(([day, d]) => ({ day, ...d })) | |
| 48 | .sort((a, b) => b.day.localeCompare(a.day)); | |
| 49 | } |
src/crew/dispatch.ts +27
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { flowOnlyHolds } from "./flow_only.ts"; |
| 12 | import { twinNote, twinOf, type Twin } from "./twin.ts"; | |
| 12 | 13 | import { NO_MERGE } from "../checks/merged.ts"; |
| 13 | 14 | import { markHeard } from "../core/issues.ts"; |
| 14 | 15 | import { capacityCount, noteBooted } from "../core/capacity.ts"; |
| @@ -282,6 +283,13 @@ export async function startCrew( | ||
| 282 | 283 | return handle; |
| 283 | 284 | } |
| 284 | 285 | |
| 286 | /** Tell the issue's thread why nothing started, once per twin and standing. */ | |
| 287 | function tellTwin(app: App, repo: Repo, issue: Issue, by: User, twin: Twin): void { | |
| 288 | const note = twinNote(twin); | |
| 289 | if (listComments(app.db, issue.id).some((c) => c.body === note)) return; | |
| 290 | addComment(app, by, repo, issue, note); | |
| 291 | } | |
| 292 | ||
| 285 | 293 | /** Say the hand-over on the pull's thread, once per hand-over. */ |
| 286 | 294 | function handOver( |
| 287 | 295 | app: App, |
| @@ -423,6 +431,7 @@ export function queueAgentRun( | ||
| 423 | 431 | // forge processes share this database, and both may react to the same |
| 424 | 432 | // thing — a run checked for by one and inserted by the other would be the |
| 425 | 433 | // same work twice (docs/ARCHITECTURE.md, "Two processes at once"). |
| 434 | let refusedAsTwin: Twin | undefined; | |
| 426 | 435 | const run = transact(app.db, () => { |
| 427 | 436 | // A follow-up is the builder's: a reviewer's own run on the pull, still |
| 428 | 437 | // running as it posts its verdict, is not the run that hears it. |
| @@ -461,7 +470,24 @@ export function queueAgentRun( | ||
| 461 | 470 | return undefined; |
| 462 | 471 | } |
| 463 | 472 | } |
| 473 | // The same title is the same work: while a twin has a run going or a | |
| 474 | // pull open, or its pull landed since this issue was opened, a build | |
| 475 | // here only repeats it. Variants are the exception: they share a title | |
| 476 | // on purpose, several tries in one group. | |
| 464 | 477 | const group = variantGroup ?? variantGroupOf(app.db, issue.id); |
| 478 | if (reason === "assigned" && group === undefined) { | |
| 479 | const twin = twinOf(app.db, issue); | |
| 480 | if (twin !== undefined) { | |
| 481 | log.info("build run refused: a twin holds the work", { | |
| 482 | repo: `${repo.ownerName}/${repo.name}`, | |
| 483 | issue: issue.number, | |
| 484 | twin: twin.number, | |
| 485 | standing: twin.standing, | |
| 486 | }); | |
| 487 | refusedAsTwin = twin; | |
| 488 | return undefined; | |
| 489 | } | |
| 490 | } | |
| 465 | 491 | return createRun(app.db, { |
| 466 | 492 | repoId: repo.id, |
| 467 | 493 | kind: "agent", |
| @@ -476,6 +502,7 @@ export function queueAgentRun( | ||
| 476 | 502 | }); |
| 477 | 503 | }); |
| 478 | 504 | if (run !== undefined) announceRun(app, run, actorId); |
| 505 | else if (refusedAsTwin !== undefined) tellTwin(app, repo, issue, agent.user, refusedAsTwin); | |
| 479 | 506 | return run; |
| 480 | 507 | } |
| 481 | 508 |
src/crew/twin.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * The same work asked for twice. Two issues with one title are one job: a | |
| 3 | * build run on the second, while the first has a run going or a pull open | |
| 4 | * (or merged), only makes a pull to be closed or an "already done". | |
| 5 | * | |
| 6 | * A twin is another open issue of the repository with the same title, case | |
| 7 | * and edge spaces aside. A pull titled the same stands as one too, unless it | |
| 8 | * is the issue's own (its branch carries the issue's number). A merged pull | |
| 9 | * only counts when it landed after this issue was opened, so a title that | |
| 10 | * comes round again ("Fix typo") can be built again. | |
| 11 | * | |
| 12 | * What stands in front is named, so the log and the person on the issue can | |
| 13 | * say why nothing started. | |
| 14 | */ | |
| 15 | import type { Database } from "bun:sqlite"; | |
| 16 | import type { Issue } from "../core/issues.ts"; | |
| 17 | ||
| 18 | export interface Twin { | |
| 19 | /** The issue or pull that holds the work. */ | |
| 20 | readonly number: number; | |
| 21 | /** In flight: a run or an open pull. Landed: a pull merged. */ | |
| 22 | readonly standing: "running" | "open" | "landed"; | |
| 23 | } | |
| 24 | ||
| 25 | const same = (title: string): string => title.trim().toLowerCase(); | |
| 26 | ||
| 27 | /** The twin, if any, already holding `issue`'s work: running before open before landed. */ | |
| 28 | export function twinOf(db: Database, issue: Pick<Issue, "id" | "repoId" | "number" | "title" | "createdAt">): Twin | undefined { | |
| 29 | const t = same(issue.title); | |
| 30 | if (t === "") return undefined; | |
| 31 | const running = db | |
| 32 | .query<{ number: number }, [string, string, string]>( | |
| 33 | `SELECT i.number FROM issue i | |
| 34 | WHERE i.repo_id = ?1 AND i.id != ?2 AND i.kind = 'issue' AND i.state = 'open' | |
| 35 | AND lower(trim(i.title)) = ?3 | |
| 36 | AND EXISTS (SELECT 1 FROM run r WHERE r.issue_id = i.id AND r.kind = 'agent' | |
| 37 | AND r.state IN ('queued','running')) | |
| 38 | ORDER BY i.number LIMIT 1`, | |
| 39 | ) | |
| 40 | .get(issue.repoId, issue.id, t); | |
| 41 | if (running !== null) return { number: running.number, standing: "running" }; | |
| 42 | // A pull is another issue row; it answers a twin by its own title, or by | |
| 43 | // the number its builder's branch carries (`<agent>/<number>-<slug>`). | |
| 44 | const pull = db | |
| 45 | .query<{ number: number; merged: number }, [string, string, string, string, string, string]>( | |
| 46 | `SELECT p.number, (pl.merged_at IS NOT NULL) AS merged | |
| 47 | FROM issue p JOIN pull pl ON pl.issue_id = p.id | |
| 48 | WHERE p.repo_id = ?1 AND p.kind = 'pull' AND p.id != ?2 | |
| 49 | AND (p.state = 'open' OR pl.merged_at >= ?6) | |
| 50 | AND pl.head_branch NOT LIKE ?4 AND pl.head_branch NOT LIKE ?5 | |
| 51 | AND (lower(trim(p.title)) = ?3 | |
| 52 | OR EXISTS (SELECT 1 FROM issue i | |
| 53 | WHERE i.repo_id = p.repo_id AND i.id != ?2 AND i.kind = 'issue' | |
| 54 | AND lower(trim(i.title)) = ?3 | |
| 55 | AND (pl.head_branch LIKE '%/' || i.number | |
| 56 | OR pl.head_branch LIKE '%/' || i.number || '-%'))) | |
| 57 | ORDER BY (pl.merged_at IS NOT NULL), p.number LIMIT 1`, | |
| 58 | ) | |
| 59 | .get(issue.repoId, issue.id, t, `%/${issue.number}`, `%/${issue.number}-%`, issue.createdAt); | |
| 60 | if (pull === null) return undefined; | |
| 61 | return { number: pull.number, standing: pull.merged === 1 ? "landed" : "open" }; | |
| 62 | } | |
| 63 | ||
| 64 | /** The words for the issue's thread: which twin holds the work, and that nothing started. */ | |
| 65 | export function twinNote(twin: Twin): string { | |
| 66 | const state = { running: "is running", open: "has a pull open", landed: "has landed" }[twin.standing]; | |
| 67 | return `#${twin.number} already holds this work (${twin.standing}): it ${state}, so no build run was started here. Close this issue as a duplicate, or retitle it if it is different work.`; | |
| 68 | } |
src/tui/screens/crewsplits.ts +11 −1
| @@ -1,10 +1,11 @@ | ||
| 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, by prompt version and by model per day, as Settings → Crew | |
| 3 | * cut by the model they ran on, by prompt version, by model per day and wasted runs per day, as Settings → Crew | |
| 4 | 4 | * has them (`agentSplits`). The gate is the page's: an admin. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { modelDays, type ModelDay } from "../../core/model_days.ts"; |
| 8 | import { wastedDays, type WastedDay } from "../../core/wasted_days.ts"; | |
| 8 | 9 | import { agentSplits, type SplitTotals } from "../../core/runs.ts"; |
| 9 | 10 | import type { Ctx, Screen } from "../app/app.ts"; |
| 10 | 11 | import { binding } from "../app/keys.ts"; |
| @@ -35,6 +36,14 @@ export function modelDayRows(days: readonly ModelDay[]): ListRow[] { | ||
| 35 | 36 | })); |
| 36 | 37 | } |
| 37 | 38 | |
| 39 | export function wastedDayRows(days: readonly WastedDay[]): ListRow[] { | |
| 40 | return days.map((d) => ({ | |
| 41 | title: `wasted ${d.day}`, | |
| 42 | secondary: `${d.wasted} of ${d.runs} runs`, | |
| 43 | meta: `$${d.spentUsd.toFixed(2)} spent`, | |
| 44 | })); | |
| 45 | } | |
| 46 | ||
| 38 | 47 | export function crewSplitsScreen(f: Session, ctx: Ctx): Screen { |
| 39 | 48 | const state = new ListState(); |
| 40 | 49 | const allowed = f.user?.isAdmin === true; |
| @@ -44,6 +53,7 @@ export function crewSplitsScreen(f: Session, ctx: Ctx): Screen { | ||
| 44 | 53 | ...splitRows("model", "default", agentSplits(f.db, "model")), |
| 45 | 54 | ...splitRows("prompt", "not recorded", agentSplits(f.db, "prompt_version")), |
| 46 | 55 | ...modelDayRows(modelDays(f.db)), |
| 56 | ...wastedDayRows(wastedDays(f.db)), | |
| 47 | 57 | ] |
| 48 | 58 | : []; |
| 49 | 59 | return { |
src/web/pages/crew.ts +34 −1
| @@ -35,6 +35,7 @@ import { | ||
| 35 | 35 | type Scorecard, |
| 36 | 36 | type SplitScorecard, |
| 37 | 37 | } from "../../core/scorecard.ts"; |
| 38 | import { wastedDays, type WastedDay } from "../../core/wasted_days.ts"; | |
| 38 | 39 | import { modelDays, type ModelDay } from "../../core/model_days.ts"; |
| 39 | 40 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 40 | 41 | import { listTeams, type Team } from "../../core/teams.ts"; |
| @@ -438,6 +439,7 @@ function loadScorecards(ctx: Ctx) { | ||
| 438 | 439 | byModel: agentSplits(ctx.app.db, "model"), |
| 439 | 440 | byPrompt: agentSplits(ctx.app.db, "prompt_version"), |
| 440 | 441 | byModelDay: modelDays(ctx.app.db), |
| 442 | wasted: wastedDays(ctx.app.db), | |
| 441 | 443 | }; |
| 442 | 444 | } |
| 443 | 445 | |
| @@ -475,7 +477,7 @@ function crewView( | ||
| 475 | 477 | splitScorecards, |
| 476 | 478 | recentSplitScorecards, |
| 477 | 479 | } = data.scorecards; |
| 478 | const { scorecards, byModel, byPrompt, byModelDay } = data.scorecards; | |
| 480 | const { scorecards, byModel, byPrompt, byModelDay, wasted } = data.scorecards; | |
| 479 | 481 | const { spending } = data; |
| 480 | 482 | const usernameError = |
| 481 | 483 | error !== undefined && /username/i.test(error) ? error : undefined; |
| @@ -587,6 +589,7 @@ function crewView( | ||
| 587 | 589 | () => html`<span class="muted">default</span>`, |
| 588 | 590 | )} |
| 589 | 591 | ${modelDaysCard(byModelDay)} |
| 592 | ${wastedDaysCard(wasted)} | |
| 590 | 593 | ${splitsCard( |
| 591 | 594 | "Per prompt version", |
| 592 | 595 | "Prompt version", |
| @@ -707,6 +710,36 @@ function modelDaysCard(rows: readonly ModelDay[]): Html { | ||
| 707 | 710 | ); |
| 708 | 711 | } |
| 709 | 712 | |
| 713 | /** Runs that ended on work not theirs to do, per day: the pull closed, nothing pushed, already done. */ | |
No test renders this card. Add one, and mirror it in the TUI (src/tui/screens/crewsplits.ts) so both doors show it. |
||
| 714 | function wastedDaysCard(rows: readonly WastedDay[]): Html { | |
| 715 | if (rows.length === 0) return html``; | |
| 716 | return card( | |
| 717 | html`<div class="table-scroll"> | |
| 718 | <table class="table" id="crew-wasted-days"> | |
| 719 | <thead> | |
| 720 | <tr> | |
| 721 | <th>Day</th> | |
| 722 | <th>Wasted runs</th> | |
| 723 | <th>Of runs</th> | |
| 724 | <th>Spent</th> | |
| 725 | </tr> | |
| 726 | </thead> | |
| 727 | <tbody> | |
| 728 | ${rows.map( | |
| 729 | (r) => html`<tr> | |
| 730 | <td class="mono">${r.day}</td> | |
| 731 | <td class="num">${r.wasted}</td> | |
| 732 | <td class="num">${r.runs}</td> | |
| 733 | <td class="num">$${r.spentUsd.toFixed(2)}</td> | |
| 734 | </tr>`, | |
| 735 | )} | |
| 736 | </tbody> | |
| 737 | </table> | |
| 738 | </div>`, | |
| 739 | { pad: false, head: html`<h2>Wasted runs per day</h2>` }, | |
| 740 | ); | |
| 741 | } | |
| 742 | ||
| 710 | 743 | /** The scorecard cut by one thing the runs ran on; nothing before a first run. */ |
| 711 | 744 | function splitsCard( |
| 712 | 745 | title: string, |
test/crew-scorecard-api.test.ts +20
| @@ -112,6 +112,26 @@ describe("the crew scorecard over the API and MCP", () => { | ||
| 112 | 112 | expect(idle).toEqual({ username: "idle", all: null, recent: null, splits: [] }); |
| 113 | 113 | }); |
| 114 | 114 | |
| 115 | test("wastedDays rides on the API and the MCP tool", async () => { | |
| 116 | const issue = app.db.query<{ repo_id: string }, []>("SELECT repo_id FROM issue LIMIT 1").get()!; | |
| 117 | const smith = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'smith'").get()!; | |
| 118 | const put = (summary: string, cost: number) => | |
| 119 | app.db | |
| 120 | .query( | |
| 121 | `INSERT INTO run (id, repo_id, kind, actor_id, state, summary, cost_usd) | |
| 122 | VALUES (?1, ?2, 'agent', ?3, 'ok', ?4, ?5)`, | |
| 123 | ) | |
| 124 | .run(crypto.randomUUID(), issue.repo_id, smith.id, summary, cost); | |
| 125 | put("no changes pushed", 0.5); | |
| 126 | put("opened pull #1", 1); | |
| 127 | const day = new Date().toISOString().slice(0, 10); | |
| 128 | const api = (await get(admin).then((r) => r.json())) as { wastedDays: any[] }; | |
| 129 | expect(api.wastedDays.find((d) => d.day === day)).toEqual({ day, wasted: 1, runs: 2, spentUsd: 0.5 }); | |
| 130 | const called = await rpc(admin, "tools/call", { name: "scorecard", arguments: {} }); | |
| 131 | expect(JSON.parse(called.result.content[0].text).wastedDays).toEqual(api.wastedDays); | |
| 132 | app.db.query("DELETE FROM run WHERE kind = 'agent'").run(); | |
| 133 | }); | |
| 134 | ||
| 115 | 135 | test("a non-admin gets 403, no token 401", async () => { |
| 116 | 136 | expect((await get(member)).status).toBe(403); |
| 117 | 137 | expect((await get()).status).toBe(401); |
test/crew-scorecard.test.ts +21
| @@ -110,6 +110,27 @@ describe("a scorecard per crew member", () => { | ||
| 110 | 110 | app.db.query("DELETE FROM run WHERE id = ?1").run(run.id); |
| 111 | 111 | }); |
| 112 | 112 | |
| 113 | test("an admin's page shows the wasted runs per day, and a guest's shows nothing", async () => { | |
| 114 | const get = (headers: Record<string, string>) => | |
| 115 | fetch(`${base}/settings/crew`, { headers }).then((r) => r.text()); | |
| 116 | expect(await get({ cookie })).not.toContain(`id="crew-wasted-days"`); | |
| 117 | for (const summary of ["no changes pushed", "already done: abc", "opened pull #9"]) { | |
| 118 | const run = createRun(app.db, { repoId, kind: "agent", actorId: agentId }); | |
| 119 | claimRun(app.db, run.id); | |
| 120 | finishRun(app.db, run.id, { state: "ok", summary, costUsd: 0.25 }); | |
| 121 | } | |
| 122 | const page = await get({ cookie }); | |
| 123 | const table = page.slice(page.indexOf(`id="crew-wasted-days"`)); | |
| 124 | expect(page).toContain("Wasted runs per day"); | |
| 125 | const day = new Date().toISOString().slice(0, 10); | |
| 126 | expect(table).toContain(`<td class="mono">${day}</td> | |
| 127 | <td class="num">2</td> | |
| 128 | <td class="num">3</td> | |
| 129 | <td class="num">$0.50</td>`); | |
| 130 | expect(await get({})).not.toContain(`id="crew-wasted-days"`); | |
| 131 | app.db.query("DELETE FROM run WHERE kind = 'agent'").run(); | |
| 132 | }); | |
| 133 | ||
| 113 | 134 | test("an agent with no pull is a row of dashes, not zeros", async () => { |
| 114 | 135 | expect(crewScorecards(app.db).get(agentId)).toBeUndefined(); |
| 115 | 136 | const page = await fetch(`${base}/settings/crew`, { |
test/pull-variants.test.ts +3 −1
| @@ -155,11 +155,13 @@ describe("a tell asks for variants", () => { | ||
| 155 | 155 | let repo: Repo; |
| 156 | 156 | let auth: Record<string, string>; |
| 157 | 157 | |
| 158 | // Each tell has its own words: the same words twice are one job (`crew/twin.ts`). | |
| 159 | let told = 0; | |
| 158 | 160 | const tellOver = (body: Record<string, unknown>) => |
| 159 | 161 | fetch(`${servers.url}/api/v1/feed/tells`, { |
| 160 | 162 | method: "POST", |
| 161 | 163 | headers: { ...auth, "content-type": "application/json" }, |
| 162 | body: JSON.stringify({ app: "alice/square", text: "Make it purple", ...body }), | |
| 164 | body: JSON.stringify({ app: "alice/square", text: `Make it purple ${++told}`, ...body }), | |
| 163 | 165 | }); |
| 164 | 166 | const runs = () => listRuns(app.db, repo.id, { kind: "agent" }); |
| 165 | 167 |
test/tui-crew-splits.test.ts +15
| @@ -81,6 +81,21 @@ test("an admin's terminal shows the cuts, with the empty key as default / not re | ||
| 81 | 81 | h.stop(); |
| 82 | 82 | }); |
| 83 | 83 | |
| 84 | test("an admin's terminal lists the wasted runs per day", async () => { | |
| 85 | const repoId = forge.db.query<{ id: string }, []>("SELECT id FROM repo LIMIT 1").get()?.id ?? ""; | |
| 86 | const agentId = getUserByName(forge.db, "smith")!.id; | |
| 87 | const run = createRun(forge.db, { repoId, kind: "agent", actorId: agentId }); | |
| 88 | claimRun(forge.db, run.id); | |
| 89 | finishRun(forge.db, run.id, { state: "ok", summary: "no changes pushed", costUsd: 0.4 }); | |
| 90 | const h = boot("alice"); | |
| 91 | await h.palette("Crew scorecard"); | |
| 92 | await h.enter(); | |
| 93 | expect(h.text()).toContain(`wasted ${new Date().toISOString().slice(0, 10)}`); | |
| 94 | expect(h.text()).toContain("1 of 3 runs"); | |
| 95 | expect(h.text()).toContain("$0.40 spent"); | |
| 96 | h.stop(); | |
| 97 | }); | |
| 98 | ||
| 84 | 99 | test("a non-admin has no command", async () => { |
| 85 | 100 | const h = boot("bob"); |
| 86 | 101 | await h.palette("Crew scorecard"); |
test/twin-runs.test.ts added +183
| @@ -0,0 +1,183 @@ | ||
| 1 | /** | |
| 2 | * The crew does not build the same thing twice (#7792): a build run on an | |
| 3 | * issue whose twin (same title) has a run going, a pull open or a pull | |
| 4 | * merged is refused; and wasted runs are counted per day. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 12 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 16 | import { createIssue, listComments, type Issue } from "../src/core/issues.ts"; | |
| 17 | import { sweepAssigned } from "../src/crew/dispatch.ts"; | |
| 18 | import { twinOf } from "../src/crew/twin.ts"; | |
| 19 | import { isWasted, wastedDays } from "../src/core/wasted_days.ts"; | |
| 20 | ||
| 21 | describe("twins", () => { | |
| 22 | let dataDir: string; | |
| 23 | let app: App; | |
| 24 | let alice: User; | |
| 25 | let bot: User; | |
| 26 | let repo: Repo; | |
| 27 | ||
| 28 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 29 | const open = (title: string): Issue => { | |
| 30 | const o = openIssue(app, alice, fresh(), { title, body: "" }); | |
| 31 | if (!o.ok) throw new Error(String(o.error)); | |
| 32 | return o.value; | |
| 33 | }; | |
| 34 | const assign = (issue: Issue): void => { | |
| 35 | const a = assignIssue(app, alice, fresh(), issue, bot); | |
| 36 | if (!a.ok) throw new Error(String(a.error)); | |
| 37 | }; | |
| 38 | const runsOn = (issue: Issue): number => | |
| 39 | app.db | |
| 40 | .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM run WHERE issue_id = ?1 AND kind = 'agent'") | |
| 41 | .get(issue.id)!.n; | |
| 42 | /** A pull on `branch`, as the store would hold it. */ | |
| 43 | const pullOn = (title: string, branch: string, merged = false): Issue => { | |
| 44 | const p = createIssue(app.db, { repoId: repo.id, authorId: bot.id, title, body: "", kind: "pull" }); | |
| 45 | if (!p.ok) throw new Error(p.error); | |
| 46 | app.db | |
| 47 | .query( | |
| 48 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) | |
| 49 | VALUES (?1, 'main', ?2, 'a', 'b', ?3, ?4)`, | |
| 50 | ) | |
| 51 | .run(p.value.id, branch, repo.id, merged ? new Date().toISOString() : null); | |
| 52 | if (merged) app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(p.value.id); | |
| 53 | return p.value; | |
| 54 | }; | |
| 55 | ||
| 56 | beforeAll(async () => { | |
| 57 | dataDir = await mkdtemp(join(tmpdir(), "forge-twin-")); | |
| 58 | app = createApp({ dataDir, port: 0 }); | |
| 59 | const a = await createUser(app.db, { | |
| 60 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 61 | password: "a-good-passphrase", adminIfFirst: true, | |
| 62 | }); | |
| 63 | if (!a.ok) throw new Error(a.error); | |
| 64 | alice = a.value; | |
| 65 | const made = await createRepo(app, alice, { name: "svc", visibility: "private" }); | |
| 66 | if (!made.ok) throw new Error(String(made.error)); | |
| 67 | repo = made.value; | |
| 68 | const created = await createAgent(app, alice, { username: "bot" }); | |
| 69 | if (!created.ok) throw new Error(String(created.error)); | |
| 70 | expect(addToCrew(app, alice, repo, "bot").ok).toBe(true); | |
| 71 | bot = getUserByName(app.db, "bot")!; | |
| 72 | }); | |
| 73 | ||
| 74 | afterAll(async () => { | |
| 75 | app.close(); | |
| 76 | await rm(dataDir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a second issue with the title of one already running starts no run", () => { | |
| 80 | const first = open("Lighthouse"); | |
| 81 | const second = open(" lighthouse "); | |
| 82 | assign(first); | |
| 83 | sweepAssigned(app); | |
| 84 | expect(runsOn(first)).toBe(1); | |
| 85 | assign(second); | |
| 86 | sweepAssigned(app); | |
| 87 | expect(runsOn(second)).toBe(0); | |
| 88 | expect(twinOf(app.db, second)).toEqual({ number: first.number, standing: "running" }); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a twin whose pull is open or landed holds the work", () => { | |
| 92 | const first = open("Pottery Wheel"); | |
| 93 | pullOn("Pottery wheel, spinning", `bot/${first.number}-pottery-wheel`); | |
| 94 | const second = open("Pottery Wheel"); | |
| 95 | assign(second); | |
| 96 | sweepAssigned(app); | |
| 97 | expect(runsOn(second)).toBe(0); | |
| 98 | expect(twinOf(app.db, second)?.standing).toBe("open"); | |
| 99 | ||
| 100 | const done = open("Sled Run"); | |
| 101 | const again = open("Sled Run"); | |
| 102 | pullOn("Sled Run", `bot/${done.number}-sled`, true); | |
| 103 | assign(again); | |
| 104 | sweepAssigned(app); | |
| 105 | expect(runsOn(again)).toBe(0); | |
| 106 | expect(twinOf(app.db, again)?.standing).toBe("landed"); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a pull titled the same stands in front, and a different title does not", () => { | |
| 110 | const dup = open("Domino Run"); | |
| 111 | pullOn("Domino Run", "bot/unrelated", true); | |
| 112 | assign(dup); | |
| 113 | sweepAssigned(app); | |
| 114 | expect(runsOn(dup)).toBe(0); | |
| 115 | const other = open("Something else"); | |
| 116 | assign(other); | |
| 117 | sweepAssigned(app); | |
| 118 | expect(runsOn(other)).toBe(1); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a pull merged before the issue was opened does not hold a title that comes round again", () => { | |
| 122 | pullOn("Fix typo", "bot/1-fix-typo", true); | |
| 123 | app.db.query("UPDATE pull SET merged_at = '2020-01-01T00:00:00.000Z' WHERE head_branch = 'bot/1-fix-typo'").run(); | |
| 124 | const again = open("Fix typo"); | |
| 125 | expect(twinOf(app.db, again)).toBeUndefined(); | |
| 126 | assign(again); | |
| 127 | sweepAssigned(app); | |
| 128 | expect(runsOn(again)).toBe(1); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a refusal is said on the issue, once, naming the twin", () => { | |
| 132 | const first = open("Weather Vane"); | |
| 133 | assign(first); | |
| 134 | sweepAssigned(app); | |
| 135 | const second = open("Weather vane"); | |
| 136 | assign(second); | |
| 137 | sweepAssigned(app); | |
| 138 | sweepAssigned(app); | |
| 139 | const said = listComments(app.db, second.id).filter((c) => c.body.includes("already holds this work")); | |
| 140 | expect(said).toHaveLength(1); | |
| 141 | expect(said[0]!.body).toContain(`#${first.number} already holds this work (running)`); | |
| 142 | expect(said[0]!.authorId).toBe(bot.id); | |
| 143 | }); | |
| 144 | ||
| 145 | test("an issue's own pull, though titled the same, is not its twin", () => { | |
| 146 | const mine = open("Own pull"); | |
| 147 | pullOn("Own pull", `bot/${mine.number}-own-pull`); | |
| 148 | expect(twinOf(app.db, mine)).toBeUndefined(); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a closed, unmerged pull holds nothing", () => { | |
| 152 | const p = pullOn("Kite", "bot/999-kite"); | |
| 153 | app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(p.id); | |
| 154 | const kite = open("Kite"); | |
| 155 | expect(twinOf(app.db, kite)).toBeUndefined(); | |
| 156 | }); | |
| 157 | ||
| 158 | test("wasted runs are counted per day, and an outage is not one", () => { | |
| 159 | expect(isWasted("the pull closed")).toBe(true); | |
| 160 | expect(isWasted("pull closed")).toBe(true); | |
| 161 | expect(isWasted("no changes pushed")).toBe(true); | |
| 162 | expect(isWasted("already done: abc")).toBe(true); | |
| 163 | expect(isWasted("no changes pushed: getaddrinfo EAI_AGAIN")).toBe(false); | |
| 164 | expect(isWasted("opened pull #3")).toBe(false); | |
| 165 | const issue = open("Wasteful"); | |
| 166 | const put = (summary: string, cost: number) => | |
| 167 | app.db | |
| 168 | .query( | |
| 169 | `INSERT INTO run (id, repo_id, kind, issue_id, actor_id, state, summary, cost_usd) | |
| 170 | VALUES (?1, ?2, 'agent', ?3, ?4, 'ok', ?5, ?6)`, | |
| 171 | ) | |
| 172 | .run(crypto.randomUUID(), repo.id, issue.id, bot.id, summary, cost); | |
| 173 | const now = Date.now(); | |
| 174 | const key = new Date(now).toISOString().slice(0, 10); | |
| 175 | const dayOf = () => wastedDays(app.db, 7, now).find((d) => d.day === key); | |
| 176 | const before = dayOf()?.wasted ?? 0; | |
| 177 | put("no changes pushed", 0.5); | |
| 178 | put("opened pull #3", 1); | |
| 179 | const today = dayOf()!; | |
| 180 | expect(today.wasted).toBe(before + 1); | |
| 181 | expect(today.spentUsd).toBeGreaterThanOrEqual(0.5); | |
| 182 | }); | |
| 183 | }); |
new above
offline
A merged pull with the same title blocks this issue permanently. Bound it by time or by relation to this issue, and test the bound.