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
@@ -20,6 +20,13 @@ import { stateDir } from "../config.ts";
20
20
  import { ensureMirror } from "../worktree.ts";
21
21
  import { launchTransientUnit, readUpgradeJournal } from "../upgrade-journal.ts";
22
22
  import { classifyUpgrade } from "../upgrade-verify.ts";
23
+ import {
24
+ GraphqlBreaker,
25
+ graphqlBreakerRefusal,
26
+ isGraphqlSurface,
27
+ isTransientServer5xx,
28
+ prUrlParts,
29
+ } from "../tracker/github.ts";
23
30
  import type { ProjectConfig, ReleaseShape, RepoTarget } from "../types.ts";
24
31
  import type { ActionOutcome, InstallExecution, ReleaseExecution, VerbActions } from "./server.ts";
25
32
 
@@ -204,15 +211,32 @@ export function githubVerbActions(
204
211
  project: ProjectConfig,
205
212
  run: CommandRunner = spawnCommand,
206
213
  prepareMirror: MirrorPreparer = ensureMirror,
214
+ graphqlBreaker?: GraphqlBreaker,
207
215
  ): VerbActions {
208
216
  const env = (): Record<string, string> => credentialedEnv();
209
217
 
210
218
  const runGh = async (argv: string[], cwd?: string): Promise<{ argv: string[]; result: CommandRun }> => {
211
219
  const full = ["gh", ...argv];
212
- return {
213
- argv: full,
214
- result: await run(full, { env: env(), ...(cwd === undefined ? {} : { cwd }) }),
215
- };
220
+ // The mutation surface rides the same GraphQL endpoint the tracker's
221
+ // parent/closer checks ride in the pinned gh (2.86): `pr merge`,
222
+ // `pr update-branch`, `pr edit`, `pr create`. When a transient 5xx has
223
+ // already proven that surface unavailable — observed by admission or by a
224
+ // sibling mutation this pass — a mutation must fast-fail WITHOUT spawning
225
+ // `gh`, exactly like the tracker's candidate checks (#642 comment 4):
226
+ // attempting a mutation into a known-broken surface is how one provider
227
+ // outage defeats queue locking. REST-backed commands (`gh api repos/...`,
228
+ // `gh release create`) keep running: the outage is GraphQL-only.
229
+ if (graphqlBreaker !== undefined && isGraphqlSurface(argv) && graphqlBreaker.refused()) {
230
+ return {
231
+ argv: full,
232
+ result: { ok: false, stdout: "", stderr: graphqlBreakerRefusal(graphqlBreaker.until) },
233
+ };
234
+ }
235
+ const result = await run(full, { env: env(), ...(cwd === undefined ? {} : { cwd }) });
236
+ if (!result.ok && graphqlBreaker !== undefined && isGraphqlSurface(argv) && isTransientServer5xx(result.stderr)) {
237
+ graphqlBreaker.open();
238
+ }
239
+ return { argv: full, result };
216
240
  };
217
241
 
218
242
  const gh = async (argv: string[], cwd?: string): Promise<ActionOutcome> => {
@@ -573,7 +597,44 @@ export function githubVerbActions(
573
597
 
574
598
  createPr: (target, opts) => openRunPr(project, target, opts),
575
599
 
576
- updatePrBranch: (prUrl) => gh(["pr", "update-branch", prUrl]),
600
+ updatePrBranch: async (prUrl) => {
601
+ // The branch refresh rides the REST update-branch endpoint, not `gh pr
602
+ // update-branch` (#655): `gh pr update-branch` is a GraphQL mutation, so
603
+ // a selective GraphQL outage made an otherwise mergeable PR unrefreshable
604
+ // even while REST answered. Both calls here are core REST, so they keep
605
+ // running while the GraphQL breaker is open.
606
+ const parts = prUrlParts(prUrl);
607
+ if (parts === undefined) {
608
+ return { ok: false, stderr: `could not parse ${prUrl} as a pull request URL` };
609
+ }
610
+ const pulls = `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`;
611
+ // The stale-head guard needs the exact head the update is bound to. It is
612
+ // the last observed head of the branch refresh, so a concurrent push is
613
+ // refused (422) rather than updating an unreviewed head.
614
+ const head = await runGh(["api", pulls, "--jq", ".head.sha"]);
615
+ if (!head.result.ok) return failed(head.result, head.argv);
616
+ const headSha = commitFrom(head.result.stdout);
617
+ if (headSha === undefined) {
618
+ return { ok: false, stderr: `${prUrl} returned no full head commit SHA` };
619
+ }
620
+ const updated = await gh([
621
+ "api",
622
+ "--method",
623
+ "PUT",
624
+ `${pulls}/update-branch`,
625
+ "-f",
626
+ `expected_head_sha=${headSha}`,
627
+ ]);
628
+ if (!updated.ok) return updated;
629
+ // The endpoint answers 202 Accepted and refreshes the branch
630
+ // asynchronously, so the head observed for `expected_head_sha` is the
631
+ // pre-update head. Producing it as `sha` would report a merge at a head
632
+ // nobody observed; the caller re-reads PR and check state instead.
633
+ return {
634
+ ok: true,
635
+ detail: `requested a base-branch update of ${prUrl} from its observed head ${headSha}; re-read PR and check state before merging`,
636
+ };
637
+ },
577
638
 
578
639
  updatePr: async (prUrl, fields) => {
579
640
  const argv = ["pr", "edit", prUrl];
@@ -589,19 +650,6 @@ export function githubVerbActions(
589
650
  // and braces on the one operation that cannot be undone.
590
651
  mergePr: (prUrl, headSha) => gh(["pr", "merge", prUrl, "--squash", "--match-head-commit", headSha]),
591
652
 
592
- setLabel: async (issue, label, action) => {
593
- const slug = project.tracker.repo;
594
- return gh([
595
- "issue",
596
- "edit",
597
- String(issue),
598
- "--repo",
599
- slug,
600
- action === "add" ? "--add-label" : "--remove-label",
601
- label,
602
- ]);
603
- },
604
-
605
653
  release: async (execution: ReleaseExecution): Promise<ActionOutcome> => {
606
654
  const slug = repoSlugFor(execution.repo);
607
655
  const tag = execution.tag;
@@ -26,6 +26,7 @@ import {
26
26
  RELEASE_REASONS,
27
27
  RELEASE_SHAPES,
28
28
  RESERVED_VERB_FIELDS,
29
+ REVIEW_REASONS,
29
30
  VERB_NAMES,
30
31
  } from "../types.ts";
31
32
  import type { ReservedVerbField, SessionRole, VerbName, VerbRefusal } from "../types.ts";
@@ -326,6 +327,50 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
326
327
  `install authority is the orchestrator's, and this is a ${role} session. ` +
327
328
  "A worker session holds no install grant whatever the config says.",
328
329
  },
330
+ conductor_pr_review: {
331
+ name: "conductor_pr_review",
332
+ mutating: true,
333
+ allowedRoles: ["orchestrator"],
334
+ description:
335
+ "Return one green, run-owned pull request to its worker with blocking findings (#677). " +
336
+ "The daemon records the revision durably — findings, exact reviewed head, round number, " +
337
+ "target run and session — then resumes the SAME OMP session on the existing branch and PR: " +
338
+ "no close, no reopen, no issue redispatch, no fresh brief. The worker edits and pushes the " +
339
+ "same branch; a new green head returns to the normal orchestrator review surface. Refused " +
340
+ "for a stale head, a PR no run of this project owns, a run that is not settled green, or a " +
341
+ "revision already in flight for the same PR.",
342
+ args: {
343
+ prUrl: {
344
+ type: "string",
345
+ required: true,
346
+ description:
347
+ "Full pull request URL — one a run of this project opened and pushed green. Must be in " +
348
+ "a routed repository.",
349
+ },
350
+ headSha: {
351
+ type: "string",
352
+ required: true,
353
+ description:
354
+ "The exact reviewed head. The live head must match it and the checks at it must be green.",
355
+ },
356
+ findings: {
357
+ type: "string",
358
+ required: true,
359
+ description:
360
+ "Structured blocking findings. Delivered verbatim to the resumed session; the worker is " +
361
+ "told to address exactly these and push the same branch.",
362
+ },
363
+ reason: {
364
+ type: "string",
365
+ required: true,
366
+ description: `Why this revision, from the closed set: ${REVIEW_REASONS.join(", ")}.`,
367
+ oneOf: REVIEW_REASONS,
368
+ },
369
+ rationale: RATIONALE_ARG,
370
+ },
371
+ roleRefusalText: (role) =>
372
+ `review authority is the orchestrator's; this is a ${role} session, and a worker never returns its own PR for revision.`,
373
+ },
329
374
  conductor_pr_status: {
330
375
  name: "conductor_pr_status",
331
376
  mutating: false,
@@ -41,12 +41,14 @@
41
41
  import { randomUUID } from "node:crypto";
42
42
  import { createServer, type Server, type Socket } from "node:net";
43
43
 
44
- import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
44
+ import { resolvePolicy, resolveReleaseGrants, resolveReview } from "../config.ts";
45
+ import { effectiveLane, laneEcho } from "../admission.ts";
45
46
  import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
46
47
  import { repoSlugFor, type readBaseChain as readBaseChainType } from "../gitops.ts";
47
48
  import { releaseRefusal } from "../release-policy.ts";
48
49
  import { LIVE_STATES } from "../store.ts";
49
50
  import type {
51
+ IssueComment,
50
52
  OpenCloser,
51
53
  PrState,
52
54
  PrVerification,
@@ -54,6 +56,7 @@ import type {
54
56
  ReleaseRequirement,
55
57
  ReleaseShape,
56
58
  RepoTarget,
59
+ ReviewReason,
57
60
  RunRecord,
58
61
  Store,
59
62
  Tracker,
@@ -167,7 +170,6 @@ export interface VerbActions {
167
170
  fields: { title?: string; body?: string },
168
171
  ): Promise<{ ok: true } | { ok: false; stderr: string }>;
169
172
  mergePr(prUrl: string, headSha: string): Promise<ActionOutcome>;
170
- setLabel(issue: number, label: string, action: "add" | "remove"): Promise<ActionOutcome>;
171
173
  release(execution: ReleaseExecution): Promise<ActionOutcome>;
172
174
  /**
173
175
  * Request the fleet upgrade itself to `version`: refuse when npm does not
@@ -298,6 +300,12 @@ export interface ReleaseFacts {
298
300
  * must wait on a process; these clear by themselves on the next sweep.
299
301
  */
300
302
  unconfirmedMerges?: number;
303
+ /**
304
+ * The unsettled runs themselves, routed-repo and issue each, so the refusal
305
+ * names what is blocking instead of a bare count (#603). Absent when the
306
+ * caller could not gather them (the plain wording is used then).
307
+ */
308
+ blockingRuns?: { repo: string; issue: number }[];
301
309
  /** Queue depth, or `undefined` when the tracker could not be read. */
302
310
  queueDepth: number | undefined;
303
311
  /** Current live-head workflow verdict for the released routed repository. */
@@ -311,7 +319,7 @@ export function releaseRequirementRefusal(
311
319
  facts: ReleaseFacts,
312
320
  ): string | undefined {
313
321
  for (const requirement of requires) {
314
- if (requirement === "runs-settled" && facts.unsettledRuns > 0) {
322
+ if ((requirement === "runs-settled" || requirement === "fleet-runs-settled") && facts.unsettledRuns > 0) {
315
323
  // The same blocker reads differently depending on WHY the row is still
316
324
  // active: a live worker is the operator's wait, an unconfirmed merge or
317
325
  // an unrecorded sweep clears by itself on the next settle pass. The
@@ -328,9 +336,16 @@ export function releaseRequirementRefusal(
328
336
  if (open > 0) parts.push(`${open} pull request(s) still open`);
329
337
  if (unconfirmed > 0) parts.push(`${unconfirmed} run(s) whose PR merge the settle sweep has not yet recorded`);
330
338
  if (remaining > 0) parts.push(`${remaining} run(s) with no merged PR (closed or none yet)`);
339
+ // Name the runs behind the count so the operator can see at a glance
340
+ // whether this is the release's own work or someone else's (#603).
341
+ const blocking =
342
+ facts.blockingRuns === undefined || facts.blockingRuns.length === 0
343
+ ? ""
344
+ : ` — blocking: ${facts.blockingRuns.map((r) => `${r.repo} #${r.issue}`).join(", ")}`;
331
345
  return (
332
- `policy.release.requires includes runs-settled and ${facts.unsettledRuns} run(s) have not settled` +
333
- (parts.length === 0 ? "" : ` — ${parts.join("; ")}`)
346
+ `policy.release.requires includes ${requirement} and ${facts.unsettledRuns} run(s) have not settled` +
347
+ (parts.length === 0 ? "" : ` — ${parts.join("; ")}`) +
348
+ blocking
334
349
  );
335
350
  }
336
351
  if (requirement === "no-open-prs" && facts.openPrs > 0) {
@@ -505,6 +520,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
505
520
  (verb === "conductor_pr_merge" ||
506
521
  verb === "conductor_pr_update_branch" ||
507
522
  verb === "conductor_pr_update" ||
523
+ verb === "conductor_pr_review" ||
508
524
  verb === "conductor_label");
509
525
  const stopped =
510
526
  spec.mutating && verb !== "conductor_release" && verb !== "conductor_install" && !orchestratorCompletion
@@ -540,6 +556,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
540
556
  return releaseVerb(deps, project, channel, args, refuse, allow);
541
557
  case "conductor_install":
542
558
  return installVerb(deps, project, channel, args, refuse, allow);
559
+ case "conductor_pr_review":
560
+ return prReviewVerb(deps, project, channel, args, refuse, allow);
543
561
  case "conductor_pr_status":
544
562
  return prStatusVerb(deps, project, channel, args, refuse, allow);
545
563
  }
@@ -797,7 +815,15 @@ async function prUpdateBranchVerb(
797
815
  if (!outcome.ok) {
798
816
  return refuse("action-failed", `refused: gh could not update the branch:\n${outcome.stderr}`, target?.issue);
799
817
  }
800
- return allow(`updated ${prUrl} with its base branch. Re-check its checks before merging.`, outcome.sha, target?.issue);
818
+ // The REST endpoint answers 202 Accepted: the refresh runs asynchronously, so
819
+ // this call produces no head SHA and the reply/ledger must not claim one —
820
+ // the head the action observed is the pre-update guard head, never a result
821
+ // (#655 review). The caller re-reads PR and check state before merging.
822
+ return allow(
823
+ `requested a base-branch update of ${prUrl} from its observed head; PR and check state must be re-read before merging.`,
824
+ undefined,
825
+ target?.issue,
826
+ );
801
827
  }
802
828
 
803
829
  async function prMergeVerb(
@@ -1064,11 +1090,59 @@ async function labelVerb(
1064
1090
  }
1065
1091
 
1066
1092
  const action = args["action"] === "remove" ? "remove" : "add";
1067
- const outcome = await deps.actions.setLabel(ref.issue, label, action);
1068
- if (!outcome.ok) {
1069
- return refuse("action-failed", `refused: the tracker rejected the label change:\n${outcome.stderr}`, ref.issue);
1093
+ // The queue label is the fleet's lock, so it must not ride the GraphQL
1094
+ // surface a provider outage just broke: `gh issue edit` is a GraphQL
1095
+ // mutation in gh 2.86, and #642 comment 4 measured a label withdrawal
1096
+ // refused by a GraphQL 503 leaving two overlapping issues claimable. The
1097
+ // tracker's label methods are core REST with noop classification for the
1098
+ // already-holding end state — the guarded path.
1099
+ try {
1100
+ if (action === "add") {
1101
+ await deps.tracker.addLabel(ref.issue, label);
1102
+ } else {
1103
+ await deps.tracker.removeLabel(ref.issue, label);
1104
+ }
1105
+ } catch (err) {
1106
+ const why = err instanceof Error ? err.message : String(err);
1107
+ return refuse("action-failed", `refused: the tracker rejected the label change:\n${why}`, ref.issue);
1070
1108
  }
1071
- return allow(`${action === "add" ? "added" : "removed"} ${label} on #${ref.issue}.`, undefined, ref.issue);
1109
+
1110
+ const outcome = `${action === "add" ? "added" : "removed"} ${label} on #${ref.issue}.`;
1111
+ // Adding the queue label is the promotion: the author is asserting the file
1112
+ // lane right now, so the verb echoes the one admission will enforce — the
1113
+ // parsed file list, or the explicit fail-open note (#724). Best-effort: an
1114
+ // unreadable issue or thread must never block the promotion itself (the
1115
+ // label is the point), so a failed read falls back to the plain message.
1116
+ let echo: string | undefined;
1117
+ if (action === "add" && label === project.queueLabel) {
1118
+ echo = await laneEchoForIssue(deps, ref.issue);
1119
+ }
1120
+ return allow(
1121
+ echo === undefined ? outcome : `${outcome} File lane: ${echo}.`,
1122
+ undefined,
1123
+ ref.issue,
1124
+ );
1125
+ }
1126
+
1127
+ /**
1128
+ * The effective file lane admission will enforce for one issue, as the
1129
+ * one-line echo (#724): the body plus the whole comment thread, the same
1130
+ * inputs `effectiveLane` reads at admission. `undefined` when either read
1131
+ * fails or the tracker cannot produce the issue — the promotion is never
1132
+ * blocked by its own feedback.
1133
+ */
1134
+ async function laneEchoForIssue(deps: VerbDeps, issue: number): Promise<string | undefined> {
1135
+ let body: string;
1136
+ let comments: IssueComment[];
1137
+ try {
1138
+ const row = await deps.tracker.getIssue(issue);
1139
+ if (row === undefined) return undefined;
1140
+ body = row.body;
1141
+ comments = await deps.tracker.listComments(issue);
1142
+ } catch {
1143
+ return undefined;
1144
+ }
1145
+ return laneEcho(effectiveLane(body, comments));
1072
1146
  }
1073
1147
 
1074
1148
  async function releaseVerb(
@@ -1151,22 +1225,36 @@ async function releaseVerb(
1151
1225
  // this release by holding. Fail-closed stays fail-closed: a row whose PR
1152
1226
  // state cannot be read is still unsettled, and an unknown PR is still
1153
1227
  // treated as open.
1228
+ //
1229
+ // The run facts are scoped to the released repo (#603): a run in another
1230
+ // routed repo cannot invalidate a tag on this one, so it must not gate this
1231
+ // release. A genuinely suite-wide shape — a pin or manifest that consumes
1232
+ // several repos — opts back into project-wide strictness through the named
1233
+ // `fleet-runs-settled` requirement, which widens the scope to every active
1234
+ // run. `no-open-prs` shares the scope: the branch being released is this
1235
+ // repo's, so an open PR elsewhere is not "against" it either.
1236
+ const fleetWide = policy.release.requires.includes("fleet-runs-settled");
1154
1237
  const wantsRunFacts =
1155
1238
  policy.release.requires.includes("runs-settled") ||
1239
+ fleetWide ||
1156
1240
  policy.release.requires.includes("no-open-prs");
1157
- let unsettledRuns = active.length;
1158
- let openPrs = active.filter((r) => r.prUrl !== undefined).length;
1241
+ const scoped = fleetWide ? active : active.filter((r) => r.repo === repoName);
1242
+ let unsettledRuns = scoped.length;
1243
+ let openPrs = scoped.filter((r) => r.prUrl !== undefined).length;
1159
1244
  let liveWorkers: number | undefined;
1160
1245
  let unconfirmedMerges: number | undefined;
1246
+ let blockingRuns: { repo: string; issue: number }[] | undefined;
1161
1247
  if (wantsRunFacts) {
1162
1248
  let unsettled = 0;
1163
1249
  let open = 0;
1164
1250
  let live = 0;
1165
1251
  let unconfirmed = 0;
1166
- for (const run of active) {
1252
+ const blocking: { repo: string; issue: number }[] = [];
1253
+ for (const run of scoped) {
1167
1254
  if (run.state === "claimed" || run.state === "running") {
1168
1255
  unsettled += 1;
1169
1256
  live += 1;
1257
+ blocking.push({ repo: run.repo, issue: run.issue });
1170
1258
  // A live worker's PR is not yet confirmable; count it as open, the
1171
1259
  // same fail-closed answer the old row-count gave it.
1172
1260
  if (run.prUrl !== undefined) open += 1;
@@ -1175,6 +1263,7 @@ async function releaseVerb(
1175
1263
  // pushed-pending / pushed-green: the worker is finished; the PR decides.
1176
1264
  if (run.prUrl === undefined) {
1177
1265
  unsettled += 1; // nothing to read; the sweep cannot settle it either
1266
+ blocking.push({ repo: run.repo, issue: run.issue });
1178
1267
  continue;
1179
1268
  }
1180
1269
  let pr: PrState | undefined;
@@ -1185,6 +1274,7 @@ async function releaseVerb(
1185
1274
  }
1186
1275
  if (pr === "merged") continue; // settled in fact; not an open PR
1187
1276
  unsettled += 1;
1277
+ blocking.push({ repo: run.repo, issue: run.issue });
1188
1278
  if (pr === "open") open += 1;
1189
1279
  else if (pr === undefined) {
1190
1280
  // Cannot confirm the merge (or its absence): fail closed, and count
@@ -1200,12 +1290,14 @@ async function releaseVerb(
1200
1290
  openPrs = open;
1201
1291
  liveWorkers = live;
1202
1292
  unconfirmedMerges = unconfirmed;
1293
+ blockingRuns = blocking;
1203
1294
  }
1204
1295
  const unmet = releaseRequirementRefusal(policy.release.requires, {
1205
1296
  unsettledRuns,
1206
1297
  openPrs,
1207
1298
  ...(liveWorkers === undefined ? {} : { liveWorkers }),
1208
1299
  ...(unconfirmedMerges === undefined ? {} : { unconfirmedMerges }),
1300
+ ...(blockingRuns === undefined ? {} : { blockingRuns }),
1209
1301
  queueDepth,
1210
1302
  ...(health === undefined ? {} : { baseCheck: health.verdict }),
1211
1303
  ...(health?.verdict === "red" && health.detail !== undefined
@@ -1509,6 +1601,171 @@ async function prUpdateVerb(
1509
1601
  return allow(`updated ${prUrl}.`, undefined, issue);
1510
1602
  }
1511
1603
 
1604
+ /**
1605
+ * Return one green, run-owned pull request to its worker with blocking
1606
+ * findings (#677): the transport that replaces the manual close-PR →
1607
+ * comment-on-issue → unblock → continuation dance.
1608
+ *
1609
+ * Everything is recorded durably BEFORE anything is woken: the findings, the
1610
+ * exact reviewed head, the round number, and the target run/session. The wake
1611
+ * itself is the daemon's next dispatch pass (the verb has no handle on the
1612
+ * worker machinery, and the CLI path must behave exactly like the embedded
1613
+ * one), and the run row is reused — same branch, same PR, same attempt, same
1614
+ * session directory — so a revision round never creates a new attempt and
1615
+ * never touches the failed-attempt or continuation budgets.
1616
+ *
1617
+ * The duplicate in-flight guard has two halves: while a revision is queued
1618
+ * but not yet dispatched, the pending `review_revisions` row refuses the
1619
+ * second request atomically inside `claimReviewRevision`; once dispatched,
1620
+ * the run row itself is `running` (claimed from `pushed-green` by the
1621
+ * dispatch pass), which this verb refuses by name.
1622
+ */
1623
+ async function prReviewVerb(
1624
+ deps: VerbDeps,
1625
+ project: ProjectConfig,
1626
+ channel: VerbChannel,
1627
+ args: Record<string, unknown>,
1628
+ refuse: Refuse,
1629
+ allow: Allow,
1630
+ ): Promise<Verdict> {
1631
+ const prUrl = String(args["prUrl"]);
1632
+ const headSha = String(args["headSha"]);
1633
+ const findings = String(args["findings"]);
1634
+ const reason = String(args["reason"]) as ReviewReason;
1635
+
1636
+ // The run whose pushed-green row owns the PR — same resolution as merge: the
1637
+ // newest attempt that recorded this PR within the recent-history window. A
1638
+ // PR no run of this project opened (or one outside the routed repos) cannot
1639
+ // be returned to a worker this project can resume.
1640
+ const target = runForPr(deps, project.name, prUrl);
1641
+ if (target === undefined) {
1642
+ return refuse(
1643
+ "pr-not-this-run",
1644
+ `refused: ${prUrl} is not a pull request any run in ${project.name} opened. ` +
1645
+ "A review revision acts on a run-owned PR only.",
1646
+ );
1647
+ }
1648
+ if (!prInProjectRouting(project, prUrl)) {
1649
+ return refuse(
1650
+ "pr-not-this-run",
1651
+ `refused: ${prUrl} is not in ${project.name}'s routed repositories ` +
1652
+ `(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}).`,
1653
+ target.issue,
1654
+ );
1655
+ }
1656
+ const issue = target.issue;
1657
+
1658
+ if (findings.trim() === "") {
1659
+ return refuse(
1660
+ "malformed-argument",
1661
+ "refused: conductor_pr_review needs a non-empty findings string — an empty revision returns the worker nothing to fix.",
1662
+ issue,
1663
+ );
1664
+ }
1665
+
1666
+ // The revisable state is a settled green run. Any other state is a worker in
1667
+ // flight (the original run or an earlier revision) or a run whose PR no
1668
+ // longer waits on revision — the detail names the state field that produced
1669
+ // the refusal.
1670
+ if (target.state !== "pushed-green") {
1671
+ const live = target.state === "running" || target.state === "claimed";
1672
+ return refuse(
1673
+ "review-in-flight",
1674
+ `refused: run ${target.id} is ${target.state}, not pushed-green — ${
1675
+ live
1676
+ ? `a worker (the original run or an earlier revision) is still live on ${prUrl}; wait for it to settle before returning it.`
1677
+ : `a review revision starts from a settled green run, and this one is ${target.state}.`
1678
+ }`,
1679
+ issue,
1680
+ );
1681
+ }
1682
+
1683
+ const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
1684
+ if (paused !== undefined) return paused;
1685
+
1686
+ // Exact-head, re-read at request time: the orchestrator reviewed a specific
1687
+ // head, and a PR that moved since is not the thing it reviewed. Same verdict
1688
+ // the merge gate uses, so "green at headSha" here is the same fact the merge
1689
+ // would have required.
1690
+ let verification: PrVerification | undefined;
1691
+ try {
1692
+ verification = await deps.tracker.verifyPr(prUrl, headSha);
1693
+ } catch (err) {
1694
+ const why = err instanceof Error ? err.message : String(err);
1695
+ return refuse(
1696
+ "head-unresolvable",
1697
+ `refused: the live head of ${prUrl} could not be read (${why}).`,
1698
+ issue,
1699
+ );
1700
+ }
1701
+ if (verification === undefined) {
1702
+ return refuse(
1703
+ "head-unresolvable",
1704
+ `refused: the live head of ${prUrl} could not be resolved. Refusing rather than returning a PR you did not re-read.`,
1705
+ issue,
1706
+ );
1707
+ }
1708
+ if (verification.status !== "green") {
1709
+ const stale = verification.status === "failed" && isHeadMismatch(verification.reason);
1710
+ return refuse(
1711
+ stale ? "head-stale" : "checks-not-green",
1712
+ stale
1713
+ ? `refused: ${verification.reason}. You reviewed ${headSha}; that is not what is on the branch now. Re-read the head and re-review.`
1714
+ : `refused: ${prUrl} is not green at ${headSha} — ${verification.reason}. A revision is returned to a green PR only.`,
1715
+ issue,
1716
+ );
1717
+ }
1718
+
1719
+ // The hard bound (#678): the round ceiling is the dispatcher's, not the
1720
+ // orchestrator's to re-read from prose. A PR may be returned at most
1721
+ // `maxRounds` times per lifecycle; at the ceiling this refuses, and the
1722
+ // refusal states the two non-actions that replace a further round — leave
1723
+ // the PR open, record the unresolved findings and escalate once. The same
1724
+ // count `latestReviewRound` drives the renderer's `review-revision N`, so
1725
+ // the bound and the visible round can never disagree.
1726
+ const review = resolveReview(project);
1727
+ if (deps.store.latestReviewRound(project.name, target.id) >= review.maxRounds) {
1728
+ return refuse(
1729
+ "review-round-ceiling",
1730
+ `refused: ${prUrl} has already been through ${review.maxRounds} review round(s) ` +
1731
+ `(project "${project.name}" is ${review.strictness} strictness, ceiling ${review.maxRounds}). ` +
1732
+ "Leave the PR open, record the unresolved findings, and escalate once — a further revision round is not available.",
1733
+ issue,
1734
+ );
1735
+ }
1736
+
1737
+ // Durable, and refused atomically against a concurrent duplicate: the row is
1738
+ // persisted before any wake, and a second request for the same run bumps
1739
+ // into the pending row rather than racing it.
1740
+ const round = deps.store.latestReviewRound(project.name, target.id) + 1;
1741
+ const recorded = deps.store.createReviewRevision({
1742
+ project: project.name,
1743
+ runId: target.id,
1744
+ issue,
1745
+ prUrl,
1746
+ headSha,
1747
+ findings,
1748
+ round,
1749
+ reason,
1750
+ ...(target.sessionFile === undefined ? {} : { sessionFile: target.sessionFile }),
1751
+ requestedAt: deps.now(),
1752
+ });
1753
+ if (recorded === undefined) {
1754
+ return refuse(
1755
+ "review-in-flight",
1756
+ `refused: a review revision is already pending for ${prUrl} (run ${target.id}); exactly one revision is in flight per PR.`,
1757
+ issue,
1758
+ );
1759
+ }
1760
+
1761
+ return allow(
1762
+ `recorded review round ${round} for ${prUrl} at ${headSha}; the daemon will resume run ${target.id}'s session` +
1763
+ `${target.sessionFile === undefined ? "" : ` (${target.sessionFile})`} on its next dispatch pass.`,
1764
+ undefined,
1765
+ issue,
1766
+ );
1767
+ }
1768
+
1512
1769
  // ------------------------------------------------------------------ the listener
1513
1770
 
1514
1771
  export interface VerbListener {