omp-conductor 0.20.1 → 0.20.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/package.json +1 -1
- package/schema/config.schema.json +24 -0
- package/src/commands/companion.ts +52 -16
- package/src/commands/daemon.ts +14 -25
- package/src/commands/drain.ts +12 -5
- package/src/commands/worker.ts +5 -0
- package/src/config-schema.ts +8 -0
- package/src/config.ts +17 -2
- package/src/daemon/drain.ts +33 -0
- package/src/daemon/http.ts +28 -0
- package/src/daemon/review.ts +11 -11
- package/src/daemon/supervision.ts +144 -1
- package/src/daemon/tick.ts +46 -15
- package/src/daemon/views.ts +17 -0
- package/src/daemon.ts +2 -1
- package/src/decisions.ts +20 -9
- package/src/diff-flags.ts +186 -31
- package/src/doctor.ts +320 -9
- package/src/escalate.ts +187 -15
- package/src/failure-class.ts +176 -4
- package/src/fleet.ts +444 -50
- package/src/graph-health.ts +17 -1
- package/src/groom.ts +11 -0
- package/src/knowledge.ts +75 -21
- package/src/lifecycle.ts +267 -5
- package/src/orchestrator-tick.ts +8 -2
- package/src/ready-gate.ts +100 -5
- package/src/reports.ts +4 -1
- package/src/settlement.ts +98 -7
- package/src/status-render.ts +27 -2
- package/src/store.ts +50 -13
- package/src/to-spec.ts +52 -0
- package/src/tracker/github.ts +132 -10
- package/src/types.ts +31 -0
- package/src/upgrade.ts +12 -5
- package/src/verbs/server.ts +16 -12
package/src/daemon/tick.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { admitCandidates, startOfToday, type AdmissionHold } from "../admission.
|
|
|
22
22
|
import { dbBackupDirFor, findProject, loadConfig, resolveCaps, stateDir } from "../config.ts";
|
|
23
23
|
import { evaluateDecisionConditions, probeNpmVersion, probeRateLimitReset } from "../decisions.ts";
|
|
24
24
|
import { dbSnapshotDue, dbSnapshotMarkerKey, localDayKey } from "../digest-schedule.ts";
|
|
25
|
-
import { runDoctor } from "../doctor.ts";
|
|
25
|
+
import { reconcileOrphanedPause, runDoctor } from "../doctor.ts";
|
|
26
26
|
import { fleetLayers, herdrPaneOmpStarts, resolveHerdrSession } from "../fleet.ts";
|
|
27
27
|
import { projectLabels } from "../label-projection.ts";
|
|
28
28
|
import { healthCheck } from "../lifecycle.ts";
|
|
@@ -42,13 +42,13 @@ import { writeAdmissionAck } from "./ack.ts";
|
|
|
42
42
|
import { dispatchAdmissions, summarizeDispatch, summarizeHeldPass, type WorkerPool } from "./admission-pass.ts";
|
|
43
43
|
import { NO_ISSUE, PACKAGE_SRC_DIR, UNROUTABLE_TEXT, type Deps } from "./deps.ts";
|
|
44
44
|
import { handleIssue } from "./dispatch.ts";
|
|
45
|
-
import {
|
|
45
|
+
import { consumeDrain, markDrained } from "./drain.ts";
|
|
46
46
|
import { dispatchToSpecGrooming } from "./groom-pass.ts";
|
|
47
47
|
import { INTEGRITY_SAMPLE, checkIntegrity, markPaged, packageManifest } from "./integrity.ts";
|
|
48
48
|
import { reconcilePanes } from "./panes.ts";
|
|
49
49
|
import { applyAdjudicationDispositions, dispatchReviewAdjudications, dispatchReviewRevisions } from "./review.ts";
|
|
50
50
|
import { cleanupRetainedRuns, watchBaseHealth, watchMergedBase } from "./settle-pass.ts";
|
|
51
|
-
import { wakeOrchestratorForMetConditions, watchOrchestrator } from "./supervision.ts";
|
|
51
|
+
import { wakeOrchestratorForMetConditions, watchOrchestrator, watchWorkerProgress } from "./supervision.ts";
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Record what this host has installed, once per dispatch pass (#919).
|
|
@@ -388,6 +388,16 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
388
388
|
// failure happened. A pause silences claiming, not the operator's right to
|
|
389
389
|
// know their supervising session stopped reading its queue.
|
|
390
390
|
await watchOrchestrator(d);
|
|
391
|
+
// The worker-side sibling, and above the pause gate with it: a stalled run
|
|
392
|
+
// holds its slot on a parked fleet exactly as hard as on a busy one, and the
|
|
393
|
+
// #1086 incident was an upgrade drain blocked for 26 minutes by one silent
|
|
394
|
+
// session. Paused workers are skipped inside the pass — their silence is
|
|
395
|
+
// the pause working (#938) — so this gate costs nothing there.
|
|
396
|
+
try {
|
|
397
|
+
await watchWorkerProgress(d);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
log(`worker-progress sweep failed: ${errText(err)}`);
|
|
400
|
+
}
|
|
391
401
|
// The down incident is reconciled the same place and for the same reason: a
|
|
392
402
|
// session that has actually died is as much the operator's concern as one
|
|
393
403
|
// that is wedged, and restarting it is the daemon's restart either way. This
|
|
@@ -400,6 +410,20 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
400
410
|
escalate: (event) => d.escalate(event),
|
|
401
411
|
log,
|
|
402
412
|
});
|
|
413
|
+
// The orphaned-pause watch rides the same above-the-pause-gate band (#1113):
|
|
414
|
+
// a sentinel whose writer has exited is exactly the condition a paused
|
|
415
|
+
// fleet cannot surface through its own gates below, and an `upgrade` pause
|
|
416
|
+
// can never be cleared by any future event. The notification ledger dedupes
|
|
417
|
+
// the page to once per sentinel instance; resuming stays a human decision.
|
|
418
|
+
try {
|
|
419
|
+
await reconcileOrphanedPause({
|
|
420
|
+
project: d.project.name,
|
|
421
|
+
escalate: (event) => d.escalate(event),
|
|
422
|
+
log,
|
|
423
|
+
});
|
|
424
|
+
} catch (err) {
|
|
425
|
+
log(`orphaned-pause watch failed: ${errText(err)}`);
|
|
426
|
+
}
|
|
403
427
|
|
|
404
428
|
// Beside the two orchestrator watches, and above the pause gate, for the same
|
|
405
429
|
// reason they are: mining reads rows this store already holds and files intake
|
|
@@ -584,11 +608,16 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
584
608
|
// the same admission boundary — settlement above it, nothing claimed below —
|
|
585
609
|
// but the intent is durable (it survives orchestrator loss) and bounded (the
|
|
586
610
|
// record carries an absolute deadline, so a crash can never strand
|
|
587
|
-
// admission).
|
|
611
|
+
// admission). Reaching an empty active set SUCCEEDS the drain; it does not
|
|
612
|
+
// end it (#1078): the release window the drain exists to create opens at
|
|
613
|
+
// that moment and holds claims down until the deadline — exactly the quiet
|
|
614
|
+
// interval a tick-driven orchestrator needs to cut a release, which the old
|
|
615
|
+
// eight-second auto-clear destroyed before any tick could use it. Four
|
|
616
|
+
// shapes, four behaviours:
|
|
588
617
|
// - fresh drain with live runs → a held pass, exactly like a pause;
|
|
589
|
-
// - fresh drain
|
|
590
|
-
//
|
|
591
|
-
//
|
|
618
|
+
// - fresh drain whose active set just reached zero → the opening is marked
|
|
619
|
+
// on the record once and logged once, and this pass admits nothing;
|
|
620
|
+
// - fresh drain already marked drained → the same held pass, silent;
|
|
592
621
|
// - malformed record → fails closed for THIS pass (it might be a fresh
|
|
593
622
|
// fence we cannot read), and the same consume removed it, so it can
|
|
594
623
|
// never become an unbounded permanent drain.
|
|
@@ -596,15 +625,17 @@ export async function tick(d: Deps, workers?: WorkerPool): Promise<void> {
|
|
|
596
625
|
if (drain.kind === "active") {
|
|
597
626
|
// Completion is the ACTIVE set, not the live-worker set: pushed-pending
|
|
598
627
|
// and pushed-green PRs still make the `runs-settled` release gate fail, so
|
|
599
|
-
// a
|
|
600
|
-
// batch the releases still see as unfinished (#776 review #2).
|
|
601
|
-
if (d.store.activeRuns(d.project.name).length === 0) {
|
|
602
|
-
|
|
603
|
-
log(
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
628
|
+
// a window declared open over a remaining run would admit work on top of
|
|
629
|
+
// a batch the releases still see as unfinished (#776 review #2).
|
|
630
|
+
if (d.store.activeRuns(d.project.name).length === 0 && drain.drain.drainedAt === undefined) {
|
|
631
|
+
markDrained(d.project.name);
|
|
632
|
+
log(
|
|
633
|
+
`project drain: fleet drained — release window open until ${drain.drain.expiresAt}` +
|
|
634
|
+
" (claims stay paused; admission resumes at the deadline or on drain cancel)",
|
|
635
|
+
);
|
|
607
636
|
}
|
|
637
|
+
d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));
|
|
638
|
+
return;
|
|
608
639
|
} else if (drain.kind === "error") {
|
|
609
640
|
log(`project drain record invalid (${drain.problem}) — removed; this pass admits nothing`);
|
|
610
641
|
d.store.recordDispatch(d.project.name, summarizeHeldPass(settled));
|
package/src/daemon/views.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { availabilityState, type AvailabilityState } from "../availability.ts";
|
|
|
23
23
|
import { configPath, findProject, loadConfig, resolveCaps, resolveReleaseGrants, resolveReview, stateDir } from "../config.ts";
|
|
24
24
|
import { digestScheduleState, type DigestScheduleState } from "../digest-schedule.ts";
|
|
25
25
|
import { SPEND_SAMPLE_ROWS, SPEND_SAMPLE_RUNS } from "../doctor.ts";
|
|
26
|
+
import { EFFECTIVE_BUDGET_SAMPLE_RUNS, observeTurnBudget, type ObservedTurnBudget } from "../failure-class.ts";
|
|
26
27
|
import type { CodeGraphHealth } from "../graph-health.ts";
|
|
27
28
|
import { isPaused, pauseProvenance, setPaused } from "../pause.ts";
|
|
28
29
|
import { branchName, route } from "../routing.ts";
|
|
@@ -189,6 +190,11 @@ export interface StatusSnapshot {
|
|
|
189
190
|
*/
|
|
190
191
|
reporting?: ReportingSummary;
|
|
191
192
|
caps: Caps;
|
|
193
|
+
/** #1063: the turn count the wall-clock ceiling actually buys at this
|
|
194
|
+
* project's observed per-turn latency, with the sample it was derived
|
|
195
|
+
* from. Absent when no qualifying completed run exists — never derived
|
|
196
|
+
* from the configured constants, whose ratio is a constant. */
|
|
197
|
+
workerTurnBudgetObserved?: ObservedTurnBudget;
|
|
192
198
|
/**
|
|
193
199
|
* The effective per-shape release grants. On the snapshot rather than re-read
|
|
194
200
|
* by each renderer because #122 began with a grant nobody had looked at in
|
|
@@ -552,6 +558,17 @@ export function statusSnapshotFromStore(
|
|
|
552
558
|
const observed = store.installSurfaces();
|
|
553
559
|
return observed === undefined ? {} : { installSurfaces: observed };
|
|
554
560
|
})(),
|
|
561
|
+
// #1063, the same read-not-probe discipline as the spend judgement above:
|
|
562
|
+
// derived from completed-run rows on this read, never probed at render
|
|
563
|
+
// time. The renderer decides whether the figure is materially lower.
|
|
564
|
+
...(() => {
|
|
565
|
+
const budget = observeTurnBudget(
|
|
566
|
+
store.recentLatencySamples(p.name, EFFECTIVE_BUDGET_SAMPLE_RUNS),
|
|
567
|
+
caps,
|
|
568
|
+
p.workerModel,
|
|
569
|
+
);
|
|
570
|
+
return budget === undefined ? {} : { workerTurnBudgetObserved: budget };
|
|
571
|
+
})(),
|
|
555
572
|
...(dispatch === undefined ? {} : { dispatch }),
|
|
556
573
|
...(planUsage === undefined ? {} : { planUsage }),
|
|
557
574
|
// Written by the tracker's hooks rather than polled, so the renderer does
|
package/src/daemon.ts
CHANGED
|
@@ -34,7 +34,7 @@ export type {
|
|
|
34
34
|
export { completionLastError, exhaustedSessionReason, verbDeps } from "./daemon/deps.ts";
|
|
35
35
|
|
|
36
36
|
export type { CreateDrainOptions, DrainProblem, DrainRecord, DrainVerdict } from "./daemon/drain.ts";
|
|
37
|
-
export { cancelDrain, consumeDrain, createDrain, drainPath, readDrain } from "./daemon/drain.ts";
|
|
37
|
+
export { cancelDrain, consumeDrain, createDrain, drainPath, markDrained, readDrain } from "./daemon/drain.ts";
|
|
38
38
|
|
|
39
39
|
export type { AdmissionAckRecord } from "./daemon/ack.ts";
|
|
40
40
|
export { admissionAckPath, daemonGeneration, readAdmissionAck, wakeDaemon, writeAdmissionAck } from "./daemon/ack.ts";
|
|
@@ -52,6 +52,7 @@ export {
|
|
|
52
52
|
wakeOrchestratorForBlockedRun,
|
|
53
53
|
wakeOrchestratorForMetConditions,
|
|
54
54
|
watchOrchestrator,
|
|
55
|
+
watchWorkerProgress,
|
|
55
56
|
} from "./daemon/supervision.ts";
|
|
56
57
|
|
|
57
58
|
export { reconcilePanes } from "./daemon/panes.ts";
|
package/src/decisions.ts
CHANGED
|
@@ -45,26 +45,35 @@ const NPM_SPEC = /^(@[^/@\s]+\/)?[^@\s]+@[^\s]+$/;
|
|
|
45
45
|
const GREEN_CHECK_STATES: Record<string, true> = { success: true, neutral: true };
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* The run states a review revision may start from (#795) — the single
|
|
48
|
+
* The run states a review revision may start from (#795, #1101) — the single
|
|
49
49
|
* definition shared by `conductor_pr_review` and the `pr-review-ready` watch,
|
|
50
50
|
* so the verb's gate and the condition can never name different sets.
|
|
51
51
|
*
|
|
52
52
|
* A revision round resumes the exact run whose row owns the PR, so the
|
|
53
|
-
* revisable states are exactly the terminal runs that
|
|
54
|
-
* `pushed-green` row,
|
|
55
|
-
* failed *after* pushing a green PR
|
|
56
|
-
*
|
|
57
|
-
*
|
|
53
|
+
* revisable states are exactly the terminal runs that own the named PR: a
|
|
54
|
+
* settled `pushed-green` row, a `failed` / `killed` row — a run that capped or
|
|
55
|
+
* failed *after* pushing a green PR — and, since #1101, a `stopped` row that
|
|
56
|
+
* pushed one. Ownership is not carried by the state alone: selection runs
|
|
57
|
+
* through `runsForProjectPr`, so only a row that itself recorded the reviewed
|
|
58
|
+
* PR can ever reach this gate. The PR is the durable artefact, the exact-head
|
|
59
|
+
* green verification is the gate on "green at the reviewed SHA", and a
|
|
60
|
+
* terminal row proves no worker is in flight, so findings are returned
|
|
58
61
|
* without the close-PR → unblock → continuation dance.
|
|
59
62
|
*
|
|
63
|
+
* A `stopped` row has no settle sweep keeping it honest (nothing transitions a
|
|
64
|
+
* stopped row when its PR merges or closes), so like `failed` / `killed` its
|
|
65
|
+
* rounds re-read PR-open and green-at-head decisively before the claim — the
|
|
66
|
+
* dispatch pass refuses to wake a worker against a dead or moved PR.
|
|
67
|
+
*
|
|
60
68
|
* Closed on purpose: a live row (`running` / `claimed`) is already doing its
|
|
61
69
|
* own work, a `pushed-pending` PR is not green yet, and a `blocked` /
|
|
62
|
-
* `orphaned` / `
|
|
70
|
+
* `orphaned` / `merged` row is not work returned for revision.
|
|
63
71
|
*/
|
|
64
72
|
export const REVISABLE_RUN_STATES: Record<string, true> = {
|
|
65
73
|
"pushed-green": true,
|
|
66
74
|
failed: true,
|
|
67
75
|
killed: true,
|
|
76
|
+
stopped: true,
|
|
68
77
|
};
|
|
69
78
|
|
|
70
79
|
/**
|
|
@@ -92,8 +101,10 @@ export const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
|
|
|
92
101
|
* `running`, and a watch keyed only to checks woke the orchestrator before
|
|
93
102
|
* `conductor_pr_review` was actionable), `pushed-pending` checks are still
|
|
94
103
|
* settling, `blocked` may resume, `orphaned` is reconciled back to live at
|
|
95
|
-
* startup, and `merged` means the PR lifecycle is over. When every row
|
|
96
|
-
* `stopped`, the newest one answers and
|
|
104
|
+
* startup, and `merged` means the PR lifecycle is over. When every row of the
|
|
105
|
+
* history is `stopped`, the newest one answers — and since #1101 it answers
|
|
106
|
+
* `ready` when it owns the named PR: stopping a worker that had already pushed
|
|
107
|
+
* must leave the PR reviewable rather than stranded.
|
|
97
108
|
*
|
|
98
109
|
* `no-owner` and `not-revisable` both fail closed: a review can never act, so
|
|
99
110
|
* a watch must not wake, even when the checks are green.
|
package/src/diff-flags.ts
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* repository.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
-
import type { FileLane, PrDiff, PrDiffFile, SettlementFlag } from "./types.ts";
|
|
40
|
+
import type { FileLane, PrDiff, PrDiffFile, SettlementFlag, SettlementFlagKind } from "./types.ts";
|
|
41
41
|
|
|
42
42
|
// ------------------------------------------------------------------ diff parse
|
|
43
43
|
|
|
@@ -657,21 +657,24 @@ export const UNREADABLE_TREE_FLAG: SettlementFlag = {
|
|
|
657
657
|
|
|
658
658
|
/**
|
|
659
659
|
* Whether a diff path counts as inside the declared lane: an explicitly
|
|
660
|
-
* declared path,
|
|
661
|
-
*
|
|
662
|
-
* (
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
* is
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
* name
|
|
660
|
+
* declared path, the co-located test of one, or a descendant of a declared
|
|
661
|
+
* directory (#1091) — admission's grammar accepts a trailing-`/` entry
|
|
662
|
+
* ("everything under `src/`"), so settlement honours the same contract rather
|
|
663
|
+
* than narrowing it back to exact files. The co-located allowance is the
|
|
664
|
+
* "obviously intended" case: `foo.ts` vouches for `foo.test.ts`. Other test
|
|
665
|
+
* shapes (`.spec.ts`, pytest's `test_` prefix) are not vouched for: the rule
|
|
666
|
+
* is the shape the fleet actually uses, and a lane that wants a
|
|
667
|
+
* differently-shaped sibling declares it. Deliberately one-directional: a lane
|
|
668
|
+
* that declares a *test* file does not vouch for its source, because declaring
|
|
669
|
+
* the test alone is a narrower promise and widening it silently is exactly
|
|
670
|
+
* what this flag exists to name. The directory rule keeps its trailing-slash
|
|
671
|
+
* boundary explicit: the entry already carries the separator, so `foo/`
|
|
672
|
+
* covers `foo/bar.ts` at any depth and can never vouch for `foobar/x.ts`.
|
|
671
673
|
*/
|
|
672
674
|
function withinLane(path: string, declared: readonly string[]): boolean {
|
|
673
675
|
if (declared.includes(path)) return true;
|
|
674
676
|
for (const d of declared) {
|
|
677
|
+
if (d.endsWith("/") && path.startsWith(d)) return true;
|
|
675
678
|
if (coLocatedTest(d) === path) return true;
|
|
676
679
|
}
|
|
677
680
|
return false;
|
|
@@ -693,11 +696,22 @@ function coLocatedTest(declared: string): string | undefined {
|
|
|
693
696
|
* The lane is the effective declaration admission resolved at dispatch —
|
|
694
697
|
* `effectiveLane(body, comments)`, so a pre-dispatch comment beats an older
|
|
695
698
|
* body declaration — and the flag uses that resolved snapshot, never a re-parse
|
|
696
|
-
* of the body.
|
|
699
|
+
* of the body. Each finding names the delivered files outside it, which is the
|
|
697
700
|
* part a reviewer is worst placed to notice: the diff's own file list is the
|
|
698
701
|
* only surface that shows the escape, and reading PR file lists by hand is
|
|
699
702
|
* exactly what nothing else in the loop does.
|
|
700
703
|
*
|
|
704
|
+
* An escape splits into up to two findings because it is up to two different
|
|
705
|
+
* events (#1096): an undeclared production module is scope escaping into code,
|
|
706
|
+
* while an undeclared test file is usually coverage arriving beside the
|
|
707
|
+
* behaviour it pins — the #1064/#1080 shape, required work the declaration
|
|
708
|
+
* could not have named. Naming the class on each finding keeps the second
|
|
709
|
+
* readable as what it usually is without letting it disguise the first. The
|
|
710
|
+
* declared source's own `.test` twin is admitted by {@link withinLane}, so
|
|
711
|
+
* the obvious case raises nothing — and the rule stays one-directional, so a
|
|
712
|
+
* lane declaring only the test still names its source when the worker edits
|
|
713
|
+
* it.
|
|
714
|
+
*
|
|
701
715
|
* Fail-open, like admission: an issue with no lane declaration has nothing to
|
|
702
716
|
* escape, so no flag — a flag on every undeclared run would be noise within a
|
|
703
717
|
* day, worse than no flag. Advisory like every other flag here: a widened lane
|
|
@@ -710,11 +724,23 @@ function detectLaneEscape(audit: SettlementAudit, flags: SettlementFlag[]): void
|
|
|
710
724
|
.map((f) => f.path)
|
|
711
725
|
.filter((path) => !withinLane(path, lane.files));
|
|
712
726
|
if (outside.length === 0) return;
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
727
|
+
// Production first: the rarer, heavier event leads the report block.
|
|
728
|
+
const production = outside.filter((path) => !isTestPath(path));
|
|
729
|
+
const otherTests = outside.filter((path) => isTestPath(path));
|
|
730
|
+
if (production.length > 0) {
|
|
731
|
+
flags.push({
|
|
732
|
+
kind: "lane-escape",
|
|
733
|
+
file: "(lane)",
|
|
734
|
+
detail: `PR diff touches production files outside the declared file lane: ${production.join(", ")}`,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
if (otherTests.length > 0) {
|
|
738
|
+
flags.push({
|
|
739
|
+
kind: "lane-escape",
|
|
740
|
+
file: "(lane)",
|
|
741
|
+
detail: `PR diff touches unrelated test files outside the declared file lane: ${otherTests.join(", ")}`,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
718
744
|
}
|
|
719
745
|
|
|
720
746
|
function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {
|
|
@@ -872,7 +898,7 @@ function scanHunks(
|
|
|
872
898
|
* an extractor that reads any backtick span in the narrative as a claim would
|
|
873
899
|
* flag every run within a day.
|
|
874
900
|
*
|
|
875
|
-
*
|
|
901
|
+
* Four tolerances make the matcher honest rather than decorative:
|
|
876
902
|
*
|
|
877
903
|
* - Claims are extracted only from a verified section — a `## Verified`-style
|
|
878
904
|
* heading or an inline `Verified:` label — never from the narrative. This is
|
|
@@ -889,6 +915,14 @@ function scanHunks(
|
|
|
889
915
|
* the worker *tried* and the guard stopped it (the honest #566/#570 shape,
|
|
890
916
|
* whose own words are "CI owns them"), which is not the same finding as a
|
|
891
917
|
* claim with no attempt at all.
|
|
918
|
+
* - Claims are positions before they are texts (#1102): a unit of the region
|
|
919
|
+
* claims what it opens with — a bullet or bare line whose lead is the
|
|
920
|
+
* backticked command, or a `**Verified:**` label declaring its whole row —
|
|
921
|
+
* never a span quoted mid-sentence. The soft-wrapped prose that quoted
|
|
922
|
+
* `issue is closed` inside #1097's Verified bullet is commentary whatever
|
|
923
|
+
* words it quotes, and the structural rule says so without a runner
|
|
924
|
+
* vocabulary that would silently stop auditing the first PR proved by
|
|
925
|
+
* anything else.
|
|
892
926
|
*/
|
|
893
927
|
|
|
894
928
|
/** The verified-section markers a claim must live under: a heading whose text
|
|
@@ -949,20 +983,96 @@ function claimedCommand(span: string): boolean {
|
|
|
949
983
|
return EXECUTABLE_TOKEN.test(words[0] ?? "");
|
|
950
984
|
}
|
|
951
985
|
|
|
986
|
+
/** A markdown list marker opening a line: `- item`, `* item`, `1. item`,
|
|
987
|
+
* `2) item`. The required trailing whitespace is what keeps a bold
|
|
988
|
+
* `**Verified:**` row or an italic `*span*` from reading as a bullet. */
|
|
989
|
+
const LIST_MARKER = /^[ \t]*(?:[-*+]|\d+[.)])[ \t]+/;
|
|
990
|
+
|
|
991
|
+
/** The inline verified label a claims row can open with — `**Verified:**`,
|
|
992
|
+
* `Verification:`, `proof.` — mirroring the label branch of
|
|
993
|
+
* {@link VERIFIED_MARKER}, so the very row that opens the region also parses
|
|
994
|
+
* as claims-bearing. */
|
|
995
|
+
const CLAIMS_ROW_LABEL = /^\s*(?:\*\*)?(?:Verif(?:ied|ication)|Proof)(?:\*\*)?\s*[:.]\s*/i;
|
|
996
|
+
|
|
997
|
+
/** Every backticked span in `text`, trimmed. */
|
|
998
|
+
function backtickSpans(text: string): string[] {
|
|
999
|
+
return [...text.matchAll(/`([^`\n]+)`/g)].map((match) => (match[1] ?? "").trim());
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** The one backticked span `text` opens with, when it opens with one. */
|
|
1003
|
+
function leadingBacktickSpan(text: string): string | undefined {
|
|
1004
|
+
const match = /^`([^`\n]+)`/.exec(text);
|
|
1005
|
+
const span = (match?.[1] ?? "").trim();
|
|
1006
|
+
return span === "" ? undefined : span;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* The logical line-units of the verified region. A heading or a list marker
|
|
1011
|
+
* always opens a unit, a blank line ends one, and every other non-blank line
|
|
1012
|
+
* is the markdown lazy continuation of the unit above it. That continuation
|
|
1013
|
+
* rule is the foundation of the #1102 fix: a bullet soft-wrapped across
|
|
1014
|
+
* physical lines stays ONE unit, so its wrapped second line — exactly where
|
|
1015
|
+
* #1097's quoted log line sat — can never pose as a fresh claim-bearing line.
|
|
1016
|
+
* The cost is deliberate and stated: bare claim lines stacked without markers
|
|
1017
|
+
* or blank lines merge into one unit, and only their first extracts. This
|
|
1018
|
+
* fleet writes claims as bullets, so the lost shape is the rarer one.
|
|
1019
|
+
*/
|
|
1020
|
+
function* claimUnits(region: string): Generator<string[]> {
|
|
1021
|
+
let unit: string[] = [];
|
|
1022
|
+
for (const line of region.split("\n")) {
|
|
1023
|
+
if (/^#{1,6}\s/.test(line) || LIST_MARKER.test(line)) {
|
|
1024
|
+
if (unit.length > 0) yield unit;
|
|
1025
|
+
unit = [line];
|
|
1026
|
+
} else if (line.trim() === "") {
|
|
1027
|
+
if (unit.length > 0) yield unit;
|
|
1028
|
+
unit = [];
|
|
1029
|
+
} else {
|
|
1030
|
+
unit.push(line);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (unit.length > 0) yield unit;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* The claim candidates one unit makes — a matter of position, not vocabulary
|
|
1038
|
+
* (#1102). A unit claims what it OPENS with:
|
|
1039
|
+
*
|
|
1040
|
+
* - a verified-label row (`**Verified:** …`, the #749/#731 semicolon-joined
|
|
1041
|
+
* shape) declares every span it carries a claim — the label is what marks
|
|
1042
|
+
* the whole row as proof;
|
|
1043
|
+
* - any other unit contributes only its leading span — `- \`cd omp && bun test …\`
|
|
1044
|
+
* — 42 pass, 0 fail.` or a bare `\`./scripts/deploy.sh\` — deployed.` line —
|
|
1045
|
+
* whose tail is commentary about the result.
|
|
1046
|
+
*
|
|
1047
|
+
* A span embedded after prose is neither: `…assert exactly one log line naming
|
|
1048
|
+
* \`issue is closed\`` quotes the transcript's own output, and quotation is
|
|
1049
|
+
* commentary whatever the quoted words spell.
|
|
1050
|
+
*/
|
|
1051
|
+
function unitClaimSpans(unit: string[]): string[] {
|
|
1052
|
+
const lead = (unit[0] ?? "").replace(LIST_MARKER, "");
|
|
1053
|
+
if (CLAIMS_ROW_LABEL.test(lead)) {
|
|
1054
|
+
return backtickSpans([lead.replace(CLAIMS_ROW_LABEL, ""), ...unit.slice(1)].join("\n"));
|
|
1055
|
+
}
|
|
1056
|
+
const span = leadingBacktickSpan(lead);
|
|
1057
|
+
return span === undefined ? [] : [span];
|
|
1058
|
+
}
|
|
1059
|
+
|
|
952
1060
|
/**
|
|
953
1061
|
* The commands a PR body claims as proof, each deduplicated by its exact span.
|
|
954
|
-
*
|
|
955
|
-
*
|
|
1062
|
+
* Extraction is structural twice over: only spans inside the verified region
|
|
1063
|
+
* count ({@link claimRegion}), and only spans in a claim-bearing position
|
|
1064
|
+
* within it ({@link unitClaimSpans}) — the two filters together are what keep
|
|
1065
|
+
* `## Verified`-island prose out of the audit.
|
|
956
1066
|
*/
|
|
957
1067
|
function claimedProofCommands(body: string): string[] {
|
|
958
|
-
const region = claimRegion(body);
|
|
959
1068
|
const claims: string[] = [];
|
|
960
1069
|
const seen = new Set<string>();
|
|
961
|
-
for (const
|
|
962
|
-
const span
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1070
|
+
for (const unit of claimUnits(claimRegion(body))) {
|
|
1071
|
+
for (const span of unitClaimSpans(unit)) {
|
|
1072
|
+
if (seen.has(span) || !claimedCommand(span)) continue;
|
|
1073
|
+
seen.add(span);
|
|
1074
|
+
claims.push(span);
|
|
1075
|
+
}
|
|
966
1076
|
}
|
|
967
1077
|
return claims;
|
|
968
1078
|
}
|
|
@@ -1229,6 +1339,21 @@ const RENDERED_FLAGS = 20;
|
|
|
1229
1339
|
|
|
1230
1340
|
const HEADING = "settlement audit";
|
|
1231
1341
|
|
|
1342
|
+
/**
|
|
1343
|
+
* The kinds that mean coverage got weaker — the quiet signals the audit
|
|
1344
|
+
* exists to deliver, and the family mining.ts mines. Presentation keeps them
|
|
1345
|
+
* ahead of everything else and lane escapes last (#1096): a check that fires
|
|
1346
|
+
* on half of all merges gets skimmed, and a skimmed finding must not sit
|
|
1347
|
+
* between the reviewer and these. Kept in step with `WEAKENING_KINDS` in
|
|
1348
|
+
* mining.ts, which is the same four kinds spelled for mining.
|
|
1349
|
+
*/
|
|
1350
|
+
const TEST_INTEGRITY_KINDS: Partial<Record<SettlementFlagKind, true>> = {
|
|
1351
|
+
"test-file-deleted": true,
|
|
1352
|
+
"test-disabled": true,
|
|
1353
|
+
"assertions-removed": true,
|
|
1354
|
+
"test-timeout-raised": true,
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1232
1357
|
/**
|
|
1233
1358
|
* The flag block appended to a settlement report, or no lines at all.
|
|
1234
1359
|
*
|
|
@@ -1253,7 +1378,17 @@ export function formatSettlementFlags(
|
|
|
1253
1378
|
`${HEADING}: ${flags.length} advisory flag(s) — the run's state is unchanged by them` +
|
|
1254
1379
|
(diff.truncated ? ", and the PR diff was too large to read in full" : ""),
|
|
1255
1380
|
];
|
|
1256
|
-
|
|
1381
|
+
// Presentation order is structural, not incidental: test-integrity flags
|
|
1382
|
+
// first, then everything else, lane escapes last (#1096). The analyser
|
|
1383
|
+
// emits them in roughly this order already, but this surface also renders
|
|
1384
|
+
// stored rows and post-settlement appends, whose order nobody chose.
|
|
1385
|
+
const rank = (flag: SettlementFlag): number =>
|
|
1386
|
+
TEST_INTEGRITY_KINDS[flag.kind] === true ? 0 : flag.kind === "lane-escape" ? 2 : 1;
|
|
1387
|
+
const ordered = flags
|
|
1388
|
+
.map((flag, index) => ({ flag, index }))
|
|
1389
|
+
.sort((a, b) => rank(a.flag) - rank(b.flag) || a.index - b.index)
|
|
1390
|
+
.map((entry) => entry.flag);
|
|
1391
|
+
for (const flag of ordered.slice(0, RENDERED_FLAGS)) {
|
|
1257
1392
|
if (flag.kind === "pr-adopted") {
|
|
1258
1393
|
lines.push(` ${flag.kind} — ${flag.detail}`);
|
|
1259
1394
|
continue;
|
|
@@ -1270,10 +1405,30 @@ export function formatSettlementFlags(
|
|
|
1270
1405
|
}
|
|
1271
1406
|
|
|
1272
1407
|
/** The one-line form for `omp-conductor status`, where a flagged run has to be
|
|
1273
|
-
* visible long after its escalation was delivered and deduplicated.
|
|
1408
|
+
* visible long after its escalation was delivered and deduplicated. Lane
|
|
1409
|
+
* escapes are presented apart from the test-integrity family (#1096), so the
|
|
1410
|
+
* noisy check cannot bury the quiet one in one comma join. */
|
|
1274
1411
|
export function settlementFlagSummary(flags: readonly SettlementFlag[] | undefined): string | undefined {
|
|
1275
1412
|
if (flags === undefined || flags.length === 0) return undefined;
|
|
1276
|
-
const
|
|
1413
|
+
const integrity = new Set<SettlementFlagKind>();
|
|
1414
|
+
const lane = new Set<SettlementFlagKind>();
|
|
1415
|
+
const rest = new Set<SettlementFlagKind>();
|
|
1416
|
+
for (const { kind } of flags) {
|
|
1417
|
+
if (TEST_INTEGRITY_KINDS[kind] === true) integrity.add(kind);
|
|
1418
|
+
else if (kind === "lane-escape") lane.add(kind);
|
|
1419
|
+
else rest.add(kind);
|
|
1420
|
+
}
|
|
1421
|
+
const segments: { label?: string; kinds: SettlementFlagKind[] }[] = [];
|
|
1422
|
+
if (integrity.size > 0) segments.push({ label: "test-integrity", kinds: [...integrity] });
|
|
1423
|
+
if (rest.size > 0) segments.push({ kinds: [...rest] });
|
|
1424
|
+
if (lane.size > 0) segments.push({ label: "lane", kinds: [...lane] });
|
|
1425
|
+
// One family alone reads exactly as before — a label with nothing to
|
|
1426
|
+
// separate is furniture.
|
|
1427
|
+
const parts = segments.map((segment) =>
|
|
1428
|
+
segments.length > 1 && segment.label !== undefined
|
|
1429
|
+
? `${segment.label}: ${segment.kinds.join(", ")}`
|
|
1430
|
+
: segment.kinds.join(", "),
|
|
1431
|
+
);
|
|
1277
1432
|
const loud = flags.some((f) => f.unattributed === true) ? ", some unattributed" : "";
|
|
1278
|
-
return `${HEADING}: ${flags.length} flag(s) — ${
|
|
1433
|
+
return `${HEADING}: ${flags.length} flag(s) — ${parts.join("; ")}${loud}`;
|
|
1279
1434
|
}
|