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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8962-reviews-and-work-a-person-assigned-alway → main 6 commits fork 3 threads · 3 unresolved
12 files
changed +224
−18
docs/CREW.md +16
| @@ -464,3 +464,19 @@ something only a passport may reach. | ||
| 464 | 464 | 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. |
| 465 | 465 | |
| 466 | 466 | The same read is a second door: the MCP `vision` tool takes an optional `number`, and for an admin returns `preview` (`{ today, after }`, or `{ why }` the direction would stay) for that open vision pull, plus `directionParts` (the direction cut into `vision` and `owner` parts). The terminal (TUI) has no such view; it is not touched by this change. |
| 467 | ||
| 468 | ## What the pace limits (#8962) | |
| 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( | ||
| 298 | 298 | issue: Issue, |
| 299 | 299 | assignee: User | null, |
| 300 | 300 | variantGroup?: string, |
| 301 | /** The crew's own pick under the operator's name: the pace holds it (`week_hold.ts`). */ | |
| 302 | pick = false, | |
| 301 | 303 | ): Result< |
| 302 | 304 | Issue, |
| 303 | 305 | AccessError | "assignee_cannot_read" | "agent_not_on_crew" | "unverified" | "not_member" | "archived" |
| @@ -335,6 +337,8 @@ export function assignIssue( | ||
| 335 | 337 | assignee: assignee?.username ?? null, |
| 336 | 338 | agent: assignee?.kind === "agent", |
| 337 | 339 | ...(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 }), | |
| 338 | 342 | }, |
| 339 | 343 | }); |
| 340 | 344 | return ok(updated); |
src/core/capacity.ts +14 −1
| @@ -26,7 +26,8 @@ export type CapacityKey = | ||
| 26 | 26 | | "guard_new_accounts" |
| 27 | 27 | | "signin.edge" |
| 28 | 28 | | "slots.smallShare" |
| 29 | | "polish.capMinutes"; | |
| 29 | | "polish.capMinutes" | |
| 30 | | "pace.floor"; | |
| 30 | 31 | |
| 31 | 32 | interface Definition { |
| 32 | 33 | /** What the settings page and the API call it. */ |
| @@ -124,6 +125,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 124 | 125 | fallback: "60", |
| 125 | 126 | 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.", |
| 126 | 127 | }, |
| 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 | }, | |
| 127 | 135 | }; |
| 128 | 136 | |
| 129 | 137 | export const CAPACITY_KEYS = Object.keys(CAPACITY) as CapacityKey[]; |
| @@ -327,6 +335,11 @@ export function capacityCount( | ||
| 327 | 335 | return Number.isInteger(n) && n > 0 ? n : fallback; |
| 328 | 336 | } |
| 329 | 337 | |
| 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 | ||
| 330 | 343 | /** How many hosted children this forge may hold now; read on every use. */ |
| 331 | 344 | export function maxChildren(db: Database): number { |
| 332 | 345 | return Number(capacityValue(db, "children.max")); |
src/crew/autonomy.ts +1 −1
| @@ -546,7 +546,7 @@ async function driveOnce( | ||
| 546 | 546 | if (!cleared.ok) return "idle"; |
| 547 | 547 | } |
| 548 | 548 | 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); | |
| 550 | 550 | if (!assigned.ok) { |
| 551 | 551 | log.warn("could not assign the next ready issue", { |
| 552 | 552 | repo: `${repo.ownerName}/${repo.name}`, |
src/crew/dispatch.ts +38 −4
| @@ -16,7 +16,7 @@ import { twinNote, twinOf, type Twin } from "./twin.ts"; | ||
| 16 | 16 | import { NO_MERGE } from "../checks/merged.ts"; |
| 17 | 17 | import { resolveListConflicts } from "../actions/list-conflicts.ts"; |
| 18 | 18 | import { markHeard } from "../core/issues.ts"; |
| 19 | import { capacityCount, noteBooted } from "../core/capacity.ts"; | |
| 19 | import { capacityCount, noteBooted, paceFloor } from "../core/capacity.ts"; | |
| 20 | 20 | import { tmpdir } from "node:os"; |
| 21 | 21 | import { emailVerified } from "../core/verify.ts"; |
| 22 | 22 | import { rmSync } from "node:fs"; |
| @@ -1441,6 +1441,39 @@ export function sweepAssigned(app: App): void { | ||
| 1441 | 1441 | } |
| 1442 | 1442 | } |
| 1443 | 1443 | |
| 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 | ||
| 1444 | 1477 | /** Per app, the runs already told why they wait, so the log says it once. */ |
| 1445 | 1478 | const notedBy = new WeakMap<App, Set<string>>(); |
| 1446 | 1479 | |
| @@ -1471,6 +1504,7 @@ function pump( | ||
| 1471 | 1504 | const started: string[] = []; |
| 1472 | 1505 | const goingIn = new Map<string, number>(); |
| 1473 | 1506 | const priorityIn = new Map<string, number>(); |
| 1507 | const priorityMemo = new Map<string, boolean>(); | |
| 1474 | 1508 | const noted = notedBy.get(app) ?? new Set<string>(); |
| 1475 | 1509 | notedBy.set(app, noted); |
| 1476 | 1510 | // Builder starts go to teams in proportion to their dials (`team_share.ts`), |
| @@ -1504,8 +1538,8 @@ function pump( | ||
| 1504 | 1538 | const going = (goingIn.get(home.id) ?? 0) + active.filter((r) => r.state === "running" && r.repoId === home.id).length; |
| 1505 | 1539 | const priorityGoing = |
| 1506 | 1540 | (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)); | |
| 1509 | 1543 | if (held !== null) { |
| 1510 | 1544 | if (!noted.has(fresh.id)) { |
| 1511 | 1545 | noted.add(fresh.id); |
| @@ -1532,7 +1566,7 @@ function pump( | ||
| 1532 | 1566 | } |
| 1533 | 1567 | started.push(fresh.id); |
| 1534 | 1568 | 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); | |
| 1536 | 1570 | noted.delete(fresh.id); |
| 1537 | 1571 | slots--; |
| 1538 | 1572 | if (queued.issueId !== null) runningIssues.add(queued.issueId); |
src/crew/week.ts +3 −3
| @@ -31,8 +31,6 @@ export const WEEK = { | ||
| 31 | 31 | tolerance: 0.25, |
| 32 | 32 | /** The share of the week's crew capacity kept for conversations and a person's own requests. */ |
| 33 | 33 | reserve: 0.1, |
| 34 | /** Slots never fall below this while the reserve holds. */ | |
| 35 | floorSlots: 2, | |
| 36 | 34 | /** Slots the crew holds at while the week's capacity is unknown. */ |
| 37 | 35 | unknownSlots: 2, |
| 38 | 36 | /** How many hours back the burn is read over. */ |
| @@ -58,6 +56,8 @@ export interface WeekInput { | ||
| 58 | 56 | readonly now: Date; |
| 59 | 57 | /** The operator's slot ceiling. */ |
| 60 | 58 | readonly ceiling: number; |
| 59 | /** Slots a fast burn never slows below while the reserve holds (`pace.floor`). */ | |
| 60 | readonly floorSlots: number; | |
| 61 | 61 | /** Builder slots not in use. */ |
| 62 | 62 | readonly idleSlots: number; |
| 63 | 63 | /** Ready issues a builder may start now. */ |
| @@ -120,7 +120,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 120 | 120 | const status: Status = burning ? "ahead" : idling ? "behind" : "on pace"; |
| 121 | 121 | |
| 122 | 122 | 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))); | |
| 124 | 124 | // The reserve is a wall: past it the crew waits for the reset. |
| 125 | 125 | if (used >= wall) slots = 0; |
| 126 | 126 | slots = Math.min(ceiling, Math.max(0, slots)); |
src/crew/week_hold.ts +5 −4
| @@ -4,16 +4,16 @@ | ||
| 4 | 4 | * the decision and the count in, the reason out. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { WEEK, type WeekDecision } from "./week.ts"; | |
| 7 | import type { WeekDecision } from "./week.ts"; | |
| 8 | 8 | |
| 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. */ | |
| 10 | 10 | export const PRIORITY_REASONS: readonly string[] = ["assigned", "review"]; |
| 11 | 11 | |
| 12 | 12 | /** |
| 13 | 13 | * The words a queued run waits on, or null when it may start. A pick waits |
| 14 | 14 | * when the week allows fewer slots than the ceiling and `going` picks already |
| 15 | 15 | * 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 | |
| 17 | 17 | * lands first and a build never holds it (#8910). |
| 18 | 18 | */ |
| 19 | 19 | export function weekHold( |
| @@ -22,11 +22,12 @@ export function weekHold( | ||
| 22 | 22 | going: number, |
| 23 | 23 | reason = "", |
| 24 | 24 | priorityGoing = 0, |
| 25 | floor = 2, | |
| 25 | 26 | ): string | null { |
| 26 | 27 | if (week === null || week.slots >= ceiling) return null; |
| 27 | 28 | // Past the reserve (no slots) the wall holds everything for the reset. |
| 28 | 29 | 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.`; | |
| 30 | 31 | } |
| 31 | 32 | if (going < week.slots) return null; |
| 32 | 33 | 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"; | ||
| 8 | 8 | import { standings, WINDOW_MS } from "../core/standing.ts"; |
| 9 | 9 | import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts"; |
| 10 | 10 | import { burnFrom, noteReading, readingsSince } from "./week_burn.ts"; |
| 11 | import { paceFloor } from "../core/capacity.ts"; | |
| 11 | 12 | import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts"; |
| 12 | 13 | |
| 13 | 14 | /** 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( | ||
| 114 | 115 | seen: { idleSlots: number; startable: number; gated: number } = { idleSlots: 0, startable: 0, gated: 0 }, |
| 115 | 116 | ): WeekDecision | null { |
| 116 | 117 | 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 }); | |
| 118 | 119 | } |
| 119 | 120 | |
| 120 | 121 | /** |
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 |
||
| 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", () => { | ||
| 29 | 29 | const frames: string[] = []; |
| 30 | 30 | const saved = { ...process.env }; |
| 31 | 31 | |
| 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 | ||
| 32 | 40 | function boot(username: string) { |
| 33 | 41 | const terminal = headlessTerminal(140, 40); |
| 34 | 42 | const user = getUserByName(forge.db, username)!; |
| @@ -100,7 +108,9 @@ describe("the snapshot destination in the terminal", () => { | ||
| 100 | 108 | const h = boot("alice"); |
| 101 | 109 | await h.open(); |
| 102 | 110 | // 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"); | |
| 104 | 114 | await h.type("ftp://user:[email protected]"); |
| 105 | 115 | const t = await h.press("enter"); |
| 106 | 116 | // 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 | ||
| 97 | 97 | const run = (reason: string, going: boolean) => { |
| 98 | 98 | const issue = openIssue(app, alice, repo, { title: `${reason} work`, body: "Do it." }); |
| 99 | 99 | 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 } }); | |
| 100 | 102 | const made = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.value.id, reason, actorId: bot.id }); |
| 101 | 103 | if (going) claimRun(app.db, made.id); |
| 102 | 104 | return made.id; |
test/week.test.ts +5 −3
| @@ -17,6 +17,7 @@ const base: WeekInput = { | ||
| 17 | 17 | meter: { freePct: 50, keptPct: 0, burnPctPerHour: 0.4, resetsAt: new Date(NOW.getTime() + 3.5 * DAY) }, |
| 18 | 18 | now: NOW, |
| 19 | 19 | ceiling: 4, |
| 20 | floorSlots: 2, | |
| 20 | 21 | idleSlots: 2, |
| 21 | 22 | startable: 0, |
| 22 | 23 | gated: 3, |
| @@ -32,7 +33,7 @@ describe("weekDecision", () => { | ||
| 32 | 33 | }); |
| 33 | 34 | |
| 34 | 35 | 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 }); | |
| 36 | 37 | }); |
| 37 | 38 | |
| 38 | 39 | test("on pace: a burn near allowed keeps the ceiling", () => { |
| @@ -79,7 +80,7 @@ describe("weekDecision", () => { | ||
| 79 | 80 | test("a fast burn slows the crew", () => { |
| 80 | 81 | const fast = used(50, { burnPctPerHour: 2.7 }); |
| 81 | 82 | expect(fast.status).toBe("ahead"); |
| 82 | expect(fast.slots).toBe(WEEK.floorSlots); | |
| 83 | expect(fast.slots).toBe(2); | |
| 83 | 84 | expect(fast.runsOutAt).not.toBeNull(); |
| 84 | 85 | expect(fast.sentence).toContain("running out about"); |
| 85 | 86 | }); |
| @@ -91,13 +92,14 @@ describe("weekDecision", () => { | ||
| 91 | 92 | meter: { freePct, keptPct: 0, burnPctPerHour, resetsAt }, |
| 92 | 93 | now: new Date(iso), |
| 93 | 94 | ceiling: 5, |
| 95 | floorSlots: 2, | |
| 94 | 96 | idleSlots: 3, |
| 95 | 97 | startable: 0, |
| 96 | 98 | gated: 2, |
| 97 | 99 | })!; |
| 98 | 100 | const hot = at("2026-11-02T19:25:00Z", 46, 2.7); |
| 99 | 101 | expect(hot.status).toBe("ahead"); |
| 100 | expect(hot.slots).toBe(WEEK.floorSlots); | |
| 102 | expect(hot.slots).toBe(2); | |
| 101 | 103 | expect(hot.release).toBe(false); |
| 102 | 104 | expect(hot.sentence).toContain("running out about 2026-11-03"); |
| 103 | 105 | const flat = at("2026-11-02T20:45:00Z", 45, 0); |
new above
offline
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.