Public: anyone can read this; the crew can change it.
The flow vitals carry gate minutes and the red-batch rate, and a rate over 30% is named as a miss #6775
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6428-the-flow-vitals-carry-gate-minutes-and-t → main 1 commit fork
4 files
changed +45
−10
src/crew/governor.test.ts +1 −1
| @@ -3,7 +3,7 @@ import type { Vitals } from "../flow/vitals.ts"; | ||
| 3 | 3 | import { governedSlots } from "./governor.ts"; |
| 4 | 4 | |
| 5 | 5 | const v = (p: Partial<Vitals>): Vitals => ({ |
| 6 | open: 0, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0, ...p, | |
| 6 | open: 0, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0, gateMinutes1h: 0, pullCheckMinutes1h: 0, batches6h: 0, redBatchRate: null, ...p, | |
| 7 | 7 | }); |
| 8 | 8 | |
| 9 | 9 | describe("governedSlots", () => { |
src/flow/vitals.ts +27 −4
| @@ -11,6 +11,8 @@ | ||
| 11 | 11 | |
| 12 | 12 | import type { Database } from "bun:sqlite"; |
| 13 | 13 | import { conflictedPulls } from "./conflicted.ts"; |
| 14 | import { readGateMinutes } from "./gate_minutes.ts"; | |
| 15 | import { readRedBatches } from "./red_batches.ts"; | |
| 14 | 16 | import { readChecksPile } from "./checks_pile.ts"; |
| 15 | 17 | import { readOldestApproved } from "./oldest_approved.ts"; |
| 16 | 18 | import { readStranded } from "./stranded.ts"; |
| @@ -21,6 +23,9 @@ export const OPEN_TARGET = 40; | ||
| 21 | 23 | export const CONFLICTED_TARGET = 5; |
| 22 | 24 | /** A clog is declared when the oldest approved pull has waited past this (#6422). */ |
| 23 | 25 | export const APPROVED_WAIT_HOURS = 24; |
| 26 | /** A merge-batch miss is named when more than this share went red, over at least `RED_BATCH_MIN` batches (#6428). */ | |
| 27 | export const RED_BATCH_TARGET = 0.3; | |
| 28 | export const RED_BATCH_MIN = 3; | |
| 24 | 29 | |
| 25 | 30 | export interface Vitals { |
| 26 | 31 | readonly open: number; |
| @@ -37,6 +42,14 @@ export interface Vitals { | ||
| 37 | 42 | readonly oldestApprovedHours: number | null; |
| 38 | 43 | /** Dollars of crew runs on open, unlanded pulls. */ |
| 39 | 44 | readonly strandedUsd: number; |
| 45 | /** Check-slot minutes the merge queue's gates spent in the last hour. */ | |
| 46 | readonly gateMinutes1h: number; | |
| 47 | /** Check-slot minutes every other check spent in the last hour. */ | |
| 48 | readonly pullCheckMinutes1h: number; | |
| 49 | /** Merge batches that finished in the last 6 h. */ | |
| 50 | readonly batches6h: number; | |
| 51 | /** Share of those that went red; null when none finished. */ | |
| 52 | readonly redBatchRate: number | null; | |
| 40 | 53 | } |
| 41 | 54 | |
| 42 | 55 | /** What a reader is shown: `strandedUsd` is left out for a reader without telemetry:read. */ |
| @@ -55,7 +68,7 @@ export function readVitals(db: Database, now = new Date(), publicOnly = false): | ||
| 55 | 68 | const h1 = new Date(now.getTime() - HOUR_MS).toISOString(); |
| 56 | 69 | const h6 = new Date(now.getTime() - 6 * HOUR_MS).toISOString(); |
| 57 | 70 | const row = db |
| 58 | .query<Omit<Vitals, "conflicted" | "red" | "queued" | "oldestApprovedHours" | "strandedUsd">, [string, string, string, number]>( | |
| 71 | .query<Omit<Vitals, "conflicted" | "red" | "queued" | "oldestApprovedHours" | "strandedUsd" | "gateMinutes1h" | "pullCheckMinutes1h" | "batches6h" | "redBatchRate">, [string, string, string, number]>( | |
| 59 | 72 | `SELECT |
| 60 | 73 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id |
| 61 | 74 | WHERE (?4 = 0 OR r.visibility = 'public') AND i.state = 'open' AND p.merged_at IS NULL) AS open, |
| @@ -70,6 +83,8 @@ export function readVitals(db: Database, now = new Date(), publicOnly = false): | ||
| 70 | 83 | ) |
| 71 | 84 | .get(h1, h6, to, publicOnly ? 1 : 0); |
| 72 | 85 | const pile = readChecksPile(db, publicOnly); |
| 86 | const gate = readGateMinutes(db, now, publicOnly); | |
| 87 | const batches = readRedBatches(db, now, publicOnly); | |
| 73 | 88 | return { |
| 74 | 89 | open: row?.open ?? 0, |
| 75 | 90 | opened1h: row?.opened1h ?? 0, |
| @@ -81,6 +96,10 @@ export function readVitals(db: Database, now = new Date(), publicOnly = false): | ||
| 81 | 96 | queued: pile.queued, |
| 82 | 97 | oldestApprovedHours: readOldestApproved(db, now, publicOnly)?.hours ?? null, |
| 83 | 98 | strandedUsd: readStranded(db, publicOnly).usd, |
| 99 | gateMinutes1h: gate.gate1h, | |
| 100 | pullCheckMinutes1h: gate.pull1h, | |
| 101 | batches6h: batches.finished, | |
| 102 | redBatchRate: batches.rate, | |
| 84 | 103 | }; |
| 85 | 104 | } |
| 86 | 105 | |
| @@ -92,14 +111,18 @@ export function vitalsMisses(v: ShownVitals): string[] { | ||
| 92 | 111 | if (v.landed6h < v.opened6h) out.push(`landed ${v.landed6h} < opened ${v.opened6h} over 6 h`); |
| 93 | 112 | if (v.oldestApprovedHours !== null && v.oldestApprovedHours > APPROVED_WAIT_HOURS) |
| 94 | 113 | out.push(`oldest approved pull has waited ${Math.floor(v.oldestApprovedHours)} h`); |
| 114 | if (v.redBatchRate !== null && v.redBatchRate > RED_BATCH_TARGET && v.batches6h >= RED_BATCH_MIN) | |
| 115 | out.push(`${Math.round(v.redBatchRate * 100)}% of merge batches went red in 6 h`); | |
| 95 | 116 | return out; |
| 96 | 117 | } |
| 97 | 118 | |
| 98 | 119 | const usd = (n: number) => `$${n.toFixed(2)}`; |
| 99 | 120 | |
| 100 | 121 | /** The new numbers, in the order both lines say them; each part is left out when it has nothing to say. */ |
| 101 | function extras(v: ShownVitals): { red: string; oldest: string | null; stranded: string | null } { | |
| 122 | function extras(v: ShownVitals): { red: string; oldest: string | null; stranded: string | null; gate: string; batches: string | null } { | |
| 102 | 123 | return { |
| 124 | gate: `gate ${Math.round(v.gateMinutes1h)} min, other checks ${Math.round(v.pullCheckMinutes1h)} min in 1 h`, | |
| 125 | batches: v.redBatchRate === null ? null : `${Math.round(v.redBatchRate * 100)}% of ${v.batches6h} batches red in 6 h`, | |
| 103 | 126 | red: `${v.red} red, ${v.queued} queued`, |
| 104 | 127 | oldest: v.oldestApprovedHours === null ? null : `oldest approved ${Math.floor(v.oldestApprovedHours)} h`, |
| 105 | 128 | stranded: v.strandedUsd === undefined ? null : `${usd(v.strandedUsd)} stranded`, |
| @@ -109,11 +132,11 @@ function extras(v: ShownVitals): { red: string; oldest: string | null; stranded: | ||
| 109 | 132 | /** The tick line's one value: `open 12, opened/landed 3/2 in 1 h, 9/11 in 6 h, conflicted 1, red 0, queued 0, oldest approved 30 h, stranded $1.20`. */ |
| 110 | 133 | export function vitalsWords(v: ShownVitals): string { |
| 111 | 134 | const x = extras(v); |
| 112 | return `open ${v.open}, opened/landed ${v.opened1h}/${v.landed1h} in 1 h, ${v.opened6h}/${v.landed6h} in 6 h, conflicted ${v.conflicted}, ${[x.red, x.oldest, x.stranded].filter((w) => w !== null).join(", ")}`; | |
| 135 | return `open ${v.open}, opened/landed ${v.opened1h}/${v.landed1h} in 1 h, ${v.opened6h}/${v.landed6h} in 6 h, conflicted ${v.conflicted}, ${[x.red, x.oldest, x.stranded, x.gate, x.batches].filter((w) => w !== null).join(", ")}`; | |
| 113 | 136 | } |
| 114 | 137 | |
| 115 | 138 | /** Home's line for an admin; the counts are the whole of it. */ |
| 116 | 139 | export function vitalsLine(v: ShownVitals): string { |
| 117 | 140 | const x = extras(v); |
| 118 | return `${v.open} open pulls · ${v.opened1h} opened, ${v.landed1h} landed in 1 h · ${v.opened6h} opened, ${v.landed6h} landed in 6 h · ${v.conflicted} conflicted · ${[x.red, x.oldest, x.stranded].filter((w) => w !== null).join(" · ")}`; | |
| 141 | return `${v.open} open pulls · ${v.opened1h} opened, ${v.landed1h} landed in 1 h · ${v.opened6h} opened, ${v.landed6h} landed in 6 h · ${v.conflicted} conflicted · ${[x.red, x.oldest, x.stranded, x.gate, x.batches].filter((w) => w !== null).join(" · ")}`; | |
| 119 | 142 | } |
test/flow-clog-prevent.test.ts +1 −1
| @@ -52,7 +52,7 @@ const open = (title: string): Issue => { | ||
| 52 | 52 | }; |
| 53 | 53 | |
| 54 | 54 | test("a seeded clog lowers the slots and holds a fresh build on the hot file", () => { |
| 55 | const clog: Vitals = { open: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40, conflicted: 0, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0 }; | |
| 55 | const clog: Vitals = { open: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40, conflicted: 0, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0, gateMinutes1h: 0, pullCheckMinutes1h: 0, batches6h: 0, redBatchRate: null }; | |
| 56 | 56 | const ceiling = 7; |
| 57 | 57 | const governed = governedSlots(clog, ceiling); |
| 58 | 58 | expect(governed.slots).toBeLessThan(ceiling); |
test/flow-vitals.test.ts +16 −4
| @@ -52,11 +52,11 @@ describe("flow vitals", () => { | ||
| 52 | 52 | .run(id(), repo.value.id, clash, ago(1)); |
| 53 | 53 | |
| 54 | 54 | const v = readVitals(app.db, NOW); |
| 55 | expect(v).toEqual({ open: 3, opened1h: 1, landed1h: 1, opened6h: 3, landed6h: 2, conflicted: 1, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0 }); | |
| 55 | expect(v).toEqual({ open: 3, opened1h: 1, landed1h: 1, opened6h: 3, landed6h: 2, conflicted: 1, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0, gateMinutes1h: 0, pullCheckMinutes1h: 0, batches6h: 0, redBatchRate: null }); | |
| 56 | 56 | expect(vitalsMisses(v)).toEqual(["landed 2 < opened 3 over 6 h"]); |
| 57 | 57 | |
| 58 | 58 | const tick = flowVitals(app.db, NOW); |
| 59 | expect(tick.pulls).toBe("open 3, opened/landed 1/1 in 1 h, 3/2 in 6 h, conflicted 1, 0 red, 0 queued, $0.00 stranded"); | |
| 59 | expect(tick.pulls).toBe("open 3, opened/landed 1/1 in 1 h, 3/2 in 6 h, conflicted 1, 0 red, 0 queued, $0.00 stranded, gate 0 min, other checks 0 min in 1 h"); | |
| 60 | 60 | expect(tick.pulls).toBe(vitalsWords(v)); |
| 61 | 61 | expect(tick.offTarget).toBe("landed 2 < opened 3 over 6 h"); |
| 62 | 62 | |
| @@ -121,7 +121,7 @@ describe("flow vitals", () => { | ||
| 121 | 121 | const app = createApp({ dataDir: root, port: 0 }); |
| 122 | 122 | try { |
| 123 | 123 | const v = readVitals(app.db, NOW); |
| 124 | expect(Object.values(v)).toEqual([0, 0, 0, 0, 0, 0, 0, 0, null, 0]); | |
| 124 | expect(Object.values(v)).toEqual([0, 0, 0, 0, 0, 0, 0, 0, null, 0, 0, 0, 0, null]); | |
| 125 | 125 | expect(vitalsMisses(v)).toEqual([]); |
| 126 | 126 | } finally { |
| 127 | 127 | app.close(); |
| @@ -130,7 +130,7 @@ describe("flow vitals", () => { | ||
| 130 | 130 | }); |
| 131 | 131 | |
| 132 | 132 | test("an approved pull waiting over 24 h is a miss; an agent without telemetry:read gets no dollars", () => { |
| 133 | const base: Vitals = { open: 1, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, red: 2, queued: 3, oldestApprovedHours: 30.4, strandedUsd: 12.5 }; | |
| 133 | const base: Vitals = { open: 1, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, red: 2, queued: 3, oldestApprovedHours: 30.4, strandedUsd: 12.5, gateMinutes1h: 0, pullCheckMinutes1h: 0, batches6h: 0, redBatchRate: null }; | |
| 134 | 134 | expect(vitalsMisses(base)).toEqual(["oldest approved pull has waited 30 h"]); |
| 135 | 135 | expect(vitalsMisses({ ...base, oldestApprovedHours: 24 })).toEqual([]); |
| 136 | 136 | expect(vitalsMisses({ ...base, oldestApprovedHours: null })).toEqual([]); |
| @@ -142,6 +142,18 @@ describe("flow vitals", () => { | ||
| 142 | 142 | expect(vitalsFor(base, true).strandedUsd).toBe(12.5); |
| 143 | 143 | }); |
| 144 | 144 | |
| 145 | test("a red-batch rate over 30% of at least 3 batches is a miss; the gate minutes are said", () => { | |
| 146 | const base: Vitals = { open: 1, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0, gateMinutes1h: 12.4, pullCheckMinutes1h: 30, batches6h: 4, redBatchRate: 0.5 }; | |
| 147 | expect(vitalsMisses(base)).toEqual(["50% of merge batches went red in 6 h"]); | |
| 148 | expect(vitalsMisses({ ...base, batches6h: 10, redBatchRate: 0.3 })).toEqual([]); | |
| 149 | expect(vitalsMisses({ ...base, batches6h: 10, redBatchRate: 0.4 })).toEqual(["40% of merge batches went red in 6 h"]); | |
| 150 | expect(vitalsMisses({ ...base, batches6h: 2, redBatchRate: 1 })).toEqual([]); | |
| 151 | expect(vitalsMisses({ ...base, batches6h: 3, redBatchRate: 1 })).toHaveLength(1); | |
| 152 | expect(vitalsMisses({ ...base, batches6h: 0, redBatchRate: null })).toEqual([]); | |
| 153 | expect(vitalsWords(base)).toContain("gate 12 min, other checks 30 min in 1 h, 50% of 4 batches red in 6 h"); | |
| 154 | expect(vitalsLine(base)).toContain("gate 12 min, other checks 30 min in 1 h · 50% of 4 batches red in 6 h"); | |
| 155 | }); | |
| 156 | ||
| 145 | 157 | test("the dollars and the old approval pass through every door only to a reader who may read them", async () => { |
| 146 | 158 | const root = mkdtempSync(join(tmpdir(), "forge-vitals-")); |
| 147 | 159 | const app = createApp({ dataDir: root, port: 0 }); |
new above
offline