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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/8910-the-pacer-s-burn-matches-the-meter-it-sa → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/8910-the-pacer-s-burn-matches-the-meter-it-sa was merged into main as ce7846a.

Review

Sign in to review this change.
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 @@
1010
1111 import { TEMP_FULL } from "../checks/disk_full.ts";
1212 import { freshPullFor } from "./fresh_pull.ts";
13 import { weekHold } from "./week_hold.ts";
13 import { PRIORITY_REASONS, weekHold } from "./week_hold.ts";
1414 import { weekFor } from "./week_read.ts";
1515 import { twinNote, twinOf, type Twin } from "./twin.ts";
1616 import { NO_MERGE } from "../checks/merged.ts";
@@ -1432,6 +1432,7 @@ function pump(
14321432 runningIssues.size;
14331433 const started: string[] = [];
14341434 const goingIn = new Map<string, number>();
1435 const priorityIn = new Map<string, number>();
14351436 const noted = notedBy.get(app) ?? new Set<string>();
14361437 notedBy.set(app, noted);
14371438 // Builder starts go to teams in proportion to their dials (`team_share.ts`),
@@ -1463,7 +1464,10 @@ function pump(
14631464 // repository: assigned, review, feedback and check runs as much as picks (#8873).
14641465 if (home !== undefined && home.autonomy === "autonomous") {
14651466 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);
14671471 if (held !== null) {
14681472 if (!noted.has(fresh.id)) {
14691473 noted.add(fresh.id);
@@ -1490,6 +1494,7 @@ function pump(
14901494 }
14911495 started.push(fresh.id);
14921496 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);
14931498 noted.delete(fresh.id);
14941499 slots--;
14951500 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 {
126126 slots = Math.min(ceiling, Math.max(0, slots));
127127
128128 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`;
130131 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.`
133134 : 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.`
135136 : 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" : ""}.`;
138139 return { status, targetPctPerHour, allowedPctPerHour: allowed, runsOutAt, slots, release, sentence };
139140 }
140141
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`. */

noteReading (dedupe window, prune) and readingsSince are untested. Add a store test.

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 @@
44 * the decision and the count in, the reason out.
55 */
66
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"];
811
912 /**
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).
1218 */
1319 export function weekHold(
1420 week: Pick<WeekDecision, "slots" | "sentence"> | null,
1521 ceiling: number,
1622 going: number,
23 reason = "",
24 priorityGoing = 0,
1725 ): 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;
1932 return `queued, not started: ${week.sentence}`;
2033 }
src/crew/week_read.ts +20 −21
@@ -7,6 +7,7 @@ import type { App } from "../app.ts";
77 import { listAutonomousRepos, type Repo } from "../core/repos.ts";
88 import { standings, WINDOW_MS } from "../core/standing.ts";
99 import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts";
10 import { burnFrom, noteReading, readingsSince } from "./week_burn.ts";
1011 import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts";
1112
1213 /** 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 {
3334 return week;
3435 }
3536
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
5037 function readWeek(app: App, repo: Repo, now: Date): Week | null {
5138 // A crew with no grant runs on the forge's own token: the account `usage`
5239 // prints as `platform`. It is metered too, never skipped (#8873).
@@ -71,11 +58,18 @@ function readWeek(app: App, repo: Repo, now: Date): Week | null {
7158 // Granted accounts with no known limit are an unknown week; the forge's own token with none is not metered at all.
7259 return granted.length > 0 ? { meter: null } : null;
7360 }
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);
7468 return {
7569 meter: {
76 freePct: (freeUsd / capUsd) * 100,
70 freePct,
7771 keptPct: (keptUsd / capUsd) * 100,
78 burnPctPerHour: ((recentUsd(app, known, now) / capUsd) * 100) / WEEK.burnHours,
72 burnPctPerHour: burnFrom(readings, now.getTime(), WEEK.burnHours),
7973 resetsAt: new Date(resetsAt),
8074 },
8175 };
@@ -124,15 +118,20 @@ export function weekFor(
124118 }
125119
126120 /**
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.

130126 */
131127 export function weekWords(app: App, now: Date = new Date()): string | null {
128 let best: { words: string; runs: number } | null = null;
132129 for (const repo of listAutonomousRepos(app.db)) {
133130 if (repo.autonomy !== "autonomous" || repo.archivedAt !== null) continue;
134131 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 };
136135 }
137 return null;
136 return best?.words ?? null;
138137 }
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",
1414 expect(weekHold(week, 4, 2)).toBe("queued, not started: Ahead of pace.");
1515 expect(weekHold({ ...week, slots: 0 }, 4, 0)).toBe("queued, not started: Ahead of pace.");
1616 });
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";
55 import { assignIssue, openIssue } from "../src/actions/issues.ts";
66 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
77 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";
910 import { noteLimit, setHeadroom } from "../src/core/standing.ts";
1011 import { addSubscription } from "../src/core/subscriptions.ts";
1112 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
6566 await until(() => mine()?.state !== "queued", "the run to start");
6667 expect(mine()?.state).not.toBe("queued");
6768 }, 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";
66 import { addToCrew, createAgent } from "../src/actions/crew.ts";
77 import { openIssue } from "../src/actions/issues.ts";
88 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
9 import { readForgeUsage } from "../src/actions/usage.ts";
910 import { createApp, type App } from "../src/app.ts";
1011 import { addDependency } from "../src/core/dependencies.ts";
1112 import { getIssue, getIssueById, type Issue } from "../src/core/issues.ts";
@@ -54,6 +55,12 @@ function spend(usd: number, at: Date): void {
5455 });
5556 }
5657
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
5764 /** The week is half over; last week the provider refused at $100, so the cap is known. */
5865 function week(usedSoFar: number, recent = 0): void {
5966 app.db.query("DELETE FROM usage").run();
@@ -65,8 +72,9 @@ function week(usedSoFar: number, recent = 0): void {
6572 said: "weekly limit",
6673 });
6774 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.
6976 if (recent > 0) spend(recent, new Date(NOW.getTime() - 3_600_000));
77 seedReading(sub, 100 - usedSoFar + recent);
7078 forgetWeek();
7179 }
7280
@@ -166,9 +174,8 @@ test("a fast afternoon slows the crew at once, on a week whose total is on pace"
166174 week(50);
167175 const calm = repoPace(app, repo, NOW).slots;
168176 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);
172179 expect(repoPace(app, repo, NOW).slots < calm).toBe(true);
173180 expect(repoPace(app, repo, NOW).reason).toContain("running out about");
174181 });
@@ -197,6 +204,7 @@ test("a crew on the forge's own token is metered: today's numbers, read through
197204 noteLimit(app.db, "platform", { window: "week", resetsAt: at(-1.7).toISOString(), observedAt: at(-1.7 - 1 / 24).toISOString(), said: "weekly limit" });
198205 onPlatform(45.9, at(-1));
199206 onPlatform(8.1, new Date(NOW.getTime() - 3_600_000));
207 seedReading("platform", 46 + 2.7);
200208 forgetWeek();
201209 const pace = repoPace(app, repo, NOW);
202210 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
207215 app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(repo.id, sub);
208216 }
209217 });
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", () => {
110110 const d = used(50, { burnPctPerHour: null });
111111 expect(d.slots).toBe(WEEK.unknownSlots);
112112 expect(d.release).toBe(false);
113 expect(d.sentence).toContain("adjusting within the hour");
113 expect(d.sentence).toContain("too few readings");
114114 });
115115
116116 test("unknown capacity is not fine: the crew holds at a modest level and home says so", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs