tumwater 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -6
- package/dist/build-info.json +2 -2
- package/dist/src/agent/backend-traits.js +14 -0
- package/dist/src/agent/claude/claude-stream.js +27 -31
- package/dist/src/agent/codex/codex-rollout.js +38 -24
- package/dist/src/agent/codex/codex-stream.js +71 -38
- package/dist/src/agent/image-input.js +18 -87
- package/dist/src/agent/opencode/opencode-catalog.js +2 -2
- package/dist/src/agent/opencode/opencode-stream.js +4 -4
- package/dist/src/agent/run-agent.js +11 -2
- package/dist/src/agent/run-settle.js +22 -13
- package/dist/src/agent/run-spawn.js +3 -12
- package/dist/src/agent/run-stream.js +2 -10
- package/dist/src/agent/session-ledger.js +16 -0
- package/dist/src/backlog/backlog-archive.js +64 -165
- package/dist/src/backlog/backlog-eligibility.js +50 -58
- package/dist/src/backlog/backlog-file-bugs.js +133 -0
- package/dist/src/backlog/backlog-index-data.js +2 -2
- package/dist/src/backlog/backlog-md.js +38 -27
- package/dist/src/backlog/backlog-release.js +11 -19
- package/dist/src/backlog/backlog-structure.js +14 -1
- package/dist/src/backlog/backlog-write.js +1 -1
- package/dist/src/backlog/backlog.js +5 -4
- package/dist/src/backlog/question-answer.js +30 -14
- package/dist/src/baseline/main-baseline.js +30 -20
- package/dist/src/baseline/main-guardrails.js +54 -17
- package/dist/src/baseline/main-watch.js +1 -1
- package/dist/src/budget/budget-wiring.js +0 -5
- package/dist/src/budget/budget.js +2 -1
- package/dist/src/build/build-check-scoped.js +70 -41
- package/dist/src/change/change-data.js +4 -2
- package/dist/src/cli/cli-command-args.js +199 -98
- package/dist/src/cli/cli-flag-specs.js +1 -4
- package/dist/src/cli/cli-review-commands.js +2 -1
- package/dist/src/cli/cli-role-command.js +27 -50
- package/dist/src/cli/cli-start.js +62 -12
- package/dist/src/cli/follow-view.js +3 -1
- package/dist/src/cli/help.js +6 -7
- package/dist/src/cli/log-commands.js +3 -1
- package/dist/src/cli.js +3 -7
- package/dist/src/collections.js +21 -7
- package/dist/src/config/config-fallbacks.js +0 -4
- package/dist/src/config/config-live.js +179 -147
- package/dist/src/config/config-views.js +2 -7
- package/dist/src/config/config-write.js +19 -26
- package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
- package/dist/src/doctor/doctor-backlog-plans.js +8 -8
- package/dist/src/doctor/doctor-backlog.js +22 -36
- package/dist/src/doctor/doctor-check-names.js +2 -2
- package/dist/src/doctor/doctor-claude-checks.js +2 -2
- package/dist/src/doctor/doctor-contention-checks.js +1 -1
- package/dist/src/doctor/doctor-holds.js +13 -11
- package/dist/src/doctor/doctor-model-checks.js +8 -184
- package/dist/src/doctor/doctor-notify.js +2 -5
- package/dist/src/doctor/doctor-opencode-checks.js +85 -48
- package/dist/src/doctor/doctor-project-checks.js +66 -17
- package/dist/src/doctor/doctor-role-notes.js +6 -24
- package/dist/src/doctor/doctor.js +6 -6
- package/dist/src/doctor/probe.js +1 -1
- package/dist/src/events/event-format.js +5 -15
- package/dist/src/events/event-labels.js +0 -1
- package/dist/src/events/event-legacy.js +1 -1
- package/dist/src/events/event-window.js +1 -1
- package/dist/src/events/events.js +15 -2
- package/dist/src/failure/failure-state-change.js +183 -68
- package/dist/src/failure/time-spend.js +1 -1
- package/dist/src/files/json-object.js +15 -2
- package/dist/src/gates/startup-gate.js +28 -28
- package/dist/src/git/slot-salvage.js +5 -4
- package/dist/src/gui/gui-admin-endpoints.js +2 -3
- package/dist/src/gui/gui-backlog-commands.js +1 -1
- package/dist/src/gui/gui-backlog-endpoints.js +1 -7
- package/dist/src/gui/gui-endpoint-commands.js +1 -1
- package/dist/src/gui/gui-operator-commands.js +0 -12
- package/dist/src/gui/gui-prompt-commands.js +26 -20
- package/dist/src/gui/gui-report-endpoints.js +2 -1
- package/dist/src/gui/gui-server.js +1 -2
- package/dist/src/history/history.js +102 -67
- package/dist/src/inbox/inbox-submit.js +1 -5
- package/dist/src/inbox/prompt-commands.js +24 -19
- package/dist/src/init/init.js +68 -15
- package/dist/src/merge/merge-core.js +1 -1
- package/dist/src/merge/merge-post-review-drain.js +1 -1
- package/dist/src/operator/operator-commands.js +68 -47
- package/dist/src/operator/retire.js +2 -1
- package/dist/src/orchestrator/retention.js +6 -5
- package/dist/src/paths.js +0 -6
- package/dist/src/pi/pi-watchdogs.js +20 -1
- package/dist/src/pi-extension/context-shake.js +55 -31
- package/dist/src/process/process.js +23 -0
- package/dist/src/process/supervisor.js +17 -1
- package/dist/src/prompt/gate-notes.js +9 -9
- package/dist/src/prompt/notes-backlog.js +11 -24
- package/dist/src/prompt/prompt-followup.js +4 -12
- package/dist/src/prompt/prompt.js +4 -5
- package/dist/src/report/report-data.js +10 -225
- package/dist/src/report/report-fold.js +20 -295
- package/dist/src/report/report-main-red.js +45 -42
- package/dist/src/report/report-render.js +66 -39
- package/dist/src/report/report-since-data.js +218 -0
- package/dist/src/report/report-usage-fold.js +288 -0
- package/dist/src/report/report.js +2 -1
- package/dist/src/review/known-flakes.js +3 -3
- package/dist/src/review/post-merge-review-accept.js +3 -2
- package/dist/src/review/post-merge-review.js +9 -9
- package/dist/src/review/quarantine-render.js +2 -1
- package/dist/src/roles/role-catalog.js +18 -30
- package/dist/src/roles/role-guidance.js +18 -43
- package/dist/src/roles/role-view.js +1 -2
- package/dist/src/run/ci-parity.js +5 -6
- package/dist/src/run/qa-coverage.js +4 -4
- package/dist/src/run/run-detail-data.js +23 -7
- package/dist/src/run/run-detail-render.js +6 -0
- package/dist/src/run/run-detail.js +44 -22
- package/dist/src/run/run-dismiss.js +43 -0
- package/dist/src/run/run-prompt.js +100 -56
- package/dist/src/run/run-resume.js +32 -31
- package/dist/src/run/run-stage.js +4 -21
- package/dist/src/run/run-timing.js +18 -4
- package/dist/src/run/run-verdict.js +8 -73
- package/dist/src/run/stage-backlog-checks.js +2 -29
- package/dist/src/run/stage-check.js +4 -12
- package/dist/src/runner/runner-outcome.js +0 -5
- package/dist/src/runner/runner-run.js +2 -79
- package/dist/src/runner/runner-state-load.js +0 -10
- package/dist/src/runner/runner.js +1 -32
- package/dist/src/scheduling/claims.js +3 -10
- package/dist/src/status/status-data.js +5 -9
- package/dist/src/status/status-role-notes.js +18 -14
- package/dist/src/status/status-roles.js +2 -35
- package/dist/src/text/datetime.js +8 -1
- package/dist/src/text/text.js +12 -0
- package/dist/src/ui/alert-feeds.js +7 -212
- package/dist/src/ui/badges.js +2 -3
- package/dist/src/ui/gui/gui-client-boot.js +19 -4
- package/dist/src/ui/gui/gui-client-drawer.js +1 -1
- package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
- package/dist/src/ui/gui/gui-client-report.js +1 -2
- package/dist/src/ui/gui/gui-client-settings.js +0 -23
- package/dist/src/ui/gui/gui-client-team.js +26 -0
- package/dist/src/ui/gui/gui-icons.js +7 -1
- package/dist/src/ui/status-model.js +2 -2
- package/dist/src/ui/status-payload.js +3 -3
- package/dist/src/ui/status-render.js +3 -3
- package/dist/src/ui/team-alert-backlog.js +23 -54
- package/dist/src/ui/team-alert-shared.js +23 -10
- package/dist/src/ui/team-alert-status.js +8 -3
- package/dist/src/ui/team-alert-tail.js +1 -14
- package/dist/src/ui/team-alerts.js +6 -17
- package/dist/src/ui/transcript-tail.js +137 -94
- package/dist/src/ui/tui/tui-editor-answer.js +54 -44
- package/dist/src/ui/tui/tui-editor-attach.js +74 -63
- package/dist/src/ui/tui/tui-editor-backends.js +62 -56
- package/dist/src/ui/tui/tui-editor-brief.js +64 -54
- package/dist/src/ui/tui/tui-editor-budget.js +105 -93
- package/dist/src/ui/tui/tui-editor-config.js +2 -4
- package/dist/src/ui/tui/tui-editor-entry.js +171 -153
- package/dist/src/ui/tui/tui-editor-instances.js +2 -1
- package/dist/src/ui/tui/tui-editor-pause.js +209 -183
- package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
- package/dist/src/ui/tui/tui-editor-queued.js +74 -64
- package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
- package/dist/src/ui/tui/tui-editor-release.js +50 -42
- package/dist/src/ui/tui/tui-editor-retire.js +57 -46
- package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
- package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
- package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
- package/dist/src/ui/tui/tui-editor-search.js +70 -58
- package/dist/src/ui/tui/tui-frame.js +1 -1
- package/dist/src/ui/tui/tui-keys.js +1 -1
- package/dist/src/ui/tui/tui-pane-activity.js +62 -50
- package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
- package/dist/src/ui/tui/tui-pane-health.js +3 -24
- package/dist/src/ui/tui/tui-pane-history.js +20 -24
- package/dist/src/ui/tui/tui-pane-report.js +74 -57
- package/dist/src/ui/tui/tui-pane-session.js +3 -2
- package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
- package/dist/src/ui/tui/tui-pane.js +201 -174
- package/dist/src/ui/tui/tui-panes.js +2 -3
- package/dist/src/ui/tui/tui.js +3 -3
- package/dist/src/verdict/fix-claim.js +18 -6
- package/dist/src/verdict/refusal.js +30 -6
- package/package.json +1 -1
- package/dist/src/doctor/image-probe.js +0 -257
- package/dist/src/ui/team-alert-backlog-feed.js +0 -181
- package/dist/src/ui/team-alert-merge.js +0 -183
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* CLI command package for domain logic.
|
|
15
15
|
*/
|
|
16
16
|
import path from "node:path";
|
|
17
|
-
import { fencedHeadingTitle, fenceTracker, nextSectionHeading } from "./backlog-md.js";
|
|
17
|
+
import { fencedHeadingTitle, fenceTracker, firstDate, nextSectionHeading } from "./backlog-md.js";
|
|
18
18
|
import { answeredQuestionEntries, entryMatches, openQuestionEntries, ageInDays, ageSuffix } from "./backlog.js";
|
|
19
19
|
import { normalizeEntryBody } from "./backlog-write.js";
|
|
20
20
|
import { collapseWhitespace, firstTrimmedLineStartingWith, trimLeadingBlankLines, trimTrailingBlankLines, truncate } from "../text/text.js";
|
|
@@ -43,10 +43,11 @@ function firstBodySuffix(body) {
|
|
|
43
43
|
/** The open question's filing stamp, `YYYY-MM-DD`, read from the heading's `(asked by <who>
|
|
44
44
|
* YYYY-MM-DD)` clause — the first `YYYY-MM-DD` after an `asked by` word in the title text — or
|
|
45
45
|
* null when the heading carries no such clause. Taken from the title text only, not the body:
|
|
46
|
-
* the stamp is the entry's own, and the body may name other dates.
|
|
46
|
+
* the stamp is the entry's own, and the body may name other dates. Routed through
|
|
47
|
+
* backlog-md.ts's firstDate - the one first-match mechanism filingDate (backlog.ts) uses too -
|
|
48
|
+
* with the `asked by` verb in place of the plans/bugs stamp verbs. */
|
|
47
49
|
export function questionFilingDate(title) {
|
|
48
|
-
|
|
49
|
-
return m === null ? null : m[1];
|
|
50
|
+
return firstDate(title, /(?:asked by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/);
|
|
50
51
|
}
|
|
51
52
|
/** The role id named in the heading's `(asked by <role> …)` clause — the first word after
|
|
52
53
|
* `asked by` — or null when the heading has no such clause. Taken from the title only, like
|
|
@@ -273,40 +274,56 @@ function answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines) {
|
|
|
273
274
|
if (expectedTitle !== undefined && block.title !== expectedTitle)
|
|
274
275
|
return { status: "moved", title: block.title };
|
|
275
276
|
const moved = lines.slice(block.start, block.end);
|
|
276
|
-
|
|
277
|
+
const rebuilt = cutOpenBlock(lines, scanned, block);
|
|
278
|
+
const text = appendAnsweredEntry(rebuilt, moved, decision, scanned.answeredIdx !== -1);
|
|
279
|
+
writeTextAtomic(file, text);
|
|
280
|
+
return { status: "answered", title: block.title, decision };
|
|
281
|
+
}
|
|
282
|
+
/** Cut `block` out of `## Open`, returning the rebuilt file text (as lines). When the section
|
|
283
|
+
* is left with no content at all, restores the `_None yet._` placeholder the skeleton carries,
|
|
284
|
+
* so an empty outbox still reads as an intentionally empty section. */
|
|
285
|
+
function cutOpenBlock(lines, scan, block) {
|
|
277
286
|
// Cut the block out of ## Open: everything before it, everything from its end on. When the
|
|
278
287
|
// section is left with no content at all, restore the `_None yet._` placeholder the skeleton
|
|
279
288
|
// carries, so an empty outbox still reads as an intentionally empty section.
|
|
280
289
|
// Emptiness is judged against the Open section only (openIdx+1 .. openEnd): with an
|
|
281
290
|
// intermediate section between Open and Answered, judging against answeredIdx would keep
|
|
282
291
|
// the placeholder from returning and, worse, the empty rebuild would drop that section.
|
|
283
|
-
const openContent = lines.slice(
|
|
284
|
-
const remaining = openContent.filter((_, i) => i < block.start -
|
|
292
|
+
const openContent = lines.slice(scan.openIdx + 1, scan.openEnd);
|
|
293
|
+
const remaining = openContent.filter((_, i) => i < block.start - scan.openIdx - 1 || i >= block.end - scan.openIdx - 1);
|
|
285
294
|
const openEmpty = remaining.every((l) => l.trim() === "");
|
|
286
295
|
const rebuilt = [];
|
|
287
296
|
if (openEmpty) {
|
|
288
|
-
rebuilt.push(...lines.slice(0,
|
|
297
|
+
rebuilt.push(...lines.slice(0, scan.openIdx + 1), "", "_None yet._",
|
|
289
298
|
// Resume at the section that followed Open (openEnd, not answeredIdx — an intermediate
|
|
290
299
|
// section between the two must survive) with the blank separator line the dropped Open
|
|
291
300
|
// content used to carry, so `_None yet._` never reads as a paragraph glued to a heading.
|
|
292
|
-
"", ...lines.slice(
|
|
301
|
+
"", ...lines.slice(scan.openEnd));
|
|
293
302
|
}
|
|
294
303
|
else {
|
|
295
304
|
rebuilt.push(...lines.slice(0, block.start), ...lines.slice(block.end));
|
|
296
305
|
}
|
|
306
|
+
return rebuilt;
|
|
307
|
+
}
|
|
308
|
+
/** Append the moved block at the end of `## Answered` (growing the section when the file has
|
|
309
|
+
* none, stripping a real `_None yet._` placeholder from it), returning the full text to write.
|
|
310
|
+
* `hasAnswered` is whether the file already held a `## Answered` section. */
|
|
311
|
+
function appendAnsweredEntry(rebuilt, moved, decision, hasAnswered) {
|
|
312
|
+
// The moved block's trailing blank lines are dropped, so the appended entry never reads
|
|
313
|
+
// as a spill of blank lines before the dated answer stamp.
|
|
314
|
+
trimTrailingBlankLines(moved);
|
|
297
315
|
// Append the block at the end of ## Answered: before the next `## ` heading when one
|
|
298
316
|
// follows, at the file's end otherwise — the section is last in the documented skeleton.
|
|
299
317
|
const stamp = `**Answered ${formatDate(new Date())} by the user:**`;
|
|
300
318
|
const stamped = decision.includes("\n") ? [stamp, "", ...decision.split("\n")] : [`${stamp} ${decision}`];
|
|
301
319
|
const answerLines = moved.concat("", ...stamped, "");
|
|
302
|
-
if (
|
|
320
|
+
if (!hasAnswered) {
|
|
303
321
|
// No ## Answered section: grow the file's end with the skeleton section plus the block.
|
|
304
322
|
let out = rebuilt.join("\n");
|
|
305
323
|
if (out !== "" && !out.endsWith("\n"))
|
|
306
324
|
out += "\n";
|
|
307
325
|
out += `\n## Answered\n\n${answerLines.join("\n")}`;
|
|
308
|
-
|
|
309
|
-
return { status: "answered", title: block.title, decision };
|
|
326
|
+
return out;
|
|
310
327
|
}
|
|
311
328
|
const fenced2 = fenceTracker();
|
|
312
329
|
let answeredAt = -1;
|
|
@@ -342,6 +359,5 @@ function answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines) {
|
|
|
342
359
|
trimTrailingBlankLines(head);
|
|
343
360
|
trimLeadingBlankLines(tail);
|
|
344
361
|
const result = head.concat("", answerLines, tail);
|
|
345
|
-
|
|
346
|
-
return { status: "answered", title: block.title, decision };
|
|
362
|
+
return result.join("\n");
|
|
347
363
|
}
|
|
@@ -174,6 +174,15 @@ export function noteInheritedGreen(sha, from) {
|
|
|
174
174
|
* verdict anyway (every code merge seeds one), so this bound only caps the rev-list on a cold
|
|
175
175
|
* cache. */
|
|
176
176
|
const INHERIT_WALK_MAX = 64;
|
|
177
|
+
/** The body of a `git rev-list --first-parent --max-count=<max> <ref>` read — the walk the three
|
|
178
|
+
* first-parent scans in this module share, spelled the same on purpose so a change to the walk
|
|
179
|
+
* shape (the bound, the `--first-parent` flag) lands once. `inheritedGreen` and `watchedBaseline`
|
|
180
|
+
* walk a commit's ancestors (`${sha}^` / `${tip}^`, the commit itself excluded);
|
|
181
|
+
* `newestGreenAboveBuild` walks `tip` itself and down. Never throws: a git failure is an empty
|
|
182
|
+
* list, and every caller already treats that as a no-inheritance / no-verdict answer. */
|
|
183
|
+
async function firstParentWalk(wt, ref, max) {
|
|
184
|
+
return gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${max}`, ref));
|
|
185
|
+
}
|
|
177
186
|
/** A green for `sha` carried over from its nearest verdict-bearing first-parent ancestor, or null
|
|
178
187
|
* when there is none to carry. The nearest ancestor with a cached verdict must be GREEN, and the
|
|
179
188
|
* tree diff from it to `sha` must touch only `exemptPaths` (config.review.exemptPaths, the same
|
|
@@ -185,7 +194,7 @@ const INHERIT_WALK_MAX = 64;
|
|
|
185
194
|
* past. An uncached ancestor is walked through, since the single diff from the green covers it.
|
|
186
195
|
* Never throws: any git failure means no inheritance, and the caller runs the suite as before. */
|
|
187
196
|
async function inheritedGreen(wt, sha, exemptPaths) {
|
|
188
|
-
const ancestors =
|
|
197
|
+
const ancestors = await firstParentWalk(wt, `${sha}^`, INHERIT_WALK_MAX);
|
|
189
198
|
for (const ancestor of ancestors) {
|
|
190
199
|
const verdict = baselineCache.get(ancestor);
|
|
191
200
|
if (verdict === undefined)
|
|
@@ -229,7 +238,7 @@ export async function watchedBaseline(wt, sha) {
|
|
|
229
238
|
const own = baselineCache.get(tip);
|
|
230
239
|
if (own)
|
|
231
240
|
return own;
|
|
232
|
-
const ancestors =
|
|
241
|
+
const ancestors = await firstParentWalk(wt, `${tip}^`, INHERIT_WALK_MAX);
|
|
233
242
|
for (const ancestor of ancestors) {
|
|
234
243
|
const verdict = baselineCache.get(ancestor);
|
|
235
244
|
if (verdict)
|
|
@@ -249,6 +258,23 @@ const REDEPLOY_WALK_MAX = 64;
|
|
|
249
258
|
export function watchVerifiesMain(root, config) {
|
|
250
259
|
return detectBuildCheck(root, config) !== null && mergeCommandOf(config) !== undefined;
|
|
251
260
|
}
|
|
261
|
+
/** The newest first-parent commit below `tip`, down to and through `buildSha`, with a green
|
|
262
|
+
* cached verdict — the walk+scan redeployTarget's watched and un-watched branches share. Only
|
|
263
|
+
* their fallback differs: `notOnHistory` when `buildSha` is not on the first-parent walk at all
|
|
264
|
+
* (the caller keeps today's answer, `tip`), `onHistoryNone` when it is but nothing above it is
|
|
265
|
+
* green (watched keeps the build; un-watched keeps `tip`). Never throws: a git failure leaves
|
|
266
|
+
* the walk empty, so the fallback answers. */
|
|
267
|
+
async function newestGreenAboveBuild(wt, tip, buildSha, notOnHistory, onHistoryNone) {
|
|
268
|
+
const walk = await firstParentWalk(wt, tip, REDEPLOY_WALK_MAX);
|
|
269
|
+
const buildAt = walk.indexOf(buildSha);
|
|
270
|
+
if (buildAt === -1)
|
|
271
|
+
return notOnHistory;
|
|
272
|
+
for (const sha of walk.slice(0, buildAt)) {
|
|
273
|
+
if (baselineCache.get(sha)?.status === "green")
|
|
274
|
+
return sha;
|
|
275
|
+
}
|
|
276
|
+
return onHistoryNone;
|
|
277
|
+
}
|
|
252
278
|
/** The commit self-redeploy should run: main's newest first-parent commit with a green full-check
|
|
253
279
|
* verdict in the cache (observed or inherited) that is newer than the running build `buildSha`,
|
|
254
280
|
* looking at most REDEPLOY_WALK_MAX commits down from `tip`.
|
|
@@ -275,28 +301,12 @@ export async function redeployTarget(wt, tip, buildSha, watched = false) {
|
|
|
275
301
|
if (watched) {
|
|
276
302
|
if (tip === "")
|
|
277
303
|
return tip;
|
|
278
|
-
|
|
279
|
-
const buildAt = walk.indexOf(buildSha);
|
|
280
|
-
if (buildAt === -1)
|
|
281
|
-
return tip;
|
|
282
|
-
for (const sha of walk.slice(0, buildAt)) {
|
|
283
|
-
if (baselineCache.get(sha)?.status === "green")
|
|
284
|
-
return sha;
|
|
285
|
-
}
|
|
286
|
-
return buildSha;
|
|
304
|
+
return newestGreenAboveBuild(wt, tip, buildSha, tip, buildSha);
|
|
287
305
|
}
|
|
288
306
|
const red = baselineCache.get(tip);
|
|
289
307
|
if (tip === "" || red?.status !== "red" || !(red.redFrom ?? []).includes(mirrorWorktreePath(wt)))
|
|
290
308
|
return tip;
|
|
291
|
-
|
|
292
|
-
const buildAt = walk.indexOf(buildSha);
|
|
293
|
-
if (buildAt === -1)
|
|
294
|
-
return tip;
|
|
295
|
-
for (const sha of walk.slice(0, buildAt)) {
|
|
296
|
-
if (baselineCache.get(sha)?.status === "green")
|
|
297
|
-
return sha;
|
|
298
|
-
}
|
|
299
|
-
return tip;
|
|
309
|
+
return newestGreenAboveBuild(wt, tip, buildSha, tip, tip);
|
|
300
310
|
}
|
|
301
311
|
/** Verify main's own build/test suite at `wt`'s HEAD — which must be pristine main (the caller
|
|
302
312
|
* is the fresh-run path right after resetWorktreeToMain; a dirty or ahead worktree would
|
|
@@ -49,25 +49,22 @@ export const QUARANTINE_WARN_COUNT = 10;
|
|
|
49
49
|
/** How many of the oldest quarantined tests a warning names before folding the rest into a
|
|
50
50
|
* remainder count. */
|
|
51
51
|
const QUARANTINE_DETAIL_MAX = 3;
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
* Returns null when main is green, or red for RED_MAIN_WARN_MS or less. */
|
|
62
|
-
export function redMainWarning(events, now) {
|
|
63
|
-
const cutoff = now - DAY_MS;
|
|
52
|
+
/** True when `e` is the newest `main_culprit` seen so far: no contender yet, or a later `ts`.
|
|
53
|
+
* The newest-in-window pick that `scanRedWindow` folds into its loop. */
|
|
54
|
+
function isNewerCulprit(c, e) {
|
|
55
|
+
return c === undefined || e.ts > c.ts;
|
|
56
|
+
}
|
|
57
|
+
/** Scans the trailing 24 h of `events` (cutoff `now - DAY_MS`), collecting the events that open,
|
|
58
|
+
* extend or end a red and picking the newest in-window `main_culprit` with a non-empty string
|
|
59
|
+
* `commit`. Owns the window scan and the newest-culprit pick. */
|
|
60
|
+
function scanRedWindow(events, cutoff) {
|
|
64
61
|
const relevant = [];
|
|
65
62
|
let culprit;
|
|
66
63
|
for (const e of events) {
|
|
67
64
|
if (typeof e.ts !== "number" || e.ts < cutoff)
|
|
68
65
|
continue;
|
|
69
66
|
if (e.type === "main_culprit") {
|
|
70
|
-
if (typeof e.commit === "string" && e.commit.length > 0 && (culprit
|
|
67
|
+
if (typeof e.commit === "string" && e.commit.length > 0 && isNewerCulprit(culprit, e)) {
|
|
71
68
|
culprit = e;
|
|
72
69
|
}
|
|
73
70
|
continue;
|
|
@@ -75,6 +72,13 @@ export function redMainWarning(events, now) {
|
|
|
75
72
|
if (isRedMain(e) || isPassingMainCheck(e))
|
|
76
73
|
relevant.push(e);
|
|
77
74
|
}
|
|
75
|
+
return { relevant, culprit };
|
|
76
|
+
}
|
|
77
|
+
/** Derives the red spell over `window`'s `relevant` events: the start event and duration from
|
|
78
|
+
* `now`, whether the spell is unbroken, and the 30-minute threshold (null when main is green,
|
|
79
|
+
* or red for RED_MAIN_WARN_MS or less). Owns the red-spell derivation and the 30-minute bail. */
|
|
80
|
+
function redSpell(window, now) {
|
|
81
|
+
const relevant = window.relevant;
|
|
78
82
|
if (relevant.length === 0)
|
|
79
83
|
return null;
|
|
80
84
|
let newest = relevant[0];
|
|
@@ -99,19 +103,52 @@ export function redMainWarning(events, now) {
|
|
|
99
103
|
if (durationMs <= RED_MAIN_WARN_MS)
|
|
100
104
|
return null;
|
|
101
105
|
const unbroken = lastPass === undefined && reds.length > 1;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
return { startEv, newest, durationMs, unbroken };
|
|
107
|
+
}
|
|
108
|
+
/** "more than 24 h" for an unbroken or full-window red, else the short duration phrase. Owns
|
|
109
|
+
* the duration wording. */
|
|
110
|
+
function redSpellDuration(spell) {
|
|
111
|
+
return spell.unbroken || spell.durationMs >= DAY_MS ? "more than 24 h" : redDurationPhrase(spell.durationMs);
|
|
112
|
+
}
|
|
113
|
+
/** The newest red's first failing test, when one is named. Owns the first-failing-test read. */
|
|
114
|
+
function redFailingTest(newest) {
|
|
115
|
+
return Array.isArray(newest.failingTests) && typeof newest.failingTests[0] === "string" ? newest.failingTests[0] : undefined;
|
|
116
|
+
}
|
|
117
|
+
/** The blame clause naming the merge the main watch blamed, when the newest `culprit` in the
|
|
118
|
+
* window blames the same sha the phrase names. Owns the blame clause. */
|
|
119
|
+
function redBlameClause(culprit, newest) {
|
|
105
120
|
// The blame names the merge the main watch blamed for the red — the same `main_culprit`
|
|
106
121
|
// commit-equals-tip rule the dashboard alert uses (status-polls only publishes a blame whose
|
|
107
122
|
// commit is the derived tip sha), so a red the watch did not blame never borrows another
|
|
108
123
|
// head's culprit. A culprit naming another commit, or none, leaves the phrase as today.
|
|
109
124
|
const shouldBlame = culprit !== undefined && culprit.commit === newest.sha;
|
|
110
|
-
|
|
125
|
+
return shouldBlame
|
|
111
126
|
? typeof culprit.runner === "string" && culprit.runner.length > 0
|
|
112
127
|
? `; the main watch blames ${roleLabel(culprit.runner)}'s merge ${shortSha(culprit.commit)}`
|
|
113
128
|
: `; the main watch blames merge ${shortSha(culprit.commit)}`
|
|
114
129
|
: "";
|
|
130
|
+
}
|
|
131
|
+
/** G1 over `events`: warns when main has been red for more than RED_MAIN_WARN_MS, over the
|
|
132
|
+
* trailing 24 h of `events` from `now`. Only `main_check` events with `durationMs` > 0 and
|
|
133
|
+
* `main_red_hold` events count; a `main_check` with `durationMs` 0 (cached or inherited) is
|
|
134
|
+
* ignored; a pass (including one naming only quarantined tests, recorded passed) ends the red.
|
|
135
|
+
* The phrase names how long main has had no passing full check, the sha it has been red since,
|
|
136
|
+
* its clock time, the newest red's first failing test when one is named, and — when the newest
|
|
137
|
+
* `main_culprit` in the window blames the same sha the phrase names — the merge the main watch
|
|
138
|
+
* blamed (`; the main watch blames <role>'s merge <sha>`, or `; the main watch blames merge
|
|
139
|
+
* <sha>` when no runner is named), the one rule the dashboard alert and this phrase share.
|
|
140
|
+
* Returns null when main is green, or red for RED_MAIN_WARN_MS or less. The per-job work is
|
|
141
|
+
* split into the module-private helpers `scanRedWindow`, `redSpell`, `redSpellDuration`,
|
|
142
|
+
* `redFailingTest` and `redBlameClause`, one per job, so each is found by name. */
|
|
143
|
+
export function redMainWarning(events, now) {
|
|
144
|
+
const window = scanRedWindow(events, now - DAY_MS);
|
|
145
|
+
const spell = redSpell(window, now);
|
|
146
|
+
if (spell === null)
|
|
147
|
+
return null;
|
|
148
|
+
const duration = redSpellDuration(spell);
|
|
149
|
+
const head = `main has had no passing full check for ${duration}: red since ${shortSha(spell.startEv.sha)} at ${clockHm(spell.startEv.ts)}`;
|
|
150
|
+
const failing = redFailingTest(spell.newest);
|
|
151
|
+
const blame = redBlameClause(window.culprit, spell.newest);
|
|
115
152
|
return failing ? `${head} — ${squash(failing, 120)}${blame}` : `${head}${blame}`;
|
|
116
153
|
}
|
|
117
154
|
/** G3 over `entries` (the loadQuarantine list): warns when the list holds more than
|
|
@@ -6,7 +6,7 @@ import { detectBuildCheck } from "../build/build-check-detect.js";
|
|
|
6
6
|
import { git, gitTry } from "../git/git-run.js";
|
|
7
7
|
import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
|
|
8
8
|
import { errorMessage } from "../text/text.js";
|
|
9
|
-
import { fileQuarantineBugsOnMain } from "../backlog/backlog-
|
|
9
|
+
import { fileQuarantineBugsOnMain } from "../backlog/backlog-file-bugs.js";
|
|
10
10
|
import { loadQuarantine, markQuarantineFiled, onlyQuarantined, quarantinedTests, quarantineTests, releaseQuarantine, } from "../review/known-flakes.js";
|
|
11
11
|
import { shortSha } from "../text/format.js";
|
|
12
12
|
import { treeDiffFiles } from "../git/git-diff.js";
|
|
@@ -59,11 +59,6 @@ function assignRunnerBudgetConfig(runners, liveConfig, view) {
|
|
|
59
59
|
// the reload, can never run on the wrong model.
|
|
60
60
|
for (const r of runners) {
|
|
61
61
|
r.config = gateRoleConfig(r.role, liveConfig, view.roleConfig, view.directorConfig);
|
|
62
|
-
// An image reroute must never spend past the daily cap: while the gate has the team on
|
|
63
|
-
// its free fallback pairs, a reroute looks only among those pairs (plans/image-input.md
|
|
64
|
-
// "Reroute target" "Regime"). Set beside the config assignment so the scheduler's regime
|
|
65
|
-
// and the roles' never disagree.
|
|
66
|
-
r.budgetFallbackActive = view.onFallback;
|
|
67
62
|
}
|
|
68
63
|
}
|
|
69
64
|
function handBackFallbackRuns(root, runners, liveConfig, accounts, view) {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* without importing either — the one-way rule that previously kept these functions in
|
|
9
9
|
* runner-state.ts. */
|
|
10
10
|
import { MODEL_TIERS } from "../config/config-schema.js";
|
|
11
|
+
import { compareCodeUnits } from "../collections.js";
|
|
11
12
|
import { nonNegativeNumber, setOwn } from "../files/json-object.js";
|
|
12
13
|
import { dayAt, dayKey } from "../text/datetime.js";
|
|
13
14
|
/** The local calendar day as YYYY-MM-DD — the same local-time convention as every other
|
|
@@ -76,7 +77,7 @@ export function topDailySpenders(states, now = Date.now(), limit = 3) {
|
|
|
76
77
|
return states
|
|
77
78
|
.map((s) => ({ role: s.role, spentUsd: dailyCost(s, now) }))
|
|
78
79
|
.filter((r) => r.spentUsd > 0)
|
|
79
|
-
.sort((a, b) => b.spentUsd - a.spentUsd || (a.role
|
|
80
|
+
.sort((a, b) => b.spentUsd - a.spentUsd || compareCodeUnits(a.role, b.role))
|
|
80
81
|
.slice(0, limit);
|
|
81
82
|
}
|
|
82
83
|
/** True when today's spend has reached the daily cost budget — the view is non-null, its cap is
|
|
@@ -5,34 +5,11 @@ import { MERGE_SCOPES, SCOPE_WORDS, buildCheckEvent, buildCheckSkipWarning, runW
|
|
|
5
5
|
import { CHECK_TIER, withCheckPermit } from "../concurrency/check-permit.js";
|
|
6
6
|
import { sampleHostLoad, sampleSleepClock } from "./host-sleep.js";
|
|
7
7
|
import { npmInstall } from "./dep-install.js";
|
|
8
|
-
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
* outcome classification (runBuildCheck, BuildCheckOutcome) — because orchestration is its own
|
|
14
|
-
* concern: the permit, the event emission, the verdict-less retry, the merge-scope unverified
|
|
15
|
-
* remapping, and the skip warning. Every run is an event with its cost: the deterministic checks
|
|
16
|
-
* are where the team's compute goes after the authoring run, and "how long does npm test take per
|
|
17
|
-
* merge" must be answerable from the feed, not by timing it by hand. Returns null when no check is
|
|
18
|
-
* declared (nothing to run — the caller passes, exactly as before this split), otherwise the
|
|
19
|
-
* declared check plus its classified outcome. A "skipped" outcome also logs its standard warning
|
|
20
|
-
* here (wording keyed on the scope, the timeout as actually set — and, when its deadline fired
|
|
21
|
-
* late, as actually enforced); "failed" and "passed" are the caller's to decide (deterministic
|
|
22
|
-
* reject vs. verifiedHead / baseline seeding). A timeout or signal kill at a merge scope is
|
|
23
|
-
* remapped to a deterministic "failed" — the tree is unverified, so it must not merge. A check
|
|
24
|
-
* killed by a signal the harness did not send is retried once at any scope — the first run's death
|
|
25
|
-
* says nothing about the tree (it is another run's `pkill`), so one verdict from a clean attempt is
|
|
26
|
-
* owed before the skip is honored; a timeout whose deadline demonstrably fired late (the harness's
|
|
27
|
-
* own host-sleep evidence) earns that same one retry at any scope, and a failed run the host slept
|
|
28
|
-
* through does too; each attempt is priced as its own build_check event, carrying when the check
|
|
29
|
-
* itself ran (buildCheckRunFields). A configured `check.gateCommand` replaces the command at scope
|
|
30
|
-
* "gate" only (same cwd and timeout); the in-lock command (Fast merge "Defaults", mergeCommandOf) —
|
|
31
|
-
* `check.mergeCommand` when set, else `check.gateCommand`, else the full check — replaces it at the
|
|
32
|
-
* merge and batch scopes, and those events then carry `full: false`. The red-main baseline, which
|
|
33
|
-
* detects on its own, keeps running check.command. `permitWait` hears
|
|
34
|
-
* about a wait for the check permit (the merge cell's "waiting for a check slot"). Never throws. */
|
|
35
|
-
export async function runScopedBuildCheck(root, role, scope, wt, config, timeoutMs = BUILD_CHECK_TIMEOUT_MS, sampleSleep = sampleSleepClock, install = npmInstall, permitWait, sampleLoad = sampleHostLoad) {
|
|
8
|
+
/** Resolve which command to detect for a scope and the effective deadline it runs under — the
|
|
9
|
+
* command-swap/detect rule (gateCommandOf/mergeCommandOf over detectBuildCheck), the
|
|
10
|
+
* merge-scope starved deadline, and the cheaper flag. Returns null when no check is declared
|
|
11
|
+
* (nothing to run). */
|
|
12
|
+
function resolveScopedCheck(wt, scope, config, timeoutMs, sampleLoad) {
|
|
36
13
|
const swapped = scope === "gate" ? gateCommandOf(config) : MERGE_SCOPES.has(scope) ? mergeCommandOf(config) : undefined;
|
|
37
14
|
const check = detectBuildCheck(wt, swapped === undefined ? config : { check: { ...config?.check, command: swapped } });
|
|
38
15
|
if (!check)
|
|
@@ -50,6 +27,12 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
|
|
|
50
27
|
? starvedDeadlineMs(checkTimeoutMs(check, timeoutMs), load)
|
|
51
28
|
: checkTimeoutMs(check, timeoutMs);
|
|
52
29
|
const runCheck = { ...check, timeoutMs: effectiveMs };
|
|
30
|
+
return { check, cheaper, runCheck, effectiveMs, load };
|
|
31
|
+
}
|
|
32
|
+
/** Run the resolved check under one permit, with the one clean retry for a verdict-less death —
|
|
33
|
+
* the one-permit-two-attempt rule. Prices the first attempt as its own build_check event and
|
|
34
|
+
* returns the final outcome and its run duration. */
|
|
35
|
+
async function runScopedCheckWithRetry(root, role, scope, wt, runCheck, timeoutMs, config, sampleSleep, install, permitWait, cheaper) {
|
|
53
36
|
// One permit covers both attempts (the killed-check retry re-runs under the permit it
|
|
54
37
|
// holds); durationMs prices the run itself, not the wait for a permit.
|
|
55
38
|
let durationMs = 0;
|
|
@@ -70,6 +53,12 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
|
|
|
70
53
|
granted: () => permitWait?.granted?.(),
|
|
71
54
|
waited: (waitedMs, parked) => logCheckWaitEvent(root, role, scope, waitedMs, parked),
|
|
72
55
|
}, `${role} ${scope}`);
|
|
56
|
+
return { raw, durationMs };
|
|
57
|
+
}
|
|
58
|
+
/** Classify the raw outcome: the merge-scope skip and slept-through rules become a deterministic
|
|
59
|
+
* unverified reject; anything else passes through. Never logs — the caller warns (see
|
|
60
|
+
* warnScopedCheck). Returns the unverified reason only when a rule fired. */
|
|
61
|
+
function unverifiedOutcomeFor(scope, check, raw, effectiveMs, load, durationMs) {
|
|
73
62
|
// A signal kill at a merge scope, or a timeout whose deadline fired on time (real slowness, a
|
|
74
63
|
// genuinely hung or oversized suite), made no verdict about the tree — and this is the check
|
|
75
64
|
// whose whole job is to catch a semantic conflict before it merges, so it rejects
|
|
@@ -89,6 +78,8 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
|
|
|
89
78
|
(((raw.status === "skipped" &&
|
|
90
79
|
(raw.skipReason === "timeout" || raw.skipReason === "killed" || raw.skipReason === "install")) ||
|
|
91
80
|
sleepFailed));
|
|
81
|
+
if (!unverifiedSkip)
|
|
82
|
+
return { outcome: raw, unverifiedReason: null };
|
|
92
83
|
const unverifiedReason = raw.skipReason === "install"
|
|
93
84
|
? installFailedPhrase(SCOPE_WORDS[scope].label, raw.install, UNVERIFIED_TREE)
|
|
94
85
|
: raw.skipReason === "killed"
|
|
@@ -99,17 +90,19 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
|
|
|
99
90
|
// An on-time kill (the late-deadline wording already names its own cause) names the
|
|
100
91
|
// starved host when the load average says the team was the check's competition.
|
|
101
92
|
(raw.run?.deadlineLateMs ?? 0) > DEADLINE_LATE_TOLERANCE_MS ? "" : loadedHostPhrase(load)}`;
|
|
102
|
-
const outcome =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
93
|
+
const outcome = {
|
|
94
|
+
status: "failed",
|
|
95
|
+
script: checkScriptName(check),
|
|
96
|
+
outputTail: [unverifiedReason],
|
|
97
|
+
run: raw.run,
|
|
98
|
+
unverified: true,
|
|
99
|
+
...(raw.install ? { install: raw.install } : {}),
|
|
100
|
+
};
|
|
101
|
+
return { outcome, unverifiedReason };
|
|
102
|
+
}
|
|
103
|
+
/** Warn for the two verdict-less roads: a skipped check (buildCheckSkipWarning) or an unverified
|
|
104
|
+
* merge reject. Reads the scope word table and the killed signal. */
|
|
105
|
+
function warnScopedCheck(root, role, scope, outcome, effectiveMs, durationMs, unverifiedReason) {
|
|
113
106
|
if (outcome.status === "skipped") {
|
|
114
107
|
// Environmental — deliberately NOT fail-closed, so a hung build script cannot wedge every
|
|
115
108
|
// code run into the 3-strike discard (gate) or a merge behind the merge lock.
|
|
@@ -118,8 +111,44 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
|
|
|
118
111
|
? { signal: outcome.killedBy, durationMs }
|
|
119
112
|
: undefined, outcome.run, outcome.install));
|
|
120
113
|
}
|
|
121
|
-
else if (
|
|
114
|
+
else if (unverifiedReason) {
|
|
122
115
|
warnEventBestEffort(root, role, `${unverifiedReason}; rejecting the merge`);
|
|
123
116
|
}
|
|
124
|
-
|
|
117
|
+
}
|
|
118
|
+
/** Run the project's declared check for a named scope — the detect → run → build_check event →
|
|
119
|
+
* skip-warning sequence the review gate's pre-check (scope "gate"), the merge path's in-lock
|
|
120
|
+
* re-check (scope "merge"), and the batch merger's one check over the whole stacked tree (scope
|
|
121
|
+
* "batch") previously each ran inline, kept in one place so the event's shape and the skip warning
|
|
122
|
+
* cannot drift between them. Split out of build-check.ts — which keeps the check's execution and
|
|
123
|
+
* outcome classification (runBuildCheck, BuildCheckOutcome) — because orchestration is its own
|
|
124
|
+
* concern: the permit, the event emission, the verdict-less retry, the merge-scope unverified
|
|
125
|
+
* remapping, and the skip warning. Every run is an event with its cost: the deterministic checks
|
|
126
|
+
* are where the team's compute goes after the authoring run, and "how long does npm test take per
|
|
127
|
+
* merge" must be answerable from the feed, not by timing it by hand. Returns null when no check is
|
|
128
|
+
* declared (nothing to run — the caller passes, exactly as before this split), otherwise the
|
|
129
|
+
* declared check plus its classified outcome. A "skipped" outcome also logs its standard warning
|
|
130
|
+
* here (wording keyed on the scope, the timeout as actually set — and, when its deadline fired
|
|
131
|
+
* late, as actually enforced); "failed" and "passed" are the caller's to decide (deterministic
|
|
132
|
+
* reject vs. verifiedHead / baseline seeding). A timeout or signal kill at a merge scope is
|
|
133
|
+
* remapped to a deterministic "failed" — the tree is unverified, so it must not merge. A check
|
|
134
|
+
* killed by a signal the harness did not send is retried once at any scope — the first run's death
|
|
135
|
+
* says nothing about the tree (it is another run's `pkill`), so one verdict from a clean attempt is
|
|
136
|
+
* owed before the skip is honored; a timeout whose deadline demonstrably fired late (the harness's
|
|
137
|
+
* own host-sleep evidence) earns that same one retry at any scope, and a failed run the host slept
|
|
138
|
+
* through does too; each attempt is priced as its own build_check event, carrying when the check
|
|
139
|
+
* itself ran (buildCheckRunFields). A configured `check.gateCommand` replaces the command at scope
|
|
140
|
+
* "gate" only (same cwd and timeout); the in-lock command (Fast merge "Defaults", mergeCommandOf) —
|
|
141
|
+
* `check.mergeCommand` when set, else `check.gateCommand`, else the full check — replaces it at the
|
|
142
|
+
* merge and batch scopes, and those events then carry `full: false`. The red-main baseline, which
|
|
143
|
+
* detects on its own, keeps running check.command. `permitWait` hears
|
|
144
|
+
* about a wait for the check permit (the merge cell's "waiting for a check slot"). Never throws. */
|
|
145
|
+
export async function runScopedBuildCheck(root, role, scope, wt, config, timeoutMs = BUILD_CHECK_TIMEOUT_MS, sampleSleep = sampleSleepClock, install = npmInstall, permitWait, sampleLoad = sampleHostLoad) {
|
|
146
|
+
const r = resolveScopedCheck(wt, scope, config, timeoutMs, sampleLoad);
|
|
147
|
+
if (!r)
|
|
148
|
+
return null;
|
|
149
|
+
const { raw, durationMs } = await runScopedCheckWithRetry(root, role, scope, wt, r.runCheck, timeoutMs, config, sampleSleep, install, permitWait, r.cheaper);
|
|
150
|
+
const { outcome, unverifiedReason } = unverifiedOutcomeFor(scope, r.check, raw, r.effectiveMs, r.load, durationMs);
|
|
151
|
+
logEventBestEffort(root, buildCheckEvent(role, scope, outcome, durationMs, r.cheaper));
|
|
152
|
+
warnScopedCheck(root, role, scope, outcome, r.effectiveMs, durationMs, unverifiedReason);
|
|
153
|
+
return { check: r.check, outcome };
|
|
125
154
|
}
|
|
@@ -10,6 +10,7 @@ import { knownRoleIdsCached, loadConfigSafe } from "../config/config.js";
|
|
|
10
10
|
import { aheadOfMain, branchExists, countCommitsInRange, currentBranch, targetBranch } from "../git/git.js";
|
|
11
11
|
import { gitTry } from "../git/git-run.js";
|
|
12
12
|
import { aheadOfMainDiff, aheadOfMainFiles, changedFiles } from "../git/git-diff.js";
|
|
13
|
+
import { compareCodeUnits } from "../collections.js";
|
|
13
14
|
import { isAbsolute } from "node:path";
|
|
14
15
|
import { branchName } from "../paths.js";
|
|
15
16
|
import { isUsableWorktree } from "../git/worktree.js";
|
|
@@ -152,7 +153,8 @@ async function cappedWorkingDiff(wt, spec = []) {
|
|
|
152
153
|
return (`[uncommitted diff truncated: the full diff is ${full.length} bytes; showing --stat]\n\n` + stat);
|
|
153
154
|
}
|
|
154
155
|
/** The paths two or more distinct roles hold: a role's duplicate path counts once, roles stay
|
|
155
|
-
* in holdings order, paths sort
|
|
156
|
+
* in holdings order, paths sort in code-unit order (`compareCodeUnits`, the locale-independent
|
|
157
|
+
* order a plain `<` comparison gives). */
|
|
156
158
|
export function fileOverlaps(holdings) {
|
|
157
159
|
const byPath = new Map();
|
|
158
160
|
for (const { role, files } of holdings) {
|
|
@@ -166,7 +168,7 @@ export function fileOverlaps(holdings) {
|
|
|
166
168
|
}
|
|
167
169
|
return [...byPath.entries()]
|
|
168
170
|
.filter(([, roles]) => roles.length >= 2)
|
|
169
|
-
.sort(([a], [b]) => (a
|
|
171
|
+
.sort(([a], [b]) => compareCodeUnits(a, b))
|
|
170
172
|
.map(([path, roles]) => ({ path, roles }));
|
|
171
173
|
}
|
|
172
174
|
/** Keep only the overlaps naming a listed role; each kept entry still names every holder. */
|