Public: anyone can read this; the crew can change it.
Weekly pacing reads the subscription's real meter: it never slowed the crew while the week fell 8 points in 3 hours #8881
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8873-weekly-pacing-reads-the-subscription-s-r → main 3 commits fork
10 files
changed +353
−80
src/crew/dispatch.ts +17
| @@ -10,6 +10,8 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { TEMP_FULL } from "../checks/disk_full.ts"; |
| 12 | 12 | import { freshPullFor } from "./fresh_pull.ts"; |
| 13 | import { weekHold } from "./week_hold.ts"; | |
| 14 | import { weekFor } from "./week_read.ts"; | |
| 13 | 15 | import { twinNote, twinOf, type Twin } from "./twin.ts"; |
| 14 | 16 | import { NO_MERGE } from "../checks/merged.ts"; |
| 15 | 17 | import { resolveListConflicts } from "../actions/list-conflicts.ts"; |
| @@ -1429,6 +1431,7 @@ function pump( | ||
| 1429 | 1431 | capacityCount(app.db, "crew.concurrency", options.concurrency) - |
| 1430 | 1432 | runningIssues.size; |
| 1431 | 1433 | const started: string[] = []; |
| 1434 | const goingIn = new Map<string, number>(); | |
| 1432 | 1435 | const noted = notedBy.get(app) ?? new Set<string>(); |
| 1433 | 1436 | notedBy.set(app, noted); |
| 1434 | 1437 | // Builder starts go to teams in proportion to their dials (`team_share.ts`), |
| @@ -1456,6 +1459,19 @@ function pump( | ||
| 1456 | 1459 | continue; |
| 1457 | 1460 | } |
| 1458 | 1461 | if (slots <= 0) break; |
| 1462 | // The week's pace holds every path that starts a run in an autonomous | |
| 1463 | // repository: assigned, review, feedback and check runs as much as picks (#8873). | |
| 1464 | if (home !== undefined && home.autonomy === "autonomous") { | |
| 1465 | const going = (goingIn.get(home.id) ?? 0) + active.filter((r) => r.state === "running" && r.repoId === home.id).length; | |
| 1466 | const held = weekHold(weekFor(app, home, new Date()), home.autonomySlots, going); | |
| 1467 | if (held !== null) { | |
| 1468 | if (!noted.has(fresh.id)) { | |
| 1469 | noted.add(fresh.id); | |
| 1470 | appendRunLog(app.config.dataDir, fresh.id, held); | |
| 1471 | } | |
| 1472 | continue; | |
| 1473 | } | |
| 1474 | } | |
| 1459 | 1475 | // An account at its concurrent-runs limit keeps this run queued, with |
| 1460 | 1476 | // the count in its log once, until one of its runs ends. |
| 1461 | 1477 | const owner = getRepoById(app.db, fresh.repoId)?.ownerId; |
| @@ -1473,6 +1489,7 @@ function pump( | ||
| 1473 | 1489 | continue; |
| 1474 | 1490 | } |
| 1475 | 1491 | started.push(fresh.id); |
| 1492 | goingIn.set(fresh.repoId, (goingIn.get(fresh.repoId) ?? 0) + 1); | |
| 1476 | 1493 | noted.delete(fresh.id); |
| 1477 | 1494 | slots--; |
| 1478 | 1495 | if (queued.issueId !== null) runningIssues.add(queued.issueId); |
src/crew/week.ts +79 −36
| @@ -1,17 +1,22 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The week's burn: whether the crew is spending its subscriptions' week at |
| 3 | * the rate that empties it by the reset, and what to do about it (#8695). | |
| 4 | * A pure policy: figures in, one decision out, the same state the same | |
| 5 | * decision. Nothing here reads the clock or the database (`week_read.ts` | |
| 6 | * does that). | |
| 3 | * the rate that empties it by the reset, and what to do about it (#8695, | |
| 4 | * #8873). A pure policy: figures in, one decision out, the same state the | |
| 5 | * same decision. Nothing here reads the clock or the database | |
| 6 | * (`week_read.ts` does that). | |
| 7 | * | |
| 8 | * The figures are percents of the week the provider's limit holds, the same | |
| 9 | * `free` share the `usage` tool prints, never a dollar cap of our own. | |
| 7 | 10 | * |
| 8 | * - Target burn: what is left for the crew, over the time to the reset. | |
| 9 | 11 | * - Pace: the week's elapsed share is what should be spent by now. Within |
| 10 | 12 | * `WEEK.tolerance` of it the crew is on pace; below, behind; above, ahead. |
| 13 | * - Burn: the last hours' spend, held against what the week can still take. | |
| 14 | * A burn that empties it before the reset slows the crew whatever the | |
| 15 | * week's total says, and names when it would run out. | |
| 16 | * - Unknown: no limit known is not a fine week. The crew holds at | |
| 17 | * `WEEK.unknownSlots` and home says the pace is unknown. | |
| 11 | 18 | * - Behind with builders idle and nothing startable: the next gated issue |
| 12 | 19 | * is released, one per look. |
| 13 | * - Ahead: slots shrink by the ratio of what should be spent to what is, | |
| 14 | * so the limit is not met before the reset. | |
| 15 | 20 | * - Always: `WEEK.reserve` of the week's crew capacity is kept for `/talk` |
| 16 | 21 | * and a person's own requests; the crew stops at it. |
| 17 | 22 | */ |
| @@ -26,16 +31,28 @@ export const WEEK = { | ||
| 26 | 31 | reserve: 0.1, |
| 27 | 32 | /** Slots ahead of pace never fall below this while the reserve holds. */ |
| 28 | 33 | floorSlots: 1, |
| 34 | /** Slots the crew holds at while the week's capacity is unknown. */ | |
| 35 | unknownSlots: 2, | |
| 36 | /** How many hours back the burn is read over. */ | |
| 37 | burnHours: 3, | |
| 29 | 38 | } as const; |
| 30 | 39 | |
| 31 | export type Status = "on pace" | "ahead" | "behind"; | |
| 40 | export type Status = "on pace" | "ahead" | "behind" | "unknown"; | |
| 41 | ||
| 42 | /** What the provider's meter says of the week, in percent of the week's limit. */ | |
| 43 | export interface WeekMeter { | |
| 44 | /** The share of the week still free, 0..100. */ | |
| 45 | readonly freePct: number; | |
| 46 | /** The share the operator keeps for people, 0..100. */ | |
| 47 | readonly keptPct: number; | |
| 48 | /** Percent of the week's limit spent an hour, over the last `WEEK.burnHours`; null when unread. */ | |
| 49 | readonly burnPctPerHour: number | null; | |
| 50 | readonly resetsAt: Date; | |
| 51 | } | |
| 32 | 52 | |
| 33 | 53 | export interface WeekInput { |
| 34 | /** US dollars the crew may spend in the week in all (its cap less what people keep). */ | |
| 35 | readonly capUsd: number; | |
| 36 | /** US dollars the crew has spent in it. */ | |
| 37 | readonly usedUsd: number; | |
| 38 | readonly resetsAt: Date; | |
| 54 | /** The week's meter; null while no account's weekly limit is known. */ | |
| 55 | readonly meter: WeekMeter | null; | |
| 39 | 56 | readonly now: Date; |
| 40 | 57 | /** The operator's slot ceiling. */ |
| 41 | 58 | readonly ceiling: number; |
| @@ -49,10 +66,12 @@ export interface WeekInput { | ||
| 49 | 66 | |
| 50 | 67 | export interface WeekDecision { |
| 51 | 68 | readonly status: Status; |
| 52 | /** US dollars a day that would spend the rest by the reset. */ | |
| 53 | readonly targetUsdPerDay: number; | |
| 54 | /** Spent minus what an even pace would have spent by now, in US dollars. */ | |
| 55 | readonly offUsd: number; | |
| 69 | /** Percent of the week's limit that would be spent an hour to use the crew's rest by the reset; null when unknown. */ | |
| 70 | readonly targetPctPerHour: number | null; | |
| 71 | /** Spent minus what an even pace would have spent by now, in percent of the limit; 0 when unknown. */ | |
| 72 | readonly offPct: number; | |
| 73 | /** When the burn would empty the crew's share of the week; null when it lasts to the reset or is unread. */ | |
| 74 | readonly runsOutAt: Date | null; | |
| 56 | 75 | /** The slots the week allows, 0..ceiling. */ |
| 57 | 76 | readonly slots: number; |
| 58 | 77 | /** Whether to release the next gated issue now. */ |
| @@ -61,38 +80,62 @@ export interface WeekDecision { | ||
| 61 | 80 | readonly sentence: string; |
| 62 | 81 | } |
| 63 | 82 | |
| 83 | const utc = (d: Date): string => `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`; | |
| 64 | 84 | const usd = (n: number): string => `$${Math.abs(n) >= 100 ? Math.round(n) : n.toFixed(2)}`; |
| 85 | const pct = (n: number): string => `${Math.round(n * 10) / 10}%`; | |
| 65 | 86 | |
| 66 | /** The decision for a week at `input`; null when the week has no known capacity or has ended. */ | |
| 87 | /** The decision for a week at `input`; null when the week has ended. */ | |
| 67 | 88 | export function weekDecision(input: WeekInput): WeekDecision | null { |
| 68 | const { capUsd, usedUsd, now, ceiling } = input; | |
| 69 | const left = input.resetsAt.getTime() - now.getTime(); | |
| 70 | if (!(capUsd > 0) || left <= 0 || left > WINDOW_MS.week) return null; | |
| 89 | const { meter, now, ceiling } = input; | |
| 90 | if (meter === null) { | |
| 91 | const slots = Math.min(ceiling, WEEK.unknownSlots); | |
| 92 | return { | |
| 93 | status: "unknown", | |
| 94 | targetPctPerHour: null, | |
| 95 | offPct: 0, | |
| 96 | runsOutAt: null, | |
| 97 | slots, | |
| 98 | release: false, | |
| 99 | sentence: `The week's pace is unknown: no subscription has named its weekly limit yet, so the crew holds at ${slots} of ${ceiling} slots.`, | |
| 100 | }; | |
| 101 | } | |
| 102 | const left = meter.resetsAt.getTime() - now.getTime(); | |
| 103 | if (left <= 0 || left > WINDOW_MS.week) return null; | |
| 104 | const hours = left / 3_600_000; | |
| 71 | 105 | const elapsed = 1 - left / WINDOW_MS.week; |
| 72 | const remaining = Math.max(0, capUsd - usedUsd); | |
| 73 | const days = left / 86_400_000; | |
| 74 | const targetUsdPerDay = remaining / days; | |
| 75 | const should = capUsd * elapsed; | |
| 76 | const offUsd = usedUsd - should; | |
| 77 | const band = capUsd * WEEK.tolerance; | |
| 78 | const status: Status = offUsd > band ? "ahead" : offUsd < -band ? "behind" : "on pace"; | |
| 106 | const capPct = Math.max(0, 100 - meter.keptPct); | |
| 107 | const used = 100 - meter.freePct; | |
| 108 | const wall = capPct * (1 - WEEK.reserve); | |
| 109 | const should = capPct * elapsed; | |
| 110 | const offPct = used - should; | |
| 111 | const band = capPct * WEEK.tolerance; | |
| 112 | const targetPctPerHour = Math.max(0, capPct - used) / hours; | |
| 113 | ||
| 114 | // Burn: what the last hours spent, held against what the crew may still spend before the reserve. | |
| 115 | const burn = meter.burnPctPerHour; | |
| 116 | const room = Math.max(0, wall - used); | |
| 117 | const allowed = room / hours; | |
| 118 | const burning = burn !== null && burn > allowed; | |
| 119 | const runsOutAt = burn !== null && burn > 0 && burning ? new Date(now.getTime() + (room / burn) * 3_600_000) : null; | |
| 120 | ||
| 121 | const status: Status = offPct > band || burning ? "ahead" : offPct < -band ? "behind" : "on pace"; | |
| 79 | 122 | |
| 80 | 123 | let slots = ceiling; |
| 81 | if (status === "ahead") | |
| 82 | slots = Math.max(WEEK.floorSlots, Math.floor((ceiling * should) / Math.max(usedUsd, 0.0001))); | |
| 124 | if (offPct > band) slots = Math.max(WEEK.floorSlots, Math.floor((ceiling * should) / Math.max(used, 0.0001))); | |
| 125 | if (burning) slots = Math.min(slots, Math.max(WEEK.floorSlots, Math.floor((ceiling * allowed) / burn))); | |
| 83 | 126 | // The reserve is a wall: past it the crew waits for the reset. |
| 84 | if (usedUsd >= capUsd * (1 - WEEK.reserve)) slots = 0; | |
| 127 | if (used >= wall) slots = 0; | |
| 85 | 128 | slots = Math.min(ceiling, Math.max(0, slots)); |
| 86 | 129 | |
| 87 | 130 | const release = status === "behind" && input.idleSlots > 0 && input.startable === 0 && input.gated > 0; |
| 88 | const by = `by ${input.resetsAt.toISOString().slice(0, 16).replace("T", " ")} UTC`; | |
| 131 | const weekLeft = `${pct(meter.freePct)} of the week left, reset ${utc(meter.resetsAt)}`; | |
| 89 | 132 | const sentence = |
| 90 | 133 | status === "on pace" |
| 91 | ? `On pace: ${usd(targetUsdPerDay)} a day spends the week's remaining ${usd(remaining)} ${by}.` | |
| 134 | ? `On pace: ${weekLeft}; ${pct(targetPctPerHour)} an hour spends it by then.` | |
| 92 | 135 | : status === "ahead" |
| 93 | ? `Ahead of pace by ${usd(offUsd)}: slowing to ${slots} of ${ceiling} slots, ${usd(targetUsdPerDay)} a day spends the rest ${by}.` | |
| 94 | : `Behind pace by ${usd(-offUsd)}: ${usd(targetUsdPerDay)} a day would spend the remaining ${usd(remaining)} ${by}.`; | |
| 95 | return { status, targetUsdPerDay, offUsd, slots, release, sentence }; | |
| 136 | ? `Ahead of pace: ${weekLeft}${burn === null ? "" : `, burning ${pct(burn)} an hour`}${runsOutAt === null ? "" : `, running out about ${utc(runsOutAt)}`}; ${slots === 0 ? "holding the crew for the reset" : `slowing to ${slots} of ${ceiling} slots`}.` | |
| 137 | : `Behind pace by ${pct(-offPct)}: ${weekLeft}; ${pct(targetPctPerHour)} an hour would spend it by then.`; | |
| 138 | return { status, targetPctPerHour, offPct, runsOutAt, slots, release, sentence }; | |
| 96 | 139 | } |
| 97 | 140 | |
| 98 | 141 | /** What each real landing cost, and how many runs it took, over a window. */ |
src/crew/week_hold.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | /** | |
| 2 | * The week's hold on a run start (#8873): whether a queued run waits because | |
| 3 | * its repository already has as many runs going as the week allows. Pure: | |
| 4 | * the decision and the count in, the reason out. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { WeekDecision } from "./week.ts"; | |
| 8 | ||
| 9 | /** | |
| 10 | * The words a queued run waits on, or null when it may start: the week | |
| 11 | * allows fewer slots than the ceiling and `going` runs already fill them. | |
| 12 | */ | |
| 13 | export function weekHold( | |
| 14 | week: Pick<WeekDecision, "slots" | "sentence"> | null, | |
| 15 | ceiling: number, | |
| 16 | going: number, | |
| 17 | ): string | null { | |
| 18 | if (week === null || week.slots >= ceiling || going < week.slots) return null; | |
| 19 | return `queued, not started: ${week.sentence}`; | |
| 20 | } |
src/crew/week_read.ts +48 −18
| @@ -6,13 +6,12 @@ | ||
| 6 | 6 | import type { App } from "../app.ts"; |
| 7 | 7 | import { listAutonomousRepos, type Repo } from "../core/repos.ts"; |
| 8 | 8 | import { standings, WINDOW_MS } from "../core/standing.ts"; |
| 9 | import { grantedAccounts } from "../core/subscriptions.ts"; | |
| 10 | import { efficiencyWords, weekDecision, type Efficiency, type WeekDecision } from "./week.ts"; | |
| 9 | import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts"; | |
| 10 | import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts"; | |
| 11 | 11 | |
| 12 | /** The week as the provider's meter reads it: a meter, or null while no account's limit is known. */ | |
| 12 | 13 | interface Week { |
| 13 | readonly capUsd: number; | |
| 14 | readonly usedUsd: number; | |
| 15 | readonly resetsAt: Date; | |
| 14 | readonly meter: WeekMeter | null; | |
| 16 | 15 | } |
| 17 | 16 | |
| 18 | 17 | /** How long a repository's week is remembered: `repoPace` is asked many times a tick. */ |
| @@ -24,7 +23,7 @@ export function forgetWeek(): void { | ||
| 24 | 23 | remembered.clear(); |
| 25 | 24 | } |
| 26 | 25 | |
| 27 | /** The week's capacity and spend over the repository's accounts; null while no account's week has a known cap. */ | |
| 26 | /** The week's meter over the repository's accounts; an unknown meter while none has a known limit. */ | |
| 28 | 27 | function weekOf(app: App, repo: Repo, now: Date): Week | null { |
| 29 | 28 | const key = `${app.config.dataDir}\0${repo.id}`; |
| 30 | 29 | const hit = remembered.get(key); |
| @@ -34,21 +33,52 @@ function weekOf(app: App, repo: Repo, now: Date): Week | null { | ||
| 34 | 33 | return week; |
| 35 | 34 | } |
| 36 | 35 | |
| 36 | /** Spend over the last `WEEK.burnHours`, in US dollars, on `ids`. */ | |
| 37 | function recentUsd(app: App, ids: readonly string[], now: Date): number { | |
| 38 | if (ids.length === 0) return 0; | |
| 39 | const since = new Date(now.getTime() - WEEK.burnHours * 3_600_000).toISOString(); | |
| 40 | const marks = ids.map((_, i) => `?${i + 2}`).join(","); | |
| 41 | return ( | |
| 42 | app.db | |
| 43 | .query<{ usd: number | null }, string[]>( | |
| 44 | `SELECT SUM(cost_usd) AS usd FROM usage WHERE at >= ?1 AND at <= ?${ids.length + 2} AND subscription_id IN (${marks})`, | |
| 45 | ) | |
| 46 | .get(since, ...ids, now.toISOString())?.usd ?? 0 | |
| 47 | ); | |
| 48 | } | |
| 49 | ||
| 37 | 50 | function readWeek(app: App, repo: Repo, now: Date): Week | null { |
| 38 | const granted = grantedAccounts(app.db, repo.id, repo.ownerId); | |
| 39 | const stand = standings(app.db, granted.map((a) => a.id), now); | |
| 51 | // A crew with no grant runs on the forge's own token: the account `usage` | |
| 52 | // prints as `platform`. It is metered too, never skipped (#8873). | |
| 53 | const granted = grantedAccounts(app.db, repo.id, repo.ownerId).map((a) => a.id); | |
| 54 | const stand = standings(app.db, granted.length > 0 ? granted : [PLATFORM], now); | |
| 40 | 55 | let capUsd = 0; |
| 41 | let usedUsd = 0; | |
| 56 | let freeUsd = 0; | |
| 57 | let keptUsd = 0; | |
| 42 | 58 | let resetsAt: number | null = null; |
| 43 | for (const s of stand.values()) { | |
| 59 | const known: string[] = []; | |
| 60 | for (const [id, s] of stand) { | |
| 44 | 61 | const w = s.windows.find((x) => x.window === "week"); |
| 45 | if (w === undefined || w.cap === null || w.kept === null || w.resetsAt === null) continue; | |
| 46 | capUsd += Math.max(0, w.cap.value - w.kept.value); | |
| 47 | usedUsd += w.used.value; | |
| 62 | if (w === undefined || w.cap === null || w.kept === null || w.remaining === null || w.resetsAt === null) continue; | |
| 63 | known.push(id); | |
| 64 | capUsd += w.cap.value; | |
| 65 | freeUsd += w.remaining.value; | |
| 66 | keptUsd += w.kept.value; | |
| 48 | 67 | const at = Date.parse(w.resetsAt.value); |
| 49 | 68 | if (resetsAt === null || at < resetsAt) resetsAt = at; |
| 50 | 69 | } |
| 51 | return resetsAt === null ? null : { capUsd, usedUsd, resetsAt: new Date(resetsAt) }; | |
| 70 | if (resetsAt === null || !(capUsd > 0)) { | |
| 71 | // Granted accounts with no known limit are an unknown week; the forge's own token with none is not metered at all. | |
| 72 | return granted.length > 0 ? { meter: null } : null; | |
| 73 | } | |
| 74 | return { | |
| 75 | meter: { | |
| 76 | freePct: (freeUsd / capUsd) * 100, | |
| 77 | keptPct: (keptUsd / capUsd) * 100, | |
| 78 | burnPctPerHour: ((recentUsd(app, known, now) / capUsd) * 100) / WEEK.burnHours, | |
| 79 | resetsAt: new Date(resetsAt), | |
| 80 | }, | |
| 81 | }; | |
| 52 | 82 | } |
| 53 | 83 | |
| 54 | 84 | /** Real landings, agent runs and what they cost since `since`: carried pulls and pulls that added nothing are left out. */ |
| @@ -74,14 +104,14 @@ export function efficiencySince(app: App, repo: Repo, since: Date): Efficiency { | ||
| 74 | 104 | /** The week's efficiency: since the week opened, or seven days back when no window is known. */ |
| 75 | 105 | export function weekEfficiency(app: App, repo: Repo, now: Date): Efficiency { |
| 76 | 106 | const week = weekOf(app, repo, now); |
| 77 | const since = week === null ? now.getTime() - WINDOW_MS.week : week.resetsAt.getTime() - WINDOW_MS.week; | |
| 107 | const since = week?.meter == null ? now.getTime() - WINDOW_MS.week : week.meter.resetsAt.getTime() - WINDOW_MS.week; | |
| 78 | 108 | return efficiencySince(app, repo, new Date(since)); |
| 79 | 109 | } |
| 80 | 110 | |
| 81 | 111 | /** |
| 82 | 112 | * The week's decision for a repository, given the builders' idle slots and |
| 83 | 113 | * the issues a builder may start and the Later gate alone holds; null while |
| 84 | * the week's capacity is unknown. | |
| 114 | * no account is metered. | |
| 85 | 115 | */ |
| 86 | 116 | export function weekFor( |
| 87 | 117 | app: App, |
| @@ -95,8 +125,8 @@ export function weekFor( | ||
| 95 | 125 | |
| 96 | 126 | /** |
| 97 | 127 | * The pace sentence and the efficiency beside it for the first autonomous |
| 98 | * repository with a crew, as the idle reason picks it; null while no account's | |
| 99 | * week has a known cap. Home and the terminal's dashboard print the same words. | |
| 128 | * repository with a crew, as the idle reason picks it; null while no account | |
| 129 | * is metered. Home and the terminal's dashboard print the same words. | |
| 100 | 130 | */ |
| 101 | 131 | export function weekWords(app: App, now: Date = new Date()): string | null { |
| 102 | 132 | for (const repo of listAutonomousRepos(app.db)) { |
src/tui/screens/dashboard.ts +1 −1
| @@ -1523,7 +1523,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1523 | 1523 | return screen; |
| 1524 | 1524 | } |
| 1525 | 1525 | |
| 1526 | /** The week's pace and efficiency, as the line an admin's home reads (#8695); none while no week's cap is known. */ | |
| 1526 | /** The week's pace and efficiency, as the line an admin's home reads (#8695); none while no account is metered. */ | |
| 1527 | 1527 | function weekLines(app: App, now: Date): string[] { |
| 1528 | 1528 | const words = weekWords(app, now); |
| 1529 | 1529 | return words === null ? [] : [words]; |
src/web/studio/week_line.ts +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Home's week line (#8695): one sentence on whether the crew is spending the |
| 3 | 3 | * subscriptions' week on pace, and beside it what a real landing costs |
| 4 | * (`weekWords`). Empty while no account's week has a known cap. | |
| 4 | * (`weekWords`). Empty while no account is metered. | |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { App } from "../../app.ts"; |
test/week-hold.test.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | /** The week's hold on a run start: waits only when the week allows fewer slots than the ceiling and they are full (#8873). */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { weekHold } from "../src/crew/week_hold.ts"; | |
| 4 | ||
| 5 | const week = { slots: 2, sentence: "Ahead of pace." }; | |
| 6 | ||
| 7 | test("no decision, or a week that allows the ceiling, never holds", () => { | |
| 8 | expect(weekHold(null, 4, 9)).toBeNull(); | |
| 9 | expect(weekHold({ ...week, slots: 4 }, 4, 9)).toBeNull(); | |
| 10 | }); | |
| 11 | ||
| 12 | test("below the week's slots a run starts; at them it waits, with the sentence", () => { | |
| 13 | expect(weekHold(week, 4, 1)).toBeNull(); | |
| 14 | expect(weekHold(week, 4, 2)).toBe("queued, not started: Ahead of pace."); | |
| 15 | expect(weekHold({ ...week, slots: 0 }, 4, 0)).toBe("queued, not started: Ahead of pace."); | |
| 16 | }); |
test/week-pump.test.ts added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | /** The week's pace holds an assigned run in an autonomous repository, not only the picks (#8873). */ | |
| 2 | import { readRunLog } from "../src/runs/log.ts"; | |
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { addToCrew } from "../src/actions/crew.ts"; | |
| 5 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 6 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 7 | import { getRepoById } from "../src/core/repos.ts"; | |
| 8 | import { createRun, listRuns } from "../src/core/runs.ts"; | |
| 9 | import { noteLimit, setHeadroom } from "../src/core/standing.ts"; | |
| 10 | import { addSubscription } from "../src/core/subscriptions.ts"; | |
| 11 | import { forgetWeek } from "../src/crew/week_read.ts"; | |
| 12 | import { recordUsage } from "../src/core/usage.ts"; | |
| 13 | import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts"; | |
| 14 | ||
| 15 | const DAY = 86_400_000; | |
| 16 | ||
| 17 | /** Wait until `done` holds, or fail at the deadline: the pump's tick is what we wait on, not a guessed pause. */ | |
| 18 | async function until(done: () => boolean, what: string, ms = 20_000): Promise<void> { | |
| 19 | const deadline = Date.now() + ms; | |
| 20 | while (!done()) { | |
| 21 | if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); | |
| 22 | await Bun.sleep(10); | |
| 23 | } | |
| 24 | } | |
| 25 | let standIn: StandInCrew; | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | standIn = await startStandInCrew({ tickMs: 50 }); | |
| 29 | }, 30_000); | |
| 30 | afterAll(() => stopStandInCrew(standIn)); | |
| 31 | ||
| 32 | test("past the week's reserve an assigned run stays queued, with the reason in its log", async () => { | |
| 33 | const { app, alice, bot } = standIn; | |
| 34 | const made = await createRepo(app, alice, { name: "held", visibility: "private" }); | |
| 35 | if (!made.ok) throw new Error(String(made.error)); | |
| 36 | const seeded = await commitToBranch(app, alice, made.value, { message: "Start", files: [{ path: "README.md", content: "# held\n" }] }); | |
| 37 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 38 | expect(addToCrew(app, alice, made.value, "bot").ok).toBe(true); | |
| 39 | const sub = addSubscription(app.db, standIn.dataDir, { name: "main", token: "sk-ant-oat01-week-pump-1" }); | |
| 40 | if (!sub.ok) throw new Error(sub.error); | |
| 41 | setHeadroom(app.db, sub.value.id, 0); | |
| 42 | app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id); | |
| 43 | // Last week the provider refused at $100; this week $95 is spent. | |
| 44 | const now = Date.now(); | |
| 45 | const spend = (usd: number, at: number) => { | |
| 46 | const run = createRun(app.db, { repoId: made.value.id, kind: "agent" }); | |
| 47 | recordUsage(app.db, { runId: run.id, repoId: made.value.id, issueId: null, agentId: null, subscriptionId: sub.value.id, tokensIn: 1, tokensOut: 1, costUsd: usd, wallMs: 1000, outcome: "ok", at: new Date(at).toISOString() }); | |
| 48 | }; | |
| 49 | spend(100, now - 4 * DAY); | |
| 50 | noteLimit(app.db, sub.value.id, { window: "week", resetsAt: new Date(now - 3.5 * DAY).toISOString(), observedAt: new Date(now - 3.5 * DAY - 3_600_000).toISOString(), said: "weekly limit" }); | |
| 51 | spend(95, now - DAY); | |
| 52 | const on = await updateRepoSettings(app, alice, made.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id }); | |
| 53 | expect(on.ok).toBe(true); | |
| 54 | const repo = getRepoById(app.db, made.value.id)!; | |
| 55 | const issue = openIssue(app, alice, repo, { title: "Assigned work", body: "Do it." }); | |
| 56 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 57 | assignIssue(app, alice, repo, issue.value, bot); | |
| 58 | const mine = () => listRuns(app.db, repo.id, { kind: "agent", limit: 10 }).find((r) => r.issueId === issue.value.id); | |
| 59 | const logOf = () => (mine() === undefined ? "" : readRunLog(app.config.dataDir, mine()!.id).lines.join("\n")); | |
| 60 | await until(() => logOf().includes("queued, not started: Ahead of pace"), "the hold to be logged"); | |
| 61 | expect(mine()?.state).toBe("queued"); | |
| 62 | // With the spend gone the week is on pace and the same run starts. | |
| 63 | app.db.query("DELETE FROM usage WHERE at >= ?1").run(new Date(now - 2 * DAY).toISOString()); | |
| 64 | forgetWeek(); | |
| 65 | await until(() => mine()?.state !== "queued", "the run to start"); | |
| 66 | expect(mine()?.state).not.toBe("queued"); | |
| 67 | }, 30_000); |
test/week-wiring.test.ts +43 −5
| @@ -144,9 +144,9 @@ test("ahead of pace, the pace cuts the slots, and the reserve stops them", () => | ||
| 144 | 144 | test("home and the terminal print the same sentence, the terminal for an admin", async () => { |
| 145 | 145 | week(20); |
| 146 | 146 | const words = weekWords(app, NOW)!; |
| 147 | expect(words).toContain("Behind pace by $30.00"); | |
| 147 | expect(words).toContain("Behind pace by 30%"); | |
| 148 | 148 | expect(words).toContain("Nothing has landed"); |
| 149 | expect(String(weekLine(app, NOW))).toContain("Behind pace by $30.00"); | |
| 149 | expect(String(weekLine(app, NOW))).toContain("Behind pace by 30%"); | |
| 150 | 150 | const terminal = headlessTerminal(240, 32); |
| 151 | 151 | const { app: tui } = createForgeApp( |
| 152 | 152 | app, |
| @@ -160,10 +160,48 @@ test("home and the terminal print the same sentence, the terminal for an admin", | ||
| 160 | 160 | expect(text).toContain("Behind pace"); |
| 161 | 161 | }); |
| 162 | 162 | |
| 163 | test("with no known weekly cap there is no sentence", () => { | |
| 163 | test("a fast afternoon slows the crew at once, on a week whose total is on pace", () => { | |
| 164 | week(50); | |
| 165 | const calm = repoPace(app, repo, NOW).slots; | |
| 166 | expect(calm).toBe(4); | |
| 167 | // 3 points an hour over the last 3 hours, in $100 of cap: $9 an hour. | |
| 168 | spend(27, new Date(NOW.getTime() - 3_600_000)); | |
| 169 | forgetWeek(); | |
| 170 | expect(repoPace(app, repo, NOW).slots < calm).toBe(true); | |
| 171 | expect(repoPace(app, repo, NOW).reason).toContain("running out about"); | |
| 172 | }); | |
| 173 | ||
| 174 | test("with no known weekly cap the pace is unknown and the crew holds at a modest level", () => { | |
| 164 | 175 | app.db.query("DELETE FROM subscription_limit").run(); |
| 165 | 176 | app.db.query("DELETE FROM usage").run(); |
| 166 | 177 | forgetWeek(); |
| 167 | expect(weekWords(app, NOW)).toBeNull(); | |
| 168 | expect(String(weekLine(app, NOW))).toBe(""); | |
| 178 | expect(weekWords(app, NOW)).toContain("pace is unknown"); | |
| 179 | expect(String(weekLine(app, NOW))).toContain("pace is unknown"); | |
| 180 | expect(repoPace(app, repo, NOW).slots).toBe(2); | |
| 181 | }); | |
| 182 | ||
| 183 | test("a crew on the forge's own token is metered: today's numbers, read through the ledger, slow it", () => { | |
| 184 | // The cause of #8873: no grant meant no account, no week, no pacing. | |
| 185 | app.db.query("DELETE FROM subscription_grant").run(); | |
| 186 | app.db.query("DELETE FROM usage").run(); | |
| 187 | try { | |
| 188 | const at = (days: number) => new Date(NOW.getTime() + days * DAY); | |
| 189 | const onPlatform = (usd: number, when: Date) => { | |
| 190 | const run = createRun(app.db, { repoId: repo.id, kind: "agent" }); | |
| 191 | recordUsage(app.db, { runId: run.id, repoId: repo.id, issueId: null, agentId: null, subscriptionId: "platform", tokensIn: 1, tokensOut: 1, costUsd: usd, wallMs: 1000, outcome: "ok", at: when.toISOString() }); | |
| 192 | }; | |
| 193 | // Last week's limit hit at $100; the reset is 5.3 days off; $54 is spent, $8.1 of it in the last 3 hours: 46% free, 2.7 points an hour. | |
| 194 | onPlatform(100, at(-2)); | |
| 195 | noteLimit(app.db, "platform", { window: "week", resetsAt: at(-1.7).toISOString(), observedAt: at(-1.7 - 1 / 24).toISOString(), said: "weekly limit" }); | |
| 196 | onPlatform(45.9, at(-1)); | |
| 197 | onPlatform(8.1, new Date(NOW.getTime() - 3_600_000)); | |
| 198 | forgetWeek(); | |
| 199 | const pace = repoPace(app, repo, NOW); | |
| 200 | expect(pace.reason).toContain("46% of the week left"); | |
| 201 | expect(pace.reason).toContain("running out about"); | |
| 202 | expect(pace.slots < 4).toBe(true); | |
| 203 | expect(weekWords(app, NOW)).toContain("46% of the week left"); | |
| 204 | } finally { | |
| 205 | app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(repo.id, sub); | |
| 206 | } | |
| 169 | 207 | }); |
test/week.test.ts +61 −19
| @@ -12,18 +12,19 @@ import { efficiencyWords, WEEK, weekDecision, type WeekInput } from "../src/crew | ||
| 12 | 12 | |
| 13 | 13 | const DAY = 86_400_000; |
| 14 | 14 | const NOW = new Date("2026-11-02T12:00:00Z"); |
| 15 | // Halfway through the week: 3.5 days to the reset. | |
| 15 | // Halfway through the week: 3.5 days to the reset, half of it free. | |
| 16 | 16 | const base: WeekInput = { |
| 17 | capUsd: 100, | |
| 18 | usedUsd: 50, | |
| 19 | resetsAt: new Date(NOW.getTime() + 3.5 * DAY), | |
| 17 | meter: { freePct: 50, keptPct: 0, burnPctPerHour: 0.4, resetsAt: new Date(NOW.getTime() + 3.5 * DAY) }, | |
| 20 | 18 | now: NOW, |
| 21 | 19 | ceiling: 4, |
| 22 | 20 | idleSlots: 2, |
| 23 | 21 | startable: 0, |
| 24 | 22 | gated: 3, |
| 25 | 23 | }; |
| 24 | const meterOf = (over: Partial<NonNullable<WeekInput["meter"]>>) => ({ ...base.meter!, ...over }); | |
| 26 | 25 | const decide = (over: Partial<WeekInput>) => weekDecision({ ...base, ...over })!; |
| 26 | const used = (usedPct: number, over: Partial<NonNullable<WeekInput["meter"]>> = {}) => | |
| 27 | decide({ meter: meterOf({ freePct: 100 - usedPct, ...over }) }); | |
| 27 | 28 | |
| 28 | 29 | describe("weekDecision", () => { |
| 29 | 30 | test("the same state gives the same decision", () => { |
| @@ -31,47 +32,88 @@ describe("weekDecision", () => { | ||
| 31 | 32 | }); |
| 32 | 33 | |
| 33 | 34 | test("settings are plain values", () => { |
| 34 | expect(WEEK).toEqual({ tolerance: 0.05, reserve: 0.1, floorSlots: 1 }); | |
| 35 | expect(WEEK).toEqual({ tolerance: 0.05, reserve: 0.1, floorSlots: 1, unknownSlots: 2, burnHours: 3 }); | |
| 35 | 36 | }); |
| 36 | 37 | |
| 37 | 38 | test("on pace: the target burn is the rest over the time left", () => { |
| 38 | 39 | const d = decide({}); |
| 39 | 40 | expect(d.status).toBe("on pace"); |
| 40 | expect(d.targetUsdPerDay).toBeCloseTo(50 / 3.5); | |
| 41 | expect(d.targetPctPerHour).toBeCloseTo(50 / 84); | |
| 41 | 42 | expect(d.release).toBe(false); |
| 42 | 43 | expect(d.slots).toBe(4); |
| 43 | expect(d.sentence).toContain("On pace"); | |
| 44 | expect(d.sentence).toContain("On pace: 50% of the week left"); | |
| 44 | 45 | }); |
| 45 | 46 | |
| 46 | 47 | test("behind with builders idle and nothing startable releases the next gated issue", () => { |
| 47 | const d = decide({ usedUsd: 20 }); | |
| 48 | const d = used(20, { burnPctPerHour: 0 }); | |
| 48 | 49 | expect(d.status).toBe("behind"); |
| 49 | 50 | expect(d.release).toBe(true); |
| 50 | expect(d.sentence).toContain("Behind pace by $30.00"); | |
| 51 | expect(d.sentence).toContain("Behind pace by 30%"); | |
| 51 | 52 | }); |
| 52 | 53 | |
| 53 | 54 | test("behind does not release while work is startable, builders are busy, or nothing is gated", () => { |
| 54 | expect(decide({ usedUsd: 20, startable: 1 }).release).toBe(false); | |
| 55 | expect(decide({ usedUsd: 20, idleSlots: 0 }).release).toBe(false); | |
| 56 | expect(decide({ usedUsd: 20, gated: 0 }).release).toBe(false); | |
| 55 | const meter = meterOf({ freePct: 80, burnPctPerHour: 0 }); | |
| 56 | expect(decide({ meter, startable: 1 }).release).toBe(false); | |
| 57 | expect(decide({ meter, idleSlots: 0 }).release).toBe(false); | |
| 58 | expect(decide({ meter, gated: 0 }).release).toBe(false); | |
| 57 | 59 | }); |
| 58 | 60 | |
| 59 | 61 | test("ahead brings the slots down before the limit", () => { |
| 60 | const d = decide({ usedUsd: 70 }); | |
| 62 | const d = used(70, { burnPctPerHour: 0 }); | |
| 61 | 63 | expect(d.status).toBe("ahead"); |
| 62 | 64 | expect(d.slots).toBe(2); |
| 63 | 65 | expect(d.release).toBe(false); |
| 64 | expect(decide({ usedUsd: 89, ceiling: 2 }).slots).toBe(1); | |
| 66 | expect(decide({ ceiling: 2, meter: meterOf({ freePct: 11, burnPctPerHour: 0 }) }).slots).toBe(1); | |
| 65 | 67 | }); |
| 66 | 68 | |
| 67 | 69 | test("the reserve for people is a wall", () => { |
| 68 | expect(decide({ usedUsd: 90 }).slots).toBe(0); | |
| 69 | expect(decide({ usedUsd: 89 }).slots).toBeGreaterThan(0); | |
| 70 | expect(used(90, { burnPctPerHour: 0 }).slots).toBe(0); | |
| 71 | expect(used(89, { burnPctPerHour: 0 }).slots).toBeGreaterThan(0); | |
| 70 | 72 | }); |
| 71 | 73 | |
| 72 | test("no known capacity or an ended week is no decision", () => { | |
| 73 | expect(weekDecision({ ...base, capUsd: 0 })).toBeNull(); | |
| 74 | expect(weekDecision({ ...base, resetsAt: NOW })).toBeNull(); | |
| 74 | test("the operator's kept share is not the crew's to spend", () => { | |
| 75 | expect(used(45, { keptPct: 50, burnPctPerHour: 0 }).slots).toBe(0); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a fast burn slows the crew even when the week's total is on pace", () => { | |
| 79 | const calm = used(50, { burnPctPerHour: 0.3 }); | |
| 80 | expect(calm.slots).toBe(4); | |
| 81 | const fast = used(50, { burnPctPerHour: 2.7 }); | |
| 82 | expect(fast.status).toBe("ahead"); | |
| 83 | expect(fast.slots).toBe(1); | |
| 84 | expect(fast.runsOutAt).not.toBeNull(); | |
| 85 | expect(fast.sentence).toContain("running out about"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("today's numbers: 46% free, 2.7 points an hour, 5.3 days to the reset, slows the crew", () => { | |
| 89 | const now = new Date("2026-11-02T19:25:00Z"); | |
| 90 | const d = weekDecision({ | |
| 91 | meter: { freePct: 46, keptPct: 0, burnPctPerHour: 2.7, resetsAt: new Date("2026-11-08T02:00:00Z") }, | |
| 92 | now, | |
| 93 | ceiling: 5, | |
| 94 | idleSlots: 0, | |
| 95 | startable: 0, | |
| 96 | gated: 0, | |
| 97 | })!; | |
| 98 | expect(d.status).toBe("ahead"); | |
| 99 | expect(d.slots).toBeLessThan(5); | |
| 100 | expect(d.slots).toBeGreaterThanOrEqual(1); | |
| 101 | expect(d.sentence).toContain("46% of the week left"); | |
| 102 | expect(d.sentence).toContain("running out about 2026-11-03"); | |
| 103 | // `/talk` keeps its reserve: at the wall the crew stops, with the reserve still unspent. | |
| 104 | expect(d.runsOutAt!.getTime()).toBeLessThan(now.getTime() + 17 * 3_600_000); | |
| 105 | }); | |
| 106 | ||
| 107 | test("unknown capacity is not fine: the crew holds at a modest level and home says so", () => { | |
| 108 | const d = decide({ meter: null }); | |
| 109 | expect(d.status).toBe("unknown"); | |
| 110 | expect(d.slots).toBe(WEEK.unknownSlots); | |
| 111 | expect(d.sentence).toContain("pace is unknown"); | |
| 112 | expect(decide({ meter: null, ceiling: 1 }).slots).toBe(1); | |
| 113 | }); | |
| 114 | ||
| 115 | test("an ended week is no decision", () => { | |
| 116 | expect(weekDecision({ ...base, meter: meterOf({ resetsAt: NOW }) })).toBeNull(); | |
| 75 | 117 | }); |
| 76 | 118 | }); |
| 77 | 119 |
new above
offline