tumwater 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-backend.js +6 -6
- package/dist/src/agent/{run-agent.js → agent-run.js} +25 -12
- package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
- package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
- package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
- package/dist/src/agent/backend-traits.js +2 -2
- package/dist/src/agent/claude/claude-account.js +1 -8
- package/dist/src/agent/claude/claude-backend.js +3 -3
- package/dist/src/agent/claude/claude-env.js +27 -31
- package/dist/src/agent/claude/claude-stream.js +111 -54
- package/dist/src/agent/codex/codex-account.js +9 -0
- package/dist/src/agent/codex/codex-backend.js +2 -2
- package/dist/src/agent/codex/codex-env.js +28 -15
- package/dist/src/agent/codex/codex-hook-trust.js +39 -20
- package/dist/src/agent/codex/codex-rollout.js +93 -56
- package/dist/src/agent/codex/codex-stream.js +1 -1
- package/dist/src/agent/env-scrub.js +18 -0
- package/dist/src/agent/image-input.js +68 -11
- package/dist/src/agent/notes-shim.js +2 -2
- package/dist/src/agent/opencode/opencode-backend.js +2 -2
- package/dist/src/agent/opencode/opencode-catalog.js +50 -27
- package/dist/src/agent/opencode/opencode-config.js +32 -14
- package/dist/src/agent/opencode/opencode-env.js +12 -13
- package/dist/src/agent/opencode/opencode-stream.js +47 -25
- package/dist/src/agent/session-ledger.js +1 -1
- package/dist/src/agent/subscription-account.js +3 -3
- package/dist/src/backlog/backlog-cli-view.js +81 -41
- package/dist/src/backlog/backlog-cli.js +57 -26
- package/dist/src/backlog/backlog-eligibility.js +58 -102
- package/dist/src/backlog/backlog-md.js +5 -5
- package/dist/src/backlog/backlog-parts.js +65 -27
- package/dist/src/backlog/backlog-release.js +12 -12
- package/dist/src/backlog/backlog-structure.js +114 -61
- package/dist/src/backlog/backlog.js +1 -1
- package/dist/src/baseline/main-baseline.js +148 -84
- package/dist/src/budget/budget-fallback-view.js +100 -243
- package/dist/src/budget/budget-wiring.js +29 -65
- package/dist/src/budget/fallback-breaker.js +2 -2
- package/dist/src/build/build-check-detect.js +43 -14
- package/dist/src/build/build-check-events.js +2 -2
- package/dist/src/build/build-check-scoped.js +40 -16
- package/dist/src/build/build-check.js +13 -5
- package/dist/src/cli/cli-command-args.js +89 -52
- package/dist/src/cli/cli-flag-specs.js +42 -22
- package/dist/src/cli/cli-init.js +34 -12
- package/dist/src/cli/cli-query-commands.js +1 -1
- package/dist/src/cli/cli-repo-gate.js +2 -2
- package/dist/src/cli/cli-start.js +91 -58
- package/dist/src/cli/completion.js +82 -46
- package/dist/src/cli/config-commands.js +82 -71
- package/dist/src/cli/help.js +4 -6
- package/dist/src/cli/log-commands.js +43 -23
- package/dist/src/cli/notify-command.js +2 -2
- package/dist/src/cli/question-commands.js +86 -60
- package/dist/src/cli.js +181 -135
- package/dist/src/collections.js +31 -16
- package/dist/src/concurrency/check-slots.js +70 -0
- package/dist/src/concurrency/lock.js +22 -0
- package/dist/src/concurrency/run-permit.js +49 -0
- package/dist/src/concurrency/semaphore.js +46 -250
- package/dist/src/config/config-editable-keys.js +4 -7
- package/dist/src/config/config-fallbacks.js +81 -101
- package/dist/src/config/config-field-checks.js +66 -37
- package/dist/src/config/config-keys.js +5 -20
- package/dist/src/config/config-live.js +4 -28
- package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
- package/dist/src/config/config-request.js +153 -102
- package/dist/src/config/config-schema.js +9 -11
- package/dist/src/config/config-selector-checks.js +1 -1
- package/dist/src/config/config-validation-agents.js +2 -2
- package/dist/src/config/config-validation-roles.js +4 -52
- package/dist/src/config/config-validation.js +4 -38
- package/dist/src/config/config-views.js +9 -34
- package/dist/src/config/config-write.js +6 -23
- package/dist/src/config/config.js +4 -5
- package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
- package/dist/src/doctor/doctor-backlog-plans.js +24 -24
- package/dist/src/doctor/doctor-backlog.js +7 -5
- package/dist/src/doctor/doctor-check-names.js +3 -6
- package/dist/src/doctor/doctor-checks.js +5 -5
- package/dist/src/doctor/doctor-claude-checks.js +44 -28
- package/dist/src/doctor/doctor-codex-checks.js +6 -6
- package/dist/src/doctor/doctor-contention-checks.js +44 -112
- package/dist/src/doctor/doctor-fallback-checks.js +2 -2
- package/dist/src/doctor/doctor-holds.js +6 -45
- package/dist/src/doctor/doctor-install.js +1 -1
- package/dist/src/doctor/doctor-launch-services.js +1 -1
- package/dist/src/doctor/doctor-model-checks.js +178 -94
- package/dist/src/doctor/doctor-notify.js +2 -2
- package/dist/src/doctor/doctor-opencode-checks.js +5 -4
- package/dist/src/doctor/doctor-orphans.js +120 -20
- package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
- package/dist/src/doctor/doctor-pi-checks.js +2 -2
- package/dist/src/doctor/doctor-principles.js +3 -3
- package/dist/src/doctor/doctor-project-checks.js +3 -3
- package/dist/src/doctor/doctor-role-notes.js +1 -1
- package/dist/src/doctor/doctor.js +8 -11
- package/dist/src/events/event-format.js +30 -54
- package/dist/src/events/event-labels.js +1 -5
- package/dist/src/events/event-legacy.js +1 -3
- package/dist/src/events/{notify.js → event-notify.js} +2 -2
- package/dist/src/events/event-read.js +1 -1
- package/dist/src/failure/failure-cluster.js +6 -6
- package/dist/src/failure/failure-data.js +8 -4
- package/dist/src/failure/failure-render.js +44 -34
- package/dist/src/failure/failure-state-change.js +4 -34
- package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
- package/dist/src/files/files.js +30 -0
- package/dist/src/files/json-object.js +8 -8
- package/dist/src/files/stat-cache.js +12 -8
- package/dist/src/gates/budget-gates.js +85 -84
- package/dist/src/gates/gate-polls.js +27 -68
- package/dist/src/gates/pause-gates.js +16 -2
- package/dist/src/gates/readiness.js +1 -1
- package/dist/src/gates/role-cap-gates.js +44 -35
- package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
- package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
- package/dist/src/git/git.js +75 -45
- package/dist/src/git/worktree-pool.js +3 -6
- package/dist/src/gui/gui-admin-endpoints.js +3 -3
- package/dist/src/gui/gui-backlog-commands.js +4 -4
- package/dist/src/gui/gui-backlog-endpoints.js +61 -41
- package/dist/src/gui/gui-command.js +43 -24
- package/dist/src/gui/gui-history-endpoints.js +100 -52
- package/dist/src/gui/gui-operator-commands.js +22 -23
- package/dist/src/gui/gui-prompt-commands.js +23 -23
- package/dist/src/gui/gui-report-endpoints.js +1 -1
- package/dist/src/history/history-data.js +71 -44
- package/dist/src/history/history-queued.js +49 -27
- package/dist/src/history/history.js +51 -33
- package/dist/src/inbox/prompt-commands.js +59 -42
- package/dist/src/init/init.js +88 -40
- package/dist/src/merge/merge-backlog-conflicts.js +1 -1
- package/dist/src/merge/merge-core.js +1 -1
- package/dist/src/merge/merge-drain.js +8 -11
- package/dist/src/merge/merge-pipeline.js +6 -7
- package/dist/src/merge/merge-post-review-drain.js +10 -13
- package/dist/src/merge/merge-requeue.js +1 -1
- package/dist/src/merge/merge-slot.js +0 -8
- package/dist/src/merge/merge-verify.js +8 -18
- package/dist/src/notes-cli.js +52 -42
- package/dist/src/operator/operator-commands.js +76 -44
- package/dist/src/operator/operator-intent.js +3 -3
- package/dist/src/orchestrator/orchestrator-launch.js +31 -63
- package/dist/src/orchestrator/orchestrator-poll.js +27 -32
- package/dist/src/orchestrator/orchestrator.js +8 -21
- package/dist/src/orchestrator/scheduling-admit.js +101 -68
- package/dist/src/orchestrator/scheduling-scope.js +1 -1
- package/dist/src/orchestrator/scheduling-skip.js +1 -1
- package/dist/src/orchestrator/startup-migrations.js +1 -1
- package/dist/src/paths.js +8 -7
- package/dist/src/pi/pi-args.js +5 -2
- package/dist/src/pi/pi-backend.js +2 -2
- package/dist/src/pi/pi-bin.js +3 -3
- package/dist/src/pi/pi-event-line.js +1 -1
- package/dist/src/pi/pi-models.js +43 -29
- package/dist/src/pi/pi-settings.js +43 -20
- package/dist/src/pi/pi-stream.js +32 -6
- package/dist/src/pi/pi-watchdogs.js +12 -5
- package/dist/src/pi/pi.js +2 -2
- package/dist/src/pi-extension/context-shake.js +32 -20
- package/dist/src/pi-extension/kill-guard.js +69 -0
- package/dist/src/process/process-group.js +49 -5
- package/dist/src/process/process.js +8 -8
- package/dist/src/process/run-marker.js +31 -13
- package/dist/src/process/supervisor.js +40 -28
- package/dist/src/prompt/dialect.js +1 -1
- package/dist/src/prompt/gate-notes.js +2 -2
- package/dist/src/prompt/notes-backlog.js +66 -37
- package/dist/src/prompt/principles.js +6 -4
- package/dist/src/prompt/prompt-followup.js +12 -4
- package/dist/src/prompt/prompt.js +88 -38
- package/dist/src/report/report-data.js +53 -37
- package/dist/src/report/report-fold.js +16 -9
- package/dist/src/report/report-render.js +21 -194
- package/dist/src/report/report-since-data.js +4 -3
- package/dist/src/report/report-since-render.js +173 -0
- package/dist/src/report/report-usage-fold.js +8 -8
- package/dist/src/report/report.js +46 -22
- package/dist/src/review/post-merge-review-render.js +9 -3
- package/dist/src/review/post-merge-review.js +1 -1
- package/dist/src/review/review-precheck.js +11 -1
- package/dist/src/review/review.js +40 -153
- package/dist/src/review/secret-leaks.js +26 -16
- package/dist/src/roles/role-catalog.js +11 -3
- package/dist/src/roles/role-guidance.js +16 -12
- package/dist/src/roles/role-render.js +1 -14
- package/dist/src/roles/role-view.js +9 -21
- package/dist/src/run/acceptance-greps.js +88 -45
- package/dist/src/run/run-apply.js +12 -6
- package/dist/src/run/run-detail-data.js +121 -68
- package/dist/src/run/run-detail-render.js +92 -35
- package/dist/src/run/run-detail.js +72 -41
- package/dist/src/run/run-prompt.js +63 -50
- package/dist/src/run/run-resume.js +76 -29
- package/dist/src/run/run-stage.js +135 -84
- package/dist/src/run/run-timing.js +8 -2
- package/dist/src/run/run-usage.js +31 -1
- package/dist/src/run/run-verdict.js +119 -50
- package/dist/src/run/stage-check.js +49 -32
- package/dist/src/run/stage-checks-shared.js +28 -0
- package/dist/src/run/stage-name-checks.js +111 -92
- package/dist/src/run/stage-path-checks.js +69 -42
- package/dist/src/run/stage-prose-checks.js +55 -40
- package/dist/src/runner/runner-outcome.js +6 -1
- package/dist/src/runner/runner-pi.js +3 -10
- package/dist/src/runner/runner-recovery.js +1 -1
- package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
- package/dist/src/runner/runner-run.js +97 -35
- package/dist/src/runner/runner-state-load.js +12 -9
- package/dist/src/runner/runner.js +41 -25
- package/dist/src/scheduling/claims.js +0 -4
- package/dist/src/scheduling/scheduling.js +53 -66
- package/dist/src/status/status-data.js +2 -2
- package/dist/src/status/status-role-notes.js +2 -2
- package/dist/src/status/status-roles.js +9 -17
- package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
- package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
- package/dist/src/team/team-hold.js +98 -65
- package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
- package/dist/src/team/team-polls.js +28 -126
- package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
- package/dist/src/team/team-state.js +4 -13
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +20 -15
- package/dist/src/text/text.js +3 -3
- package/dist/src/ui/alert-feeds.js +49 -73
- package/dist/src/ui/badges.js +3 -13
- package/dist/src/ui/gui/gui-client-boot.js +2 -2
- package/dist/src/ui/gui/gui-client-composer.js +1 -1
- package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
- package/dist/src/ui/gui/gui-client-drawer.js +1 -1
- package/dist/src/ui/gui/gui-client-history.js +28 -14
- package/dist/src/ui/gui/gui-client-model.js +0 -12
- package/dist/src/ui/gui/gui-client-pending.js +7 -6
- package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
- package/dist/src/ui/gui/gui-client-report.js +1 -1
- package/dist/src/ui/gui/gui-client-roles.js +0 -1
- package/dist/src/ui/gui/gui-client-settings.js +43 -84
- package/dist/src/ui/gui/gui-client-team.js +4 -4
- package/dist/src/ui/gui/gui-client.js +7 -1
- package/dist/src/ui/progress-data.js +53 -40
- package/dist/src/ui/run-progress-model.js +9 -2
- package/dist/src/ui/status-model.js +2 -24
- package/dist/src/ui/status-payload.js +3 -7
- package/dist/src/ui/status-render.js +5 -9
- package/dist/src/ui/team-alert-backlog.js +6 -6
- package/dist/src/ui/team-alert-status.js +9 -170
- package/dist/src/ui/team-alert-tail.js +19 -215
- package/dist/src/ui/team-alerts.js +7 -8
- package/dist/src/ui/transcript-run.js +27 -16
- package/dist/src/ui/tui/tui-editor-answer.js +12 -10
- package/dist/src/ui/tui/tui-editor-attach.js +4 -3
- package/dist/src/ui/tui/tui-editor-backends.js +4 -3
- package/dist/src/ui/tui/tui-editor-brief.js +6 -9
- package/dist/src/ui/tui/tui-editor-budget.js +4 -3
- package/dist/src/ui/tui/tui-editor-config.js +80 -68
- package/dist/src/ui/tui/tui-editor-entry.js +12 -20
- package/dist/src/ui/tui/tui-editor-pause.js +51 -57
- package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
- package/dist/src/ui/tui/tui-editor-queued.js +11 -14
- package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
- package/dist/src/ui/tui/tui-editor-release.js +4 -3
- package/dist/src/ui/tui/tui-editor-retire.js +7 -7
- package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
- package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
- package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
- package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
- package/dist/src/ui/tui/tui-editor-search.js +4 -3
- package/dist/src/ui/tui/tui-editor-session.js +17 -4
- package/dist/src/ui/tui/tui-editors.js +1 -7
- package/dist/src/ui/tui/tui-frame.js +182 -134
- package/dist/src/ui/tui/tui-guard.js +20 -0
- package/dist/src/ui/tui/tui-input.js +159 -92
- package/dist/src/ui/tui/tui-keymap.js +51 -29
- package/dist/src/ui/tui/tui-keys.js +1 -1
- package/dist/src/ui/tui/tui-pane-activity.js +3 -3
- package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
- package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
- package/dist/src/ui/tui/tui-pane-health.js +252 -228
- package/dist/src/ui/tui/tui-pane-history.js +61 -70
- package/dist/src/ui/tui/tui-pane-pending.js +284 -247
- package/dist/src/ui/tui/tui-pane-queued.js +25 -27
- package/dist/src/ui/tui/tui-pane-report.js +2 -1
- package/dist/src/ui/tui/tui-pane-session.js +11 -7
- package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
- package/dist/src/ui/tui/tui-pane.js +14 -11
- package/dist/src/ui/tui/tui-panes.js +29 -32
- package/dist/src/ui/tui/tui.js +3 -4
- package/dist/src/verdict/fix-claim.js +76 -54
- package/dist/src/verdict/refusal.js +1 -1
- package/package.json +5 -5
- package/dist/src/concurrency/provider-lease.js +0 -105
- package/dist/src/runner/model-fallback.js +0 -76
- package/dist/src/runner/runner-fallback.js +0 -66
- package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
- package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
- /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
- /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
- /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
- /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
- /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
- /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
|
@@ -215,6 +215,113 @@ export function doubleBlockFixedEntries(md) {
|
|
|
215
215
|
}
|
|
216
216
|
return found;
|
|
217
217
|
}
|
|
218
|
+
/** The duplicate-heading rule (a): the first `## ` title `head` carries more of than `base` did,
|
|
219
|
+
* or undefined when none. A base that already carries a duplicate never blocks unrelated edits —
|
|
220
|
+
* this fires only when the head's count EXCEEDS the base's, so a change that removes a duplicate
|
|
221
|
+
* always passes. */
|
|
222
|
+
function duplicateSectionReason(file, headCounts, baseCounts) {
|
|
223
|
+
for (const [title, count] of headCounts)
|
|
224
|
+
if (count > 1 && count > (baseCounts.get(title) ?? 0))
|
|
225
|
+
return (`${file} adds another "## ${title}" heading — readers take the first section of a ` +
|
|
226
|
+
`name, so anything under the duplicate is invisible; keep one "## ${title}" per file`);
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
/** The dropped-known-section rule (b): the first `## ` title the base carried but `head` dropped,
|
|
230
|
+
* restricted to this file's KNOWN_SECTIONS, or undefined when none. A `## ` title that is not one
|
|
231
|
+
* of this file's known sections is not load-bearing: ADDING one is a legitimate project extension
|
|
232
|
+
* (this repo grew `## Verified`, a doc-only stack can grow `## Notes`), so a new one passes — but
|
|
233
|
+
* DROPPING one is the repair for a misplaced heading, so this rule guards only known sections and
|
|
234
|
+
* leaves an unknown one free to be demoted to `### ` under a known section (BUGS.md 2026-10-23). */
|
|
235
|
+
function droppedSectionReason(file, headCounts, baseCounts) {
|
|
236
|
+
// A `## ` title that is not one of this file's known sections (KNOWN_SECTIONS) is not
|
|
237
|
+
// load-bearing: ADDING one is a legitimate project extension (this repo grew `## Verified`,
|
|
238
|
+
// a doc-only stack can grow `## Notes`), so a new one passes — but DROPPING one is the repair
|
|
239
|
+
// for a misplaced heading, so rule (b) guards only known sections and leaves an unknown one
|
|
240
|
+
// free to be demoted to `### ` under a known section (BUGS.md 2026-10-23).
|
|
241
|
+
const known = KNOWN_SECTIONS[file] ?? [];
|
|
242
|
+
for (const title of baseCounts.keys())
|
|
243
|
+
if (!headCounts.has(title) && known.includes(title))
|
|
244
|
+
return (`${file} drops the "## ${title}" section the base had — entries filed under it would ` +
|
|
245
|
+
`be invisible to every section reader; restore "## ${title}"`);
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
/** The re-added-archive-heading rule: for PLANS.md's and BUGS.md's archives only, rejects a head
|
|
249
|
+
* that adds a copy of a `### ` heading the base archive already carries (readdedHeadings) —
|
|
250
|
+
* finished history pasted back, never a new finished entry — or undefined for every other file.
|
|
251
|
+
* An archive only grows by new headings: a copy of one the base archive already carries is
|
|
252
|
+
* finished history pasted back (readdedHeadings), never a new finished entry.
|
|
253
|
+
*/
|
|
254
|
+
function readdedHeadingReason(file, base, head) {
|
|
255
|
+
if (file === PLANS_ARCHIVE || file === BUGS_ARCHIVE) {
|
|
256
|
+
const readded = readdedHeadings(base, head);
|
|
257
|
+
if (readded.length > 0)
|
|
258
|
+
return (`${file} adds ${plural(readded.length, '"### " heading', '"### " headings')} it already carries ` +
|
|
259
|
+
`(first: "${truncate(readded[0], 160)}") — the archive holds each finished entry once, so the ` +
|
|
260
|
+
`copies duplicate its history; remove them, and edit an archived entry in place instead`);
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
/** The misplaced-plan rule (part 4/4), for PLANS.md and its archive only, else undefined: rejects
|
|
265
|
+
* a head that ADDS a `### ` entry directly under `## Done` whose joined heading metadata carries
|
|
266
|
+
* `(planned YYYY-MM-DD` but no `done YYYY-MM-DD`, and whose key the base's PLANS.md and archive
|
|
267
|
+
* never carried in ANY section — a plan filed into the wrong section. strandedPlanEntries already
|
|
268
|
+
* does the fence-aware, joined-metadata reading the first two conditions need; the base's key set
|
|
269
|
+
* — over both files, since tumwater moves a finished plan from one to the other
|
|
270
|
+
* (plans/backlog-archive.md) — supplies the third. Judged against the merge-base (the same base
|
|
271
|
+
* the heading rules use), so a Planned → Done move of an entry the base already had passes and a
|
|
272
|
+
* pre-existing stranded entry (whose key the base has) never blocks unrelated merges. */
|
|
273
|
+
async function misplacedPlanReason(file, wt, baseRev, base, head) {
|
|
274
|
+
// Part 4/4's rule, PLANS.md and its archive (see this function's doc comment): a NEW plan
|
|
275
|
+
// filed directly under ## Done with no done date. strandedPlanEntries already does the
|
|
276
|
+
// fence-aware, joined-metadata reading the first two conditions need; the base's key set —
|
|
277
|
+
// over both files, since tumwater moves a finished plan from one to the other
|
|
278
|
+
// (plans/backlog-archive.md) — supplies the third.
|
|
279
|
+
if (file === "PLANS.md" || file === PLANS_ARCHIVE) {
|
|
280
|
+
const baseKeys = new Set([
|
|
281
|
+
...planHeadingKeys(file === "PLANS.md" ? base : await fileContentAt(wt, baseRev, "PLANS.md")),
|
|
282
|
+
...planHeadingKeys(file === PLANS_ARCHIVE ? base : await fileContentAt(wt, baseRev, PLANS_ARCHIVE)),
|
|
283
|
+
]);
|
|
284
|
+
const newcomer = strandedPlanEntries(head).find((e) => e.section === "Done" && !baseKeys.has(planHeadingKey(e.title)));
|
|
285
|
+
if (newcomer)
|
|
286
|
+
// The fault and its fix differ by file: in PLANS.md a NEW plan (the base never carried
|
|
287
|
+
// it in any section) was pasted under ## Done unfinished and belongs under ## Planned;
|
|
288
|
+
// in the archive the same heading is a finished plan the commit-time sweep already moved
|
|
289
|
+
// here, whose only fault is a missing `done YYYY-MM-DD` token — telling its author to
|
|
290
|
+
// "file it under ## Planned" is the opposite of the fix and wastes a revision.
|
|
291
|
+
return file === PLANS_ARCHIVE
|
|
292
|
+
? `${file} files "${newcomer.title}" directly under "## Done" with no done date — the ` +
|
|
293
|
+
`entry is finished and archived, so the only fix is to add \`done YYYY-MM-DD\` to its ` +
|
|
294
|
+
`"### " heading; do not move it back under "## Planned"`
|
|
295
|
+
: `${file} files "${newcomer.title}" directly under "## Done" with no done date — a plan ` +
|
|
296
|
+
`written into the done section is invisible to every Planned reader; file it under ` +
|
|
297
|
+
`"## Planned" instead`;
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
/** The hidden-fixed-record rule, for BUGS.md and its archive only, else undefined: rejects a head
|
|
302
|
+
* whose multi-block `## Fixed` entries (doubleBlockFixedEntries) the base did not carry — a new
|
|
303
|
+
* headingless record merged into a neighbor's body — or undefined when none. A new headingless
|
|
304
|
+
* record cannot hide in a Fixed entry's body: reject a head whose multi-block entries the base did
|
|
305
|
+
* not carry (an existing one never blocks unrelated edits, matching the heading-set rules above).
|
|
306
|
+
* The Fix paragraph is the guard's unit, so a merged second block would otherwise sit inside the
|
|
307
|
+
* neighbor it was symbol-checked against. BUGS-FIXED.md holds the same entries once archived.
|
|
308
|
+
*/
|
|
309
|
+
function hiddenFixedRecordReason(file, base, head) {
|
|
310
|
+
// A new headingless record cannot hide in a Fixed entry's body: reject a head whose
|
|
311
|
+
// multi-block entries the base did not carry (an existing one never blocks unrelated
|
|
312
|
+
// edits, matching the heading-set rules above). The Fix paragraph is the guard's unit, so
|
|
313
|
+
// a merged second block would otherwise sit inside the neighbor it was symbol-checked
|
|
314
|
+
// against. BUGS-FIXED.md holds the same entries once archived.
|
|
315
|
+
if (file === "BUGS.md" || file === BUGS_ARCHIVE) {
|
|
316
|
+
const baseTitles = new Set(doubleBlockFixedEntries(base));
|
|
317
|
+
const newcomer = doubleBlockFixedEntries(head).find((title) => !baseTitles.has(title));
|
|
318
|
+
if (newcomer)
|
|
319
|
+
return (`${file}'s Fixed entry "${newcomer}" holds more than one **Symptom:**/**Fix:** block — ` +
|
|
320
|
+
`a record with no "### " heading has merged into it, so the hidden record is invisible ` +
|
|
321
|
+
`to every section reader; give it its own "### " heading`);
|
|
322
|
+
}
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
218
325
|
/** The first structural fault a diff merging into `mainBranch` leaves in a backlog file, or
|
|
219
326
|
* undefined when none: for each touched backlog file it compares the `## ` heading set of the tree
|
|
220
327
|
* being merged (`wt`) against the diff's merge-base — the same base falseFixReason measures
|
|
@@ -268,67 +375,13 @@ export async function backlogStructureReason(wt, mainBranch, files) {
|
|
|
268
375
|
const base = await fileContentAt(wt, baseRev, file);
|
|
269
376
|
const headCounts = sectionTitleCounts(head);
|
|
270
377
|
const baseCounts = sectionTitleCounts(base);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// for a misplaced heading, so rule (b) guards only known sections and leaves an unknown one
|
|
279
|
-
// free to be demoted to `### ` under a known section (BUGS.md 2026-10-23).
|
|
280
|
-
const known = KNOWN_SECTIONS[file] ?? [];
|
|
281
|
-
for (const title of baseCounts.keys())
|
|
282
|
-
if (!headCounts.has(title) && known.includes(title))
|
|
283
|
-
return (`${file} drops the "## ${title}" section the base had — entries filed under it would ` +
|
|
284
|
-
`be invisible to every section reader; restore "## ${title}"`);
|
|
285
|
-
// An archive only grows by new headings: a copy of one the base archive already carries is
|
|
286
|
-
// finished history pasted back (readdedHeadings), never a new finished entry.
|
|
287
|
-
if (file === PLANS_ARCHIVE || file === BUGS_ARCHIVE) {
|
|
288
|
-
const readded = readdedHeadings(base, head);
|
|
289
|
-
if (readded.length > 0)
|
|
290
|
-
return (`${file} adds ${plural(readded.length, '"### " heading', '"### " headings')} it already carries ` +
|
|
291
|
-
`(first: "${truncate(readded[0], 160)}") — the archive holds each finished entry once, so the ` +
|
|
292
|
-
`copies duplicate its history; remove them, and edit an archived entry in place instead`);
|
|
293
|
-
}
|
|
294
|
-
// Part 4/4's rule, PLANS.md and its archive (see this function's doc comment): a NEW plan
|
|
295
|
-
// filed directly under ## Done with no done date. strandedPlanEntries already does the
|
|
296
|
-
// fence-aware, joined-metadata reading the first two conditions need; the base's key set —
|
|
297
|
-
// over both files, since tumwater moves a finished plan from one to the other
|
|
298
|
-
// (plans/backlog-archive.md) — supplies the third.
|
|
299
|
-
if (file === "PLANS.md" || file === PLANS_ARCHIVE) {
|
|
300
|
-
const baseKeys = new Set([
|
|
301
|
-
...planHeadingKeys(file === "PLANS.md" ? base : await fileContentAt(wt, baseRev, "PLANS.md")),
|
|
302
|
-
...planHeadingKeys(file === PLANS_ARCHIVE ? base : await fileContentAt(wt, baseRev, PLANS_ARCHIVE)),
|
|
303
|
-
]);
|
|
304
|
-
const newcomer = strandedPlanEntries(head).find((e) => e.section === "Done" && !baseKeys.has(planHeadingKey(e.title)));
|
|
305
|
-
if (newcomer)
|
|
306
|
-
// The fault and its fix differ by file: in PLANS.md a NEW plan (the base never carried
|
|
307
|
-
// it in any section) was pasted under ## Done unfinished and belongs under ## Planned;
|
|
308
|
-
// in the archive the same heading is a finished plan the commit-time sweep already moved
|
|
309
|
-
// here, whose only fault is a missing `done YYYY-MM-DD` token — telling its author to
|
|
310
|
-
// "file it under ## Planned" is the opposite of the fix and wastes a revision.
|
|
311
|
-
return file === PLANS_ARCHIVE
|
|
312
|
-
? `${file} files "${newcomer.title}" directly under "## Done" with no done date — the ` +
|
|
313
|
-
`entry is finished and archived, so the only fix is to add \`done YYYY-MM-DD\` to its ` +
|
|
314
|
-
`"### " heading; do not move it back under "## Planned"`
|
|
315
|
-
: `${file} files "${newcomer.title}" directly under "## Done" with no done date — a plan ` +
|
|
316
|
-
`written into the done section is invisible to every Planned reader; file it under ` +
|
|
317
|
-
`"## Planned" instead`;
|
|
318
|
-
}
|
|
319
|
-
// A new headingless record cannot hide in a Fixed entry's body: reject a head whose
|
|
320
|
-
// multi-block entries the base did not carry (an existing one never blocks unrelated
|
|
321
|
-
// edits, matching the heading-set rules above). The Fix paragraph is the guard's unit, so
|
|
322
|
-
// a merged second block would otherwise sit inside the neighbor it was symbol-checked
|
|
323
|
-
// against. BUGS-FIXED.md holds the same entries once archived.
|
|
324
|
-
if (file === "BUGS.md" || file === BUGS_ARCHIVE) {
|
|
325
|
-
const baseTitles = new Set(doubleBlockFixedEntries(base));
|
|
326
|
-
const newcomer = doubleBlockFixedEntries(head).find((title) => !baseTitles.has(title));
|
|
327
|
-
if (newcomer)
|
|
328
|
-
return (`${file}'s Fixed entry "${newcomer}" holds more than one **Symptom:**/**Fix:** block — ` +
|
|
329
|
-
`a record with no "### " heading has merged into it, so the hidden record is invisible ` +
|
|
330
|
-
`to every section reader; give it its own "### " heading`);
|
|
331
|
-
}
|
|
378
|
+
const reason = duplicateSectionReason(file, headCounts, baseCounts) ??
|
|
379
|
+
droppedSectionReason(file, headCounts, baseCounts) ??
|
|
380
|
+
readdedHeadingReason(file, base, head) ??
|
|
381
|
+
(await misplacedPlanReason(file, wt, baseRev, base, head)) ??
|
|
382
|
+
hiddenFixedRecordReason(file, base, head);
|
|
383
|
+
if (reason !== undefined)
|
|
384
|
+
return reason;
|
|
332
385
|
}
|
|
333
386
|
return undefined;
|
|
334
387
|
}
|
|
@@ -81,7 +81,7 @@ export function sectionCompletionLags(root, fileName, sectionTitle, dateRe) {
|
|
|
81
81
|
* because the stamp is the entry's own and the body may name other dates.
|
|
82
82
|
* null when the heading carries no such clause. Routed through backlog-md.ts's firstDate -
|
|
83
83
|
* the one first-match mechanism - with this module's own stamp regex.
|
|
84
|
-
* questionFilingDate (question-answer.ts) is the same recipe for the `asked by` verb. */
|
|
84
|
+
* questionFilingDate (backlog-question-answer.ts) is the same recipe for the `asked by` verb. */
|
|
85
85
|
const STAMP_DATE_RE = /(?:planned|reported|found by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/;
|
|
86
86
|
export function filingDate(title) {
|
|
87
87
|
return firstDate(title, STAMP_DATE_RE);
|
|
@@ -86,29 +86,44 @@ export function persistBaselineVerdicts(root) {
|
|
|
86
86
|
const file = baselineVerdictsPath(root);
|
|
87
87
|
greensFile = undefined; // the load below re-writes nothing it just read
|
|
88
88
|
const stored = readJsonFile(file);
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (baselineCache.has(entry.sha))
|
|
94
|
-
continue;
|
|
95
|
-
const from = typeof entry.inheritedFrom === "string" ? { inheritedFrom: entry.inheritedFrom } : {};
|
|
96
|
-
rememberBaseline(entry.sha, { status: "green", sha: entry.sha, ...from });
|
|
97
|
-
}
|
|
98
|
-
const reds = Array.isArray(stored?.reds) ? stored.reds : [];
|
|
99
|
-
for (const entry of reds) {
|
|
100
|
-
if (!isJsonObject(entry) || typeof entry.sha !== "string" || !/^[0-9a-f]{40,64}$/.test(entry.sha))
|
|
101
|
-
continue;
|
|
102
|
-
if (baselineCache.has(entry.sha))
|
|
103
|
-
continue;
|
|
104
|
-
const failingTests = Array.isArray(entry.failingTests)
|
|
105
|
-
? entry.failingTests.filter((t) => typeof t === "string")
|
|
106
|
-
: [];
|
|
107
|
-
const script = typeof entry.script === "string" ? { script: entry.script } : {};
|
|
108
|
-
rememberBaseline(entry.sha, { status: "red", sha: entry.sha, ...script, failingTests });
|
|
109
|
-
}
|
|
89
|
+
for (const entry of storedVerdictList(stored, "greens"))
|
|
90
|
+
restoreGreenVerdict(entry);
|
|
91
|
+
for (const entry of storedVerdictList(stored, "reds"))
|
|
92
|
+
restoreRedVerdict(entry);
|
|
110
93
|
greensFile = file;
|
|
111
94
|
}
|
|
95
|
+
/** The saved array for one kind of verdict, or an empty list when absent or not an array. */
|
|
96
|
+
function storedVerdictList(stored, key) {
|
|
97
|
+
const value = stored?.[key];
|
|
98
|
+
return Array.isArray(value) ? value : [];
|
|
99
|
+
}
|
|
100
|
+
/** A saved entry that is an object with a 40-64 hex `sha`, or null when it is not. */
|
|
101
|
+
function storedVerdictEntry(entry) {
|
|
102
|
+
if (!isJsonObject(entry))
|
|
103
|
+
return null;
|
|
104
|
+
if (typeof entry.sha !== "string" || !/^[0-9a-f]{40,64}$/.test(entry.sha))
|
|
105
|
+
return null;
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
/** Restore one saved green, carrying an optional `inheritedFrom`, unless the cache holds it. */
|
|
109
|
+
function restoreGreenVerdict(entry) {
|
|
110
|
+
const parsed = storedVerdictEntry(entry);
|
|
111
|
+
if (parsed === null || baselineCache.has(parsed.sha))
|
|
112
|
+
return;
|
|
113
|
+
const from = typeof parsed.inheritedFrom === "string" ? { inheritedFrom: parsed.inheritedFrom } : {};
|
|
114
|
+
rememberBaseline(parsed.sha, { status: "green", sha: parsed.sha, ...from });
|
|
115
|
+
}
|
|
116
|
+
/** Restore one saved red: optional `script` and failing tests, unless the cache holds it. */
|
|
117
|
+
function restoreRedVerdict(entry) {
|
|
118
|
+
const parsed = storedVerdictEntry(entry);
|
|
119
|
+
if (parsed === null || baselineCache.has(parsed.sha))
|
|
120
|
+
return;
|
|
121
|
+
const failingTests = Array.isArray(parsed.failingTests)
|
|
122
|
+
? parsed.failingTests.filter((t) => typeof t === "string")
|
|
123
|
+
: [];
|
|
124
|
+
const script = typeof parsed.script === "string" ? { script: parsed.script } : {};
|
|
125
|
+
rememberBaseline(parsed.sha, { status: "red", sha: parsed.sha, ...script, failingTests });
|
|
126
|
+
}
|
|
112
127
|
/** In-flight dedup: concurrent runs on the same not-yet-cached SHA (a fresh main move wakes
|
|
113
128
|
* every role's verifier at once) share one check run instead of racing N npm invocations. Keyed by
|
|
114
129
|
* SHA for ordinary checks; a re-verification adds its worktree, because joining another
|
|
@@ -350,76 +365,22 @@ tier = CHECK_TIER.other) {
|
|
|
350
365
|
const sha = await refSha(wt, "HEAD");
|
|
351
366
|
if (!sha)
|
|
352
367
|
return { baseline: null }; // No HEAD (unborn branch) — nothing to key on.
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
if (cached && !forceFresh && !shouldRerunRed(cached, wt, reverifyRed))
|
|
368
|
+
const { cached, key } = cachedBaselineFor(sha, wt, { reverifyRed, observedOnly, forceFresh });
|
|
369
|
+
if (cached)
|
|
356
370
|
return { baseline: cached };
|
|
357
|
-
|
|
371
|
+
// A re-run of a red keys the in-flight map by worktree (key !== sha): joining another
|
|
372
|
+
// worktree's run would observe the environment this run exists to re-test, and it is not a
|
|
373
|
+
// carry-over candidate either — only a SHA with no cached verdict may inherit a green.
|
|
374
|
+
if (!forceFresh && !observedOnly && key === sha) {
|
|
358
375
|
// Outside the in-flight dedup below on purpose: a forceFresh or observedOnly caller joining
|
|
359
376
|
// a pending promise must get a run, never a carried-over verdict.
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
if (inherited) {
|
|
363
|
-
// Re-read after the await: a run that settled meanwhile knows more than the carry-over.
|
|
364
|
-
const settled = baselineCache.get(sha);
|
|
365
|
-
if (settled)
|
|
366
|
-
return { baseline: settled };
|
|
367
|
-
rememberBaseline(sha, inherited);
|
|
377
|
+
const inherited = await inheritedBaselineFor(wt, sha, config);
|
|
378
|
+
if (inherited)
|
|
368
379
|
return { baseline: inherited };
|
|
369
|
-
}
|
|
370
380
|
}
|
|
371
|
-
// A re-run of a red keys the in-flight map by worktree: joining another worktree's run would
|
|
372
|
-
// observe the environment this run exists to re-test.
|
|
373
|
-
const key = cached?.status === "red" ? `${sha}\u0000${wt}` : sha;
|
|
374
381
|
let pending = baselineInFlight.get(key);
|
|
375
382
|
if (!pending) {
|
|
376
|
-
pending = (
|
|
377
|
-
const check = detectBuildCheck(wt, config);
|
|
378
|
-
if (!check)
|
|
379
|
-
return { baseline: null }; // No declared check — nothing to verify, nothing to block on.
|
|
380
|
-
// The same process-wide check permit the scoped checks take (build/build-check.ts's
|
|
381
|
-
// withCheckPermit): this runs the suite directly, so it must count against the cap too.
|
|
382
|
-
// Only the one run per key holds a permit; deduped waiters above await `pending` holding
|
|
383
|
-
// none.
|
|
384
|
-
const { outcome, durationMs } = await withCheckPermit(config, tier, () => runWithCleanRetry(() => runBuildCheck(wt, check, undefined, undefined, sampleSleep),
|
|
385
|
-
// Weather (a late deadline, a foreign kill, a host sleep) made no verdict about the
|
|
386
|
-
// tree: the first attempt is priced as its own run, and the retry's outcome stands —
|
|
387
|
-
// the same rule runScopedBuildCheck applies at every other scope.
|
|
388
|
-
(first, dur) => onRun?.({ outcome: first, durationMs: dur })), undefined, "main baseline");
|
|
389
|
-
onRun?.({ outcome, durationMs });
|
|
390
|
-
if (outcome.status === "skipped") {
|
|
391
|
-
// Environmental (timeout/no-npm), after the clean retry above for weather shapes:
|
|
392
|
-
// warn-and-proceed semantics like the gate's pre-check; never cache red for a skip.
|
|
393
|
-
return { baseline: null, skipReason: outcome.skipReason, run: outcome.run };
|
|
394
|
-
}
|
|
395
|
-
if (outcome.status === "failed" && (outcome.run?.sleptMs ?? 0) > SLEEP_SPAN_TOLERANCE_MS) {
|
|
396
|
-
// A failure the host slept through made no verdict about the tree either (BUGS.md
|
|
397
|
-
// 2026-09-30): the sleep expired a test's own wall-clock wait at the wake and the suite
|
|
398
|
-
// exited 1 inside the deadline. Caching it red would make sleep weather the team's
|
|
399
|
-
// authoritative red main, so nothing is cached — the next consult re-runs, and a clean
|
|
400
|
-
// verdict then settles the SHA exactly as before.
|
|
401
|
-
return { baseline: null, run: outcome.run, unverified: true };
|
|
402
|
-
}
|
|
403
|
-
const prior = baselineCache.get(sha);
|
|
404
|
-
const priorRedFrom = prior?.status === "red" ? (prior.redFrom ?? []) : [];
|
|
405
|
-
const baseline = outcome.status === "passed"
|
|
406
|
-
? { status: "green", sha }
|
|
407
|
-
: {
|
|
408
|
-
status: "red",
|
|
409
|
-
sha,
|
|
410
|
-
script: outcome.script,
|
|
411
|
-
outputTail: outcome.outputTail,
|
|
412
|
-
failingTests: outcome.failingTests ?? [],
|
|
413
|
-
redFrom: priorRedFrom.includes(wt) ? priorRedFrom : [...priorRedFrom, wt],
|
|
414
|
-
};
|
|
415
|
-
// A green is always recorded (promoting a provisional red); a red never overwrites an
|
|
416
|
-
// observed green — a concurrent run may have promoted this SHA while this one was still
|
|
417
|
-
// going. An inherited green is no observation, so this run's verdict replaces it either way.
|
|
418
|
-
const current = baselineCache.get(sha);
|
|
419
|
-
if (current?.status !== "green" || current.inheritedFrom !== undefined)
|
|
420
|
-
rememberBaseline(sha, baseline);
|
|
421
|
-
return { baseline };
|
|
422
|
-
})();
|
|
383
|
+
pending = runBaselineOnce(sha, wt, config, onRun, sampleSleep, tier);
|
|
423
384
|
baselineInFlight.set(key, pending);
|
|
424
385
|
}
|
|
425
386
|
try {
|
|
@@ -430,6 +391,109 @@ tier = CHECK_TIER.other) {
|
|
|
430
391
|
baselineInFlight.delete(key);
|
|
431
392
|
}
|
|
432
393
|
}
|
|
394
|
+
/** The usable cached verdict for `sha` — or null when the suite must run — together with the
|
|
395
|
+
* in-flight key its run dedups on. Applies the observedOnly inherited-green filter, the
|
|
396
|
+
* forceFresh / red re-run short-circuit, and the worktree-qualified key a red re-run keys by.
|
|
397
|
+
* `key === sha` tells the caller the SHA had no verdict to re-run (a possible carry-over
|
|
398
|
+
* candidate); a worktree-qualified key is a pending red re-run, which never inherits. */
|
|
399
|
+
function cachedBaselineFor(sha, wt, opts) {
|
|
400
|
+
const stored = baselineCache.get(sha);
|
|
401
|
+
const cached = opts.observedOnly && stored?.inheritedFrom !== undefined ? undefined : stored;
|
|
402
|
+
const usable = cached && !opts.forceFresh && !shouldRerunRed(cached, wt, opts.reverifyRed) ? cached : null;
|
|
403
|
+
const key = cached?.status === "red" ? `${sha}\u0000${wt}` : sha;
|
|
404
|
+
return { cached: usable, key };
|
|
405
|
+
}
|
|
406
|
+
/** The inherited green for `sha` across review-exempt ancestors, or null when there is none to
|
|
407
|
+
* carry over. The exemptPathsOf(config) + inheritedGreen(wt, sha, exempt) walk, the post-await
|
|
408
|
+
* cache re-read (a run that settled meanwhile knows more than the carry-over), and the
|
|
409
|
+
* rememberBaseline re-seed of the carried-over verdict. */
|
|
410
|
+
async function inheritedBaselineFor(wt, sha, config) {
|
|
411
|
+
const exempt = exemptPathsOf(config);
|
|
412
|
+
const inherited = exempt.length > 0 ? await inheritedGreen(wt, sha, exempt) : null;
|
|
413
|
+
if (inherited) {
|
|
414
|
+
// Re-read after the await: a run that settled meanwhile knows more than the carry-over.
|
|
415
|
+
const settled = baselineCache.get(sha);
|
|
416
|
+
if (settled)
|
|
417
|
+
return settled;
|
|
418
|
+
rememberBaseline(sha, inherited);
|
|
419
|
+
return inherited;
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
/** Run the declared check once under the same process-wide check permit the scoped checks take,
|
|
424
|
+
* retrying once when the first attempt made no verdict about the tree (a late deadline, a
|
|
425
|
+
* foreign kill, a host sleep). Returns the outcome and its duration, or null when no check is
|
|
426
|
+
* declared (nothing to verify, nothing to block on). */
|
|
427
|
+
async function detectedBaselineOutcome(wt, config, onRun, sampleSleep, tier) {
|
|
428
|
+
const check = detectBuildCheck(wt, config);
|
|
429
|
+
if (!check)
|
|
430
|
+
return null; // No declared check — nothing to verify, nothing to block on.
|
|
431
|
+
// The same process-wide check permit the scoped checks take (build/build-check.ts's
|
|
432
|
+
// withCheckPermit): this runs the suite directly, so it must count against the cap too.
|
|
433
|
+
// Only the one run per key holds a permit; deduped waiters above await `pending` holding
|
|
434
|
+
// none.
|
|
435
|
+
return withCheckPermit(config, tier, () => runWithCleanRetry(() => runBuildCheck(wt, check, undefined, undefined, sampleSleep),
|
|
436
|
+
// Weather (a late deadline, a foreign kill, a host sleep) made no verdict about the
|
|
437
|
+
// tree: the first attempt is priced as its own run, and the retry's outcome stands —
|
|
438
|
+
// the same rule runScopedBuildCheck applies at every other scope.
|
|
439
|
+
(first, dur) => onRun?.({ outcome: first, durationMs: dur })), undefined, "main baseline");
|
|
440
|
+
}
|
|
441
|
+
/** Build the green/red verdict from a run's outcome, folding the prior red's observed worktrees
|
|
442
|
+
* (redFrom) into a fresh red. Does not write the cache — the caller decides whether to promote. */
|
|
443
|
+
function baselineFromOutcome(sha, wt, outcome) {
|
|
444
|
+
const prior = baselineCache.get(sha);
|
|
445
|
+
const priorRedFrom = prior?.status === "red" ? (prior.redFrom ?? []) : [];
|
|
446
|
+
return outcome.status === "passed"
|
|
447
|
+
? { status: "green", sha }
|
|
448
|
+
: {
|
|
449
|
+
status: "red",
|
|
450
|
+
sha,
|
|
451
|
+
script: outcome.script,
|
|
452
|
+
outputTail: outcome.outputTail,
|
|
453
|
+
failingTests: outcome.failingTests ?? [],
|
|
454
|
+
redFrom: priorRedFrom.includes(wt) ? priorRedFrom : [...priorRedFrom, wt],
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/** Persist a freshly-run verdict unless a concurrent run already promoted the SHA with an
|
|
458
|
+
* observed green. A green is always recorded (promoting a provisional red); a red never
|
|
459
|
+
* overwrites an observed green — a concurrent run may have promoted this SHA while this one was
|
|
460
|
+
* still going. An inherited green is no observation, so this run's verdict replaces it either
|
|
461
|
+
* way. */
|
|
462
|
+
function rememberPromotableBaseline(sha, baseline) {
|
|
463
|
+
// A green is always recorded (promoting a provisional red); a red never overwrites an
|
|
464
|
+
// observed green — a concurrent run may have promoted this SHA while this one was still
|
|
465
|
+
// going. An inherited green is no observation, so this run's verdict replaces it either way.
|
|
466
|
+
const current = baselineCache.get(sha);
|
|
467
|
+
if (current?.status !== "green" || current.inheritedFrom !== undefined)
|
|
468
|
+
rememberBaseline(sha, baseline);
|
|
469
|
+
}
|
|
470
|
+
/** Run the suite once for `sha` at `wt`: detect the check, run it under the permit with a clean
|
|
471
|
+
* retry and its two onRun calls, fold the outcome into a verdict — skips and slept-through
|
|
472
|
+
* failures return no verdict and write nothing — and promote it unless an observed green
|
|
473
|
+
* outranks it. This replaces the old `(async () => …)()` closure stored in the in-flight map. */
|
|
474
|
+
async function runBaselineOnce(sha, wt, config, onRun, sampleSleep, tier) {
|
|
475
|
+
const detected = await detectedBaselineOutcome(wt, config, onRun, sampleSleep, tier);
|
|
476
|
+
if (!detected)
|
|
477
|
+
return { baseline: null };
|
|
478
|
+
const { outcome, durationMs } = detected;
|
|
479
|
+
onRun?.({ outcome, durationMs });
|
|
480
|
+
if (outcome.status === "skipped") {
|
|
481
|
+
// Environmental (timeout/no-npm), after the clean retry above for weather shapes:
|
|
482
|
+
// warn-and-proceed semantics like the gate's pre-check; never cache red for a skip.
|
|
483
|
+
return { baseline: null, skipReason: outcome.skipReason, run: outcome.run };
|
|
484
|
+
}
|
|
485
|
+
if (outcome.status === "failed" && (outcome.run?.sleptMs ?? 0) > SLEEP_SPAN_TOLERANCE_MS) {
|
|
486
|
+
// A failure the host slept through made no verdict about the tree either (BUGS.md
|
|
487
|
+
// 2026-09-30): the sleep expired a test's own wall-clock wait at the wake and the suite
|
|
488
|
+
// exited 1 inside the deadline. Caching it red would make sleep weather the team's
|
|
489
|
+
// authoritative red main, so nothing is cached — the next consult re-runs, and a clean
|
|
490
|
+
// verdict then settles the SHA exactly as before.
|
|
491
|
+
return { baseline: null, run: outcome.run, unverified: true };
|
|
492
|
+
}
|
|
493
|
+
const baseline = baselineFromOutcome(sha, wt, outcome);
|
|
494
|
+
rememberPromotableBaseline(sha, baseline);
|
|
495
|
+
return { baseline };
|
|
496
|
+
}
|
|
433
497
|
/** Is main green at the mirror worktree's HEAD? The merge path seeds a green verdict for every
|
|
434
498
|
* merged code SHA (noteGreenBaseline — its in-lock post-rebase re-check, or the gate's pre-check
|
|
435
499
|
* when the rebase was a no-op), so the common case is a cache hit. A head that only added doc
|