tumwater 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -6
- package/dist/build-info.json +2 -2
- package/dist/src/agent/backend-traits.js +14 -0
- package/dist/src/agent/claude/claude-stream.js +27 -31
- package/dist/src/agent/codex/codex-rollout.js +38 -24
- package/dist/src/agent/codex/codex-stream.js +71 -38
- package/dist/src/agent/image-input.js +18 -87
- package/dist/src/agent/opencode/opencode-catalog.js +2 -2
- package/dist/src/agent/opencode/opencode-stream.js +4 -4
- package/dist/src/agent/run-agent.js +11 -2
- package/dist/src/agent/run-settle.js +22 -13
- package/dist/src/agent/run-spawn.js +3 -12
- package/dist/src/agent/run-stream.js +2 -10
- package/dist/src/agent/session-ledger.js +16 -0
- package/dist/src/backlog/backlog-archive.js +64 -165
- package/dist/src/backlog/backlog-eligibility.js +50 -58
- package/dist/src/backlog/backlog-file-bugs.js +133 -0
- package/dist/src/backlog/backlog-index-data.js +2 -2
- package/dist/src/backlog/backlog-md.js +38 -27
- package/dist/src/backlog/backlog-release.js +11 -19
- package/dist/src/backlog/backlog-structure.js +14 -1
- package/dist/src/backlog/backlog-write.js +1 -1
- package/dist/src/backlog/backlog.js +5 -4
- package/dist/src/backlog/question-answer.js +30 -14
- package/dist/src/baseline/main-baseline.js +30 -20
- package/dist/src/baseline/main-guardrails.js +54 -17
- package/dist/src/baseline/main-watch.js +1 -1
- package/dist/src/budget/budget-wiring.js +0 -5
- package/dist/src/budget/budget.js +2 -1
- package/dist/src/build/build-check-scoped.js +70 -41
- package/dist/src/change/change-data.js +4 -2
- package/dist/src/cli/cli-command-args.js +199 -98
- package/dist/src/cli/cli-flag-specs.js +1 -4
- package/dist/src/cli/cli-review-commands.js +2 -1
- package/dist/src/cli/cli-role-command.js +27 -50
- package/dist/src/cli/cli-start.js +62 -12
- package/dist/src/cli/follow-view.js +3 -1
- package/dist/src/cli/help.js +6 -7
- package/dist/src/cli/log-commands.js +3 -1
- package/dist/src/cli.js +3 -7
- package/dist/src/collections.js +21 -7
- package/dist/src/config/config-fallbacks.js +0 -4
- package/dist/src/config/config-live.js +179 -147
- package/dist/src/config/config-views.js +2 -7
- package/dist/src/config/config-write.js +19 -26
- package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
- package/dist/src/doctor/doctor-backlog-plans.js +8 -8
- package/dist/src/doctor/doctor-backlog.js +22 -36
- package/dist/src/doctor/doctor-check-names.js +2 -2
- package/dist/src/doctor/doctor-claude-checks.js +2 -2
- package/dist/src/doctor/doctor-contention-checks.js +1 -1
- package/dist/src/doctor/doctor-holds.js +13 -11
- package/dist/src/doctor/doctor-model-checks.js +8 -184
- package/dist/src/doctor/doctor-notify.js +2 -5
- package/dist/src/doctor/doctor-opencode-checks.js +85 -48
- package/dist/src/doctor/doctor-project-checks.js +66 -17
- package/dist/src/doctor/doctor-role-notes.js +6 -24
- package/dist/src/doctor/doctor.js +6 -6
- package/dist/src/doctor/probe.js +1 -1
- package/dist/src/events/event-format.js +5 -15
- package/dist/src/events/event-labels.js +0 -1
- package/dist/src/events/event-legacy.js +1 -1
- package/dist/src/events/event-window.js +1 -1
- package/dist/src/events/events.js +15 -2
- package/dist/src/failure/failure-state-change.js +183 -68
- package/dist/src/failure/time-spend.js +1 -1
- package/dist/src/files/json-object.js +15 -2
- package/dist/src/gates/startup-gate.js +28 -28
- package/dist/src/git/slot-salvage.js +5 -4
- package/dist/src/gui/gui-admin-endpoints.js +2 -3
- package/dist/src/gui/gui-backlog-commands.js +1 -1
- package/dist/src/gui/gui-backlog-endpoints.js +1 -7
- package/dist/src/gui/gui-endpoint-commands.js +1 -1
- package/dist/src/gui/gui-operator-commands.js +0 -12
- package/dist/src/gui/gui-prompt-commands.js +26 -20
- package/dist/src/gui/gui-report-endpoints.js +2 -1
- package/dist/src/gui/gui-server.js +1 -2
- package/dist/src/history/history.js +102 -67
- package/dist/src/inbox/inbox-submit.js +1 -5
- package/dist/src/inbox/prompt-commands.js +24 -19
- package/dist/src/init/init.js +68 -15
- package/dist/src/merge/merge-core.js +1 -1
- package/dist/src/merge/merge-post-review-drain.js +1 -1
- package/dist/src/operator/operator-commands.js +68 -47
- package/dist/src/operator/retire.js +2 -1
- package/dist/src/orchestrator/retention.js +6 -5
- package/dist/src/paths.js +0 -6
- package/dist/src/pi/pi-watchdogs.js +20 -1
- package/dist/src/pi-extension/context-shake.js +55 -31
- package/dist/src/process/process.js +23 -0
- package/dist/src/process/supervisor.js +17 -1
- package/dist/src/prompt/gate-notes.js +9 -9
- package/dist/src/prompt/notes-backlog.js +11 -24
- package/dist/src/prompt/prompt-followup.js +4 -12
- package/dist/src/prompt/prompt.js +4 -5
- package/dist/src/report/report-data.js +10 -225
- package/dist/src/report/report-fold.js +20 -295
- package/dist/src/report/report-main-red.js +45 -42
- package/dist/src/report/report-render.js +66 -39
- package/dist/src/report/report-since-data.js +218 -0
- package/dist/src/report/report-usage-fold.js +288 -0
- package/dist/src/report/report.js +2 -1
- package/dist/src/review/known-flakes.js +3 -3
- package/dist/src/review/post-merge-review-accept.js +3 -2
- package/dist/src/review/post-merge-review.js +9 -9
- package/dist/src/review/quarantine-render.js +2 -1
- package/dist/src/roles/role-catalog.js +18 -30
- package/dist/src/roles/role-guidance.js +18 -43
- package/dist/src/roles/role-view.js +1 -2
- package/dist/src/run/ci-parity.js +5 -6
- package/dist/src/run/qa-coverage.js +4 -4
- package/dist/src/run/run-detail-data.js +23 -7
- package/dist/src/run/run-detail-render.js +6 -0
- package/dist/src/run/run-detail.js +44 -22
- package/dist/src/run/run-dismiss.js +43 -0
- package/dist/src/run/run-prompt.js +100 -56
- package/dist/src/run/run-resume.js +32 -31
- package/dist/src/run/run-stage.js +4 -21
- package/dist/src/run/run-timing.js +18 -4
- package/dist/src/run/run-verdict.js +8 -73
- package/dist/src/run/stage-backlog-checks.js +2 -29
- package/dist/src/run/stage-check.js +4 -12
- package/dist/src/runner/runner-outcome.js +0 -5
- package/dist/src/runner/runner-run.js +2 -79
- package/dist/src/runner/runner-state-load.js +0 -10
- package/dist/src/runner/runner.js +1 -32
- package/dist/src/scheduling/claims.js +3 -10
- package/dist/src/status/status-data.js +5 -9
- package/dist/src/status/status-role-notes.js +18 -14
- package/dist/src/status/status-roles.js +2 -35
- package/dist/src/text/datetime.js +8 -1
- package/dist/src/text/text.js +12 -0
- package/dist/src/ui/alert-feeds.js +7 -212
- package/dist/src/ui/badges.js +2 -3
- package/dist/src/ui/gui/gui-client-boot.js +19 -4
- package/dist/src/ui/gui/gui-client-drawer.js +1 -1
- package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
- package/dist/src/ui/gui/gui-client-report.js +1 -2
- package/dist/src/ui/gui/gui-client-settings.js +0 -23
- package/dist/src/ui/gui/gui-client-team.js +26 -0
- package/dist/src/ui/gui/gui-icons.js +7 -1
- package/dist/src/ui/status-model.js +2 -2
- package/dist/src/ui/status-payload.js +3 -3
- package/dist/src/ui/status-render.js +3 -3
- package/dist/src/ui/team-alert-backlog.js +23 -54
- package/dist/src/ui/team-alert-shared.js +23 -10
- package/dist/src/ui/team-alert-status.js +8 -3
- package/dist/src/ui/team-alert-tail.js +1 -14
- package/dist/src/ui/team-alerts.js +6 -17
- package/dist/src/ui/transcript-tail.js +137 -94
- package/dist/src/ui/tui/tui-editor-answer.js +54 -44
- package/dist/src/ui/tui/tui-editor-attach.js +74 -63
- package/dist/src/ui/tui/tui-editor-backends.js +62 -56
- package/dist/src/ui/tui/tui-editor-brief.js +64 -54
- package/dist/src/ui/tui/tui-editor-budget.js +105 -93
- package/dist/src/ui/tui/tui-editor-config.js +2 -4
- package/dist/src/ui/tui/tui-editor-entry.js +171 -153
- package/dist/src/ui/tui/tui-editor-instances.js +2 -1
- package/dist/src/ui/tui/tui-editor-pause.js +209 -183
- package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
- package/dist/src/ui/tui/tui-editor-queued.js +74 -64
- package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
- package/dist/src/ui/tui/tui-editor-release.js +50 -42
- package/dist/src/ui/tui/tui-editor-retire.js +57 -46
- package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
- package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
- package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
- package/dist/src/ui/tui/tui-editor-search.js +70 -58
- package/dist/src/ui/tui/tui-frame.js +1 -1
- package/dist/src/ui/tui/tui-keys.js +1 -1
- package/dist/src/ui/tui/tui-pane-activity.js +62 -50
- package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
- package/dist/src/ui/tui/tui-pane-health.js +3 -24
- package/dist/src/ui/tui/tui-pane-history.js +20 -24
- package/dist/src/ui/tui/tui-pane-report.js +74 -57
- package/dist/src/ui/tui/tui-pane-session.js +3 -2
- package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
- package/dist/src/ui/tui/tui-pane.js +201 -174
- package/dist/src/ui/tui/tui-panes.js +2 -3
- package/dist/src/ui/tui/tui.js +3 -3
- package/dist/src/verdict/fix-claim.js +18 -6
- package/dist/src/verdict/refusal.js +30 -6
- package/package.json +1 -1
- package/dist/src/doctor/image-probe.js +0 -257
- package/dist/src/ui/team-alert-backlog-feed.js +0 -181
- package/dist/src/ui/team-alert-merge.js +0 -183
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
/** The doctor's live image-input probe (plans/image-input.md "Doctor", part 2/5): a claimed
|
|
2
|
-
* image-input capability in pi's models.json is only a claim — doctor shows each claimed model
|
|
3
|
-
* a real image and checks the answer. This module owns three things: the in-process PNG
|
|
4
|
-
* generator (a random 4-digit code drawn with a built-in 5×7 bitmap, encoded with
|
|
5
|
-
* `zlib.deflateSync` + `zlib.crc32` is newer than the `>=20.3` node floor, so the CRC-32 here is
|
|
6
|
-
* hand-rolled), the probe runner (the resolved pi binary in a run's environment, bounded like
|
|
7
|
-
* the doctor's other binary probes), and the `.tumwater/state/image-probes.json` cache the
|
|
8
|
-
* doctor check reads back. No new dependency; runs entirely offline against the operator's pi. */
|
|
9
|
-
import fs from "node:fs";
|
|
10
|
-
import os from "node:os";
|
|
11
|
-
import path from "node:path";
|
|
12
|
-
import { randomInt } from "node:crypto";
|
|
13
|
-
import zlib from "node:zlib";
|
|
14
|
-
import { DAY_MS } from "../text/datetime.js";
|
|
15
|
-
import { PiStreamParser } from "../pi/pi-stream.js";
|
|
16
|
-
import { resolveBackendBin } from "../pi/pi-bin.js";
|
|
17
|
-
import { imageProbesPath } from "../paths.js";
|
|
18
|
-
import { removeTree } from "../files/files.js";
|
|
19
|
-
import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
|
|
20
|
-
import { isJsonObject, validEntries } from "../files/json-object.js";
|
|
21
|
-
import { boundedProbe } from "./probe.js";
|
|
22
|
-
/** How long a cached image-probe verdict stays valid while the claim is unchanged (7 days) —
|
|
23
|
-
* a model's endpoint can change underneath a stale pass, so the answer is dated. */
|
|
24
|
-
export const IMAGE_PROBE_TTL_MS = 7 * DAY_MS;
|
|
25
|
-
/** The probe's deadline (90 s): a local model may need to load, far beyond the quick credential
|
|
26
|
-
* probes, but a doctor run is still a pre-flight — a hung backend must lose, not stall the
|
|
27
|
-
* report. */
|
|
28
|
-
const PROBE_TIMEOUT_MS = 90_000;
|
|
29
|
-
/** Cap on the probe's captured stdout, the same guard as the auth probes: a doctor run must not
|
|
30
|
-
* grow an unbounded buffer off a runaway log. */
|
|
31
|
-
const PROBE_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
32
|
-
/** The cache-file key for one seam — agent/provider/model, the natural identity of a team
|
|
33
|
-
* model, joined on `/` (agent and provider never contain it and a model id is fully qualified,
|
|
34
|
-
* so the key cannot collide with another seam's). */
|
|
35
|
-
export function imageProbeKey(agent, provider, model) {
|
|
36
|
-
return [agent ?? "", provider ?? "", model ?? ""].join("/");
|
|
37
|
-
}
|
|
38
|
-
/** Whether a cached entry still stands for the current claim: it exists, it recorded a real
|
|
39
|
-
* verdict (never `could-not-verify`, which is never cached), it tested the image-input claim,
|
|
40
|
-
* and it is younger than the TTL. */
|
|
41
|
-
export function imageProbeEntryFresh(entry, now = Date.now()) {
|
|
42
|
-
return (entry !== undefined &&
|
|
43
|
-
(entry.verdict === "pass" || entry.verdict === "fail") &&
|
|
44
|
-
entry.claim &&
|
|
45
|
-
now - entry.at <= IMAGE_PROBE_TTL_MS);
|
|
46
|
-
}
|
|
47
|
-
/** Whether a parsed cache entry has the shape of an `ImageProbeCacheEntry`. */
|
|
48
|
-
function isImageProbeCacheEntry(value) {
|
|
49
|
-
return (isJsonObject(value) &&
|
|
50
|
-
(value.verdict === "pass" || value.verdict === "fail") &&
|
|
51
|
-
typeof value.detail === "string" &&
|
|
52
|
-
typeof value.at === "number" &&
|
|
53
|
-
Number.isFinite(value.at) &&
|
|
54
|
-
typeof value.claim === "boolean");
|
|
55
|
-
}
|
|
56
|
-
/** Read the probe cache, or an empty cache when the file is missing or torn (the harness's
|
|
57
|
-
* no-data policy: a crashed writer never takes down a reader). An entry of a foreign shape is
|
|
58
|
-
* dropped, so it reads as unprobed, and the doctor's next cache write leaves it out. */
|
|
59
|
-
export function readImageProbeCache(root) {
|
|
60
|
-
return validEntries(readJsonFile(imageProbesPath(root)), isImageProbeCacheEntry);
|
|
61
|
-
}
|
|
62
|
-
/** Write the probe cache atomically (tmp+rename) so a concurrent reader never sees a torn
|
|
63
|
-
* file. */
|
|
64
|
-
export function writeImageProbeCache(root, cache) {
|
|
65
|
-
writeJsonAtomic(imageProbesPath(root), cache);
|
|
66
|
-
}
|
|
67
|
-
// ── PNG generator ─────────────────────────────────────────────────────────────────────────
|
|
68
|
-
/** Each digit as 7 rows of 5 bits, '1' = drawn (black); the standard 5×7 seven-segment-ish
|
|
69
|
-
* font, big enough for a vision model to read and small enough to stay a trivial payload. */
|
|
70
|
-
const DIGIT_BITMAP = {
|
|
71
|
-
"0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
|
|
72
|
-
"1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
|
|
73
|
-
"2": ["01110", "10001", "00001", "00110", "01000", "10000", "11111"],
|
|
74
|
-
"3": ["11111", "00010", "00100", "00010", "00001", "10001", "01110"],
|
|
75
|
-
"4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
|
|
76
|
-
"5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110"],
|
|
77
|
-
"6": ["00110", "01000", "10000", "11110", "10001", "10001", "01110"],
|
|
78
|
-
"7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
|
|
79
|
-
"8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
|
|
80
|
-
"9": ["01110", "10001", "10001", "01111", "00001", "00010", "01100"],
|
|
81
|
-
};
|
|
82
|
-
/** A hand-rolled CRC-32 (the PNG chunk checksum): `zlib.crc32` is newer than the `>=20.3` node
|
|
83
|
-
* floor, so this bitwise table version is the one home of the polynomial. */
|
|
84
|
-
const CRC32_TABLE = (() => {
|
|
85
|
-
const table = new Uint32Array(256);
|
|
86
|
-
for (let n = 0; n < 256; n++) {
|
|
87
|
-
let c = n;
|
|
88
|
-
for (let k = 0; k < 8; k++)
|
|
89
|
-
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
90
|
-
table[n] = c >>> 0;
|
|
91
|
-
}
|
|
92
|
-
return table;
|
|
93
|
-
})();
|
|
94
|
-
function crc32(buf) {
|
|
95
|
-
let c = 0xffffffff;
|
|
96
|
-
for (let i = 0; i < buf.length; i++)
|
|
97
|
-
c = (CRC32_TABLE[(c ^ (buf[i] ?? 0)) & 0xff] ?? 0) ^ (c >>> 8);
|
|
98
|
-
return (c ^ 0xffffffff) >>> 0;
|
|
99
|
-
}
|
|
100
|
-
/** One PNG chunk: 4-byte big-endian length, the 4-byte ASCII type, the data, and the CRC over
|
|
101
|
-
* type+data. */
|
|
102
|
-
function pngChunk(type, data) {
|
|
103
|
-
const len = Buffer.alloc(4);
|
|
104
|
-
len.writeUInt32BE(data.length, 0);
|
|
105
|
-
const typeBuf = Buffer.from(type, "ascii");
|
|
106
|
-
const crc = Buffer.alloc(4);
|
|
107
|
-
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
|
108
|
-
return Buffer.concat([len, typeBuf, data, crc]);
|
|
109
|
-
}
|
|
110
|
-
/** A PNG showing `code` black on white, drawn with the 5×7 bitmap scaled `scale`-fold (each
|
|
111
|
-
* font cell becomes a `scale`×`scale` block) and one `scale`-wide gap between digits. Truecolor
|
|
112
|
-
* (color type 2) so any reader handles it and the decode-check in the tests is trivial. Exported
|
|
113
|
-
* for tests (and the doctor's own validity check of a generated image). */
|
|
114
|
-
export function makeProbePng(code, scale = 8) {
|
|
115
|
-
const digits = [...code].map((d) => DIGIT_BITMAP[d] ?? DIGIT_BITMAP["0"]);
|
|
116
|
-
const width = digits.length * 5 * scale + (digits.length - 1) * scale;
|
|
117
|
-
const height = 7 * scale;
|
|
118
|
-
// Truecolor RGB scanlines, each prefixed with filter byte 0 (none).
|
|
119
|
-
const raw = Buffer.alloc(height * (1 + width * 3));
|
|
120
|
-
for (let y = 0; y < height; y++) {
|
|
121
|
-
const rowStart = y * (1 + width * 3);
|
|
122
|
-
raw[rowStart] = 0;
|
|
123
|
-
for (let x = 0; x < width; x++) {
|
|
124
|
-
// Which font cell does this pixel fall in?
|
|
125
|
-
const cell = Math.floor(x / (5 * scale + scale));
|
|
126
|
-
const inCellX = x - cell * (5 * scale + scale);
|
|
127
|
-
const cellDigit = digits[cell];
|
|
128
|
-
const row = Math.floor(y / scale);
|
|
129
|
-
const col = Math.floor(inCellX / scale);
|
|
130
|
-
const bit = inCellX < 5 * scale && cellDigit !== undefined
|
|
131
|
-
? ((cellDigit[row] ?? "")[col] ?? "")
|
|
132
|
-
: "";
|
|
133
|
-
const on = bit === "1";
|
|
134
|
-
const p = rowStart + 1 + x * 3;
|
|
135
|
-
raw[p] = on ? 0 : 255;
|
|
136
|
-
raw[p + 1] = on ? 0 : 255;
|
|
137
|
-
raw[p + 2] = on ? 0 : 255;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
const ihdr = Buffer.alloc(13);
|
|
141
|
-
ihdr.writeUInt32BE(width, 0);
|
|
142
|
-
ihdr.writeUInt32BE(height, 4);
|
|
143
|
-
ihdr[8] = 8; // bit depth
|
|
144
|
-
ihdr[9] = 2; // color type: truecolor RGB
|
|
145
|
-
const idat = zlib.deflateSync(raw, { level: 9 });
|
|
146
|
-
return Buffer.concat([
|
|
147
|
-
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
148
|
-
pngChunk("IHDR", ihdr),
|
|
149
|
-
pngChunk("IDAT", idat),
|
|
150
|
-
pngChunk("IEND", Buffer.alloc(0)),
|
|
151
|
-
]);
|
|
152
|
-
}
|
|
153
|
-
/** A random 4-digit code for one probe attempt. */
|
|
154
|
-
function randomCode() {
|
|
155
|
-
let out = "";
|
|
156
|
-
for (let i = 0; i < 4; i++)
|
|
157
|
-
out += String(randomInt(0, 10));
|
|
158
|
-
return out;
|
|
159
|
-
}
|
|
160
|
-
// ── Probe runner ──────────────────────────────────────────────────────────────────────────
|
|
161
|
-
/** Parse the resolved probe's stdout (pi's `--mode json` event stream) the same way a real run
|
|
162
|
-
* is parsed, so a live probe sees the same final text and error classification the harness
|
|
163
|
-
* would. */
|
|
164
|
-
function parseProbeOutput(stdout) {
|
|
165
|
-
const parser = new PiStreamParser();
|
|
166
|
-
parser.feed(stdout);
|
|
167
|
-
return {
|
|
168
|
-
finalText: parser.finalText,
|
|
169
|
-
configError: parser.configError,
|
|
170
|
-
transientRateLimit: parser.transientRateLimit,
|
|
171
|
-
transientBackend: parser.transientBackend,
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
/** Whether an answer looks like a (wrong) read of the 4-digit code rather than a refusal: the
|
|
175
|
-
* text contains a 4-digit run of digits (e.g. "The number is 5825."). A model that cannot see
|
|
176
|
-
* at all does not emit a number. */
|
|
177
|
-
function isFourDigitNumber(text) {
|
|
178
|
-
return /\b\d{4}\b/.test(text);
|
|
179
|
-
}
|
|
180
|
-
/** Probe one claimed pi seam with a real image (plans/image-input.md "Doctor"). Runs the
|
|
181
|
-
* resolved pi binary in a run's environment (the caller passes `env`, typically
|
|
182
|
-
* `childEnv({ PATH: pathEnv })` which inherits `PI_CODING_AGENT_DIR`, so the probe reads the
|
|
183
|
-
* same models.json a real run would):
|
|
184
|
-
*
|
|
185
|
-
* `pi --print --mode json --no-session --no-tools --no-mcp --provider P --model M
|
|
186
|
-
* --thinking <level> @<png> "Reply with only the 4-digit number shown in the image."`
|
|
187
|
-
*
|
|
188
|
-
* Pass: the final assistant text contains the code. A wrong answer retries once with a fresh
|
|
189
|
-
* code (a misread is not blindness). Two wrong answers that are not both numbers fail (a
|
|
190
|
-
* refusal or empty answer is blindness), and so does a 4xx. Two wrong *numbers* — the model
|
|
191
|
-
* read a number, wrongly — cannot be told from a flaky misread, so it is `could-not-verify`
|
|
192
|
-
* rather than `fail` and is never cached: the next doctor run probes again instead of turning
|
|
193
|
-
* a capable seam text-only for a week. A timeout, spawn failure, 429, 5xx, or connection error
|
|
194
|
-
* is also `could-not-verify` — it says nothing about capability and is never cached. `code` is
|
|
195
|
-
* a test seam (a real probe always draws a fresh random code); `env` and `timeoutMs` are
|
|
196
|
-
* injectable so tests need no real pi. */
|
|
197
|
-
export async function runImageProbe(config, seam, env, timeoutMs = PROBE_TIMEOUT_MS, code) {
|
|
198
|
-
const envBin = env.TUMWATER_PI_BIN?.trim();
|
|
199
|
-
// Resolve from the run's own environment (env override → config), exactly as a real run's
|
|
200
|
-
// spawn would rather than only the harness process env — so tests can point a fake pi there.
|
|
201
|
-
const bin = envBin
|
|
202
|
-
? envBin.includes("/")
|
|
203
|
-
? path.resolve(envBin)
|
|
204
|
-
: envBin
|
|
205
|
-
: resolveBackendBin(config).bin;
|
|
206
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tumwater-image-probe-"));
|
|
207
|
-
const wrong = []; // the per-attempt "answered …" detail for a wrong answer
|
|
208
|
-
const misread = []; // whether each wrong answer was a (wrong) 4-digit number
|
|
209
|
-
try {
|
|
210
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
211
|
-
const thisCode = code ?? randomCode();
|
|
212
|
-
const png = makeProbePng(thisCode);
|
|
213
|
-
const pngPath = path.join(dir, `probe-${attempt}.png`);
|
|
214
|
-
fs.writeFileSync(pngPath, png);
|
|
215
|
-
const args = ["--print", "--mode", "json", "--no-session", "--no-tools", "--no-mcp"];
|
|
216
|
-
if (seam.provider)
|
|
217
|
-
args.push("--provider", seam.provider);
|
|
218
|
-
if (seam.model)
|
|
219
|
-
args.push("--model", seam.model);
|
|
220
|
-
if (seam.thinking)
|
|
221
|
-
args.push("--thinking", seam.thinking);
|
|
222
|
-
args.push(`@${pngPath}`, "Reply with only the 4-digit number shown in the image.");
|
|
223
|
-
const result = await boundedProbe(bin, args, env, timeoutMs, PROBE_MAX_OUTPUT_BYTES);
|
|
224
|
-
if (result === null)
|
|
225
|
-
return { verdict: "could-not-verify", detail: "the probe could not run or timed out" };
|
|
226
|
-
const parsed = parseProbeOutput(result.stdout);
|
|
227
|
-
if (parsed.configError)
|
|
228
|
-
return { verdict: "fail", detail: "the provider rejected the request (4xx)" };
|
|
229
|
-
if (parsed.transientRateLimit)
|
|
230
|
-
return { verdict: "could-not-verify", detail: "the provider rate-limited the request (429)" };
|
|
231
|
-
if (parsed.transientBackend)
|
|
232
|
-
return { verdict: "could-not-verify", detail: "the provider backend failed (5xx or connection)" };
|
|
233
|
-
const text = parsed.finalText.trim();
|
|
234
|
-
if (text.includes(thisCode))
|
|
235
|
-
return { verdict: "pass", detail: `answered "${text}"` };
|
|
236
|
-
// A wrong 4-digit number says "I looked and got the digits wrong"; a refusal or empty
|
|
237
|
-
// answer says "I cannot see". The drawn code is exactly 4 digits, so a 4-digit answer is
|
|
238
|
-
// a plausible (wrong) read.
|
|
239
|
-
misread.push(isFourDigitNumber(text));
|
|
240
|
-
wrong.push(text ? `answered "${text}"` : "answered nothing");
|
|
241
|
-
}
|
|
242
|
-
// A refusal or empty answer on either attempt is blindness — the two-strikes rule: fail,
|
|
243
|
-
// cached. Two wrong *numbers* cannot be told from a flaky misread, so it is could-not-verify
|
|
244
|
-
// and never cached: the next doctor run probes again instead of turning a capable seam
|
|
245
|
-
// text-only for a week. Either way the detail names what the model said, so the doctor
|
|
246
|
-
// line, the alert, and a bug report can tell blindness from a misread.
|
|
247
|
-
return misread[0] && misread[1]
|
|
248
|
-
? {
|
|
249
|
-
verdict: "could-not-verify",
|
|
250
|
-
detail: `two misreads (${wrong.join(", ")}) — the model read a number, wrongly; not blindness, re-probe on the next doctor run`,
|
|
251
|
-
}
|
|
252
|
-
: { verdict: "fail", detail: `two wrong answers (${wrong[1]})` };
|
|
253
|
-
}
|
|
254
|
-
finally {
|
|
255
|
-
removeTree(dir);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
/** The backlog-feed team alerts, in emission order: stale open bugs, a failed notify page,
|
|
2
|
-
* waiting bugs, stranded inbox prompts, a stalled merge queue, skipped merge-queue files, and
|
|
3
|
-
* merges refused by an uncommitted primary checkout. One of the per-domain builders split out of
|
|
4
|
-
* src/ui/team-alerts.ts; the shared types and wording helpers live in team-alert-shared.ts. */
|
|
5
|
-
import { moreDetail, openBacklogAction } from "./team-alert-shared.js";
|
|
6
|
-
import { plural } from "../text/phrases.js";
|
|
7
|
-
import { roleLabel } from "../roles/role-labels.js";
|
|
8
|
-
import { duration } from "./run-progress-model.js";
|
|
9
|
-
import { humanAgo } from "../text/datetime.js";
|
|
10
|
-
import { joinWithMore, squash } from "../text/text.js";
|
|
11
|
-
// The per-arm builders below follow the push convention shared with the sibling splits: each
|
|
12
|
-
// takes the `out` array plus the one input field its arm reads and pushes exactly the alert the
|
|
13
|
-
// arm pushes, so buildBacklogFeedAlerts's emission order is its call order.
|
|
14
|
-
function pushStaleBugsAlert(out, staleBugs) {
|
|
15
|
-
// A stale bug (doctor's STALE_BUG_DAYS rule, src/doctor/doctor-backlog-bugs.ts) sits when no
|
|
16
|
-
// role fixes it — the bugfix role is disabled or failing — so it asks (amber) the way the
|
|
17
|
-
// refused alert does, naming the oldest, its age, and the doctor's retract advice. Its
|
|
18
|
-
// callers pass it oldest-first (alert-feeds.ts staleOpenBugs sorts most-days-waited first),
|
|
19
|
-
// so the first element is the most-stale bug and is named with its position and age; the rest
|
|
20
|
-
// are folded through entryListDetail like the questions and refused alerts, so the wording and
|
|
21
|
-
// counts cannot drift.
|
|
22
|
-
if (staleBugs.length) {
|
|
23
|
-
const first = staleBugs[0];
|
|
24
|
-
const rest = staleBugs.slice(1);
|
|
25
|
-
out.push({
|
|
26
|
-
key: "stale-bugs",
|
|
27
|
-
tone: "amber",
|
|
28
|
-
title: `${plural(staleBugs.length, "open bug waited", "open bugs waited")} 3+ days for a fix`,
|
|
29
|
-
detail: `#${first.position} "${first.title}" has waited ${first.days}d for a fix` +
|
|
30
|
-
moreDetail(rest.map((r) => r.title)) +
|
|
31
|
-
` · check that the bugfix role is enabled and not failing, or retract it with \`tumwater bug --cancel ${first.position}\``,
|
|
32
|
-
// The action lands the operator on the first stale bug.
|
|
33
|
-
actions: openBacklogAction({ file: "bugs", index: first.index }),
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
function pushNotifyAlert(out, notifyFailure, now) {
|
|
38
|
-
// A failed notify page (doctor's NOTIFY_CHECK_WINDOW_MS rule, src/doctor/doctor-notify.ts
|
|
39
|
-
// checkNotify) leaves the team unsupervised — a dead webhook must not page the operator when
|
|
40
|
-
// something else goes wrong — so a page that failed within the last day asks (amber) the way
|
|
41
|
-
// the stale-bug alert does, naming the count, the age of the latest, its squashed message,
|
|
42
|
-
// and the `tumwater notify --test` fix. Its callers feed it the latest failure's message via
|
|
43
|
-
// alert-feeds.ts's notifyFailures (the same isNotifyFailure filter and window), so the
|
|
44
|
-
// alert and the doctor cannot drift.
|
|
45
|
-
if (notifyFailure) {
|
|
46
|
-
out.push({
|
|
47
|
-
key: "notify",
|
|
48
|
-
tone: "amber",
|
|
49
|
-
title: `${plural(notifyFailure.count, "failed notify page", "failed notify pages")} in the last day`,
|
|
50
|
-
detail: `the notify hook could not reach you ${humanAgo(notifyFailure.latestTs, now)}: ${squash(notifyFailure.message, 160)} — check it with \`tumwater notify --test\``,
|
|
51
|
-
actions: [],
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
function pushWaitingBugsAlert(out, staleWaitingBugs) {
|
|
56
|
-
// A waiting bug (doctor's STALE_WAIT_DAYS rule, src/doctor/doctor-backlog-bugs.ts
|
|
57
|
-
// checkWaitingBugs) whose flake has not come back parks indefinitely, so a hold that has
|
|
58
|
-
// outlived a week asks (amber) the way the stale-bug alert does, naming the oldest hold, its
|
|
59
|
-
// age, and the release-or-keep advice. Its callers pass it most-days-first (alert-feeds.ts
|
|
60
|
-
// staleWaitingBugs sorts by days descending, ties in file order), so the first element is the
|
|
61
|
-
// longest-held bug and is named with its position and days; the rest are folded through
|
|
62
|
-
// entryListDetail like the questions and refused alerts, so the wording and counts cannot
|
|
63
|
-
// drift.
|
|
64
|
-
if (staleWaitingBugs.length) {
|
|
65
|
-
const first = staleWaitingBugs[0];
|
|
66
|
-
const rest = staleWaitingBugs.slice(1);
|
|
67
|
-
out.push({
|
|
68
|
-
key: "waiting-bugs", // vocabulary-ok: the waiting-bugs amber alert's key
|
|
69
|
-
tone: "amber",
|
|
70
|
-
title: `${plural(staleWaitingBugs.length, "waiting bug held", "waiting bugs held")} 7+ days`,
|
|
71
|
-
detail: `#${first.position} "${first.title}" has waited ${first.days}d for its next failure` +
|
|
72
|
-
moreDetail(rest.map((r) => `#${r.position} "${r.title}"`)) +
|
|
73
|
-
` · the hold has outlived a week and this surface omits the doctor's recurrence scan — \`tumwater doctor\` reports whether the flake came back; release it with \`tumwater bug --release ${first.position}\`, or keep the hold if the failure is still expected`,
|
|
74
|
-
// The action lands the operator on the first stale waiting bug.
|
|
75
|
-
actions: openBacklogAction({ file: "bugs", index: first.index }),
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
function pushStalePromptsAlert(out, stalePrompts) {
|
|
80
|
-
// A stranded inbox prompt (doctor's STALE_PROMPT_MS rule, src/doctor/doctor-backlog.ts) sits
|
|
81
|
-
// when a role is paused, disabled, or failing and never reaches the run that would dequeue the
|
|
82
|
-
// prompt, so it asks (amber) the way the stale-bug alert does, naming the oldest prompt, its
|
|
83
|
-
// wait, and the deliver-or-drop advice. Its callers feed it oldest-first (alert-feeds.ts
|
|
84
|
-
// stalePrompts sorts by queuedAtMs ascending), so the first element is the most-stale prompt
|
|
85
|
-
// and is named with its role, position and wait; the rest are folded through entryListDetail
|
|
86
|
-
// like the questions and refused alerts, so the wording and counts cannot drift.
|
|
87
|
-
if (stalePrompts.length) {
|
|
88
|
-
const first = stalePrompts[0];
|
|
89
|
-
const rest = stalePrompts.slice(1);
|
|
90
|
-
out.push({
|
|
91
|
-
key: "stale-prompts",
|
|
92
|
-
tone: "amber",
|
|
93
|
-
title: `${plural(stalePrompts.length, "queued prompt waited", "queued prompts waited")} 1+ day for its role`,
|
|
94
|
-
detail: `inbox prompt for ${roleLabel(first.role)} #${first.position} has waited ${duration(first.waitedMs)} — the role is not dequeuing it (paused, disabled, or failing); resume or enable it, or drop the prompt with \`tumwater prompt --cancel ${first.position} --role ${first.role}\`` +
|
|
95
|
-
moreDetail(rest.map((p) => `${roleLabel(p.role)} #${p.position}`)),
|
|
96
|
-
// The action lands the operator on the Inbox tab (the dashboard's backlog arm reads `act:
|
|
97
|
-
// "queued"` as the Inbox view), where the stranded prompt is cancellable.
|
|
98
|
-
actions: [{ label: "Open inbox", act: "queued" }],
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
function pushStaleMergesAlert(out, staleMerges) {
|
|
103
|
-
// A stalled merge queue (the doctor's MERGE_QUEUE_STALE_MS rule,
|
|
104
|
-
// src/doctor/doctor-backlog.ts, set by the stale-merges alert) sits when a queued change
|
|
105
|
-
// never starts — neither verified nor flagged — so a drain that has stopped reads healthy
|
|
106
|
-
// except for this row. Its callers feed it oldest-first (alert-feeds.ts staleMerges reads
|
|
107
|
-
// queuedMerges in queue-file order), so the first element is the longest-waiting merge and is
|
|
108
|
-
// named with its role, summary and wait; the rest are folded through joinWithMore like the
|
|
109
|
-
// actions and no-image alerts, so the wording and counts cannot drift.
|
|
110
|
-
if (staleMerges.length) {
|
|
111
|
-
const first = staleMerges[0];
|
|
112
|
-
const rest = staleMerges.slice(1);
|
|
113
|
-
out.push({
|
|
114
|
-
key: "stale-merges",
|
|
115
|
-
tone: "amber",
|
|
116
|
-
title: `${plural(staleMerges.length, "queued merge waited", "queued merges waited")} 6+ hours to start`,
|
|
117
|
-
detail: `${roleLabel(first.role)} — "${first.summary}" — has waited ${duration(first.waitedMs)} to start` +
|
|
118
|
-
moreDetail(rest.map((m) => roleLabel(m.role)), (labels) => joinWithMore(labels, 3)) +
|
|
119
|
-
` — the merge queue has stopped moving; run \`tumwater queue\` to see it, or drop the entry with \`tumwater queue --cancel <n>\``,
|
|
120
|
-
// The header's `merge queue: N` badge and the Pending view already carry the drill-in.
|
|
121
|
-
actions: [],
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
function pushSkippedMergesAlert(out, skippedMergeFiles) {
|
|
126
|
-
// A merge-queue file `readMergeQueue` skips (torn, foreign, or vanished mid-listing) is the
|
|
127
|
-
// always-on twin of the doctor's `merge-queue files` row (doctor-backlog.ts
|
|
128
|
-
// skippedMergeQueueFiles, feeding this from alert-feeds.ts's skippedMergeFiles): it sits
|
|
129
|
-
// in .tumwater/merge-queue/ forever because the drain drops only an unreadable head, so a
|
|
130
|
-
// skipped file after a healthy entry strands its merge until someone removes it. The stale-
|
|
131
|
-
// merges alert above reads only readable entries, so it can never cover one. The doctor's row
|
|
132
|
-
// and this alert share the one relative-path list, so they name the same files; the count and
|
|
133
|
-
// the first skipped file are shown, the rest folded through joinWithMore.
|
|
134
|
-
if (skippedMergeFiles.length) {
|
|
135
|
-
const [first, ...rest] = skippedMergeFiles;
|
|
136
|
-
out.push({
|
|
137
|
-
key: "skipped-merges",
|
|
138
|
-
tone: "amber",
|
|
139
|
-
title: `${plural(skippedMergeFiles.length, "merge-queue file is", "merge-queue files are")} unreadable`,
|
|
140
|
-
detail: `the queue reader skips ${first} — a torn or foreign file never clears itself, so its merge is stranded; remove it (a crashed enqueue's merge ref still recovers on the next run)` +
|
|
141
|
-
moreDetail(rest, (labels) => joinWithMore(labels, 3)),
|
|
142
|
-
actions: [],
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
function pushPrimaryOverwriteAlert(out, primaryOverwrite, now) {
|
|
147
|
-
// A merge the primary checkout blocked (merge-git.ts primaryCheckoutBlockers, the
|
|
148
|
-
// overwriteBlockedReason `warning` the merge path writes) — the always-on twin of the doctor's
|
|
149
|
-
// `uncommitted backlog` row, but read off events so it reaches the status poll (the doctor's
|
|
150
|
-
// git read is async and never gets there). A team stuck retrying one dirty file is a single
|
|
151
|
-
// amber banner until the file is committed and the refusals age out; alert-feeds.ts's
|
|
152
|
-
// primaryOverwriteBlocks feeds it the last day's refusals, counting them and naming the
|
|
153
|
-
// latest's age, squashed message, and the commit fix.
|
|
154
|
-
if (primaryOverwrite) {
|
|
155
|
-
out.push({
|
|
156
|
-
key: "primary-overwrite",
|
|
157
|
-
tone: "amber",
|
|
158
|
-
title: `${plural(primaryOverwrite.count, "merge was", "merges were")} refused by files uncommitted in your primary checkout`,
|
|
159
|
-
detail: `most recently ${humanAgo(primaryOverwrite.latestTs, now)}: ${squash(primaryOverwrite.message, 160)} — roles cannot commit your checkout; commit the file(s) on main with \`tumwater doctor --commit-backlog\` (or by hand) so merges can proceed`,
|
|
160
|
-
actions: [],
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
/** The backlog-feed alerts, in emission order: stale open bugs, a failed notify page, waiting
|
|
165
|
-
* bugs, stranded inbox prompts, a stalled merge queue, skipped merge-queue files, and merges
|
|
166
|
-
* refused by an uncommitted primary checkout. Extracted from teamAlerts (part 3/5 of the
|
|
167
|
-
* teamAlerts split) so the shared module stays small; the input names exactly the fields this
|
|
168
|
-
* run reads. Each arm is one module-private builder named push<Alert>Alert, called here in arm
|
|
169
|
-
* order, so a backlog-feed alert is found by name and the caller's emission order is preserved. */
|
|
170
|
-
export function buildBacklogFeedAlerts(input) {
|
|
171
|
-
const out = [];
|
|
172
|
-
const { staleBugs, notifyFailure, staleWaitingBugs, stalePrompts, staleMerges, skippedMergeFiles, primaryOverwrite, now, } = input;
|
|
173
|
-
pushStaleBugsAlert(out, staleBugs);
|
|
174
|
-
pushNotifyAlert(out, notifyFailure, now);
|
|
175
|
-
pushWaitingBugsAlert(out, staleWaitingBugs);
|
|
176
|
-
pushStalePromptsAlert(out, stalePrompts);
|
|
177
|
-
pushStaleMergesAlert(out, staleMerges);
|
|
178
|
-
pushSkippedMergesAlert(out, skippedMergeFiles);
|
|
179
|
-
pushPrimaryOverwriteAlert(out, primaryOverwrite, now);
|
|
180
|
-
return out;
|
|
181
|
-
}
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
/** The quarantine, flapped-check, and post-merge review team alerts, in emission order: a stale
|
|
2
|
-
* or oversized flake quarantine, checks flapping more than once, a burst of post-merge review
|
|
3
|
-
* rejections, a post-merge review one failed run from being dropped, a post-merge review the
|
|
4
|
-
* drain dropped leaves its commit on main unreviewed, a main tip the watch's diff rules
|
|
5
|
-
* rejected, and the ci role's full suite stalled over three days without covering a new tip.
|
|
6
|
-
* One of the per-domain builders split out of src/ui/team-alerts.ts; the shared types and
|
|
7
|
-
* wording helpers live in team-alert-shared.ts. */
|
|
8
|
-
import { seeFailures } from "./team-alert-shared.js";
|
|
9
|
-
import { CI_PARITY_STALE_MS, ciParityStalled } from "../run/ci-parity.js";
|
|
10
|
-
import { QA_COVERAGE_STALE_MS } from "../run/qa-coverage.js";
|
|
11
|
-
import { plural } from "../text/phrases.js";
|
|
12
|
-
import { humanAgo, DAY_MS } from "../text/datetime.js";
|
|
13
|
-
import { squash } from "../text/text.js";
|
|
14
|
-
import { roleLabel } from "../roles/role-labels.js";
|
|
15
|
-
import { shortSha } from "../text/format.js";
|
|
16
|
-
// A stale or oversized flake quarantine — the always-on twin of the doctor's `flaky checks`
|
|
17
|
-
// row (main-guardrails.ts checkFlakyChecks, quarantineWarning): a quarantined test counts as
|
|
18
|
-
// a known failure until its filed bug leaves BUGS.md `## Open`, so an old entry means the
|
|
19
|
-
// team is merging on a suite whose red is being excused. alert-feeds.ts's quarantineAlert
|
|
20
|
-
// feeds it from the same quarantineWarning the doctor calls, so the alert and the doctor
|
|
21
|
-
// cannot drift on when the list is a problem; the warning phrase and the oldest test's age
|
|
22
|
-
// are shown, folded through squash like other long names.
|
|
23
|
-
function pushQuarantineAlert(out, quarantined, now) {
|
|
24
|
-
if (quarantined) {
|
|
25
|
-
out.push({
|
|
26
|
-
key: "quarantine",
|
|
27
|
-
tone: "amber",
|
|
28
|
-
title: `${plural(quarantined.count, "test")} in the flake quarantine — the gate is excusing their failures`,
|
|
29
|
-
detail: `${quarantined.warning} · oldest: ${squash(quarantined.oldestTest, 80)} (${humanAgo(quarantined.oldestFirstSeen, now)}) — a quarantined test counts as a known failure until its filed bug leaves BUGS.md \`## Open\`, so fix the test or file the missing bug`,
|
|
30
|
-
actions: seeFailures(),
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
// Checks that flapped more than once in the last day — the always-on twin of the doctor's
|
|
35
|
-
// `flaky checks` row (doctor-project-checks.ts checkFlakyChecks): the gate's single re-run
|
|
36
|
-
// absorbs a one-off flake, but a check flapping twice silently pays that re-run on every
|
|
37
|
-
// merge it touches, so the tally belongs on every observer surface while it happens.
|
|
38
|
-
// alert-feeds.ts's flappedChecksAlert feeds it from the same repeatedFlakeCounts and
|
|
39
|
-
// flappedChecksPhrase the doctor calls, so the alert and the doctor cannot drift on the
|
|
40
|
-
// count or the wording.
|
|
41
|
-
function pushFlappedChecksAlert(out, flappedChecks) {
|
|
42
|
-
if (flappedChecks) {
|
|
43
|
-
out.push({
|
|
44
|
-
key: "flapped-checks",
|
|
45
|
-
tone: "amber",
|
|
46
|
-
title: `${plural(flappedChecks.count, "check has", "checks have")} flapped more than once in the last day`,
|
|
47
|
-
detail: `${flappedChecks.detail} — a flapping check pays the gate's one re-run on every merge it touches; fix it or file it with \`tumwater bug\``,
|
|
48
|
-
actions: seeFailures(),
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
// Last day's post-merge review rejections — the reviewer-fix signal for the `review.afterMerge`
|
|
53
|
-
// tiering (src/review/post-merge-review.ts `reviewMergedCommit`). A post-merge rejection is the
|
|
54
|
-
// costly kind: the commit already reached main, so the rejection is answered only by the
|
|
55
|
-
// reviewer's own fix, and the always-on signal for a day crossing POST_MERGE_REJECT_WARN is
|
|
56
|
-
// this alert. alert-feeds.ts's postMergeChurnAlert feeds it
|
|
57
|
-
// from the same `afterMerge: true` `review_rejected` events the report's
|
|
58
|
-
// postMergeRejectionsByRole folds, so the alert and the report cannot disagree on which
|
|
59
|
-
// rejections are post-merge.
|
|
60
|
-
function pushPostMergeChurnAlert(out, postMergeChurn) {
|
|
61
|
-
if (postMergeChurn) {
|
|
62
|
-
out.push({
|
|
63
|
-
key: "post-merge-churn",
|
|
64
|
-
tone: "amber",
|
|
65
|
-
title: `${plural(postMergeChurn.count, "post-merge review rejection", "post-merge review rejections")} in the last day`,
|
|
66
|
-
detail: `${postMergeChurn.roles.map((r) => `${roleLabel(r.role)} ×${r.count}`).join(", ")} — each commit already reached main and stays there with the reviewer's recorded reasons (never re-reviewed — Forward, never back); narrow \`review.afterMerge\` for the noisy role or raise the authoring bar`,
|
|
67
|
-
actions: seeFailures(),
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
// A post-merge review one failed run from being dropped — the always-on twin of the doctor's
|
|
72
|
-
// `post-merge reviews` row (src/doctor/doctor-backlog.ts checkPostReviews) — the last moment
|
|
73
|
-
// an operator can fix the reviewer backend or deliberately accept the commit unreviewed.
|
|
74
|
-
// postReviewRiskAlert feeds it from the same atRiskPostReviews traversal and phrase the doctor
|
|
75
|
-
// calls, so the alert and the doctor cannot drift on the trigger or the wording.
|
|
76
|
-
function pushPostMergeRiskAlert(out, postReviewRisk) {
|
|
77
|
-
if (postReviewRisk) {
|
|
78
|
-
out.push({
|
|
79
|
-
key: "post-merge-risk",
|
|
80
|
-
tone: "amber",
|
|
81
|
-
title: `${plural(postReviewRisk.count, "post-merge review is", "post-merge reviews are")} one failed run from being dropped`,
|
|
82
|
-
detail: postReviewRisk.detail,
|
|
83
|
-
actions: seeFailures(),
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
// A post-merge review the drain actually dropped — the commit it leaves on main unreviewed
|
|
88
|
-
// (src/merge/merge-post-review-drain.ts `startPostReview` at POST_REVIEW_FAILURE_LIMIT).
|
|
89
|
-
// Reads only the `warning` event the drop branch writes, after the at-risk alert is moot:
|
|
90
|
-
// the entry is gone, so no surface names the commit without this.
|
|
91
|
-
// droppedPostReviewsAlert feeds it from the same DROPPED_POST_REVIEW_MARKER the drain's
|
|
92
|
-
// cannot drift on what a drop is (the per-run failed-review warning never matches).
|
|
93
|
-
function pushDroppedPostReviewsAlert(out, droppedPostReviews, now) {
|
|
94
|
-
if (droppedPostReviews) {
|
|
95
|
-
out.push({
|
|
96
|
-
key: "dropped-post-reviews",
|
|
97
|
-
tone: "amber",
|
|
98
|
-
title: `${plural(droppedPostReviews.count, "post-merge review was dropped", "post-merge reviews were dropped")} in the last day — ${plural(droppedPostReviews.count, "the commit sits", "the commits sit")} on main unreviewed`,
|
|
99
|
-
detail: `most recently ${humanAgo(droppedPostReviews.latestTs, now)}: ${squash(droppedPostReviews.latestMessage, 160)} — the reviewer kept failing past \`POST_REVIEW_FAILURE_LIMIT\`, so the commit is beyond the review gate; fix the reviewer backend so later commits are reviewed`,
|
|
100
|
-
actions: [{ label: "See failures", act: "view", arg: "failures" }],
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
// A main tip the watch's five diff rules rejected in the trailing day — the always-on
|
|
105
|
-
// surface for the `warning` main-watch.ts's `watchRules` logs (`main's tip <short sha> fails
|
|
106
|
-
// the diff rules: <reason>`). The gate judges one change; the watch judges each first-parent
|
|
107
|
-
// commit's own diff, so a conflict marker, a protected edit, a leaked secret, a phantom fix
|
|
108
|
-
// claim or a broken backlog structure that reached main through a gate blind spot sits on
|
|
109
|
-
// main with only that event log to say so. alert-feeds.ts's mainRuleFailures feeds it
|
|
110
|
-
// from the same marker, so the alert and the watch cannot drift on what a rule failure is (a
|
|
111
|
-
// `main_check` or `review_rejected` carrying the same text never matches).
|
|
112
|
-
function pushMainDiffRulesAlert(out, mainRuleFailure, now) {
|
|
113
|
-
if (mainRuleFailure) {
|
|
114
|
-
out.push({
|
|
115
|
-
key: "main-diff-rules",
|
|
116
|
-
tone: "amber",
|
|
117
|
-
title: `${plural(mainRuleFailure.count, "diff-rule failure on main", "diff-rule failures on main")} in the last day`,
|
|
118
|
-
detail: `most recently ${humanAgo(mainRuleFailure.latestTs, now)}: ${squash(mainRuleFailure.latestMessage, 160)} — the gate approved a diff the watch's five rules reject, so main carries what the gate would refuse; repair the tip or revert it with \`tumwater revert <sha>\``,
|
|
119
|
-
actions: seeFailures(),
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
// The ci role's full suite silently stopped covering new tips — the always-on twin of the
|
|
124
|
-
// doctor's `ci parity` row (doctor-project-checks.ts checkCiParity), read from the same
|
|
125
|
-
// ciParityStalled predicate and CI_PARITY_STALE_MS threshold so the alert and the row cannot
|
|
126
|
-
// disagree on when a post-merge gap is a stall. A `stale` record is normal right after a
|
|
127
|
-
// merge (main moves on every merge and the role runs on its own clock), but one that stays
|
|
128
|
-
// stale past three days means the role that is now the full suite's own runner has silently
|
|
129
|
-
// stopped covering new tips (disabled, paused, wedged, or failing before it records). The
|
|
130
|
-
// fix is a role toggle the role drawer already has, so there is no action button here.
|
|
131
|
-
function pushCiStaleAlert(out, ciParity, now) {
|
|
132
|
-
if (ciParity && ciParityStalled(ciParity, now)) {
|
|
133
|
-
out.push({
|
|
134
|
-
key: "ci-stale",
|
|
135
|
-
tone: "amber",
|
|
136
|
-
title: `main's full suite has not covered a tip in over ${CI_PARITY_STALE_MS / DAY_MS} days`,
|
|
137
|
-
detail: `the ci role's last full-suite run covered ${shortSha(ciParity.covered)} ${humanAgo(ciParity.lastRunAt, now)}; main has moved since, so no full-suite run covers the current tip — the ci role is disabled, paused, or unable to record; resume or enable it, or check \`tumwater doctor\``,
|
|
138
|
-
actions: [],
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
// The qa role's flow coverage silently stopped advancing — the always-on twin of the
|
|
143
|
-
// doctor's `qa coverage` row (doctor-project-checks.ts checkQaCoverage), fed from the same
|
|
144
|
-
// qaCoverageStalled predicate via alert-feeds.ts's qaCoverageAlert so the alert and the
|
|
145
|
-
// row cannot disagree on when a quiet qa is a stall. A missing ledger (no record yet) is not
|
|
146
|
-
// a stall, so this fires only once a recorded flow's newest exercise is over three days old —
|
|
147
|
-
// the team's only flow-level observer has silently stopped recording (disabled, paused,
|
|
148
|
-
// wedged, or failing before it records). The fix is a role toggle the role drawer already has,
|
|
149
|
-
// so there is no action button here.
|
|
150
|
-
function pushQaStaleAlert(out, qaCoverage, now) {
|
|
151
|
-
if (qaCoverage) {
|
|
152
|
-
out.push({
|
|
153
|
-
key: "qa-stale",
|
|
154
|
-
tone: "amber",
|
|
155
|
-
title: `qa's flow coverage has not advanced in over ${QA_COVERAGE_STALE_MS / DAY_MS} days`,
|
|
156
|
-
detail: `the qa role's newest flow ${qaCoverage.newestFlow} was exercised ${humanAgo(qaCoverage.newestAt, now)}; coverage has not advanced since, so the qa role is disabled, paused, or unable to record; resume or enable it, or check \`tumwater doctor\``,
|
|
157
|
-
actions: [],
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
/** The quarantine, flapped-check, and post-merge review alerts, in emission order: a
|
|
162
|
-
* stale or oversized flake quarantine, checks flapping more than once, a
|
|
163
|
-
* burst of post-merge review rejections, a post-merge review one failed run from being
|
|
164
|
-
* dropped, a post-merge review the drain dropped leaves its commit on main unreviewed, a
|
|
165
|
-
* main tip the watch's diff rules rejected, and the ci role's full suite stalled over three
|
|
166
|
-
* days without covering a new tip.
|
|
167
|
-
* Extracted from teamAlerts (part 4/5 of the teamAlerts split) so the shared module stays
|
|
168
|
-
* small; the input names exactly the fields this run reads (including `now`, which the
|
|
169
|
-
* quarantine and dropped-post-reviews age lines need). The caller pushes the returned alerts
|
|
170
|
-
* at the position this run used to occupy, so the emitted key order is unchanged. */
|
|
171
|
-
export function buildMergeAlerts(input) {
|
|
172
|
-
const out = [];
|
|
173
|
-
const { quarantined, flappedChecks, postMergeChurn, postReviewRisk, droppedPostReviews, mainRuleFailure, ciParity, qaCoverage, now } = input;
|
|
174
|
-
pushQuarantineAlert(out, quarantined, now);
|
|
175
|
-
pushFlappedChecksAlert(out, flappedChecks);
|
|
176
|
-
pushPostMergeChurnAlert(out, postMergeChurn);
|
|
177
|
-
pushPostMergeRiskAlert(out, postReviewRisk);
|
|
178
|
-
pushDroppedPostReviewsAlert(out, droppedPostReviews, now);
|
|
179
|
-
pushMainDiffRulesAlert(out, mainRuleFailure, now);
|
|
180
|
-
pushCiStaleAlert(out, ciParity, now);
|
|
181
|
-
pushQaStaleAlert(out, qaCoverage, now);
|
|
182
|
-
return out;
|
|
183
|
-
}
|