@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.
- package/README.md +99 -215
- package/bin/supervisor.mjs +497 -29
- package/package.json +1 -1
- package/src/enforcementNotice.mjs +36 -0
- package/src/managedContainer.mjs +51 -4
- package/src/repairGate.mjs +57 -0
- package/src/repairMarker.mjs +248 -0
- package/src/reviewGate.mjs +18 -8
- package/src/workerLogs.mjs +14 -10
package/bin/supervisor.mjs
CHANGED
|
@@ -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
|
|
24
|
-
// borrowed
|
|
25
|
-
//
|
|
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
|
|
165
|
-
//
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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-
|
|
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-
|
|
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 {
|
|
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
|
|
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 &&
|
|
464
|
-
|
|
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 =
|
|
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,
|