Public: anyone can read this; the crew can change it.
The week's subscription is spent by reset: the crew paces to it and pulls the next ranked issues in itself #8701
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8695-the-week-s-subscription-is-spent-by-rese → main 3 commits fork 3 threads · 3 unresolved
9 files
changed +560
−2
docs/features/week-pace.md added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | # The week's pace (#8695) | |
| 2 | ||
| 3 | The crew spends the subscriptions' week so that it ends empty at the reset. | |
| 4 | ||
| 5 | - **Home** (and an admin's terminal dashboard) carries one sentence under the figures: on pace, ahead or behind, with the target burn a day, and beside it what a real landing cost and how many runs it took this week. Pulls closed as carried (already on the base) are not landings. | |
| 6 | - **Behind**, with builders idle and nothing startable, the autonomy tick releases the next issue only the Later gate holds (lowest number first), one per tick, with a log line and a comment on the issue saying why. | |
| 7 | - **Ahead**, slots come down in proportion to how far ahead, never below one while the reserve holds. | |
| 8 | - **Reserve**: a tenth of the week's crew capacity is held back from the crew: at it, slots go to zero until the reset. That leaves the week's last tenth to conversations and a person's own runs only by the crew standing down; those runs are not otherwise counted against it. | |
| 9 | ||
| 10 | The settings are the plain values in `WEEK` (`src/crew/week.ts`); the decision is a pure function of the figures (`weekDecision`), read from the standing by `src/crew/week_read.ts`. Tested in `test/week.test.ts`. | |
| 11 | ||
| 12 | ## Bets (#8713) | |
| 13 | ||
| 14 | Bets are not in the forge yet, so the release above falls back to the lowest-numbered gate-held issue. When bets land, "behind" releases the next scope of a backed bet, or offers the next bet to back, in place of that fallback; the change is the one candidate read in `releaseForPace` (`src/crew/autonomy.ts`). Appetites then sit inside the week's capacity the decision already works out (`capUsd` less the reserve), so no bet's share can exceed it. |
src/crew/autonomy.ts +30 −2
| @@ -101,6 +101,8 @@ import { grantedAccounts } from "../core/subscriptions.ts"; | ||
| 101 | 101 | import { reviewSummary } from "../core/reviews.ts"; |
| 102 | 102 | import { standing } from "../core/standing.ts"; |
| 103 | 103 | import { pace, type Pace } from "./pacing.ts"; |
| 104 | import { heldByGateAlone, releaseFromGate } from "./release_later.ts"; | |
| 105 | import { weekFor } from "./week_read.ts"; | |
| 104 | 106 | import { announceGovernor, governorFor } from "./governed.ts"; |
| 105 | 107 | import { BREAKER_NOTE, breakerTripped } from "./breaker.ts"; |
| 106 | 108 | import { given, pendingReplans } from "../core/limb_replan.ts"; |
| @@ -361,7 +363,31 @@ export function repoPace(app: App, repo: Repo, now = new Date()): Pace { | ||
| 361 | 363 | ); |
| 362 | 364 | const paced = pace(accounts, repo.autonomySlots, meanRunUsd(app.db, repo.id), now); |
| 363 | 365 | const governed = governorFor(app, repo, now).slots; |
| 364 | return governed < paced.slots ? { ...paced, slots: governed } : paced; | |
| 366 | const limited = governed < paced.slots ? { ...paced, slots: governed } : paced; | |
| 367 | // The week's own pace and the reserve for people (#8695). | |
| 368 | const week = weekFor(app, repo, now, { idleSlots: 0, startable: 0, gated: 0 }); | |
| 369 | return week !== null && week.slots < limited.slots | |
| 370 | ? { ...limited, slots: week.slots, reason: `${week.slots} of ${repo.autonomySlots} slots: ${week.sentence}` } | |
| 371 | : limited; | |
| 372 | } | |
| 373 | ||
| 374 | /** | |
| 375 | * Behind the week's pace with builders idle and nothing startable, the next | |
| 376 | * gated issue (the lowest number the Later gate alone holds) is released: | |
| 377 | * one per look, each logged and noted on the issue with why (#8695). | |
| 378 | */ | |
| 379 | function releaseForPace(app: App, repo: Repo, by: User, slotted: number, startable: number, now: Date): number | undefined { | |
| 380 | const gated = heldByGateAlone(app.db, repo.id); | |
| 381 | const week = weekFor(app, repo, now, { idleSlots: Math.max(0, repo.autonomySlots - slotted), startable, gated: gated.length }); | |
| 382 | const next = gated[0]; | |
| 383 | if (week === null || !week.release || next === undefined) return undefined; | |
| 384 | const [number] = releaseFromGate(app.db, repo.id, [next.number]); | |
| 385 | if (number === undefined) return undefined; | |
| 386 | const why = `Released from Later to use the week's subscription: ${week.sentence}`; | |
| 387 | log.info("released", { repo: `${repo.ownerName}/${repo.name}`, issue: number, why }); | |
| 388 | const issue = getIssueById(app.db, next.id); | |
| 389 | if (issue !== undefined) addComment(app, by, repo, issue, why); | |
| 390 | return number; | |
| 365 | 391 | } |
| 366 | 392 | |
| 367 | 393 | /** The builder slots free now: none while paused, else the paced slots less what is slotted. */ |
| @@ -404,7 +430,7 @@ export function smallShareWords(app: App, repo: Repo, now = new Date()): string | ||
| 404 | 430 | return shareWords(running, cap); |
| 405 | 431 | } |
| 406 | 432 | |
| 407 | export type Drove = "off" | "paused" | "waiting" | "full" | "picked" | "planned" | "idle"; | |
| 433 | export type Drove = "off" | "paused" | "waiting" | "full" | "picked" | "planned" | "released" | "idle"; | |
| 408 | 434 | |
| 409 | 435 | /** |
| 410 | 436 | * One turn of the loop for one repository. Returns what it did, for the |
| @@ -451,6 +477,8 @@ export async function driveRepo( | ||
| 451 | 477 | held.relieving, |
| 452 | 478 | ); |
| 453 | 479 | const ready = held.startable; |
| 480 | if (slotted.length < paced.slots && releaseForPace(app, repo, by, slotted.length, ready.length, now) !== undefined) | |
The driveRepo release path has no test (return "released", log line, issue comment). Add one that fails if this line is removed. |
||
| 481 | return "released"; | |
| 454 | 482 | if (slotted.length >= paced.slots) { |
| 455 | 483 | // The builders are busy; the planner can still refill the map, when |
| 456 | 484 | // the reserve is short — counted, never assumed: a full crew with a |
src/crew/week.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** | |
| 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). | |
| 7 | * | |
| 8 | * - Target burn: what is left for the crew, over the time to the reset. | |
| 9 | * - Pace: the week's elapsed share is what should be spent by now. Within | |
| 10 | * `WEEK.tolerance` of it the crew is on pace; below, behind; above, ahead. | |
| 11 | * - Behind with builders idle and nothing startable: the next gated issue | |
| 12 | * 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 | * - Always: `WEEK.reserve` of the week's crew capacity is kept for `/talk` | |
| 16 | * and a person's own requests; the crew stops at it. | |
| 17 | */ | |
| 18 | ||
| 19 | import { WINDOW_MS } from "../core/standing.ts"; | |
| 20 | ||
| 21 | /** The week's settings: plain values, read here and nowhere else. */ | |
| 22 | export const WEEK = { | |
| 23 | /** How far from the even pace still counts as on pace, as a share of the week's capacity. */ | |
| 24 | tolerance: 0.05, | |
| 25 | /** The share of the week's crew capacity kept for conversations and a person's own requests. */ | |
| 26 | reserve: 0.1, | |
| 27 | /** Slots ahead of pace never fall below this while the reserve holds. */ | |
| 28 | floorSlots: 1, | |
| 29 | } as const; | |
| 30 | ||
| 31 | export type Status = "on pace" | "ahead" | "behind"; | |
| 32 | ||
| 33 | 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; | |
| 39 | readonly now: Date; | |
| 40 | /** The operator's slot ceiling. */ | |
| 41 | readonly ceiling: number; | |
| 42 | /** Builder slots not in use. */ | |
| 43 | readonly idleSlots: number; | |
| 44 | /** Ready issues a builder may start now. */ | |
| 45 | readonly startable: number; | |
| 46 | /** Issues only the Later gate holds. */ | |
| 47 | readonly gated: number; | |
| 48 | } | |
| 49 | ||
| 50 | export interface WeekDecision { | |
| 51 | 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; | |
| 56 | /** The slots the week allows, 0..ceiling. */ | |
| 57 | readonly slots: number; | |
| 58 | /** Whether to release the next gated issue now. */ | |
| 59 | readonly release: boolean; | |
| 60 | /** One sentence for home and the log. */ | |
| 61 | readonly sentence: string; | |
| 62 | } | |
| 63 | ||
| 64 | const usd = (n: number): string => `$${Math.abs(n) >= 100 ? Math.round(n) : n.toFixed(2)}`; | |
| 65 | ||
| 66 | /** The decision for a week at `input`; null when the week has no known capacity or has ended. */ | |
| 67 | 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; | |
| 71 | 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"; | |
| 79 | ||
| 80 | let slots = ceiling; | |
| 81 | if (status === "ahead") | |
| 82 | slots = Math.max(WEEK.floorSlots, Math.floor((ceiling * should) / Math.max(usedUsd, 0.0001))); | |
| 83 | // The reserve is a wall: past it the crew waits for the reset. | |
| 84 | if (usedUsd >= capUsd * (1 - WEEK.reserve)) slots = 0; | |
| 85 | slots = Math.min(ceiling, Math.max(0, slots)); | |
| 86 | ||
| 87 | 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`; | |
| 89 | const sentence = | |
| 90 | status === "on pace" | |
| 91 | ? `On pace: ${usd(targetUsdPerDay)} a day spends the week's remaining ${usd(remaining)} ${by}.` | |
| 92 | : 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 }; | |
| 96 | } | |
| 97 | ||
| 98 | /** What each real landing cost, and how many runs it took, over a window. */ | |
| 99 | export interface Efficiency { | |
| 100 | readonly landings: number; | |
| 101 | readonly runs: number; | |
| 102 | readonly costUsd: number; | |
| 103 | } | |
| 104 | ||
| 105 | /** `$3.20 and 2.5 runs per landing (4 landings)`; says so plainly when nothing landed. */ | |
| 106 | export function efficiencyWords(e: Efficiency): string { | |
| 107 | if (e.landings === 0) return `Nothing has landed this week (${e.runs} runs, ${usd(e.costUsd)}).`; | |
| 108 | const runs = Math.round((e.runs / e.landings) * 10) / 10; | |
| 109 | return `Each landing cost ${usd(e.costUsd / e.landings)} and ${runs} runs this week (${e.landings} landed, pulls closed as already on the base not counted).`; | |
| 110 | } |
src/crew/week_read.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * The week's figures for a repository, read from the standing and the | |
| 3 | * ledger and handed to the pure policy in `week.ts` (#8695). | |
| 4 | */ | |
| 5 | ||
| 6 | import type { App } from "../app.ts"; | |
| 7 | import { listAutonomousRepos, type Repo } from "../core/repos.ts"; | |
| 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"; | |
| 11 | ||
| 12 | interface Week { | |
| 13 | readonly capUsd: number; | |
| 14 | readonly usedUsd: number; | |
| 15 | readonly resetsAt: Date; | |
| 16 | } | |
| 17 | ||
| 18 | /** How long a repository's week is remembered: `repoPace` is asked many times a tick. */ | |
| 19 | const REMEMBER_MS = 5_000; | |
| 20 | const remembered = new Map<string, { at: number; week: Week | null }>(); | |
| 21 | ||
| 22 | /** Forget what was remembered, so the next ask reads again. */ | |
| 23 | export function forgetWeek(): void { | |
| 24 | remembered.clear(); | |
| 25 | } | |
| 26 | ||
| 27 | /** The week's capacity and spend over the repository's accounts; null while no account's week has a known cap. */ | |
| 28 | function weekOf(app: App, repo: Repo, now: Date): Week | null { | |
| 29 | const key = `${app.config.dataDir}\0${repo.id}`; | |
| 30 | const hit = remembered.get(key); | |
| 31 | if (hit !== undefined && Math.abs(now.getTime() - hit.at) < REMEMBER_MS) return hit.week; | |
| 32 | const week = readWeek(app, repo, now); | |
| 33 | remembered.set(key, { at: now.getTime(), week }); | |
| 34 | return week; | |
| 35 | } | |
| 36 | ||
| 37 | 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); | |
| 40 | let capUsd = 0; | |
| 41 | let usedUsd = 0; | |
| 42 | let resetsAt: number | null = null; | |
| 43 | for (const s of stand.values()) { | |
| 44 | 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; | |
| 48 | const at = Date.parse(w.resetsAt.value); | |
| 49 | if (resetsAt === null || at < resetsAt) resetsAt = at; | |
| 50 | } | |
| 51 | return resetsAt === null ? null : { capUsd, usedUsd, resetsAt: new Date(resetsAt) }; | |
| 52 | } | |
| 53 | ||
| 54 | /** Real landings, agent runs and what they cost since `since`: carried pulls and pulls that added nothing are left out. */ | |
| 55 | export function efficiencySince(app: App, repo: Repo, since: Date): Efficiency { | |
| 56 | const at = since.toISOString(); | |
| 57 | const landings = | |
| 58 | app.db | |
| 59 | .query<{ n: number }, [string, string]>( | |
| 60 | `SELECT COUNT(*) AS n FROM pull q JOIN issue i ON i.id = q.issue_id | |
| 61 | WHERE q.merged_at IS NOT NULL AND q.merged_at >= ?2 AND i.repo_id = ?1 | |
| 62 | AND q.carried = 0 AND q.merged_oid != q.base_oid`, | |
| 63 | ) | |
| 64 | .get(repo.id, at)?.n ?? 0; | |
| 65 | const runs = app.db | |
| 66 | .query<{ n: number; usd: number | null }, [string, string]>( | |
| 67 | `SELECT COUNT(*) AS n, SUM(cost_usd) AS usd FROM run | |
| 68 | WHERE repo_id = ?1 AND kind = 'agent' AND created_at >= ?2`, | |
| 69 | ) | |
| 70 | .get(repo.id, at); | |
| 71 | return { landings, runs: runs?.n ?? 0, costUsd: runs?.usd ?? 0 }; | |
| 72 | } | |
| 73 | ||
| 74 | /** The week's efficiency: since the week opened, or seven days back when no window is known. */ | |
| 75 | export function weekEfficiency(app: App, repo: Repo, now: Date): Efficiency { | |
| 76 | const week = weekOf(app, repo, now); | |
| 77 | const since = week === null ? now.getTime() - WINDOW_MS.week : week.resetsAt.getTime() - WINDOW_MS.week; | |
| 78 | return efficiencySince(app, repo, new Date(since)); | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * The week's decision for a repository, given the builders' idle slots and | |
| 83 | * the issues a builder may start and the Later gate alone holds; null while | |
| 84 | * the week's capacity is unknown. | |
| 85 | */ | |
| 86 | export function weekFor( | |
| 87 | app: App, | |
| 88 | repo: Repo, | |
| 89 | now: Date, | |
| 90 | seen: { idleSlots: number; startable: number; gated: number } = { idleSlots: 0, startable: 0, gated: 0 }, | |
| 91 | ): WeekDecision | null { | |
| 92 | const week = weekOf(app, repo, now); | |
| 93 | return week === null ? null : weekDecision({ ...week, now, ceiling: repo.autonomySlots, ...seen }); | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * 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. | |
| 100 | */ | |
| 101 | export function weekWords(app: App, now: Date = new Date()): string | null { | |
| 102 | for (const repo of listAutonomousRepos(app.db)) { | |
| 103 | if (repo.autonomy !== "autonomous" || repo.archivedAt !== null) continue; | |
| 104 | const week = weekFor(app, repo, now); | |
| 105 | if (week !== null) return `${week.sentence} ${efficiencyWords(weekEfficiency(app, repo, now))}`; | |
| 106 | } | |
| 107 | return null; | |
| 108 | } |
src/tui/screens/dashboard.ts +8
| @@ -119,6 +119,7 @@ import { lastClearedNote } from "../../flow/clog_tick.ts"; | ||
| 119 | 119 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| 120 | 120 | import { alarmIdleCrew } from "../../flow/alarm_idle_crew.ts"; |
| 121 | 121 | import { idleLine, idleReason } from "../../crew/idle_reason.ts"; |
| 122 | import { weekWords } from "../../crew/week_read.ts"; | |
| 122 | 123 | import { noReleaseAlarm } from "../../flow/alarm_no_release.ts"; |
| 123 | 124 | import { redMainAlarm } from "../../flow/alarm_red_main.ts"; |
| 124 | 125 | import { alarmStuck } from "../../flow/alarm_stuck.ts"; |
| @@ -399,6 +400,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 399 | 400 | ? [] |
| 400 | 401 | : [ |
| 401 | 402 | ...idleLines(f.app, now), |
| 403 | ...weekLines(f.app, now), | |
| 402 | 404 | ...kpiLines(readKpis(f.db, now, { ...kpiForge(f.app), idle: () => idleReason(f.app, now) }), commit === null ? null : { sha: commit, since: ago(BOOTED) }), |
| 403 | 405 | vitalsLine(readVitals(f.db, now)), |
| 404 | 406 | ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says), |
| @@ -1521,6 +1523,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1521 | 1523 | return screen; |
| 1522 | 1524 | } |
| 1523 | 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. */ | |
| 1527 | function weekLines(app: App, now: Date): string[] { | |
| 1528 | const words = weekWords(app, now); | |
| 1529 | return words === null ? [] : [words]; | |
| 1530 | } | |
| 1531 | ||
| 1524 | 1532 | /** Why the builders are idle, as the one line an admin's home reads; none while they are not. */ |
| 1525 | 1533 | function idleLines(app: App, now: Date): string[] { |
| 1526 | 1534 | const reason = idleReason(app, now); |
src/web/studio/home.ts +2
| @@ -55,6 +55,7 @@ import { composer, feedJs, feedNote, feedRegion, registerWorkerJs } from "./feed | ||
| 55 | 55 | import { appChips, movingRegion, tellBox } from "./living.ts"; |
| 56 | 56 | import { alarmLines, cachedAlarms } from "./alarm_lines.ts"; |
| 57 | 57 | import { kpiRow, waitingLine } from "./kpi_row.ts"; |
| 58 | import { weekLine } from "./week_line.ts"; | |
| 58 | 59 | import { feedCandidateCache } from "../../feed/feed.ts"; |
| 59 | 60 | import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 60 | 61 | import { idleReason } from "../../crew/idle_reason.ts"; |
| @@ -237,6 +238,7 @@ export function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html | | ||
| 237 | 238 | const kpiTime: KpiTimer = (name, fn) => timePhase(request, name, fn); |
| 238 | 239 | const kpiOpts = { idle: () => idleReason(ctx.app, new Date()), dataDir: ctx.app.config.dataDir, time: kpiTime, ...(ceilingUsd === null ? {} : { ceilingUsd }) }; |
| 239 | 240 | return html`${timePhase(request, "kpis", () => kpiRow(db, kpiOpts), () => kpiCache(db))} |
| 241 | ${weekLine(ctx.app)} | |
| 240 | 242 | ${alarmLines(cachedAlarms(db, new Date(), ceilingUsd))}`; |
| 241 | 243 | }, |
| 242 | 244 | waiting: () => timePhase(request, "waiting", () => waitingLine(db, user), () => waitingCache(db)), |
src/web/studio/week_line.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | /** | |
| 2 | * Home's week line (#8695): one sentence on whether the crew is spending the | |
| 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. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../../app.ts"; | |
| 8 | import { weekWords } from "../../crew/week_read.ts"; | |
| 9 | import { html, type Html } from "../html.ts"; | |
| 10 | ||
| 11 | export function weekLine(app: App, now: Date = new Date()): Html { | |
| 12 | const words = weekWords(app, now); | |
| 13 | return words === null ? html`` : html`<p class="small muted break">${words}</p>`; | |
| 14 | } | |
outdated
No test of the rendered line, and no terminal screen shows the same sentence. Add src/tui screen and a test. |
test/week-wiring.test.ts added +169
| @@ -0,0 +1,169 @@ | ||
| 1 | /** The week's pace wired in (#8695): the tick releases gated work when behind, the pace cuts slots when ahead, and home and the terminal print the sentence. */ | |
| 2 | import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 7 | import { openIssue } from "../src/actions/issues.ts"; | |
| 8 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 11 | import { getIssue, getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 12 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 13 | import { createRun } from "../src/core/runs.ts"; | |
| 14 | import { noteLimit, setHeadroom } from "../src/core/standing.ts"; | |
| 15 | import { addSubscription } from "../src/core/subscriptions.ts"; | |
| 16 | import { recordUsage } from "../src/core/usage.ts"; | |
| 17 | import { type User } from "../src/core/users.ts"; | |
| 18 | import { driveRepo, repoPace } from "../src/crew/autonomy.ts"; | |
| 19 | import { forgetWeek, weekWords } from "../src/crew/week_read.ts"; | |
| 20 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { weekLine } from "../src/web/studio/week_line.ts"; | |
| 23 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | import { settleApp } from "./helpers/tui.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | const DAY = 86_400_000; | |
| 30 | const NOW = new Date(); | |
| 31 | let dir: string; | |
| 32 | let app: App; | |
| 33 | let alice: User; | |
| 34 | let repo: Repo; | |
| 35 | let sub: string; | |
| 36 | let gate: Issue; | |
| 37 | let gated: Issue; | |
| 38 | ||
| 39 | /** A run that spent `usd` at `at`, on the account. */ | |
| 40 | function spend(usd: number, at: Date): void { | |
| 41 | const run = createRun(app.db, { repoId: repo.id, kind: "agent" }); | |
| 42 | recordUsage(app.db, { | |
| 43 | runId: run.id, | |
| 44 | repoId: repo.id, | |
| 45 | issueId: null, | |
| 46 | agentId: null, | |
| 47 | subscriptionId: sub, | |
| 48 | tokensIn: 1, | |
| 49 | tokensOut: 1, | |
| 50 | costUsd: usd, | |
| 51 | wallMs: 1000, | |
| 52 | outcome: "ok", | |
| 53 | at: at.toISOString(), | |
| 54 | }); | |
| 55 | } | |
| 56 | ||
| 57 | /** The week is half over; last week the provider refused at $100, so the cap is known. */ | |
| 58 | function week(usedSoFar: number): void { | |
| 59 | app.db.query("DELETE FROM usage").run(); | |
| 60 | spend(100, new Date(NOW.getTime() - 4 * DAY)); | |
| 61 | noteLimit(app.db, sub, { | |
| 62 | window: "week", | |
| 63 | resetsAt: new Date(NOW.getTime() - 3.5 * DAY).toISOString(), | |
| 64 | observedAt: new Date(NOW.getTime() - 3.5 * DAY - 3_600_000).toISOString(), | |
| 65 | said: "weekly limit", | |
| 66 | }); | |
| 67 | if (usedSoFar > 0) spend(usedSoFar, new Date(NOW.getTime() - DAY)); | |
| 68 | forgetWeek(); | |
| 69 | } | |
| 70 | ||
| 71 | beforeAll(async () => { | |
| 72 | dir = await mkdtemp(join(tmpdir(), "forge-week-wiring-")); | |
| 73 | app = createApp({ dataDir: dir, port: 0 }); | |
| 74 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 75 | if (!a.ok) throw new Error("signup failed"); | |
| 76 | alice = a.value; | |
| 77 | const made = await createRepo(app, alice, { name: "svc", visibility: "private" }); | |
| 78 | if (!made.ok) throw new Error(String(made.error)); | |
| 79 | repo = made.value; | |
| 80 | const bot = await createAgent(app, alice, { username: "builderbot" }); | |
| 81 | if (!bot.ok) throw new Error(String(bot.error)); | |
| 82 | expect(addToCrew(app, alice, repo, "builderbot").ok).toBe(true); | |
| 83 | const on = await updateRepoSettings(app, alice, repo, { | |
| 84 | autonomy: "autonomous", | |
| 85 | autonomyDirection: "Ship it.", | |
| 86 | autonomyCeilingUsd: 1000, | |
| 87 | autonomySlots: 4, | |
| 88 | autonomyBy: alice.id, | |
| 89 | }); | |
| 90 | expect(on.ok).toBe(true); | |
| 91 | repo = getRepoById(app.db, repo.id)!; | |
| 92 | const added = addSubscription(app.db, dir, { name: "main", token: "sk-ant-oat01-week-wiring-1" }); | |
| 93 | if (!added.ok) throw new Error(added.error); | |
| 94 | sub = added.value.id; | |
| 95 | setHeadroom(app.db, sub, 0); | |
| 96 | app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(repo.id, sub); | |
| 97 | const g = openIssue(app, alice, repo, { title: "Later gate: nothing behind this starts until the owner opens it" }); | |
| 98 | const w = openIssue(app, alice, repo, { title: "Ship the widget" }); | |
| 99 | if (!g.ok || !w.ok) throw new Error("issues"); | |
| 100 | gate = g.value; | |
| 101 | gated = w.value; | |
| 102 | expect(addDependency(app.db, gated, gate, alice.id).ok).toBe(true); | |
| 103 | }); | |
| 104 | ||
| 105 | afterAll(async () => { | |
| 106 | app.close(); | |
| 107 | await rm(dir, { recursive: true, force: true }); | |
| 108 | }); | |
| 109 | ||
| 110 | beforeEach(() => forgetWeek()); | |
| 111 | ||
| 112 | test("behind pace, with an idle slot and nothing startable, the tick releases the gated issue and says why", async () => { | |
| 113 | week(20); | |
| 114 | expect(await driveRepo(app, repo, NOW)).toBe("released"); | |
| 115 | const blockers = app.db.query("SELECT COUNT(*) AS n FROM issue_dependency WHERE issue_id = ?1").get(gated.id) as { n: number }; | |
| 116 | expect(blockers.n).toBe(0); | |
| 117 | const comments = app.db.query("SELECT body FROM comment WHERE issue_id = ?1").all(gated.id) as { body: string }[]; | |
| 118 | expect(comments.map((c) => c.body).join("\n")).toContain("Released from Later to use the week's subscription: Behind pace"); | |
| 119 | expect(getIssueById(app.db, gated.id)?.state).toBe("open"); | |
| 120 | }); | |
| 121 | ||
| 122 | test("on pace, the gated issue stays behind the gate", async () => { | |
| 123 | const again = openIssue(app, alice, repo, { title: "Polish the widget" }); | |
| 124 | if (!again.ok) throw new Error("issue"); | |
| 125 | expect(addDependency(app.db, again.value, gate, alice.id).ok).toBe(true); | |
| 126 | // Nothing startable remains for the first issue once it is picked; pace is what decides. | |
| 127 | week(50); | |
| 128 | expect(await driveRepo(app, repo, NOW)).not.toBe("released"); | |
| 129 | expect(getIssue(app.db, repo.id, again.value.number)).toBeDefined(); | |
| 130 | const blockers = app.db.query("SELECT COUNT(*) AS n FROM issue_dependency WHERE issue_id = ?1").get(again.value.id) as { n: number }; | |
| 131 | expect(blockers.n).toBe(1); | |
| 132 | }); | |
| 133 | ||
| 134 | test("ahead of pace, the pace cuts the slots, and the reserve stops them", () => { | |
| 135 | week(70); | |
| 136 | expect(repoPace(app, repo, NOW).slots).toBe(2); | |
| 137 | expect(repoPace(app, repo, NOW).reason).toContain("Ahead of pace"); | |
| 138 | week(95); | |
| 139 | expect(repoPace(app, repo, NOW).slots).toBe(0); | |
| 140 | week(50); | |
| 141 | expect(repoPace(app, repo, NOW).slots).toBe(4); | |
| 142 | }); | |
| 143 | ||
| 144 | test("home and the terminal print the same sentence, the terminal for an admin", async () => { | |
| 145 | week(20); | |
| 146 | const words = weekWords(app, NOW)!; | |
| 147 | expect(words).toContain("Behind pace by $30.00"); | |
| 148 | expect(words).toContain("Nothing has landed"); | |
| 149 | expect(String(weekLine(app, NOW))).toContain("Behind pace by $30.00"); | |
| 150 | const terminal = headlessTerminal(240, 32); | |
| 151 | const { app: tui } = createForgeApp( | |
| 152 | app, | |
| 153 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: alice }, | |
| 154 | { terminal, exitHooks: false }, | |
| 155 | ); | |
| 156 | tui.start(); | |
| 157 | await settleApp(tui); | |
| 158 | const text = tui.screen.toText(); | |
| 159 | tui.stop(); | |
| 160 | expect(text).toContain("Behind pace"); | |
| 161 | }); | |
| 162 | ||
| 163 | test("with no known weekly cap there is no sentence", () => { | |
| 164 | app.db.query("DELETE FROM subscription_limit").run(); | |
| 165 | app.db.query("DELETE FROM usage").run(); | |
| 166 | forgetWeek(); | |
| 167 | expect(weekWords(app, NOW)).toBeNull(); | |
| 168 | expect(String(weekLine(app, NOW))).toBe(""); | |
| 169 | }); |
test/week.test.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** The week's pace: the same state gives the same decision, and the efficiency leaves out pulls that added nothing (#8695). */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/core/repos.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { id } from "../src/kernel/id.ts"; | |
| 10 | import { efficiencySince } from "../src/crew/week_read.ts"; | |
| 11 | import { efficiencyWords, WEEK, weekDecision, type WeekInput } from "../src/crew/week.ts"; | |
| 12 | ||
| 13 | const DAY = 86_400_000; | |
| 14 | const NOW = new Date("2026-11-02T12:00:00Z"); | |
| 15 | // Halfway through the week: 3.5 days to the reset. | |
| 16 | const base: WeekInput = { | |
| 17 | capUsd: 100, | |
| 18 | usedUsd: 50, | |
| 19 | resetsAt: new Date(NOW.getTime() + 3.5 * DAY), | |
| 20 | now: NOW, | |
| 21 | ceiling: 4, | |
| 22 | idleSlots: 2, | |
| 23 | startable: 0, | |
| 24 | gated: 3, | |
| 25 | }; | |
| 26 | const decide = (over: Partial<WeekInput>) => weekDecision({ ...base, ...over })!; | |
| 27 | ||
| 28 | describe("weekDecision", () => { | |
| 29 | test("the same state gives the same decision", () => { | |
| 30 | expect(weekDecision(base)).toEqual(weekDecision({ ...base })); | |
| 31 | }); | |
| 32 | ||
| 33 | test("settings are plain values", () => { | |
| 34 | expect(WEEK).toEqual({ tolerance: 0.05, reserve: 0.1, floorSlots: 1 }); | |
| 35 | }); | |
| 36 | ||
| 37 | test("on pace: the target burn is the rest over the time left", () => { | |
| 38 | const d = decide({}); | |
| 39 | expect(d.status).toBe("on pace"); | |
| 40 | expect(d.targetUsdPerDay).toBeCloseTo(50 / 3.5); | |
| 41 | expect(d.release).toBe(false); | |
| 42 | expect(d.slots).toBe(4); | |
| 43 | expect(d.sentence).toContain("On pace"); | |
| 44 | }); | |
| 45 | ||
| 46 | test("behind with builders idle and nothing startable releases the next gated issue", () => { | |
| 47 | const d = decide({ usedUsd: 20 }); | |
| 48 | expect(d.status).toBe("behind"); | |
| 49 | expect(d.release).toBe(true); | |
| 50 | expect(d.sentence).toContain("Behind pace by $30.00"); | |
| 51 | }); | |
| 52 | ||
| 53 | 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); | |
| 57 | }); | |
| 58 | ||
| 59 | test("ahead brings the slots down before the limit", () => { | |
| 60 | const d = decide({ usedUsd: 70 }); | |
| 61 | expect(d.status).toBe("ahead"); | |
| 62 | expect(d.slots).toBe(2); | |
| 63 | expect(d.release).toBe(false); | |
| 64 | expect(decide({ usedUsd: 89, ceiling: 2 }).slots).toBe(1); | |
| 65 | }); | |
| 66 | ||
| 67 | 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 | }); | |
| 71 | ||
| 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(); | |
| 75 | }); | |
| 76 | }); | |
| 77 | ||
| 78 | describe("efficiency", () => { | |
| 79 | test("pulls closed as already on the base are not landings", async () => { | |
| 80 | const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-week-")), port: 0 }); | |
| 81 | const u = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 82 | if (!u.ok) throw new Error(u.error); | |
| 83 | const made = await createRepo(app.db, app.reposRoot, { ownerId: u.value.id, name: "r", visibility: "public", description: "r" }); | |
| 84 | if (!made.ok) throw new Error(made.error); | |
| 85 | const repo = made.value; | |
| 86 | const at = NOW.toISOString(); | |
| 87 | let n = 0; | |
| 88 | const pull = (carried: number, mergedOid: string) => { | |
| 89 | const issueId = id(); | |
| 90 | n++; | |
| 91 | app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 'P', '', ?4, 'open', ?5)`).run(issueId, repo.id, n, u.value.id, at); | |
| 92 | app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at, merged_oid, carried) VALUES (?1, ?2, ?3, 'base', '', ?4, ?5, ?6, ?7)`).run(issueId, repo.defaultBranch, `b/${n}`, repo.id, at, mergedOid, carried); | |
| 93 | }; | |
| 94 | pull(0, "new1"); | |
| 95 | pull(0, "new2"); | |
| 96 | pull(1, "old"); | |
| 97 | pull(0, "base"); | |
| 98 | for (const cost of [1, 2, 3, 4, 5, 6]) | |
| 99 | app.db.query(`INSERT INTO run (id, repo_id, kind, state, cost_usd, created_at) VALUES (?1, ?2, 'agent', 'ok', ?3, ?4)`).run(id(), repo.id, cost, at); | |
| 100 | const e = efficiencySince(app, repo, new Date(NOW.getTime() - DAY)); | |
| 101 | expect(e).toEqual({ landings: 2, runs: 6, costUsd: 21 }); | |
| 102 | expect(efficiencyWords(e)).toContain("$10.50 and 3 runs"); | |
| 103 | expect(efficiencyWords({ landings: 0, runs: 2, costUsd: 4 })).toContain("Nothing has landed"); | |
| 104 | }); | |
| 105 | }); |
new above
offline
repoPace is called on hot paths; this adds DB reads each call. Compute once per tick or memoize, and test the ahead-of-pace cut through here.