omp-conductor 0.15.10 → 0.15.12

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 (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +150 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +100 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +32 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
@@ -15,9 +15,13 @@
15
15
  */
16
16
 
17
17
  import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor, scrubUserinfo } from "../gitops.ts";
18
+ import { livingDaemon } from "../lifecycle.ts";
19
+ import { stateDir } from "../config.ts";
18
20
  import { ensureMirror } from "../worktree.ts";
21
+ import { launchTransientUnit, readUpgradeJournal } from "../upgrade-journal.ts";
22
+ import { classifyUpgrade } from "../upgrade-verify.ts";
19
23
  import type { ProjectConfig, ReleaseShape, RepoTarget } from "../types.ts";
20
- import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
24
+ import type { ActionOutcome, InstallExecution, ReleaseExecution, VerbActions } from "./server.ts";
21
25
 
22
26
  /**
23
27
  * What this daemon can actually cut.
@@ -779,5 +783,73 @@ export function githubVerbActions(
779
783
  }
780
784
  : failed(pushed.result, pushed.argv);
781
785
  },
786
+
787
+ // The fleet-installs-itself request (#486). Two gates, then a spawn:
788
+ //
789
+ // 1. `npm view` must actually expose the version with a full gitHead —
790
+ // "install it" may only name what npm can serve. Refused with npm's
791
+ // own detail so the orchestrator learns why, not that something was
792
+ // wrong.
793
+ // 2. A live daemon must exist to verify the install on its first tick
794
+ // after the restart. A stopped daemon can never come back to do that,
795
+ // so the request is refused and the operator is pointed at the plain
796
+ // `upgrade` command, whose own process is its witness.
797
+ install: async (execution: InstallExecution): Promise<ActionOutcome> => {
798
+ if (livingDaemon() === undefined) {
799
+ return {
800
+ ok: false,
801
+ stderr:
802
+ "the dispatch daemon is not running, so nobody can verify this install after the restart " +
803
+ "it performs. Run `omp-conductor upgrade` from a shell instead.",
804
+ };
805
+ }
806
+ // One install at a time: the journal's newest request is either closed or
807
+ // in flight, and a second request while one is in flight would race it
808
+ // for the same restarts. The refusal names the state so the caller knows
809
+ // to wait for the verifier's outcome instead of retrying blind.
810
+ const pending = classifyUpgrade(readUpgradeJournal(stateDir()));
811
+ if (pending.kind !== "none" && pending.kind !== "closed") {
812
+ return {
813
+ ok: false,
814
+ stderr:
815
+ `an install is already in flight (journal verdict: ${pending.kind}); ` +
816
+ "the fleet verifies or rolls it back before accepting another request.",
817
+ };
818
+ }
819
+ const looked = await run(
820
+ ["npm", "view", `omp-conductor@${execution.version}`, "version", "gitHead", "--json"],
821
+ { env: env() },
822
+ );
823
+ if (!looked.ok) {
824
+ const detail = looked.stderr.trim() || looked.stdout.trim() || "the npm view command failed";
825
+ return {
826
+ ok: false,
827
+ stderr:
828
+ `npm does not expose omp-conductor@${execution.version}: ${detail}. ` +
829
+ "Only a published release with a full gitHead can be installed; the request was not started.",
830
+ };
831
+ }
832
+ const metadata = objectFromJson(looked.stdout);
833
+ const published = metadata === undefined ? undefined : String(metadata["version"] ?? "");
834
+ const gitHead = metadata === undefined ? undefined : String(metadata["gitHead"] ?? "");
835
+ if (published !== execution.version || !/^[0-9a-f]{40}$/i.test(gitHead ?? "")) {
836
+ return {
837
+ ok: false,
838
+ stderr:
839
+ `npm does not expose omp-conductor@${execution.version} with a full gitHead ` +
840
+ `(${published === undefined ? "unreadable metadata" : `saw ${published}`}); the request was not started.`,
841
+ };
842
+ }
843
+ const launched = await launchTransientUnit(
844
+ async (argv) => {
845
+ const ran = await run([...argv] as string[], { env: env() });
846
+ return { code: ran.ok ? 0 : 1, stdout: ran.stdout, stderr: ran.stderr };
847
+ },
848
+ env(),
849
+ { kind: "install", version: execution.version },
850
+ );
851
+ if (!launched.ok) return { ok: false, stderr: launched.stderr };
852
+ return { ok: true, detail: launched.unit };
853
+ },
782
854
  };
783
855
  }
@@ -288,15 +288,40 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
288
288
  `release authority is the orchestrator's, and this is a ${role} session. ` +
289
289
  "A worker session holds no release grant whatever the config says.",
290
290
  },
291
+ conductor_install: {
292
+ name: "conductor_install",
293
+ mutating: true,
294
+ allowedRoles: ["orchestrator"],
295
+ description:
296
+ "Request the fleet upgrade itself to a published omp-conductor release (#486). The install " +
297
+ "shape gates it like a release shape and stays with the human by default; with the grant, " +
298
+ "the daemon validates the version against npm and starts a detached transient unit that " +
299
+ "pauses, drains, installs the CLI/omp plugin/Herdr plugin and reloads — outside this session " +
300
+ "and the daemon. The unit never declares its own success: the first tick after the restart " +
301
+ "verifies version, /healthz, ticks, pane and doctor, and reports through the durable outbox.",
302
+ args: {
303
+ version: {
304
+ type: "string",
305
+ required: true,
306
+ description:
307
+ "The exact published semver to install (e.g. 0.15.11). The request is refused when npm " +
308
+ "does not expose it with a full gitHead.",
309
+ },
310
+ rationale: RATIONALE_ARG,
311
+ },
312
+ roleRefusalText: (role) =>
313
+ `install authority is the orchestrator's, and this is a ${role} session. ` +
314
+ "A worker session holds no install grant whatever the config says.",
315
+ },
291
316
  conductor_pr_status: {
292
317
  name: "conductor_pr_status",
293
318
  mutating: false,
294
319
  allowedRoles: ["worker", "orchestrator"],
295
320
  description:
296
- "Read one pull request's live state and current head: is it open and are its checks green. " +
297
- "Optionally compare that head with one the caller already observed. This is the daemon's own " +
298
- "merge-gate verdict, not a summary of it. A poll, not a watcher — call it again after waiting. " +
299
- "A worker may omit prUrl and gets its own run's.",
321
+ "Read one pull request's live state and current head — open, merged, or closed — and the checks " +
322
+ "available for it (checks are unavailable for a merged or closed PR). Optionally compare that head " +
323
+ "with one the caller already observed. This is the daemon's own merge-gate verdict, not a summary of it. " +
324
+ "A poll, not a watcher — call it again after waiting. A worker may omit prUrl and gets its own run's.",
300
325
  args: {
301
326
  prUrl: {
302
327
  type: "string",
@@ -139,6 +139,12 @@ export interface ReleaseExecution {
139
139
  environment?: string;
140
140
  }
141
141
 
142
+ /** What one install request asks the privileged half to do, after policy said yes. */
143
+ export interface InstallExecution {
144
+ /** The published semver to pin all three surfaces to, exactly as npm has it. */
145
+ version: string;
146
+ }
147
+
142
148
  /**
143
149
  * The privileged half, as an interface so every check above it is testable
144
150
  * without a repository, a credential or a network.
@@ -163,6 +169,13 @@ export interface VerbActions {
163
169
  mergePr(prUrl: string, headSha: string): Promise<ActionOutcome>;
164
170
  setLabel(issue: number, label: string, action: "add" | "remove"): Promise<ActionOutcome>;
165
171
  release(execution: ReleaseExecution): Promise<ActionOutcome>;
172
+ /**
173
+ * Request the fleet upgrade itself to `version`: refuse when npm does not
174
+ * expose it, then start the detached transient unit that performs it and
175
+ * survives the restarts. The implementation owns the npm read and the
176
+ * systemd-run spawn (#486) — the server only gates and records.
177
+ */
178
+ install(execution: InstallExecution): Promise<ActionOutcome>;
166
179
  }
167
180
 
168
181
  export interface VerbDeps {
@@ -272,6 +285,19 @@ export interface ReleaseFacts {
272
285
  unsettledRuns: number;
273
286
  /** Of those, the ones with a pull request still open. */
274
287
  openPrs: number;
288
+ /**
289
+ * Of those, the ones with a live worker (claimed/running), as opposed to
290
+ * finished pushes. Absent when the caller could not distinguish (the plain
291
+ * refusal wording is used then).
292
+ */
293
+ liveWorkers?: number;
294
+ /**
295
+ * Of those, finished runs whose PR the tracker could not confirm merged yet
296
+ * — the settle sweep either has not run or could not read GitHub. Distinct
297
+ * from {@link liveWorkers} because only a live worker means the operator
298
+ * must wait on a process; these clear by themselves on the next sweep.
299
+ */
300
+ unconfirmedMerges?: number;
275
301
  /** Queue depth, or `undefined` when the tracker could not be read. */
276
302
  queueDepth: number | undefined;
277
303
  /** Current live-head workflow verdict for the released routed repository. */
@@ -286,7 +312,26 @@ export function releaseRequirementRefusal(
286
312
  ): string | undefined {
287
313
  for (const requirement of requires) {
288
314
  if (requirement === "runs-settled" && facts.unsettledRuns > 0) {
289
- return `policy.release.requires includes runs-settled and ${facts.unsettledRuns} run(s) have not settled`;
315
+ // The same blocker reads differently depending on WHY the row is still
316
+ // active: a live worker is the operator's wait, an unconfirmed merge or
317
+ // an unrecorded sweep clears by itself on the next settle pass. The
318
+ // gate has the split; the message must hand it to the reader.
319
+ const live = facts.liveWorkers ?? 0;
320
+ const unconfirmed = facts.unconfirmedMerges ?? 0;
321
+ // openPrs counts verifiably-open PRs *and* unread ones (fail-closed for
322
+ // no-open-prs), so the verifiably-open sub-bucket is the difference.
323
+ const open = Math.max(0, (facts.openPrs ?? 0) - unconfirmed);
324
+ // Everything not named above: closed unmerged PRs and rows with no PR.
325
+ const remaining = facts.unsettledRuns - live - unconfirmed - open;
326
+ const parts: string[] = [];
327
+ if (live > 0) parts.push(`${live} worker(s) still running`);
328
+ if (open > 0) parts.push(`${open} pull request(s) still open`);
329
+ if (unconfirmed > 0) parts.push(`${unconfirmed} run(s) whose PR merge the settle sweep has not yet recorded`);
330
+ if (remaining > 0) parts.push(`${remaining} run(s) with no merged PR (closed or none yet)`);
331
+ return (
332
+ `policy.release.requires includes runs-settled and ${facts.unsettledRuns} run(s) have not settled` +
333
+ (parts.length === 0 ? "" : ` — ${parts.join("; ")}`)
334
+ );
290
335
  }
291
336
  if (requirement === "no-open-prs" && facts.openPrs > 0) {
292
337
  return `policy.release.requires includes no-open-prs and ${facts.openPrs} pull request(s) are still open`;
@@ -462,7 +507,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
462
507
  verb === "conductor_pr_update" ||
463
508
  verb === "conductor_label");
464
509
  const stopped =
465
- spec.mutating && verb !== "conductor_release" && !orchestratorCompletion
510
+ spec.mutating && verb !== "conductor_release" && verb !== "conductor_install" && !orchestratorCompletion
466
511
  ? deps.fleetStop()
467
512
  : undefined;
468
513
  if (stopped !== undefined) {
@@ -473,7 +518,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
473
518
  "fleet-paused",
474
519
  `refused: ${stopped}. The pause refuses new claims and work-starting mutations. ` +
475
520
  "Completion verbs for runs admitted before the pause (conductor_pr_merge, conductor_pr_update_branch, " +
476
- "conductor_pr_update, conductor_label) and conductor_release remain available.",
521
+ "conductor_pr_update, conductor_label), conductor_release and conductor_install remain available.",
477
522
  );
478
523
  }
479
524
  }
@@ -493,6 +538,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
493
538
  return labelVerb(deps, project, channel, args, refuse, allow);
494
539
  case "conductor_release":
495
540
  return releaseVerb(deps, project, channel, args, refuse, allow);
541
+ case "conductor_install":
542
+ return installVerb(deps, project, channel, args, refuse, allow);
496
543
  case "conductor_pr_status":
497
544
  return prStatusVerb(deps, project, channel, args, refuse, allow);
498
545
  }
@@ -641,9 +688,11 @@ async function prCreateVerb(
641
688
  export const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
642
689
 
643
690
  function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | undefined {
644
- return deps.store
645
- .recentRuns(project, deps.now() - PR_LOOKUP_WINDOW_MS)
646
- .find((candidate) => candidate.prUrl === prUrl);
691
+ // Every attempt that recorded this PR, newest first — not `recentRuns`'
692
+ // newest-attempt-per-issue view. The row a PR belongs to is the newest
693
+ // attempt that owns it, and a requeued continuation that never inherited
694
+ // the URL must not hide its predecessor's ownership (#434).
695
+ return deps.store.runsForProjectPr(project, prUrl, deps.now() - PR_LOOKUP_WINDOW_MS)[0];
647
696
  }
648
697
 
649
698
  /**
@@ -1043,9 +1092,69 @@ async function releaseVerb(
1043
1092
  const health = wantsBase
1044
1093
  ? deps.store.baseHealth(project.name).find((row) => row.repo === repoName)
1045
1094
  : undefined;
1095
+
1096
+ // `runs-settled` (and `no-open-prs`) read the PR fact, not the row (#482):
1097
+ // a `pushed-green` row whose PR has actually merged is settled work, and a
1098
+ // merged PR is not an open one. The settle sweep records the same fact on
1099
+ // its next tick; the gate must not wait an extra tick the operator reached
1100
+ // this release by holding. Fail-closed stays fail-closed: a row whose PR
1101
+ // state cannot be read is still unsettled, and an unknown PR is still
1102
+ // treated as open.
1103
+ const wantsRunFacts =
1104
+ policy.release.requires.includes("runs-settled") ||
1105
+ policy.release.requires.includes("no-open-prs");
1106
+ let unsettledRuns = active.length;
1107
+ let openPrs = active.filter((r) => r.prUrl !== undefined).length;
1108
+ let liveWorkers: number | undefined;
1109
+ let unconfirmedMerges: number | undefined;
1110
+ if (wantsRunFacts) {
1111
+ let unsettled = 0;
1112
+ let open = 0;
1113
+ let live = 0;
1114
+ let unconfirmed = 0;
1115
+ for (const run of active) {
1116
+ if (run.state === "claimed" || run.state === "running") {
1117
+ unsettled += 1;
1118
+ live += 1;
1119
+ // A live worker's PR is not yet confirmable; count it as open, the
1120
+ // same fail-closed answer the old row-count gave it.
1121
+ if (run.prUrl !== undefined) open += 1;
1122
+ continue;
1123
+ }
1124
+ // pushed-pending / pushed-green: the worker is finished; the PR decides.
1125
+ if (run.prUrl === undefined) {
1126
+ unsettled += 1; // nothing to read; the sweep cannot settle it either
1127
+ continue;
1128
+ }
1129
+ let pr: PrState | undefined;
1130
+ try {
1131
+ pr = await deps.tracker.prState(run.prUrl);
1132
+ } catch {
1133
+ pr = undefined;
1134
+ }
1135
+ if (pr === "merged") continue; // settled in fact; not an open PR
1136
+ unsettled += 1;
1137
+ if (pr === "open") open += 1;
1138
+ else if (pr === undefined) {
1139
+ // Cannot confirm the merge (or its absence): fail closed, and count
1140
+ // the row as an unconfirmed merge. Also open — an unread PR may be
1141
+ // open — so `no-open-prs` keeps its fail-closed refusal.
1142
+ open += 1;
1143
+ unconfirmed += 1;
1144
+ }
1145
+ // "closed": the row still owes a failure write from the sweep; blocks
1146
+ // exactly as it did before, the message bucket covers it.
1147
+ }
1148
+ unsettledRuns = unsettled;
1149
+ openPrs = open;
1150
+ liveWorkers = live;
1151
+ unconfirmedMerges = unconfirmed;
1152
+ }
1046
1153
  const unmet = releaseRequirementRefusal(policy.release.requires, {
1047
- unsettledRuns: active.length,
1048
- openPrs: active.filter((r) => r.prUrl !== undefined).length,
1154
+ unsettledRuns,
1155
+ openPrs,
1156
+ ...(liveWorkers === undefined ? {} : { liveWorkers }),
1157
+ ...(unconfirmedMerges === undefined ? {} : { unconfirmedMerges }),
1049
1158
  queueDepth,
1050
1159
  ...(health === undefined ? {} : { baseCheck: health.verdict }),
1051
1160
  ...(health?.verdict === "red" && health.detail !== undefined
@@ -1152,14 +1261,66 @@ async function releaseVerb(
1152
1261
  }
1153
1262
 
1154
1263
 
1264
+ /**
1265
+ * The fleet-installs-itself request (#486).
1266
+ *
1267
+ * Gated like a release act: the caller must be an orchestrator (spec), and the
1268
+ * `install` shape must be granted to the orchestrator (release policy) — the
1269
+ * package floor's "nobody patches the running conductor" is the deny default
1270
+ * an operator deliberately moves, never something this verb works around.
1271
+ *
1272
+ * Everything past the gate is the privileged half's job: `actions.install`
1273
+ * refussses a version npm does not expose and starts the detached transient
1274
+ * unit. This verb records the request durably (the journal the post-restart
1275
+ * tick verifies against) and tells the caller where the evidence will land.
1276
+ */
1277
+ async function installVerb(
1278
+ deps: VerbDeps,
1279
+ project: ProjectConfig,
1280
+ channel: VerbChannel,
1281
+ args: Record<string, unknown>,
1282
+ refuse: Refuse,
1283
+ allow: Allow,
1284
+ ): Promise<Verdict> {
1285
+ const grants = resolveReleaseGrants(project);
1286
+ const granted = releaseRefusal(grants, channel.role, "install");
1287
+ if (granted !== undefined) {
1288
+ return refuse("release-not-granted", `refused: ${granted.reason}`);
1289
+ }
1290
+
1291
+ const version = args["version"];
1292
+ if (typeof version !== "string" || version.trim().length === 0) {
1293
+ return refuse(
1294
+ "malformed-argument",
1295
+ `refused: conductor_install needs a version, got ${JSON.stringify(version)}. ` +
1296
+ "Name the exact published semver, e.g. 0.15.11.",
1297
+ );
1298
+ }
1299
+
1300
+ const outcome = await deps.actions.install({ version: version.trim() });
1301
+ if (!outcome.ok) {
1302
+ return refuse("action-failed", `refused: the install request failed:\n${outcome.stderr}`);
1303
+ }
1304
+ const unit = outcome.detail;
1305
+ return allow(
1306
+ `install requested: omp-conductor@${version} will be installed by a detached unit` +
1307
+ `${unit === undefined ? "" : ` (${unit})`} that survives the restart. ` +
1308
+ "The first tick after the fleet restarts verifies version, /healthz, ticks, pane and doctor, " +
1309
+ "and the outcome is reported through the durable outbox.",
1310
+ );
1311
+ }
1312
+
1155
1313
  /**
1156
1314
  * The one read verb, and the reason a worker reports a PR's real state rather
1157
1315
  * than its own recollection.
1158
1316
  *
1159
- * Reuses `Tracker.verifyPr`, so the answer is literally the merge gate's own
1160
- * verdict rather than a second opinion assembled here — including the
1161
- * moved-head case, whose reason already names both shas. A worker that quotes
1162
- * this has read a fact; a worker that says "should be green" has not.
1317
+ * Reuses `Tracker.verifyPr` in read mode, so the answer is literally the merge
1318
+ * gate's own verdict rather than a second opinion assembled here — including
1319
+ * the moved-head case, whose reason already names both shas. A worker that
1320
+ * quotes this has read a fact; a worker that says "should be green" has not.
1321
+ * Read mode also reports a merged or closed PR as a fact (`ok: true`) instead
1322
+ * of the merge gate's `expected OPEN` refusal, so an orchestrator can confirm
1323
+ * release evidence on a just-merged PR (#389).
1163
1324
  */
1164
1325
  async function prStatusVerb(
1165
1326
  deps: VerbDeps,
@@ -1190,17 +1351,16 @@ async function prStatusVerb(
1190
1351
  prUrl = own.prUrl;
1191
1352
  issue = channel.issue;
1192
1353
  } else {
1354
+ // A read resolves any syntactically valid PR URL — nothing to gate. A
1355
+ // release-preparation PR the workflow opened (never a run row, and not
1356
+ // necessarily in `routing.repos`) still has to be confirmable before the
1357
+ // orchestrator cuts a release (#389). `runForPr` only decides which issue
1358
+ // to record on the ledger; `verifyPr`'s `PR_URL` check rejects a URL that
1359
+ // is not a pull request.
1193
1360
  if (typeof asked !== "string") {
1194
1361
  return refuse("malformed-argument", "refused: conductor_pr_status needs prUrl when it has no run to infer one from.");
1195
1362
  }
1196
1363
  const target = runForPr(deps, project.name, asked);
1197
- if (target === undefined && !prInProjectRouting(project, asked)) {
1198
- return refuse(
1199
- "pr-not-this-run",
1200
- `refused: ${asked} is not a pull request in ${project.name}'s routed repositories ` +
1201
- `(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}).`,
1202
- );
1203
- }
1204
1364
  prUrl = asked;
1205
1365
  issue = target?.issue;
1206
1366
  }
@@ -1209,7 +1369,10 @@ async function prStatusVerb(
1209
1369
  const expectedHead = typeof askedHead === "string" ? askedHead : undefined;
1210
1370
  let verification: PrVerification | undefined;
1211
1371
  try {
1212
- verification = await deps.tracker.verifyPr(prUrl, expectedHead);
1372
+ // Read mode: a merged or closed PR is a fact to report, not the merge
1373
+ // gate's `expected OPEN` refusal (#389). The mutating verbs never pass
1374
+ // this, so their open-PR gate is untouched.
1375
+ verification = await deps.tracker.verifyPr(prUrl, expectedHead, { read: true });
1213
1376
  } catch (err) {
1214
1377
  verification = undefined;
1215
1378
  deps.log(`verb pr_status could not read ${prUrl}: ${err instanceof Error ? err.message : String(err)}`);
package/src/worker.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  */
12
12
 
13
13
  import { createSession, disposeSession, SessionAdmissionClosedError, type AgentSessionLike } from "./omp.ts";
14
- import type { ReleaseBlockContext } from "./release-policy.ts";
15
- import type { Caps, ReleaseShape, ResolvedGrants, RunState } from "./types.ts";
14
+ import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
15
+ import type { Caps, ResolvedGrants, RunState } from "./types.ts";
16
16
 
17
17
  /** Structured evidence fields from the worker's final report. */
18
18
  const PR_URL_PATTERN = /^pr:\s*(https:\/\/github\.com\/\S+\/pull\/\d+)\s*$/im;
@@ -86,7 +86,7 @@ export interface WorkerOpts {
86
86
  */
87
87
  releaseGrants?: ResolvedGrants;
88
88
  /** Durable audit sink for rejected release/deploy calls. */
89
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
89
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
90
90
  /** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
91
91
  onSpawn?: (pid: number) => void;
92
92
  /** Control socket for that child, beside the run's own session directory. */