Public: anyone can read this; the crew can change it.

Weekly pacing reads the subscription's real meter: it never slowed the crew while the week fell 8 points in 3 hours #8881

Merged opened this in answer to

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

Merged

mason/forge:mason/8873-weekly-pacing-reads-the-subscription-s-r → main 3 commits fork

  • Merged. mason/8873-weekly-pacing-reads-the-subscription-s-r was merged into main as 92190d3.

Review

Sign in to review this change.
10 files changed +353 −80
src/crew/dispatch.ts +17
@@ -10,6 +10,8 @@
1010
1111 import { TEMP_FULL } from "../checks/disk_full.ts";
1212 import { freshPullFor } from "./fresh_pull.ts";
13 import { weekHold } from "./week_hold.ts";
14 import { weekFor } from "./week_read.ts";
1315 import { twinNote, twinOf, type Twin } from "./twin.ts";
1416 import { NO_MERGE } from "../checks/merged.ts";
1517 import { resolveListConflicts } from "../actions/list-conflicts.ts";
@@ -1429,6 +1431,7 @@ function pump(
14291431 capacityCount(app.db, "crew.concurrency", options.concurrency) -
14301432 runningIssues.size;
14311433 const started: string[] = [];
1434 const goingIn = new Map<string, number>();
14321435 const noted = notedBy.get(app) ?? new Set<string>();
14331436 notedBy.set(app, noted);
14341437 // Builder starts go to teams in proportion to their dials (`team_share.ts`),
@@ -1456,6 +1459,19 @@ function pump(
14561459 continue;
14571460 }
14581461 if (slots <= 0) break;
1462 // The week's pace holds every path that starts a run in an autonomous
1463 // repository: assigned, review, feedback and check runs as much as picks (#8873).
1464 if (home !== undefined && home.autonomy === "autonomous") {
1465 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 if (held !== null) {
1468 if (!noted.has(fresh.id)) {
1469 noted.add(fresh.id);
1470 appendRunLog(app.config.dataDir, fresh.id, held);
1471 }
1472 continue;
1473 }
1474 }
14591475 // An account at its concurrent-runs limit keeps this run queued, with
14601476 // the count in its log once, until one of its runs ends.
14611477 const owner = getRepoById(app.db, fresh.repoId)?.ownerId;
@@ -1473,6 +1489,7 @@ function pump(
14731489 continue;
14741490 }
14751491 started.push(fresh.id);
1492 goingIn.set(fresh.repoId, (goingIn.get(fresh.repoId) ?? 0) + 1);
14761493 noted.delete(fresh.id);
14771494 slots--;
14781495 if (queued.issueId !== null) runningIssues.add(queued.issueId);
src/crew/week.ts +79 −36
@@ -1,17 +1,22 @@
11 /**
22 * The week's burn: whether the crew is spending its subscriptions' week at
3 * the rate that empties it by the reset, and what to do about it (#8695).
4 * A pure policy: figures in, one decision out, the same state the same
5 * decision. Nothing here reads the clock or the database (`week_read.ts`
6 * does that).
3 * the rate that empties it by the reset, and what to do about it (#8695,
4 * #8873). A pure policy: figures in, one decision out, the same state the
5 * same decision. Nothing here reads the clock or the database
6 * (`week_read.ts` does that).
7 *
8 * The figures are percents of the week the provider's limit holds, the same
9 * `free` share the `usage` tool prints, never a dollar cap of our own.
710 *
8 * - Target burn: what is left for the crew, over the time to the reset.
911 * - Pace: the week's elapsed share is what should be spent by now. Within
1012 * `WEEK.tolerance` of it the crew is on pace; below, behind; above, ahead.
13 * - Burn: the last hours' spend, held against what the week can still take.
14 * A burn that empties it before the reset slows the crew whatever the
15 * week's total says, and names when it would run out.
16 * - Unknown: no limit known is not a fine week. The crew holds at
17 * `WEEK.unknownSlots` and home says the pace is unknown.
1118 * - Behind with builders idle and nothing startable: the next gated issue
1219 * is released, one per look.
13 * - Ahead: slots shrink by the ratio of what should be spent to what is,
14 * so the limit is not met before the reset.
1520 * - Always: `WEEK.reserve` of the week's crew capacity is kept for `/talk`
1621 * and a person's own requests; the crew stops at it.
1722 */
@@ -26,16 +31,28 @@ export const WEEK = {
2631 reserve: 0.1,
2732 /** Slots ahead of pace never fall below this while the reserve holds. */
2833 floorSlots: 1,
34 /** Slots the crew holds at while the week's capacity is unknown. */
35 unknownSlots: 2,
36 /** How many hours back the burn is read over. */
37 burnHours: 3,
2938 } as const;
3039
31 export type Status = "on pace" | "ahead" | "behind";
40 export type Status = "on pace" | "ahead" | "behind" | "unknown";
41
42 /** What the provider's meter says of the week, in percent of the week's limit. */
43 export interface WeekMeter {
44 /** The share of the week still free, 0..100. */
45 readonly freePct: number;
46 /** The share the operator keeps for people, 0..100. */
47 readonly keptPct: number;
48 /** Percent of the week's limit spent an hour, over the last `WEEK.burnHours`; null when unread. */
49 readonly burnPctPerHour: number | null;
50 readonly resetsAt: Date;
51 }
3252
3353 export interface WeekInput {
34 /** US dollars the crew may spend in the week in all (its cap less what people keep). */
35 readonly capUsd: number;
36 /** US dollars the crew has spent in it. */
37 readonly usedUsd: number;
38 readonly resetsAt: Date;
54 /** The week's meter; null while no account's weekly limit is known. */
55 readonly meter: WeekMeter | null;
3956 readonly now: Date;
4057 /** The operator's slot ceiling. */
4158 readonly ceiling: number;
@@ -49,10 +66,12 @@ export interface WeekInput {
4966
5067 export interface WeekDecision {
5168 readonly status: Status;
52 /** US dollars a day that would spend the rest by the reset. */
53 readonly targetUsdPerDay: number;
54 /** Spent minus what an even pace would have spent by now, in US dollars. */
55 readonly offUsd: number;
69 /** Percent of the week's limit that would be spent an hour to use the crew's rest by the reset; null when unknown. */
70 readonly targetPctPerHour: number | null;
71 /** Spent minus what an even pace would have spent by now, in percent of the limit; 0 when unknown. */
72 readonly offPct: number;
73 /** When the burn would empty the crew's share of the week; null when it lasts to the reset or is unread. */
74 readonly runsOutAt: Date | null;
5675 /** The slots the week allows, 0..ceiling. */
5776 readonly slots: number;
5877 /** Whether to release the next gated issue now. */
@@ -61,38 +80,62 @@ export interface WeekDecision {
6180 readonly sentence: string;
6281 }
6382
83 const utc = (d: Date): string => `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`;
6484 const usd = (n: number): string => `$${Math.abs(n) >= 100 ? Math.round(n) : n.toFixed(2)}`;
85 const pct = (n: number): string => `${Math.round(n * 10) / 10}%`;
6586
66 /** The decision for a week at `input`; null when the week has no known capacity or has ended. */
87 /** The decision for a week at `input`; null when the week has ended. */
6788 export function weekDecision(input: WeekInput): WeekDecision | null {
68 const { capUsd, usedUsd, now, ceiling } = input;
69 const left = input.resetsAt.getTime() - now.getTime();
70 if (!(capUsd > 0) || left <= 0 || left > WINDOW_MS.week) return null;
89 const { meter, now, ceiling } = input;
90 if (meter === null) {
91 const slots = Math.min(ceiling, WEEK.unknownSlots);
92 return {
93 status: "unknown",
94 targetPctPerHour: null,
95 offPct: 0,
96 runsOutAt: null,
97 slots,
98 release: false,
99 sentence: `The week's pace is unknown: no subscription has named its weekly limit yet, so the crew holds at ${slots} of ${ceiling} slots.`,
100 };
101 }
102 const left = meter.resetsAt.getTime() - now.getTime();
103 if (left <= 0 || left > WINDOW_MS.week) return null;
104 const hours = left / 3_600_000;
71105 const elapsed = 1 - left / WINDOW_MS.week;
72 const remaining = Math.max(0, capUsd - usedUsd);
73 const days = left / 86_400_000;
74 const targetUsdPerDay = remaining / days;
75 const should = capUsd * elapsed;
76 const offUsd = usedUsd - should;
77 const band = capUsd * WEEK.tolerance;
78 const status: Status = offUsd > band ? "ahead" : offUsd < -band ? "behind" : "on pace";
106 const capPct = Math.max(0, 100 - meter.keptPct);
107 const used = 100 - meter.freePct;
108 const wall = capPct * (1 - WEEK.reserve);
109 const should = capPct * elapsed;
110 const offPct = used - should;
111 const band = capPct * WEEK.tolerance;
112 const targetPctPerHour = Math.max(0, capPct - used) / hours;
113
114 // Burn: what the last hours spent, held against what the crew may still spend before the reserve.
115 const burn = meter.burnPctPerHour;
116 const room = Math.max(0, wall - used);
117 const allowed = room / hours;
118 const burning = burn !== null && burn > allowed;
119 const runsOutAt = burn !== null && burn > 0 && burning ? new Date(now.getTime() + (room / burn) * 3_600_000) : null;
120
121 const status: Status = offPct > band || burning ? "ahead" : offPct < -band ? "behind" : "on pace";
79122
80123 let slots = ceiling;
81 if (status === "ahead")
82 slots = Math.max(WEEK.floorSlots, Math.floor((ceiling * should) / Math.max(usedUsd, 0.0001)));
124 if (offPct > band) slots = Math.max(WEEK.floorSlots, Math.floor((ceiling * should) / Math.max(used, 0.0001)));
125 if (burning) slots = Math.min(slots, Math.max(WEEK.floorSlots, Math.floor((ceiling * allowed) / burn)));
83126 // The reserve is a wall: past it the crew waits for the reset.
84 if (usedUsd >= capUsd * (1 - WEEK.reserve)) slots = 0;
127 if (used >= wall) slots = 0;
85128 slots = Math.min(ceiling, Math.max(0, slots));
86129
87130 const release = status === "behind" && input.idleSlots > 0 && input.startable === 0 && input.gated > 0;
88 const by = `by ${input.resetsAt.toISOString().slice(0, 16).replace("T", " ")} UTC`;
131 const weekLeft = `${pct(meter.freePct)} of the week left, reset ${utc(meter.resetsAt)}`;
89132 const sentence =
90133 status === "on pace"
91 ? `On pace: ${usd(targetUsdPerDay)} a day spends the week's remaining ${usd(remaining)} ${by}.`
134 ? `On pace: ${weekLeft}; ${pct(targetPctPerHour)} an hour spends it by then.`
92135 : status === "ahead"
93 ? `Ahead of pace by ${usd(offUsd)}: slowing to ${slots} of ${ceiling} slots, ${usd(targetUsdPerDay)} a day spends the rest ${by}.`
94 : `Behind pace by ${usd(-offUsd)}: ${usd(targetUsdPerDay)} a day would spend the remaining ${usd(remaining)} ${by}.`;
95 return { status, targetUsdPerDay, offUsd, slots, release, sentence };
136 ? `Ahead of pace: ${weekLeft}${burn === null ? "" : `, burning ${pct(burn)} an hour`}${runsOutAt === null ? "" : `, running out about ${utc(runsOutAt)}`}; ${slots === 0 ? "holding the crew for the reset" : `slowing to ${slots} of ${ceiling} slots`}.`
137 : `Behind pace by ${pct(-offPct)}: ${weekLeft}; ${pct(targetPctPerHour)} an hour would spend it by then.`;
138 return { status, targetPctPerHour, offPct, runsOutAt, slots, release, sentence };
96139 }
97140
98141 /** What each real landing cost, and how many runs it took, over a window. */
src/crew/week_hold.ts added +20
@@ -0,0 +1,20 @@
1 /**
2 * The week's hold on a run start (#8873): whether a queued run waits because
3 * its repository already has as many runs going as the week allows. Pure:
4 * the decision and the count in, the reason out.
5 */
6
7 import type { WeekDecision } from "./week.ts";
8
9 /**
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.
12 */
13 export function weekHold(
14 week: Pick<WeekDecision, "slots" | "sentence"> | null,
15 ceiling: number,
16 going: number,
17 ): string | null {
18 if (week === null || week.slots >= ceiling || going < week.slots) return null;
19 return `queued, not started: ${week.sentence}`;
20 }
src/crew/week_read.ts +48 −18
@@ -6,13 +6,12 @@
66 import type { App } from "../app.ts";
77 import { listAutonomousRepos, type Repo } from "../core/repos.ts";
88 import { standings, WINDOW_MS } from "../core/standing.ts";
9 import { grantedAccounts } from "../core/subscriptions.ts";
10 import { efficiencyWords, weekDecision, type Efficiency, type WeekDecision } from "./week.ts";
9 import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts";
10 import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts";
1111
12 /** The week as the provider's meter reads it: a meter, or null while no account's limit is known. */
1213 interface Week {
13 readonly capUsd: number;
14 readonly usedUsd: number;
15 readonly resetsAt: Date;
14 readonly meter: WeekMeter | null;
1615 }
1716
1817 /** How long a repository's week is remembered: `repoPace` is asked many times a tick. */
@@ -24,7 +23,7 @@ export function forgetWeek(): void {
2423 remembered.clear();
2524 }
2625
27 /** The week's capacity and spend over the repository's accounts; null while no account's week has a known cap. */
26 /** The week's meter over the repository's accounts; an unknown meter while none has a known limit. */
2827 function weekOf(app: App, repo: Repo, now: Date): Week | null {
2928 const key = `${app.config.dataDir}\0${repo.id}`;
3029 const hit = remembered.get(key);
@@ -34,21 +33,52 @@ function weekOf(app: App, repo: Repo, now: Date): Week | null {
3433 return week;
3534 }
3635
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
3750 function readWeek(app: App, repo: Repo, now: Date): Week | null {
38 const granted = grantedAccounts(app.db, repo.id, repo.ownerId);
39 const stand = standings(app.db, granted.map((a) => a.id), now);
51 // A crew with no grant runs on the forge's own token: the account `usage`
52 // prints as `platform`. It is metered too, never skipped (#8873).
53 const granted = grantedAccounts(app.db, repo.id, repo.ownerId).map((a) => a.id);
54 const stand = standings(app.db, granted.length > 0 ? granted : [PLATFORM], now);
4055 let capUsd = 0;
41 let usedUsd = 0;
56 let freeUsd = 0;
57 let keptUsd = 0;
4258 let resetsAt: number | null = null;
43 for (const s of stand.values()) {
59 const known: string[] = [];
60 for (const [id, s] of stand) {
4461 const w = s.windows.find((x) => x.window === "week");
45 if (w === undefined || w.cap === null || w.kept === null || w.resetsAt === null) continue;
46 capUsd += Math.max(0, w.cap.value - w.kept.value);
47 usedUsd += w.used.value;
62 if (w === undefined || w.cap === null || w.kept === null || w.remaining === null || w.resetsAt === null) continue;
63 known.push(id);
64 capUsd += w.cap.value;
65 freeUsd += w.remaining.value;
66 keptUsd += w.kept.value;
4867 const at = Date.parse(w.resetsAt.value);
4968 if (resetsAt === null || at < resetsAt) resetsAt = at;
5069 }
51 return resetsAt === null ? null : { capUsd, usedUsd, resetsAt: new Date(resetsAt) };
70 if (resetsAt === null || !(capUsd > 0)) {
71 // Granted accounts with no known limit are an unknown week; the forge's own token with none is not metered at all.
72 return granted.length > 0 ? { meter: null } : null;
73 }
74 return {
75 meter: {
76 freePct: (freeUsd / capUsd) * 100,
77 keptPct: (keptUsd / capUsd) * 100,
78 burnPctPerHour: ((recentUsd(app, known, now) / capUsd) * 100) / WEEK.burnHours,
79 resetsAt: new Date(resetsAt),
80 },
81 };
5282 }
5383
5484 /** Real landings, agent runs and what they cost since `since`: carried pulls and pulls that added nothing are left out. */
@@ -74,14 +104,14 @@ export function efficiencySince(app: App, repo: Repo, since: Date): Efficiency {
74104 /** The week's efficiency: since the week opened, or seven days back when no window is known. */
75105 export function weekEfficiency(app: App, repo: Repo, now: Date): Efficiency {
76106 const week = weekOf(app, repo, now);
77 const since = week === null ? now.getTime() - WINDOW_MS.week : week.resetsAt.getTime() - WINDOW_MS.week;
107 const since = week?.meter == null ? now.getTime() - WINDOW_MS.week : week.meter.resetsAt.getTime() - WINDOW_MS.week;
78108 return efficiencySince(app, repo, new Date(since));
79109 }
80110
81111 /**
82112 * The week's decision for a repository, given the builders' idle slots and
83113 * the issues a builder may start and the Later gate alone holds; null while
84 * the week's capacity is unknown.
114 * no account is metered.
85115 */
86116 export function weekFor(
87117 app: App,
@@ -95,8 +125,8 @@ export function weekFor(
95125
96126 /**
97127 * The pace sentence and the efficiency beside it for the first autonomous
98 * repository with a crew, as the idle reason picks it; null while no account's
99 * week has a known cap. Home and the terminal's dashboard print the same words.
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.
100130 */
101131 export function weekWords(app: App, now: Date = new Date()): string | null {
102132 for (const repo of listAutonomousRepos(app.db)) {
src/tui/screens/dashboard.ts +1 −1
@@ -1523,7 +1523,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
15231523 return screen;
15241524 }
15251525
1526 /** The week's pace and efficiency, as the line an admin's home reads (#8695); none while no week's cap is known. */
1526 /** The week's pace and efficiency, as the line an admin's home reads (#8695); none while no account is metered. */
15271527 function weekLines(app: App, now: Date): string[] {
15281528 const words = weekWords(app, now);
15291529 return words === null ? [] : [words];
src/web/studio/week_line.ts +1 −1
@@ -1,7 +1,7 @@
11 /**
22 * Home's week line (#8695): one sentence on whether the crew is spending the
33 * subscriptions' week on pace, and beside it what a real landing costs
4 * (`weekWords`). Empty while no account's week has a known cap.
4 * (`weekWords`). Empty while no account is metered.
55 */
66
77 import type { App } from "../../app.ts";
test/week-hold.test.ts added +16
@@ -0,0 +1,16 @@
1 /** The week's hold on a run start: waits only when the week allows fewer slots than the ceiling and they are full (#8873). */
2 import { expect, test } from "bun:test";
3 import { weekHold } from "../src/crew/week_hold.ts";
4
5 const week = { slots: 2, sentence: "Ahead of pace." };
6
7 test("no decision, or a week that allows the ceiling, never holds", () => {
8 expect(weekHold(null, 4, 9)).toBeNull();
9 expect(weekHold({ ...week, slots: 4 }, 4, 9)).toBeNull();
10 });
11
12 test("below the week's slots a run starts; at them it waits, with the sentence", () => {
13 expect(weekHold(week, 4, 1)).toBeNull();
14 expect(weekHold(week, 4, 2)).toBe("queued, not started: Ahead of pace.");
15 expect(weekHold({ ...week, slots: 0 }, 4, 0)).toBe("queued, not started: Ahead of pace.");
16 });
test/week-pump.test.ts added +67
@@ -0,0 +1,67 @@
1 /** The week's pace holds an assigned run in an autonomous repository, not only the picks (#8873). */
2 import { readRunLog } from "../src/runs/log.ts";
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { addToCrew } from "../src/actions/crew.ts";
5 import { assignIssue, openIssue } from "../src/actions/issues.ts";
6 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
7 import { getRepoById } from "../src/core/repos.ts";
8 import { createRun, listRuns } from "../src/core/runs.ts";
9 import { noteLimit, setHeadroom } from "../src/core/standing.ts";
10 import { addSubscription } from "../src/core/subscriptions.ts";
11 import { forgetWeek } from "../src/crew/week_read.ts";
12 import { recordUsage } from "../src/core/usage.ts";
13 import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts";
14
15 const DAY = 86_400_000;
16
17 /** Wait until `done` holds, or fail at the deadline: the pump's tick is what we wait on, not a guessed pause. */
18 async function until(done: () => boolean, what: string, ms = 20_000): Promise<void> {
19 const deadline = Date.now() + ms;
20 while (!done()) {
21 if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
22 await Bun.sleep(10);
23 }
24 }
25 let standIn: StandInCrew;
26
27 beforeAll(async () => {
28 standIn = await startStandInCrew({ tickMs: 50 });
29 }, 30_000);
30 afterAll(() => stopStandInCrew(standIn));
31
32 test("past the week's reserve an assigned run stays queued, with the reason in its log", async () => {
33 const { app, alice, bot } = standIn;
34 const made = await createRepo(app, alice, { name: "held", visibility: "private" });
35 if (!made.ok) throw new Error(String(made.error));
36 const seeded = await commitToBranch(app, alice, made.value, { message: "Start", files: [{ path: "README.md", content: "# held\n" }] });
37 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
38 expect(addToCrew(app, alice, made.value, "bot").ok).toBe(true);
39 const sub = addSubscription(app.db, standIn.dataDir, { name: "main", token: "sk-ant-oat01-week-pump-1" });
40 if (!sub.ok) throw new Error(sub.error);
41 setHeadroom(app.db, sub.value.id, 0);
42 app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id);
43 // Last week the provider refused at $100; this week $95 is spent.
44 const now = Date.now();
45 const spend = (usd: number, at: number) => {
46 const run = createRun(app.db, { repoId: made.value.id, kind: "agent" });
47 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() });
48 };
49 spend(100, now - 4 * DAY);
50 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" });
51 spend(95, now - DAY);
52 const on = await updateRepoSettings(app, alice, made.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id });
53 expect(on.ok).toBe(true);
54 const repo = getRepoById(app.db, made.value.id)!;
55 const issue = openIssue(app, alice, repo, { title: "Assigned work", body: "Do it." });
56 if (!issue.ok) throw new Error(String(issue.error));
57 assignIssue(app, alice, repo, issue.value, bot);
58 const mine = () => listRuns(app.db, repo.id, { kind: "agent", limit: 10 }).find((r) => r.issueId === issue.value.id);
59 const logOf = () => (mine() === undefined ? "" : readRunLog(app.config.dataDir, mine()!.id).lines.join("\n"));
60 await until(() => logOf().includes("queued, not started: Ahead of pace"), "the hold to be logged");
61 expect(mine()?.state).toBe("queued");
62 // With the spend gone the week is on pace and the same run starts.
63 app.db.query("DELETE FROM usage WHERE at >= ?1").run(new Date(now - 2 * DAY).toISOString());
64 forgetWeek();
65 await until(() => mine()?.state !== "queued", "the run to start");
66 expect(mine()?.state).not.toBe("queued");
67 }, 30_000);
test/week-wiring.test.ts +43 −5
@@ -144,9 +144,9 @@ test("ahead of pace, the pace cuts the slots, and the reserve stops them", () =>
144144 test("home and the terminal print the same sentence, the terminal for an admin", async () => {
145145 week(20);
146146 const words = weekWords(app, NOW)!;
147 expect(words).toContain("Behind pace by $30.00");
147 expect(words).toContain("Behind pace by 30%");
148148 expect(words).toContain("Nothing has landed");
149 expect(String(weekLine(app, NOW))).toContain("Behind pace by $30.00");
149 expect(String(weekLine(app, NOW))).toContain("Behind pace by 30%");
150150 const terminal = headlessTerminal(240, 32);
151151 const { app: tui } = createForgeApp(
152152 app,
@@ -160,10 +160,48 @@ test("home and the terminal print the same sentence, the terminal for an admin",
160160 expect(text).toContain("Behind pace");
161161 });
162162
163 test("with no known weekly cap there is no sentence", () => {
163 test("a fast afternoon slows the crew at once, on a week whose total is on pace", () => {
164 week(50);
165 const calm = repoPace(app, repo, NOW).slots;
166 expect(calm).toBe(4);
167 // 3 points an hour over the last 3 hours, in $100 of cap: $9 an hour.
168 spend(27, new Date(NOW.getTime() - 3_600_000));
169 forgetWeek();
170 expect(repoPace(app, repo, NOW).slots < calm).toBe(true);
171 expect(repoPace(app, repo, NOW).reason).toContain("running out about");
172 });
173
174 test("with no known weekly cap the pace is unknown and the crew holds at a modest level", () => {
164175 app.db.query("DELETE FROM subscription_limit").run();
165176 app.db.query("DELETE FROM usage").run();
166177 forgetWeek();
167 expect(weekWords(app, NOW)).toBeNull();
168 expect(String(weekLine(app, NOW))).toBe("");
178 expect(weekWords(app, NOW)).toContain("pace is unknown");
179 expect(String(weekLine(app, NOW))).toContain("pace is unknown");
180 expect(repoPace(app, repo, NOW).slots).toBe(2);
181 });
182
183 test("a crew on the forge's own token is metered: today's numbers, read through the ledger, slow it", () => {
184 // The cause of #8873: no grant meant no account, no week, no pacing.
185 app.db.query("DELETE FROM subscription_grant").run();
186 app.db.query("DELETE FROM usage").run();
187 try {
188 const at = (days: number) => new Date(NOW.getTime() + days * DAY);
189 const onPlatform = (usd: number, when: Date) => {
190 const run = createRun(app.db, { repoId: repo.id, kind: "agent" });
191 recordUsage(app.db, { runId: run.id, repoId: repo.id, issueId: null, agentId: null, subscriptionId: "platform", tokensIn: 1, tokensOut: 1, costUsd: usd, wallMs: 1000, outcome: "ok", at: when.toISOString() });
192 };
193 // Last week's limit hit at $100; the reset is 5.3 days off; $54 is spent, $8.1 of it in the last 3 hours: 46% free, 2.7 points an hour.
194 onPlatform(100, at(-2));
195 noteLimit(app.db, "platform", { window: "week", resetsAt: at(-1.7).toISOString(), observedAt: at(-1.7 - 1 / 24).toISOString(), said: "weekly limit" });
196 onPlatform(45.9, at(-1));
197 onPlatform(8.1, new Date(NOW.getTime() - 3_600_000));
198 forgetWeek();
199 const pace = repoPace(app, repo, NOW);
200 expect(pace.reason).toContain("46% of the week left");
201 expect(pace.reason).toContain("running out about");
202 expect(pace.slots < 4).toBe(true);
203 expect(weekWords(app, NOW)).toContain("46% of the week left");
204 } finally {
205 app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(repo.id, sub);
206 }
169207 });
test/week.test.ts +61 −19
@@ -12,18 +12,19 @@ import { efficiencyWords, WEEK, weekDecision, type WeekInput } from "../src/crew
1212
1313 const DAY = 86_400_000;
1414 const NOW = new Date("2026-11-02T12:00:00Z");
15 // Halfway through the week: 3.5 days to the reset.
15 // Halfway through the week: 3.5 days to the reset, half of it free.
1616 const base: WeekInput = {
17 capUsd: 100,
18 usedUsd: 50,
19 resetsAt: new Date(NOW.getTime() + 3.5 * DAY),
17 meter: { freePct: 50, keptPct: 0, burnPctPerHour: 0.4, resetsAt: new Date(NOW.getTime() + 3.5 * DAY) },
2018 now: NOW,
2119 ceiling: 4,
2220 idleSlots: 2,
2321 startable: 0,
2422 gated: 3,
2523 };
24 const meterOf = (over: Partial<NonNullable<WeekInput["meter"]>>) => ({ ...base.meter!, ...over });
2625 const decide = (over: Partial<WeekInput>) => weekDecision({ ...base, ...over })!;
26 const used = (usedPct: number, over: Partial<NonNullable<WeekInput["meter"]>> = {}) =>
27 decide({ meter: meterOf({ freePct: 100 - usedPct, ...over }) });
2728
2829 describe("weekDecision", () => {
2930 test("the same state gives the same decision", () => {
@@ -31,47 +32,88 @@ describe("weekDecision", () => {
3132 });
3233
3334 test("settings are plain values", () => {
34 expect(WEEK).toEqual({ tolerance: 0.05, reserve: 0.1, floorSlots: 1 });
35 expect(WEEK).toEqual({ tolerance: 0.05, reserve: 0.1, floorSlots: 1, unknownSlots: 2, burnHours: 3 });
3536 });
3637
3738 test("on pace: the target burn is the rest over the time left", () => {
3839 const d = decide({});
3940 expect(d.status).toBe("on pace");
40 expect(d.targetUsdPerDay).toBeCloseTo(50 / 3.5);
41 expect(d.targetPctPerHour).toBeCloseTo(50 / 84);
4142 expect(d.release).toBe(false);
4243 expect(d.slots).toBe(4);
43 expect(d.sentence).toContain("On pace");
44 expect(d.sentence).toContain("On pace: 50% of the week left");
4445 });
4546
4647 test("behind with builders idle and nothing startable releases the next gated issue", () => {
47 const d = decide({ usedUsd: 20 });
48 const d = used(20, { burnPctPerHour: 0 });
4849 expect(d.status).toBe("behind");
4950 expect(d.release).toBe(true);
50 expect(d.sentence).toContain("Behind pace by $30.00");
51 expect(d.sentence).toContain("Behind pace by 30%");
5152 });
5253
5354 test("behind does not release while work is startable, builders are busy, or nothing is gated", () => {
54 expect(decide({ usedUsd: 20, startable: 1 }).release).toBe(false);
55 expect(decide({ usedUsd: 20, idleSlots: 0 }).release).toBe(false);
56 expect(decide({ usedUsd: 20, gated: 0 }).release).toBe(false);
55 const meter = meterOf({ freePct: 80, burnPctPerHour: 0 });
56 expect(decide({ meter, startable: 1 }).release).toBe(false);
57 expect(decide({ meter, idleSlots: 0 }).release).toBe(false);
58 expect(decide({ meter, gated: 0 }).release).toBe(false);
5759 });
5860
5961 test("ahead brings the slots down before the limit", () => {
60 const d = decide({ usedUsd: 70 });
62 const d = used(70, { burnPctPerHour: 0 });
6163 expect(d.status).toBe("ahead");
6264 expect(d.slots).toBe(2);
6365 expect(d.release).toBe(false);
64 expect(decide({ usedUsd: 89, ceiling: 2 }).slots).toBe(1);
66 expect(decide({ ceiling: 2, meter: meterOf({ freePct: 11, burnPctPerHour: 0 }) }).slots).toBe(1);
6567 });
6668
6769 test("the reserve for people is a wall", () => {
68 expect(decide({ usedUsd: 90 }).slots).toBe(0);
69 expect(decide({ usedUsd: 89 }).slots).toBeGreaterThan(0);
70 expect(used(90, { burnPctPerHour: 0 }).slots).toBe(0);
71 expect(used(89, { burnPctPerHour: 0 }).slots).toBeGreaterThan(0);
7072 });
7173
72 test("no known capacity or an ended week is no decision", () => {
73 expect(weekDecision({ ...base, capUsd: 0 })).toBeNull();
74 expect(weekDecision({ ...base, resetsAt: NOW })).toBeNull();
74 test("the operator's kept share is not the crew's to spend", () => {
75 expect(used(45, { keptPct: 50, burnPctPerHour: 0 }).slots).toBe(0);
76 });
77
78 test("a fast burn slows the crew even when the week's total is on pace", () => {
79 const calm = used(50, { burnPctPerHour: 0.3 });
80 expect(calm.slots).toBe(4);
81 const fast = used(50, { burnPctPerHour: 2.7 });
82 expect(fast.status).toBe("ahead");
83 expect(fast.slots).toBe(1);
84 expect(fast.runsOutAt).not.toBeNull();
85 expect(fast.sentence).toContain("running out about");
86 });
87
88 test("today's numbers: 46% free, 2.7 points an hour, 5.3 days to the reset, slows the crew", () => {
89 const now = new Date("2026-11-02T19:25:00Z");
90 const d = weekDecision({
91 meter: { freePct: 46, keptPct: 0, burnPctPerHour: 2.7, resetsAt: new Date("2026-11-08T02:00:00Z") },
92 now,
93 ceiling: 5,
94 idleSlots: 0,
95 startable: 0,
96 gated: 0,
97 })!;
98 expect(d.status).toBe("ahead");
99 expect(d.slots).toBeLessThan(5);
100 expect(d.slots).toBeGreaterThanOrEqual(1);
101 expect(d.sentence).toContain("46% of the week left");
102 expect(d.sentence).toContain("running out about 2026-11-03");
103 // `/talk` keeps its reserve: at the wall the crew stops, with the reserve still unspent.
104 expect(d.runsOutAt!.getTime()).toBeLessThan(now.getTime() + 17 * 3_600_000);
105 });
106
107 test("unknown capacity is not fine: the crew holds at a modest level and home says so", () => {
108 const d = decide({ meter: null });
109 expect(d.status).toBe("unknown");
110 expect(d.slots).toBe(WEEK.unknownSlots);
111 expect(d.sentence).toContain("pace is unknown");
112 expect(decide({ meter: null, ceiling: 1 }).slots).toBe(1);
113 });
114
115 test("an ended week is no decision", () => {
116 expect(weekDecision({ ...base, meter: meterOf({ resetsAt: NOW }) })).toBeNull();
75117 });
76118 });
77119

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs