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.
- package/bin/engine/phases.mjs +28 -7
- package/bin/engine-cli.mjs +28 -13
- package/bin/lean/batch.mjs +9 -3
- package/bin/lean/batch.test.mjs +31 -1
- package/bin/lean/effects.mjs +28 -2
- package/bin/lean/execution.mjs +4 -2
- package/bin/lean/lean.mjs +16 -3
- package/bin/lean/lean.test.mjs +88 -3
- package/bin/lean/orchestrate.mjs +30 -9
- package/bin/lean/retry.mjs +15 -1
- package/bin/lean/schedd.mjs +50 -14
- package/bin/lean/schedd.test.mjs +133 -21
- package/package.json +1 -1
- package/scripts/refresh-bot-token.test.sh +56 -0
package/bin/engine/phases.mjs
CHANGED
|
@@ -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
|
|
313
|
-
|
|
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
|
|
318
|
-
*
|
|
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
|
|
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`
|
|
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
|
*/
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
1202
|
+
let sock = await connect(), req = msg, reattached = false, losses = 0;
|
|
1203
1203
|
let id = resumeId;
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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.
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -181,7 +181,10 @@ export async function waitLane({ batch, issue, pid, alive = isLaneAlive, sleep =
|
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
/**
|
|
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)
|
|
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
|
};
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -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) }),
|
package/bin/lean/execution.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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);
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -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"] });
|
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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) {
|
package/bin/lean/retry.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/bin/lean/schedd.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 })
|
|
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
|
-
/**
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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(
|
|
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>} */
|
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
63
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 ]
|