forgedock 1.42.0 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine-cli.mjs +76 -17
- package/bin/lean/autochildren.mjs +103 -0
- package/bin/lean/autochildren.test.mjs +126 -0
- package/bin/lean/batch.mjs +8 -2
- package/bin/lean/batch.test.mjs +29 -2
- package/bin/lean/conformance.mjs +121 -8
- package/bin/lean/conformance.test.mjs +132 -2
- 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/lean.mjs +40 -16
- package/bin/lean/lean.test.mjs +66 -5
- 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/prompts.mjs +19 -4
- package/bin/lean/schedd.mjs +79 -15
- package/bin/lean/schedd.test.mjs +92 -2
- 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/commands/orchestrate.md +2 -2
- package/docs/CONFIG.md +8 -2
- package/docs/RELEASE-NOTES.md +3 -0
- package/package.json +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
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++) {
|
|
@@ -1379,7 +1407,7 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1379
1407
|
if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
|
|
1380
1408
|
let sock = null;
|
|
1381
1409
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1382
|
-
if (sock) await schedd.request(sock, { op: "set-parallel", id, k });
|
|
1410
|
+
if (sock) await schedd.request(sock, { op: "set-parallel", id, k }, undefined, { timeoutMs: schedd.REQUEST_MS });
|
|
1383
1411
|
else {
|
|
1384
1412
|
// No daemon: the batch is not running anywhere; the parallel file is all there is to change.
|
|
1385
1413
|
const batchMod = await import("./lean/batch.mjs");
|
|
@@ -1394,17 +1422,17 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1394
1422
|
const addVia = (id) => async (issues) => {
|
|
1395
1423
|
let sock = null;
|
|
1396
1424
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1397
|
-
return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
|
|
1425
|
+
return sock ? schedd.request(sock, { op: "add", id, issues }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
|
|
1398
1426
|
};
|
|
1399
1427
|
const requeueVia = (id) => async (issues, reason, by) => {
|
|
1400
1428
|
let sock = null;
|
|
1401
1429
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1402
|
-
return sock ? schedd.request(sock, { op: "requeue", id, issues, reason, by }) : null;
|
|
1430
|
+
return sock ? schedd.request(sock, { op: "requeue", id, issues, reason, by }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
|
|
1403
1431
|
};
|
|
1404
1432
|
const holdVia = (id) => async (op, issues) => {
|
|
1405
1433
|
let sock = null;
|
|
1406
1434
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1407
|
-
return sock ? schedd.request(sock, { op, id, issues }) : null;
|
|
1435
|
+
return sock ? schedd.request(sock, { op, id, issues }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
|
|
1408
1436
|
};
|
|
1409
1437
|
const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
|
|
1410
1438
|
if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root: await batchRootOf() });
|
|
@@ -1414,7 +1442,7 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1414
1442
|
return cancelBatch({ id, root: await batchRootOf(), stopRemote: argv.includes("--stop-remote"), viaDaemon: async () => {
|
|
1415
1443
|
let sock = null;
|
|
1416
1444
|
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;
|
|
1445
|
+
return sock ? schedd.request(sock, { op: "cancel", id, stopRemote: argv.includes("--stop-remote") }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
|
|
1418
1446
|
} });
|
|
1419
1447
|
}
|
|
1420
1448
|
if (argv.includes("--requeue")) {
|
|
@@ -1718,6 +1746,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1718
1746
|
lanes = { ...lanes, stopLane };
|
|
1719
1747
|
}
|
|
1720
1748
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
1749
|
+
// #4965: --auto-children overrides forge.yaml; it is part of plan.argv, so --resume keeps it
|
|
1750
|
+
const autoMod = _autoChildren, yamlAuto = readAutoChildren(cwd);
|
|
1751
|
+
const autoChildren = { mode: argv.includes("--auto-children") ? autoMod.parseAutoModeFlag(flag(argv, "--auto-children")) : yamlAuto.mode, pollS: yamlAuto.pollS,
|
|
1752
|
+
epics: argv.flatMap((a, i) => (a === "--epic" && /^#?\d+$/.test(argv[i + 1] ?? "") ? [parseInt(argv[i + 1].replace("#", ""), 10)] : [])) };
|
|
1721
1753
|
// #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
|
|
1722
1754
|
const recoveryOn = Boolean(orch.recovery?.maxAttempts) && (deps.enginePath !== undefined ? deps.enginePath : readEnginePath(cwd)) !== "full";
|
|
1723
1755
|
if (orch.recovery?.maxAttempts && !recoveryOn) sink.line("recovery: off (engine.path is full; recovery lanes run on the core path only)");
|
|
@@ -1835,7 +1867,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1835
1867
|
const hostIo = await laneCheck.hostLaneIo({ env });
|
|
1836
1868
|
preflight = async () => (await laneCheck.runLaneChecks({ exec: execForNeeds, env, io: hostIo })).filter((c) => !c.ok);
|
|
1837
1869
|
}
|
|
1838
|
-
let rows;
|
|
1870
|
+
let rows, childFields = null; // #4965: the host gh's accepted `issue list --json` fields, read once from its help
|
|
1839
1871
|
try {
|
|
1840
1872
|
rows = await orchestrate({
|
|
1841
1873
|
...(estimate ? { estimate } : {}),
|
|
@@ -1843,9 +1875,11 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1843
1875
|
...(preflight ? { preflight } : {}),
|
|
1844
1876
|
...(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
1877
|
trusted: (n) => trustedOf.get(n) === true,
|
|
1878
|
+
autoChildren,
|
|
1879
|
+
...(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
1880
|
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
1881
|
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)) } : {}),
|
|
1882
|
+
...(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
1883
|
...(remote ? { remote } : {}),
|
|
1850
1884
|
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
1851
1885
|
...(recoveryOn ? { recovery: orch.recovery } : {}),
|
|
@@ -2058,8 +2092,20 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
2058
2092
|
if (argv[0] === "status") {
|
|
2059
2093
|
let sock;
|
|
2060
2094
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { console.log("schedd: not running"); return { running: false }; }
|
|
2061
|
-
|
|
2062
|
-
|
|
2095
|
+
let st;
|
|
2096
|
+
try { st = await schedd.request(sock, { op: "status" }, undefined, { timeoutMs: deps.statusMs ?? schedd.REQUEST_MS }); }
|
|
2097
|
+
catch (e) {
|
|
2098
|
+
if (e?.code !== "schedd.timeout") throw e;
|
|
2099
|
+
console.log(`schedd: not responding (${e.message}); see ${schedd.scheddPaths(dir).log}`);
|
|
2100
|
+
process.exitCode = 1;
|
|
2101
|
+
return { running: true, responding: false };
|
|
2102
|
+
}
|
|
2103
|
+
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` : ""}`);
|
|
2104
|
+
for (const b of st.batches) {
|
|
2105
|
+
const age = b.lastProgressAgeSec == null ? "no progress yet" : `last progress ${b.lastProgressAgeSec}s ago`;
|
|
2106
|
+
const pend = b.pendingAdds?.length ? `, pending adds: ${b.pendingAdds.map((n) => `#${n}`).join(" ")}` : "";
|
|
2107
|
+
console.log(` ${b.id}${b.running ? "" : " (not hosted)"}: ${age}${pend}`);
|
|
2108
|
+
}
|
|
2063
2109
|
return { running: true, ...st };
|
|
2064
2110
|
}
|
|
2065
2111
|
if (argv[0] === "watchdog") { // #4780: run by the schedd watchdog timer; signals a live daemon whose heartbeat went stale
|
|
@@ -2073,7 +2119,13 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
2073
2119
|
process.title = "forgedock-schedd"; // distinctive name for OOM-killer avoid lists and ps (#4673)
|
|
2074
2120
|
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }),
|
|
2075
2121
|
...(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)
|
|
2122
|
+
if (!d.running) {
|
|
2123
|
+
// #4775: under systemd, exiting here (code 0) makes `Restart=always` loop while another daemon holds the lock: stand by, take over when it dies
|
|
2124
|
+
if (!(deps.standby ?? Boolean(process.env.INVOCATION_ID))) return d;
|
|
2125
|
+
const wait = deps.standbyMs ?? 5000, alive = deps.alive ?? ((await import("./lean/sched.mjs")).pidAlive);
|
|
2126
|
+
do await (deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(wait); while (alive(d.owner));
|
|
2127
|
+
return scheddFromCli(argv, deps);
|
|
2128
|
+
}
|
|
2077
2129
|
// #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
|
|
2078
2130
|
schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
|
|
2079
2131
|
await d.closed;
|
|
@@ -2129,7 +2181,7 @@ export async function decisionsFromCli(argv, deps = {}) {
|
|
|
2129
2181
|
const [sub, ...rest] = argv;
|
|
2130
2182
|
if (sub !== "list") throw new Error(usage);
|
|
2131
2183
|
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));
|
|
2184
|
+
const run = deps.exec ?? (async (cmd, args) => String((await pexec(cmd, args, { maxBuffer: 64 * 1024 * 1024, timeout: 60000 })).stdout));
|
|
2133
2185
|
const repo = flag("--repo") ?? String(await run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])).trim();
|
|
2134
2186
|
if (rest.includes("--refresh")) {
|
|
2135
2187
|
const jq = '.[] | select(.body | startswith("<!-- FORGE:DECISION {")) | {body, user, author_association, issue_url} | tojson';
|
|
@@ -2266,6 +2318,13 @@ export async function schedFromCli(argv, deps = {}) {
|
|
|
2266
2318
|
/** #4773: is the author of a `viewIssue` record (author plus the REST association, #4859) trusted (the engine admission's `isTrustedActor`)? */
|
|
2267
2319
|
const bodyTrusted = (v) => isTrustedActor({ association: v?.authorAssociation, type: v?.author?.is_bot ? "Bot" : "User", login: v?.author?.login });
|
|
2268
2320
|
|
|
2321
|
+
/** forge.yaml `orchestration.auto_add_children` (#4965; default `decomposed`) and `auto_add_children_poll_s` (default 120). */
|
|
2322
|
+
export function readAutoChildren(cwd = process.cwd()) {
|
|
2323
|
+
let o = {};
|
|
2324
|
+
try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestration ?? {}; } catch { o = {}; }
|
|
2325
|
+
return { mode: _autoChildren.parseAutoMode(o.auto_add_children), pollS: _autoChildren.parsePollS(o.auto_add_children_poll_s) };
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2269
2328
|
/**
|
|
2270
2329
|
* forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off),
|
|
2271
2330
|
* `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
|
|
@@ -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
|
|
|
@@ -220,6 +220,12 @@ export function openBatch(root, id) {
|
|
|
220
220
|
clearCancel: () => rmSync(f("cancel"), { force: true }),
|
|
221
221
|
/** Added issues the batch never took into its `order` (a finished or unhosted batch with pending adds). */
|
|
222
222
|
unrunAdds: () => { const order = new Set(readJson(f("state.json"))?.order ?? []); return readAdded(f("add")).filter((n) => !order.has(n)); },
|
|
223
|
+
/** #4775: epoch ms of the batch's last progress (newest mtime of `state.json` / `log.jsonl`), or null when neither exists. */
|
|
224
|
+
lastProgressMs: () => {
|
|
225
|
+
let best = null;
|
|
226
|
+
for (const n of ["state.json", "log.jsonl"]) { try { const m = statSync(f(n)).mtimeMs; if (best === null || m > best) best = m; } catch { /* absent */ } }
|
|
227
|
+
return best;
|
|
228
|
+
},
|
|
223
229
|
/** The lane updates in `log.jsonl`, in order (unparsable lines skipped). */
|
|
224
230
|
readLog: () => {
|
|
225
231
|
let text = "";
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -75,12 +75,13 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
75
75
|
{ code: "merge.transient" },
|
|
76
76
|
{ code: "push.transient" },
|
|
77
77
|
{ code: "cli.idle" },
|
|
78
|
+
{ code: "cli.idle" },
|
|
78
79
|
];
|
|
79
80
|
const starts = [];
|
|
80
81
|
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3,
|
|
81
82
|
runOne: async () => { starts.push(now()); const d = seq.shift(); return { terminalReason: "deferred", detail: d.code, ...d }; } });
|
|
82
83
|
const gaps = starts.slice(1).map((t, i) => (t - starts[i]) / 1000);
|
|
83
|
-
assert.deepEqual(gaps, [60, 600, DEFER_BACKOFF_S * 2, DEFER_BACKOFF_S * 4]);
|
|
84
|
+
assert.deepEqual(gaps, [60, 600, DEFER_BACKOFF_S, DEFER_BACKOFF_S * 2, DEFER_BACKOFF_S * 4]);
|
|
84
85
|
assert.equal(rows[0].outcome, "deferred");
|
|
85
86
|
assert.match(rows[0].detail, /gave up after 3 deferrals/);
|
|
86
87
|
// the usage-limit reset time and a lease expiry are honoured
|
|
@@ -844,7 +845,7 @@ describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () =>
|
|
|
844
845
|
assert.deepEqual(deferPolicy({ code: "sandbox.capacity" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
|
|
845
846
|
assert.deepEqual(deferPolicy({ code: "sandbox.capacity", retryInSeconds: 30 }, { now: 0, deferrals: 99, maxDeferrals: 3 }), { delayMs: 30000, host: true });
|
|
846
847
|
assert.deepEqual(deferPolicy({ code: "sandbox.unreachable", retryInSeconds: 60 }, { now: 0, deferrals: 99, maxDeferrals: 3 }), { delayMs: 60000, host: true }); // #4788
|
|
847
|
-
for (const code of ["merge.transient", "gh.unavailable", "cli.idle", "
|
|
848
|
+
for (const code of ["merge.transient", "gh.unavailable", "cli.idle", "sandbox.start_failed"]) {
|
|
848
849
|
assert.deepEqual(deferPolicy({ code, retryInSeconds: 60 }, { now: 0, deferrals: 3, maxDeferrals: 3 }), { capped: true }, code);
|
|
849
850
|
}
|
|
850
851
|
});
|
|
@@ -873,6 +874,32 @@ describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () =>
|
|
|
873
874
|
});
|
|
874
875
|
});
|
|
875
876
|
|
|
877
|
+
describe("#4784: usage-limit deferrals", () => {
|
|
878
|
+
it("rate_limited is never capped and keeps its hold; a deferred row is requeuable", async () => {
|
|
879
|
+
assert.deepEqual(deferPolicy({ code: "rate_limited", resetAtEpochMs: 5000 }, { now: 1000, deferrals: 99, maxDeferrals: 3 }), { delayMs: 4000, hold: true });
|
|
880
|
+
const { now, sleep } = fakeClock();
|
|
881
|
+
let runs = 0;
|
|
882
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3,
|
|
883
|
+
runOne: async () => (++runs <= 6 ? { terminalReason: "deferred", code: "rate_limited", retryInSeconds: 600 } : { terminalReason: "merged", pr: 9 }) });
|
|
884
|
+
assert.equal(runs, 7);
|
|
885
|
+
assert.equal(rows[0].outcome, "merged");
|
|
886
|
+
const b = openBatch(tmpDir("batch-requeue-deferred-"), "b1");
|
|
887
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
888
|
+
b.store.save({ order: [1], results: { 1: { outcome: "deferred" } } });
|
|
889
|
+
assert.deepEqual(b.requeue([1]).requeued, [1]);
|
|
890
|
+
});
|
|
891
|
+
it("a requeuable deferred row: an issue that gave up after N deferrals (#4984)", async () => {
|
|
892
|
+
const { now, sleep } = fakeClock();
|
|
893
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3,
|
|
894
|
+
runOne: async () => ({ terminalReason: "deferred", code: "followup.unavailable", detail: "no tracking issue was returned for R2" }) });
|
|
895
|
+
assert.match(rows[0].detail, /gave up after 3 deferrals/);
|
|
896
|
+
const b = openBatch(tmpDir("batch-requeue-gaveup-"), "b1");
|
|
897
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
898
|
+
b.store.save({ order: [1], results: { 1: { outcome: rows[0].outcome, detail: rows[0].detail } } });
|
|
899
|
+
assert.deepEqual(b.requeue([1]).requeued, [1]);
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
876
903
|
describe("#4550: forgedock orchestrate --requeue", () => {
|
|
877
904
|
const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
|
|
878
905
|
it("a needs-human issue is requeued and relaunched; its skipped dependent returns to pending and runs after it merges; one row per issue", async () => {
|