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

Merged opened this in answer to

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

Merged

mason/forge:mason/8695-the-week-s-subscription-is-spent-by-rese → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/8695-the-week-s-subscription-is-spent-by-rese was merged into main as 48c48f6.

Review

Sign in to review this change.
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";
101101 import { reviewSummary } from "../core/reviews.ts";
102102 import { standing } from "../core/standing.ts";
103103 import { pace, type Pace } from "./pacing.ts";
104 import { heldByGateAlone, releaseFromGate } from "./release_later.ts";
105 import { weekFor } from "./week_read.ts";
104106 import { announceGovernor, governorFor } from "./governed.ts";
105107 import { BREAKER_NOTE, breakerTripped } from "./breaker.ts";
106108 import { given, pendingReplans } from "../core/limb_replan.ts";
@@ -361,7 +363,31 @@ export function repoPace(app: App, repo: Repo, now = new Date()): Pace {
361363 );
362364 const paced = pace(accounts, repo.autonomySlots, meanRunUsd(app.db, repo.id), now);
363365 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 }

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.

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;
365391 }
366392
367393 /** 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
404430 return shareWords(running, cap);
405431 }
406432
407 export type Drove = "off" | "paused" | "waiting" | "full" | "picked" | "planned" | "idle";
433 export type Drove = "off" | "paused" | "waiting" | "full" | "picked" | "planned" | "released" | "idle";
408434
409435 /**
410436 * One turn of the loop for one repository. Returns what it did, for the
@@ -451,6 +477,8 @@ export async function driveRepo(
451477 held.relieving,
452478 );
453479 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";
454482 if (slotted.length >= paced.slots) {
455483 // The builders are busy; the planner can still refill the map, when
456484 // 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";
119119 import { forgeCeiling } from "../../flow/ceiling.ts";
120120 import { alarmIdleCrew } from "../../flow/alarm_idle_crew.ts";
121121 import { idleLine, idleReason } from "../../crew/idle_reason.ts";
122 import { weekWords } from "../../crew/week_read.ts";
122123 import { noReleaseAlarm } from "../../flow/alarm_no_release.ts";
123124 import { redMainAlarm } from "../../flow/alarm_red_main.ts";
124125 import { alarmStuck } from "../../flow/alarm_stuck.ts";
@@ -399,6 +400,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
399400 ? []
400401 : [
401402 ...idleLines(f.app, now),
403 ...weekLines(f.app, now),
402404 ...kpiLines(readKpis(f.db, now, { ...kpiForge(f.app), idle: () => idleReason(f.app, now) }), commit === null ? null : { sha: commit, since: ago(BOOTED) }),
403405 vitalsLine(readVitals(f.db, now)),
404406 ...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 {
15211523 return screen;
15221524 }
15231525
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
15241532 /** Why the builders are idle, as the one line an admin's home reads; none while they are not. */
15251533 function idleLines(app: App, now: Date): string[] {
15261534 const reason = idleReason(app, now);
src/web/studio/home.ts +2
@@ -55,6 +55,7 @@ import { composer, feedJs, feedNote, feedRegion, registerWorkerJs } from "./feed
5555 import { appChips, movingRegion, tellBox } from "./living.ts";
5656 import { alarmLines, cachedAlarms } from "./alarm_lines.ts";
5757 import { kpiRow, waitingLine } from "./kpi_row.ts";
58 import { weekLine } from "./week_line.ts";
5859 import { feedCandidateCache } from "../../feed/feed.ts";
5960 import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
6061 import { idleReason } from "../../crew/idle_reason.ts";
@@ -237,6 +238,7 @@ export function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html |
237238 const kpiTime: KpiTimer = (name, fn) => timePhase(request, name, fn);
238239 const kpiOpts = { idle: () => idleReason(ctx.app, new Date()), dataDir: ctx.app.config.dataDir, time: kpiTime, ...(ceilingUsd === null ? {} : { ceilingUsd }) };
239240 return html`${timePhase(request, "kpis", () => kpiRow(db, kpiOpts), () => kpiCache(db))}
241 ${weekLine(ctx.app)}
240242 ${alarmLines(cachedAlarms(db, new Date(), ceilingUsd))}`;
241243 },
242244 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs