forgedock 1.42.0 → 1.43.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/outcome-codes.mjs +1 -0
- package/bin/engine-cli.mjs +91 -19
- package/bin/journey.mjs +2 -2
- package/bin/lean/autochildren.mjs +103 -0
- package/bin/lean/autochildren.test.mjs +126 -0
- package/bin/lean/batch.mjs +11 -3
- package/bin/lean/batch.test.mjs +66 -2
- package/bin/lean/conformance.mjs +140 -9
- package/bin/lean/conformance.test.mjs +164 -2
- package/bin/lean/effects.mjs +1 -1
- package/bin/lean/execution.test.mjs +9 -6
- package/bin/lean/fixround.test.mjs +9 -1
- package/bin/lean/guardgate.mjs +66 -7
- package/bin/lean/guardgate.test.mjs +109 -3
- package/bin/lean/heavy.test.mjs +11 -1
- package/bin/lean/issuewatch.mjs +2 -2
- package/bin/lean/lean.mjs +80 -24
- package/bin/lean/lean.test.mjs +119 -7
- package/bin/lean/orchestrate-blocked.test.mjs +16 -0
- package/bin/lean/orchestrate-children.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +51 -12
- package/bin/lean/postmerge.mjs +5 -2
- package/bin/lean/postmerge.test.mjs +7 -0
- package/bin/lean/preflight.mjs +21 -0
- package/bin/lean/preflight.test.mjs +15 -1
- package/bin/lean/probe.mjs +1 -1
- package/bin/lean/prompts.mjs +19 -4
- package/bin/lean/schedd.mjs +116 -19
- package/bin/lean/schedd.test.mjs +201 -2
- package/bin/lean/selftest-live.mjs +1 -1
- package/bin/lean/selftest-nightly.mjs +1 -1
- package/bin/lean/signoff.mjs +11 -1
- package/bin/lean/signoff.test.mjs +24 -1
- package/bin/lean/toolstate.mjs +19 -3
- package/bin/lean/toolstate.test.mjs +22 -0
- package/bin/lean/verify.mjs +9 -1
- package/bin/lean/verify.test.mjs +4 -0
- package/bin/pool/agent.mjs +1 -1
- package/bin/runner.mjs +1 -1
- package/commands/orchestrate.md +2 -2
- package/docs/CONFIG.md +8 -2
- package/docs/OUTCOME-CODES.md +1 -0
- package/docs/RELEASE-NOTES.md +3 -0
- package/package.json +1 -1
- package/scripts/ci-local/run.mjs +1 -1
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +7 -0
- package/scripts/review-pr-staging-scope.test.sh +3 -3
- package/scripts/review-prepare.sh +3 -0
- package/scripts/review-staging-pre.sh +88 -43
- package/scripts/review-staging-pre.test.sh +42 -1
|
@@ -45,6 +45,7 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
45
45
|
RUN_INTERRUPTED: "run.interrupted", // forge#4179: SIGTERM/SIGINT on a core run; teardown ran from the ledger, lane requeued
|
|
46
46
|
PATH_SWITCH_CONFLICT: "path.switch_conflict",
|
|
47
47
|
ENGINE_ERROR: "engine.error",
|
|
48
|
+
ENGINE_DCO_REWRITE: "engine.dco_rewrite", // forge#4997: the DCO sign-off rewrite of unpushed commits failed (git filter-branch); mechanical engine-error (not needs-human), relaunched by the batch runner
|
|
48
49
|
ENGINE_VALIDATE_RESULT_LOST: "engine.validate_result_lost", // forge#4708: a validate result recorded before a CI fix round is missing after it and could not be carried forward
|
|
49
50
|
INFRA_DISK_QUOTA: "infra.disk_quota",
|
|
50
51
|
INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lea
|
|
|
32
32
|
import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
|
|
33
33
|
import { parseLightConfig, LIGHT_DEFAULTS } from "./lean/light.mjs";
|
|
34
34
|
import { OUTCOME_CODES } from "./engine/outcome-codes.mjs";
|
|
35
|
+
import * as _autoChildren from "./lean/autochildren.mjs";
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
|
|
@@ -408,8 +409,8 @@ export function readVerify(cwd = process.cwd()) {
|
|
|
408
409
|
try {
|
|
409
410
|
const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.verify;
|
|
410
411
|
if (!sec || typeof sec !== "object") return undefined;
|
|
411
|
-
const invariants = readInvariants(cwd), flaky = readYamlList(cwd, "verify", "flaky");
|
|
412
|
-
return parseVerify({ ...sec, ...(invariants ? { invariants } : {}), ...(flaky ? { flaky } : {}) });
|
|
412
|
+
const invariants = readInvariants(cwd), flaky = readYamlList(cwd, "verify", "flaky"), guard_command = readYamlMap(cwd, "verify", "guard_command");
|
|
413
|
+
return parseVerify({ ...sec, ...(invariants ? { invariants } : {}), ...(flaky ? { flaky } : {}), ...(guard_command ? { guard_command } : {}) });
|
|
413
414
|
} catch { return undefined; }
|
|
414
415
|
}
|
|
415
416
|
/** #4542: forge.yaml `execution.host_capabilities` (+ patterns) for a lane's remote-block mapping; undefined when none are declared. */
|
|
@@ -447,6 +448,30 @@ export function readPrecheckCommands(cwd = process.cwd()) {
|
|
|
447
448
|
export function readInvariants(cwd = process.cwd()) {
|
|
448
449
|
return readYamlList(cwd, "verify", "invariants");
|
|
449
450
|
}
|
|
451
|
+
/** #4792: a block map under `section.key` (` ".py": "cmd"` or ` py: cmd`); the one-level parser drops these lines. undefined when absent or empty. */
|
|
452
|
+
export function readYamlMap(cwd, section, key) {
|
|
453
|
+
let raw;
|
|
454
|
+
try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
|
|
455
|
+
const out = {};
|
|
456
|
+
const head = new RegExp(`^${section}:\\s*(#.*)?$`), keyRe = new RegExp(`^\\s+${key}:\\s*(#.*)?$`);
|
|
457
|
+
let inSection = false, mapIndent = null;
|
|
458
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
459
|
+
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
460
|
+
if (/^\S/.test(line)) { inSection = head.test(line); mapIndent = null; continue; }
|
|
461
|
+
if (!inSection) continue;
|
|
462
|
+
const indent = line.length - line.trimStart().length;
|
|
463
|
+
if (mapIndent === null) { if (keyRe.test(line)) mapIndent = indent; continue; }
|
|
464
|
+
if (indent <= mapIndent) { mapIndent = keyRe.test(line) ? indent : null; continue; }
|
|
465
|
+
const m = /^\s+(?:"([^"]+)"|'([^']+)'|([^\s:#"']+))\s*:\s+(.*)$/.exec(line);
|
|
466
|
+
if (!m) continue;
|
|
467
|
+
let v = m[4].trim();
|
|
468
|
+
const q = /^"((?:[^"\\]|\\.)*)"\s*(#.*)?$/.exec(v) ?? /^'((?:[^']|'')*)'\s*(#.*)?$/.exec(v);
|
|
469
|
+
if (q) v = v.startsWith('"') ? q[1].replace(/\\(["\\])/g, "$1") : q[1].replace(/''/g, "'");
|
|
470
|
+
else v = v.replace(/\s+#.*$/, "").trim();
|
|
471
|
+
if (v) out[m[1] ?? m[2] ?? m[3]] = v;
|
|
472
|
+
}
|
|
473
|
+
return Object.keys(out).length ? out : undefined;
|
|
474
|
+
}
|
|
450
475
|
function readYamlList(cwd, section, key) {
|
|
451
476
|
let raw;
|
|
452
477
|
try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
|
|
@@ -496,11 +521,14 @@ function readPrecheckOptions(cwd = process.cwd()) {
|
|
|
496
521
|
*/
|
|
497
522
|
export function readEnvConfig(cwd = process.cwd()) {
|
|
498
523
|
try {
|
|
499
|
-
const
|
|
524
|
+
const y = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"));
|
|
525
|
+
const e = y?.env ?? {};
|
|
526
|
+
const tr = y?.verification?.test_runners; // #4857: {path prefix: runner command prefix} for validate commands
|
|
527
|
+
const runners = tr && typeof tr === "object" && !Array.isArray(tr) ? Object.fromEntries(Object.entries(tr).filter(([k, v]) => k && typeof v === "string" && v.trim())) : {};
|
|
500
528
|
const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
501
529
|
const kb = typeof e.sparse_max_kb === "string" && /^\d+$/.test(e.sparse_max_kb.trim()) ? Number(e.sparse_max_kb) : e.sparse_max_kb;
|
|
502
530
|
return { ...(s(e.toolchain) ? { toolchainOverride: s(e.toolchain) } : {}), ...(s(e.setup) ? { envSetup: s(e.setup) } : {}),
|
|
503
|
-
...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}) };
|
|
531
|
+
...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}) };
|
|
504
532
|
} catch { return {}; }
|
|
505
533
|
}
|
|
506
534
|
export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
|
|
@@ -1204,8 +1232,8 @@ async function envBatch({ argv, root, print = (l) => console.log(l) }) {
|
|
|
1204
1232
|
return { ok: true, batch: id };
|
|
1205
1233
|
}
|
|
1206
1234
|
|
|
1207
|
-
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --clear-breaker <id> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...> | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY";
|
|
1208
|
-
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason", "--force"];
|
|
1235
|
+
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] [--auto-children off|decomposed|epic|all] [--epic N] | --resume <id> [issue...] | --set-parallel <id> <K> | --clear-breaker <id> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...> | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY";
|
|
1236
|
+
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason", "--force", "--auto-children", "--epic"];
|
|
1209
1237
|
const issuesOf = (argv) => {
|
|
1210
1238
|
const issues = [];
|
|
1211
1239
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -1255,6 +1283,11 @@ async function addedEdges({ added, records, io, repo }) {
|
|
|
1255
1283
|
* state`): skipped `closed` (`unreadable` when the read fails); the rest go to the batch's `add` file, through the daemon
|
|
1256
1284
|
* when `viaDaemon(issues)` answers (non-null), else written here (batch.mjs `addIssues` skips issues already in the batch).
|
|
1257
1285
|
*/
|
|
1286
|
+
/** #5000: what happened to the batch after an add/requeue; never implies a run when no daemon hosts it. */
|
|
1287
|
+
const hostedLine = (id, res) => res.reopened ? `batch ${id}: finished batch reopened and hosted by the schedd`
|
|
1288
|
+
: res.hosted ? `batch ${id}: hosted by the schedd; the runner picks it up on its next pass`
|
|
1289
|
+
: `batch ${id}: queued but NOT hosted (no schedd hosts it); run: forgedock orchestrate --resume ${id}`;
|
|
1290
|
+
|
|
1258
1291
|
async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1259
1292
|
const batchMod = await import("./lean/batch.mjs");
|
|
1260
1293
|
const b = batchMod.openBatch(root, id);
|
|
@@ -1280,11 +1313,13 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
|
|
|
1280
1313
|
let res = { added: [], skipped: [], requeued: [] };
|
|
1281
1314
|
if (open.length) {
|
|
1282
1315
|
res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
|
|
1283
|
-
if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // #4687: no daemon: the next --resume reruns it
|
|
1316
|
+
if (res.reopened === undefined && (res.added?.length || res.requeued?.length) && b.readReport()) b.clearReport(); // #4687/#5000: no daemon: the next --resume reruns it
|
|
1284
1317
|
}
|
|
1285
1318
|
const out = { ok: true, batch: id, added: res.added ?? [], requeued: res.requeued ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
|
|
1319
|
+
if (res.hosted !== undefined) { out.hosted = !!res.hosted; out.reopened = !!res.reopened; }
|
|
1286
1320
|
out.edges = await addedEdges({ added: out.added, records, io, repo }); // #4773
|
|
1287
1321
|
print(`batch ${id}: added ${out.added.map((n) => `#${n}`).join(" ") || (out.requeued.length ? "" : "(none)")}${out.requeued.length ? `${out.added.length ? " " : ""}${out.requeued.map((n) => `requeued #${n}`).join(" ")}` : ""}${out.skipped.length ? `; skipped ${out.skipped.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
|
|
1322
|
+
if (out.added.length || out.requeued.length) print(hostedLine(id, res));
|
|
1288
1323
|
if (out.edges.length) print(`batch ${id}: edges ${out.edges.map((e) => `#${e.from} blocked by #${e.to}`).join(", ")}`);
|
|
1289
1324
|
return out;
|
|
1290
1325
|
}
|
|
@@ -1343,7 +1378,9 @@ async function requeueBatch({ id, tokens, reason, root, io, viaDaemon = null, pr
|
|
|
1343
1378
|
if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // no daemon: the next --resume reruns it
|
|
1344
1379
|
}
|
|
1345
1380
|
const out = { ok: true, batch: id, requeued: res.requeued ?? [], refused: [...(res.refused ?? []), ...refused] };
|
|
1381
|
+
if (res.hosted !== undefined) { out.hosted = !!res.hosted; out.reopened = !!res.reopened; }
|
|
1346
1382
|
print(`batch ${id}: ${out.requeued.map((n) => `requeued #${n}`).join(" ") || "requeued (none)"}${out.refused.length ? `; refused ${out.refused.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
|
|
1383
|
+
if (out.requeued.length) print(hostedLine(id, res));
|
|
1347
1384
|
return out;
|
|
1348
1385
|
}
|
|
1349
1386
|
|
|
@@ -1379,7 +1416,7 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1379
1416
|
if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
|
|
1380
1417
|
let sock = null;
|
|
1381
1418
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1382
|
-
if (sock) await schedd.request(sock, { op: "set-parallel", id, k });
|
|
1419
|
+
if (sock) await schedd.request(sock, { op: "set-parallel", id, k }, undefined, { timeoutMs: schedd.REQUEST_MS });
|
|
1383
1420
|
else {
|
|
1384
1421
|
// No daemon: the batch is not running anywhere; the parallel file is all there is to change.
|
|
1385
1422
|
const batchMod = await import("./lean/batch.mjs");
|
|
@@ -1394,17 +1431,17 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1394
1431
|
const addVia = (id) => async (issues) => {
|
|
1395
1432
|
let sock = null;
|
|
1396
1433
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1397
|
-
return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
|
|
1434
|
+
return sock ? schedd.request(sock, { op: "add", id, issues }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
|
|
1398
1435
|
};
|
|
1399
1436
|
const requeueVia = (id) => async (issues, reason, by) => {
|
|
1400
1437
|
let sock = null;
|
|
1401
1438
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1402
|
-
return sock ? schedd.request(sock, { op: "requeue", id, issues, reason, by }) : null;
|
|
1439
|
+
return sock ? schedd.request(sock, { op: "requeue", id, issues, reason, by }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
|
|
1403
1440
|
};
|
|
1404
1441
|
const holdVia = (id) => async (op, issues) => {
|
|
1405
1442
|
let sock = null;
|
|
1406
1443
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1407
|
-
return sock ? schedd.request(sock, { op, id, issues }) : null;
|
|
1444
|
+
return sock ? schedd.request(sock, { op, id, issues }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
|
|
1408
1445
|
};
|
|
1409
1446
|
const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
|
|
1410
1447
|
if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root: await batchRootOf() });
|
|
@@ -1414,7 +1451,7 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1414
1451
|
return cancelBatch({ id, root: await batchRootOf(), stopRemote: argv.includes("--stop-remote"), viaDaemon: async () => {
|
|
1415
1452
|
let sock = null;
|
|
1416
1453
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1417
|
-
return sock ? schedd.request(sock, { op: "cancel", id, stopRemote: argv.includes("--stop-remote") }) : null;
|
|
1454
|
+
return sock ? schedd.request(sock, { op: "cancel", id, stopRemote: argv.includes("--stop-remote") }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
|
|
1418
1455
|
} });
|
|
1419
1456
|
}
|
|
1420
1457
|
if (argv.includes("--requeue")) {
|
|
@@ -1718,6 +1755,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1718
1755
|
lanes = { ...lanes, stopLane };
|
|
1719
1756
|
}
|
|
1720
1757
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
1758
|
+
// #4965: --auto-children overrides forge.yaml; it is part of plan.argv, so --resume keeps it
|
|
1759
|
+
const autoMod = _autoChildren, yamlAuto = readAutoChildren(cwd);
|
|
1760
|
+
const autoChildren = { mode: argv.includes("--auto-children") ? autoMod.parseAutoModeFlag(flag(argv, "--auto-children")) : yamlAuto.mode, pollS: yamlAuto.pollS,
|
|
1761
|
+
epics: argv.flatMap((a, i) => (a === "--epic" && /^#?\d+$/.test(argv[i + 1] ?? "") ? [parseInt(argv[i + 1].replace("#", ""), 10)] : [])) };
|
|
1721
1762
|
// #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
|
|
1722
1763
|
const recoveryOn = Boolean(orch.recovery?.maxAttempts) && (deps.enginePath !== undefined ? deps.enginePath : readEnginePath(cwd)) !== "full";
|
|
1723
1764
|
if (orch.recovery?.maxAttempts && !recoveryOn) sink.line("recovery: off (engine.path is full; recovery lanes run on the core path only)");
|
|
@@ -1835,7 +1876,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1835
1876
|
const hostIo = await laneCheck.hostLaneIo({ env });
|
|
1836
1877
|
preflight = async () => (await laneCheck.runLaneChecks({ exec: execForNeeds, env, io: hostIo })).filter((c) => !c.ok);
|
|
1837
1878
|
}
|
|
1838
|
-
let rows;
|
|
1879
|
+
let rows, childFields = null; // #4965: the host gh's accepted `issue list --json` fields, read once from its help
|
|
1839
1880
|
try {
|
|
1840
1881
|
rows = await orchestrate({
|
|
1841
1882
|
...(estimate ? { estimate } : {}),
|
|
@@ -1843,9 +1884,11 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1843
1884
|
...(preflight ? { preflight } : {}),
|
|
1844
1885
|
...(orch.blockedLabel ? { getBlocked: async () => JSON.parse(await leanIo.gh(["issue", "list", "-R", repo, "--label", orch.blockedLabel, "--state", "open", "--limit", "200", "--json", "number"])).map((x) => x.number) } : {}), // #4773
|
|
1845
1886
|
trusted: (n) => trustedOf.get(n) === true,
|
|
1887
|
+
autoChildren,
|
|
1888
|
+
...(autoMod.addsEpicChildren(autoChildren.mode) ? { getChildren: async () => ({ list: await autoMod.readChildList({ gh: (a) => leanIo.gh(a), repo, fields: childFields ??= await autoMod.hostListFields((a) => leanIo.gh(a)) }), elsewhere: autoMod.liveElsewhere({ batchMod, root, self: batch.id, repo }) }) } : {}),
|
|
1846
1889
|
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, getGates: batch.readGates, ...lanes,
|
|
1847
1890
|
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
|
|
1848
|
-
...(registry ? { sched: { registry, batch: batch.id, repo, cap: liveLaneCap(orch.globalParallel ?? null), exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1891
|
+
...(registry ? { sched: { registry, batch: batch.id, repo, cap: liveLaneCap(orch.globalParallel ?? null, { cfg: readAdmission(cwd) }), exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1849
1892
|
...(remote ? { remote } : {}),
|
|
1850
1893
|
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
1851
1894
|
...(recoveryOn ? { recovery: orch.recovery } : {}),
|
|
@@ -1928,7 +1971,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1928
1971
|
} catch { /* alerts never fail a batch */ }
|
|
1929
1972
|
for (const l of report) sink.line(l);
|
|
1930
1973
|
const ok = rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome));
|
|
1931
|
-
|
|
1974
|
+
// #4917: a batch with any pending issue is not finished: no report, so a resume or the daemon picks it up
|
|
1975
|
+
let unfinished = false;
|
|
1976
|
+
try { unfinished = batch.pendingIssues().length > 0 && !batch.readCancel(); } catch { unfinished = false; } // a cancel ends the batch for good
|
|
1977
|
+
if (!unfinished) try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
|
|
1932
1978
|
return { rows, crossLane, batch: batch.id, reclaimed, ok };
|
|
1933
1979
|
}
|
|
1934
1980
|
|
|
@@ -2058,8 +2104,21 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
2058
2104
|
if (argv[0] === "status") {
|
|
2059
2105
|
let sock;
|
|
2060
2106
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { console.log("schedd: not running"); return { running: false }; }
|
|
2061
|
-
|
|
2062
|
-
|
|
2107
|
+
let st;
|
|
2108
|
+
try { st = await schedd.request(sock, { op: "status" }, undefined, { timeoutMs: deps.statusMs ?? schedd.REQUEST_MS }); }
|
|
2109
|
+
catch (e) {
|
|
2110
|
+
if (e?.code !== "schedd.timeout") throw e;
|
|
2111
|
+
console.log(`schedd: not responding (${e.message}); see ${schedd.scheddPaths(dir).log}`);
|
|
2112
|
+
process.exitCode = 1;
|
|
2113
|
+
return { running: true, responding: false };
|
|
2114
|
+
}
|
|
2115
|
+
console.log(`schedd: pid ${st.pid}, ${st.batches.length} batch(es)${Number.isFinite(st.lastTick) ? `, last tick ${Math.round((Date.now() - st.lastTick) / 1000)} s ago` : ""}`);
|
|
2116
|
+
for (const b of st.batches) {
|
|
2117
|
+
const age = b.lastProgressAgeSec == null ? "no progress yet" : `last progress ${b.lastProgressAgeSec}s ago`;
|
|
2118
|
+
const pend = b.pendingAdds?.length ? `, pending adds: ${b.pendingAdds.map((n) => `#${n}`).join(" ")}` : "";
|
|
2119
|
+
console.log(` ${b.id}${b.running ? "" : " (not hosted)"}: ${age}${pend}`);
|
|
2120
|
+
}
|
|
2121
|
+
for (const u of st.unhosted ?? []) console.log(`schedd: batch ${u.id} is not hosted but has ${u.pending.length} pending: ${u.pending.map((n) => `#${n}`).join(" ")} (forgedock orchestrate --resume ${u.id})`);
|
|
2063
2122
|
return { running: true, ...st };
|
|
2064
2123
|
}
|
|
2065
2124
|
if (argv[0] === "watchdog") { // #4780: run by the schedd watchdog timer; signals a live daemon whose heartbeat went stale
|
|
@@ -2073,7 +2132,13 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
2073
2132
|
process.title = "forgedock-schedd"; // distinctive name for OOM-killer avoid lists and ps (#4673)
|
|
2074
2133
|
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }),
|
|
2075
2134
|
...(deps.daemonPreflight !== undefined ? { preflight: deps.daemonPreflight } : deps.batchDeps ? {} : { preflight: async () => { const lc = await import("./lean/lanecheck.mjs"); return (await lc.runLaneChecks({ exec: readExecution(process.cwd()), io: await lc.hostLaneIo() })).filter((c) => !c.ok); } }) });
|
|
2076
|
-
if (!d.running)
|
|
2135
|
+
if (!d.running) {
|
|
2136
|
+
// #4775: under systemd, exiting here (code 0) makes `Restart=always` loop while another daemon holds the lock: stand by, take over when it dies
|
|
2137
|
+
if (!(deps.standby ?? Boolean(process.env.INVOCATION_ID))) return d;
|
|
2138
|
+
const wait = deps.standbyMs ?? 5000, alive = deps.alive ?? ((await import("./lean/sched.mjs")).pidAlive);
|
|
2139
|
+
do await (deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(wait); while (alive(d.owner));
|
|
2140
|
+
return scheddFromCli(argv, deps);
|
|
2141
|
+
}
|
|
2077
2142
|
// #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
|
|
2078
2143
|
schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
|
|
2079
2144
|
await d.closed;
|
|
@@ -2129,7 +2194,7 @@ export async function decisionsFromCli(argv, deps = {}) {
|
|
|
2129
2194
|
const [sub, ...rest] = argv;
|
|
2130
2195
|
if (sub !== "list") throw new Error(usage);
|
|
2131
2196
|
const flag = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : null; };
|
|
2132
|
-
const run = deps.exec ?? (async (cmd, args) => String((await pexec(cmd, args, { maxBuffer: 64 * 1024 * 1024 })).stdout));
|
|
2197
|
+
const run = deps.exec ?? (async (cmd, args) => String((await pexec(cmd, args, { maxBuffer: 64 * 1024 * 1024, timeout: 60000 })).stdout));
|
|
2133
2198
|
const repo = flag("--repo") ?? String(await run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])).trim();
|
|
2134
2199
|
if (rest.includes("--refresh")) {
|
|
2135
2200
|
const jq = '.[] | select(.body | startswith("<!-- FORGE:DECISION {")) | {body, user, author_association, issue_url} | tojson';
|
|
@@ -2266,6 +2331,13 @@ export async function schedFromCli(argv, deps = {}) {
|
|
|
2266
2331
|
/** #4773: is the author of a `viewIssue` record (author plus the REST association, #4859) trusted (the engine admission's `isTrustedActor`)? */
|
|
2267
2332
|
const bodyTrusted = (v) => isTrustedActor({ association: v?.authorAssociation, type: v?.author?.is_bot ? "Bot" : "User", login: v?.author?.login });
|
|
2268
2333
|
|
|
2334
|
+
/** forge.yaml `orchestration.auto_add_children` (#4965; default `decomposed`) and `auto_add_children_poll_s` (default 120). */
|
|
2335
|
+
export function readAutoChildren(cwd = process.cwd()) {
|
|
2336
|
+
let o = {};
|
|
2337
|
+
try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestration ?? {}; } catch { o = {}; }
|
|
2338
|
+
return { mode: _autoChildren.parseAutoMode(o.auto_add_children), pollS: _autoChildren.parsePollS(o.auto_add_children_poll_s) };
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2269
2341
|
/**
|
|
2270
2342
|
* forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off),
|
|
2271
2343
|
* `orchestration.max_deferrals` (default 3) and `orchestration.global_parallel` (#4191: host-wide lane cap; unset = none). The parser flattens one nested level, so the breaker keys arrive as
|
package/bin/journey.mjs
CHANGED
|
@@ -642,9 +642,9 @@ export function openUrl(url) {
|
|
|
642
642
|
if (process.platform === "win32") {
|
|
643
643
|
spawnSync("cmd", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true });
|
|
644
644
|
} else if (process.platform === "darwin") {
|
|
645
|
-
spawnSync("open", [url], { detached: true, stdio: "ignore" });
|
|
645
|
+
spawnSync("open", [url], { detached: true, windowsHide: true, stdio: "ignore" });
|
|
646
646
|
} else {
|
|
647
|
-
spawnSync("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
647
|
+
spawnSync("xdg-open", [url], { detached: true, windowsHide: true, stdio: "ignore" });
|
|
648
648
|
}
|
|
649
649
|
} catch {
|
|
650
650
|
// Best-effort — if the browser can't be opened, the journey continues.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// Auto-add of epic and decomposition children to a running batch (#4965): `orchestration.auto_add_children`.
|
|
4
|
+
// Pure and deterministic: the child scan reads one open-issue list per pass; no LLM call.
|
|
5
|
+
import { parentOf } from "./context.mjs";
|
|
6
|
+
import { parseHelpFields } from "./selftest-static.mjs";
|
|
7
|
+
|
|
8
|
+
export const AUTO_MODES = ["off", "decomposed", "epic", "all"];
|
|
9
|
+
export const DEFAULT_AUTO_MODE = "decomposed";
|
|
10
|
+
export const DEFAULT_POLL_S = 120;
|
|
11
|
+
|
|
12
|
+
/** @returns {string} a valid mode (case-insensitive), else `fallback` (forge.yaml typos never stop a batch). */
|
|
13
|
+
export function parseAutoMode(v, fallback = DEFAULT_AUTO_MODE) {
|
|
14
|
+
const m = String(v ?? "").trim().toLowerCase();
|
|
15
|
+
return AUTO_MODES.includes(m) ? m : fallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The CLI form: an unknown mode is an operator error. */
|
|
19
|
+
export function parseAutoModeFlag(v) {
|
|
20
|
+
const m = String(v ?? "").trim().toLowerCase();
|
|
21
|
+
if (!AUTO_MODES.includes(m)) throw new Error(`--auto-children must be one of ${AUTO_MODES.join(" | ")}`);
|
|
22
|
+
return m;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Seconds between scans: a positive integer, else the default. */
|
|
26
|
+
export function parsePollS(v, fallback = DEFAULT_POLL_S) {
|
|
27
|
+
const n = Number(v);
|
|
28
|
+
return v !== undefined && v !== null && v !== "" && Number.isInteger(n) && n >= 1 ? n : fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Does `mode` add the epic/tracker children (`epic`, `all`)? */
|
|
32
|
+
export const addsEpicChildren = (mode) => mode === "epic" || mode === "all";
|
|
33
|
+
/** Does `mode` join the sub-issues of a decomposed lane (`decomposed`, `all`; `epic` keeps today's join too)? */
|
|
34
|
+
export const joinsDecomposed = (mode) => mode !== "off";
|
|
35
|
+
|
|
36
|
+
const nodesOf = (v) => (Array.isArray(v) ? v : Array.isArray(v?.nodes) ? v.nodes : []);
|
|
37
|
+
const labelsOf = (it) => nodesOf(it?.labels).map((l) => String(l?.name ?? l));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {{number:number, body?:string, labels?:any, parent?:any, subIssues?:any}[]} list one open-issue list
|
|
41
|
+
* @param {Iterable<number>} roots the batch issues plus the `--epic N` epics
|
|
42
|
+
* @param {Iterable<number>} skip issues the batch already holds
|
|
43
|
+
* @returns {number[]} open children of a root, ascending: `**Parent**: #P` line, `parent` field, or the root's `subIssues`;
|
|
44
|
+
* an issue labelled `needs-human` is left out.
|
|
45
|
+
*/
|
|
46
|
+
export function selectChildren({ list, roots, skip = [] }) {
|
|
47
|
+
const rootSet = new Set(roots), skipSet = new Set(skip), open = new Set(), out = new Set();
|
|
48
|
+
for (const it of list ?? []) if (Number.isSafeInteger(it?.number)) open.add(it.number);
|
|
49
|
+
for (const it of list ?? []) {
|
|
50
|
+
const n = it?.number;
|
|
51
|
+
if (!open.has(n)) continue;
|
|
52
|
+
if (rootSet.has(n)) for (const s of nodesOf(it.subIssues)) if (open.has(s?.number)) out.add(s.number);
|
|
53
|
+
const p = it.parent?.number ?? parentOf(it.body ?? "");
|
|
54
|
+
if (p != null && rootSet.has(p)) out.add(n);
|
|
55
|
+
}
|
|
56
|
+
const byNum = new Map((list ?? []).map((it) => [it.number, it]));
|
|
57
|
+
return [...out].filter((n) => !skipSet.has(n) && !labelsOf(byNum.get(n)).includes("needs-human")).sort((a, b) => a - b);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const REQUIRED_FIELDS = ["number", "body", "labels"], OPTIONAL_FIELDS = ["parent", "subIssues"];
|
|
61
|
+
|
|
62
|
+
/** The `--json` list this host's `gh issue list` accepts (read from its `--help`, K4): the required fields plus whichever sub-issue fields it advertises. */
|
|
63
|
+
export async function hostListFields(gh) {
|
|
64
|
+
let have = [];
|
|
65
|
+
try { have = parseHelpFields(await gh(["issue", "list", "--help"])); } catch { have = []; }
|
|
66
|
+
return [...REQUIRED_FIELDS, ...OPTIONAL_FIELDS.filter((f) => have.includes(f))];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One open-issue list with what `selectChildren` reads. A host `gh` without `parent`/`subIssues` still lists with the
|
|
71
|
+
* required fields, so children are found by their `**Parent**: #P` line.
|
|
72
|
+
*/
|
|
73
|
+
export async function readChildList({ gh, repo, limit = 1000, fields = null }) {
|
|
74
|
+
const f = fields ?? (await hostListFields(gh));
|
|
75
|
+
const raw = await gh(["issue", "list", "-R", repo, "--state", "open", "--limit", String(limit), "--json", f.join(",")]);
|
|
76
|
+
const v = JSON.parse(raw);
|
|
77
|
+
return Array.isArray(v) ? v : [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Issues another live batch of this repo still holds (in its order, no final row): children there are not added here.
|
|
82
|
+
* @param {{batchMod:object, root:string, self:string, repo:string}} p
|
|
83
|
+
*/
|
|
84
|
+
export function liveElsewhere({ batchMod, root, self, repo }) {
|
|
85
|
+
const held = new Set();
|
|
86
|
+
let ids = [];
|
|
87
|
+
try { ids = batchMod.listBatches(root); } catch { return held; }
|
|
88
|
+
for (const raw of ids) {
|
|
89
|
+
const id = typeof raw === "string" ? raw : raw?.id;
|
|
90
|
+
if (!id || id === self) continue;
|
|
91
|
+
try {
|
|
92
|
+
const b = batchMod.openBatch(root, id);
|
|
93
|
+
if (b.readReport() || (b.readPlan()?.repo ?? repo) !== repo) continue;
|
|
94
|
+
const st = b.store.load();
|
|
95
|
+
if (!st) continue;
|
|
96
|
+
for (const n of st.order ?? []) if (!(st.results ?? {})[n]) held.add(n);
|
|
97
|
+
} catch { /* an unreadable batch holds nothing */ }
|
|
98
|
+
}
|
|
99
|
+
return held;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `#a blocked by #b, ...` for the declared edges of the added children that stay inside the batch. */
|
|
103
|
+
export const edgeText = (edges) => edges.map((e) => `#${e.from} blocked by #${e.to}`).join(", ");
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4965: auto-add of epic and decomposition children: mode parse, child selection, filters.
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { parseAutoMode, parseAutoModeFlag, parsePollS, selectChildren, readChildList, hostListFields, liveElsewhere, joinsDecomposed, addsEpicChildren, DEFAULT_POLL_S } from "./autochildren.mjs";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
|
|
9
|
+
describe("#4965 modes", () => {
|
|
10
|
+
it("default is decomposed; unknown yaml values fall back; the flag rejects them", () => {
|
|
11
|
+
assert.equal(parseAutoMode(undefined), "decomposed");
|
|
12
|
+
assert.equal(parseAutoMode("EPIC"), "epic");
|
|
13
|
+
assert.equal(parseAutoMode("bogus"), "decomposed");
|
|
14
|
+
assert.equal(parseAutoModeFlag("all"), "all");
|
|
15
|
+
assert.throws(() => parseAutoModeFlag("bogus"), /--auto-children/);
|
|
16
|
+
assert.equal(parsePollS(undefined), DEFAULT_POLL_S);
|
|
17
|
+
assert.equal(parsePollS("30"), 30);
|
|
18
|
+
assert.equal(parsePollS("0"), DEFAULT_POLL_S);
|
|
19
|
+
});
|
|
20
|
+
it("which modes add epic children and which join decomposed", () => {
|
|
21
|
+
assert.deepEqual(["off", "decomposed", "epic", "all"].map(addsEpicChildren), [false, false, true, true]);
|
|
22
|
+
assert.deepEqual(["off", "decomposed", "epic", "all"].map(joinsDecomposed), [false, true, true, true]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("#4965 selectChildren", () => {
|
|
27
|
+
const list = [
|
|
28
|
+
{ number: 1, body: "epic", labels: [], subIssues: { nodes: [{ number: 5 }, { number: 9 }] } },
|
|
29
|
+
{ number: 2, body: "x\n\n**Parent**: #1", labels: [] },
|
|
30
|
+
{ number: 3, body: "", labels: [], parent: { number: 1 } },
|
|
31
|
+
{ number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }] },
|
|
32
|
+
{ number: 5, body: "", labels: [] },
|
|
33
|
+
{ number: 6, body: "**Parent**: #77", labels: [] },
|
|
34
|
+
{ number: 7, body: "**Parent**: #1", labels: [] },
|
|
35
|
+
];
|
|
36
|
+
it("finds children by Parent line, parent field and sub-issues; skips needs-human, closed sub-issues and unrelated", () => {
|
|
37
|
+
assert.deepEqual(selectChildren({ list, roots: [1], skip: [1] }), [2, 3, 5, 7]);
|
|
38
|
+
});
|
|
39
|
+
it("skips issues already in the batch or held elsewhere", () => {
|
|
40
|
+
assert.deepEqual(selectChildren({ list, roots: [1], skip: [1, 2, 7] }), [3, 5]);
|
|
41
|
+
});
|
|
42
|
+
it("an --epic root outside the batch yields its children", () => {
|
|
43
|
+
assert.deepEqual(selectChildren({ list, roots: [77] }), [6]);
|
|
44
|
+
});
|
|
45
|
+
it("children filed later appear on a later list", () => {
|
|
46
|
+
assert.deepEqual(selectChildren({ list: [...list, { number: 8, body: "**Parent**: #1", labels: [] }], roots: [1], skip: [1, 2, 3, 5, 7] }), [8]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("#4965 readers", () => {
|
|
51
|
+
const help = (fields) => `Usage\n\nJSON FIELDS\n ${fields.join(", ")}\n\nEXAMPLES\n $ gh issue list\n`;
|
|
52
|
+
const fakeGh = (fields, calls) => async (a) => {
|
|
53
|
+
calls.push(a);
|
|
54
|
+
if (a.includes("--help")) return help(fields);
|
|
55
|
+
const j = a[a.indexOf("--json") + 1].split(",");
|
|
56
|
+
const bad = j.find((f) => !fields.includes(f));
|
|
57
|
+
if (bad) throw new Error(`Unknown JSON field: "${bad}"`);
|
|
58
|
+
return "[{\"number\":1}]";
|
|
59
|
+
};
|
|
60
|
+
it("readChildList asks the host gh for its fields, then makes one list call", async () => {
|
|
61
|
+
const calls = [];
|
|
62
|
+
const r = await readChildList({ gh: fakeGh(["number", "body", "labels", "parent", "subIssues", "title"], calls), repo: "o/r" });
|
|
63
|
+
assert.equal(calls.filter((a) => !a.includes("--help")).length, 1);
|
|
64
|
+
assert.equal(calls.at(-1)[calls.at(-1).indexOf("--json") + 1], "number,body,labels,parent,subIssues");
|
|
65
|
+
assert.deepEqual(r, [{ number: 1 }]);
|
|
66
|
+
});
|
|
67
|
+
it("a host gh without parent/subIssues still lists, with the fields it has (children found by Parent line)", async () => {
|
|
68
|
+
const calls = [];
|
|
69
|
+
const gh = fakeGh(["number", "body", "labels", "title"], calls);
|
|
70
|
+
assert.deepEqual(await hostListFields(gh), ["number", "body", "labels"]);
|
|
71
|
+
assert.deepEqual(await readChildList({ gh, repo: "o/r" }), [{ number: 1 }]);
|
|
72
|
+
assert.equal(calls.at(-1)[calls.at(-1).indexOf("--json") + 1], "number,body,labels");
|
|
73
|
+
});
|
|
74
|
+
it("liveElsewhere holds the unsettled issues of other live batches of the repo only", () => {
|
|
75
|
+
const mk = (o) => ({ readReport: () => o.report ?? null, readPlan: () => ({ repo: o.repo ?? "o/r" }), store: { load: () => ({ order: o.order, results: o.results ?? {} }) } });
|
|
76
|
+
const bs = { a: mk({ order: [1, 2], results: { 1: {} } }), b: mk({ order: [3], report: {} }), c: mk({ order: [4], repo: "x/y" }), self: mk({ order: [5] }) };
|
|
77
|
+
const batchMod = { listBatches: () => Object.keys(bs), openBatch: (_r, id) => bs[id] };
|
|
78
|
+
assert.deepEqual([...liveElsewhere({ batchMod, root: "/r", self: "self", repo: "o/r" })], [2]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("#4965 forge.yaml and resume", () => {
|
|
83
|
+
it("readAutoChildren reads auto_add_children and its poll; defaults keep today's behaviour", async () => {
|
|
84
|
+
const { readAutoChildren } = await import("../engine-cli.mjs");
|
|
85
|
+
const { writeFileSync } = await import("node:fs");
|
|
86
|
+
const { join } = await import("node:path");
|
|
87
|
+
const d = tmpDir("ac-");
|
|
88
|
+
assert.equal(readAutoChildren(d).mode, "decomposed");
|
|
89
|
+
assert.equal(readAutoChildren(d).pollS, 120);
|
|
90
|
+
writeFileSync(join(d, "forge.yaml"), "orchestration:\n auto_add_children: epic\n auto_add_children_poll_s: 30\n");
|
|
91
|
+
assert.equal(readAutoChildren(d).mode, "epic");
|
|
92
|
+
assert.equal(readAutoChildren(d).pollS, 30);
|
|
93
|
+
});
|
|
94
|
+
it("--resume keeps the mode: a resumed batch whose plan carries --auto-children epic --epic 9 adds #9's children", async () => {
|
|
95
|
+
const { openBatch } = await import("./batch.mjs");
|
|
96
|
+
const { orchestrateFromCli } = await import("../engine-cli.mjs");
|
|
97
|
+
const fs = await import("node:fs");
|
|
98
|
+
const { join } = await import("node:path");
|
|
99
|
+
process.env.FORGEDOCK_SCHED_DIR = tmpDir("ac-sched-");
|
|
100
|
+
const work = tmpDir("acb-"), root = join(work, "batches"), launches = join(work, "launches");
|
|
101
|
+
fs.writeFileSync(launches, "");
|
|
102
|
+
const stub = join(work, "stub.mjs");
|
|
103
|
+
fs.writeFileSync(stub, `import { appendFileSync, writeFileSync } from "node:fs";
|
|
104
|
+
const a = process.argv.slice(2); const rf = a[a.indexOf("--result-file") + 1];
|
|
105
|
+
appendFileSync(${JSON.stringify(launches)}, a[1] + "\\n"); writeFileSync(rf, JSON.stringify({ terminalReason: "merged", pr: 100 + Number(a[1]), cost_usd: 0 }));
|
|
106
|
+
`);
|
|
107
|
+
const b = openBatch(root, "b20261101-000000-1");
|
|
108
|
+
b.writePlan({ v: 1, id: b.id, argv: ["1", "--repo", "o/r", "--no-prefilter", "--auto-children", "epic", "--epic", "9"], repo: "o/r", cwd: process.cwd(), created: new Date().toISOString() });
|
|
109
|
+
b.setParallel(2);
|
|
110
|
+
const lists = [];
|
|
111
|
+
const gh = async (a) => {
|
|
112
|
+
if (a[0] === "issue" && a[1] === "list" && a.includes("--help")) return "JSON FIELDS\n number, body, labels, parent, subIssues\n\nEXAMPLES\n";
|
|
113
|
+
if (a[0] === "issue" && a[1] === "list" && a.includes("--state") && a.includes("number,body,labels,parent,subIssues")) { lists.push(a); return JSON.stringify([{ number: 9, body: "", labels: [] }, { number: 51, body: "**Parent**: #9", labels: [] }]); }
|
|
114
|
+
if (a.includes("milestone")) return JSON.stringify({ milestone: null });
|
|
115
|
+
if (a[0] === "repo") return "main\n";
|
|
116
|
+
return JSON.stringify({ body: "", state: "OPEN", labels: [] });
|
|
117
|
+
};
|
|
118
|
+
const deps = { batchRoot: root, allowTmpfs: true, laneScript: stub, pollMs: 20, orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\n" },
|
|
119
|
+
leanIo: { gh, git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : "") } };
|
|
120
|
+
const l = console.log; console.log = () => {};
|
|
121
|
+
let res;
|
|
122
|
+
try { res = await orchestrateFromCli(["--resume", b.id], deps); } finally { console.log = l; }
|
|
123
|
+
assert.ok(lists.length >= 1, "the child list was read: the epic mode survived the resume");
|
|
124
|
+
assert.deepEqual(res.rows.map((r) => r.issue).sort((x, y) => x - y), [1, 51]);
|
|
125
|
+
});
|
|
126
|
+
});
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* running; a resumed dispatcher re-attaches to it by pid, checking `/proc/<pid>/cmdline` against its result path. These
|
|
16
16
|
* detached lanes are the `local` execution (#4253); `execution.mjs` is the sandbox-provider seam for remote lanes.
|
|
17
17
|
*/
|
|
18
|
-
import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
18
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { dirname, join, resolve, sep } from "node:path";
|
|
@@ -86,7 +86,7 @@ const readAdded = (file) => {
|
|
|
86
86
|
};
|
|
87
87
|
|
|
88
88
|
/** `outcome`s `--requeue` may move back to pending (#4550). */
|
|
89
|
-
export const REQUEUABLE = (outcome) => ["needs-human", "invalid", "skipped", "ci-pending", "budget-exceeded", "lane-refused"].includes(outcome) || /^dependency-/.test(String(outcome));
|
|
89
|
+
export const REQUEUABLE = (outcome) => ["needs-human", "invalid", "skipped", "ci-pending", "budget-exceeded", "lane-refused", "deferred"].includes(outcome) || /^dependency-/.test(String(outcome));
|
|
90
90
|
/** Key of one requeue entry; the runner records the applied keys in state.json `requeueApplied`. */
|
|
91
91
|
export const requeueKey = (e) => `${e.issue}@${e.at}`;
|
|
92
92
|
|
|
@@ -191,6 +191,8 @@ export function openBatch(root, id) {
|
|
|
191
191
|
}
|
|
192
192
|
return { requeued, refused };
|
|
193
193
|
},
|
|
194
|
+
/** #4917: the issues still `pending` in state.json (deferred or requeued included); [] when none or unreadable. */
|
|
195
|
+
pendingIssues: () => { const p = readJson(f("state.json"))?.pending; return Array.isArray(p) ? p.filter((n) => Number.isSafeInteger(n)) : []; },
|
|
194
196
|
/** Every `--requeue` entry, in order; [] when missing or invalid. */
|
|
195
197
|
readRequeued: () => readRequeueFile(f("requeue")),
|
|
196
198
|
/** Entries the runner has not applied yet (a finished or unhosted batch with a pending requeue). */
|
|
@@ -220,6 +222,12 @@ export function openBatch(root, id) {
|
|
|
220
222
|
clearCancel: () => rmSync(f("cancel"), { force: true }),
|
|
221
223
|
/** Added issues the batch never took into its `order` (a finished or unhosted batch with pending adds). */
|
|
222
224
|
unrunAdds: () => { const order = new Set(readJson(f("state.json"))?.order ?? []); return readAdded(f("add")).filter((n) => !order.has(n)); },
|
|
225
|
+
/** #4775: epoch ms of the batch's last progress (newest mtime of `state.json` / `log.jsonl`), or null when neither exists. */
|
|
226
|
+
lastProgressMs: () => {
|
|
227
|
+
let best = null;
|
|
228
|
+
for (const n of ["state.json", "log.jsonl"]) { try { const m = statSync(f(n)).mtimeMs; if (best === null || m > best) best = m; } catch { /* absent */ } }
|
|
229
|
+
return best;
|
|
230
|
+
},
|
|
223
231
|
/** The lane updates in `log.jsonl`, in order (unparsable lines skipped). */
|
|
224
232
|
readLog: () => {
|
|
225
233
|
let text = "";
|
|
@@ -259,7 +267,7 @@ export function launchLane({ batch, issue, args = [], bin = process.execPath, sc
|
|
|
259
267
|
const out = openSync(batch.logFile(issue), "a", 0o600);
|
|
260
268
|
try {
|
|
261
269
|
// FORGEDOCK_BATCH_ID names the lane's resource ledger dir (~/.forge/ledger/<batch>/, #4179).
|
|
262
|
-
const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env: { ...env, FORGEDOCK_BATCH_ID: batch.id }, detached: true, stdio: ["ignore", out, out] });
|
|
270
|
+
const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env: { ...env, FORGEDOCK_BATCH_ID: batch.id }, detached: true, windowsHide: true, stdio: ["ignore", out, out] });
|
|
263
271
|
child.on?.("error", () => { /* surfaces as a dead pid without a result */ });
|
|
264
272
|
child.unref?.();
|
|
265
273
|
return { pid: child.pid };
|