omp-conductor 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/REFERENCE.md +12 -8
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +40 -1
  4. package/src/admission.ts +263 -44
  5. package/src/ask.ts +39 -3
  6. package/src/availability.ts +27 -1
  7. package/src/backups.ts +2 -2
  8. package/src/briefs/orchestrator.md +1 -0
  9. package/src/briefs/worker.md +38 -19
  10. package/src/command-help.ts +8 -1
  11. package/src/command-manifest.ts +5 -2
  12. package/src/commands/arm.ts +6 -3
  13. package/src/commands/message.ts +32 -4
  14. package/src/commands/watch.ts +62 -3
  15. package/src/config-schema.ts +53 -0
  16. package/src/config.ts +97 -1
  17. package/src/daemon.ts +1479 -1483
  18. package/src/decisions.ts +51 -6
  19. package/src/depends-on.ts +261 -1
  20. package/src/diff-flags.ts +350 -0
  21. package/src/digest-schedule.ts +37 -0
  22. package/src/doctor.ts +310 -22
  23. package/src/escalate.ts +560 -57
  24. package/src/failure-class.ts +71 -15
  25. package/src/fleet.ts +189 -34
  26. package/src/gitops.ts +103 -24
  27. package/src/graph-health.ts +20 -7
  28. package/src/graph.ts +313 -68
  29. package/src/lifecycle.ts +43 -7
  30. package/src/omp.ts +42 -0
  31. package/src/orchestrator-tick.ts +430 -162
  32. package/src/release-policy.ts +177 -5
  33. package/src/routing.ts +11 -3
  34. package/src/session-host.ts +16 -0
  35. package/src/settlement.ts +1728 -0
  36. package/src/setup-host.ts +193 -4
  37. package/src/setup-install.ts +91 -30
  38. package/src/setup-wizard.ts +1257 -78
  39. package/src/setup.ts +153 -6
  40. package/src/status-render.ts +36 -4
  41. package/src/store.ts +411 -17
  42. package/src/tracker/github.ts +607 -12
  43. package/src/types.ts +331 -5
  44. package/src/upgrade.ts +50 -19
  45. package/src/verbs/actions.ts +66 -18
  46. package/src/verbs/protocol.ts +45 -0
  47. package/src/verbs/server.ts +270 -13
  48. package/src/worker.ts +239 -6
  49. package/src/worktree.ts +115 -8
  50. package/systemd/omp-conductor-recover.sh +73 -0
  51. package/systemd/recover-unit-test.sh +61 -0
package/REFERENCE.md CHANGED
@@ -1078,11 +1078,15 @@ omp-conductor setup graph --print # print the plan: clones, index commands,
1078
1078
  omp-conductor setup graph # run it: clone, install, enable, seed, verify
1079
1079
  ```
1080
1080
 
1081
- `setup graph --print` prints a `git clone` for every clone that does not exist yet, the
1082
- one-shot index command per repo, and a `cbm-reindex.service` + `cbm-reindex.timer`
1083
- pair built from the project's own repos and branches. `--write` stages all three
1084
- in the state directory and prints the two `sudo` lines that install and enable
1085
- them; it never runs `systemctl`.
1081
+ `setup graph --print` prints the plan: the host prerequisites, a `git clone` for
1082
+ every clone that does not exist yet, the one-shot index command per repo, and
1083
+ this project's `cbm-reindex-<project>.sh` script plus `cbm-reindex-<project>.service`
1084
+ + `cbm-reindex-<project>.timer` unit pair, named for the project so a second
1085
+ project on the same host gets its own refresh instead of silently replacing the
1086
+ first project's (#720). A run stages those three files in the state directory
1087
+ **only after its consent prompt** — declining leaves the staged tree
1088
+ byte-identical — then clones, installs and enables the timer as root, seeds one
1089
+ indexing run, and verifies; it never runs `systemctl` on its own authority.
1086
1090
 
1087
1091
  **Run it as the account the fleet runs as, never under `sudo`** — it refuses if
1088
1092
  you try. Everything it derives resolves per-account: the config it loads, the
@@ -1864,7 +1868,7 @@ policy instead of restating it — no threshold lives in two places.
1864
1868
 
1865
1869
  | Field | Values | Default | Means |
1866
1870
  | --- | --- | --- | --- |
1867
- | `requires` | `runs-settled`, `no-open-prs`, `queue-drained`, `base-branch-green`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. `runs-settled` reads each active run's PR fact at release time: a pushed run whose PR has merged counts as settled even when the settle sweep has not yet written the terminal row — so a hold-drained release does not wait an extra tick the operator reached the gate by holding. Live workers and unmerged/unknown PRs still refuse, and the message names which is which. `base-branch-green` requires the current live head's push-triggered workflow verdict for that routed repository to be green; pending, unknown, red, or no observation refuses release. Order and duplicates do not matter; the loader canonicalises. |
1871
+ | `requires` | `runs-settled`, `fleet-runs-settled`, `no-open-prs`, `queue-drained`, `base-branch-green`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. `runs-settled` reads each active run of the released repo's PR fact at release time: a pushed run whose PR has merged counts as settled even when the settle sweep has not yet written the terminal row — so a hold-drained release does not wait an extra tick the operator reached the gate by holding. Runs in other routed repos never gate a repo-scoped release — they cannot invalidate the artifact being shipped; a genuinely suite-wide shape (a pin or manifest consuming several repos) opts back into project-wide strictness with the named `fleet-runs-settled` requirement. Live workers and unmerged/unknown PRs still refuse, the message names which is which and which runs are blocking by repo and issue. `base-branch-green` requires the current live head's push-triggered workflow verdict for that routed repository to be green; pending, unknown, red, or no observation refuses release. Order and duplicates do not matter; the loader canonicali…
1868
1872
  | `requiredChecks` | any check names | `[]` | Checks that must be green on the branch being released. Empty means every check it reports. |
1869
1873
  | `artefacts` | any names | `[]` | The packages or images this project releases. **Empty denies**: nothing has been authorised to ship. |
1870
1874
  | `environments` | any names | `[]` | Deploy targets. **Empty denies** every environment. |
@@ -2255,7 +2259,7 @@ omp-conductor help
2255
2259
  | --- | --- | --- |
2256
2260
  | `setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]` | project | The deterministic interview, with styled Clack prompts on an interactive TTY and byte-stable plain output for pipes or `OMP_CONDUCTOR_PLAIN_UI=1`. `--answers` validates a JSON object of stable prompt keys before setup and replaces every prompt; a missing required key exits `1` naming the key and file instead of hanging. `--save-answers` records accepted interactive answers as replayable JSON after a successful run. Bare setup is a full first run, or — when the project already exists — a chooser of which area to amend. Naming an area positionally skips that chooser and amends only that area: `tracker`, `gates`, `caps`, `code-graph`, `authority`, `policy`, `escalation`, `reporting`, `brief`. `host` and `graph` are install subcommands rather than areas and are matched first; anything else exits `2` listing both vocabularies. Every prompt shows its current value as the default, and Enter accepts what you see; `Ctrl-C` at any prompt abandons the run and writes nothing. Setup also **reads your repos to propose answers**: the gates prompt is pre-filled from what CI actually runs, and the brief's `## Project context` and release procedure are drafted from every routing repo and shown for confirmation before anything is written. Each probe is a short session with **no shell, no editor and no verbs** in a throwaway shallow clone, and every answer is a proposal you edit or decline — a probe that cannot clone, cannot reach a model, or answers unusably costs you one warning and the shipped stub. `--no-ai` asks every question with the reading half removed. |
2257
2261
  | `setup host [--project NAME]` | host | Re-render and stage the systemd unit, then **run** the install: `install -m 0644` into `/etc/systemd/system`, `daemon-reload`, `enable`, `restart`. Stages the fleet recovery oneshot (`omp-conductor-recover.service`) and its playbook (`/usr/local/sbin/omp-conductor-recover`) alongside, and installs them **before** the fleet units: both fleet units carry `OnFailure=` to the recovery unit, so a crash-looped daemon or herdr session now collects evidence durably, attempts one bounded recovery, and pages tier-2 instead of dying silently (#485). Every command is shown with its exact argv, one confirm covers the batch, and `sudo` asks for your password once before the first step — or is skipped entirely on a fleet that genuinely runs as root. The first failure stops the rest and prints the un-run remainder verbatim so you can finish by hand. Refuses an *escalated* invocation (`sudo`, or `sudo -i`/`su -` detected by the invoking account disagreeing with the fleet's) before writing anything, naming both accounts, because staging derives the unit's `User=`/`HOME=` from whoever ran it. On a non-Linux host the files are still staged and only the `systemctl` steps are refused. |
2258
- | `setup graph [--no-seed] [--print] [--project NAME]` | project | The code-graph install end to end, in one preview and one confirm: check the prerequisites read-only and stop before installing anything when `codebase-memory-mcp` is absent or no MCP entry mounts it (printing the entry to add); `git clone` each missing index-only checkout **as you, never through sudo**; install and enable `cbm-reindex.timer` as root; then seed one indexing run so the first fetch happens while you watch, and verify with the same probe `status` uses. A repo that does not verify is a failure with the remediation, not a success — staged-but-not-trusted is how you discover months later that no worker read an index. `--no-seed` enables the timer without the seeding run and says plainly the graph is unusable until it first fires; it never skips the prerequisite or clone steps. `--print` changes nothing. Exits `1` when no repo has [`graphProject`](#configuration). |
2262
+ | `setup graph [--no-seed] [--print] [--project NAME]` | project | The code-graph install end to end, in one preview and one confirm: check the prerequisites read-only and stop before installing anything when `codebase-memory-mcp` is absent or no MCP entry mounts it (printing the entry to add); stage this project's `cbm-reindex-<project>.{sh,service,timer}` **after** the confirm — never before, so a declined run leaves the staged tree byte-identical — refusing a stem that already belongs to another project or to a file it did not generate (#720); `git clone` each missing index-only checkout **as you, never through sudo**; install and enable `cbm-reindex-<project>.timer` as root; then seed one indexing run so the first fetch happens while you watch, and verify with the same probe `status` uses. A repo that does not verify is a failure with the remediation, not a success — staged-but-not-trusted is how you discover months later that no worker read an index. `--no-seed` enables the timer without the seeding run and says plainly the graph is unusable until it first fires; it never skips the prerequisite or clone steps. `--print` changes nothing. Exits `1` when no repo has [`graphProject`](#configuration). |
2259
2263
  | `start` | host | Start `herdr-fleet.service` when that optional unit is installed, clearing a previous pane-recovery pin, then start the dispatch daemon and wait until it answers `GET /healthz`. When `omp-conductor.service` is installed, systemd is the only start path: even `start --project NAME` restores the shared unit and uses the name only to verify that `/healthz` serves the requested project. A detached daemon is allowed only when the unit is proven absent. It never clears pause or arms ticks. Refuses if a daemon is already live, naming its pid; manager refusal or unprovable ownership is an error rather than a detached fallback. |
2260
2264
  | `stop` | fleet | Prefer `systemctl stop omp-conductor.service` when that unit's MainPID is the live daemon — systemd then owns the stop and will not schedule a restart for the exit it just requested. Otherwise `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop, and tags the confirmation with `(via systemctl)` when the unit path was used. |
2261
2265
  | `restart [--now] [--timeout SECONDS] [--port N] [--project NAME]` | host | Drains the fleet by default: pause new claims, wait until live workers reach `0 / N` (bounded by `--timeout SECONDS`, default 1800 = 30 min), restart, then restore the prior dispatch state. A daemon serving multiple configured projects makes restart host-wide: `--project` is rejected because draining one queue and restarting the shared process would kill another project's workers. Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; only a host proven not to have the installed unit may fall back to the standalone stop/start path. `--now` skips the drain and restarts immediately, orphaning any live runs (old behaviour). A drain that hits `--timeout` restarts nothing and leaves dispatch paused — `omp-conductor resume` lifts it, or re-run `restart` to keep waiting. The new process **salvages dirty live worktrees before orphaning** those rows — see [Deploying a new package onto a busy fleet](#deploying-a-new-package-onto-a-busy-fleet). |
@@ -2355,7 +2359,7 @@ curl -s localhost:8787/healthz
2355
2359
  "configured": true,
2356
2360
  "status": "degraded",
2357
2361
  "checkedAt": "2026-08-08T13:00:00.000Z",
2358
- "prerequisites": { "indexer": "present", "mcpMount": "missing" },
2362
+ "prerequisites": { "indexer": "present", "mcpMount": "unconfigured" },
2359
2363
  "repos": [
2360
2364
  {
2361
2365
  "name": "api",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -136,7 +136,8 @@
136
136
  "properties": {
137
137
  "inProgress": {},
138
138
  "blocked": {},
139
- "failed": {}
139
+ "failed": {},
140
+ "backlog": {}
140
141
  },
141
142
  "additionalProperties": false
142
143
  },
@@ -325,6 +326,43 @@
325
326
  },
326
327
  "additionalProperties": false
327
328
  },
329
+ "arm": {
330
+ "type": "object",
331
+ "properties": {
332
+ "proof": {
333
+ "default": "challenge",
334
+ "type": "string",
335
+ "enum": [
336
+ "challenge",
337
+ "claim-only"
338
+ ]
339
+ }
340
+ },
341
+ "additionalProperties": false,
342
+ "description": "How `arm` proves a human just approved arming"
343
+ },
344
+ "review": {
345
+ "type": "object",
346
+ "properties": {
347
+ "strictness": {
348
+ "default": "medium",
349
+ "type": "string",
350
+ "enum": [
351
+ "low",
352
+ "medium",
353
+ "high"
354
+ ]
355
+ },
356
+ "maxRounds": {
357
+ "default": 3,
358
+ "type": "integer",
359
+ "minimum": 1,
360
+ "maximum": 6
361
+ }
362
+ },
363
+ "additionalProperties": false,
364
+ "description": "Review strictness and round ceiling for green PRs"
365
+ },
328
366
  "authority": {
329
367
  "type": "object",
330
368
  "properties": {
@@ -433,6 +471,7 @@
433
471
  "type": "string",
434
472
  "enum": [
435
473
  "runs-settled",
474
+ "fleet-runs-settled",
436
475
  "no-open-prs",
437
476
  "queue-drained",
438
477
  "base-branch-green",
package/src/admission.ts CHANGED
@@ -19,6 +19,7 @@ import { log, errText, safeEscalate } from "./log.ts";
19
19
  import type {
20
20
  Caps,
21
21
  Escalation,
22
+ IssueComment,
22
23
  IssueSnapshot,
23
24
  OpenCloser,
24
25
  ProjectConfig,
@@ -28,10 +29,11 @@ import type {
28
29
  AdmissionHoldReason,
29
30
  } from "./types.ts";
30
31
  import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.ts";
31
- import type { CriticalBaseProbe, CriticalBaseVerdict, RunLaneProbe } from "./gitops.ts";
32
+ import type { CriticalBaseProbe, CriticalBaseVerdict, LaneFile, LaneSource, RunLaneProbe } from "./gitops.ts";
32
33
  import { repoSlugFor } from "./gitops.ts";
33
34
  import { branchName, type Routed } from "./routing.ts";
34
- import { parseDependsOn } from "./depends-on.ts";
35
+ import { parseDependsOn, buildDependencyCycles } from "./depends-on.ts";
36
+ import type { DependencyCycle, DependencyNodeReader } from "./depends-on.ts";
35
37
 
36
38
  /** Fleet-wide escalations still need an issue number in the payload; 0 is the
37
39
  * sentinel that reads as "no issue" in every renderer. */
@@ -63,6 +65,16 @@ export interface AdmissionDeps {
63
65
  * prerequisite closed too — the daemon always wires it.
64
66
  */
65
67
  probeIssueIn?: (repo: string, issue: number) => Promise<IssueSnapshot | undefined>;
68
+ /**
69
+ * Reads one issue's BODY in a repository the admission tracker is NOT bound
70
+ * to — the cross-repo half of the dependency-graph cycle pass (#421). The
71
+ * tracker bound to `project.tracker.repo` already answers same-repo bodies
72
+ * via {@link Tracker.issueBody}; a reachable prerequisite body in a routed
73
+ * repo goes through this. Undefined means "could not tell"; unset, a routed
74
+ * reachable body fails that branch closed (no false cycle), exactly like
75
+ * {@link Tracker.issueBody}. The daemon always wires it.
76
+ */
77
+ probeBodyIn?: (repo: string, issue: number) => Promise<string | undefined>;
66
78
  }
67
79
  /** `stops` are the operational ends that each require one resume. */
68
80
  export function hasContinuationBudget(stops: number, maxContinuations: number): boolean {
@@ -77,10 +89,16 @@ export function hasFailedAttemptBudget(failures: number, maxAttempts: number): b
77
89
  return failures < maxAttempts;
78
90
  }
79
91
 
80
- /** A candidate cleared for dispatch, with the attempt number it will run as. */
92
+ /** A candidate cleared for dispatch, with the attempt number it will run as.
93
+ * `lane` is the effective file-lane declaration admission resolved for it
94
+ * (#608): the exact snapshot the overlap gate enforced. Dispatch renders this
95
+ * value into the worker brief, so a changed or failed second comment read can
96
+ * neither hide nor reword the lane admission held — the gate and the
97
+ * worker-visible brief are one value, not two reads of the same thread. */
81
98
  export interface Admission {
82
99
  r: Routed;
83
100
  attempt: number;
101
+ lane?: FileLane;
84
102
  }
85
103
 
86
104
  export interface AdmissionHold {
@@ -174,27 +192,94 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
174
192
  * files an issue declares it will touch (#555).
175
193
  *
176
194
  * The orchestrator already writes exactly this list into every promotion brief
177
- * in prose; this parses that same sentence out of the issue body so the
178
- * interlock is load-bearing rather than advisory. A body line beginning with
179
- * "file lane" (case-insensitive, optional bold/heading markers) is accepted,
180
- * and paths are read as backtick-delimited spans (the brief form) with a bare
181
- * comma/space-separated fallback that keeps tokens that look like relative
182
- * paths. Anything else including an absent line, which is the default — is
183
- * an empty lane: the issue is admitted exactly as today (`fail open`), and the
184
- * gate never refuse work for wanting a lane. Exported so the format is pinned
185
- * independent of admission.
195
+ * in prose; this parses that same sentence out of the issue body or out of a
196
+ * pre-dispatch comment, which is the same sentence on a supported brief
197
+ * surface since #517 so the interlock is load-bearing rather than advisory.
198
+ * A line beginning with "file lane" (case-insensitive, optional
199
+ * bold/heading markers) is accepted, and paths are read as backtick-delimited
200
+ * spans (the brief form) with a bare comma/space-separated fallback that keeps
201
+ * tokens that look like relative paths. A line whose tokens are not pathlike
202
+ * (like `File lane: none`) is not a declaration. Anything else including an
203
+ * absent declaration, which is the default — is an empty lane: the issue is
204
+ * admitted exactly as today (`fail open`), and the gate never refuses work for
205
+ * wanting a lane. This is the one grammar for all surfaces; there is no second
206
+ * comment-only spelling.
186
207
  */
187
- export function declaredLane(body: string): string[] {
188
- const match = body.match(
208
+ export function laneDeclaration(text: string): LaneDeclaration | undefined {
209
+ const match = text.match(
189
210
  /^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*([^\n]*)$/im,
190
211
  );
191
- if (match === null) return [];
212
+ if (match === null) return undefined;
192
213
  const rest = match[1] ?? "";
193
214
  const backticked = [...rest.matchAll(/`([^`]+)`/g)]
194
215
  .map((m) => m[1]!.trim())
195
216
  .filter(isPathLike);
196
- if (backticked.length > 0) return [...new Set(backticked)];
197
- return [...new Set(rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike))];
217
+ const files =
218
+ backticked.length > 0
219
+ ? [...new Set(backticked)]
220
+ : [...new Set(rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike))];
221
+ if (files.length === 0) return undefined;
222
+ return { files, source: match[0].trim() };
223
+ }
224
+
225
+ /** One parsed `File lane:` declaration: the paths and the verbatim source
226
+ * line, so a renderer can reproduce the declaration itself rather than a
227
+ * summary of it. Exported for the brief's guarantee that the gate's effective
228
+ * lane is always visible to the worker (#608). */
229
+ export interface LaneDeclaration {
230
+ files: string[];
231
+ /** The declaration line verbatim, as written on the surface it came from. */
232
+ source: string;
233
+ }
234
+
235
+ /**
236
+ * The effective file lane as both admission and the worker brief must read it
237
+ * (#608): the latest `File lane:` declaration among the issue body and every
238
+ * comment, in the tracker's oldest-first order. This is the "later correction
239
+ * visibly supersedes" contract applied across both surfaces at once, and it is
240
+ * the single source of truth the gate enforces and the brief renders — so a
241
+ * declaration can never control admission while staying invisible to the
242
+ * worker. `at` records which surface won (`"body"`, or the 0-based comment
243
+ * index), letting the brief reproduce the declaration verbatim when the
244
+ * winning comment sits beyond its rendered discussion budget.
245
+ */
246
+ export interface FileLane extends LaneDeclaration {
247
+ at: "body" | number;
248
+ }
249
+
250
+ /** Who holds one file's lane this admission pass, and which read proved it.
251
+ * `"declared"` is the same-pass half: an admitted candidate's own lane
252
+ * occupies for the rest of the pass without any probe read. */
253
+ export type LaneHolder = { issue: number; source: LaneSource | "declared" };
254
+
255
+ /** Resolves the effective lane across the body and the whole comment thread. */
256
+ export function effectiveLane(body: string, comments: IssueComment[]): FileLane | undefined {
257
+ let current: FileLane | undefined;
258
+ const bodyDecl = laneDeclaration(body);
259
+ if (bodyDecl !== undefined) current = { ...bodyDecl, at: "body" };
260
+ for (let i = 0; i < comments.length; i++) {
261
+ const decl = laneDeclaration(comments[i]!.body);
262
+ if (decl !== undefined) current = { ...decl, at: i };
263
+ }
264
+ return current;
265
+ }
266
+
267
+ /** The paths of an issue body's declared lane (empty for no declaration).
268
+ * Exported so the format is pinned independent of admission. */
269
+ export function declaredLane(body: string): string[] {
270
+ return laneDeclaration(body)?.files ?? [];
271
+ }
272
+
273
+ /**
274
+ * The file lane as one line, for surfaces where a human asserts the claim
275
+ * (#724): the parsed file list exactly as admission will enforce it, or the
276
+ * explicit fail-open note when nothing parsed. Verbatim parser output only —
277
+ * a paraphrase would hide the very over-parse (#720) or under-parse (#684)
278
+ * this echo exists to surface.
279
+ */
280
+ export function laneEcho(lane: LaneDeclaration | undefined): string {
281
+ if (lane === undefined) return "no lane declared (fail open)";
282
+ return lane.files.join(", ");
198
283
  }
199
284
 
200
285
  /** A plausible relative path: it has a `.` extension or a directory separator. */
@@ -271,14 +356,14 @@ export async function admitCandidates(
271
356
  // and one on the web repo are different files. Built lazily and once, only
272
357
  // when the first candidate that declares a lane reaches the gate, so a queue
273
358
  // of laneless issues pays nothing for it.
274
- let laneOccupancy: Map<string, Map<string, number>> | undefined;
275
- const ensureLaneOccupancy = async (): Promise<Map<string, Map<string, number>>> => {
359
+ let laneOccupancy: Map<string, Map<string, LaneHolder>> | undefined;
360
+ const ensureLaneOccupancy = async (): Promise<Map<string, Map<string, LaneHolder>>> => {
276
361
  if (laneOccupancy !== undefined) return laneOccupancy;
277
- const occupied = new Map<string, Map<string, number>>();
362
+ const occupied = new Map<string, Map<string, LaneHolder>>();
278
363
  for (const run of activeRuns) {
279
364
  if (d.probeWorktreeLane === undefined) break;
280
365
  const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
281
- let files: string[];
366
+ let files: LaneFile[];
282
367
  try {
283
368
  files = await d.probeWorktreeLane({
284
369
  worktree: run.worktree,
@@ -291,20 +376,64 @@ export async function admitCandidates(
291
376
  // well-formed issue because one probe could not be answered.
292
377
  files = [];
293
378
  }
294
- for (const file of files) {
295
- if (occupied.get(run.repo)?.has(file) === true) continue;
379
+ for (const { file, source } of files) {
296
380
  let perRepo = occupied.get(run.repo);
297
381
  if (perRepo === undefined) {
298
382
  perRepo = new Map();
299
383
  occupied.set(run.repo, perRepo);
300
384
  }
301
- perRepo.set(file, run.issue);
385
+ // First read wins: a file the run both commits and edits in its
386
+ // worktree is tagged by its live uncommitted half.
387
+ if (!perRepo.has(file)) perRepo.set(file, { issue: run.issue, source });
302
388
  }
303
389
  }
304
390
  laneOccupancy = occupied;
305
391
  return occupied;
306
392
  };
307
393
 
394
+ // The candidate half of the file-lane interlock (#555): the machine-readable
395
+ // lane an issue declares. Since #517 a promotion brief may live in a
396
+ // pre-dispatch comment rather than the body, so the lane is read from both
397
+ // surfaces — the same two the worker brief renders — through the tracker's
398
+ // existing comment port, never a second comment reader. Body and comments
399
+ // share the one `laneDeclaration` grammar, and the brief's "later correction
400
+ // visibly supersedes" contract holds: the effective lane is the latest
401
+ // declaration among the body and the WHOLE thread, so a correction posted as
402
+ // a comment replaces an earlier body lane instead of widening it (#608).
403
+ //
404
+ // Reading every comment — not just the ones the brief's discussion budget
405
+ // renders — is deliberate: this is the same `effectiveLane` the brief
406
+ // renders, and the dispatch side guarantees the winning declaration appears
407
+ // in the brief verbatim even when it sits beyond the budget. The gate and
408
+ // the worker therefore agree on one lane, which is the #608 defect's shape.
409
+ //
410
+ // Comments are read at most once per issue per pass — the lane feeds both
411
+ // the gate and the same-pass sibling occupancy below, so the cache is what
412
+ // stops one candidate costing two comment reads. Unreadable comments fail
413
+ // open to the body declaration (a body lane stays load-bearing), and with no
414
+ // readable declaration at all the #555 fail-open admission is unchanged.
415
+ //
416
+ // The resolved `FileLane` — not just its paths — is what an admitted
417
+ // candidate carries into dispatch (#608): the gate and the brief must agree
418
+ // on the *same declaration* (paths, verbatim source line, and which surface
419
+ // it came from), so dispatch renders this cached value rather than reading
420
+ // the thread a second time and hoping it did not change.
421
+ const laneCache = new Map<number, FileLane | undefined>();
422
+ const laneFor = async (r: Routed): Promise<FileLane | undefined> => {
423
+ const issue = r.issue.number;
424
+ if (laneCache.has(issue)) return laneCache.get(issue);
425
+ let lane: FileLane | undefined;
426
+ try {
427
+ lane = effectiveLane(r.issue.body, await tracker.listComments(issue));
428
+ } catch (err) {
429
+ const bodyDecl = laneDeclaration(r.issue.body);
430
+ lane = bodyDecl === undefined ? undefined : { ...bodyDecl, at: "body" };
431
+ log(`#${issue} comments unreadable at admission; the body's file lane stands: ${errText(err)}`);
432
+ }
433
+ laneCache.set(issue, lane);
434
+ return lane;
435
+ };
436
+
308
437
  // The plan allowance is a fleet-wide question, so it is asked once per pass
309
438
  // and answers for every candidate — unlike every gate below it, which is
310
439
  // per-issue. It sits here rather than beside the spend cap in `tick` for one
@@ -363,8 +492,79 @@ export async function admitCandidates(
363
492
  }
364
493
 
365
494
  const admitted: Admission[] = [];
495
+
496
+ // Phase A — dependency-graph cycle pass (#421). Built ONCE per pass, before
497
+ // any per-candidate open-prerequisite hold, so a candidate sitting on a cycle
498
+ // is held as `dependency-cycle` instead of having an open first edge mask the
499
+ // group. Readers follow the same tracker/probe seams as the per-candidate
500
+ // interlock: same-repo refs via `tracker`, routed refs via `probeIssueIn` /
501
+ // `probeBodyIn`. Traversal is bounded to the tracker/routed repos and every
502
+ // node is read at most once; a tracker failure fails closed (no edge) rather
503
+ // than ever synthesising a cycle.
504
+ const readDepNode: DependencyNodeReader = async (repo, issue) => {
505
+ const snap = await (async () => {
506
+ try {
507
+ if (repo === project.tracker.repo) return await tracker.issueSnapshot(issue);
508
+ return d.probeIssueIn === undefined ? undefined : await d.probeIssueIn(repo, issue);
509
+ } catch {
510
+ return undefined;
511
+ }
512
+ })();
513
+ if (snap === undefined) return undefined;
514
+ if (snap.state === "closed") return { state: "closed", body: "" };
515
+ const body = await (async () => {
516
+ try {
517
+ if (repo === project.tracker.repo) return await tracker.issueBody(issue);
518
+ return d.probeBodyIn === undefined ? undefined : await d.probeBodyIn(repo, issue);
519
+ } catch {
520
+ return undefined;
521
+ }
522
+ })();
523
+ return body === undefined ? undefined : { state: "open", body };
524
+ };
525
+ const graph = await buildDependencyCycles(
526
+ routed.map((r) => ({ repo: project.tracker.repo, issue: r.issue.number, body: r.issue.body })),
527
+ project.tracker.repo,
528
+ (ownerRepo) => crossRepoTarget(project, ownerRepo),
529
+ readDepNode,
530
+ );
531
+ // candidate issue -> the cycle path that holds it, for status/digest.
532
+ const memberCycle = new Map<number, string>();
533
+ for (const cycle of graph.cycles) {
534
+ for (const member of cycle.members) {
535
+ const [repo, issueStr] = member.split("#") as [string, string];
536
+ if (repo !== project.tracker.repo) continue;
537
+ const n = Number(issueStr);
538
+ // Prefer the cycle anchored at this member (its "own" cycle) when a
539
+ // candidate sits on more than one.
540
+ if (!memberCycle.has(n) || cycle.anchorIssue === n) memberCycle.set(n, cycle.display);
541
+ }
542
+ // One material event per stable cycle identity: the escalation ledger
543
+ // dedupes on project/issue/tier/summary, and the summary carries the
544
+ // canonical path, so a stable cycle pages once and a changed cycle emits a
545
+ // new event (the rotated/queue-ordered spelling is canonicalised away).
546
+ await safeEscalate(d, {
547
+ tier: 1,
548
+ project: project.name,
549
+ issue: cycle.anchorIssue,
550
+ summary: `Dependency cycle detected: ${cycle.display}`,
551
+ detail: [
552
+ "None of the issues on this cycle can be dispatched until one edge is broken.",
553
+ `Cycle: ${cycle.display}`,
554
+ "Break it by closing or reordering a `Depends-on:` reference, and the affected",
555
+ "candidates become claimable again on the next tick.",
556
+ ].join("\n"),
557
+ });
558
+ }
559
+
366
560
  for (const r of routed) {
367
561
  const issue = r.issue.number;
562
+ const cyclePath = memberCycle.get(issue);
563
+ if (cyclePath !== undefined) {
564
+ hold(issue, "dependency-cycle", cyclePath);
565
+ log(`#${issue} held (dependency-cycle): ${cyclePath}`);
566
+ continue;
567
+ }
368
568
  if (admitted.length >= slots) {
369
569
  hold(issue, "capacity");
370
570
  continue;
@@ -505,25 +705,30 @@ export async function admitCandidates(
505
705
  }
506
706
  if (verdict.state === "unknown") {
507
707
  // Fail closed: a branch that cannot be *proven* to contain the marker
508
- // is refused, and the reason names the unverifiable marker so the
509
- // operator can fix the fetch or the marker rather than guess.
510
- hold(issue, "stale-base");
708
+ // is refused. This is a verification/lookup failure the mirror fetch
709
+ // failed, the marker did not resolve, or no probe is wired — never
710
+ // evidence that the branch predates the marker. Hold with a distinct
711
+ // reason so status and the friction rollup do not read a provider
712
+ // outage as branch staleness, and prescribe no branch change: the next
713
+ // admission pass re-runs the probe and admits the unchanged branch
714
+ // once verification succeeds.
715
+ hold(issue, "critical-base-verify-error");
511
716
  log(
512
- `#${issue} held (stale-base): continuation branch ${branch} could not be verified ` +
717
+ `#${issue} held (critical-base-verify-error): continuation branch ${branch} could not be verified ` +
513
718
  `against critical-base marker(s) ${markers.join(", ")} (${verdict.error})`,
514
719
  );
515
720
  await safeEscalate(d, {
516
721
  tier: 1,
517
722
  project: project.name,
518
723
  issue,
519
- summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (stale-base)`,
724
+ summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (critical-base-verify-error)`,
520
725
  detail: [
521
726
  r.issue.title,
522
727
  r.issue.url,
523
728
  `The retained branch ${branch} could not be verified against critical-base marker(s) ${markers.join(", ")}: ${verdict.error}`,
524
- "Recovery: merge current base into the branch so it contains the marker, and the next",
525
- "admission pass re-admits it automatically without losing the branch's work; or review",
526
- "the branch by hand and clear the hold once the fix is present.",
729
+ "The branch is not claimed to predate the marker: this is a verification failure.",
730
+ "No branch change is prescribed. The next admission pass retries the critical-base",
731
+ "verification automatically and admits the unchanged branch once the probe succeeds.",
527
732
  ].join("\n"),
528
733
  });
529
734
  continue;
@@ -650,6 +855,15 @@ export async function admitCandidates(
650
855
  log(`#${issue} skipped: queue label ${project.queueLabel} was removed (search index lag)`);
651
856
  continue;
652
857
  }
858
+ // Park beats queue here too (#734): the candidate list was fetched from
859
+ // the search index before the operator parked the issue, so the fresh
860
+ // snapshot is the last gate. A park never touches a live run — it only
861
+ // stops a claim.
862
+ if (snapshot.labels.includes(project.stateLabels.backlog)) {
863
+ hold(issue, "issue-parked");
864
+ log(`#${issue} skipped: park label ${project.stateLabels.backlog} was applied since listing`);
865
+ continue;
866
+ }
653
867
 
654
868
  // The Depends-on interlock (#419/#420): a candidate declares the issues it
655
869
  // must not be dispatched before — same-repo (`#123`) or cross-repo
@@ -795,18 +1009,23 @@ export async function admitCandidates(
795
1009
  // a live run's *actual* lane is held until that run's work has merged, so
796
1010
  // no second worker is sent at files another worker is still writing. The
797
1011
  // gate is the mechanical version of the prose rule that failed three times
798
- // in one day. Only the machine-readable lane participates: a candidate
799
- // without one is admitted exactly as today (fail open), and a candidate's
800
- // own retained run never holds it that is the continuation it continues.
801
- const lane = declaredLane(r.issue.body);
802
- if (lane.length > 0) {
1012
+ // in one day. Only the machine-readable lane participates read from the
1013
+ // body and pre-dispatch comments alike, so the gate sees the same surface
1014
+ // the worker brief renders (#608)and a candidate without one is
1015
+ // admitted exactly as today (fail open), while a candidate's own retained
1016
+ // run never holds it — that is the continuation it continues.
1017
+ const lane = await laneFor(r);
1018
+ if (lane !== undefined && lane.files.length > 0) {
803
1019
  const occupied = await ensureLaneOccupancy();
804
1020
  const perRepo = occupied.get(r.repo.name);
805
1021
  let blocked = false;
806
- for (const file of lane) {
1022
+ for (const file of lane.files) {
807
1023
  const holder = perRepo?.get(file);
808
- if (holder !== undefined && holder !== issue) {
809
- const detail = `${file} held by run #${holder}`;
1024
+ if (holder !== undefined && holder.issue !== issue) {
1025
+ // The source names the probe read that proved the occupancy, so a
1026
+ // hold reads as authored work — worktree or branch — rather than the
1027
+ // base-reconciliation noise #684 filters out of the probe entirely.
1028
+ const detail = `${file} held by run #${holder.issue} (${holder.source})`;
810
1029
  hold(issue, "file-lane", detail);
811
1030
  log(`#${issue} held (file-lane): ${detail}`);
812
1031
  blocked = true;
@@ -816,20 +1035,20 @@ export async function admitCandidates(
816
1035
  if (blocked) continue;
817
1036
  }
818
1037
 
819
- admitted.push({ r, attempt: priorRuns + 1 });
1038
+ admitted.push({ r, attempt: priorRuns + 1, lane });
820
1039
  liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
821
1040
  // Same-pass sibling occupancy for the file-lane gate: once admitted, a
822
1041
  // candidate's declared lane occupies for the rest of the pass, so a later
823
1042
  // overlapping candidate is held rather than both clearing in one tick.
824
- if (lane.length > 0) {
1043
+ if (lane !== undefined && lane.files.length > 0) {
825
1044
  const occupied = await ensureLaneOccupancy();
826
1045
  let perRepo = occupied.get(r.repo.name);
827
1046
  if (perRepo === undefined) {
828
1047
  perRepo = new Map();
829
1048
  occupied.set(r.repo.name, perRepo);
830
1049
  }
831
- for (const file of lane) {
832
- if (!perRepo.has(file)) perRepo.set(file, issue);
1050
+ for (const file of lane.files) {
1051
+ if (!perRepo.has(file)) perRepo.set(file, { issue, source: "declared" });
833
1052
  }
834
1053
  }
835
1054
  if (parent !== undefined) {