forgedock 1.43.1 → 1.45.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/outcome-codes.mjs +2 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +50 -11
- package/bin/forgedock.mjs +50 -24
- package/bin/gc.mjs +3 -1
- package/bin/hooks/pre-tool-use.mjs +54 -3
- package/bin/lean/autochildren.mjs +13 -5
- package/bin/lean/autochildren.test.mjs +36 -18
- package/bin/lean/batch.test.mjs +46 -0
- package/bin/lean/conformance.mjs +107 -16
- package/bin/lean/conformance.test.mjs +126 -13
- package/bin/lean/decision-engine.mjs +1 -1
- package/bin/lean/decision-engine.test.mjs +11 -0
- package/bin/lean/execution.mjs +7 -4
- package/bin/lean/execution.test.mjs +21 -0
- package/bin/lean/gatewatch.test.mjs +5 -5
- package/bin/lean/lane.mjs +22 -4
- package/bin/lean/lane.test.mjs +33 -1
- package/bin/lean/lean.mjs +49 -16
- package/bin/lean/lean.test.mjs +66 -0
- package/bin/lean/orchestrate-blocked.test.mjs +35 -0
- package/bin/lean/orchestrate-children.test.mjs +16 -4
- package/bin/lean/orchestrate.mjs +61 -13
- package/bin/lean/pmap.mjs +17 -0
- package/bin/lean/pmap.test.mjs +27 -0
- package/bin/lean/postdeploy-check.test.mjs +3 -1
- package/bin/lean/preflight.mjs +8 -3
- package/bin/lean/preflight.test.mjs +13 -1
- package/bin/lean/prodwatch-triage.mjs +6 -0
- package/bin/lean/prodwatch-triage.test.mjs +14 -1
- package/bin/lean/prodwatch.mjs +1 -3
- package/bin/lean/prompts.mjs +1 -1
- package/bin/lean/recovery.test.mjs +21 -4
- package/bin/lean/retry.mjs +11 -0
- package/bin/lean/retry.test.mjs +16 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +15 -4
- package/bin/lean/sandbox-remote.mjs +19 -7
- package/bin/lean/sched.mjs +25 -2
- package/bin/lean/sched.test.mjs +58 -1
- package/bin/lean/selftest-live.mjs +56 -31
- package/bin/lean/selftest-live.test.mjs +48 -6
- package/bin/lean/selftest-nightly.mjs +7 -3
- package/bin/lean/selftest-nightly.test.mjs +24 -2
- package/bin/lean/selftest-static.mjs +18 -11
- package/bin/lean/selftest-static.test.mjs +8 -0
- package/bin/lean/signoff.mjs +16 -3
- package/bin/lean/signoff.test.mjs +52 -1
- package/bin/pool/agent.mjs +3 -3
- package/bin/pool/cli.mjs +29 -1
- package/bin/pool/router.mjs +17 -6
- package/commands/orchestrate.md +5 -2
- package/docs/CONFIG.md +10 -4
- package/docs/DECISIONS.md +5 -1
- package/docs/OUTCOME-CODES.md +2 -2
- package/docs/TELEMETRY.md +1 -1
- package/package.json +1 -1
- package/scripts/plan-validate-live.mjs +15 -8
|
@@ -199,13 +199,13 @@ export const ENV_VALIDATE_TIMEOUT = "env.validate_timeout";
|
|
|
199
199
|
* Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
|
|
200
200
|
* (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
|
|
201
201
|
*/
|
|
202
|
-
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED]);
|
|
202
|
+
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED, OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CONFORMANCE_OMISSION, OUTCOME_CODES.CONFORMANCE_UNJUSTIFIED_DEFERRAL]);
|
|
203
203
|
/** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
|
|
204
204
|
export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
207
|
* `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
|
|
208
|
-
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.validate_timeout`, `env.capability_remote`, `budget.exceeded`, `contract.invalid` after a mid-run issue
|
|
208
|
+
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.validate_timeout`, `env.capability_remote`, `budget.exceeded`, `conformance.unimplemented` / `conformance.omission` / `conformance.unjustified_deferral` (#5035; regression, guard_broken, scope_creep and e2e_unverified stay terminal), `contract.invalid` after a mid-run issue
|
|
209
209
|
* edit (`issueEdited`) or when every problem was an unmatched quote (`quoteMismatch`), `ci-pending` on a conflicting PR (`conflicting`), and the non-human `budget-exceeded` turn-overrun end (forge#4505). Human: `builder.blocked`. Anything else
|
|
210
210
|
* (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
|
|
211
211
|
*/
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms; lean adds milestone, conformance_mode, shadow_would, the pr and
|
|
9
9
|
* builder_tier/class/model/escalated 0|1, forge#4394)
|
|
10
10
|
* SANDBOX_USAGE -> `sandbox_usage` (provider, sandbox_id, minutes, cost_usd, batch; one per settled remote lane, forge#4255)
|
|
11
|
-
* DECISION_ENGINE -> `decision_engine` (decided, escalated, vetoed, reversed, mode, needs_human_before/after; forge#4766)
|
|
11
|
+
* DECISION_ENGINE -> `decision_engine` (decided, escalated, vetoed, reversed, mode, needs_human_before/after; forge#4766; emitted once the engine is wired)
|
|
12
12
|
* PRODWATCH_METRICS -> `prodwatch_metrics` (source, sample, invalid, fp_rate, fix_ineffective_rate, recurrence_rate, signal_to_issue/merge/verified_ms; forge#4837)
|
|
13
13
|
* STRUCTURE_DELTA -> `structure_delta` (edges_added/removed, import_files_added/removed, boundary_changed/loosened 0|1; forge#4159)
|
|
14
14
|
* lean's merge-time record (forge#4165) adds milestone, the pr and string lists: deps_added and
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { parseForgeYaml } from "./forge-utils.mjs";
|
|
|
21
21
|
import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
|
|
22
22
|
import { makeEventEmitter, probeSweepEvents, experimentEvent } from "./engine/events.mjs";
|
|
23
23
|
import { readExperiment } from "./experiment.mjs";
|
|
24
|
-
import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
|
|
24
|
+
import { readBranches, resolveLane, isDefaultLane, makeLaneCache } from "./lean/lane.mjs";
|
|
25
25
|
import { parseVerify } from "./lean/verify.mjs";
|
|
26
26
|
import { normalizeKindModes } from "./lean/conformance.mjs";
|
|
27
27
|
import { viewIssue } from "./lean/gh-assoc.mjs";
|
|
@@ -525,10 +525,12 @@ export function readEnvConfig(cwd = process.cwd()) {
|
|
|
525
525
|
const e = y?.env ?? {};
|
|
526
526
|
const tr = y?.verification?.test_runners; // #4857: {path prefix: runner command prefix} for validate commands
|
|
527
527
|
const runners = tr && typeof tr === "object" && !Array.isArray(tr) ? Object.fromEntries(Object.entries(tr).filter(([k, v]) => k && typeof v === "string" && v.trim())) : {};
|
|
528
|
+
const rm = readYamlMap(cwd, "validate", "recipes"); // #5020: y?.validate?.recipes, a block map {match regex: replacement} applied to frozen validate and repro commands
|
|
529
|
+
const recipes = Object.entries(rm ?? {}).map(([match, replace]) => ({ match, replace }));
|
|
528
530
|
const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
529
531
|
const kb = typeof e.sparse_max_kb === "string" && /^\d+$/.test(e.sparse_max_kb.trim()) ? Number(e.sparse_max_kb) : e.sparse_max_kb;
|
|
530
532
|
return { ...(s(e.toolchain) ? { toolchainOverride: s(e.toolchain) } : {}), ...(s(e.setup) ? { envSetup: s(e.setup) } : {}),
|
|
531
|
-
...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}) };
|
|
533
|
+
...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}), ...(recipes.length ? { validateRecipes: recipes } : {}) };
|
|
532
534
|
} catch { return {}; }
|
|
533
535
|
}
|
|
534
536
|
export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
|
|
@@ -1327,6 +1329,26 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
|
|
|
1327
1329
|
/** #4592: a resume's release line, only when it freed something. */
|
|
1328
1330
|
const onlyReleased = (l, out = (t) => console.log(t)) => { if (!l.endsWith("(none)")) out(l); };
|
|
1329
1331
|
|
|
1332
|
+
/**
|
|
1333
|
+
* #5031: the `baseWait` seam of `orchestrate` for issues admitted while a dependency's merge commit was not yet on their base:
|
|
1334
|
+
* a detail string until every awaited commit is an ancestor of origin/<base> (fetched at most every 30s per base), else null.
|
|
1335
|
+
*/
|
|
1336
|
+
function baseWaitOf(waits, { io, leanIo, cwd, now = () => Date.now() }) {
|
|
1337
|
+
const fetched = new Map();
|
|
1338
|
+
return async (n) => {
|
|
1339
|
+
const l = waits.get(n);
|
|
1340
|
+
if (!l) return null;
|
|
1341
|
+
const missing = [];
|
|
1342
|
+
for (const w of l) {
|
|
1343
|
+
if (now() - (fetched.get(w.base) ?? -Infinity) > 30000) { fetched.set(w.base, now()); try { await leanIo.git(["fetch", "origin", w.base], { cwd }); } catch { /* offline: the local ref */ } }
|
|
1344
|
+
let on = false;
|
|
1345
|
+
for (const ref of [`origin/${w.base}`, w.base]) if (!on) on = await io.git?.(["-C", cwd, "merge-base", "--is-ancestor", w.sha, ref]).then(() => true, () => false);
|
|
1346
|
+
if (!on) missing.push(w);
|
|
1347
|
+
}
|
|
1348
|
+
return missing.length ? `waiting for ${missing.map((w) => `#${w.dep} (${w.sha.slice(0, 10)})`).join(", ")} on ${missing[0].base}` : null;
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1330
1352
|
/**
|
|
1331
1353
|
* `--hold <id> N...` / `--release <id> N...` (#4592): the batch's `hold` file, through the daemon (`viaDaemon(op, issues)`, null
|
|
1332
1354
|
* when none runs) or directly. A held issue is not admitted (a running lane is not stopped); `--release` (or a `--resume` that
|
|
@@ -1569,12 +1591,15 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1569
1591
|
await holdBatch({ op: "release", id: resumeId, tokens: resumeExtra, root, print: (l) => onlyReleased(l, (t) => sink.line(t)) }); // #4592
|
|
1570
1592
|
}
|
|
1571
1593
|
let batch, plan = null;
|
|
1594
|
+
const baseWaits = new Map(); // #5031: issue -> [{dep, sha, base}] merge commits not yet on its base at start
|
|
1572
1595
|
if (resumeId) {
|
|
1573
1596
|
batch = batchMod.openBatch(root, resumeId);
|
|
1574
1597
|
batch.scrubPlan(); // #4787: a pre-fix plan.json keeps no secret; the lanes re-read it from the environment
|
|
1575
1598
|
plan = batch.readPlan();
|
|
1576
1599
|
if (!plan) throw new Error(`no batch ${resumeId} in ${root}`);
|
|
1577
1600
|
argv = plan.argv;
|
|
1601
|
+
// #5031: a resume (operator or schedd restart) keeps the base waits recorded at start; a landed commit releases on the first check
|
|
1602
|
+
for (const [n, l] of Object.entries(plan.baseWaits ?? {})) if (Array.isArray(l) && l.length) baseWaits.set(Number(n), l.filter((w) => w && typeof w.sha === "string" && typeof w.base === "string"));
|
|
1578
1603
|
}
|
|
1579
1604
|
const issues = issuesOf(argv);
|
|
1580
1605
|
if (!issues.length) throw new Error(ORCH_USAGE);
|
|
@@ -1602,16 +1627,26 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1602
1627
|
const { unmetDependencies } = await import("../scripts/plan-validate-live.mjs");
|
|
1603
1628
|
const gateBranches = readBranches(cwd);
|
|
1604
1629
|
const gateIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1605
|
-
const
|
|
1630
|
+
const isAncestor = async (sha, b) => {
|
|
1631
|
+
for (const ref of [`origin/${b}`, b]) if (await io.git?.(["-C", cwd, "merge-base", "--is-ancestor", sha, ref]).then(() => true, () => false)) return true;
|
|
1632
|
+
return false;
|
|
1633
|
+
};
|
|
1634
|
+
let unmet = await unmetDependencies({ issues, repo, gh: io.gh, isAncestor, baseOf: async (n) => {
|
|
1606
1635
|
const r = await resolveLane({ issue: n, repo, io: gateIo, cwd, branches: gateBranches, requested: lane, override: laneOverride });
|
|
1607
1636
|
return r.ok ? r.lane : null;
|
|
1608
1637
|
} });
|
|
1638
|
+
// #5031: a dependency merged but not yet on the base is admitted and held until its commit lands; any other unmet edge still refuses
|
|
1639
|
+
for (const u of unmet.filter((x) => x.kind === "commit")) {
|
|
1640
|
+
const list = baseWaits.get(u.issue) ?? []; list.push({ dep: u.dep, sha: u.sha, base: u.base }); baseWaits.set(u.issue, list);
|
|
1641
|
+
}
|
|
1642
|
+
for (const [n, l] of baseWaits) sink.line(`held: #${n} waits for ${l.map((w) => `#${w.dep} (${w.sha.slice(0, 10)}) on ${w.base}`).join(", ")}; admitted once the commit lands`);
|
|
1643
|
+
unmet = unmet.filter((x) => x.kind !== "commit");
|
|
1609
1644
|
if (unmet.length) throw new Error(`parallel milestone start refused: ${unmet.map((u) => `#${u.issue} depends on #${u.dep} (${u.why}; base ${u.base})`).join("; ")}. Merge the dependencies first, or pass --force "<reason>".`);
|
|
1610
1645
|
}
|
|
1611
1646
|
const base = deps.batchId ?? batchMod.newBatchId();
|
|
1612
1647
|
batch = batchMod.openBatch(root, base);
|
|
1613
1648
|
for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
|
|
1614
|
-
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(force ? { forced: force } : {}), ...(daemon ? { daemon: true, env: kept } : {}) };
|
|
1649
|
+
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(force ? { forced: force } : {}), ...(baseWaits.size ? { baseWaits: Object.fromEntries(baseWaits) } : {}), ...(daemon ? { daemon: true, env: kept } : {}) };
|
|
1615
1650
|
batch.writePlan(plan);
|
|
1616
1651
|
batch.setParallel(parallel);
|
|
1617
1652
|
} else {
|
|
@@ -1644,8 +1679,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1644
1679
|
const prefilter = argv.includes("--no-prefilter") ? null
|
|
1645
1680
|
: makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd), minAge: await readPostDeployMinAge(cwd) });
|
|
1646
1681
|
const fetched = new Set();
|
|
1682
|
+
const laneCache = makeLaneCache(), milestoneOf = new Map(); // #4963: shared default-branch / ls-remote reads; the milestone readBody already fetched
|
|
1647
1683
|
const laneOf = async (n) => {
|
|
1648
|
-
const
|
|
1684
|
+
const known = milestoneOf.get(n); milestoneOf.delete(n); // used once: a later retry re-reads the live milestone
|
|
1685
|
+
const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride, cache: laneCache, milestone: known });
|
|
1649
1686
|
if (r.ok && prefilter && !fetched.has(r.lane)) { fetched.add(r.lane); try { await leanIo.git(["fetch", "origin", r.lane], { cwd }); } catch { /* offline: use the local ref */ } }
|
|
1650
1687
|
return r;
|
|
1651
1688
|
};
|
|
@@ -1727,12 +1764,13 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1727
1764
|
return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom(), needs: needsOf.get(n) ?? [], relays });
|
|
1728
1765
|
})();
|
|
1729
1766
|
// #4540: provider free slots, refreshed in the background (admission is sync); unknown (null) never blocks.
|
|
1730
|
-
let capSample = null, onlineSample = null, capAt = 0, capLast = -Infinity; // #4547: online = agents alive, null unknown
|
|
1767
|
+
let capSample = null, onlineSample = null, capAt = 0, capLast = -Infinity, capFailed = false; // #5048: capFailed = last probe rejected // #4547: online = agents alive, null unknown
|
|
1731
1768
|
const capacity = () => {
|
|
1769
|
+
if (capFailed) throw new Error("capacity probe failed"); // #5048: until the next successful sample, so every caller sees it (release review CONC-1)
|
|
1732
1770
|
const t = deps.now ? deps.now() : Date.now();
|
|
1733
1771
|
if (typeof provider.capacity === "function" && t - capLast >= (deps.pollMs ?? 5000)) {
|
|
1734
1772
|
capLast = t;
|
|
1735
|
-
Promise.resolve().then(() => provider.capacity()).then((c) => { capSample = c && Number.isInteger(c.free) ? c.free : null; onlineSample = c && Number.isInteger(c.online) ? c.online : null; capAt++; }, () => { capSample = null; onlineSample = null; capAt++; });
|
|
1773
|
+
Promise.resolve().then(() => provider.capacity()).then((c) => { capFailed = false; capSample = c && Number.isInteger(c.free) ? c.free : null; onlineSample = c && Number.isInteger(c.online) ? c.online : null; capAt++; }, () => { capSample = null; onlineSample = null; capFailed = true; capAt++; });
|
|
1736
1774
|
}
|
|
1737
1775
|
return capSample === null && onlineSample === null ? null : { free: capSample, online: onlineSample, at: capAt };
|
|
1738
1776
|
};
|
|
@@ -1886,7 +1924,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1886
1924
|
trusted: (n) => trustedOf.get(n) === true,
|
|
1887
1925
|
autoChildren,
|
|
1888
1926
|
...(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 }) }) } : {}),
|
|
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,
|
|
1927
|
+
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, baseWait: baseWaits.size ? baseWaitOf(baseWaits, { io, leanIo, cwd }) : null, ...lanes,
|
|
1890
1928
|
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
|
|
1891
1929
|
...(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)) } : {}),
|
|
1892
1930
|
...(remote ? { remote } : {}),
|
|
@@ -1928,8 +1966,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1928
1966
|
sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
|
|
1929
1967
|
},
|
|
1930
1968
|
readBody: maint || registry || hybrid
|
|
1931
|
-
? async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,labels,comments,author"); trustedOf.set(n, bodyTrusted(v)); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }
|
|
1932
|
-
: async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,comments,author"); trustedOf.set(n, bodyTrusted(v)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }, // #4785: decisions and the frozen contract's validate commands too
|
|
1969
|
+
? async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,labels,comments,author,milestone"); if ("milestone" in v) milestoneOf.set(n, v.milestone?.title ?? null); trustedOf.set(n, bodyTrusted(v)); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }
|
|
1970
|
+
: async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,comments,author,milestone"); if ("milestone" in v) milestoneOf.set(n, v.milestone?.title ?? null); trustedOf.set(n, bodyTrusted(v)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }, // #4785: decisions and the frozen contract's validate commands too
|
|
1933
1971
|
onUpdate: (r) => sink.row(r),
|
|
1934
1972
|
});
|
|
1935
1973
|
} finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
@@ -2301,11 +2339,12 @@ export async function schedFromCli(argv, deps = {}) {
|
|
|
2301
2339
|
const st = reg.list();
|
|
2302
2340
|
const who = (e) => `#${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} P${e.priority}`;
|
|
2303
2341
|
console.log(deps.tick ?? health.tickText(health.stallCheck({ dir: schedMod.schedDir() })));
|
|
2304
|
-
console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}`);
|
|
2342
|
+
console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}, remote ${(st.remote ?? []).length}`);
|
|
2305
2343
|
const ws = Object.entries(st.weights ?? {});
|
|
2306
2344
|
console.log(`repo weights: ${ws.length ? ws.map(([r, n]) => `${r}=${n}`).join(", ") : "equal (none set)"}`);
|
|
2307
2345
|
for (const e of st.slots) console.log(` running ${who(e)} dispatcher ${e.pid}${e.lanePid ? ` lane ${e.lanePid}` : ""}`);
|
|
2308
2346
|
for (const e of st.queue) console.log(` queued ${who(e)} dispatcher ${e.pid}`);
|
|
2347
|
+
for (const l of schedMod.remoteLines(st)) console.log(l); // #4964
|
|
2309
2348
|
return st;
|
|
2310
2349
|
}
|
|
2311
2350
|
if (sub === "cap") {
|
package/bin/forgedock.mjs
CHANGED
|
@@ -221,6 +221,19 @@ function detectInstallPaths() {
|
|
|
221
221
|
|
|
222
222
|
const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* True when a link at ~/.claude/<kind>/<rel> points at a ForgeDock-owned
|
|
226
|
+
* source: the package file, or its copy in the persisted ~/.forge home
|
|
227
|
+
* (persistHome(), #1943, makes npm installs link there). Path-only, so links
|
|
228
|
+
* left dangling after ~/.forge is deleted still count as ours (#4962).
|
|
229
|
+
*/
|
|
230
|
+
function isOwnedLinkTarget(dest, src, kind, rel) {
|
|
231
|
+
return (
|
|
232
|
+
dest === src ||
|
|
233
|
+
resolve(dest) === resolve(join(HOME, ".forge", kind, rel))
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
224
237
|
// PIPELINE_SCRIPTS (the allowlist of universal pipeline-agent scripts linked
|
|
225
238
|
// to ~/.claude/scripts/) is defined in journey.mjs — forge()'s
|
|
226
239
|
// linkPipelineScripts() step is what writes them there (forge#1885,
|
|
@@ -1183,7 +1196,7 @@ async function uninstall() {
|
|
|
1183
1196
|
const stats = await lstat(target);
|
|
1184
1197
|
if (stats.isSymbolicLink()) {
|
|
1185
1198
|
const current = await readlink(target);
|
|
1186
|
-
if (current
|
|
1199
|
+
if (isOwnedLinkTarget(current, file, "commands", rel)) {
|
|
1187
1200
|
await unlink(target);
|
|
1188
1201
|
console.log(` ${RED}Removed${RESET}: ${rel}`);
|
|
1189
1202
|
removed++;
|
|
@@ -1259,7 +1272,7 @@ async function uninstall() {
|
|
|
1259
1272
|
const stats = await lstat(target);
|
|
1260
1273
|
if (stats.isSymbolicLink()) {
|
|
1261
1274
|
const current = await readlink(target);
|
|
1262
|
-
if (current
|
|
1275
|
+
if (isOwnedLinkTarget(current, file, "scripts", rel)) {
|
|
1263
1276
|
await unlink(target);
|
|
1264
1277
|
console.log(` ${RED}Removed${RESET}: scripts/${rel}`);
|
|
1265
1278
|
scriptsRemoved++;
|
|
@@ -2507,10 +2520,16 @@ async function doctor(fix = false) {
|
|
|
2507
2520
|
const lstats = await lstat(tgt);
|
|
2508
2521
|
if (lstats.isSymbolicLink()) {
|
|
2509
2522
|
const dest = await readlink(tgt);
|
|
2510
|
-
if (dest !== src) {
|
|
2523
|
+
if (dest !== src && !isOwnedLinkTarget(dest, src, "commands", rel)) {
|
|
2511
2524
|
broken++;
|
|
2512
2525
|
brokenLinks.push(rel);
|
|
2513
2526
|
symlinkOk = false;
|
|
2527
|
+
} else if (dest !== src && readFileSync(tgt, "utf-8") !== readFileSync(src, "utf-8")) {
|
|
2528
|
+
// Linked into the persisted ~/.forge home: valid only while
|
|
2529
|
+
// that copy matches the package source (#4962).
|
|
2530
|
+
broken++;
|
|
2531
|
+
brokenLinks.push(`${rel} (stale ~/.forge copy)`);
|
|
2532
|
+
symlinkOk = false;
|
|
2514
2533
|
} else {
|
|
2515
2534
|
try {
|
|
2516
2535
|
// readlink() only validates the stored target. Opening the
|
|
@@ -3111,34 +3130,41 @@ async function doctor(fix = false) {
|
|
|
3111
3130
|
// server presence is orthogonal to the core ForgeDock install — but missing
|
|
3112
3131
|
// it causes /qa-sweep to silently fail mid-sweep.
|
|
3113
3132
|
{
|
|
3133
|
+
// Claude Code registers servers in ~/.claude.json (user + per-project) and
|
|
3134
|
+
// project .mcp.json; the legacy mcp_servers.json is still honoured (#4962).
|
|
3114
3135
|
const mcpServersPath = join(GLOBAL_CLAUDE_DIR, "mcp_servers.json");
|
|
3115
3136
|
let playwrightFound = false;
|
|
3116
3137
|
let fileReadable = false;
|
|
3117
3138
|
let hasMcpServers = false;
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3139
|
+
const readJson = (p) => {
|
|
3140
|
+
try {
|
|
3141
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
3142
|
+
} catch {
|
|
3143
|
+
return null;
|
|
3144
|
+
}
|
|
3145
|
+
};
|
|
3146
|
+
const userCfg = readJson(join(HOME, ".claude.json"));
|
|
3147
|
+
const sources = [
|
|
3148
|
+
readJson(mcpServersPath),
|
|
3149
|
+
userCfg,
|
|
3150
|
+
userCfg?.projects?.[process.cwd()],
|
|
3151
|
+
readJson(join(process.cwd(), ".mcp.json")),
|
|
3152
|
+
];
|
|
3153
|
+
for (const cfg of sources) {
|
|
3154
|
+
if (!cfg || typeof cfg !== "object") continue;
|
|
3123
3155
|
fileReadable = true;
|
|
3124
|
-
const servers =
|
|
3125
|
-
if (servers
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
if (nameLower.includes("playwright") || cmdStr.includes("playwright")) {
|
|
3135
|
-
playwrightFound = true;
|
|
3136
|
-
break;
|
|
3137
|
-
}
|
|
3156
|
+
const servers = cfg.mcpServers;
|
|
3157
|
+
if (!servers || typeof servers !== "object") continue;
|
|
3158
|
+
hasMcpServers = true;
|
|
3159
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
3160
|
+
const cmdStr = [
|
|
3161
|
+
entry?.command ?? "",
|
|
3162
|
+
...(Array.isArray(entry?.args) ? entry.args : []),
|
|
3163
|
+
].join(" ").toLowerCase();
|
|
3164
|
+
if (name.toLowerCase().includes("playwright") || cmdStr.includes("playwright")) {
|
|
3165
|
+
playwrightFound = true;
|
|
3138
3166
|
}
|
|
3139
3167
|
}
|
|
3140
|
-
} catch {
|
|
3141
|
-
// File absent or unreadable — treat as no MCP servers configured
|
|
3142
3168
|
}
|
|
3143
3169
|
|
|
3144
3170
|
if (!fileReadable) {
|
package/bin/gc.mjs
CHANGED
|
@@ -600,7 +600,9 @@ export function hygieneCheck(plan, { applied = null, ttlMs = plan?.ttlMs ?? hygi
|
|
|
600
600
|
if (owned.length) parts.push(`${owned.length} ${t}${owned.length === 1 ? "" : "s"} ${fmtSize(owned.reduce((s, i) => s + (i.sizeBytes ?? 0), 0))}`);
|
|
601
601
|
}
|
|
602
602
|
const reclaimable = items.filter((i) => i.owned && i.action === "reclaim").length;
|
|
603
|
-
|
|
603
|
+
// Unrelated docker projects (compose etc.) are not ours to nag about (#4962): only ForgeDock-named docker resources count.
|
|
604
|
+
const forgeNamed = (i) => (i.type !== "container" && i.type !== "volume") || /^(forgedock|forge-|fdrsbx)/i.test(String(i.id));
|
|
605
|
+
const stale = items.filter((i) => !i.owned && forgeNamed(i) && i.ageMs != null && i.ageMs >= (ttlMs?.[i.type] ?? LEDGER_TTL_MS)).length;
|
|
604
606
|
const bits = [`owned: ${parts.length ? parts.join(", ") : "none"}`];
|
|
605
607
|
if (applied) bits.push(`reclaimed ${applied.reclaimed.length} of ${reclaimable}${applied.failed.length ? ` (${applied.failed.length} failed)` : ""}${applied.deferred?.length ? ` (${applied.deferred.length} deferred)` : ""}`);
|
|
606
608
|
else bits.push(`${reclaimable} reclaimable`);
|
|
@@ -602,7 +602,7 @@ async function main() {
|
|
|
602
602
|
if (!(await isForgeDockManagedCwd())) { process.exit(0); return; }
|
|
603
603
|
|
|
604
604
|
// --- Rule 1: PR branch target validation ---
|
|
605
|
-
const prViolation = checkPrTarget(command);
|
|
605
|
+
const prViolation = await checkPrTarget(command);
|
|
606
606
|
if (prViolation) {
|
|
607
607
|
process.stderr.write(prViolation);
|
|
608
608
|
process.exit(2);
|
|
@@ -749,6 +749,51 @@ async function isForgeDockManagedCwd() {
|
|
|
749
749
|
// Rule 1: PR branch target validation
|
|
750
750
|
// ---------------------------------------------------------------------------
|
|
751
751
|
|
|
752
|
+
/**
|
|
753
|
+
* Resolve the `owner/repo` a `gh pr create` command targets: `-R`/`--repo`,
|
|
754
|
+
* else the cwd's `origin` remote. Lower-cased; null when unresolvable.
|
|
755
|
+
*
|
|
756
|
+
* @param {string} command
|
|
757
|
+
* @returns {string|null}
|
|
758
|
+
*/
|
|
759
|
+
function resolvePrTargetRepo(command) {
|
|
760
|
+
const slug = (v) => {
|
|
761
|
+
const m = String(v || "").trim().match(/([^/:\s]+)\/([^/\s]+?)(?:\.git)?\/?$/);
|
|
762
|
+
return m ? `${m[1]}/${m[2]}`.toLowerCase() : null;
|
|
763
|
+
};
|
|
764
|
+
const flagged = extractFlag(command, "--repo") || extractFlag(command, "-R");
|
|
765
|
+
if (flagged) return slug(flagged);
|
|
766
|
+
try {
|
|
767
|
+
const { execFileSync: exec } = _require("child_process");
|
|
768
|
+
return slug(exec("git", ["remote", "get-url", "origin"], {
|
|
769
|
+
encoding: "utf-8",
|
|
770
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
771
|
+
timeout: 5000,
|
|
772
|
+
}));
|
|
773
|
+
} catch {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* `owner/repo` of the forge.yaml in cwd (project.owner / project.repo),
|
|
780
|
+
* lower-cased; null when absent or unreadable.
|
|
781
|
+
*
|
|
782
|
+
* @returns {Promise<string|null>}
|
|
783
|
+
*/
|
|
784
|
+
async function configuredRepoSlug() {
|
|
785
|
+
try {
|
|
786
|
+
const raw = _require("fs").readFileSync(join(process.cwd(), "forge.yaml"), "utf-8");
|
|
787
|
+
const { parseForgeYaml } = await import(
|
|
788
|
+
pathToFileURL(join(FORGE_HOME, "bin", "forge-utils.mjs")).href
|
|
789
|
+
);
|
|
790
|
+
const { owner, repo } = parseForgeYaml(raw)?.project ?? {};
|
|
791
|
+
return owner && repo ? `${owner}/${repo}`.toLowerCase() : null;
|
|
792
|
+
} catch {
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
752
797
|
/**
|
|
753
798
|
* Check whether a gh pr create command targets a forbidden base branch.
|
|
754
799
|
*
|
|
@@ -757,9 +802,9 @@ async function isForgeDockManagedCwd() {
|
|
|
757
802
|
* main are still hard-blocked.
|
|
758
803
|
*
|
|
759
804
|
* @param {string} command
|
|
760
|
-
* @returns {string|null} Error message to show, or null if allowed.
|
|
805
|
+
* @returns {Promise<string|null>} Error message to show, or null if allowed.
|
|
761
806
|
*/
|
|
762
|
-
function checkPrTarget(command) {
|
|
807
|
+
async function checkPrTarget(command) {
|
|
763
808
|
// Only check gh pr create commands.
|
|
764
809
|
if (!/gh\s+pr\s+create/.test(command)) return null;
|
|
765
810
|
|
|
@@ -767,6 +812,12 @@ function checkPrTarget(command) {
|
|
|
767
812
|
if (!base) return null; // no --base flag — gh will use the default
|
|
768
813
|
|
|
769
814
|
if (FORBIDDEN_PR_BASES.includes(base.toLowerCase())) {
|
|
815
|
+
// The staging/milestone rule only governs the configured forge repo; a PR
|
|
816
|
+
// to a positively-identified other repo may legitimately target main.
|
|
817
|
+
const target = resolvePrTargetRepo(command);
|
|
818
|
+
const configured = await configuredRepoSlug();
|
|
819
|
+
if (target && configured && target !== configured) return null;
|
|
820
|
+
|
|
770
821
|
// Allow staging → main (the deploy path, not a pipeline violation).
|
|
771
822
|
const head = extractFlag(command, "--head") || extractFlag(command, "-H") || currentGitBranch();
|
|
772
823
|
if (head && /^staging$/.test(head)) {
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// Pure and deterministic: the child scan reads one open-issue list per pass; no LLM call.
|
|
5
5
|
import { parentOf } from "./context.mjs";
|
|
6
6
|
import { parseHelpFields } from "./selftest-static.mjs";
|
|
7
|
+
import { listAssociations } from "./gh-assoc.mjs";
|
|
8
|
+
import { isTrustedActor } from "../engine/phases.mjs";
|
|
7
9
|
|
|
8
10
|
export const AUTO_MODES = ["off", "decomposed", "epic", "all"];
|
|
9
11
|
export const DEFAULT_AUTO_MODE = "decomposed";
|
|
@@ -41,9 +43,11 @@ const labelsOf = (it) => nodesOf(it?.labels).map((l) => String(l?.name ?? l));
|
|
|
41
43
|
* @param {Iterable<number>} roots the batch issues plus the `--epic N` epics
|
|
42
44
|
* @param {Iterable<number>} skip issues the batch already holds
|
|
43
45
|
* @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.
|
|
46
|
+
* an issue labelled `needs-human` is left out. #4982: so is a child whose author is not trusted (the `Blocked by` edge
|
|
47
|
+
* rule, `isTrustedActor`; a missing association fails closed), reported through `onSkip({number, reason})`.
|
|
48
|
+
* @param {(s:{number:number, reason:string})=>void} [onSkip]
|
|
45
49
|
*/
|
|
46
|
-
export function selectChildren({ list, roots, skip = [] }) {
|
|
50
|
+
export function selectChildren({ list, roots, skip = [], onSkip }) {
|
|
47
51
|
const rootSet = new Set(roots), skipSet = new Set(skip), open = new Set(), out = new Set();
|
|
48
52
|
for (const it of list ?? []) if (Number.isSafeInteger(it?.number)) open.add(it.number);
|
|
49
53
|
for (const it of list ?? []) {
|
|
@@ -54,10 +58,12 @@ export function selectChildren({ list, roots, skip = [] }) {
|
|
|
54
58
|
if (p != null && rootSet.has(p)) out.add(n);
|
|
55
59
|
}
|
|
56
60
|
const byNum = new Map((list ?? []).map((it) => [it.number, it]));
|
|
57
|
-
|
|
61
|
+
const trusted = (it) => isTrustedActor({ association: it?.authorAssociation, type: it?.author?.is_bot ? "Bot" : "User", login: it?.author?.login });
|
|
62
|
+
return [...out].filter((n) => !skipSet.has(n) && !labelsOf(byNum.get(n)).includes("needs-human")).sort((a, b) => a - b)
|
|
63
|
+
.filter((n) => trusted(byNum.get(n)) || (onSkip?.({ number: n, reason: "untrusted author" }), false));
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
const REQUIRED_FIELDS = ["number", "body", "labels"], OPTIONAL_FIELDS = ["parent", "subIssues"];
|
|
66
|
+
const REQUIRED_FIELDS = ["number", "body", "labels", "author"], OPTIONAL_FIELDS = ["parent", "subIssues"];
|
|
61
67
|
|
|
62
68
|
/** 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
69
|
export async function hostListFields(gh) {
|
|
@@ -74,7 +80,9 @@ export async function readChildList({ gh, repo, limit = 1000, fields = null }) {
|
|
|
74
80
|
const f = fields ?? (await hostListFields(gh));
|
|
75
81
|
const raw = await gh(["issue", "list", "-R", repo, "--state", "open", "--limit", String(limit), "--json", f.join(",")]);
|
|
76
82
|
const v = JSON.parse(raw);
|
|
77
|
-
|
|
83
|
+
if (!Array.isArray(v)) return [];
|
|
84
|
+
const assoc = await listAssociations(gh, repo); // #4982: not a `gh issue list` field (#4859); unreadable = every author untrusted
|
|
85
|
+
return v.map((it) => ({ ...it, authorAssociation: assoc.get(it?.number) }));
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
/**
|
|
@@ -26,12 +26,12 @@ describe("#4965 modes", () => {
|
|
|
26
26
|
describe("#4965 selectChildren", () => {
|
|
27
27
|
const list = [
|
|
28
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: [] },
|
|
29
|
+
{ number: 2, body: "x\n\n**Parent**: #1", labels: [], authorAssociation: "MEMBER" },
|
|
30
|
+
{ number: 3, body: "", labels: [], parent: { number: 1 }, authorAssociation: "MEMBER" },
|
|
31
|
+
{ number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }], authorAssociation: "MEMBER" },
|
|
32
|
+
{ number: 5, body: "", labels: [], authorAssociation: "MEMBER" },
|
|
33
|
+
{ number: 6, body: "**Parent**: #77", labels: [], authorAssociation: "MEMBER" },
|
|
34
|
+
{ number: 7, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" },
|
|
35
35
|
];
|
|
36
36
|
it("finds children by Parent line, parent field and sub-issues; skips needs-human, closed sub-issues and unrelated", () => {
|
|
37
37
|
assert.deepEqual(selectChildren({ list, roots: [1], skip: [1] }), [2, 3, 5, 7]);
|
|
@@ -42,8 +42,25 @@ describe("#4965 selectChildren", () => {
|
|
|
42
42
|
it("an --epic root outside the batch yields its children", () => {
|
|
43
43
|
assert.deepEqual(selectChildren({ list, roots: [77] }), [6]);
|
|
44
44
|
});
|
|
45
|
+
it("#4982 selectChildren drops children of an untrusted author and reports each", () => {
|
|
46
|
+
const l = [
|
|
47
|
+
{ number: 1, body: "", labels: [], authorAssociation: "OWNER", subIssues: { nodes: [{ number: 5 }] } },
|
|
48
|
+
{ number: 2, body: "**Parent**: #1", labels: [], authorAssociation: "NONE", author: { login: "eve" } },
|
|
49
|
+
{ number: 3, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" },
|
|
50
|
+
{ number: 4, body: "**Parent**: #1", labels: [], author: { login: "app", is_bot: true } },
|
|
51
|
+
{ number: 5, body: "", labels: [] },
|
|
52
|
+
{ number: 6, body: "", labels: [], parent: { number: 1 } },
|
|
53
|
+
];
|
|
54
|
+
const skipped = [];
|
|
55
|
+
assert.deepEqual(selectChildren({ list: l, roots: [1], skip: [1], onSkip: (s) => skipped.push(s) }), [3, 4]);
|
|
56
|
+
assert.deepEqual(skipped, [2, 5, 6].map((number) => ({ number, reason: "untrusted author" })));
|
|
57
|
+
});
|
|
58
|
+
it("#4982 readChildList merges the REST author association per issue", async () => {
|
|
59
|
+
const gh = async (a) => a[0] === "api" ? "1\tMEMBER\n" : a.includes("--help") ? "JSON FIELDS\n number, body, labels, author\n\nEXAMPLES\n" : "[{\"number\":1},{\"number\":2}]";
|
|
60
|
+
assert.deepEqual(await readChildList({ gh, repo: "o/r" }), [{ number: 1, authorAssociation: "MEMBER" }, { number: 2, authorAssociation: undefined }]);
|
|
61
|
+
});
|
|
45
62
|
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]);
|
|
63
|
+
assert.deepEqual(selectChildren({ list: [...list, { number: 8, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" }], roots: [1], skip: [1, 2, 3, 5, 7] }), [8]);
|
|
47
64
|
});
|
|
48
65
|
});
|
|
49
66
|
|
|
@@ -59,17 +76,17 @@ describe("#4965 readers", () => {
|
|
|
59
76
|
};
|
|
60
77
|
it("readChildList asks the host gh for its fields, then makes one list call", async () => {
|
|
61
78
|
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) =>
|
|
64
|
-
assert.equal(calls.
|
|
65
|
-
assert.deepEqual(r, [{ number: 1 }]);
|
|
79
|
+
const r = await readChildList({ gh: fakeGh(["number", "body", "labels", "author", "parent", "subIssues", "title"], calls), repo: "o/r" });
|
|
80
|
+
assert.equal(calls.filter((a) => a.includes("--json")).length, 1);
|
|
81
|
+
assert.equal(calls.findLast((a) => a.includes("--json"))[calls.findLast((a) => a.includes("--json")).indexOf("--json") + 1], "number,body,labels,author,parent,subIssues");
|
|
82
|
+
assert.deepEqual(r, [{ number: 1, authorAssociation: undefined }]);
|
|
66
83
|
});
|
|
67
84
|
it("a host gh without parent/subIssues still lists, with the fields it has (children found by Parent line)", async () => {
|
|
68
85
|
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.
|
|
86
|
+
const gh = fakeGh(["number", "body", "labels", "author", "title"], calls);
|
|
87
|
+
assert.deepEqual(await hostListFields(gh), ["number", "body", "labels", "author"]);
|
|
88
|
+
assert.deepEqual(await readChildList({ gh, repo: "o/r" }), [{ number: 1, authorAssociation: undefined }]);
|
|
89
|
+
assert.equal(calls.findLast((a) => a.includes("--json"))[calls.findLast((a) => a.includes("--json")).indexOf("--json") + 1], "number,body,labels,author");
|
|
73
90
|
});
|
|
74
91
|
it("liveElsewhere holds the unsettled issues of other live batches of the repo only", () => {
|
|
75
92
|
const mk = (o) => ({ readReport: () => o.report ?? null, readPlan: () => ({ repo: o.repo ?? "o/r" }), store: { load: () => ({ order: o.order, results: o.results ?? {} }) } });
|
|
@@ -109,11 +126,12 @@ appendFileSync(${JSON.stringify(launches)}, a[1] + "\\n"); writeFileSync(rf, JSO
|
|
|
109
126
|
b.setParallel(2);
|
|
110
127
|
const lists = [];
|
|
111
128
|
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: [] }]); }
|
|
129
|
+
if (a[0] === "issue" && a[1] === "list" && a.includes("--help")) return "JSON FIELDS\n number, body, labels, author, parent, subIssues\n\nEXAMPLES\n";
|
|
130
|
+
if (a[0] === "issue" && a[1] === "list" && a.includes("--state") && a.includes("number,body,labels,author,parent,subIssues")) { lists.push(a); return JSON.stringify([{ number: 9, body: "", labels: [], authorAssociation: "MEMBER" }, { number: 51, body: "**Parent**: #9", labels: [], authorAssociation: "MEMBER" }]); }
|
|
131
|
+
if (a[0] === "api" && /issues\?state=open/.test(a[1])) return "9\tMEMBER\n51\tMEMBER\n";
|
|
114
132
|
if (a.includes("milestone")) return JSON.stringify({ milestone: null });
|
|
115
133
|
if (a[0] === "repo") return "main\n";
|
|
116
|
-
return JSON.stringify({ body: "", state: "OPEN", labels: [] });
|
|
134
|
+
return JSON.stringify({ body: "", state: "OPEN", labels: [], authorAssociation: "MEMBER" });
|
|
117
135
|
};
|
|
118
136
|
const deps = { batchRoot: root, allowTmpfs: true, laneScript: stub, pollMs: 20, orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\n" },
|
|
119
137
|
leanIo: { gh, git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : "") } };
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
+
import { resolveLane, makeLaneCache } from "./lane.mjs";
|
|
9
10
|
import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists } from "./orchestrate.mjs";
|
|
10
11
|
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
|
|
11
12
|
import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
|
|
@@ -1194,4 +1195,49 @@ describe("batch plan secrets (#4787)", () => {
|
|
|
1194
1195
|
assert.equal(statSync(join(b.dir, "plan.json")).mode & 0o777, 0o600);
|
|
1195
1196
|
assert.deepEqual(b.scrubPlan(), []);
|
|
1196
1197
|
});
|
|
1198
|
+
|
|
1199
|
+
it("setup reads run with bounded concurrency, keep order and deps, and write an early setup state (#4963)", async () => {
|
|
1200
|
+
const saves = []; let live = 0, peak = 0;
|
|
1201
|
+
const bodies = { 1: "Blocked by #2", 2: "", 3: "Depends on #1", 4: "", 5: "" };
|
|
1202
|
+
const store = { load: () => null, save: (s) => saves.push(JSON.parse(JSON.stringify(s))) };
|
|
1203
|
+
await orchestrate({ issues: [1, 2, 3, 4, 5], parallel: 1, store, setupConcurrency: 2, trusted: () => true,
|
|
1204
|
+
readBody: async (n) => { live++; peak = Math.max(peak, live); await new Promise((r) => setTimeout(r, 15 - n * 2)); live--; return bodies[n]; },
|
|
1205
|
+
runOne: async () => ({ terminalReason: "merged" }), now: () => Date.now(), sleep: async () => {}, pollMs: 1 });
|
|
1206
|
+
assert.equal(peak, 2);
|
|
1207
|
+
const setupSaves = saves.filter((s) => s.phase === "setup");
|
|
1208
|
+
assert.deepEqual(setupSaves[0].setup, { read: 0, total: 5 });
|
|
1209
|
+
assert.equal(setupSaves.at(-1).setup.read, 5);
|
|
1210
|
+
assert.deepEqual(setupSaves[0].order, [1, 2, 3, 4, 5]);
|
|
1211
|
+
const last = saves.at(-1);
|
|
1212
|
+
assert.equal(last.phase, undefined);
|
|
1213
|
+
assert.deepEqual(last.deps, { 1: [2], 2: [], 3: [1], 4: [], 5: [] });
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
it("a setup save never records an issue as read before its lane is resolved (#4963 resume safety)", async () => {
|
|
1217
|
+
const saves = [];
|
|
1218
|
+
const store = { load: () => null, save: (s) => saves.push(JSON.parse(JSON.stringify(s))) };
|
|
1219
|
+
await orchestrate({ issues: [1, 2, 3, 4], parallel: 1, store, setupConcurrency: 4, trusted: () => true,
|
|
1220
|
+
readBody: async () => "",
|
|
1221
|
+
resolveLane: async (n) => { await new Promise((r) => setTimeout(r, 5 + n * 10)); return { ok: true, lane: "staging" }; },
|
|
1222
|
+
runOne: async () => ({ terminalReason: "merged" }), now: () => Date.now(), sleep: async () => {}, pollMs: 1 });
|
|
1223
|
+
for (const s of saves.filter((x) => x.phase === "setup")) for (const n of Object.keys(s.deps ?? {})) assert.ok(s.lanes?.[n], `setup save lists #${n} in deps without its lane`);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it("5 issues with slow gh/git calls reach their first admitted lane in a fraction of the serial setup time (#4963)", async () => {
|
|
1227
|
+
const LAT = 30; // ms per gh/git call, standing in for a loaded host
|
|
1228
|
+
const wait = () => new Promise((r) => setTimeout(r, LAT));
|
|
1229
|
+
const firstLane = async (setupConcurrency, cache) => {
|
|
1230
|
+
const io = { gh: async (a) => { await wait(); return a[0] === "repo" ? "main\n" : JSON.stringify({ milestone: null }); }, git: async () => { await wait(); return "abc\trefs/heads/staging\n"; } };
|
|
1231
|
+
const t0 = Date.now(); let first = null;
|
|
1232
|
+
await orchestrate({ issues: [1, 2, 3, 4, 5], parallel: 5, setupConcurrency, trusted: () => true, now: () => Date.now(), sleep: async () => {}, pollMs: 1,
|
|
1233
|
+
readBody: async () => { await wait(); return ""; },
|
|
1234
|
+
resolveLane: (n) => resolveLane({ issue: n, repo: "o/r", io, cwd: "/r", ...(cache ? { cache: makeLaneCache(), milestone: null } : {}) }),
|
|
1235
|
+
runOne: async () => { first ??= Date.now() - t0; return { terminalReason: "merged" }; } });
|
|
1236
|
+
return first;
|
|
1237
|
+
};
|
|
1238
|
+
const serial = await firstLane(1, false), fast = await firstLane(6, true);
|
|
1239
|
+
// Serial: 5 issues x 4 calls x LAT = 600 ms; bounded + shared lookups: about 3 rounds = 90 ms. The 2-minute target is a 5x cut or better here.
|
|
1240
|
+
assert.ok(fast < serial / 3, `fast ${fast} ms vs serial ${serial} ms`);
|
|
1241
|
+
assert.ok(fast < 10 * LAT, `first lane after ${fast} ms`);
|
|
1242
|
+
});
|
|
1197
1243
|
});
|