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
package/src/admission.ts CHANGED
@@ -18,9 +18,13 @@ import { join } from "node:path";
18
18
  import { log, errText, safeEscalate } from "./log.ts";
19
19
  import type {
20
20
  Caps,
21
+ EffectiveModel,
21
22
  Escalation,
23
+ FileLane,
22
24
  IssueComment,
23
25
  IssueSnapshot,
26
+ LaneDeclaration,
27
+ ModelDeclaration,
24
28
  OpenCloser,
25
29
  ProjectConfig,
26
30
  RunRecord,
@@ -32,7 +36,8 @@ import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.t
32
36
  import type { CriticalBaseProbe, CriticalBaseVerdict, LaneFile, LaneSource, RunLaneProbe } from "./gitops.ts";
33
37
  import { repoSlugFor } from "./gitops.ts";
34
38
  import { branchName, type Routed } from "./routing.ts";
35
- import { parseDependsOn } from "./depends-on.ts";
39
+ import { parseDependsOn, buildDependencyCycles } from "./depends-on.ts";
40
+ import type { DependencyCycle, DependencyNodeReader } from "./depends-on.ts";
36
41
 
37
42
  /** Fleet-wide escalations still need an issue number in the payload; 0 is the
38
43
  * sentinel that reads as "no issue" in every renderer. */
@@ -64,6 +69,16 @@ export interface AdmissionDeps {
64
69
  * prerequisite closed too — the daemon always wires it.
65
70
  */
66
71
  probeIssueIn?: (repo: string, issue: number) => Promise<IssueSnapshot | undefined>;
72
+ /**
73
+ * Reads one issue's BODY in a repository the admission tracker is NOT bound
74
+ * to — the cross-repo half of the dependency-graph cycle pass (#421). The
75
+ * tracker bound to `project.tracker.repo` already answers same-repo bodies
76
+ * via {@link Tracker.issueBody}; a reachable prerequisite body in a routed
77
+ * repo goes through this. Undefined means "could not tell"; unset, a routed
78
+ * reachable body fails that branch closed (no false cycle), exactly like
79
+ * {@link Tracker.issueBody}. The daemon always wires it.
80
+ */
81
+ probeBodyIn?: (repo: string, issue: number) => Promise<string | undefined>;
67
82
  }
68
83
  /** `stops` are the operational ends that each require one resume. */
69
84
  export function hasContinuationBudget(stops: number, maxContinuations: number): boolean {
@@ -83,11 +98,16 @@ export function hasFailedAttemptBudget(failures: number, maxAttempts: number): b
83
98
  * (#608): the exact snapshot the overlap gate enforced. Dispatch renders this
84
99
  * value into the worker brief, so a changed or failed second comment read can
85
100
  * neither hide nor reword the lane admission held — the gate and the
86
- * worker-visible brief are one value, not two reads of the same thread. */
101
+ * worker-visible brief are one value, not two reads of the same thread.
102
+ * `model` is the effective `Model:` declaration resolved from the same read
103
+ * (#535): not a gate, but the same one-value contract — dispatch launches on
104
+ * this selector and the brief echoes it, so a second read can neither hide
105
+ * the model admission held nor drift from it. */
87
106
  export interface Admission {
88
107
  r: Routed;
89
108
  attempt: number;
90
109
  lane?: FileLane;
110
+ model?: EffectiveModel;
91
111
  }
92
112
 
93
113
  export interface AdmissionHold {
@@ -181,20 +201,42 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
181
201
  * files an issue declares it will touch (#555).
182
202
  *
183
203
  * The orchestrator already writes exactly this list into every promotion brief
184
- * in prose; this parses that same sentence out of the issue body — or out of a
185
- * pre-dispatch comment, which is the same sentence on a supported brief
204
+ * in prose; this parses that same declaration out of the issue body — or out of
205
+ * a pre-dispatch comment, which is the same declaration on a supported brief
186
206
  * surface since #517 — so the interlock is load-bearing rather than advisory.
187
- * A line beginning with "file lane" (case-insensitive, optional
188
- * bold/heading markers) is accepted, and paths are read as backtick-delimited
189
- * spans (the brief form) with a bare comma/space-separated fallback that keeps
190
- * tokens that look like relative paths. A line whose tokens are not pathlike
191
- * (like `File lane: none`) is not a declaration. Anything else — including an
207
+ * Two spellings are accepted, and both work on every surface:
208
+ *
209
+ * - The inline line: a line beginning with "file lane" (case-insensitive,
210
+ * optional bold/heading markers) whose rest carries the paths as
211
+ * backtick-delimited spans (the brief form), with a bare
212
+ * comma/space-separated fallback that keeps tokens that look like relative
213
+ * paths.
214
+ * - The write-lane section (#825): a markdown heading "Exact write lane" (or
215
+ * "Write lane" / "write-lane") whose immediately following bullet items
216
+ * carry the paths — the package-floor decomposition format, where a groomed
217
+ * child names the files it writes as one backticked path per bullet. Only
218
+ * the contiguous bullet run under that heading is read: a following
219
+ * paragraph (like a "Read only:" caveat) or a later section ends it, so
220
+ * read-only entry points, proof commands and acceptance bullets elsewhere in
221
+ * the issue are never captured.
222
+ *
223
+ * Inline wins when both are present — the machine-shaped sentence has held
224
+ * since #555, and the mediated label echo shows exactly which declaration
225
+ * admission enforces. A line or section whose tokens are not pathlike (like
226
+ * `File lane: none`) is not a declaration. Anything else — including an
192
227
  * absent declaration, which is the default — is an empty lane: the issue is
193
228
  * admitted exactly as today (`fail open`), and the gate never refuses work for
194
- * wanting a lane. This is the one grammar for all surfaces; there is no second
195
- * comment-only spelling.
229
+ * wanting a lane. A clearly delimited write-lane section that parsed nothing
230
+ * is NOT an absent declaration: the promotion verb refuses it with an
231
+ * actionable syntax error ({@link writeLaneSectionHeading}) rather than
232
+ * echoing a fail-open the section's own heading contradicts.
196
233
  */
197
234
  export function laneDeclaration(text: string): LaneDeclaration | undefined {
235
+ return inlineLaneDeclaration(text) ?? sectionLaneDeclaration(text);
236
+ }
237
+
238
+ /** The inline `File lane:`/`File-lane=` sentence grammar (see {@link laneDeclaration}). */
239
+ function inlineLaneDeclaration(text: string): LaneDeclaration | undefined {
198
240
  const match = text.match(
199
241
  /^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*([^\n]*)$/im,
200
242
  );
@@ -211,34 +253,58 @@ export function laneDeclaration(text: string): LaneDeclaration | undefined {
211
253
  return { files, source: match[0].trim() };
212
254
  }
213
255
 
214
- /** One parsed `File lane:` declaration: the paths and the verbatim source
215
- * line, so a renderer can reproduce the declaration itself rather than a
216
- * summary of it. Exported for the brief's guarantee that the gate's effective
217
- * lane is always visible to the worker (#608). */
218
- export interface LaneDeclaration {
219
- files: string[];
220
- /** The declaration line verbatim, as written on the surface it came from. */
221
- source: string;
222
- }
223
-
224
256
  /**
225
- * The effective file lane as both admission and the worker brief must read it
226
- * (#608): the latest `File lane:` declaration among the issue body and every
227
- * comment, in the tracker's oldest-first order. This is the "later correction
228
- * visibly supersedes" contract applied across both surfaces at once, and it is
229
- * the single source of truth the gate enforces and the brief renders — so a
230
- * declaration can never control admission while staying invisible to the
231
- * worker. `at` records which surface won (`"body"`, or the 0-based comment
232
- * index), letting the brief reproduce the declaration verbatim when the
233
- * winning comment sits beyond its rendered discussion budget.
257
+ * A markdown heading that opens a clearly delimited write-lane section
258
+ * (#825): `## Exact write lane`, `## Write lane`, or a hyphenated spelling,
259
+ * optional trailing colon. Returns the heading line verbatim, or undefined.
260
+ * The promotion verb uses this to refuse a section that parsed to nothing —
261
+ * the echo must never claim fail-open for a heading that plainly tried to
262
+ * declare one.
234
263
  */
235
- export interface FileLane extends LaneDeclaration {
236
- at: "body" | number;
264
+ export function writeLaneSectionHeading(text: string): string | undefined {
265
+ const match = text.match(
266
+ /^[ \t]*#{1,6}[ \t]+(?:exact[ \t]+)?write[-\s]lane[ \t]*[:.]?[ \t]*$/im,
267
+ );
268
+ return match === null ? undefined : match[0].trim();
269
+ }
270
+
271
+ /** The write-lane section grammar (see {@link laneDeclaration}): the heading
272
+ * plus the contiguous bullet run beneath it. A non-bullet line ends the run,
273
+ * so a caveat paragraph or the next section bounds the declaration. */
274
+ function sectionLaneDeclaration(text: string): LaneDeclaration | undefined {
275
+ const lines = text.split("\n");
276
+ let start = -1;
277
+ for (let i = 0; i < lines.length; i++) {
278
+ if (writeLaneSectionHeading(lines[i]!) !== undefined) {
279
+ start = i;
280
+ break;
281
+ }
282
+ }
283
+ if (start < 0) return undefined;
284
+ const files: string[] = [];
285
+ const bullets: string[] = [];
286
+ for (let i = start + 1; i < lines.length; i++) {
287
+ const line = lines[i]!;
288
+ if (line.trim() === "") continue;
289
+ // A markdown bullet marker (`- `, `* `, `+ `, `1. `), with an optional
290
+ // task checkbox. Anything else ends the section's bullet run.
291
+ const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/);
292
+ if (marker === null) break;
293
+ const rest = line.slice(marker[0].length);
294
+ const backticked = [...rest.matchAll(/`([^`]+)`/g)]
295
+ .map((m) => m[1]!.trim())
296
+ .filter(isPathLike);
297
+ files.push(...(backticked.length > 0 ? backticked : rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike)));
298
+ bullets.push(line.trim());
299
+ }
300
+ if (files.length === 0) return undefined;
301
+ return { files: [...new Set(files)], source: [lines[start]!.trim(), ...bullets].join("\n") };
237
302
  }
238
303
 
239
304
  /** Who holds one file's lane this admission pass, and which read proved it.
240
- * `"declared"` is the same-pass half: an admitted candidate's own lane
241
- * occupies for the rest of the pass without any probe read. */
305
+ * `"declared"` covers both occupancy halves: an admitted candidate's own
306
+ * lane for the rest of its pass, and since #744 — a live run's persisted
307
+ * declaration on every later pass, without any probe read. */
242
308
  export type LaneHolder = { issue: number; source: LaneSource | "declared" };
243
309
 
244
310
  /** Resolves the effective lane across the body and the whole comment thread. */
@@ -259,6 +325,53 @@ export function declaredLane(body: string): string[] {
259
325
  return laneDeclaration(body)?.files ?? [];
260
326
  }
261
327
 
328
+ /**
329
+ * The model half of a promotion brief (#535): the selector an issue declares
330
+ * it will dispatch on, read from the body or a pre-dispatch comment — the
331
+ * same two surfaces `File lane:` reads. Conductor names a selector and omp
332
+ * resolves what it means, so the declaration is one token in omp's own model
333
+ * syntax — a role alias (`@slow`) or a concrete `provider/model`, either
334
+ * optionally `:thinking`-suffixed — passed through verbatim as the session's
335
+ * `modelPattern`. A line whose rest is not exactly one selector (blank,
336
+ * `none`, multi-word prose) is not a declaration: dispatch fails open to the
337
+ * project's `workerModel`, exactly as a lane-less issue is admitted today.
338
+ * This is the one grammar for all surfaces; there is no second comment-only
339
+ * spelling.
340
+ */
341
+ export function modelDeclaration(text: string): ModelDeclaration | undefined {
342
+ const match = text.match(
343
+ /^\s*(?:[#>*-]\s*)*model\s*[:=]\s*([^\n]*)$/im,
344
+ );
345
+ if (match === null) return undefined;
346
+ const rest = (match[1] ?? "").trim().replace(/^[*`\s]+|[*`\s]+$/g, "").trim();
347
+ if (rest === "" || /^none$/i.test(rest) || /\s/.test(rest)) return undefined;
348
+ return { model: rest, source: match[0].trim() };
349
+ }
350
+
351
+ /** Resolves the effective model across the body and the whole comment thread. */
352
+ export function effectiveModel(body: string, comments: IssueComment[]): EffectiveModel | undefined {
353
+ let current: EffectiveModel | undefined;
354
+ const bodyDecl = modelDeclaration(body);
355
+ if (bodyDecl !== undefined) current = { ...bodyDecl, at: "body" };
356
+ for (let i = 0; i < comments.length; i++) {
357
+ const decl = modelDeclaration(comments[i]!.body);
358
+ if (decl !== undefined) current = { ...decl, at: i };
359
+ }
360
+ return current;
361
+ }
362
+
363
+ /**
364
+ * The file lane as one line, for surfaces where a human asserts the claim
365
+ * (#724): the parsed file list exactly as admission will enforce it, or the
366
+ * explicit fail-open note when nothing parsed. Verbatim parser output only —
367
+ * a paraphrase would hide the very over-parse (#720) or under-parse (#684)
368
+ * this echo exists to surface.
369
+ */
370
+ export function laneEcho(lane: LaneDeclaration | undefined): string {
371
+ if (lane === undefined) return "no lane declared (fail open)";
372
+ return lane.files.join(", ");
373
+ }
374
+
262
375
  /** A plausible relative path: it has a `.` extension or a directory separator. */
263
376
  function isPathLike(token: string): boolean {
264
377
  return token !== "" && !/\s/.test(token) && (token.includes("/") || /\.[A-Za-z0-9]{1,10}$/.test(token));
@@ -325,43 +438,61 @@ export async function admitCandidates(
325
438
  };
326
439
 
327
440
  // The file-lane interlock (#555): repo -> file -> the issue holding that file.
328
- // Seeded from the *actual* worktrees/branches of active runs never from
329
- // their issue bodies, which is precisely how #288 collidedand extended as
330
- // this pass admits candidates with declared lanes, so two overlapping
331
- // candidates cannot both clear the gate in one tick. Keyed by repo because a
332
- // path only collides within its own checkout: a `daemon.ts` on the api repo
333
- // and one on the web repo are different files. Built lazily and once, only
334
- // when the first candidate that declares a lane reaches the gate, so a queue
335
- // of laneless issues pays nothing for it.
441
+ // Seeded from what a live run actually *occupies*, never from its issue body:
442
+ // the probed worktrees/branches of active runs (#288) unionedsince #744
443
+ // with the lane declaration admission persisted on the row at dispatch, which
444
+ // is the exact enforced snapshot, not a re-parse. The declared half is what
445
+ // survives across passes: a run admitted with a lane it has not yet written
446
+ // still occupies until it reaches a terminal state and leaves the active set,
447
+ // and the declared lanes are extended as this pass admits candidates, so two
448
+ // overlapping candidates cannot both clear the gate in one tick. Keyed by
449
+ // repo because a path only collides within its own checkout: a `daemon.ts` on
450
+ // the api repo and one on the web repo are different files. Built lazily and
451
+ // once, only when the first candidate that declares a lane reaches the gate,
452
+ // so a queue of laneless issues pays nothing for it.
336
453
  let laneOccupancy: Map<string, Map<string, LaneHolder>> | undefined;
337
454
  const ensureLaneOccupancy = async (): Promise<Map<string, Map<string, LaneHolder>>> => {
338
455
  if (laneOccupancy !== undefined) return laneOccupancy;
339
456
  const occupied = new Map<string, Map<string, LaneHolder>>();
340
- for (const run of activeRuns) {
341
- if (d.probeWorktreeLane === undefined) break;
342
- const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
343
- let files: LaneFile[];
344
- try {
345
- files = await d.probeWorktreeLane({
346
- worktree: run.worktree,
347
- baseRef: `refs/remotes/origin/${base}`,
348
- ...(run.branch === "" ? {} : { branchRef: `refs/heads/${run.branch}` }),
349
- mirror: join(project.mirrorRoot, `${run.repo}.git`),
350
- });
351
- } catch {
352
- // Fail open, like an unreadable lane: the gate never refuses a
353
- // well-formed issue because one probe could not be answered.
354
- files = [];
457
+ // Shared per-file first-read-wins: a file a run both declares and has
458
+ // actually touched is tagged by the concrete probe read, and across runs
459
+ // whichever live run comes first in the active set is the holder named —
460
+ // both are legitimate for the gate, which holds on any overlap.
461
+ const occupy = (run: RunRecord, file: string, source: LaneSource | "declared"): void => {
462
+ let perRepo = occupied.get(run.repo);
463
+ if (perRepo === undefined) {
464
+ perRepo = new Map();
465
+ occupied.set(run.repo, perRepo);
355
466
  }
356
- for (const { file, source } of files) {
357
- let perRepo = occupied.get(run.repo);
358
- if (perRepo === undefined) {
359
- perRepo = new Map();
360
- occupied.set(run.repo, perRepo);
467
+ if (!perRepo.has(file)) perRepo.set(file, { issue: run.issue, source });
468
+ };
469
+ for (const run of activeRuns) {
470
+ // The probed actual half first, so the concrete read outnames the
471
+ // declaration for a file the run has already written.
472
+ if (d.probeWorktreeLane !== undefined) {
473
+ const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
474
+ let files: LaneFile[];
475
+ try {
476
+ files = await d.probeWorktreeLane({
477
+ worktree: run.worktree,
478
+ baseRef: `refs/remotes/origin/${base}`,
479
+ ...(run.branch === "" ? {} : { branchRef: `refs/heads/${run.branch}` }),
480
+ mirror: join(project.mirrorRoot, `${run.repo}.git`),
481
+ });
482
+ } catch {
483
+ // Fail open, like an unreadable lane: the gate never refuses a
484
+ // well-formed issue because one probe could not be answered.
485
+ files = [];
361
486
  }
362
- // First read wins: a file the run both commits and edits in its
363
- // worktree is tagged by its live uncommitted half.
364
- if (!perRepo.has(file)) perRepo.set(file, { issue: run.issue, source });
487
+ for (const { file, source } of files) occupy(run, file, source);
488
+ }
489
+ // The declared durable half (#744): the lane admission persisted at
490
+ // dispatch. Occupies even before the run has written anything, on every
491
+ // pass — the declaration lives on the row, not in a per-pass memo.
492
+ // Released by the active-set filter exactly like probed occupancy: a
493
+ // terminal run leaves `activeRuns` and its files stop occupying here.
494
+ if (run.lane !== undefined && run.lane.files.length > 0) {
495
+ for (const file of run.lane.files) occupy(run, file, "declared");
365
496
  }
366
497
  }
367
498
  laneOccupancy = occupied;
@@ -385,31 +516,45 @@ export async function admitCandidates(
385
516
  // the worker therefore agree on one lane, which is the #608 defect's shape.
386
517
  //
387
518
  // Comments are read at most once per issue per pass — the lane feeds both
388
- // the gate and the same-pass sibling occupancy below, so the cache is what
389
- // stops one candidate costing two comment reads. Unreadable comments fail
390
- // open to the body declaration (a body lane stays load-bearing), and with no
391
- // readable declaration at all the #555 fail-open admission is unchanged.
519
+ // the gate and the same-pass sibling occupancy below, and the model rides
520
+ // the same read for its one-value contract with dispatch (#535), so the
521
+ // cache is what stops one candidate costing two comment reads. Unreadable
522
+ // comments fail open to the body declarations (a body lane stays
523
+ // load-bearing, and a body model stays the one dispatch follows), and with
524
+ // no readable declaration at all the #555 fail-open admission is unchanged.
392
525
  //
393
526
  // The resolved `FileLane` — not just its paths — is what an admitted
394
527
  // candidate carries into dispatch (#608): the gate and the brief must agree
395
528
  // on the *same declaration* (paths, verbatim source line, and which surface
396
529
  // it came from), so dispatch renders this cached value rather than reading
397
- // the thread a second time and hoping it did not change.
530
+ // the thread a second time and hoping it did not change. The resolved
531
+ // `EffectiveModel` is carried the same way and for the same reason: dispatch
532
+ // launches on exactly this selector and the brief echoes it.
398
533
  const laneCache = new Map<number, FileLane | undefined>();
399
- const laneFor = async (r: Routed): Promise<FileLane | undefined> => {
534
+ const modelCache = new Map<number, EffectiveModel | undefined>();
535
+ const declarationsFor = async (r: Routed): Promise<void> => {
400
536
  const issue = r.issue.number;
401
- if (laneCache.has(issue)) return laneCache.get(issue);
537
+ if (laneCache.has(issue)) return;
402
538
  let lane: FileLane | undefined;
539
+ let model: EffectiveModel | undefined;
403
540
  try {
404
- lane = effectiveLane(r.issue.body, await tracker.listComments(issue));
541
+ const comments = await tracker.listComments(issue);
542
+ lane = effectiveLane(r.issue.body, comments);
543
+ model = effectiveModel(r.issue.body, comments);
405
544
  } catch (err) {
406
545
  const bodyDecl = laneDeclaration(r.issue.body);
407
546
  lane = bodyDecl === undefined ? undefined : { ...bodyDecl, at: "body" };
408
- log(`#${issue} comments unreadable at admission; the body's file lane stands: ${errText(err)}`);
547
+ const bodyModel = modelDeclaration(r.issue.body);
548
+ model = bodyModel === undefined ? undefined : { ...bodyModel, at: "body" };
549
+ log(`#${issue} comments unreadable at admission; the body's declarations stand: ${errText(err)}`);
409
550
  }
410
551
  laneCache.set(issue, lane);
411
- return lane;
552
+ modelCache.set(issue, model);
412
553
  };
554
+ const laneFor = (r: Routed): Promise<FileLane | undefined> =>
555
+ declarationsFor(r).then(() => laneCache.get(r.issue.number));
556
+ const modelFor = (r: Routed): Promise<EffectiveModel | undefined> =>
557
+ declarationsFor(r).then(() => modelCache.get(r.issue.number));
413
558
 
414
559
  // The plan allowance is a fleet-wide question, so it is asked once per pass
415
560
  // and answers for every candidate — unlike every gate below it, which is
@@ -427,6 +572,9 @@ export async function admitCandidates(
427
572
  log(`plan usage gate holding ${String(routed.length)} candidate(s): ${plan.detail}`);
428
573
  const escalation = planUsageEscalation(project.name, plan);
429
574
  if (escalation !== undefined) await safeEscalate(d, escalation);
575
+ // Every pass exit reconciles the durable blocked inventory against this
576
+ // pass's holds, so a hold that stopped applying clears itself (#735).
577
+ store.reconcileGrooming(project.name, holds);
430
578
  return { admitted: [], holds };
431
579
  }
432
580
 
@@ -469,8 +617,79 @@ export async function admitCandidates(
469
617
  }
470
618
 
471
619
  const admitted: Admission[] = [];
620
+
621
+ // Phase A — dependency-graph cycle pass (#421). Built ONCE per pass, before
622
+ // any per-candidate open-prerequisite hold, so a candidate sitting on a cycle
623
+ // is held as `dependency-cycle` instead of having an open first edge mask the
624
+ // group. Readers follow the same tracker/probe seams as the per-candidate
625
+ // interlock: same-repo refs via `tracker`, routed refs via `probeIssueIn` /
626
+ // `probeBodyIn`. Traversal is bounded to the tracker/routed repos and every
627
+ // node is read at most once; a tracker failure fails closed (no edge) rather
628
+ // than ever synthesising a cycle.
629
+ const readDepNode: DependencyNodeReader = async (repo, issue) => {
630
+ const snap = await (async () => {
631
+ try {
632
+ if (repo === project.tracker.repo) return await tracker.issueSnapshot(issue);
633
+ return d.probeIssueIn === undefined ? undefined : await d.probeIssueIn(repo, issue);
634
+ } catch {
635
+ return undefined;
636
+ }
637
+ })();
638
+ if (snap === undefined) return undefined;
639
+ if (snap.state === "closed") return { state: "closed", body: "" };
640
+ const body = await (async () => {
641
+ try {
642
+ if (repo === project.tracker.repo) return await tracker.issueBody(issue);
643
+ return d.probeBodyIn === undefined ? undefined : await d.probeBodyIn(repo, issue);
644
+ } catch {
645
+ return undefined;
646
+ }
647
+ })();
648
+ return body === undefined ? undefined : { state: "open", body };
649
+ };
650
+ const graph = await buildDependencyCycles(
651
+ routed.map((r) => ({ repo: project.tracker.repo, issue: r.issue.number, body: r.issue.body })),
652
+ project.tracker.repo,
653
+ (ownerRepo) => crossRepoTarget(project, ownerRepo),
654
+ readDepNode,
655
+ );
656
+ // candidate issue -> the cycle path that holds it, for status/digest.
657
+ const memberCycle = new Map<number, string>();
658
+ for (const cycle of graph.cycles) {
659
+ for (const member of cycle.members) {
660
+ const [repo, issueStr] = member.split("#") as [string, string];
661
+ if (repo !== project.tracker.repo) continue;
662
+ const n = Number(issueStr);
663
+ // Prefer the cycle anchored at this member (its "own" cycle) when a
664
+ // candidate sits on more than one.
665
+ if (!memberCycle.has(n) || cycle.anchorIssue === n) memberCycle.set(n, cycle.display);
666
+ }
667
+ // One material event per stable cycle identity: the escalation ledger
668
+ // dedupes on project/issue/tier/summary, and the summary carries the
669
+ // canonical path, so a stable cycle pages once and a changed cycle emits a
670
+ // new event (the rotated/queue-ordered spelling is canonicalised away).
671
+ await safeEscalate(d, {
672
+ tier: 1,
673
+ project: project.name,
674
+ issue: cycle.anchorIssue,
675
+ summary: `Dependency cycle detected: ${cycle.display}`,
676
+ detail: [
677
+ "None of the issues on this cycle can be dispatched until one edge is broken.",
678
+ `Cycle: ${cycle.display}`,
679
+ "Break it by closing or reordering a `Depends-on:` reference, and the affected",
680
+ "candidates become claimable again on the next tick.",
681
+ ].join("\n"),
682
+ });
683
+ }
684
+
472
685
  for (const r of routed) {
473
686
  const issue = r.issue.number;
687
+ const cyclePath = memberCycle.get(issue);
688
+ if (cyclePath !== undefined) {
689
+ hold(issue, "dependency-cycle", cyclePath);
690
+ log(`#${issue} held (dependency-cycle): ${cyclePath}`);
691
+ continue;
692
+ }
474
693
  if (admitted.length >= slots) {
475
694
  hold(issue, "capacity");
476
695
  continue;
@@ -761,6 +980,15 @@ export async function admitCandidates(
761
980
  log(`#${issue} skipped: queue label ${project.queueLabel} was removed (search index lag)`);
762
981
  continue;
763
982
  }
983
+ // Park beats queue here too (#734): the candidate list was fetched from
984
+ // the search index before the operator parked the issue, so the fresh
985
+ // snapshot is the last gate. A park never touches a live run — it only
986
+ // stops a claim.
987
+ if (snapshot.labels.includes(project.stateLabels.backlog)) {
988
+ hold(issue, "issue-parked");
989
+ log(`#${issue} skipped: park label ${project.stateLabels.backlog} was applied since listing`);
990
+ continue;
991
+ }
764
992
 
765
993
  // The Depends-on interlock (#419/#420): a candidate declares the issues it
766
994
  // must not be dispatched before — same-repo (`#123`) or cross-repo
@@ -932,7 +1160,7 @@ export async function admitCandidates(
932
1160
  if (blocked) continue;
933
1161
  }
934
1162
 
935
- admitted.push({ r, attempt: priorRuns + 1, lane });
1163
+ admitted.push({ r, attempt: priorRuns + 1, lane, model: await modelFor(r) });
936
1164
  liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
937
1165
  // Same-pass sibling occupancy for the file-lane gate: once admitted, a
938
1166
  // candidate's declared lane occupies for the rest of the pass, so a later
@@ -961,5 +1189,9 @@ export async function admitCandidates(
961
1189
  }
962
1190
  }
963
1191
 
1192
+ // Every pass exit reconciles the durable blocked inventory against this
1193
+ // pass's holds, so a lane/dependency hold that stopped applying clears
1194
+ // itself on the very next pass (#735).
1195
+ store.reconcileGrooming(project.name, holds);
964
1196
  return { admitted, holds };
965
1197
  }