Public: anyone can read this; the crew can change it.
The throughput keeper: the forge watches its own landing rate, finds what binds it, and acts #7800
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7645-the-throughput-keeper-the-forge-watches → main 10 commits fork
20 files
changed +1038
−8
README.md +4
| @@ -400,6 +400,10 @@ days drawn beside it, on its page, in the terminal's Milestones screen and in | ||
| 400 | 400 | the MCP's `milestones` tool (`closedPerDay`, `paceDate`); `docs/PLANNING.md` |
| 401 | 401 | has the rule. |
| 402 | 402 | |
| 403 | ### The throughput keeper | |
| 404 | ||
| 405 | Once a minute the forge measures its landing rate, names the one cause binding it, and lifts Flow only or unsticks assigned issues when that is its to do; if landings do not rise in 2 h it puts the switch back and asks the owner one question with one button. It shows as a line on home and `/boards` (a site admin sees repository names; everyone else a repository-free cause), in the MCP `flow` report, in the terminal's Flow dialog (`l` answers) and as "Forge per day" on the crew scorecard. No model, no dependency. See `docs/features/keeper.md`. | |
| 406 | ||
| 403 | 407 | ## Where the rest is |
| 404 | 408 | |
| 405 | 409 | - [](docs/DEVELOP.md) — developing the forge: tests, checks, the gate, lint and types. |
docs/features/keeper.md added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | # The throughput keeper (#7645) | |
| 2 | ||
| 3 | The forge watches its own landing rate, names the one thing binding it, and acts where the act is reversible and its own. `src/flow/keeper.ts` ticks once a minute. It reads the database and asks no model, so it still speaks when the subscription is out. | |
| 4 | ||
| 5 | 1. **Measure** (`keeper_rate.ts`): pulls landed per hour over the last 3 h against the best 3 h rate of the week before, and the share of the last 24 hours that ran (a landing or a builder run started). Low is under half the best and under one an hour. | |
| 6 | 2. **Find** (`keeper_cause.ts`): the first of `ceiling`, `usage` ("resets at HH:MM UTC"), `unassigned`, `empty_pillar`, `red_checks`, `stale_pile`, `capacity`. It asks the dispatcher's own questions (`heldNow`, `atCeiling`, `pace`). | |
| 7 | 3. **Act**: `unassigned` sweeps assigned issues with no run. `empty_pillar`, `red_checks` and `stale_pile` become one filed issue in the forge's own repository, once a week per cause. `ceiling`, `usage` and `capacity` are only said. | |
| 8 | 4. **Check** after 2 h: landings rose and the act stands, or it is undone, the cause is left alone for 6 h and the owner is asked one question with one button (`POST /flow/keeper/answer`). | |
| 9 | ||
| 10 | Where it shows: one line on home and on the boards panel, `keeper` in the MCP `flow` report, the terminal's Flow dialog (the line, the question, and `l` for its one button after a confirm) and its crew scorecard ("Forge per day" rows), and "Forge per day" (hours running, landed) on the crew scorecard and in `scorecard` (`keeperDays`). Every attempt is a row in `keeper_attempt`. | |
| 11 | ||
| 12 | Not done: DORA change-fail rate, revert rate, queue-wait p95 and rework per agent from the issue discussion are not measured yet. | |
| 13 | ||
| 14 | Who reads what: a site admin reads the sentence with repository names and the question with its button. Everyone else, guests and agents included, reads the cause with no repository in it (`readKeeper(db, full)`). The Flow only switch is retired (#7771), so no act asks a question today; one already on the books is answered with its button, and the answer is a `keeper.answered` event carrying the owner's id. |
src/actions/scorecard.ts +4
| @@ -15,6 +15,7 @@ import { modelDays, type ModelDay } from "../core/model_days.ts"; | ||
| 15 | 15 | import { readReview } from "./model_review.ts"; |
| 16 | 16 | import { wastedDays, type WastedDay } from "../core/wasted_days.ts"; |
| 17 | 17 | import { listAgents } from "../core/agents.ts"; |
| 18 | import { keeperDays, type KeeperDay } from "../flow/keeper_rate.ts"; | |
| 18 | 19 | import type { User } from "../core/users.ts"; |
| 19 | 20 | |
| 20 | 21 | export interface AgentScorecard { |
| @@ -47,6 +48,7 @@ export function readScorecards( | ||
| 47 | 48 | agents: AgentScorecard[]; |
| 48 | 49 | modelDays: ModelDay[]; |
| 49 | 50 | wastedDays: WastedDay[]; |
| 51 | keeperDays: KeeperDay[]; | |
| 50 | 52 | modelReview: ReturnType<typeof readReview>; |
| 51 | 53 | } | undefined { |
| 52 | 54 | if (actor?.isAdmin !== true) return undefined; |
| @@ -58,6 +60,8 @@ export function readScorecards( | ||
| 58 | 60 | const key = (p: SplitScorecard) => `${p.model}\u0000${p.promptVersion}`; |
| 59 | 61 | return { |
| 60 | 62 | modelDays: modelDays(app.db), |
| 63 | // Hours the forge ran and pulls landed, per UTC day of the last week (#7645). | |
| 64 | keeperDays: keeperDays(app.db, new Date()), | |
| 61 | 65 | modelReview: readReview(app), |
| 62 | 66 | wastedDays: wastedDays(app.db), |
| 63 | 67 | agents: listAgents(app.db).map((agent) => { |
src/core/event_audience.ts +2
| @@ -161,6 +161,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 161 | 161 | kind.startsWith("crew.model_") || |
| 162 | 162 | // A clog is the whole forge's flow, the owner's to hear (`flow/clog_tick.ts`). |
| 163 | 163 | kind.startsWith("clog.") || |
| 164 | // What the throughput keeper did to lift a brake is the owner's too (`flow/keeper.ts`). | |
| 165 | kind.startsWith("keeper.") || | |
| 164 | 166 | // Events the retired FLOW ONLY switch wrote stay the admins' to hear. |
| 165 | 167 | kind === "flow_only.cleared" || |
| 166 | 168 | // A report from outside waits for a person who decides; its arrival is |
src/core/migration-pins/20261101130000-keeper.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | e10ae5d8c6a6c7afdd71f28231f6e26c25db781f10ea3138fac5793accb5d3fb |
src/core/migrations/20261101130000-keeper.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | // The throughput keeper (#7645): what it found binding now (one row), and | |
| 2 | // each attempt it made, asked or filed (`flow/keeper.ts`). An attempt waits | |
| 3 | // `pending` until its effect is checked, then is `worked` or `undone`; `asked` | |
| 4 | // is a question for the owner with one button; `filed` is an issue it opened | |
| 5 | // once for a cause it cannot act on. | |
| 6 | export default ` | |
| 7 | CREATE TABLE IF NOT EXISTS keeper_state ( | |
| 8 | id INTEGER PRIMARY KEY CHECK (id = 1), | |
| 9 | cause TEXT NOT NULL DEFAULT '', | |
| 10 | says TEXT NOT NULL DEFAULT '', | |
| 11 | at INTEGER NOT NULL DEFAULT 0 | |
| 12 | ); | |
| 13 | CREATE TABLE IF NOT EXISTS keeper_attempt ( | |
| 14 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 15 | at INTEGER NOT NULL, | |
| 16 | cause TEXT NOT NULL, | |
| 17 | action TEXT NOT NULL, | |
| 18 | repo_id TEXT NOT NULL DEFAULT '', | |
| 19 | says TEXT NOT NULL DEFAULT '', | |
| 20 | outcome TEXT NOT NULL CHECK (outcome IN ('pending','worked','undone','asked','answered','filed')), | |
| 21 | check_at INTEGER NOT NULL DEFAULT 0 | |
| 22 | ); | |
| 23 | CREATE INDEX IF NOT EXISTS keeper_attempt_at ON keeper_attempt(at DESC); | |
| 24 | `; |
src/flow/keeper.ts added +255
| @@ -0,0 +1,255 @@ | ||
| 1 | /** | |
| 2 | * The throughput keeper (#7645): once a minute it measures the landing rate | |
| 3 | * (`keeper_rate.ts`); when it is low it names the one cause binding it | |
| 4 | * (`keeper_cause.ts`) and acts only where the act is reversible and its own: | |
| 5 | * unsticking assigned issues with no run. After `EFFECT_MS` it checks whether | |
| 6 | * landings rose; if not it undoes the act, remembers the cause as tried and | |
| 7 | * asks the owner one question with one button. A cause it cannot act on is | |
| 8 | * filed as one issue, once. It reads the database and spends no model tokens, | |
| 9 | * so it still speaks when the subscription is out. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import type { App } from "../app.ts"; | |
| 14 | import { createIssue } from "../core/issues.ts"; | |
| 15 | import { getRepo } from "../core/repos.ts"; | |
| 16 | import { createLog } from "../kernel/log.ts"; | |
| 17 | import { ownRepoPath } from "../kernel/own_repo.ts"; | |
| 18 | import { KEEPER_CAUSES, pickCause, readKeeperFacts, type KeeperCause, type KeeperMove } from "./keeper_cause.ts"; | |
| 19 | import { isLow, landedBetween, readRate, type Rate } from "./keeper_rate.ts"; | |
| 20 | ||
| 21 | const log = createLog("keeper"); | |
| 22 | const HOUR_MS = 3_600_000; | |
| 23 | /** How long after an act landings are compared with the same span before it. */ | |
| 24 | export const EFFECT_MS = 2 * HOUR_MS; | |
| 25 | /** A cause that did not help is left alone this long. */ | |
| 26 | export const TRIED_MS = 6 * HOUR_MS; | |
| 27 | /** A cause filed as an issue is not filed again this long. */ | |
| 28 | export const FILE_ONCE_MS = 7 * 24 * HOUR_MS; | |
| 29 | ||
| 30 | type Outcome = "pending" | "worked" | "undone" | "asked" | "answered" | "filed"; | |
| 31 | ||
| 32 | export interface Attempt { | |
| 33 | readonly id: number; | |
| 34 | readonly at: number; | |
| 35 | readonly cause: KeeperCause; | |
| 36 | readonly action: string; | |
| 37 | readonly repoId: string; | |
| 38 | readonly says: string; | |
| 39 | readonly outcome: Outcome; | |
| 40 | readonly checkAt: number; | |
| 41 | } | |
| 42 | ||
| 43 | interface AttemptRow { | |
| 44 | id: number; | |
| 45 | at: number; | |
| 46 | cause: string; | |
| 47 | action: string; | |
| 48 | repo_id: string; | |
| 49 | says: string; | |
| 50 | outcome: string; | |
| 51 | check_at: number; | |
| 52 | } | |
| 53 | ||
| 54 | const toAttempt = (r: AttemptRow): Attempt => ({ | |
| 55 | id: r.id, | |
| 56 | at: r.at, | |
| 57 | cause: r.cause as KeeperCause, | |
| 58 | action: r.action, | |
| 59 | repoId: r.repo_id, | |
| 60 | says: r.says, | |
| 61 | outcome: r.outcome as Outcome, | |
| 62 | checkAt: r.check_at, | |
| 63 | }); | |
| 64 | ||
| 65 | const COLS = "id, at, cause, action, repo_id, says, outcome, check_at"; | |
| 66 | ||
| 67 | export function listAttempts(db: Database, limit = 20): Attempt[] { | |
| 68 | return db.query<AttemptRow, [number]>(`SELECT ${COLS} FROM keeper_attempt ORDER BY id DESC LIMIT ?1`).all(limit).map(toAttempt); | |
| 69 | } | |
| 70 | ||
| 71 | function record(db: Database, a: Omit<Attempt, "id">): number { | |
| 72 | return Number( | |
| 73 | db | |
| 74 | .query("INSERT INTO keeper_attempt (at, cause, action, repo_id, says, outcome, check_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)") | |
| 75 | .run(a.at, a.cause, a.action, a.repoId, a.says, a.outcome, a.checkAt).lastInsertRowid, | |
| 76 | ); | |
| 77 | } | |
| 78 | ||
| 79 | const setOutcome = (db: Database, id: number, outcome: Outcome): void => { | |
| 80 | db.query("UPDATE keeper_attempt SET outcome = ?2 WHERE id = ?1").run(id, outcome); | |
| 81 | }; | |
| 82 | ||
| 83 | /** Causes that were undone lately, or filed within the week: not tried again. */ | |
| 84 | function skipped(db: Database, now: number): Set<KeeperCause> { | |
| 85 | const rows = db | |
| 86 | .query<{ cause: string; outcome: string }, [number, number]>( | |
| 87 | `SELECT cause, outcome FROM keeper_attempt | |
| 88 | WHERE (outcome IN ('undone','asked') AND at >= ?1) OR (outcome = 'filed' AND at >= ?2)`, | |
| 89 | ) | |
| 90 | .all(now - TRIED_MS, now - FILE_ONCE_MS); | |
| 91 | return new Set(rows.map((r) => r.cause).filter((c): c is KeeperCause => (KEEPER_CAUSES as readonly string[]).includes(c))); | |
| 92 | } | |
| 93 | ||
| 94 | /** Checks every act whose time is up: landings rose and it stands, or it is undone and the owner is asked. */ | |
| 95 | function settle(app: App, now: number): void { | |
| 96 | const { db } = app; | |
| 97 | const due = db | |
| 98 | .query<AttemptRow, [number]>(`SELECT ${COLS} FROM keeper_attempt WHERE outcome = 'pending' AND check_at <= ?1`) | |
| 99 | .all(now) | |
| 100 | .map(toAttempt); | |
| 101 | for (const a of due) { | |
| 102 | const after = landedBetween(db, a.at, now); | |
| 103 | const before = landedBetween(db, a.at - (now - a.at), a.at); | |
| 104 | if (after > before) { | |
| 105 | setOutcome(db, a.id, "worked"); | |
| 106 | continue; | |
| 107 | } | |
| 108 | setOutcome(db, a.id, "undone"); | |
| 109 | app.bus.append({ kind: "keeper.undone", repoId: a.repoId, payload: { cause: a.cause, action: a.action } }); | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | /** Files one issue for a cause the keeper cannot act on, in the forge's own repository. */ | |
| 114 | function fileOnce(app: App, found: { cause: KeeperCause; says: string }, rate: Rate, now: number): void { | |
| 115 | const [owner, name] = ownRepoPath().split("/"); | |
| 116 | const repo = owner && name ? getRepo(app.db, owner, name) : undefined; | |
| 117 | if (!repo) return log.warn("keeper cannot file: the forge's own repository is missing", { path: ownRepoPath() }); | |
| 118 | const made = createIssue(app.db, { | |
| 119 | repoId: repo.id, | |
| 120 | authorId: repo.ownerId, | |
| 121 | title: `Throughput: ${found.says}`.slice(0, 200), | |
| 122 | body: `The throughput keeper found this binding landings (${rate.recent.toFixed(1)} an hour against a best of ${rate.best.toFixed(1)}) and cannot act on it itself.\n\n${found.says}\n\nFiles: docs/features/keeper.md`, | |
| 123 | }); | |
| 124 | if (!made.ok) return; | |
| 125 | app.bus.append({ kind: "issue.opened", repoId: repo.id, payload: { number: made.value.number } }); | |
| 126 | record(app.db, { at: now, cause: found.cause, action: "file", repoId: repo.id, says: `Filed #${made.value.number}`, outcome: "filed", checkAt: 0 }); | |
| 127 | } | |
| 128 | ||
| 129 | function saveState(db: Database, cause: string, says: string, now: number): void { | |
| 130 | db.query( | |
| 131 | `INSERT INTO keeper_state (id, cause, says, at) VALUES (1, ?1, ?2, ?3) | |
| 132 | ON CONFLICT (id) DO UPDATE SET cause = ?1, says = ?2, at = ?3`, | |
| 133 | ).run(cause, says, now); | |
| 134 | } | |
| 135 | ||
| 136 | export interface KeeperTick { | |
| 137 | readonly cause: KeeperCause | null; | |
| 138 | readonly acted: KeeperMove | null; | |
| 139 | } | |
| 140 | ||
| 141 | /** One tick. `onLift` runs after a brake is lifted (a sweep of assigned issues). */ | |
| 142 | export function tickKeeper(app: App, now = new Date(), onLift: () => void = () => log.warn("keeper lifted a brake with no sweep to run")): KeeperTick { | |
| 143 | const { db } = app; | |
| 144 | const t = now.getTime(); | |
| 145 | settle(app, t); | |
| 146 | const rate = readRate(db, now); | |
| 147 | if (!isLow(rate)) { | |
| 148 | saveState(db, "", "", t); | |
| 149 | return { cause: null, acted: null }; | |
| 150 | } | |
| 151 | const found = pickCause(readKeeperFacts(app, now), skipped(db, t)); | |
| 152 | if (!found) { | |
| 153 | saveState(db, "", "", t); | |
| 154 | return { cause: null, acted: null }; | |
| 155 | } | |
| 156 | // One act at a time: an act waiting on its check is left to show its effect, and its cause stays named. | |
| 157 | const waiting = db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM keeper_attempt WHERE outcome = 'pending'").get(); | |
| 158 | if ((waiting?.n ?? 0) > 0) return { cause: found.cause, acted: null }; | |
| 159 | saveState(db, found.cause, found.says, t); | |
| 160 | if (found.move === "unstick") { | |
| 161 | onLift(); | |
| 162 | record(db, { at: t, cause: found.cause, action: found.move, repoId: found.repoId, says: found.says, outcome: "pending", checkAt: t + EFFECT_MS }); | |
| 163 | app.bus.append({ kind: "keeper.acted", repoId: found.repoId, payload: { cause: found.cause, action: found.move, says: found.says } }); | |
| 164 | return { cause: found.cause, acted: found.move }; | |
| 165 | } | |
| 166 | if (found.move === "file") { | |
| 167 | fileOnce(app, found, rate, t); | |
| 168 | return { cause: found.cause, acted: found.move }; | |
| 169 | } | |
| 170 | return { cause: found.cause, acted: null }; | |
| 171 | } | |
| 172 | ||
| 173 | /** Ticks once a minute; a tick that throws is logged and the next one runs. Returns the stop. */ | |
| 174 | export function startKeeper(app: App, onLift: () => void, everyMs = 60_000, now: () => number = Date.now): () => void { | |
| 175 | const timer = setInterval(() => { | |
| 176 | try { | |
| 177 | tickKeeper(app, new Date(now()), onLift); | |
| 178 | } catch (error) { | |
| 179 | log.warn("keeper tick failed", { error: String(error) }); | |
| 180 | } | |
| 181 | }, everyMs); | |
| 182 | return () => clearInterval(timer); | |
| 183 | } | |
| 184 | ||
| 185 | export interface KeeperAsk { | |
| 186 | readonly id: number; | |
| 187 | readonly question: string; | |
| 188 | readonly button: string; | |
| 189 | } | |
| 190 | ||
| 191 | export interface KeeperRead { | |
| 192 | /** The cause binding landings now, one sentence; null while landings are fine. */ | |
| 193 | readonly cause: string | null; | |
| 194 | readonly name: KeeperCause | null; | |
| 195 | /** The last thing it did, one sentence; null before it did anything. */ | |
| 196 | readonly last: string | null; | |
| 197 | readonly ask: KeeperAsk | null; | |
| 198 | } | |
| 199 | ||
| 200 | const ACTION_WORDS: Record<string, string> = { | |
| 201 | unstick: "unstuck assigned issues", | |
| 202 | file: "filed an issue", | |
| 203 | }; | |
| 204 | ||
| 205 | const clock = (ms: number): string => new Date(ms).toISOString().slice(11, 16); | |
| 206 | ||
| 207 | /** The cause with no repository named in it: what a reader who may not see every repository is told. */ | |
| 208 | const GENERIC: Record<KeeperCause, string> = { | |
| 209 | ceiling: "A daily ceiling is reached: builders start again at 00:00 UTC.", | |
| 210 | usage: "Usage is the limit: no subscription has headroom.", | |
| 211 | unassigned: "Ready issues wait and no builder is running.", | |
| 212 | empty_pillar: "Nothing is ready: no pillar has an issue a builder can start.", | |
| 213 | red_checks: "Open crew pulls have a red check.", | |
| 214 | stale_pile: "Open crew pulls are waiting on a builder or a reviewer.", | |
| 215 | capacity: "Every builder slot is busy.", | |
| 216 | }; | |
| 217 | ||
| 218 | /** | |
| 219 | * What home, the pipeline and the MCP show: cheap, three small reads. Only | |
| 220 | * `full` (a site admin) reads the sentence that names repositories and the | |
| 221 | * question; anyone else reads the cause with no repository in it. | |
| 222 | */ | |
| 223 | export function readKeeper(db: Database, full: boolean): KeeperRead { | |
| 224 | const state = db.query<{ cause: string; says: string }, []>("SELECT cause, says FROM keeper_state WHERE id = 1").get(); | |
| 225 | const acts = db | |
| 226 | .query<AttemptRow, []>(`SELECT ${COLS} FROM keeper_attempt WHERE outcome IN ('pending','worked','undone','filed') ORDER BY id DESC LIMIT 1`) | |
| 227 | .all() | |
| 228 | .map(toAttempt)[0]; | |
| 229 | const asked = db.query<AttemptRow, []>(`SELECT ${COLS} FROM keeper_attempt WHERE outcome = 'asked' ORDER BY id DESC LIMIT 1`).all().map(toAttempt)[0]; | |
| 230 | const last = acts | |
| 231 | ? `At ${clock(acts.at)} UTC it ${ACTION_WORDS[acts.action] ?? acts.action}${acts.outcome === "undone" ? ": landings did not rise" : acts.outcome === "worked" ? ": landings rose" : acts.outcome === "pending" ? ", checking the effect" : ""}.` | |
| 232 | : null; | |
| 233 | return { | |
| 234 | cause: state && state.cause !== "" ? (full ? state.says : (GENERIC[state.cause as KeeperCause] ?? "")) || null : null, | |
| 235 | name: state && state.cause !== "" ? (state.cause as KeeperCause) : null, | |
| 236 | last, | |
| 237 | // No act asks a question since FLOW ONLY left (#7771); a question already on the books is still answerable. | |
| 238 | ask: full && asked ? { id: asked.id, question: asked.says, button: "Got it" } : null, | |
| 239 | }; | |
| 240 | } | |
| 241 | ||
| 242 | /** The line for a page: the cause and the last act, in one sentence run; empty when there is neither. */ | |
| 243 | export function keeperLine(read: KeeperRead): string { | |
| 244 | return [read.cause === null ? "" : `Binding landings: ${read.cause}`, read.last ?? ""].filter((s) => s !== "").join(" "); | |
| 245 | } | |
| 246 | ||
| 247 | /** The owner answers a question with its one button: the act it proposed, now for good. */ | |
| 248 | export function answerKeeper(app: App, id: number, actorId?: string): { done: boolean; message: string } { | |
| 249 | const row = app.db.query<AttemptRow, [number]>(`SELECT ${COLS} FROM keeper_attempt WHERE id = ?1 AND outcome = 'asked'`).get(id); | |
| 250 | if (!row) return { done: false, message: "That question is already answered." }; | |
| 251 | const a = toAttempt(row); | |
| 252 | setOutcome(app.db, a.id, "answered"); | |
| 253 | app.bus.append({ kind: "keeper.answered", repoId: a.repoId, ...(actorId === undefined ? {} : { actorId }), payload: { action: a.action } }); | |
| 254 | return { done: true, message: "Done." }; | |
| 255 | } |
src/flow/keeper_cause.ts added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | /** | |
| 2 | * What binds the forge's landings right now (#7645): one cause from a closed | |
| 3 | * list, in the order the dispatcher meets them. `pickCause` is plain data in, | |
| 4 | * plain data out; `readKeeperFacts` reads the same questions the dispatcher | |
| 5 | * asks (`heldNow`, `atCeiling`, `pace`) so the keeper and the builders cannot | |
| 6 | * disagree. No model is asked anything. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { listCrewRoles } from "../core/agents.ts"; | |
| 11 | import { listAutonomousRepos } from "../core/repos.ts"; | |
| 12 | import { listActive } from "../core/runs.ts"; | |
| 13 | import { standings } from "../core/standing.ts"; | |
| 14 | import { grantedAccounts } from "../core/subscriptions.ts"; | |
| 15 | import { slottedRuns, spentToday } from "../crew/autonomy.ts"; | |
| 16 | import { atCeiling } from "../crew/dispatch.ts"; | |
| 17 | import { heldNow } from "../crew/held.ts"; | |
| 18 | import { pace } from "../crew/pacing.ts"; | |
| 19 | import { readPile } from "./pile.ts"; | |
| 20 | ||
| 21 | export const KEEPER_CAUSES = [ | |
| 22 | "ceiling", | |
| 23 | "usage", | |
| 24 | "unassigned", | |
| 25 | "empty_pillar", | |
| 26 | "red_checks", | |
| 27 | "stale_pile", | |
| 28 | "capacity", | |
| 29 | ] as const; | |
| 30 | ||
| 31 | export type KeeperCause = (typeof KEEPER_CAUSES)[number]; | |
| 32 | ||
| 33 | /** What the keeper does about a cause: unstick, file once, or only say. */ | |
| 34 | export type KeeperMove = "unstick" | "file" | "say"; | |
| 35 | ||
| 36 | export interface RepoFacts { | |
| 37 | readonly repoId: string; | |
| 38 | readonly slug: string; | |
| 39 | /** Ready issues a builder could start now. */ | |
| 40 | readonly startable: number; | |
| 41 | /** Builder runs in flight, and the slots they fill. */ | |
| 42 | readonly running: number; | |
| 43 | readonly slots: number; | |
| 44 | readonly ceilingUsd: number; | |
| 45 | readonly atCeiling: boolean; | |
| 46 | readonly noHeadroom: boolean; | |
| 47 | /** When the soonest spent window resets (ISO), if a subscription is out. */ | |
| 48 | readonly resetsAt: string | null; | |
| 49 | } | |
| 50 | ||
| 51 | export interface KeeperFacts { | |
| 52 | readonly repos: readonly RepoFacts[]; | |
| 53 | readonly red: number; | |
| 54 | readonly stale: number; | |
| 55 | } | |
| 56 | ||
| 57 | export interface Found { | |
| 58 | readonly cause: KeeperCause; | |
| 59 | readonly move: KeeperMove; | |
| 60 | /** One sentence naming it. */ | |
| 61 | readonly says: string; | |
| 62 | readonly repoId: string; | |
| 63 | } | |
| 64 | ||
| 65 | /** The pile that is not moving counts as stale from this many pulls; red from this many. */ | |
| 66 | export const STALE_PULLS = 20; | |
| 67 | export const RED_PULLS = 5; | |
| 68 | ||
| 69 | const hhmm = (iso: string): string => `${iso.slice(11, 16)} UTC`; | |
| 70 | ||
| 71 | /** The first cause that binds, skipping those already tried; null when none does. */ | |
| 72 | export function pickCause(f: KeeperFacts, skip: ReadonlySet<KeeperCause> = new Set()): Found | null { | |
| 73 | const hit = (found: Found): Found | null => (skip.has(found.cause) ? null : found); | |
| 74 | for (const r of f.repos) { | |
| 75 | const waiting = r.startable; | |
| 76 | if (r.atCeiling && waiting > 0 && !skip.has("ceiling")) | |
| 77 | return { cause: "ceiling", move: "say", repoId: r.repoId, says: `The daily ceiling ($${r.ceilingUsd}) is reached in ${r.slug}: builders start again at 00:00 UTC.` }; | |
| 78 | } | |
| 79 | for (const r of f.repos) { | |
| 80 | if (r.noHeadroom && r.startable > 0 && !skip.has("usage")) | |
| 81 | return { | |
| 82 | cause: "usage", | |
| 83 | move: "say", | |
| 84 | repoId: r.repoId, | |
| 85 | says: `Usage is the limit: no subscription has headroom${r.resetsAt ? `, resets at ${hhmm(r.resetsAt)}` : ""}.`, | |
| 86 | }; | |
| 87 | } | |
| 88 | for (const r of f.repos) { | |
| 89 | if (r.startable > 0 && r.running === 0 && !r.atCeiling && !r.noHeadroom) { | |
| 90 | const found = hit({ | |
| 91 | cause: "unassigned", | |
| 92 | move: "unstick", | |
| 93 | repoId: r.repoId, | |
| 94 | says: `${r.startable} ready issue${r.startable === 1 ? "" : "s"} in ${r.slug} and no builder is running.`, | |
| 95 | }); | |
| 96 | if (found) return found; | |
| 97 | } | |
| 98 | } | |
| 99 | const first = f.repos[0]; | |
| 100 | if (first && f.repos.every((r) => r.startable === 0)) { | |
| 101 | const found = hit({ cause: "empty_pillar", move: "file", repoId: first.repoId, says: "Nothing is ready: no pillar has an issue a builder can start." }); | |
| 102 | if (found) return found; | |
| 103 | } | |
| 104 | if (first && f.red >= RED_PULLS) { | |
| 105 | const found = hit({ cause: "red_checks", move: "file", repoId: first.repoId, says: `${f.red} open crew pulls have a red check.` }); | |
| 106 | if (found) return found; | |
| 107 | } | |
| 108 | if (first && f.stale >= STALE_PULLS) { | |
| 109 | const found = hit({ cause: "stale_pile", move: "file", repoId: first.repoId, says: `${f.stale} open crew pulls are waiting on a builder or a reviewer.` }); | |
| 110 | if (found) return found; | |
| 111 | } | |
| 112 | for (const r of f.repos) { | |
| 113 | if (r.running >= r.slots && r.slots > 0 && !skip.has("capacity")) | |
| 114 | return { cause: "capacity", move: "say", repoId: r.repoId, says: `All ${r.slots} builder slot${r.slots === 1 ? "" : "s"} in ${r.slug} are busy.` }; | |
| 115 | } | |
| 116 | return null; | |
| 117 | } | |
| 118 | ||
| 119 | /** The facts, read from the dispatcher's own questions. */ | |
| 120 | export function readKeeperFacts(app: App, now: Date): KeeperFacts { | |
| 121 | const { db } = app; | |
| 122 | const repos: RepoFacts[] = []; | |
| 123 | for (const repo of listAutonomousRepos(db)) { | |
| 124 | if (repo.autonomy !== "autonomous" || repo.autonomyBy === null || repo.archivedAt !== null) continue; | |
| 125 | const crew = listCrewRoles(db, repo.id); | |
| 126 | if (crew.length === 0) continue; | |
| 127 | const held = heldNow(app, repo); | |
| 128 | const active = listActive(db, "agent").filter((r) => r.repoId === repo.id); | |
| 129 | let noHeadroom = false; | |
| 130 | let resetsAt: string | null = null; | |
| 131 | const granted = grantedAccounts(db, repo.id, repo.ownerId); | |
| 132 | if (granted.length > 0) { | |
| 133 | const stand = standings(db, granted.map((a) => a.id), now); | |
| 134 | const accounts = granted.flatMap((a) => { | |
| 135 | const s = stand.get(a.id); | |
| 136 | return s === undefined ? [] : [{ name: a.name, standing: s }]; | |
| 137 | }); | |
| 138 | noHeadroom = pace(accounts, repo.autonomySlots, 1, now).slots === 0; | |
| 139 | if (noHeadroom) | |
| 140 | for (const a of accounts) | |
| 141 | for (const w of a.standing.windows) | |
| 142 | if (w.resetsAt && (resetsAt === null || w.resetsAt.value < resetsAt)) resetsAt = w.resetsAt.value; | |
| 143 | } | |
| 144 | repos.push({ | |
| 145 | repoId: repo.id, | |
| 146 | slug: `${repo.ownerName}/${repo.name}`, | |
| 147 | startable: held.startable.length, | |
| 148 | running: slottedRuns(active, crew).length, | |
| 149 | slots: repo.autonomySlots, | |
| 150 | ceilingUsd: repo.autonomyCeilingUsd, | |
| 151 | atCeiling: atCeiling(repo, spentToday(db, repo.id, now)), | |
| 152 | noHeadroom, | |
| 153 | resetsAt, | |
| 154 | }); | |
| 155 | } | |
| 156 | const pile = readPile(db); | |
| 157 | return { repos, red: pile.red, stale: pile.awaitingReview + pile.changesAsked + pile.approved }; | |
| 158 | } |
src/flow/keeper_rate.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * What the throughput keeper measures (#7645), from pulls and runs the forge | |
| 3 | * already keeps: landed per hour against the best the forge has recently | |
| 4 | * sustained, and the share of hours it ran. An hour ran when a pull landed in | |
| 5 | * it or a builder started a run in it. No model, no table of its own. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | ||
| 10 | const HOUR_MS = 3_600_000; | |
| 11 | const DAY_MS = 24 * HOUR_MS; | |
| 12 | /** Hours of history the best is taken over. */ | |
| 13 | export const BEST_HOURS = 7 * 24; | |
| 14 | /** The span a rate is averaged over, in hours. */ | |
| 15 | const SPAN = 3; | |
| 16 | ||
| 17 | /** Pulls merged into a default branch in [from, to], by ISO time. */ | |
| 18 | export function landedBetween(db: Database, fromMs: number, toMs: number): number { | |
| 19 | const row = db | |
| 20 | .query<{ n: number }, [string, string]>( | |
| 21 | `SELECT COUNT(*) AS n FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 22 | WHERE p.base_branch = r.default_branch AND p.merged_at >= ?1 AND p.merged_at <= ?2`, | |
| 23 | ) | |
| 24 | .get(new Date(fromMs).toISOString(), new Date(toMs).toISOString()); | |
| 25 | return row?.n ?? 0; | |
| 26 | } | |
| 27 | ||
| 28 | /** Per hour back from `now` (index 0 the last hour): landings, and whether a builder started a run. */ | |
| 29 | export function hourly(db: Database, now: number, hours: number): { landed: number[]; ran: boolean[] } { | |
| 30 | const since = new Date(now - hours * HOUR_MS).toISOString(); | |
| 31 | const until = new Date(now).toISOString(); | |
| 32 | const landed = new Array<number>(hours).fill(0); | |
| 33 | const ran = new Array<boolean>(hours).fill(false); | |
| 34 | const at = (iso: string): number => Math.floor((now - Date.parse(iso)) / HOUR_MS); | |
| 35 | const merged = db | |
| 36 | .query<{ at: string }, [string, string]>( | |
| 37 | `SELECT p.merged_at AS at FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 38 | WHERE p.base_branch = r.default_branch AND p.merged_at >= ?1 AND p.merged_at <= ?2`, | |
| 39 | ) | |
| 40 | .all(since, until); | |
| 41 | for (const m of merged) { | |
| 42 | const h = at(m.at); | |
| 43 | if (h >= 0 && h < hours) { | |
| 44 | landed[h]! += 1; | |
| 45 | ran[h] = true; | |
| 46 | } | |
| 47 | } | |
| 48 | const started = db | |
| 49 | .query<{ at: string }, [string, string]>( | |
| 50 | "SELECT started_at AS at FROM run WHERE kind = 'agent' AND started_at >= ?1 AND started_at <= ?2", | |
| 51 | ) | |
| 52 | .all(since, until); | |
| 53 | for (const s of started) { | |
| 54 | const h = at(s.at); | |
| 55 | if (h >= 0 && h < hours) ran[h] = true; | |
| 56 | } | |
| 57 | return { landed, ran }; | |
| 58 | } | |
| 59 | ||
| 60 | export interface Rate { | |
| 61 | /** Landed in the last hour. */ | |
| 62 | readonly lastHour: number; | |
| 63 | /** Landed per hour over the last three. */ | |
| 64 | readonly recent: number; | |
| 65 | /** The best three-hour rate in the week before those three; 0 with no history. */ | |
| 66 | readonly best: number; | |
| 67 | /** Share of the last 24 hours that ran, 0 to 1. */ | |
| 68 | readonly ranShare: number; | |
| 69 | } | |
| 70 | ||
| 71 | export function readRate(db: Database, now: Date): Rate { | |
| 72 | const { landed, ran } = hourly(db, now.getTime(), BEST_HOURS); | |
| 73 | const sum = (from: number): number => landed.slice(from, from + SPAN).reduce((a, b) => a + b, 0) / SPAN; | |
| 74 | let best = 0; | |
| 75 | for (let h = SPAN; h + SPAN <= landed.length; h++) best = Math.max(best, sum(h)); | |
| 76 | return { | |
| 77 | lastHour: landed[0] ?? 0, | |
| 78 | recent: sum(0), | |
| 79 | best, | |
| 80 | ranShare: ran.slice(0, 24).filter(Boolean).length / 24, | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | /** Landings well under what the forge has sustained: under half the best, and under one an hour. */ | |
| 85 | export function isLow(rate: Rate): boolean { | |
| 86 | return rate.recent < Math.max(1, rate.best / 2); | |
| 87 | } | |
| 88 | ||
| 89 | export interface KeeperDay { | |
| 90 | readonly day: string; | |
| 91 | readonly hoursRunning: number; | |
| 92 | readonly landed: number; | |
| 93 | } | |
| 94 | ||
| 95 | /** The last `days` UTC days, oldest first: hours that ran and pulls landed, for the scorecard. */ | |
| 96 | export function keeperDays(db: Database, now: Date, days = 7): KeeperDay[] { | |
| 97 | const start = Math.floor(now.getTime() / DAY_MS) * DAY_MS - (days - 1) * DAY_MS; | |
| 98 | const hours = Math.ceil((now.getTime() - start) / HOUR_MS); | |
| 99 | const { landed, ran } = hourly(db, now.getTime(), hours); | |
| 100 | const out = new Map<string, { hoursRunning: number; landed: number }>(); | |
| 101 | for (let d = 0; d < days; d++) out.set(new Date(start + d * DAY_MS).toISOString().slice(0, 10), { hoursRunning: 0, landed: 0 }); | |
| 102 | for (let h = 0; h < hours; h++) { | |
| 103 | const slot = out.get(new Date(now.getTime() - (h + 1) * HOUR_MS + 1).toISOString().slice(0, 10)); | |
| 104 | if (!slot) continue; | |
| 105 | slot.landed += landed[h]!; | |
| 106 | if (ran[h]) slot.hoursRunning += 1; | |
| 107 | } | |
| 108 | return [...out].map(([day, v]) => ({ day, ...v })); | |
| 109 | } |
src/http/server.ts +2
| @@ -283,6 +283,7 @@ import { | ||
| 283 | 283 | asksMute, |
| 284 | 284 | asksPage, |
| 285 | 285 | } from "../web/studio/asks_page.ts"; |
| 286 | import { keeperAnswer } from "../web/studio/keeper_row.ts"; | |
| 286 | 287 | import { homeArchive, homeArchived, homeArchiveUndo } from "../web/studio/home_archive.ts"; |
| 287 | 288 | import { liveFeedFragment } from "../web/studio/feed.ts"; |
| 288 | 289 | import { serviceWorker } from "../web/sw.ts"; |
| @@ -751,6 +752,7 @@ export function buildRouter(): Router<Handler> { | ||
| 751 | 752 | router.add("GET", "/explore", explorePage); |
| 752 | 753 | router.add("POST", "/front", frontUpdate); |
| 753 | 754 | router.add("POST", "/home/archive", homeArchive); |
| 755 | router.add("POST", "/flow/keeper/answer", keeperAnswer); | |
| 754 | 756 | router.add("GET", "/home/archived/:op", homeArchived); |
| 755 | 757 | router.add("POST", "/home/archived/:op/undo", homeArchiveUndo); |
| 756 | 758 | router.add("GET", "/signin", signinPage); |
src/main.ts +5 −1
| @@ -51,7 +51,7 @@ import { failAbandonedRuns } from "./core/runs.ts"; | ||
| 51 | 51 | import { DRAIN_LINGER_MAX_S, drainLingerMs, startDraining } from "./http/health.ts"; |
| 52 | 52 | import { takePresence, warmBehind } from "./apps/presence.ts"; |
| 53 | 53 | import { STALE_AFTER_MS } from "./runs/lease.ts"; |
| 54 | import { startCrew } from "./crew/dispatch.ts"; | |
| 54 | import { startCrew, sweepAssigned } from "./crew/dispatch.ts"; | |
| 55 | 55 | import { CREW_OFF_IN_PREVIEW, crewOffInPreview } from "./crew/preview.ts"; |
| 56 | 56 | import { typeSafeDecider } from "./crew/decide.ts"; |
| 57 | 57 | import { graphInstagram } from "./crew/instagram.ts"; |
| @@ -59,6 +59,7 @@ import { peopleOnCrews } from "./core/agents.ts"; | ||
| 59 | 59 | import { startNotifier } from "./crew/notify.ts"; |
| 60 | 60 | import { expireCrewHolds } from "./actions/holds.ts"; |
| 61 | 61 | import { startClogTick } from "./flow/clog_tick.ts"; |
| 62 | import { startKeeper } from "./flow/keeper.ts"; | |
| 62 | 63 | import { sweepStaleAsks } from "./crew/stale_asks.ts"; |
| 63 | 64 | import { recordAskLedger } from "./web/studio/asks.ts"; |
| 64 | 65 | import { startPromptRoutine } from "./crew/prompts.ts"; |
| @@ -593,6 +594,8 @@ const processTick = (): void => { | ||
| 593 | 594 | }; |
| 594 | 595 | // The clog watch (#6524, #6757) reads the flow every minute; its state is the database's. |
| 595 | 596 | const stopClogTick = startClogTick(app.db, app.bus, (error) => app.log.warn("clog tick failed", { error: String(error) }), 60_000); |
| 597 | // The throughput keeper (#7645) names what binds landings each minute and lifts what is its to lift. | |
| 598 | const stopKeeper = startKeeper(app, () => sweepAssigned(app)); | |
| 596 | 599 | // An agent's hold that has stood 24 h is released each minute (#6918). |
| 597 | 600 | const holdTimer = setInterval(() => { |
| 598 | 601 | try { |
| @@ -750,6 +753,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> { | ||
| 750 | 753 | clearInterval(staleTimer); |
| 751 | 754 | clearInterval(followTimer); |
| 752 | 755 | stopClogTick(); |
| 756 | stopKeeper(); | |
| 753 | 757 | clearInterval(processTimer); |
| 754 | 758 | clearInterval(holdTimer); |
| 755 | 759 | clearInterval(modelReviewTimer); |
src/system/telemetry.ts +3
| @@ -11,6 +11,7 @@ import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../ | ||
| 11 | 11 | import { firedAlarms } from "../flow/alarms.ts"; |
| 12 | 12 | import { readVitals, vitalsFor, vitalsLine, vitalsMisses } from "../flow/vitals.ts"; |
| 13 | 13 | import { parkedSays, readParked } from "../flow/parked.ts"; |
| 14 | import { readKeeper } from "../flow/keeper.ts"; | |
| 14 | 15 | import { readPipeline, verdict } from "../flow/pipeline.ts"; |
| 15 | 16 | import { type QueueReading, readQueues } from "../flow/queue.ts"; |
| 16 | 17 | import { reviewersBusy, reviewingSays } from "../flow/reviewing.ts"; |
| @@ -39,6 +40,8 @@ export function flowFor(app: App, reader: Reader, now = new Date()) { | ||
| 39 | 40 | const parked = readParked(app.db, now, reader.kind === "agent"); |
| 40 | 41 | return { |
| 41 | 42 | says: verdict(stages, now.getTime()), |
| 43 | // The cause binding landings now and the keeper's last act (#7645). | |
| 44 | keeper: readKeeper(app.db, reader.kind === "owner"), | |
| 42 | 45 | // How long each main has gone unverified after landing (#7786); private repositories for the owner only. |
| 43 | 46 | verified: readVerified(app, reader.kind === "owner").map((row) => ({ ...row, says: verifiedSays(row, now.getTime()) })), |
| 44 | 47 | // Issues the crew parked in the last 24 h and 7 d, the newest three named (#6471). |
src/tui/forge.ts +5 −1
| @@ -56,6 +56,7 @@ import { capacityScreen, mayCapacity } from "./screens/capacity.ts"; | ||
| 56 | 56 | import { forgeCeiling } from "../flow/ceiling.ts"; |
| 57 | 57 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| 58 | 58 | import { readHeld } from "../flow/held.ts"; |
| 59 | import { answerKeeper, readKeeper } from "../flow/keeper.ts"; | |
| 59 | 60 | import { readVerified } from "../flow/verified.ts"; |
| 60 | 61 | import { memoryScreen } from "./screens/system.ts"; |
| 61 | 62 | import { noteRead, telemetryReader } from "../actions/telemetry.ts"; |
| @@ -293,6 +294,8 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 293 | 294 | hint: "the pipeline's bottleneck and throughput", |
| 294 | 295 | run: () => { |
| 295 | 296 | if (session.user) noteRead(session.app, session.user, flowReader, "flow"); |
| 297 | const keeper = readKeeper(session.db, flowReader.kind === "owner"); | |
| 298 | const user = session.user; | |
| 296 | 299 | ctx.push(flowScreen(ctx, flowLines( |
| 297 | 300 | session.db, |
| 298 | 301 | new Date(), |
| @@ -301,7 +304,8 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 301 | 304 | readHeld(session.app, flowReader.kind === "owner"), |
| 302 | 305 | forgeCeiling(session.app.config), |
| 303 | 306 | readVerified(session.app, flowReader.kind === "owner"), |
| 304 | ))); | |
| 307 | keeper, | |
| 308 | ), keeper.ask && user ? { ask: keeper.ask, answer: () => answerKeeper(session.app, keeper.ask!.id, user.id) } : undefined)); | |
| 305 | 309 | }, |
| 306 | 310 | }); |
| 307 | 311 | // The forge's capacity and guards, as MCP `capacity` gates them: a forge admin who is a person. |
src/tui/screens/flow.ts +29 −3
| @@ -24,6 +24,7 @@ import { pullCounts } from "../../crew/hot_files.ts"; | ||
| 24 | 24 | import { warmingLine } from "../../crew/hot_history.ts"; |
| 25 | 25 | import { ago } from "../../feed/rank.ts"; |
| 26 | 26 | import type { HeldRow } from "../../flow/held.ts"; |
| 27 | import { keeperLine, type KeeperAsk, type KeeperRead } from "../../flow/keeper.ts"; | |
| 27 | 28 | import { verifiedSays, type VerifiedRow } from "../../flow/verified.ts"; |
| 28 | 29 | import { conflictedSays, rebaseLaneSays } from "../../flow/conflicted.ts"; |
| 29 | 30 | import { IDLE_LIMIT, type IdlePull } from "../../flow/idle_pulls.ts"; |
| @@ -47,6 +48,7 @@ import { binding } from "../app/keys.ts"; | ||
| 47 | 48 | import { pad, truncate, wrap } from "../term/width.ts"; |
| 48 | 49 | import { dialog } from "../ui/widgets.ts"; |
| 49 | 50 | import { live } from "../../system/live.ts"; |
| 51 | import { confirmScreen } from "./common.ts"; | |
| 50 | 52 | import { duplicateLines } from "./system.ts"; |
| 51 | 53 | |
| 52 | 54 | const WIDTH = 64; |
| @@ -64,6 +66,8 @@ export function flowLines( | ||
| 64 | 66 | ceilingUsd: number | null = null, |
| 65 | 67 | /** How long each main has gone unverified after landing (`readVerified`, already cut to what the reader may see). */ |
| 66 | 68 | verified?: readonly VerifiedRow[], |
| 69 | /** The keeper's cause and last act (`readKeeper`, already cut to what the reader may see). */ | |
| 70 | keeper?: KeeperRead, | |
| 67 | 71 | ): string[] { |
| 68 | 72 | const stages = readPipeline(db, now); |
| 69 | 73 | const flow = readThroughput(db, stages.map((s) => s.key), now); |
| @@ -85,6 +89,8 @@ export function flowLines( | ||
| 85 | 89 | const ids = repoIds(db, queues.map((q) => q.repo)); |
| 86 | 90 | return [ |
| 87 | 91 | ...wrap(verdict(stages, now.getTime()), inner), |
| 92 | ...(keeper === undefined || keeperLine(keeper) === "" ? [] : ["", ...wrap(keeperLine(keeper), inner)]), | |
| 93 | ...(keeper?.ask == null ? [] : wrap(`${keeper.ask.question} Press l to ${keeper.ask.button.toLowerCase()}.`, inner)), | |
| 88 | 94 | "", |
| 89 | 95 | ...wrap(vitalsLine(vitalsFor(readVitals(db, now, agent !== undefined), agent === undefined)), inner), |
| 90 | 96 | ...(agent === undefined ? clogLines(readClog(db, now), now.getTime()).flatMap((l) => wrap(l, inner)) : []), |
| @@ -226,10 +232,15 @@ export function heldLines(rows: readonly HeldRow[], inner: number): string[] { | ||
| 226 | 232 | ]; |
| 227 | 233 | } |
| 228 | 234 | |
| 229 | export function flowScreen(ctx: Ctx, lines: readonly string[]): Screen { | |
| 230 | return { | |
| 235 | export function flowScreen( | |
| 236 | ctx: Ctx, | |
| 237 | lines: readonly string[], | |
| 238 | /** The keeper's open question (a site admin's); `answer` presses its one button and says what happened. */ | |
| 239 | keeper?: { ask: KeeperAsk; answer: () => { done: boolean; message: string } }, | |
| 240 | ): Screen { | |
| 241 | const self: Screen = { | |
| 231 | 242 | title: "Flow", |
| 232 | keys: [binding(["enter", "esc", "q"], "close")], | |
| 243 | keys: [binding(["enter", "esc", "q"], "close"), ...(keeper?.ask ? [binding(["l"], keeper.ask.button.toLowerCase())] : [])], | |
| 233 | 244 | render(area, s) { |
| 234 | 245 | const buttons = dialog(s, area, ctx.theme.c, { |
| 235 | 246 | title: "Flow", |
| @@ -242,7 +253,22 @@ export function flowScreen(ctx: Ctx, lines: readonly string[]): Screen { | ||
| 242 | 253 | }, |
| 243 | 254 | key(e) { |
| 244 | 255 | if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); |
| 256 | else if (e.key === "l" && keeper) | |
| 257 | ctx.push( | |
| 258 | confirmScreen(ctx, self, { | |
| 259 | title: keeper.ask.button, | |
| 260 | body: wrap(keeper.ask.question, WIDTH - 6), | |
| 261 | actions: [{ label: "Cancel", variant: "ghost" }, { label: keeper.ask.button }], | |
| 262 | onChoose: (i) => { | |
| 263 | if (i !== 1) return; | |
| 264 | const answered = keeper.answer(); | |
| 265 | ctx.toast(answered.message, answered.done ? "success" : "danger"); | |
| 266 | ctx.pop(); | |
| 267 | }, | |
| 268 | }), | |
| 269 | ); | |
| 245 | 270 | return true; |
| 246 | 271 | }, |
| 247 | 272 | }; |
| 273 | return self; | |
| 248 | 274 | } |
src/tui/screens/scorecard.ts +10 −1
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | type Scorecard, |
| 22 | 22 | type SplitScorecard, |
| 23 | 23 | } from "../../core/scorecard.ts"; |
| 24 | import { keeperDays, type KeeperDay } from "../../flow/keeper_rate.ts"; | |
| 24 | 25 | import type { Agent } from "../../core/agents.ts"; |
| 25 | 26 | import { listTeams, type Team } from "../../core/teams.ts"; |
| 26 | 27 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -136,6 +137,14 @@ export function scorecardRows( | ||
| 136 | 137 | return [...agentRows, ...teamRows]; |
| 137 | 138 | } |
| 138 | 139 | |
| 140 | /** "Forge per day" as the page's table has it: hours the forge ran and pulls it landed, newest day first (#7645). */ | |
| 141 | export function forgeDayRows(days: readonly KeeperDay[]): ListRow[] { | |
| 142 | return [...days].reverse().map((d) => ({ | |
| 143 | title: `Forge ${d.day}`, | |
| 144 | secondary: `${d.hoursRunning} hour${d.hoursRunning === 1 ? "" : "s"} running · ${d.landed} landed`, | |
| 145 | })); | |
| 146 | } | |
| 147 | ||
| 139 | 148 | type Review = ReturnType<typeof readReview>; |
| 140 | 149 | |
| 141 | 150 | /** |
| @@ -195,7 +204,7 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen { | ||
| 195 | 204 | const names = new Map((listed?.ok === true ? listed.value : []).map((a) => [a.user.id, a.user.username])); |
| 196 | 205 | // Re-read after a decision, so the button's result shows at once. |
| 197 | 206 | let review = allowed && cards.length > 0 ? reviewRows(readReview(f.app), names) : { rows: [], changeAt: [] }; |
| 198 | const rows = (): ListRow[] => [...cards, ...review.rows]; | |
| 207 | const rows = (): ListRow[] => [...cards, ...review.rows, ...(allowed ? forgeDayRows(keeperDays(f.db, new Date())) : [])]; | |
| 199 | 208 | const waiting = (): string | undefined => review.changeAt[state.index - cards.length]; |
| 200 | 209 | const decide = (take: boolean): void => { |
| 201 | 210 | const id = waiting(); |
src/web/pages/crew.ts +31 −1
| @@ -35,6 +35,7 @@ import { | ||
| 35 | 35 | type Scorecard, |
| 36 | 36 | type SplitScorecard, |
| 37 | 37 | } from "../../core/scorecard.ts"; |
| 38 | import { keeperDays, type KeeperDay } from "../../flow/keeper_rate.ts"; | |
| 38 | 39 | import { decideChange, readReview } from "../../actions/model_review.ts"; |
| 39 | 40 | import { wastedDays, type WastedDay } from "../../core/wasted_days.ts"; |
| 40 | 41 | import { modelDays, type ModelDay } from "../../core/model_days.ts"; |
| @@ -440,6 +441,7 @@ function loadScorecards(ctx: Ctx) { | ||
| 440 | 441 | byModel: agentSplits(ctx.app.db, "model"), |
| 441 | 442 | byPrompt: agentSplits(ctx.app.db, "prompt_version"), |
| 442 | 443 | byModelDay: modelDays(ctx.app.db), |
| 444 | keeperDays: keeperDays(ctx.app.db, new Date()), | |
| 443 | 445 | wasted: wastedDays(ctx.app.db), |
| 444 | 446 | }; |
| 445 | 447 | } |
| @@ -581,7 +583,7 @@ function crewView( | ||
| 581 | 583 | splitScorecards, |
| 582 | 584 | recentSplitScorecards, |
| 583 | 585 | } = data.scorecards; |
| 584 | const { scorecards, byModel, byPrompt, byModelDay, wasted } = data.scorecards; | |
| 586 | const { scorecards, byModel, byPrompt, byModelDay, wasted, keeperDays: forgeDays } = data.scorecards; | |
| 585 | 587 | const { spending } = data; |
| 586 | 588 | const usernameError = |
| 587 | 589 | error !== undefined && /username/i.test(error) ? error : undefined; |
| @@ -694,6 +696,7 @@ function crewView( | ||
| 694 | 696 | () => html`<span class="muted">default</span>`, |
| 695 | 697 | )} |
| 696 | 698 | ${modelDaysCard(byModelDay)} |
| 699 | ${keeperDaysCard(forgeDays)} | |
| 697 | 700 | ${wastedDaysCard(wasted)} |
| 698 | 701 | ${splitsCard( |
| 699 | 702 | "Per prompt version", |
| @@ -783,6 +786,33 @@ function drillCard(drill: CrewHandover["drill"]): Html { | ||
| 783 | 786 | ); |
| 784 | 787 | } |
| 785 | 788 | |
| 789 | /** Hours the forge ran and pulls it landed, per day of the last week (#7645). */ | |
| 790 | function keeperDaysCard(rows: readonly KeeperDay[]): Html { | |
| 791 | return card( | |
| 792 | html`<div class="table-scroll"> | |
| 793 | <table class="table" id="crew-keeper-days"> | |
| 794 | <thead> | |
| 795 | <tr> | |
| 796 | <th>Day</th> | |
| 797 | <th>Hours running</th> | |
| 798 | <th>Landed</th> | |
| 799 | </tr> | |
| 800 | </thead> | |
| 801 | <tbody> | |
| 802 | ${rows.map( | |
| 803 | (r) => html`<tr> | |
| 804 | <td class="mono">${r.day}</td> | |
| 805 | <td class="num">${r.hoursRunning}</td> | |
| 806 | <td class="num">${r.landed}</td> | |
| 807 | </tr>`, | |
| 808 | )} | |
| 809 | </tbody> | |
| 810 | </table> | |
| 811 | </div>`, | |
| 812 | { pad: false, head: html`<h2>Forge per day</h2>` }, | |
| 813 | ); | |
| 814 | } | |
| 815 | ||
| 786 | 816 | /** Spend and landed pulls per model per day: the escalation, measured. */ |
| 787 | 817 | function modelDaysCard(rows: readonly ModelDay[]): Html { |
| 788 | 818 | if (rows.length === 0) return html``; |
src/web/pages/flow-panel.ts +2 −1
| @@ -46,6 +46,7 @@ import { readHeld } from "../../flow/held.ts"; | ||
| 46 | 46 | import { readVerified, verifiedSays, type VerifiedRow } from "../../flow/verified.ts"; |
| 47 | 47 | import type { App } from "../../app.ts"; |
| 48 | 48 | import { html, type Html } from "../html.ts"; |
| 49 | import { keeperRow } from "../studio/keeper_row.ts"; | |
| 49 | 50 | import { gatesCard } from "./boards-gates.ts"; |
| 50 | 51 | import { heldCard } from "./boards-held.ts"; |
| 51 | 52 | import { badge, card, meter } from "../ui.ts"; |
| @@ -113,7 +114,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 113 | 114 | const newest = parked.newest[0]; |
| 114 | 115 | const listed = asked(url); |
| 115 | 116 | const shown = listed === null ? undefined : out.get(listed.key); |
| 116 | return html`${card( | |
| 117 | return html`${keeperRow(app.db, reader.kind === "owner")}${card( | |
| 117 | 118 | html`<p id="bottleneck" data-stage="${worst?.key ?? ""}"> |
| 118 | 119 | <strong>${verdict(stages, now)}</strong> |
| 119 | 120 | </p> |
src/web/studio/home.ts +2
| @@ -53,6 +53,7 @@ import { needsYouSection } from "./asks_page.ts"; | ||
| 53 | 53 | import { flowCards } from "./flow_card.ts"; |
| 54 | 54 | import { composer, feedJs, feedNote, feedRegion } from "./feed.ts"; |
| 55 | 55 | import { alarmLines, cachedAlarms } from "./alarm_lines.ts"; |
| 56 | import { keeperRow } from "./keeper_row.ts"; | |
| 56 | 57 | import { clogRow, kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts"; |
| 57 | 58 | import { feedCandidateCache } from "../../feed/feed.ts"; |
| 58 | 59 | import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| @@ -295,6 +296,7 @@ export async function studioHome( | ||
| 295 | 296 | : liveLine(ctx)} |
| 296 | 297 | ${figures ? timePhase(ctx.request, "vitals", () => vitalsRow(ctx.app.db)) : null} |
| 297 | 298 | ${figures ? clogRow(ctx.app.db) : null} |
| 299 | ${figures ? keeperRow(ctx.app.db, true) : null} | |
| 298 | 300 | ${figures ? alarmLines(cachedAlarms(ctx.app.db, new Date(), ceilingUsd)) : null} |
| 299 | 301 | ${timePhase(ctx.request, "waiting", () => waitingLine(ctx.app.db, user), () => waitingCache(ctx.app.db))} |
| 300 | 302 | ${figures |
src/web/studio/keeper_row.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * The throughput keeper on a page (#7645): one muted line, the cause binding | |
| 3 | * landings and the last thing it did, and, when it asks, the question with | |
| 4 | * its one button. Home and the pipeline panel both draw it; `POST | |
| 5 | * /flow/keeper/answer` is the button (a site admin's, same-origin). | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { answerKeeper, keeperLine, readKeeper } from "../../flow/keeper.ts"; | |
| 10 | import type { Ctx } from "../../http/context.ts"; | |
| 11 | import { signInFirst } from "../chrome.ts"; | |
| 12 | import { readForm } from "../form.ts"; | |
| 13 | import { html, type Html } from "../html.ts"; | |
| 14 | import { redirect } from "../layout.ts"; | |
| 15 | import { btn } from "../ui.ts"; | |
| 16 | ||
| 17 | export function keeperRow(db: Database, full: boolean): Html { | |
| 18 | const read = readKeeper(db, full); | |
| 19 | const line = keeperLine(read); | |
| 20 | if (line === "" && read.ask === null) return html``; | |
| 21 | return html`${line === "" ? null : html`<p class="small muted break" id="keeper">${line}</p>`} | |
| 22 | ${read.ask === null | |
| 23 | ? null | |
| 24 | : html`<form method="post" action="/flow/keeper/answer" id="keeper-ask" class="bar small"> | |
| 25 | <span>${read.ask.question}</span> | |
| 26 | <input type="hidden" name="id" value="${String(read.ask.id)}" /> | |
| 27 | ${btn(read.ask.button, { type: "submit" })} | |
| 28 | </form>`}`; | |
| 29 | } | |
| 30 | ||
| 31 | // POST /flow/keeper/answer — the one button on the keeper's question. | |
| 32 | export async function keeperAnswer(ctx: Ctx): Promise<Response> { | |
| 33 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 34 | const form = await readForm(ctx, "admin"); | |
| 35 | if (form instanceof Response) return form; | |
| 36 | if (!ctx.auth.user.isAdmin) return new Response("forbidden", { status: 403 }); | |
| 37 | const raw = form.get("id"); | |
| 38 | const id = typeof raw === "string" && /^\d{1,12}$/.test(raw) ? Number(raw) : 0; | |
| 39 | const answered = id > 0 ? answerKeeper(ctx.app, id, ctx.auth.user.id) : { done: false, message: "That question is already answered." }; | |
| 40 | return redirect("/", { | |
| 41 | url: ctx.url, | |
| 42 | forgeUrl: ctx.app.config.url, | |
| 43 | flash: { tone: answered.done ? "success" : "danger", message: answered.message }, | |
| 44 | }); | |
| 45 | } |
test/flow-keeper.test.ts added +333
| @@ -0,0 +1,333 @@ | ||
| 1 | /** The throughput keeper (#7645): it names what binds landings, unsticks what is its to unstick, checks, undoes, and files once. */ | |
| 2 | import { afterAll, beforeAll, 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 { createApp, type App } from "../src/app.ts"; | |
| 7 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 8 | import { openIssue } from "../src/actions/issues.ts"; | |
| 9 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 10 | import { readScorecards } from "../src/actions/scorecard.ts"; | |
| 11 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { noteLimit } from "../src/core/standing.ts"; | |
| 14 | import { addSubscription, EVERYONE, setGrants } from "../src/core/subscriptions.ts"; | |
| 15 | import type { User } from "../src/core/users.ts"; | |
| 16 | import { isLow, keeperDays, readRate, type Rate } from "../src/flow/keeper_rate.ts"; | |
| 17 | import { createUser } from "../src/core/users.ts"; | |
| 18 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 19 | import { flowLines, flowScreen } from "../src/tui/screens/flow.ts"; | |
| 20 | import { scorecardScreen } from "../src/tui/screens/scorecard.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { settleApp } from "./helpers/tui.ts"; | |
| 23 | import { answerKeeper, EFFECT_MS, listAttempts, readKeeper, tickKeeper } from "../src/flow/keeper.ts"; | |
| 24 | import { pickCause, type KeeperFacts, type RepoFacts } from "../src/flow/keeper_cause.ts"; | |
| 25 | import { flowFor } from "../src/system/telemetry.ts"; | |
| 26 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 27 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 28 | import { quietLogs } from "./helpers/globals.ts"; | |
| 29 | ||
| 30 | quietLogs(); | |
| 31 | ||
| 32 | let dir: string; | |
| 33 | let app: App; | |
| 34 | let servers: Servers; | |
| 35 | let admin: User; | |
| 36 | let repo: Repo; | |
| 37 | let was: string | undefined; | |
| 38 | const fresh = (): Repo => getRepoById(app.db, repo.id)!; | |
| 39 | const cookie = (): Record<string, string> => ({ cookie: `forge_session=${createSession(app.db, admin.id).token}; forge_mode=studio` }); | |
| 40 | const filed = (): number => listAttempts(app.db, 50).filter((a) => a.outcome === "filed").length; | |
| 41 | ||
| 42 | beforeAll(async () => { | |
| 43 | was = process.env.FORGE_SENTINEL_REPO; | |
| 44 | process.env.FORGE_SENTINEL_REPO = "alice/engine"; | |
| 45 | dir = await mkdtemp(join(tmpdir(), "forge-keeper-")); | |
| 46 | app = createApp({ dataDir: dir, port: 0 }); | |
| 47 | servers = await serve(app); | |
| 48 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 49 | if (!a.ok) throw new Error("signup failed"); | |
| 50 | admin = a.value; | |
| 51 | const made = await createRepo(app, admin, { name: "engine", visibility: "private" }); | |
| 52 | if (!made.ok) throw new Error(String(made.error)); | |
| 53 | repo = made.value; | |
| 54 | const seeded = await commitToBranch(app, admin, repo, { message: "Start", files: [{ path: "README.md", content: "# engine\n" }] }); | |
| 55 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 56 | const agent = await createAgent(app, admin, { username: "mason" }); | |
| 57 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 58 | expect(addToCrew(app, admin, repo, "mason").ok).toBe(true); | |
| 59 | const on = await updateRepoSettings(app, admin, repo, { | |
| 60 | autonomy: "autonomous", | |
| 61 | autonomyDirection: "Build.", | |
| 62 | autonomyCeilingUsd: 10, | |
| 63 | autonomySlots: 2, | |
| 64 | autonomyBy: admin.id, | |
| 65 | }); | |
| 66 | expect(on.ok).toBe(true); | |
| 67 | }, 60_000); | |
| 68 | ||
| 69 | afterAll(async () => { | |
| 70 | if (was === undefined) delete process.env.FORGE_SENTINEL_REPO; | |
| 71 | else process.env.FORGE_SENTINEL_REPO = was; | |
| 72 | servers.stop(); | |
| 73 | app.close(); | |
| 74 | await rm(dir, { recursive: true, force: true }); | |
| 75 | }); | |
| 76 | ||
| 77 | test("2026-11-01: ready issues wait and no builder runs: it names the cause and unsticks", async () => { | |
| 78 | for (let n = 1; n <= 55; n++) { | |
| 79 | const o = openIssue(app, admin, fresh(), { title: `Work ${n}` }); | |
| 80 | if (!o.ok) throw new Error(String(o.error)); | |
| 81 | } | |
| 82 | let swept = 0; | |
| 83 | const now = new Date(); | |
| 84 | const tick = tickKeeper(app, now, () => void (swept += 1)); | |
| 85 | expect(tick).toEqual({ cause: "unassigned", acted: "unstick" }); | |
| 86 | expect(swept).toBe(1); | |
| 87 | const read = readKeeper(app.db, true); | |
| 88 | expect(read.cause).toContain("55 ready issues in alice/engine and no builder is running"); | |
| 89 | expect(read.last).toContain("unstuck assigned issues"); | |
| 90 | // Not again while its check is pending. | |
| 91 | expect(tickKeeper(app, new Date(now.getTime() + 60_000), () => void (swept += 1)).acted).toBeNull(); | |
| 92 | expect(swept).toBe(1); | |
| 93 | }); | |
| 94 | ||
| 95 | test("the cause and last act show on home, the boards panel, and the MCP flow report", async () => { | |
| 96 | const home = await (await fetch(`${servers.url}/`, { headers: cookie() })).text(); | |
| 97 | expect(home).toContain('id="keeper"'); | |
| 98 | expect(home).toContain("Binding landings:"); | |
| 99 | const boards = await (await fetch(`${servers.url}/boards`, { headers: cookie() })).text(); | |
| 100 | expect(boards).toContain("unstuck assigned issues"); | |
| 101 | const report = flowFor(app, { kind: "owner" }); | |
| 102 | expect(report.keeper.cause).toContain("no builder is running"); | |
| 103 | expect(report.keeper.last).toContain("unstuck assigned issues"); | |
| 104 | }); | |
| 105 | ||
| 106 | test("no rise in landings after the check time: the act is undone and the next tick does not try it again", () => { | |
| 107 | const later = new Date(Date.now() + EFFECT_MS + 60_000); | |
| 108 | tickKeeper(app, later); | |
| 109 | expect(listAttempts(app.db).map((a) => a.outcome)).toEqual(["undone"]); | |
| 110 | expect(readKeeper(app.db, true).ask).toBeNull(); | |
| 111 | expect(tickKeeper(app, new Date(later.getTime() + 60_000)).acted).toBeNull(); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a rise in landings keeps the act", async () => { | |
| 115 | app.db.query("DELETE FROM keeper_attempt").run(); | |
| 116 | const t = new Date(Date.now() + 10 * EFFECT_MS); | |
| 117 | expect(tickKeeper(app, t, () => {}).acted).toBe("unstick"); | |
| 118 | const issue = openIssue(app, admin, fresh(), { title: "Landed" }); | |
| 119 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 120 | app.db | |
| 121 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, 'b/1', '', '', ?3, ?4)`) | |
| 122 | .run(issue.value.id, fresh().defaultBranch, repo.id, new Date(t.getTime() + 30 * 60_000).toISOString()); | |
| 123 | tickKeeper(app, new Date(t.getTime() + EFFECT_MS + 1000)); | |
| 124 | expect(listAttempts(app.db).some((a) => a.action === "unstick" && a.outcome === "worked")).toBe(true); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a cause it cannot act on is filed once, not once per tick", () => { | |
| 128 | const before = filed(); | |
| 129 | // Nothing is ready any more: every open issue is closed. | |
| 130 | app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1").run(repo.id); | |
| 131 | const t = Date.now() + 100 * EFFECT_MS; | |
| 132 | for (let i = 0; i < 4; i++) tickKeeper(app, new Date(t + i * 60_000)); | |
| 133 | expect(filed()).toBe(before + 1); | |
| 134 | const issues = app.db.query<{ title: string }, []>("SELECT title FROM issue WHERE title LIKE 'Throughput:%'").all(); | |
| 135 | expect(issues.length).toBe(1); | |
| 136 | expect(issues[0]!.title).toContain("Nothing is ready"); | |
| 137 | }); | |
| 138 | ||
| 139 | test("usage is the limit: it says when it resets, and spends nothing to say it", () => { | |
| 140 | const sub = addSubscription(app.db, dir, { name: "espo", token: "sk-ant-oat01-" + "x".repeat(24) }); | |
| 141 | if (!sub.ok) throw new Error(sub.error); | |
| 142 | setGrants(app.db, repo.id, EVERYONE, [sub.value.id]); | |
| 143 | const now = Date.now() + 200 * EFFECT_MS; | |
| 144 | const resets = new Date(now + 3_600_000); | |
| 145 | noteLimit(app.db, sub.value.id, { window: "5h", resetsAt: resets.toISOString(), observedAt: new Date(now).toISOString(), said: "limit" }); | |
| 146 | app.db.query("UPDATE issue SET state = 'open' WHERE repo_id = ?1 AND title = 'Work 1'").run(repo.id); | |
| 147 | const tick = tickKeeper(app, new Date(now)); | |
| 148 | expect(tick).toEqual({ cause: "usage", acted: null }); | |
| 149 | expect(readKeeper(app.db, true).cause).toBe(`Usage is the limit: no subscription has headroom, resets at ${resets.toISOString().slice(11, 16)} UTC.`); | |
| 150 | }); | |
| 151 | ||
| 152 | test("the closed list: the ceiling outranks usage, and a tried cause gives way to the next", () => { | |
| 153 | const repoFacts: RepoFacts = { | |
| 154 | repoId: "r", slug: "a/b", startable: 2, running: 0, slots: 1, | |
| 155 | ceilingUsd: 10, atCeiling: true, noHeadroom: true, resetsAt: null, | |
| 156 | }; | |
| 157 | const facts: KeeperFacts = { repos: [repoFacts], red: 0, stale: 0 }; | |
| 158 | expect(pickCause(facts)?.cause).toBe("ceiling"); | |
| 159 | expect(pickCause(facts, new Set(["ceiling"]))?.cause).toBe("usage"); | |
| 160 | const open = repoFacts; | |
| 161 | expect(pickCause({ repos: [open], red: 0, stale: 0 })?.cause).toBe("ceiling"); | |
| 162 | expect(pickCause({ repos: [open], red: 0, stale: 0 }, new Set(["ceiling"]))?.cause).toBe("usage"); | |
| 163 | expect(pickCause({ repos: [{ ...repoFacts, atCeiling: false, noHeadroom: false }], red: 0, stale: 0 })?.cause).toBe("unassigned"); | |
| 164 | expect(pickCause({ repos: [], red: 6, stale: 0 })).toBeNull(); | |
| 165 | }); | |
| 166 | ||
| 167 | test("the scorecard carries hours running and landed per day for a week", () => { | |
| 168 | const card = readScorecards(app, admin); | |
| 169 | expect(card?.keeperDays.length).toBe(7); | |
| 170 | const before = card!.keeperDays.reduce((n, d) => n + d.landed, 0); | |
| 171 | const issue = openIssue(app, admin, fresh(), { title: "Landed a minute ago" }); | |
| 172 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 173 | app.db | |
| 174 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, 'b/recent', '', '', ?3, ?4)`) | |
| 175 | .run(issue.value.id, fresh().defaultBranch, repo.id, new Date(Date.now() - 5 * 60_000).toISOString()); | |
| 176 | const after = readScorecards(app, admin)!.keeperDays; | |
| 177 | expect(after.reduce((n, d) => n + d.landed, 0)).toBe(before + 1); | |
| 178 | expect(card!.keeperDays.every((d) => /^\d{4}-\d\d-\d\d$/.test(d.day))).toBe(true); | |
| 179 | }); | |
| 180 | ||
| 181 | const post = (headers: Record<string, string>, body: string) => | |
| 182 | fetch(`${servers.url}/flow/keeper/answer`, { | |
| 183 | method: "POST", | |
| 184 | headers: { origin: servers.url, "content-type": "application/x-www-form-urlencoded", ...headers }, | |
| 185 | body, | |
| 186 | redirect: "manual", | |
| 187 | }); | |
| 188 | const ask = (): number => { | |
| 189 | app.db.query("DELETE FROM keeper_attempt").run(); | |
| 190 | return Number( | |
| 191 | app.db | |
| 192 | .query("INSERT INTO keeper_attempt (at, cause, action, repo_id, says, outcome, check_at) VALUES (?1, 'unassigned', 'unstick', ?2, 'Unsticking did not help. Leave it?', 'asked', 0)") | |
| 193 | .run(Date.now(), repo.id).lastInsertRowid, | |
| 194 | ); | |
| 195 | }; | |
| 196 | ||
| 197 | test("a guest, a non-admin and an agent never read a private repository's name from the keeper", async () => { | |
| 198 | ask(); | |
| 199 | app.db.query("DELETE FROM keeper_state").run(); | |
| 200 | app.db | |
| 201 | .query("INSERT INTO keeper_state (id, cause, says, at) VALUES (1, 'unassigned', '55 ready issues in alice/engine and no builder is running.', ?1)") | |
| 202 | .run(Date.now()); | |
| 203 | const guest = await (await fetch(`${servers.url}/boards`, { redirect: "follow" })).text(); | |
| 204 | expect(guest).not.toContain("alice/engine"); | |
| 205 | const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-passphrase" }); | |
| 206 | if (!bob.ok) throw new Error("bob"); | |
| 207 | const bobCookie = { cookie: `forge_session=${createSession(app.db, bob.value.id).token}` }; | |
| 208 | for (const path of ["/boards", "/"]) { | |
| 209 | const page = await (await fetch(`${servers.url}${path}`, { headers: bobCookie })).text(); | |
| 210 | expect(page).not.toContain("alice/engine"); | |
| 211 | expect(page).not.toContain('id="keeper-ask"'); | |
| 212 | } | |
| 213 | const agentRead = flowFor(app, { kind: "agent" }).keeper; | |
| 214 | expect(agentRead.cause).not.toBeNull(); | |
| 215 | expect(JSON.stringify(agentRead)).not.toContain("alice/engine"); | |
| 216 | expect(agentRead.ask).toBeNull(); | |
| 217 | const owner = flowFor(app, { kind: "owner" }).keeper; | |
| 218 | expect(owner.cause).toContain("alice/engine"); | |
| 219 | expect(owner.ask).not.toBeNull(); | |
| 220 | }); | |
| 221 | ||
| 222 | test("POST /flow/keeper/answer: a guest signs in first, a non-admin is refused, a bad or stale id says it is gone", async () => { | |
| 223 | const id = ask(); | |
| 224 | const guest = await post({}, `id=${id}`); | |
| 225 | expect(guest.status).toBe(303); | |
| 226 | expect(guest.headers.get("location")).toContain("sign"); | |
| 227 | const bobCookie = { | |
| 228 | cookie: `forge_session=${createSession(app.db, app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'bob'").get()!.id).token}`, | |
| 229 | }; | |
| 230 | expect((await post(bobCookie, `id=${id}`)).status).toBe(403); | |
| 231 | for (const body of ["id=0", "id=abc", "id=", "", `id=${id + 1000}`]) { | |
| 232 | const gone = await post(cookie(), body); | |
| 233 | expect(gone.status).toBe(303); | |
| 234 | expect(gone.headers.get("set-cookie") ?? "").toContain("already"); | |
| 235 | } | |
| 236 | expect(answerKeeper(app, id + 1000)).toEqual({ done: false, message: "That question is already answered." }); | |
| 237 | }); | |
| 238 | ||
| 239 | test("a question for any action is answered with Done", () => { | |
| 240 | const id = Number( | |
| 241 | app.db | |
| 242 | .query("INSERT INTO keeper_attempt (at, cause, action, repo_id, says, outcome, check_at) VALUES (?1, 'red_checks', 'file', ?2, 'Other', 'asked', 0)") | |
| 243 | .run(Date.now(), repo.id).lastInsertRowid, | |
| 244 | ); | |
| 245 | expect(answerKeeper(app, id).message).toBe("Done."); | |
| 246 | }); | |
| 247 | ||
| 248 | test("the owner's answer is on the audit trail with the owner as actor", () => { | |
| 249 | app.db.query("DELETE FROM event WHERE kind = 'keeper.answered'").run(); | |
| 250 | const id = ask(); | |
| 251 | expect(answerKeeper(app, id, admin.id).done).toBe(true); | |
| 252 | const row = app.db.query<{ actor_id: string | null }, []>("SELECT actor_id FROM event WHERE kind = 'keeper.answered'").get(); | |
| 253 | expect(row?.actor_id).toBe(admin.id); | |
| 254 | }); | |
| 255 | ||
| 256 | test("unstick: the sweep runs, the row is pending, and it is undone when landings do not rise", async () => { | |
| 257 | app.db.query("DELETE FROM keeper_attempt").run(); | |
| 258 | app.db.query("UPDATE issue SET state = 'open' WHERE repo_id = ?1 AND title LIKE 'Work %'").run(repo.id); | |
| 259 | let swept = 0; | |
| 260 | const t = new Date(Date.now() + 400 * EFFECT_MS); | |
| 261 | expect(tickKeeper(app, t, () => void (swept += 1))).toEqual({ cause: "unassigned", acted: "unstick" }); | |
| 262 | expect(swept).toBe(1); | |
| 263 | const [row] = listAttempts(app.db); | |
| 264 | expect(row).toMatchObject({ action: "unstick", outcome: "pending", cause: "unassigned" }); | |
| 265 | tickKeeper(app, new Date(t.getTime() + EFFECT_MS + 1000), () => void (swept += 1)); | |
| 266 | expect(listAttempts(app.db).find((a) => a.action === "unstick")?.outcome).toBe("undone"); | |
| 267 | }); | |
| 268 | ||
| 269 | test("isLow, readRate and keeperDays bucket pulls and runs by hour and UTC day", () => { | |
| 270 | const T = Date.UTC(2040, 0, 10, 12, 30); | |
| 271 | let n = 0; | |
| 272 | const land = (iso: string): void => { | |
| 273 | const issue = openIssue(app, admin, fresh(), { title: `Seed ${(n += 1)}` }); | |
| 274 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 275 | app.db | |
| 276 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, ?3, '', '', ?4, ?5)`) | |
| 277 | .run(issue.value.id, fresh().defaultBranch, `seed/${n}`, repo.id, iso); | |
| 278 | }; | |
| 279 | for (let i = 0; i < 3; i++) land("2040-01-10T11:45:00.000Z"); | |
| 280 | land("2040-01-09T23:50:00.000Z"); | |
| 281 | for (let i = 0; i < 12; i++) land("2040-01-08T10:30:00.000Z"); | |
| 282 | app.db | |
| 283 | .query("INSERT INTO run (id, repo_id, kind, state, started_at) VALUES ('keeper-seed-run', ?1, 'agent', 'ok', '2040-01-10T09:10:00.000Z')") | |
| 284 | .run(repo.id); | |
| 285 | const rate = readRate(app.db, new Date(T)); | |
| 286 | expect(rate.lastHour).toBe(3); | |
| 287 | expect(rate.recent).toBe(1); | |
| 288 | expect(rate.best).toBe(4); | |
| 289 | expect(rate.ranShare).toBeCloseTo(3 / 24); | |
| 290 | expect(isLow(rate)).toBe(true); | |
| 291 | const rated = (recent: number, best: number): Rate => ({ lastHour: 0, recent, best, ranShare: 0 }); | |
| 292 | expect(isLow(rated(0.5, 0))).toBe(true); | |
| 293 | expect(isLow(rated(1, 1.5))).toBe(false); | |
| 294 | expect(isLow(rated(1.2, 5))).toBe(true); | |
| 295 | expect(isLow(rated(2.5, 5))).toBe(false); | |
| 296 | const days = keeperDays(app.db, new Date(T)); | |
| 297 | expect(days.map((d) => d.day)).toEqual(["2040-01-04", "2040-01-05", "2040-01-06", "2040-01-07", "2040-01-08", "2040-01-09", "2040-01-10"]); | |
| 298 | const byDay = Object.fromEntries(days.map((d) => [d.day, d])); | |
| 299 | expect(byDay["2040-01-10"]).toEqual({ day: "2040-01-10", hoursRunning: 2, landed: 3 }); | |
| 300 | expect(byDay["2040-01-09"]).toEqual({ day: "2040-01-09", hoursRunning: 1, landed: 1 }); | |
| 301 | expect(byDay["2040-01-08"]).toEqual({ day: "2040-01-08", hoursRunning: 1, landed: 12 }); | |
| 302 | expect(byDay["2040-01-07"]).toEqual({ day: "2040-01-07", hoursRunning: 0, landed: 0 }); | |
| 303 | }); | |
| 304 | ||
| 305 | test("the terminal shows the keeper line and its one button on Flow, and Forge per day on the scorecard", async () => { | |
| 306 | const id = ask(); | |
| 307 | const keeper = readKeeper(app.db, true); | |
| 308 | expect(keeper.ask?.id).toBe(id); | |
| 309 | const { app: tui, session } = createForgeApp( | |
| 310 | app, | |
| 311 | { sshUsername: "alice", remoteAddress: "127.0.0.1", user: admin }, | |
| 312 | { terminal: headlessTerminal(120, 40), exitHooks: false }, | |
| 313 | ); | |
| 314 | tui.start(); | |
| 315 | await settleApp(tui); | |
| 316 | const key = async (k: string): Promise<string> => { | |
| 317 | tui.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 318 | await settleApp(tui); | |
| 319 | return tui.screen.toText(); | |
| 320 | }; | |
| 321 | tui.push(flowScreen(tui.ctx, flowLines(app.db, new Date(), undefined, undefined, undefined, null, undefined, keeper), { ask: keeper.ask!, answer: () => answerKeeper(app, id, admin.id) })); | |
| 322 | await settleApp(tui); | |
| 323 | expect(tui.screen.toText()).toContain("Leave it?"); | |
| 324 | expect(tui.screen.toText()).toContain("Press l"); | |
| 325 | await key("l"); | |
| 326 | await key("right"); | |
| 327 | await key("enter"); | |
| 328 | expect(readKeeper(app.db, true).ask).toBeNull(); | |
| 329 | tui.push(scorecardScreen(session, tui.ctx)); | |
| 330 | await settleApp(tui); | |
| 331 | expect(tui.screen.toText()).toContain("Forge 20"); | |
| 332 | expect(tui.screen.toText()).toContain("running"); | |
| 333 | }); |
new above
offline