omp-conductor 0.17.1 → 0.18.0
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/REFERENCE.md +11 -7
- package/package.json +1 -1
- package/schema/config.schema.json +24 -1
- package/src/admission.ts +104 -1
- package/src/ask.ts +39 -3
- package/src/backups.ts +2 -2
- package/src/briefs/orchestrator.md +1 -0
- package/src/briefs/worker.md +36 -19
- package/src/command-help.ts +8 -1
- package/src/command-manifest.ts +5 -2
- package/src/commands/watch.ts +62 -3
- package/src/config-schema.ts +33 -0
- package/src/config.ts +60 -1
- package/src/daemon.ts +253 -1479
- package/src/decisions.ts +51 -6
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +350 -0
- package/src/digest-schedule.ts +37 -0
- package/src/failure-class.ts +15 -2
- package/src/fleet.ts +20 -2
- package/src/graph-health.ts +20 -7
- package/src/graph.ts +313 -68
- package/src/lifecycle.ts +36 -5
- package/src/omp.ts +42 -0
- package/src/orchestrator-tick.ts +104 -12
- package/src/routing.ts +11 -3
- package/src/session-host.ts +16 -0
- package/src/settlement.ts +1728 -0
- package/src/setup-install.ts +91 -30
- package/src/setup-wizard.ts +75 -0
- package/src/setup.ts +93 -3
- package/src/status-render.ts +31 -9
- package/src/store.ts +78 -5
- package/src/tracker/github.ts +46 -0
- package/src/types.ts +127 -3
- package/src/verbs/server.ts +58 -2
- package/src/worker.ts +214 -7
- package/src/worktree.ts +115 -8
package/src/setup-install.ts
CHANGED
|
@@ -30,12 +30,17 @@ import {
|
|
|
30
30
|
type UpgradeScope,
|
|
31
31
|
} from "./upgrade.ts";
|
|
32
32
|
import {
|
|
33
|
+
graphConflictMessage,
|
|
33
34
|
graphRepos,
|
|
34
35
|
formatGraphSetup,
|
|
36
|
+
legacyReindexFiles,
|
|
37
|
+
legacyReindexNote,
|
|
35
38
|
mcpEntry,
|
|
39
|
+
planGraphSetup,
|
|
36
40
|
reindexScriptPath,
|
|
41
|
+
reindexUnitName,
|
|
37
42
|
resolvePrereqs,
|
|
38
|
-
|
|
43
|
+
stagingConflict,
|
|
39
44
|
unitPaths,
|
|
40
45
|
writeGraphSetup,
|
|
41
46
|
type GraphPrereqs,
|
|
@@ -131,16 +136,18 @@ function linuxOnly(deps: InstallDeps): string | undefined {
|
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
/**
|
|
134
|
-
* Whether
|
|
135
|
-
* `actionFor`: a unit systemd already reads that matches what
|
|
136
|
-
* needs no reinstall. Reads can fail (permissions, vanished
|
|
137
|
-
* read error is a mismatch, never silently "matches", because
|
|
138
|
-
* is to make the plan only claim a unit install is redundant
|
|
139
|
-
* the installed copy.
|
|
139
|
+
* Whether a file holds exactly the rendered bytes. The "keep" side of
|
|
140
|
+
* setup-host.ts's `actionFor`: a unit systemd already reads that matches what
|
|
141
|
+
* would be written needs no reinstall. Reads can fail (permissions, vanished
|
|
142
|
+
* mid-flight) — a read error is a mismatch, never silently "matches", because
|
|
143
|
+
* the whole point is to make the plan only claim a unit install is redundant
|
|
144
|
+
* when it can see the installed copy. Comparing against the rendered content
|
|
145
|
+
* rather than the staged file keeps the comparison read-only: staging now
|
|
146
|
+
* happens only after consent (#720), so nothing may be written to compare.
|
|
140
147
|
*/
|
|
141
|
-
function
|
|
148
|
+
function sameContent(path: string, content: string): boolean {
|
|
142
149
|
try {
|
|
143
|
-
return readFileSync(
|
|
150
|
+
return readFileSync(path, "utf8") === content;
|
|
144
151
|
} catch {
|
|
145
152
|
return false;
|
|
146
153
|
}
|
|
@@ -450,9 +457,10 @@ export interface GraphInstallOptions extends InstallDeps {
|
|
|
450
457
|
* Staging and enabling alone installs a service that fails on every run: the
|
|
451
458
|
* generated script runs under `set -euo pipefail` and `cd "<graphProject>"` as
|
|
452
459
|
* its first act per repo, so a missing clone is a `cd` failure at 03:00 rather
|
|
453
|
-
* than a graph. That is why
|
|
454
|
-
*
|
|
455
|
-
* seed and verify — one preview, one confirm
|
|
460
|
+
* than a graph. That is why staging was never allowed to call the install a
|
|
461
|
+
* finished job. So: prerequisites, then a conflict check, then clones, then
|
|
462
|
+
* install, then seed and verify — one preview, one confirm, and nothing
|
|
463
|
+
* staged before the confirm (#720).
|
|
456
464
|
*/
|
|
457
465
|
export async function runGraphInstall(
|
|
458
466
|
project: ProjectConfig,
|
|
@@ -494,7 +502,38 @@ export async function runGraphInstall(
|
|
|
494
502
|
return { kind: "refused", reason: missing };
|
|
495
503
|
}
|
|
496
504
|
|
|
497
|
-
const
|
|
505
|
+
const unitDir = options.unitDir ?? SYSTEMD_UNIT_DIR;
|
|
506
|
+
// The plan is computed eagerly — `planGraphSetup` renders every byte, so the
|
|
507
|
+
// prompt can print exactly what would be written and the unitsCurrent gate
|
|
508
|
+
// below can decide a re-run installs nothing. But rendering is read-only: no
|
|
509
|
+
// file is written here. Staging happens only after consent (#720), so the
|
|
510
|
+
// prompt's "stages once you confirm" is true of the staged tree when it is
|
|
511
|
+
// printed, and a declined run leaves every staged file byte-identical.
|
|
512
|
+
const plan = planGraphSetup(project, unitDir);
|
|
513
|
+
// Never overwrite another project's staged artefact: the stems derive from
|
|
514
|
+
// the project name, and two names can fold to one stem ("My Project" vs
|
|
515
|
+
// "my_project"), so an existing file's generated-for marker is checked
|
|
516
|
+
// before the consent prompt — a collision is refused before anything is
|
|
517
|
+
// asked, let alone written (#720).
|
|
518
|
+
const conflict = stagingConflict(project, [
|
|
519
|
+
plan.script.path,
|
|
520
|
+
plan.service.path,
|
|
521
|
+
plan.timer.path,
|
|
522
|
+
...Object.values(unitPaths(project, unitDir)),
|
|
523
|
+
]);
|
|
524
|
+
if (conflict !== undefined) {
|
|
525
|
+
const message = graphConflictMessage(conflict);
|
|
526
|
+
ui.notify(message, "error");
|
|
527
|
+
return { kind: "refused", reason: message };
|
|
528
|
+
}
|
|
529
|
+
// A host that ran the pre-#720 project-less version keeps its old files:
|
|
530
|
+
// their timer refreshes whichever project generated it last, forever. Name
|
|
531
|
+
// the remediation before the consent prompt — deleting files outside this
|
|
532
|
+
// command's own names is exactly how the overwrite happened in the first
|
|
533
|
+
// place, so it is host action, not install action.
|
|
534
|
+
const legacy = legacyReindexFiles();
|
|
535
|
+
if (legacy.length > 0) ui.notify(legacyReindexNote(legacy), "warning");
|
|
536
|
+
|
|
498
537
|
const absent = repos.filter((r) => !existsSync(r.graphProject));
|
|
499
538
|
|
|
500
539
|
// 2. Missing clones, as the operator. A root-owned index-only clone under the
|
|
@@ -507,17 +546,18 @@ export async function runGraphInstall(
|
|
|
507
546
|
}));
|
|
508
547
|
|
|
509
548
|
const blocked = linuxOnly(options);
|
|
510
|
-
const
|
|
511
|
-
const
|
|
512
|
-
// The units systemd already reads, compared to
|
|
549
|
+
const stem = reindexUnitName(project);
|
|
550
|
+
const { service, timer } = unitPaths(project, unitDir);
|
|
551
|
+
// The units systemd already reads, compared to the rendered content — the
|
|
513
552
|
// same distinction setup-host.ts draws between `installedAction` and
|
|
514
553
|
// `service.action` (read the installed file, compare to the fresh content,
|
|
515
554
|
// "keep" vs "update"). "Installed" is not enough: a green timer serving a
|
|
516
555
|
// stale unit is the older bug, so when they differ the install runs. Only
|
|
517
556
|
// when both units already match does the plan omit them — then just the
|
|
518
|
-
// outstanding clone and seed remain.
|
|
557
|
+
// outstanding clone and seed remain. Nothing is written to compare: staging
|
|
558
|
+
// is post-consent now, so the comparison is against the rendered bytes.
|
|
519
559
|
const unitsCurrent =
|
|
520
|
-
blocked === undefined &&
|
|
560
|
+
blocked === undefined && sameContent(service, plan.service.content) && sameContent(timer, plan.timer.content);
|
|
521
561
|
// 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
|
|
522
562
|
// one preview and one confirm, and a second confirm here also invented a
|
|
523
563
|
// third outcome — a declined seed — that neither the caller nor the
|
|
@@ -525,32 +565,52 @@ export async function runGraphInstall(
|
|
|
525
565
|
const seed: PrivilegedStep[] =
|
|
526
566
|
options.noSeed === true
|
|
527
567
|
? []
|
|
528
|
-
: [{ title: `seed the indexes (runs ${
|
|
568
|
+
: [{ title: `seed the indexes (runs ${stem}.service once, minutes per repo)`, argv: ["systemctl", "start", `${stem}.service`] }];
|
|
529
569
|
const install: PrivilegedStep[] =
|
|
530
570
|
blocked === undefined
|
|
531
571
|
? [
|
|
532
572
|
...(unitsCurrent
|
|
533
573
|
? []
|
|
534
574
|
: [
|
|
535
|
-
{ title: "install the reindex unit and timer", argv: ["install", "-m", "0644",
|
|
575
|
+
{ title: "install the reindex unit and timer", argv: ["install", "-m", "0644", plan.service.path, plan.timer.path, join(unitDir, "")] },
|
|
536
576
|
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
537
|
-
{ title: `enable ${
|
|
577
|
+
{ title: `enable ${stem}.timer`, argv: ["systemctl", "enable", "--now", `${stem}.timer`] },
|
|
538
578
|
]),
|
|
539
579
|
...seed,
|
|
540
580
|
]
|
|
541
581
|
: [];
|
|
542
582
|
|
|
543
583
|
if (blocked !== undefined) {
|
|
584
|
+
// A non-Linux host cannot run systemd, so staging IS the deliverable: the
|
|
585
|
+
// rendered files are written for the operator to copy to the box that will
|
|
586
|
+
// run them, and only the systemctl half is refused. There is no consent
|
|
587
|
+
// prompt on this path — nothing privileged to approve — so the write lands
|
|
588
|
+
// here, not in the (never reached) post-consent hook (#720, as #510).
|
|
589
|
+
const staged = writeGraphSetup(project, unitDir);
|
|
544
590
|
ui.notify(`${blocked} ${service} and ${timer}`, "warning");
|
|
545
591
|
return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
|
|
546
592
|
}
|
|
547
593
|
|
|
594
|
+
// What the post-consent staging actually wrote, surfaced by the outcomes
|
|
595
|
+
// that report it. Empty on a decline — the hook never ran.
|
|
596
|
+
let wrote: readonly string[] = [];
|
|
597
|
+
const beforeRun = async (): Promise<void> => {
|
|
598
|
+
// Consent has been given; this project's files may now land. Staging here —
|
|
599
|
+
// post-confirm, pre-step, under the same confirm that authorised the batch
|
|
600
|
+
// — is what makes the prompt's plan true of the staged tree when it is
|
|
601
|
+
// printed: a declined prompt never reaches this hook, so nothing is staged
|
|
602
|
+
// and a re-run still plans an install (#720). `writeGraphSetup`'s ownership
|
|
603
|
+
// backstop throws here with nothing written if a collision raced the
|
|
604
|
+
// pre-consent check.
|
|
605
|
+
wrote = writeGraphSetup(project, unitDir).written;
|
|
606
|
+
};
|
|
607
|
+
|
|
548
608
|
const outcome = await runPrivileged([...clones, ...install], ui, {
|
|
549
609
|
...(options.privileged === undefined ? {} : { deps: options.privileged }),
|
|
550
610
|
title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
|
|
551
611
|
answerKey: `install-code-graph.${project.name}`,
|
|
552
612
|
preamble: [
|
|
553
|
-
`
|
|
613
|
+
`Stages ${[plan.script.path, plan.service.path, plan.timer.path].join(", ")} once you confirm — nothing is written before that.`,
|
|
554
614
|
...(clones.length === 0
|
|
555
615
|
? ["Every indexed clone already exists."]
|
|
556
616
|
: [
|
|
@@ -561,8 +621,9 @@ export async function runGraphInstall(
|
|
|
561
621
|
...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []),
|
|
562
622
|
`Indexer: ${prereqs.indexer ?? "on PATH"}.`,
|
|
563
623
|
],
|
|
624
|
+
beforeRun,
|
|
564
625
|
});
|
|
565
|
-
if (outcome.kind === "declined") return { kind: "declined", wrote
|
|
626
|
+
if (outcome.kind === "declined") return { kind: "declined", wrote };
|
|
566
627
|
if (outcome.kind === "failed") {
|
|
567
628
|
return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
|
|
568
629
|
}
|
|
@@ -571,10 +632,10 @@ export async function runGraphInstall(
|
|
|
571
632
|
// answer: an unseeded graph is not usable until the timer first fires.
|
|
572
633
|
if (options.noSeed === true) {
|
|
573
634
|
ui.notify(
|
|
574
|
-
`Timer enabled; skipped the seeding run. The graph is NOT usable until ${
|
|
635
|
+
`Timer enabled; skipped the seeding run. The graph is NOT usable until ${stem}.timer first fires.`,
|
|
575
636
|
"warning",
|
|
576
637
|
);
|
|
577
|
-
return { kind: "installed", wrote
|
|
638
|
+
return { kind: "installed", wrote };
|
|
578
639
|
}
|
|
579
640
|
|
|
580
641
|
const health = await (options.probe ?? probeCodeGraph)(project);
|
|
@@ -583,13 +644,13 @@ export async function runGraphInstall(
|
|
|
583
644
|
// Staged but not trusted. Reporting success here is how an operator learns
|
|
584
645
|
// months later that no worker ever read an index.
|
|
585
646
|
ui.notify(
|
|
586
|
-
[`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "",
|
|
647
|
+
[`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", plan.next].join("\n"),
|
|
587
648
|
"error",
|
|
588
649
|
);
|
|
589
650
|
return { kind: "failed", reason: `unverified: ${unhealthy.join(", ")}` };
|
|
590
651
|
}
|
|
591
652
|
ui.notify(`Code graph installed and verified for ${repos.map((r) => r.name).join(", ")}.`, "info");
|
|
592
|
-
return { kind: "installed", wrote
|
|
653
|
+
return { kind: "installed", wrote };
|
|
593
654
|
}
|
|
594
655
|
|
|
595
656
|
/** The two prerequisites that make an enabled timer meaningful, or `undefined`. */
|
|
@@ -618,7 +679,7 @@ export function graphInstallable(project: ProjectConfig): GraphRepo[] {
|
|
|
618
679
|
return graphRepos(project);
|
|
619
680
|
}
|
|
620
681
|
|
|
621
|
-
/** Where
|
|
622
|
-
export function reindexScriptLocation(): string {
|
|
623
|
-
return reindexScriptPath();
|
|
682
|
+
/** Where this project's reindex script lands, for the wizard's tail to name. */
|
|
683
|
+
export function reindexScriptLocation(project: ProjectConfig): string {
|
|
684
|
+
return reindexScriptPath(project);
|
|
624
685
|
}
|
package/src/setup-wizard.ts
CHANGED
|
@@ -93,6 +93,7 @@ import {
|
|
|
93
93
|
POLICY_BRIEF_NAME,
|
|
94
94
|
RELEASE_REQUIREMENT_CHOICES,
|
|
95
95
|
REPORT_SCOPE_CHOICES,
|
|
96
|
+
REVIEW_STRICTNESS_CHOICES,
|
|
96
97
|
SETUP_DEFAULTS,
|
|
97
98
|
amendChoices,
|
|
98
99
|
answersFromProject,
|
|
@@ -125,11 +126,16 @@ import {
|
|
|
125
126
|
BASE_FRESHNESS,
|
|
126
127
|
BEHIND_BASE_ACTIONS,
|
|
127
128
|
DEFAULT_CAPS,
|
|
129
|
+
DEFAULT_REVIEW_MAX_ROUNDS,
|
|
130
|
+
DEFAULT_REVIEW_STRICTNESS,
|
|
128
131
|
DENIED_RELEASE_GRANTS,
|
|
129
132
|
DRAFT_POLICIES,
|
|
130
133
|
RELEASE_REQUIREMENTS,
|
|
131
134
|
INTERRUPT_CATEGORIES,
|
|
132
135
|
RELEASE_SHAPES,
|
|
136
|
+
REVIEW_MAX_ROUNDS_MAX,
|
|
137
|
+
REVIEW_MAX_ROUNDS_MIN,
|
|
138
|
+
REVIEW_STRICTNESS,
|
|
133
139
|
WEEKDAYS,
|
|
134
140
|
type AuthorityHolder,
|
|
135
141
|
type Caps,
|
|
@@ -142,6 +148,7 @@ import {
|
|
|
142
148
|
type ReleaseRequirement,
|
|
143
149
|
type ReportScopeChoice,
|
|
144
150
|
type ResolvedGrants,
|
|
151
|
+
type ReviewPolicy,
|
|
145
152
|
} from "./types.ts";
|
|
146
153
|
import { withProgress } from "./ui/progress.ts";
|
|
147
154
|
import type { WizardUi } from "./wizard-ui.ts";
|
|
@@ -1379,6 +1386,73 @@ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
|
|
|
1379
1386
|
* and `claim-only` means anything that can invoke it can start dispatch once
|
|
1380
1387
|
* the live claim and poller pass.
|
|
1381
1388
|
*/
|
|
1389
|
+
/**
|
|
1390
|
+
* How many review rounds one PR lifecycle may be returned at most — the hard
|
|
1391
|
+
* bound against endless polishing (#678). Validated in the dialog against the
|
|
1392
|
+
* same integer range the loader validates against, so an out-of-range answer
|
|
1393
|
+
* is re-asked rather than written for the daemon to reject. The consequence of
|
|
1394
|
+
* the ceiling is stated in the title: at it, the PR is left open and escalated
|
|
1395
|
+
* once, never returned again.
|
|
1396
|
+
*/
|
|
1397
|
+
async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
|
|
1398
|
+
// Bounded like askValid above: a dialog that cannot be escaped is worse than
|
|
1399
|
+
// one that gives up and leaves the config alone. The `--answers` path
|
|
1400
|
+
// matters here specifically — answersUi is a map, not a consumer, so every
|
|
1401
|
+
// retry reads the same value and an out-of-range file used to recurse
|
|
1402
|
+
// forever instead of failing closed (#678).
|
|
1403
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1404
|
+
const raw = await ask(
|
|
1405
|
+
ui,
|
|
1406
|
+
"review-max-rounds",
|
|
1407
|
+
`Review rounds per PR lifecycle — an integer ${REVIEW_MAX_ROUNDS_MIN} to ${REVIEW_MAX_ROUNDS_MAX}; ` +
|
|
1408
|
+
`at the ceiling the PR is left open and escalated once`,
|
|
1409
|
+
String(current),
|
|
1410
|
+
);
|
|
1411
|
+
const value = Number(raw);
|
|
1412
|
+
if (Number.isInteger(value) && value >= REVIEW_MAX_ROUNDS_MIN && value <= REVIEW_MAX_ROUNDS_MAX) {
|
|
1413
|
+
return value;
|
|
1414
|
+
}
|
|
1415
|
+
ui.notify(
|
|
1416
|
+
`Review rounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX} ` +
|
|
1417
|
+
`— shown the current value again.`,
|
|
1418
|
+
"warning",
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
throw new Cancelled();
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* How green PRs are reviewed (#678), asked with the merge preconditions and
|
|
1426
|
+
* the arming proof: they are the same kind of declared policy — a typed,
|
|
1427
|
+
* mechanical boundary on what may happen to a PR — so they ride the same area.
|
|
1428
|
+
*
|
|
1429
|
+
* The three levels and their bars are shown before the select, with the
|
|
1430
|
+
* recommended level marked, so an operator chooses between named thresholds
|
|
1431
|
+
* rather than between three words. The select cursor opens on the current
|
|
1432
|
+
* answer (the configured level on a re-run, the recommended default on a
|
|
1433
|
+
* first run); the rounds are a validated integer within the same range the
|
|
1434
|
+
* loader enforces.
|
|
1435
|
+
*/
|
|
1436
|
+
async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPolicy> {
|
|
1437
|
+
ui.notify(
|
|
1438
|
+
REVIEW_STRICTNESS.map((level) => `${level}${level === DEFAULT_REVIEW_STRICTNESS ? " (recommended)" : ""} — ${REVIEW_STRICTNESS_CHOICES[level]}`).join(
|
|
1439
|
+
"\n",
|
|
1440
|
+
),
|
|
1441
|
+
"info",
|
|
1442
|
+
);
|
|
1443
|
+
return {
|
|
1444
|
+
strictness: await askLiteral(
|
|
1445
|
+
ui,
|
|
1446
|
+
"review-strictness",
|
|
1447
|
+
"Review strictness for green PRs",
|
|
1448
|
+
REVIEW_STRICTNESS,
|
|
1449
|
+
REVIEW_STRICTNESS_CHOICES,
|
|
1450
|
+
a.review.strictness,
|
|
1451
|
+
),
|
|
1452
|
+
maxRounds: await askReviewRounds(ui, a.review.maxRounds),
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1382
1456
|
const askPolicy: AreaAsker = async (ui, a) => ({
|
|
1383
1457
|
...a,
|
|
1384
1458
|
policy: await askPolicyPreconditions(ui, a.policy, a.authority.release),
|
|
@@ -1390,6 +1464,7 @@ const askPolicy: AreaAsker = async (ui, a) => ({
|
|
|
1390
1464
|
ARM_PROOF_CHOICES,
|
|
1391
1465
|
a.armProof,
|
|
1392
1466
|
),
|
|
1467
|
+
review: await askReviewPolicy(ui, a),
|
|
1393
1468
|
});
|
|
1394
1469
|
|
|
1395
1470
|
/** How a stuck run reaches a human, and who triages it when it does. */
|
package/src/setup.ts
CHANGED
|
@@ -39,12 +39,14 @@ import {
|
|
|
39
39
|
import {
|
|
40
40
|
clonePolicy,
|
|
41
41
|
configPath,
|
|
42
|
+
DEFAULT_STATE_LABELS,
|
|
42
43
|
defaultMirrorRoot,
|
|
43
44
|
defaultWorkspaceRoot,
|
|
44
45
|
resolveArmProof,
|
|
45
46
|
resolveCaps,
|
|
46
47
|
resolvePolicy,
|
|
47
48
|
resolveReleaseGrants,
|
|
49
|
+
resolveReview,
|
|
48
50
|
SCOPE_PRESETS,
|
|
49
51
|
stateDir,
|
|
50
52
|
} from "./config.ts";
|
|
@@ -57,8 +59,10 @@ import {
|
|
|
57
59
|
DEFAULT_CAPS,
|
|
58
60
|
DEFAULT_PROJECT_POLICY,
|
|
59
61
|
DEFAULT_REPORT_SCOPE,
|
|
62
|
+
DEFAULT_REVIEW_POLICY,
|
|
60
63
|
DENIED_RELEASE_GRANTS,
|
|
61
64
|
RELEASE_SHAPES,
|
|
65
|
+
REVIEW_STRICTNESS,
|
|
62
66
|
WEEKDAYS,
|
|
63
67
|
type ArmProof,
|
|
64
68
|
type BaseFreshness,
|
|
@@ -75,6 +79,8 @@ import {
|
|
|
75
79
|
type ReleaseRequirement,
|
|
76
80
|
type ReportScopeChoice,
|
|
77
81
|
type ReportingPolicy,
|
|
82
|
+
type ReviewPolicy,
|
|
83
|
+
type ReviewStrictness,
|
|
78
84
|
type Weekday,
|
|
79
85
|
type WeeklyAvailability,
|
|
80
86
|
type RepoTarget,
|
|
@@ -215,6 +221,13 @@ export interface SetupAnswers {
|
|
|
215
221
|
* leave it to be defaulted by whichever reader gets there first.
|
|
216
222
|
*/
|
|
217
223
|
armProof: ArmProof;
|
|
224
|
+
/**
|
|
225
|
+
* How green PRs are reviewed and returned (#678): the strictness level and
|
|
226
|
+
* the hard ceiling on review rounds per PR lifecycle. Always complete: the
|
|
227
|
+
* wizard asks about it in the policy area, so an answers object can never
|
|
228
|
+
* leave it to be defaulted by whichever reader gets there first.
|
|
229
|
+
*/
|
|
230
|
+
review: ReviewPolicy;
|
|
218
231
|
/**
|
|
219
232
|
* Hand-edited recovery merge authorizations carried through setup unchanged.
|
|
220
233
|
* The wizard never grants one; forgetting them during an unrelated amend
|
|
@@ -314,6 +327,8 @@ export const SETUP_DEFAULTS = {
|
|
|
314
327
|
releaseGrants: DENIED_RELEASE_GRANTS,
|
|
315
328
|
/** The strictest reading of the prose these conditions replaced (#129). */
|
|
316
329
|
policy: DEFAULT_PROJECT_POLICY,
|
|
330
|
+
/** The recommended review strictness and its default ceiling (#678). */
|
|
331
|
+
review: DEFAULT_REVIEW_POLICY,
|
|
317
332
|
/** The daemon runs its own triage session unless an operator already runs one. */
|
|
318
333
|
orchestratorMode: "embedded",
|
|
319
334
|
} as const;
|
|
@@ -405,6 +420,35 @@ export const ARM_PROOF_CHOICES: { readonly [K in ArmProof]: string } = {
|
|
|
405
420
|
"claim-only": "anyone who can invoke the already-privileged `omp-conductor arm` command can start dispatch once the live claim and poller pass",
|
|
406
421
|
};
|
|
407
422
|
|
|
423
|
+
/**
|
|
424
|
+
* What each review strictness blocks, in the operator's words, and the single
|
|
425
|
+
* canonical phrasing of the three bars (#678).
|
|
426
|
+
*
|
|
427
|
+
* Mapped over the closed union for the reason {@link MERGE_DUTY} is: a fourth
|
|
428
|
+
* level fails to compile here instead of reaching a wizard with no question for
|
|
429
|
+
* it, an amend row that cannot describe it, and a rendered brief that shows it
|
|
430
|
+
* blank. Each sentence is self-contained — "Low's bar" would reference prose
|
|
431
|
+
* the brief may not be rendering — and it is the *same* text the wizard shows
|
|
432
|
+
* one level at a time and the composed orchestrator brief carries for the
|
|
433
|
+
* configured level, so the definition an operator chose is the threshold a
|
|
434
|
+
* session enforces.
|
|
435
|
+
*
|
|
436
|
+
* The two consumers read this map rather than their own prose: the setup
|
|
437
|
+
* dialog explains every level from it, and {@link reviewDuty} renders the
|
|
438
|
+
* effective one from it. "One canonical implementation, not duplicated prose
|
|
439
|
+
* branches" is this object.
|
|
440
|
+
*/
|
|
441
|
+
export const REVIEW_STRICTNESS_CHOICES: { readonly [K in ReviewStrictness]: string } = {
|
|
442
|
+
low: "correctness, security, data-loss or explicit acceptance-criteria failures — anything else stays a review comment",
|
|
443
|
+
medium:
|
|
444
|
+
"correctness, security, data-loss or explicit acceptance-criteria failures, plus material maintainability or " +
|
|
445
|
+
"reliability defects likely to become incidents within six months — anything else stays a review comment",
|
|
446
|
+
high:
|
|
447
|
+
"correctness, security, data-loss or explicit acceptance-criteria failures, plus material maintainability or " +
|
|
448
|
+
"reliability defects likely to become incidents within six months, plus concrete quality defects — never " +
|
|
449
|
+
"subjective style churn or unbounded refactoring",
|
|
450
|
+
};
|
|
451
|
+
|
|
408
452
|
export const RELEASE_REQUIREMENT_CHOICES: { readonly [K in ReleaseRequirement]: string } = {
|
|
409
453
|
"runs-settled": "every run of the released repo actually merged, not merely reached a green PR",
|
|
410
454
|
"fleet-runs-settled": "every run in the project actually merged — suite-wide strictness for shapes that consume several repos",
|
|
@@ -465,6 +509,34 @@ export const MERGE_DUTY: { readonly [K in ProjectConfig["authority"]["merge"]]:
|
|
|
465
509
|
" one is a hard boundary, not a preference.",
|
|
466
510
|
};
|
|
467
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Duty 1's review-return contract, worded from `project.review` (#678).
|
|
514
|
+
*
|
|
515
|
+
* One canonical implementation rather than prose branches in the template: the
|
|
516
|
+
* configured level's bar is the same {@link REVIEW_STRICTNESS_CHOICES} text
|
|
517
|
+
* the setup dialog explains each level with, and the ceiling is the configured
|
|
518
|
+
* number — so the strictness an operator chose in setup and the threshold this
|
|
519
|
+
* session enforces cannot diverge. The paragraph names `conductor_pr_review`
|
|
520
|
+
* (the #677 verb) as the only return path and states the three ceiling
|
|
521
|
+
* behaviours: leave the PR open, record the unresolved findings, escalate once.
|
|
522
|
+
*/
|
|
523
|
+
export function reviewDuty(p: ProjectConfig): string {
|
|
524
|
+
const { strictness, maxRounds } = resolveReview(p);
|
|
525
|
+
return (
|
|
526
|
+
"\n" +
|
|
527
|
+
"**Review policy:** before you merge (or settle) a green PR, review it at\n" +
|
|
528
|
+
`this project's strictness. The level is **${strictness}**: ` +
|
|
529
|
+
`${REVIEW_STRICTNESS_CHOICES[strictness]}. ` +
|
|
530
|
+
"`conductor_pr_review` is the verb for findings at or above that bar, and\n" +
|
|
531
|
+
"for nothing else — anything below it stays a review comment on the PR,\n" +
|
|
532
|
+
"never a return. Every corrected head gets a fresh review, up to a hard\n" +
|
|
533
|
+
`ceiling of ${maxRounds} ${maxRounds === 1 ? "round" : "rounds"} per PR lifecycle, visible in ` +
|
|
534
|
+
"`omp-conductor status` as `review-revision N`. At the ceiling\n" +
|
|
535
|
+
"`conductor_pr_review` refuses: leave the PR open, record the unresolved\n" +
|
|
536
|
+
"findings, and escalate once — never a further round."
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
468
540
|
/**
|
|
469
541
|
* The Promotion paragraph, worded from `authority.promotion`.
|
|
470
542
|
*
|
|
@@ -901,7 +973,7 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
901
973
|
name: a.projectName,
|
|
902
974
|
tracker: { kind: "github", repo: a.trackerRepo },
|
|
903
975
|
queueLabel: a.queueLabel,
|
|
904
|
-
stateLabels: { ...a.stateLabels },
|
|
976
|
+
stateLabels: { ...a.stateLabels, backlog: DEFAULT_STATE_LABELS.backlog },
|
|
905
977
|
routing: { labelPrefix: a.routingLabelPrefix, repos },
|
|
906
978
|
caps,
|
|
907
979
|
...(a.workerModel !== undefined && a.workerModel.trim().length > 0
|
|
@@ -928,6 +1000,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
928
1000
|
// policy has a line in the file to point at — and the recovery playbook
|
|
929
1001
|
// can read which proof the project opted into (#613).
|
|
930
1002
|
arm: { proof: a.armProof },
|
|
1003
|
+
// Written out in full for the same reason as `arm`: the file then carries
|
|
1004
|
+
// the strictness and the ceiling an operator chose, without anyone having
|
|
1005
|
+
// to know a migration rule (#678).
|
|
1006
|
+
review: { ...a.review },
|
|
931
1007
|
...(a.recoveryMerges === undefined
|
|
932
1008
|
? {}
|
|
933
1009
|
: { recoveryMerges: a.recoveryMerges.map((entry) => ({ ...entry })) }),
|
|
@@ -1018,6 +1094,10 @@ export function defaultAnswers(projectName: string, opts: { added?: boolean } =
|
|
|
1018
1094
|
// The strict reading, matching the loader's absent-key default: a project
|
|
1019
1095
|
// that never answered keeps today's authenticated challenge round-trip.
|
|
1020
1096
|
armProof: DEFAULT_ARM_PROOF,
|
|
1097
|
+
// First-run default: the recommended strictness and its ceiling, so a
|
|
1098
|
+
// fresh project opens on the answer the issue recommends — and the loader
|
|
1099
|
+
// materialises the same value for a config that has no key at all.
|
|
1100
|
+
review: { ...SETUP_DEFAULTS.review },
|
|
1021
1101
|
orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
|
|
1022
1102
|
reportScope: SETUP_DEFAULT_REPORT_SCOPE,
|
|
1023
1103
|
writeOrchestratorBrief: false,
|
|
@@ -1117,6 +1197,10 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
1117
1197
|
// The loader materialises `arm` complete, so this is the answer the project
|
|
1118
1198
|
// actually has — never a default guessed at the amend prompt.
|
|
1119
1199
|
armProof: resolveArmProof(p),
|
|
1200
|
+
// Same reason as `armProof`: the loader materialises `review` complete, so
|
|
1201
|
+
// a re-run preserves the configured strictness and ceiling rather than
|
|
1202
|
+
// opening on a default the operator already answered.
|
|
1203
|
+
review: resolveReview(p),
|
|
1120
1204
|
orchestratorMode: p.escalation.orchestrator,
|
|
1121
1205
|
reportScope: reportScopeFromPolicy(p.reporting),
|
|
1122
1206
|
writeOrchestratorBrief: false,
|
|
@@ -1184,6 +1268,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
|
|
|
1184
1268
|
QUEUE_LABEL: p.queueLabel,
|
|
1185
1269
|
RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
|
|
1186
1270
|
MERGE_DUTY: MERGE_DUTY[p.authority.merge],
|
|
1271
|
+
REVIEW_DUTY: reviewDuty(p),
|
|
1187
1272
|
PROMOTION_DUTY: PROMOTION_DUTIES[p.authority.promotion ?? DEFAULT_AUTHORITY.promotion],
|
|
1188
1273
|
POLICY_SOURCE: policySourceLine(p),
|
|
1189
1274
|
};
|
|
@@ -1693,6 +1778,9 @@ export function summarisePlan(
|
|
|
1693
1778
|
` environments ${a.policy.release.environments.join(", ") || "none declared — every deploy target is refused"}`,
|
|
1694
1779
|
);
|
|
1695
1780
|
|
|
1781
|
+
lines.push("", "review", ` strictness ${a.review.strictness} — ${REVIEW_STRICTNESS_CHOICES[a.review.strictness]}`);
|
|
1782
|
+
lines.push(` max rounds/PR ${a.review.maxRounds} — at the ceiling the PR is left open, the findings recorded, and it is escalated once`);
|
|
1783
|
+
|
|
1696
1784
|
const reporting = project.reporting as ReportingPolicy;
|
|
1697
1785
|
const briefPath = orchestratorBriefPath(a);
|
|
1698
1786
|
lines.push(
|
|
@@ -1909,16 +1997,18 @@ export const AMEND_AREAS: {
|
|
|
1909
1997
|
},
|
|
1910
1998
|
policy: {
|
|
1911
1999
|
name: "merge & release preconditions",
|
|
1912
|
-
asks: "the checks, base freshness, draft rule and behind-base action for a merge, then what a release requires and what it ships",
|
|
2000
|
+
asks: "the checks, base freshness, draft rule and behind-base action for a merge, then what a release requires and what it ships — and how green PRs are reviewed, and how many rounds a PR may be returned",
|
|
1913
2001
|
describe: (p) => {
|
|
1914
2002
|
const policy = resolvePolicy(p);
|
|
2003
|
+
const review = resolveReview(p);
|
|
1915
2004
|
// Counted rather than listed: this row is elided at 96 characters, and the
|
|
1916
2005
|
// full table is in the plan summary the consent screen shows next.
|
|
1917
2006
|
return (
|
|
1918
2007
|
`merge: ${policy.merge.requiredChecks.length === 0 ? "every check" : `${policy.merge.requiredChecks.length} check(s)`}, ` +
|
|
1919
2008
|
`base ${policy.merge.baseFreshness}, drafts ${policy.merge.drafts}, behind → ${policy.merge.whenBehindBase}; ` +
|
|
1920
2009
|
`release: ${policy.release.requires.length} must-land, ${policy.release.artefacts.length} artefact(s), ` +
|
|
1921
|
-
`${policy.release.environments.length} env(s)`
|
|
2010
|
+
`${policy.release.environments.length} env(s); ` +
|
|
2011
|
+
`review ${review.strictness} ×${review.maxRounds}`
|
|
1922
2012
|
);
|
|
1923
2013
|
},
|
|
1924
2014
|
},
|
package/src/status-render.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type { DaemonStop } from "./types.ts";
|
|
|
22
22
|
import { settlementFlagSummary } from "./diff-flags.ts";
|
|
23
23
|
import type { CodeGraphHealth } from "./graph-health.ts";
|
|
24
24
|
import { formatDigestBacklog, formatOpenReports } from "./reports.ts";
|
|
25
|
-
import { formatOrchestratorDown } from "./orchestrator-down.ts";
|
|
25
|
+
import { formatDownDuration, formatOrchestratorDown } from "./orchestrator-down.ts";
|
|
26
26
|
import { planUsageLine } from "./usage.ts";
|
|
27
27
|
import { SYSTEMD_UNIT, type UnitOwnership } from "./lifecycle.ts";
|
|
28
28
|
import type { WorkerPausePhase } from "./worker.ts";
|
|
@@ -32,12 +32,12 @@ import {
|
|
|
32
32
|
formatDispatchSummary,
|
|
33
33
|
formatFreezes,
|
|
34
34
|
formatReleaseGrants,
|
|
35
|
-
formatSalvagedRuns,
|
|
36
35
|
isPaused,
|
|
37
36
|
pausedAt,
|
|
38
37
|
pauseProvenance,
|
|
39
38
|
type StatusSnapshot,
|
|
40
39
|
} from "./daemon.ts";
|
|
40
|
+
import { formatQuarantinedRuns, formatSalvagedRuns } from "./settlement.ts";
|
|
41
41
|
|
|
42
42
|
// layered status
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
@@ -405,6 +405,13 @@ function formatProjectBody(
|
|
|
405
405
|
` continuations ${s.caps.maxContinuationsPerIssue}`,
|
|
406
406
|
"",
|
|
407
407
|
...formatReleaseGrants(s.releaseGrants),
|
|
408
|
+
// The effective review policy (#678), read from the same snapshot surface
|
|
409
|
+
// as the grants: the level is what Duty 1 judges findings against, and the
|
|
410
|
+
// ceiling is its hard bound — an orchestrator reading `review-revision N`
|
|
411
|
+
// below has to see both without opening the config.
|
|
412
|
+
"review",
|
|
413
|
+
` strictness ${s.review.strictness}`,
|
|
414
|
+
` max rounds/PR ${s.review.maxRounds}`,
|
|
408
415
|
"",
|
|
409
416
|
formatDispatchSummary(s.dispatch),
|
|
410
417
|
"",
|
|
@@ -419,16 +426,30 @@ function formatProjectBody(
|
|
|
419
426
|
// a failure and from an ordinary continuation, with the round number
|
|
420
427
|
// from the durable revision row (#692). The live pause overlay still
|
|
421
428
|
// wins: an operator pause is the newer fact about the same session.
|
|
429
|
+
const paused = phase === "pausing" || phase === "paused";
|
|
422
430
|
const round = s.reviewRounds?.[r.id];
|
|
423
|
-
const state =
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
431
|
+
const state = paused
|
|
432
|
+
? phase
|
|
433
|
+
: round !== undefined
|
|
434
|
+
? `review-revision ${round}`
|
|
435
|
+
: r.state;
|
|
436
|
+
// Turn rate (#730): a stalled run and a fast one used to render
|
|
437
|
+
// identically as a bare turn count. The rate is the lifetime average
|
|
438
|
+
// over the elapsed shown — the snapshot carries no checkpoint from
|
|
439
|
+
// which a recent-interval rate could be derived — so it is labelled
|
|
440
|
+
// `avg`, and `elapsed` is elapsed since claim, never "remaining wall
|
|
441
|
+
// clock": a paused worker banks its budget, which the snapshot cannot
|
|
442
|
+
// know. A paused run gets neither number, because its elapsed includes
|
|
443
|
+
// banked pause time and its state already says `paused`.
|
|
444
|
+
const elapsedMs = Math.max(0, now - r.startedAt);
|
|
445
|
+
const progress = paused
|
|
446
|
+
? ""
|
|
447
|
+
: ` ${formatDownDuration(elapsedMs)} elapsed ${(
|
|
448
|
+
(r.turns * 60_000) / Math.max(elapsedMs, 1_000)
|
|
449
|
+
).toFixed(1)} turns/min avg`;
|
|
429
450
|
lines.push(
|
|
430
451
|
` #${r.issue} ${r.repo} ${state} attempt ${r.attempt} ` +
|
|
431
|
-
`${r.turns}/${r.maxTurns} turns ${r.spendUsd.toFixed(2)} ${r.branch}` +
|
|
452
|
+
`${r.turns}/${r.maxTurns} turns${progress} ${r.spendUsd.toFixed(2)} ${r.branch}` +
|
|
432
453
|
(r.prUrl ? ` ${r.prUrl}` : ""),
|
|
433
454
|
);
|
|
434
455
|
// The orchestrator's Duty 1 reads this command, and a flagged run's
|
|
@@ -441,6 +462,7 @@ function formatProjectBody(
|
|
|
441
462
|
lines.push(...formatBaseHealth(s.baseHealth));
|
|
442
463
|
lines.push(...formatFreezes(s.freezes));
|
|
443
464
|
lines.push(...formatSalvagedRuns(s.salvagedRuns));
|
|
465
|
+
lines.push(...formatQuarantinedRuns(s.quarantinedRuns));
|
|
444
466
|
lines.push(...formatOpenReports(s.openReports));
|
|
445
467
|
lines.push(...formatDigestBacklog(s.digestBacklog));
|
|
446
468
|
if (s.liveWorkers > 0) {
|