omp-conductor 0.18.2 → 0.19.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 (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 +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  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 +321 -1155
  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 +299 -12
  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
@@ -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
  }
@@ -40,15 +40,23 @@
40
40
 
41
41
  import { randomUUID } from "node:crypto";
42
42
  import { createServer, type Server, type Socket } from "node:net";
43
+ import { join } from "node:path";
43
44
 
44
45
  import { resolvePolicy, resolveReleaseGrants, resolveReview } from "../config.ts";
46
+ import { wakeDispatch } from "../wake.ts";
45
47
  // The mediated release is the drain's closing gesture (#791): a successful
46
48
  // release ends this project's bounded release window, so the privileged half
47
49
  // clears the record through the same operator surface the CLI uses.
48
50
  import { cancelDrain } from "../fleet.ts";
49
51
  import { effectiveLane, laneEcho, writeLaneSectionHeading } from "../admission.ts";
50
52
  import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
51
- import { repoSlugFor, type readBaseChain as readBaseChainType } from "../gitops.ts";
53
+ import {
54
+ repoSlugFor,
55
+ type LaneFile,
56
+ type LaneSource,
57
+ type RunLaneProbe,
58
+ type readBaseChain as readBaseChainType,
59
+ } from "../gitops.ts";
52
60
  import { releaseRefusal } from "../release-policy.ts";
53
61
  import { PR_LOOKUP_WINDOW_MS, REVISABLE_RUN_STATES, prReviewReadiness } from "../decisions.ts";
54
62
  import { LIVE_STATES } from "../store.ts";
@@ -146,6 +154,16 @@ export interface ReleaseExecution {
146
154
  shape: ReleaseShape;
147
155
  repo: RepoTarget;
148
156
  tag?: string;
157
+ /**
158
+ * The exact commit a tag shape must point at (#695). Absent means "whatever
159
+ * the released repo's default branch is at cut time", which is the behaviour
160
+ * that stranded a tag every time the release window needed a merge: cutting
161
+ * required a quiet window, reaching that window required merging the open PR,
162
+ * and merging moved the branch past the tag that had just been cut. A pinned
163
+ * commit removes the dependence on *when* the tag is cut; the privileged half
164
+ * still refuses one that is not reachable from that branch.
165
+ */
166
+ sha?: string;
149
167
  artefact?: string;
150
168
  environment?: string;
151
169
  }
@@ -178,6 +196,14 @@ export interface VerbActions {
178
196
  fields: { title?: string; body?: string },
179
197
  ): Promise<{ ok: true } | { ok: false; stderr: string }>;
180
198
  mergePr(prUrl: string, headSha: string): Promise<ActionOutcome>;
199
+ /**
200
+ * Close one pull request, having first posted `comment` on it (#876).
201
+ *
202
+ * Both halves in one action because the ordering is the contract: a close that
203
+ * lands while its explanation fails leaves a PR nobody can account for, so the
204
+ * comment goes first and a failed comment means no close.
205
+ */
206
+ closePr(prUrl: string, comment: string): Promise<ActionOutcome>;
181
207
  release(execution: ReleaseExecution): Promise<ActionOutcome>;
182
208
  /**
183
209
  * Request the fleet upgrade itself to `version`: refuse when npm does not
@@ -221,6 +247,17 @@ export interface VerbDeps {
221
247
  * whether the merge would fork the chain, it never changes it (#227).
222
248
  */
223
249
  chain: { readBaseChain: typeof readBaseChainType };
250
+ /**
251
+ * Read-only access to one run's actual file lane (#925), kept off
252
+ * {@link VerbDeps.actions} for the same reason {@link VerbDeps.chain} is:
253
+ * `conductor_pr_recover` consults what a branch carries, it never changes it.
254
+ *
255
+ * Required rather than optional on purpose. This seam decides a fail-CLOSED
256
+ * refusal, so "the field was not wired" must be a compile error rather than a
257
+ * silently skipped check — an unwired guard that reports success is worse
258
+ * than no guard, because nothing anywhere says the lane went unproven.
259
+ */
260
+ lane: { probeRunLane: RunLaneProbe };
224
261
  }
225
262
 
226
263
  /**
@@ -553,6 +590,12 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
553
590
  verb === "conductor_pr_update_branch" ||
554
591
  verb === "conductor_pr_update" ||
555
592
  verb === "conductor_pr_review" ||
593
+ // A clearance disposes of evidence against a PR a pre-pause run already
594
+ // pushed (#913). It starts no work, and a pause that refused it would
595
+ // simply relocate the deadlock it exists to break: `conductor_pr_merge`
596
+ // stays available under a pause, so the one thing that unblocks it must
597
+ // too.
598
+ verb === "conductor_pr_review_clear" ||
556
599
  verb === "conductor_pr_recover" ||
557
600
  verb === "conductor_label");
558
601
  const stopped =
@@ -567,7 +610,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
567
610
  "fleet-paused",
568
611
  `refused: ${stopped}. The pause refuses new claims and work-starting mutations. ` +
569
612
  "Completion verbs for runs admitted before the pause (conductor_pr_merge, conductor_pr_update_branch, " +
570
- "conductor_pr_update, conductor_pr_recover, conductor_label), conductor_release and conductor_install remain available.",
613
+ "conductor_pr_update, conductor_pr_review, conductor_pr_review_clear, conductor_pr_recover, " +
614
+ "conductor_label), conductor_release and conductor_install remain available.",
571
615
  );
572
616
  }
573
617
  }
@@ -591,6 +635,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
591
635
  return installVerb(deps, project, channel, args, refuse, allow);
592
636
  case "conductor_pr_review":
593
637
  return prReviewVerb(deps, project, channel, args, refuse, allow);
638
+ case "conductor_pr_review_clear":
639
+ return prReviewClearVerb(deps, project, channel, args, refuse, allow);
594
640
  case "conductor_pr_recover":
595
641
  return prRecoverVerb(deps, project, channel, args, refuse, allow);
596
642
  case "conductor_pr_status":
@@ -970,6 +1016,96 @@ async function prMergeVerb(
970
1016
  }
971
1017
  }
972
1018
 
1019
+ // Exact-head review gate (#888): green checks say nothing about the durable
1020
+ // review lifecycle, and #886 merged through exactly that blind spot — CI
1021
+ // green at the live head while a review revision had settled `failed` on
1022
+ // that same head hours earlier. Consulted here, before the lock and every
1023
+ // `gh` call, from the same local rows the review verb writes: any round not
1024
+ // yet settled (queued, or dispatched and crashed mid-review) and any round
1025
+ // settled `failed` at exactly the requested head refuses the merge, unless a
1026
+ // `conductor_pr_review_clear` recorded at or after that round's own last
1027
+ // activity has dispositioned it (#913). A finding tied only to an older head
1028
+ // never appears here — clearance follows the exact head, so a corrected push
1029
+ // is never poisoned.
1030
+ // A head an adjudication decided against never merges (#876), whatever else
1031
+ // is green. The disposition normally closes the PR, so this rarely fires —
1032
+ // but "rarely" is not "never": a close that failed, or a race between the
1033
+ // disposition pass and this call, must not leave a rejected diff mergeable.
1034
+ // Read from the same durable row status renders, so the refusal and the board
1035
+ // cannot disagree.
1036
+ const decided = deps.store.reviewAdjudicationForHead(project.name, prUrl, headSha);
1037
+ if (decided !== undefined && decided.settledAt !== undefined && decided.state !== "cleared") {
1038
+ return refuse(
1039
+ "merge-blocked-by-review",
1040
+ `refused: the review-ceiling adjudication for ${prUrl} at ${headSha.slice(0, 12)} settled ` +
1041
+ `${decided.state}, not cleared${decided.evidence === undefined ? "" : ` — ${decided.evidence}`}. ` +
1042
+ "There is no further review round and no second adjudicator: this head does not merge. " +
1043
+ "Push a corrected head, which is adjudicated on its own merits.",
1044
+ issue,
1045
+ );
1046
+ }
1047
+
1048
+ // The exact-head review-evidence gate (#888), whose reasoning the block above
1049
+ // this one continues from: any round not yet settled (queued, or dispatched
1050
+ // and crashed mid-review) and any round settled `failed` at exactly the
1051
+ // requested head refuses the merge, unless a `conductor_pr_review_clear`
1052
+ // recorded at or after that round's own last activity has dispositioned it.
1053
+ const blocking = deps.store.mergeBlockingReviews(project.name, prUrl, headSha);
1054
+ if (blocking.length > 0) {
1055
+ const b = blocking[0]!;
1056
+ const phase =
1057
+ b.settledAt !== undefined
1058
+ ? `settled ${b.outcome ?? "failed"} with unaddressed findings`
1059
+ : b.dispatchedAt !== undefined
1060
+ ? "was dispatched and never finished (crashed mid-review)"
1061
+ : "is queued and has not been addressed";
1062
+ const more = blocking.length > 1 ? ` (+${blocking.length - 1} further blocking round(s) at this head)` : "";
1063
+ // The advice names only clearances that exist (#913). Re-reviewing the
1064
+ // same head was the old wording's suggestion and it provably does not
1065
+ // work: `enqueueReviewRevision` appends to the pending row or opens a new
1066
+ // unsettled one, so a fresh round at an unchanged head is another blocking
1067
+ // round — and at the review-round ceiling there is no further round to
1068
+ // spend at all.
1069
+ return refuse(
1070
+ "merge-blocked-by-review",
1071
+ `refused: ${prUrl} cannot merge at ${headSha.slice(0, 12)} — review round ${b.round} at this exact head ` +
1072
+ `${phase}${more}. Green checks do not clear durable review findings, and neither does re-reviewing ` +
1073
+ "this head: a fresh `conductor_pr_review` at an unchanged head opens (or appends to) another " +
1074
+ "blocking round rather than superseding one. The two clearances that exist are a corrected head " +
1075
+ "pushed by the run — clearance follows the exact head, so a different head is not poisoned by this " +
1076
+ "one — or an explicit `conductor_pr_review_clear` for this exact head, recorded with its reason.",
1077
+ issue,
1078
+ );
1079
+ }
1080
+
1081
+ // Active release-composition gate (#850): while the project has a release
1082
+ // declared as being assembled, only a PR named in that composition — or one
1083
+ // with an explicit recorded operator override — may merge into it. Placed
1084
+ // after identity resolution and before the merge lock and every tracker
1085
+ // call, so an unrelated green PR is refused without spending a gh round
1086
+ // trip: this is the mechanical form of "a patch contains no unrelated
1087
+ // merges", not a status warning. The override bypasses only this check —
1088
+ // authority, pause, base-red-freeze, exact-head, green-checks and
1089
+ // single-flight all still apply below.
1090
+ const composition = deps.store.activeReleaseComposition(project.name);
1091
+ if (composition !== undefined && !composition.allowedPrUrls.includes(prUrl)) {
1092
+ const overridden =
1093
+ deps.store.releaseCompositionOverride(project.name, composition.campaign, prUrl) !== undefined;
1094
+ if (!overridden) {
1095
+ const allowed =
1096
+ composition.allowedPrUrls.length === 0 ? "none yet" : composition.allowedPrUrls.join(", ");
1097
+ return refuse(
1098
+ "outside-active-release",
1099
+ `refused: ${project.name} is assembling release ${composition.campaign} and ${prUrl} is not in it ` +
1100
+ `(allowed so far: ${allowed}). Merging it would smuggle unrelated work into that release ` +
1101
+ "(outside-active-release). The guard clears only through `omp-conductor release-composition " +
1102
+ "complete|cancel`, or an explicit one-PR `omp-conductor release-composition override --pr <url> " +
1103
+ "--reason <why>` — this call's merge reason is not an override.",
1104
+ issue,
1105
+ );
1106
+ }
1107
+ }
1108
+
973
1109
  // Taken before any network call, so two concurrent callers contend here
974
1110
  // rather than both spending a `gh` round trip and racing at the merge.
975
1111
  const holderId = randomUUID();
@@ -1184,8 +1320,21 @@ async function labelVerb(
1184
1320
  return refuse("action-failed", `refused: the tracker rejected the label change:\n${why}`, ref.issue);
1185
1321
  }
1186
1322
 
1323
+ // A promotion that committed has made an issue claimable *now*, so the
1324
+ // resident daemon is poked rather than leaving the candidate for the next
1325
+ // scheduled pass (#878): three claimable issues once sat against `workers
1326
+ // 0 / 3` for five minutes, which reads from outside exactly like a stalled
1327
+ // queue. Only the queue label earns it — no other label changes what is
1328
+ // dispatchable — and only after the tracker mutation above committed, so a
1329
+ // refused or failed change never pokes anything. The pass it prompts applies
1330
+ // every hold, drain, lane and budget gate as usual.
1331
+ const woken =
1332
+ action === "add" && label === project.queueLabel ? await wakeDispatch(project.name) : undefined;
1333
+
1187
1334
  return allow(
1188
- echo === undefined ? outcome : `${outcome} File lane: ${echo}.`,
1335
+ [echo === undefined ? outcome : `${outcome} File lane: ${echo}.`, woken]
1336
+ .filter((part) => part !== undefined)
1337
+ .join(" "),
1189
1338
  undefined,
1190
1339
  ref.issue,
1191
1340
  );
@@ -1395,10 +1544,27 @@ async function releaseVerb(
1395
1544
  }
1396
1545
 
1397
1546
  const tag = args["tag"];
1547
+ const sha = args["sha"];
1548
+ // A pinned commit is only meaningful for the two tag shapes; anywhere else it
1549
+ // would be a decorative argument, which is exactly the silent fake this
1550
+ // guards against. Refused rather than ignored, so the caller learns.
1551
+ if (typeof sha === "string" && shape !== "git-tag" && shape !== "git-push-tags") {
1552
+ return refuse(
1553
+ "malformed-argument",
1554
+ `refused: sha pins a tag to one commit and only git-tag and git-push-tags cut tags; you asked for ${shape}.`,
1555
+ );
1556
+ }
1557
+ if (typeof sha === "string" && !FULL_OR_ABBREVIATED_SHA.test(sha)) {
1558
+ return refuse(
1559
+ "malformed-argument",
1560
+ `refused: ${JSON.stringify(sha)} is not a commit id. Pass the exact hex sha the tag must point at.`,
1561
+ );
1562
+ }
1398
1563
  const outcome = await deps.actions.release({
1399
1564
  shape,
1400
1565
  repo,
1401
1566
  ...(typeof tag === "string" ? { tag } : {}),
1567
+ ...(typeof sha === "string" ? { sha } : {}),
1402
1568
  ...(typeof artefact === "string" ? { artefact } : {}),
1403
1569
  ...(typeof environment === "string" ? { environment } : {}),
1404
1570
  });
@@ -1857,11 +2023,46 @@ async function prReviewVerb(
1857
2023
  const review = resolveReview(project);
1858
2024
  const pendingForRun = deps.store.pendingReviewForRun(project.name, target.id);
1859
2025
  if (pendingForRun === undefined && deps.store.latestReviewRound(project.name, target.id) >= review.maxRounds) {
1860
- return refuse(
1861
- "review-round-ceiling",
1862
- `refused: ${prUrl} has already been through ${review.maxRounds} review round(s) ` +
1863
- `(project "${project.name}" is ${review.strictness} strictness, ceiling ${review.maxRounds}). ` +
1864
- "Leave the PR open, record the unresolved findings, and escalate once — a further revision round is not available.",
2026
+ // The ceiling no longer ends in an operator task (#873, #932). There are no
2027
+ // worker rounds left — that part is unchanged and is the whole point of a
2028
+ // ceiling so what escalates is the AUTOMATION: this admits one bounded
2029
+ // adjudication under the project's configured stronger role, and the
2030
+ // daemon's next dispatch pass launches it.
2031
+ //
2032
+ // The head is the one already verified live and green above, so a moved head
2033
+ // has refused (`head-stale`) before reaching here: an adjudication is never
2034
+ // opened against a head nobody re-read. The findings being submitted travel
2035
+ // with it — they are the most relevant single input the adjudicator gets,
2036
+ // and dropping them on the floor would make it re-derive (or miss) exactly
2037
+ // the concern that provoked the escalation.
2038
+ const admitted = deps.store.openReviewAdjudication({
2039
+ project: project.name,
2040
+ issue,
2041
+ prUrl,
2042
+ headSha,
2043
+ role: review.adjudicator,
2044
+ findings,
2045
+ requestedAt: deps.now(),
2046
+ });
2047
+ if (admitted.kind === "refused") {
2048
+ return refuse(
2049
+ "review-in-flight",
2050
+ `refused: an adjudication is already live for ${prUrl} at ${admitted.record.headSha} ` +
2051
+ `(${admitted.record.state}), and this call names ${headSha}. That PR moved while it was being ` +
2052
+ "adjudicated; wait for the live adjudication to settle before returning a finding at the new head.",
2053
+ issue,
2054
+ );
2055
+ }
2056
+ // Idempotent by construction: a repeated call at the same head is told the
2057
+ // row it already has, never given a second adjudicator.
2058
+ return allow(
2059
+ admitted.kind === "created"
2060
+ ? `review round ceiling reached (${review.maxRounds}) — recorded a final adjudication for ${prUrl} at ` +
2061
+ `${headSha} under the "${review.adjudicator}" model role. The daemon launches it on its next dispatch ` +
2062
+ "pass; do not review, merge, close or repair this PR by hand."
2063
+ : `an adjudication for ${prUrl} at ${headSha} already exists (${admitted.record.state}) under the ` +
2064
+ `"${admitted.record.role}" role — nothing further to record. The daemon owns it from here.`,
2065
+ undefined,
1865
2066
  issue,
1866
2067
  );
1867
2068
  }
@@ -1922,6 +2123,155 @@ async function prReviewVerb(
1922
2123
  );
1923
2124
  }
1924
2125
 
2126
+ /**
2127
+ * Record that the durable review findings standing at one exact head are
2128
+ * settled, so the #888 merge gate stops refusing that head (#913).
2129
+ *
2130
+ * The gate it feeds had no reachable clearance at an unchanged head. Its own
2131
+ * refusal advised a fresh `conductor_pr_review`, and that provably does not
2132
+ * work: `enqueueReviewRevision` either appends the finding to the pending row
2133
+ * or inserts a new unsettled one, and both block. The only other clearances
2134
+ * are a corrected head — which needs a worker — or a round settling
2135
+ * `skipped`/`revised`, which nothing an orchestrator can call does. A PR at
2136
+ * the review-round ceiling was therefore permanently unmergeable at that head:
2137
+ * a gate that can deadlock a release with no way out.
2138
+ *
2139
+ * This is a separate verb rather than an argument on the merge, following
2140
+ * #850's composition override exactly: a merge call's own `reason` is not an
2141
+ * override, because an override that rides inside the act it authorizes leaves
2142
+ * no separable record of the judgement. Here the judgement is its own ledger
2143
+ * row, its own material event and its own append-only table row.
2144
+ *
2145
+ * Three gates, and each exists against a specific way of faking it:
2146
+ * - **ownership**, through the same `prReviewReadiness` the review verb uses,
2147
+ * but *without* its revisable-state requirement: the run legitimately sits
2148
+ * `pushed-green` here, and demanding revisability would make the clearance
2149
+ * unreachable for exactly the PRs it exists for;
2150
+ * - **exact head**, re-read live. Green is deliberately NOT required —
2151
+ * clearing findings and passing checks are different questions, and the
2152
+ * merge gate re-checks green itself immediately before merging;
2153
+ * - **non-vacuous**: a clearance at a head where nothing blocks is refused,
2154
+ * so one can never be banked "just in case" against a finding that has not
2155
+ * been written yet. Combined with the store's timestamp bound, a clearance
2156
+ * can only ever dispose of evidence that already existed when it was taken.
2157
+ */
2158
+ async function prReviewClearVerb(
2159
+ deps: VerbDeps,
2160
+ project: ProjectConfig,
2161
+ channel: VerbChannel,
2162
+ args: Record<string, unknown>,
2163
+ refuse: Refuse,
2164
+ allow: Allow,
2165
+ ): Promise<Verdict> {
2166
+ const prUrl = String(args["prUrl"]);
2167
+ const headSha = String(args["headSha"]);
2168
+ const reason = String(args["reason"]);
2169
+
2170
+ // Ownership only: the newest project-owned run that recorded this PR. A
2171
+ // `not-revisable` readiness is the *expected* answer — the run pushed green
2172
+ // and is waiting on the merge — so only `no-owner` refuses here.
2173
+ const readiness = prReviewReadiness(deps.store, project.name, prUrl, deps.now());
2174
+ if (readiness.kind === "no-owner") {
2175
+ return refuse(
2176
+ "pr-not-this-run",
2177
+ `refused: ${prUrl} is not a pull request any run in ${project.name} opened. ` +
2178
+ "A review clearance disposes of evidence recorded against a run-owned PR only.",
2179
+ );
2180
+ }
2181
+ const target = readiness.run;
2182
+ if (!prInProjectRouting(project, prUrl)) {
2183
+ return refuse(
2184
+ "pr-not-this-run",
2185
+ `refused: ${prUrl} is not in ${project.name}'s routed repositories ` +
2186
+ `(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}).`,
2187
+ target.issue,
2188
+ );
2189
+ }
2190
+ const issue = target.issue;
2191
+
2192
+ if (reason.trim() === "") {
2193
+ return refuse(
2194
+ "malformed-argument",
2195
+ "refused: conductor_pr_review_clear needs a non-empty reason — the reason IS the audit trail for " +
2196
+ "merging a head a review round refused, and an unexplained clearance is indistinguishable from " +
2197
+ "the blanket override this verb exists not to be.",
2198
+ issue,
2199
+ );
2200
+ }
2201
+
2202
+ const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
2203
+ if (paused !== undefined) return paused;
2204
+
2205
+ // Exact head, read live. `verification.headSha` is the live head read in the
2206
+ // same request, so the comparison is against what is on the branch now — not
2207
+ // against the status verdict, which is why a red or pending PR is cleared
2208
+ // just as well as a green one. The merge gate is what insists on green.
2209
+ let verification: PrVerification | undefined;
2210
+ try {
2211
+ verification = await deps.tracker.verifyPr(prUrl, headSha);
2212
+ } catch (err) {
2213
+ const why = err instanceof Error ? err.message : String(err);
2214
+ return refuse("head-unresolvable", `refused: the live head of ${prUrl} could not be read (${why}).`, issue);
2215
+ }
2216
+ if (verification === undefined) {
2217
+ return refuse(
2218
+ "head-unresolvable",
2219
+ `refused: the live head of ${prUrl} could not be resolved. Refusing rather than clearing findings ` +
2220
+ "against a head nobody re-read.",
2221
+ issue,
2222
+ );
2223
+ }
2224
+ if (verification.headSha.toLowerCase() !== headSha.toLowerCase()) {
2225
+ return refuse(
2226
+ "head-stale",
2227
+ `refused: ${prUrl} is at ${verification.headSha}, not ${headSha}. A clearance names the exact head ` +
2228
+ "whose findings it disposes of; the branch moved, so those findings no longer stand between this PR " +
2229
+ "and a merge. Re-read the head and re-review it.",
2230
+ issue,
2231
+ );
2232
+ }
2233
+
2234
+ // Non-vacuous. Read after the head check on purpose: if the caller named a
2235
+ // head that moved, the honest refusal is the stale head, not "nothing to
2236
+ // clear".
2237
+ const blocking = deps.store.mergeBlockingReviews(project.name, prUrl, headSha);
2238
+ if (blocking.length === 0) {
2239
+ return refuse(
2240
+ "review-clearance-vacuous",
2241
+ `refused: nothing blocks merging ${prUrl} at ${headSha.slice(0, 12)} — there is no review evidence ` +
2242
+ "at this head to dispose of. A clearance is a disposition of specific findings, never a standing " +
2243
+ "pass recorded ahead of them: a later round at this head would not be cleared by it anyway.",
2244
+ issue,
2245
+ );
2246
+ }
2247
+
2248
+ const cleared = deps.store.recordReviewClearance({
2249
+ project: project.name,
2250
+ prUrl,
2251
+ headSha,
2252
+ by: "orchestrator",
2253
+ reason,
2254
+ at: deps.now(),
2255
+ });
2256
+ deps.store.recordMaterialEvent({
2257
+ project: project.name,
2258
+ category: "review-clearance",
2259
+ summary:
2260
+ `#${issue} review findings cleared at ${headSha.slice(0, 12)} ` +
2261
+ `(round(s) ${blocking.map((row) => row.round).join(", ")}): ${reason}`,
2262
+ evidence: prUrl,
2263
+ occurredAt: cleared.at,
2264
+ recordedAt: cleared.at,
2265
+ });
2266
+ return allow(
2267
+ `cleared ${blocking.length} blocking review round(s) (${blocking.map((row) => row.round).join(", ")}) ` +
2268
+ `for ${prUrl} at ${headSha}: ${reason}. conductor_pr_merge may now proceed at this exact head; every ` +
2269
+ "other gate still applies, and a finding recorded after this clearance blocks again.",
2270
+ headSha,
2271
+ issue,
2272
+ );
2273
+ }
2274
+
1925
2275
  /**
1926
2276
  * The run states a missing-PR recovery may act on (#806).
1927
2277
  *
@@ -1947,6 +2297,15 @@ const RECOVERABLE_RUN_STATES: Record<string, true> = {
1947
2297
  /** A complete commit SHA, as current workers record it. */
1948
2298
  const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
1949
2299
 
2300
+ /**
2301
+ * A commit id a release may pin a tag to (#695): full, or a git-legal
2302
+ * abbreviation of at least 7 hex — the same lower bound the recovery path uses,
2303
+ * because shorter prefixes are ambiguous. Syntax only; whether the commit
2304
+ * exists and is on the released branch is the privileged half's answer, which
2305
+ * needs the mirror.
2306
+ */
2307
+ const FULL_OR_ABBREVIATED_SHA = /^[0-9a-f]{7,40}$/i;
2308
+
1950
2309
  /**
1951
2310
  * A legacy abbreviated commit SHA (#836): the `git rev-parse --short` output
1952
2311
  * an older worker generation recorded instead of the full SHA. Git defaults
@@ -2416,6 +2775,86 @@ async function prRecoverVerb(
2416
2775
  // through and replace it with a real PR at the verified head.
2417
2776
  }
2418
2777
 
2778
+ // The lane gate (#925), and the last check before anything is published.
2779
+ //
2780
+ // #899 released a worker-free branch's files: its worker is finished, nothing
2781
+ // is writing through it, and holding its whole diff forever idled the fleet.
2782
+ // The consequence is that by the time recovery runs, another worker may
2783
+ // legitimately hold those same files — and publishing this frozen branch then
2784
+ // puts an older diff into review, and into the merge queue, beside a live
2785
+ // worker still rewriting it. That is the one reactivation nothing else
2786
+ // catches: the merge gate reads checks and heads, never who else is mid-edit.
2787
+ //
2788
+ // This half fails CLOSED, unlike admission's interlock, and the asymmetry is
2789
+ // the point. Admission runs unattended on every tick, so refusing there on an
2790
+ // unreadable probe would stall the queue on a transient. Recovery is one
2791
+ // deliberate call whose refusal is legible and retryable: it costs a retry and
2792
+ // names the file, the holder and when to try again. Between "publish
2793
+ // silently on an unproven lane" and "say what could not be read", only the
2794
+ // second is defensible for a call that ends in a public pull request.
2795
+ const leases = deps.store.leasedRuns(project.name).filter((run) => run.repo === target.repo && run.issue !== issue);
2796
+ if (leases.length > 0) {
2797
+ const base = routed.defaultBranch;
2798
+ const mirror = join(project.mirrorRoot, `${target.repo}.git`);
2799
+ const laneOf = async (run: RunRecord): Promise<LaneFile[]> =>
2800
+ deps.lane.probeRunLane({
2801
+ worktree: run.worktree,
2802
+ baseRef: `refs/remotes/origin/${base}`,
2803
+ ...(run.branch === "" ? {} : { branchRef: `refs/heads/${run.branch}` }),
2804
+ mirror,
2805
+ });
2806
+ let carried: LaneFile[];
2807
+ try {
2808
+ carried = await laneOf(target);
2809
+ } catch (err) {
2810
+ return refuse(
2811
+ "recovery-lane-unprovable",
2812
+ `refused: the files preserved branch ${target.branch} carries could not be read ` +
2813
+ `(${err instanceof Error ? err.message : String(err)}), and ${leases.length} live mutation lease(s) are ` +
2814
+ `writing in ${identity}. Recovery will not publish a branch whose overlap with live work is unknown — ` +
2815
+ "retry, or wait for those runs to settle.",
2816
+ issue,
2817
+ );
2818
+ }
2819
+ // A branch that carries nothing cannot collide with anything: no read of
2820
+ // the leaseholders is needed, and none is made.
2821
+ if (carried.length > 0) {
2822
+ const wanted = new Set(carried.map(({ file }) => file));
2823
+ for (const run of leases) {
2824
+ let held: LaneFile[];
2825
+ try {
2826
+ held = await laneOf(run);
2827
+ } catch (err) {
2828
+ return refuse(
2829
+ "recovery-lane-unprovable",
2830
+ `refused: the lane of live run ${run.id} (#${run.issue}, ${run.state}) could not be read ` +
2831
+ `(${err instanceof Error ? err.message : String(err)}), so recovery cannot prove ${target.branch} does ` +
2832
+ "not overlap work in flight. Retry, or wait for that run to settle.",
2833
+ issue,
2834
+ );
2835
+ }
2836
+ // The run's own persisted declaration counts too: a leaseholder admitted
2837
+ // on a lane it has not written yet still owns those files, exactly as
2838
+ // admission's occupancy reads it (#744).
2839
+ const occupies = [
2840
+ ...held.map(({ file, source }) => ({ file, source: source as LaneSource | "declared" })),
2841
+ ...(run.lane?.files ?? []).map((file) => ({ file, source: "declared" as const })),
2842
+ ];
2843
+ const collision = occupies.find(({ file }) => wanted.has(file));
2844
+ if (collision !== undefined) {
2845
+ return refuse(
2846
+ "recovery-lane-occupied",
2847
+ `refused: ${collision.file} is held by run ${run.id} for #${run.issue} (${run.state}, ${collision.source}), ` +
2848
+ `and preserved branch ${target.branch} carries it. Publishing now would put this frozen diff into ` +
2849
+ "review beside a worker still rewriting that file. The lease releases when that run settles — recover " +
2850
+ "then, or land its work first.",
2851
+ issue,
2852
+ );
2853
+ }
2854
+ }
2855
+ }
2856
+ }
2857
+
2419
2858
  // The PR's own voice comes from durable state only: the issue title and the
2420
2859
  // run's settlement report. The closing keyword must name the TRACKER
2421
2860
  // repository — the issue lives there, not in the routed code repo, and