@nickysagan/issue-orchestrator 0.1.2 → 0.2.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.
@@ -6,12 +6,19 @@ import { access, realpath } from "node:fs/promises";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { promisify } from "node:util";
8
8
  import { createManagedContainerClient, resolveContainerId as resolveOwnContainerId } from "../src/managedContainer.mjs";
9
+ import { describeEnforcementTransition } from "../src/enforcementNotice.mjs";
9
10
  import { BLOCKED, MERGE_REVIEW, READY, REVIEW, RUNNING, phaseOf } from "../src/labels.mjs";
10
11
  import { ensureLabels } from "../src/labels.mjs";
11
12
  import { selectApplicableMarker } from "../src/reviewMarker.mjs";
13
+ import {
14
+ formatRepairAttemptComment, formatRepairBudgetComment, formatRepairFailureComment,
15
+ formatInvalidRepairHistoryComment, hasInvalidRepairHistoryComment,
16
+ hasRepairBudgetComment, hasRepairFailure,
17
+ } from "../src/repairMarker.mjs";
18
+ import { exactPhase, planRepair } from "../src/repairGate.mjs";
12
19
  import { createWorkerLogs } from "../src/workerLogs.mjs";
13
20
  import {
14
- applyReviewPlan, classifyChecks, failClosed, mirrorLabels, planVerdict, resolveManagedPrs,
21
+ applyReviewPlan, classifyChecks, failClosed, mirrorLabels, planVerdict, resolveManagedPrs, setPhase,
15
22
  } from "../src/reviewGate.mjs";
16
23
 
17
24
  const run = promisify(execFile);
@@ -20,11 +27,17 @@ const run = promisify(execFile);
20
27
  // Fixed defaults per the issue's simplicity constraints. Only the Sentinel URL
21
28
  // and the poll interval are environment-tunable.
22
29
  //
23
- // Two reserved implementation slots plus one reviewer slot that is never
24
- // borrowed for implementation. The pools are separate counters over separate
25
- // tmux window namespaces, so an orphan in one role can never eat the other's.
30
+ // Two shared implementation/repair slots plus one reviewer slot that is never
31
+ // borrowed. Issue and repair windows share one counter; review windows remain
32
+ // independent.
26
33
  const IMPLEMENTATION_SLOTS = 2;
27
34
  const REVIEWER_SLOTS = 1;
35
+ // A reviewer that exits without gating its PR is relaunched, because the usual
36
+ // cause is transient. It is not relaunched forever: after this many launches
37
+ // the issue fails closed, rather than burning a `ccode` run and the single
38
+ // reviewer slot every poll on a reviewer that cannot start.
39
+ const REVIEWER_ATTEMPTS = 3;
40
+ const REPAIR_ATTEMPTS = 2;
28
41
  const POLL_MS = Number(process.env.POLL_MS || 60000);
29
42
  const TOKEN_SCRIPT = process.env.GH_APP_TOKEN_SCRIPT || "/opt/agent-devcontainer/gh-app-token.sh";
30
43
  const SESSION = "orchestrator";
@@ -39,6 +52,38 @@ export async function isAgentSetupReady({ accessImpl = access } = {}) {
39
52
  }
40
53
  }
41
54
 
55
+ // Admission control for new launches. The pinned `github-issue` skill refuses
56
+ // to isolate issue work when the primary worktree is dirty, so claiming an
57
+ // issue in that state only produces a worker that exits before opening a PR.
58
+ // This runs the same predicate that guard runs — `git status --porcelain` —
59
+ // before anything is claimed.
60
+ //
61
+ // The primary worktree is resolved explicitly rather than assumed to be the
62
+ // caller's cwd: `git worktree list --porcelain` always reports it first, so a
63
+ // supervisor started from inside a linked worktree still inspects the tree
64
+ // isolation will actually branch from.
65
+ //
66
+ // Read-only by construction: it never stashes, resets, cleans, checks out, or
67
+ // commits, and it fails closed — an unreadable result is not a clean one.
68
+ export async function checkPrimaryWorktree({ exec }) {
69
+ try {
70
+ const { stdout: worktrees } = await exec("git", ["worktree", "list", "--porcelain"]);
71
+ const primary = String(worktrees).split("\n")
72
+ .find((line) => line.startsWith("worktree "))?.slice("worktree ".length).trim();
73
+ if (!primary) throw new Error("could not resolve the primary worktree");
74
+ const { stdout: status } = await exec("git", ["-C", primary, "status", "--porcelain"]);
75
+ const pending = String(status).split("\n").filter((line) => line.trim() !== "");
76
+ if (pending.length === 0) return { clean: true, reason: "clean", detail: "" };
77
+ return {
78
+ clean: false,
79
+ reason: "dirty",
80
+ detail: `${pending.length} pending change${pending.length === 1 ? "" : "s"} in ${primary}`,
81
+ };
82
+ } catch (err) {
83
+ return { clean: false, reason: "unreadable", detail: err.message };
84
+ }
85
+ }
86
+
42
87
  export async function acquireSupervisorOwnership(repo, {
43
88
  createServer = createNetServer,
44
89
  } = {}) {
@@ -126,6 +171,14 @@ export function createGitHub({ exec, repo }) {
126
171
  .sort((a, b) => a.number - b.number);
127
172
  }
128
173
 
174
+ async function getIssue(number) {
175
+ const { stdout } = await exec("gh", [
176
+ "issue", "view", String(number), ...R, "--json", "number,labels,updatedAt,body",
177
+ ]);
178
+ const issue = JSON.parse(stdout || "null");
179
+ return issue && { ...issue, labels: names(issue.labels) };
180
+ }
181
+
129
182
  // One call per poll carries everything the review gate needs: the marker
130
183
  // fingerprint inputs (SHAs and body), mirror labels, and the check rollup.
131
184
  async function listOpenPrs() {
@@ -135,6 +188,12 @@ export function createGitHub({ exec, repo }) {
135
188
  return JSON.parse(stdout || "[]").map((pr) => ({ ...pr, labels: names(pr.labels) }));
136
189
  }
137
190
 
191
+ async function getPr(number) {
192
+ const { stdout } = await exec("gh", ["pr", "view", String(number), ...R, "--json", PR_FIELDS]);
193
+ const pr = JSON.parse(stdout || "null");
194
+ return pr && { ...pr, labels: names(pr.labels) };
195
+ }
196
+
138
197
  async function listPrComments(number) {
139
198
  const { stdout } = await exec("gh", ["pr", "view", String(number), ...R, "--json", "comments"]);
140
199
  return JSON.parse(stdout || "{}").comments || [];
@@ -161,9 +220,13 @@ export function createGitHub({ exec, repo }) {
161
220
 
162
221
  const claim = (n) => setIssueLabels(n, { add: [RUNNING], remove: [READY] });
163
222
  // A launch that fails before any work exists reverts to the queue. This is
164
- // the only path that removes `agent-running`, which is otherwise durable
165
- // until Phase 7 cleanup.
223
+ // the only path that returns the issue to `agent-ready`; every other removal
224
+ // of `agent-running` goes through `release` below.
166
225
  const restore = (n) => setIssueLabels(n, { add: [READY], remove: [RUNNING] });
226
+ // Implementation-only completion releases the active claim after the PR is
227
+ // ready for the repository owner. The label itself remains the queue's
228
+ // durable "worker owns this issue" marker while work is in progress.
229
+ const release = (n) => setIssueLabels(n, { remove: [RUNNING] });
167
230
 
168
231
  const markPrReady = (n) => exec("gh", ["pr", "ready", String(n), ...R]);
169
232
  const markPrDraft = (n) => exec("gh", ["pr", "ready", String(n), ...R, "--undo"]);
@@ -181,8 +244,8 @@ export function createGitHub({ exec, repo }) {
181
244
 
182
245
  // Deliberately no approve and no merge: the supervisor never does either.
183
246
  return {
184
- listReadyIssues, listRunningIssues, listOpenPrs, listPrComments,
185
- claim, restore, setIssueLabels, setPrLabels, markPrReady, markPrDraft,
247
+ listReadyIssues, listRunningIssues, getIssue, listOpenPrs, getPr, listPrComments,
248
+ claim, restore, release, setIssueLabels, setPrLabels, markPrReady, markPrDraft,
186
249
  commentIssue, commentPr, listLabels, createLabel,
187
250
  };
188
251
  }
@@ -204,6 +267,14 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
204
267
  return n;
205
268
  }
206
269
 
270
+ function reviewFingerprint(value) {
271
+ const fingerprint = String(value ?? "");
272
+ if (!/^[0-9a-f]{64}$/.test(fingerprint)) {
273
+ throw new Error(`invalid review fingerprint: ${value}`);
274
+ }
275
+ return fingerprint;
276
+ }
277
+
207
278
  // tmux passes its command to /bin/sh, which then invokes interactive bash so
208
279
  // the subscription-authenticated `ccode` alias is available. Quote every
209
280
  // model-derived or recorded value before it crosses either shell boundary.
@@ -225,7 +296,7 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
225
296
  const set = new Set();
226
297
  for (const line of stdout.split("\n")) {
227
298
  const m = line.trim().match(pattern);
228
- if (m) set.add(Number(m[1]));
299
+ if (m && Number(m[1]) > 0) set.add(Number(m[1]));
229
300
  }
230
301
  return set;
231
302
  }
@@ -234,6 +305,7 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
234
305
  // Reviewer windows are a separate namespace so the reserved reviewer slot can
235
306
  // never be consumed by — or borrowed for — implementation.
236
307
  const listReviewIssues = () => listWindows("review");
308
+ const listRepairIssues = () => listWindows("repair");
237
309
 
238
310
  // Callers validate their numbers before calling this, so an invalid number
239
311
  // still throws synchronously rather than rejecting a returned promise.
@@ -246,13 +318,18 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
246
318
  // finish instant — the supervisor learns of it up to a poll later, while
247
319
  // `tee` may still hold the file open. `date` matches `formatLogLine`'s ISO
248
320
  // 8601 UTC shape, so both kinds of orchestrator line read alike.
249
- async function newWindow(role, number, name, command, completion) {
250
- const path = await logs.prepare(role, number);
251
- const cmd = `bash -ic ${shellQuote(command)}${path
252
- ? ` 2>&1 | tee ${shellQuote(path)}`
253
- + `; printf '[orchestrator %s] %s\\n' "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)"`
254
- + ` ${shellQuote(completion)} >> ${shellQuote(path)}`
255
- : ""}`;
321
+ async function newWindow(role, number, name, command, completion, options = {}) {
322
+ const path = options.pathReserved ? options.path : await logs.prepare(role, number);
323
+ if (!path) {
324
+ return exec("tmux", ["new-window", "-t", session, "-n", name, `bash -ic ${shellQuote(command)}`]);
325
+ }
326
+ const pipeline = `bash -ic ${shellQuote(command)} 2>&1 | tee ${shellQuote(path)}`;
327
+ const status = options.preserveExitStatus ? `; status=\${PIPESTATUS[0]}` : "";
328
+ const stamp = options.preserveExitStatus
329
+ ? `; printf '[orchestrator %s] %s (exit status %s)\\n' "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" ${shellQuote(completion)} "$status" >> ${shellQuote(path)}; exit "$status"`
330
+ : `; printf '[orchestrator %s] %s\\n' "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" ${shellQuote(completion)} >> ${shellQuote(path)}`;
331
+ const script = `${pipeline}${status}${stamp}`;
332
+ const cmd = options.preserveExitStatus ? `bash -c ${shellQuote(script)}` : script;
256
333
  return exec("tmux", ["new-window", "-t", session, "-n", name, cmd]);
257
334
  }
258
335
 
@@ -262,20 +339,42 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
262
339
  // window command via a non-interactive shell that never sources .bashrc,
263
340
  // so the alias would silently fail to resolve without `bash -ic`, which
264
341
  // forces alias expansion regardless of login/interactive invocation.
265
- const command = `ccode --print --permission-mode auto --model claude-opus-4-8 "/github-issue ${n}"`;
342
+ const command = `ccode --print --permission-mode auto --model claude-opus-5 "/github-issue ${n}"`;
266
343
  // No PR to name: the worker opens one mid-run, long after this launch.
267
344
  return newWindow("issue", n, `issue-${n}`, command, `Worker finished for #${n}`);
268
345
  }
269
346
 
270
347
  // The reviewer is a read-only pass over an existing PR; the window is named
271
348
  // for the issue so capacity accounting lines up with the managed issue set.
349
+ //
350
+ // `review-pr` reviews a linked *pair* and takes the issue first, then the PR
351
+ // — the order its SKILL.md and both consumer specs document, which is the
352
+ // reverse of the order its own snapshot/publish helper scripts take. The
353
+ // reviewer runs under `--print`, so a missing argument cannot be asked for:
354
+ // it would simply never resolve its inputs and never publish a pass.
272
355
  function openReviewer(number, prNumber) {
273
356
  const i = issueNumber(number);
274
357
  const p = issueNumber(prNumber);
275
- const command = `ccode --print --permission-mode auto --model claude-opus-4-8 "/review-pr ${p}"`;
358
+ const command = `ccode --print --permission-mode auto --model claude-opus-5 "/review-pr ${i} ${p}"`;
276
359
  return newWindow("review", i, `review-${i}`, command, `Reviewer finished for #${i} (PR #${p})`);
277
360
  }
278
361
 
362
+ function openRepair(number, prNumber, fingerprint, reservedPath) {
363
+ const i = issueNumber(number);
364
+ const p = issueNumber(prNumber);
365
+ const f = reviewFingerprint(fingerprint);
366
+ if (reservedPath === undefined) {
367
+ throw new Error("reserved repair log path must be provided explicitly");
368
+ }
369
+ const command = `ccode --print --permission-mode auto --model claude-opus-5 "/address-review ${i} ${p} ${f}"`;
370
+
371
+ return newWindow("repair", i, `repair-${i}`, command, `Repair finished for #${i} (PR #${p})`, {
372
+ pathReserved: true, path: reservedPath, preserveExitStatus: true,
373
+ });
374
+ }
375
+
376
+ const prepareRepair = (number) => logs.prepare("repair", issueNumber(number));
377
+
279
378
  // Suppress ONLY a confirmed "window absent" failure (idempotent close). Any
280
379
  // other tmux failure (e.g. server down) propagates so the caller does not
281
380
  // wrongly free the slot for a window that may still be alive.
@@ -290,7 +389,170 @@ export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
290
389
  }
291
390
  }
292
391
 
293
- return { ensureSession, listWorkerIssues, listReviewIssues, openWorker, openReviewer, closeWorker };
392
+ return {
393
+ ensureSession, listWorkerIssues, listReviewIssues, listRepairIssues,
394
+ openWorker, openReviewer, prepareRepair, openRepair, closeWorker,
395
+ };
396
+ }
397
+
398
+ // Map open PRs to the implementation issue they close. GitHub's parsed closing
399
+ // linkage is authoritative whenever the PR declares any, so a PR on
400
+ // `agent/<other>-*` that closes a different issue never maps to the branch
401
+ // number. The conventional branch prefix applies only to PRs that declare no
402
+ // closing reference at all. Two or more applicable PRs are reported as
403
+ // ambiguous rather than resolved to the first match: the caller must not act.
404
+ export function implementationPrForIssue(prs, number) {
405
+ const prefix = `agent/${number}-`;
406
+ const applies = (pr) => (Array.isArray(pr.closingIssuesReferences) && pr.closingIssuesReferences.length > 0
407
+ ? pr.closingIssuesReferences.some((ref) => ref?.number === number)
408
+ : typeof pr.headRefName === "string" && pr.headRefName.startsWith(prefix));
409
+ const matches = (prs || []).filter(applies);
410
+ if (matches.length > 1) return { pr: null, ambiguous: true };
411
+ return { pr: matches[0] || null, ambiguous: false };
412
+ }
413
+
414
+ // Production poll route after the managed-review rollback. It deliberately
415
+ // knows only queue, implementation worker and owner-ready PR states. Review,
416
+ // repair, approval and merge remain outside the supervisor.
417
+ export async function runImplementationOnlyOnce({
418
+ gh, tmux,
419
+ checkSetupReady = async () => true,
420
+ checkPrimaryClean = async () => ({ clean: true, reason: "clean", detail: "" }),
421
+ implementationSlots = IMPLEMENTATION_SLOTS,
422
+ log,
423
+ logs = NO_WORKER_LOGS,
424
+ runningIssues,
425
+ }) {
426
+ await tmux.ensureSession();
427
+ const running = runningIssues !== undefined ? runningIssues : await gh.listRunningIssues();
428
+ // A supervisor restarted into an `orchestrator` session left by the managed
429
+ // review release inherits live `review-N`/`repair-N` windows this route never
430
+ // opens. They are read before any mutation, alongside the implementation
431
+ // windows, so an inspection failure aborts the poll rather than running with
432
+ // unknown capacity.
433
+ const liveIssues = await tmux.listWorkerIssues();
434
+ const liveReview = await tmux.listReviewIssues();
435
+ const liveRepair = await tmux.listRepairIssues();
436
+ const prs = await gh.listOpenPrs();
437
+ // A legacy repair worker is a full `ccode` run sharing the implementation
438
+ // slots, exactly as it did before the rollback; a legacy reviewer had its own
439
+ // reserved slot and still consumes none.
440
+ let liveCount = liveIssues.size + liveRepair.size;
441
+
442
+ // Legacy windows are drained, never killed and never re-created: each may
443
+ // hold a worktree, a pushed branch, a draft PR and labels mid-transition, and
444
+ // the logic that knew how to finish or unwind that was removed with the
445
+ // managed review route. They exit on their own; until then they are visible
446
+ // here so nothing is launched over them and the supervisor cannot call itself
447
+ // idle. This line is the operator's only notice, so it names each one.
448
+ const legacyWindows = [
449
+ ...[...liveReview].sort((a, b) => a - b).map((n) => `review-${n}`),
450
+ ...[...liveRepair].sort((a, b) => a - b).map((n) => `repair-${n}`),
451
+ ];
452
+ if (legacyWindows.length > 0) {
453
+ log(`Legacy managed-review windows still live: ${legacyWindows.join(", ")}`
454
+ + " — draining; not managed, not restarted, and never killed by this supervisor");
455
+ }
456
+
457
+ for (const issue of running) {
458
+ try {
459
+ // Releasing the claim, or acting on a PR a legacy worker is still
460
+ // editing, is the race this reconciliation exists to avoid. Defer the
461
+ // whole issue; once its legacy window exits, the next poll reconciles it
462
+ // by the ordinary rules.
463
+ if (liveReview.has(issue.number) || liveRepair.has(issue.number)) {
464
+ log(`#${issue.number} has a live legacy window — deferring reconciliation to a later poll`);
465
+ continue;
466
+ }
467
+ const { pr, ambiguous } = implementationPrForIssue(prs, issue.number);
468
+ if (ambiguous) {
469
+ log(`#${issue.number}: multiple open PRs claim this issue — leaving worker and claim untouched`);
470
+ continue;
471
+ }
472
+ if (pr && pr.isDraft === false) {
473
+ if (liveIssues.has(issue.number)) {
474
+ await tmux.closeWorker(issue.number);
475
+ liveIssues.delete(issue.number);
476
+ liveCount -= 1;
477
+ }
478
+ await gh.release(issue.number);
479
+ log(`Completed #${issue.number} (PR ready for owner) — agent-running released`);
480
+ } else if (!liveIssues.has(issue.number)) {
481
+ const diagnostics = await logs.diagnostics("issue", issue.number);
482
+ const detail = diagnostics
483
+ ? `; log: ${diagnostics.path}\n${diagnostics.tail}`
484
+ : "";
485
+ await gh.release(issue.number);
486
+ log(`Worker for #${issue.number} vanished; PR still draft (${pr ? pr.url : "no PR"}) — released, not restarted${detail}`);
487
+ }
488
+ } catch (err) {
489
+ log(`reconcile error for #${issue.number} (leaving for next poll): ${err.message}`);
490
+ }
491
+ }
492
+
493
+ const ready = await gh.listReadyIssues();
494
+ if (ready.length > 0 && !await checkSetupReady()) {
495
+ log("Agent setup incomplete — pausing worker launches");
496
+ return {
497
+ done: false, implLive: liveCount, reviewLive: liveReview.size,
498
+ repairLive: liveRepair.size,
499
+ started: 0, reviewsStarted: 0, repairsStarted: 0,
500
+ };
501
+ }
502
+
503
+ // Admission control. Isolation refuses a dirty primary worktree, so claiming
504
+ // an issue here would only burn a label transition on a worker that exits
505
+ // before opening a PR — which the next poll then misreports as vanished.
506
+ // Reconciliation above has already run, so live work keeps its slot, its
507
+ // claim and its lease; only new launches pause, and only for this poll.
508
+ if (ready.length > 0) {
509
+ const primary = await checkPrimaryClean();
510
+ if (!primary.clean) {
511
+ log(`Primary worktree ${primary.reason} (${primary.detail})`
512
+ + " — no issue claimed, worker launches paused this poll");
513
+ return {
514
+ done: false, implLive: liveCount, reviewLive: liveReview.size,
515
+ repairLive: liveRepair.size,
516
+ started: 0, reviewsStarted: 0, repairsStarted: 0,
517
+ };
518
+ }
519
+ }
520
+
521
+ let started = 0;
522
+ for (const number of ready) {
523
+ if (liveCount >= implementationSlots) break;
524
+ let claimed = false;
525
+ try {
526
+ await gh.claim(number);
527
+ claimed = true;
528
+ await tmux.openWorker(number);
529
+ liveCount += 1;
530
+ started += 1;
531
+ log(`Started worker for #${number}`);
532
+ } catch (err) {
533
+ const cleanup = [];
534
+ if (claimed) {
535
+ try {
536
+ await gh.restore(number);
537
+ } catch (cleanupError) {
538
+ cleanup.push(`restore: ${cleanupError.message}`);
539
+ }
540
+ }
541
+ log(`failed to start #${number} (leaving for next poll): ${err.message}${cleanup.length ? `; cleanup failed: ${cleanup.join(", ")}` : ""}`);
542
+ }
543
+ }
544
+
545
+ return {
546
+ // `done` drops the Sentinel lease and stops the supervisor, so a live
547
+ // legacy worker of either kind must hold it false.
548
+ done: ready.length === 0 && liveCount === 0 && liveReview.size === 0,
549
+ implLive: liveCount,
550
+ reviewLive: liveReview.size,
551
+ repairLive: liveRepair.size,
552
+ started,
553
+ reviewsStarted: 0,
554
+ repairsStarted: 0,
555
+ };
294
556
  }
295
557
 
296
558
  // ---- one poll cycle ---------------------------------------------------------
@@ -305,6 +567,7 @@ export async function runOnce({
305
567
  checkSetupReady = async () => true,
306
568
  implementationSlots = IMPLEMENTATION_SLOTS,
307
569
  reviewerSlots = REVIEWER_SLOTS,
570
+ reviewerAttempts = REVIEWER_ATTEMPTS,
308
571
  log,
309
572
  logs = NO_WORKER_LOGS,
310
573
  runningIssues,
@@ -314,15 +577,21 @@ export async function runOnce({
314
577
  const running = runningIssues !== undefined ? runningIssues : await gh.listRunningIssues();
315
578
  const liveImpl = await tmux.listWorkerIssues();
316
579
  const liveReview = await tmux.listReviewIssues();
580
+ const liveRepair = await tmux.listRepairIssues();
317
581
  const prs = await gh.listOpenPrs();
318
582
 
319
- let implLive = liveImpl.size;
583
+ let implLive = liveImpl.size + liveRepair.size;
320
584
  let reviewLive = liveReview.size;
321
585
 
322
586
  // Phase state as this poll last knew it, updated in place by each transition
323
587
  // so the exit condition below reflects the work just done.
324
588
  const phases = new Map(running.map((issue) => [issue.number, phaseOf(issue.labels)]));
325
589
  const { managed, problems } = resolveManagedPrs(running, prs);
590
+ const commentsByPr = new Map();
591
+ const prComments = async (number) => {
592
+ if (!commentsByPr.has(number)) commentsByPr.set(number, gh.listPrComments(number));
593
+ return commentsByPr.get(number);
594
+ };
326
595
 
327
596
  // --- linkage ambiguity fails closed ---------------------------------------
328
597
  for (const problem of problems) {
@@ -370,15 +639,89 @@ export async function runOnce({
370
639
  }
371
640
  }
372
641
 
642
+ // --- blocked repair reconciliation and eligibility ------------------------
643
+ // First reconcile durable reservations and collect complete candidates.
644
+ // Launching happens only after every managed item has been inspected.
645
+ const eligibleRepairs = [];
646
+ const liveRepairPairs = new Set();
647
+ const reconciledRepairPairs = new Set();
648
+ for (const { issue, pr } of managed) {
649
+ try {
650
+ if (![BLOCKED, REVIEW].includes(phases.get(issue.number))) continue;
651
+ const comments = await prComments(pr.number);
652
+ const identity = { issue: issue.number, pr: pr.number };
653
+ const plan = planRepair({
654
+ issue, pr, comments, phase: phases.get(issue.number),
655
+ liveRepair: liveRepair.has(issue.number), maxAttempts: REPAIR_ATTEMPTS,
656
+ });
657
+ if (plan.action === "invalid-history") {
658
+ log(`#${issue.number}: invalid repair history — leaving blocked for manual intervention`);
659
+ const sourceFingerprint = plan.latestReview?.fingerprint;
660
+ if (sourceFingerprint && !hasInvalidRepairHistoryComment(comments, identity, sourceFingerprint)) {
661
+ await gh.commentPr(pr.number, formatInvalidRepairHistoryComment({ ...identity, sourceFingerprint }));
662
+ }
663
+ continue;
664
+ }
665
+ if (plan.action === "live") {
666
+ liveRepairPairs.add(issue.number);
667
+ continue;
668
+ }
669
+ if (plan.action === "review") {
670
+ await setPhase({ gh, issue, pr, phase: REVIEW });
671
+ phases.set(issue.number, REVIEW);
672
+ reconciledRepairPairs.add(issue.number);
673
+ continue;
674
+ }
675
+ if (plan.action === "failed") {
676
+ const { reservation } = plan;
677
+ await setPhase({ gh, issue, pr, phase: BLOCKED });
678
+ phases.set(issue.number, BLOCKED);
679
+ reconciledRepairPairs.add(issue.number);
680
+ if (!hasRepairFailure(comments, identity, reservation.attempt, reservation.sourceFingerprint)) {
681
+ const diagnostics = await logs.diagnostics("repair", issue.number);
682
+ const correlated = reservation.log && diagnostics?.path === reservation.log ? diagnostics : null;
683
+ const exitMatch = String(correlated?.tail ?? "").match(/exit status\s+(\d+)/i);
684
+ await gh.commentPr(pr.number, formatRepairFailureComment({
685
+ ...identity,
686
+ attempt: reservation.attempt,
687
+ sourceFingerprint: reservation.sourceFingerprint,
688
+ logUrl: correlated?.path ?? null,
689
+ exitCode: exitMatch ? Number(exitMatch[1]) : "unavailable",
690
+ }));
691
+ }
692
+ continue;
693
+ }
694
+ if (plan.action === "budget") {
695
+ if (!hasRepairBudgetComment(comments, identity, plan.marker.fingerprint)) {
696
+ await gh.commentPr(pr.number, formatRepairBudgetComment({
697
+ ...identity,
698
+ maxAttempts: REPAIR_ATTEMPTS,
699
+ sourceFingerprint: plan.marker.fingerprint,
700
+ blockingReviewUrls: plan.history.blockingPasses.map(({ url }) => url),
701
+ }));
702
+ }
703
+ continue;
704
+ }
705
+ if (plan.action !== "eligible") continue;
706
+ eligibleRepairs.push({
707
+ issue, pr, attempt: plan.attempt, sourceFingerprint: plan.sourceFingerprint,
708
+ blockingCreatedAt: plan.blockingCreatedAt,
709
+ });
710
+ } catch (err) {
711
+ log(`repair reconciliation error for #${issue.number} (leaving for next poll): ${err.message}`);
712
+ }
713
+ }
714
+
373
715
  // --- review reconciliation -------------------------------------------------
374
716
  const needsReview = [];
375
717
  for (const { issue, pr } of managed) {
376
718
  try {
719
+ if (liveRepairPairs.has(issue.number) || reconciledRepairPairs.has(issue.number)) continue;
377
720
  await mirrorLabels({ gh, issue, pr });
378
721
  if (phases.get(issue.number) !== REVIEW) continue;
379
722
 
380
723
  const checkedAt = now();
381
- const comments = await gh.listPrComments(pr.number);
724
+ const comments = await prComments(pr.number);
382
725
  const marker = selectApplicableMarker(comments, {
383
726
  issue: issue.number, pr: pr.number,
384
727
  head: pr.headRefOid, base: pr.baseRefOid,
@@ -403,10 +746,10 @@ export async function runOnce({
403
746
  }
404
747
 
405
748
  const ready = await gh.listReadyIssues();
406
- const wantsLaunch = ready.length > 0 || needsReview.length > 0;
749
+ const wantsLaunch = ready.length > 0 || needsReview.length > 0 || eligibleRepairs.length > 0;
407
750
  if (wantsLaunch && !await checkSetupReady()) {
408
751
  log("Agent setup incomplete — pausing worker and reviewer launches");
409
- return { done: false, implLive, reviewLive, started: 0, reviewsStarted: 0 };
752
+ return { done: false, implLive, reviewLive, started: 0, reviewsStarted: 0, repairsStarted: 0 };
410
753
  }
411
754
 
412
755
  // --- reviewer launches (oldest PR first) -----------------------------------
@@ -424,6 +767,23 @@ export async function runOnce({
424
767
  // eventually displace it and the relaunch itself explains nothing.
425
768
  const diagnostics = await logs.diagnostics("review", issue.number);
426
769
  if (diagnostics) {
770
+ // The reviewer log's attempt number is the durable launch count, so a
771
+ // reviewer that never gates its PR is bounded instead of relaunched
772
+ // every poll forever. A missing count (no log at all, or a helper that
773
+ // reports none) keeps the unbounded behaviour: logs are best effort and
774
+ // must never be what blocks an issue.
775
+ if (Number.isInteger(diagnostics.attempt) && diagnostics.attempt >= reviewerAttempts) {
776
+ await failClosed({
777
+ gh, issue, pr, log,
778
+ message: `The reviewer for #${issue.number} exited without gating PR #${pr.number}`
779
+ + ` after ${diagnostics.attempt} attempts. Its linked PR is ${pr.url}.`
780
+ + `\n\nReviewer log: \`${diagnostics.path}\`\n\n\`\`\`\n${diagnostics.tail}\n\`\`\``,
781
+ });
782
+ phases.set(issue.number, BLOCKED);
783
+ // The slot is deliberately not consumed: another PR can use it in
784
+ // this same poll.
785
+ continue;
786
+ }
427
787
  log(`A previous reviewer for #${issue.number} (${pr.url}) left ${diagnostics.path}:\n${diagnostics.tail}`);
428
788
  }
429
789
  await tmux.openReviewer(issue.number, pr.number);
@@ -435,6 +795,76 @@ export async function runOnce({
435
795
  }
436
796
  }
437
797
 
798
+ // --- repair launches (oldest blocking review first) -----------------------
799
+ eligibleRepairs.sort((a, b) =>
800
+ String(a.blockingCreatedAt ?? "").localeCompare(String(b.blockingCreatedAt ?? ""))
801
+ || a.issue.number - b.issue.number);
802
+ let repairsStarted = 0;
803
+ for (const repair of eligibleRepairs) {
804
+ if (implLive >= implementationSlots) break;
805
+ const { issue, pr, attempt, sourceFingerprint } = repair;
806
+ let currentIssue;
807
+ let currentPr;
808
+ try {
809
+ currentIssue = await gh.getIssue(issue.number);
810
+ currentPr = await gh.getPr(pr.number);
811
+ const comments = await gh.listPrComments(pr.number);
812
+ const freshPlan = planRepair({
813
+ issue: currentIssue, pr: currentPr, comments, phase: BLOCKED,
814
+ liveRepair: false, maxAttempts: REPAIR_ATTEMPTS,
815
+ });
816
+ if (freshPlan.action === "invalid-history") {
817
+ const identity = { issue: issue.number, pr: pr.number };
818
+ const fingerprint = freshPlan.latestReview?.fingerprint;
819
+ if (fingerprint && !hasInvalidRepairHistoryComment(comments, identity, fingerprint)) {
820
+ await gh.commentPr(pr.number, formatInvalidRepairHistoryComment({ ...identity, sourceFingerprint: fingerprint }));
821
+ }
822
+ log(`#${issue.number}: repair history became invalid during launch revalidation`);
823
+ continue;
824
+ }
825
+ const linked = currentPr?.closingIssuesReferences?.some(({ number }) => number === issue.number);
826
+ const valid = currentIssue?.number === issue.number && currentPr?.number === pr.number && linked
827
+ && freshPlan.action === "eligible" && freshPlan.attempt === attempt
828
+ && freshPlan.sourceFingerprint === sourceFingerprint;
829
+ if (!valid) {
830
+ if (freshPlan.action === "review"
831
+ && ["stale-review-snapshot", "reserved-head-changed"].includes(freshPlan.reason)
832
+ && currentIssue && currentPr
833
+ && exactPhase(currentIssue.labels, BLOCKED) && exactPhase(currentPr.labels, BLOCKED)) {
834
+ await setPhase({ gh, issue: currentIssue, pr: currentPr, phase: REVIEW });
835
+ phases.set(issue.number, REVIEW);
836
+ }
837
+ continue;
838
+ }
839
+ } catch (err) {
840
+ log(`failed to revalidate repair for #${issue.number} (leaving for next poll): ${err.message}`);
841
+ continue;
842
+ }
843
+ const logPath = await tmux.prepareRepair(issue.number);
844
+ try {
845
+ await gh.commentPr(pr.number, formatRepairAttemptComment({
846
+ issue: issue.number,
847
+ pr: pr.number,
848
+ attempt,
849
+ maxAttempts: REPAIR_ATTEMPTS,
850
+ sourceFingerprint,
851
+ head: currentPr.headRefOid,
852
+ log: logPath,
853
+ }));
854
+ } catch (err) {
855
+ log(`failed to reserve repair for #${issue.number} (leaving for next poll): ${err.message}`);
856
+ continue;
857
+ }
858
+ try {
859
+ await tmux.openRepair(issue.number, pr.number, sourceFingerprint, logPath);
860
+ implLive += 1;
861
+ repairsStarted += 1;
862
+ log(`Started repair ${attempt} for #${issue.number} (PR #${pr.number})`);
863
+ } catch (err) {
864
+ log(`failed to start repair for #${issue.number} (reservation remains for reconciliation): ${err.message}`);
865
+ }
866
+ }
867
+
438
868
  // --- implementation launches ----------------------------------------------
439
869
  let started = 0;
440
870
  for (const n of ready) {
@@ -460,8 +890,9 @@ export async function runOnce({
460
890
  // nothing is queued or live. Merge and cleanup monitoring belong to later
461
891
  // groups.
462
892
  const settled = [...phases.values()].every((phase) => phase === BLOCKED || phase === MERGE_REVIEW);
463
- const done = ready.length === 0 && implLive === 0 && reviewLive === 0 && settled;
464
- return { done, implLive, reviewLive, started, reviewsStarted };
893
+ const done = ready.length === 0 && eligibleRepairs.length === 0
894
+ && implLive === 0 && reviewLive === 0 && settled;
895
+ return { done, implLive, reviewLive, started, reviewsStarted, repairsStarted };
465
896
  }
466
897
 
467
898
  // ---- real-environment wiring ------------------------------------------------
@@ -518,15 +949,50 @@ export async function main({
518
949
  bootstrapLabels = bootstrapRepoLabels,
519
950
  resolveContainerId: resolveContainer = resolveOwnContainerId,
520
951
  createLeaseClient = createConfiguredLease,
521
- runPoll = runOnce,
952
+ runPoll = runImplementationOnlyOnce,
522
953
  checkSetupReady = isAgentSetupReady,
954
+ checkPrimaryClean = () => checkPrimaryWorktree({ exec }),
523
955
  sleepImpl = sleep,
524
956
  log = (message) => console.log(formatLogLine(message)),
957
+ stdout = process.stdout,
525
958
  } = {}) {
526
959
  const repo = await resolve(exec);
527
960
  const releaseOwnership = await acquireOwnership(repo);
528
961
  const lease = createLeaseClient();
529
962
  let containerId;
963
+ // Docker freezes this process rather than restarting it, so the last observed
964
+ // enforcement state survives the pause in memory — nothing needs persisting.
965
+ let enforcementState = "running";
966
+
967
+ // Resolves only once the line has actually left the process. Workers run under
968
+ // `tmux … | tee`, where stdout is a pipe and `console.log` does not block, so
969
+ // an unflushed pause message could lose the race against Docker.
970
+ function logFlush(message) {
971
+ return new Promise((resolve, reject) => {
972
+ stdout.write(`${formatLogLine(message)}\n`, (err) => (err ? reject(err) : resolve()));
973
+ });
974
+ }
975
+
976
+ // Announce a transition, flush it, then acknowledge — in that order. The
977
+ // acknowledgment lets Sentinel pause this very container, so an unflushed
978
+ // message would race Docker and could be lost. A failed acknowledgment is
979
+ // visible but not fatal: Sentinel pauses anyway once its timeout elapses, so
980
+ // failing here would cost availability without adding enforcement.
981
+ //
982
+ // The parameter default covers a client that predates the enforcement field;
983
+ // the current one always parses and returns it.
984
+ async function notifyEnforcement(enforcement = { state: "running" }) {
985
+ const transition = describeEnforcementTransition(enforcementState, enforcement);
986
+ enforcementState = transition.state;
987
+ if (transition.message) await logFlush(transition.message);
988
+ if (!transition.acknowledge) return;
989
+ try {
990
+ await lease.acknowledge(containerId);
991
+ } catch (err) {
992
+ log(`pause acknowledgment failed: ${err.message}`);
993
+ }
994
+ }
995
+
530
996
  try {
531
997
  // One helper for the whole run: the launcher that reserves an attempt and
532
998
  // the poll that later reads it must agree on where the logs live.
@@ -556,8 +1022,9 @@ export async function main({
556
1022
  // silently running unenforced.
557
1023
  try {
558
1024
  containerId = await resolveContainer();
559
- const { status } = await lease.register(containerId);
1025
+ const { status, enforcement } = await lease.register(containerId);
560
1026
  log(`Managed container lease ${status} for ${containerId}`);
1027
+ await notifyEnforcement(enforcement);
561
1028
  } catch (err) {
562
1029
  throw new Error(`managed-container registration failed: ${err.message}`);
563
1030
  }
@@ -569,7 +1036,8 @@ export async function main({
569
1036
  for (let firstPoll = true; ; firstPoll = false) {
570
1037
  if (!firstPoll) {
571
1038
  try {
572
- await lease.register(containerId);
1039
+ const { enforcement } = await lease.register(containerId);
1040
+ await notifyEnforcement(enforcement);
573
1041
  } catch (err) {
574
1042
  log(`lease heartbeat failed: ${err.message}`);
575
1043
  }
@@ -579,7 +1047,7 @@ export async function main({
579
1047
  const token = await mint(repo);
580
1048
  const gh = createGitHub({ exec: authExec(token), repo });
581
1049
  result = await runPoll({
582
- gh, tmux, checkSetupReady,
1050
+ gh, tmux, checkSetupReady, checkPrimaryClean,
583
1051
  implementationSlots: IMPLEMENTATION_SLOTS,
584
1052
  reviewerSlots: REVIEWER_SLOTS,
585
1053
  log, logs,