omp-conductor 0.17.1 → 0.18.1

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 +34 -0
  2. package/REFERENCE.md +71 -17
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +53 -1
  6. package/src/admission.ts +308 -76
  7. package/src/ask.ts +307 -10
  8. package/src/backups.ts +2 -2
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +43 -14
  11. package/src/briefs/to-spec.md +84 -0
  12. package/src/briefs/worker.md +37 -19
  13. package/src/cli.ts +2 -0
  14. package/src/command-help.ts +19 -1
  15. package/src/command-manifest.ts +27 -2
  16. package/src/commands/context.ts +1 -0
  17. package/src/commands/drain.ts +176 -0
  18. package/src/commands/extend.ts +6 -10
  19. package/src/commands/status.ts +5 -1
  20. package/src/commands/watch.ts +110 -3
  21. package/src/commands/worker.ts +9 -10
  22. package/src/config-schema.ts +57 -0
  23. package/src/config.ts +102 -2
  24. package/src/daemon.ts +1220 -1517
  25. package/src/dashboard/app.js +4 -1
  26. package/src/dashboard/server.ts +5 -2
  27. package/src/decisions.ts +279 -16
  28. package/src/depends-on.ts +261 -1
  29. package/src/diff-flags.ts +425 -1
  30. package/src/digest-schedule.ts +37 -0
  31. package/src/doctor.ts +52 -0
  32. package/src/escalate.ts +9 -3
  33. package/src/failure-class.ts +43 -4
  34. package/src/fleet.ts +166 -24
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +55 -8
  37. package/src/graph.ts +379 -69
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +567 -2
  40. package/src/lifecycle.ts +158 -6
  41. package/src/omp.ts +269 -20
  42. package/src/orchestrator-tick.ts +1489 -26
  43. package/src/orchestrator.ts +12 -0
  44. package/src/privileged.ts +1 -4
  45. package/src/release-policy.ts +503 -9
  46. package/src/routing.ts +11 -3
  47. package/src/session-host.ts +115 -5
  48. package/src/settlement.ts +1780 -0
  49. package/src/setup-host.ts +1205 -6
  50. package/src/setup-install.ts +119 -30
  51. package/src/setup-wizard.ts +88 -2
  52. package/src/setup.ts +119 -13
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +100 -11
  55. package/src/store.ts +519 -45
  56. package/src/to-spec.ts +387 -0
  57. package/src/tracker/github.ts +150 -14
  58. package/src/types.ts +470 -16
  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 +770 -40
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +239 -9
  65. package/src/worktree.ts +142 -18
@@ -41,12 +41,21 @@
41
41
  import { randomUUID } from "node:crypto";
42
42
  import { createServer, type Server, type Socket } from "node:net";
43
43
 
44
- import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
44
+ import { resolvePolicy, resolveReleaseGrants, resolveReview } from "../config.ts";
45
+ // 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";
45
50
  import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
46
51
  import { repoSlugFor, type readBaseChain as readBaseChainType } from "../gitops.ts";
47
52
  import { releaseRefusal } from "../release-policy.ts";
53
+ import { PR_LOOKUP_WINDOW_MS, REVISABLE_RUN_STATES, prReviewReadiness } from "../decisions.ts";
48
54
  import { LIVE_STATES } from "../store.ts";
49
55
  import type {
56
+ FileLane,
57
+ IssueComment,
58
+ IssueState,
50
59
  OpenCloser,
51
60
  PrState,
52
61
  PrVerification,
@@ -56,13 +65,14 @@ import type {
56
65
  RepoTarget,
57
66
  ReviewReason,
58
67
  RunRecord,
68
+ RunState,
59
69
  Store,
60
70
  Tracker,
61
71
  VerbLedgerEntry,
62
72
  VerbName,
63
73
  VerbRefusal,
64
74
  } from "../types.ts";
65
- import { prUrlParts } from "../tracker/github.ts";
75
+ import { prUrlParts, GhPrMissingError } from "../tracker/github.ts";
66
76
  import { parseVerbRequest, roleRefusal, VERB_SPECS, type VerbReply } from "./protocol.ts";
67
77
  import {
68
78
  peerVerdict,
@@ -222,11 +232,18 @@ export interface VerbDeps {
222
232
  * every lifecycle label on the tracker was written by the conductor (#26).
223
233
  * Telling a caller "not in the vocabulary" about a label it can plainly see on
224
234
  * the issue teaches it to try harder; telling it "that transition is the
225
- * 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.
226
241
  */
227
242
  export interface LabelVocabulary {
228
243
  grantable: string[];
229
244
  lifecycle: string[];
245
+ /** Labels only the operator may change: the park gesture. */
246
+ operator: string[];
230
247
  }
231
248
 
232
249
  export function labelVocabulary(p: ProjectConfig): LabelVocabulary {
@@ -236,6 +253,7 @@ export function labelVocabulary(p: ProjectConfig): LabelVocabulary {
236
253
  ...Object.keys(p.routing.repos).map((name) => `${p.routing.labelPrefix}${name}`),
237
254
  ],
238
255
  lifecycle: [p.stateLabels.inProgress, p.stateLabels.blocked, p.stateLabels.failed],
256
+ operator: [p.stateLabels.backlog],
239
257
  };
240
258
  }
241
259
 
@@ -306,6 +324,12 @@ export interface ReleaseFacts {
306
324
  blockingRuns?: { repo: string; issue: number }[];
307
325
  /** Queue depth, or `undefined` when the tracker could not be read. */
308
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;
309
333
  /** Current live-head workflow verdict for the released routed repository. */
310
334
  baseCheck?: RunRecord["baseCheck"];
311
335
  /** Evidence attached to a current red verdict. */
@@ -340,10 +364,20 @@ export function releaseRequirementRefusal(
340
364
  facts.blockingRuns === undefined || facts.blockingRuns.length === 0
341
365
  ? ""
342
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.";
343
376
  return (
344
377
  `policy.release.requires includes ${requirement} and ${facts.unsettledRuns} run(s) have not settled` +
345
378
  (parts.length === 0 ? "" : ` — ${parts.join("; ")}`) +
346
- blocking
379
+ blocking +
380
+ drain
347
381
  );
348
382
  }
349
383
  if (requirement === "no-open-prs" && facts.openPrs > 0) {
@@ -519,6 +553,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
519
553
  verb === "conductor_pr_update_branch" ||
520
554
  verb === "conductor_pr_update" ||
521
555
  verb === "conductor_pr_review" ||
556
+ verb === "conductor_pr_recover" ||
522
557
  verb === "conductor_label");
523
558
  const stopped =
524
559
  spec.mutating && verb !== "conductor_release" && verb !== "conductor_install" && !orchestratorCompletion
@@ -532,7 +567,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
532
567
  "fleet-paused",
533
568
  `refused: ${stopped}. The pause refuses new claims and work-starting mutations. ` +
534
569
  "Completion verbs for runs admitted before the pause (conductor_pr_merge, conductor_pr_update_branch, " +
535
- "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.",
536
571
  );
537
572
  }
538
573
  }
@@ -556,6 +591,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
556
591
  return installVerb(deps, project, channel, args, refuse, allow);
557
592
  case "conductor_pr_review":
558
593
  return prReviewVerb(deps, project, channel, args, refuse, allow);
594
+ case "conductor_pr_recover":
595
+ return prRecoverVerb(deps, project, channel, args, refuse, allow);
559
596
  case "conductor_pr_status":
560
597
  return prStatusVerb(deps, project, channel, args, refuse, allow);
561
598
  }
@@ -700,9 +737,9 @@ async function prCreateVerb(
700
737
  *
701
738
  * How far back to look is the recent-history cutoff `status` already uses: a
702
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.
703
742
  */
704
- export const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
705
-
706
743
  function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | undefined {
707
744
  // Every attempt that recorded this PR, newest first — not `recentRuns`'
708
745
  // newest-attempt-per-issue view. The row a PR belongs to is the newest
@@ -1071,6 +1108,18 @@ async function labelVerb(
1071
1108
 
1072
1109
  const label = String(args["label"]);
1073
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
+ }
1074
1123
  if (vocabulary.lifecycle.includes(label)) {
1075
1124
  return refuse(
1076
1125
  "label-is-lifecycle",
@@ -1088,6 +1137,36 @@ async function labelVerb(
1088
1137
  }
1089
1138
 
1090
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
+ }
1091
1170
  // The queue label is the fleet's lock, so it must not ride the GraphQL
1092
1171
  // surface a provider outage just broke: `gh issue edit` is a GraphQL
1093
1172
  // mutation in gh 2.86, and #642 comment 4 measured a label withdrawal
@@ -1104,7 +1183,44 @@ async function labelVerb(
1104
1183
  const why = err instanceof Error ? err.message : String(err);
1105
1184
  return refuse("action-failed", `refused: the tracker rejected the label change:\n${why}`, ref.issue);
1106
1185
  }
1107
- return allow(`${action === "add" ? "added" : "removed"} ${label} on #${ref.issue}.`, undefined, ref.issue);
1186
+
1187
+ return allow(
1188
+ echo === undefined ? outcome : `${outcome} File lane: ${echo}.`,
1189
+ undefined,
1190
+ ref.issue,
1191
+ );
1192
+ }
1193
+
1194
+ /**
1195
+ * The effective file lane admission will enforce for one issue, as the
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.
1200
+ */
1201
+ async function laneForEcho(deps: VerbDeps, issue: number): Promise<
1202
+ | { lane: FileLane | undefined; malformed: string | undefined }
1203
+ | undefined
1204
+ > {
1205
+ let body: string;
1206
+ let comments: IssueComment[];
1207
+ try {
1208
+ const row = await deps.tracker.getIssue(issue);
1209
+ if (row === undefined) return undefined;
1210
+ body = row.body;
1211
+ comments = await deps.tracker.listComments(issue);
1212
+ } catch {
1213
+ return undefined;
1214
+ }
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 };
1108
1224
  }
1109
1225
 
1110
1226
  async function releaseVerb(
@@ -1261,6 +1377,7 @@ async function releaseVerb(
1261
1377
  ...(unconfirmedMerges === undefined ? {} : { unconfirmedMerges }),
1262
1378
  ...(blockingRuns === undefined ? {} : { blockingRuns }),
1263
1379
  queueDepth,
1380
+ project: project.name,
1264
1381
  ...(health === undefined ? {} : { baseCheck: health.verdict }),
1265
1382
  ...(health?.verdict === "red" && health.detail !== undefined
1266
1383
  ? { redBase: health.detail }
@@ -1288,6 +1405,15 @@ async function releaseVerb(
1288
1405
  if (!outcome.ok) {
1289
1406
  return refuse("action-failed", `refused: the release command failed:\n${outcome.stderr}`);
1290
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.
1291
1417
  if (shape === "version-bump-pr") {
1292
1418
  if (outcome.review === undefined) {
1293
1419
  return allow(
@@ -1359,6 +1485,7 @@ async function releaseVerb(
1359
1485
  deps.store.releaseMergeLock(project.name, holderId);
1360
1486
  }
1361
1487
  }
1488
+ cancelDrain(project.name);
1362
1489
  return allow(
1363
1490
  `cut ${shape} for ${repo.name}${outcome.detail === undefined ? "" : ` — ${outcome.detail}`}.`,
1364
1491
  outcome.sha,
@@ -1576,12 +1703,41 @@ async function prUpdateVerb(
1576
1703
  * session directory — so a revision round never creates a new attempt and
1577
1704
  * never touches the failed-attempt or continuation budgets.
1578
1705
  *
1579
- * The duplicate in-flight guard has two halves: while a revision is queued
1580
- * but not yet dispatched, the pending `review_revisions` row refuses the
1581
- * second request atomically inside `claimReviewRevision`; once dispatched,
1582
- * the run row itself is `running` (claimed from `pushed-green` by the
1583
- * 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).
1584
1739
  */
1740
+
1585
1741
  async function prReviewVerb(
1586
1742
  deps: VerbDeps,
1587
1743
  project: ProjectConfig,
@@ -1595,18 +1751,21 @@ async function prReviewVerb(
1595
1751
  const findings = String(args["findings"]);
1596
1752
  const reason = String(args["reason"]) as ReviewReason;
1597
1753
 
1598
- // The run whose pushed-green row owns the PR — same resolution as merge: the
1599
- // newest attempt that recorded this PR within the recent-history window. A
1600
- // PR no run of this project opened (or one outside the routed repos) cannot
1601
- // be returned to a worker this project can resume.
1602
- const target = runForPr(deps, project.name, prUrl);
1603
- if (target === undefined) {
1754
+ // The shared review-readiness gate (#844): the newest project-owned run
1755
+ // that recorded the PR, in a revisable state — the same predicate the
1756
+ // `pr-review-ready` watch reads, so the verb and the condition resolve
1757
+ // ownership and revisability identically. A PR no run of this project
1758
+ // opened (or one outside the routed repos) cannot be returned to a worker
1759
+ // this project can resume.
1760
+ const readiness = prReviewReadiness(deps.store, project.name, prUrl, deps.now());
1761
+ if (readiness.kind === "no-owner") {
1604
1762
  return refuse(
1605
1763
  "pr-not-this-run",
1606
1764
  `refused: ${prUrl} is not a pull request any run in ${project.name} opened. ` +
1607
1765
  "A review revision acts on a run-owned PR only.",
1608
1766
  );
1609
1767
  }
1768
+ const target = readiness.run;
1610
1769
  if (!prInProjectRouting(project, prUrl)) {
1611
1770
  return refuse(
1612
1771
  "pr-not-this-run",
@@ -1625,18 +1784,22 @@ async function prReviewVerb(
1625
1784
  );
1626
1785
  }
1627
1786
 
1628
- // The revisable state is a settled green run. Any other state is a worker in
1629
- // flight (the original run or an earlier revision) or a run whose PR no
1630
- // longer waits on revision — the detail names the state field that produced
1631
- // the refusal.
1632
- if (target.state !== "pushed-green") {
1787
+ // The revisable state is a run that pushed a green PR and is not live: a
1788
+ // settled `pushed-green` row, or a `failed` / `killed` row — a run that
1789
+ // capped or failed after the push (#795). The exact-head verification below
1790
+ // still re-reads the PR and insists it is green at the reviewed SHA, so
1791
+ // admitting a capped row here never reviews a PR that stopped being green.
1792
+ // Any other state is a worker in flight (the original run or an earlier
1793
+ // revision) or a run whose PR no longer waits on revision — the detail names
1794
+ // the state field that produced the refusal.
1795
+ if (readiness.kind !== "ready") {
1633
1796
  const live = target.state === "running" || target.state === "claimed";
1634
1797
  return refuse(
1635
1798
  "review-in-flight",
1636
- `refused: run ${target.id} is ${target.state}, not pushed-green — ${
1799
+ `refused: run ${target.id} is ${target.state}, not a settled green run — ${
1637
1800
  live
1638
1801
  ? `a worker (the original run or an earlier revision) is still live on ${prUrl}; wait for it to settle before returning it.`
1639
- : `a review revision starts from a settled green run, and this one is ${target.state}.`
1802
+ : `a review revision starts from a run that pushed a green PR, and this one is ${target.state}.`
1640
1803
  }`,
1641
1804
  issue,
1642
1805
  );
@@ -1678,11 +1841,53 @@ async function prReviewVerb(
1678
1841
  );
1679
1842
  }
1680
1843
 
1681
- // Durable, and refused atomically against a concurrent duplicate: the row is
1682
- // persisted before any wake, and a second request for the same run bumps
1683
- // into the pending row rather than racing it.
1844
+ // The hard bound (#678): the round ceiling is the dispatcher's, not the
1845
+ // orchestrator's to re-read from prose. A PR may be returned at most
1846
+ // `maxRounds` times per lifecycle; at the ceiling this refuses, and the
1847
+ // refusal states the two non-actions that replace a further round — leave
1848
+ // the PR open, record the unresolved findings and escalate once. The same
1849
+ // count `latestReviewRound` drives the renderer's `review-revision N`, so
1850
+ // the bound and the visible round can never disagree.
1851
+ //
1852
+ // A finding that folds into an already-pending revision opens no round, so
1853
+ // the ceiling never binds an append — only a genuine new round counts. The
1854
+ // store is the single authority on the fold; this read exists only to
1855
+ // decide whether the ceiling gates this call.
1856
+ const review = resolveReview(project);
1857
+ const pendingForRun = deps.store.pendingReviewForRun(project.name, target.id);
1858
+ if (pendingForRun === undefined && deps.store.latestReviewRound(project.name, target.id) >= review.maxRounds) {
1859
+ return refuse(
1860
+ "review-round-ceiling",
1861
+ `refused: ${prUrl} has already been through ${review.maxRounds} review round(s) ` +
1862
+ `(project "${project.name}" is ${review.strictness} strictness, ceiling ${review.maxRounds}). ` +
1863
+ "Leave the PR open, record the unresolved findings, and escalate once — a further revision round is not available.",
1864
+ issue,
1865
+ );
1866
+ }
1867
+
1868
+ // The run row is re-read synchronously, immediately before the durable
1869
+ // enqueue and with no await in between: the daemon's dispatch pass
1870
+ // (`pushed-green` → `running`) cannot land inside a single synchronous
1871
+ // block, so a revision that dispatched while the live-head verification
1872
+ // above was in flight is refused here rather than racing the worker that
1873
+ // now owns the PR — an already-running worker's prompt is never silently
1874
+ // changed.
1875
+ const fresh = deps.store.getRun(target.id);
1876
+ if (fresh === undefined || REVISABLE_RUN_STATES[fresh.state] !== true) {
1877
+ return refuse(
1878
+ "review-in-flight",
1879
+ `refused: run ${target.id} is no longer revisable on ${prUrl} (now ${fresh?.state ?? "gone"}) — ` +
1880
+ "the pending revision has dispatched to a worker. " +
1881
+ "Wait for it to settle, then return the finding as its own later review round.",
1882
+ issue,
1883
+ );
1884
+ }
1885
+
1886
+ // Durable, and decided atomically against a concurrent same-head request:
1887
+ // the same transaction that records a new round also folds a same-head
1888
+ // finding into the one already pending, and the merge never overwrites.
1684
1889
  const round = deps.store.latestReviewRound(project.name, target.id) + 1;
1685
- const recorded = deps.store.createReviewRevision({
1890
+ const enqueued = deps.store.enqueueReviewRevision({
1686
1891
  project: project.name,
1687
1892
  runId: target.id,
1688
1893
  issue,
@@ -1691,20 +1896,531 @@ async function prReviewVerb(
1691
1896
  findings,
1692
1897
  round,
1693
1898
  reason,
1694
- ...(target.sessionFile === undefined ? {} : { sessionFile: target.sessionFile }),
1899
+ ...(fresh.sessionFile === undefined ? {} : { sessionFile: fresh.sessionFile }),
1695
1900
  requestedAt: deps.now(),
1696
1901
  });
1697
- if (recorded === undefined) {
1902
+ if (enqueued.kind === "refused") {
1698
1903
  return refuse(
1699
1904
  "review-in-flight",
1700
- `refused: a review revision is already pending for ${prUrl} (run ${target.id}); exactly one revision is in flight per PR.`,
1905
+ `refused: a review revision is already pending for ${prUrl} (run ${target.id}) at a different head — ` +
1906
+ "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.",
1907
+ issue,
1908
+ );
1909
+ }
1910
+ const revision = enqueued.record;
1911
+ return allow(
1912
+ enqueued.kind === "created"
1913
+ ? `recorded review round ${revision.round} for ${prUrl} at ${headSha}; the daemon will resume run ${target.id}'s session` +
1914
+ `${fresh.sessionFile === undefined ? "" : ` (${fresh.sessionFile})`} on its next dispatch pass.`
1915
+ : `appended a finding to the pending review round ${revision.round} for ${prUrl} at ${headSha}; ` +
1916
+ "the daemon will resume run " +
1917
+ `${target.id}'s session${fresh.sessionFile === undefined ? "" : ` (${fresh.sessionFile})`} on its next dispatch pass ` +
1918
+ "and the resumed worker reads both findings.",
1919
+ undefined,
1920
+ issue,
1921
+ );
1922
+ }
1923
+
1924
+ /**
1925
+ * The run states a missing-PR recovery may act on (#806).
1926
+ *
1927
+ * Recovery exists for a run whose worker finished and recorded an exact head
1928
+ * but whose PR never materialised — exactly the states the settlement sweep
1929
+ * owns: `pushed-pending` (the #777/#809 ghosts) and `pushed-green`. Those
1930
+ * rows sit in the store's active set, so the sweep verifies the recovered
1931
+ * PR's checks on its next tick (`pushed-pending` → `pushed-green`) and
1932
+ * review/merge settlement resumes normally. A terminal row outside this set —
1933
+ * failed/killed/stopped/orphaned/blocked — has its own prescribed
1934
+ * reconciliation (continuation or review revision, operator stop, restart
1935
+ * reconciliation, unblock), and receiving a PR would strand it: nothing would
1936
+ * ever verify, review or merge it.
1937
+ *
1938
+ * Closed on purpose, like {@link REVISABLE_RUN_STATES}: recovery may never
1939
+ * widen into a state the daemon's own sweep does not revisit.
1940
+ */
1941
+ const RECOVERABLE_RUN_STATES: Record<string, true> = {
1942
+ "pushed-pending": true,
1943
+ "pushed-green": true,
1944
+ };
1945
+
1946
+ /** A complete commit SHA, as current workers record it. */
1947
+ const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
1948
+
1949
+ /**
1950
+ * A legacy abbreviated commit SHA (#836): the `git rev-parse --short` output
1951
+ * an older worker generation recorded instead of the full SHA. Git defaults
1952
+ * to 7 hex characters and lengthens the abbreviation when 7 is ambiguous, so
1953
+ * a legacy record is always at least 7 hex; 4–6 hex are git-legal
1954
+ * abbreviations but cannot be legacy records, and at that length the prefix
1955
+ * evidence is too weak for an identity recovery to rest on. Anything shorter
1956
+ * than git's own abbreviation minimum (4) is not a usable object id at all —
1957
+ * ambiguous, and it fails closed.
1958
+ */
1959
+ const ABBREVIATED_HEAD_SHA = /^[0-9a-f]{7,39}$/i;
1960
+
1961
+ /**
1962
+ * Open (or adopt) the missing pull request for a settled run whose branch was
1963
+ * preserved (#806).
1964
+ *
1965
+ * A worker that pushed and recorded an exact head can still leave the run
1966
+ * spent: the PR it claimed never existed (a guessed URL — #779), or the run
1967
+ * settled before a PR was ever opened. Nothing else in the verb surface can
1968
+ * act then — the mediated PR verbs resolve the PR from a URL some run
1969
+ * recorded (`conductor_pr_review` refuses "not a PR any run opened"), the
1970
+ * worker-only create verb is gone with the worker, and the package-floor
1971
+ * fallback is a fresh clone and re-push, which republishes code. This is the
1972
+ * strictly smaller act: publish the pull request the run's work already
1973
+ * earned, at the exact head the daemon recorded.
1974
+ *
1975
+ * Every identity the worker's own `conductor_pr_create` read off its live
1976
+ * channel is re-proven here in its absence:
1977
+ * - the run is terminal — no worker is live on it (`claimed`/`running`
1978
+ * refuse, and a merged row is resolved, not recoverable);
1979
+ * - the routed repository is still routed, and the branch comes from the run
1980
+ * row, never from an argument — `branch`/`base`/`headSha` are assertions
1981
+ * exactly as on the worker verb, and a disagreement is a refusal, never a
1982
+ * redirect;
1983
+ * - the remote branch still exists at the recorded identity: a 40-hex head
1984
+ * must equal the live branch head exactly, and a legacy abbreviated head
1985
+ * (7–39 hex, #836) may only canonicalize against the fixed routed branch —
1986
+ * the live branch head must begin with it, and that canonical object is
1987
+ * the identity compared everywhere after, never an argument;
1988
+ * - the issue still wants the work: a closed issue, a merged row or a merged
1989
+ * recorded PR is "resolved" and refuses; a recorded PR a human closed
1990
+ * without merging refuses as `pr-not-open`;
1991
+ * - no PR already represents that head: an open closer on the run's branch
1992
+ * at the recorded head is adopted (idempotent — the row gains the URL it
1993
+ * never recorded, so review/merge verbs can act on it), an open closer at
1994
+ * a different head or on another branch refuses, and a recorded PR that is
1995
+ * still open is that same recovery's answer.
1996
+ *
1997
+ * The PR describes itself from durable state only — no title/body arguments,
1998
+ * because nothing here may publish a headline a session did not already
1999
+ * settle. A definitively missing recorded PR (a corroborated 404 — the #779
2000
+ * guess) is replaced by the new one; an unreadable recorded PR, unreadable
2001
+ * branch head or unreadable closer list fails closed and stays retryable.
2002
+ */
2003
+ async function prRecoverVerb(
2004
+ deps: VerbDeps,
2005
+ project: ProjectConfig,
2006
+ channel: VerbChannel,
2007
+ args: Record<string, unknown>,
2008
+ refuse: Refuse,
2009
+ allow: Allow,
2010
+ ): Promise<Verdict> {
2011
+ const ref = issueRefFrom(String(args["issueUrl"]));
2012
+ if (ref === undefined) {
2013
+ return refuse(
2014
+ "malformed-argument",
2015
+ `refused: "${String(args["issueUrl"])}" is not a full issue URL. A bare number resolves against whatever ` +
2016
+ "repository the daemon happens to be in, which is how a recovered PR lands on the wrong project's issue.",
2017
+ );
2018
+ }
2019
+ if (ref.repo !== project.tracker.repo) {
2020
+ return refuse(
2021
+ "malformed-argument",
2022
+ `refused: ${ref.repo} is not this project's tracker (${project.tracker.repo}).`,
2023
+ );
2024
+ }
2025
+
2026
+ // The run is the newest attempt for the issue — the row that occupies the
2027
+ // issue's slot, and exactly the row `unblock`/`worker stop` name by issue
2028
+ // number. Recovery never re-implements dispatch's per-issue choice.
2029
+ const target = deps.store.latestRun(project.name, ref.issue);
2030
+ if (target === undefined) {
2031
+ return refuse(
2032
+ "recovery-no-run",
2033
+ `refused: ${project.name} has no recorded attempt for #${ref.issue}, so there is no run branch to recover a ` +
2034
+ "pull request for. Check the issue's status before trying again.",
2035
+ );
2036
+ }
2037
+ const issue = target.issue;
2038
+
2039
+ if (LIVE_STATES.some((state) => state === target.state)) {
2040
+ return refuse(
2041
+ "recovery-run-live",
2042
+ `refused: run ${target.id} for #${issue} is ${target.state} — a worker is still live on it. ` +
2043
+ "Recovery opens the missing PR only for a settled run; wait for it to settle (or stop the worker) first.",
2044
+ issue,
2045
+ );
2046
+ }
2047
+ if (target.state === "merged") {
2048
+ return refuse(
2049
+ "recovery-issue-resolved",
2050
+ `refused: run ${target.id} for #${issue} already merged — the work landed and there is no missing PR to recover.`,
2051
+ issue,
2052
+ );
2053
+ }
2054
+ if (RECOVERABLE_RUN_STATES[target.state] !== true) {
2055
+ const guidance =
2056
+ target.state === "failed" || target.state === "killed"
2057
+ ? "A failed or killed run's branch continues through `omp-conductor unblock` (continuation), or through a review revision once it owns a PR."
2058
+ : target.state === "stopped"
2059
+ ? "The operator ended this run; recovery does not reopen stopped work."
2060
+ : target.state === "orphaned"
2061
+ ? "Restart reconciliation owns orphaned runs."
2062
+ : "Answer the run's question and unblock the issue.";
2063
+ return refuse(
2064
+ "recovery-unsettled-state",
2065
+ `refused: run ${target.id} for #${issue} is ${target.state}, a state the settlement sweep does not own — a ` +
2066
+ `recovered PR would never be verified, reviewed or merged. ${guidance}`,
2067
+ issue,
2068
+ );
2069
+ }
2070
+
2071
+ const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
2072
+ if (paused !== undefined) return paused;
2073
+
2074
+ const routed = Object.values(project.routing.repos).find((repo) => repo.name === target.repo);
2075
+ if (routed === undefined) {
2076
+ return refuse(
2077
+ "recovery-repo-unrouted",
2078
+ `refused: run ${target.id} was routed to repository ${target.repo}, which ${project.name} no longer routes ` +
2079
+ `(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}). ` +
2080
+ "A recovery PR cannot be created for a repository the daemon has no entry for; re-route the repo first.",
2081
+ issue,
2082
+ );
2083
+ }
2084
+
2085
+ // The recorded head the preserved branch must still carry. Absent, the
2086
+ // recovery cannot prove it would publish this run's work rather than
2087
+ // whatever the branch tip happens to be — fail closed on the missing
2088
+ // record. Legacy rows recorded git's default abbreviation (`rev-parse
2089
+ // --short`, 7+ hex) instead of the full SHA; those stay eligible and are
2090
+ // canonicalized against the fixed routed branch below (#836).
2091
+ const recordedHead = target.headSha;
2092
+ if (recordedHead === undefined) {
2093
+ return refuse(
2094
+ "recovery-unrecorded-head",
2095
+ `refused: run ${target.id} for #${issue} records no head to verify its branch against. ` +
2096
+ "Recovery publishes only the head the run itself recorded.",
2097
+ issue,
2098
+ );
2099
+ }
2100
+ const isFullHead = FULL_HEAD_SHA.test(recordedHead);
2101
+ const isAbbreviatedHead = !isFullHead && ABBREVIATED_HEAD_SHA.test(recordedHead);
2102
+ if (!isFullHead && !isAbbreviatedHead) {
2103
+ return refuse(
2104
+ "recovery-unrecorded-head",
2105
+ `refused: run ${target.id} for #${issue} records ${JSON.stringify(recordedHead)}, which is neither a full ` +
2106
+ "40-hex SHA nor an unambiguous legacy abbreviation of one (7–39 hex). Recovery publishes only the head " +
2107
+ "the run itself recorded.",
2108
+ issue,
2109
+ );
2110
+ }
2111
+
2112
+ // Assertions, never selectors — the mirror of `conductor_pr_create`'s
2113
+ // head/base claims. A disagreement means the caller is pointing somewhere
2114
+ // the run row does not own; the daemon derives everything from the row.
2115
+ const claimedBranch = args["branch"];
2116
+ if (typeof claimedBranch === "string" && claimedBranch !== target.branch) {
2117
+ return refuse(
2118
+ "ref-not-run-branch",
2119
+ `refused: run ${target.id}'s recorded branch is ${target.branch}, and you named "${claimedBranch}". ` +
2120
+ "Recovery derives the head branch from the run; it cannot be pointed at another branch.",
2121
+ issue,
2122
+ );
2123
+ }
2124
+ const claimedBase = args["base"];
2125
+ if (typeof claimedBase === "string" && claimedBase !== routed.defaultBranch) {
2126
+ return refuse(
2127
+ "base-not-default-branch",
2128
+ `refused: ${routed.name} targets ${routed.defaultBranch}, and you named "${claimedBase}". ` +
2129
+ "The base is the configured defaultBranch, not an argument.",
2130
+ issue,
2131
+ );
2132
+ }
2133
+ const claimedHead = args["headSha"];
2134
+ if (typeof claimedHead === "string" && claimedHead.toLowerCase() !== recordedHead.toLowerCase()) {
2135
+ return refuse(
2136
+ "recovery-head-mismatch",
2137
+ `refused: run ${target.id} recorded head ${recordedHead}, and you asserted "${claimedHead}". ` +
2138
+ "The exact recorded head is the daemon's, not a selector.",
2139
+ issue,
2140
+ );
2141
+ }
2142
+
2143
+ // Closed issues are resolved issues: a merge closed it, or a human closed it
2144
+ // without merging. Either way it does not want a recovered PR — and an
2145
+ // unreadable state is not a green light: "could not tell" must never open a
2146
+ // PR beside (or for) an issue that may already be closed, so it fails
2147
+ // closed and stays retryable like every sibling read in this verb.
2148
+ let issueState: IssueState | undefined;
2149
+ try {
2150
+ issueState = await deps.tracker.issueState(issue);
2151
+ } catch (err) {
2152
+ issueState = undefined;
2153
+ deps.log(`conductor_pr_recover could not read #${issue} state: ${err instanceof Error ? err.message : String(err)}`);
2154
+ }
2155
+ if (issueState === undefined) {
2156
+ return refuse(
2157
+ "issue-state-unreadable",
2158
+ `refused: the tracker could not say whether #${issue} is open. An unreadable state is not a green light — ` +
2159
+ "retry before recovering, so a recovered PR is never opened beside (or for) a closed issue.",
2160
+ issue,
2161
+ );
2162
+ }
2163
+ if (issueState === "closed") {
2164
+ return refuse(
2165
+ "recovery-issue-resolved",
2166
+ `refused: #${issue} is closed. The work already merged or a human declined it; recovery does not reopen either.`,
2167
+ issue,
2168
+ );
2169
+ }
2170
+
2171
+ // The branch/head verification: the routed repository's branch, read live,
2172
+ // must still sit at the exact head this run recorded. This is the proof the
2173
+ // PR to be created publishes verified work and not something that replaced
2174
+ // it. An unreadable branch stays retryable — that is "could not tell", never
2175
+ // a green light.
2176
+ const identity = repoSlugFor(routed);
2177
+ let liveBranchHead: string | undefined;
2178
+ try {
2179
+ liveBranchHead = await deps.tracker.branchHead(identity, target.branch);
2180
+ } catch (err) {
2181
+ liveBranchHead = undefined;
2182
+ deps.log(
2183
+ `conductor_pr_recover could not read ${identity}/${target.branch} head: ${err instanceof Error ? err.message : String(err)}`,
2184
+ );
2185
+ }
2186
+ if (liveBranchHead === undefined) {
2187
+ return refuse(
2188
+ "head-unresolvable",
2189
+ `refused: the live head of ${identity}/${target.branch} could not be read. That is a transient or unknown ` +
2190
+ "state, not a green light — retry before recovering, and check the branch still exists.",
2191
+ issue,
2192
+ );
2193
+ }
2194
+
2195
+ // The canonical object, decided from the fixed routed branch only (#836). A
2196
+ // full 40-hex record keeps its exact-identity behavior: the branch must
2197
+ // still sit at that object. A legacy abbreviated record can never be used
2198
+ // as an identity on its own — the live branch head must literally begin
2199
+ // with the abbreviation, and that full head becomes the identity every
2200
+ // later check compares against (PR adoption, no-duplicate proof, the PR's
2201
+ // own description). Nothing resolves the abbreviation anywhere else, and
2202
+ // never from caller input: `headSha` remains an assertion of the recorded
2203
+ // value, so no argument can appoint a different object.
2204
+ const canonicalHead: string = isFullHead ? recordedHead : liveBranchHead;
2205
+ if (isAbbreviatedHead) {
2206
+ if (!FULL_HEAD_SHA.test(liveBranchHead)) {
2207
+ return refuse(
2208
+ "head-unresolvable",
2209
+ `refused: the live head of ${identity}/${target.branch} is ${liveBranchHead}, not a full commit SHA, so the ` +
2210
+ `recorded abbreviation ${recordedHead} cannot be canonicalized against it. That is an unknown state, not a ` +
2211
+ "green light — retry before recovering.",
2212
+ issue,
2213
+ );
2214
+ }
2215
+ if (!liveBranchHead.toLowerCase().startsWith(recordedHead.toLowerCase())) {
2216
+ return refuse(
2217
+ "recovery-head-mismatch",
2218
+ `refused: ${identity}/${target.branch} now points at ${liveBranchHead}, which does not begin with the ` +
2219
+ `recorded run head ${recordedHead}. A legacy abbreviated head canonicalizes only against a branch that ` +
2220
+ "still carries it; the branch was replaced, force-updated or continued elsewhere — a PR created now would " +
2221
+ "publish work the daemon did not verify at that head. Do not force; reconcile what the branch should be first.",
2222
+ issue,
2223
+ );
2224
+ }
2225
+ } else if (liveBranchHead.toLowerCase() !== recordedHead.toLowerCase()) {
2226
+ return refuse(
2227
+ "recovery-head-mismatch",
2228
+ `refused: ${identity}/${target.branch} now points at ${liveBranchHead}, not the recorded run head ${recordedHead}. ` +
2229
+ "The branch was replaced, force-updated or continued elsewhere — a PR created now would publish work the daemon " +
2230
+ "did not verify at that head. Do not force; reconcile what the branch should be first.",
2231
+ issue,
2232
+ );
2233
+ }
2234
+
2235
+ // The no-duplicate-proof: any OPEN PR that already closes the issue. The
2236
+ // run's own branch at the recorded head is the run's PR (whoever opened it —
2237
+ // the operator's manual recovery is PR #805 for run #777) and is adopted,
2238
+ // idempotently; the same branch at a different head, or another branch with
2239
+ // the issue, each refuse rather than duplicate.
2240
+ let closers: OpenCloser[];
2241
+ try {
2242
+ closers = await deps.tracker.openClosersFor(issue);
2243
+ } catch (err) {
2244
+ const why = err instanceof Error ? err.message : String(err);
2245
+ return refuse(
2246
+ "open-pr-lookup-error",
2247
+ `refused: the tracker could not say whether #${issue} already has an open pull request (${why}). ` +
2248
+ "Refusing rather than opening a second one.",
2249
+ issue,
2250
+ );
2251
+ }
2252
+ const sameBranch = closers.find(
2253
+ (closer) => closer.headRefName !== "" && closer.headRefName === target.branch && closer.repo === identity,
2254
+ );
2255
+ if (sameBranch !== undefined) {
2256
+ let live: string | undefined;
2257
+ try {
2258
+ live = await deps.tracker.prHead(sameBranch.url);
2259
+ } catch (err) {
2260
+ live = undefined;
2261
+ deps.log(
2262
+ `conductor_pr_recover could not read ${sameBranch.url} head: ${err instanceof Error ? err.message : String(err)}`,
2263
+ );
2264
+ }
2265
+ if (live === undefined) {
2266
+ return refuse(
2267
+ "head-unresolvable",
2268
+ `refused: ${sameBranch.url} is open on ${target.branch}, but its head could not be read. ` +
2269
+ "Retry before deciding whether to adopt it or treat it as a conflict.",
2270
+ issue,
2271
+ );
2272
+ }
2273
+ if (live.toLowerCase() === canonicalHead.toLowerCase()) {
2274
+ const already = deps.store.getRun(target.id)?.prUrl === sameBranch.url;
2275
+ if (!already) deps.store.updateRun(target.id, { prUrl: sameBranch.url });
2276
+ return allow(
2277
+ `recovered ${sameBranch.url} — an open pull request already exists at run head ${canonicalHead} ` +
2278
+ `(${target.branch}); ${
2279
+ already
2280
+ ? "it is already this run's recorded PR"
2281
+ : `recorded it on run ${target.id} so review and merge verbs can act on it`
2282
+ }.`,
2283
+ undefined,
2284
+ issue,
2285
+ );
2286
+ }
2287
+ return refuse(
2288
+ "recovery-head-mismatch",
2289
+ `refused: ${sameBranch.url} is open on ${target.branch} but at ${live}, not the recorded run head ${canonicalHead}. ` +
2290
+ "That open PR superseded the run's recorded head; a second PR would duplicate it.",
2291
+ issue,
2292
+ );
2293
+ }
2294
+ if (closers.length > 0) {
2295
+ const other = closers[0]!;
2296
+ return refuse(
2297
+ "open-pr-exists",
2298
+ `refused: ${other.url} is already open and already closes #${issue} (head ${other.headRefName || "unknown"} in ${
2299
+ other.repo || "unknown"
2300
+ }). Recovery opens a PR only for the run's own preserved branch, and this issue already has open work.`,
1701
2301
  issue,
1702
2302
  );
1703
2303
  }
1704
2304
 
2305
+ if (target.prUrl !== undefined) {
2306
+ // The run believes it has a PR. Before creating one, the recorded URL is
2307
+ // decided: still open → it is the recovery's answer (idempotent); merged →
2308
+ // the work landed; closed → a human declined it; definitively missing →
2309
+ // the #779 guess, replaced by a real PR below.
2310
+ let recorded: PrState | "missing" | undefined;
2311
+ try {
2312
+ recorded = await deps.tracker.prState(target.prUrl);
2313
+ } catch (err) {
2314
+ recorded = err instanceof GhPrMissingError ? "missing" : undefined;
2315
+ if (recorded !== "missing") {
2316
+ deps.log(
2317
+ `conductor_pr_recover could not read recorded PR ${target.prUrl}: ${err instanceof Error ? err.message : String(err)}`,
2318
+ );
2319
+ }
2320
+ }
2321
+ if (recorded === "open") {
2322
+ // An open recorded PR is the recovery's answer only when it actually
2323
+ // represents the recorded head — otherwise it is some other PR the row
2324
+ // once believed in, and answering "already open, nothing to create"
2325
+ // would leave the run's own head unpublished.
2326
+ let recordedLive: string | undefined;
2327
+ try {
2328
+ recordedLive = await deps.tracker.prHead(target.prUrl);
2329
+ } catch (err) {
2330
+ recordedLive = undefined;
2331
+ deps.log(
2332
+ `conductor_pr_recover could not read ${target.prUrl} head: ${err instanceof Error ? err.message : String(err)}`,
2333
+ );
2334
+ }
2335
+ if (recordedLive === undefined) {
2336
+ return refuse(
2337
+ "head-unresolvable",
2338
+ `refused: ${target.prUrl} is open, but its head could not be read. ` +
2339
+ "Retry before deciding whether it is this run's PR or another one.",
2340
+ issue,
2341
+ );
2342
+ }
2343
+ if (recordedLive.toLowerCase() === canonicalHead.toLowerCase()) {
2344
+ return allow(
2345
+ `recovered ${target.prUrl} — this run's recorded pull request is already open at the recorded head; nothing to create.`,
2346
+ undefined,
2347
+ issue,
2348
+ );
2349
+ }
2350
+ return refuse(
2351
+ "recovery-head-mismatch",
2352
+ `refused: the run records ${target.prUrl}, which is open at ${recordedLive}, not the recorded run head ${canonicalHead}. ` +
2353
+ "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.",
2354
+ issue,
2355
+ );
2356
+ }
2357
+ if (recorded === "merged") {
2358
+ return refuse(
2359
+ "recovery-issue-resolved",
2360
+ `refused: the run's recorded PR ${target.prUrl} already merged — the work landed; there is no missing PR to recover.`,
2361
+ issue,
2362
+ );
2363
+ }
2364
+ if (recorded === "closed") {
2365
+ return refuse(
2366
+ "pr-not-open",
2367
+ `refused: the run's recorded PR ${target.prUrl} was closed without merging. ` +
2368
+ "Recovery does not resurrect a PR a human declined; reopen it by hand if that was a mistake.",
2369
+ issue,
2370
+ );
2371
+ }
2372
+ if (recorded === undefined) {
2373
+ return refuse(
2374
+ "head-unresolvable",
2375
+ `refused: the run records PR ${target.prUrl}, but its state could not be read. ` +
2376
+ "Retry rather than creating a second PR next to one that may exist.",
2377
+ issue,
2378
+ );
2379
+ }
2380
+ // `missing`: the recorded URL provably never existed (a #779 guess). Fall
2381
+ // through and replace it with a real PR at the verified head.
2382
+ }
2383
+
2384
+ // The PR's own voice comes from durable state only: the issue title and the
2385
+ // run's settlement report. The closing keyword must name the TRACKER
2386
+ // repository — the issue lives there, not in the routed code repo, and
2387
+ // `openClosersFor` asks the tracker's GraphQL `closedByPullRequestsReferences`
2388
+ // whether an OPEN PR already closes it. A keyword aimed at the code repo
2389
+ // would close an unrelated same-numbered issue there, leave the tracker
2390
+ // issue open, and hide this PR from the duplicate guard on the next call.
2391
+ let issueTitle: string | undefined;
2392
+ try {
2393
+ issueTitle = (await deps.tracker.getIssue(issue))?.title;
2394
+ } catch (err) {
2395
+ issueTitle = undefined;
2396
+ deps.log(`conductor_pr_recover could not read #${issue} title: ${err instanceof Error ? err.message : String(err)}`);
2397
+ }
2398
+ const body = [
2399
+ `Closes ${project.tracker.repo}#${issue}`,
2400
+ "",
2401
+ `Recovered pull request for run ${target.id} (attempt ${target.attempt}).`,
2402
+ `The run ended ${target.state} with no pull request; this PR opens its preserved branch ${target.branch} at the ` +
2403
+ `exact recorded head ${canonicalHead} through the orchestrator's conductor_pr_recover verb.`,
2404
+ ...(target.report === undefined ? [] : ["", "Worker report:", "", target.report]),
2405
+ ].join("\n");
2406
+ // The worktree of a settled run is long removed; `openRunPr` resolves
2407
+ // everything from the remote identities (repo slug, branch, base) and never
2408
+ // opens the path — it is carried only to satisfy the shared action surface.
2409
+ const outcome = await deps.actions.createPr(
2410
+ { repo: routed, runRepoPath: target.worktree, branch: target.branch },
2411
+ {
2412
+ title: issueTitle ?? `recover ${target.branch} (${project.tracker.repo} #${issue})`,
2413
+ body,
2414
+ base: routed.defaultBranch,
2415
+ },
2416
+ );
2417
+ if (!outcome.ok) {
2418
+ return refuse("action-failed", `refused: gh could not open the recovered pull request:\n${outcome.stderr}`, issue);
2419
+ }
2420
+ deps.store.updateRun(target.id, { prUrl: outcome.url });
1705
2421
  return allow(
1706
- `recorded review round ${round} for ${prUrl} at ${headSha}; the daemon will resume run ${target.id}'s session` +
1707
- `${target.sessionFile === undefined ? "" : ` (${target.sessionFile})`} on its next dispatch pass.`,
2422
+ `opened ${outcome.url} for ${target.branch} at ${canonicalHead} (${identity} → ${routed.defaultBranch}), ` +
2423
+ "recorded on the run so review/merge settlement resumes.",
1708
2424
  undefined,
1709
2425
  issue,
1710
2426
  );
@@ -1743,6 +2459,14 @@ export class VerbSocketRefusal extends Error {
1743
2459
  export interface ListenOptions {
1744
2460
  peerReader?: PeerReader;
1745
2461
  daemonUid?: number;
2462
+ /**
2463
+ * The identity the channel is secured to — the worker account for a worker
2464
+ * run channel (#798). When set, the bound socket is chowned to that uid/gid
2465
+ * (the worker child, and only it, must be able to connect) and the peer
2466
+ * verdict expects that uid on the wire. Absent, the channel stays
2467
+ * daemon-owned and daemon-uid as today (orchestrator, tests).
2468
+ */
2469
+ channelOwner?: { uid: number; gid: number };
1746
2470
  /** Injected in tests; the real one chowns, which needs privilege. */
1747
2471
  secure?: typeof secureBoundSocket;
1748
2472
  }
@@ -1780,8 +2504,10 @@ export async function listenVerbChannel(
1780
2504
  // a connection arrives, not the one that existed when the server was created.
1781
2505
  let boundPid: number | undefined;
1782
2506
 
2507
+ const expectedUid = opts.channelOwner?.uid ?? process.getuid?.() ?? 0;
2508
+
1783
2509
  const server: Server = createServer((socket) => {
1784
- handleConnection(deps, channel, socket, opts.peerReader, () => boundPid);
2510
+ handleConnection(deps, channel, socket, opts.peerReader, () => boundPid, expectedUid);
1785
2511
  });
1786
2512
  server.on("error", (err) => {
1787
2513
  deps.log(`verb socket ${channel.path} errored: ${err.message}`);
@@ -1795,7 +2521,10 @@ export async function listenVerbChannel(
1795
2521
  });
1796
2522
  });
1797
2523
 
1798
- const secure = opts.secure ?? secureBoundSocket;
2524
+ // A worker-run channel belongs to the worker: the socket is chowned to it so
2525
+ // the session child can connect, and the ownership line in the startup banner
2526
+ // says so rather than guessing.
2527
+ const secure = opts.secure ?? ((path: string) => secureBoundSocket(path, { owner: opts.channelOwner }));
1799
2528
  const ownership = secure(channel.path);
1800
2529
 
1801
2530
  return {
@@ -1821,6 +2550,7 @@ function handleConnection(
1821
2550
  socket: Socket,
1822
2551
  peerReader: PeerReader | undefined,
1823
2552
  boundPid: () => number | undefined,
2553
+ expectedUid: number,
1824
2554
  ): void {
1825
2555
  const fd = socketFd(socket);
1826
2556
  const peer = fd === undefined || peerReader === undefined ? undefined : peerReader(fd);
@@ -1837,12 +2567,12 @@ function handleConnection(
1837
2567
  ? {
1838
2568
  ok: false,
1839
2569
  peerUid: peer?.uid ?? -1,
1840
- expectedUid: process.getuid?.() ?? 0,
2570
+ expectedUid,
1841
2571
  detail:
1842
2572
  "this channel has not been bound to a process yet, so the caller cannot be identified — " +
1843
- "every session on this host shares the daemon's uid",
2573
+ "this socket is 0600 and its owner is the only uid that can reach it",
1844
2574
  }
1845
- : peerVerdict({ uid: process.getuid?.() ?? 0, pid: expectedPid }, peer);
2575
+ : peerVerdict({ uid: expectedUid, pid: expectedPid }, peer);
1846
2576
  if (!verdict.ok) {
1847
2577
  // Not a client error, and deliberately not answered: a caller who is not
1848
2578
  // who the socket was allocated to gets no reply to calibrate against.