forgedock 1.32.0 → 1.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -309,19 +309,40 @@ async function readIssueRecord(n, io) {
309
309
  }
310
310
 
311
311
  // A declaration line: optional list marker, optional bold, "Depends on" or "Blocked by" (any case), optional colon
312
- // inside or after the bold, then one or more `#N` separated by commas, "and", "&" or spaces (#4121).
313
- const DEPENDS_ON_RE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?(?:depends on|blocked by)[ \t]*:?(?:\*\*|__)?[ \t]*:?[ \t]*(#\d+\b(?:[ \t]*(?:,|&|\band\b)?[ \t]*#\d+\b)*)/gim;
312
+ // inside or after the bold, then a list of `#N` (#4121, #4506). The list is `#N` items joined by `,` `&` `and` `or`
313
+ // (each item may carry a parenthetical note, whose own `#N` are prose); any other text ends it, so a trailing
314
+ // "see #99", "Fixes #12", a code span or a sentence never adds an edge (release review #4515).
315
+ const DEPENDS_ON_RE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?(?:depends on|blocked by)[ \t]*:?(?:\*\*|__)?[ \t]*:?[ \t]*(#\d+\b[^\n]*)/gim;
316
+ const DEP_ITEM_RE = /^(?<![\w/])#(\d+)\b(?:[ \t]*\([^()\n]*\))?/;
317
+ const DEP_JOIN_RE = /^[ \t]*(?:,[ \t]*(?:(?:and|or)\b[ \t]*)?|&[ \t]*|(?:and|or)\b[ \t]*)/i;
318
+
319
+ /** The `#N` list at the start of a declaration's tail: items and joiners only, stopping at the first other text. */
320
+ function dependsOnList(tail) {
321
+ const out = [];
322
+ let rest = tail;
323
+ for (;;) {
324
+ const item = DEP_ITEM_RE.exec(rest);
325
+ if (!item) break;
326
+ out.push(Number(item[1]));
327
+ rest = rest.slice(item[0].length);
328
+ const join = DEP_JOIN_RE.exec(rest);
329
+ if (!join || !/^#\d/.test(rest.slice(join[0].length))) break;
330
+ rest = rest.slice(join[0].length);
331
+ }
332
+ return out;
333
+ }
314
334
 
315
335
  /**
316
336
  * Issue numbers an issue body declares it waits on: `**Depends on**: #X`, `**Depends on** #X, #Y`, `Depends on #X`,
317
- * `- Depends on: #X and #Y`, `Blocked by #X`. Only declaration lines count; a mid-line mention is prose. Shared by the
318
- * engine's admission (`openDependency`) and `forgedock orchestrate` so both agree on what blocks what.
337
+ * `- Depends on: #X and #Y`, `Depends on #X (why) and #Y (why)`, `Blocked by #X`. Only declaration lines count, and on
338
+ * them only the leading `#N` list; a mid-line or trailing mention is prose. Shared by the engine's admission
339
+ * (`openDependency`) and `forgedock orchestrate` so both agree on what blocks what.
319
340
  * @returns {number[]} unique, positive, never `issue` itself
320
341
  */
321
342
  export function parseDependsOn(body, issue = null) {
322
343
  const out = new Set();
323
344
  for (const m of String(body ?? "").replace(/\r\n/g, "\n").matchAll(DEPENDS_ON_RE)) {
324
- for (const r of m[1].matchAll(/#(\d+)/g)) out.add(Number(r[1]));
345
+ for (const n of dependsOnList(m[1])) out.add(n);
325
346
  }
326
347
  return [...out].filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
327
348
  }
@@ -329,8 +350,8 @@ export function parseDependsOn(body, issue = null) {
329
350
  /**
330
351
  * forge#3932: does the issue declare a `Depends on` / `Blocked by` dependency that is still OPEN?
331
352
  * Honoured only when the issue BODY's author is trusted (`isTrustedComment`: OWNER/MEMBER/COLLABORATOR,
332
- * Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for a declaration line `parseDependsOn` accepts (mid-line
333
- * prose is ignored). A self-reference is ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
353
+ * Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for a declaration line's leading `#N` list as `parseDependsOn` reads it (mid-line
354
+ * and trailing prose is ignored). A self-reference is ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
334
355
  * when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
335
356
  * An empty read of the issue itself means "no body to trust" and yields `null`.
336
357
  */
@@ -1199,19 +1199,34 @@ async function orchestrateClient(argv, deps = {}) {
1199
1199
  }
1200
1200
  msg = { op: "submit", argv: sub, cwd: process.cwd(), env: schedd.batchEnv(process.env) };
1201
1201
  }
1202
- const sock = await connect();
1202
+ let sock = await connect(), req = msg, reattached = false, losses = 0;
1203
1203
  let id = resumeId;
1204
- try {
1205
- const done = await schedd.request(sock, msg, (m) => {
1206
- if (m.type === "batch") id = m.id;
1207
- else if (m.type === "row") console.log(formatRowLine(m.row));
1208
- else if (m.type === "line") console.log(m.text);
1209
- else if (m.type === "done" && m.replay && m.report) console.log(m.report);
1210
- });
1211
- return done.result;
1212
- } catch (e) {
1213
- if (id && /closed the connection/.test(e?.message ?? "")) throw new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
1214
- throw e;
1204
+ const onMsg = (m) => {
1205
+ if (m.type === "batch") id = m.id;
1206
+ else if (m.type === "row") { if (!(reattached && m.replay)) console.log(formatRowLine(m.row)); }
1207
+ else if (m.type === "line") console.log(m.text);
1208
+ else if (m.type === "done" && m.replay && m.report) console.log(m.report);
1209
+ };
1210
+ // #4503: when the daemon dies mid-batch, reconnect (spawning one unless `spawnDaemon: null`) and re-attach, within `reconnectMs`.
1211
+ const reconnectMs = deps.reconnectMs ?? 60_000, sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1212
+ for (;;) {
1213
+ try {
1214
+ return (await schedd.request(sock, req, onMsg)).result;
1215
+ } catch (e) {
1216
+ if (!id || !(/closed the connection/.test(e?.message ?? "") || ["ECONNRESET", "EPIPE"].includes(e?.code))) throw e;
1217
+ const giveUp = () => new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
1218
+ if (++losses > 10) throw giveUp();
1219
+ console.log(`schedd connection lost; reconnecting to batch ${id}`);
1220
+ const end = Date.now() + reconnectMs;
1221
+ for (sock = null; !sock;) {
1222
+ try { sock = await connect(); } catch {
1223
+ if (Date.now() >= end) throw giveUp();
1224
+ await sleep(250);
1225
+ }
1226
+ }
1227
+ req = { op: "attach", id };
1228
+ reattached = true;
1229
+ }
1215
1230
  }
1216
1231
  }
1217
1232
 
@@ -1350,7 +1365,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1350
1365
  const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
1351
1366
  lanes = withPlacement({
1352
1367
  runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
1353
- adopt: (n, rec) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec),
1368
+ adopt: (n, rec, ctx) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec, ctx),
1354
1369
  });
1355
1370
  }
1356
1371
  // #4255: hard execution budget per batch and per host UTC day; every settled sandbox is recorded and reported.
@@ -181,7 +181,10 @@ export async function waitLane({ batch, issue, pid, alive = isLaneAlive, sleep =
181
181
  }
182
182
  }
183
183
 
184
- /** Lane seams for `orchestrate()`: `runOne` launches a detached lane, `adopt` re-attaches to a live one (else null). */
184
+ /**
185
+ * Lane seams for `orchestrate()`: `runOne` launches a detached lane, `adopt` re-attaches to a live one (else null). A lane
186
+ * that already ended resolves at once with its result file; a live one calls `ctx.onLive()` before it is awaited (#4504).
187
+ */
185
188
  export function detachedLanes({ batch, laneArgs, script, cwd, env, bin, alive = isLaneAlive, pollMs, spawnFn }) {
186
189
  return {
187
190
  runOne: async (n, lane, ctx = {}) => {
@@ -189,9 +192,12 @@ export function detachedLanes({ batch, laneArgs, script, cwd, env, bin, alive =
189
192
  ctx.onStart?.({ pid });
190
193
  return waitLane({ batch, issue: n, pid, alive, pollMs });
191
194
  },
192
- adopt: async (n, rec) => {
195
+ adopt: async (n, rec, ctx = {}) => {
193
196
  const rf = batch.resultFile(n);
194
- if (!existsSync(rf) && !alive(rec.pid, rf)) return null;
197
+ if (!existsSync(rf)) {
198
+ if (!alive(rec.pid, rf)) return null;
199
+ ctx.onLive?.();
200
+ }
195
201
  return waitLane({ batch, issue: n, pid: rec.pid, alive, pollMs });
196
202
  },
197
203
  };
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
10
- import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
10
+ import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
11
11
  import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
12
12
 
13
13
  // orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir, also for spawned dispatchers.
@@ -629,3 +629,33 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
629
629
  assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
630
630
  });
631
631
  });
632
+
633
+ describe("#4504: a resume settles lanes that ended while no dispatcher ran", () => {
634
+ it("#4504: a resume settles a dead lane's result and requeues a dead lane without one before scheduling, while an earlier live lane still runs", async () => {
635
+ const dir = tmpDir("batch-4504-");
636
+ const batch = { resultFile: (n) => join(dir, `${n}.json`), logFile: (n) => join(dir, `${n}.log`) };
637
+ writeFileSync(batch.resultFile(2), JSON.stringify({ terminalReason: "merged", pr: 102 }));
638
+ const { adopt } = detachedLanes({ batch, laneArgs: () => [], script: "x", alive: (pid) => pid === 11, pollMs: 5 });
639
+ let saved = { v: 1, order: [1, 2, 3, 4], deps: { 1: [], 2: [], 3: [], 4: [] }, lanes: {}, crossSeen: [], results: {},
640
+ running: { 1: { startedAt: 1, pid: 11 }, 2: { startedAt: 1, pid: 22 }, 3: { startedAt: 1, pid: 33 } }, pending: [4],
641
+ notBefore: {}, requeues: {}, deferrals: {}, engineErrors: {}, prefilter: {}, errorTimes: [], breaker: null };
642
+ const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
643
+ const launched = [];
644
+ const done = orchestrate({ issues: [], parallel: 3, pollMs: 5, readBody: async () => "", store, adopt,
645
+ runOne: async (n) => { launched.push(n); return { terminalReason: "merged", pr: 100 + n }; } });
646
+ let rows;
647
+ try {
648
+ await until(() => launched.includes(3) && launched.includes(4) && saved.results?.["2"], 3000);
649
+ assert.equal(saved.results["2"].outcome, "merged", "the dead lane's result is settled");
650
+ assert.equal(saved.results["2"].pr, 102);
651
+ assert.ok(saved.running["1"], "the live lane is still running");
652
+ assert.equal(saved.running["2"], undefined, "the settled lane left running");
653
+ assert.ok(!launched.includes(1) && !launched.includes(2), "neither adopted lane is relaunched");
654
+ } finally {
655
+ writeFileSync(batch.resultFile(1), JSON.stringify({ terminalReason: "merged", pr: 101 })); // the live lane ends
656
+ rows = await done;
657
+ }
658
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102], [3, "merged", 103], [4, "merged", 104]]);
659
+ assert.deepEqual(launched.sort(), [3, 4], "the dead lane without a result is requeued and run once");
660
+ });
661
+ });
@@ -3,7 +3,7 @@
3
3
  /** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
4
4
  import { execFile, execFileSync, spawn } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
- import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
6
+ import { readFileSync, appendFileSync, mkdirSync, mkdtempSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
7
7
  import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
8
8
  import { promises as fsp } from "node:fs";
9
9
  import { dirname, join, resolve, sep } from "node:path";
@@ -234,10 +234,36 @@ export function runCheckCommand({ command, cwd, timeoutMs = 300000, env = null }
234
234
  });
235
235
  }
236
236
 
237
+ /**
238
+ * #4507: a per-run gh HTTP cache (XDG_CACHE_HOME on the gh child only), so concurrent lanes never share a cache entry.
239
+ * `reset()` drops the current dir (only one this cache created) and switches to a fresh one; with no `root` the dirs
240
+ * are mkdtemp dirs under `tmp`, removed by `dispose()`.
241
+ */
242
+ export function makeGhCache({ root = null, tmp = tmpdir() } = {}) {
243
+ let n = 0, dir = root ? join(root, "gh-cache") : null, disposed = false;
244
+ const made = new Set();
245
+ const drop = (d) => { if (d && made.has(d)) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } made.delete(d); } };
246
+ return {
247
+ env() {
248
+ if (!dir || disposed) return undefined; // after dispose (run teardown) gh falls back to its default cache
249
+ if (!made.has(dir)) { try { mkdirSync(dir, { recursive: true, mode: 0o700 }); made.add(dir); } catch { return undefined; } }
250
+ return { XDG_CACHE_HOME: dir };
251
+ },
252
+ reset() {
253
+ drop(dir);
254
+ if (disposed) return;
255
+ if (root) { dir = join(root, `gh-cache-${++n}`); return; }
256
+ try { dir = mkdtempSync(join(tmp, "forge-gh-cache-")); made.add(dir); } catch { dir = null; }
257
+ },
258
+ dispose() { disposed = true; for (const d of [...made]) drop(d); },
259
+ };
260
+ }
261
+
237
262
  export function makeLeanIo({ repo }) {
238
263
  const base = makeIo();
239
- const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
264
+ const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000, ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}) })).stdout;
240
265
  return {
266
+ ghCache: (root) => makeGhCache({ root }),
241
267
  gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), heavy: makeHeavyIo({ host: makeHostProbe() }), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
242
268
  /** Shared dependency cache (#4220): the repo's own package manager, argv only, bounded by the cache's timeout. */
243
269
  deps: makeDepCache({ exec: async (cmd, args, { cwd, env, timeoutMs } = {}) => String((await pexec(cmd, args, { cwd, env: { ...process.env, ...(env ?? {}) }, timeout: timeoutMs ?? 0, maxBuffer: 100 * 1024 * 1024 })).stdout) }),
@@ -411,7 +411,7 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
411
411
  await tear(led, h); // every path, after the result is in (tear never throws)
412
412
  return started ? withUsage(r, h) : r; // a sandbox that never started used nothing
413
413
  },
414
- adopt: async (n, rec) => {
414
+ adopt: async (n, rec, ctx = {}) => {
415
415
  const saved = rec?.handle;
416
416
  if (!saved?.id || saved.provider !== provider.name) return null;
417
417
  // Usage runs from the sandbox's real start (persisted on the handle), not from this adopt.
@@ -420,7 +420,9 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
420
420
  led?.register("sandbox", h.id, { provider: provider.name, handle: { provider: h.provider, id: h.id, issue: h.issue, ...(Number.isFinite(h.startedAt) ? { startedAt: h.startedAt } : {}) } }); // now owned by this dispatcher
421
421
  let r;
422
422
  try {
423
- r = !(await provider.fetchResult(h)) && (await provider.status(h)) !== "running" ? null : await wait(h); // null: relaunched
423
+ const ended = await provider.fetchResult(h);
424
+ if (!ended && (await provider.status(h)) !== "running") r = null; // relaunched
425
+ else { if (!ended) ctx.onLive?.(); r = await wait(h); } // #4504: a live sandbox is awaited in the background
424
426
  } catch (e) {
425
427
  r = { terminalReason: "engine-error", detail: `sandbox ${h.id}: ${errText(e)}` };
426
428
  }
package/bin/lean/lean.mjs CHANGED
@@ -25,7 +25,7 @@ import { prepareToolchain } from "./toolchain.mjs";
25
25
  import { toolStats } from "./toolstats.mjs";
26
26
  import { readTestMap, readTestMapAtRef, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
27
27
  import { runChecks, planPrecheck, worktreeFingerprint, selfCheckState, quietFailure, CHECK_PLAN_FILE, SELF_CHECK_FILE, CHECK_LOG_DIR } from "./precheck.mjs";
28
- import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
28
+ import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
29
29
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
30
30
  import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
31
31
  import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
@@ -216,10 +216,11 @@ export async function runCore(o) {
216
216
 
217
217
  const ev = (e) => appendEvent(dir, issue, e, { repo }); // #4432: the run log is keyed by repo + issue
218
218
  const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
219
- const ghc = (args) => io.gh(args);
219
+ let ghCache = null; // #4507: the run's own gh HTTP cache (set once the run tmp dir exists)
220
+ const ghc = (args) => ghCacheRetry((env) => io.gh(args, env ? { env } : undefined), ghCache);
220
221
  const sleepFn = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
221
222
  /** gh writes (comment, pr create, issue edit/close): retried for 5xx and secondary rate limits (#4114). */
222
- const ghw = (args) => retrying(() => io.gh(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
223
+ const ghw = (args) => retrying(() => ghc(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
223
224
  const git = (args) => io.git(args, { cwd: wt });
224
225
 
225
226
  const builderTriggers = []; // #4362: implement-attempt triggers in order, for the lane's first-pass flag
@@ -327,6 +328,7 @@ export async function runCore(o) {
327
328
  }
328
329
  // Never discard work silently: an unmerged lane commits and pushes what is left before its worktree goes.
329
330
  const saved = !wtCreated || reason === "merged" || (await checkpoint(`exit (${reason})`));
331
+ try { ghCache?.dispose?.(); } catch { /* best effort */ }
330
332
  if (runTmp) {
331
333
  try { if ((await io.runTmp.remove(runTmp)) !== false) ledgerDo((l) => l.release("tmp", runTmp)); } catch { /* best effort */ }
332
334
  runTmp = null;
@@ -890,6 +892,7 @@ export async function runCore(o) {
890
892
  try { runTmp = await io.runTmp.make(issue, runId); } catch { runTmp = null; }
891
893
  if (runTmp && runTmp !== planned) ledgerDo((l) => l.register("tmp", runTmp));
892
894
  }
895
+ ghCache = io.ghCache?.(runTmp) ?? null;
893
896
  ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
894
897
  try {
895
898
  iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
@@ -2133,6 +2136,10 @@ export async function runCore(o) {
2133
2136
  }
2134
2137
  return "unknown";
2135
2138
  };
2139
+ /** #4507: true only when the PR reads MERGED; unreadable counts as not merged. */
2140
+ const prMerged = async () => {
2141
+ try { return String(JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "state"])) || "{}").state ?? "").toUpperCase() === "MERGED"; } catch { return false; }
2142
+ };
2136
2143
  /** #4158: MERGEABLE but BLOCKED is a base branch policy block (ruleset / required checks pending), not a conflict. */
2137
2144
  const policyBlocked = async () => {
2138
2145
  let j;
@@ -2424,6 +2431,12 @@ export async function runCore(o) {
2424
2431
  break;
2425
2432
  } catch (e) {
2426
2433
  const msg = `${e?.stderr ?? ""}${e?.message ?? ""}`;
2434
+ if (await prMerged()) { // #4507: the merge went through despite the error
2435
+ T.decisions.push(`gh pr merge errored but PR #${pr} is MERGED: recorded merged (${msg.split("\n")[0].slice(0, 120)})`);
2436
+ try { seqLease?.release(); } catch { /* fail-open */ }
2437
+ seqLease = null;
2438
+ break;
2439
+ }
2427
2440
  if (MERGE_TRANSIENT_RE.test(msg) && !/not mergeable|merge conflict/i.test(msg)) {
2428
2441
  // "Base branch was modified", GraphQL and 5xx are transient: back off, then the loop top re-reads mergeable and the head.
2429
2442
  if (++mergeRetries > maxMergeRetries) return await finish("deferred", `gh pr merge kept failing transiently (${mergeRetries - 1} retries): ${msg.slice(0, 300)}`, { pr, code: OUTCOME_CODES.MERGE_TRANSIENT }, OUTCOME_CODES.MERGE_TRANSIENT);
@@ -19,8 +19,8 @@ import { mapRecord } from "../engine/telemetry-seam.mjs";
19
19
  import { readLog as readRepoLog } from "../engine/runlog.mjs";
20
20
  // #4432: the run log is keyed by repo + issue; every lane in this file runs in repo o/r.
21
21
  const readLog = (dir, issue, repo = "o/r") => readRepoLog(dir, issue, { repo });
22
- import { makeRunTmp, wrapLaneLimits, systemdRunAvailable, makeLeanRunner, requiredChecksFor, procsInDir, makeLeanIo } from "./effects.mjs";
23
- import { retrying, PUSH_TRANSIENT_RE } from "./retry.mjs";
22
+ import { makeRunTmp, wrapLaneLimits, systemdRunAvailable, makeLeanRunner, requiredChecksFor, procsInDir, makeLeanIo, makeGhCache } from "./effects.mjs";
23
+ import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE } from "./retry.mjs";
24
24
  import { checkAdmission, hostParallelism } from "./admission.mjs";
25
25
  import { OUTCOME_CODES, classifyNeedsHuman } from "../engine/outcome-codes.mjs";
26
26
  import * as refactorMod from "./refactor.mjs";
@@ -73,8 +73,10 @@ function world(o = {}) {
73
73
  w.writeStages = writeStages;
74
74
  w.io = {
75
75
  now: () => w.clock, sleep: async (ms) => { w.clock += ms; },
76
- gh: async (a) => {
76
+ gh: async (a, opts) => {
77
77
  const k = a.slice(0, 2).join(" ");
78
+ if (opts?.env) (w.ghEnvs ??= []).push(opts.env);
79
+ if (k === "pr view" && a.at(-1) === "state" && a.at(-2) === "--json") return JSON.stringify({ state: o.prState ?? "OPEN" });
78
80
  if (a[0] === "api" && a.includes("PATCH")) { w.patches.push(a); return "{}"; }
79
81
  if (a[0] === "api") return a.some((x) => /\/comments$/.test(x)) ? (w.issueComments ?? []).map((c) => JSON.stringify(c)).join("\n") : "";
80
82
  if (k === "pr list") return JSON.stringify(w.prList);
@@ -2146,6 +2148,17 @@ describe("orchestrate", () => {
2146
2148
  assert.deepEqual(parseDeps("This is blocked by #2 and depends on #4\nsee **Depends on**: #5", 1), []);
2147
2149
  });
2148
2150
 
2151
+ it("#4506 parseDeps reads every #N to the end of a declaration line", () => {
2152
+ assert.deepEqual(parseDeps("Depends on #36648 (action results must reach …) and #36649 (the included action budget …)", 1), [36648, 36649]);
2153
+ assert.deepEqual(parseDeps("Depends on #36651 (executor test harness) and #36648.", 1), [36651, 36648]);
2154
+ assert.deepEqual(parseDeps("**Depends on** #2, #3 and #4", 1), [2, 3, 4]);
2155
+ assert.deepEqual(parseDeps("__Depends on__: #5 (x) and #6", 1), [5, 6]);
2156
+ assert.deepEqual(parseDeps("**Blocked by:** #7 (why) & #8", 1), [7, 8]);
2157
+ assert.deepEqual(parseDeps("- depends on: #9 (a), #10", 1), [9, 10]);
2158
+ assert.deepEqual(parseDeps("Depends on #11 and other/repo#12", 1), [11]);
2159
+ assert.deepEqual(parseDeps("This is blocked by #2 and depends on #4", 1), []);
2160
+ });
2161
+
2149
2162
  it("runs at most K at once", async () => {
2150
2163
  const h = harness({});
2151
2164
  const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6], parallel: 2, ...h });
@@ -3222,6 +3235,78 @@ describe("batch robustness (#4112-#4115)", () => {
3222
3235
  assert.ok(w.comments.some((c) => c.includes("FORGE:BASE_SYNC")));
3223
3236
  });
3224
3237
 
3238
+ // ---- #4507 ----
3239
+ const CORRUPT = "invalid character '{' looking for beginning of object key string";
3240
+ it("#4507 a gh JSON parse error at merge (corrupt gh cache) is retried and ends merged", async () => {
3241
+ const w = world({ mergeErrors: [CORRUPT] });
3242
+ const r = await run(w);
3243
+ assert.equal(r.terminalReason, "merged");
3244
+ assert.equal(w.mergeAttempts, 2);
3245
+ assert.equal(w.merges.length, 1);
3246
+ });
3247
+
3248
+ it("#4507 a merge error on a PR that is already MERGED records merged, not engine-error", async () => {
3249
+ const w = world({ mergeErrors: ["GraphQL: validation failed (422)"], prState: "MERGED" });
3250
+ const r = await run(w);
3251
+ assert.equal(r.terminalReason, "merged");
3252
+ assert.equal(w.mergeAttempts, 1);
3253
+ assert.equal(w.closed.length, 1);
3254
+ assert.ok(traj(w)[0].includes("is MERGED: recorded merged"));
3255
+ });
3256
+
3257
+ it("#4507 a persistent gh parse error at merge ends deferred merge.transient, never engine-error", async () => {
3258
+ const w = world({ mergeErrors: Array(40).fill(CORRUPT) });
3259
+ const r = await run(w, { maxMergeRetries: 2 });
3260
+ assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "merge.transient");
3261
+ assert.equal(w.mergeAttempts, 6, "each merge attempt is retried once with a fresh cache");
3262
+ });
3263
+
3264
+ it("#4507 with io.ghCache every engine gh call carries the run's own XDG_CACHE_HOME", async () => {
3265
+ const w = world();
3266
+ w.io.runTmp = { prune: async () => {}, make: async (_i, id) => `/rt/${id}`, remove: async () => true };
3267
+ let made = null, disposed = 0;
3268
+ w.io.ghCache = (root) => { made = root; return { env: () => ({ XDG_CACHE_HOME: `${root}/gh-cache` }), reset() {}, dispose() { disposed++; } }; };
3269
+ assert.equal((await run(w)).terminalReason, "merged");
3270
+ assert.match(made, /^\/rt\//);
3271
+ assert.ok(w.ghEnvs.length > 0 && w.ghEnvs.every((e) => e.XDG_CACHE_HOME === `${made}/gh-cache`));
3272
+ assert.equal(disposed, 1);
3273
+ });
3274
+
3275
+ it("#4507 ghCacheRetry retries exactly once on a parse error and rethrows other errors untouched", async () => {
3276
+ let n = 0, resets = 0;
3277
+ const cache = { env: () => ({ XDG_CACHE_HOME: `/c${resets}` }), reset: () => { resets++; } };
3278
+ const seen = [];
3279
+ assert.equal(await ghCacheRetry(async (env) => { seen.push(env.XDG_CACHE_HOME); if (n++ === 0) throw new Error(CORRUPT); return "ok"; }, cache), "ok");
3280
+ assert.deepEqual(seen, ["/c0", "/c1"]); assert.equal(resets, 1);
3281
+ n = 0;
3282
+ await assert.rejects(ghCacheRetry(async () => { n++; throw new Error(CORRUPT); }, null), /invalid character/);
3283
+ assert.equal(n, 2);
3284
+ n = 0;
3285
+ const other = new Error("HTTP 404: Not Found");
3286
+ await assert.rejects(ghCacheRetry(async () => { n++; throw other; }, cache), (e) => e === other);
3287
+ assert.equal(n, 1);
3288
+ });
3289
+
3290
+ it("#4507 makeGhCache: a per-root cache dir, reset swaps in a fresh dir, the no-root fallback is disposed", () => {
3291
+ const root = tmpDir("ghcache-");
3292
+ const c = makeGhCache({ root });
3293
+ const first = c.env().XDG_CACHE_HOME;
3294
+ assert.equal(first, join(root, "gh-cache")); assert.ok(existsSync(first));
3295
+ c.reset();
3296
+ const second = c.env().XDG_CACHE_HOME;
3297
+ assert.notEqual(second, first); assert.ok(existsSync(second)); assert.ok(!existsSync(first));
3298
+ const tmp = tmpDir("ghcache-tmp-");
3299
+ const f = makeGhCache({ tmp });
3300
+ assert.equal(f.env(), undefined);
3301
+ f.reset();
3302
+ const fb = f.env().XDG_CACHE_HOME;
3303
+ assert.ok(fb.startsWith(join(tmp, "forge-gh-cache-")) && existsSync(fb));
3304
+ f.dispose();
3305
+ assert.ok(!existsSync(fb));
3306
+ c.dispose();
3307
+ assert.ok(!existsSync(second)); assert.equal(c.env(), undefined, "no dir is re-created after the run tears down");
3308
+ });
3309
+
3225
3310
  // ---- #4114 ----
3226
3311
  it("#4114 a push failing once with 'remote unpack failed' then succeeding completes the run", async () => {
3227
3312
  const w = world({ pushErrors: ["error: remote unpack failed: unable to create temporary object directory"] });
@@ -14,7 +14,9 @@
14
14
  * lane is refused ends `lane-refused` without a run, and a dependency edge between two lanes goes to `onCrossLane`.
15
15
  *
16
16
  * Durable batch runner (#4121): with a `store` every transition is persisted, so a restarted dispatcher continues the
17
- * batch; lanes it launched that are still alive are re-attached through `adopt(issue, rec)` instead of relaunched.
17
+ * batch; lanes it launched that are still alive are re-attached through `adopt(issue, rec, {onLive})` instead of relaunched.
18
+ * A seam calls `onLive()` before it waits on a live lane (#4504); an adopt that resolves first is settled (or requeued on null)
19
+ * before scheduling, so a live lane does not hold up the others.
18
20
  * `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
19
21
  * limit holds admissions for that account until its reset, and non-host deferrals stop after `maxDeferrals`.
20
22
  * Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
@@ -666,15 +668,34 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
666
668
  };
667
669
 
668
670
  // Lanes launched by an earlier dispatcher: re-attach to the live ones, queue the rest again (no result was recorded).
669
- for (const [n, rec] of [...runRecs]) {
670
- let p = null;
671
- if (adopt && (rec?.pid || rec?.handle)) { try { p = await adopt(n, rec); } catch { p = null; } }
672
- if (p && rec.remote) { remoteRun.add(n); track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null))); }
673
- else if (p) {
674
- if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); }
675
- track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null)));
671
+ // #4504: every adopt starts at once and races its `onLive` signal, so a live lane never holds up the others: one that
672
+ // already ended is settled from its result (or requeued without one) before scheduling; a live one is awaited in the background.
673
+ const requeueLost = (n) => { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) { pending.unshift(n); enqueued.set(n, now()); } };
674
+ const adopting = [...runRecs].map(([n, rec]) => {
675
+ if (!adopt || !(rec?.pid || rec?.handle)) return { n, rec, live: false, p: Promise.resolve(null) };
676
+ let onLive;
677
+ const isLive = new Promise((r) => { onLive = () => r(true); });
678
+ const p = Promise.resolve().then(() => adopt(n, rec, { onLive })).catch(() => null);
679
+ return { n, rec, p, first: Promise.race([p.then(() => false), isLive]) };
680
+ });
681
+ for (const a of adopting) a.live = a.first ? await a.first : false;
682
+ for (const { n, rec, p, live } of adopting) {
683
+ if (live) {
684
+ if (rec.remote) remoteRun.add(n);
685
+ else if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); }
686
+ track(n, p.then((r) => {
687
+ if (r) return settle(n, r, rec, checked.get(n) ?? null);
688
+ if (halted) return; // #4454: kept for the next resume
689
+ running.delete(n); releaseSlot(n); remoteRun.delete(n); requeueLost(n); save(); // ended without a result after all
690
+ }));
691
+ continue;
676
692
  }
677
- else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) { pending.unshift(n); enqueued.set(n, now()); } }
693
+ const r = await p;
694
+ if (!r) { requeueLost(n); continue; }
695
+ if (rec.remote) remoteRun.add(n);
696
+ else if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); } // released by the settle
697
+ track(n, Promise.resolve(r).then((x) => settle(n, x, rec, checked.get(n) ?? null)));
698
+ await running.get(n); // settled before scheduling
678
699
  }
679
700
  // A resume re-derives each unfinished issue's lane from its current milestone (#4153): a re-milestone takes effect.
680
701
  if (saved && resolveLane) {
@@ -5,7 +5,9 @@
5
5
  export const PUSH_TRANSIENT_RE = /remote unpack failed|index-pack failed|RPC failed|unexpected disconnect|early EOF|the remote end hung up|HTTP\/?\s?5\d\d|returned error: 5\d\d|\b50[0234]\b|internal server error|bad gateway|service unavailable|gateway time-?out|connection (?:reset|timed out|refused)|timed out|could not resolve host|broken pipe|ETIMEDOUT|ECONNRESET|temporarily unavailable|\[remote rejected\][^\n]*\(failed\)|unable to write file|unable to create temporary object|unpacker error|failed to write object/i;
6
6
  export const PUSH_PERMANENT_RE = /protected branch|GH006|GH013|repository rule violations|permission (?:denied|to .* denied)|denied to|\b403\b|authentication failed|not allowed|hook declined|pre-receive hook declined|\[rejected\]|non-fast-forward|fetch first/i;
7
7
  export const GH_TRANSIENT_RE = /HTTP 5\d\d|\b50[0234]\b|secondary rate limit|rate limit exceeded|abuse detection|something went wrong|internal server error|bad gateway|service unavailable|ETIMEDOUT|ECONNRESET|connection reset|timed out|\bEOF\b|temporarily unavailable/i;
8
- export const MERGE_TRANSIENT_RE = new RegExp(`${GH_TRANSIENT_RE.source}|base branch was modified|head branch was modified|mergeability.*unknown|try again`, "i");
8
+ /** #4507: gh failing to parse its own input: a torn HTTP cache entry (JSON) or a half-written config (YAML). */
9
+ export const GH_PARSE_RE = /invalid character .{0,40}looking for|unexpected end of JSON input|cannot unmarshal|yaml: line \d+|yaml: unmarshal/i;
10
+ export const MERGE_TRANSIENT_RE = new RegExp(`${GH_TRANSIENT_RE.source}|${GH_PARSE_RE.source}|base branch was modified|head branch was modified|mergeability.*unknown|try again`, "i");
9
11
  export const MERGE_CONFLICT_RE = /not mergeable|merge conflict|conflict/i;
10
12
 
11
13
  export const errText = (e) => `${e?.stderr ?? ""}\n${e?.message ?? ""}\n${e?.stdout ?? ""}`;
@@ -29,3 +31,15 @@ export async function retrying(fn, { transient, permanent = null, attempts = 4,
29
31
  last.transientExhausted = true;
30
32
  throw last;
31
33
  }
34
+
35
+ /**
36
+ * #4507: run `call(env)` with the lane's gh cache env; on a gh parse error (corrupt cache) reset the cache and run it
37
+ * once more. Without a cache it still retries once. Any other error is rethrown unchanged.
38
+ */
39
+ export async function ghCacheRetry(call, cache) {
40
+ try { return await call(cache?.env?.()); } catch (e) {
41
+ if (!GH_PARSE_RE.test(errText(e))) throw e;
42
+ try { await cache?.reset?.(); } catch { /* retry on the old dir */ }
43
+ return await call(cache?.env?.());
44
+ }
45
+ }
@@ -24,6 +24,9 @@
24
24
  * Shutdown (#4454): `close()` aborts every hosted batch's `signal` (its loop admits nothing more and leaves, lanes keep
25
25
  * running, no report is written), waits for them up to `stopMs`, and only then releases `schedd.lock` and the socket, so a
26
26
  * daemon started next never resumes a batch this one still dispatches. `exitOnSignals` exits 128+signo after that.
27
+ * Exit reasons (#4503): every exit logs a line: `idle, exiting`, `exiting (<signal>|uncaught: …)`, `lost lock|socket …,
28
+ * exiting` (checked every `lockCheckMs`: the lock no longer holds this pid, or the socket is gone; nothing it does not own
29
+ * is removed), then `exited (code N)`. Taking over a dead owner's lock is logged, so a SIGKILLed daemon leaves a trace.
27
30
  * `runBatch({argv, cwd, env, sink, keepBreaker, signal})` is injected (engine-cli `runBatch`); `sink` gets `batch(b)`, `row(r)`, `line(text)`.
28
31
  */
29
32
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
@@ -35,6 +38,7 @@ import { batchRoot, listBatches, openBatch } from "./batch.mjs";
35
38
 
36
39
  export const IDLE_MS = 10 * 60 * 1000;
37
40
  export const STOP_MS = 10 * 1000;
41
+ export const LOCK_CHECK_MS = 5 * 1000;
38
42
 
39
43
  export function scheddPaths(dir = schedDir()) {
40
44
  return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
@@ -54,17 +58,19 @@ export function mergeEnv(base, kept) {
54
58
  return { ...out, ...kept };
55
59
  }
56
60
 
57
- /** Take the daemon lock; null when another live daemon holds it. */
61
+ /** Take the daemon lock: `{took}` (the dead owner's pid it replaced, or null); null when another live daemon holds it. */
58
62
  function takeLock(lock, pid, alive) {
63
+ let took = null;
59
64
  for (let i = 0; i < 5; i++) {
60
65
  try {
61
66
  const fd = openSync(lock, "wx");
62
67
  try { writeSync(fd, String(pid)); } finally { closeSync(fd); }
63
- return true;
68
+ return { took };
64
69
  } catch (e) { if (e?.code !== "EEXIST") throw e; }
65
70
  let owner = NaN;
66
71
  try { owner = parseInt(readFileSync(lock, "utf8"), 10); } catch { continue; }
67
72
  if (Number.isInteger(owner) && owner !== pid && alive(owner)) return null;
73
+ if (Number.isInteger(owner) && owner !== pid) took = owner;
68
74
  rmSync(lock, { force: true });
69
75
  }
70
76
  return null;
@@ -74,10 +80,13 @@ function takeLock(lock, pid, alive) {
74
80
  * Start the daemon. @returns {Promise<{running:true, pid, close, closed:Promise} | {running:false, owner:number}>}
75
81
  */
76
82
  export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
77
- root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS } = {}) {
83
+ root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
84
+ lockCheckMs = LOCK_CHECK_MS } = {}) {
78
85
  const p = scheddPaths(dir);
79
86
  mkdirSync(dir, { recursive: true });
80
- if (!takeLock(p.lock, pid, alive)) {
87
+ const lock = takeLock(p.lock, pid, alive);
88
+ if (lock?.took) log(`schedd: took over the lock of dead pid ${lock.took} (it exited without a clean shutdown)`);
89
+ if (!lock) {
81
90
  let owner = NaN;
82
91
  try { owner = parseInt(readFileSync(p.lock, "utf8"), 10); } catch { /* gone */ }
83
92
  log(`schedd: already running (pid ${owner})`);
@@ -88,7 +97,8 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
88
97
  const hosted = new Map(); // batch id -> {id, dir, subs:Set<fn>}
89
98
  const conns = new Set();
90
99
  const runs = new Map(); // every runBatch in flight (hosted or not yet started) -> its AbortController (#4454)
91
- let closing = null, resolveClosed;
100
+ const starting = new Map(); // batch id -> host() promise of a start-up resume not yet hosted (#4503: an attach waits for it)
101
+ let closing = null, resolveClosed, keepSock = false;
92
102
  let lastActive = Date.now();
93
103
  const touch = () => { lastActive = Date.now(); };
94
104
 
@@ -143,6 +153,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
143
153
  return send({ type: "batch", id: ent.id, dir: ent.dir });
144
154
  }
145
155
  if (req.op === "attach") {
156
+ if (!hosted.has(req.id) && starting.has(req.id)) await starting.get(req.id).catch(() => {});
146
157
  const live = hosted.get(req.id);
147
158
  if (live) {
148
159
  // Synchronous read + subscribe: no row is lost or doubled between the replay and the live stream.
@@ -191,6 +202,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
191
202
  const close = () => {
192
203
  if (closing) return closing;
193
204
  clearInterval(idleTimer);
205
+ clearInterval(lockTimer);
194
206
  for (const c of conns) c.destroy();
195
207
  const stopping = [...runs.keys()];
196
208
  for (const ac of runs.values()) ac.abort();
@@ -198,9 +210,11 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
198
210
  // #4454: the lock goes only once every hosted batch loop has stopped (or `stopMs` passed: the caller exits regardless)
199
211
  const stopped = stopping.length ? Promise.race([Promise.allSettled(stopping).then(() => true), new Promise((r) => { timer = setTimeout(() => r(false), stopMs); })])
200
212
  .then((ok) => { clearTimeout(timer); if (!ok) log(`schedd: ${runs.size} batch(es) did not stop within ${stopMs} ms`); }) : Promise.resolve();
201
- closing = Promise.all([new Promise((r) => server.close(() => r())), stopped]).then(() => {
213
+ // A lost lock: the socket path may be the successor's, and closing the server unlinks it (libuv), so leave it unref'd.
214
+ const serverClosed = keepSock ? Promise.resolve(server.unref()) : new Promise((r) => server.close(() => r()));
215
+ closing = Promise.all([serverClosed, stopped]).then(() => {
202
216
  try { if (parseInt(readFileSync(p.lock, "utf8"), 10) === pid) rmSync(p.lock, { force: true }); } catch { /* gone */ }
203
- rmSync(p.sock, { force: true });
217
+ if (!keepSock) rmSync(p.sock, { force: true });
204
218
  resolveClosed();
205
219
  });
206
220
  return closing;
@@ -209,6 +223,15 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
209
223
  if (!hosted.size && !conns.size && Date.now() - lastActive >= idleMs) { log("schedd: idle, exiting"); close(); }
210
224
  }, Math.min(idleMs, 30_000));
211
225
  idleTimer.unref?.();
226
+ // #4503: a daemon whose lock or socket was taken stops (its batches stay resumable) instead of running unreachable.
227
+ const lockTimer = setInterval(() => {
228
+ if (closing) return;
229
+ let owner = NaN;
230
+ try { owner = parseInt(readFileSync(p.lock, "utf8"), 10); } catch { /* gone */ }
231
+ if (owner !== pid) { keepSock = true; log(`schedd: lost lock (owner ${Number.isInteger(owner) ? `pid ${owner}` : "none"}), exiting`); close(); }
232
+ else if (!existsSync(p.sock)) { log(`schedd: lost socket ${p.sock}, exiting`); close(); }
233
+ }, lockCheckMs);
234
+ lockTimer.unref?.();
212
235
 
213
236
  // Daemon restart: resume every daemon-hosted batch that never wrote its report; live lanes are re-adopted.
214
237
  if (resumeOnStart) {
@@ -216,22 +239,35 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
216
239
  const b = openBatch(root, id);
217
240
  if (!b.readPlan()?.daemon || b.readReport()) continue;
218
241
  log(`schedd: resuming batch ${id}`);
219
- host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true }).catch((e) => log(`schedd: resume ${id} failed: ${e?.message ?? e}`));
242
+ const started = host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true });
243
+ starting.set(id, started);
244
+ started.catch((e) => log(`schedd: resume ${id} failed: ${e?.message ?? e}`)).finally(() => starting.delete(id));
220
245
  }
221
246
  }
222
247
  return { running: true, pid, sock: p.sock, close, closed };
223
248
  }
224
249
 
225
- /** #4454: on SIGTERM/SIGINT stop the daemon (`close()`: batch loops first, then lock and socket), then exit 128+signo, once. */
226
- export function exitOnSignals(d, { on = (s, f) => process.once(s, f), exit = (c) => process.exit(c), signals = ["SIGTERM", "SIGINT"] } = {}) {
227
- const SIGNO = { SIGINT: 2, SIGTERM: 15 };
250
+ /**
251
+ * #4454: on SIGTERM/SIGINT/SIGHUP stop the daemon (`close()`: batch loops first, then lock and socket), then exit 128+signo,
252
+ * once; an uncaught exception or unhandled rejection does the same and exits 1. #4503: each logs its reason first, and the
253
+ * process `exit` logs `schedd: exited (code N)`.
254
+ */
255
+ export function exitOnSignals(d, { on = (s, f) => (s.startsWith("SIG") || s === "exit" ? process.once(s, f) : process.on(s, f)),
256
+ exit = (c) => process.exit(c), log = (m) => console.log(m), signals = ["SIGTERM", "SIGINT", "SIGHUP"] } = {}) {
257
+ const SIGNO = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
228
258
  let fired = false;
229
- for (const sig of signals) on(sig, async () => {
259
+ const stop = async (reason, code, detail = null) => {
230
260
  if (fired) return;
231
261
  fired = true;
262
+ log(`schedd: exiting (${reason})`);
263
+ if (detail) log(detail);
232
264
  try { await d.close(); } catch { /* exit anyway */ }
233
- exit(128 + (SIGNO[sig] ?? 0));
234
- });
265
+ exit(code);
266
+ };
267
+ for (const sig of signals) on(sig, () => stop(sig, 128 + (SIGNO[sig] ?? 0)));
268
+ on("uncaughtException", (e) => stop(`uncaught: ${String(e?.message ?? e).split("\n")[0]}`, 1, e?.stack ?? null));
269
+ on("unhandledRejection", (e) => stop(`unhandled rejection: ${String(e?.message ?? e).split("\n")[0]}`, 1, e?.stack ?? null));
270
+ on("exit", (code) => log(`schedd: exited (code ${code})`));
235
271
  }
236
272
 
237
273
  /** Connect to the daemon, spawning it (`spawnDaemon()`) when nobody listens. @returns {Promise<import("node:net").Socket>} */
@@ -3,14 +3,23 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { homedir, tmpdir } from "node:os";
7
8
  import { join } from "node:path";
8
9
  import { tmpDir } from "../tests/_tmp.mjs";
9
10
  import { openBatch, isLaneAlive, listBatches } from "./batch.mjs";
10
- import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv } from "./schedd.mjs";
11
+ import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv, exitOnSignals } from "./schedd.mjs";
12
+ import { schedDir } from "./sched.mjs";
11
13
  import { formatTable, orchestrate } from "./orchestrate.mjs";
12
14
  import { orchestrateFromCli } from "../engine-cli.mjs";
13
15
 
16
+ // #4503: this process, and the env its clients submit for their lanes, never resolve the host's `~/.forge/sched`.
17
+ process.env.FORGEDOCK_SCHED_DIR = tmpDir("schedd-home-");
18
+ // Stub lanes run detached: a host whose test never reached its `finally` still has them reaped when the file ends.
19
+ const hosts = [];
20
+ process.on("exit", () => { for (const h of hosts) h.reap(); });
21
+ const LANE_MAX_MS = 120_000;
22
+
14
23
  const until = async (cond, ms = 15000) => {
15
24
  const end = Date.now() + ms;
16
25
  while (!cond()) { if (Date.now() > end) throw new Error("timed out waiting"); await new Promise((r) => setTimeout(r, 25)); }
@@ -24,13 +33,20 @@ const capture = async (fn) => {
24
33
  /** A host: sched + batch dirs, a stub lane that waits for `release`, and a daemon script with injected lanes. */
25
34
  function host() {
26
35
  const work = tmpDir("schedd-");
27
- const h = { work, sched: join(work, "s"), root: join(work, "batches"), launches: join(work, "launches"), release: join(work, "release"), procs: [] };
36
+ const h = { work, sched: join(work, "s"), root: join(work, "batches"), launches: join(work, "launches"), release: join(work, "release"), procs: [],
37
+ lanePidFile: join(work, "lane-pids"), log: join(work, "schedd.log") };
28
38
  writeFileSync(h.launches, "");
39
+ writeFileSync(h.lanePidFile, "");
40
+ hosts.push(h);
29
41
  const stub = join(work, "stub-lane.mjs");
30
42
  writeFileSync(stub, `import { appendFileSync, existsSync, writeFileSync, renameSync } from "node:fs";
31
43
  const a = process.argv.slice(2); const n = Number(a[1]); const rf = a[a.indexOf("--result-file") + 1];
32
44
  appendFileSync(${JSON.stringify(h.launches)}, n + "\\n");
33
- const t = setInterval(() => { if (!existsSync(${JSON.stringify(h.release)})) return; clearInterval(t);
45
+ appendFileSync(${JSON.stringify(h.lanePidFile)}, process.pid + "\\n");
46
+ // #4503: a stub whose test run is gone (work dir removed) or that outlived its bound exits instead of polling forever.
47
+ const born = Date.now();
48
+ const t = setInterval(() => { if (!existsSync(${JSON.stringify(work)}) || Date.now() - born > ${LANE_MAX_MS}) process.exit(0);
49
+ if (!existsSync(${JSON.stringify(h.release)})) return; clearInterval(t);
34
50
  writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "merged", pr: 100 + n, cost_usd: 0 })); renameSync(rf + ".tmp", rf); }, 20);
35
51
  // Like run-issue's #4179 teardown: SIGTERM reports deferred run.interrupted.
36
52
  process.once("SIGTERM", () => { writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "deferred", code: "run.interrupted", detail: "interrupted by SIGTERM" })); renameSync(rf + ".tmp", rf); process.exit(143); });
@@ -49,7 +65,9 @@ await d.closed;
49
65
  h.env = { ...process.env, FORGEDOCK_SCHED_DIR: h.sched, FORGEDOCK_BATCH_DIR: h.root, FORGEDOCK_LIMITS_FILE: join(work, "limits.json") };
50
66
  h.daemon = async () => {
51
67
  const before = existsSync(scheddPaths(h.sched).lock) ? readFileSync(scheddPaths(h.sched).lock, "utf8") : null;
52
- const child = spawn(process.execPath, [h.daemonScript], { env: h.env, stdio: "ignore" });
68
+ const out = openSync(h.log, "a");
69
+ let child;
70
+ try { child = spawn(process.execPath, [h.daemonScript], { env: h.env, stdio: ["ignore", out, out] }); } finally { closeSync(out); }
53
71
  h.procs.push(child);
54
72
  await until(() => existsSync(scheddPaths(h.sched).sock) && existsSync(scheddPaths(h.sched).lock) && readFileSync(scheddPaths(h.sched).lock, "utf8") === String(child.pid) && readFileSync(scheddPaths(h.sched).lock, "utf8") !== before);
55
73
  // Wait for the socket to accept.
@@ -59,9 +77,15 @@ await d.closed;
59
77
  h.client = { scheddDir: h.sched, spawnDaemon: null };
60
78
  h.launched = () => readFileSync(h.launches, "utf8").trim().split("\n").filter(Boolean).map(Number).sort();
61
79
  h.lanePids = () => listBatches(h.root).flatMap((id) => Object.values(openBatch(h.root, id).store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean));
62
- h.cleanup = () => {
63
- writeFileSync(h.release, "");
80
+ h.stubPids = () => (existsSync(h.lanePidFile) ? readFileSync(h.lanePidFile, "utf8").trim().split("\n").filter(Boolean).map(Number) : []);
81
+ h.reap = () => {
64
82
  for (const p of h.procs) { try { p.kill("SIGKILL"); } catch { /* gone */ } }
83
+ for (const pid of h.stubPids()) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
84
+ };
85
+ h.cleanup = async () => {
86
+ if (existsSync(h.work)) writeFileSync(h.release, "");
87
+ h.reap();
88
+ await until(() => h.stubPids().every((pid) => !isLaneAlive(pid, null)), 5000).catch(() => {});
65
89
  };
66
90
  return h;
67
91
  }
@@ -98,7 +122,7 @@ describe("schedd host scheduler (#4192)", () => {
98
122
  const id = done[0].batch;
99
123
  await withRoot(h, () => capture(() => orchestrateFromCli(["--set-parallel", id, "5"], h.client)));
100
124
  assert.equal(openBatch(h.root, id).readParallel(), 5);
101
- } finally { h.cleanup(); }
125
+ } finally { await h.cleanup(); }
102
126
  });
103
127
 
104
128
  it("a client that disconnects does not stop the batch; --resume reattaches, and replays the report once done", async () => {
@@ -127,7 +151,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
127
151
  assert.deepEqual(again.res.rows, res.rows);
128
152
  assert.ok(again.lines.join("\n").includes(formatTable(res.rows)));
129
153
  assert.deepEqual(h.launched(), [1, 2]);
130
- } finally { h.cleanup(); }
154
+ } finally { await h.cleanup(); }
131
155
  });
132
156
 
133
157
  it("a daemon restart resumes the batch from state and re-adopts its live lanes without duplicate launches", async () => {
@@ -150,7 +174,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
150
174
  assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102], [3, "merged", 103]]);
151
175
  assert.deepEqual(h.launched(), [1, 2, 3], "adopted, not relaunched");
152
176
  assert.deepEqual(openBatch(h.root, id).store.load().running, {});
153
- } finally { h.cleanup(); }
177
+ } finally { await h.cleanup(); }
154
178
  });
155
179
 
156
180
  it("#4408: --add and --resume <id> N add issues to the live batch (no second batch); an unhosted finished batch runs its adds on --resume", async () => {
@@ -181,7 +205,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
181
205
  }));
182
206
  assert.equal(res.rows.length, 3);
183
207
  assert.deepEqual(h.launched(), [1, 2, 4, 6], "each issue launched once");
184
- } finally { h.cleanup(); }
208
+ } finally { await h.cleanup(); }
185
209
  });
186
210
 
187
211
  it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
@@ -211,7 +235,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
211
235
  assert.equal(again.cancelled, false, "a finished batch is left alone");
212
236
  await assert.rejects(orchestrateFromCli(["--cancel", "nope"], h.client), /no batch nope/);
213
237
  }));
214
- } finally { h.cleanup(); }
238
+ } finally { await h.cleanup(); }
215
239
  });
216
240
 
217
241
  it("#4481: a batch cancelled through the daemon is not resumed by a schedd restart", async () => {
@@ -232,7 +256,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
232
256
  assert.deepEqual(await statusIds(), [], "the restarted daemon hosts no cancelled batch");
233
257
  assert.deepEqual(h.launched(), [1]);
234
258
  }));
235
- } finally { h.cleanup(); }
259
+ } finally { await h.cleanup(); }
236
260
  });
237
261
 
238
262
  it("single instance: a live daemon keeps its lock; a dead owner's lock and socket are taken over", async () => {
@@ -252,7 +276,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
252
276
  assert.equal(st.pid, process.pid);
253
277
  } finally { await third.close(); }
254
278
  assert.equal(existsSync(scheddPaths(h.sched).lock), false, "lock released on close");
255
- } finally { h.cleanup(); }
279
+ } finally { await h.cleanup(); }
256
280
  });
257
281
 
258
282
  it("#4454: close() stops every hosted batch loop before releasing the lock; no batch admits a lane after close()", async () => {
@@ -309,11 +333,12 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
309
333
  const h = host();
310
334
  try {
311
335
  const d1 = await h.daemon();
312
- await withRoot(h, () => capture(async () => {
313
- const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client).then(() => null, (e) => e);
336
+ const { lines } = await withRoot(h, () => capture(async () => {
337
+ // #4503: a gives up at once (reconnectMs 0) with the resume hint; b re-attaches to the next daemon on its own.
338
+ const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], { ...h.client, reconnectMs: 0 }).then(() => null, (e) => e);
314
339
  await until(() => h.launched().length === 1 && h.lanePids().length === 1);
315
340
  const [idA] = listBatches(h.root);
316
- const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client).then(() => null, (e) => e);
341
+ const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
317
342
  await until(() => h.launched().includes(5) && h.lanePids().length === 2);
318
343
  const idB = listBatches(h.root).find((x) => x !== idA);
319
344
  const lanes = h.lanePids();
@@ -327,15 +352,102 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
327
352
  assert.equal(openBatch(h.root, idA).readReport(), null, "a stopped batch writes no report");
328
353
  assert.equal(openBatch(h.root, idB).readReport(), null);
329
354
  assert.match(String((await a)?.message), /--resume/);
330
- await b;
331
355
  await h.daemon(); // resumes both batches
332
- const ra = orchestrateFromCli(["--resume", idA], h.client), rb = orchestrateFromCli(["--resume", idB], h.client);
356
+ const ra = orchestrateFromCli(["--resume", idA], h.client);
333
357
  setTimeout(() => writeFileSync(h.release, ""), 200);
334
358
  assert.deepEqual((await ra).rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
335
- assert.deepEqual((await rb).rows.map((r) => [r.issue, r.outcome]), [[5, "merged"]]);
359
+ assert.deepEqual((await b).rows.map((r) => [r.issue, r.outcome]), [[5, "merged"]], "b re-attached and finished");
336
360
  assert.deepEqual(h.launched(), [1, 2, 5], "adopted, not relaunched");
337
361
  }));
338
- } finally { h.cleanup(); }
362
+ assert.ok(lines.some((l) => l.startsWith("schedd connection lost; reconnecting to batch ")), "the client said it reconnects");
363
+ const log = readFileSync(h.log, "utf8");
364
+ assert.match(log, /^schedd: exiting \(SIGTERM\)$/m);
365
+ assert.match(log, /^schedd: exited \(code 143\)$/m);
366
+ } finally { await h.cleanup(); }
367
+ });
368
+
369
+ it("#4503: every schedd exit writes a reason line", async () => {
370
+ for (const [ev, arg, code, re] of [["SIGTERM", undefined, 143, /^schedd: exiting \(SIGTERM\)$/], ["SIGINT", undefined, 130, /^schedd: exiting \(SIGINT\)$/],
371
+ ["SIGHUP", undefined, 129, /^schedd: exiting \(SIGHUP\)$/], ["uncaughtException", new Error("boom\nmore"), 1, /^schedd: exiting \(uncaught: boom\)$/],
372
+ ["unhandledRejection", new Error("nope"), 1, /^schedd: exiting \(unhandled rejection: nope\)$/]]) {
373
+ const handlers = {}, lines = [], exits = [];
374
+ exitOnSignals({ close: async () => { lines.push("closed"); } }, { on: (s, f) => { handlers[s] = f; }, exit: (c) => exits.push(c), log: (m) => lines.push(m) });
375
+ await handlers[ev](arg);
376
+ assert.match(lines[0], re, `${ev}: the reason is logged before close()`);
377
+ assert.equal(lines.at(-1), "closed");
378
+ assert.deepEqual(exits, [code]);
379
+ handlers.exit(code);
380
+ assert.equal(lines.at(-1), `schedd: exited (code ${code})`);
381
+ }
382
+ const dir = tmpDir("schedd-idle-"), lines = [];
383
+ const d = await startDaemon({ dir, root: join(dir, "batches"), runBatch: async () => ({}), log: (m) => lines.push(m), resumeOnStart: false, idleMs: 50 });
384
+ await d.closed;
385
+ assert.ok(lines.includes("schedd: idle, exiting"));
386
+ });
387
+
388
+ it("#4503: a daemon that lost its lock or socket logs it and stops its batches resumable, never removing the successor's lock or socket; a dead owner's takeover is logged", async () => {
389
+ const dir = tmpDir("schedd-lost-"), p = scheddPaths(dir), root = join(dir, "batches"), lines = [];
390
+ let stopped = false;
391
+ const d = await startDaemon({ dir, root, log: (m) => lines.push(m), resumeOnStart: false, lockCheckMs: 30,
392
+ runBatch: ({ sink, signal }) => { sink.batch({ id: "x", dir }); return new Promise((_, rej) => signal.addEventListener("abort", () => { stopped = true; rej(Object.assign(new Error("stopped"), { code: "batch.stopped" })); })); } });
393
+ request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["x"], cwd: dir, env: {} }).catch(() => {});
394
+ await until(() => lines.some((l) => l.includes("listening")));
395
+ await new Promise((r) => setTimeout(r, 50));
396
+ writeFileSync(p.lock, "999999999"); // a successor (now dead) took the lock
397
+ await d.closed;
398
+ assert.ok(lines.includes("schedd: lost lock (owner pid 999999999), exiting"), lines.join("\n"));
399
+ assert.equal(stopped, true, "the hosted batch loop was stopped (no report: the next daemon resumes it)");
400
+ assert.equal(readFileSync(p.lock, "utf8"), "999999999", "the successor's lock is left alone");
401
+ assert.equal(existsSync(p.sock), true, "the successor's socket is left alone");
402
+
403
+ const lines2 = [];
404
+ const d2 = await startDaemon({ dir, root, runBatch: async () => ({}), log: (m) => lines2.push(m), resumeOnStart: false, lockCheckMs: 30 });
405
+ assert.ok(lines2.includes("schedd: took over the lock of dead pid 999999999 (it exited without a clean shutdown)"), lines2.join("\n"));
406
+ rmSync(p.sock);
407
+ await d2.closed;
408
+ assert.ok(lines2.includes(`schedd: lost socket ${p.sock}, exiting`), lines2.join("\n"));
409
+ assert.equal(existsSync(p.lock), false, "its own lock is released");
410
+ });
411
+
412
+ it("#4503: a client whose daemon is SIGKILLed re-attaches to the next daemon and finishes, without relaunching a lane", async () => {
413
+ const h = host();
414
+ try {
415
+ const d1 = await h.daemon();
416
+ await withRoot(h, () => capture(async () => {
417
+ const a = orchestrateFromCli(["1", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
418
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
419
+ const gone = new Promise((r) => d1.on("exit", r));
420
+ d1.kill("SIGKILL");
421
+ await gone;
422
+ await new Promise((r) => setTimeout(r, 300)); // the client is retrying against the dead socket
423
+ await h.daemon();
424
+ setTimeout(() => writeFileSync(h.release, ""), 200);
425
+ assert.deepEqual((await a).rows.map((r) => [r.issue, r.outcome]), [[1, "merged"]]);
426
+ assert.deepEqual(h.launched(), [1], "adopted, not relaunched");
427
+ }));
428
+ assert.match(readFileSync(h.log, "utf8"), /^schedd: took over the lock of dead pid \d+ \(it exited without a clean shutdown\)$/m);
429
+ } finally { await h.cleanup(); }
430
+ });
431
+
432
+ it("#4503: test runs never resolve ~/.forge/sched, and teardown leaves no live stub lane", async () => {
433
+ assert.notEqual(schedDir(), join(homedir(), ".forge", "sched"));
434
+ assert.ok(schedDir().startsWith(tmpdir()), schedDir());
435
+ const h = host();
436
+ let pids = [], a;
437
+ try {
438
+ await h.daemon();
439
+ await withRoot(h, async () => {
440
+ a = capture(() => orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], { ...h.client, reconnectMs: 0 })).catch((e) => e);
441
+ await until(() => h.stubPids().length === 2 && h.lanePids().length === 2);
442
+ });
443
+ pids = h.stubPids();
444
+ for (const pid of pids) assert.equal(isLaneAlive(pid, null), true);
445
+ const [id] = listBatches(h.root);
446
+ assert.equal(openBatch(h.root, id).readPlan().env.FORGEDOCK_SCHED_DIR, schedDir(), "the lanes' env pins the test sched dir");
447
+ } finally { await h.cleanup(); }
448
+ await a;
449
+ assert.equal(pids.length, 2);
450
+ for (const pid of pids) assert.equal(isLaneAlive(pid, null), false, `stub lane ${pid} reaped`);
339
451
  });
340
452
 
341
453
  it("lane env: allowlisted keys come from the client only, the rest from the daemon", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.32.0",
3
+ "version": "1.32.1",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Fixtures for scripts/refresh-bot-token.sh (#4507): hosts.yml is replaced by rename, never rewritten in place.
6
+ # Run: bash scripts/refresh-bot-token.test.sh (bash 3.2 compatible). Stubs `node` and `gh`; no network.
7
+
8
+ set -u
9
+ DIR=$(cd "$(dirname "$0")" && pwd)
10
+ S="$DIR/refresh-bot-token.sh"
11
+ T=$(mktemp -d "${TMPDIR:-/tmp}/refresh-bot-token-test.XXXXXX")
12
+ trap 'rm -rf "$T"' EXIT
13
+ pass=0; fail=0
14
+ ok() { pass=$((pass+1)); }
15
+ no() { fail=$((fail+1)); echo "FAIL: $1"; }
16
+
17
+ mkdir -p "$T/bin" "$T/cfg"
18
+ echo key > "$T/key.pem"
19
+ cat > "$T/bin/node" <<'STUB'
20
+ #!/usr/bin/env bash
21
+ printf 'tok-new'
22
+ STUB
23
+ cat > "$T/bin/gh" <<'STUB'
24
+ #!/usr/bin/env bash
25
+ echo "$GH_CONFIG_DIR $*" >> "$GH_LOG"
26
+ case "$1 $2" in
27
+ "auth login") [ -n "${GH_FAIL:-}" ] && exit 1; cat > /dev/null; printf 'github.com:\n oauth_token: tok-new\n' > "$GH_CONFIG_DIR/hosts.yml" ;;
28
+ "auth status") echo "Logged in to github.com account rapiercraft-forgedock[bot]" ;;
29
+ esac
30
+ exit 0
31
+ STUB
32
+ chmod +x "$T/bin/node" "$T/bin/gh"
33
+ inode() { ls -i "$1" | awk '{print $1}'; }
34
+
35
+ # (a) success: gh writes in a staging dir; hosts.yml is renamed into place
36
+ printf 'github.com:\n oauth_token: tok-old\n' > "$T/cfg/hosts.yml"
37
+ before=$(inode "$T/cfg/hosts.yml")
38
+ OUT=$(PATH="$T/bin:$PATH" GH_CONFIG_DIR="$T/cfg" GH_LOG="$T/log" bash "$S" --pem "$T/key.pem" 2>&1); RC=$?
39
+ [ "$RC" = 0 ] && ok || no "(a) exit $RC: $OUT"
40
+ grep -q 'tok-new' "$T/cfg/hosts.yml" && ok || no "(a) hosts.yml not updated"
41
+ [ "$(inode "$T/cfg/hosts.yml")" != "$before" ] && ok || no "(a) hosts.yml rewritten in place (same inode)"
42
+ if awk '{print $1}' "$T/log" | grep -qx "$T/cfg"; then no "(a) gh ran against the live config dir"; else ok; fi
43
+ grep -q "auth login" "$T/log" && ok || no "(a) gh auth login not run"
44
+ ls -a "$T/cfg" | grep -q '^\.hosts-stage\.' && no "(a) staging dir left behind" || ok
45
+ case "$OUT" in *"token refreshed"*) ok ;; *) no "(a) success message missing: $OUT" ;; esac
46
+
47
+ # (b) failed login: the live hosts.yml stays byte-identical, exit != 0
48
+ printf 'github.com:\n oauth_token: tok-keep\n' > "$T/cfg/hosts.yml"
49
+ cp "$T/cfg/hosts.yml" "$T/expect"
50
+ PATH="$T/bin:$PATH" GH_CONFIG_DIR="$T/cfg" GH_LOG="$T/log" GH_FAIL=1 bash "$S" --pem "$T/key.pem" >/dev/null 2>&1; RC=$?
51
+ [ "$RC" != 0 ] && ok || no "(b) failed login exited 0"
52
+ cmp -s "$T/expect" "$T/cfg/hosts.yml" && ok || no "(b) hosts.yml changed after a failed login"
53
+ ls -a "$T/cfg" | grep -q '^\.hosts-stage\.' && no "(b) staging dir left behind" || ok
54
+
55
+ echo "refresh-bot-token: $pass passed, $fail failed"
56
+ [ "$fail" = 0 ]