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
@@ -1,6 +1,7 @@
1
1
  import { distinctOrdered } from "../collections.js";
2
- import { checkMainBaseline } from "./main-baseline.js";
3
- import { baselineCheckLogger, clearHealKeyOnGreen, withMainTipWorktree } from "./main-red.js";
2
+ import { checkMainBaseline, settleRedByRerun } from "./main-baseline.js";
3
+ import { canRerun, rerunTests } from "../build/build-check-rerun.js";
4
+ import { baselineCheckLogger, clearHealKeyOnGreen, dropHealPromptsCoveredBy, withMainTipWorktree } from "./main-red.js";
4
5
  import { CHECK_TIER } from "../concurrency/check-permit.js";
5
6
  import { FAILING_TESTS_MAX } from "../build/build-check-report.js";
6
7
  import { detectBuildCheck } from "../build/build-check-detect.js";
@@ -32,6 +33,14 @@ function mainCheckEvent(baseline, red, quarantined, covered, durationMs) {
32
33
  }
33
34
  /** The failing tests of each root's newest watch verdict, keyed by root. */
34
35
  const knownFailures = new Map();
36
+ /** The commit each root's red spell was opened at, keyed by root — the tip of the first red
37
+ * verdict since the last green. It stays the named culprit while the spell is open: a later tip
38
+ * re-checks red (the red never inherits across a doc commit) but did not open it, so it is not
39
+ * re-attributed. A green verdict clears it, so a red that opens after a green names its own
40
+ * opener. In-memory only: a restart mid-red loses the spell's opener, and the next settled red
41
+ * tip then names itself as culprit — the pre-fix behaviour, and a bounded one (one tip per
42
+ * restart, since a spell's later tips inherit the red and settle without a re-run). */
43
+ const redSpellOpeners = new Map();
35
44
  /** Main's known failures: the failing tests of the watch's newest verdict — main's tip, or the
36
45
  * nearest tip it checked while the newest head waits — and every quarantined flaky test
37
46
  * (review/known-flakes.ts). Empty when the verdict is green, red with no parsed test, or none has
@@ -138,10 +147,14 @@ export function createMainWatch(root, mainBranch, onCulprit) {
138
147
  }
139
148
  }
140
149
  };
141
- /** Run the declared build check on main's tip, re-running a red once and quarantining a flake
142
- * whose re-run turns green. Returns the check's result and whether any run actually executed
143
- * (false for a cache hit, a skip, or a sleep-spanned run). `config` and `root` are captured
144
- * from `watchOnce`. */
150
+ /** Run the declared build check on main's tip, confirming a red once and quarantining a flake
151
+ * whose confirmation passes. A red whose failing tests can be re-run on their own (canRerun) is
152
+ * confirmed by re-running just those tests, so the full check runs once per tip: a test that
153
+ * passes there is quarantined, and the red narrows to the tests that failed again (or turns
154
+ * green when none did). A re-run that reaches no verdict leaves the first red standing. Without
155
+ * a re-run command the whole check runs again, as before. Returns the check's result and
156
+ * whether any full run actually executed (false for a cache hit, a skip, or a sleep-spanned
157
+ * run). `config` and `root` are captured from `watchOnce`. */
145
158
  async function runBaselineCheck(wt) {
146
159
  let ran = false;
147
160
  const onRun = baselineCheckLogger(root, TUMWATER_SOURCE);
@@ -150,13 +163,27 @@ export function createMainWatch(root, mainBranch, onCulprit) {
150
163
  onRun(run);
151
164
  };
152
165
  let check = await checkMainBaseline(wt, config, hook, false, false, undefined, false, CHECK_TIER.merge);
153
- if (check.baseline?.status === "red") {
154
- const first = check.baseline;
155
- check = await checkMainBaseline(wt, config, hook, true, false, undefined, false, CHECK_TIER.merge);
156
- // A red the same tree's confirming re-run turns green is a flake: quarantine its tests.
157
- if (check.baseline?.status === "green")
158
- quarantineTests(root, first.failingTests, (first.failingTests ?? []).join("\n"));
166
+ if (check.baseline?.status !== "red")
167
+ return { check, ran };
168
+ const first = check.baseline;
169
+ const named = first.failingTests ?? [];
170
+ if (canRerun(wt, config, named)) {
171
+ const rerun = await rerunTests(root, TUMWATER_SOURCE, wt, config, named, first.failingTestFiles, CHECK_TIER.merge);
172
+ if (rerun) {
173
+ // Each test that failed and then passed on this same tree is a flake.
174
+ const passed = named.filter((t) => !rerun.failing.includes(t));
175
+ if (passed.length > 0)
176
+ quarantineTests(root, passed, passed.join("\n"));
177
+ const settled = settleRedByRerun(first.sha, rerun.failing);
178
+ if (settled)
179
+ check = { baseline: settled };
180
+ }
181
+ return { check, ran };
159
182
  }
183
+ check = await checkMainBaseline(wt, config, hook, true, false, undefined, false, CHECK_TIER.merge);
184
+ // A red the same tree's confirming re-run turns green is a flake: quarantine its tests.
185
+ if (check.baseline?.status === "green")
186
+ quarantineTests(root, named, named.join("\n"));
160
187
  return { check, ran };
161
188
  }
162
189
  /** The covered-commit count for a main_check event: the first-parent commits between the
@@ -179,24 +206,37 @@ export function createMainWatch(root, mainBranch, onCulprit) {
179
206
  logEventBestEffort(root, mainCheckEvent(baseline, red, quarantined, covered, ran ? Date.now() - started : 0));
180
207
  return red;
181
208
  }
182
- /** Name a settled red tip as the culprit: its merging runner, the main_culprit event, and
183
- * the onCulprit callback with the tip and its failing tests. */
209
+ /** Name a settled red tip's spell as the culprit: the merging runner, the main_culprit
210
+ * event, and the onCulprit callback with the tip and its failing tests. The commit named is
211
+ * the one that OPENED the red — the first red tip since the last green — not the newest red
212
+ * tip: a doc-exempt tip that lands after the red opened cannot have changed the suite, so
213
+ * blaming it would name a merge in the G1 phrase (src/baseline/main-guardrails.ts) and the
214
+ * ci heal prompt (src/baseline/main-red.ts) that did not break anything. The opener is named
215
+ * once per spell with the tip's failing tests and, when the newest merged event names the
216
+ * opener, its runner; while the spell is open a later tip re-settling red re-attributes
217
+ * nothing. */
184
218
  function reportCulprit(baseline) {
185
- // The newest head is the culprit ("Every commit is verified on main, part 2/3"): the watch
186
- // checks each new red tip, so whatever it found belongs to the head it checked. Once per red
187
- // head — lastWatched already holds it, so a later poll does not attribute it again — name
188
- // the tip with its own failing tests and, when the newest merged event names it, its runner.
189
- const runner = mergingRoleOf(root, baseline.sha);
219
+ // A red spell that is already open keeps its opener named: the first red tip since the
220
+ // last green caused the red, so a later tip re-settling red (a doc-exempt tip cannot
221
+ // change the suite, and a code tip does not open a SECOND spell until a green closed the
222
+ // first) is not re-attributed — no new event, no new heal prompt (the heal dedup already
223
+ // re-queues when the failing set returns after a green).
224
+ if (redSpellOpeners.has(root))
225
+ return;
226
+ const culprit = baseline.sha;
227
+ redSpellOpeners.set(root, culprit);
228
+ const runner = mergingRoleOf(root, culprit);
190
229
  logEventBestEffort(root, {
191
230
  source: TUMWATER_SOURCE,
192
231
  type: "main_culprit",
193
- commit: baseline.sha,
232
+ commit: culprit,
194
233
  ...(runner !== undefined ? { runner } : {}),
195
234
  failingTests: baseline.failingTests ?? [],
196
235
  });
197
236
  onCulprit?.({
198
- commit: baseline.sha,
237
+ commit: culprit,
199
238
  failingTests: baseline.failingTests ?? [],
239
+ ...(baseline.failingTestFiles ? { failingTestFiles: baseline.failingTestFiles } : {}),
200
240
  tip: baseline.sha,
201
241
  });
202
242
  }
@@ -227,13 +267,20 @@ export function createMainWatch(root, mainBranch, onCulprit) {
227
267
  // names the whole tip as the culprit, so the two red kinds are judged independently.
228
268
  await watchRules(wt, previous, baseline.sha);
229
269
  // A settled non-red verdict (a green, or a red naming only quarantined tests) clears the
230
- // heal-dedup key, so a failing set that returns after a green main is healed again. A
231
- // settled red verdict below does NOT clear it: the heal-dedup key stays set while main
232
- // is red.
270
+ // heal-dedup key, so a failing set that returns after a green main is healed again, and
271
+ // drops the heal prompts still waiting for a red this tip has passed. A settled red
272
+ // verdict below does NOT clear it: the heal-dedup key stays set while main is red.
233
273
  if (!red) {
234
274
  clearHealKeyOnGreen();
275
+ await dropHealPromptsCoveredBy(root, wt, baseline.sha);
276
+ redSpellOpeners.delete(root); // a green closes the red spell; the next red names its own opener
235
277
  return;
236
278
  }
279
+ // The spell's opener is blamed once: the first settled red tip since the last green names
280
+ // itself, and a later tip re-settling the same open red re-checks the suite without re-
281
+ // naming the culprit (BUGS.md 2026-10-30 mis-attribution). The map holds the open spell's
282
+ // opener, so a second red while the spell is still open re-checks without re-naming; a
283
+ // green clears the entry (above) so a later red names its own opener.
237
284
  reportCulprit(baseline);
238
285
  });
239
286
  }
@@ -4,18 +4,19 @@
4
4
  * stage-check.ts). This module owns everything pollBudgetGate does from its `onFallback` seam
5
5
  * through the end of the function: the seam/backends walk, the motion key (`resolvedKey`,
6
6
  * `fromKey`), the fallback view derivation (`fallbackConfig`), `chainInfo`, and the `pairs`
7
- * handback list. It no longer moves any tier for a used-up subscription window (part 5d/6): only
8
- * a spent `maxDailyCostUsd` triggers a move, so the view's one job is the cap's free-seam
9
- * install. Its one entry reads the inputs pollBudgetGate's first half already resolved and
10
- * returns the view fields `BudgetGatePoll` carries, so pollBudgetGate keeps its call signature
11
- * and src/gates/gate-polls.ts's destructure of it is unchanged. The plan references the moved
12
- * comments carry (part 5b/8, part 5c/8, 6a/7) hold here. */
7
+ * handback list. It no longer moves any tier for a used-up subscription window (part 5d/6): a
8
+ * spent `maxDailyCostUsd` moves every priced tier to its free seam, and, at any spend, a tier
9
+ * whose own primary failed its model check moves to a verified step of its chain
10
+ * (plans/model-check.md "Fallback"). Its one entry reads the inputs pollBudgetGate's first half
11
+ * already resolved and returns the view fields `BudgetGatePoll` carries, so pollBudgetGate keeps
12
+ * its call signature and src/gates/gate-polls.ts's destructure of it is unchanged. The plan
13
+ * references the moved comments carry (part 5b/8, part 5c/8, 6a/7) hold here. */
13
14
  import { MODEL_TIERS, } from "../config/config-schema.js";
14
15
  import { modelPairName, pairDailyCost } from "../budget/budget.js";
15
16
  import { fallbackServingPair, } from "../budget/fallback-breaker.js";
16
17
  import { applyFallbackModel, tierChainSteps, } from "../config/config-fallbacks.js";
17
18
  import { seamAgent } from "../config/config-model-selector.js";
18
- import { withModelOverrides } from "../config/config-views.js";
19
+ import { seamPairName, withModelOverrides } from "../config/config-views.js";
19
20
  import { piPairFree, seamFree } from "../pi/pi-models.js";
20
21
  import { dayAt } from "../text/datetime.js";
21
22
  /** Can the account not take extra usage: some window is rejected, or the newest non-reset
@@ -44,21 +45,41 @@ export function subscriptionBackendOf(x, accounts) {
44
45
  const account = accounts(id);
45
46
  return account?.billing === "subscription" ? id : null;
46
47
  }
48
+ /** The pair a tier's own primary resolves to under the live config — the name the model check
49
+ * keys it by. */
50
+ function tierPrimaryPair(config, tier) {
51
+ return seamPairName(withModelOverrides(config, tier, {}));
52
+ }
53
+ /** `base` with each check-moved tier's entry replaced by its verified step. */
54
+ function withCheckMoves(base, checkResolved, checkMoved) {
55
+ const out = { ...base };
56
+ for (const tier of checkMoved)
57
+ out[tier] = checkResolved[tier];
58
+ return out;
59
+ }
47
60
  /** Install `state.fallbackConfig` when the fallback changed the view key: at the cap, the free
48
61
  * pair installed top-level and every per-role/reviewer model override dropped, so no seam can
49
62
  * reach a priced model. A breaker-demoted fallback keeps the view too: its gate is `paused`,
50
63
  * but a run parked in the semaphore when it tripped, the half-open probe, and the merges must
51
64
  * still run on the free pair — a demotion must never promote them to the priced model the cap
52
- * already spent. */
53
- function installFallbackConfig(a, fromKey) {
65
+ * already spent. A tier the model check moved (deriveCheckMovedTiers) installs its verified
66
+ * step at any spend, and only its own primary seams move: every other tier, and a role or
67
+ * reviewer pinned to some other pair, keeps its model. */
68
+ function installFallbackConfig(a, fromKey, checkMoved) {
54
69
  const { state, config: liveConfig, providers, catalog, accounts, priceResolved, reached } = a;
55
- if (reached && (state.from !== liveConfig || state.fromKey !== fromKey)) {
70
+ if ((reached || checkMoved.size > 0) && (state.from !== liveConfig || state.fromKey !== fromKey)) {
56
71
  state.from = liveConfig;
57
72
  state.fromKey = fromKey;
73
+ const primaries = new Map([...checkMoved].map((t) => [t, tierPrimaryPair(liveConfig, t)]));
74
+ const checkKeeps = (seam, tier) => primaries.get(tier) !== seamPairName(seam);
58
75
  // At the cap only free seams are available (a subscription claude seam is free money), so
59
76
  // every role/reviewer override that resolves to a priced seam drops to the fallback pair.
60
- const available = (seam) => seamFree(providers, seam, accounts, catalog);
61
- state.fallbackConfig = applyFallbackModel(liveConfig, priceResolved, available);
77
+ const keeps = reached
78
+ ? (seam, tier) => seamFree(providers, seam, accounts, catalog) && checkKeeps(seam, tier)
79
+ : checkKeeps;
80
+ // The cap's base view is the check-aware resolution while the check is on (a cap-moved
81
+ // tier's failed step stays out of the config its roles run under), else the price view.
82
+ state.fallbackConfig = applyFallbackModel(liveConfig, withCheckMoves(a.checkFreeResolved ?? priceResolved, a.checkResolved, checkMoved), keeps);
62
83
  }
63
84
  }
64
85
  /** The tiers `applyFallbackModel` moved at the cap: those whose own seam fails the `available`
@@ -73,28 +94,64 @@ function deriveMovedTiers(a) {
73
94
  }
74
95
  return movedTiers;
75
96
  }
97
+ /** The tiers the model check moves (plans/model-check.md "Fallback"): a tier still on its own
98
+ * primary — every tier below the cap, at it only one the cap leaves in place (`capMoved` holds
99
+ * the rest) — whose primary failed its check while the tier's verified-only resolution has a
100
+ * step to run on. A tier whose primary failed with no verified step stays held on it. */
101
+ function deriveCheckMovedTiers(a, capMoved) {
102
+ const moved = new Set();
103
+ if (a.checkResolved === undefined || a.checkFailed === undefined)
104
+ return moved;
105
+ for (const tier of MODEL_TIERS) {
106
+ if (capMoved.has(tier) || a.checkResolved[tier].pair === null)
107
+ continue;
108
+ if (a.checkFailed(tierPrimaryPair(a.config, tier)))
109
+ moved.add(tier);
110
+ }
111
+ return moved;
112
+ }
76
113
  /** Derive the fallback view for one poll from the verdict half's already-resolved inputs
77
- * (`priceResolved`, `servingResolved`, `chainPairs`, `pairNames`) and the `reached` flag and
78
- * the shared reads (`state`, `config`, `accounts`, `providers`, `catalog`), writing the
79
- * cross-poll view state (`from`/`fromKey`/`fallbackConfig`) and returning the view fields
80
- * pollBudgetGate hands back. The director always keeps the live config (part 5d/6): with the
81
- * window move gone no input moves it, so `directorConfig` is the live config by construction. */
114
+ * (`priceResolved`, `servingResolved`, `checkResolved`, `checkFreeResolved`, `chainPairs`,
115
+ * `pairNames`) and the `reached` flag and the shared reads (`state`, `config`, `accounts`,
116
+ * `providers`, `catalog`), writing the cross-poll view state (`from`/`fromKey`/
117
+ * `fallbackConfig`) and returning the view
118
+ * fields pollBudgetGate hands back. The view is installed at the cap, and below it while a tier
119
+ * the model check moved runs on its verified step; `onFallback` (the image-reroute regime) stays
120
+ * the cap's alone. The director always keeps the live config (part 5d/6): with the window move
121
+ * gone no input moves it, so `directorConfig` is the live config by construction. */
82
122
  export function budgetFallbackView(a) {
83
123
  const { state, config: liveConfig, reached, priceResolved } = a;
84
124
  const onFallback = reached && a.pairNames.length > 0;
125
+ const movedTiers = deriveMovedTiers(a);
126
+ const checkMoved = deriveCheckMovedTiers(a, movedTiers);
85
127
  // The resolved pairs are part of the key: a priced step crossing its own daily cap changes
86
- // the resolution without a config or window change, and the view must follow it.
128
+ // the resolution without a config or window change, and the view must follow it. So are the
129
+ // model check's moves, appended only while there are some.
87
130
  const resolvedKey = MODEL_TIERS.map((t) => {
88
131
  const rp = priceResolved[t].pair;
89
132
  return rp ? modelPairName(rp) : "-";
90
133
  }).join(",");
91
- const fromKey = `${reached ? "c" : ""}|${resolvedKey}`;
92
- installFallbackConfig(a, fromKey);
93
- const movedTiers = deriveMovedTiers(a);
134
+ const checkKey = MODEL_TIERS.map((t) => (checkMoved.has(t) ? modelPairName(a.checkResolved[t].pair) : "-")).join(",");
135
+ // The cap view's check-aware pairs follow the price pairs when present: a check verdict
136
+ // failing or clearing a step changes the installed config without a price change, so the
137
+ // view must re-install on the next poll. Absent while the check is off, so the key is
138
+ // unchanged then.
139
+ const checkFreeKey = a.checkFreeResolved
140
+ ? "|" +
141
+ MODEL_TIERS.map((t) => {
142
+ const rp = a.checkFreeResolved[t].pair;
143
+ return rp ? modelPairName(rp) : "-";
144
+ }).join(",")
145
+ : "";
146
+ const fromKey = `${reached ? "c" : ""}|${resolvedKey}${checkMoved.size > 0 ? `|${checkKey}` : ""}${checkFreeKey}`;
147
+ installFallbackConfig(a, fromKey, checkMoved);
94
148
  const chain = chainInfo({
95
149
  config: liveConfig,
96
- resolved: a.servingResolved,
97
- moved: movedTiers,
150
+ resolved: withCheckMoves(a.servingResolved, a.checkResolved, checkMoved),
151
+ moved: new Set([...movedTiers, ...checkMoved]),
152
+ checkMoved,
153
+ ...(a.checkFailed !== undefined ? { modelCheckFailed: a.checkFailed } : {}),
154
+ ...(a.checkVerified !== undefined ? { modelCheckVerified: a.checkVerified } : {}),
98
155
  breakers: state.breakers,
99
156
  states: a.states,
100
157
  now: a.now,
@@ -104,16 +161,20 @@ export function budgetFallbackView(a) {
104
161
  return {
105
162
  onFallback,
106
163
  chain,
107
- roleConfig: onFallback ? state.fallbackConfig : liveConfig,
164
+ roleConfig: onFallback || checkMoved.size > 0 ? state.fallbackConfig : liveConfig,
108
165
  pairs: distinctPairs(priceResolved, a.chainPairs),
109
166
  directorConfig: liveConfig,
167
+ checkMoved,
110
168
  };
111
169
  }
112
170
  /** The `chain` block: for each tier this poll moved off its primary, the step it runs on and
113
171
  * why the position before it cannot run (plans/fallback-chain.md "Events and surfaces").
114
172
  * `moved` names the tiers the poll moved; a tier the gate pauses (`pair: null`) gets no entry.
115
173
  * The reason is read from the position just before the active step — the previous chain step,
116
- * or the tier's primary at step 0 — in the order demoted, step-cap, overage, cap. */
174
+ * or the tier's primary at step 0 — in the order demoted, step-cap, overage, model-check, cap.
175
+ * `checkMoved` names the moved tiers whose primary failed its model check rather than the cap
176
+ * moving them; the model check's verdicts say whether a previous step failed (or, for such a
177
+ * tier, which moves only to verified steps, has not answered yet). */
117
178
  export function chainInfo(a) {
118
179
  const out = {};
119
180
  const steps = tierChainSteps(a.config);
@@ -127,7 +188,7 @@ export function chainInfo(a) {
127
188
  pair: modelPairName(r.pair),
128
189
  step: position.step,
129
190
  of: position.of,
130
- ...movedTierReason(a, position.prior, position.priorName, midnight),
191
+ ...movedTierReason(a, tier, position.prior, position.priorName, midnight),
131
192
  };
132
193
  Object.assign(info, movedTierSpend(a, r.pair));
133
194
  out[tier] = info;
@@ -146,10 +207,11 @@ function movedTierPosition(steps, tier, r) {
146
207
  return { step: idx + 1, of: own.length, prior, priorName: prior ? modelPairName(prior) : "" };
147
208
  }
148
209
  /** Why the position before the active step cannot run, in INV-BN1's fixed order (demoted,
149
- * step-cap, overage, cap). A demoted or overage result carries no `returnsAt`; a step-cap or
210
+ * step-cap, overage, model-check, cap). A demoted, overage or model-check result carries no
211
+ * `returnsAt` (a model check lifts when a re-check answers, not at a clock time); a step-cap or
150
212
  * cap result pins it to `midnight` (INV-BN2: local midnight only for step-cap and cap).
151
213
  * Answers "why can't the position before the active step run, and when does it come back?". */
152
- function movedTierReason(a, prior, priorName, midnight) {
214
+ function movedTierReason(a, tier, prior, priorName, midnight) {
153
215
  if (prior && a.breakers[priorName] !== undefined && !fallbackServingPair(a.breakers, priorName)) {
154
216
  return { reason: "demoted" };
155
217
  }
@@ -163,6 +225,12 @@ function movedTierReason(a, prior, priorName, midnight) {
163
225
  claudeOverageRefused(a.accounts("claude"))) {
164
226
  return { reason: "overage" };
165
227
  }
228
+ const checkMoved = a.checkMoved?.has(tier) === true;
229
+ const priorChecked = prior === null
230
+ ? checkMoved
231
+ : a.modelCheckFailed?.(priorName) === true || (checkMoved && a.modelCheckVerified?.(priorName) !== true);
232
+ if (priorChecked)
233
+ return { reason: "model-check" };
166
234
  return { reason: "cap", returnsAt: midnight };
167
235
  }
168
236
  /** What the active step has spent against its own cap: both keys absent unless the step is a
@@ -119,9 +119,8 @@ export function detectBuildCheck(startDir, config, maxLevels = WALK_UP_LEVELS) {
119
119
  /** The configured per-change gate command (`check.gateCommand`, PLANS.md Land-queue speed 3e) when
120
120
  * it is a non-blank string, else undefined — off. Opt-in: when no `mergeCommand` is declared, a
121
121
  * project that sets one merges on it, not the full check (mergeCommandOf's fallback, PLANS.md
122
- * "Fast merge" "Defaults"). One reading shared by runScopedBuildCheck (which runs it at scope
123
- * "gate") and merge-to-main.ts's verifyMerge (which must then never treat the gate's green as the
124
- * full check's), so the two cannot disagree about whether it is on. */
122
+ * "Fast merge" "Defaults"). One reading every caller shares (runScopedBuildCheck runs it at
123
+ * scope "gate"), so none can disagree about whether it is on. */
125
124
  export function gateCommandOf(config) {
126
125
  const g = config?.check?.gateCommand;
127
126
  return isNonBlankString(g) ? g : undefined;
@@ -141,6 +140,26 @@ export function mergeCommandOf(config) {
141
140
  return gateCommandOf(config);
142
141
  return isNonBlankString(m) ? m : undefined;
143
142
  }
143
+ /** The npm script the re-run of named tests auto-detects (rerunCommandOf). */
144
+ const RERUN_SCRIPT = "test:rerun";
145
+ /** The command that re-runs only named tests (`check.rerunCommand`, build-check-rerun.ts), or
146
+ * undefined — off, and its callers run the full check instead. A configured non-blank string
147
+ * wins; a blank one (`""`) is the explicit opt-out. Absent, a project whose check is the npm
148
+ * walk-up (no `check.command`) re-runs with `npm run test:rerun --` when the package.json the
149
+ * walk-up finds declares that script, so an npm project opts in from its own manifest. Never
150
+ * throws. */
151
+ export function rerunCommandOf(startDir, config) {
152
+ const r = config?.check?.rerunCommand;
153
+ if (r !== undefined)
154
+ return isNonBlankString(r) ? r : undefined;
155
+ if (isNonBlankString(config?.check?.command))
156
+ return undefined;
157
+ const root = walkUp(startDir, WALK_UP_LEVELS, (dir) => (hasInstall(dir) ? dir : null));
158
+ if (root === null)
159
+ return undefined;
160
+ const scripts = readJsonFile(path.join(root, "package.json"))?.scripts;
161
+ return isJsonObject(scripts) && isCheckScript(scripts, RERUN_SCRIPT) ? `npm run ${RERUN_SCRIPT} --` : undefined;
162
+ }
144
163
  /** Resolve `node_modules/<rel>` by walking UP from `startDir` — the same climb detectBuildCheck
145
164
  * makes, and the same one npm's run-script PATH walk makes: a tumwater worktree has no install
146
165
  * of its own (node_modules is gitignored, so it exists only where someone ran npm install), and
@@ -8,6 +8,7 @@
8
8
  * runScopedBuildCheck (build-check-scoped.ts) is the only caller that both runs a check and
9
9
  * uses this wording; the baseline surfaces use the event/warning helpers alone. */
10
10
  import { logEventBestEffort } from "../events/events.js";
11
+ import { hostLoadStarved } from "./host-sleep.js";
11
12
  /** Does this build_check scope stand for a full-suite verdict — merge, batch, or the red-main
12
13
  * baseline? A gate check can run a subset of the tree, so a scope outside this set is never a
13
14
  * full-suite check. The one vocabulary the three predicates that recognize a full-suite check
@@ -143,16 +144,36 @@ const STARVED_DEADLINE_MAX_FACTOR = 2;
143
144
  * firing on time under the team's own load, with a passing tail at 299.97 s — the check was
144
145
  * measuring the load, not the tree (BUGS.md 2026-10-12). */
145
146
  export function starvedDeadlineMs(baseMs, load) {
146
- if (!(load.cpus > 0) || !(load.load1 > load.cpus))
147
+ if (!hostLoadStarved(load))
147
148
  return baseMs;
148
149
  return Math.round(baseMs * Math.min(load.load1 / load.cpus, STARVED_DEADLINE_MAX_FACTOR));
149
150
  }
151
+ /** The reviewed diff's size against which the post-merge reviewer's wall stretches. Calibrated
152
+ * to the class this exists for: the largest-diff member (BUGS-FIXED.md "post-merge review of
153
+ * 3b682bed …", a 270/247-line split, ~41 KB of diff) timed out 21× at the 900 s wall even after
154
+ * the load-only stretch landed, so a diff of that size must already sit past the reference and
155
+ * earn the 2× factor. It is deliberately NOT the reviewer's own `DIFF_MAX_BYTES` diff cap
156
+ * (src/git/git-diff.ts, 200 KB): that cap bounds the prompt's text, not how many turns a
157
+ * strong-tier review of the diff needs, and it sits well above every diff that has timed out,
158
+ * so referencing it would stretch none of the class's members. */
159
+ export const REVIEW_DIFF_REFERENCE_BYTES = 20_000;
160
+ /** The budget the post-merge reviewer's wall gets for the reviewed diff's size: `baseMs` at or
161
+ * under REVIEW_DIFF_REFERENCE_BYTES (see its calibration), and above it the overage ratio capped
162
+ * at STARVED_DEADLINE_MAX_FACTOR — the same cap as the load stretch, so load and diff each
163
+ * contribute at most 2× and a wedged review's quiet-watchdog wall is untouched (it is
164
+ * independent of the run budget). A review of a large diff needs the diff's proportionally more
165
+ * turns, so a wall that ignores its size kills a progressing review before its VERDICT. */
166
+ export function diffScaledMs(baseMs, diffBytes) {
167
+ if (!(diffBytes > REVIEW_DIFF_REFERENCE_BYTES))
168
+ return baseMs;
169
+ return Math.round(baseMs * Math.min(diffBytes / REVIEW_DIFF_REFERENCE_BYTES, STARVED_DEADLINE_MAX_FACTOR));
170
+ }
150
171
  /** The note an on-time deadline kill carries when the host was starved: the load average
151
172
  * beyond the core count means the check shared the machine with the rest of the team, so the
152
173
  * 300 s kill is a statement about the deadline under load, not a measured verdict on the
153
174
  * change (BUGS.md 2026-10-12). Empty when the host had a core to spare. */
154
175
  export function loadedHostPhrase(load) {
155
- if (!(load.cpus > 0) || !(load.load1 > load.cpus))
176
+ if (!hostLoadStarved(load))
156
177
  return "";
157
178
  return ` (host load average ${Math.round(load.load1)} on ${load.cpus} cores: the check ran starved, so the kill says nothing about the change itself)`;
158
179
  }
@@ -187,9 +208,8 @@ export function installFailedPhrase(label, install, proceeding) {
187
208
  /** Did this outcome make no verdict about the tree? runScopedBuildCheck records that verdict-
188
209
  * less shape explicitly (`unverified: true`) at the merge scopes; a gate-scope failure carries
189
210
  * only the run's own sleep evidence (BuildCheckRun.sleptMs), so both spellings count. The
190
- * consumers that attribute a red — review.ts's gate, merge-check-failures.ts's attribution, the
191
- * red-main baseline — ask this instead of re-deriving the evidence, so the attribution policy
192
- * cannot drift between them (BUGS.md 2026-09-30). */
211
+ * consumer that attributes a red — the review gate (review-precheck.ts) — asks this instead of
212
+ * re-deriving the evidence, so the attribution policy cannot drift (BUGS.md 2026-09-30). */
193
213
  export function unverifiedTreeOutcome(outcome) {
194
214
  return outcome.unverified === true || (outcome.run?.sleptMs ?? 0) > SLEEP_SPAN_TOLERANCE_MS;
195
215
  }
@@ -47,7 +47,7 @@ export function parseFailingTests(output) {
47
47
  const line = raw.trim();
48
48
  if (!FAILED_TEST_LINE.test(line))
49
49
  continue;
50
- const title = line.slice(2).replace(/ \([\d.]+m?s\)$/, "").trim();
50
+ const title = resultTitle(line);
51
51
  if (title !== "")
52
52
  titles.add(title);
53
53
  if (titles.size >= FAILING_TESTS_MAX)
@@ -55,6 +55,43 @@ export function parseFailingTests(output) {
55
55
  }
56
56
  return [...titles];
57
57
  }
58
+ /** A failed test's line title: the `✖ ` mark and the trailing duration stripped. */
59
+ function resultTitle(line) {
60
+ return line.slice(2).replace(/ \([\d.]+m?s\)$/, "").trim();
61
+ }
62
+ /** A failure's location line in node:test's `✖ failing tests:` section, printed right above the
63
+ * failed test's `✖` line: `test at <file>:<line>:<col>`. */
64
+ const FAILED_TEST_AT = /^test at (.+):\d+:\d+$/;
65
+ /** The file each failing test lives in, keyed by the title parseFailingTests reports, read from the
66
+ * `test at <file>:<line>:<col>` line node:test's `✖ failing tests:` section prints above each
67
+ * failure. A file is named by its base name without the extension (`merger.test` for
68
+ * dist/test/merger.test.js): the form a re-run's `<file>#<name>` filter takes, the same in
69
+ * every worktree. A title whose location the output does not print is absent. */
70
+ export function parseFailingTestFiles(output) {
71
+ const files = new Map();
72
+ let at;
73
+ for (const raw of output.split("\n")) {
74
+ const line = raw.trim();
75
+ const location = FAILED_TEST_AT.exec(line);
76
+ if (location) {
77
+ at = location[1];
78
+ continue;
79
+ }
80
+ if (at !== undefined && FAILED_TEST_LINE.test(line)) {
81
+ const title = resultTitle(line);
82
+ if (title !== "" && !files.has(title))
83
+ files.set(title, fileStem(at));
84
+ }
85
+ at = undefined;
86
+ }
87
+ return Object.fromEntries(files);
88
+ }
89
+ /** A test file's base name without its extension, from a path or a `file://` URL. */
90
+ function fileStem(file) {
91
+ const base = file.replace(/^file:\/\//, "").split(/[\\/]/).pop() ?? "";
92
+ const dot = base.lastIndexOf(".");
93
+ return dot > 0 ? base.slice(0, dot) : base;
94
+ }
58
95
  /** Keep the TAIL of a build's combined output: last ≤10 meaningful lines, each via clipReason —
59
96
  * so a chatty build cannot bloat persisted state or the injected next-run note. Blank lines
60
97
  * and npm's own script banner (`> pkg@1.0 script`, `> <command>`) are dropped: they name the
@@ -127,10 +164,9 @@ export function describeCheck(check) {
127
164
  }
128
165
  /** The single-line headline of a red check — the failureHeadline when the tail has one to
129
166
  * offer (a test name or marker plus a non-framing message), else the check's describeCheck
130
- * command name — the name the gate, merge, and review warnings print for a failed check.
167
+ * command name — the name the gate and review warnings print for a failed check.
131
168
  * One definition for every red that names itself to a human: review-precheck's two
132
- * flake/failure warnings, merge-to-main's merge-retry quarantine warning, and
133
- * merge-check-failures's kept-pin headline record and attributeRedCheck absent-tail headline.
169
+ * flake/failure warnings.
134
170
  */
135
171
  export function redCheckHeadline(check, tail) {
136
172
  return failureHeadline(tail) ?? describeCheck(check);
@@ -0,0 +1,62 @@
1
+ /** Re-running only named failing tests (`check.rerunCommand`, rerunCommandOf), instead of the whole
2
+ * check: the main watch confirms a red tip with it, and the review gate asks it whether a change's
3
+ * failing tests also fail on main. The full check runs at most once per main commit — the watch's
4
+ * own run — and every later question about the same failures is answered in seconds by running
5
+ * just those tests. Split out of build-check.ts, which keeps running and classifying the declared
6
+ * check: this module only builds the narrower command and reads back which named tests still
7
+ * fail. */
8
+ import { runBuildCheck } from "./build-check.js";
9
+ import { BUILD_CHECK_TIMEOUT_MS, detectBuildCheck, rerunCommandOf } from "./build-check-detect.js";
10
+ import { FAILING_TESTS_MAX } from "./build-check-report.js";
11
+ import { buildCheckEvent } from "./build-check-events.js";
12
+ import { CHECK_TIER, withCheckPermit } from "../concurrency/check-permit.js";
13
+ import { logEventBestEffort } from "../events/events.js";
14
+ import { shellQuote } from "../agent/write-guard.js";
15
+ /** The re-run's filter arguments, one per test: `<file>#<name>`, with the file's base name from
16
+ * the failure output (BuildCheckOutcome.failingTestFiles). When any test's file is unknown every
17
+ * argument is a bare `#<name>`: a runner that selects files by the named ones would otherwise
18
+ * never load the unlocated test. */
19
+ function rerunFilters(tests, files) {
20
+ const located = tests.every((t) => files?.[t] !== undefined);
21
+ return tests.map((t) => `${located ? files?.[t] : ""}#${t}`);
22
+ }
23
+ /** The shell command that re-runs `tests`: `command` followed by each filter, single-quoted. The
24
+ * line the harness runs, and the one a heal prompt hands an agent to reproduce the failures. */
25
+ export function rerunCommandLine(command, tests, files) {
26
+ return [command, ...rerunFilters(tests, files).map(shellQuote)].join(" ");
27
+ }
28
+ /** Can `tests` be re-run on their own: a re-run command is in effect at `wt` and the list is
29
+ * complete — non-empty and short of FAILING_TESTS_MAX, which may have been cut short. */
30
+ export function canRerun(wt, config, tests) {
31
+ return tests.length > 0 && tests.length < FAILING_TESTS_MAX && rerunCommandOf(wt, config) !== undefined;
32
+ }
33
+ /** Re-run only `tests` in `wt` under the process-wide check permit, logged as a `rerun`-scope
34
+ * build_check event under `source`. Null when no verdict came of it: no re-run command, an
35
+ * incomplete list (canRerun), a skipped run (timeout, kill, broken toolchain, a host sleep), or a
36
+ * failure that names none of `tests` — a compile error, a test file that would not load, or a
37
+ * filter that matched nothing. Never throws. */
38
+ export async function rerunTests(root, source, wt, config, tests, files, tier = CHECK_TIER.other) {
39
+ const command = canRerun(wt, config, tests) ? rerunCommandOf(wt, config) : undefined;
40
+ if (command === undefined)
41
+ return null;
42
+ const check = detectBuildCheck(wt, { ...config, check: { ...config?.check, command: rerunCommandLine(command, tests, files) } });
43
+ if (check === null)
44
+ return null;
45
+ try {
46
+ const started = Date.now();
47
+ const outcome = await withCheckPermit(config, tier, () => runBuildCheck(wt, check, BUILD_CHECK_TIMEOUT_MS), undefined, `${source} rerun`);
48
+ logEventBestEffort(root, buildCheckEvent(source, "rerun", outcome, Date.now() - started));
49
+ if (outcome.status === "passed")
50
+ return { failing: [] };
51
+ if (outcome.status !== "failed")
52
+ return null;
53
+ // A failure naming none of the tests asked about (a test file that would not load names
54
+ // itself) says nothing about them.
55
+ const named = outcome.failingTests ?? [];
56
+ const failing = tests.filter((t) => named.includes(t));
57
+ return failing.length > 0 ? { failing } : null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
@@ -50,7 +50,16 @@ async function runScopedCheckWithRetry(root, role, scope, wt, runCheck, timeoutM
50
50
  waiting: () => permitWait?.waiting?.(),
51
51
  granted: () => permitWait?.granted?.(),
52
52
  waited: (waitedMs, parked) => logCheckWaitEvent(root, role, scope, waitedMs, parked),
53
- }, `${role} ${scope}`);
53
+ }, `${role} ${scope}`,
54
+ // A check whose caller was stopped (a user abort, a lapsed restart hand-off, a shutdown) while
55
+ // it waited for a permit settles the way a mid-run abort does, without spawning: otherwise
56
+ // the grant would run the whole suite for a merge that no longer exists, holding a permit
57
+ // other checks need. A check granted a free permit under a fired signal still runs (see
58
+ // PermitWaitAbort).
59
+ {
60
+ signal,
61
+ outcome: () => ({ status: "skipped", script: checkScriptName(runCheck), skipReason: "aborted" }),
62
+ });
54
63
  return { raw, durationMs };
55
64
  }
56
65
  /** Classify the raw outcome: the merge-scope skip and slept-through rules become a deterministic
@@ -163,7 +172,9 @@ function warnScopedCheck(root, role, scope, outcome, effectiveMs, durationMs, un
163
172
  * `check.mergeCommand` when set, else `check.gateCommand`, else the full check — replaces it at the
164
173
  * merge and batch scopes, and those events then carry `full: false`. The red-main baseline, which
165
174
  * detects on its own, keeps running check.command. `permitWait` hears
166
- * about a wait for the check permit (the merge cell's "waiting for a check slot"). Never throws. */
175
+ * about a wait for the check permit (the merge cell's "waiting for a check slot"). `signal` firing
176
+ * mid-run stops the check's tree, and firing while the check waits for its permit ends the wait
177
+ * unspawned — both settle `skipped` with skipReason "aborted". Never throws. */
167
178
  export async function runScopedBuildCheck(root, role, scope, wt, config, timeoutMs = BUILD_CHECK_TIMEOUT_MS, sampleSleep = sampleSleepClock, install = npmInstall, permitWait, sampleLoad = sampleHostLoad, signal) {
168
179
  const r = resolveScopedCheck(wt, scope, config, timeoutMs, sampleLoad);
169
180
  if (!r)