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

Pacing decides from the provider's measured percent left, and concurrency goes back to its ceiling #9002

Merged opened this

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

Merged

torque/forge:torque/8960-pacing-decides-from-the-provider-s-measu → main 7 commits fork 2 threads · 2 unresolved

  • Merged. torque/8960-pacing-decides-from-the-provider-s-measu was merged into main as 8a8980d.

Review

Sign in to review this change.
21 files changed +468 −96
docs/CREW.md +19
@@ -465,6 +465,25 @@ An open vision pull's page shows, to an admin of the repository, "Direction afte
465465
466466 The same read is a second door: the MCP `vision` tool takes an optional `number`, and for an admin returns `preview` (`{ today, after }`, or `{ why }` the direction would stay) for that open vision pull, plus `directionParts` (the direction cut into `vision` and `owner` parts). The terminal (TUI) has no such view; it is not touched by this change.
467467
468 ## The weekly pace reads the provider's measure (#8960)
469
470 The pacer decides from the percent of the week the provider reports as used
471 (`subscription_measure`, noted from the session's `rate_limit_event`) against
472 the share of the week's time gone. No dollar cap is inferred. Its burn per
473 hour is the fall of that percent between successive readings, so a meter that
474 has not moved reads 0% an hour. With no measure (never reported, or its week
475 has reset) the sentence says the pace is unknown and the crew holds at a
476 modest level; it is never off. `crew.concurrency` is not lowered in the
477 repository (the chart and `capacity.ts` defaults are the ceiling); pacing is
478 what slows the crew now.
479
480 An admin sees the measure in `usage` (the MCP tool and the usage page): each
481 subscription's `headroom` lists the window's percent free and its reset, taken
482 from the provider's measure while its window is open, else inferred from the
483 ledger. The terminal reads the same `readForgeUsage`: the Usage screen prints
484 each subscription's `week 38%, 5h 80%` line, and the forge usage block
485 `week 38% free`; home and the dashboard print the pace sentence.
486
468487 ## What the pace limits (#8962)
469488
470489 The week's pace (`src/crew/week.ts`) limits only the crew's own picks: the
src/actions/usage.ts +10 −5
@@ -20,6 +20,7 @@ import {
2020 type Repo,
2121 } from "../core/repos.ts";
2222 import { forgeSpend, type SpanSpend } from "../core/usage_report.ts";
23 import { measureFor } from "../core/measure.ts";
2324 import { freePct, standings, type Standing } from "../core/standing.ts";
2425 import {
2526 budgetsOf,
@@ -274,11 +275,15 @@ export function readForgeUsage(
274275 subscription: names.get(id) ?? (id === PLATFORM ? id : "a removed subscription"),
275276 today: at(spend.today, id),
276277 last24h: at(spend.last24h, id),
277 headroom: (read.get(id)?.windows ?? []).map((w) => ({
278 window: w.window,
279 pct: freePct(w.cap?.value ?? null, w.used.value),
280 resetsAt: w.resetsAt?.value ?? null,
281 })),
278 headroom: (read.get(id)?.windows ?? []).map((w) => {
279 // The provider's own measurement wins over the ledger's inference (#8960).
280 const m = measureFor(db, id, w.window, now);
281 return {
282 window: w.window,
283 pct: m === null ? freePct(w.cap?.value ?? null, w.used.value) : Math.round(100 - m.usedPct),
284 resetsAt: m === null ? (w.resetsAt?.value ?? null) : m.resetsAt,
285 };
286 }),
282287 }))
283288 .sort((a, b) => a.subscription.localeCompare(b.subscription));
284289 // Totals come from every row, so no spend can fall out of them.
src/core/measure.ts added +64
@@ -0,0 +1,64 @@
1 /**
2 * The provider's own measurement of a window (#8960): Claude Code reports,
3 * as a session runs, how much of the 5-hour or weekly limit is used and when
4 * it resets. The percent is kept as the provider said it; no dollar is ever
5 * turned into percent here. Pure data: no authz, no events.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import type { LimitWindow } from "./standing.ts";
10
11 /** One measurement: percent of the window used, 0..100, and the window's reset. */
12 export interface Measure {
13 readonly window: LimitWindow;
14 readonly usedPct: number;
15 readonly resetsAt: string;
16 readonly observedAt: string;
17 }
18
19 /**

Non-blocking: the documented shape here is assumed, not recorded. Confirm utilization against a real session line.

20 * The measurement in a Claude Code `rate_limit_event` record, or undefined
21 * when it carries none. Assumed shape: `rate_limit_info` holds
22 * `rateLimitType` (`five_hour` | `seven_day`), `resetsAt` in unix seconds and
23 * `utilization` as a fraction 0..1 of the limit (the provider omits it
24 * until a threshold is near; then there is no measurement).
25 */
26 export function measureOf(event: Record<string, unknown>, now: Date): Measure | undefined {
27 if (event["type"] !== "rate_limit_event") return undefined;
28 const info = event["rate_limit_info"];
29 if (typeof info !== "object" || info === null) return undefined;
30 const i = info as Record<string, unknown>;
31 const window: LimitWindow | undefined =
32 i["rateLimitType"] === "seven_day" ? "week" : i["rateLimitType"] === "five_hour" ? "5h" : undefined;
33 const used = i["utilization"];
34 const resets = i["resetsAt"];
35 if (window === undefined || typeof used !== "number" || !(used >= 0 && used <= 1)) return undefined;
36 if (typeof resets !== "number" || !Number.isFinite(resets) || resets <= 0) return undefined;
37 return {
38 window,
39 usedPct: Math.round(used * 1000) / 10,
40 resetsAt: new Date(resets * 1000).toISOString(),
41 observedAt: now.toISOString(),
42 };
43 }
44
45 /** The newest word for a window replaces the last; an unknown subscription is ignored. */
46 export function noteMeasure(db: Database, subscriptionId: string, m: Measure): void {
47 db.query(
48 `INSERT INTO subscription_measure (subscription_id, span, used_pct, resets_at, observed_at)
49 SELECT ?1, ?2, ?3, ?4, ?5 WHERE EXISTS (SELECT 1 FROM subscription WHERE id = ?1)
50 ON CONFLICT (subscription_id, span) DO UPDATE SET
51 used_pct = excluded.used_pct, resets_at = excluded.resets_at, observed_at = excluded.observed_at`,
52 ).run(subscriptionId, m.window, m.usedPct, m.resetsAt, m.observedAt);
53 }
54
55 /** The measurement of `window` for a subscription whose reset is still ahead of `now`; null when none. */
56 export function measureFor(db: Database, subscriptionId: string, window: LimitWindow, now: Date): Measure | null {
57 const row = db
58 .query<{ used_pct: number; resets_at: string; observed_at: string }, [string, string]>(
59 "SELECT used_pct, resets_at, observed_at FROM subscription_measure WHERE subscription_id = ?1 AND span = ?2",
60 )
61 .get(subscriptionId, window);
62 if (row === null || Date.parse(row.resets_at) <= now.getTime()) return null;
63 return { window, usedPct: row.used_pct, resetsAt: row.resets_at, observedAt: row.observed_at };
64 }
src/core/migration-pins/20261103160000-subscription-measure.pin added +1
@@ -0,0 +1,1 @@
1 69615b1151ed883b8f8210da1cdc0849b64520d2520c398bfbaff5bfbc0ea9f6
src/core/migrations/20261103160000-subscription-measure.ts added +14
@@ -0,0 +1,14 @@
1 // What the provider measured of a subscription's window (#8960): the percent
2 // of it used, as the provider reported it with the window's reset. One row
3 // per subscription and window kind, the newest word replacing the last.
4 // Additive.
5 export default `
6 CREATE TABLE subscription_measure (
7 subscription_id TEXT NOT NULL,
8 span TEXT NOT NULL,
9 used_pct REAL NOT NULL,
10 resets_at TEXT NOT NULL,
11 observed_at TEXT NOT NULL,
12 PRIMARY KEY (subscription_id, span)
13 ) WITHOUT ROWID;
14 `;
src/crew/claude.ts +8
@@ -12,6 +12,7 @@
1212 * settings files of the operator's, a spend budget, and a deadline.
1313 */
1414
15 import { measureOf, type Measure } from "../core/measure.ts";
1516 import { BACKGROUND_CPU_WEIGHT, backgroundArgv, crewCpus } from "../apps/background.ts";
1617 import { cgroupHost, openCellCgroup, tryEnterArgv } from "../apps/cgroup.ts";
1718 import { join } from "node:path";
@@ -51,6 +52,8 @@ export interface ClaudeInput {
5152 * for this invocation, never deltas.
5253 */
5354 readonly onUsage?: ((spent: Spent) => void) | undefined;
55 /** The provider's measurement of a window, each time the session reports one (#8960). */
56 readonly onMeasure?: ((measure: Measure) => void) | undefined;
5457 /**
5558 * Talks to the forge through MCP alone (#7651): no file or shell tools, no
5659 * clone, the one server named in `config` (`--mcp-config` JSON) allowed.
@@ -362,6 +365,11 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
362365 sessionId = e["session_id"];
363366 input.onSession?.(sessionId);
364367 }
368 if (type === "rate_limit_event") {
369 const measure = measureOf(e, new Date());
370 if (measure !== undefined) input.onMeasure?.(measure);
371 return;
372 }
365373 if (type === "system") {
366374 if (e["subtype"] === "init")
367375 input.log(`session ${sessionId} (${String(e["model"] ?? "")})`);
src/crew/runner.ts +2
@@ -11,6 +11,7 @@
1111 * what the forge has.
1212 */
1313
14 import { noteMeasure } from "../core/measure.ts";
1415 import { capacityEnv } from "../core/capacity.ts";
1516 import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
1617 import { join } from "node:path";
@@ -1399,6 +1400,7 @@ async function work(
13991400 onSteer: (send) => {
14001401 listenForSteer(run.id, send);
14011402 },
1403 onMeasure: (m) => noteMeasure(app.db, meter.subscriptionId ?? PLATFORM, m),
14021404 onUsage: (so) =>
14031405 meterLive(
14041406 app,
src/crew/week.ts +3 −2
@@ -98,7 +98,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null {
9898 runsOutAt: null,
9999 slots,
100100 release: false,
101 sentence: `The week's pace is unknown: no subscription has named its weekly limit yet, so the crew holds at ${slots} of ${ceiling} slots.`,
101 sentence: `The week's pace is unknown: the provider has not yet reported a measure of the week, so the crew holds at ${slots} of ${ceiling} slots.`,
102102 };
103103 }
104104 const left = meter.resetsAt.getTime() - now.getTime();
@@ -126,7 +126,8 @@ 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 = `${Math.round(meter.freePct)}% of the week left, reset ${utc(meter.resetsAt)}`;
129 const gone = Math.round((1 - left / WINDOW_MS.week) * 100);
130 const weekLeft = `${Math.round(meter.freePct)}% of the week left with ${gone}% of its time gone, reset ${utc(meter.resetsAt)}`;
130131 const burnWords = burn === null ? "unread (too few readings)" : `${pct(burn)} an hour`;
131132 const sentence =
132133 burn === null && !burning
src/crew/week_read.ts +32 −34
@@ -5,11 +5,11 @@
55
66 import type { App } from "../app.ts";
77 import { listAutonomousRepos, type Repo } from "../core/repos.ts";
8 import { standings, WINDOW_MS } from "../core/standing.ts";
9 import { capacityCount } from "../core/capacity.ts";
8 import { measureFor } from "../core/measure.ts";
9 import { getHeadroom, WINDOW_MS } from "../core/standing.ts";
1010 import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts";
1111 import { burnFrom, noteReading, readingsSince } from "./week_burn.ts";
12 import { paceFloor } from "../core/capacity.ts";
12 import { capacityCount, paceFloor } from "../core/capacity.ts";
1313 import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts";
1414
1515 /** The week as the provider's meter reads it: a meter, or null while no account's limit is known. */
@@ -21,7 +21,7 @@ interface Week {
2121
2222 /** How long a repository's week is remembered: `repoPace` is asked many times a tick. */
2323 const REMEMBER_MS = 5_000;
24 const remembered = new Map<string, { at: number; week: Week | null }>();
24 const remembered = new Map<string, { at: number; week: Week }>();
2525
2626 /** Forget what was remembered, so the next ask reads again. */
2727 export function forgetWeek(): void {
@@ -29,7 +29,7 @@ export function forgetWeek(): void {
2929 }
3030
3131 /** The week's meter over the repository's accounts; an unknown meter while none has a known limit. */
32 function weekOf(app: App, repo: Repo, now: Date): Week | null {
32 function weekOf(app: App, repo: Repo, now: Date): Week {
3333 const key = `${app.config.dataDir}\0${repo.id}`;
3434 const hit = remembered.get(key);
3535 if (hit !== undefined && Math.abs(now.getTime() - hit.at) < REMEMBER_MS) return hit.week;
@@ -38,33 +38,30 @@ function weekOf(app: App, repo: Repo, now: Date): Week | null {
3838 return week;
3939 }
4040
41 function readWeek(app: App, repo: Repo, now: Date): Week | null {
41 function readWeek(app: App, repo: Repo, now: Date): Week {
4242 // A crew with no grant runs on the forge's own token: the account `usage`
4343 // prints as `platform`. It is metered too, never skipped (#8873).
4444 const granted = grantedAccounts(app.db, repo.id, repo.ownerId).map((a) => a.id);
45 const stand = standings(app.db, granted.length > 0 ? granted : [PLATFORM], now);
46 let capUsd = 0;
47 let freeUsd = 0;
48 let keptUsd = 0;
45 const accounts = granted.length > 0 ? granted : [PLATFORM];
46 // The provider's own percent of the week used (#8960), never dollars: an
47 // account it has not measured, or whose week has reset since, is unknown.
48 let usedPct = 0;
49 let keptPct = 0;
4950 let resetsAt: number | null = null;
5051 const known: string[] = [];
51 for (const [id, s] of stand) {
52 const w = s.windows.find((x) => x.window === "week");
53 if (w === undefined || w.cap === null || w.kept === null || w.remaining === null || w.resetsAt === null) continue;
52 for (const id of accounts) {
53 const m = measureFor(app.db, id, "week", now);
54 if (m === null) continue;
5455 known.push(id);
55 capUsd += w.cap.value;
56 freeUsd += w.remaining.value;
57 keptUsd += w.kept.value;
58 const at = Date.parse(w.resetsAt.value);
56 usedPct += m.usedPct;
57 keptPct += getHeadroom(app.db, id) ?? 0;
58 const at = Date.parse(m.resetsAt);
5959 if (resetsAt === null || at < resetsAt) resetsAt = at;
6060 }
61 if (resetsAt === null || !(capUsd > 0)) {
62 // Granted accounts with no known limit are an unknown week; the forge's own token with none is not metered at all.
63 return granted.length > 0 ? { meter: null, scope: [...granted].sort().join(",") } : null;
64 }
65 // The burn is read off the readings of the meter, not off dollars: the
66 // week's free share is stored as it is read and the burn is how fast it fell.
67 const freePct = (freeUsd / capUsd) * 100;
61 // No measurement is an unknown week, which holds the crew; it is never "not metered".
62 if (resetsAt === null) return { meter: null, scope: [...accounts].sort().join(",") };
63 const freePct = 100 - usedPct / known.length;
64 // The burn is how fast the measured percent fell between successive readings of it.
6865 const scope = [...known].sort().join(",");
6966 const opened = resetsAt - WINDOW_MS.week;
7067 noteReading(app.db, scope, now.getTime(), freePct, WINDOW_MS.week);
@@ -73,7 +70,7 @@ function readWeek(app: App, repo: Repo, now: Date): Week | null {
7370 scope,
7471 meter: {
7572 freePct,
76 keptPct: (keptUsd / capUsd) * 100,
73 keptPct: keptPct / known.length,
7774 burnPctPerHour: burnFrom(readings, now.getTime(), WEEK.burnHours),
7875 resetsAt: new Date(resetsAt),
7976 },
@@ -103,14 +100,15 @@ export function efficiencySince(app: App, repo: Repo, since: Date): Efficiency {
103100 /** The week's efficiency: since the week opened, or seven days back when no window is known. */
104101 export function weekEfficiency(app: App, repo: Repo, now: Date): Efficiency {
105102 const week = weekOf(app, repo, now);
106 const since = week?.meter == null ? now.getTime() - WINDOW_MS.week : week.meter.resetsAt.getTime() - WINDOW_MS.week;
103 const since = week.meter === null ? now.getTime() - WINDOW_MS.week : week.meter.resetsAt.getTime() - WINDOW_MS.week;
107104 return efficiencySince(app, repo, new Date(since));
108105 }
109106
110107 /**
111108 * The week's decision for a repository, given the builders' idle slots and
112 * the issues a builder may start and the Later gate alone holds; null while
113 * no account is metered.
109 * the issues a builder may start and the Later gate alone holds. An unknown
110 * week (no measure) is a decision that holds the crew; null only when the
111 * measured reset is past or more than a week away, where no pace applies.
114112 */
115113 export function weekFor(
116114 app: App,
@@ -119,12 +117,12 @@ export function weekFor(
119117 seen: { idleSlots: number; startable: number; gated: number } = { idleSlots: 0, startable: 0, gated: 0 },
120118 ): WeekDecision | null {
121119 const week = weekOf(app, repo, now);
122 return week === null ? null : weekDecision({ ...week, now, ceiling: repo.autonomySlots, floorSlots: paceFloor(app.db), ...seen });
120 return weekDecision({ ...week, now, ceiling: repo.autonomySlots, floorSlots: paceFloor(app.db), ...seen });
123121 }
124122
125 /** The key of the accounts a repository draws its week from; null while no account is metered. */
126 export function weekScope(app: App, repo: Repo, now: Date): string | null {
127 return weekOf(app, repo, now)?.scope ?? null;
123 /** The key of the accounts a repository draws its week from. */
124 export function weekScope(app: App, repo: Repo, now: Date): string {
125 return weekOf(app, repo, now).scope;
128126 }
129127
130128 /** The slots the whole crew may run at once: what `crew.concurrency` says, else the repository's own. */
@@ -139,14 +137,14 @@ export function crewCeiling(app: App, repo: Repo): number {
139137 */
140138 export function crewWeekFor(app: App, repo: Repo, now: Date): WeekDecision | null {
141139 const week = weekOf(app, repo, now);
142 return week === null ? null : weekDecision({ ...week, now, ceiling: crewCeiling(app, repo), floorSlots: paceFloor(app.db), idleSlots: 0, startable: 0, gated: 0 });
140 return weekDecision({ ...week, now, ceiling: crewCeiling(app, repo), floorSlots: paceFloor(app.db), idleSlots: 0, startable: 0, gated: 0 });
143141 }
144142
145143 /**
146144 * The pace sentence and the efficiency beside it for the autonomous
147145 * repository the crew works most this week (the most agent runs; the first by
148146 * name on a tie), not whichever sorts first: a quiet repository's "nothing
149 * landed" is not the forge's. Null while no account is metered. Home and the
147 * landed" is not the forge's. With no measurement it says the pace is unknown; null only when no autonomous repository has a pace to state (a reset past or over a week off). Home and the
150148 * terminal's dashboard print the same words.
151149 */
152150 export function weekWords(app: App, now: Date = new Date()): string | null {
test/crew-governed-unit.test.ts +2
@@ -7,6 +7,7 @@
77 * (runs started, the feed, the tick line) is proved in crew-governed.test.ts.
88 * Each test has its own database, so none depends on another's pulls.
99 */
10 import { quietWeek } from "./helpers/week-quiet.ts";
1011 import { afterEach, expect, test } from "bun:test";
1112 import { mkdtempSync, rmSync } from "node:fs";
1213 import { tmpdir } from "node:os";
@@ -36,6 +37,7 @@ afterEach(() => {
3637 async function forge(slots: number) {
3738 const root = mkdtempSync(join(tmpdir(), "forge-governed-unit-"));
3839 const app: App = createApp({ dataDir: root, port: 0 });
40 quietWeek(app.db);
3941 cleanup.push(() => {
4042 app.close();
4143 rmSync(root, { recursive: true, force: true });
test/crew-held.test.ts +2
@@ -6,6 +6,7 @@
66 * terminal's Flow dialog show the same counts, private repositories to the
77 * owner only. Each test makes its own repository and crew.
88 */
9 import { quietWeek } from "./helpers/week-quiet.ts";
910 import { seatAs } from "./helpers/seat.ts";
1011 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1112 import { mkdtemp, rm } from "node:fs/promises";
@@ -98,6 +99,7 @@ describe("the tick says why each ready issue cannot start", () => {
9899 beforeAll(async () => {
99100 dataDir = await mkdtemp(join(tmpdir(), "forge-held-"));
100101 app = createApp({ dataDir, port: 0 });
102 quietWeek(app.db);
101103 const a = await createUser(app.db, {
102104 origin: "seed",
103105 trusted: true,
test/crew-small-share.test.ts +2
@@ -3,6 +3,7 @@
33 * or small additions — hold at most `slots.smallShare` of the slots, and an
44 * issue under an open epic starts before a small one.
55 */
6 import { quietWeek } from "./helpers/week-quiet.ts";
67 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
78 import { mkdtemp, rm } from "node:fs/promises";
89 import { tmpdir } from "node:os";
@@ -99,6 +100,7 @@ async function drive(issues: readonly Issue[]): Promise<Issue[]> {
99100 beforeAll(async () => {
100101 dataDir = await mkdtemp(join(tmpdir(), "forge-small-share-"));
101102 app = createApp({ dataDir, port: 0 });
103 quietWeek(app.db);
102104 const a = await createUser(app.db, {
103105 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
104106 password: "a-good-passphrase", adminIfFirst: true,
test/finishing-room.test.ts +2
@@ -4,6 +4,7 @@
44 * queues no more bring-up-to-date runs — each a push, each a check — than
55 * the lane has slots for, however many builder slots are free.
66 */
7 import { quietWeek } from "./helpers/week-quiet.ts";
78 import { seatAs } from "./helpers/seat.ts";
89 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
910 import { mkdtemp, rm } from "node:fs/promises";
@@ -45,6 +46,7 @@ async function tick(): Promise<void> {
4546 beforeAll(async () => {
4647 dataDir = await mkdtemp(join(tmpdir(), "forge-finishing-room-"));
4748 app = createApp({ dataDir, port: 0 });
49 quietWeek(app.db);
4850 const a = await createUser(app.db, {
4951 origin: "seed",
5052 trusted: true,
test/helpers/week-quiet.ts added +14
@@ -0,0 +1,14 @@
1 /**
2 * A week the provider has measured but whose pace does not apply yet: the
3 * reset lies more than a week off, so `weekDecision` has nothing to say and
4 * the crew keeps its slots (#8960). For tests about something other than
5 * pacing, which would otherwise hold at the unknown-week level.
6 */
7 import type { Database } from "bun:sqlite";
8 import { noteMeasure } from "../../src/core/measure.ts";
9
10 export function quietWeek(db: Database): void {
11 const ids = db.query<{ id: string }, []>("SELECT id FROM subscription").all().map((r) => r.id);
12 for (const id of new Set(["platform", ...ids]))
13 noteMeasure(db, id, { window: "week", usedPct: 10, resetsAt: "2100-01-01T00:00:00.000Z", observedAt: new Date().toISOString() });
14 }
test/ledger.test.ts +2
@@ -3,6 +3,7 @@
33 * ship or show, try again or stop — each with a rule underneath and a fake
44 * decider on top, and each written down with what came of it.
55 */
6 import { quietWeek } from "./helpers/week-quiet.ts";
67 import { seatAs } from "./helpers/seat.ts";
78 import { beforeAll, afterAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
@@ -78,6 +79,7 @@ describe("the decision ledger", () => {
7879 beforeAll(async () => {
7980 dataDir = await mkdtemp(join(tmpdir(), "forge-ledger-"));
8081 app = createApp({ dataDir, port: 0, decider });
82 quietWeek(app.db);
8183 servers = await serve(app);
8284 const a = await createUser(app.db, { origin: "seed", trusted: true,
8385 username: "alice",
test/pace-floor.test.ts +4 −9
@@ -11,9 +11,9 @@ import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/r
1111 import { paceFloor, setCapacity } from "../src/core/capacity.ts";
1212 import { getRepoById, type Repo } from "../src/core/repos.ts";
1313 import { claimRun, createRun, getRun, listActive } from "../src/core/runs.ts";
14 import { noteLimit, setHeadroom } from "../src/core/standing.ts";
14 import { noteMeasure } from "../src/core/measure.ts";
15 import { setHeadroom } from "../src/core/standing.ts";
1516 import { addSubscription } from "../src/core/subscriptions.ts";
16 import { recordUsage } from "../src/core/usage.ts";
1717 import { forgetWeek } from "../src/crew/week_read.ts";
1818 import { readRunLog } from "../src/runs/log.ts";
1919 import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts";
@@ -48,13 +48,8 @@ async function pacedRepo(name: string, spentUsd: number): Promise<Repo> {
4848 setHeadroom(app.db, sub.value.id, 0);
4949 app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id);
5050 const now = Date.now();
51 const spend = (usd: number, at: number) => {
52 const run = createRun(app.db, { repoId: made.value.id, kind: "agent" });
53 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() });
54 };
55 spend(100, now - 4 * DAY);
56 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" });
57 spend(spentUsd, now - DAY);
51 // The provider's meter: `spentUsd` percent of the week used, with half the week's time gone.
52 noteMeasure(app.db, sub.value.id, { window: "week", usedPct: spentUsd, resetsAt: new Date(now + 3.5 * DAY).toISOString(), observedAt: new Date(now).toISOString() });
5853 app.db.query("INSERT INTO week_reading (scope, at, free_pct) VALUES (?1, ?2, ?3)").run(sub.value.id, new Date(now - 3_600_000).toISOString(), 100 - spentUsd + 6);
5954 const on = await updateRepoSettings(app, alice, made.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id });
6055 expect(on.ok).toBe(true);
test/runner-measure.test.ts added +93
@@ -0,0 +1,93 @@
1 /** A run stores the provider's measure against the subscription it ran on, or `platform` (#8960). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { chmodSync, writeFileSync } from "node:fs";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { addToCrew, createAgent } from "../src/actions/crew.ts";
8 import { assignIssue, openIssue } from "../src/actions/issues.ts";
9 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
10 import { createApp, type App } from "../src/app.ts";
11 import { measureFor } from "../src/core/measure.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import { listRuns } from "../src/core/runs.ts";
14 import { addSubscription, PLATFORM, setGrants } from "../src/core/subscriptions.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let crew: CrewHandle;
23 let alice: User;
24 let bot: User;
25 let repo: Repo;
26
27 async function runOnce(title: string): Promise<void> {
28 const issue = openIssue(app, alice, repo, { title, body: "x" });
29 if (!issue.ok) throw new Error(String(issue.error));
30 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
31 const deadline = Date.now() + 30_000;
32 while (Date.now() < deadline) {
33 const run = listRuns(app.db, repo.id, { kind: "agent" }).find((r) => r.issueId === issue.value.id);
34 if (run && ["ok", "failed", "cancelled"].includes(run.state)) return;
35 await Bun.sleep(100);
36 }
37 throw new Error("agent run did not settle");
38 }
39
40 beforeAll(async () => {
41 dataDir = await mkdtemp(join(tmpdir(), "forge-runmeasure-"));
42 app = createApp({ dataDir, port: 0 });
43 servers = await serve(app);
44 const fake = join(dataDir, "fake-claude");
45 writeFileSync(
46 fake,
47 `#!/bin/sh
48 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
49 IFS= read -r first
50 at=$(($(date +%s) + 86400))
51 echo '{"type":"system","subtype":"init","session_id":"fake-1","model":"fake"}'
52 echo '{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","resetsAt":'$at',"rateLimitType":"seven_day","utilization":0.5}}'
53 echo "work" >> CREW.md
54 git add -A && git commit -qm "crew work" && git push -q -u origin HEAD
55 echo '{"type":"result","subtype":"success","is_error":false,"result":"done","total_cost_usd":0.01,"num_turns":1,"session_id":"fake-1","terminal_reason":"completed"}'
56 `,
57 );
58 chmodSync(fake, 0o755);
59 crew = await startCrew(app, { forgeUrl: servers.url, localUrl: servers.url, binary: fake, concurrency: 1, budgetUsd: 2, runTimeoutMs: 60_000 });
60 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
61 if (!a.ok) throw new Error(a.error);
62 alice = a.value;
63 const made = await createAgent(app, alice, { username: "bot" });
64 if (!made.ok) throw new Error(String(made.error));
65 bot = made.value.user;
66 const created = await createRepo(app, alice, { name: "garden", visibility: "private" });
67 if (!created.ok) throw new Error(String(created.error));
68 repo = created.value;
69 const seeded = await commitToBranch(app, alice, repo, { message: "Start", files: [{ path: "README.md", content: "# garden\n" }] });
70 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
71 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
72 }, 30_000);
73
74 afterAll(async () => {
75 await crew.stop();
76 servers.stop();
77 app.close();
78 await rm(dataDir, { recursive: true, force: true });
79 });
80
81 test("a run with no grant stores the measure against platform; with a grant, against that subscription", async () => {
82 const added = addSubscription(app.db, dataDir, { name: "main", token: "sk-ant-oat01-runner-1" });
83 if (!added.ok) throw new Error(added.error);
84 const sub = added.value.id;
85 const now = new Date();
86 await runOnce("first");
87 expect(measureFor(app.db, PLATFORM, "week", now)?.usedPct).toBe(50);
88 expect(measureFor(app.db, sub, "week", now)).toBeNull();
89
90 setGrants(app.db, repo.id, "", [sub]);
91 await runOnce("second");
92 expect(measureFor(app.db, sub, "week", now)?.usedPct).toBe(50);
93 }, 90_000);
test/subscription-measure.test.ts added +149
@@ -0,0 +1,149 @@
1 /** The provider's measure of a window (#8960): read from the session's event, kept, and read back only while its window is open. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { chmodSync, writeFileSync } from "node:fs";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { readForgeUsage } from "../src/actions/usage.ts";
9 import { createApp, type App } from "../src/app.ts";
10 import { measureFor, measureOf, noteMeasure, type Measure } from "../src/core/measure.ts";
11 import type { Repo } from "../src/core/repos.ts";
12 import { addSubscription, setGrants } from "../src/core/subscriptions.ts";
13 import type { User } from "../src/core/users.ts";
14 import { runClaude } from "../src/crew/claude.ts";
15 import { sandboxPolicy } from "../src/crew/sandbox.ts";
16 import { seedAdmin } from "./helpers/admin.ts";
17
18 let dir: string;
19 let app: App;
20 let sub: string;
21 const NOW = new Date("2026-11-02T12:00:00Z");
22 const reset = Math.floor(NOW.getTime() / 1000) + 86_400;
23 const event = (info: Record<string, unknown>) => ({ type: "rate_limit_event", rate_limit_info: info });
24
25 beforeAll(async () => {
26 dir = await mkdtemp(join(tmpdir(), "forge-measure-"));
27 app = createApp({ dataDir: dir, port: 0 });
28 const added = addSubscription(app.db, dir, { name: "main", token: "sk-ant-oat01-measure-1" });
29 if (!added.ok) throw new Error(added.error);
30 sub = added.value.id;
31 });
32
33 afterAll(async () => {
34 app.close();
35 await rm(dir, { recursive: true, force: true });
36 });
37
38 test("a rate-limit event carries a window, a percent used and a reset", () => {
39 expect(measureOf(event({ rateLimitType: "seven_day", utilization: 0.42, resetsAt: reset }), NOW)).toEqual({
40 window: "week",
41 usedPct: 42,
42 resetsAt: new Date(reset * 1000).toISOString(),
43 observedAt: NOW.toISOString(),
44 });
45 expect(measureOf(event({ rateLimitType: "five_hour", utilization: 1, resetsAt: reset }), NOW)?.window).toBe("5h");
46 });
47
48 test("an event without a usable measure is no measure", () => {
49 expect(measureOf(event({ rateLimitType: "seven_day", resetsAt: reset }), NOW)).toBeUndefined();
50 expect(measureOf(event({ rateLimitType: "seven_day", utilization: 1.5, resetsAt: reset }), NOW)).toBeUndefined();
51 expect(measureOf(event({ rateLimitType: "seven_day", utilization: 0.2 }), NOW)).toBeUndefined();
52 expect(measureOf(event({ rateLimitType: "overage", utilization: 0.2, resetsAt: reset }), NOW)).toBeUndefined();
53 expect(measureOf({ type: "assistant" }, NOW)).toBeUndefined();
54 });
55
56 test("the newest word replaces the last, and a window that has reset is unknown", () => {
57 const m = measureOf(event({ rateLimitType: "seven_day", utilization: 0.3, resetsAt: reset }), NOW)!;
58 noteMeasure(app.db, sub, m);
59 noteMeasure(app.db, sub, { ...m, usedPct: 35 });
60 expect(measureFor(app.db, sub, "week", NOW)?.usedPct).toBe(35);
61 expect(measureFor(app.db, sub, "5h", NOW)).toBeNull();
62 expect(measureFor(app.db, sub, "week", new Date((reset + 1) * 1000))).toBeNull();
63 });
64
65 test("an unknown subscription is not given a measure", () => {
66 const m = measureOf(event({ rateLimitType: "seven_day", utilization: 0.3, resetsAt: reset }), NOW)!;
67 noteMeasure(app.db, "nobody", m);
68 expect(measureFor(app.db, "nobody", "week", NOW)).toBeNull();
69 });
70
71 // A rate_limit_event as Claude Code's stream-json writes it. NOT a recording:
72 // this sandbox cannot run Claude Code, so the shape is the documented one
73 // (`rate_limit_info`: status, resetsAt in unix seconds, rateLimitType, and
74 // `utilization` only when the provider sends it). Replace with a recorded line.
75 const WARNING = (at: number) =>
76 `{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","resetsAt":${at},"rateLimitType":"seven_day","utilization":0.75},"uuid":"u1","session_id":"s1"}`;
77 const PLAIN = (at: number) =>
78 `{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":${at},"rateLimitType":"five_hour"},"uuid":"u2","session_id":"s1"}`;
79
80 test("the fixture lines: one carries a measure, the status-only one carries none", () => {
81 expect(measureOf(JSON.parse(WARNING(reset)), NOW)?.usedPct).toBe(75);
82 expect(measureOf(JSON.parse(PLAIN(reset)), NOW)).toBeUndefined();
83 });
84
85 test("a rate_limit_event line reaches onMeasure and nothing else in the stream parser", async () => {
86 const fake = join(dir, "fake-claude");
87 const future = Math.floor(Date.now() / 1000) + 86_400;
88 writeFileSync(
89 fake,
90 `#!/bin/sh
91 IFS= read -r first
92 echo '{"type":"system","subtype":"init","session_id":"s1","model":"fake"}'
93 echo '${WARNING(future)}'
94 echo '${PLAIN(future)}'
95 echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.01,"num_turns":1}'
96 `,
97 );
98 chmodSync(fake, 0o755);
99 const seen: Measure[] = [];
100 const lines: string[] = [];
101 const outcome = await runClaude({
102 binary: fake,
103 cwd: dir,
104 prompt: "go",
105 appendSystemPrompt: "",
106 budgetUsd: 1,
107 env: {},
108 sandbox: sandboxPolicy({ dataDir: dir, agentName: "bot", urls: [] }),
109 timeoutMs: 10_000,
110 log: (l) => lines.push(l),
111 signal: new AbortController().signal,
112 onMeasure: (m) => seen.push(m),
113 });
114 expect(outcome.ok).toBe(true);
115 expect(seen.map((m) => [m.window, m.usedPct])).toEqual([["week", 75]]);
116 expect(lines.join("\n")).not.toContain("rate_limit");
117 });
118
119 describe("the usage headroom", () => {
120 let repo: Repo;
121 let admin: User;
122 const week = () => readForgeUsage(app, admin, NOW).subscriptions.find((s) => s.subscription === "main")!.headroom.find((w) => w.window === "week");
123 const clear = () => app.db.query("DELETE FROM subscription_measure").run();
124
125 beforeAll(async () => {
126 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
127 if (!a.ok) throw new Error("admin");
128 admin = a.value;
129 const made = await createRepo(app, admin, { name: "svc", visibility: "private" });
130 if (!made.ok) throw new Error("repo");
131 repo = made.value;
132 setGrants(app.db, repo.id, "", [sub]);
133 });
134
135 test("prefers the provider's measure over the ledger's inference", () => {
136 clear();
137 noteMeasure(app.db, sub, { window: "week", usedPct: 62.4, resetsAt: new Date(reset * 1000).toISOString(), observedAt: NOW.toISOString() });
138 expect(week()).toEqual({ window: "week", pct: 38, resetsAt: new Date(reset * 1000).toISOString() });
139 });
140
141 test("falls back when there is no measure, or its window has reset", () => {
142 clear();
143 const none = week();
144 expect(none?.pct ?? null).toBeNull();
145 expect(none?.resetsAt ?? null).not.toBe(new Date(reset * 1000).toISOString());
146 noteMeasure(app.db, sub, { window: "week", usedPct: 62.4, resetsAt: new Date(NOW.getTime() - 1000).toISOString(), observedAt: NOW.toISOString() });
147 expect(week()?.pct ?? null).toBeNull();
148 });
149 });
test/tui-crawlers.test.ts +2
@@ -1,4 +1,5 @@
11 /** #8129: the crawler share and refusals in the terminal, a forge admin's who is a person; a member is offered nothing. */
2 import { quietWeek } from "./helpers/week-quiet.ts";
23 import { afterAll, beforeAll, expect, test } from "bun:test";
34 import { mkdtemp, rm } from "node:fs/promises";
45 import { tmpdir } from "node:os";
@@ -23,6 +24,7 @@ const identity = (user: User) => ({ sshUsername: user.username, remoteAddress: "
2324 beforeAll(async () => {
2425 dir = await mkdtemp(join(tmpdir(), "forge-tui-crawlers-"));
2526 app = createApp({ dataDir: dir, port: 0 });
27 quietWeek(app.db);
2628 const mk = async (username: string, isAdmin: boolean) => {
2729 const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", isAdmin });
2830 if (!r.ok) throw new Error(r.error);
test/week-pump.test.ts +8 −19
@@ -7,10 +7,10 @@ import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/r
77 import { getRepoById } from "../src/core/repos.ts";
88 import { setCapacity } from "../src/core/capacity.ts";
99 import { claimRun, createRun, getRun, listRuns } from "../src/core/runs.ts";
10 import { noteLimit, setHeadroom } from "../src/core/standing.ts";
10 import { noteMeasure } from "../src/core/measure.ts";
11 import { setHeadroom } from "../src/core/standing.ts";
1112 import { addSubscription } from "../src/core/subscriptions.ts";
1213 import { forgetWeek } from "../src/crew/week_read.ts";
13 import { recordUsage } from "../src/core/usage.ts";
1414 import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts";
1515
1616 const DAY = 86_400_000;
@@ -41,15 +41,10 @@ test("past the week's reserve an assigned run stays queued, with the reason in i
4141 if (!sub.ok) throw new Error(sub.error);
4242 setHeadroom(app.db, sub.value.id, 0);
4343 app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id);
44 // Last week the provider refused at $100; this week $95 is spent.
44 // The provider says the week is half over and 95% used.
4545 const now = Date.now();
46 const spend = (usd: number, at: number) => {
47 const run = createRun(app.db, { repoId: made.value.id, kind: "agent" });
48 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() });
49 };
50 spend(100, now - 4 * DAY);
51 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" });
52 spend(95, now - DAY);
46 const measure = (used: number) => noteMeasure(app.db, sub.value.id, { window: "week", usedPct: used, resetsAt: new Date(now + 3.5 * DAY).toISOString(), observedAt: new Date(now).toISOString() });
47 measure(95);
5348 const on = await updateRepoSettings(app, alice, made.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id });
5449 expect(on.ok).toBe(true);
5550 const repo = getRepoById(app.db, made.value.id)!;
@@ -60,8 +55,8 @@ test("past the week's reserve an assigned run stays queued, with the reason in i
6055 const logOf = () => (mine() === undefined ? "" : readRunLog(app.config.dataDir, mine()!.id).lines.join("\n"));
6156 await until(() => logOf().includes("queued, not started: Ahead of pace"), "the hold to be logged");
6257 expect(mine()?.state).toBe("queued");
63 // With the spend gone the week is on pace and the same run starts.
64 app.db.query("DELETE FROM usage WHERE at >= ?1").run(new Date(now - 2 * DAY).toISOString());
58 // With the meter back at 50% the week is on pace and the same run starts.
59 measure(50);
6560 forgetWeek();
6661 await until(() => mine()?.state !== "queued", "the run to start");
6762 expect(mine()?.state).not.toBe("queued");
@@ -80,13 +75,7 @@ test("builds fill the pace slots, yet a review or assigned run still starts; a t
8075 app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id);
8176 setCapacity(app.db, "crew.concurrency", "10", alice.id);
8277 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);
78 noteMeasure(app.db, sub.value.id, { window: "week", usedPct: 70, resetsAt: new Date(now + 3.5 * DAY).toISOString(), observedAt: new Date(now).toISOString() });
9079 // 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.
9180 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());
9281 const on = await updateRepoSettings(app, alice, made.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id });
test/week-wiring.test.ts +35 −27
@@ -12,7 +12,8 @@ import { addDependency } from "../src/core/dependencies.ts";
1212 import { getIssue, getIssueById, type Issue } from "../src/core/issues.ts";
1313 import { getRepoById, type Repo } from "../src/core/repos.ts";
1414 import { createRun } from "../src/core/runs.ts";
15 import { noteLimit, setHeadroom } from "../src/core/standing.ts";
15 import { noteMeasure } from "../src/core/measure.ts";
16 import { setHeadroom } from "../src/core/standing.ts";
1617 import { addSubscription } from "../src/core/subscriptions.ts";
1718 import { recordUsage } from "../src/core/usage.ts";
1819 import { type User } from "../src/core/users.ts";
@@ -61,19 +62,14 @@ function seedReading(scope: string, freePct: number): void {
6162 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);
6263 }
6364
64 /** The week is half over; last week the provider refused at $100, so the cap is known. */
65 /** The provider says the week is `used`% used, half over (reset 3.5 days ahead); the meter read `recent` points higher an hour ago. */
66 function measure(account: string, used: number, at: Date = NOW): void {
67 noteMeasure(app.db, account, { window: "week", usedPct: used, resetsAt: new Date(NOW.getTime() + 3.5 * DAY).toISOString(), observedAt: at.toISOString() });
68 }
69
6570 function week(usedSoFar: number, recent = 0): void {
66 app.db.query("DELETE FROM usage").run();
67 spend(100, new Date(NOW.getTime() - 4 * DAY));
68 noteLimit(app.db, sub, {
69 window: "week",
70 resetsAt: new Date(NOW.getTime() - 3.5 * DAY).toISOString(),
71 observedAt: new Date(NOW.getTime() - 3.5 * DAY - 3_600_000).toISOString(),
72 said: "weekly limit",
73 });
74 if (usedSoFar - recent > 0) spend(usedSoFar - recent, new Date(NOW.getTime() - DAY));
71 measure(sub, usedSoFar);
7572 // The meter read `recent` points higher an hour ago: that is the burn, an hour. Zero is a flat meter.
76 if (recent > 0) spend(recent, new Date(NOW.getTime() - 3_600_000));
7773 seedReading(sub, 100 - usedSoFar + recent);
7874 forgetWeek();
7975 }
@@ -180,30 +176,21 @@ test("a fast afternoon slows the crew at once, on a week whose total is on pace"
180176 expect(repoPace(app, repo, NOW).reason).toContain("running out about");
181177 });
182178
183 test("with no known weekly cap the pace is unknown and the crew holds at a modest level", () => {
184 app.db.query("DELETE FROM subscription_limit").run();
185 app.db.query("DELETE FROM usage").run();
179 test("with no measurement of the week the pace is unknown and the crew holds at a modest level", () => {
180 app.db.query("DELETE FROM subscription_measure").run();
186181 forgetWeek();
187182 expect(weekWords(app, NOW)).toContain("pace is unknown");
188183 expect(String(weekLine(app, NOW))).toContain("pace is unknown");
189184 expect(repoPace(app, repo, NOW).slots).toBe(2);
190185 });
191186
192 test("a crew on the forge's own token is metered: today's numbers, read through the ledger, slow it", () => {
187 test("a crew on the forge's own token is metered: today's numbers, read from the provider's measure, slow it", () => {
193188 // The cause of #8873: no grant meant no account, no week, no pacing.
194189 app.db.query("DELETE FROM subscription_grant").run();
195190 app.db.query("DELETE FROM usage").run();
196191 try {
197 const at = (days: number) => new Date(NOW.getTime() + days * DAY);
198 const onPlatform = (usd: number, when: Date) => {
199 const run = createRun(app.db, { repoId: repo.id, kind: "agent" });
200 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() });
201 };
202 // 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.
203 onPlatform(100, at(-2));
204 noteLimit(app.db, "platform", { window: "week", resetsAt: at(-1.7).toISOString(), observedAt: at(-1.7 - 1 / 24).toISOString(), said: "weekly limit" });
205 onPlatform(45.9, at(-1));
206 onPlatform(8.1, new Date(NOW.getTime() - 3_600_000));
192 // The provider measured 54% used; an hour ago the meter read 2.7 points higher.
193 measure("platform", 54);
207194 seedReading("platform", 46 + 2.7);
208195 forgetWeek();
209196 const pace = repoPace(app, repo, NOW);
@@ -233,13 +220,14 @@ test("readWeek stores a reading of the meter, so the burn is not seeded by hand
233220 const rows = app.db.query("SELECT scope, free_pct FROM week_reading").all() as { scope: string; free_pct: number }[];
234221 expect(rows).toEqual([{ scope: sub, free_pct: 70 }]);
235222 // 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));
223 measure(sub, 34, new Date(NOW.getTime() + 3_600_000));
237224 forgetWeek();
238225 expect(weekWords(app, new Date(NOW.getTime() + 3_600_000))).toContain("burning 4% an hour");
239226 });
240227
241228 test("home reads the repository the crew works, not the first by name", async () => {
242229 week(20);
230 spend(1, new Date(NOW.getTime() - DAY));
243231 const quiet = await createRepo(app, alice, { name: "aaa-quiet", visibility: "private" });
244232 if (!quiet.ok) throw new Error(String(quiet.error));
245233 expect(addToCrew(app, alice, quiet.value, "builderbot").ok).toBe(true);
@@ -252,3 +240,23 @@ test("home reads the repository the crew works, not the first by name", async ()
252240 expect(words).not.toContain("(0 runs");
253241 expect(words).toMatch(/\(\d+ runs/);
254242 });
243
244 test("a week that fell 8 points in 3 hours slows the picks; a flat meter does not (#8960)", () => {
245 const threeHoursAgo = new Date(NOW.getTime() - 3 * 3_600_000).toISOString();
246 const readAt = (freePct: number) => {
247 app.db.query("DELETE FROM week_reading").run();
248 app.db.query("INSERT INTO week_reading (scope, at, free_pct) VALUES (?1, ?2, ?3)").run(sub, threeHoursAgo, freePct);
249 forgetWeek();
250 };
251 // 3 hours ago the provider read 8 points more free than it does now: 8/3 points an hour, over an hour allowed 0.7.
252 measure(sub, 60);
253 readAt(48);
254 const falling = repoPace(app, repo, NOW);
255 expect(falling.slots < 4).toBe(true);
256 expect(falling.reason).toContain("2.7% an hour");
257 // The same measure, unmoved over the same 3 hours: 0% an hour, no slowing.
258 readAt(40);
259 const flat = repoPace(app, repo, NOW);
260 expect(flat.slots).toBe(4);
261 expect(weekWords(app, NOW)).toContain("burning 0% an hour");
262 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs