omp-conductor 0.18.0 → 0.18.2

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 (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
@@ -42,13 +42,20 @@ import { randomUUID } from "node:crypto";
42
42
  import { createServer, type Server, type Socket } from "node:net";
43
43
 
44
44
  import { resolvePolicy, resolveReleaseGrants, resolveReview } from "../config.ts";
45
- import { effectiveLane, laneEcho } from "../admission.ts";
45
+ // The mediated release is the drain's closing gesture (#791): a successful
46
+ // release ends this project's bounded release window, so the privileged half
47
+ // clears the record through the same operator surface the CLI uses.
48
+ import { cancelDrain } from "../fleet.ts";
49
+ import { effectiveLane, laneEcho, writeLaneSectionHeading } from "../admission.ts";
46
50
  import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
47
51
  import { repoSlugFor, type readBaseChain as readBaseChainType } from "../gitops.ts";
48
52
  import { releaseRefusal } from "../release-policy.ts";
53
+ import { PR_LOOKUP_WINDOW_MS, REVISABLE_RUN_STATES, prReviewReadiness } from "../decisions.ts";
49
54
  import { LIVE_STATES } from "../store.ts";
50
55
  import type {
56
+ FileLane,
51
57
  IssueComment,
58
+ IssueState,
52
59
  OpenCloser,
53
60
  PrState,
54
61
  PrVerification,
@@ -58,13 +65,14 @@ import type {
58
65
  RepoTarget,
59
66
  ReviewReason,
60
67
  RunRecord,
68
+ RunState,
61
69
  Store,
62
70
  Tracker,
63
71
  VerbLedgerEntry,
64
72
  VerbName,
65
73
  VerbRefusal,
66
74
  } from "../types.ts";
67
- import { prUrlParts } from "../tracker/github.ts";
75
+ import { prUrlParts, GhPrMissingError } from "../tracker/github.ts";
68
76
  import { parseVerbRequest, roleRefusal, VERB_SPECS, type VerbReply } from "./protocol.ts";
69
77
  import {
70
78
  peerVerdict,
@@ -224,11 +232,18 @@ export interface VerbDeps {
224
232
  * every lifecycle label on the tracker was written by the conductor (#26).
225
233
  * Telling a caller "not in the vocabulary" about a label it can plainly see on
226
234
  * the issue teaches it to try harder; telling it "that transition is the
227
- * daemon's" tells it to stop.
235
+ * daemon's" tells it to stop. The operator's park label is a third kind and a
236
+ * harder line than either: it is not "not in the vocabulary" (setup creates it,
237
+ * so that refusal would evaporate the day the vocabulary grows) and not the
238
+ * daemon's either — it is the operator's, and naming that owner is the point
239
+ * (#507). An orchestrator that could set or clear a park could hide work it
240
+ * does not want to groom.
228
241
  */
229
242
  export interface LabelVocabulary {
230
243
  grantable: string[];
231
244
  lifecycle: string[];
245
+ /** Labels only the operator may change: the park gesture. */
246
+ operator: string[];
232
247
  }
233
248
 
234
249
  export function labelVocabulary(p: ProjectConfig): LabelVocabulary {
@@ -238,6 +253,7 @@ export function labelVocabulary(p: ProjectConfig): LabelVocabulary {
238
253
  ...Object.keys(p.routing.repos).map((name) => `${p.routing.labelPrefix}${name}`),
239
254
  ],
240
255
  lifecycle: [p.stateLabels.inProgress, p.stateLabels.blocked, p.stateLabels.failed],
256
+ operator: [p.stateLabels.backlog],
241
257
  };
242
258
  }
243
259
 
@@ -308,6 +324,12 @@ export interface ReleaseFacts {
308
324
  blockingRuns?: { repo: string; issue: number }[];
309
325
  /** Queue depth, or `undefined` when the tracker could not be read. */
310
326
  queueDepth: number | undefined;
327
+ /**
328
+ * The project this release targets, so a runs-settled refusal can name that
329
+ * project's own bounded drain command as the remedy (#791). Absent when the
330
+ * caller cannot name one; the remedy then omits --project.
331
+ */
332
+ project?: string;
311
333
  /** Current live-head workflow verdict for the released routed repository. */
312
334
  baseCheck?: RunRecord["baseCheck"];
313
335
  /** Evidence attached to a current red verdict. */
@@ -342,10 +364,20 @@ export function releaseRequirementRefusal(
342
364
  facts.blockingRuns === undefined || facts.blockingRuns.length === 0
343
365
  ? ""
344
366
  : ` — blocking: ${facts.blockingRuns.map((r) => `${r.repo} #${r.issue}`).join(", ")}`;
367
+ // The bounded drain is the release-window remedy (#484): it pauses only
368
+ // claiming while the settle pass keeps running, and expires on an
369
+ // absolute deadline. It is the one command that reaches this gate
370
+ // honestly — not hold, which stops every pass, and no queue-label
371
+ // churn, which writes tracker state (#791).
372
+ const drain =
373
+ ". Settle the fleet with a bounded drain: " +
374
+ `omp-conductor drain start${facts.project === undefined ? "" : ` --project ${facts.project}`} --until <deadline>` +
375
+ " — claiming pauses while settlement runs, and admission resumes at the deadline on its own.";
345
376
  return (
346
377
  `policy.release.requires includes ${requirement} and ${facts.unsettledRuns} run(s) have not settled` +
347
378
  (parts.length === 0 ? "" : ` — ${parts.join("; ")}`) +
348
- blocking
379
+ blocking +
380
+ drain
349
381
  );
350
382
  }
351
383
  if (requirement === "no-open-prs" && facts.openPrs > 0) {
@@ -521,6 +553,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
521
553
  verb === "conductor_pr_update_branch" ||
522
554
  verb === "conductor_pr_update" ||
523
555
  verb === "conductor_pr_review" ||
556
+ verb === "conductor_pr_recover" ||
524
557
  verb === "conductor_label");
525
558
  const stopped =
526
559
  spec.mutating && verb !== "conductor_release" && verb !== "conductor_install" && !orchestratorCompletion
@@ -534,7 +567,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
534
567
  "fleet-paused",
535
568
  `refused: ${stopped}. The pause refuses new claims and work-starting mutations. ` +
536
569
  "Completion verbs for runs admitted before the pause (conductor_pr_merge, conductor_pr_update_branch, " +
537
- "conductor_pr_update, conductor_label), conductor_release and conductor_install remain available.",
570
+ "conductor_pr_update, conductor_pr_recover, conductor_label), conductor_release and conductor_install remain available.",
538
571
  );
539
572
  }
540
573
  }
@@ -558,6 +591,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
558
591
  return installVerb(deps, project, channel, args, refuse, allow);
559
592
  case "conductor_pr_review":
560
593
  return prReviewVerb(deps, project, channel, args, refuse, allow);
594
+ case "conductor_pr_recover":
595
+ return prRecoverVerb(deps, project, channel, args, refuse, allow);
561
596
  case "conductor_pr_status":
562
597
  return prStatusVerb(deps, project, channel, args, refuse, allow);
563
598
  }
@@ -702,9 +737,9 @@ async function prCreateVerb(
702
737
  *
703
738
  * How far back to look is the recent-history cutoff `status` already uses: a
704
739
  * pull request older than that is not one an orchestrator is mid-flight on.
740
+ * The bound itself is shared with the review watch (`PR_LOOKUP_WINDOW_MS` in
741
+ * `decisions.ts`), so verb and condition resolve ownership identically.
705
742
  */
706
- export const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
707
-
708
743
  function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | undefined {
709
744
  // Every attempt that recorded this PR, newest first — not `recentRuns`'
710
745
  // newest-attempt-per-issue view. The row a PR belongs to is the newest
@@ -1073,6 +1108,18 @@ async function labelVerb(
1073
1108
 
1074
1109
  const label = String(args["label"]);
1075
1110
  const vocabulary = labelVocabulary(project);
1111
+ if (vocabulary.operator.includes(label)) {
1112
+ // Harder than the lifecycle refusal: add and remove alike, and never
1113
+ // learned away by growing the vocabulary. The park is the operator's
1114
+ // gesture — an orchestrator that can park work can hide work it does not
1115
+ // want to groom (#507).
1116
+ return refuse(
1117
+ "label-is-operator-owned",
1118
+ `refused: ${label} is the operator's park label and only the operator may set or clear it. ` +
1119
+ "No session — orchestrator or worker — may add or remove it; use the tracker directly.",
1120
+ ref.issue,
1121
+ );
1122
+ }
1076
1123
  if (vocabulary.lifecycle.includes(label)) {
1077
1124
  return refuse(
1078
1125
  "label-is-lifecycle",
@@ -1090,6 +1137,36 @@ async function labelVerb(
1090
1137
  }
1091
1138
 
1092
1139
  const action = args["action"] === "remove" ? "remove" : "add";
1140
+ const outcome = `${action === "add" ? "added" : "removed"} ${label} on #${ref.issue}.`;
1141
+ // Adding the queue label is the promotion: the author is asserting the file
1142
+ // lane right now, so the verb echoes the one admission will enforce — the
1143
+ // parsed file list, or the explicit fail-open note (#724). A clearly
1144
+ // delimited write-lane section that parsed nothing REFUSES the promotion
1145
+ // instead of failing open (#825): the echo must never claim fail-open for a
1146
+ // heading that plainly tried to declare one, and failing open beside
1147
+ // overlapping work is the exact gap the interlock exists to close. Best
1148
+ // effort still holds for reads: an unreadable issue or thread can never
1149
+ // block the promotion itself (the label is the point), so a failed read
1150
+ // falls back to the plain message.
1151
+ let echo: string | undefined;
1152
+ let malformed: string | undefined;
1153
+ if (action === "add" && label === project.queueLabel) {
1154
+ const lane = await laneForEcho(deps, ref.issue);
1155
+ if (lane !== undefined) {
1156
+ malformed = lane.malformed;
1157
+ echo = laneEcho(lane.lane);
1158
+ }
1159
+ }
1160
+ if (malformed !== undefined) {
1161
+ return refuse(
1162
+ "file-lane-unparseable",
1163
+ `refused: ${label} was not added — #${ref.issue} declares a write-lane section (${malformed}) but no ` +
1164
+ "path-like files parsed under it, so admission would fail open beside overlapping work. " +
1165
+ 'Name the files as backticked bullets directly under the heading (e.g. "- `omp/src/a.ts` — what ' +
1166
+ 'changes"), or remove the heading, then add the queue label again.',
1167
+ ref.issue,
1168
+ );
1169
+ }
1093
1170
  // The queue label is the fleet's lock, so it must not ride the GraphQL
1094
1171
  // surface a provider outage just broke: `gh issue edit` is a GraphQL
1095
1172
  // mutation in gh 2.86, and #642 comment 4 measured a label withdrawal
@@ -1107,16 +1184,6 @@ async function labelVerb(
1107
1184
  return refuse("action-failed", `refused: the tracker rejected the label change:\n${why}`, ref.issue);
1108
1185
  }
1109
1186
 
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
1187
  return allow(
1121
1188
  echo === undefined ? outcome : `${outcome} File lane: ${echo}.`,
1122
1189
  undefined,
@@ -1126,12 +1193,15 @@ async function labelVerb(
1126
1193
 
1127
1194
  /**
1128
1195
  * 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.
1196
+ * one-line echo (#724), plus the malformed write-lane section marker (#825):
1197
+ * the body plus the whole comment thread, the same inputs `effectiveLane`
1198
+ * reads at admission. `undefined` when either read fails or the tracker cannot
1199
+ * produce the issue — the promotion is never blocked by its own feedback.
1133
1200
  */
1134
- async function laneEchoForIssue(deps: VerbDeps, issue: number): Promise<string | undefined> {
1201
+ async function laneForEcho(deps: VerbDeps, issue: number): Promise<
1202
+ | { lane: FileLane | undefined; malformed: string | undefined }
1203
+ | undefined
1204
+ > {
1135
1205
  let body: string;
1136
1206
  let comments: IssueComment[];
1137
1207
  try {
@@ -1142,7 +1212,15 @@ async function laneEchoForIssue(deps: VerbDeps, issue: number): Promise<string |
1142
1212
  } catch {
1143
1213
  return undefined;
1144
1214
  }
1145
- return laneEcho(effectiveLane(body, comments));
1215
+ const lane = effectiveLane(body, comments);
1216
+ if (lane !== undefined) return { lane, malformed: undefined };
1217
+ // No declaration parsed anywhere. If a clearly delimited write-lane section
1218
+ // exists anyway, the section tried to declare and failed — the echo must
1219
+ // refuse, never claim fail-open.
1220
+ const heading =
1221
+ writeLaneSectionHeading(body) ??
1222
+ comments.map((c) => writeLaneSectionHeading(c.body)).find((h) => h !== undefined);
1223
+ return { lane: undefined, malformed: heading };
1146
1224
  }
1147
1225
 
1148
1226
  async function releaseVerb(
@@ -1299,6 +1377,7 @@ async function releaseVerb(
1299
1377
  ...(unconfirmedMerges === undefined ? {} : { unconfirmedMerges }),
1300
1378
  ...(blockingRuns === undefined ? {} : { blockingRuns }),
1301
1379
  queueDepth,
1380
+ project: project.name,
1302
1381
  ...(health === undefined ? {} : { baseCheck: health.verdict }),
1303
1382
  ...(health?.verdict === "red" && health.detail !== undefined
1304
1383
  ? { redBase: health.detail }
@@ -1326,6 +1405,15 @@ async function releaseVerb(
1326
1405
  if (!outcome.ok) {
1327
1406
  return refuse("action-failed", `refused: the release command failed:\n${outcome.stderr}`);
1328
1407
  }
1408
+ // The bounded drain is a release window: it stays latched until the release
1409
+ // work it fences has actually completed, then clears so admission resumes
1410
+ // without a second operator action (#791). Every refusal and failed action
1411
+ // above returned without touching it. The release-window boundary is the
1412
+ // *terminal* act: a version-bump preparation — whether opened, already
1413
+ // prepared, or merged — explicitly leaves git-tag pending (review #1 on
1414
+ // #823), so clearing there would admit a run that makes that follow-up
1415
+ // git-tag fail runs-settled and forces a second drain. Only the final
1416
+ // cut/publish/deploy allow below clears the drain.
1329
1417
  if (shape === "version-bump-pr") {
1330
1418
  if (outcome.review === undefined) {
1331
1419
  return allow(
@@ -1397,6 +1485,7 @@ async function releaseVerb(
1397
1485
  deps.store.releaseMergeLock(project.name, holderId);
1398
1486
  }
1399
1487
  }
1488
+ cancelDrain(project.name);
1400
1489
  return allow(
1401
1490
  `cut ${shape} for ${repo.name}${outcome.detail === undefined ? "" : ` — ${outcome.detail}`}.`,
1402
1491
  outcome.sha,
@@ -1614,12 +1703,41 @@ async function prUpdateVerb(
1614
1703
  * session directory — so a revision round never creates a new attempt and
1615
1704
  * never touches the failed-attempt or continuation budgets.
1616
1705
  *
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.
1706
+ * A second orchestrator axis finishing against the same fixed head before the
1707
+ * first revision dispatches folds its finding into the pending revision
1708
+ * (#786) rather than being refused: one round, one row, one worker attempt,
1709
+ * and the resumed session reads both findings. The fold is only for the same
1710
+ * reviewed head; a pending revision at a different head is a review of a
1711
+ * moved PR and is refused, as is anything once the revision has dispatched.
1712
+ *
1713
+ * The duplicate in-flight guard therefore has two halves: while a revision is
1714
+ * queued but not yet dispatched, the pending `review_revisions` row folds a
1715
+ * same-head finding into itself atomically inside `enqueueReviewRevision` and
1716
+ * refuses a different head; once dispatched, the run row itself is `running`
1717
+ * (claimed from a revisable terminal state by the dispatch pass), which this
1718
+ * verb refuses by name — an already-running worker's prompt is never silently
1719
+ * changed.
1720
+ */
1721
+
1722
+ /**
1723
+ * The run states a review revision may start from (#795).
1724
+ *
1725
+ * A revision round resumes the exact run whose row owns the PR, so the
1726
+ * revisable states are exactly the terminal runs that pushed one: a settled
1727
+ * `pushed-green` row, or a `failed` / `killed` row — a run that capped or
1728
+ * failed *after* pushing a green PR. The PR is the durable artefact, the
1729
+ * exact-head green verification in the verb is the gate on "green at the
1730
+ * reviewed SHA", and a terminal row proves no worker is in flight, so the
1731
+ * findings are returned without the close-PR → unblock → continuation dance.
1732
+ *
1733
+ * Closed on purpose: a live row (`running` / `claimed`) is already doing its
1734
+ * own work, a `pushed-pending` PR is not green yet, and a `blocked` /
1735
+ * `orphaned` / `stopped` / `merged` row is not work returned for revision.
1736
+ * The set itself is the shared `REVISABLE_RUN_STATES` in `decisions.ts` —
1737
+ * the one definition the review verb and the `pr-review-ready` watch gate on,
1738
+ * so the two can never drift (#844).
1622
1739
  */
1740
+
1623
1741
  async function prReviewVerb(
1624
1742
  deps: VerbDeps,
1625
1743
  project: ProjectConfig,
@@ -1633,18 +1751,22 @@ async function prReviewVerb(
1633
1751
  const findings = String(args["findings"]);
1634
1752
  const reason = String(args["reason"]) as ReviewReason;
1635
1753
 
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) {
1754
+ // The shared review-readiness gate (#844, #870): the newest project-owned
1755
+ // run that recorded the PR a stopped duplicate is transparent to that
1756
+ // selection (#870) in a revisable state; the same predicate the
1757
+ // `pr-review-ready` watch reads, so the verb and the condition resolve
1758
+ // ownership and revisability identically. A PR no run of this project
1759
+ // opened (or one outside the routed repos) cannot be returned to a worker
1760
+ // this project can resume.
1761
+ const readiness = prReviewReadiness(deps.store, project.name, prUrl, deps.now());
1762
+ if (readiness.kind === "no-owner") {
1642
1763
  return refuse(
1643
1764
  "pr-not-this-run",
1644
1765
  `refused: ${prUrl} is not a pull request any run in ${project.name} opened. ` +
1645
1766
  "A review revision acts on a run-owned PR only.",
1646
1767
  );
1647
1768
  }
1769
+ const target = readiness.run;
1648
1770
  if (!prInProjectRouting(project, prUrl)) {
1649
1771
  return refuse(
1650
1772
  "pr-not-this-run",
@@ -1663,18 +1785,22 @@ async function prReviewVerb(
1663
1785
  );
1664
1786
  }
1665
1787
 
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") {
1788
+ // The revisable state is a run that pushed a green PR and is not live: a
1789
+ // settled `pushed-green` row, or a `failed` / `killed` row — a run that
1790
+ // capped or failed after the push (#795). The exact-head verification below
1791
+ // still re-reads the PR and insists it is green at the reviewed SHA, so
1792
+ // admitting a capped row here never reviews a PR that stopped being green.
1793
+ // Any other state is a worker in flight (the original run or an earlier
1794
+ // revision) or a run whose PR no longer waits on revision — the detail names
1795
+ // the state field that produced the refusal.
1796
+ if (readiness.kind !== "ready") {
1671
1797
  const live = target.state === "running" || target.state === "claimed";
1672
1798
  return refuse(
1673
1799
  "review-in-flight",
1674
- `refused: run ${target.id} is ${target.state}, not pushed-green — ${
1800
+ `refused: run ${target.id} is ${target.state}, not a settled green run — ${
1675
1801
  live
1676
1802
  ? `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}.`
1803
+ : `a review revision starts from a run that pushed a green PR, and this one is ${target.state}.`
1678
1804
  }`,
1679
1805
  issue,
1680
1806
  );
@@ -1723,8 +1849,14 @@ async function prReviewVerb(
1723
1849
  // the PR open, record the unresolved findings and escalate once. The same
1724
1850
  // count `latestReviewRound` drives the renderer's `review-revision N`, so
1725
1851
  // the bound and the visible round can never disagree.
1852
+ //
1853
+ // A finding that folds into an already-pending revision opens no round, so
1854
+ // the ceiling never binds an append — only a genuine new round counts. The
1855
+ // store is the single authority on the fold; this read exists only to
1856
+ // decide whether the ceiling gates this call.
1726
1857
  const review = resolveReview(project);
1727
- if (deps.store.latestReviewRound(project.name, target.id) >= review.maxRounds) {
1858
+ const pendingForRun = deps.store.pendingReviewForRun(project.name, target.id);
1859
+ if (pendingForRun === undefined && deps.store.latestReviewRound(project.name, target.id) >= review.maxRounds) {
1728
1860
  return refuse(
1729
1861
  "review-round-ceiling",
1730
1862
  `refused: ${prUrl} has already been through ${review.maxRounds} review round(s) ` +
@@ -1734,11 +1866,29 @@ async function prReviewVerb(
1734
1866
  );
1735
1867
  }
1736
1868
 
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.
1869
+ // The run row is re-read synchronously, immediately before the durable
1870
+ // enqueue and with no await in between: the daemon's dispatch pass
1871
+ // (`pushed-green` `running`) cannot land inside a single synchronous
1872
+ // block, so a revision that dispatched while the live-head verification
1873
+ // above was in flight is refused here rather than racing the worker that
1874
+ // now owns the PR — an already-running worker's prompt is never silently
1875
+ // changed.
1876
+ const fresh = deps.store.getRun(target.id);
1877
+ if (fresh === undefined || REVISABLE_RUN_STATES[fresh.state] !== true) {
1878
+ return refuse(
1879
+ "review-in-flight",
1880
+ `refused: run ${target.id} is no longer revisable on ${prUrl} (now ${fresh?.state ?? "gone"}) — ` +
1881
+ "the pending revision has dispatched to a worker. " +
1882
+ "Wait for it to settle, then return the finding as its own later review round.",
1883
+ issue,
1884
+ );
1885
+ }
1886
+
1887
+ // Durable, and decided atomically against a concurrent same-head request:
1888
+ // the same transaction that records a new round also folds a same-head
1889
+ // finding into the one already pending, and the merge never overwrites.
1740
1890
  const round = deps.store.latestReviewRound(project.name, target.id) + 1;
1741
- const recorded = deps.store.createReviewRevision({
1891
+ const enqueued = deps.store.enqueueReviewRevision({
1742
1892
  project: project.name,
1743
1893
  runId: target.id,
1744
1894
  issue,
@@ -1747,20 +1897,565 @@ async function prReviewVerb(
1747
1897
  findings,
1748
1898
  round,
1749
1899
  reason,
1750
- ...(target.sessionFile === undefined ? {} : { sessionFile: target.sessionFile }),
1900
+ ...(fresh.sessionFile === undefined ? {} : { sessionFile: fresh.sessionFile }),
1751
1901
  requestedAt: deps.now(),
1752
1902
  });
1753
- if (recorded === undefined) {
1903
+ if (enqueued.kind === "refused") {
1754
1904
  return refuse(
1755
1905
  "review-in-flight",
1756
- `refused: a review revision is already pending for ${prUrl} (run ${target.id}); exactly one revision is in flight per PR.`,
1906
+ `refused: a review revision is already pending for ${prUrl} (run ${target.id}) at a different head ` +
1907
+ "amend nothing, because that round reviewed a moved PR. Wait for it to dispatch and settle, then return the finding as its own later review round.",
1908
+ issue,
1909
+ );
1910
+ }
1911
+ const revision = enqueued.record;
1912
+ return allow(
1913
+ enqueued.kind === "created"
1914
+ ? `recorded review round ${revision.round} for ${prUrl} at ${headSha}; the daemon will resume run ${target.id}'s session` +
1915
+ `${fresh.sessionFile === undefined ? "" : ` (${fresh.sessionFile})`} on its next dispatch pass.`
1916
+ : `appended a finding to the pending review round ${revision.round} for ${prUrl} at ${headSha}; ` +
1917
+ "the daemon will resume run " +
1918
+ `${target.id}'s session${fresh.sessionFile === undefined ? "" : ` (${fresh.sessionFile})`} on its next dispatch pass ` +
1919
+ "and the resumed worker reads both findings.",
1920
+ undefined,
1921
+ issue,
1922
+ );
1923
+ }
1924
+
1925
+ /**
1926
+ * The run states a missing-PR recovery may act on (#806).
1927
+ *
1928
+ * Recovery exists for a run whose worker finished and recorded an exact head
1929
+ * but whose PR never materialised — exactly the states the settlement sweep
1930
+ * owns: `pushed-pending` (the #777/#809 ghosts) and `pushed-green`. Those
1931
+ * rows sit in the store's active set, so the sweep verifies the recovered
1932
+ * PR's checks on its next tick (`pushed-pending` → `pushed-green`) and
1933
+ * review/merge settlement resumes normally. A terminal row outside this set —
1934
+ * failed/killed/stopped/orphaned/blocked — has its own prescribed
1935
+ * reconciliation (continuation or review revision, operator stop, restart
1936
+ * reconciliation, unblock), and receiving a PR would strand it: nothing would
1937
+ * ever verify, review or merge it.
1938
+ *
1939
+ * Closed on purpose, like {@link REVISABLE_RUN_STATES}: recovery may never
1940
+ * widen into a state the daemon's own sweep does not revisit.
1941
+ */
1942
+ const RECOVERABLE_RUN_STATES: Record<string, true> = {
1943
+ "pushed-pending": true,
1944
+ "pushed-green": true,
1945
+ };
1946
+
1947
+ /** A complete commit SHA, as current workers record it. */
1948
+ const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
1949
+
1950
+ /**
1951
+ * A legacy abbreviated commit SHA (#836): the `git rev-parse --short` output
1952
+ * an older worker generation recorded instead of the full SHA. Git defaults
1953
+ * to 7 hex characters and lengthens the abbreviation when 7 is ambiguous, so
1954
+ * a legacy record is always at least 7 hex; 4–6 hex are git-legal
1955
+ * abbreviations but cannot be legacy records, and at that length the prefix
1956
+ * evidence is too weak for an identity recovery to rest on. Anything shorter
1957
+ * than git's own abbreviation minimum (4) is not a usable object id at all —
1958
+ * ambiguous, and it fails closed.
1959
+ */
1960
+ const ABBREVIATED_HEAD_SHA = /^[0-9a-f]{7,39}$/i;
1961
+
1962
+ /**
1963
+ * The durable PR field's worker settlement sentinel (#866). A run row whose
1964
+ * settlement recorded "no PR observed yet" carries the literal `pending`
1965
+ * instead of a URL. It asserts nothing about any GitHub pull request — there
1966
+ * is no PR to read — so recovery normalizes it to absence and re-proves the
1967
+ * no-duplicate shape before opening a PR, exactly like a row that recorded no
1968
+ * URL at all. Querying GitHub with it would only produce the tracker's
1969
+ * fail-closed `undefined`, which recovery would misread as an unreadable-but-
1970
+ * real PR and refuse as retryable forever.
1971
+ */
1972
+ const PR_PENDING_SENTINEL = "pending";
1973
+
1974
+ /**
1975
+ * Open (or adopt) the missing pull request for a settled run whose branch was
1976
+ * preserved (#806).
1977
+ *
1978
+ * A worker that pushed and recorded an exact head can still leave the run
1979
+ * spent: the PR it claimed never existed (a guessed URL — #779), or the run
1980
+ * settled before a PR was ever opened. Nothing else in the verb surface can
1981
+ * act then — the mediated PR verbs resolve the PR from a URL some run
1982
+ * recorded (`conductor_pr_review` refuses "not a PR any run opened"), the
1983
+ * worker-only create verb is gone with the worker, and the package-floor
1984
+ * fallback is a fresh clone and re-push, which republishes code. This is the
1985
+ * strictly smaller act: publish the pull request the run's work already
1986
+ * earned, at the exact head the daemon recorded.
1987
+ *
1988
+ * Every identity the worker's own `conductor_pr_create` read off its live
1989
+ * channel is re-proven here in its absence:
1990
+ * - the run is terminal — no worker is live on it (`claimed`/`running`
1991
+ * refuse, and a merged row is resolved, not recoverable);
1992
+ * - the routed repository is still routed, and the branch comes from the run
1993
+ * row, never from an argument — `branch`/`base`/`headSha` are assertions
1994
+ * exactly as on the worker verb, and a disagreement is a refusal, never a
1995
+ * redirect;
1996
+ * - the remote branch still exists at the recorded identity: a 40-hex head
1997
+ * must equal the live branch head exactly, and a legacy abbreviated head
1998
+ * (7–39 hex, #836) may only canonicalize against the fixed routed branch —
1999
+ * the live branch head must begin with it, and that canonical object is
2000
+ * the identity compared everywhere after, never an argument;
2001
+ * - the issue still wants the work: a closed issue, a merged row or a merged
2002
+ * recorded PR is "resolved" and refuses; a recorded PR a human closed
2003
+ * without merging refuses as `pr-not-open`;
2004
+ * - no PR already represents that head: an open closer on the run's branch
2005
+ * at the recorded head is adopted (idempotent — the row gains the URL it
2006
+ * never recorded, so review/merge verbs can act on it), an open closer at
2007
+ * a different head or on another branch refuses, and a recorded PR that is
2008
+ * still open is that same recovery's answer.
2009
+ *
2010
+ * The PR describes itself from durable state only — no title/body arguments,
2011
+ * because nothing here may publish a headline a session did not already
2012
+ * settle. A definitively missing recorded PR (a corroborated 404 — the #779
2013
+ * guess) is replaced by the new one; an unreadable recorded PR, unreadable
2014
+ * branch head or unreadable closer list fails closed and stays retryable.
2015
+ */
2016
+ async function prRecoverVerb(
2017
+ deps: VerbDeps,
2018
+ project: ProjectConfig,
2019
+ channel: VerbChannel,
2020
+ args: Record<string, unknown>,
2021
+ refuse: Refuse,
2022
+ allow: Allow,
2023
+ ): Promise<Verdict> {
2024
+ const ref = issueRefFrom(String(args["issueUrl"]));
2025
+ if (ref === undefined) {
2026
+ return refuse(
2027
+ "malformed-argument",
2028
+ `refused: "${String(args["issueUrl"])}" is not a full issue URL. A bare number resolves against whatever ` +
2029
+ "repository the daemon happens to be in, which is how a recovered PR lands on the wrong project's issue.",
2030
+ );
2031
+ }
2032
+ if (ref.repo !== project.tracker.repo) {
2033
+ return refuse(
2034
+ "malformed-argument",
2035
+ `refused: ${ref.repo} is not this project's tracker (${project.tracker.repo}).`,
2036
+ );
2037
+ }
2038
+
2039
+ // The run is the newest attempt for the issue — the row that occupies the
2040
+ // issue's slot, and exactly the row `unblock`/`worker stop` name by issue
2041
+ // number. Recovery never re-implements dispatch's per-issue choice.
2042
+ const target = deps.store.latestRun(project.name, ref.issue);
2043
+ if (target === undefined) {
2044
+ return refuse(
2045
+ "recovery-no-run",
2046
+ `refused: ${project.name} has no recorded attempt for #${ref.issue}, so there is no run branch to recover a ` +
2047
+ "pull request for. Check the issue's status before trying again.",
2048
+ );
2049
+ }
2050
+ const issue = target.issue;
2051
+
2052
+ if (LIVE_STATES.some((state) => state === target.state)) {
2053
+ return refuse(
2054
+ "recovery-run-live",
2055
+ `refused: run ${target.id} for #${issue} is ${target.state} — a worker is still live on it. ` +
2056
+ "Recovery opens the missing PR only for a settled run; wait for it to settle (or stop the worker) first.",
2057
+ issue,
2058
+ );
2059
+ }
2060
+ if (target.state === "merged") {
2061
+ return refuse(
2062
+ "recovery-issue-resolved",
2063
+ `refused: run ${target.id} for #${issue} already merged — the work landed and there is no missing PR to recover.`,
2064
+ issue,
2065
+ );
2066
+ }
2067
+ if (RECOVERABLE_RUN_STATES[target.state] !== true) {
2068
+ const guidance =
2069
+ target.state === "failed" || target.state === "killed"
2070
+ ? "A failed or killed run's branch continues through `omp-conductor unblock` (continuation), or through a review revision once it owns a PR."
2071
+ : target.state === "stopped"
2072
+ ? "The operator ended this run; recovery does not reopen stopped work."
2073
+ : target.state === "orphaned"
2074
+ ? "Restart reconciliation owns orphaned runs."
2075
+ : "Answer the run's question and unblock the issue.";
2076
+ return refuse(
2077
+ "recovery-unsettled-state",
2078
+ `refused: run ${target.id} for #${issue} is ${target.state}, a state the settlement sweep does not own — a ` +
2079
+ `recovered PR would never be verified, reviewed or merged. ${guidance}`,
2080
+ issue,
2081
+ );
2082
+ }
2083
+
2084
+ const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
2085
+ if (paused !== undefined) return paused;
2086
+
2087
+ const routed = Object.values(project.routing.repos).find((repo) => repo.name === target.repo);
2088
+ if (routed === undefined) {
2089
+ return refuse(
2090
+ "recovery-repo-unrouted",
2091
+ `refused: run ${target.id} was routed to repository ${target.repo}, which ${project.name} no longer routes ` +
2092
+ `(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}). ` +
2093
+ "A recovery PR cannot be created for a repository the daemon has no entry for; re-route the repo first.",
2094
+ issue,
2095
+ );
2096
+ }
2097
+
2098
+ // The recorded head the preserved branch must still carry. Absent, the
2099
+ // recovery cannot prove it would publish this run's work rather than
2100
+ // whatever the branch tip happens to be — fail closed on the missing
2101
+ // record. Legacy rows recorded git's default abbreviation (`rev-parse
2102
+ // --short`, 7+ hex) instead of the full SHA; those stay eligible and are
2103
+ // canonicalized against the fixed routed branch below (#836).
2104
+ const recordedHead = target.headSha;
2105
+ if (recordedHead === undefined) {
2106
+ return refuse(
2107
+ "recovery-unrecorded-head",
2108
+ `refused: run ${target.id} for #${issue} records no head to verify its branch against. ` +
2109
+ "Recovery publishes only the head the run itself recorded.",
2110
+ issue,
2111
+ );
2112
+ }
2113
+ const isFullHead = FULL_HEAD_SHA.test(recordedHead);
2114
+ const isAbbreviatedHead = !isFullHead && ABBREVIATED_HEAD_SHA.test(recordedHead);
2115
+ if (!isFullHead && !isAbbreviatedHead) {
2116
+ return refuse(
2117
+ "recovery-unrecorded-head",
2118
+ `refused: run ${target.id} for #${issue} records ${JSON.stringify(recordedHead)}, which is neither a full ` +
2119
+ "40-hex SHA nor an unambiguous legacy abbreviation of one (7–39 hex). Recovery publishes only the head " +
2120
+ "the run itself recorded.",
2121
+ issue,
2122
+ );
2123
+ }
2124
+
2125
+ // Assertions, never selectors — the mirror of `conductor_pr_create`'s
2126
+ // head/base claims. A disagreement means the caller is pointing somewhere
2127
+ // the run row does not own; the daemon derives everything from the row.
2128
+ const claimedBranch = args["branch"];
2129
+ if (typeof claimedBranch === "string" && claimedBranch !== target.branch) {
2130
+ return refuse(
2131
+ "ref-not-run-branch",
2132
+ `refused: run ${target.id}'s recorded branch is ${target.branch}, and you named "${claimedBranch}". ` +
2133
+ "Recovery derives the head branch from the run; it cannot be pointed at another branch.",
2134
+ issue,
2135
+ );
2136
+ }
2137
+ const claimedBase = args["base"];
2138
+ if (typeof claimedBase === "string" && claimedBase !== routed.defaultBranch) {
2139
+ return refuse(
2140
+ "base-not-default-branch",
2141
+ `refused: ${routed.name} targets ${routed.defaultBranch}, and you named "${claimedBase}". ` +
2142
+ "The base is the configured defaultBranch, not an argument.",
2143
+ issue,
2144
+ );
2145
+ }
2146
+ const claimedHead = args["headSha"];
2147
+ if (typeof claimedHead === "string" && claimedHead.toLowerCase() !== recordedHead.toLowerCase()) {
2148
+ return refuse(
2149
+ "recovery-head-mismatch",
2150
+ `refused: run ${target.id} recorded head ${recordedHead}, and you asserted "${claimedHead}". ` +
2151
+ "The exact recorded head is the daemon's, not a selector.",
2152
+ issue,
2153
+ );
2154
+ }
2155
+
2156
+ // Closed issues are resolved issues: a merge closed it, or a human closed it
2157
+ // without merging. Either way it does not want a recovered PR — and an
2158
+ // unreadable state is not a green light: "could not tell" must never open a
2159
+ // PR beside (or for) an issue that may already be closed, so it fails
2160
+ // closed and stays retryable like every sibling read in this verb.
2161
+ let issueState: IssueState | undefined;
2162
+ try {
2163
+ issueState = await deps.tracker.issueState(issue);
2164
+ } catch (err) {
2165
+ issueState = undefined;
2166
+ deps.log(`conductor_pr_recover could not read #${issue} state: ${err instanceof Error ? err.message : String(err)}`);
2167
+ }
2168
+ if (issueState === undefined) {
2169
+ return refuse(
2170
+ "issue-state-unreadable",
2171
+ `refused: the tracker could not say whether #${issue} is open. An unreadable state is not a green light — ` +
2172
+ "retry before recovering, so a recovered PR is never opened beside (or for) a closed issue.",
2173
+ issue,
2174
+ );
2175
+ }
2176
+ if (issueState === "closed") {
2177
+ return refuse(
2178
+ "recovery-issue-resolved",
2179
+ `refused: #${issue} is closed. The work already merged or a human declined it; recovery does not reopen either.`,
2180
+ issue,
2181
+ );
2182
+ }
2183
+
2184
+ // The branch/head verification: the routed repository's branch, read live,
2185
+ // must still sit at the exact head this run recorded. This is the proof the
2186
+ // PR to be created publishes verified work and not something that replaced
2187
+ // it. An unreadable branch stays retryable — that is "could not tell", never
2188
+ // a green light.
2189
+ const identity = repoSlugFor(routed);
2190
+ let liveBranchHead: string | undefined;
2191
+ try {
2192
+ liveBranchHead = await deps.tracker.branchHead(identity, target.branch);
2193
+ } catch (err) {
2194
+ liveBranchHead = undefined;
2195
+ deps.log(
2196
+ `conductor_pr_recover could not read ${identity}/${target.branch} head: ${err instanceof Error ? err.message : String(err)}`,
2197
+ );
2198
+ }
2199
+ if (liveBranchHead === undefined) {
2200
+ return refuse(
2201
+ "head-unresolvable",
2202
+ `refused: the live head of ${identity}/${target.branch} could not be read. That is a transient or unknown ` +
2203
+ "state, not a green light — retry before recovering, and check the branch still exists.",
1757
2204
  issue,
1758
2205
  );
1759
2206
  }
1760
2207
 
2208
+ // The canonical object, decided from the fixed routed branch only (#836). A
2209
+ // full 40-hex record keeps its exact-identity behavior: the branch must
2210
+ // still sit at that object. A legacy abbreviated record can never be used
2211
+ // as an identity on its own — the live branch head must literally begin
2212
+ // with the abbreviation, and that full head becomes the identity every
2213
+ // later check compares against (PR adoption, no-duplicate proof, the PR's
2214
+ // own description). Nothing resolves the abbreviation anywhere else, and
2215
+ // never from caller input: `headSha` remains an assertion of the recorded
2216
+ // value, so no argument can appoint a different object.
2217
+ const canonicalHead: string = isFullHead ? recordedHead : liveBranchHead;
2218
+ if (isAbbreviatedHead) {
2219
+ if (!FULL_HEAD_SHA.test(liveBranchHead)) {
2220
+ return refuse(
2221
+ "head-unresolvable",
2222
+ `refused: the live head of ${identity}/${target.branch} is ${liveBranchHead}, not a full commit SHA, so the ` +
2223
+ `recorded abbreviation ${recordedHead} cannot be canonicalized against it. That is an unknown state, not a ` +
2224
+ "green light — retry before recovering.",
2225
+ issue,
2226
+ );
2227
+ }
2228
+ if (!liveBranchHead.toLowerCase().startsWith(recordedHead.toLowerCase())) {
2229
+ return refuse(
2230
+ "recovery-head-mismatch",
2231
+ `refused: ${identity}/${target.branch} now points at ${liveBranchHead}, which does not begin with the ` +
2232
+ `recorded run head ${recordedHead}. A legacy abbreviated head canonicalizes only against a branch that ` +
2233
+ "still carries it; the branch was replaced, force-updated or continued elsewhere — a PR created now would " +
2234
+ "publish work the daemon did not verify at that head. Do not force; reconcile what the branch should be first.",
2235
+ issue,
2236
+ );
2237
+ }
2238
+ } else if (liveBranchHead.toLowerCase() !== recordedHead.toLowerCase()) {
2239
+ return refuse(
2240
+ "recovery-head-mismatch",
2241
+ `refused: ${identity}/${target.branch} now points at ${liveBranchHead}, not the recorded run head ${recordedHead}. ` +
2242
+ "The branch was replaced, force-updated or continued elsewhere — a PR created now would publish work the daemon " +
2243
+ "did not verify at that head. Do not force; reconcile what the branch should be first.",
2244
+ issue,
2245
+ );
2246
+ }
2247
+
2248
+ // The no-duplicate-proof: any OPEN PR that already closes the issue. The
2249
+ // run's own branch at the recorded head is the run's PR (whoever opened it —
2250
+ // the operator's manual recovery is PR #805 for run #777) and is adopted,
2251
+ // idempotently; the same branch at a different head, or another branch with
2252
+ // the issue, each refuse rather than duplicate.
2253
+ let closers: OpenCloser[];
2254
+ try {
2255
+ closers = await deps.tracker.openClosersFor(issue);
2256
+ } catch (err) {
2257
+ const why = err instanceof Error ? err.message : String(err);
2258
+ return refuse(
2259
+ "open-pr-lookup-error",
2260
+ `refused: the tracker could not say whether #${issue} already has an open pull request (${why}). ` +
2261
+ "Refusing rather than opening a second one.",
2262
+ issue,
2263
+ );
2264
+ }
2265
+ const sameBranch = closers.find(
2266
+ (closer) => closer.headRefName !== "" && closer.headRefName === target.branch && closer.repo === identity,
2267
+ );
2268
+ if (sameBranch !== undefined) {
2269
+ let live: string | undefined;
2270
+ try {
2271
+ live = await deps.tracker.prHead(sameBranch.url);
2272
+ } catch (err) {
2273
+ live = undefined;
2274
+ deps.log(
2275
+ `conductor_pr_recover could not read ${sameBranch.url} head: ${err instanceof Error ? err.message : String(err)}`,
2276
+ );
2277
+ }
2278
+ if (live === undefined) {
2279
+ return refuse(
2280
+ "head-unresolvable",
2281
+ `refused: ${sameBranch.url} is open on ${target.branch}, but its head could not be read. ` +
2282
+ "Retry before deciding whether to adopt it or treat it as a conflict.",
2283
+ issue,
2284
+ );
2285
+ }
2286
+ if (live.toLowerCase() === canonicalHead.toLowerCase()) {
2287
+ const already = deps.store.getRun(target.id)?.prUrl === sameBranch.url;
2288
+ if (!already) deps.store.updateRun(target.id, { prUrl: sameBranch.url });
2289
+ return allow(
2290
+ `recovered ${sameBranch.url} — an open pull request already exists at run head ${canonicalHead} ` +
2291
+ `(${target.branch}); ${
2292
+ already
2293
+ ? "it is already this run's recorded PR"
2294
+ : `recorded it on run ${target.id} so review and merge verbs can act on it`
2295
+ }.`,
2296
+ undefined,
2297
+ issue,
2298
+ );
2299
+ }
2300
+ return refuse(
2301
+ "recovery-head-mismatch",
2302
+ `refused: ${sameBranch.url} is open on ${target.branch} but at ${live}, not the recorded run head ${canonicalHead}. ` +
2303
+ "That open PR superseded the run's recorded head; a second PR would duplicate it.",
2304
+ issue,
2305
+ );
2306
+ }
2307
+ if (closers.length > 0) {
2308
+ const other = closers[0]!;
2309
+ return refuse(
2310
+ "open-pr-exists",
2311
+ `refused: ${other.url} is already open and already closes #${issue} (head ${other.headRefName || "unknown"} in ${
2312
+ other.repo || "unknown"
2313
+ }). Recovery opens a PR only for the run's own preserved branch, and this issue already has open work.`,
2314
+ issue,
2315
+ );
2316
+ }
2317
+
2318
+ // The durable PR field is normalized and validated before any recorded-PR
2319
+ // lookup (#866). A real recorded PR is a pull-request URL the tracker can
2320
+ // address — the `prUrlParts` shape, the same guard the tracker's own
2321
+ // `prState` applies. The `pending` settlement sentinel is not a URL: it
2322
+ // records the run believed a PR was in flight but never observed one, so it
2323
+ // is normalized to absence and recovery proceeds like a run with no
2324
+ // recorded PR. Any other non-URL value is corruption — there is nothing for
2325
+ // the tracker to read, and had it been queried the fail-closed `undefined`
2326
+ // would read as an unreadable-but-real PR that can never clear — so it
2327
+ // refuses by name instead of hanging the run on a retry.
2328
+ const recordedPrUrl = target.prUrl;
2329
+ const pendingSentinel = recordedPrUrl === PR_PENDING_SENTINEL;
2330
+ if (recordedPrUrl !== undefined && !pendingSentinel && prUrlParts(recordedPrUrl) === undefined) {
2331
+ return refuse(
2332
+ "recorded-pr-corrupt",
2333
+ `refused: run ${target.id} for #${issue} records prUrl ${JSON.stringify(recordedPrUrl)}, which is not a pull ` +
2334
+ "request URL. A non-URL durable prUrl is a corrupt run record, not an unreadable PR — recovery will not " +
2335
+ "guess a GitHub pull request from it. Clear or correct the run's prUrl, then retry.",
2336
+ issue,
2337
+ );
2338
+ }
2339
+
2340
+ if (!pendingSentinel && target.prUrl !== undefined) {
2341
+ // The run believes it has a PR. Before creating one, the recorded URL is
2342
+ // decided: still open → it is the recovery's answer (idempotent); merged →
2343
+ // the work landed; closed → a human declined it; definitively missing →
2344
+ // the #779 guess, replaced by a real PR below.
2345
+ let recorded: PrState | "missing" | undefined;
2346
+ try {
2347
+ recorded = await deps.tracker.prState(target.prUrl);
2348
+ } catch (err) {
2349
+ recorded = err instanceof GhPrMissingError ? "missing" : undefined;
2350
+ if (recorded !== "missing") {
2351
+ deps.log(
2352
+ `conductor_pr_recover could not read recorded PR ${target.prUrl}: ${err instanceof Error ? err.message : String(err)}`,
2353
+ );
2354
+ }
2355
+ }
2356
+ if (recorded === "open") {
2357
+ // An open recorded PR is the recovery's answer only when it actually
2358
+ // represents the recorded head — otherwise it is some other PR the row
2359
+ // once believed in, and answering "already open, nothing to create"
2360
+ // would leave the run's own head unpublished.
2361
+ let recordedLive: string | undefined;
2362
+ try {
2363
+ recordedLive = await deps.tracker.prHead(target.prUrl);
2364
+ } catch (err) {
2365
+ recordedLive = undefined;
2366
+ deps.log(
2367
+ `conductor_pr_recover could not read ${target.prUrl} head: ${err instanceof Error ? err.message : String(err)}`,
2368
+ );
2369
+ }
2370
+ if (recordedLive === undefined) {
2371
+ return refuse(
2372
+ "head-unresolvable",
2373
+ `refused: ${target.prUrl} is open, but its head could not be read. ` +
2374
+ "Retry before deciding whether it is this run's PR or another one.",
2375
+ issue,
2376
+ );
2377
+ }
2378
+ if (recordedLive.toLowerCase() === canonicalHead.toLowerCase()) {
2379
+ return allow(
2380
+ `recovered ${target.prUrl} — this run's recorded pull request is already open at the recorded head; nothing to create.`,
2381
+ undefined,
2382
+ issue,
2383
+ );
2384
+ }
2385
+ return refuse(
2386
+ "recovery-head-mismatch",
2387
+ `refused: the run records ${target.prUrl}, which is open at ${recordedLive}, not the recorded run head ${canonicalHead}. ` +
2388
+ "That PR is not this run's head; recovery cannot claim it. Match the PR to the head by hand, or close it and recover afresh.",
2389
+ issue,
2390
+ );
2391
+ }
2392
+ if (recorded === "merged") {
2393
+ return refuse(
2394
+ "recovery-issue-resolved",
2395
+ `refused: the run's recorded PR ${target.prUrl} already merged — the work landed; there is no missing PR to recover.`,
2396
+ issue,
2397
+ );
2398
+ }
2399
+ if (recorded === "closed") {
2400
+ return refuse(
2401
+ "pr-not-open",
2402
+ `refused: the run's recorded PR ${target.prUrl} was closed without merging. ` +
2403
+ "Recovery does not resurrect a PR a human declined; reopen it by hand if that was a mistake.",
2404
+ issue,
2405
+ );
2406
+ }
2407
+ if (recorded === undefined) {
2408
+ return refuse(
2409
+ "head-unresolvable",
2410
+ `refused: the run records PR ${target.prUrl}, but its state could not be read. ` +
2411
+ "Retry rather than creating a second PR next to one that may exist.",
2412
+ issue,
2413
+ );
2414
+ }
2415
+ // `missing`: the recorded URL provably never existed (a #779 guess). Fall
2416
+ // through and replace it with a real PR at the verified head.
2417
+ }
2418
+
2419
+ // The PR's own voice comes from durable state only: the issue title and the
2420
+ // run's settlement report. The closing keyword must name the TRACKER
2421
+ // repository — the issue lives there, not in the routed code repo, and
2422
+ // `openClosersFor` asks the tracker's GraphQL `closedByPullRequestsReferences`
2423
+ // whether an OPEN PR already closes it. A keyword aimed at the code repo
2424
+ // would close an unrelated same-numbered issue there, leave the tracker
2425
+ // issue open, and hide this PR from the duplicate guard on the next call.
2426
+ let issueTitle: string | undefined;
2427
+ try {
2428
+ issueTitle = (await deps.tracker.getIssue(issue))?.title;
2429
+ } catch (err) {
2430
+ issueTitle = undefined;
2431
+ deps.log(`conductor_pr_recover could not read #${issue} title: ${err instanceof Error ? err.message : String(err)}`);
2432
+ }
2433
+ const body = [
2434
+ `Closes ${project.tracker.repo}#${issue}`,
2435
+ "",
2436
+ `Recovered pull request for run ${target.id} (attempt ${target.attempt}).`,
2437
+ `The run ended ${target.state} with no pull request; this PR opens its preserved branch ${target.branch} at the ` +
2438
+ `exact recorded head ${canonicalHead} through the orchestrator's conductor_pr_recover verb.`,
2439
+ ...(target.report === undefined ? [] : ["", "Worker report:", "", target.report]),
2440
+ ].join("\n");
2441
+ // The worktree of a settled run is long removed; `openRunPr` resolves
2442
+ // everything from the remote identities (repo slug, branch, base) and never
2443
+ // opens the path — it is carried only to satisfy the shared action surface.
2444
+ const outcome = await deps.actions.createPr(
2445
+ { repo: routed, runRepoPath: target.worktree, branch: target.branch },
2446
+ {
2447
+ title: issueTitle ?? `recover ${target.branch} (${project.tracker.repo} #${issue})`,
2448
+ body,
2449
+ base: routed.defaultBranch,
2450
+ },
2451
+ );
2452
+ if (!outcome.ok) {
2453
+ return refuse("action-failed", `refused: gh could not open the recovered pull request:\n${outcome.stderr}`, issue);
2454
+ }
2455
+ deps.store.updateRun(target.id, { prUrl: outcome.url });
1761
2456
  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.`,
2457
+ `opened ${outcome.url} for ${target.branch} at ${canonicalHead} (${identity} ${routed.defaultBranch}), ` +
2458
+ "recorded on the run so review/merge settlement resumes.",
1764
2459
  undefined,
1765
2460
  issue,
1766
2461
  );
@@ -1799,6 +2494,14 @@ export class VerbSocketRefusal extends Error {
1799
2494
  export interface ListenOptions {
1800
2495
  peerReader?: PeerReader;
1801
2496
  daemonUid?: number;
2497
+ /**
2498
+ * The identity the channel is secured to — the worker account for a worker
2499
+ * run channel (#798). When set, the bound socket is chowned to that uid/gid
2500
+ * (the worker child, and only it, must be able to connect) and the peer
2501
+ * verdict expects that uid on the wire. Absent, the channel stays
2502
+ * daemon-owned and daemon-uid as today (orchestrator, tests).
2503
+ */
2504
+ channelOwner?: { uid: number; gid: number };
1802
2505
  /** Injected in tests; the real one chowns, which needs privilege. */
1803
2506
  secure?: typeof secureBoundSocket;
1804
2507
  }
@@ -1836,8 +2539,10 @@ export async function listenVerbChannel(
1836
2539
  // a connection arrives, not the one that existed when the server was created.
1837
2540
  let boundPid: number | undefined;
1838
2541
 
2542
+ const expectedUid = opts.channelOwner?.uid ?? process.getuid?.() ?? 0;
2543
+
1839
2544
  const server: Server = createServer((socket) => {
1840
- handleConnection(deps, channel, socket, opts.peerReader, () => boundPid);
2545
+ handleConnection(deps, channel, socket, opts.peerReader, () => boundPid, expectedUid);
1841
2546
  });
1842
2547
  server.on("error", (err) => {
1843
2548
  deps.log(`verb socket ${channel.path} errored: ${err.message}`);
@@ -1851,7 +2556,10 @@ export async function listenVerbChannel(
1851
2556
  });
1852
2557
  });
1853
2558
 
1854
- const secure = opts.secure ?? secureBoundSocket;
2559
+ // A worker-run channel belongs to the worker: the socket is chowned to it so
2560
+ // the session child can connect, and the ownership line in the startup banner
2561
+ // says so rather than guessing.
2562
+ const secure = opts.secure ?? ((path: string) => secureBoundSocket(path, { owner: opts.channelOwner }));
1855
2563
  const ownership = secure(channel.path);
1856
2564
 
1857
2565
  return {
@@ -1877,6 +2585,7 @@ function handleConnection(
1877
2585
  socket: Socket,
1878
2586
  peerReader: PeerReader | undefined,
1879
2587
  boundPid: () => number | undefined,
2588
+ expectedUid: number,
1880
2589
  ): void {
1881
2590
  const fd = socketFd(socket);
1882
2591
  const peer = fd === undefined || peerReader === undefined ? undefined : peerReader(fd);
@@ -1893,12 +2602,12 @@ function handleConnection(
1893
2602
  ? {
1894
2603
  ok: false,
1895
2604
  peerUid: peer?.uid ?? -1,
1896
- expectedUid: process.getuid?.() ?? 0,
2605
+ expectedUid,
1897
2606
  detail:
1898
2607
  "this channel has not been bound to a process yet, so the caller cannot be identified — " +
1899
- "every session on this host shares the daemon's uid",
2608
+ "this socket is 0600 and its owner is the only uid that can reach it",
1900
2609
  }
1901
- : peerVerdict({ uid: process.getuid?.() ?? 0, pid: expectedPid }, peer);
2610
+ : peerVerdict({ uid: expectedUid, pid: expectedPid }, peer);
1902
2611
  if (!verdict.ok) {
1903
2612
  // Not a client error, and deliberately not answered: a caller who is not
1904
2613
  // who the socket was allocated to gets no reply to calibrate against.