@nickysagan/issue-orchestrator 0.1.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 +314 -0
- package/bin/supervisor.mjs +614 -0
- package/package.json +19 -0
- package/src/labels.mjs +68 -0
- package/src/managedContainer.mjs +120 -0
- package/src/reviewGate.mjs +308 -0
- package/src/reviewMarker.mjs +80 -0
- package/src/workerLogs.mjs +175 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { createServer as createNetServer } from "node:net";
|
|
5
|
+
import { access, realpath } from "node:fs/promises";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { createManagedContainerClient, resolveContainerId as resolveOwnContainerId } from "../src/managedContainer.mjs";
|
|
9
|
+
import { BLOCKED, MERGE_REVIEW, READY, REVIEW, RUNNING, phaseOf } from "../src/labels.mjs";
|
|
10
|
+
import { ensureLabels } from "../src/labels.mjs";
|
|
11
|
+
import { selectApplicableMarker } from "../src/reviewMarker.mjs";
|
|
12
|
+
import { createWorkerLogs } from "../src/workerLogs.mjs";
|
|
13
|
+
import {
|
|
14
|
+
applyReviewPlan, classifyChecks, failClosed, mirrorLabels, planVerdict, resolveManagedPrs,
|
|
15
|
+
} from "../src/reviewGate.mjs";
|
|
16
|
+
|
|
17
|
+
const run = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
// ---- Configuration ----------------------------------------------------------
|
|
20
|
+
// Fixed defaults per the issue's simplicity constraints. Only the Sentinel URL
|
|
21
|
+
// and the poll interval are environment-tunable.
|
|
22
|
+
//
|
|
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.
|
|
26
|
+
const IMPLEMENTATION_SLOTS = 2;
|
|
27
|
+
const REVIEWER_SLOTS = 1;
|
|
28
|
+
const POLL_MS = Number(process.env.POLL_MS || 60000);
|
|
29
|
+
const TOKEN_SCRIPT = process.env.GH_APP_TOKEN_SCRIPT || "/opt/agent-devcontainer/gh-app-token.sh";
|
|
30
|
+
const SESSION = "orchestrator";
|
|
31
|
+
const AGENT_SETUP_MARKER = "/run/agent-devcontainer/agent-setup-complete";
|
|
32
|
+
|
|
33
|
+
export async function isAgentSetupReady({ accessImpl = access } = {}) {
|
|
34
|
+
try {
|
|
35
|
+
await accessImpl(AGENT_SETUP_MARKER);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function acquireSupervisorOwnership(repo, {
|
|
43
|
+
createServer = createNetServer,
|
|
44
|
+
} = {}) {
|
|
45
|
+
const key = createHash("sha256").update(repo).digest("hex");
|
|
46
|
+
// Linux abstract Unix sockets are kernel-owned: bind is atomic, creates no
|
|
47
|
+
// filesystem state, and is automatically released when the process dies.
|
|
48
|
+
// The installed CLI runs in the Linux agent-devcontainer targeted by this repo.
|
|
49
|
+
const address = `\0issue-orchestrator-${key}`;
|
|
50
|
+
const server = createServer((socket) => socket.destroy());
|
|
51
|
+
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
function onError(error) {
|
|
54
|
+
server.off("listening", onListening);
|
|
55
|
+
if (error.code === "EADDRINUSE") {
|
|
56
|
+
reject(new Error(`supervisor already running for ${repo}`));
|
|
57
|
+
} else {
|
|
58
|
+
reject(error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function onListening() {
|
|
62
|
+
server.off("error", onError);
|
|
63
|
+
resolve();
|
|
64
|
+
}
|
|
65
|
+
server.once("error", onError);
|
|
66
|
+
server.once("listening", onListening);
|
|
67
|
+
server.listen(address);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let releasePromise;
|
|
71
|
+
return async function release() {
|
|
72
|
+
if (!releasePromise) {
|
|
73
|
+
releasePromise = new Promise((resolve, reject) => {
|
|
74
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return releasePromise;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Sentinel's managed-container lease: it records where an active orchestrator
|
|
82
|
+
// runs so Sentinel can pause that container at the usage thresholds. It carries
|
|
83
|
+
// no admission decision and never gates a start.
|
|
84
|
+
export function createConfiguredLease({ env = process.env, fetchImpl = fetch } = {}) {
|
|
85
|
+
const url = env.SENTINEL_URL || "http://usage-sentinel:4317";
|
|
86
|
+
return createManagedContainerClient({ url, fetchImpl });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- exec -------------------------------------------------------------------
|
|
90
|
+
// Returns an `exec(file, args) => Promise<{stdout, stderr}>` bound to a fixed
|
|
91
|
+
// environment. Injected into the adapters so tests never spawn real processes.
|
|
92
|
+
export function makeExec(env = process.env) {
|
|
93
|
+
return async function exec(file, args) {
|
|
94
|
+
const { stdout, stderr } = await run(file, args, { env, maxBuffer: 10 * 1024 * 1024 });
|
|
95
|
+
return { stdout, stderr };
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- GitHub adapter ---------------------------------------------------------
|
|
100
|
+
const PR_FIELDS = [
|
|
101
|
+
"number", "headRefName", "headRefOid", "baseRefOid", "body", "isDraft", "url",
|
|
102
|
+
"updatedAt", "createdAt", "labels", "closingIssuesReferences", "statusCheckRollup",
|
|
103
|
+
].join(",");
|
|
104
|
+
|
|
105
|
+
export function createGitHub({ exec, repo }) {
|
|
106
|
+
const R = ["--repo", repo];
|
|
107
|
+
const names = (labels) => (Array.isArray(labels) ? labels.map((l) => l.name) : []);
|
|
108
|
+
|
|
109
|
+
async function listReadyIssues() {
|
|
110
|
+
const { stdout } = await exec("gh", [
|
|
111
|
+
"issue", "list", ...R, "--label", READY, "--state", "open",
|
|
112
|
+
"--json", "number", "--limit", "100",
|
|
113
|
+
]);
|
|
114
|
+
return JSON.parse(stdout || "[]").map((i) => i.number).sort((a, b) => a - b);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Labels come back with the issue so phase state costs no extra call; the
|
|
118
|
+
// body rides along too, because it is a review-marker fingerprint input.
|
|
119
|
+
async function listRunningIssues() {
|
|
120
|
+
const { stdout } = await exec("gh", [
|
|
121
|
+
"issue", "list", ...R, "--label", RUNNING, "--state", "open",
|
|
122
|
+
"--json", "number,labels,updatedAt,body", "--limit", "100",
|
|
123
|
+
]);
|
|
124
|
+
return JSON.parse(stdout || "[]")
|
|
125
|
+
.map((i) => ({ number: i.number, labels: names(i.labels), updatedAt: i.updatedAt, body: i.body }))
|
|
126
|
+
.sort((a, b) => a.number - b.number);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// One call per poll carries everything the review gate needs: the marker
|
|
130
|
+
// fingerprint inputs (SHAs and body), mirror labels, and the check rollup.
|
|
131
|
+
async function listOpenPrs() {
|
|
132
|
+
const { stdout } = await exec("gh", [
|
|
133
|
+
"pr", "list", ...R, "--state", "open", "--json", PR_FIELDS, "--limit", "100",
|
|
134
|
+
]);
|
|
135
|
+
return JSON.parse(stdout || "[]").map((pr) => ({ ...pr, labels: names(pr.labels) }));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function listPrComments(number) {
|
|
139
|
+
const { stdout } = await exec("gh", ["pr", "view", String(number), ...R, "--json", "comments"]);
|
|
140
|
+
return JSON.parse(stdout || "{}").comments || [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function labelArgs({ add = [], remove = [] }) {
|
|
144
|
+
const args = [];
|
|
145
|
+
for (const name of add) args.push("--add-label", name);
|
|
146
|
+
for (const name of remove) args.push("--remove-label", name);
|
|
147
|
+
return args;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function setIssueLabels(number, delta) {
|
|
151
|
+
const args = labelArgs(delta);
|
|
152
|
+
if (args.length === 0) return Promise.resolve();
|
|
153
|
+
return exec("gh", ["issue", "edit", String(number), ...R, ...args]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setPrLabels(number, delta) {
|
|
157
|
+
const args = labelArgs(delta);
|
|
158
|
+
if (args.length === 0) return Promise.resolve();
|
|
159
|
+
return exec("gh", ["pr", "edit", String(number), ...R, ...args]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const claim = (n) => setIssueLabels(n, { add: [RUNNING], remove: [READY] });
|
|
163
|
+
// 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.
|
|
166
|
+
const restore = (n) => setIssueLabels(n, { add: [READY], remove: [RUNNING] });
|
|
167
|
+
|
|
168
|
+
const markPrReady = (n) => exec("gh", ["pr", "ready", String(n), ...R]);
|
|
169
|
+
const markPrDraft = (n) => exec("gh", ["pr", "ready", String(n), ...R, "--undo"]);
|
|
170
|
+
|
|
171
|
+
const commentIssue = (n, body) => exec("gh", ["issue", "comment", String(n), ...R, "--body", body]);
|
|
172
|
+
const commentPr = (n, body) => exec("gh", ["pr", "comment", String(n), ...R, "--body", body]);
|
|
173
|
+
|
|
174
|
+
async function listLabels() {
|
|
175
|
+
const { stdout } = await exec("gh", ["label", "list", ...R, "--json", "name", "--limit", "200"]);
|
|
176
|
+
return JSON.parse(stdout || "[]");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const createLabel = ({ name, color, description }) =>
|
|
180
|
+
exec("gh", ["label", "create", name, ...R, "--color", color, "--description", description]);
|
|
181
|
+
|
|
182
|
+
// Deliberately no approve and no merge: the supervisor never does either.
|
|
183
|
+
return {
|
|
184
|
+
listReadyIssues, listRunningIssues, listOpenPrs, listPrComments,
|
|
185
|
+
claim, restore, setIssueLabels, setPrLabels, markPrReady, markPrDraft,
|
|
186
|
+
commentIssue, commentPr, listLabels, createLabel,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- tmux adapter -----------------------------------------------------------
|
|
191
|
+
// Worker logs are a diagnostic, never a dependency: a caller that wires none
|
|
192
|
+
// gets exactly the behaviour that existed before they did.
|
|
193
|
+
const NO_WORKER_LOGS = { prepare: async () => null, diagnostics: async () => null };
|
|
194
|
+
|
|
195
|
+
export function createTmux({ exec, session = SESSION, logs = NO_WORKER_LOGS }) {
|
|
196
|
+
// The window command is run by tmux via `/bin/sh -c`, so the issue number
|
|
197
|
+
// must never carry shell metacharacters. It is always a GitHub issue number,
|
|
198
|
+
// but validate anyway so interpolation can never become an injection vector.
|
|
199
|
+
function issueNumber(number) {
|
|
200
|
+
const n = Number(number);
|
|
201
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
202
|
+
throw new Error(`invalid issue number: ${number}`);
|
|
203
|
+
}
|
|
204
|
+
return n;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// tmux passes its command to /bin/sh, which then invokes interactive bash so
|
|
208
|
+
// the subscription-authenticated `ccode` alias is available. Quote every
|
|
209
|
+
// model-derived or recorded value before it crosses either shell boundary.
|
|
210
|
+
function shellQuote(value) {
|
|
211
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function ensureSession() {
|
|
215
|
+
try {
|
|
216
|
+
await exec("tmux", ["has-session", "-t", session]);
|
|
217
|
+
} catch {
|
|
218
|
+
await exec("tmux", ["new-session", "-d", "-s", session, "-n", "supervisor"]);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function listWindows(prefix) {
|
|
223
|
+
const { stdout } = await exec("tmux", ["list-windows", "-t", session, "-F", "#{window_name}"]);
|
|
224
|
+
const pattern = new RegExp(`^${prefix}-(\\d+)$`);
|
|
225
|
+
const set = new Set();
|
|
226
|
+
for (const line of stdout.split("\n")) {
|
|
227
|
+
const m = line.trim().match(pattern);
|
|
228
|
+
if (m) set.add(Number(m[1]));
|
|
229
|
+
}
|
|
230
|
+
return set;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const listWorkerIssues = () => listWindows("issue");
|
|
234
|
+
// Reviewer windows are a separate namespace so the reserved reviewer slot can
|
|
235
|
+
// never be consumed by — or borrowed for — implementation.
|
|
236
|
+
const listReviewIssues = () => listWindows("review");
|
|
237
|
+
|
|
238
|
+
// Callers validate their numbers before calling this, so an invalid number
|
|
239
|
+
// still throws synchronously rather than rejecting a returned promise.
|
|
240
|
+
// `tee` sees the pane's combined output, which is the whole point of the
|
|
241
|
+
// log; an unreserved log leaves the command byte-identical to a plain launch.
|
|
242
|
+
async function newWindow(role, number, name, command) {
|
|
243
|
+
const path = await logs.prepare(role, number);
|
|
244
|
+
const cmd = `bash -ic ${shellQuote(command)}${path ? ` 2>&1 | tee ${shellQuote(path)}` : ""}`;
|
|
245
|
+
return exec("tmux", ["new-window", "-t", session, "-n", name, cmd]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function openWorker(number) {
|
|
249
|
+
const n = issueNumber(number);
|
|
250
|
+
// `ccode` is a bash alias (setup-agents.sh), not a binary — tmux runs the
|
|
251
|
+
// window command via a non-interactive shell that never sources .bashrc,
|
|
252
|
+
// so the alias would silently fail to resolve without `bash -ic`, which
|
|
253
|
+
// forces alias expansion regardless of login/interactive invocation.
|
|
254
|
+
const command = `ccode --print --permission-mode auto --model claude-opus-4-8 "/github-issue ${n}"`;
|
|
255
|
+
return newWindow("issue", n, `issue-${n}`, command);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The reviewer is a read-only pass over an existing PR; the window is named
|
|
259
|
+
// for the issue so capacity accounting lines up with the managed issue set.
|
|
260
|
+
function openReviewer(number, prNumber) {
|
|
261
|
+
const i = issueNumber(number);
|
|
262
|
+
const p = issueNumber(prNumber);
|
|
263
|
+
const command = `ccode --print --permission-mode auto --model claude-opus-4-8 "/review-pr ${p}"`;
|
|
264
|
+
return newWindow("review", i, `review-${i}`, command);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Suppress ONLY a confirmed "window absent" failure (idempotent close). Any
|
|
268
|
+
// other tmux failure (e.g. server down) propagates so the caller does not
|
|
269
|
+
// wrongly free the slot for a window that may still be alive.
|
|
270
|
+
async function closeWorker(number) {
|
|
271
|
+
const n = issueNumber(number);
|
|
272
|
+
try {
|
|
273
|
+
await exec("tmux", ["kill-window", "-t", `${session}:issue-${n}`]);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
const msg = `${err.stderr || ""} ${err.message || ""}`;
|
|
276
|
+
if (/can.?t find window|no such window|window not found/i.test(msg)) return;
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { ensureSession, listWorkerIssues, listReviewIssues, openWorker, openReviewer, closeWorker };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---- one poll cycle ---------------------------------------------------------
|
|
285
|
+
// Reconciles managed issues, gates reviewed PRs, then fills free slots. Every
|
|
286
|
+
// per-issue action is isolated in its own try/catch so one failure never skips
|
|
287
|
+
// the rest of the poll; only a tmux inspection failure aborts before any
|
|
288
|
+
// mutation, because capacity would otherwise be unknown. Usage is not consulted
|
|
289
|
+
// here: Sentinel enforces limits by pausing this whole container, so no worker
|
|
290
|
+
// is ever killed for usage reasons.
|
|
291
|
+
export async function runOnce({
|
|
292
|
+
gh, tmux,
|
|
293
|
+
checkSetupReady = async () => true,
|
|
294
|
+
implementationSlots = IMPLEMENTATION_SLOTS,
|
|
295
|
+
reviewerSlots = REVIEWER_SLOTS,
|
|
296
|
+
log,
|
|
297
|
+
logs = NO_WORKER_LOGS,
|
|
298
|
+
runningIssues,
|
|
299
|
+
now = Date.now,
|
|
300
|
+
}) {
|
|
301
|
+
await tmux.ensureSession();
|
|
302
|
+
const running = runningIssues !== undefined ? runningIssues : await gh.listRunningIssues();
|
|
303
|
+
const liveImpl = await tmux.listWorkerIssues();
|
|
304
|
+
const liveReview = await tmux.listReviewIssues();
|
|
305
|
+
const prs = await gh.listOpenPrs();
|
|
306
|
+
|
|
307
|
+
let implLive = liveImpl.size;
|
|
308
|
+
let reviewLive = liveReview.size;
|
|
309
|
+
|
|
310
|
+
// Phase state as this poll last knew it, updated in place by each transition
|
|
311
|
+
// so the exit condition below reflects the work just done.
|
|
312
|
+
const phases = new Map(running.map((issue) => [issue.number, phaseOf(issue.labels)]));
|
|
313
|
+
const { managed, problems } = resolveManagedPrs(running, prs);
|
|
314
|
+
|
|
315
|
+
// --- linkage ambiguity fails closed ---------------------------------------
|
|
316
|
+
for (const problem of problems) {
|
|
317
|
+
try {
|
|
318
|
+
await failClosed({ gh, issue: problem.issue, pr: problem.pr, message: problem.message, log });
|
|
319
|
+
phases.set(problem.issue.number, BLOCKED);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
log(`linkage repair failed for #${problem.issue.number} (leaving for next poll): ${err.message}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- implementation reconciliation ----------------------------------------
|
|
326
|
+
// `agent-running` is durable, so completion is "the issue reached a phase",
|
|
327
|
+
// never "the label went away".
|
|
328
|
+
for (const issue of running) {
|
|
329
|
+
try {
|
|
330
|
+
const phase = phases.get(issue.number);
|
|
331
|
+
if (phase) {
|
|
332
|
+
if (liveImpl.has(issue.number)) {
|
|
333
|
+
await tmux.closeWorker(issue.number);
|
|
334
|
+
liveImpl.delete(issue.number);
|
|
335
|
+
implLive -= 1;
|
|
336
|
+
log(`Implementation for #${issue.number} finished (${phase}) — slot freed`);
|
|
337
|
+
}
|
|
338
|
+
} else if (!liveImpl.has(issue.number)) {
|
|
339
|
+
// Window gone with no phase reached: the workflow died. Group 1 has no
|
|
340
|
+
// repair worker, so this fails closed rather than silently stranding
|
|
341
|
+
// the issue outside the lifecycle's terminal states.
|
|
342
|
+
const match = managed.find((m) => m.issue.number === issue.number);
|
|
343
|
+
const pr = match ? match.pr : null;
|
|
344
|
+
// The pane took its output with it, so the reserved log is the only
|
|
345
|
+
// surviving evidence of why the worker died. It belongs in the comment
|
|
346
|
+
// that blocks the issue, where whoever picks it up looks first.
|
|
347
|
+
const diagnostics = await logs.diagnostics("issue", issue.number);
|
|
348
|
+
let message = `The implementation worker for #${issue.number} exited before reaching a review phase.`;
|
|
349
|
+
if (diagnostics) {
|
|
350
|
+
message += `${pr ? ` Its linked PR is ${pr.url}.` : ""}`
|
|
351
|
+
+ `\n\nWorker log: \`${diagnostics.path}\`\n\n\`\`\`\n${diagnostics.tail}\n\`\`\``;
|
|
352
|
+
}
|
|
353
|
+
await failClosed({ gh, issue, pr, message, log });
|
|
354
|
+
phases.set(issue.number, BLOCKED);
|
|
355
|
+
}
|
|
356
|
+
} catch (err) {
|
|
357
|
+
log(`reconcile error for #${issue.number} (leaving for next poll): ${err.message}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// --- review reconciliation -------------------------------------------------
|
|
362
|
+
const needsReview = [];
|
|
363
|
+
for (const { issue, pr } of managed) {
|
|
364
|
+
try {
|
|
365
|
+
await mirrorLabels({ gh, issue, pr });
|
|
366
|
+
if (phases.get(issue.number) !== REVIEW) continue;
|
|
367
|
+
|
|
368
|
+
const checkedAt = now();
|
|
369
|
+
const comments = await gh.listPrComments(pr.number);
|
|
370
|
+
const marker = selectApplicableMarker(comments, {
|
|
371
|
+
issue: issue.number, pr: pr.number,
|
|
372
|
+
head: pr.headRefOid, base: pr.baseRefOid,
|
|
373
|
+
issueBody: issue.body, prBody: pr.body,
|
|
374
|
+
});
|
|
375
|
+
const plan = planVerdict({
|
|
376
|
+
marker,
|
|
377
|
+
checks: classifyChecks(pr.statusCheckRollup, checkedAt),
|
|
378
|
+
isDraft: pr.isDraft,
|
|
379
|
+
});
|
|
380
|
+
if (plan.action === "review") {
|
|
381
|
+
needsReview.push({ issue, pr });
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const { phase } = await applyReviewPlan({
|
|
385
|
+
gh, issue, pr, plan, rollup: pr.statusCheckRollup, now: checkedAt, log,
|
|
386
|
+
});
|
|
387
|
+
phases.set(issue.number, phase);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
log(`review error for #${issue.number} (leaving for next poll): ${err.message}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const ready = await gh.listReadyIssues();
|
|
394
|
+
const wantsLaunch = ready.length > 0 || needsReview.length > 0;
|
|
395
|
+
if (wantsLaunch && !await checkSetupReady()) {
|
|
396
|
+
log("Agent setup incomplete — pausing worker and reviewer launches");
|
|
397
|
+
return { done: false, implLive, reviewLive, started: 0, reviewsStarted: 0 };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --- reviewer launches (oldest PR first) -----------------------------------
|
|
401
|
+
needsReview.sort((a, b) =>
|
|
402
|
+
String(a.pr.createdAt).localeCompare(String(b.pr.createdAt)) || a.issue.number - b.issue.number);
|
|
403
|
+
let reviewsStarted = 0;
|
|
404
|
+
for (const { issue, pr } of needsReview) {
|
|
405
|
+
if (reviewLive >= reviewerSlots) break;
|
|
406
|
+
// A reviewer already running for this issue holds the slot; it is counted
|
|
407
|
+
// in `reviewLive` and must not be relaunched.
|
|
408
|
+
if (liveReview.has(issue.number)) continue;
|
|
409
|
+
try {
|
|
410
|
+
// Reaching a launch here means an earlier reviewer left without gating
|
|
411
|
+
// the PR. Surface its log before the relaunch, because retention will
|
|
412
|
+
// eventually displace it and the relaunch itself explains nothing.
|
|
413
|
+
const diagnostics = await logs.diagnostics("review", issue.number);
|
|
414
|
+
if (diagnostics) {
|
|
415
|
+
log(`A previous reviewer for #${issue.number} (${pr.url}) left ${diagnostics.path}:\n${diagnostics.tail}`);
|
|
416
|
+
}
|
|
417
|
+
await tmux.openReviewer(issue.number, pr.number);
|
|
418
|
+
reviewLive += 1;
|
|
419
|
+
reviewsStarted += 1;
|
|
420
|
+
log(`Started reviewer for #${issue.number} (PR #${pr.number})`);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
log(`failed to start reviewer for #${issue.number} (leaving for next poll): ${err.message}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// --- implementation launches ----------------------------------------------
|
|
427
|
+
let started = 0;
|
|
428
|
+
for (const n of ready) {
|
|
429
|
+
if (implLive >= implementationSlots) break;
|
|
430
|
+
let claimed = false;
|
|
431
|
+
try {
|
|
432
|
+
await gh.claim(n);
|
|
433
|
+
claimed = true;
|
|
434
|
+
await tmux.openWorker(n);
|
|
435
|
+
implLive += 1;
|
|
436
|
+
started += 1;
|
|
437
|
+
log(`Started worker for #${n}`);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
const cleanup = [];
|
|
440
|
+
if (claimed) {
|
|
441
|
+
try { await gh.restore(n); } catch (cleanupError) { cleanup.push(`restore: ${cleanupError.message}`); }
|
|
442
|
+
}
|
|
443
|
+
log(`failed to start #${n} (leaving for next poll): ${err.message}${cleanup.length ? `; cleanup failed: ${cleanup.join(", ")}` : ""}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Group 1 may exit once every managed issue rests in a terminal phase and
|
|
448
|
+
// nothing is queued or live. Merge and cleanup monitoring belong to later
|
|
449
|
+
// groups.
|
|
450
|
+
const settled = [...phases.values()].every((phase) => phase === BLOCKED || phase === MERGE_REVIEW);
|
|
451
|
+
const done = ready.length === 0 && implLive === 0 && reviewLive === 0 && settled;
|
|
452
|
+
return { done, implLive, reviewLive, started, reviewsStarted };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---- real-environment wiring ------------------------------------------------
|
|
456
|
+
function sleep(ms) {
|
|
457
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function resolveRepo(exec) {
|
|
461
|
+
const { stdout } = await exec("git", ["remote", "get-url", "origin"]);
|
|
462
|
+
const m = stdout.trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
463
|
+
if (!m) throw new Error(`cannot parse repo from remote: ${stdout.trim()}`);
|
|
464
|
+
return m[1];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Mint a short-lived GitHub App installation token (cached & re-minted by the
|
|
468
|
+
// helper). `gh` does not auto-consume the App credential, so it is injected as
|
|
469
|
+
// GH_TOKEN into the exec environment for every gh call.
|
|
470
|
+
async function mintToken(repo) {
|
|
471
|
+
const { stdout } = await run(TOKEN_SCRIPT, [], {
|
|
472
|
+
env: { ...process.env, GITHUB_APP_REPO: repo },
|
|
473
|
+
maxBuffer: 1 << 20,
|
|
474
|
+
});
|
|
475
|
+
const token = stdout.trim();
|
|
476
|
+
if (!token) throw new Error("gh-app-token helper returned an empty token");
|
|
477
|
+
return token;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function ghExec(token) {
|
|
481
|
+
return makeExec({ ...process.env, GH_TOKEN: token });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Prefix an orchestrator log line with an ISO 8601 UTC timestamp so lines can
|
|
485
|
+
// be placed in time (and counted in poll cycles) during live diagnosis. `now`
|
|
486
|
+
// is injectable purely so the format can be asserted deterministically.
|
|
487
|
+
export function formatLogLine(message, now = new Date()) {
|
|
488
|
+
return `[orchestrator ${now.toISOString()}] ${message}`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Label bootstrap runs at startup against the resolved repository, never at
|
|
492
|
+
// Docker build time — the image has neither a target repository nor runtime
|
|
493
|
+
// credentials.
|
|
494
|
+
async function bootstrapRepoLabels({ gh, log }) {
|
|
495
|
+
await ensureLabels({ listLabels: gh.listLabels, createLabel: gh.createLabel, log });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function main({
|
|
499
|
+
exec = makeExec(),
|
|
500
|
+
resolveRepo: resolve = resolveRepo,
|
|
501
|
+
acquireOwnership = acquireSupervisorOwnership,
|
|
502
|
+
mintToken: mint = mintToken,
|
|
503
|
+
authExec = ghExec,
|
|
504
|
+
createTmux: tmuxFactory = createTmux,
|
|
505
|
+
createWorkerLogs: workerLogsFactory = createWorkerLogs,
|
|
506
|
+
bootstrapLabels = bootstrapRepoLabels,
|
|
507
|
+
resolveContainerId: resolveContainer = resolveOwnContainerId,
|
|
508
|
+
createLeaseClient = createConfiguredLease,
|
|
509
|
+
runPoll = runOnce,
|
|
510
|
+
checkSetupReady = isAgentSetupReady,
|
|
511
|
+
sleepImpl = sleep,
|
|
512
|
+
log = (message) => console.log(formatLogLine(message)),
|
|
513
|
+
} = {}) {
|
|
514
|
+
const repo = await resolve(exec);
|
|
515
|
+
const releaseOwnership = await acquireOwnership(repo);
|
|
516
|
+
const lease = createLeaseClient();
|
|
517
|
+
let containerId;
|
|
518
|
+
try {
|
|
519
|
+
// One helper for the whole run: the launcher that reserves an attempt and
|
|
520
|
+
// the poll that later reads it must agree on where the logs live.
|
|
521
|
+
const logs = workerLogsFactory({ exec, log });
|
|
522
|
+
const tmux = tmuxFactory({ exec, logs });
|
|
523
|
+
|
|
524
|
+
// Startup auth smoke test — fail fast with a clear message rather than
|
|
525
|
+
// silently churning on unauthenticated gh calls every poll.
|
|
526
|
+
try {
|
|
527
|
+
const token = await mint(repo);
|
|
528
|
+
await authExec(token)("gh", ["repo", "view", repo, "--json", "nameWithOwner", "-q", ".nameWithOwner"]);
|
|
529
|
+
log(`Authenticated as GitHub App for ${repo}`);
|
|
530
|
+
} catch (err) {
|
|
531
|
+
throw new Error(`GitHub App auth check failed: ${err.message}`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Idempotent: creates only missing labels and never edits an existing one.
|
|
535
|
+
// A permission or authentication failure here is fatal.
|
|
536
|
+
{
|
|
537
|
+
const token = await mint(repo);
|
|
538
|
+
await bootstrapLabels({ gh: createGitHub({ exec: authExec(token), repo }), log });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Register the container Sentinel may pause. Registration is a lease, not
|
|
542
|
+
// an admission decision — but without it nothing enforces the usage
|
|
543
|
+
// thresholds, so a local or transport failure here is fatal rather than
|
|
544
|
+
// silently running unenforced.
|
|
545
|
+
try {
|
|
546
|
+
containerId = await resolveContainer();
|
|
547
|
+
const { status } = await lease.register(containerId);
|
|
548
|
+
log(`Managed container lease ${status} for ${containerId}`);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
throw new Error(`managed-container registration failed: ${err.message}`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// The startup registration above is the first poll's refresh; every later
|
|
554
|
+
// poll re-PUTs the same ID, well inside the five-minute lease. A failed PUT
|
|
555
|
+
// is retried by the next poll; Sentinel's lease rules handle a container
|
|
556
|
+
// that really died.
|
|
557
|
+
for (let firstPoll = true; ; firstPoll = false) {
|
|
558
|
+
if (!firstPoll) {
|
|
559
|
+
try {
|
|
560
|
+
await lease.register(containerId);
|
|
561
|
+
} catch (err) {
|
|
562
|
+
log(`lease heartbeat failed: ${err.message}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
let result;
|
|
566
|
+
try {
|
|
567
|
+
const token = await mint(repo);
|
|
568
|
+
const gh = createGitHub({ exec: authExec(token), repo });
|
|
569
|
+
result = await runPoll({
|
|
570
|
+
gh, tmux, checkSetupReady,
|
|
571
|
+
implementationSlots: IMPLEMENTATION_SLOTS,
|
|
572
|
+
reviewerSlots: REVIEWER_SLOTS,
|
|
573
|
+
log, logs,
|
|
574
|
+
});
|
|
575
|
+
} catch (err) {
|
|
576
|
+
log(`poll error (continuing): ${err.message}`);
|
|
577
|
+
await sleepImpl(POLL_MS);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (result.done) {
|
|
581
|
+
log("Queue empty and no live workers — stopping.");
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
await sleepImpl(POLL_MS);
|
|
585
|
+
}
|
|
586
|
+
} finally {
|
|
587
|
+
// Best-effort only: correctness never depends on a clean exit, because
|
|
588
|
+
// Sentinel expires a stale running lease on its own.
|
|
589
|
+
if (containerId) {
|
|
590
|
+
try {
|
|
591
|
+
await lease.unregister(containerId);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
log(`lease unregister failed (Sentinel lease expiry recovers this): ${err.message}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
await releaseOwnership();
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function isDirectEntry() {
|
|
601
|
+
if (!process.argv[1]) return false;
|
|
602
|
+
const [modulePath, entryPath] = await Promise.all([
|
|
603
|
+
realpath(fileURLToPath(import.meta.url)),
|
|
604
|
+
realpath(process.argv[1]),
|
|
605
|
+
]);
|
|
606
|
+
return modulePath === entryPath;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (await isDirectEntry()) {
|
|
610
|
+
main().catch((err) => {
|
|
611
|
+
console.error(formatLogLine(`FATAL: ${err.message}`));
|
|
612
|
+
process.exitCode = 1;
|
|
613
|
+
});
|
|
614
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nickysagan/issue-orchestrator",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A small, supervised GitHub issue queue that keeps autonomous coding workers alive.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Sadotu/issue-orchestrator.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": { "issue-orchestrator": "bin/supervisor.mjs" },
|
|
12
|
+
"files": ["bin", "src"],
|
|
13
|
+
"publishConfig": { "registry": "https://registry.npmjs.org", "access": "public" },
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test 'test/**/*.test.mjs'",
|
|
16
|
+
"start": "node bin/supervisor.mjs"
|
|
17
|
+
},
|
|
18
|
+
"engines": { "node": ">=20.8.0" }
|
|
19
|
+
}
|