omp-conductor 0.17.0 → 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.
Files changed (51) hide show
  1. package/REFERENCE.md +12 -8
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +40 -1
  4. package/src/admission.ts +263 -44
  5. package/src/ask.ts +39 -3
  6. package/src/availability.ts +27 -1
  7. package/src/backups.ts +2 -2
  8. package/src/briefs/orchestrator.md +1 -0
  9. package/src/briefs/worker.md +38 -19
  10. package/src/command-help.ts +8 -1
  11. package/src/command-manifest.ts +5 -2
  12. package/src/commands/arm.ts +6 -3
  13. package/src/commands/message.ts +32 -4
  14. package/src/commands/watch.ts +62 -3
  15. package/src/config-schema.ts +53 -0
  16. package/src/config.ts +97 -1
  17. package/src/daemon.ts +1479 -1483
  18. package/src/decisions.ts +51 -6
  19. package/src/depends-on.ts +261 -1
  20. package/src/diff-flags.ts +350 -0
  21. package/src/digest-schedule.ts +37 -0
  22. package/src/doctor.ts +310 -22
  23. package/src/escalate.ts +560 -57
  24. package/src/failure-class.ts +71 -15
  25. package/src/fleet.ts +189 -34
  26. package/src/gitops.ts +103 -24
  27. package/src/graph-health.ts +20 -7
  28. package/src/graph.ts +313 -68
  29. package/src/lifecycle.ts +43 -7
  30. package/src/omp.ts +42 -0
  31. package/src/orchestrator-tick.ts +430 -162
  32. package/src/release-policy.ts +177 -5
  33. package/src/routing.ts +11 -3
  34. package/src/session-host.ts +16 -0
  35. package/src/settlement.ts +1728 -0
  36. package/src/setup-host.ts +193 -4
  37. package/src/setup-install.ts +91 -30
  38. package/src/setup-wizard.ts +1257 -78
  39. package/src/setup.ts +153 -6
  40. package/src/status-render.ts +36 -4
  41. package/src/store.ts +411 -17
  42. package/src/tracker/github.ts +607 -12
  43. package/src/types.ts +331 -5
  44. package/src/upgrade.ts +50 -19
  45. package/src/verbs/actions.ts +66 -18
  46. package/src/verbs/protocol.ts +45 -0
  47. package/src/verbs/server.ts +270 -13
  48. package/src/worker.ts +239 -6
  49. package/src/worktree.ts +115 -8
  50. package/systemd/omp-conductor-recover.sh +73 -0
  51. package/systemd/recover-unit-test.sh +61 -0
package/src/setup.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  * least tested code in the package.
9
9
  *
10
10
  * Two functions here mutate something outside the process: `createMissingLabels`
11
- * and `writeOrchestratorBrief`. Everything else reads, or computes. That is what
11
+ * (and its rollback compensation half, `deleteCreatedLabels`) and
12
+ * `writeOrchestratorBrief`. Everything else reads, or computes. That is what
12
13
  * lets the plugin show a complete plan before asking for consent, and it is a
13
14
  * property worth preserving — check it before adding a function.
14
15
  *
@@ -38,11 +39,14 @@ import {
38
39
  import {
39
40
  clonePolicy,
40
41
  configPath,
42
+ DEFAULT_STATE_LABELS,
41
43
  defaultMirrorRoot,
42
44
  defaultWorkspaceRoot,
45
+ resolveArmProof,
43
46
  resolveCaps,
44
47
  resolvePolicy,
45
48
  resolveReleaseGrants,
49
+ resolveReview,
46
50
  SCOPE_PRESETS,
47
51
  stateDir,
48
52
  } from "./config.ts";
@@ -50,13 +54,17 @@ import { graphProjectPath, graphRepos } from "./graph.ts";
50
54
  import { ompSettingsOverlay } from "./omp-settings.ts";
51
55
  import {
52
56
  CONFIG_VERSION,
57
+ DEFAULT_ARM_PROOF,
53
58
  DEFAULT_AUTHORITY,
54
59
  DEFAULT_CAPS,
55
60
  DEFAULT_PROJECT_POLICY,
56
61
  DEFAULT_REPORT_SCOPE,
62
+ DEFAULT_REVIEW_POLICY,
57
63
  DENIED_RELEASE_GRANTS,
58
64
  RELEASE_SHAPES,
65
+ REVIEW_STRICTNESS,
59
66
  WEEKDAYS,
67
+ type ArmProof,
60
68
  type BaseFreshness,
61
69
  type BehindBaseAction,
62
70
  type AuthorityHolder,
@@ -71,6 +79,8 @@ import {
71
79
  type ReleaseRequirement,
72
80
  type ReportScopeChoice,
73
81
  type ReportingPolicy,
82
+ type ReviewPolicy,
83
+ type ReviewStrictness,
74
84
  type Weekday,
75
85
  type WeeklyAvailability,
76
86
  type RepoTarget,
@@ -205,6 +215,19 @@ export interface SetupAnswers {
205
215
  * leave one to be defaulted by whichever reader gets there first.
206
216
  */
207
217
  policy: ProjectPolicy;
218
+ /**
219
+ * How `arm` proves a human just approved arming (#613). Always complete:
220
+ * the wizard asks about it in the policy area, so an answers object can never
221
+ * leave it to be defaulted by whichever reader gets there first.
222
+ */
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;
208
231
  /**
209
232
  * Hand-edited recovery merge authorizations carried through setup unchanged.
210
233
  * The wizard never grants one; forgetting them during an unrelated amend
@@ -304,6 +327,8 @@ export const SETUP_DEFAULTS = {
304
327
  releaseGrants: DENIED_RELEASE_GRANTS,
305
328
  /** The strictest reading of the prose these conditions replaced (#129). */
306
329
  policy: DEFAULT_PROJECT_POLICY,
330
+ /** The recommended review strictness and its default ceiling (#678). */
331
+ review: DEFAULT_REVIEW_POLICY,
307
332
  /** The daemon runs its own triage session unless an operator already runs one. */
308
333
  orchestratorMode: "embedded",
309
334
  } as const;
@@ -381,8 +406,52 @@ export const BEHIND_BASE_CHOICES: { readonly [K in BehindBaseAction]: string } =
381
406
  escalate: "page a human rather than guess",
382
407
  };
383
408
 
409
+ /**
410
+ * What each arming proof means to the operator being asked about it, in their
411
+ * words rather than the gate's (conductor #613). The `claim-only` consequence
412
+ * is the one line the issue contract requires the question to state: anything
413
+ * that can invoke the already-privileged `omp-conductor arm` command can start
414
+ * dispatch once the live claim and poller pass. Mapped over the closed union
415
+ * so a third proof fails to compile here instead of reaching a wizard with no
416
+ * question for it.
417
+ */
418
+ export const ARM_PROOF_CHOICES: { readonly [K in ArmProof]: string } = {
419
+ challenge: "an authenticated Telegram challenge proves a human just approved arming",
420
+ "claim-only": "anyone who can invoke the already-privileged `omp-conductor arm` command can start dispatch once the live claim and poller pass",
421
+ };
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
+
384
452
  export const RELEASE_REQUIREMENT_CHOICES: { readonly [K in ReleaseRequirement]: string } = {
385
- "runs-settled": "every run this release covers actually merged, not merely reached a green PR",
453
+ "runs-settled": "every run of the released repo actually merged, not merely reached a green PR",
454
+ "fleet-runs-settled": "every run in the project actually merged — suite-wide strictness for shapes that consume several repos",
386
455
  "no-open-prs": "no pull request is still open against the branch being released",
387
456
  "queue-drained": "nothing still carries the queue label",
388
457
  "base-branch-green": "the newest observed post-merge base-branch workflows are green",
@@ -440,6 +509,34 @@ export const MERGE_DUTY: { readonly [K in ProjectConfig["authority"]["merge"]]:
440
509
  " one is a hard boundary, not a preference.",
441
510
  };
442
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
+
443
540
  /**
444
541
  * The Promotion paragraph, worded from `authority.promotion`.
445
542
  *
@@ -700,11 +797,33 @@ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]
700
797
  continue;
701
798
  }
702
799
  if (/already exists/i.test(r.stderr)) continue;
703
- throw new Error(`Could not create label "${label.name}" in ${trackerRepo}: ${briefly(r.stderr)}`);
800
+ // The labels created before this one failed ride on the error: the setup
801
+ // transaction compensates the tracker exactly, and a throw without the
802
+ // partial list would orphan them (#652).
803
+ throw Object.assign(
804
+ new Error(`Could not create label "${label.name}" in ${trackerRepo}: ${briefly(r.stderr)}`),
805
+ { created },
806
+ );
704
807
  }
705
808
  return created;
706
809
  }
707
810
 
811
+ /**
812
+ * Compensation half of {@link createMissingLabels}: deletes exactly the labels
813
+ * a failed setup apply created, so the tracker returns to its pre-entry state.
814
+ * Runs only on rollback, where a deletion failure is a reported restoration
815
+ * failure — the orphaned label is named, never silently left behind (#652).
816
+ */
817
+ export async function deleteCreatedLabels(trackerRepo: string, created: string[]): Promise<void> {
818
+ const failures: string[] = [];
819
+ for (const name of created) {
820
+ const r = await gh(["label", "delete", name, "--repo", trackerRepo, "--yes"]);
821
+ if (r.code === 0 || /not found/i.test(r.stderr)) continue;
822
+ failures.push(`could not delete label "${name}" in ${trackerRepo}: ${briefly(r.stderr)}`);
823
+ }
824
+ if (failures.length > 0) throw new Error(failures.join("; "));
825
+ }
826
+
708
827
  const QUIET_INTERRUPT_ON: InterruptCategory[] = [
709
828
  "tier2",
710
829
  "fleet-stopped",
@@ -854,7 +973,7 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
854
973
  name: a.projectName,
855
974
  tracker: { kind: "github", repo: a.trackerRepo },
856
975
  queueLabel: a.queueLabel,
857
- stateLabels: { ...a.stateLabels },
976
+ stateLabels: { ...a.stateLabels, backlog: DEFAULT_STATE_LABELS.backlog },
858
977
  routing: { labelPrefix: a.routingLabelPrefix, repos },
859
978
  caps,
860
979
  ...(a.workerModel !== undefined && a.workerModel.trim().length > 0
@@ -877,6 +996,14 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
877
996
  // Written out in full for the same reason: the file then says what a merge
878
997
  // and a release require without anyone having to know a default (#129).
879
998
  policy: clonePolicy(a.policy),
999
+ // Written out even when it is the default, so an operator amending the
1000
+ // policy has a line in the file to point at — and the recovery playbook
1001
+ // can read which proof the project opted into (#613).
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 },
880
1007
  ...(a.recoveryMerges === undefined
881
1008
  ? {}
882
1009
  : { recoveryMerges: a.recoveryMerges.map((entry) => ({ ...entry })) }),
@@ -964,6 +1091,13 @@ export function defaultAnswers(projectName: string, opts: { added?: boolean } =
964
1091
  authority: { ...SETUP_DEFAULTS.authority },
965
1092
  releaseGrants: { ...SETUP_DEFAULTS.releaseGrants },
966
1093
  policy: clonePolicy(SETUP_DEFAULTS.policy),
1094
+ // The strict reading, matching the loader's absent-key default: a project
1095
+ // that never answered keeps today's authenticated challenge round-trip.
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 },
967
1101
  orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
968
1102
  reportScope: SETUP_DEFAULT_REPORT_SCOPE,
969
1103
  writeOrchestratorBrief: false,
@@ -1060,6 +1194,13 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
1060
1194
  authority: { ...p.authority },
1061
1195
  releaseGrants: resolveReleaseGrants(p),
1062
1196
  policy: resolvePolicy(p),
1197
+ // The loader materialises `arm` complete, so this is the answer the project
1198
+ // actually has — never a default guessed at the amend prompt.
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),
1063
1204
  orchestratorMode: p.escalation.orchestrator,
1064
1205
  reportScope: reportScopeFromPolicy(p.reporting),
1065
1206
  writeOrchestratorBrief: false,
@@ -1127,6 +1268,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
1127
1268
  QUEUE_LABEL: p.queueLabel,
1128
1269
  RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
1129
1270
  MERGE_DUTY: MERGE_DUTY[p.authority.merge],
1271
+ REVIEW_DUTY: reviewDuty(p),
1130
1272
  PROMOTION_DUTY: PROMOTION_DUTIES[p.authority.promotion ?? DEFAULT_AUTHORITY.promotion],
1131
1273
  POLICY_SOURCE: policySourceLine(p),
1132
1274
  };
@@ -1636,6 +1778,9 @@ export function summarisePlan(
1636
1778
  ` environments ${a.policy.release.environments.join(", ") || "none declared — every deploy target is refused"}`,
1637
1779
  );
1638
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
+
1639
1784
  const reporting = project.reporting as ReportingPolicy;
1640
1785
  const briefPath = orchestratorBriefPath(a);
1641
1786
  lines.push(
@@ -1852,16 +1997,18 @@ export const AMEND_AREAS: {
1852
1997
  },
1853
1998
  policy: {
1854
1999
  name: "merge & release preconditions",
1855
- 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",
1856
2001
  describe: (p) => {
1857
2002
  const policy = resolvePolicy(p);
2003
+ const review = resolveReview(p);
1858
2004
  // Counted rather than listed: this row is elided at 96 characters, and the
1859
2005
  // full table is in the plan summary the consent screen shows next.
1860
2006
  return (
1861
2007
  `merge: ${policy.merge.requiredChecks.length === 0 ? "every check" : `${policy.merge.requiredChecks.length} check(s)`}, ` +
1862
2008
  `base ${policy.merge.baseFreshness}, drafts ${policy.merge.drafts}, behind → ${policy.merge.whenBehindBase}; ` +
1863
2009
  `release: ${policy.release.requires.length} must-land, ${policy.release.artefacts.length} artefact(s), ` +
1864
- `${policy.release.environments.length} env(s)`
2010
+ `${policy.release.environments.length} env(s); ` +
2011
+ `review ${review.strictness} ×${review.maxRounds}`
1865
2012
  );
1866
2013
  },
1867
2014
  },
@@ -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
  "",
@@ -415,10 +422,34 @@ function formatProjectBody(
415
422
  lines.push("active runs");
416
423
  for (const r of s.activeRuns) {
417
424
  const phase = workerPhases.get(r.issue);
418
- const state = phase === "pausing" || phase === "paused" ? phase : r.state;
425
+ // A run in a live review round reads `review-revision N`, distinct from
426
+ // a failure and from an ordinary continuation, with the round number
427
+ // from the durable revision row (#692). The live pause overlay still
428
+ // wins: an operator pause is the newer fact about the same session.
429
+ const paused = phase === "pausing" || phase === "paused";
430
+ const round = s.reviewRounds?.[r.id];
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`;
419
450
  lines.push(
420
451
  ` #${r.issue} ${r.repo} ${state} attempt ${r.attempt} ` +
421
- `${r.turns}/${r.maxTurns} turns ${r.spendUsd.toFixed(2)} ${r.branch}` +
452
+ `${r.turns}/${r.maxTurns} turns${progress} ${r.spendUsd.toFixed(2)} ${r.branch}` +
422
453
  (r.prUrl ? ` ${r.prUrl}` : ""),
423
454
  );
424
455
  // The orchestrator's Duty 1 reads this command, and a flagged run's
@@ -431,6 +462,7 @@ function formatProjectBody(
431
462
  lines.push(...formatBaseHealth(s.baseHealth));
432
463
  lines.push(...formatFreezes(s.freezes));
433
464
  lines.push(...formatSalvagedRuns(s.salvagedRuns));
465
+ lines.push(...formatQuarantinedRuns(s.quarantinedRuns));
434
466
  lines.push(...formatOpenReports(s.openReports));
435
467
  lines.push(...formatDigestBacklog(s.digestBacklog));
436
468
  if (s.liveWorkers > 0) {