tumwater 0.4.4 → 0.4.6
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 +1 -1
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-run.js +1 -0
- package/dist/src/agent/opencode/write-guard-plugin/index.js +11 -8
- package/dist/src/agent/subscription-account.js +68 -3
- package/dist/src/agent/write-guard.js +50 -13
- package/dist/src/backlog/backlog-eligibility.js +8 -4
- package/dist/src/backlog/backlog-index.js +2 -2
- package/dist/src/backlog/backlog-md.js +45 -19
- package/dist/src/backlog/backlog-release.js +3 -11
- package/dist/src/backlog/backlog-write.js +19 -11
- package/dist/src/baseline/main-baseline.js +47 -72
- package/dist/src/baseline/main-guardrails.js +11 -7
- package/dist/src/baseline/main-red.js +171 -19
- package/dist/src/baseline/main-watch.js +71 -24
- package/dist/src/budget/budget-fallback-view.js +96 -28
- package/dist/src/build/build-check-detect.js +22 -3
- package/dist/src/build/build-check-events.js +25 -5
- package/dist/src/build/build-check-report.js +40 -4
- package/dist/src/build/build-check-rerun.js +62 -0
- package/dist/src/build/build-check-scoped.js +13 -2
- package/dist/src/build/build-check.js +2 -1
- package/dist/src/build/build-info.js +4 -0
- package/dist/src/build/dep-install.js +1 -2
- package/dist/src/build/host-sleep.js +9 -0
- package/dist/src/change/change-render.js +2 -1
- package/dist/src/cli/cli-args.js +48 -3
- package/dist/src/cli/cli-command-args.js +12 -4
- package/dist/src/cli/cli-flag-specs.js +5 -3
- package/dist/src/cli/cli-init.js +5 -1
- package/dist/src/cli/cli-log-commands.js +2 -2
- package/dist/src/cli/cli-query-commands.js +31 -15
- package/dist/src/cli/cli-review-commands.js +3 -3
- package/dist/src/cli/cli-role-command.js +338 -244
- package/dist/src/cli/cli-start.js +10 -2
- package/dist/src/cli/completion.js +6 -2
- package/dist/src/cli/help.js +6 -3
- package/dist/src/cli.js +112 -140
- package/dist/src/collections.js +21 -0
- package/dist/src/concurrency/check-permit.js +30 -3
- package/dist/src/concurrency/lock.js +73 -14
- package/dist/src/config/config-editable-keys.js +9 -0
- package/dist/src/config/config-fallbacks.js +16 -5
- package/dist/src/config/config-schema.js +3 -3
- package/dist/src/config/config-validation.js +2 -0
- package/dist/src/config/config-views.js +55 -33
- package/dist/src/config/config-write.js +76 -16
- package/dist/src/config/config.js +12 -5
- package/dist/src/doctor/doctor-backlog-bugs.js +33 -1
- package/dist/src/doctor/doctor-backlog.js +6 -8
- package/dist/src/doctor/doctor-check-names.js +2 -0
- package/dist/src/doctor/doctor-checks.js +13 -6
- package/dist/src/doctor/doctor-claude-checks.js +6 -13
- package/dist/src/doctor/doctor-codex-checks.js +2 -2
- package/dist/src/doctor/doctor-contention-checks.js +7 -6
- package/dist/src/doctor/doctor-fallback-checks.js +37 -2
- package/dist/src/doctor/doctor-holds.js +7 -10
- package/dist/src/doctor/doctor-model-checks.js +16 -1
- package/dist/src/doctor/doctor-opencode-checks.js +2 -2
- package/dist/src/doctor/doctor-orphans.js +103 -27
- package/dist/src/doctor/doctor-project-checks.js +43 -26
- package/dist/src/doctor/doctor.js +5 -3
- package/dist/src/events/event-format.js +113 -136
- package/dist/src/events/event-labels.js +3 -1
- package/dist/src/events/events.js +3 -3
- package/dist/src/failure/failure-data.js +6 -4
- package/dist/src/failure/failure-render.js +1 -0
- package/dist/src/failure/failure-state-change.js +14 -4
- package/dist/src/failure/failure-time-spend.js +1 -0
- package/dist/src/files/file-queue.js +32 -19
- package/dist/src/files/files.js +2 -2
- package/dist/src/files/json-object.js +3 -2
- package/dist/src/gates/budget-gates.js +37 -2
- package/dist/src/gates/gate-polls.js +8 -7
- package/dist/src/gates/{maintenance-quota.js → maintenance-quota-gates.js} +1 -1
- package/dist/src/gates/startup-gates.js +18 -1
- package/dist/src/git/git.js +8 -0
- package/dist/src/git/slot-salvage.js +6 -0
- package/dist/src/git/worktree-pool.js +18 -5
- package/dist/src/gui/gui-admin-endpoints.js +11 -1
- package/dist/src/gui/gui-args.js +2 -2
- package/dist/src/gui/gui-backlog-endpoints.js +2 -1
- package/dist/src/gui/gui-history-endpoints.js +7 -7
- package/dist/src/gui/gui-operator-commands.js +2 -3
- package/dist/src/history/history.js +12 -8
- package/dist/src/{run → history}/run-detail-data.js +2 -2
- package/dist/src/{run → history}/run-detail-render.js +2 -2
- package/dist/src/{run → history}/run-detail.js +2 -2
- package/dist/src/inbox/director-request.js +92 -0
- package/dist/src/inbox/inbox-top.js +2 -1
- package/dist/src/merge/merge-backlog-conflicts.js +33 -11
- package/dist/src/merge/merge-batch.js +22 -235
- package/dist/src/merge/merge-cancel.js +2 -1
- package/dist/src/merge/merge-core.js +26 -22
- package/dist/src/merge/merge-drain.js +116 -101
- package/dist/src/merge/merge-pipeline.js +11 -8
- package/dist/src/merge/merge-post-review-drain.js +8 -2
- package/dist/src/merge/merge-questions.js +3 -3
- package/dist/src/merge/merge-queue-render.js +2 -2
- package/dist/src/merge/merge-queue.js +9 -1
- package/dist/src/merge/merge-requeue.js +67 -26
- package/dist/src/merge/merge-slot.js +41 -10
- package/dist/src/merge/merge-to-main.js +20 -165
- package/dist/src/merge/merge-top.js +2 -1
- package/dist/src/merge/merge-verify.js +18 -5
- package/dist/src/merge/merge-withdrawn.js +71 -0
- package/dist/src/model-check/model-check-gate.js +206 -0
- package/dist/src/model-check/model-check-probe.js +109 -0
- package/dist/src/model-check/model-check.js +101 -0
- package/dist/src/{run → observer}/ci-parity.js +15 -2
- package/dist/src/operator/operator-commands.js +8 -25
- package/dist/src/operator/operator-revert.js +1 -1
- package/dist/src/orchestrator/orchestrator-launch.js +6 -1
- package/dist/src/orchestrator/orchestrator-poll.js +32 -5
- package/dist/src/orchestrator/orchestrator-scheduling.js +7 -7
- package/dist/src/orchestrator/orchestrator.js +74 -13
- package/dist/src/paths.js +41 -3
- package/dist/src/pi/pi-run-result.js +16 -0
- package/dist/src/pi/pi-watchdogs.js +15 -4
- package/dist/src/process/process.js +24 -0
- package/dist/src/prompt/gate-notes.js +10 -4
- package/dist/src/prompt/gate-prompts.js +19 -15
- package/dist/src/prompt/principles.js +13 -3
- package/dist/src/prompt/prompt-followup.js +14 -5
- package/dist/src/prompt/prompt.js +11 -0
- package/dist/src/redeploy/redeploy-probes.js +53 -18
- package/dist/src/redeploy/redeploy-target.js +100 -0
- package/dist/src/redeploy/redeployer.js +135 -70
- package/dist/src/report/report-main-red.js +2 -2
- package/dist/src/report/report-render.js +2 -2
- package/dist/src/report/report.js +2 -2
- package/dist/src/review/known-flakes.js +105 -42
- package/dist/src/review/post-merge-review-accept.js +2 -1
- package/dist/src/review/post-merge-review.js +46 -34
- package/dist/src/review/review-precheck.js +72 -28
- package/dist/src/review/review-verdict.js +25 -17
- package/dist/src/roles/role-catalog.js +23 -7
- package/dist/src/roles/role-render.js +158 -83
- package/dist/src/roles/role-view.js +297 -75
- package/dist/src/roles/roles.js +1 -1
- package/dist/src/run/run-apply.js +3 -8
- package/dist/src/run/run-dismiss.js +2 -2
- package/dist/src/run/run-finalize.js +4 -0
- package/dist/src/run/run-prompt.js +4 -4
- package/dist/src/run/run-stage.js +7 -1
- package/dist/src/run/run-timing.js +2 -2
- package/dist/src/run/run-usage.js +9 -1
- package/dist/src/run/run-verdict.js +80 -52
- package/dist/src/runner/runner-leftover.js +48 -1
- package/dist/src/runner/runner-outcome.js +1 -2
- package/dist/src/runner/runner-pi.js +6 -0
- package/dist/src/runner/runner-recovery.js +5 -4
- package/dist/src/runner/runner-revision.js +2 -1
- package/dist/src/runner/runner-run.js +165 -110
- package/dist/src/runner/runner-state-load.js +115 -53
- package/dist/src/runner/runner-state.js +3 -2
- package/dist/src/runner/runner.js +39 -20
- package/dist/src/{orchestrator/scheduling-admit.js → scheduling/admit.js} +2 -2
- package/dist/src/scheduling/backoff.js +6 -6
- package/dist/src/scheduling/claims.js +103 -26
- package/dist/src/{orchestrator/scheduling-scope.js → scheduling/scope.js} +24 -60
- package/dist/src/{orchestrator/scheduling-skip.js → scheduling/skip.js} +112 -78
- package/dist/src/{run → stage}/stage-path-checks.js +3 -3
- package/dist/src/status/status-budget.js +38 -4
- package/dist/src/status/status-data.js +23 -14
- package/dist/src/status/status-polls.js +83 -26
- package/dist/src/status/status-role-notes.js +25 -7
- package/dist/src/status/status-roles.js +56 -25
- package/dist/src/team/team-orchestrator-info.js +51 -0
- package/dist/src/team/team-state.js +25 -9
- package/dist/src/text/format.js +20 -2
- package/dist/src/text/phrases.js +59 -12
- package/dist/src/text/suggest.js +14 -1
- package/dist/src/text/text.js +31 -2
- package/dist/src/ui/badges.js +102 -17
- package/dist/src/ui/gui/gui-client-drawer-config.js +12 -6
- package/dist/src/ui/gui/gui-client-model.js +5 -0
- package/dist/src/ui/gui/gui-client-operator.js +11 -14
- package/dist/src/ui/gui/gui-client-pending.js +10 -3
- package/dist/src/ui/gui/gui-client-report-chart.js +22 -15
- package/dist/src/ui/gui/gui-client-settings.js +25 -11
- package/dist/src/ui/gui/gui-client-team.js +7 -0
- package/dist/src/ui/status-model.js +21 -6
- package/dist/src/ui/status-payload.js +19 -5
- package/dist/src/ui/status-render.js +14 -8
- package/dist/src/ui/team-alert-shared.js +2 -2
- package/dist/src/ui/team-alert-standings.js +96 -0
- package/dist/src/ui/team-alert-status.js +39 -12
- package/dist/src/ui/team-alerts.js +8 -4
- package/dist/src/ui/tone.js +2 -1
- package/dist/src/ui/tui/tui-backlog.js +12 -0
- package/dist/src/ui/tui/tui-editor-attach.js +2 -6
- package/dist/src/ui/tui/tui-editor-brief.js +2 -6
- package/dist/src/ui/tui/tui-editor-interval.js +2 -2
- package/dist/src/ui/tui/tui-editor-release.js +2 -6
- package/dist/src/ui/tui/tui-editor-retire.js +2 -6
- package/dist/src/ui/tui/tui-editor-role-enabled.js +2 -6
- package/dist/src/ui/tui/tui-editor-role-model.js +5 -1
- package/dist/src/ui/tui/tui-editor-session.js +14 -0
- package/dist/src/ui/tui/tui-guard.js +16 -0
- package/dist/src/ui/tui/tui-input.js +2 -2
- package/dist/src/ui/tui/tui-keys.js +1 -1
- package/dist/src/ui/tui/tui-pane-backlog.js +2 -2
- package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
- package/dist/src/ui/tui/tui-pane-health.js +29 -35
- package/dist/src/ui/tui/tui-pane-history.js +158 -479
- package/dist/src/ui/tui/tui-pane-pending-selection.js +346 -0
- package/dist/src/ui/tui/tui-pane-pending.js +54 -344
- package/dist/src/ui/tui/tui-pane-queued.js +2 -2
- package/dist/src/ui/tui/tui-pane-trail.js +415 -0
- package/dist/src/ui/tui/tui-pane-transcript.js +2 -7
- package/dist/src/ui/tui/tui-pane.js +3 -3
- package/dist/src/ui/tui/tui.js +2 -1
- package/dist/src/verdict/fix-claim.js +10 -4
- package/dist/src/verdict/reply-contract.js +14 -5
- package/package.json +9 -7
- package/dist/src/merge/merge-check-failures.js +0 -212
- package/dist/src/merge/merge-stack.js +0 -235
- /package/dist/src/{run → observer}/feedback-filings.js +0 -0
- /package/dist/src/{run → observer}/qa-coverage.js +0 -0
- /package/dist/src/{run → observer}/selfcheck-digest.js +0 -0
- /package/dist/src/{run → stage}/acceptance-greps.js +0 -0
- /package/dist/src/{run → stage}/stage-backlog-checks.js +0 -0
- /package/dist/src/{run → stage}/stage-check.js +0 -0
- /package/dist/src/{run → stage}/stage-checks-shared.js +0 -0
- /package/dist/src/{run → stage}/stage-name-checks.js +0 -0
- /package/dist/src/{run → stage}/stage-prose-checks.js +0 -0
|
@@ -9,10 +9,14 @@ import { treeDiffFiles } from "../git/git-diff.js";
|
|
|
9
9
|
import { isExemptDiff } from "../review/exemptions.js";
|
|
10
10
|
import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
|
|
11
11
|
import { isJsonObject } from "../files/json-object.js";
|
|
12
|
-
import { baselineVerdictsPath
|
|
12
|
+
import { baselineVerdictsPath } from "../paths.js";
|
|
13
13
|
import { memoSetLru } from "../collections.js";
|
|
14
|
-
/** Team-shared verdict cache, keyed by main SHA.
|
|
15
|
-
*
|
|
14
|
+
/** Team-shared verdict cache, keyed by main SHA. Exported read-only to the one consumer outside
|
|
15
|
+
* this module that decides redeploy's target from it (src/redeploy/redeploy-target.ts's
|
|
16
|
+
* `redeployTarget`); writes stay here — `rememberBaseline`, `noteGreenBaseline` and
|
|
17
|
+
* `noteInheritedGreen` — so the cache's write policy has one home. In-memory only: after a
|
|
18
|
+
* restart the cache is cold and one re-check per red SHA happens — cheap and deterministic,
|
|
19
|
+
* mirroring the budget gate's
|
|
16
20
|
* stateless resume. Entries come from two sources: checkMainBaseline's own runs, and
|
|
17
21
|
* noteGreenBaseline seeding a green verdict for a SHA that just became main
|
|
18
22
|
* (src/merge/merge-to-main.ts: either its in-lock post-rebase re-check passed on exactly that
|
|
@@ -33,7 +37,7 @@ import { memoSetLru } from "../collections.js";
|
|
|
33
37
|
* not opt-in only: a red carries the worktrees that observed it (`redFrom`) and is re-run by the
|
|
34
38
|
* next DIFFERENT worktree to consult it, so a single environmental red can no longer block every
|
|
35
39
|
* role until main moves — see shouldRerunRed. */
|
|
36
|
-
const baselineCache = new Map();
|
|
40
|
+
export const baselineCache = new Map();
|
|
37
41
|
/** Safety cap so the per-process verdict cache can never grow unbounded: it is keyed by every
|
|
38
42
|
* merged code SHA and a red entry carries the failed run's output tail, so a team running for
|
|
39
43
|
* weeks would accumulate one entry per merge forever. Evicting the oldest entry (Map's insertion
|
|
@@ -150,15 +154,10 @@ function shouldRerunRed(cached, wt, reverifyRed) {
|
|
|
150
154
|
const seen = cached.redFrom ?? [];
|
|
151
155
|
return seen.length < 2 && !seen.includes(wt);
|
|
152
156
|
}
|
|
153
|
-
/** Record a green baseline verdict for `sha` WITHOUT running anything. The
|
|
154
|
-
* path (src/merge/merge-to-main.ts's
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* its own in-lock re-check just ran this project's declared check green on that tree, or the
|
|
158
|
-
* rebase was a no-op so the review gate's pre-check (which runs outside the merge lock) had
|
|
159
|
-
* already run green on exactly this tree — and mergeStack with the stacked tip when a
|
|
160
|
-
* scope-`batch` check ran green on exactly it (a skipped check seeds nothing, and neither does a
|
|
161
|
-
* merge_blocked stack or a doc-only re-stack that merged without a check of its own). Seeding
|
|
157
|
+
/** Record a green baseline verdict for `sha` WITHOUT running anything. The caller is the merge
|
|
158
|
+
* path (src/merge/merge-to-main.ts's mergeToMain), called only after its fast-forward SUCCEEDED,
|
|
159
|
+
* with the exact SHA that just became main.
|
|
160
|
+
* Seeding
|
|
162
161
|
* here means that once the merge completes — main now points at this very SHA — the next fresh
|
|
163
162
|
* run's checkMainBaseline is a cache hit instead of re-running the full suite on an
|
|
164
163
|
* already-verified tree: for tumwater itself that saves one redundant `npm test` (~1 min) per
|
|
@@ -171,11 +170,23 @@ function shouldRerunRed(cached, wt, reverifyRed) {
|
|
|
171
170
|
export function noteGreenBaseline(sha) {
|
|
172
171
|
rememberBaseline(sha, { status: "green", sha });
|
|
173
172
|
}
|
|
173
|
+
/** Settle `sha`'s red verdict by a re-run of only its failing tests on the same tree
|
|
174
|
+
* (build-check-rerun.ts; the main watch's confirmation, which replaced a second full run): with
|
|
175
|
+
* none failing again the SHA is promoted green, as the full re-run's green promoted it — every test
|
|
176
|
+
* has now passed on this tree — and otherwise the red narrows to the tests that failed again.
|
|
177
|
+
* Returns the settled verdict, or null when `sha` holds no red to settle. */
|
|
178
|
+
export function settleRedByRerun(sha, failing) {
|
|
179
|
+
const red = baselineCache.get(sha);
|
|
180
|
+
if (red?.status !== "red")
|
|
181
|
+
return null;
|
|
182
|
+
const settled = failing.length === 0 ? { status: "green", sha } : { ...red, failingTests: [...failing] };
|
|
183
|
+
rememberBaseline(sha, settled);
|
|
184
|
+
return settled;
|
|
185
|
+
}
|
|
174
186
|
/** Record an INHERITED green for `sha` (MainBaseline.inheritedFrom): a tree no check ran on, whose
|
|
175
|
-
* only difference from the green-verified `from` is in review-exempt paths.
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* verifyMerge's own noteGreenBaseline calls do. If the fast-forward then fails, the entry is
|
|
187
|
+
* only difference from the green-verified `from` is in review-exempt paths. It seeds the head
|
|
188
|
+
* about to become main, as
|
|
189
|
+
* noteGreenBaseline calls do. If the fast-forward then fails, the entry is
|
|
179
190
|
* still true of that tree and simply never consulted. It never displaces an observed verdict: a
|
|
180
191
|
* check that actually ran on this tree knows more than the carry-over. */
|
|
181
192
|
export function noteInheritedGreen(sha, from) {
|
|
@@ -193,7 +204,7 @@ const INHERIT_WALK_MAX = 64;
|
|
|
193
204
|
* first-parent scans in this module share, spelled the same on purpose so a change to the walk
|
|
194
205
|
* shape (the bound, the `--first-parent` flag) lands once. `inheritedGreen` and `watchedBaseline`
|
|
195
206
|
* walk a commit's ancestors (`${sha}^` / `${tip}^`, the commit itself excluded);
|
|
196
|
-
* `
|
|
207
|
+
* `newestObservedBaseline` walks `tip` itself and down. Never throws: a git failure is an empty
|
|
197
208
|
* list, and every caller already treats that as a no-inheritance / no-verdict answer. */
|
|
198
209
|
async function firstParentWalk(wt, ref, max) {
|
|
199
210
|
return gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${max}`, ref));
|
|
@@ -261,8 +272,21 @@ export async function watchedBaseline(wt, sha) {
|
|
|
261
272
|
}
|
|
262
273
|
return null;
|
|
263
274
|
}
|
|
264
|
-
/**
|
|
265
|
-
|
|
275
|
+
/** The newest verdict a check OBSERVED on `tip` or its first-parent ancestors — `tip` itself
|
|
276
|
+
* first, looking at most INHERIT_WALK_MAX commits down — WITHOUT running anything; null when
|
|
277
|
+
* there is none. An inherited green is walked past: no check ran on that tree, and a doc edit can
|
|
278
|
+
* break a test that reads the docs (checkMainBaseline's `observedOnly`). The review gate's
|
|
279
|
+
* attribution reads this when the main watch verifies main (main-red.ts's watchedMainVerdict):
|
|
280
|
+
* with `check.mergeCommand` in effect every observed verdict is a full check the watch or
|
|
281
|
+
* another consumer ran. Never throws: a git failure reads as no verdict. */
|
|
282
|
+
export async function newestObservedBaseline(wt, tip) {
|
|
283
|
+
for (const sha of await firstParentWalk(wt, tip, INHERIT_WALK_MAX)) {
|
|
284
|
+
const verdict = baselineCache.get(sha);
|
|
285
|
+
if (verdict && verdict.inheritedFrom === undefined)
|
|
286
|
+
return verdict;
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
266
290
|
/** True when the main watch (src/baseline/main-watch.ts) verifies main — the configs where no
|
|
267
291
|
* merge seeds a green verdict (`watchedBaseline`'s doc) and the watch has a check to run: a
|
|
268
292
|
* declared check and a `check.mergeCommand` (or its gate-command fallback) in effect. In this
|
|
@@ -273,56 +297,6 @@ const REDEPLOY_WALK_MAX = 64;
|
|
|
273
297
|
export function watchVerifiesMain(root, config) {
|
|
274
298
|
return detectBuildCheck(root, config) !== null && mergeCommandOf(config) !== undefined;
|
|
275
299
|
}
|
|
276
|
-
/** The newest first-parent commit below `tip`, down to and through `buildSha`, with a green
|
|
277
|
-
* cached verdict — the walk+scan redeployTarget's watched and un-watched branches share. Only
|
|
278
|
-
* their fallback differs: `notOnHistory` when `buildSha` is not on the first-parent walk at all
|
|
279
|
-
* (the caller keeps today's answer, `tip`), `onHistoryNone` when it is but nothing above it is
|
|
280
|
-
* green (watched keeps the build; un-watched keeps `tip`). Never throws: a git failure leaves
|
|
281
|
-
* the walk empty, so the fallback answers. */
|
|
282
|
-
async function newestGreenAboveBuild(wt, tip, buildSha, notOnHistory, onHistoryNone) {
|
|
283
|
-
const walk = await firstParentWalk(wt, tip, REDEPLOY_WALK_MAX);
|
|
284
|
-
const buildAt = walk.indexOf(buildSha);
|
|
285
|
-
if (buildAt === -1)
|
|
286
|
-
return notOnHistory;
|
|
287
|
-
for (const sha of walk.slice(0, buildAt)) {
|
|
288
|
-
if (baselineCache.get(sha)?.status === "green")
|
|
289
|
-
return sha;
|
|
290
|
-
}
|
|
291
|
-
return onHistoryNone;
|
|
292
|
-
}
|
|
293
|
-
/** The commit self-redeploy should run: main's newest first-parent commit with a green full-check
|
|
294
|
-
* verdict in the cache (observed or inherited) that is newer than the running build `buildSha`,
|
|
295
|
-
* looking at most REDEPLOY_WALK_MAX commits down from `tip`.
|
|
296
|
-
*
|
|
297
|
-
* With `watched` false (the default, a project whose main the watch does not verify), a tip the
|
|
298
|
-
* cache has no verdict for is the target as is — the redeployer checks it. A tip whose cached red
|
|
299
|
-
* was observed in the redeployer's mirror worktree (mirrorWorktreePath of `wt`) falls back to the
|
|
300
|
-
* newest older green above the build. Any other red may be another worktree's environmental one,
|
|
301
|
-
* which the redeployer re-verifies; a build that is not a first-parent ancestor of `tip`, or no
|
|
302
|
-
* green, also leaves the tip the target, and the redeployer blocks that commit alone.
|
|
303
|
-
*
|
|
304
|
-
* With `watched` true (watchVerifiesMain), the watch supplies the verdicts and the redeployer only
|
|
305
|
-
* reads them: walk `tip`'s first-parent history down to `buildSha` and return the first commit
|
|
306
|
-
* whose cached verdict is green — a cached red is walked past, never targeted. With no green
|
|
307
|
-
* above the build, target `buildSha` itself, so buildStaleness reads it as not stale and no
|
|
308
|
-
* episode, hold or pre-warm starts until the watch calls a newer commit green. A build that is
|
|
309
|
-
* not a first-parent ancestor of `tip` keeps today's answer (`tip`, which the redeployer
|
|
310
|
-
* checks), as does `watched` false. Every target `watched` picks has a cached green, so
|
|
311
|
-
* mainIsGreen on it is a cache hit and the redeployer starts no suite.
|
|
312
|
-
* The cache holds only full-check verdicts, so a cheaper command's green never makes a commit
|
|
313
|
-
* deployable.
|
|
314
|
-
* Never throws: a git failure leaves the tip as the target. */
|
|
315
|
-
export async function redeployTarget(wt, tip, buildSha, watched = false) {
|
|
316
|
-
if (watched) {
|
|
317
|
-
if (tip === "")
|
|
318
|
-
return tip;
|
|
319
|
-
return newestGreenAboveBuild(wt, tip, buildSha, tip, buildSha);
|
|
320
|
-
}
|
|
321
|
-
const red = baselineCache.get(tip);
|
|
322
|
-
if (tip === "" || red?.status !== "red" || !(red.redFrom ?? []).includes(mirrorWorktreePath(wt)))
|
|
323
|
-
return tip;
|
|
324
|
-
return newestGreenAboveBuild(wt, tip, buildSha, tip, tip);
|
|
325
|
-
}
|
|
326
300
|
/** Verify main's own build/test suite at `wt`'s HEAD — which must be pristine main (the caller
|
|
327
301
|
* is the fresh-run path right after resetWorktreeToMain; a dirty or ahead worktree would
|
|
328
302
|
* measure the wrong thing). Cache hit returns immediately; on miss runs detectBuildCheck +
|
|
@@ -451,6 +425,7 @@ function baselineFromOutcome(sha, wt, outcome) {
|
|
|
451
425
|
script: outcome.script,
|
|
452
426
|
outputTail: outcome.outputTail,
|
|
453
427
|
failingTests: outcome.failingTests ?? [],
|
|
428
|
+
...(outcome.failingTestFiles ? { failingTestFiles: outcome.failingTestFiles } : {}),
|
|
454
429
|
redFrom: priorRedFrom.includes(wt) ? priorRedFrom : [...priorRedFrom, wt],
|
|
455
430
|
};
|
|
456
431
|
}
|
|
@@ -495,8 +470,8 @@ async function runBaselineOnce(sha, wt, config, onRun, sampleSleep, tier) {
|
|
|
495
470
|
return { baseline };
|
|
496
471
|
}
|
|
497
472
|
/** Is main green at the mirror worktree's HEAD? The merge path seeds a green verdict for every
|
|
498
|
-
* merged code SHA (noteGreenBaseline —
|
|
499
|
-
*
|
|
473
|
+
* merged code SHA (noteGreenBaseline — the stack lander's scope-`batch` check on the merged tip,
|
|
474
|
+
* or a no-op gate pre-check, so the common case is a cache hit. A head that only added doc
|
|
500
475
|
* commits on top of such a SHA inherits its green (checkMainBaseline's inheritedGreen). The
|
|
501
476
|
* redeployer's cooldown pre-warm asks this for every new head, and those doc heads were most of the
|
|
502
477
|
* harness's baseline runs (247 of 319 over 2026-10-02..12). Otherwise the project's declared check
|
|
@@ -115,13 +115,17 @@ function redFailingTest(newest) {
|
|
|
115
115
|
return Array.isArray(newest.failingTests) && typeof newest.failingTests[0] === "string" ? newest.failingTests[0] : undefined;
|
|
116
116
|
}
|
|
117
117
|
/** The blame clause naming the merge the main watch blamed, when the newest `culprit` in the
|
|
118
|
-
* window
|
|
119
|
-
function redBlameClause(culprit, newest) {
|
|
118
|
+
* window names the spell's opener or its newest red. Owns the blame clause. */
|
|
119
|
+
function redBlameClause(culprit, startEv, newest) {
|
|
120
120
|
// The blame names the merge the main watch blamed for the red — the same `main_culprit`
|
|
121
121
|
// commit-equals-tip rule the dashboard alert uses (status-polls only publishes a blame whose
|
|
122
122
|
// commit is the derived tip sha), so a red the watch did not blame never borrows another
|
|
123
123
|
// head's culprit. A culprit naming another commit, or none, leaves the phrase as today.
|
|
124
|
-
|
|
124
|
+
// The watch names the commit that OPENED the red (its first red tip since the last green)
|
|
125
|
+
// even when a later tip re-checks red, so the match accepts the spell's opener as well as the
|
|
126
|
+
// newest red tip: the opener is what the watch blames for the ongoing spell, and a
|
|
127
|
+
// pre-fix log that named the newest tip instead still matches its own newest red.
|
|
128
|
+
const shouldBlame = culprit !== undefined && (culprit.commit === newest.sha || culprit.commit === startEv.sha);
|
|
125
129
|
return shouldBlame
|
|
126
130
|
? typeof culprit.runner === "string" && culprit.runner.length > 0
|
|
127
131
|
? `; the main watch blames ${roleLabel(culprit.runner)}'s merge ${shortSha(culprit.commit)}`
|
|
@@ -134,9 +138,9 @@ function redBlameClause(culprit, newest) {
|
|
|
134
138
|
* ignored; a pass (including one naming only quarantined tests, recorded passed) ends the red.
|
|
135
139
|
* The phrase names how long main has had no passing full check, the sha it has been red since,
|
|
136
140
|
* its clock time, the newest red's first failing test when one is named, and — when the newest
|
|
137
|
-
* `main_culprit` in the window
|
|
138
|
-
* blamed (`; the main watch blames <role>'s merge <sha>`, or `; the main watch blames
|
|
139
|
-
* <sha>` when no runner is named), the one rule the dashboard alert and this phrase share.
|
|
141
|
+
* `main_culprit` in the window names the spell's opener or its newest red tip — the merge the
|
|
142
|
+
* main watch blamed (`; the main watch blames <role>'s merge <sha>`, or `; the main watch blames
|
|
143
|
+
* merge <sha>` when no runner is named), the one rule the dashboard alert and this phrase share.
|
|
140
144
|
* Returns null when main is green, or red for RED_MAIN_WARN_MS or less. The per-job work is
|
|
141
145
|
* split into the module-private helpers `scanRedWindow`, `redSpell`, `redSpellDuration`,
|
|
142
146
|
* `redFailingTest` and `redBlameClause`, one per job, so each is found by name. */
|
|
@@ -148,7 +152,7 @@ export function redMainWarning(events, now) {
|
|
|
148
152
|
const duration = redSpellDuration(spell);
|
|
149
153
|
const head = `main has had no passing full check for ${duration}: red since ${shortSha(spell.startEv.sha)} at ${clockHm(spell.startEv.ts)}`;
|
|
150
154
|
const failing = redFailingTest(spell.newest);
|
|
151
|
-
const blame = redBlameClause(window.culprit, spell.newest);
|
|
155
|
+
const blame = redBlameClause(window.culprit, spell.startEv, spell.newest);
|
|
152
156
|
return failing ? `${head} — ${squash(failing, 120)}${blame}` : `${head}${blame}`;
|
|
153
157
|
}
|
|
154
158
|
/** G3 over `entries` (the loadQuarantine list): warns when the list holds more than
|
|
@@ -1,17 +1,20 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { baseRoleOf } from "../roles/runner-ids.js";
|
|
2
|
-
import { enqueueRolePrompt } from "../inbox/inbox.js";
|
|
3
|
+
import { enqueueRolePrompt, queuedRolePromptRecords } from "../inbox/inbox.js";
|
|
4
|
+
import { cancelQueuedFile } from "../inbox/inbox-cancel.js";
|
|
3
5
|
import { liveConfig } from "../config/config.js";
|
|
4
|
-
import { mergeCommandOf } from "../build/build-check-detect.js";
|
|
6
|
+
import { mergeCommandOf, rerunCommandOf } from "../build/build-check-detect.js";
|
|
7
|
+
import { canRerun, rerunCommandLine, rerunTests } from "../build/build-check-rerun.js";
|
|
5
8
|
import { failureHeadline, FAILING_TESTS_MAX } from "../build/build-check-report.js";
|
|
6
9
|
import { baselineCheckEventLogger } from "../build/build-check-events.js";
|
|
7
|
-
import { checkMainBaseline, watchedBaseline } from "./main-baseline.js";
|
|
10
|
+
import { checkMainBaseline, newestObservedBaseline, watchedBaseline } from "./main-baseline.js";
|
|
8
11
|
import { buildMainRedNote } from "../prompt/gate-notes.js";
|
|
9
12
|
import { logEventBestEffort, warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
|
|
10
13
|
import { errorMessage } from "../text/text.js";
|
|
11
14
|
import { mainRedPhrase, plural } from "../text/phrases.js";
|
|
12
15
|
import { clipToWidth } from "../text/text-width.js";
|
|
13
16
|
import { shortSha } from "../text/format.js";
|
|
14
|
-
import { branchHead } from "../git/git.js";
|
|
17
|
+
import { branchHead, headOf, isMergedInto } from "../git/git.js";
|
|
15
18
|
import { gateMainWorktreePath } from "../paths.js";
|
|
16
19
|
import { useWorktree } from "../git/worktree-use.js";
|
|
17
20
|
import { ensureDetachedWorktree } from "../git/worktree.js";
|
|
@@ -36,13 +39,14 @@ let lastMainRedSha = null;
|
|
|
36
39
|
* names, or the red SHA when the red names none. Two red commits with the same failing set send
|
|
37
40
|
* one heal prompt, not one per commit; a green main observed by the ci handoff
|
|
38
41
|
* (`mainRedHealNote`) or the main watch (`clearHealKeyOnGreen`) clears it, so a set that
|
|
39
|
-
* returns after green is healed again.
|
|
42
|
+
* returns after green is healed again. The key is per process; what bounds ci's inbox across
|
|
43
|
+
* restarts is queueHealPrompt's replacement of a waiting heal prompt. */
|
|
40
44
|
let lastHealKey = null;
|
|
41
45
|
/** The failing tests a red verdict names, when the list is complete — non-empty and short of
|
|
42
46
|
* FAILING_TESTS_MAX, which may have been cut short (the same rule as onlyKnownMainFailures).
|
|
43
47
|
* Empty when it names none: main's red is then not a set of known failures, and the heal is
|
|
44
|
-
* keyed to the red SHA rather than a failing set (the event reads "
|
|
45
|
-
*
|
|
48
|
+
* keyed to the red SHA rather than a failing set (the event reads "a change whose own check
|
|
49
|
+
* fails is kept until main moves"). */
|
|
46
50
|
export function namedFailures(red) {
|
|
47
51
|
const tests = red.failingTests ?? [];
|
|
48
52
|
return tests.length > 0 && tests.length < FAILING_TESTS_MAX ? tests : [];
|
|
@@ -59,7 +63,8 @@ function redMainMessage(red, action) {
|
|
|
59
63
|
* lastMainRedSha) so the ci handoff (`mainRedHealNote`) and mainTipVerdict share one guard:
|
|
60
64
|
* whichever observes the red first logs it, the other stays silent. `foundBy` is the role
|
|
61
65
|
* whose path verified the red — when that role is not ci itself, one prompt is enqueued to
|
|
62
|
-
* ci's own inbox
|
|
66
|
+
* ci's own inbox (replacing a heal prompt already waiting there, queueHealPrompt) so the
|
|
67
|
+
* healer runs on the orchestrator's next poll instead of waiting out
|
|
63
68
|
* its own interval or deferral; ci finding its own red skips the enqueue because its running
|
|
64
69
|
* run already carries the note and IS the heal (and enqueuing from inside it would queue a
|
|
65
70
|
* second run). A queued per-role prompt makes the role due by itself (`isEligible`,
|
|
@@ -81,7 +86,7 @@ function warnMainRedOnce(root, red, foundBy) {
|
|
|
81
86
|
...(named.length > 0 ? { failingTests: named } : {}),
|
|
82
87
|
message: named.length > 0
|
|
83
88
|
? `${mainRedPhrase(red.sha)} (${plural(named.length, "failing test")}: ${clipToWidth(named[0] ?? "", 80)}${named.length > 1 ? `, +${named.length - 1} more` : ""}); changes that add no new failures still merge`
|
|
84
|
-
: redMainMessage(red, "
|
|
89
|
+
: redMainMessage(red, "a change whose own check fails is kept until main moves"),
|
|
85
90
|
});
|
|
86
91
|
// The ci role is the healer, and it schedules as an observer, so a red main discovered by any
|
|
87
92
|
// other path could sit unrepaired until ci's own interval or a coincidental wake.
|
|
@@ -94,7 +99,7 @@ function warnMainRedOnce(root, red, foundBy) {
|
|
|
94
99
|
lastHealKey = healKey;
|
|
95
100
|
if (baseRoleOf(foundBy) !== "ci") {
|
|
96
101
|
try {
|
|
97
|
-
|
|
102
|
+
queueHealPrompt(root, mainRedHealPrompt(red.sha));
|
|
98
103
|
}
|
|
99
104
|
catch (err) {
|
|
100
105
|
// Clear the key so a later verification retries the heal. lastMainRedSha stays set, so a
|
|
@@ -112,23 +117,96 @@ function warnMainRedOnce(root, red, foundBy) {
|
|
|
112
117
|
* before the healer consumes the prompt, mainRedHealNote returns undefined (no `<main-red>`
|
|
113
118
|
* note is appended) and the "verify green" clause makes the run a no-op. */
|
|
114
119
|
export function mainRedHealPrompt(sha, detail) {
|
|
115
|
-
const base =
|
|
120
|
+
const base = `${HEAL_PROMPT_OPENING}${shortSha(sha)}. Verify main is green now; if it is still red, reproduce the failure and land the fix.`;
|
|
116
121
|
if (!detail)
|
|
117
122
|
return base;
|
|
118
123
|
const tests = detail.failingTests.length > 0 ? ` Failing: ${detail.failingTests.join("; ")}.` : "";
|
|
124
|
+
const rerun = detail.rerun ? ` ${rerunAdvice(detail.rerun)}` : "";
|
|
119
125
|
const tail = detail.outputTail && detail.outputTail.length > 0 ? ` Output tail: ${detail.outputTail.join(" | ")}` : "";
|
|
120
|
-
return `${base} The watch named ${shortSha(detail.culprit)} as the commit that turned it red.${tests}${tail}`;
|
|
126
|
+
return `${base} The watch named ${shortSha(detail.culprit)} as the commit that turned it red.${tests}${rerun}${tail}`;
|
|
127
|
+
}
|
|
128
|
+
/** The heal's one instruction about re-running: the command that runs only main's failing tests,
|
|
129
|
+
* for reproducing them and for proving the fix, instead of the whole suite. */
|
|
130
|
+
function rerunAdvice(command) {
|
|
131
|
+
return `Run only the failing tests, to reproduce them and again to prove the fix, with: ${command}`;
|
|
132
|
+
}
|
|
133
|
+
/** How every heal prompt opens: mainRedHealPrompt writes it, and queuedHealPrompts recognizes a
|
|
134
|
+
* waiting heal prompt in ci's inbox by it and reads back the SHA that follows. */
|
|
135
|
+
const HEAL_PROMPT_OPENING = "Main was reported red at commit ";
|
|
136
|
+
/** The heal prompts waiting in ci's inbox, oldest first: each queue file's basename and the
|
|
137
|
+
* short SHA it was reported red at. Every other prompt in the inbox (an operator's, a resume's
|
|
138
|
+
* re-queued non-heal request) is left out. An inbox that cannot be listed is warned and reads
|
|
139
|
+
* as holding none. */
|
|
140
|
+
function queuedHealPrompts(root) {
|
|
141
|
+
try {
|
|
142
|
+
return queuedRolePromptRecords(root, "ci").flatMap((e) => {
|
|
143
|
+
if (!e.text.startsWith(HEAL_PROMPT_OPENING))
|
|
144
|
+
return [];
|
|
145
|
+
const sha = /^[0-9a-f]+(?=\.)/.exec(e.text.slice(HEAL_PROMPT_OPENING.length))?.[0];
|
|
146
|
+
return sha ? [{ file: e.file, sha }] : [];
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `could not list ci's inbox for waiting heal prompts: ${errorMessage(err)}`);
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Remove waiting heal prompts from ci's inbox, each logged as a prompt_cancelled under ci
|
|
155
|
+
* (cancelQueuedFile). Best-effort: a prompt that cannot be removed is warned and stays queued,
|
|
156
|
+
* so the heal just queued or the green just settled never fails on it. */
|
|
157
|
+
function dropHealPrompts(root, files) {
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
try {
|
|
160
|
+
cancelQueuedFile(root, "ci", file);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `could not drop the stale heal prompt ${file} from ci's inbox: ${errorMessage(err)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Queue `prompt` for the ci healer in place of every heal prompt already waiting. A waiting one
|
|
168
|
+
* names an older red, and the healer verifies main as it is now whichever prompt it reads, so
|
|
169
|
+
* one waiting heal prompt is enough: without the replacement each red commit on a long red
|
|
170
|
+
* added one, and a healer that clears one per run fell 69 behind (2026-10-30 to 11-01). The new
|
|
171
|
+
* prompt is written first, so a write that fails throws with the older prompts still queued. */
|
|
172
|
+
function queueHealPrompt(root, prompt) {
|
|
173
|
+
const queued = path.basename(enqueueRolePrompt(root, "ci", prompt));
|
|
174
|
+
dropHealPrompts(root, queuedHealPrompts(root).filter((p) => p.file !== queued).map((p) => p.file));
|
|
175
|
+
}
|
|
176
|
+
/** Drop each heal prompt waiting in ci's inbox whose red commit `greenSha` contains: main has
|
|
177
|
+
* passed its check since that red, so the run the prompt would start could only find main
|
|
178
|
+
* green. A prompt naming a commit `greenSha` does not contain (a red reported on a newer tip)
|
|
179
|
+
* stays queued. `wt` is any checkout of the repository, for the ancestry check. Called beside
|
|
180
|
+
* clearHealKeyOnGreen by the main watch's settled green and the ci hand-off's own green.
|
|
181
|
+
* Never throws. */
|
|
182
|
+
export async function dropHealPromptsCoveredBy(root, wt, greenSha) {
|
|
183
|
+
const covered = [];
|
|
184
|
+
for (const { file, sha } of queuedHealPrompts(root)) {
|
|
185
|
+
if (await isMergedInto(wt, sha, greenSha))
|
|
186
|
+
covered.push(file);
|
|
187
|
+
}
|
|
188
|
+
dropHealPrompts(root, covered);
|
|
189
|
+
}
|
|
190
|
+
/** The command line that re-runs `red`'s named failures on their own (rerunCommandLine), or
|
|
191
|
+
* undefined when they cannot be re-run alone (canRerun) — the healer is then left to the full
|
|
192
|
+
* check. */
|
|
193
|
+
function healRerunLine(root, config, red) {
|
|
194
|
+
const tests = namedFailures(red);
|
|
195
|
+
const command = canRerun(root, config, tests) ? rerunCommandOf(root, config) : undefined;
|
|
196
|
+
return command === undefined ? undefined : rerunCommandLine(command, tests, red.failingTestFiles);
|
|
121
197
|
}
|
|
122
198
|
/** Queue the heal prompt for ci with the culprit's detail: the one way a named red-main culprit
|
|
123
199
|
* is answered (Forward, never back, part 4/6): the watch's onCulprit hand-off routes through
|
|
124
200
|
* this, and nothing is ever reverted. (The ci gate's own red-main hand-off is the separate
|
|
125
|
-
*
|
|
201
|
+
* queueHealPrompt call in warnMainRedOnce; both replace a heal prompt already waiting.) Never
|
|
202
|
+
* throws: a prompt that cannot be
|
|
126
203
|
* queued (an unwritable inbox) is warned, like that hand-off guard in warnMainRedOnce — so
|
|
127
204
|
* the watch's onCulprit (whose caller discards the result) cannot derail a watch run or end
|
|
128
205
|
* the orchestrator. */
|
|
129
206
|
export function queueCulpritHeal(root, tip, detail) {
|
|
130
207
|
try {
|
|
131
|
-
|
|
208
|
+
const rerun = healRerunLine(root, liveConfig(root), { failingTests: [...detail.failingTests], failingTestFiles: detail.failingTestFiles });
|
|
209
|
+
queueHealPrompt(root, mainRedHealPrompt(tip, { ...detail, ...(rerun ? { rerun } : {}) }));
|
|
132
210
|
}
|
|
133
211
|
catch (err) {
|
|
134
212
|
warnEventBestEffort(root, TUMWATER_SOURCE, `could not queue the heal prompt for ci: ${errorMessage(err)}`);
|
|
@@ -138,8 +216,8 @@ export function queueCulpritHeal(root, tip, detail) {
|
|
|
138
216
|
* that paid for it, with its duration — the gate's build_check sibling, so both halves of the
|
|
139
217
|
* team's deterministic verification are priced in the feed. The one logger for every baseline
|
|
140
218
|
* check this module runs — the ci handoff, the main watch, and mainTipVerdict's tip
|
|
141
|
-
* check (which review
|
|
142
|
-
*
|
|
219
|
+
* check (which the review pre-check reaches through it) — so the event's shape cannot drift
|
|
220
|
+
* between them. */
|
|
143
221
|
export function baselineCheckLogger(root, role) {
|
|
144
222
|
return baselineCheckEventLogger(role, (event) => logEventBestEffort(root, event));
|
|
145
223
|
}
|
|
@@ -164,12 +242,14 @@ export async function mainRedHealNote(root, role, wt) {
|
|
|
164
242
|
? { baseline: await watchedBaseline(wt) }
|
|
165
243
|
: await checkMainBaseline(wt, config, baselineCheckLogger(root, role));
|
|
166
244
|
const red = baseline.baseline;
|
|
167
|
-
if (red?.status === "green")
|
|
245
|
+
if (red?.status === "green") {
|
|
168
246
|
clearHealKeyOnGreen();
|
|
247
|
+
await dropHealPromptsCoveredBy(root, wt, red.sha);
|
|
248
|
+
}
|
|
169
249
|
if (red?.status !== "red")
|
|
170
250
|
return undefined;
|
|
171
251
|
warnMainRedOnce(root, red, role);
|
|
172
|
-
return buildMainRedNote(red.sha, red.script, failureHeadline(red.outputTail), red.outputTail);
|
|
252
|
+
return buildMainRedNote(red.sha, red.script, failureHeadline(red.outputTail), red.outputTail, healRerunLine(wt, config, red));
|
|
173
253
|
}
|
|
174
254
|
/** Serializes mainTipVerdict's use of its one worktree: the merge pipeline's verifies run their
|
|
175
255
|
* gates concurrently, and a second gate re-pointing the checkout at a newer main while the first's
|
|
@@ -233,7 +313,7 @@ async function verdictAtMainTipIn(root, role, mainBranch, config, forceFresh) {
|
|
|
233
313
|
return { status: "green", sha: baseline.sha };
|
|
234
314
|
if (baseline?.status === "red") {
|
|
235
315
|
warnMainRedOnce(root, baseline, role);
|
|
236
|
-
return { status: "red", sha: baseline.sha };
|
|
316
|
+
return { status: "red", sha: baseline.sha, failingTests: baseline.failingTests ?? [] };
|
|
237
317
|
}
|
|
238
318
|
return {
|
|
239
319
|
status: "unavailable",
|
|
@@ -248,3 +328,75 @@ async function verdictAtMainTipIn(root, role, mainBranch, config, forceFresh) {
|
|
|
248
328
|
return { status: "unavailable", why: errorMessage(err) };
|
|
249
329
|
}
|
|
250
330
|
}
|
|
331
|
+
/** Main's verdict at its tip on a gate's twice-failed check, for an attribution that has to run
|
|
332
|
+
* something: when the failures name their tests and those can be re-run alone (canRerun), only
|
|
333
|
+
* they run, in the `_gate-main` mirror at main's tip — a red names the ones that fail there too,
|
|
334
|
+
* a green says none does. Otherwise, or when the re-run reaches no verdict, main's full check runs
|
|
335
|
+
* fresh (mainTipVerdict with forceFresh), as before. A re-run is no full check: it never touches
|
|
336
|
+
* the verdict cache and warns nothing. Never throws. */
|
|
337
|
+
export async function mainTipFailures(root, role, wt, mainBranch, config, failed) {
|
|
338
|
+
const rerun = failed ? await rerunAtMainTip(root, role, wt, mainBranch, config, failed) : null;
|
|
339
|
+
return rerun ?? mainTipVerdict(root, role, mainBranch, config, true);
|
|
340
|
+
}
|
|
341
|
+
/** mainTipFailures's re-run alone: `failed`'s tests run on their own in the `_gate-main` mirror at
|
|
342
|
+
* main's tip, queued behind the gates and the main watch that share it. Null when they cannot be
|
|
343
|
+
* re-run alone (canRerun) or the re-run reaches no verdict. Never throws. */
|
|
344
|
+
export async function rerunAtMainTip(root, role, wt, mainBranch, config, failed) {
|
|
345
|
+
if (!canRerun(wt, config, failed.tests))
|
|
346
|
+
return null;
|
|
347
|
+
const rerun = await withMainTipWorktree(root, mainBranch, async (mirror) => {
|
|
348
|
+
const result = await rerunTests(root, role, mirror, config, failed.tests, failed.files);
|
|
349
|
+
return result ? { result, sha: await headOf(mirror, "HEAD") } : null;
|
|
350
|
+
}).catch(() => null);
|
|
351
|
+
if (!rerun)
|
|
352
|
+
return null;
|
|
353
|
+
const { result, sha } = rerun;
|
|
354
|
+
return result.failing.length === 0 ? { status: "green", sha } : { status: "red", sha, failingTests: result.failing };
|
|
355
|
+
}
|
|
356
|
+
/** The main watch's newest observed verdict at or below `tip` (newestObservedBaseline), and
|
|
357
|
+
* whether it covers `base`: it was observed on `base` itself or on a commit that contains it, so
|
|
358
|
+
* the tree the change was rebased onto is part of what the watch checked. Shared by the gate's
|
|
359
|
+
* attribution and the kept change's re-queue, so the two agree on when the wait is over. Never
|
|
360
|
+
* throws: a git failure reads as not covered. */
|
|
361
|
+
export async function watchVerdictCovering(wt, tip, base) {
|
|
362
|
+
const verdict = await newestObservedBaseline(wt, tip);
|
|
363
|
+
const covers = verdict !== null && (verdict.sha === base || (await isMergedInto(wt, base, verdict.sha)));
|
|
364
|
+
return { verdict, covers };
|
|
365
|
+
}
|
|
366
|
+
/** Main's verdict for attributing a gate check that failed twice, read from the main watch's
|
|
367
|
+
* verdicts instead of a full check of the gate's own (plans/fast-merge.md part 6/14). The gate
|
|
368
|
+
* used to run main's whole suite here, queued behind the watch's own runs in `_gate-main`: on
|
|
369
|
+
* 2026-10-30 that took 166–2,282 s for a 30 s gate, and two of five runs repeated a commit the
|
|
370
|
+
* watch or an earlier attribution had just checked.
|
|
371
|
+
*
|
|
372
|
+
* `base` is the commit the change was rebased onto (its merge-base with main). When the watch's
|
|
373
|
+
* newest observed verdict covers it (watchVerdictCovering), that verdict answers at once. When the
|
|
374
|
+
* watch is still behind it, the answer is `awaiting`: the gate keeps the change with no strike
|
|
375
|
+
* and the merge pipeline re-queues it once the watch catches up. Waiting instead of judging
|
|
376
|
+
* against an older verdict matters: a commit the watch has not reached yet may be what broke the
|
|
377
|
+
* test, and the change would be rejected for main's failure.
|
|
378
|
+
*
|
|
379
|
+
* `waited` is set on the retry after such a wait, and the gate does not wait twice: after
|
|
380
|
+
* `covered` the newest observed verdict answers even if main moved past it again; after
|
|
381
|
+
* `timed_out` (the watch never got there — held, failing, or main's tip only carried doc commits
|
|
382
|
+
* past its last observed check) main is asked here (mainTipFailures: the gate's `failed` tests
|
|
383
|
+
* re-run alone on main's tip when they can be, else main's full check). The same fallback answers a
|
|
384
|
+
* `covered` retry that finds no observed verdict at all. A red is not warned again here: the watch
|
|
385
|
+
* that observed it reported it. Never throws. */
|
|
386
|
+
export async function watchedMainVerdict(root, role, wt, mainBranch, config, base, waited, failed) {
|
|
387
|
+
const fallback = () => mainTipFailures(root, role, wt, mainBranch, config, failed);
|
|
388
|
+
if (waited === "timed_out")
|
|
389
|
+
return fallback();
|
|
390
|
+
const tip = await branchHead(root, mainBranch);
|
|
391
|
+
if (!tip)
|
|
392
|
+
return { status: "unavailable", why: `${mainBranch} is unreadable` };
|
|
393
|
+
const { verdict, covers } = await watchVerdictCovering(wt, tip, base);
|
|
394
|
+
if (verdict && (covers || waited === "covered")) {
|
|
395
|
+
return verdict.status === "red"
|
|
396
|
+
? { status: "red", sha: verdict.sha, failingTests: verdict.failingTests ?? [] }
|
|
397
|
+
: { status: "green", sha: verdict.sha };
|
|
398
|
+
}
|
|
399
|
+
if (waited === "covered")
|
|
400
|
+
return fallback();
|
|
401
|
+
return { status: "awaiting", ...(verdict ? { sha: verdict.sha } : {}) };
|
|
402
|
+
}
|