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

Reviews and work a person assigned always run, within a stated floor, while the pacer slows picks #8982

Merged opened this

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

Merged

scout/forge:scout/8962-reviews-and-work-a-person-assigned-alway → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/8962-reviews-and-work-a-person-assigned-alway was merged into main as 1a7ffe3.

Review

Sign in to review this change.
12 files changed +224 −18
docs/CREW.md +16
@@ -464,3 +464,19 @@ something only a passport may reach.
464464 An open vision pull's page shows, to an admin of the repository, "Direction after the merge": today's direction beside the one an admin's merge will write, computed by the same `nextDirection` the merge uses (`src/crew/vision.ts`), so the two cannot differ. Repository settings (Crew) marks the direction's fenced block `follows docs/VISION.md` and the words around it `the owner's`. Today and After the merge stack vertically at every width.
465465
466466 The same read is a second door: the MCP `vision` tool takes an optional `number`, and for an admin returns `preview` (`{ today, after }`, or `{ why }` the direction would stay) for that open vision pull, plus `directionParts` (the direction cut into `vision` and `owner` parts). The terminal (TUI) has no such view; it is not touched by this change.
467
468 ## What the pace limits (#8962)

Name the config key for the floor once it moves out of the constant, and state the cutover for picks queued before the flag existed.

469
470 The week's pace (`src/crew/week.ts`) limits only the crew's own picks: the
471 builds the tick assigns under the operator's name (marked `pick` on the
472 `issue.assigned` event). Reviews and issues a person assigned are not slowed
473 by it: while the pace allows any slots at all, up to `pace.floor` of them (a
474 capacity setting, env `FORGE_PACE_FLOOR`, default 2, 1 to 16, read on every
475 pump) run at once, apart from the picks; it is also the fewest slots a fast burn
476 slows a repository to. At the reserve's wall (no slots) everything waits for the
477 reset. Not covered: feedback, check and conflict runs still count with the picks.
478
479 A person's assignment is marked `person` on its `issue.assigned` event. An
480 assignment event with neither mark was written before the marks existed and is
481 held as a pick: an `assigned` run queued before this deployed uses the pace
482 like a pick until it starts, then the cutover is over (no migration).
src/actions/issues.ts +4
@@ -298,6 +298,8 @@ export function assignIssue(
298298 issue: Issue,
299299 assignee: User | null,
300300 variantGroup?: string,
301 /** The crew's own pick under the operator's name: the pace holds it (`week_hold.ts`). */
302 pick = false,
301303 ): Result<
302304 Issue,
303305 AccessError | "assignee_cannot_read" | "agent_not_on_crew" | "unverified" | "not_member" | "archived"
@@ -335,6 +337,8 @@ export function assignIssue(
335337 assignee: assignee?.username ?? null,
336338 agent: assignee?.kind === "agent",
337339 ...(variantGroup ? { variantGroup } : {}),
340 // A person's assignment says so; an event without it (queued before the mark) is a pick.
341 ...(pick ? { pick: true } : { person: true }),
338342 },
339343 });
340344 return ok(updated);
src/core/capacity.ts +14 −1
@@ -26,7 +26,8 @@ export type CapacityKey =
2626 | "guard_new_accounts"
2727 | "signin.edge"
2828 | "slots.smallShare"
29 | "polish.capMinutes";
29 | "polish.capMinutes"
30 | "pace.floor";
3031
3132 interface Definition {
3233 /** What the settings page and the API call it. */
@@ -124,6 +125,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
124125 fallback: "60",
125126 help: "An issue labelled polish is small work: a builder run on it that has gone this many minutes is stopped, not extended, and the issue gets one comment saying what is left. Read on every tick.",
126127 },
128 "pace.floor": {
129 label: "Slots the pace always leaves reviews and assigned work",
130 env: "FORGE_PACE_FLOOR",
131 kind: { type: "count", min: 1, max: 16 },
132 fallback: "2",
133 help: "While the week's pace allows any slots, this many reviews and issues a person assigned may run at once, apart from the crew's own picks, however hard the pace slows them; also the fewest slots a fast burn slows a repository to. At the reserve's wall everything waits. Read on every pump.",
134 },
127135 };
128136
129137 export const CAPACITY_KEYS = Object.keys(CAPACITY) as CapacityKey[];
@@ -327,6 +335,11 @@ export function capacityCount(
327335 return Number.isInteger(n) && n > 0 ? n : fallback;
328336 }
329337
338 /** The slots the pace leaves reviews and assigned work (`pace.floor`). */
339 export function paceFloor(db: Database): number {
340 return Number(capacityValue(db, "pace.floor"));
341 }
342
330343 /** How many hosted children this forge may hold now; read on every use. */
331344 export function maxChildren(db: Database): number {
332345 return Number(capacityValue(db, "children.max"));
src/crew/autonomy.ts +1 −1
@@ -546,7 +546,7 @@ async function driveOnce(
546546 if (!cleared.ok) return "idle";
547547 }
548548 const current = getIssueById(app.db, next.issue.id) ?? next.issue;
549 const assigned = assignIssue(app, by, repo, current, builder.user);
549 const assigned = assignIssue(app, by, repo, current, builder.user, undefined, true);
550550 if (!assigned.ok) {
551551 log.warn("could not assign the next ready issue", {
552552 repo: `${repo.ownerName}/${repo.name}`,
src/crew/dispatch.ts +38 −4
@@ -16,7 +16,7 @@ import { twinNote, twinOf, type Twin } from "./twin.ts";
1616 import { NO_MERGE } from "../checks/merged.ts";
1717 import { resolveListConflicts } from "../actions/list-conflicts.ts";
1818 import { markHeard } from "../core/issues.ts";
19 import { capacityCount, noteBooted } from "../core/capacity.ts";
19 import { capacityCount, noteBooted, paceFloor } from "../core/capacity.ts";
2020 import { tmpdir } from "node:os";
2121 import { emailVerified } from "../core/verify.ts";
2222 import { rmSync } from "node:fs";
@@ -1441,6 +1441,39 @@ export function sweepAssigned(app: App): void {
14411441 }
14421442 }
14431443
1444 /**
1445 * Whether the run's newest assignment was a person's (`assignIssue` marks it
1446 * `person`; the tick's own pick says `pick`). An assignment event without
1447 * either mark was written before the marks existed and counts as a pick.
1448 */
1449 export function assignedByPerson(app: App, run: Run): boolean {
outdated

This query runs for every active run on every pump pass, with json_extract over events. Cache per pass, or store the pick mark on the run when it is queued.

1450 if (run.issueId === null) return false;
1451 const row = app.db
1452 .query<{ person: number | null }, [string]>(
1453 `SELECT json_extract(e.payload, '$.person') AS person FROM event e INDEXED BY event_repo_kind_number_at
1454 JOIN issue ON issue.repo_id = e.repo_id AND e.kind = issue.kind || '.assigned'
1455 AND json_extract(e.payload, '$.number') = issue.number + 0
1456 WHERE issue.id = ? ORDER BY e.id DESC LIMIT 1`,
1457 )
1458 .get(run.issueId);
1459 return row?.person === 1;
1460 }
1461
1462 /**
1463 * A review, or work a person assigned: the runs the pace leaves a floor for.
1464 * `memo` holds the answer per run for one pump pass, so the events are read
1465 * once for each run, and only for `assigned` ones.
1466 */
1467 export function isPriorityRun(app: App, run: Run, memo?: Map<string, boolean>): boolean {
1468 if (!PRIORITY_REASONS.includes(run.reason)) return false;
1469 if (run.reason !== "assigned") return true;
1470 const known = memo?.get(run.id);
1471 if (known !== undefined) return known;
1472 const person = assignedByPerson(app, run);
1473 memo?.set(run.id, person);
1474 return person;
1475 }
1476
14441477 /** Per app, the runs already told why they wait, so the log says it once. */
14451478 const notedBy = new WeakMap<App, Set<string>>();
14461479
@@ -1471,6 +1504,7 @@ function pump(
14711504 const started: string[] = [];
14721505 const goingIn = new Map<string, number>();
14731506 const priorityIn = new Map<string, number>();
1507 const priorityMemo = new Map<string, boolean>();
14741508 const noted = notedBy.get(app) ?? new Set<string>();
14751509 notedBy.set(app, noted);
14761510 // Builder starts go to teams in proportion to their dials (`team_share.ts`),
@@ -1504,8 +1538,8 @@ function pump(
15041538 const going = (goingIn.get(home.id) ?? 0) + active.filter((r) => r.state === "running" && r.repoId === home.id).length;
15051539 const priorityGoing =
15061540 (priorityIn.get(home.id) ?? 0) +
1507 active.filter((r) => r.state === "running" && r.repoId === home.id && PRIORITY_REASONS.includes(r.reason)).length;
1508 const held = weekHold(weekFor(app, home, new Date()), home.autonomySlots, going - priorityGoing, fresh.reason, priorityGoing);
1541 active.filter((r) => r.state === "running" && r.repoId === home.id && isPriorityRun(app, r, priorityMemo)).length;
1542 const held = weekHold(weekFor(app, home, new Date()), home.autonomySlots, going - priorityGoing, isPriorityRun(app, fresh, priorityMemo) ? fresh.reason : "pick", priorityGoing, paceFloor(app.db));
15091543 if (held !== null) {
15101544 if (!noted.has(fresh.id)) {
15111545 noted.add(fresh.id);
@@ -1532,7 +1566,7 @@ function pump(
15321566 }
15331567 started.push(fresh.id);
15341568 goingIn.set(fresh.repoId, (goingIn.get(fresh.repoId) ?? 0) + 1);
1535 if (PRIORITY_REASONS.includes(fresh.reason)) priorityIn.set(fresh.repoId, (priorityIn.get(fresh.repoId) ?? 0) + 1);
1569 if (isPriorityRun(app, fresh, priorityMemo)) priorityIn.set(fresh.repoId, (priorityIn.get(fresh.repoId) ?? 0) + 1);
15361570 noted.delete(fresh.id);
15371571 slots--;
15381572 if (queued.issueId !== null) runningIssues.add(queued.issueId);
src/crew/week.ts +3 −3
@@ -31,8 +31,6 @@ export const WEEK = {
3131 tolerance: 0.25,
3232 /** The share of the week's crew capacity kept for conversations and a person's own requests. */
3333 reserve: 0.1,
34 /** Slots never fall below this while the reserve holds. */
35 floorSlots: 2,
3634 /** Slots the crew holds at while the week's capacity is unknown. */
3735 unknownSlots: 2,
3836 /** How many hours back the burn is read over. */
@@ -58,6 +56,8 @@ export interface WeekInput {
5856 readonly now: Date;
5957 /** The operator's slot ceiling. */
6058 readonly ceiling: number;
59 /** Slots a fast burn never slows below while the reserve holds (`pace.floor`). */
60 readonly floorSlots: number;
6161 /** Builder slots not in use. */
6262 readonly idleSlots: number;
6363 /** Ready issues a builder may start now. */
@@ -120,7 +120,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null {
120120 const status: Status = burning ? "ahead" : idling ? "behind" : "on pace";
121121
122122 let slots = burn === null ? Math.min(ceiling, WEEK.unknownSlots) : ceiling;
123 if (burning && burn !== null && burn > 0) slots = Math.min(slots, Math.max(WEEK.floorSlots, Math.floor((ceiling * allowed) / burn)));
123 if (burning && burn !== null && burn > 0) slots = Math.min(slots, Math.max(input.floorSlots, Math.floor((ceiling * allowed) / burn)));
124124 // The reserve is a wall: past it the crew waits for the reset.
125125 if (used >= wall) slots = 0;
126126 slots = Math.min(ceiling, Math.max(0, slots));
src/crew/week_hold.ts +5 −4
@@ -4,16 +4,16 @@
44 * the decision and the count in, the reason out.
55 */
66
7 import { WEEK, type WeekDecision } from "./week.ts";
7 import type { WeekDecision } from "./week.ts";
88
9 /** Why a run is a person's or finished work: these run within `WEEK.floorSlots` of their own, whatever the pace. */
9 /** Why a run is a person's or finished work: these run within `pace.floor` of their own, whatever the pace. */
1010 export const PRIORITY_REASONS: readonly string[] = ["assigned", "review"];
1111
1212 /**
1313 * The words a queued run waits on, or null when it may start. A pick waits
1414 * when the week allows fewer slots than the ceiling and `going` picks already
1515 * fill them; at no slots everything waits. A review or work a person assigned has a lane of its own: it
16 * waits only when `WEEK.floorSlots` of its kind are going, so finished work
16 * waits only when `pace.floor` of its kind are going, so finished work
1717 * lands first and a build never holds it (#8910).
1818 */
1919 export function weekHold(
@@ -22,11 +22,12 @@ export function weekHold(
2222 going: number,
2323 reason = "",
2424 priorityGoing = 0,
25 floor = 2,
2526 ): string | null {
2627 if (week === null || week.slots >= ceiling) return null;
2728 // Past the reserve (no slots) the wall holds everything for the reset.
2829 if (week.slots > 0 && PRIORITY_REASONS.includes(reason)) {
29 return priorityGoing < Math.min(ceiling, WEEK.floorSlots) ? null : `queued, not started: ${priorityGoing} of ${WEEK.floorSlots} places for reviews and assigned work are taken, the most the pace lets run at once.`;
30 return priorityGoing < Math.min(ceiling, floor) ? null : `queued, not started: ${priorityGoing} of ${floor} places for reviews and assigned work are taken, the most the pace lets run at once.`;
3031 }
3132 if (going < week.slots) return null;
3233 return `queued, not started: ${week.sentence}`;
src/crew/week_read.ts +2 −1
@@ -8,6 +8,7 @@ import { listAutonomousRepos, type Repo } from "../core/repos.ts";
88 import { standings, WINDOW_MS } from "../core/standing.ts";
99 import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts";
1010 import { burnFrom, noteReading, readingsSince } from "./week_burn.ts";
11 import { paceFloor } from "../core/capacity.ts";
1112 import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts";
1213
1314 /** The week as the provider's meter reads it: a meter, or null while no account's limit is known. */
@@ -114,7 +115,7 @@ export function weekFor(
114115 seen: { idleSlots: number; startable: number; gated: number } = { idleSlots: 0, startable: 0, gated: 0 },
115116 ): WeekDecision | null {
116117 const week = weekOf(app, repo, now);
117 return week === null ? null : weekDecision({ ...week, now, ceiling: repo.autonomySlots, ...seen });
118 return week === null ? null : weekDecision({ ...week, now, ceiling: repo.autonomySlots, floorSlots: paceFloor(app.db), ...seen });
118119 }
119120
120121 /**
test/pace-floor.test.ts added +123
@@ -0,0 +1,123 @@
1 /**
2 * Through the pump: at the pacer's slowest a review and work a person
3 * assigned still start within the floor, the crew's own pick waits with the
4 * pace sentence, and at the reserve's wall everything waits (#8962).
5 */
6
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { addToCrew } from "../src/actions/crew.ts";
9 import { assignIssue, openIssue } from "../src/actions/issues.ts";
10 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
11 import { paceFloor, setCapacity } from "../src/core/capacity.ts";
12 import { getRepoById, type Repo } from "../src/core/repos.ts";
13 import { claimRun, createRun, getRun, listActive } 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 { forgetWeek } from "../src/crew/week_read.ts";
18 import { readRunLog } from "../src/runs/log.ts";
19 import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts";
20
21 const DAY = 86_400_000;
22 let standIn: StandInCrew;
23
24 beforeAll(async () => {
25 standIn = await startStandInCrew({ tickMs: 50 });
26 setCapacity(standIn.app.db, "crew.concurrency", "10", standIn.alice.id);
27 }, 30_000);
28 afterAll(() => stopStandInCrew(standIn));
29
30 async function until(done: () => boolean, what: string, ms = 20_000): Promise<void> {
31 const deadline = Date.now() + ms;
32 while (!done()) {
33 if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
34 await Bun.sleep(10);
35 }
36 }
37
38 /** An autonomous repository whose week has `spentUsd` of $100 gone and burns 6 points an hour. */
39 async function pacedRepo(name: string, spentUsd: number): Promise<Repo> {
40 const { app, alice, dataDir } = standIn;
41 const made = await createRepo(app, alice, { name, visibility: "private" });
outdated

This tests the pure helpers, not pump. Go through the dispatch path so reverting the pump change fails it, and add a wall case (slots 0).

42 if (!made.ok) throw new Error(String(made.error));
43 const seeded = await commitToBranch(app, alice, made.value, { message: "Start", files: [{ path: "README.md", content: `# ${name}\n` }] });
44 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
45 expect(addToCrew(app, alice, made.value, "bot").ok).toBe(true);
46 const sub = addSubscription(app.db, dataDir, { name, token: `sk-ant-oat01-pace-floor-${name}` });
47 if (!sub.ok) throw new Error(sub.error);
48 setHeadroom(app.db, sub.value.id, 0);
49 app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id);
50 const now = Date.now();
51 const spend = (usd: number, at: number) => {
52 const run = createRun(app.db, { repoId: made.value.id, kind: "agent" });
53 recordUsage(app.db, { runId: run.id, repoId: made.value.id, issueId: null, agentId: null, subscriptionId: sub.value.id, tokensIn: 1, tokensOut: 1, costUsd: usd, wallMs: 1000, outcome: "ok", at: new Date(at).toISOString() });
54 };
55 spend(100, now - 4 * DAY);
56 noteLimit(app.db, sub.value.id, { window: "week", resetsAt: new Date(now - 3.5 * DAY).toISOString(), observedAt: new Date(now - 3.5 * DAY - 3_600_000).toISOString(), said: "weekly limit" });
57 spend(spentUsd, now - DAY);
58 app.db.query("INSERT INTO week_reading (scope, at, free_pct) VALUES (?1, ?2, ?3)").run(sub.value.id, new Date(now - 3_600_000).toISOString(), 100 - spentUsd + 6);
59 const on = await updateRepoSettings(app, alice, made.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id });
60 expect(on.ok).toBe(true);
61 forgetWeek();
62 return getRepoById(app.db, made.value.id)!;
63 }
64
65 const logOf = (id: string) => readRunLog(standIn.app.config.dataDir, id).lines.join("\n");
66 const stateOf = (id: string) => getRun(standIn.app.db, id)!.state;
67
68 /** A run queued by assigning a fresh issue to the agent; `pick` marks it the crew's own. */
69 function assigned(repo: Repo, title: string, pick: boolean): string {
70 const { app, alice, bot } = standIn;
71 const issue = openIssue(app, alice, repo, { title, body: "Do it." });
72 if (!issue.ok) throw new Error(String(issue.error));
73 assignIssue(app, alice, repo, issue.value, bot, undefined, pick);
74 const run = listActive(app.db, "agent").find((r) => r.issueId === issue.value.id);
75 if (run === undefined) throw new Error("no run queued");
76 return run.id;
77 }
78
79 test("the floor is a config value, 2 by default", () => {
80 expect(paceFloor(standIn.app.db)).toBe(2);
81 });
82
83 test("at the slowest pace a review and a person's assignment start, a pick waits with the pace sentence", async () => {
84 const { app } = standIn;
85 // 30% free, 36% an hour ago: far over what is allowed, so the pace is down to the floor of 2 slots.
86 const repo = await pacedRepo("floor", 70);
87 // Two builds fill the pace's slots.
88 for (let i = 0; i < 2; i++) {
89 const issue = openIssue(app, standIn.alice, repo, { title: `build ${i}`, body: "x" });
90 if (!issue.ok) throw new Error(String(issue.error));
91 claimRun(app.db, createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.value.id, reason: "push", actorId: standIn.bot.id }).id);
92 }
93 const pick = assigned(repo, "The crew's pick", true);
94 await until(() => logOf(pick).includes("queued, not started: Ahead of pace"), "the pick to be held");
95 const person = assigned(repo, "A person's issue", false);
96 const review = createRun(app.db, { repoId: repo.id, kind: "agent", reason: "review", actorId: standIn.bot.id }).id;
97 await until(() => stateOf(person) !== "queued" && stateOf(review) !== "queued", "the person's run and the review to start");
98 expect(stateOf(pick)).toBe("queued");
99 });
100
101 test("an assignment event without a mark, from before the marks, is held as a pick", async () => {
102 const { app } = standIn;
103 const repo = await pacedRepo("oldmark", 70);
104 for (let i = 0; i < 2; i++) {
105 const issue = openIssue(app, standIn.alice, repo, { title: `build ${i}`, body: "x" });
106 if (!issue.ok) throw new Error(String(issue.error));
107 claimRun(app.db, createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.value.id, reason: "push", actorId: standIn.bot.id }).id);
108 }
109 const old = assigned(repo, "Queued before the mark", false);
110 app.db.query("UPDATE event SET payload = json_remove(payload, '$.person') WHERE repo_id = ?1 AND kind LIKE '%.assigned'").run(repo.id);
111 await until(() => logOf(old).includes("queued, not started: Ahead of pace"), "the unmarked run to be held");
112 expect(stateOf(old)).toBe("queued");
113 });
114
115 test("at the reserve's wall everything waits, reviews and a person's assignment too", async () => {
116 const { app } = standIn;
117 const repo = await pacedRepo("wall", 95);
118 const person = assigned(repo, "A person's issue", false);
119 const review = createRun(app.db, { repoId: repo.id, kind: "agent", reason: "review", actorId: standIn.bot.id }).id;
120 await until(() => logOf(person).includes("queued, not started:") && logOf(review).includes("queued, not started:"), "both to be held");
121 expect(stateOf(person)).toBe("queued");
122 expect(stateOf(review)).toBe("queued");
123 });
test/tui-backups.test.ts +11 −1
@@ -29,6 +29,14 @@ describe("the snapshot destination in the terminal", () => {
2929 const frames: string[] = [];
3030 const saved = { ...process.env };
3131
32 /** Under a loaded machine a screen can land a turn after the first settle: settle again until it shows. */
33 async function until(h: { text(): string; press(...k: string[]): Promise<string> }, what: string) {
34 for (let i = 0; i < 100 && !h.text().includes(what); i++) {
35 await Bun.sleep(20);
36 await h.press();
37 }
38 }
39
3240 function boot(username: string) {
3341 const terminal = headlessTerminal(140, 40);
3442 const user = getUserByName(forge.db, username)!;
@@ -100,7 +108,9 @@ describe("the snapshot destination in the terminal", () => {
100108 const h = boot("alice");
101109 await h.open();
102110 // The prompt says up front that a refusal is told in full on the screen behind it.
103 expect(await h.press("d")).toContain("a refusal is said whole behind");
111 await h.press("d");
112 await until(h, "a refusal is said whole behind");
113 expect(h.text()).toContain("a refusal is said whole behind");
104114 await h.type("ftp://user:[email protected]");
105115 const t = await h.press("enter");
106116 // The prompt has room for the start of it; the modal covers the middle of the line behind.
test/week-pump.test.ts +2
@@ -97,6 +97,8 @@ test("builds fill the pace slots, yet a review or assigned run still starts; a t
9797 const run = (reason: string, going: boolean) => {
9898 const issue = openIssue(app, alice, repo, { title: `${reason} work`, body: "Do it." });
9999 if (!issue.ok) throw new Error(String(issue.error));
100 // A person's assignment says so on its event (`assignedByPerson`).
101 if (reason === "assigned") app.bus.append({ kind: `${issue.value.kind}.assigned`, repoId: repo.id, actorId: alice.id, payload: { number: issue.value.number, person: true } });
100102 const made = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.value.id, reason, actorId: bot.id });
101103 if (going) claimRun(app.db, made.id);
102104 return made.id;
test/week.test.ts +5 −3
@@ -17,6 +17,7 @@ const base: WeekInput = {
1717 meter: { freePct: 50, keptPct: 0, burnPctPerHour: 0.4, resetsAt: new Date(NOW.getTime() + 3.5 * DAY) },
1818 now: NOW,
1919 ceiling: 4,
20 floorSlots: 2,
2021 idleSlots: 2,
2122 startable: 0,
2223 gated: 3,
@@ -32,7 +33,7 @@ describe("weekDecision", () => {
3233 });
3334
3435 test("settings are plain values", () => {
35 expect(WEEK).toEqual({ tolerance: 0.25, reserve: 0.1, floorSlots: 2, unknownSlots: 2, burnHours: 1 });
36 expect(WEEK).toEqual({ tolerance: 0.25, reserve: 0.1, unknownSlots: 2, burnHours: 1 });
3637 });
3738
3839 test("on pace: a burn near allowed keeps the ceiling", () => {
@@ -79,7 +80,7 @@ describe("weekDecision", () => {
7980 test("a fast burn slows the crew", () => {
8081 const fast = used(50, { burnPctPerHour: 2.7 });
8182 expect(fast.status).toBe("ahead");
82 expect(fast.slots).toBe(WEEK.floorSlots);
83 expect(fast.slots).toBe(2);
8384 expect(fast.runsOutAt).not.toBeNull();
8485 expect(fast.sentence).toContain("running out about");
8586 });
@@ -91,13 +92,14 @@ describe("weekDecision", () => {
9192 meter: { freePct, keptPct: 0, burnPctPerHour, resetsAt },
9293 now: new Date(iso),
9394 ceiling: 5,
95 floorSlots: 2,
9496 idleSlots: 3,
9597 startable: 0,
9698 gated: 2,
9799 })!;
98100 const hot = at("2026-11-02T19:25:00Z", 46, 2.7);
99101 expect(hot.status).toBe("ahead");
100 expect(hot.slots).toBe(WEEK.floorSlots);
102 expect(hot.slots).toBe(2);
101103 expect(hot.release).toBe(false);
102104 expect(hot.sentence).toContain("running out about 2026-11-03");
103105 const flat = at("2026-11-02T20:45:00Z", 45, 0);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs