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.
Files changed (227) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-run.js +1 -0
  4. package/dist/src/agent/opencode/write-guard-plugin/index.js +11 -8
  5. package/dist/src/agent/subscription-account.js +68 -3
  6. package/dist/src/agent/write-guard.js +50 -13
  7. package/dist/src/backlog/backlog-eligibility.js +8 -4
  8. package/dist/src/backlog/backlog-index.js +2 -2
  9. package/dist/src/backlog/backlog-md.js +45 -19
  10. package/dist/src/backlog/backlog-release.js +3 -11
  11. package/dist/src/backlog/backlog-write.js +19 -11
  12. package/dist/src/baseline/main-baseline.js +47 -72
  13. package/dist/src/baseline/main-guardrails.js +11 -7
  14. package/dist/src/baseline/main-red.js +171 -19
  15. package/dist/src/baseline/main-watch.js +71 -24
  16. package/dist/src/budget/budget-fallback-view.js +96 -28
  17. package/dist/src/build/build-check-detect.js +22 -3
  18. package/dist/src/build/build-check-events.js +25 -5
  19. package/dist/src/build/build-check-report.js +40 -4
  20. package/dist/src/build/build-check-rerun.js +62 -0
  21. package/dist/src/build/build-check-scoped.js +13 -2
  22. package/dist/src/build/build-check.js +2 -1
  23. package/dist/src/build/build-info.js +4 -0
  24. package/dist/src/build/dep-install.js +1 -2
  25. package/dist/src/build/host-sleep.js +9 -0
  26. package/dist/src/change/change-render.js +2 -1
  27. package/dist/src/cli/cli-args.js +48 -3
  28. package/dist/src/cli/cli-command-args.js +12 -4
  29. package/dist/src/cli/cli-flag-specs.js +5 -3
  30. package/dist/src/cli/cli-init.js +5 -1
  31. package/dist/src/cli/cli-log-commands.js +2 -2
  32. package/dist/src/cli/cli-query-commands.js +31 -15
  33. package/dist/src/cli/cli-review-commands.js +3 -3
  34. package/dist/src/cli/cli-role-command.js +338 -244
  35. package/dist/src/cli/cli-start.js +10 -2
  36. package/dist/src/cli/completion.js +6 -2
  37. package/dist/src/cli/help.js +6 -3
  38. package/dist/src/cli.js +112 -140
  39. package/dist/src/collections.js +21 -0
  40. package/dist/src/concurrency/check-permit.js +30 -3
  41. package/dist/src/concurrency/lock.js +73 -14
  42. package/dist/src/config/config-editable-keys.js +9 -0
  43. package/dist/src/config/config-fallbacks.js +16 -5
  44. package/dist/src/config/config-schema.js +3 -3
  45. package/dist/src/config/config-validation.js +2 -0
  46. package/dist/src/config/config-views.js +55 -33
  47. package/dist/src/config/config-write.js +76 -16
  48. package/dist/src/config/config.js +12 -5
  49. package/dist/src/doctor/doctor-backlog-bugs.js +33 -1
  50. package/dist/src/doctor/doctor-backlog.js +6 -8
  51. package/dist/src/doctor/doctor-check-names.js +2 -0
  52. package/dist/src/doctor/doctor-checks.js +13 -6
  53. package/dist/src/doctor/doctor-claude-checks.js +6 -13
  54. package/dist/src/doctor/doctor-codex-checks.js +2 -2
  55. package/dist/src/doctor/doctor-contention-checks.js +7 -6
  56. package/dist/src/doctor/doctor-fallback-checks.js +37 -2
  57. package/dist/src/doctor/doctor-holds.js +7 -10
  58. package/dist/src/doctor/doctor-model-checks.js +16 -1
  59. package/dist/src/doctor/doctor-opencode-checks.js +2 -2
  60. package/dist/src/doctor/doctor-orphans.js +103 -27
  61. package/dist/src/doctor/doctor-project-checks.js +43 -26
  62. package/dist/src/doctor/doctor.js +5 -3
  63. package/dist/src/events/event-format.js +113 -136
  64. package/dist/src/events/event-labels.js +3 -1
  65. package/dist/src/events/events.js +3 -3
  66. package/dist/src/failure/failure-data.js +6 -4
  67. package/dist/src/failure/failure-render.js +1 -0
  68. package/dist/src/failure/failure-state-change.js +14 -4
  69. package/dist/src/failure/failure-time-spend.js +1 -0
  70. package/dist/src/files/file-queue.js +32 -19
  71. package/dist/src/files/files.js +2 -2
  72. package/dist/src/files/json-object.js +3 -2
  73. package/dist/src/gates/budget-gates.js +37 -2
  74. package/dist/src/gates/gate-polls.js +8 -7
  75. package/dist/src/gates/{maintenance-quota.js → maintenance-quota-gates.js} +1 -1
  76. package/dist/src/gates/startup-gates.js +18 -1
  77. package/dist/src/git/git.js +8 -0
  78. package/dist/src/git/slot-salvage.js +6 -0
  79. package/dist/src/git/worktree-pool.js +18 -5
  80. package/dist/src/gui/gui-admin-endpoints.js +11 -1
  81. package/dist/src/gui/gui-args.js +2 -2
  82. package/dist/src/gui/gui-backlog-endpoints.js +2 -1
  83. package/dist/src/gui/gui-history-endpoints.js +7 -7
  84. package/dist/src/gui/gui-operator-commands.js +2 -3
  85. package/dist/src/history/history.js +12 -8
  86. package/dist/src/{run → history}/run-detail-data.js +2 -2
  87. package/dist/src/{run → history}/run-detail-render.js +2 -2
  88. package/dist/src/{run → history}/run-detail.js +2 -2
  89. package/dist/src/inbox/director-request.js +92 -0
  90. package/dist/src/inbox/inbox-top.js +2 -1
  91. package/dist/src/merge/merge-backlog-conflicts.js +33 -11
  92. package/dist/src/merge/merge-batch.js +22 -235
  93. package/dist/src/merge/merge-cancel.js +2 -1
  94. package/dist/src/merge/merge-core.js +26 -22
  95. package/dist/src/merge/merge-drain.js +116 -101
  96. package/dist/src/merge/merge-pipeline.js +11 -8
  97. package/dist/src/merge/merge-post-review-drain.js +8 -2
  98. package/dist/src/merge/merge-questions.js +3 -3
  99. package/dist/src/merge/merge-queue-render.js +2 -2
  100. package/dist/src/merge/merge-queue.js +9 -1
  101. package/dist/src/merge/merge-requeue.js +67 -26
  102. package/dist/src/merge/merge-slot.js +41 -10
  103. package/dist/src/merge/merge-to-main.js +20 -165
  104. package/dist/src/merge/merge-top.js +2 -1
  105. package/dist/src/merge/merge-verify.js +18 -5
  106. package/dist/src/merge/merge-withdrawn.js +71 -0
  107. package/dist/src/model-check/model-check-gate.js +206 -0
  108. package/dist/src/model-check/model-check-probe.js +109 -0
  109. package/dist/src/model-check/model-check.js +101 -0
  110. package/dist/src/{run → observer}/ci-parity.js +15 -2
  111. package/dist/src/operator/operator-commands.js +8 -25
  112. package/dist/src/operator/operator-revert.js +1 -1
  113. package/dist/src/orchestrator/orchestrator-launch.js +6 -1
  114. package/dist/src/orchestrator/orchestrator-poll.js +32 -5
  115. package/dist/src/orchestrator/orchestrator-scheduling.js +7 -7
  116. package/dist/src/orchestrator/orchestrator.js +74 -13
  117. package/dist/src/paths.js +41 -3
  118. package/dist/src/pi/pi-run-result.js +16 -0
  119. package/dist/src/pi/pi-watchdogs.js +15 -4
  120. package/dist/src/process/process.js +24 -0
  121. package/dist/src/prompt/gate-notes.js +10 -4
  122. package/dist/src/prompt/gate-prompts.js +19 -15
  123. package/dist/src/prompt/principles.js +13 -3
  124. package/dist/src/prompt/prompt-followup.js +14 -5
  125. package/dist/src/prompt/prompt.js +11 -0
  126. package/dist/src/redeploy/redeploy-probes.js +53 -18
  127. package/dist/src/redeploy/redeploy-target.js +100 -0
  128. package/dist/src/redeploy/redeployer.js +135 -70
  129. package/dist/src/report/report-main-red.js +2 -2
  130. package/dist/src/report/report-render.js +2 -2
  131. package/dist/src/report/report.js +2 -2
  132. package/dist/src/review/known-flakes.js +105 -42
  133. package/dist/src/review/post-merge-review-accept.js +2 -1
  134. package/dist/src/review/post-merge-review.js +46 -34
  135. package/dist/src/review/review-precheck.js +72 -28
  136. package/dist/src/review/review-verdict.js +25 -17
  137. package/dist/src/roles/role-catalog.js +23 -7
  138. package/dist/src/roles/role-render.js +158 -83
  139. package/dist/src/roles/role-view.js +297 -75
  140. package/dist/src/roles/roles.js +1 -1
  141. package/dist/src/run/run-apply.js +3 -8
  142. package/dist/src/run/run-dismiss.js +2 -2
  143. package/dist/src/run/run-finalize.js +4 -0
  144. package/dist/src/run/run-prompt.js +4 -4
  145. package/dist/src/run/run-stage.js +7 -1
  146. package/dist/src/run/run-timing.js +2 -2
  147. package/dist/src/run/run-usage.js +9 -1
  148. package/dist/src/run/run-verdict.js +80 -52
  149. package/dist/src/runner/runner-leftover.js +48 -1
  150. package/dist/src/runner/runner-outcome.js +1 -2
  151. package/dist/src/runner/runner-pi.js +6 -0
  152. package/dist/src/runner/runner-recovery.js +5 -4
  153. package/dist/src/runner/runner-revision.js +2 -1
  154. package/dist/src/runner/runner-run.js +165 -110
  155. package/dist/src/runner/runner-state-load.js +115 -53
  156. package/dist/src/runner/runner-state.js +3 -2
  157. package/dist/src/runner/runner.js +39 -20
  158. package/dist/src/{orchestrator/scheduling-admit.js → scheduling/admit.js} +2 -2
  159. package/dist/src/scheduling/backoff.js +6 -6
  160. package/dist/src/scheduling/claims.js +103 -26
  161. package/dist/src/{orchestrator/scheduling-scope.js → scheduling/scope.js} +24 -60
  162. package/dist/src/{orchestrator/scheduling-skip.js → scheduling/skip.js} +112 -78
  163. package/dist/src/{run → stage}/stage-path-checks.js +3 -3
  164. package/dist/src/status/status-budget.js +38 -4
  165. package/dist/src/status/status-data.js +23 -14
  166. package/dist/src/status/status-polls.js +83 -26
  167. package/dist/src/status/status-role-notes.js +25 -7
  168. package/dist/src/status/status-roles.js +56 -25
  169. package/dist/src/team/team-orchestrator-info.js +51 -0
  170. package/dist/src/team/team-state.js +25 -9
  171. package/dist/src/text/format.js +20 -2
  172. package/dist/src/text/phrases.js +59 -12
  173. package/dist/src/text/suggest.js +14 -1
  174. package/dist/src/text/text.js +31 -2
  175. package/dist/src/ui/badges.js +102 -17
  176. package/dist/src/ui/gui/gui-client-drawer-config.js +12 -6
  177. package/dist/src/ui/gui/gui-client-model.js +5 -0
  178. package/dist/src/ui/gui/gui-client-operator.js +11 -14
  179. package/dist/src/ui/gui/gui-client-pending.js +10 -3
  180. package/dist/src/ui/gui/gui-client-report-chart.js +22 -15
  181. package/dist/src/ui/gui/gui-client-settings.js +25 -11
  182. package/dist/src/ui/gui/gui-client-team.js +7 -0
  183. package/dist/src/ui/status-model.js +21 -6
  184. package/dist/src/ui/status-payload.js +19 -5
  185. package/dist/src/ui/status-render.js +14 -8
  186. package/dist/src/ui/team-alert-shared.js +2 -2
  187. package/dist/src/ui/team-alert-standings.js +96 -0
  188. package/dist/src/ui/team-alert-status.js +39 -12
  189. package/dist/src/ui/team-alerts.js +8 -4
  190. package/dist/src/ui/tone.js +2 -1
  191. package/dist/src/ui/tui/tui-backlog.js +12 -0
  192. package/dist/src/ui/tui/tui-editor-attach.js +2 -6
  193. package/dist/src/ui/tui/tui-editor-brief.js +2 -6
  194. package/dist/src/ui/tui/tui-editor-interval.js +2 -2
  195. package/dist/src/ui/tui/tui-editor-release.js +2 -6
  196. package/dist/src/ui/tui/tui-editor-retire.js +2 -6
  197. package/dist/src/ui/tui/tui-editor-role-enabled.js +2 -6
  198. package/dist/src/ui/tui/tui-editor-role-model.js +5 -1
  199. package/dist/src/ui/tui/tui-editor-session.js +14 -0
  200. package/dist/src/ui/tui/tui-guard.js +16 -0
  201. package/dist/src/ui/tui/tui-input.js +2 -2
  202. package/dist/src/ui/tui/tui-keys.js +1 -1
  203. package/dist/src/ui/tui/tui-pane-backlog.js +2 -2
  204. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  205. package/dist/src/ui/tui/tui-pane-health.js +29 -35
  206. package/dist/src/ui/tui/tui-pane-history.js +158 -479
  207. package/dist/src/ui/tui/tui-pane-pending-selection.js +346 -0
  208. package/dist/src/ui/tui/tui-pane-pending.js +54 -344
  209. package/dist/src/ui/tui/tui-pane-queued.js +2 -2
  210. package/dist/src/ui/tui/tui-pane-trail.js +415 -0
  211. package/dist/src/ui/tui/tui-pane-transcript.js +2 -7
  212. package/dist/src/ui/tui/tui-pane.js +3 -3
  213. package/dist/src/ui/tui/tui.js +2 -1
  214. package/dist/src/verdict/fix-claim.js +10 -4
  215. package/dist/src/verdict/reply-contract.js +14 -5
  216. package/package.json +9 -7
  217. package/dist/src/merge/merge-check-failures.js +0 -212
  218. package/dist/src/merge/merge-stack.js +0 -235
  219. /package/dist/src/{run → observer}/feedback-filings.js +0 -0
  220. /package/dist/src/{run → observer}/qa-coverage.js +0 -0
  221. /package/dist/src/{run → observer}/selfcheck-digest.js +0 -0
  222. /package/dist/src/{run → stage}/acceptance-greps.js +0 -0
  223. /package/dist/src/{run → stage}/stage-backlog-checks.js +0 -0
  224. /package/dist/src/{run → stage}/stage-check.js +0 -0
  225. /package/dist/src/{run → stage}/stage-checks-shared.js +0 -0
  226. /package/dist/src/{run → stage}/stage-name-checks.js +0 -0
  227. /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, mirrorWorktreePath } from "../paths.js";
12
+ import { baselineVerdictsPath } from "../paths.js";
13
13
  import { memoSetLru } from "../collections.js";
14
- /** Team-shared verdict cache, keyed by main SHA. In-memory only: after a restart the cache is cold
15
- * and one re-check per red SHA happens — cheap and deterministic, mirroring the budget gate's
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 callers are the merge
154
- * path (src/merge/merge-to-main.ts's verifyMerge) and the stack lander
155
- * (src/merge/merge-stack.ts' mergeStack) — both call it only after their fast-forward SUCCEEDED,
156
- * with the exact SHA that just became main: verifyMerge with the POST-rebase head in two cases —
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. The caller is
176
- * merge-to-main.ts's verifyMerge, after it skipped the in-lock check because main moved
177
- * under the gate-verified head only by doc commits. It seeds the head about to become main, as
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
- * `newestGreenAboveBuild` walks `tip` itself and down. Never throws: a git failure is an empty
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
- /** How many first-parent commits below main's tip redeployTarget looks at. */
265
- const REDEPLOY_WALK_MAX = 64;
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 — its in-lock post-rebase re-check, or the gate's pre-check
499
- * when the rebase was a no-op), so the common case is a cache hit. A head that only added doc
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 blames the same sha the phrase names. Owns the blame clause. */
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
- const shouldBlame = culprit !== undefined && culprit.commit === newest.sha;
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 blames the same sha the phrase names — the merge the main watch
138
- * blamed (`; the main watch blames <role>'s merge <sha>`, or `; the main watch blames merge
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 "code merges blocked until
45
- * main is green"). */
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 so the healer runs on the orchestrator's next poll instead of waiting out
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, "code merges blocked until main is green"),
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
- enqueueRolePrompt(root, "ci", mainRedHealPrompt(red.sha));
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 = `Main was reported red at commit ${shortSha(sha)}. Verify main is green now; if it is still red, reproduce the failure and land the fix.`;
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
- * direct `enqueueRolePrompt` in warnMainRedOnce.) Never throws: a prompt that cannot be
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
- enqueueRolePrompt(root, "ci", mainRedHealPrompt(tip, detail));
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.ts's gate and merge-check-failures.ts's attributeRedCheck reach
142
- * through it) — so the event's shape cannot drift between them. */
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
+ }