Public: anyone can read this; the crew can change it.
The pacer's burn matches the meter: it says 1.4% an hour while the real meter hasn't moved in over an hour #8912
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8910-the-pacer-s-burn-matches-the-meter-it-sa → main 2 commits fork 2 threads · 2 unresolved
12 files
changed +300
−39
src/core/migration-pins/20261103140000-week-readings.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | aa041cedda14e8670bc9900017964050ccfd81c227cd93bb78e20ac0e5e5723c |
src/core/migrations/20261103140000-week-readings.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | // The week's readings (#8910): the share of the provider's week still free, | |
| 2 | // stored as the crew reads it, so the burn is the change in the measured | |
| 3 | // percent over time and never dollars turned into percent. Additive. | |
| 4 | export default ` | |
| 5 | CREATE TABLE week_reading ( | |
| 6 | scope TEXT NOT NULL, | |
| 7 | at TEXT NOT NULL, | |
| 8 | free_pct REAL NOT NULL, | |
| 9 | PRIMARY KEY (scope, at) | |
| 10 | ) WITHOUT ROWID; | |
| 11 | `; |
src/crew/dispatch.ts +7 −2
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { TEMP_FULL } from "../checks/disk_full.ts"; |
| 12 | 12 | import { freshPullFor } from "./fresh_pull.ts"; |
| 13 | import { weekHold } from "./week_hold.ts"; | |
| 13 | import { PRIORITY_REASONS, weekHold } from "./week_hold.ts"; | |
| 14 | 14 | import { weekFor } from "./week_read.ts"; |
| 15 | 15 | import { twinNote, twinOf, type Twin } from "./twin.ts"; |
| 16 | 16 | import { NO_MERGE } from "../checks/merged.ts"; |
| @@ -1432,6 +1432,7 @@ function pump( | ||
| 1432 | 1432 | runningIssues.size; |
| 1433 | 1433 | const started: string[] = []; |
| 1434 | 1434 | const goingIn = new Map<string, number>(); |
| 1435 | const priorityIn = new Map<string, number>(); | |
| 1435 | 1436 | const noted = notedBy.get(app) ?? new Set<string>(); |
| 1436 | 1437 | notedBy.set(app, noted); |
| 1437 | 1438 | // Builder starts go to teams in proportion to their dials (`team_share.ts`), |
| @@ -1463,7 +1464,10 @@ function pump( | ||
| 1463 | 1464 | // repository: assigned, review, feedback and check runs as much as picks (#8873). |
| 1464 | 1465 | if (home !== undefined && home.autonomy === "autonomous") { |
| 1465 | 1466 | const going = (goingIn.get(home.id) ?? 0) + active.filter((r) => r.state === "running" && r.repoId === home.id).length; |
| 1466 | const held = weekHold(weekFor(app, home, new Date()), home.autonomySlots, going); | |
| 1467 | const priorityGoing = | |
| 1468 | (priorityIn.get(home.id) ?? 0) + | |
| 1469 | active.filter((r) => r.state === "running" && r.repoId === home.id && PRIORITY_REASONS.includes(r.reason)).length; | |
| 1470 | const held = weekHold(weekFor(app, home, new Date()), home.autonomySlots, going - priorityGoing, fresh.reason, priorityGoing); | |
| 1467 | 1471 | if (held !== null) { |
| 1468 | 1472 | if (!noted.has(fresh.id)) { |
| 1469 | 1473 | noted.add(fresh.id); |
| @@ -1490,6 +1494,7 @@ function pump( | ||
| 1490 | 1494 | } |
| 1491 | 1495 | started.push(fresh.id); |
| 1492 | 1496 | goingIn.set(fresh.repoId, (goingIn.get(fresh.repoId) ?? 0) + 1); |
| 1497 | if (PRIORITY_REASONS.includes(fresh.reason)) priorityIn.set(fresh.repoId, (priorityIn.get(fresh.repoId) ?? 0) + 1); | |
| 1493 | 1498 | noted.delete(fresh.id); |
| 1494 | 1499 | slots--; |
| 1495 | 1500 | if (queued.issueId !== null) runningIssues.add(queued.issueId); |
src/crew/week.ts +7 −6
| @@ -126,15 +126,16 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 126 | 126 | slots = Math.min(ceiling, Math.max(0, slots)); |
| 127 | 127 | |
| 128 | 128 | const release = status === "behind" && input.idleSlots > 0 && input.startable === 0 && input.gated > 0; |
| 129 | const weekLeft = `${pct(meter.freePct)} of the week left, reset ${utc(meter.resetsAt)}`; | |
| 129 | const weekLeft = `${Math.round(meter.freePct)}% of the week left, reset ${utc(meter.resetsAt)}`; | |
| 130 | const burnWords = burn === null ? "unread (too few readings)" : `${pct(burn)} an hour`; | |
| 130 | 131 | const sentence = |
| 131 | burn === null | |
| 132 | ? `Burn not known yet: ${weekLeft}; starting at ${slots} of ${ceiling} slots and adjusting within the hour.` | |
| 132 | burn === null && !burning | |
| 133 | ? `Burn not known yet: too few readings of the meter; ${weekLeft}; holding at ${slots} of ${ceiling} slots until it has them.` | |
| 133 | 134 | : status === "on pace" |
| 134 | ? `On pace: ${weekLeft}; burning ${pct(burn)} an hour, ${pct(allowed)} is allowed.` | |
| 135 | ? `On pace: ${weekLeft}; burning ${burnWords}, ${pct(allowed)} is allowed.` | |
| 135 | 136 | : status === "ahead" |
| 136 | ? `Ahead of pace: ${weekLeft}, burning ${pct(burn)} an hour against ${pct(allowed)} allowed${runsOutAt === null ? "" : `, running out about ${utc(runsOutAt)}`}; ${slots === 0 ? "holding the crew for the reset" : `slowing to ${slots} of ${ceiling} slots`}.` | |
| 137 | : `Behind pace: ${weekLeft}, burning ${pct(burn)} an hour against ${pct(allowed)} allowed; ${slots === 0 ? "holding the crew for the reset" : `speeding up to ${slots} of ${ceiling} slots`}${release ? " and releasing the next issue" : ""}.`; | |
| 137 | ? `Ahead of pace: ${weekLeft}, burning ${burnWords} against ${pct(allowed)} allowed${runsOutAt === null ? "" : `, running out about ${utc(runsOutAt)}`}; ${slots === 0 ? "holding the crew for the reset" : `slowing to ${slots} of ${ceiling} slots`}.` | |
| 138 | : `Behind pace: ${weekLeft}, burning ${burnWords} against ${pct(allowed)} allowed; ${slots === 0 ? "holding the crew for the reset" : `speeding up to ${slots} of ${ceiling} slots`}${release ? " and releasing the next issue" : ""}.`; | |
| 138 | 139 | return { status, targetPctPerHour, allowedPctPerHour: allowed, runsOutAt, slots, release, sentence }; |
| 139 | 140 | } |
| 140 | 141 |
src/crew/week_burn.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** | |
| 2 | * The week's burn (#8910): the change in the measured share of the week over | |
| 3 | * time, from stored readings of the meter. Dollars are never turned into | |
| 4 | * percent here. `burnFrom` is pure; `noteReading` and `readingsSince` keep | |
| 5 | * the readings in `week_reading`. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | ||
| 10 | /** One reading of the meter: the share of the week still free, 0..100. */ | |
| 11 | export interface Reading { | |
| 12 | readonly at: number; | |
| 13 | readonly freePct: number; | |
| 14 | } | |
| 15 | ||
| 16 | /** Readings closer than this are one reading. */ | |
| 17 | export const READING_GAP_MS = 60_000; | |
| 18 | /** Readings further apart than this say nothing about the pace now. */ | |
| 19 | export const BURN_MIN_SPAN_MS = 15 * 60_000; | |
| 20 | ||
| 21 | /** | |
| 22 | * Percent of the week spent an hour over the `hours` before `now`, or null | |
| 23 | * with too few readings. The baseline is the newest reading at or before the | |
| 24 | * window's start, else the oldest inside it; the span between it and the | |
| 25 | * newest reading must reach `BURN_MIN_SPAN_MS`. A rise (a reset) is no burn. | |
| 26 | */ | |
| 27 | export function burnFrom(readings: readonly Reading[], now: number, hours: number): number | null { | |
| 28 | const seen = readings.filter((r) => r.at <= now).sort((a, b) => a.at - b.at); | |
| 29 | const last = seen[seen.length - 1]; | |
| 30 | if (last === undefined) return null; | |
| 31 | const start = now - hours * 3_600_000; | |
| 32 | const before = seen.filter((r) => r.at <= start); | |
| 33 | const base = before[before.length - 1] ?? seen.find((r) => r.at > start); | |
| 34 | if (base === undefined || last.at - base.at < BURN_MIN_SPAN_MS) return null; | |
| 35 | return Math.max(0, base.freePct - last.freePct) / ((last.at - base.at) / 3_600_000); | |
| 36 | } | |
| 37 | ||
| 38 | /** Store a reading unless one was stored within `READING_GAP_MS`; forget what is older than `keepMs`. */ | |
| 39 | export function noteReading(db: Database, scope: string, now: number, freePct: number, keepMs: number): void { | |
| 40 | const at = new Date(now).toISOString(); | |
| 41 | const newest = db | |
| 42 | .query<{ at: string }, [string]>("SELECT MAX(at) AS at FROM week_reading WHERE scope = ?1") | |
| 43 | .get(scope)?.at; | |
| 44 | if (newest != null && Math.abs(now - Date.parse(newest)) < READING_GAP_MS) return; | |
| 45 | db.query("INSERT OR REPLACE INTO week_reading (scope, at, free_pct) VALUES (?1, ?2, ?3)").run(scope, at, freePct); | |
| 46 | db.query("DELETE FROM week_reading WHERE scope = ?1 AND at < ?2").run(scope, new Date(now - keepMs).toISOString()); | |
| 47 | } | |
| 48 | ||
| 49 | /** The readings of `scope` since `since` (ms). */ | |
| 50 | export function readingsSince(db: Database, scope: string, since: number): Reading[] { | |
| 51 | return db | |
| 52 | .query<{ at: string; free_pct: number }, [string, string]>( | |
| 53 | "SELECT at, free_pct FROM week_reading WHERE scope = ?1 AND at >= ?2 ORDER BY at", | |
| 54 | ) | |
| 55 | .all(scope, new Date(since).toISOString()) | |
| 56 | .map((r) => ({ at: Date.parse(r.at), freePct: r.free_pct })); | |
| 57 | } |
src/crew/week_hold.ts +17 −4
| @@ -4,17 +4,30 @@ | ||
| 4 | 4 | * the decision and the count in, the reason out. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { WeekDecision } from "./week.ts"; | |
| 7 | import { WEEK, type WeekDecision } from "./week.ts"; | |
| 8 | ||
| 9 | /** Why a run is a person's or finished work: these run within `WEEK.floorSlots` of their own, whatever the pace. */ | |
| 10 | export const PRIORITY_REASONS: readonly string[] = ["assigned", "review"]; | |
| 8 | 11 | |
| 9 | 12 | /** |
| 10 | * The words a queued run waits on, or null when it may start: the week | |
| 11 | * allows fewer slots than the ceiling and `going` runs already fill them. | |
| 13 | * The words a queued run waits on, or null when it may start. A pick waits | |
| 14 | * when the week allows fewer slots than the ceiling and `going` picks already | |
| 15 | * fill them; at no slots everything waits. A review or work a person assigned has a lane of its own: it | |
| 16 | * waits only when `WEEK.floorSlots` of its kind are going, so finished work | |
| 17 | * lands first and a build never holds it (#8910). | |
| 12 | 18 | */ |
| 13 | 19 | export function weekHold( |
| 14 | 20 | week: Pick<WeekDecision, "slots" | "sentence"> | null, |
| 15 | 21 | ceiling: number, |
| 16 | 22 | going: number, |
| 23 | reason = "", | |
| 24 | priorityGoing = 0, | |
| 17 | 25 | ): string | null { |
| 18 | if (week === null || week.slots >= ceiling || going < week.slots) return null; | |
| 26 | if (week === null || week.slots >= ceiling) return null; | |
| 27 | // Past the reserve (no slots) the wall holds everything for the reset. | |
| 28 | if (week.slots > 0 && PRIORITY_REASONS.includes(reason)) { | |
| 29 | return priorityGoing < Math.min(ceiling, WEEK.floorSlots) ? null : `queued, not started: ${priorityGoing} of ${WEEK.floorSlots} places for reviews and assigned work are taken, the most the pace lets run at once.`; | |
| 30 | } | |
| 31 | if (going < week.slots) return null; | |
| 19 | 32 | return `queued, not started: ${week.sentence}`; |
| 20 | 33 | } |
src/crew/week_read.ts +20 −21
| @@ -7,6 +7,7 @@ import type { App } from "../app.ts"; | ||
| 7 | 7 | import { listAutonomousRepos, type Repo } from "../core/repos.ts"; |
| 8 | 8 | import { standings, WINDOW_MS } from "../core/standing.ts"; |
| 9 | 9 | import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts"; |
| 10 | import { burnFrom, noteReading, readingsSince } from "./week_burn.ts"; | |
| 10 | 11 | import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts"; |
| 11 | 12 | |
| 12 | 13 | /** The week as the provider's meter reads it: a meter, or null while no account's limit is known. */ |
| @@ -33,20 +34,6 @@ function weekOf(app: App, repo: Repo, now: Date): Week | null { | ||
| 33 | 34 | return week; |
| 34 | 35 | } |
| 35 | 36 | |
| 36 | /** Spend over the last `WEEK.burnHours`, in US dollars, on `ids`. */ | |
| 37 | function recentUsd(app: App, ids: readonly string[], now: Date): number { | |
| 38 | if (ids.length === 0) return 0; | |
| 39 | const since = new Date(now.getTime() - WEEK.burnHours * 3_600_000).toISOString(); | |
| 40 | const marks = ids.map((_, i) => `?${i + 2}`).join(","); | |
| 41 | return ( | |
| 42 | app.db | |
| 43 | .query<{ usd: number | null }, string[]>( | |
| 44 | `SELECT SUM(cost_usd) AS usd FROM usage WHERE at >= ?1 AND at <= ?${ids.length + 2} AND subscription_id IN (${marks})`, | |
| 45 | ) | |
| 46 | .get(since, ...ids, now.toISOString())?.usd ?? 0 | |
| 47 | ); | |
| 48 | } | |
| 49 | ||
| 50 | 37 | function readWeek(app: App, repo: Repo, now: Date): Week | null { |
| 51 | 38 | // A crew with no grant runs on the forge's own token: the account `usage` |
| 52 | 39 | // prints as `platform`. It is metered too, never skipped (#8873). |
| @@ -71,11 +58,18 @@ function readWeek(app: App, repo: Repo, now: Date): Week | null { | ||
| 71 | 58 | // Granted accounts with no known limit are an unknown week; the forge's own token with none is not metered at all. |
| 72 | 59 | return granted.length > 0 ? { meter: null } : null; |
| 73 | 60 | } |
| 61 | // The burn is read off the readings of the meter, not off dollars: the | |
| 62 | // week's free share is stored as it is read and the burn is how fast it fell. | |
| 63 | const freePct = (freeUsd / capUsd) * 100; | |
| 64 | const scope = [...known].sort().join(","); | |
| 65 | const opened = resetsAt - WINDOW_MS.week; | |
| 66 | noteReading(app.db, scope, now.getTime(), freePct, WINDOW_MS.week); | |
| 67 | const readings = readingsSince(app.db, scope, opened); | |
| 74 | 68 | return { |
| 75 | 69 | meter: { |
| 76 | freePct: (freeUsd / capUsd) * 100, | |
| 70 | freePct, | |
| 77 | 71 | keptPct: (keptUsd / capUsd) * 100, |
| 78 | burnPctPerHour: ((recentUsd(app, known, now) / capUsd) * 100) / WEEK.burnHours, | |
| 72 | burnPctPerHour: burnFrom(readings, now.getTime(), WEEK.burnHours), | |
| 79 | 73 | resetsAt: new Date(resetsAt), |
| 80 | 74 | }, |
| 81 | 75 | }; |
| @@ -124,15 +118,20 @@ export function weekFor( | ||
| 124 | 118 | } |
| 125 | 119 | |
| 126 | 120 | /** |
| 127 | * The pace sentence and the efficiency beside it for the first autonomous | |
| 128 | * repository with a crew, as the idle reason picks it; null while no account | |
| 129 | * is metered. Home and the terminal's dashboard print the same words. | |
| 121 | * The pace sentence and the efficiency beside it for the autonomous | |
| 122 | * repository the crew works most this week (the most agent runs; the first by | |
| 123 | * name on a tie), not whichever sorts first: a quiet repository's "nothing | |
| 124 | * landed" is not the forge's. Null while no account is metered. Home and the | |
| 125 | * terminal's dashboard print the same words. | |
weekWords now picks the repository by run count. This behaviour has no test; add one that fails on the old first-by-name pick. |
||
| 130 | 126 | */ |
| 131 | 127 | export function weekWords(app: App, now: Date = new Date()): string | null { |
| 128 | let best: { words: string; runs: number } | null = null; | |
| 132 | 129 | for (const repo of listAutonomousRepos(app.db)) { |
| 133 | 130 | if (repo.autonomy !== "autonomous" || repo.archivedAt !== null) continue; |
| 134 | 131 | const week = weekFor(app, repo, now); |
| 135 | if (week !== null) return `${week.sentence} ${efficiencyWords(weekEfficiency(app, repo, now))}`; | |
| 132 | if (week === null) continue; | |
| 133 | const efficiency = weekEfficiency(app, repo, now); | |
| 134 | if (best === null || efficiency.runs > best.runs) best = { words: `${week.sentence} ${efficiencyWords(efficiency)}`, runs: efficiency.runs }; | |
| 136 | 135 | } |
| 137 | return null; | |
| 136 | return best?.words ?? null; | |
| 138 | 137 | } |
test/week-burn.test.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** The burn is the change in the measured percent over time (#8910), replayed on the readings of 2026-11-02. */ | |
| 2 | import { Database } from "bun:sqlite"; | |
| 3 | import { describe, expect, test } from "bun:test"; | |
| 4 | import migration from "../src/core/migrations/20261103140000-week-readings.ts"; | |
| 5 | import { burnFrom, noteReading, readingsSince, type Reading } from "../src/crew/week_burn.ts"; | |
| 6 | ||
| 7 | const WEEK_MS = 7 * 86_400_000; | |
| 8 | const migrationSql = (): string => migration; | |
| 9 | const at = (hhmm: string): number => Date.parse(`2026-11-02T${hhmm}:00Z`); | |
| 10 | ||
| 11 | /** What the `usage` tool read: 54% at 16:33Z, 46% at 19:25Z, then 45% from 20:05Z to 21:15Z every five minutes. */ | |
| 12 | const readings: Reading[] = [{ at: at("16:33"), freePct: 54 }, { at: at("19:25"), freePct: 46 }]; | |
| 13 | for (let t = at("20:05"); t <= at("21:15"); t += 300_000) readings.push({ at: t, freePct: 45 }); | |
| 14 | ||
| 15 | test("at 21:15Z the meter has barely moved in the hour, so the burn is nowhere near 1.4% an hour", () => { | |
| 16 | const burn = burnFrom(readings, at("21:15"), 1)!; | |
| 17 | expect(burn).toBeLessThan(0.2); | |
| 18 | expect(burn).toBeGreaterThanOrEqual(0); | |
| 19 | }); | |
| 20 | ||
| 21 | test("at 19:30Z the burn is the fall since 16:33Z, about 2.7 points an hour", () => { | |
| 22 | expect(burnFrom(readings, at("19:30"), 1)!).toBeCloseTo(8 / (172 / 60), 1); | |
| 23 | }); | |
| 24 | ||
| 25 | test("too few readings say so: none, one, or two a minute apart", () => { | |
| 26 | expect(burnFrom([], at("21:15"), 1)).toBeNull(); | |
| 27 | expect(burnFrom([readings[0]!], at("21:15"), 1)).toBeNull(); | |
| 28 | expect(burnFrom([{ at: at("21:00"), freePct: 45 }, { at: at("21:01"), freePct: 44 }], at("21:02"), 1)).toBeNull(); | |
| 29 | }); | |
| 30 | ||
| 31 | test("a rise, a reset, is no burn", () => { | |
| 32 | expect(burnFrom([{ at: at("20:00"), freePct: 10 }, { at: at("21:00"), freePct: 100 }], at("21:00"), 1)).toBe(0); | |
| 33 | }); | |
| 34 | ||
| 35 | describe("the readings store", () => { | |
| 36 | const db = new Database(":memory:"); | |
| 37 | db.exec(migrationSql()); | |
| 38 | const T = at("12:00"); | |
| 39 | ||
| 40 | test("a reading within a minute of the last is one reading; a minute on is a new one", () => { | |
| 41 | noteReading(db, "s", T, 50, WEEK_MS); | |
| 42 | noteReading(db, "s", T + 59_000, 49, WEEK_MS); | |
| 43 | expect(readingsSince(db, "s", 0)).toEqual([{ at: T, freePct: 50 }]); | |
| 44 | noteReading(db, "s", T + 60_000, 49, WEEK_MS); | |
| 45 | expect(readingsSince(db, "s", 0).map((r) => r.freePct)).toEqual([50, 49]); | |
| 46 | }); | |
| 47 | ||
| 48 | test("scopes are apart, and since leaves out the older", () => { | |
| 49 | noteReading(db, "other", T, 10, WEEK_MS); | |
| 50 | expect(readingsSince(db, "other", 0)).toEqual([{ at: T, freePct: 10 }]); | |
| 51 | expect(readingsSince(db, "s", T + 30_000)).toEqual([{ at: T + 60_000, freePct: 49 }]); | |
| 52 | }); | |
| 53 | ||
| 54 | test("what is older than a week is forgotten on the next reading", () => { | |
| 55 | noteReading(db, "s", T + WEEK_MS + 120_000, 40, WEEK_MS); | |
| 56 | expect(readingsSince(db, "s", 0)).toEqual([{ at: T + WEEK_MS + 120_000, freePct: 40 }]); | |
| 57 | }); | |
| 58 | ||
| 59 | test("stored readings give the burn", () => { | |
| 60 | noteReading(db, "b", T, 60, WEEK_MS); | |
| 61 | noteReading(db, "b", T + 3_600_000, 57, WEEK_MS); | |
| 62 | expect(burnFrom(readingsSince(db, "b", 0), T + 3_600_000, 1)).toBeCloseTo(3, 5); | |
| 63 | }); | |
| 64 | }); |
test/week-hold.test.ts +10
| @@ -14,3 +14,13 @@ test("below the week's slots a run starts; at them it waits, with the sentence", | ||
| 14 | 14 | expect(weekHold(week, 4, 2)).toBe("queued, not started: Ahead of pace."); |
| 15 | 15 | expect(weekHold({ ...week, slots: 0 }, 4, 0)).toBe("queued, not started: Ahead of pace."); |
| 16 | 16 | }); |
| 17 | ||
| 18 | test("a review or assigned run has its own lane of two, so a build never holds it (#8910)", () => { | |
| 19 | const slowed = { slots: 1, sentence: "Ahead of pace." }; | |
| 20 | expect(weekHold(slowed, 6, 1, "build", 0)).not.toBeNull(); | |
| 21 | expect(weekHold(slowed, 6, 1, "review", 0)).toBeNull(); | |
| 22 | expect(weekHold(slowed, 6, 1, "assigned", 1)).toBeNull(); | |
| 23 | expect(weekHold(slowed, 6, 1, "assigned", 2)).not.toBeNull(); | |
| 24 | // At the reserve's wall everything waits for the reset. | |
| 25 | expect(weekHold({ slots: 0, sentence: "Ahead." }, 6, 0, "review", 0)).not.toBeNull(); | |
| 26 | }); |
test/week-pump.test.ts +56 −1
| @@ -5,7 +5,8 @@ import { addToCrew } from "../src/actions/crew.ts"; | ||
| 5 | 5 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 6 | 6 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 7 | 7 | import { getRepoById } from "../src/core/repos.ts"; |
| 8 | import { createRun, listRuns } from "../src/core/runs.ts"; | |
| 8 | import { setCapacity } from "../src/core/capacity.ts"; | |
| 9 | import { claimRun, createRun, getRun, listRuns } from "../src/core/runs.ts"; | |
| 9 | 10 | import { noteLimit, setHeadroom } from "../src/core/standing.ts"; |
| 10 | 11 | import { addSubscription } from "../src/core/subscriptions.ts"; |
| 11 | 12 | import { forgetWeek } from "../src/crew/week_read.ts"; |
| @@ -65,3 +66,57 @@ test("past the week's reserve an assigned run stays queued, with the reason in i | ||
| 65 | 66 | await until(() => mine()?.state !== "queued", "the run to start"); |
| 66 | 67 | expect(mine()?.state).not.toBe("queued"); |
| 67 | 68 | }, 30_000); |
| 69 | ||
| 70 | test("builds fill the pace slots, yet a review or assigned run still starts; a third of them waits (#8910)", async () => { | |
| 71 | const { app, alice, bot } = standIn; | |
| 72 | const made = await createRepo(app, alice, { name: "lane", visibility: "private" }); | |
| 73 | if (!made.ok) throw new Error(String(made.error)); | |
| 74 | const seeded = await commitToBranch(app, alice, made.value, { message: "Start", files: [{ path: "README.md", content: "# lane\n" }] }); | |
| 75 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 76 | expect(addToCrew(app, alice, made.value, "bot").ok).toBe(true); | |
| 77 | const sub = addSubscription(app.db, standIn.dataDir, { name: "lane", token: "sk-ant-oat01-week-pump-2" }); | |
| 78 | if (!sub.ok) throw new Error(sub.error); | |
| 79 | setHeadroom(app.db, sub.value.id, 0); | |
| 80 | app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id); | |
| 81 | setCapacity(app.db, "crew.concurrency", "10", alice.id); | |
| 82 | const now = Date.now(); | |
| 83 | const spend = (usd: number, at: number) => { | |
| 84 | const run = createRun(app.db, { repoId: made.value.id, kind: "agent" }); | |
| 85 | recordUsage(app.db, { runId: run.id, repoId: made.value.id, issueId: null, agentId: null, subscriptionId: sub.value.id, tokensIn: 1, tokensOut: 1, costUsd: usd, wallMs: 1000, outcome: "ok", at: new Date(at).toISOString() }); | |
| 86 | }; | |
| 87 | spend(100, now - 4 * DAY); | |
| 88 | noteLimit(app.db, sub.value.id, { window: "week", resetsAt: new Date(now - 3.5 * DAY).toISOString(), observedAt: new Date(now - 3.5 * DAY - 3_600_000).toISOString(), said: "weekly limit" }); | |
| 89 | spend(70, now - DAY); | |
| 90 | // 30% free now, 36% an hour ago: 6 points an hour, far over what is allowed, so the pace is the floor of 2 slots. | |
| 91 | app.db.query("INSERT INTO week_reading (scope, at, free_pct) VALUES (?1, ?2, 36)").run(sub.value.id, new Date(now - 3_600_000).toISOString()); | |
| 92 | const on = await updateRepoSettings(app, alice, made.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id }); | |
| 93 | expect(on.ok).toBe(true); | |
| 94 | const repo = getRepoById(app.db, made.value.id)!; | |
| 95 | forgetWeek(); | |
| 96 | // A run of `reason` on a fresh issue; `going` claims it, so the pump counts it as running. | |
| 97 | const run = (reason: string, going: boolean) => { | |
| 98 | const issue = openIssue(app, alice, repo, { title: `${reason} work`, body: "Do it." }); | |
| 99 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 100 | const made = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.value.id, reason, actorId: bot.id }); | |
| 101 | if (going) claimRun(app.db, made.id); | |
| 102 | return made.id; | |
| 103 | }; | |
| 104 | const stateOf = (id: string) => getRun(app.db, id)!.state; | |
| 105 | const logOf = (id: string) => readRunLog(app.config.dataDir, id).lines.join("\n"); | |
| 106 | // Two builds fill the pace; one review and one assigned run are going in their own lane. | |
| 107 | run("push", true); | |
| 108 | run("push", true); | |
| 109 | run("review", true); | |
| 110 | run("assigned", true); | |
| 111 | const build = run("push", false); | |
| 112 | const review = run("review", false); | |
| 113 | await until(() => logOf(build).includes("queued, not started: Ahead of pace"), "the build to be held"); | |
| 114 | await until(() => logOf(review).includes("2 of 2 places for reviews and assigned work are taken"), "the third priority run to be held"); | |
| 115 | expect(stateOf(build)).toBe("queued"); | |
| 116 | expect(stateOf(review)).toBe("queued"); | |
| 117 | // One priority run ends: the lane has room and the review starts, the build still waits. | |
| 118 | const gone = app.db.query<{ id: string }, [string]>("SELECT id FROM run WHERE repo_id = ?1 AND reason = 'assigned'").get(repo.id)!.id; | |
| 119 | app.db.query("UPDATE run SET state = 'ok' WHERE id = ?1").run(gone); | |
| 120 | await until(() => stateOf(review) !== "queued", "the review to start"); | |
| 121 | expect(stateOf(build)).toBe("queued"); | |
| 122 | }, 30_000); |
test/week-wiring.test.ts +49 −4
| @@ -6,6 +6,7 @@ import { join } from "node:path"; | ||
| 6 | 6 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 7 | 7 | import { openIssue } from "../src/actions/issues.ts"; |
| 8 | 8 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 9 | import { readForgeUsage } from "../src/actions/usage.ts"; | |
| 9 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 10 | 11 | import { addDependency } from "../src/core/dependencies.ts"; |
| 11 | 12 | import { getIssue, getIssueById, type Issue } from "../src/core/issues.ts"; |
| @@ -54,6 +55,12 @@ function spend(usd: number, at: Date): void { | ||
| 54 | 55 | }); |
| 55 | 56 | } |
| 56 | 57 | |
| 58 | /** The meter, read an hour ago at `freePct`: the burn is how far it has fallen since. */ | |
| 59 | function seedReading(scope: string, freePct: number): void { | |
| 60 | app.db.query("DELETE FROM week_reading").run(); | |
| 61 | app.db.query("INSERT INTO week_reading (scope, at, free_pct) VALUES (?1, ?2, ?3)").run(scope, new Date(NOW.getTime() - 3_600_000).toISOString(), freePct); | |
| 62 | } | |
| 63 | ||
| 57 | 64 | /** The week is half over; last week the provider refused at $100, so the cap is known. */ |
| 58 | 65 | function week(usedSoFar: number, recent = 0): void { |
| 59 | 66 | app.db.query("DELETE FROM usage").run(); |
| @@ -65,8 +72,9 @@ function week(usedSoFar: number, recent = 0): void { | ||
| 65 | 72 | said: "weekly limit", |
| 66 | 73 | }); |
| 67 | 74 | if (usedSoFar - recent > 0) spend(usedSoFar - recent, new Date(NOW.getTime() - DAY)); |
| 68 | // `recent` dollars fall inside the burn's window: recent / 3 points an hour. | |
| 75 | // The meter read `recent` points higher an hour ago: that is the burn, an hour. Zero is a flat meter. | |
| 69 | 76 | if (recent > 0) spend(recent, new Date(NOW.getTime() - 3_600_000)); |
| 77 | seedReading(sub, 100 - usedSoFar + recent); | |
| 70 | 78 | forgetWeek(); |
| 71 | 79 | } |
| 72 | 80 | |
| @@ -166,9 +174,8 @@ test("a fast afternoon slows the crew at once, on a week whose total is on pace" | ||
| 166 | 174 | week(50); |
| 167 | 175 | const calm = repoPace(app, repo, NOW).slots; |
| 168 | 176 | expect(calm).toBe(4); |
| 169 | // 3 points an hour over the last 3 hours, in $100 of cap: $9 an hour. | |
| 170 | spend(27, new Date(NOW.getTime() - 3_600_000)); | |
| 171 | forgetWeek(); | |
| 177 | // The meter fell 9 points in the last hour. | |
| 178 | week(77, 9); | |
| 172 | 179 | expect(repoPace(app, repo, NOW).slots < calm).toBe(true); |
| 173 | 180 | expect(repoPace(app, repo, NOW).reason).toContain("running out about"); |
| 174 | 181 | }); |
| @@ -197,6 +204,7 @@ test("a crew on the forge's own token is metered: today's numbers, read through | ||
| 197 | 204 | noteLimit(app.db, "platform", { window: "week", resetsAt: at(-1.7).toISOString(), observedAt: at(-1.7 - 1 / 24).toISOString(), said: "weekly limit" }); |
| 198 | 205 | onPlatform(45.9, at(-1)); |
| 199 | 206 | onPlatform(8.1, new Date(NOW.getTime() - 3_600_000)); |
| 207 | seedReading("platform", 46 + 2.7); | |
| 200 | 208 | forgetWeek(); |
| 201 | 209 | const pace = repoPace(app, repo, NOW); |
| 202 | 210 | expect(pace.reason).toContain("46% of the week left"); |
| @@ -207,3 +215,40 @@ test("a crew on the forge's own token is metered: today's numbers, read through | ||
| 207 | 215 | app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(repo.id, sub); |
| 208 | 216 | } |
| 209 | 217 | }); |
| 218 | ||
| 219 | test("the free percent home prints is the one the usage tool reports for the same state (#8910)", () => { | |
| 220 | for (const used of [20, 45.4, 54.6, 70]) { | |
| 221 | week(used, 0); | |
| 222 | const home = /(\d+)% of the week left/.exec(weekWords(app, NOW)!)![1]; | |
| 223 | const usage = readForgeUsage(app, alice, NOW).subscriptions.find((s) => s.subscription === "main")!.headroom.find((w) => w.window === "week")!.pct; | |
| 224 | expect(Number(home)).toBe(usage!); | |
| 225 | } | |
| 226 | }); | |
| 227 | ||
| 228 | test("readWeek stores a reading of the meter, so the burn is not seeded by hand only", () => { | |
| 229 | week(30, 0); | |
| 230 | app.db.query("DELETE FROM week_reading").run(); | |
| 231 | forgetWeek(); | |
| 232 | weekWords(app, NOW); | |
| 233 | const rows = app.db.query("SELECT scope, free_pct FROM week_reading").all() as { scope: string; free_pct: number }[]; | |
| 234 | expect(rows).toEqual([{ scope: sub, free_pct: 70 }]); | |
| 235 | // Read an hour on, 4 points lower: the burn is that fall, from the two stored readings. | |
| 236 | spend(4, new Date(NOW.getTime() + 3_600_000)); | |
| 237 | forgetWeek(); | |
| 238 | expect(weekWords(app, new Date(NOW.getTime() + 3_600_000))).toContain("burning 4% an hour"); | |
| 239 | }); | |
| 240 | ||
| 241 | test("home reads the repository the crew works, not the first by name", async () => { | |
| 242 | week(20); | |
| 243 | const quiet = await createRepo(app, alice, { name: "aaa-quiet", visibility: "private" }); | |
| 244 | if (!quiet.ok) throw new Error(String(quiet.error)); | |
| 245 | expect(addToCrew(app, alice, quiet.value, "builderbot").ok).toBe(true); | |
| 246 | const on = await updateRepoSettings(app, alice, quiet.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id }); | |
| 247 | expect(on.ok).toBe(true); | |
| 248 | app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(quiet.value.id, sub); | |
| 249 | forgetWeek(); | |
| 250 | const words = weekWords(app, NOW)!; | |
| 251 | expect(words).toContain("Nothing has landed this week"); | |
| 252 | expect(words).not.toContain("(0 runs"); | |
| 253 | expect(words).toMatch(/\(\d+ runs/); | |
| 254 | }); |
test/week.test.ts +1 −1
| @@ -110,7 +110,7 @@ describe("weekDecision", () => { | ||
| 110 | 110 | const d = used(50, { burnPctPerHour: null }); |
| 111 | 111 | expect(d.slots).toBe(WEEK.unknownSlots); |
| 112 | 112 | expect(d.release).toBe(false); |
| 113 | expect(d.sentence).toContain("adjusting within the hour"); | |
| 113 | expect(d.sentence).toContain("too few readings"); | |
| 114 | 114 | }); |
| 115 | 115 | |
| 116 | 116 | test("unknown capacity is not fine: the crew holds at a modest level and home says so", () => { |
new above
offline
noteReading (dedupe window, prune) and readingsSince are untested. Add a store test.