omp-conductor 0.18.2 → 0.19.1

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 (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +379 -22
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +511 -101
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +325 -1159
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +326 -47
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
@@ -321,6 +321,46 @@ export function githubVerbActions(
321
321
  : { ok: true, sha };
322
322
  };
323
323
 
324
+ /**
325
+ * Resolve a caller-pinned release commit and prove it belongs on the released
326
+ * branch (#695).
327
+ *
328
+ * Two questions, both fail-closed. `rev-parse <sha>^{commit}` answers "does
329
+ * this commit exist in the released repository at all" — an abbreviation is
330
+ * expanded here, so what the tag receives is always a full id. `merge-base
331
+ * --is-ancestor` answers the one that matters: a tag pointing at a commit
332
+ * that is not on the released branch is a release of code nobody merged, and
333
+ * that is the failure a decorative `sha` argument would create. The ancestor
334
+ * check accepts the branch tip itself, which is the ordinary case.
335
+ */
336
+ const pinnedReleaseCommit = async (
337
+ mirror: string,
338
+ repo: RepoTarget,
339
+ sha: string,
340
+ ): Promise<CommitOutcome> => {
341
+ const resolved = await readCommit(mirror, ["rev-parse", `${sha}^{commit}`], `pinned commit ${sha}`);
342
+ if (!resolved.ok) {
343
+ return {
344
+ ok: false,
345
+ stderr:
346
+ `refusing release: ${sha} is not a commit in ${repo.name}. ` +
347
+ `Pass a commit that exists on ${repo.defaultBranch}.\n${resolved.stderr}`,
348
+ };
349
+ }
350
+ const branchRef = `refs/remotes/origin/${repo.defaultBranch}`;
351
+ const reachable = await git(mirror, ["merge-base", "--is-ancestor", resolved.sha, branchRef]);
352
+ if (!reachable.result.ok) {
353
+ return {
354
+ ok: false,
355
+ stderr:
356
+ `refusing release: ${resolved.sha} is not reachable from ${repo.name}'s ${repo.defaultBranch}. ` +
357
+ "A tag must never point at a commit that is not on the released branch — check the sha, or " +
358
+ "wait for the commit to land.",
359
+ };
360
+ }
361
+ return resolved;
362
+ };
363
+
324
364
  const releaseTargetMoved = (repo: RepoTarget, target: string, live: string): { ok: false; stderr: string } => ({
325
365
  ok: false,
326
366
  stderr:
@@ -636,6 +676,38 @@ export function githubVerbActions(
636
676
  };
637
677
  },
638
678
 
679
+ // Closing a rejected PR is the one destructive half of an adjudication's
680
+ // disposition (#876), and it is REST rather than `gh pr close` for the same
681
+ // reason `updatePrBranch` is: a selective GraphQL outage must not leave a
682
+ // rejected PR open, permanently blocking the issue's reimplementation.
683
+ closePr: async (prUrl, comment) => {
684
+ const parts = prUrlParts(prUrl);
685
+ if (parts === undefined) {
686
+ return { ok: false, stderr: `could not parse ${prUrl} as a pull request URL` };
687
+ }
688
+ // The comment first, and only then the close: a closed PR still shows its
689
+ // comments, but a close that succeeds while the explanation fails leaves a
690
+ // PR nobody can account for. Ordering the durable explanation before the
691
+ // irreversible act is the same rule the merge path follows.
692
+ const said = await gh([
693
+ "api",
694
+ "--method",
695
+ "POST",
696
+ `repos/${parts.owner}/${parts.repo}/issues/${parts.number}/comments`,
697
+ "-f",
698
+ `body=${comment}`,
699
+ ]);
700
+ if (!said.ok) return { ok: false, stderr: said.stderr };
701
+ return gh([
702
+ "api",
703
+ "--method",
704
+ "PATCH",
705
+ `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`,
706
+ "-f",
707
+ "state=closed",
708
+ ]);
709
+ },
710
+
639
711
  updatePr: async (prUrl, fields) => {
640
712
  const argv = ["pr", "edit", prUrl];
641
713
  if (fields.title !== undefined) argv.push("--title", fields.title);
@@ -682,15 +754,31 @@ export function githubVerbActions(
682
754
  if (!prepared.ok) return prepared;
683
755
  const mirror = prepared.path;
684
756
  if (execution.shape === "git-tag") {
685
- const target = await readCommit(
686
- mirror,
687
- ["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`],
688
- `refreshed ${execution.repo.defaultBranch}`,
689
- );
757
+ // A pinned commit (#695) is the whole point of the argument: the tag
758
+ // points where the caller says, so the branch moving afterwards — which
759
+ // is what reaching a quiet release window requires — cannot strand it.
760
+ // The moved-branch guards below exist to catch main shifting under an
761
+ // implicit "current main" target; for an explicit commit they would
762
+ // refuse exactly the case the pin was asked for, so they are skipped
763
+ // and the reachability check is the invariant instead.
764
+ const pinned =
765
+ execution.sha === undefined
766
+ ? undefined
767
+ : await pinnedReleaseCommit(mirror, execution.repo, execution.sha);
768
+ if (pinned !== undefined && !pinned.ok) return pinned;
769
+ const target =
770
+ pinned ??
771
+ (await readCommit(
772
+ mirror,
773
+ ["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`],
774
+ `refreshed ${execution.repo.defaultBranch}`,
775
+ ));
690
776
  if (!target.ok) return target;
691
- const liveBefore = await liveDefaultHead(mirror, execution.repo);
692
- if (!liveBefore.ok) return liveBefore;
693
- if (target.sha !== liveBefore.sha) return releaseTargetMoved(execution.repo, target.sha, liveBefore.sha);
777
+ if (pinned === undefined) {
778
+ const liveBefore = await liveDefaultHead(mirror, execution.repo);
779
+ if (!liveBefore.ok) return liveBefore;
780
+ if (target.sha !== liveBefore.sha) return releaseTargetMoved(execution.repo, target.sha, liveBefore.sha);
781
+ }
694
782
 
695
783
  const versionReady = await releaseVersionRefusal(mirror, execution.repo, target.sha, tag);
696
784
  if (!versionReady.ok) return versionReady;
@@ -736,7 +824,12 @@ export function githubVerbActions(
736
824
  ]);
737
825
  if (!tagged.result.ok) return failed(tagged.result, tagged.argv);
738
826
 
739
- const liveAfter = await liveDefaultHead(mirror, execution.repo);
827
+ // Only meaningful for an implicit target: a pinned tag is *expected* to
828
+ // sit behind a branch that has moved on.
829
+ const liveAfter =
830
+ pinned === undefined
831
+ ? await liveDefaultHead(mirror, execution.repo)
832
+ : ({ ok: true, sha: target.sha } as const);
740
833
  if (!liveAfter.ok || liveAfter.sha !== target.sha) {
741
834
  const rollback = previousSha !== undefined
742
835
  ? await git(mirror, [
@@ -774,6 +867,27 @@ export function githubVerbActions(
774
867
  const target = await readCommit(mirror, ["rev-parse", `refs/tags/${tag}^{commit}`], `tag ${tag}`);
775
868
  if (!target.ok) return target;
776
869
  let tagSha = target.sha;
870
+ // A pinned push publishes the tag EXACTLY as it was cut (#695). Without
871
+ // this the retarget-to-live-main path below would quietly move a pinned
872
+ // tag onto whatever merged since — which is the same stranding, inverted:
873
+ // the caller would get a published tag pointing somewhere it never asked
874
+ // for. The pin is re-proven against the branch and against the local tag,
875
+ // so a mismatch is a refusal rather than a silent move.
876
+ const pinnedPush =
877
+ execution.sha === undefined
878
+ ? undefined
879
+ : await pinnedReleaseCommit(mirror, execution.repo, execution.sha);
880
+ if (pinnedPush !== undefined) {
881
+ if (!pinnedPush.ok) return pinnedPush;
882
+ if (pinnedPush.sha !== tagSha) {
883
+ return {
884
+ ok: false,
885
+ stderr:
886
+ `refusing release: ${tag} is at ${tagSha} locally, and you asked to publish it at ` +
887
+ `${pinnedPush.sha}. A published tag is never force-moved; cut the tag you mean to push.`,
888
+ };
889
+ }
890
+ }
777
891
  const live = await liveDefaultHead(mirror, execution.repo);
778
892
  if (!live.ok) return live;
779
893
 
@@ -795,7 +909,7 @@ export function githubVerbActions(
795
909
  if (!versionReady.ok) return versionReady;
796
910
 
797
911
  let oldSha: string | undefined;
798
- if (tagSha !== live.sha) {
912
+ if (pinnedPush === undefined && tagSha !== live.sha) {
799
913
  const fresh = await readCommit(
800
914
  mirror,
801
915
  ["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`],
@@ -286,6 +286,14 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
286
286
  required: false,
287
287
  description: "Tag to prepare or cut, for version-bump-pr, git-tag, and github-release.",
288
288
  },
289
+ sha: {
290
+ type: "string",
291
+ required: false,
292
+ description:
293
+ "Pin the tag to this exact commit instead of whatever the released repo's default branch " +
294
+ "is at cut time (#695), for git-tag and git-push-tags. The commit must be reachable from " +
295
+ "that branch, or the release is refused naming it. Omit it and the behaviour is unchanged.",
296
+ },
289
297
  artefact: {
290
298
  type: "string",
291
299
  required: false,
@@ -371,6 +379,47 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
371
379
  roleRefusalText: (role) =>
372
380
  `review authority is the orchestrator's; this is a ${role} session, and a worker never returns its own PR for revision.`,
373
381
  },
382
+ conductor_pr_review_clear: {
383
+ name: "conductor_pr_review_clear",
384
+ mutating: true,
385
+ allowedRoles: ["orchestrator"],
386
+ description:
387
+ "Record that the durable review findings standing at one exact head are settled, so " +
388
+ "`conductor_pr_merge` stops refusing that head with merge-blocked-by-review (#913). This is the " +
389
+ "only clearance reachable at an UNCHANGED head: a re-review at the same head opens or appends to " +
390
+ "another blocking round rather than superseding one, and nothing settles a round from outside a " +
391
+ "worker. The clearance applies strictly backwards — it suppresses the evidence recorded up to the " +
392
+ "moment it is taken, never a finding recorded afterwards — and to that one head only. It bypasses " +
393
+ "nothing else: authority, pause, base-red-freeze, exact-head, green checks, release composition and " +
394
+ "single-flight all still apply to the merge. Refused for a PR no run of this project owns, a head " +
395
+ "that is not the live head, and a head where nothing is blocking.",
396
+ args: {
397
+ prUrl: {
398
+ type: "string",
399
+ required: true,
400
+ description:
401
+ "Full pull request URL — one a run of this project recorded, in a routed repository.",
402
+ },
403
+ headSha: {
404
+ type: "string",
405
+ required: true,
406
+ description:
407
+ "The exact head being cleared. Must equal the live head; checks are not re-read here " +
408
+ "(the merge gate re-checks green itself).",
409
+ },
410
+ reason: {
411
+ type: "string",
412
+ required: true,
413
+ description:
414
+ "Why this evidence is settled, in your own words. Non-empty, recorded verbatim in the " +
415
+ "ledger and the digest — this is the audit trail for a merge that a review round refused.",
416
+ },
417
+ rationale: RATIONALE_ARG,
418
+ },
419
+ roleRefusalText: (role) =>
420
+ `clearing review findings is the orchestrator's disposition; this is a ${role} session, ` +
421
+ "and a worker never clears the findings standing against its own pull request.",
422
+ },
374
423
  conductor_pr_recover: {
375
424
  name: "conductor_pr_recover",
376
425
  mutating: true,
@@ -570,13 +619,32 @@ export function parseVerbRequest(raw: unknown): VerbParse {
570
619
  if (arg.oneOf !== undefined && !arg.oneOf.some((allowed) => allowed === value)) {
571
620
  // `reason` is the field #129 closed on purpose, so it gets its own
572
621
  // refusal code: an out-of-enum reason is vocabulary drift, not a typo.
622
+ const reasonDrift = key === "reason";
623
+ // Measured on this fleet 2026-08-23: this is the largest single refusal
624
+ // cause in the ledger — 99 of 241, across the four verbs whose `reason`
625
+ // is a closed set, still firing. Every one of the 99 carried a real
626
+ // explanation ("landed directly in PR #940; queue label would re-dispatch
627
+ // finished work"), which is exactly the audit content the ledger exists
628
+ // to hold, and 7578 characters of it were discarded. None of the 99
629
+ // supplied `rationale` — the field built for it — because the moment a
630
+ // caller has a sentence to record is the moment they are told only that
631
+ // `reason` is closed (#968).
632
+ //
633
+ // Keyed on the spec rather than a hardcoded verb list: a verb with an
634
+ // enum `reason` and no `rationale` must not be told to use a field it
635
+ // would then refuse as `unknown-argument`, turning one wasted round trip
636
+ // into two.
637
+ const carriesRationale = reasonDrift && spec.args["rationale"] !== undefined;
573
638
  return {
574
639
  ok: false,
575
640
  verb,
576
- refusal: key === "reason" ? "reason-not-in-enum" : "malformed-argument",
641
+ refusal: reasonDrift ? "reason-not-in-enum" : "malformed-argument",
577
642
  detail:
578
643
  `refused: ${verb} argument "${key}" must be one of ${arg.oneOf.join(", ")}, ` +
579
- `got ${JSON.stringify(value)}.`,
644
+ `got ${JSON.stringify(value)}.` +
645
+ (carriesRationale
646
+ ? ' Pick the enum value that fits and put that sentence in "rationale", which is logged verbatim.'
647
+ : ""),
580
648
  };
581
649
  }
582
650
  }