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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8960-pacing-decides-from-the-provider-s-measu → main 7 commits fork 2 threads · 2 unresolved
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 | ||
| 465 | 465 | |
| 466 | 466 | 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. |
| 467 | 467 | |
| 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 | ||
| 468 | 487 | ## What the pace limits (#8962) |
| 469 | 488 | |
| 470 | 489 | 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 { | ||
| 20 | 20 | type Repo, |
| 21 | 21 | } from "../core/repos.ts"; |
| 22 | 22 | import { forgeSpend, type SpanSpend } from "../core/usage_report.ts"; |
| 23 | import { measureFor } from "../core/measure.ts"; | |
| 23 | 24 | import { freePct, standings, type Standing } from "../core/standing.ts"; |
| 24 | 25 | import { |
| 25 | 26 | budgetsOf, |
| @@ -274,11 +275,15 @@ export function readForgeUsage( | ||
| 274 | 275 | subscription: names.get(id) ?? (id === PLATFORM ? id : "a removed subscription"), |
| 275 | 276 | today: at(spend.today, id), |
| 276 | 277 | 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 | }), | |
| 282 | 287 | })) |
| 283 | 288 | .sort((a, b) => a.subscription.localeCompare(b.subscription)); |
| 284 | 289 | // 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 | /** | |
| 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 @@ | ||
| 12 | 12 | * settings files of the operator's, a spend budget, and a deadline. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { measureOf, type Measure } from "../core/measure.ts"; | |
| 15 | 16 | import { BACKGROUND_CPU_WEIGHT, backgroundArgv, crewCpus } from "../apps/background.ts"; |
| 16 | 17 | import { cgroupHost, openCellCgroup, tryEnterArgv } from "../apps/cgroup.ts"; |
| 17 | 18 | import { join } from "node:path"; |
| @@ -51,6 +52,8 @@ export interface ClaudeInput { | ||
| 51 | 52 | * for this invocation, never deltas. |
| 52 | 53 | */ |
| 53 | 54 | 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; | |
| 54 | 57 | /** |
| 55 | 58 | * Talks to the forge through MCP alone (#7651): no file or shell tools, no |
| 56 | 59 | * clone, the one server named in `config` (`--mcp-config` JSON) allowed. |
| @@ -362,6 +365,11 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | ||
| 362 | 365 | sessionId = e["session_id"]; |
| 363 | 366 | input.onSession?.(sessionId); |
| 364 | 367 | } |
| 368 | if (type === "rate_limit_event") { | |
| 369 | const measure = measureOf(e, new Date()); | |
| 370 | if (measure !== undefined) input.onMeasure?.(measure); | |
| 371 | return; | |
| 372 | } | |
| 365 | 373 | if (type === "system") { |
| 366 | 374 | if (e["subtype"] === "init") |
| 367 | 375 | input.log(`session ${sessionId} (${String(e["model"] ?? "")})`); |
src/crew/runner.ts +2
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * what the forge has. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { noteMeasure } from "../core/measure.ts"; | |
| 14 | 15 | import { capacityEnv } from "../core/capacity.ts"; |
| 15 | 16 | import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; |
| 16 | 17 | import { join } from "node:path"; |
| @@ -1399,6 +1400,7 @@ async function work( | ||
| 1399 | 1400 | onSteer: (send) => { |
| 1400 | 1401 | listenForSteer(run.id, send); |
| 1401 | 1402 | }, |
| 1403 | onMeasure: (m) => noteMeasure(app.db, meter.subscriptionId ?? PLATFORM, m), | |
| 1402 | 1404 | onUsage: (so) => |
| 1403 | 1405 | meterLive( |
| 1404 | 1406 | app, |
src/crew/week.ts +3 −2
| @@ -98,7 +98,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 98 | 98 | runsOutAt: null, |
| 99 | 99 | slots, |
| 100 | 100 | 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.`, | |
| 102 | 102 | }; |
| 103 | 103 | } |
| 104 | 104 | const left = meter.resetsAt.getTime() - now.getTime(); |
| @@ -126,7 +126,8 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 126 | 126 | slots = Math.min(ceiling, Math.max(0, slots)); |
| 127 | 127 | |
| 128 | 128 | const release = status === "behind" && input.idleSlots > 0 && input.startable === 0 && input.gated > 0; |
| 129 | const weekLeft = `${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)}`; | |
| 130 | 131 | const burnWords = burn === null ? "unread (too few readings)" : `${pct(burn)} an hour`; |
| 131 | 132 | const sentence = |
| 132 | 133 | burn === null && !burning |
src/crew/week_read.ts +32 −34
| @@ -5,11 +5,11 @@ | ||
| 5 | 5 | |
| 6 | 6 | import type { App } from "../app.ts"; |
| 7 | 7 | 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"; | |
| 10 | 10 | import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts"; |
| 11 | 11 | import { burnFrom, noteReading, readingsSince } from "./week_burn.ts"; |
| 12 | import { paceFloor } from "../core/capacity.ts"; | |
| 12 | import { capacityCount, paceFloor } from "../core/capacity.ts"; | |
| 13 | 13 | import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts"; |
| 14 | 14 | |
| 15 | 15 | /** 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 { | ||
| 21 | 21 | |
| 22 | 22 | /** How long a repository's week is remembered: `repoPace` is asked many times a tick. */ |
| 23 | 23 | 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 }>(); | |
| 25 | 25 | |
| 26 | 26 | /** Forget what was remembered, so the next ask reads again. */ |
| 27 | 27 | export function forgetWeek(): void { |
| @@ -29,7 +29,7 @@ export function forgetWeek(): void { | ||
| 29 | 29 | } |
| 30 | 30 | |
| 31 | 31 | /** 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 { | |
| 33 | 33 | const key = `${app.config.dataDir}\0${repo.id}`; |
| 34 | 34 | const hit = remembered.get(key); |
| 35 | 35 | 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 { | ||
| 38 | 38 | return week; |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | function readWeek(app: App, repo: Repo, now: Date): Week | null { | |
| 41 | function readWeek(app: App, repo: Repo, now: Date): Week { | |
| 42 | 42 | // A crew with no grant runs on the forge's own token: the account `usage` |
| 43 | 43 | // prints as `platform`. It is metered too, never skipped (#8873). |
| 44 | 44 | 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; | |
| 49 | 50 | let resetsAt: number | null = null; |
| 50 | 51 | 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; | |
| 54 | 55 | 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); | |
| 59 | 59 | if (resetsAt === null || at < resetsAt) resetsAt = at; |
| 60 | 60 | } |
| 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. | |
| 68 | 65 | const scope = [...known].sort().join(","); |
| 69 | 66 | const opened = resetsAt - WINDOW_MS.week; |
| 70 | 67 | noteReading(app.db, scope, now.getTime(), freePct, WINDOW_MS.week); |
| @@ -73,7 +70,7 @@ function readWeek(app: App, repo: Repo, now: Date): Week | null { | ||
| 73 | 70 | scope, |
| 74 | 71 | meter: { |
| 75 | 72 | freePct, |
| 76 | keptPct: (keptUsd / capUsd) * 100, | |
| 73 | keptPct: keptPct / known.length, | |
| 77 | 74 | burnPctPerHour: burnFrom(readings, now.getTime(), WEEK.burnHours), |
| 78 | 75 | resetsAt: new Date(resetsAt), |
| 79 | 76 | }, |
| @@ -103,14 +100,15 @@ export function efficiencySince(app: App, repo: Repo, since: Date): Efficiency { | ||
| 103 | 100 | /** The week's efficiency: since the week opened, or seven days back when no window is known. */ |
| 104 | 101 | export function weekEfficiency(app: App, repo: Repo, now: Date): Efficiency { |
| 105 | 102 | 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; | |
| 107 | 104 | return efficiencySince(app, repo, new Date(since)); |
| 108 | 105 | } |
| 109 | 106 | |
| 110 | 107 | /** |
| 111 | 108 | * 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. | |
| 114 | 112 | */ |
| 115 | 113 | export function weekFor( |
| 116 | 114 | app: App, |
| @@ -119,12 +117,12 @@ export function weekFor( | ||
| 119 | 117 | seen: { idleSlots: number; startable: number; gated: number } = { idleSlots: 0, startable: 0, gated: 0 }, |
| 120 | 118 | ): WeekDecision | null { |
| 121 | 119 | 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 }); | |
| 123 | 121 | } |
| 124 | 122 | |
| 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; | |
| 128 | 126 | } |
| 129 | 127 | |
| 130 | 128 | /** 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 { | ||
| 139 | 137 | */ |
| 140 | 138 | export function crewWeekFor(app: App, repo: Repo, now: Date): WeekDecision | null { |
| 141 | 139 | 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 }); | |
| 143 | 141 | } |
| 144 | 142 | |
| 145 | 143 | /** |
| 146 | 144 | * The pace sentence and the efficiency beside it for the autonomous |
| 147 | 145 | * repository the crew works most this week (the most agent runs; the first by |
| 148 | 146 | * 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 | |
| 150 | 148 | * terminal's dashboard print the same words. |
| 151 | 149 | */ |
| 152 | 150 | export function weekWords(app: App, now: Date = new Date()): string | null { |
test/crew-governed-unit.test.ts +2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * (runs started, the feed, the tick line) is proved in crew-governed.test.ts. |
| 8 | 8 | * Each test has its own database, so none depends on another's pulls. |
| 9 | 9 | */ |
| 10 | import { quietWeek } from "./helpers/week-quiet.ts"; | |
| 10 | 11 | import { afterEach, expect, test } from "bun:test"; |
| 11 | 12 | import { mkdtempSync, rmSync } from "node:fs"; |
| 12 | 13 | import { tmpdir } from "node:os"; |
| @@ -36,6 +37,7 @@ afterEach(() => { | ||
| 36 | 37 | async function forge(slots: number) { |
| 37 | 38 | const root = mkdtempSync(join(tmpdir(), "forge-governed-unit-")); |
| 38 | 39 | const app: App = createApp({ dataDir: root, port: 0 }); |
| 40 | quietWeek(app.db); | |
| 39 | 41 | cleanup.push(() => { |
| 40 | 42 | app.close(); |
| 41 | 43 | rmSync(root, { recursive: true, force: true }); |
test/crew-held.test.ts +2
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * terminal's Flow dialog show the same counts, private repositories to the |
| 7 | 7 | * owner only. Each test makes its own repository and crew. |
| 8 | 8 | */ |
| 9 | import { quietWeek } from "./helpers/week-quiet.ts"; | |
| 9 | 10 | import { seatAs } from "./helpers/seat.ts"; |
| 10 | 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 11 | 12 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -98,6 +99,7 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 98 | 99 | beforeAll(async () => { |
| 99 | 100 | dataDir = await mkdtemp(join(tmpdir(), "forge-held-")); |
| 100 | 101 | app = createApp({ dataDir, port: 0 }); |
| 102 | quietWeek(app.db); | |
| 101 | 103 | const a = await createUser(app.db, { |
| 102 | 104 | origin: "seed", |
| 103 | 105 | trusted: true, |
test/finishing-room.test.ts +2
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * queues no more bring-up-to-date runs — each a push, each a check — than |
| 5 | 5 | * the lane has slots for, however many builder slots are free. |
| 6 | 6 | */ |
| 7 | import { quietWeek } from "./helpers/week-quiet.ts"; | |
| 7 | 8 | import { seatAs } from "./helpers/seat.ts"; |
| 8 | 9 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; |
| 9 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -45,6 +46,7 @@ async function tick(): Promise<void> { | ||
| 45 | 46 | beforeAll(async () => { |
| 46 | 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-finishing-room-")); |
| 47 | 48 | app = createApp({ dataDir, port: 0 }); |
| 49 | quietWeek(app.db); | |
| 48 | 50 | const a = await createUser(app.db, { |
| 49 | 51 | origin: "seed", |
| 50 | 52 | 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 @@ | ||
| 3 | 3 | * ship or show, try again or stop — each with a rule underneath and a fake |
| 4 | 4 | * decider on top, and each written down with what came of it. |
| 5 | 5 | */ |
| 6 | import { quietWeek } from "./helpers/week-quiet.ts"; | |
| 6 | 7 | import { seatAs } from "./helpers/seat.ts"; |
| 7 | 8 | import { beforeAll, afterAll, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -78,6 +79,7 @@ describe("the decision ledger", () => { | ||
| 78 | 79 | beforeAll(async () => { |
| 79 | 80 | dataDir = await mkdtemp(join(tmpdir(), "forge-ledger-")); |
| 80 | 81 | app = createApp({ dataDir, port: 0, decider }); |
| 82 | quietWeek(app.db); | |
| 81 | 83 | servers = await serve(app); |
| 82 | 84 | const a = await createUser(app.db, { origin: "seed", trusted: true, |
| 83 | 85 | username: "alice", |
test/pace-floor.test.ts +4 −9
| @@ -11,9 +11,9 @@ import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/r | ||
| 11 | 11 | import { paceFloor, setCapacity } from "../src/core/capacity.ts"; |
| 12 | 12 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 13 | 13 | 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"; | |
| 15 | 16 | import { addSubscription } from "../src/core/subscriptions.ts"; |
| 16 | import { recordUsage } from "../src/core/usage.ts"; | |
| 17 | 17 | import { forgetWeek } from "../src/crew/week_read.ts"; |
| 18 | 18 | import { readRunLog } from "../src/runs/log.ts"; |
| 19 | 19 | import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts"; |
| @@ -48,13 +48,8 @@ async function pacedRepo(name: string, spentUsd: number): Promise<Repo> { | ||
| 48 | 48 | setHeadroom(app.db, sub.value.id, 0); |
| 49 | 49 | app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id); |
| 50 | 50 | 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() }); | |
| 58 | 53 | 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); |
| 59 | 54 | const on = await updateRepoSettings(app, alice, made.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id }); |
| 60 | 55 | 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 @@ | ||
| 1 | 1 | /** #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"; | |
| 2 | 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | 4 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 5 | import { tmpdir } from "node:os"; |
| @@ -23,6 +24,7 @@ const identity = (user: User) => ({ sshUsername: user.username, remoteAddress: " | ||
| 23 | 24 | beforeAll(async () => { |
| 24 | 25 | dir = await mkdtemp(join(tmpdir(), "forge-tui-crawlers-")); |
| 25 | 26 | app = createApp({ dataDir: dir, port: 0 }); |
| 27 | quietWeek(app.db); | |
| 26 | 28 | const mk = async (username: string, isAdmin: boolean) => { |
| 27 | 29 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", isAdmin }); |
| 28 | 30 | 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 | ||
| 7 | 7 | import { getRepoById } from "../src/core/repos.ts"; |
| 8 | 8 | import { setCapacity } from "../src/core/capacity.ts"; |
| 9 | 9 | 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"; | |
| 11 | 12 | import { addSubscription } from "../src/core/subscriptions.ts"; |
| 12 | 13 | import { forgetWeek } from "../src/crew/week_read.ts"; |
| 13 | import { recordUsage } from "../src/core/usage.ts"; | |
| 14 | 14 | import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts"; |
| 15 | 15 | |
| 16 | 16 | 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 | ||
| 41 | 41 | if (!sub.ok) throw new Error(sub.error); |
| 42 | 42 | setHeadroom(app.db, sub.value.id, 0); |
| 43 | 43 | 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. | |
| 45 | 45 | 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); | |
| 53 | 48 | const on = await updateRepoSettings(app, alice, made.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id }); |
| 54 | 49 | expect(on.ok).toBe(true); |
| 55 | 50 | 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 | ||
| 60 | 55 | const logOf = () => (mine() === undefined ? "" : readRunLog(app.config.dataDir, mine()!.id).lines.join("\n")); |
| 61 | 56 | await until(() => logOf().includes("queued, not started: Ahead of pace"), "the hold to be logged"); |
| 62 | 57 | 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); | |
| 65 | 60 | forgetWeek(); |
| 66 | 61 | await until(() => mine()?.state !== "queued", "the run to start"); |
| 67 | 62 | 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 | ||
| 80 | 75 | app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id); |
| 81 | 76 | setCapacity(app.db, "crew.concurrency", "10", alice.id); |
| 82 | 77 | 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() }); | |
| 90 | 79 | // 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 | 80 | 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 | 81 | 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"; | ||
| 12 | 12 | import { getIssue, getIssueById, type Issue } from "../src/core/issues.ts"; |
| 13 | 13 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 14 | 14 | 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"; | |
| 16 | 17 | import { addSubscription } from "../src/core/subscriptions.ts"; |
| 17 | 18 | import { recordUsage } from "../src/core/usage.ts"; |
| 18 | 19 | import { type User } from "../src/core/users.ts"; |
| @@ -61,19 +62,14 @@ function seedReading(scope: string, freePct: number): void { | ||
| 61 | 62 | 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 | } |
| 63 | 64 | |
| 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 | ||
| 65 | 70 | 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); | |
| 75 | 72 | // 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)); | |
| 77 | 73 | seedReading(sub, 100 - usedSoFar + recent); |
| 78 | 74 | forgetWeek(); |
| 79 | 75 | } |
| @@ -180,30 +176,21 @@ test("a fast afternoon slows the crew at once, on a week whose total is on pace" | ||
| 180 | 176 | expect(repoPace(app, repo, NOW).reason).toContain("running out about"); |
| 181 | 177 | }); |
| 182 | 178 | |
| 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(); | |
| 186 | 181 | forgetWeek(); |
| 187 | 182 | expect(weekWords(app, NOW)).toContain("pace is unknown"); |
| 188 | 183 | expect(String(weekLine(app, NOW))).toContain("pace is unknown"); |
| 189 | 184 | expect(repoPace(app, repo, NOW).slots).toBe(2); |
| 190 | 185 | }); |
| 191 | 186 | |
| 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", () => { | |
| 193 | 188 | // The cause of #8873: no grant meant no account, no week, no pacing. |
| 194 | 189 | app.db.query("DELETE FROM subscription_grant").run(); |
| 195 | 190 | app.db.query("DELETE FROM usage").run(); |
| 196 | 191 | 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); | |
| 207 | 194 | seedReading("platform", 46 + 2.7); |
| 208 | 195 | forgetWeek(); |
| 209 | 196 | 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 | ||
| 233 | 220 | const rows = app.db.query("SELECT scope, free_pct FROM week_reading").all() as { scope: string; free_pct: number }[]; |
| 234 | 221 | expect(rows).toEqual([{ scope: sub, free_pct: 70 }]); |
| 235 | 222 | // 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)); | |
| 237 | 224 | forgetWeek(); |
| 238 | 225 | expect(weekWords(app, new Date(NOW.getTime() + 3_600_000))).toContain("burning 4% an hour"); |
| 239 | 226 | }); |
| 240 | 227 | |
| 241 | 228 | test("home reads the repository the crew works, not the first by name", async () => { |
| 242 | 229 | week(20); |
| 230 | spend(1, new Date(NOW.getTime() - DAY)); | |
| 243 | 231 | const quiet = await createRepo(app, alice, { name: "aaa-quiet", visibility: "private" }); |
| 244 | 232 | if (!quiet.ok) throw new Error(String(quiet.error)); |
| 245 | 233 | 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 () | ||
| 252 | 240 | expect(words).not.toContain("(0 runs"); |
| 253 | 241 | expect(words).toMatch(/\(\d+ runs/); |
| 254 | 242 | }); |
| 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 | }); |
new above
offline
Non-blocking: the documented shape here is assumed, not recorded. Confirm
utilizationagainst a real session line.