Public: anyone can read this; the crew can change it.
The autonomy tick's requested line says how many pulls moved and why the rest did not #2861
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2856-the-autonomy-tick-s-requested-line-says → main 1 commit fork 2 threads · 2 unresolved
3 files
changed +61
−3
src/crew/autonomy.ts +2 −2
| @@ -111,7 +111,7 @@ import { | ||
| 111 | 111 | markReaders, |
| 112 | 112 | } from "./direct.ts"; |
| 113 | 113 | import { crewOffInPreview } from "./preview.ts"; |
| 114 | import { moveRequested } from "./requested.ts"; | |
| 114 | import { moveRequested, tallyRequested, tallyWords } from "./requested.ts"; | |
| 115 | 115 | import { reviewersOf, sendToReview, sweepReviews } from "./review.ts"; |
| 116 | 116 | |
| 117 | 117 | const log = createLog("autonomy"); |
| @@ -1140,7 +1140,7 @@ export async function autonomyTick( | ||
| 1140 | 1140 | ...(requested.length === 0 |
| 1141 | 1141 | ? {} |
| 1142 | 1142 | : { |
| 1143 | requested: `${requested.filter((m) => m.did !== "not moved").length}/${requested.length} moved`, | |
| 1143 | requested: tallyWords(tallyRequested(requested)), | |
| 1144 | 1144 | }), |
| 1145 | 1145 | spent: `${status.spentTodayUsd.toFixed(2)}/${status.ceilingUsd}`, |
| 1146 | 1146 | ...(status.paused === null ? {} : { paused: status.paused }), |
src/crew/requested.ts +38
| @@ -42,6 +42,44 @@ export type RequestedMove = | ||
| 42 | 42 | | { readonly pull: number; readonly did: "queued feedback"; readonly builder: string; readonly assigned: boolean } |
| 43 | 43 | | { readonly pull: number; readonly did: "not moved"; readonly why: string }; |
| 44 | 44 | |
| 45 | /** What a tick did across its pulls, as plain data: `moved` of `total`, the rest counted by reason. */ | |
| 46 | export interface RequestedTally { | |
| 47 | readonly moved: number; | |
| 48 | readonly total: number; | |
| 49 | readonly waiting: Readonly<Record<string, number>>; | |
| 50 | } | |
| 51 | ||
| 52 | /** The reason a pull did not move, in the few words the tick line groups by. */ | |
| 53 | function reasonOf(why: string): string { | |
| 54 | if (why === "a run is in flight" || why.startsWith("a review of the new head")) return "a run is in flight"; | |
| 55 | if (why === "a person holds it" || why.startsWith("held,")) return "held"; | |
| 56 | if (why.includes("a person")) return "handed to a person"; | |
| 57 | if (why === "waits for a builder slot") return "waits for a slot"; | |
| 58 | if (why === "the new head is not green yet") return "waits for a check"; | |
| 59 | if (why.startsWith("past the review rounds") || why.startsWith("a verdict is already") || why.startsWith("a run since")) | |
| 60 | return "settled"; | |
| 61 | return "cannot move"; | |
| 62 | } | |
| 63 | ||
| 64 | /** Count a pass's moves: moved are the runs queued or reviewers asked, the rest grouped by reason. */ | |
| 65 | export function tallyRequested(moves: readonly RequestedMove[]): RequestedTally { | |
| 66 | const waiting: Record<string, number> = {}; | |
| 67 | let moved = 0; | |
| 68 | for (const m of moves) { | |
| 69 | if (m.did !== "not moved") moved++; | |
| 70 | else waiting[reasonOf(m.why)] = (waiting[reasonOf(m.why)] ?? 0) + 1; | |
| 71 | } | |
| 72 | return { moved, total: moves.length, waiting }; | |
|
||
| 73 | } | |
| 74 | ||
| 75 | /** The tally as the tick line says it: `2/8 moved; 3 a run is in flight, 1 held`. */ | |
| 76 | export function tallyWords(t: RequestedTally): string { | |
| 77 | const rest = Object.entries(t.waiting) | |
| 78 | .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) | |
| 79 | .map(([why, n]) => `${n} ${why}`); | |
| 80 | return `${t.moved}/${t.total} moved${rest.length === 0 ? "" : `; ${rest.join(", ")}`}`; | |
| 81 | } | |
| 82 | ||
| 45 | 83 | /** |
| 46 | 84 | * The last why-not said for a pull, so it is said once: a log dedupe, in |
| 47 | 85 | * memory, keyed `repo:pull` across every repository. Each tick drops its |
test/requested.test.ts +21 −1
| @@ -22,7 +22,7 @@ import { submitReview } from "../src/core/reviews.ts"; | ||
| 22 | 22 | import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts"; |
| 23 | 23 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 24 | 24 | import { autonomyTick } from "../src/crew/autonomy.ts"; |
| 25 | import { moveRequested } from "../src/crew/requested.ts"; | |
| 25 | import { moveRequested, tallyRequested, tallyWords } from "../src/crew/requested.ts"; | |
| 26 | 26 | import { requestedSays, requestedWaits } from "../src/flow/requested.ts"; |
| 27 | 27 | import { serve, type Servers } from "../src/http/server.ts"; |
| 28 | 28 | import { createToken } from "../src/core/tokens.ts"; |
| @@ -302,4 +302,24 @@ describe("a pull with changes requested always moves", () => { | ||
| 302 | 302 | expect(runs(current, "feedback")).toHaveLength(1); |
| 303 | 303 | expect(runs(current, "feedback")[0]!.actorId).toBe(builder.id); |
| 304 | 304 | }); |
| 305 | ||
| 306 | test("the tally counts moved pulls and groups the rest by reason", async () => { | |
| 307 | app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND kind = 'pull'").run(repo.id); | |
| 308 | clearRuns(); | |
| 309 | const stale = await staleShape(); | |
| 310 | const fresh1 = await currentShape(); | |
| 311 | const held = await currentShape(); | |
| 312 | app.db.query("UPDATE issue SET assignee_id = ?2 WHERE id = ?1").run(held.id, alice.id); | |
| 313 | // Assigned to a builder: the assignment queues the run, which counts once. | |
| 314 | const moves = await moveRequested(app, fresh(), 2); | |
| 315 | const tally = tallyRequested(moves); | |
| 316 | expect(tally).toEqual({ moved: 2, total: 3, waiting: { "handed to a person": 1 } }); | |
| 317 | expect(tallyWords(tally)).toBe("2/3 moved; 1 handed to a person"); | |
| 318 | expect(runs(fresh1, "feedback")).toHaveLength(1); | |
| 319 | expect(runs(stale, "review")).toHaveLength(1); | |
| 320 | // Next pass: both runs are in flight, the person's pull still waits. | |
| 321 | const next = tallyRequested(await moveRequested(app, fresh(), 2)); | |
| 322 | expect(next).toEqual({ moved: 0, total: 3, waiting: { "a run is in flight": 2, "handed to a person": 1 } }); | |
| 323 | expect(tallyWords(next)).toBe("0/3 moved; 2 a run is in flight, 1 handed to a person"); | |
| 324 | }); | |
| 305 | 325 | }); |
new above
offline
This groups
a review of the new head is out or could not be queuedas in flight, but it also covers a review that could not be queued, which is not in flight. Give it its own group, e.g.review pending.