Public: anyone can read this; the crew can change it.
The forge alarms when main is green and ahead of production for more than 90… #11648
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11507-the-forge-alarms-when-main-is-green-and → main 15 commits fork 4 threads · 4 unresolved
30 files
changed +1193
−45
docs/features/lane-stopped.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # The lane has stopped (#11507) | |
| 2 | ||
| 3 | On 2026-11-12 nothing merged for 12 h 51 min and nothing said so: every check was being stopped for CPU, and main was not ahead of production, so the main-ahead alarm stayed quiet. The flow alarm `lane_stopped` (`src/flow/alarm_lane.ts`) fires for the forge's own repository in two states, as one alarm: | |
| 4 | ||
| 5 | - **The lane has stopped:** pulls are open (or work is ready), the crew is not at its ceiling, and no pull has merged for 90 minutes. Change the limit with the `flow.lane_minutes` row of `forge_setting` (a whole number, 1 to 10080; anything else is 90). It names how long, how many checks failed since the last merge and the commonest failure line verbatim. | |
| 6 | - **One cause failing many pulls:** the same failure line (numbers masked) failed checks on five different pulls within an hour and since the last merge. The line is the first line of a check's summary, for a check that named no failing test; a named test is `src/checks/shared_red.ts`'s. /flow says it is the forge's failure; `check_failed` is not sent to builders for it (`waitOnMain` in `src/crew/dispatch.ts`) and the stale-red sweep neither parks nor reworks its pulls, only checks them again. | |
| 7 | ||
| 8 | It is an ask for the owner and one Sentinel issue (`src/crew/sentinel_lane.ts`), closed at the next hunt after a pull merges and the cause is gone, unless a person commented on it. `land_past_gate` does not open for it: the break-glass rests on red gates only (`landingStopped` in `src/checks/gate_stopped.ts`), since a merge drought with pulls waiting on review is not a red gate. | |
| 9 | ||
| 10 | Left out: the Sentinel issue's evidence carries the failure count, so it is commented again as that count moves. |
docs/features/release-heartbeat.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # Release heartbeat and the main-ahead alarm (#11507) | |
| 2 | ||
| 3 | The release tick records a heartbeat each time it runs (`src/keeper/beat.ts`, table `keeper_beat`): when, whether it cut, skipped or failed, and why. The outcome is the keeper's own (`tickStep` in `src/keeper/keeper.ts` says it where it decides), never read back from the line's words; a tick that throws is recorded as failed (`stepWithBeat`). None for 60 minutes is said plainly ("No heartbeat from the release tick for …"). | |
| 4 | ||
| 5 | Where to see it: | |
| 6 | ||
| 7 | - `/releases`, in the release keeper section: the last heartbeat (`#keeper-heartbeat`) and, while main is green and ahead of production, how far and for how long (`#keeper-ahead`). | |
| 8 | - The terminal: the palette's **Release keeper** screen, gated as /releases is, shows the same two lines under what holds the next cut. | |
| 9 | - `release_keeper` (MCP) and `GET /api/v1/keeper`: `heartbeat` (at, outcome, reason, silent, says). | |
| 10 | ||
| 11 | The flow alarm `main_ahead` (`src/flow/alarm_main_ahead.ts`) fires when main's last finished check passed, its head is not the commit production runs, and main has been ahead for more than 90 minutes. Change the limit with the `release.ahead_minutes` row of `forge_setting` (a whole number, 1 to 10080; anything else is 90). It names production's version and commit, the commits and pulls ahead, how long, and when the tick was last heard from. It is an ask for the owner (home) and one Sentinel issue (`src/crew/sentinel_main_ahead.ts`), which closes itself at the next hunt after production catches up, unless a person commented on it. The issue names the tick's last outcome and time, never its reason: the issue may be public, and the reason is shown only to readers of /releases. | |
| 12 | ||
| 13 | `/api/v1/telemetry/home`: `live` is the version and commit this process runs (null when unstamped); `main` is the head of the forge's own default branch and `behind` what main holds that live does not (#11676). |
src/checks/gate_stopped.ts +4 −1
| @@ -66,7 +66,10 @@ function oneCause(gates: readonly { reason: string | null; tests: string }[]): b | ||
| 66 | 66 | export function ownLandingStopped(db: Database, repo: { readonly id: string }): Stopped | undefined { |
| 67 | 67 | const [owner, name] = ownRepoPath().split("/"); |
| 68 | 68 | const own = owner && name ? getRepo(db, owner, name) : undefined; |
| 69 | return own !== undefined && own.id === repo.id ? landingStopped(db, own.id) : undefined; | |
| 69 | if (own === undefined || own.id !== repo.id) return undefined; | |
| 70 | // Gate evidence only: the lane alarm (#11507) is a heuristic over merges and | |
| 71 | // run rows, and a merge drought with pulls waiting on review is not a red gate. | |
| 72 | return landingStopped(db, own.id); | |
| 70 | 73 | } |
| 71 | 74 | |
| 72 | 75 | /** The one sentence home and the pipeline carry. */ |
src/core/migration-pins/20261111170000-keeper-beat.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | a57e110db188bf5db091597675159f303ad60ac496f0c841a66d62e09a6df833 |
src/core/migrations/20261111170000-keeper-beat.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | // The release tick's last heartbeat (#11507): one row, replaced each time the tick | |
| 2 | // reports in — when, what it did (cut, skipped, failed) and why. Additive. | |
| 3 | export default ` | |
| 4 | CREATE TABLE keeper_beat ( | |
| 5 | id INTEGER PRIMARY KEY CHECK (id = 1), | |
| 6 | at TEXT NOT NULL, | |
| 7 | outcome TEXT NOT NULL CHECK (outcome IN ('cut', 'skipped', 'failed')), | |
| 8 | reason TEXT NOT NULL | |
| 9 | ); | |
| 10 | `; |
src/crew/dispatch.ts +6
| @@ -48,6 +48,7 @@ import { appendRunLog } from "../runs/log.ts"; | ||
| 48 | 48 | import { noteHeld, type HoldCause } from "../runs/held.ts"; |
| 49 | 49 | import { decideAndRecord } from "./ledger.ts"; |
| 50 | 50 | import { bugsFirst } from "../flow/bugs.ts"; |
| 51 | import { forgeFailed } from "../flow/alarm_lane.ts"; | |
| 51 | 52 | import { byDials } from "./team_share.ts"; |
| 52 | 53 | import { peopleFirst } from "./people_first.ts"; |
| 53 | 54 | import { inMaintenance } from "../maintenance/state.ts"; |
| @@ -1099,6 +1100,11 @@ function waitOnMain( | ||
| 1099 | 1100 | }); |
| 1100 | 1101 | return true; |
| 1101 | 1102 | } |
| 1103 | // One failure line on five pulls within the hour is the forge's own (#11507): no builder is sent, no pull parked. | |
| 1104 | if (forgeFailed(app.db, run, Date.now())) { | |
| 1105 | log.info("check failed on a line five pulls share; the forge's failure, not the pull's", { issue: issue.number }); | |
| 1106 | return true; | |
| 1107 | } | |
| 1102 | 1108 | const main = sharedWithMain(redMain(app.db, repo.id), run); |
| 1103 | 1109 | if (!main && !sharedWithPulls(app.db, run)) return false; |
| 1104 | 1110 | if (typeof commitOid === "string" && typeof env === "string") { |
src/crew/person_spoke.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** Whether a person, not an agent, has commented on an issue: then they decide, not the sentinel. */ | |
| 2 | ||
| 3 | import type { Database } from "bun:sqlite"; | |
| 4 | ||
| 5 | export function personSpoke(db: Database, issueId: string): boolean { | |
| 6 | const spoke = db | |
| 7 | .query<{ n: number }, [string]>( | |
| 8 | `SELECT COUNT(*) AS n FROM comment c JOIN user u ON u.id = c.author_id | |
| 9 | WHERE c.issue_id = ?1 AND u.is_agent = 0 AND u.kind = 'user'`, | |
| 10 | ) | |
| 11 | .get(issueId); | |
| 12 | return spoke !== null && spoke.n > 0; | |
| 13 | } |
src/crew/sentinel.ts +16 −17
| @@ -47,6 +47,10 @@ import { | ||
| 47 | 47 | } from "../checks/shared_red.ts"; |
| 48 | 48 | import { readRunLogTail } from "../runs/log.ts"; |
| 49 | 49 | import { clip } from "../text/clip.ts"; |
| 50 | import { forgeCeiling } from "../flow/ceiling.ts"; | |
| 51 | import { personSpoke } from "./person_spoke.ts"; | |
| 52 | import { closeCaughtUp, mainAhead } from "./sentinel_main_ahead.ts"; | |
| 53 | import { closeLaneMoving, laneStopped } from "./sentinel_lane.ts"; | |
| 50 | 54 | import type { FindingKind } from "../hunt/finding.ts"; |
| 51 | 55 | import { addLabels, kindLabels } from "../hunt/label.ts"; |
| 52 | 56 | |
| @@ -400,7 +404,7 @@ export function repeatedErrors(now: number): Finding[] { | ||
| 400 | 404 | * Each cause whose spans total more than ten minutes in the day ending at |
| 401 | 405 | * `now`, quoting its sentence. Shorter totals stay in the history. |
| 402 | 406 | */ |
| 403 | export function degradedCauses(db: Database, now: number, forgeRepoId?: string, dataDir?: string): Finding[] { | |
| 407 | export function degradedCauses(db: Database, now: number, forgeRepoId?: string, dataDir?: string, ceilingUsd: number | null = null): Finding[] { | |
| 404 | 408 | const from = now - DAY_MS; |
| 405 | 409 | const byCause = new Map< |
| 406 | 410 | string, |
| @@ -479,7 +483,7 @@ export function ownCause(cause: string, forgeRepoId: string | undefined): boolea | ||
| 479 | 483 | * at once, not when the day has added up. The finding shares its key with |
| 480 | 484 | * `degradedCauses`, so one cause is one issue. |
| 481 | 485 | */ |
| 482 | export function budgetBreaches(db: Database, now: number, forgeRepoId?: string, dataDir?: string): Finding[] { | |
| 486 | export function budgetBreaches(db: Database, now: number, forgeRepoId?: string, dataDir?: string, ceilingUsd: number | null = null): Finding[] { | |
| 483 | 487 | const out: Finding[] = []; |
| 484 | 488 | for (const s of spans(db, now - DAY_MS, now)) { |
| 485 | 489 | if (s.closedAt !== null || !BREACH_CAUSE.test(s.cause) || !ownCause(s.cause, forgeRepoId)) continue; |
| @@ -569,8 +573,10 @@ function sharedLines( | ||
| 569 | 573 | } |
| 570 | 574 | |
| 571 | 575 | /** Everything the sentinel reads, in one pass. */ |
| 572 | export function findFaults(db: Database, now = Date.now(), forgeRepoId?: string, dataDir?: string): Finding[] { | |
| 576 | export function findFaults(db: Database, now = Date.now(), forgeRepoId?: string, dataDir?: string, ceilingUsd: number | null = null): Finding[] { | |
| 573 | 577 | const found = [ |
| 578 | ...mainAhead(db, now), | |
| 579 | ...laneStopped(db, now, ceilingUsd), | |
| 574 | 580 | ...budgetBreaches(db, now, forgeRepoId, dataDir), |
| 575 | 581 | ...heldSlots(db, now), |
| 576 | 582 | ...strayTriggers(db), |
| @@ -770,17 +776,6 @@ function sinceFix(app: App, repo: Repo, actor: User, f: Finding, evidence: strin | ||
| 770 | 776 | return 1; |
| 771 | 777 | } |
| 772 | 778 | |
| 773 | /** Whether a person, not an agent, has commented on the issue: then they decide, not the sentinel. */ | |
| 774 | function personSpoke(db: Database, issueId: string): boolean { | |
| 775 | const spoke = db | |
| 776 | .query<{ n: number }, [string]>( | |
| 777 | `SELECT COUNT(*) AS n FROM comment c JOIN user u ON u.id = c.author_id | |
| 778 | WHERE c.issue_id = ?1 AND u.is_agent = 0 AND u.kind = 'user'`, | |
| 779 | ) | |
| 780 | .get(issueId); | |
| 781 | return spoke !== null && spoke.n > 0; | |
| 782 | } | |
| 783 | ||
| 784 | 779 | /** The test a shared finding names, read back from its evidence. */ |
| 785 | 780 | const testOf = (evidence: readonly string[]): string | undefined => |
| 786 | 781 | evidence.find((e) => e.startsWith("test: "))?.slice(6); |
| @@ -1031,7 +1026,7 @@ export function hunt( | ||
| 1031 | 1026 | let commented = 0; |
| 1032 | 1027 | const [owner = "", name = ""] = repoPath.split("/"); |
| 1033 | 1028 | const repo = getRepo(app.db, owner, name); |
| 1034 | const findings = find(app.db, now, repo?.id, app.config.dataDir); | |
| 1029 | const findings = find(app.db, now, repo?.id, app.config.dataDir, forgeCeiling(app.config)); | |
| 1035 | 1030 | const actor = sentinelActor(app.db); |
| 1036 | 1031 | if (repo !== undefined && actor !== undefined) { |
| 1037 | 1032 | settleShared(app, repo, actor, now); |
| @@ -1106,8 +1101,12 @@ export function hunt( | ||
| 1106 | 1101 | } |
| 1107 | 1102 | } |
| 1108 | 1103 | } |
| 1109 | if (repo !== undefined && actor !== undefined) | |
| 1110 | closeHeld(app, repo, actor, new Set(findings.map((f) => f.key)), now); | |
| 1104 | if (repo !== undefined && actor !== undefined) { | |
| 1105 | const active = new Set(findings.map((f) => f.key)); | |
| 1106 | closeHeld(app, repo, actor, active, now); | |
| 1107 | closeCaughtUp(app, repo, actor, active, now); | |
| 1108 | closeLaneMoving(app, repo, actor, active, now); | |
| 1109 | } | |
| 1111 | 1110 | // A hunt with nowhere to file is recorded as such, never as a quiet one. |
| 1112 | 1111 | const unfiled = repo === undefined || actor === undefined; |
| 1113 | 1112 | app.db |
src/crew/sentinel_lane.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | /** | |
| 2 | * The sentinel's half of the lane-stopped alarm (#11507): nothing merging for | |
| 3 | * the limit, or one cause failing many pulls, is one finding; its issue closes | |
| 4 | * at the next hunt after the lane moves, unless a person spoke on it. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import type { App } from "../app.ts"; | |
| 9 | import type { Repo } from "../core/repos.ts"; | |
| 10 | import type { User } from "../core/users.ts"; | |
| 11 | import { laneAlarm } from "../flow/alarm_lane.ts"; | |
| 12 | import { closeCaughtUp } from "./sentinel_main_ahead.ts"; | |
| 13 | import type { Finding } from "./sentinel.ts"; | |
| 14 | ||
| 15 | export const LANE_KEY = "lane-stopped"; | |
| 16 | ||
| 17 | export function laneStopped(db: Database, now: number, ceilingUsd: number | null): Finding[] { | |
| 18 | const a = laneAlarm(db, new Date(now), { ceilingUsd }); | |
| 19 | if (!a.fired) return []; | |
| 20 | return [{ | |
| 21 | key: LANE_KEY, | |
| 22 | title: a.forge === true ? "One failure is stopping many pulls: the forge's, not theirs" : "The lane has stopped: nothing has merged and nothing said so", | |
| 23 | evidence: a.facts ?? [a.says], | |
| 24 | pillar: "P0 Trust: the forge says when it is not well, and is well again", | |
| 25 | kind: "defect", | |
| 26 | lookFor: "A pull merged since, and no one failure line failing five pulls within an hour.", | |
| 27 | urgent: true, | |
| 28 | }]; | |
| 29 | } | |
| 30 | ||
| 31 | /** Close the open lane issue once the lane moves; `active` is this hunt's finding keys. */ | |
| 32 | export function closeLaneMoving(app: App, repo: Repo, actor: User, active: ReadonlySet<string>, now: number): void { | |
| 33 | closeCaughtUp(app, repo, actor, active, now, LANE_KEY, "The lane moved: a pull merged and no one failure is stopping many"); | |
| 34 | } |
src/crew/sentinel_main_ahead.ts added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | /** | |
| 2 | * The sentinel's half of the main-ahead alarm (#11507): main green and ahead | |
| 3 | * of production past the setting is one finding, and its issue closes itself | |
| 4 | * at the next hunt after production catches up — unless a person spoke on it, | |
| 5 | * then it is theirs to close. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { addComment, setIssueState } from "../actions/issues.ts"; | |
| 11 | import { getIssueById } from "../core/issues.ts"; | |
| 12 | import type { Repo } from "../core/repos.ts"; | |
| 13 | import type { User } from "../core/users.ts"; | |
| 14 | import { mainAheadAlarm } from "../flow/alarm_main_ahead.ts"; | |
| 15 | import { createLog } from "../kernel/log.ts"; | |
| 16 | import { personSpoke } from "./person_spoke.ts"; | |
| 17 | import type { Finding } from "./sentinel.ts"; | |
| 18 | ||
| 19 | const log = createLog("sentinel"); | |
| 20 | ||
| 21 | export const MAIN_AHEAD_KEY = "main-ahead"; | |
| 22 | ||
| 23 | export function mainAhead(db: Database, now: number): Finding[] { | |
| 24 | const a = mainAheadAlarm(db, new Date(now)); | |
| 25 | if (!a.fired) return []; | |
| 26 | return [{ | |
| 27 | key: MAIN_AHEAD_KEY, | |
| 28 | title: "Main is green and ahead of production: the release tick has not cut", | |
| 29 | evidence: a.facts ?? [a.says], | |
| 30 | pillar: "P0 Trust: the forge says when it is not well, and is well again", | |
| 31 | kind: "defect", | |
| 32 | lookFor: "Production runs main's head, or main has been ahead of it for under the limit.", | |
| 33 | urgent: true, | |
| 34 | }]; | |
| 35 | } | |
| 36 | ||
| 37 | /** Close the open main-ahead issue once its finding is gone; `active` is this hunt's finding keys. */ | |
| 38 | export function closeCaughtUp( | |
| 39 | app: App, | |
| 40 | repo: Repo, | |
| 41 | actor: User, | |
| 42 | active: ReadonlySet<string>, | |
| 43 | now: number, | |
| 44 | key: string = MAIN_AHEAD_KEY, | |
| 45 | cleared: string = "Production caught up with main", | |
| 46 | ): void { | |
| 47 | if (active.has(key)) return; | |
| 48 | const open = app.db | |
| 49 | .query<{ issue_id: string }, [string]>( | |
| 50 | `SELECT t.issue_id FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id WHERE i.state = 'open' AND t.key = ?1`, | |
| 51 | ) | |
| 52 | .all(key); | |
| 53 | for (const t of open) { | |
| 54 | try { | |
| 55 | if (personSpoke(app.db, t.issue_id)) continue; | |
| 56 | const issue = getIssueById(app.db, t.issue_id); | |
| 57 | if (issue === undefined || issue.repoId !== repo.id) continue; | |
| 58 | const said = addComment(app, actor, repo, issue, `${cleared} at ${new Date(now).toISOString()}. Closing as done; if it falls behind again it is filed again.`); | |
| 59 | if (!said.ok) continue; | |
| 60 | setIssueState(app, actor, repo, issue, "closed", { reason: "done", system: true }); | |
| 61 | } catch (error) { | |
| 62 | log.error("sentinel close failed", { key, error: String(error) }); | |
| 63 | } | |
| 64 | } | |
| 65 | } |
src/crew/stale_reds.ts +6 −1
| @@ -28,6 +28,7 @@ import { closesFromBody, listOpenPulls, pullDir, type Pull } from "../core/pulls | ||
| 28 | 28 | import type { Repo } from "../core/repos.ts"; |
| 29 | 29 | import { automaticRunsSince, getRun, INFRA_SUMMARY, type Run } from "../core/runs.ts"; |
| 30 | 30 | import { getUserById } from "../core/users.ts"; |
| 31 | import { type ForgeLines, forgeFailed } from "../flow/alarm_lane.ts"; | |
| 31 | 32 | import { QUIET_MS, SWEEP_REASON } from "../flow/stale_reds.ts"; |
| 32 | 33 | import { mergeable, resolveOid } from "../git/merge.ts"; |
| 33 | 34 | import { createLog } from "../kernel/log.ts"; |
| @@ -129,6 +130,7 @@ export async function sweepStaleReds( | ||
| 129 | 130 | if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return moves; |
| 130 | 131 | let budget = Math.min(PER_TICK, Math.max(room, 0)); |
| 131 | 132 | const main = redMain(app.db, repo.id); |
| 133 | const forgeLines: ForgeLines = new Map(); | |
| 132 | 134 | for (const pull of listOpenPulls(app.db, repo.id)) { |
| 133 | 135 | if (!pull.authorIsAgent || pull.mergedAt !== null || pull.headOid === "") continue; |
| 134 | 136 | if (getHold(app.db, pull.id) !== undefined || authorsInRepoPull(pull)) continue; |
| @@ -159,7 +161,9 @@ export async function sweepStaleReds( | ||
| 159 | 161 | } |
| 160 | 162 | |
| 161 | 163 | const since = lastHumanWord(app, issue, agent); |
| 162 | if (automaticRunsSince(app.db, issue.id, since) >= CHECK_FAILED_LIMIT) { | |
| 164 | // The forge's failure (#11507) is never a reason to park: the pull is checked again, not reworked. | |
| 165 | const forge = forgeFailed(app.db, red.run, now.getTime(), forgeLines); | |
| 166 | if (!forge && automaticRunsSince(app.db, issue.id, since) >= CHECK_FAILED_LIMIT) { | |
| 163 | 167 | const why = `the check stayed red through ${CHECK_FAILED_LIMIT} automatic tries: ${failingLine(red.run)}`; |
| 164 | 168 | if (await parkPullTold(app, repo, pull, why, since)) moves.push({ pull: pull.number, did: "parked" }); |
| 165 | 169 | continue; |
| @@ -178,6 +182,7 @@ export async function sweepStaleReds( | ||
| 178 | 182 | continue; |
| 179 | 183 | } |
| 180 | 184 | if (again.state !== "failed" || again.summary.startsWith(INFRA_SUMMARY)) continue; |
| 185 | if (forgeFailed(app.db, again, now.getTime(), forgeLines)) continue; | |
| 181 | 186 | // Said as the builder, not as a person: a person's word restarts the tries. |
| 182 | 187 | addComment( |
| 183 | 188 | app, |
src/flow/alarm_lane.ts added +195
| @@ -0,0 +1,195 @@ | ||
| 1 | /** | |
| 2 | * The "lane has stopped" flow alarm (#11507). On 2026-11-12 nothing merged for | |
| 3 | * 12 h 51 min and nothing said so: main was not ahead of production, so the | |
| 4 | * main-ahead alarm stayed quiet, and every check was being stopped for CPU. | |
| 5 | * Two states raise this one alarm for the forge's own repository: | |
| 6 | * | |
| 7 | * - the lane has stopped: pulls are open or work is ready, the crew is not | |
| 8 | * at its ceiling, and nothing has merged for `flow.lane_minutes` (90); | |
| 9 | * - one cause fails many pulls: the same failure line (numbers masked) failed | |
| 10 | * checks on {@link FORGE_PULLS} different pulls within an hour. That is the | |
| 11 | * forge's failure, not the builders': `forgeFailed` is what the crew asks | |
| 12 | * before it reworks or parks a pull for it. | |
| 13 | * | |
| 14 | * A failure line is the first line of a check's summary, for a check that | |
| 15 | * named no failing test (a stop for CPU, a budget, a lost machine); a named | |
| 16 | * test is `checks/shared_red.ts`'s. Pure of the clock. | |
| 17 | */ | |
| 18 | ||
| 19 | import type { Database } from "bun:sqlite"; | |
| 20 | import { spentTodayEverywhere } from "../crew/autonomy.ts"; | |
| 21 | import type { Run } from "../core/runs.ts"; | |
| 22 | import { getRepo } from "../core/repos.ts"; | |
| 23 | import { agoWords } from "../keeper/beat.ts"; | |
| 24 | import { ownRepoPath } from "../kernel/own_repo.ts"; | |
| 25 | import { ceilingReached } from "./ceiling.ts"; | |
| 26 | import { readPipeline } from "./pipeline.ts"; | |
| 27 | ||
| 28 | export const LANE_SETTING = "flow.lane_minutes"; | |
| 29 | export const LANE_DEFAULT_MIN = 90; | |
| 30 | /** Different pulls failing one line within {@link FORGE_WINDOW_MS} make it the forge's. */ | |
| 31 | export const FORGE_PULLS = 5; | |
| 32 | export const FORGE_WINDOW_MS = 3_600_000; | |
| 33 | const LINE_MAX = 200; | |
| 34 | ||
| 35 | export interface LaneAlarm { | |
| 36 | readonly fired: boolean; | |
| 37 | /** When the lane last merged, or the first failure of the shared line; set when fired. */ | |
| 38 | readonly since?: string; | |
| 39 | readonly says: string; | |
| 40 | /** The commonest failure line, verbatim, when one exists. */ | |
| 41 | readonly line?: string | undefined; | |
| 42 | /** Whether one cause is failing many pulls. */ | |
| 43 | readonly forge?: boolean; | |
| 44 | readonly facts?: readonly string[]; | |
| 45 | } | |
| 46 | ||
| 47 | /** The minutes without a merge before the alarm; a whole number from 1 to 10080 is the owner's, else 90. */ | |
| 48 | export function laneMinutes(db: Database): number { | |
| 49 | const row = db.query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1").get(LANE_SETTING); | |
| 50 | const n = row === null ? NaN : Number(row.value); | |
| 51 | return Number.isInteger(n) && n >= 1 && n <= 10_080 ? n : LANE_DEFAULT_MIN; | |
| 52 | } | |
| 53 | ||
| 54 | /** The same line with every number as `#`, so `16.0 cores for 30s` and `15.2 cores for 31s` are one cause. */ | |
| 55 | export const maskLine = (line: string): string => line.replace(/\d+(?:\.\d+)?/g, "#"); | |
| 56 | ||
| 57 | interface Failure { | |
| 58 | readonly issue: string; | |
| 59 | readonly at: string; | |
| 60 | readonly line: string; | |
| 61 | } | |
| 62 | ||
| 63 | /** A check's failure line, or "" when it named a failing test (not this alarm's) or said nothing. */ | |
| 64 | function lineOf(summary: string, detail: string): string { | |
| 65 | try { | |
| 66 | const names = (JSON.parse(detail) as { names?: unknown } | null)?.names; | |
| 67 | if (Array.isArray(names) && names.length > 0) return ""; | |
| 68 | } catch { | |
| 69 | // An unreadable detail names no test. | |
| 70 | } | |
| 71 | const first = summary.trim().split("\n")[0] ?? ""; | |
| 72 | return first.replace(/^check failed: /, "").slice(0, LINE_MAX); | |
| 73 | } | |
| 74 | ||
| 75 | /** Failed pull checks of the repository finished since `since`, newest first, bounded. */ | |
| 76 | function failuresSince(db: Database, repoId: string, since: string): Failure[] { | |
| 77 | return db | |
| 78 | .query<{ issue_id: string | null; finished_at: string; summary: string; check_detail: string }, [string, string]>( | |
| 79 | `SELECT issue_id, finished_at, summary, check_detail FROM run | |
| 80 | WHERE repo_id = ?1 AND kind = 'check' AND state = 'failed' AND finished_at >= ?2 | |
| 81 | ORDER BY finished_at DESC LIMIT 2000`, | |
| 82 | ) | |
| 83 | .all(repoId, since) | |
| 84 | .map((r) => ({ issue: r.issue_id ?? "", at: r.finished_at, line: lineOf(r.summary, r.check_detail) })); | |
| 85 | } | |
| 86 | ||
| 87 | interface Group { | |
| 88 | readonly masked: string; | |
| 89 | /** The newest instance, verbatim. */ | |
| 90 | readonly line: string; | |
| 91 | count: number; | |
| 92 | readonly pulls: Set<string>; | |
| 93 | since: string; | |
| 94 | } | |
| 95 | ||
| 96 | function groups(failures: readonly Failure[]): Group[] { | |
| 97 | const by = new Map<string, Group>(); | |
| 98 | for (const f of failures) { | |
| 99 | if (f.line === "") continue; | |
| 100 | const m = maskLine(f.line); | |
| 101 | const g = by.get(m) ?? { masked: m, line: f.line, count: 0, pulls: new Set<string>(), since: f.at }; | |
| 102 | g.count++; | |
| 103 | if (f.issue !== "") g.pulls.add(f.issue); | |
| 104 | if (f.at < g.since) g.since = f.at; | |
| 105 | by.set(m, g); | |
| 106 | } | |
| 107 | return [...by.values()].sort((a, b) => b.count - a.count || (a.masked < b.masked ? -1 : 1)); | |
| 108 | } | |
| 109 | ||
| 110 | /** When a pull of the repository last merged. */ | |
| 111 | function lastMerge(db: Database, repoId: string): string | null { | |
| 112 | return ( | |
| 113 | db | |
| 114 | .query<{ at: string | null }, [string]>(`SELECT MAX(p.merged_at) AS at FROM pull p JOIN issue i ON i.id = p.issue_id WHERE i.repo_id = ?1`) | |
| 115 | .get(repoId)?.at ?? null | |
| 116 | ); | |
| 117 | } | |
| 118 | ||
| 119 | /** The failure lines that failed {@link FORGE_PULLS} pulls' checks within the hour and since the last merge (a merge proves the lane): the forge's own. */ | |
| 120 | function forgeGroups(db: Database, repoId: string, now: number): Group[] { | |
| 121 | const hour = new Date(now - FORGE_WINDOW_MS).toISOString(); | |
| 122 | const merged = lastMerge(db, repoId); | |
| 123 | const since = merged !== null && merged > hour ? merged : hour; | |
| 124 | return groups(failuresSince(db, repoId, since)).filter((g) => g.pulls.size >= FORGE_PULLS); | |
| 125 | } | |
| 126 | ||
| 127 | /** The masked forge lines per repository, read once per sweep: a sweep asks {@link forgeFailed} for every pull. */ | |
| 128 | export type ForgeLines = Map<string, ReadonlySet<string>>; | |
| 129 | ||
| 130 | /** Whether this failed check's line is one the forge failed on many pulls: the crew neither reworks nor parks for it. */ | |
| 131 | export function forgeFailed(db: Database, run: Run | undefined, now: number, memo?: ForgeLines): boolean { | |
| 132 | if (run === undefined || run.kind !== "check" || run.state !== "failed") return false; | |
| 133 | const line = lineOf(run.summary, run.checkDetail === null || run.checkDetail === undefined ? "{}" : JSON.stringify(run.checkDetail)); | |
| 134 | if (line === "") return false; | |
| 135 | let lines = memo?.get(run.repoId); | |
| 136 | if (lines === undefined) { | |
| 137 | lines = new Set(forgeGroups(db, run.repoId, now).map((g) => g.masked)); | |
| 138 | memo?.set(run.repoId, lines); | |
| 139 | } | |
| 140 | return lines.has(maskLine(line)); | |
| 141 | } | |
| 142 | ||
| 143 | function ownRepoId(db: Database): string | undefined { | |
| 144 | const [owner, name] = ownRepoPath().split("/"); | |
| 145 | return owner && name ? getRepo(db, owner, name)?.id : undefined; | |
| 146 | } | |
| 147 | ||
| 148 | const count = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`; | |
| 149 | ||
| 150 | export function laneAlarm(db: Database, now: Date, forge: { readonly ceilingUsd: number | null } = { ceilingUsd: null }): LaneAlarm { | |
| 151 | const quiet = { fired: false, says: "Pulls are landing, or there is nothing to land, or the crew is paused on purpose." } as const; | |
| 152 | const repoId = ownRepoId(db); | |
| 153 | if (repoId === undefined) return quiet; | |
| 154 | const at = now.getTime(); | |
| 155 | const shared = forgeGroups(db, repoId, at)[0]; | |
| 156 | const last = lastMerge(db, repoId); | |
| 157 | const open = db | |
| 158 | .query<{ n: number }, [string]>(`SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND kind = 'pull' AND state = 'open'`) | |
| 159 | .get(repoId)?.n ?? 0; | |
| 160 | const limit = laneMinutes(db) * 60_000; | |
| 161 | let stopped = false; | |
| 162 | let wait = 0; | |
| 163 | if (last !== null && (at - Date.parse(last) > limit)) { | |
| 164 | wait = at - Date.parse(last); | |
| 165 | const ready = open > 0 ? open : (readPipeline(db, now).find((s) => s.key === "ready")?.waiting ?? 0); | |
| 166 | stopped = ready > 0 && !ceilingReached(spentTodayEverywhere(db, now), forge.ceilingUsd); | |
| 167 | } | |
| 168 | if (!stopped && shared === undefined) return quiet; | |
| 169 | ||
| 170 | const parts: string[] = []; | |
| 171 | const facts: string[] = []; | |
| 172 | let line: string | undefined; | |
| 173 | let since: string; | |
| 174 | if (stopped) { | |
| 175 | const failed = failuresSince(db, repoId, last!); | |
| 176 | const top = groups(failed)[0]; | |
| 177 | line = top?.line; | |
| 178 | since = last!; | |
| 179 | parts.push( | |
| 180 | `Nothing has merged for ${agoWords(wait).replace(" ago", "")} (since ${last}) with ${count(open, "pull")} open; ${failed.length} ${failed.length === 1 ? "check" : "checks"} failed in that span` + | |
| 181 | (top === undefined ? "." : `, ${top.count} of them with: ${top.line}`), | |
| 182 | ); | |
| 183 | facts.push(`last merge at ${last}`, `${count(open, "pull")} open`, `${failed.length} checks failed since the last merge`); | |
| 184 | if (top !== undefined) facts.push(`commonest failure, ${top.count} times: ${top.line}`); | |
| 185 | } else since = shared!.since; | |
| 186 | if (shared !== undefined) { | |
| 187 | line = line ?? shared.line; | |
| 188 | if (stopped && shared.masked !== maskLine(line)) facts.push(`failed ${count(shared.pulls.size, "pull")} within the hour: ${shared.line}`); | |
| 189 | parts.push( | |
| 190 | `One cause is failing ${count(shared.pulls.size, "pull")} within the hour: ${shared.line}. That is the forge's failure, not the builders': their reworks and parks for it are held`, | |
| 191 | ); | |
| 192 | facts.push(`forge failure on ${count(shared.pulls.size, "pull")} within the hour since ${shared.since}: ${shared.line}`); | |
| 193 | } | |
| 194 | return { fired: true, since, says: `${parts.join(". ")}.`.replace(/\.\.$/, "."), line, forge: shared !== undefined, facts }; | |
| 195 | } |
src/flow/alarm_main_ahead.ts added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | /** | |
| 2 | * The "main is ahead of production" flow alarm (#11507): the forge's own main | |
| 3 | * head is proven green (its last finished check passed), differs from the | |
| 4 | * commit production runs, and has for longer than the setting | |
| 5 | * `release.ahead_minutes` (default 90). On 2026-11-11 production stood still | |
| 6 | * for 5 h 50 min with main green and nothing said so. Names production's | |
| 7 | * version and commit, how far main is ahead (commits and pulls), how long, and | |
| 8 | * the last time the release tick was heard from. Pure of the clock. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Database } from "bun:sqlite"; | |
| 12 | import { getRepo } from "../core/repos.ts"; | |
| 13 | import { agoWords, lastBeat } from "../keeper/beat.ts"; | |
| 14 | import { ownRepoPath } from "../kernel/own_repo.ts"; | |
| 15 | import { running } from "../kernel/version.ts"; | |
| 16 | import { waitingRows } from "../release/landings.ts"; | |
| 17 | ||
| 18 | export const AHEAD_SETTING = "release.ahead_minutes"; | |
| 19 | export const AHEAD_DEFAULT_MIN = 90; | |
| 20 | ||
| 21 | export interface MainAheadAlarm { | |
| 22 | readonly fired: boolean; | |
| 23 | /** When main first moved past production; while it is ahead. */ | |
| 24 | readonly since?: string; | |
| 25 | readonly says: string; | |
| 26 | /** Facts for the issue and the ask; only when fired. */ | |
| 27 | readonly facts?: readonly string[]; | |
| 28 | } | |
| 29 | ||
| 30 | /** The minutes main may be ahead and green before the alarm; a whole number of 1 or more is the owner's, else 90. */ | |
| 31 | export function aheadMinutes(db: Database): number { | |
| 32 | const row = db.query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1").get(AHEAD_SETTING); | |
| 33 | const n = row === null ? NaN : Number(row.value); | |
| 34 | return Number.isInteger(n) && n >= 1 && n <= 10_080 ? n : AHEAD_DEFAULT_MIN; | |
| 35 | } | |
| 36 | ||
| 37 | /** The commit of the last finished check of the forge's default branch, when that check passed. */ | |
| 38 | function greenHead(db: Database): string | undefined { | |
| 39 | const [owner, name] = ownRepoPath().split("/"); | |
| 40 | const repo = owner && name ? getRepo(db, owner, name) : undefined; | |
| 41 | if (repo === undefined) return undefined; | |
| 42 | const last = db | |
| 43 | .query<{ state: string; commit_oid: string }, [string, string]>( | |
| 44 | `SELECT state, commit_oid FROM run | |
| 45 | WHERE repo_id = ?1 AND kind = 'check' AND issue_id IS NULL AND env = ?2 AND state IN ('ok', 'failed') | |
| 46 | ORDER BY id DESC LIMIT 1`, | |
| 47 | ) | |
| 48 | .get(repo.id, repo.defaultBranch); | |
| 49 | return last?.state === "ok" ? last.commit_oid : undefined; | |
| 50 | } | |
| 51 | ||
| 52 | export function mainAheadAlarm(db: Database, now: Date): MainAheadAlarm { | |
| 53 | const quiet = { fired: false, says: "Production runs main's head, or main's head is not proven green." } as const; | |
| 54 | // The cheap checks first: this runs on every /flow and home request, and the waiting pulls are read only once main's green head is not production's. | |
| 55 | const prod = running(); | |
| 56 | if (prod.commit === null) return quiet; | |
| 57 | const head = greenHead(db); | |
| 58 | if (head === undefined || head.startsWith(prod.commit) || prod.commit.startsWith(head)) return quiet; | |
| 59 | const waiting = waitingRows(db); | |
| 60 | const first = waiting[0]; | |
| 61 | if (first === undefined) return quiet; | |
| 62 | const wait = now.getTime() - Date.parse(first.at); | |
| 63 | const limit = aheadMinutes(db) * 60_000; | |
| 64 | if (!(wait > limit)) return { fired: false, since: first.at, says: `Main is ahead of production ${agoWords(wait).replace(" ago", "")}; not yet due.` }; | |
| 65 | const commits = new Set(waiting.map((r) => r.oid).filter((o) => o !== "")).size || waiting.length; | |
| 66 | const pulls = waiting.length; | |
| 67 | const beat = lastBeat(db); | |
| 68 | const tick = beat === null ? "never" : agoWords(now.getTime() - Date.parse(beat.at)); | |
| 69 | const how = `${commits} ${commits === 1 ? "commit" : "commits"} and ${pulls} ${pulls === 1 ? "pull" : "pulls"}`; | |
| 70 | const forHow = agoWords(wait).replace(" ago", ""); | |
| 71 | const facts = [ | |
| 72 | `production runs ${prod.version} at ${prod.commit.slice(0, 7)}`, | |
| 73 | `main is green at ${head.slice(0, 7)}, ${how} ahead`, | |
| 74 | `ahead since ${first.at}`, | |
| 75 | // Absolute times: the evidence is commented again only when it changes, not as the clock moves. | |
| 76 | // The tick's reason stays out: these facts are a Sentinel issue's, which may be public; /releases shows it to its readers. | |
| 77 | `the release tick was last heard from ${beat === null ? "never" : `${beat.at} (${beat.outcome})`}`, | |
| 78 | ]; | |
| 79 | return { | |
| 80 | fired: true, | |
| 81 | since: first.at, | |
| 82 | says: `Main is green and ${how} ahead of production (${prod.version} at ${prod.commit.slice(0, 7)}) for ${forHow}; the release tick was last heard from ${tick}.`, | |
| 83 | facts, | |
| 84 | }; | |
| 85 | } | |
| 86 | ||
| 87 | /** Main's state against production as one sentence for /releases and the terminal; undefined when main is not ahead and green. */ | |
| 88 | export function aheadLine(db: Database, now: Date): string | undefined { | |
| 89 | const a = mainAheadAlarm(db, now); | |
| 90 | return a.fired || a.since !== undefined ? a.says : undefined; | |
| 91 | } |
src/flow/alarms.ts +7 −1
| @@ -16,6 +16,8 @@ import { alarmBackup } from "./alarm_backup.ts"; | ||
| 16 | 16 | import { alarmFromApp } from "./alarm_from_app.ts"; |
| 17 | 17 | import { alarmPaidOrder } from "./alarm_paid_order.ts"; |
| 18 | 18 | import { alarmIdleCrew } from "./alarm_idle_crew.ts"; |
| 19 | import { laneAlarm } from "./alarm_lane.ts"; | |
| 20 | import { mainAheadAlarm } from "./alarm_main_ahead.ts"; | |
| 19 | 21 | import { noReleaseAlarm } from "./alarm_no_release.ts"; |
| 20 | 22 | import { redMainAlarm } from "./alarm_red_main.ts"; |
| 21 | 23 | import { alarmRepeat } from "./alarm_repeat.ts"; |
| @@ -23,7 +25,7 @@ import { alarmSilent } from "./alarm_silent.ts"; | ||
| 23 | 25 | import { alarmStuck } from "./alarm_stuck.ts"; |
| 24 | 26 | |
| 25 | 27 | export interface FiredAlarm { |
| 26 | readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "from_app" | "unreachable" | "backup" | "paid_order"; | |
| 28 | readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "main_ahead" | "lane_stopped" | "bug_clock" | "silent" | "repeat" | "from_app" | "unreachable" | "backup" | "paid_order"; | |
| 27 | 29 | readonly says: string; |
| 28 | 30 | readonly since: string; |
| 29 | 31 | /** A same-origin path to the item the alarm names. */ |
| @@ -70,6 +72,10 @@ export function firedAlarms( | ||
| 70 | 72 | } |
| 71 | 73 | const release = noReleaseAlarm(db, now); |
| 72 | 74 | if (release.fired) out.push({ key: "no_release", says: release.says, since: release.since!, href: "/releases" }); |
| 75 | const ahead = mainAheadAlarm(db, now); | |
outdated
Issue #11507 asks /flow to show the last heartbeat; only the alarm is added. Add the heartbeat line here, gated like /releases. |
||
| 76 | if (ahead.fired) out.push({ key: "main_ahead", says: ahead.says, since: ahead.since!, href: "/releases" }); | |
| 77 | const lane = laneAlarm(db, now, { ceilingUsd: opts.ceilingUsd ?? null }); | |
| 78 | if (lane.fired) out.push({ key: "lane_stopped", says: lane.says, since: lane.since!, href: "/flow" }); | |
| 73 | 79 | const bug = bugClockAlarm(db, now, opts.publicOnly ? { publicOnly: true } : {}); |
| 74 | 80 | if (bug.fired) out.push({ key: "bug_clock", says: bug.says, since: bug.since!, href: bug.href }); |
| 75 | 81 | return out; |
src/http/mcp/tools/release_keeper.ts +1 −1
| @@ -4,7 +4,7 @@ import { bad, good, type Tool } from "../shared.ts"; | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "release_keeper", |
| 7 | doc: "`{}`: what the release keeper (#7557) did: its releases newest first (version, commit (short sha), stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason, landedToLiveSeconds — from the merge of its pull to the new version answering, null until it has (#8042)) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; for whoever may read /releases", | |
| 7 | doc: "`{}`: what the release keeper (#7557) did: its releases newest first (version, commit (short sha), stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason, landedToLiveSeconds — from the merge of its pull to the new version answering, null until it has (#8042)) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; `heartbeat` is the release tick's last report (at, outcome cut/skipped/failed, reason, silent when over an hour old, #11507); for whoever may read /releases", | |
| 8 | 8 | description: "The release keeper's releases, holds and what blocks the next cut.", |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | writes: false, |
src/keeper/beat.ts added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | /** | |
| 2 | * The release tick's heartbeat (#11507): each time the tick runs it reports | |
| 3 | * what it did — cut, skipped and why, or failed and why — and the forge keeps | |
| 4 | * the last. The tick runs outside the forge's own judgment, so when it stops | |
| 5 | * this is the only trace; `release_keeper`, /releases and the terminal's | |
| 6 | * release keeper screen show it, and a beat older | |
| 7 | * than {@link SILENT_MS} is said plainly. Plain rows in, plain rows out. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | ||
| 12 | export type Outcome = "cut" | "skipped" | "failed"; | |
| 13 | ||
| 14 | /** No heartbeat for this long is itself said. */ | |
| 15 | export const SILENT_MS = 60 * 60_000; | |
| 16 | const REASON_MAX = 300; | |
| 17 | ||
| 18 | export interface Beat { | |
| 19 | readonly at: string; | |
| 20 | readonly outcome: Outcome; | |
| 21 | readonly reason: string; | |
| 22 | } | |
| 23 | ||
| 24 | export function recordBeat(db: Database, outcome: Outcome, reason: string, at: Date = new Date()): Beat { | |
| 25 | const beat: Beat = { at: at.toISOString(), outcome, reason: reason.replace(/\s+/g, " ").trim().slice(0, REASON_MAX) }; | |
| 26 | db.query( | |
| 27 | `INSERT INTO keeper_beat (id, at, outcome, reason) VALUES (1, ?1, ?2, ?3) | |
| 28 | ON CONFLICT (id) DO UPDATE SET at = excluded.at, outcome = excluded.outcome, reason = excluded.reason`, | |
| 29 | ).run(beat.at, beat.outcome, beat.reason); | |
| 30 | return beat; | |
| 31 | } | |
| 32 | ||
| 33 | export function lastBeat(db: Database): Beat | null { | |
| 34 | return db.query<Beat, []>("SELECT at, outcome, reason FROM keeper_beat WHERE id = 1").get() ?? null; | |
| 35 | } | |
| 36 | ||
| 37 | /** What one tick did, as the keeper says it: the line and its outcome, never read back from the words. */ | |
| 38 | export interface Step { | |
| 39 | readonly line: string; | |
| 40 | readonly outcome: Outcome; | |
| 41 | } | |
| 42 | ||
| 43 | /** Run one tick and record its beat; a tick that throws is recorded as failed and the error goes on. */ | |
| 44 | export async function stepWithBeat(db: Database, step: () => Promise<Step>, now: () => Date = () => new Date()): Promise<string> { | |
| 45 | let done: Step; | |
| 46 | try { | |
| 47 | done = await step(); | |
outdated
If recordBeat throws here the catch below records 'failed' for a tick that succeeded. Separate the step's error from the beat write. |
||
| 48 | } catch (error) { | |
| 49 | writeBeat(db, "failed", `step failed: ${error instanceof Error ? error.message : String(error)}`, now()); | |
| 50 | throw error; | |
| 51 | } | |
| 52 | writeBeat(db, done.outcome, done.line, now()); | |
| 53 | return done.line; | |
| 54 | } | |
| 55 | ||
| 56 | /** The beat is a trace, not the tick: a write that fails is said and never turns a good tick into a failed one. */ | |
| 57 | function writeBeat(db: Database, outcome: Outcome, reason: string, at: Date): void { | |
| 58 | try { | |
| 59 | recordBeat(db, outcome, reason, at); | |
| 60 | } catch (error) { | |
| 61 | console.error(`keeper heartbeat not recorded: ${error instanceof Error ? error.message : String(error)}`); | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | /** "never", "4 min ago", "2 h 5 min ago". */ | |
| 66 | export function agoWords(ms: number): string { | |
| 67 | const min = Math.max(0, Math.floor(ms / 60_000)); | |
| 68 | if (min < 60) return `${min} min ago`; | |
| 69 | return `${Math.floor(min / 60)} h ${min % 60} min ago`; | |
| 70 | } | |
| 71 | ||
| 72 | /** The heartbeat as one sentence, saying plainly when there is none or it is old. */ | |
| 73 | export function beatLine(beat: Beat | null, now: Date): string { | |
| 74 | if (beat === null) return "The release tick has never reported a heartbeat."; | |
| 75 | const age = now.getTime() - Date.parse(beat.at); | |
| 76 | const said = `The release tick last reported ${agoWords(age)} (${beat.outcome}): ${beat.reason}`; | |
| 77 | return age > SILENT_MS ? `No heartbeat from the release tick for ${agoWords(age).replace(" ago", "")}. ${said}` : said; | |
| 78 | } | |
| 79 | ||
| 80 | /** The heartbeat as a `dl` row's value: outcome, age and reason; "never reported" before the first. */ | |
| 81 | export function beatFact(beat: Beat | null, now: Date): string { | |
| 82 | if (beat === null) return "never reported"; | |
| 83 | const age = now.getTime() - Date.parse(beat.at); | |
| 84 | return `${beat.outcome}, ${agoWords(age)}${age > SILENT_MS ? " (silent)" : ""}: ${beat.reason}`; | |
| 85 | } |
src/keeper/keeper.ts +23 −11
| @@ -37,6 +37,7 @@ import { stallReason, STALL_HOLD_MS } from "../perf/boot_stall.ts"; | ||
| 37 | 37 | import { countRollProbe, dropCut, finish, getKeeperRelease, inFlight, keepStall, keepTally, listKeeperReleases, markLive, releaseBefore, recordCut, type KeeperRelease } from "./record.ts"; |
| 38 | 38 | import { rollBackToNamed } from "./rollback.ts"; |
| 39 | 39 | import type { Probe, Tally, Verdict } from "./verify.ts"; |
| 40 | import type { Step } from "./beat.ts"; | |
| 40 | 41 | |
| 41 | 42 | /** A release not live this long after its cut never rolled: 3 h (the tag's check may take 2 h, the build some more). */ |
| 42 | 43 | export const ROLL_DEADLINE_MS = 3 * 3_600_000; |
| @@ -116,8 +117,8 @@ function fileDark(p: KeeperPorts, r: KeeperRelease): number | undefined { | ||
| 116 | 117 | const verifying = new WeakMap<KeeperPorts, number>(); |
| 117 | 118 | |
| 118 | 119 | /** The verify of an in-flight release this process is the new version of, to its end. */ |
| 119 | async function verifyAndDecide(p: KeeperPorts, r: KeeperRelease): Promise<string> { | |
| 120 | if (verifying.has(p)) return `verifying ${r.version}`; | |
| 120 | async function verifyAndDecide(p: KeeperPorts, r: KeeperRelease): Promise<Step> { | |
| 121 | if (verifying.has(p)) return { line: `verifying ${r.version}`, outcome: "skipped" }; | |
| 121 | 122 | verifying.set(p, r.id); |
| 122 | 123 | try { |
| 123 | 124 | const verdict = await p.verify((t) => keepTally(p.db, r.id, t, r.rollNon200)); |
| @@ -131,9 +132,9 @@ async function verifyAndDecide(p: KeeperPorts, r: KeeperRelease): Promise<string | ||
| 131 | 132 | const lifted = liftFixed(p.db, r.version, p.now()); |
| 132 | 133 | p.bus.append({ kind: "release.verified_live", repoId: p.repo.id, payload: { ...numbers, holdsLifted: lifted } }); |
| 133 | 134 | fileDark(p, getKeeperRelease(p.db, r.id)!); |
| 134 | return `${r.version} verified live: ${kept.probes} probes, worst ${kept.worstMs} ms, ${kept.secondsDark} s dark${stalled === undefined ? "" : `; ${stalled}`}`; | |
| 135 | return { line: `${r.version} verified live: ${kept.probes} probes, worst ${kept.worstMs} ms, ${kept.secondsDark} s dark${stalled === undefined ? "" : `; ${stalled}`}`, outcome: "cut" }; | |
| 135 | 136 | } |
| 136 | return await rollBack(p, kept, verdict.reason); | |
| 137 | return { line: await rollBack(p, kept, verdict.reason), outcome: "failed" }; | |
| 137 | 138 | } finally { |
| 138 | 139 | verifying.delete(p); |
| 139 | 140 | } |
| @@ -171,6 +172,16 @@ async function rollBack(p: KeeperPorts, r: KeeperRelease, reason: string): Promi | ||
| 171 | 172 | |
| 172 | 173 | /** One step of the keeper; the line says what it did or why it did nothing. */ |
| 173 | 174 | export async function tick(p: KeeperPorts): Promise<string> { |
| 175 | return (await tickStep(p)).line; | |
| 176 | } | |
| 177 | ||
| 178 | /** | |
| 179 | * One step with its outcome, said where it is decided (#11507): `cut` when it | |
| 180 | * moved a release (cut one, or verified one live), `failed` when something | |
| 181 | * refused or broke (a verify, a tag, the deployer's admit, main unreadable), | |
| 182 | * else `skipped` with the reason. | |
| 183 | */ | |
| 184 | export async function tickStep(p: KeeperPorts): Promise<Step> { | |
| 174 | 185 | const holds = standingHolds(p.db); |
| 175 | 186 | findCauses(p, holds); |
| 176 | 187 | const flying = inFlight(p.db); |
| @@ -182,14 +193,14 @@ export async function tick(p: KeeperPorts): Promise<string> { | ||
| 182 | 193 | // A cut recorded but never tagged (the process died between the two): tag it now, or drop it. |
| 183 | 194 | if (flying.stage === "rolling" && (await p.newest())?.version !== flying.version) { |
| 184 | 195 | const refused = await tagged(p, flying); |
| 185 | if (refused !== undefined) return refused; | |
| 196 | if (refused !== undefined) return { line: refused, outcome: "failed" }; | |
| 186 | 197 | } |
| 187 | 198 | if (flying.stage === "rolling" && p.now() - Date.parse(flying.cutAt) > ROLL_DEADLINE_MS) |
| 188 | return rollBack(p, flying, `${flying.version} was not live ${ROLL_DEADLINE_MS / 3_600_000} h after its cut`); | |
| 199 | return { line: await rollBack(p, flying, `${flying.version} was not live ${ROLL_DEADLINE_MS / 3_600_000} h after its cut`), outcome: "failed" }; | |
| 189 | 200 | // A promote that did not take (the API refused, or something set the image back) is sent again. |
| 190 | 201 | const want = refFor(p, flying.knownGood.image, flying.version, flying.sha); |
| 191 | 202 | if (flying.stage === "rolling" && (await p.deployer.image()) !== want) await promote(p, flying.version, want); |
| 192 | return `${flying.version} is ${flying.stage}`; | |
| 203 | return { line: `${flying.version} is ${flying.stage}`, outcome: "skipped" }; | |
| 193 | 204 | } |
| 194 | 205 | const main = await p.main(); |
| 195 | 206 | const newest = await p.newest(); |
| @@ -207,19 +218,20 @@ export async function tick(p: KeeperPorts): Promise<string> { | ||
| 207 | 218 | now: p.now(), |
| 208 | 219 | ...(p.minIntervalMs === undefined ? {} : { minIntervalMs: p.minIntervalMs }), |
| 209 | 220 | }); |
| 210 | if (!decision.cut || main === undefined) return decision.cut ? "main is unreadable" : decision.why; | |
| 221 | if (!decision.cut) return { line: decision.why, outcome: "skipped" }; | |
| 222 | if (main === undefined) return { line: "main is unreadable", outcome: "failed" }; | |
| 211 | 223 | // A tree the deployer cannot release (dependencies changed, preflight red) is not cut: no tag, no row. |
| 212 | 224 | const refused = await p.deployer.admits?.(main); |
| 213 | if (refused !== undefined) return `not cut: ${refused}`; | |
| 225 | if (refused !== undefined) return { line: `not cut: ${refused}`, outcome: "failed" }; | |
| 214 | 226 | const image = await p.deployer.image(); |
| 215 | 227 | const knownGood = { version: p.live(), image }; |
| 216 | 228 | // The row first, the tag second: a crash between them leaves a `rolling` row |
| 217 | 229 | // whose tag the next tick sends (`tagged`), never a tag the keeper has no row for. |
| 218 | 230 | const r = recordCut(p.db, { version: decision.version, sha: main, knownGood, at: p.now() }); |
| 219 | 231 | const tagRefused = await tagged(p, r); |
| 220 | if (tagRefused !== undefined) return tagRefused; | |
| 232 | if (tagRefused !== undefined) return { line: tagRefused, outcome: "failed" }; | |
| 221 | 233 | const sent = await promote(p, r.version, refFor(p, image, r.version, main)); |
| 222 | return `cut ${r.version} at ${main.slice(0, 12)}; ${sent}; known good: ${knownGood.version}`; | |
| 234 | return { line: `cut ${r.version} at ${main.slice(0, 12)}; ${sent}; known good: ${knownGood.version}`, outcome: "cut" }; | |
| 223 | 235 | } |
| 224 | 236 | |
| 225 | 237 | const refFor = (p: KeeperPorts, image: string, version: string, sha: string): string => |
src/keeper/landed.ts +15 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { beatLine, lastBeat, SILENT_MS, type Beat } from "./beat.ts"; | |
| 10 | 11 | import { blocking, listHolds, standingHolds, type Hold } from "./hold.ts"; |
| 11 | 12 | import { listKeeperReleases, type KeeperRelease } from "./record.ts"; |
| 12 | 13 | |
| @@ -55,11 +56,24 @@ export function withLandedToLive(db: Database, releases: readonly KeeperRelease[ | ||
| 55 | 56 | return releases.map((r) => ({ ...r, commit: r.sha.slice(0, 7), landedToLiveSeconds: landedToLive(db, r) ?? null })); |
| 56 | 57 | } |
| 57 | 58 | |
| 59 | /** The tick's last heartbeat with its sentence and whether it has gone quiet (#11507); null before the first. */ | |
| 60 | export type Heartbeat = Beat & { readonly silent: boolean; readonly says: string }; | |
| 61 | ||
| 62 | export function heartbeatOf(db: Database, now: Date = new Date()): Heartbeat | null { | |
| 63 | const beat = lastBeat(db); | |
| 64 | if (beat === null) return null; | |
| 65 | return { ...beat, silent: now.getTime() - Date.parse(beat.at) > SILENT_MS, says: beatLine(beat, now) }; | |
| 66 | } | |
| 67 | ||
| 58 | 68 | /** What the keeper did and what stops the next cut: one answer for MCP `release_keeper` and `GET /api/v1/keeper`. */ |
| 59 | export function keeperAnswer(db: Database): { blocked: string | null; releases: LandedRelease[]; holds: Hold[] } { | |
| 69 | export function keeperAnswer( | |
| 70 | db: Database, | |
| 71 | now: Date = new Date(), | |
| 72 | ): { blocked: string | null; releases: LandedRelease[]; holds: Hold[]; heartbeat: Heartbeat | null } { | |
| 60 | 73 | return { |
| 61 | 74 | blocked: blocking(standingHolds(db)) ?? null, |
| 62 | 75 | releases: withLandedToLive(db, listKeeperReleases(db)), |
| 63 | 76 | holds: listHolds(db), |
| 77 | heartbeat: heartbeatOf(db, now), | |
| 64 | 78 | }; |
| 65 | 79 | } |
src/main.ts +3 −2
| @@ -97,7 +97,8 @@ import { proveRunning } from "./release/prove.ts"; | ||
| 97 | 97 | import { CHECK_EVERY_MS, setBehind, watchBehind } from "./release/behind.ts"; |
| 98 | 98 | import { readLandings } from "./release/landings.ts"; |
| 99 | 99 | import { ownTagsReader } from "./release/own_tags.ts"; |
| 100 | import { rollProbe, tick as keeperTick } from "./keeper/keeper.ts"; | |
| 100 | import { rollProbe, tickStep as keeperTick } from "./keeper/keeper.ts"; | |
| 101 | import { stepWithBeat } from "./keeper/beat.ts"; | |
| 101 | 102 | import { keeperEnv, keeperPorts } from "./keeper/wire.ts"; |
| 102 | 103 | import { snapshotPlace } from "./actions/health.ts"; |
| 103 | 104 | import { capacityDefault, egressEnvIgnored } from "./core/capacity.ts"; |
| @@ -824,7 +825,7 @@ if (keeperOn.on) { | ||
| 824 | 825 | else { |
| 825 | 826 | let said = ""; |
| 826 | 827 | const step = () => |
| 827 | void keeperTick(ports) | |
| 828 | void stepWithBeat(app.db, () => keeperTick(ports)) | |
| 828 | 829 | .then((line) => { |
| 829 | 830 | if (line !== said) app.log.info("release keeper", { line }); |
| 830 | 831 | said = line; |
src/release/landings.ts +8
| @@ -139,6 +139,14 @@ export function waitingSince(db: Database): string | undefined { | ||
| 139 | 139 | return onItsWay.at(-1)?.at; |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | /** The merged pulls not yet in the running commit, oldest first, with their merge commits; empty when nothing was read (#11507). */ | |
| 143 | export function waitingRows(db: Database): MergedRow[] { | |
| 144 | if (kept === undefined) return []; | |
| 145 | const rows = mergedRows(db); | |
| 146 | const way = new Set(split(rows, kept).onItsWay.map((l) => `${l.repo}#${l.number}`)); | |
| 147 | return rows.filter((r) => way.has(`${r.repo}#${r.number}`)).reverse(); | |
| 148 | } | |
| 149 | ||
| 142 | 150 | /** How long a miss is believed: a repository can turn public, or merge its first pull, after boot. */ |
| 143 | 151 | const MISS_MS = 60_000; |
| 144 | 152 |
src/system/telemetry.ts +5 −1
| @@ -11,6 +11,7 @@ import type { App } from "../app.ts"; | ||
| 11 | 11 | import { SOURCES } from "../boards/sources.ts"; |
| 12 | 12 | import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../flow/conflicted.ts"; |
| 13 | 13 | import { firedAlarms } from "../flow/alarms.ts"; |
| 14 | import { forgeCeiling } from "../flow/ceiling.ts"; | |
| 14 | 15 | import { running } from "../kernel/version.ts"; |
| 15 | 16 | import { readTalkTurns, talkTurnsWords } from "../flow/talk_turns.ts"; |
| 16 | 17 | import { readTalkResumes, talkResumesWords } from "../flow/talk_resumes.ts"; |
| @@ -18,6 +19,7 @@ import { readVitals, vitalsFor, vitalsLine, vitalsMisses } from "../flow/vitals. | ||
| 18 | 19 | import { parkedSays, readParked } from "../flow/parked.ts"; |
| 19 | 20 | import { readDeclines } from "../flow/declines.ts"; |
| 20 | 21 | import { readKeeper } from "../flow/keeper.ts"; |
| 22 | import { heartbeatOf } from "../keeper/landed.ts"; | |
| 21 | 23 | import { readPipeline, readStepStats, statWords, verdict } from "../flow/pipeline.ts"; |
| 22 | 24 | import { type QueueReading, readQueues } from "../flow/queue.ts"; |
| 23 | 25 | import { reviewersBusy, reviewingSays } from "../flow/reviewing.ts"; |
| @@ -103,6 +105,8 @@ function flowBody(x: { | ||
| 103 | 105 | steps: x.steps.map((m) => ({ ...m, says: statWords(m) })), |
| 104 | 106 | // The cause binding landings now and the keeper's last act (#7645). |
| 105 | 107 | keeper: readKeeper(app.db, reader.kind === "owner"), |
| 108 | // The release tick's last report, reason and all, for the owner only (#11507): the reason may name private work. | |
| 109 | heartbeat: reader.kind === "owner" ? heartbeatOf(app.db, now) : null, | |
| 106 | 110 | // How long each main has gone unverified after landing (#7786); private repositories for the owner only. |
| 107 | 111 | verified: x.verified.map((row) => ({ ...row, says: verifiedSays(row, now.getTime()) })), |
| 108 | 112 | // Issues the crew parked in the last 24 h and 7 d, the newest three named (#6471). |
| @@ -117,7 +121,7 @@ function flowBody(x: { | ||
| 117 | 121 | // Declined build runs of the last 24 h by reason, each with the runs behind it. |
| 118 | 122 | declines: readDeclines(app.db, now, reader.kind === "agent"), |
| 119 | 123 | // The four alarms (#5930) that are firing now; empty when none is. |
| 120 | alarms: firedAlarms(app.db, now, { publicOnly: reader.kind === "agent", dataDir: app.config.dataDir }), | |
| 124 | alarms: firedAlarms(app.db, now, { publicOnly: reader.kind === "agent", dataDir: app.config.dataDir, ceilingUsd: forgeCeiling(app.config) }), | |
| 121 | 125 | // Review runs in flight per reviewer, cut to public repositories for an agent (#4733). |
| 122 | 126 | reviewing: { says: reviewingSays(busy), reviewers: busy }, |
| 123 | 127 | conflicted: { says: conflictedSays(conflicted, now.getTime()), pulls: conflicted }, |
src/tui/forge.ts +2
| @@ -83,6 +83,7 @@ import { readHandoverLists } from "../flow/handover_read.ts"; | ||
| 83 | 83 | import { readDrifts } from "../flow/alarm_drift.ts"; |
| 84 | 84 | import { readHeld } from "../flow/held.ts"; |
| 85 | 85 | import { answerKeeper, readKeeper } from "../flow/keeper.ts"; |
| 86 | import { beatLine, lastBeat } from "../keeper/beat.ts"; | |
| 86 | 87 | import { readVerified } from "../flow/verified.ts"; |
| 87 | 88 | import { memoryScreen } from "./screens/system.ts"; |
| 88 | 89 | import { noteRead, telemetryReader } from "../actions/telemetry.ts"; |
| @@ -502,6 +503,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 502 | 503 | readVerified(session.app, flowReader.kind === "owner"), |
| 503 | 504 | keeper, |
| 504 | 505 | session.app.config.dataDir, |
| 506 | flowReader.kind === "owner" ? beatLine(lastBeat(session.db), new Date()) : undefined, | |
| 505 | 507 | ), keeper.ask && user ? { ask: keeper.ask, answer: () => answerKeeper(session.app, keeper.ask!.id, user.id) } : undefined, |
| 506 | 508 | mayDrift(session) |
| 507 | 509 | ? { read: async () => readHandoverLists(session.db, await readDrifts(session.app), Date.now()), update: (n) => askUpdate(session, n) } |
src/tui/screens/flow.ts +3
| @@ -93,6 +93,8 @@ export function flowLines( | ||
| 93 | 93 | keeper?: KeeperRead, |
| 94 | 94 | /** Where the backup outcome files are kept; without it the off-box backup alarm cannot be read (the terminal passes the app's). */ |
| 95 | 95 | dataDir: string | null = null, |
| 96 | /** The release tick's heartbeat line, an owner's only (#11507). */ | |
| 97 | heartbeat?: string, | |
| 96 | 98 | ): string[] { |
| 97 | 99 | const stages = readPipeline(db, now); |
| 98 | 100 | const flow = readThroughput(db, stages.map((s) => s.key), now); |
| @@ -115,6 +117,7 @@ export function flowLines( | ||
| 115 | 117 | return [ |
| 116 | 118 | ...wrap(verdict(stages, now.getTime()), inner), |
| 117 | 119 | ...(keeper === undefined || keeperLine(keeper) === "" ? [] : ["", ...wrap(keeperLine(keeper), inner)]), |
| 120 | ...(heartbeat === undefined ? [] : ["", ...wrap(heartbeat, inner)]), | |
| 118 | 121 | ...(keeper?.ask == null ? [] : wrap(`${keeper.ask.question} Press l to ${keeper.ask.button.toLowerCase()}.`, inner)), |
| 119 | 122 | "", |
| 120 | 123 | ...wrap(vitalsLine(vitalsFor(readVitals(db, now, agent !== undefined), agent === undefined)), inner), |
src/tui/screens/keeper.ts +21 −4
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The release keeper in the terminal (#7557), the /releases card's read: |
| 3 | * what holds the next cut, then each release the keeper cut with its stage, | |
| 3 | * what holds the next cut, the release tick's heartbeat and whether main is | |
| 4 | * green and ahead of production (#11507), then each release the keeper cut with its stage, | |
| 4 | 5 | * its short commit, landed to live (#8042), the known-good version it rolls |
| 5 | 6 | * back to and what the probes counted. |
| 6 | 7 | * Gated by `release/gate.ts` as the page is. `h` holds releases with a |
| @@ -8,6 +9,8 @@ | ||
| 8 | 9 | * functions the card and MCP `release_hold` call. |
| 9 | 10 | */ |
| 10 | 11 | |
| 12 | import { aheadLine } from "../../flow/alarm_main_ahead.ts"; | |
| 13 | import { beatLine, lastBeat } from "../../keeper/beat.ts"; | |
| 11 | 14 | import { stallWords } from "../../perf/boot_stall.ts"; |
| 12 | 15 | import { blocking, holdAs, liftAs, standingHolds } from "../../keeper/hold.ts"; |
| 13 | 16 | import { secondsWords, withLandedToLive, type LandedRelease } from "../../keeper/landed.ts"; |
| @@ -32,13 +35,23 @@ const STAGE: Record<Stage, { label: string; tone: Tone }> = { | ||
| 32 | 35 | export interface KeeperRead { |
| 33 | 36 | /** What holds the next cut, or the line that says nothing does. */ |
| 34 | 37 | readonly blocked: string; |
| 38 | /** The release tick's last heartbeat as a sentence, said plainly when there is none or it is old. */ | |
| 39 | readonly heartbeat: string; | |
| 40 | /** Main green and ahead of production, and for how long; absent when it is not. */ | |
| 41 | readonly ahead?: string; | |
| 35 | 42 | readonly releases: readonly LandedRelease[]; |
| 36 | 43 | } |
| 37 | 44 | |
| 38 | 45 | /** The card's content for this person; undefined for anyone the page would 404. */ |
| 39 | export function readKeeper(f: Session): KeeperRead | undefined { | |
| 46 | export function readKeeper(f: Session, now: Date = new Date()): KeeperRead | undefined { | |
| 40 | 47 | if (!mayRead(f.app, f.user)) return undefined; |
| 41 | return { blocked: blocking(standingHolds(f.db)) ?? "Nothing holds the next cut.", releases: withLandedToLive(f.db, listKeeperReleases(f.db)) }; | |
| 48 | const ahead = aheadLine(f.db, now); | |
| 49 | return { | |
| 50 | blocked: blocking(standingHolds(f.db)) ?? "Nothing holds the next cut.", | |
| 51 | heartbeat: beatLine(lastBeat(f.db), now), | |
| 52 | ...(ahead === undefined ? {} : { ahead }), | |
| 53 | releases: withLandedToLive(f.db, listKeeperReleases(f.db)), | |
| 54 | }; | |
| 42 | 55 | } |
| 43 | 56 | |
| 44 | 57 | export function keeperRows(releases: readonly LandedRelease[]): ListRow[] { |
| @@ -90,7 +103,11 @@ export function keeperScreen(f: Session, ctx: Ctx): Screen { | ||
| 90 | 103 | return; |
| 91 | 104 | } |
| 92 | 105 | // What holds the cut ends with what to land ("release hold N"): wrapped, up to three lines, never cut short of it. |
| 93 | const lines = wrap(read.blocked, area.w).slice(0, 3); | |
| 106 | const lines = [ | |
| 107 | ...wrap(read.blocked, area.w).slice(0, 3), | |
| 108 | ...wrap(read.heartbeat, area.w).slice(0, 2), | |
| 109 | ...(read.ahead === undefined ? [] : wrap(read.ahead, area.w).slice(0, 2)), | |
| 110 | ]; | |
| 94 | 111 | const [head, body] = split(area, "v", [len(lines.length + 1), fill(1)]); |
| 95 | 112 | lines.forEach((l, i) => s.text(head!.x, head!.y + i, truncate(l, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w)); |
| 96 | 113 | if (read.releases.length === 0) { |
src/web/pages/flow-panel.ts +4 −1
| @@ -53,6 +53,7 @@ import type { App } from "../../app.ts"; | ||
| 53 | 53 | import { html, type Html } from "../html.ts"; |
| 54 | 54 | import { doctorCard } from "./boards-doctor.ts"; |
| 55 | 55 | import { keeperRow } from "../studio/keeper_row.ts"; |
| 56 | import { beatFact, lastBeat } from "../../keeper/beat.ts"; | |
| 56 | 57 | import { gatesSection } from "./boards-gates.ts"; |
| 57 | 58 | import { heldCard } from "./boards-held.ts"; |
| 58 | 59 | import { badge, card, meter } from "../ui.ts"; |
| @@ -127,7 +128,9 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 127 | 128 | ["Verified after landing", html`A repository that declares <span class="mono">check.verify</span> in forge.json has its main verified after landing.`], |
| 128 | 129 | ["Elsewhere", html`Limits are set on <a href="/settings/capacity">Capacity</a>. How the forge stands against its threats is on <a href="/resilience">Resilience</a>; where an outside agent can help is on <a href="/contribute">Contribute</a>.`], |
| 129 | 130 | ], helpAsked(url)); |
| 130 | return html`${keeperRow(app.db, reader.kind === "owner")}${help}${card( | |
| 131 | return html`${keeperRow(app.db, reader.kind === "owner")}${reader.kind === "owner" | |
| 132 | ? html`<dl class="status-rows" id="keeper-heartbeat-row"><div class="status-row"><dt>Release tick</dt><dd id="keeper-heartbeat">${beatFact(lastBeat(app.db), new Date(now))}</dd></div></dl>` | |
| 133 | : ""}${help}${card( | |
| 131 | 134 | html`<p id="bottleneck" data-stage="${worst?.key ?? ""}"> |
| 132 | 135 | <strong>${verdict(stages, now)}</strong> |
| 133 | 136 | </p> |
src/web/pages/keeper-panel.ts +12 −3
| @@ -1,11 +1,14 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The release keeper on /releases (#7557): what stops the next cut, and |
| 3 | 3 | * each release it cut with its stage, the known-good version it rolls back |
| 4 | * to, and what the probes counted. Nothing when it has done nothing. | |
| 4 | * to, and what the probes counted; the release tick's heartbeat and whether | |
| 5 | * main is green and ahead of production (#11507). Nothing when it has done nothing. | |
| 5 | 6 | * A plain section under an h2, no card (#9998). |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { aheadLine } from "../../flow/alarm_main_ahead.ts"; | |
| 11 | import { beatFact, lastBeat } from "../../keeper/beat.ts"; | |
| 9 | 12 | import { blocking, MAX_REASON, standingHolds } from "../../keeper/hold.ts"; |
| 10 | 13 | import { secondsWords, withLandedToLive } from "../../keeper/landed.ts"; |
| 11 | 14 | import { listKeeperReleases, type Stage } from "../../keeper/record.ts"; |
| @@ -21,11 +24,13 @@ const STAGE: Record<Stage, { label: string; tone: Tone }> = { | ||
| 21 | 24 | }; |
| 22 | 25 | |
| 23 | 26 | /** `mayHold` is `mayRollBack`: the section then offers the owner's hold and its lift. */ |
| 24 | export function keeperPanel(db: Database, mayHold = false): Html | undefined { | |
| 27 | export function keeperPanel(db: Database, mayHold = false, now: Date = new Date()): Html | undefined { | |
| 25 | 28 | const releases = withLandedToLive(db, listKeeperReleases(db)); |
| 26 | 29 | const holds = standingHolds(db); |
| 27 | 30 | const blocked = blocking(holds); |
| 28 | if (releases.length === 0 && blocked === undefined && !mayHold) return undefined; | |
| 31 | const beat = lastBeat(db); | |
| 32 | const ahead = aheadLine(db, now); | |
| 33 | if (releases.length === 0 && blocked === undefined && beat === null && ahead === undefined && !mayHold) return undefined; | |
| 29 | 34 | const rows = releases.map((r, i) => { |
| 30 | 35 | const s = STAGE[r.stage]; |
| 31 | 36 | return html`<tr> |
| @@ -43,6 +48,10 @@ export function keeperPanel(db: Database, mayHold = false): Html | undefined { | ||
| 43 | 48 | return html`<section class="settings-block" id="keeper"> |
| 44 | 49 | <h2>Release keeper</h2> |
| 45 | 50 | <p class="small ${blocked === undefined ? "muted" : ""}" id="keeper-blocked">${blocked ?? "Nothing holds the next cut."}</p> |
| 51 | <dl class="status-rows" id="keeper-facts"> | |
| 52 | <div class="status-row"><dt>Release tick</dt><dd id="keeper-heartbeat">${beatFact(beat, now)}</dd></div> | |
outdated
Principles 1 and 7: a third stacked sentence. Make the heartbeat and ahead facts dl rows, not more paragraphs. |
||
| 53 | ${when(ahead !== undefined, () => html`<div class="status-row"><dt>Main vs production</dt><dd id="keeper-ahead">${ahead}</dd></div>`)} | |
| 54 | </dl> | |
| 46 | 55 | ${when(rows.length > 0, () => html`<div class="table-scroll" role="region" tabindex="0" aria-label="Keeper releases"><table class="table" data-sticky-lead> |
| 47 | 56 | <thead><tr><th>Version</th><th>Cut</th><th>Landed to live</th><th>Known good</th><th>Roll non-200</th><th>Probes / pages</th><th>Worst</th><th>Stall, first 5 min</th><th>Dark</th></tr></thead> |
| 48 | 57 | <tbody>${rows}</tbody> |
test/flow-alarm-lane.test.ts added +203
| @@ -0,0 +1,203 @@ | ||
| 1 | /** The lane has stopped (#11507): ten open pulls, 91 minutes without a merge and one repeated failure line raise exactly one alarm; a merge clears it. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo, getRepo } from "../src/core/repos.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { getRun } from "../src/core/runs.ts"; | |
| 10 | import { ownLandingStopped } from "../src/checks/gate_stopped.ts"; | |
| 11 | import { hunt } from "../src/crew/sentinel.ts"; | |
| 12 | import { firedAlarms } from "../src/flow/alarms.ts"; | |
| 13 | import { forgeFailed, laneAlarm, laneMinutes, maskLine } from "../src/flow/alarm_lane.ts"; | |
| 14 | import { id } from "../src/kernel/id.ts"; | |
| 15 | ||
| 16 | const NOW = new Date("2026-11-12T17:11:00.000Z"); | |
| 17 | const ago = (m: number) => new Date(NOW.getTime() - m * 60_000).toISOString(); | |
| 18 | const LINE = "stopped: used 16.0 cores for 30s, over its limit of 16"; | |
| 19 | ||
| 20 | let root: string; | |
| 21 | let app: App; | |
| 22 | let repoId: string; | |
| 23 | let userId: string; | |
| 24 | let n = 0; | |
| 25 | const saved = { sentinel: process.env["FORGE_SENTINEL_REPO"], commit: process.env["FORGE_COMMIT"] }; | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | process.env["FORGE_SENTINEL_REPO"] = "trevato/forge"; | |
| 29 | process.env["FORGE_COMMIT"] = "abcdef1234567890abcdef1234567890abcdef12"; | |
| 30 | root = mkdtempSync(join(tmpdir(), "forge-lane-")); | |
| 31 | app = createApp({ dataDir: root, port: 0 }); | |
| 32 | const u = await createUser(app.db, { username: "trevato", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 33 | if (!u.ok) throw new Error(u.error); | |
| 34 | userId = u.value.id; | |
| 35 | const repo = await createRepo(app.db, app.reposRoot, { ownerId: u.value.id, name: "forge", visibility: "public", description: "f" }); | |
| 36 | if (!repo.ok) throw new Error(repo.error); | |
| 37 | repoId = repo.value.id; | |
| 38 | }); | |
| 39 | afterAll(() => { | |
| 40 | app.close(); | |
| 41 | rmSync(root, { recursive: true, force: true }); | |
| 42 | for (const [k, v] of [["FORGE_SENTINEL_REPO", saved.sentinel], ["FORGE_COMMIT", saved.commit]] as const) | |
| 43 | if (v === undefined) delete process.env[k]; | |
| 44 | else process.env[k] = v; | |
| 45 | }); | |
| 46 | ||
| 47 | function pull(state: "open" | "closed", mergedMinAgo?: number): string { | |
| 48 | const issueId = id(); | |
| 49 | n++; | |
| 50 | app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 'P', '', ?4, ?5, ?6)`).run(issueId, repoId, 2000 + n, userId, state, ago(600)); | |
| 51 | app.db | |
| 52 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at, merged_oid) VALUES (?1, 'main', ?2, '', '', ?3, ?4, ?5)`) | |
| 53 | .run(issueId, `b/${n}`, repoId, mergedMinAgo === undefined ? null : ago(mergedMinAgo), mergedMinAgo === undefined ? "" : "9".repeat(40)); | |
| 54 | return issueId; | |
| 55 | } | |
| 56 | function failed(issueId: string, minAgo: number, summary = LINE, detail = "{}"): string { | |
| 57 | const runId = `${String(++n).padStart(4, "0")}${id()}`; | |
| 58 | app.db | |
| 59 | .query(`INSERT INTO run (id, repo_id, issue_id, kind, env, state, summary, check_detail, commit_oid, created_at, finished_at) VALUES (?1, ?2, ?3, 'check', 'pull-1', 'failed', ?4, ?5, 'c', ?6, ?6)`) | |
| 60 | .run(runId, repoId, issueId, summary, detail, ago(minAgo)); | |
| 61 | return runId; | |
| 62 | } | |
| 63 | const reset = () => { | |
| 64 | app.db.query(`DELETE FROM run`).run(); | |
| 65 | app.db.query(`DELETE FROM pull`).run(); | |
| 66 | app.db.query(`DELETE FROM issue`).run(); | |
| 67 | }; | |
| 68 | const lane = () => firedAlarms(app.db, NOW, { dataDir: null }).filter((a) => a.key === "lane_stopped"); | |
| 69 | ||
| 70 | /** Ten open pulls and a last merge `since` minutes ago; one failed check apiece after it. */ | |
| 71 | function stalled(since: number): string[] { | |
| 72 | reset(); | |
| 73 | pull("closed", since); | |
| 74 | const pulls = Array.from({ length: 10 }, () => pull("open")); | |
| 75 | pulls.forEach((p, i) => { | |
| 76 | failed(p, 75 + i, LINE.replace("16.0", `1${i}.0`)); | |
| 77 | }); | |
| 78 | return pulls; | |
| 79 | } | |
| 80 | ||
| 81 | describe("the lane has stopped", () => { | |
| 82 | test("ten open pulls, 91 minutes without a merge and one repeated line raise exactly one alarm with that line", () => { | |
| 83 | stalled(91); | |
| 84 | const a = lane(); | |
| 85 | expect(a).toHaveLength(1); | |
| 86 | expect(a[0]!.says).toContain("Nothing has merged for 1 h 31 min"); | |
| 87 | expect(a[0]!.says).toContain("10 pulls open"); | |
| 88 | expect(a[0]!.says).toContain("10 checks failed"); | |
| 89 | expect(a[0]!.since).toBe(ago(91)); | |
| 90 | // The commonest line, verbatim as the forge holds it (the newest instance of the masked group). | |
| 91 | expect(laneAlarm(app.db, NOW).line).toBe(LINE.replace("16.0", "10.0")); | |
| 92 | }); | |
| 93 | ||
| 94 | test("the line is quoted as recorded, with its count in the span", () => { | |
| 95 | reset(); | |
| 96 | pull("closed", 120); | |
| 97 | const p = pull("open"); | |
| 98 | for (let i = 0; i < 3; i++) failed(p, 100 - i); | |
| 99 | failed(p, 50, "check failed: install broke"); | |
| 100 | const a = laneAlarm(app.db, NOW); | |
| 101 | expect(a.fired).toBe(true); | |
| 102 | expect(a.says).toContain(`4 checks failed in that span, 3 of them with: ${LINE}`); | |
| 103 | }); | |
| 104 | ||
| 105 | test("89 minutes is quiet, and so is a setting that moves the limit", () => { | |
| 106 | stalled(89); | |
| 107 | expect(lane()).toHaveLength(0); | |
| 108 | app.db.query(`INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES ('flow.lane_minutes', '60', 't', 'now')`).run(); | |
| 109 | expect(laneMinutes(app.db)).toBe(60); | |
| 110 | expect(lane()).toHaveLength(1); | |
| 111 | app.db.query(`UPDATE forge_setting SET value = 'abc' WHERE key = 'flow.lane_minutes'`).run(); | |
| 112 | expect(laneMinutes(app.db)).toBe(90); | |
| 113 | app.db.query(`DELETE FROM forge_setting WHERE key = 'flow.lane_minutes'`).run(); | |
| 114 | }); | |
| 115 | ||
| 116 | test("nothing open or ready, or the crew at its ceiling, is quiet", () => { | |
| 117 | reset(); | |
| 118 | pull("closed", 300); | |
| 119 | expect(lane()).toHaveLength(0); | |
| 120 | pull("open"); | |
| 121 | expect(firedAlarms(app.db, NOW, { dataDir: null, ceilingUsd: null }).filter((a) => a.key === "lane_stopped")).toHaveLength(1); | |
| 122 | app.db | |
| 123 | .query(`INSERT INTO run (id, repo_id, kind, state, cost_usd, created_at, summary, check_detail) VALUES (?1, ?2, 'agent', 'ok', 50, ?3, '', '')`) | |
| 124 | .run(id(), repoId, new Date(NOW.getTime() - 60_000).toISOString()); | |
| 125 | expect(firedAlarms(app.db, NOW, { dataDir: null, ceilingUsd: 20 }).filter((a) => a.key === "lane_stopped")).toHaveLength(0); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a merge clears it", () => { | |
| 129 | stalled(91); | |
| 130 | expect(lane()).toHaveLength(1); | |
| 131 | pull("closed", 1); | |
| 132 | expect(lane()).toHaveLength(0); | |
| 133 | }); | |
| 134 | ||
| 135 | test("it reaches the owner's ask and one Sentinel issue, which closes when a pull merges", () => { | |
| 136 | stalled(91); | |
| 137 | const owner = app.db.query<{ id: string }, []>(`SELECT id FROM user WHERE username = 'trevato'`).get()!; | |
| 138 | expect(owner.id).toBe(userId); | |
| 139 | hunt(app, "trevato/forge", NOW.getTime()); | |
| 140 | hunt(app, "trevato/forge", NOW.getTime() + 60_000); | |
| 141 | const open = () => app.db.query<{ body: string }, []>(`SELECT body FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: %lane has stopped%'`).all(); | |
| 142 | expect(open()).toHaveLength(1); | |
| 143 | expect(open()[0]!.body).toContain("stopped: used"); | |
| 144 | expect(open()[0]!.body).toContain("checks failed since the last merge"); | |
| 145 | pull("closed", 1); | |
| 146 | hunt(app, "trevato/forge", NOW.getTime() + 120_000); | |
| 147 | expect(open()).toHaveLength(0); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a sweep reads the forge's lines once: the memo answers the second pull", () => { | |
| 151 | reset(); | |
| 152 | pull("closed", 10); | |
| 153 | const runs = Array.from({ length: 5 }, (_, i) => failed(pull("open"), 5 + i, LINE.replace("16.0", `${i + 11}.5`))); | |
| 154 | const memo = new Map<string, ReadonlySet<string>>(); | |
| 155 | expect(forgeFailed(app.db, getRun(app.db, runs[0]!), NOW.getTime(), memo)).toBe(true); | |
| 156 | expect(memo.get(repoId)?.size).toBe(1); | |
| 157 | app.db.query(`DELETE FROM run WHERE id != ?1`).run(runs[1]!); | |
| 158 | expect(forgeFailed(app.db, getRun(app.db, runs[1]!), NOW.getTime(), memo)).toBe(true); | |
| 159 | expect(forgeFailed(app.db, getRun(app.db, runs[1]!), NOW.getTime())).toBe(false); | |
| 160 | }); | |
| 161 | ||
| 162 | test("land_past_gate stays shut: a merge drought is not a red gate", () => { | |
| 163 | stalled(91); | |
| 164 | expect(laneAlarm(app.db, NOW).fired).toBe(true); | |
| 165 | const repo = getRepo(app.db, "trevato", "forge")!; | |
| 166 | expect(ownLandingStopped(app.db, repo)).toBeUndefined(); | |
| 167 | }); | |
| 168 | }); | |
| 169 | ||
| 170 | describe("one cause failing many pulls", () => { | |
| 171 | test("five pulls in an hour on one masked line are the forge's: alarm names it, the check is the forge's", () => { | |
| 172 | reset(); | |
| 173 | pull("closed", 10); | |
| 174 | const pulls = Array.from({ length: 5 }, () => pull("open")); | |
| 175 | const runs = pulls.map((p, i) => failed(p, 5 + i, LINE.replace("16.0", `${i + 11}.5`))); | |
| 176 | const a = lane(); | |
| 177 | expect(a).toHaveLength(1); | |
| 178 | expect(a[0]!.says).toContain("the forge's failure, not the builders'"); | |
| 179 | expect(a[0]!.says).toContain("5 pulls"); | |
| 180 | expect(laneAlarm(app.db, NOW).forge).toBe(true); | |
| 181 | expect(forgeFailed(app.db, getRun(app.db, runs[0]!), NOW.getTime())).toBe(true); | |
| 182 | }); | |
| 183 | ||
| 184 | test("four pulls, or five over two hours, or a named test, are the pull's own", () => { | |
| 185 | reset(); | |
| 186 | pull("closed", 10); | |
| 187 | const pulls = Array.from({ length: 5 }, () => pull("open")); | |
| 188 | const runs = pulls.slice(0, 4).map((p, i) => failed(p, 5 + i)); | |
| 189 | expect(lane()).toHaveLength(0); | |
| 190 | expect(forgeFailed(app.db, getRun(app.db, runs[0]!), NOW.getTime())).toBe(false); | |
| 191 | failed(pulls[4]!, 130); | |
| 192 | expect(forgeFailed(app.db, getRun(app.db, runs[0]!), NOW.getTime())).toBe(false); | |
| 193 | reset(); | |
| 194 | pull("closed", 10); | |
| 195 | const named = Array.from({ length: 5 }, () => pull("open")).map((p, i) => failed(p, 5 + i, "1 test failed", JSON.stringify({ names: ["a > b"] }))); | |
| 196 | expect(lane()).toHaveLength(0); | |
| 197 | expect(forgeFailed(app.db, getRun(app.db, named[0]!), NOW.getTime())).toBe(false); | |
| 198 | }); | |
| 199 | ||
| 200 | test("numbers are masked", () => { | |
| 201 | expect(maskLine(LINE)).toBe("stopped: used # cores for #s, over its limit of #"); | |
| 202 | }); | |
| 203 | }); |
test/flow-alarm-main-ahead.test.ts added +313
| @@ -0,0 +1,313 @@ | ||
| 1 | /** Main green and ahead of production (#11507): one alarm past 90 min with the facts, none at 89, closes on catch-up; the heartbeat shows. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/core/repos.ts"; | |
| 8 | import { createUser, type User } from "../src/core/users.ts"; | |
| 9 | import { addComment } from "../src/actions/issues.ts"; | |
| 10 | import { getIssueById } from "../src/core/issues.ts"; | |
| 11 | import { getRepo } from "../src/core/repos.ts"; | |
| 12 | import { hunt } from "../src/crew/sentinel.ts"; | |
| 13 | import { firedAlarms } from "../src/flow/alarms.ts"; | |
| 14 | import { aheadMinutes, mainAheadAlarm } from "../src/flow/alarm_main_ahead.ts"; | |
| 15 | import { lastBeat, recordBeat, stepWithBeat } from "../src/keeper/beat.ts"; | |
| 16 | import { keeperPanel } from "../src/web/pages/keeper-panel.ts"; | |
| 17 | import { keeperAnswer } from "../src/keeper/landed.ts"; | |
| 18 | import { tickStep } from "../src/keeper/keeper.ts"; | |
| 19 | import { keeperForge } from "./helpers/keeper.ts"; | |
| 20 | import { id } from "../src/kernel/id.ts"; | |
| 21 | import { forgetLive, keepLive } from "../src/release/landings.ts"; | |
| 22 | import { alarmLines } from "../src/crew/notify.ts"; | |
| 23 | import { homeTruth } from "../src/system/home_truth.ts"; | |
| 24 | import { Session } from "../src/tui/forge.ts"; | |
| 25 | import { keeperScreen, readKeeper } from "../src/tui/screens/keeper.ts"; | |
| 26 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 27 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 28 | import { settleApp } from "./helpers/tui.ts"; | |
| 29 | import { flowFor } from "../src/system/telemetry.ts"; | |
| 30 | import { flowPanel } from "../src/web/pages/flow-panel.ts"; | |
| 31 | ||
| 32 | const NOW = new Date("2026-11-11T12:00:00.000Z"); | |
| 33 | const ago = (m: number) => new Date(NOW.getTime() - m * 60_000).toISOString(); | |
| 34 | const PROD = "abcdef1234567890abcdef1234567890abcdef12"; | |
| 35 | const HEAD = "1111111111111111111111111111111111111111"; | |
| 36 | ||
| 37 | let root: string; | |
| 38 | let app: App; | |
| 39 | let repoId: string; | |
| 40 | let branch: string; | |
| 41 | let userId: string; | |
| 42 | let owner: User; | |
| 43 | let n = 0; | |
| 44 | const saved = { sentinel: process.env["FORGE_SENTINEL_REPO"], commit: process.env["FORGE_COMMIT"], version: process.env["FORGE_VERSION"] }; | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | process.env["FORGE_SENTINEL_REPO"] = "trevato/forge"; | |
| 48 | process.env["FORGE_COMMIT"] = PROD; | |
| 49 | process.env["FORGE_VERSION"] = "0.7.535"; | |
| 50 | root = mkdtempSync(join(tmpdir(), "forge-main-ahead-")); | |
| 51 | app = createApp({ dataDir: root, port: 0 }); | |
| 52 | const u = await createUser(app.db, { username: "trevato", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 53 | if (!u.ok) throw new Error(u.error); | |
| 54 | userId = u.value.id; | |
| 55 | owner = u.value; | |
| 56 | const repo = await createRepo(app.db, app.reposRoot, { ownerId: u.value.id, name: "forge", visibility: "public", description: "f" }); | |
| 57 | if (!repo.ok) throw new Error(repo.error); | |
| 58 | repoId = repo.value.id; | |
| 59 | branch = repo.value.defaultBranch; | |
| 60 | }); | |
| 61 | afterAll(() => { | |
| 62 | app.close(); | |
| 63 | rmSync(root, { recursive: true, force: true }); | |
| 64 | for (const [k, v] of [["FORGE_SENTINEL_REPO", saved.sentinel], ["FORGE_COMMIT", saved.commit], ["FORGE_VERSION", saved.version]] as const) | |
| 65 | if (v === undefined) delete process.env[k]; | |
| 66 | else process.env[k] = v; | |
| 67 | forgetLive(); | |
| 68 | }); | |
| 69 | ||
| 70 | function check(state: "ok" | "failed", oid: string): void { | |
| 71 | app.db | |
| 72 | .query(`INSERT INTO run (id, repo_id, kind, env, state, summary, check_detail, commit_oid, created_at, finished_at) VALUES (?1, ?2, 'check', ?3, ?4, '', '{}', ?5, ?6, ?6)`) | |
| 73 | .run(`${String(++n).padStart(4, "0")}${id()}`, repoId, branch, state, oid, ago(1)); | |
| 74 | } | |
| 75 | function merge(minAgo: number, oid: string): void { | |
| 76 | const issueId = id(); | |
| 77 | n++; | |
| 78 | app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 'P', '', ?4, 'closed', ?5)`).run(issueId, repoId, 1000 + n, userId, ago(minAgo)); | |
| 79 | app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at, merged_oid) VALUES (?1, ?2, ?3, '', '', ?4, ?5, ?6)`).run(issueId, branch, `b/${n}`, repoId, ago(minAgo), oid); | |
| 80 | } | |
| 81 | const reset = () => { | |
| 82 | app.db.query(`DELETE FROM run`).run(); | |
| 83 | app.db.query(`DELETE FROM pull`).run(); | |
| 84 | keepLive("trevato/forge", [], NOW.getTime() - 600 * 60_000); | |
| 85 | }; | |
| 86 | ||
| 87 | describe("main ahead alarm", () => { | |
| 88 | test("none at 89 minutes, one at 91 with the facts", () => { | |
| 89 | reset(); | |
| 90 | check("ok", HEAD); | |
| 91 | merge(89, "2".repeat(40)); | |
| 92 | expect(mainAheadAlarm(app.db, NOW).fired).toBe(false); | |
| 93 | expect(firedAlarms(app.db, NOW, { dataDir: null }).filter((a) => a.key === "main_ahead")).toHaveLength(0); | |
| 94 | reset(); | |
| 95 | check("ok", HEAD); | |
| 96 | merge(91, "2".repeat(40)); | |
| 97 | merge(30, "3".repeat(40)); | |
| 98 | recordBeat(app.db, "skipped", "no cut: a hold stands", new Date(NOW.getTime() - 61 * 60_000)); | |
| 99 | const fired = firedAlarms(app.db, NOW, { dataDir: null }).filter((a) => a.key === "main_ahead"); | |
| 100 | expect(fired).toHaveLength(1); | |
| 101 | const says = fired[0]!.says; | |
| 102 | expect(says).toContain("0.7.535"); | |
| 103 | expect(says).toContain("abcdef1"); | |
| 104 | expect(says).toContain("2 commits and 2 pulls"); | |
| 105 | expect(says).toContain("1 h 31 min"); | |
| 106 | expect(says).toContain("1 h 1 min ago"); | |
| 107 | expect(fired[0]!.since).toBe(ago(91)); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a red head, or production on the head, does not fire", () => { | |
| 111 | reset(); | |
| 112 | merge(200, "2".repeat(40)); | |
| 113 | check("failed", HEAD); | |
| 114 | expect(mainAheadAlarm(app.db, NOW).fired).toBe(false); | |
| 115 | check("ok", PROD); | |
| 116 | expect(mainAheadAlarm(app.db, NOW).fired).toBe(false); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the limit is a setting", () => { | |
| 120 | reset(); | |
| 121 | check("ok", HEAD); | |
| 122 | merge(50, "2".repeat(40)); | |
| 123 | expect(mainAheadAlarm(app.db, NOW).fired).toBe(false); | |
| 124 | app.db.query(`INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES ('release.ahead_minutes', '45', 'test', 'now') ON CONFLICT (key) DO UPDATE SET value = '45'`).run(); | |
| 125 | expect(mainAheadAlarm(app.db, NOW).fired).toBe(true); | |
| 126 | app.db.query(`DELETE FROM forge_setting WHERE key = 'release.ahead_minutes'`).run(); | |
| 127 | }); | |
| 128 | ||
| 129 | test("it reaches the owner's ask and one Sentinel issue, which closes when production catches up", () => { | |
| 130 | reset(); | |
| 131 | check("ok", HEAD); | |
| 132 | merge(120, "2".repeat(40)); | |
| 133 | expect(owner.isAdmin).toBe(true); | |
| 134 | const lines = alarmLines(app, owner, NOW.getTime()); | |
| 135 | expect(lines.some((l) => l.id.startsWith("alarm:main_ahead@"))).toBe(true); | |
| 136 | hunt(app, "trevato/forge", NOW.getTime()); | |
| 137 | hunt(app, "trevato/forge", NOW.getTime() + 60_000); | |
| 138 | const open = () => app.db.query<{ title: string }, []>(`SELECT title FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Main is green and ahead%'`).all(); | |
| 139 | expect(open()).toHaveLength(1); | |
| 140 | // Production catches up: it runs the head and the pull is in the running commit. | |
| 141 | keepLive("trevato/forge", ["2".repeat(40)], NOW.getTime()); | |
| 142 | hunt(app, "trevato/forge", NOW.getTime() + 120_000); | |
| 143 | expect(open()).toHaveLength(0); | |
| 144 | }); | |
| 145 | }); | |
| 146 | ||
| 147 | describe("heartbeat", () => { | |
| 148 | test("release_keeper shows the last heartbeat and its reason, and says when it is quiet", () => { | |
| 149 | app.db.query(`DELETE FROM keeper_beat`).run(); | |
| 150 | expect(keeperAnswer(app.db, NOW).heartbeat).toBeNull(); | |
| 151 | recordBeat(app.db, "skipped", "not cut: a hold stands", new Date(NOW.getTime() - 5 * 60_000)); | |
| 152 | const h = keeperAnswer(app.db, NOW).heartbeat!; | |
| 153 | expect(h).toMatchObject({ outcome: "skipped", reason: "not cut: a hold stands", silent: false }); | |
| 154 | recordBeat(app.db, "failed", "tag refused", new Date(NOW.getTime() - 61 * 60_000)); | |
| 155 | const quiet = keeperAnswer(app.db, NOW).heartbeat!; | |
| 156 | expect(quiet.silent).toBe(true); | |
| 157 | expect(quiet.says).toContain("No heartbeat from the release tick for 1 h 1 min"); | |
| 158 | expect(lastBeat(app.db)!.reason).toBe("tag refused"); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("home telemetry", () => { | |
| 163 | test("live names the commit production runs, not main's newest deploy", () => { | |
| 164 | reset(); | |
| 165 | app.db.query(`INSERT INTO run (id, repo_id, kind, env, state, commit_oid, created_at, serving_at) VALUES (?1, ?2, 'deploy', 'production', 'ok', ?3, ?4, ?4)`).run(id(), repoId, HEAD, ago(5)); | |
| 166 | const t = homeTruth(app, owner, NOW); | |
| 167 | expect(t.live).toEqual({ sha: PROD, version: "0.7.535" }); | |
| 168 | }); | |
| 169 | ||
| 170 | test("with the running commit unknown, live is null", () => { | |
| 171 | const commit = process.env["FORGE_COMMIT"]; | |
| 172 | delete process.env["FORGE_COMMIT"]; | |
| 173 | try { | |
| 174 | expect(homeTruth(app, owner, NOW).live).toBeNull(); | |
| 175 | } finally { | |
| 176 | process.env["FORGE_COMMIT"] = commit; | |
| 177 | } | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | describe("the main-ahead issue", () => { | |
| 182 | const open = () => app.db.query<{ id: string; body: string }, []>(`SELECT id, body FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Main is green and ahead%'`).all(); | |
| 183 | ||
| 184 | test("keeps the tick's reason out of the issue, and stays open once a person spoke on it", async () => { | |
| 185 | reset(); | |
| 186 | check("ok", HEAD); | |
| 187 | merge(120, "4".repeat(40)); | |
| 188 | recordBeat(app.db, "skipped", "private words from the tick", new Date(NOW.getTime() - 5 * 60_000)); | |
| 189 | hunt(app, "trevato/forge", NOW.getTime() + 600_000); | |
| 190 | const filed = open(); | |
| 191 | expect(filed).toHaveLength(1); | |
| 192 | expect(filed[0]!.body).toContain("skipped"); | |
| 193 | expect(filed[0]!.body).not.toContain("private words"); | |
| 194 | const repo = getRepo(app.db, "trevato", "forge")!; | |
| 195 | const said = addComment(app, owner, repo, getIssueById(app.db, filed[0]!.id)!, "Looking at the tick now."); | |
| 196 | expect(said.ok).toBe(true); | |
| 197 | keepLive("trevato/forge", ["4".repeat(40)], NOW.getTime()); | |
| 198 | hunt(app, "trevato/forge", NOW.getTime() + 720_000); | |
| 199 | expect(open()).toHaveLength(1); | |
| 200 | }); | |
| 201 | }); | |
| 202 | ||
| 203 | describe("the limit setting", () => { | |
| 204 | const set = (v: string) => | |
| 205 | app.db.query(`INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES ('release.ahead_minutes', ?1, 'test', 'now') ON CONFLICT (key) DO UPDATE SET value = ?1`).run(v); | |
| 206 | test("a value out of range or not a whole number is 90", () => { | |
| 207 | for (const bad of ["0", "20000", "abc", "1.5", ""]) { | |
| 208 | set(bad); | |
| 209 | expect(aheadMinutes(app.db)).toBe(90); | |
| 210 | } | |
| 211 | set("10080"); | |
| 212 | expect(aheadMinutes(app.db)).toBe(10080); | |
| 213 | app.db.query(`DELETE FROM forge_setting WHERE key = 'release.ahead_minutes'`).run(); | |
| 214 | expect(aheadMinutes(app.db)).toBe(90); | |
| 215 | }); | |
| 216 | }); | |
| 217 | ||
| 218 | describe("the tick's beat", () => { | |
| 219 | test("a step's outcome is recorded as the keeper said it, whatever its words", async () => { | |
| 220 | const at = new Date(NOW.getTime() - 60_000); | |
| 221 | const line = await stepWithBeat(app.db, async () => ({ line: "not cut: a failed hold stands", outcome: "skipped" }), () => at); | |
| 222 | expect(line).toBe("not cut: a failed hold stands"); | |
| 223 | expect(lastBeat(app.db)).toEqual({ at: at.toISOString(), outcome: "skipped", reason: "not cut: a failed hold stands" }); | |
| 224 | }); | |
| 225 | ||
| 226 | test("a step that throws is recorded as failed, and the error goes on", async () => { | |
| 227 | const step = stepWithBeat(app.db, async () => { throw new Error("git is gone"); }, () => NOW); | |
| 228 | await expect(step).rejects.toThrow("git is gone"); | |
| 229 | expect(lastBeat(app.db)).toEqual({ at: NOW.toISOString(), outcome: "failed", reason: "step failed: git is gone" }); | |
| 230 | }); | |
| 231 | }); | |
| 232 | ||
| 233 | describe("a beat that cannot be written", () => { | |
| 234 | test("a good tick is still reported good", async () => { | |
| 235 | const broken = { query: () => { throw new Error("disk full"); } } as never; | |
| 236 | const line = await stepWithBeat(broken, async () => ({ line: "cut 0.7.9", outcome: "cut" }), () => NOW); | |
| 237 | expect(line).toBe("cut 0.7.9"); | |
| 238 | }); | |
| 239 | }); | |
| 240 | ||
| 241 | describe("both doors", () => { | |
| 242 | test("/releases and the terminal's keeper screen show the heartbeat and that main is ahead", () => { | |
| 243 | reset(); | |
| 244 | check("ok", HEAD); | |
| 245 | merge(120, "5".repeat(40)); | |
| 246 | recordBeat(app.db, "skipped", "<b>hold</b> stands", new Date(NOW.getTime() - 3 * 60_000)); | |
| 247 | const page = String(keeperPanel(app.db, false, NOW)); | |
| 248 | expect(page).toContain('id="keeper-heartbeat"'); | |
| 249 | expect(page).toContain("skipped, 3 min ago: "); | |
| 250 | expect(page).toContain('<dt>Release tick</dt>'); | |
| 251 | expect(page).toContain("<b>hold</b>"); | |
| 252 | expect(page).not.toContain("<b>hold"); | |
| 253 | expect(page).toMatch(/<dt>Main vs production<\/dt><dd id="keeper-ahead">Main is green and 1 commit and 1 pull ahead/); | |
| 254 | const read = readKeeper(new Session(app, { sshUsername: owner.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: owner }), NOW)!; | |
| 255 | expect(read.heartbeat).toContain("3 min ago (skipped)"); | |
| 256 | expect(read.ahead).toContain("for 2 h 0 min"); | |
| 257 | }); | |
| 258 | ||
| 259 | test("with main on production, no ahead line, and the heartbeat says when it is quiet", () => { | |
| 260 | reset(); | |
| 261 | check("ok", PROD); | |
| 262 | recordBeat(app.db, "cut", "cut 0.7.536", new Date(NOW.getTime() - 90 * 60_000)); | |
| 263 | const read = readKeeper(new Session(app, { sshUsername: owner.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: owner }), NOW)!; | |
| 264 | expect(read.ahead).toBeUndefined(); | |
| 265 | expect(read.heartbeat).toContain("No heartbeat from the release tick for 1 h 30 min"); | |
| 266 | expect(String(keeperPanel(app.db, false, NOW))).toContain("cut, 1 h 30 min ago (silent): cut 0.7.536"); | |
| 267 | expect(String(keeperPanel(app.db, false, NOW))).not.toContain("keeper-ahead"); | |
| 268 | }); | |
| 269 | }); | |
| 270 | ||
| 271 | describe("the terminal screen and /flow", () => { | |
| 272 | test("the keeper screen draws the heartbeat and the ahead line", async () => { | |
| 273 | reset(); | |
| 274 | check("ok", HEAD); | |
| 275 | merge(120, "5".repeat(40)); | |
| 276 | recordBeat(app.db, "failed", "tag refused", new Date()); | |
| 277 | const id = { sshUsername: owner.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: owner }; | |
| 278 | const { app: tui, session } = createForgeApp(app, id, { terminal: headlessTerminal(160, 20), exitHooks: false }); | |
| 279 | tui.start(); | |
| 280 | tui.ctx.push(keeperScreen(session, tui.ctx)); | |
| 281 | await settleApp(tui); | |
| 282 | const text = tui.screen.toText().replace(/\s+/g, " "); | |
| 283 | tui.stop?.(); | |
| 284 | expect(text).toContain("The release tick last reported 0 min ago (failed): tag refused"); | |
| 285 | expect(text).toContain("Main is green and 1 commit and 1 pull ahead"); | |
| 286 | }); | |
| 287 | ||
| 288 | test("/flow shows the heartbeat with its reason to the owner and never to an agent", () => { | |
| 289 | reset(); | |
| 290 | recordBeat(app.db, "skipped", "a private reason", new Date(NOW.getTime() - 5 * 60_000)); | |
| 291 | expect(flowFor(app, { kind: "owner" }, NOW).heartbeat?.reason).toBe("a private reason"); | |
| 292 | expect(flowFor(app, { kind: "agent" }, NOW).heartbeat).toBeNull(); | |
| 293 | expect(JSON.stringify(flowFor(app, { kind: "agent" }, NOW))).not.toContain("a private reason"); | |
| 294 | const url = new URL("http://forge.test/flow"); | |
| 295 | expect(String(flowPanel(app, { kind: "owner" }, url, "/flow"))).toContain("skipped, "); | |
| 296 | expect(String(flowPanel(app, { kind: "agent" }, url, "/flow"))).not.toContain("a private reason"); | |
| 297 | }); | |
| 298 | }); | |
| 299 | ||
| 300 | describe("the keeper's own outcome", () => { | |
| 301 | test("a cut, a failed verify, then a skip because the failed hold stands", async () => { | |
| 302 | const f = await keeperForge(); | |
| 303 | expect((await tickStep(f.ports)).outcome).toBe("cut"); | |
| 304 | f.world.live = "0.7.284"; | |
| 305 | f.world.healthz = (n) => (n >= 5 ? { status: 503, ms: 2 } : { status: 200, ms: 2 }); | |
| 306 | const failed = await tickStep(f.ports); | |
| 307 | expect(failed.outcome).toBe("failed"); | |
| 308 | expect(failed.line).toContain("failed its verify"); | |
| 309 | const held = await tickStep(f.ports); | |
| 310 | expect(held.line).toContain("failed"); | |
| 311 | expect(held.outcome).toBe("skipped"); | |
| 312 | }); | |
| 313 | }); |
test/stale_reds.test.ts +23
| @@ -227,3 +227,26 @@ test("the flow report counts stale reds and the keeper names them as a cause", ( | ||
| 227 | 227 | const found = pickCause({ repos: [{ repoId: "r", slug: "a/b", startable: 1, running: 1, slots: 4, ceilingUsd: 10, atCeiling: false, noHeadroom: false, resetsAt: null }], red: 0, staleReds: STALE_REDS, stale: 0 }); |
| 228 | 228 | expect(found?.cause).toBe("stale_reds"); |
| 229 | 229 | }); |
| 230 | ||
| 231 | test("one failure line on five pulls within the hour is the forge's (#11507): past their tries, none is parked", async () => { | |
| 232 | app.db.query(`UPDATE issue SET state = 'closed' WHERE state = 'open' AND kind = 'pull'`).run(); | |
| 233 | const made: Awaited<ReturnType<typeof pullBy>>[] = []; | |
| 234 | for (let i = 0; i < 5; i++) { | |
| 235 | const p = await pullBy(mason); | |
| 236 | const red = finished(p, "check", "failed", 5000 + i); | |
| 237 | app.db.query("UPDATE run SET summary = ?2 WHERE id = ?1").run(red.id, `check failed: stopped: used 1${i}.0 cores for 30s, over its limit of 16`); | |
| 238 | finished(p, "agent", "ok", 5010 + i, "check_failed"); | |
| 239 | finished(p, "agent", "ok", 5020 + i, "check_failed"); | |
| 240 | made.push(p); | |
| 241 | } | |
| 242 | const moves = await sweepStaleReds(app, repo, at(5060), 0); | |
| 243 | expect(moves.filter((m) => made.some((p) => p.pull.number === m.pull))).toEqual([]); | |
| 244 | expect(made.every((p) => open(p.pull.id))).toBe(true); | |
| 245 | // A line on one pull only is the pull's: past its tries, it is parked. | |
| 246 | const lone = await pullBy(mason); | |
| 247 | finished(lone, "check", "failed", 5025, "", "engine > lone"); | |
| 248 | finished(lone, "agent", "ok", 5026, "check_failed"); | |
| 249 | finished(lone, "agent", "ok", 5027, "check_failed"); | |
| 250 | const later = await sweepStaleReds(app, repo, at(5060), 0); | |
| 251 | expect(later).toEqual([{ pull: lone.pull.number, did: "parked" }]); | |
| 252 | }); |
test/tui-release-keeper.test.ts +1 −1
| @@ -56,6 +56,6 @@ describe("the release keeper screen", () => { | ||
| 56 | 56 | test("with nothing cut, it says nothing holds the next cut and that no releases are kept yet", async () => { |
| 57 | 57 | const f = await keeperForge(); |
| 58 | 58 | const read = readKeeper(new Session(f.app, identity(f.user))); |
| 59 | expect(read).toEqual({ blocked: "Nothing holds the next cut.", releases: [] }); | |
| 59 | expect(read).toEqual({ blocked: "Nothing holds the next cut.", heartbeat: "The release tick has never reported a heartbeat.", releases: [] }); | |
| 60 | 60 | }); |
| 61 | 61 | }); |
new above
offline
Blocking: do not let the lane alarm make landing past the gate available. A merge drought with open pulls is not a red gate. Return only
landingStoppedhere, or add an explicit, tested condition based on failing gates or checks.