@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.
@@ -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
+ }