omp-conductor 0.15.6 → 0.15.7

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.
@@ -120,7 +120,15 @@ export type VerbChannel =
120
120
  role: "orchestrator";
121
121
  };
122
122
 
123
- export type ActionOutcome = { ok: true; sha?: string; detail?: string } | { ok: false; stderr: string };
123
+ export type ActionOutcome =
124
+ | {
125
+ ok: true;
126
+ sha?: string;
127
+ detail?: string;
128
+ /** A reviewable version bump. New PRs return immediately; existing ones are re-checked and merged. */
129
+ review?: { prUrl: string; created: boolean };
130
+ }
131
+ | { ok: false; stderr: string };
124
132
 
125
133
  /** What one release asks the privileged half to do, after policy said yes. */
126
134
  export interface ReleaseExecution {
@@ -645,11 +653,15 @@ function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | u
645
653
  * opened (workflow-generated release pins, etc.). Scope is the routed clone
646
654
  * slugs — not "any PR a run once recorded".
647
655
  */
648
- function prInProjectRouting(project: ProjectConfig, prUrl: string): boolean {
656
+ function repoForPr(project: ProjectConfig, prUrl: string): RepoTarget | undefined {
649
657
  const parts = prUrlParts(prUrl);
650
- if (parts === undefined) return false;
658
+ if (parts === undefined) return undefined;
651
659
  const slug = `${parts.owner}/${parts.repo}`;
652
- return Object.values(project.routing.repos).some((repo) => repoSlugFor(repo) === slug);
660
+ return Object.values(project.routing.repos).find((repo) => repoSlugFor(repo) === slug);
661
+ }
662
+
663
+ function prInProjectRouting(project: ProjectConfig, prUrl: string): boolean {
664
+ return repoForPr(project, prUrl) !== undefined;
653
665
  }
654
666
 
655
667
  /** Refuse an orchestrator completion mutation that cannot prove a pre-pause run. */
@@ -747,11 +759,26 @@ async function prMergeVerb(
747
759
  refuse: Refuse,
748
760
  allow: Allow,
749
761
  ): Promise<Verdict> {
750
- // The grant says *who*. Compared against the caller rather than tested for a
751
- // single forbidden value: `authority` has two holders, so `!== "human"` would
752
- // have let a worker through, and `"human"` has to refuse the orchestrator too.
762
+
763
+ const prUrl = String(args["prUrl"]);
764
+ const headSha = String(args["headSha"]);
765
+ const reason = String(args["reason"]);
766
+ const target = runForPr(deps, project.name, prUrl);
767
+ const recovery =
768
+ target === undefined
769
+ ? project.recoveryMerges?.find((authorization) => authorization.prUrl === prUrl)
770
+ : undefined;
771
+ const routedRepo = repoForPr(project, prUrl);
772
+ const authorizedRecovery =
773
+ recovery !== undefined &&
774
+ recovery.headSha.toLowerCase() === headSha.toLowerCase() &&
775
+ recovery.reason === reason;
776
+
777
+ // Ordinary calls obey the standing holder. A recovery entry is itself the
778
+ // operator's one-PR grant to the orchestrator, so its exact URL/head/reason
779
+ // match replaces that broad holder check without widening any other merge.
753
780
  const holder = project.authority.merge;
754
- if (holder !== channel.role) {
781
+ if (holder !== channel.role && !authorizedRecovery) {
755
782
  return refuse(
756
783
  "authority-holder",
757
784
  `refused: merge authority is the ${holder}'s for ${project.name}, and this is a ${channel.role} session. ` +
@@ -760,16 +787,27 @@ async function prMergeVerb(
760
787
  : "Ask the operator which session is meant to hold it."),
761
788
  );
762
789
  }
763
-
764
- const prUrl = String(args["prUrl"]);
765
- const headSha = String(args["headSha"]);
766
- const target = runForPr(deps, project.name, prUrl);
767
- if (target === undefined) {
790
+ if (target === undefined && recovery === undefined) {
768
791
  return refuse("pr-not-this-run", `refused: ${prUrl} is not a pull request any run in ${project.name} opened.`);
769
792
  }
793
+ if (target === undefined && routedRepo === undefined) {
794
+ return refuse(
795
+ "pr-not-this-run",
796
+ `refused: ${prUrl} is not in ${project.name}'s routed repositories; a recovery authorization cannot widen project scope.`,
797
+ );
798
+ }
799
+ if (recovery !== undefined && !authorizedRecovery) {
800
+ return refuse(
801
+ "recovery-authorization-mismatch",
802
+ `refused: ${prUrl} has a recovery authorization, but not for this exact head and reason.`,
803
+ );
804
+ }
805
+ const issue = target?.issue;
770
806
 
771
- const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
772
- if (paused !== undefined) return paused;
807
+ if (!authorizedRecovery) {
808
+ const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
809
+ if (paused !== undefined) return paused;
810
+ }
773
811
 
774
812
  // Taken before any network call, so two concurrent callers contend here
775
813
  // rather than both spending a `gh` round trip and racing at the merge.
@@ -782,7 +820,7 @@ async function prMergeVerb(
782
820
  `refused: a merge is already in flight for ${project.name}` +
783
821
  (live === undefined ? "" : ` (${live.prUrl})`) +
784
822
  ". PRs land one at a time, each re-checked against its base: two agent merges at once is how they clobber each other.",
785
- target.issue,
823
+ issue,
786
824
  );
787
825
  }
788
826
 
@@ -795,13 +833,13 @@ async function prMergeVerb(
795
833
  verification = await deps.tracker.verifyPr(prUrl, headSha);
796
834
  } catch (err) {
797
835
  const why = err instanceof Error ? err.message : String(err);
798
- return refuse("head-unresolvable", `refused: the live head of ${prUrl} could not be read (${why}).`, target.issue);
836
+ return refuse("head-unresolvable", `refused: the live head of ${prUrl} could not be read (${why}).`, issue);
799
837
  }
800
838
  if (verification === undefined) {
801
839
  return refuse(
802
840
  "head-unresolvable",
803
841
  `refused: the live head of ${prUrl} could not be resolved. Refusing rather than merging what you last saw.`,
804
- target.issue,
842
+ issue,
805
843
  );
806
844
  }
807
845
  if (verification.status !== "green") {
@@ -812,11 +850,14 @@ async function prMergeVerb(
812
850
  ? `refused: ${verification.reason}. You asked to merge ${headSha}; that is not what is on the branch now. ` +
813
851
  "Re-read the head, re-check the checks at it, and call again."
814
852
  : `refused: ${prUrl} is not mergeable at ${headSha} — ${verification.reason}.`,
815
- target.issue,
853
+ issue,
816
854
  );
817
855
  }
818
856
 
819
- const repoTarget = Object.values(project.routing.repos).find((r) => r.name === target.repo);
857
+ const repoTarget =
858
+ target === undefined
859
+ ? routedRepo
860
+ : Object.values(project.routing.repos).find((repo) => repo.name === target.repo);
820
861
  const chainDir = repoTarget?.migrations?.dir;
821
862
  if (chainDir !== undefined && repoTarget !== undefined) {
822
863
  // Checked against the base tip at merge time, not against a stale advisory:
@@ -826,7 +867,7 @@ async function prMergeVerb(
826
867
  return refuse(
827
868
  "chain-unreadable",
828
869
  `refused: the diff of ${prUrl} could not be read, so the migration chain in ${chainDir} cannot be verified. Failing closed: a wrong merge here is a silent production schema drift (#227).`,
829
- target.issue,
870
+ issue,
830
871
  );
831
872
  }
832
873
  const { added, changed, deleted } = chainEntriesFromDiff(diff.files, chainDir);
@@ -835,7 +876,7 @@ async function prMergeVerb(
835
876
  return refuse(
836
877
  "chain-unreadable",
837
878
  `refused: the diff of ${prUrl} was truncated and it touches ${chainDir} — the chain cannot be verified from a partial diff.`,
838
- target.issue,
879
+ issue,
839
880
  );
840
881
  }
841
882
  const base = await deps.chain.readBaseChain(project, repoTarget, chainDir);
@@ -843,7 +884,7 @@ async function prMergeVerb(
843
884
  return refuse(
844
885
  "chain-unreadable",
845
886
  `refused: could not read ${chainDir} at the tip of ${repoTarget.defaultBranch} (${base.stderr}).`,
846
- target.issue,
887
+ issue,
847
888
  );
848
889
  }
849
890
  const violations = chainViolations({ base: base.entries, added, changed, deleted });
@@ -851,7 +892,7 @@ async function prMergeVerb(
851
892
  return refuse(
852
893
  "chain-conflict",
853
894
  `refused: merging ${prUrl} would corrupt the ordered migration chain in ${chainDir} — ${violations.join("; ")}. Rebase onto the current tip, regenerate the migration, and call again.`,
854
- target.issue,
895
+ issue,
855
896
  );
856
897
  }
857
898
  }
@@ -859,12 +900,14 @@ async function prMergeVerb(
859
900
 
860
901
  const outcome = await deps.actions.mergePr(prUrl, headSha);
861
902
  if (!outcome.ok) {
862
- return refuse("action-failed", `refused: gh could not merge:\n${outcome.stderr}`, target.issue);
903
+ return refuse("action-failed", `refused: gh could not merge:\n${outcome.stderr}`, issue);
863
904
  }
864
905
  return allow(
865
- `merged ${prUrl} at ${headSha} (${verification.reason}).`,
906
+ `merged ${prUrl} at ${headSha} (${verification.reason})${
907
+ recovery === undefined ? "." : " under its exact operator recovery authorization."
908
+ }`,
866
909
  outcome.sha ?? headSha,
867
- target.issue,
910
+ issue,
868
911
  );
869
912
  } finally {
870
913
  deps.store.releaseMergeLock(project.name, holderId);
@@ -1031,6 +1074,77 @@ async function releaseVerb(
1031
1074
  if (!outcome.ok) {
1032
1075
  return refuse("action-failed", `refused: the release command failed:\n${outcome.stderr}`);
1033
1076
  }
1077
+ if (shape === "version-bump-pr") {
1078
+ if (outcome.review === undefined) {
1079
+ return allow(
1080
+ `release version for ${repo.name} is already prepared${outcome.detail === undefined ? "" : ` — ${outcome.detail}`}.`,
1081
+ outcome.sha,
1082
+ );
1083
+ }
1084
+ if (outcome.sha === undefined) {
1085
+ return refuse("action-failed", "refused: version-bump-pr returned no exact pull-request head.");
1086
+ }
1087
+ if (outcome.review.created) {
1088
+ return allow(
1089
+ `opened release preparation ${outcome.review.prUrl} at ${outcome.sha}; wait for its checks, then call version-bump-pr again.`,
1090
+ outcome.sha,
1091
+ );
1092
+ }
1093
+
1094
+ const holderId = randomUUID();
1095
+ const lock = deps.store.acquireMergeLock(
1096
+ project.name,
1097
+ holderId,
1098
+ outcome.review.prUrl,
1099
+ deps.now(),
1100
+ MERGE_LOCK_STALE_MS,
1101
+ );
1102
+ if (lock === undefined) {
1103
+ const live = deps.store.mergeLock(project.name);
1104
+ return refuse(
1105
+ "merge-in-flight",
1106
+ `refused: a merge is already in flight for ${project.name}` +
1107
+ (live === undefined ? "" : ` (${live.prUrl})`) +
1108
+ ". Release preparation waits for the project's single merge slot.",
1109
+ );
1110
+ }
1111
+ try {
1112
+ let verification: PrVerification | undefined;
1113
+ try {
1114
+ verification = await deps.tracker.verifyPr(outcome.review.prUrl, outcome.sha);
1115
+ } catch (err) {
1116
+ const why = err instanceof Error ? err.message : String(err);
1117
+ return refuse(
1118
+ "head-unresolvable",
1119
+ `refused: the live head of release preparation ${outcome.review.prUrl} could not be read (${why}).`,
1120
+ );
1121
+ }
1122
+ if (verification === undefined) {
1123
+ return refuse(
1124
+ "head-unresolvable",
1125
+ `refused: the live head of release preparation ${outcome.review.prUrl} could not be resolved.`,
1126
+ );
1127
+ }
1128
+ if (verification.status !== "green") {
1129
+ const stale = verification.status === "failed" && isHeadMismatch(verification.reason);
1130
+ return refuse(
1131
+ stale ? "head-stale" : "checks-not-green",
1132
+ `refused: release preparation ${outcome.review.prUrl} is not mergeable at ${outcome.sha} — ${verification.reason}.`,
1133
+ );
1134
+ }
1135
+
1136
+ const merged = await deps.actions.mergePr(outcome.review.prUrl, outcome.sha);
1137
+ if (!merged.ok) {
1138
+ return refuse("action-failed", `refused: gh could not merge the release preparation:\n${merged.stderr}`);
1139
+ }
1140
+ return allow(
1141
+ `merged release preparation ${outcome.review.prUrl} at ${outcome.sha}; git-tag remains refused until the live version matches.`,
1142
+ merged.sha ?? outcome.sha,
1143
+ );
1144
+ } finally {
1145
+ deps.store.releaseMergeLock(project.name, holderId);
1146
+ }
1147
+ }
1034
1148
  return allow(
1035
1149
  `cut ${shape} for ${repo.name}${outcome.detail === undefined ? "" : ` — ${outcome.detail}`}.`,
1036
1150
  outcome.sha,