forgedock 1.32.1 → 1.33.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.
Files changed (64) hide show
  1. package/bin/engine/outcome-codes.mjs +7 -3
  2. package/bin/engine/phases.mjs +3 -3
  3. package/bin/engine-cli.mjs +56 -11
  4. package/bin/forgedock.mjs +21 -1
  5. package/bin/lean/basesync.mjs +98 -0
  6. package/bin/lean/basesync.test.mjs +151 -0
  7. package/bin/lean/batch.test.mjs +103 -2
  8. package/bin/lean/budget.mjs +7 -1
  9. package/bin/lean/budget.test.mjs +9 -1
  10. package/bin/lean/conformance.mjs +20 -7
  11. package/bin/lean/conformance.test.mjs +29 -2
  12. package/bin/lean/depcache.mjs +23 -3
  13. package/bin/lean/depcache.test.mjs +42 -1
  14. package/bin/lean/effects.mjs +1 -1
  15. package/bin/lean/execution.mjs +102 -12
  16. package/bin/lean/execution.test.mjs +101 -1
  17. package/bin/lean/hostkill.mjs +8 -0
  18. package/bin/lean/lean.mjs +49 -21
  19. package/bin/lean/lean.test.mjs +133 -7
  20. package/bin/lean/lightlane.mjs +38 -13
  21. package/bin/lean/lightlane.test.mjs +35 -1
  22. package/bin/lean/orchestrate.mjs +63 -17
  23. package/bin/lean/prompts.mjs +17 -7
  24. package/bin/lean/recovery.test.mjs +26 -0
  25. package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
  26. package/bin/lean/sandbox-remote.mjs +40 -7
  27. package/bin/lean/seqlease.mjs +49 -2
  28. package/bin/lean/seqlease.test.mjs +14 -0
  29. package/bin/lean/sequence.mjs +54 -6
  30. package/bin/lean/sequence.test.mjs +83 -1
  31. package/bin/pool/agent.mjs +435 -0
  32. package/bin/pool/cli.mjs +110 -0
  33. package/bin/pool/docker.mjs +56 -0
  34. package/bin/pool/http.mjs +55 -0
  35. package/bin/pool/router.mjs +299 -0
  36. package/bin/pool/store.mjs +25 -0
  37. package/bin/pool/version.mjs +44 -0
  38. package/bin/runner.mjs +1 -0
  39. package/bin/stats/aggregate.mjs +6 -0
  40. package/bin/stats/index.mjs +14 -2
  41. package/bin/stats/local-source.mjs +1 -1
  42. package/bin/stats/pool.mjs +51 -0
  43. package/bin/stats/pool.test.mjs +39 -0
  44. package/bin/stats/query.mjs +2 -2
  45. package/bin/stats/stats.test.mjs +15 -1
  46. package/commands/orchestrate.md +3 -3
  47. package/docs/CONFIG.md +23 -9
  48. package/docs/OUTCOME-CODES.md +5 -3
  49. package/docs/STATS.md +7 -0
  50. package/package.json +2 -1
  51. package/pool/README.md +13 -0
  52. package/pool/image/Dockerfile +46 -0
  53. package/pool/image/claude-shim +8 -0
  54. package/pool/image/cs-cred +12 -0
  55. package/pool/image/entrypoint.sh +61 -0
  56. package/pool/image/gh-shim +6 -0
  57. package/pool/install/install-agent-linux.sh +53 -0
  58. package/pool/install/install-agent.ps1 +225 -0
  59. package/pool/install/install-agent.sh +101 -0
  60. package/pool/install/install-router.sh +67 -0
  61. package/pool/install/keepalive.ps1 +36 -0
  62. package/pool/install/uninstall-agent.ps1 +20 -0
  63. package/scripts/hotspots.sh +33 -21
  64. package/scripts/hotspots.test.sh +22 -3
@@ -120,6 +120,16 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
120
120
  assert.equal(starts[1].length, 2);
121
121
  });
122
122
 
123
+ it("#4518 cli.host_killed deferrals are requeued and never trip the breaker", async () => {
124
+ const { now, sleep } = fakeClock();
125
+ const updates = []; let calls = 0;
126
+ const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3, breaker: { errors: 1, window_min: 30 },
127
+ onUpdate: (r) => updates.push(r),
128
+ runOne: async () => (++calls <= 2 ? { terminalReason: "deferred", code: "cli.host_killed", retryInSeconds: 60, detail: "builder host kill" } : { terminalReason: "merged", pr: 5 }) });
129
+ assert.equal(rows[0].outcome, "merged");
130
+ assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
131
+ });
132
+
123
133
  it("the breaker stops admissions after N engine-errors in M minutes and lets running lanes finish", async () => {
124
134
  const started = [];
125
135
  let releaseSlow;
@@ -203,9 +213,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
203
213
  assert.doesNotThrow(() => assertDiskPath("/home/u/.forge/batches", mounts));
204
214
  assert.equal(isLaneAlive(process.pid, "--definitely-not-in-my-cmdline"), false);
205
215
  assert.equal(isLaneAlive(2 ** 22 + 12345, null), false);
206
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } });
216
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 }, baseSync: { enabled: true, unionGlobs: [] } });
207
217
  writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n");
208
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4, recovery: { maxAttempts: 1 } });
218
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4, recovery: { maxAttempts: 1 }, baseSync: { enabled: true, unionGlobs: [] } });
209
219
  });
210
220
 
211
221
  it("run-issue --result-file writes the lane result, also when the run throws", async () => {
@@ -659,3 +669,94 @@ describe("#4504: a resume settles lanes that ended while no dispatcher ran", ()
659
669
  assert.deepEqual(launched.sort(), [3, 4], "the dead lane without a result is requeued and run once");
660
670
  });
661
671
  });
672
+
673
+ describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () => {
674
+ it("sandbox.capacity is uncapped and honours retryInSeconds; other codes still cap at maxDeferrals", () => {
675
+ assert.deepEqual(deferPolicy({ code: "sandbox.capacity" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
676
+ assert.deepEqual(deferPolicy({ code: "sandbox.capacity", retryInSeconds: 30 }, { now: 0, deferrals: 99, maxDeferrals: 3 }), { delayMs: 30000, host: true });
677
+ for (const code of ["merge.transient", "gh.unavailable", "cli.idle", "rate_limited", "sandbox.start_failed"]) {
678
+ assert.deepEqual(deferPolicy({ code, retryInSeconds: 60 }, { now: 0, deferrals: 3, maxDeferrals: 3 }), { capped: true }, code);
679
+ }
680
+ });
681
+
682
+ it("with K above the pool size every issue eventually runs and none is skipped for capacity", async () => {
683
+ const c = { t: 1_000_000 };
684
+ const now = () => c.t;
685
+ // Fake clock that advances after a macrotask, so running lanes progress while deferred ones wait.
686
+ const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
687
+ const POOL = 2;
688
+ let inUse = 0;
689
+ const capacityDefers = new Map();
690
+ const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6], parallel: 4, maxDeferrals: 3, now, sleep, readBody: async () => "",
691
+ runOne: async (n) => {
692
+ if (inUse >= POOL) {
693
+ capacityDefers.set(n, (capacityDefers.get(n) ?? 0) + 1);
694
+ return { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 60, detail: "sandbox launch: HTTP 503" };
695
+ }
696
+ inUse++;
697
+ try { await new Promise((r) => setTimeout(r, 20)); } finally { inUse--; }
698
+ return { terminalReason: "merged", pr: 100 + n };
699
+ } });
700
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]).sort((a, b) => a[0] - b[0]), [1, 2, 3, 4, 5, 6].map((n) => [n, "merged"]));
701
+ assert.ok(!rows.some((r) => /gave up/.test(r.detail ?? "")));
702
+ assert.ok(Math.max(...capacityDefers.values()) > 3, "an issue was deferred for capacity more than maxDeferrals times");
703
+ });
704
+ });
705
+
706
+ describe("#4540: remote admissions are capped to the provider's free capacity", () => {
707
+ const run = async (capacity) => {
708
+ const c = { t: 1_000_000 };
709
+ const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
710
+ let live = 0, peak = 0, bounced = 0;
711
+ const POOL = 2;
712
+ const rows = await orchestrate({ issues: [1, 2, 3, 4, 5], parallel: 5, now: () => c.t, sleep, pollMs: 1, readBody: async () => "",
713
+ remote: { max: 5, isRemote: () => true, capacity: capacity(() => POOL - live) },
714
+ runOne: async (n) => {
715
+ if (live >= POOL) { bounced++; return { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 1, detail: "sandbox launch: HTTP 503" }; }
716
+ live++; peak = Math.max(peak, live);
717
+ try { await new Promise((r) => setTimeout(r, 10)); } finally { live--; }
718
+ return { terminalReason: "merged", pr: 100 + n };
719
+ } });
720
+ return { rows, peak, bounced };
721
+ };
722
+ it("queues beyond the free slots inside the batch: no 503 bounce, every issue still runs", async () => {
723
+ let at = 0;
724
+ const { rows, bounced } = await run((free) => () => ({ free: free(), at: ++at })); // a fresh sample on every read
725
+ assert.equal(bounced, 0);
726
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged", "merged", "merged"]);
727
+ });
728
+ it("unknown capacity (null) admits as before and the 503 deferral path is the backstop", async () => {
729
+ const { rows, bounced } = await run(() => () => null);
730
+ assert.ok(bounced > 0);
731
+ assert.deepEqual(rows.map((r) => r.outcome).filter((o) => o !== "merged"), []);
732
+ });
733
+ });
734
+
735
+ describe("#4547: opt-in local fallback in mode remote when no pool agent is online", () => {
736
+ const run = async (remoteExtra, online) => {
737
+ const c = { t: 1_000_000 };
738
+ const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
739
+ const where = new Map();
740
+ let remoteTries = 0;
741
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 3, now: () => c.t, sleep, pollMs: 1, readBody: async () => "",
742
+ remote: { max: 3, isRemote: () => true, capacity: () => (online === null ? null : { free: null, online, at: 1 }), ...remoteExtra },
743
+ runOne: async (n, _ln, ctx = {}) => {
744
+ where.set(n, ctx.placement?.where ?? "remote");
745
+ if (ctx.placement?.where !== "local") { remoteTries++; if (online === 0) return { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 1, detail: "HTTP 503" }; }
746
+ return { terminalReason: "merged", pr: 100 + n };
747
+ } });
748
+ return { rows, where, remoteTries };
749
+ };
750
+ it("every agent offline: the batch completes on the host", async () => {
751
+ const { rows, where, remoteTries } = await run({ offlineFallback: true }, 0);
752
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged"]);
753
+ assert.ok([...where.values()].every((w) => w === "local"));
754
+ assert.equal(remoteTries, 0);
755
+ });
756
+ it("an agent online, or liveness unknown, stays remote", async () => {
757
+ for (const online of [1, null]) {
758
+ const { where } = await run({ offlineFallback: true }, online);
759
+ assert.ok([...where.values()].every((w) => w === "remote"), String(online));
760
+ }
761
+ });
762
+ });
@@ -5,7 +5,8 @@
5
5
  * else the frozen contract's distinct deliverable paths (S <= 2, M <= 5, else L). The budget for the size is forge.yaml
6
6
  * `engine.turn_budget_s|m|l` (defaults 25/60/100; 0 disables that size). The implement stage (the build call and its
7
7
  * context/timeout restarts) spends it; on overrun the builder gets one wrap-up grant to finish or stop with a digest, and
8
- * an unfinished lane ends needs-human `budget.exceeded` (recoverable once by the batch). Pure module: no I/O.
8
+ * an unfinished lane ends the non-human `budget-exceeded` reason (code `budget.exceeded`, #4505): the batch recovers it once
9
+ * from the checkpoint at the next size (`recoverySize`); only a final recovery that overruns too ends needs-human. Pure module: no I/O.
9
10
  */
10
11
 
11
12
  export const TURN_BUDGET_DEFAULTS = Object.freeze({ S: 25, M: 60, L: 100 });
@@ -36,6 +37,11 @@ export function laneSize({ contract = null, labels = [] } = {}) {
36
37
  return n <= 2 ? "S" : n <= 5 ? "M" : "L";
37
38
  }
38
39
 
40
+ /** #4505: the next size up (S -> M -> L; L stays L); anything else unchanged. */
41
+ export const nextSize = (size) => (SIZES.includes(size) ? SIZES[Math.min(SIZES.indexOf(size) + 1, SIZES.length - 1)] : size);
42
+ /** #4505: a `budget.exceeded` recovery lane runs at the next size's budget; any other lane keeps its size. */
43
+ export const recoverySize = (size, recover) => (recover?.code === "budget.exceeded" ? nextSize(size) : size);
44
+
39
45
  /** Turns the wrap-up call may use after the budget is spent. */
40
46
  export const wrapupGrant = (budget) => Math.max(5, Math.ceil(budget / 5));
41
47
 
@@ -5,7 +5,7 @@ import { describe, it } from "node:test";
5
5
  import assert from "node:assert/strict";
6
6
  import { writeFileSync, chmodSync } from "node:fs";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
- import { TURN_BUDGET_DEFAULTS, parseTurnBudgets, laneSize, wrapupGrant, makeTurnBudget, budgetDigest, formatTurnSummary } from "./budget.mjs";
8
+ import { TURN_BUDGET_DEFAULTS, parseTurnBudgets, laneSize, nextSize, recoverySize, wrapupGrant, makeTurnBudget, budgetDigest, formatTurnSummary } from "./budget.mjs";
9
9
  import { orchestrate } from "./orchestrate.mjs";
10
10
  import { OUTCOME_CODES, RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
11
11
  import { RECOVER_HOW } from "./lean.mjs";
@@ -34,6 +34,14 @@ describe("#4392 turn budget", () => {
34
34
  assert.equal(laneSize({}), null);
35
35
  });
36
36
 
37
+ it("#4505 a budget.exceeded recovery runs at the next size (S->M->L, L stays L); other lanes keep their size", () => {
38
+ assert.deepEqual(["S", "M", "L"].map(nextSize), ["M", "L", "L"]);
39
+ assert.equal(nextSize(null), null); assert.equal(nextSize("XL"), "XL");
40
+ assert.equal(recoverySize("S", { code: "budget.exceeded", attempt: 1, final: true }), "M");
41
+ assert.equal(recoverySize("L", { code: "budget.exceeded" }), "L");
42
+ assert.equal(recoverySize("S", { code: "ci.red" }), "S"); assert.equal(recoverySize("S", null), "S"); assert.equal(recoverySize(null, { code: "budget.exceeded" }), null);
43
+ });
44
+
37
45
  it("the tally: build turns spend the budget, other attempts count only in total; off or unsized is unlimited", () => {
38
46
  const tb = makeTurnBudget({ M: 60 });
39
47
  assert.equal(tb.record(), null); assert.equal(tb.remaining(), Infinity);
@@ -261,18 +261,28 @@ export function validateContract(c, issueText, { repro = false } = {}) {
261
261
  return { ok: problems.length === 0, problems };
262
262
  }
263
263
 
264
+ /** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
265
+ const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
266
+
264
267
  /**
265
268
  * Apply result.json amendments to the frozen contract. Kinds: add-deliverable {path}, add-requirement {requirement},
266
269
  * defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
270
+ * `generatedPaths` (forge#4519, forge.yaml maintenance.generated_paths via scripts/hotspots.sh): an add-deliverable on a
271
+ * matching path is refused; the repo's generator or bot owns that file.
267
272
  */
268
- export function applyAmendments(frozen, amendments, issueText = null) {
273
+ export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [] } = {}) {
269
274
  const c = structuredClone(frozen);
270
275
  c.deliverables = c.deliverables ?? [];
271
276
  const applied = [], toTrack = [], problems = [];
277
+ const genRes = (Array.isArray(generatedPaths) ? generatedPaths : []).filter((g) => typeof g === "string" && g).map(shellGlobRe);
272
278
  for (const a of Array.isArray(amendments) ? amendments : []) {
273
279
  const tag = JSON.stringify(a ?? null).slice(0, 100);
274
280
  if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
275
281
  if (a.kind === "add-deliverable" && !blank(a.path)) {
282
+ if (genRes.some((re) => re.test(cleanPath(a.path)))) {
283
+ problems.push(`add-deliverable ${a.path}: generated path (forge.yaml maintenance.generated_paths) is owned by the repo's generator/bot; revert the file instead of amending it in`);
284
+ continue;
285
+ }
276
286
  const have = c.deliverables.find((d) => d.path === a.path);
277
287
  // An existing deliverable keeps its id and gains the why, so an amendment naming a package on an already-listed
278
288
  // package.json declares the edge (forge#4159); before, it was reported as applied but changed nothing.
@@ -343,9 +353,11 @@ export function repoTracks(paths) {
343
353
  * `frozen` (#4350): the contract before typed amendments. A failed scope-check validate (isScopeCheck) whose only files
344
354
  * outside `frozen` are covered by deliverables an amendment added, with no scope creep left, is superseded by the engine's
345
355
  * scope gate: listed in `superseded` [{id, files}], not a failure. Without `frozen`, `superseded` is always [].
356
+ * `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
357
+ * tool), for the trajectory; always an array.
346
358
  */
347
359
  export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null }) {
348
- const failures = [], superseded = [];
360
+ const failures = [], superseded = [], ciCovered = [];
349
361
  const outside = (k) => changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(k, f));
350
362
  const creep = outside(contract);
351
363
  const amendedOnly = frozenAmendedFiles(frozen, contract, outside, creep);
@@ -355,13 +367,13 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
355
367
  const r = runs.find((x) => x?.id === v.id);
356
368
  // #4167: a head the engine verified on CI covers validate ids the builder left to CI (no result, or ci:true without an exit)
357
369
  const leftToCi = !r || (r.ci === true && r.exit === undefined);
358
- if (ciVerified && leftToCi) continue;
370
+ if (ciVerified && leftToCi) { ciCovered.push(v.id); continue; }
359
371
  if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
360
372
  else if (r.exit !== 0) {
361
373
  const rerun = validateRuns?.[v.id];
362
374
  if (rerun?.rc === 0) continue;
363
375
  const miss = rerun ? missingTool(rerun, { repoHas, command: v.command }) : null;
364
- if (miss && ciVerified) continue;
376
+ if (miss && ciVerified) { ciCovered.push(v.id); continue; }
365
377
  if (miss) failures.push({ code: C.ENV_TOOL_MISSING, kind: "validate_env", tool: miss.tool, detail: `validate ${v.id} (\`${v.command}\`) could not run: the host is missing ${KIND_NAME[miss.kind]} '${miss.tool}' (environment, not the change)` });
366
378
  else if (amendedOnly && isScopeCheck(v.command)) superseded.push({ id: v.id, files: amendedOnly });
367
379
  else failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
@@ -382,7 +394,7 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
382
394
  }
383
395
  failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
384
396
  if (repro) failures.push(...reproFailures(contract, repro));
385
- return { ok: failures.length === 0, failures, superseded };
397
+ return { ok: failures.length === 0, failures, superseded, ciCovered };
386
398
  }
387
399
 
388
400
  /**
@@ -419,7 +431,8 @@ export function reproFailures(contract, { base, head } = {}) {
419
431
 
420
432
  const EXEMPT_RE = /hotspot-exempt:\s*\S/i;
421
433
  /**
422
- * forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded) that grew by more
434
+ * forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded; forge#4519: a
435
+ * `generated` row, from linguist-generated, maintenance.generated_paths or a generated header, never counts) that grew by more
423
436
  * than `growth_lines` since the base, with no `hotspot-exempt: <reason>` in a covering deliverable's change or an
424
437
  * add-deliverable amendment's why. `hotspots` is scripts/hotspots.sh --base output; null or disabled checks nothing.
425
438
  */
@@ -431,7 +444,7 @@ export function hotspotGrowth({ contract, result, hotspots, changedFiles = [] })
431
444
  const exempt = (f) => (contract?.deliverables ?? []).some((d) => pathCovers(d.path, f) && EXEMPT_RE.test(String(d.change ?? "")))
432
445
  || amends.some((a) => a?.kind === "add-deliverable" && pathCovers(a.path, f) && EXEMPT_RE.test(String(a.why ?? "")));
433
446
  return hotspots.files
434
- .filter((h) => h?.over && !h.excluded && Number(h.growth) > limit && changed.has(cleanPath(h.path)) && !exempt(h.path))
447
+ .filter((h) => h?.over && !h.excluded && !h.generated && Number(h.growth) > limit && changed.has(cleanPath(h.path)) && !exempt(h.path))
435
448
  .map((h) => ({ code: C.MAINTENANCE_HOTSPOT_GROWTH, kind: "hotspot_growth",
436
449
  detail: `${h.path} grew by ${h.growth} lines (now ${h.lines}, limit +${limit} over ${max}) without hotspot-exempt` }));
437
450
  }
@@ -62,7 +62,7 @@ describe("amendments", () => {
62
62
 
63
63
  describe("precheck (deterministic, no reviewer)", () => {
64
64
  const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
65
- it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [], superseded: [] }));
65
+ it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [], superseded: [], ciCovered: [] }));
66
66
  it("unauthorized file -> scope_creep; tests and ignored paths are always allowed", () => {
67
67
  assert.deepEqual(codes(precheck({ contract: C, result: res, changedFiles: [...files, "src/billing.js", ".forge-lean/x", "tests/test_x.py", "pkg/foo_test.go"], ignore: [".forge-lean/"] })), ["conformance.scope_creep"]);
68
68
  for (const t of ["test/a.js", "src/a.test.ts", "tests/test_a.py", "a_test.go", "spec/a.spec.js"]) assert.ok(isTestPath(t), t);
@@ -85,6 +85,15 @@ describe("precheck (deterministic, no reviewer)", () => {
85
85
  const local = precheck({ contract: C, result: failed, changedFiles: files, repoHas: repoTracks(["src/leak/__init__.py"]), validateRuns: { V1: { rc: 1, output: "ModuleNotFoundError: No module named 'leak.core'" } } });
86
86
  assert.deepEqual(codes(local), ["conformance.unimplemented"], "a repo module the change did not create is a code defect");
87
87
  });
88
+ it("#4527 a CI-verified head lists the validate ids it accepted without a local result; a ran-and-failed validate still fails", () => {
89
+ const C2 = { ...C, validate: [{ id: "V1", command: "node --test a.test.mjs" }, { id: "V2", command: "tsc" }, { id: "V3", command: "lint" }] };
90
+ const pc = precheck({ contract: C2, result: { ...res, validation: [{ id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files, ciVerified: true });
91
+ assert.deepEqual(codes(pc), []); assert.deepEqual(pc.ciCovered, ["V1", "V2"]);
92
+ const local = precheck({ contract: C2, result: { ...res, validation: [{ id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files });
93
+ assert.deepEqual(local.failures.map((f) => f.kind), ["validate_missing", "validate_missing"]); assert.deepEqual(local.ciCovered, []);
94
+ const failed = precheck({ contract: C2, result: { ...res, validation: [{ id: "V1", exit: 1 }, { id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files, ciVerified: true });
95
+ assert.deepEqual(failed.failures.map((f) => f.kind), ["validate_failed"]); assert.deepEqual(failed.ciCovered, ["V2"]);
96
+ });
88
97
  it("#4323 missingTool: commands, python and node modules; paths, relative and repo-tracked names are not the environment", () => {
89
98
  assert.deepEqual(missingTool({ rc: 127, output: "sh: 1: jq: not found" }), { tool: "jq", kind: "command" });
90
99
  assert.deepEqual(missingTool({ rc: 127, output: "" }, { command: "uv run pytest -q" }), { tool: "uv", kind: "command" });
@@ -362,6 +371,24 @@ describe("hotspot growth precheck (forge#4195)", () => {
362
371
  assert.match(pc.failures[0].detail, /^src\/scrape\.js grew by 200 lines \(now 2600, limit \+150 over 2000\) without hotspot-exempt$/);
363
372
  });
364
373
 
374
+ it("#4519 generated paths: applyAmendments refuses add-deliverable on maintenance.generated_paths, hotspotGrowth skips generated rows", () => {
375
+ const gen = ["web/public/openapi*.json", "web/public/openapi-versions/*"];
376
+ const amends = [{ kind: "add-deliverable", path: "web/public/openapi-versions/3.29.2.json", why: "sync the spec archive" },
377
+ { kind: "add-deliverable", path: "./web/public/openapi.json", why: "sync the spec" }, { kind: "add-deliverable", path: "src/shared.js", why: "helper" }];
378
+ const a = applyAmendments(C, amends, null, { generatedPaths: gen });
379
+ assert.deepEqual(a.applied.map((x) => x.target), ["src/shared.js"]);
380
+ assert.equal(a.problems.length, 2);
381
+ assert.match(a.problems[0], /^add-deliverable web\/public\/openapi-versions\/3\.29\.2\.json: generated path \(forge\.yaml maintenance\.generated_paths\)/);
382
+ assert.ok(!a.contract.deliverables.some((d) => d.path.includes("openapi")), "a refused path never becomes a deliverable");
383
+ assert.equal(applyAmendments(C, amends).applied.length, 3, "without generatedPaths every add-deliverable applies as before");
384
+ assert.equal(applyAmendments(C, amends, null, { generatedPaths: ["web/public/openapi.json"] }).applied.length, 2, "a literal glob matches only that path");
385
+ // linguist-generated / generated_paths / header rows (hotspots.sh `generated`) never trigger hotspot_growth
386
+ const big = H({ path: "src/scrape.js", lines: 83974, growth: 83974 });
387
+ assert.equal(run(hs([big])).ok, false);
388
+ assert.equal(run(hs([{ ...big, generated: true }])).ok, true);
389
+ assert.equal(run(hs([{ ...big, generated: true, excluded: true }])).ok, true);
390
+ });
391
+
365
392
  it("within the limit, under the threshold, excluded, unchanged, disabled or no facts: no failure", () => {
366
393
  for (const h of [hs([H({ growth: 150 })]), hs([H({ over: false, lines: 1900 })]), hs([H({ excluded: true })]), hs([H({ path: "src/other.js" })]),
367
394
  hs([H()], { enabled: false }), hs([H({ growth: null })]), null, undefined, {}]) assert.equal(run(h).ok, true, JSON.stringify(h));
@@ -398,7 +425,7 @@ describe("repro_test on bug lanes (#4271)", () => {
398
425
  });
399
426
  it("red on base and green on head passes; anything else fails the precheck as conformance.unimplemented", () => {
400
427
  const k = { ...C, repro_test: RT };
401
- assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [], superseded: [] });
428
+ assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [], superseded: [], ciCovered: [] });
402
429
  for (const repro of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "SKIPPED", head: "RAN_NOTHING" }]) {
403
430
  const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
404
431
  assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_red_green"]], JSON.stringify(repro));
@@ -11,7 +11,9 @@
11
11
  * <repoKey>/<eco>-<key>.tmp-<pid> snapshot being published (atomic rename into place)
12
12
  * stores/{pnpm-store,npm-cache,uv-cache,poetry-cache,pip-cache} the package managers' own shared stores
13
13
  * The repo's own package manager does every install (frozen lockfile). Every effect is injected; nothing here throws:
14
- * a missing tool, another filesystem (EXDEV) or a failed install leaves the lane as it was before.
14
+ * a missing tool, another filesystem (EXDEV) or a failed install leaves the lane as it was before. A snapshot that fails
15
+ * to link or repair falls back to a fresh install in the lane (status materialize-fallback, #4522); failed only when that
16
+ * install fails too.
15
17
  */
16
18
  import * as nodeFs from "node:fs";
17
19
  import { createHash } from "node:crypto";
@@ -28,7 +30,17 @@ const TMP_RE = /^(pnpm|npm|yarn|uv|poetry)-[0-9a-f]{16}\.tmp-(\d+)$/;
28
30
  const LOCK_RE = /^(pnpm|npm|yarn|uv|poetry)-[0-9a-f]{16}\.lock$/;
29
31
 
30
32
  const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code === "EPERM"; } };
31
- const msg = (e) => String(e?.stderr || e?.message || e).trim().split("\n").slice(-3).join(" ").slice(0, 300);
33
+ /**
34
+ * Error text for a reason: pnpm prints its ERR_PNPM_* errors on stdout (#4522), so the first such line from either
35
+ * stream leads, then the last lines of stderr and stdout; the message only when both streams are empty.
36
+ */
37
+ const msg = (e) => {
38
+ const lines = (s) => String(s ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
39
+ const err = lines(e?.stderr), out = lines(e?.stdout);
40
+ const pm = [...err, ...out].find((l) => /\bERR_PNPM_[A-Z0-9_]+/.test(l));
41
+ const parts = [pm, ...err.slice(-3), ...out.slice(-3)].filter((l, i, a) => l && a.indexOf(l) === i);
42
+ return (parts.length ? parts.join(" ") : String(e?.message || e).trim().split("\n").slice(-3).join(" ")).slice(0, 500);
43
+ };
32
44
 
33
45
  /** Lockfile -> ecosystem, in priority order: at most one JS and one Python ecosystem per repo. */
34
46
  function detect(fs, wt) {
@@ -215,7 +227,8 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
215
227
  return done("hit", { key });
216
228
  } catch (e) {
217
229
  rmTrees(wt, created);
218
- return done(e?.code === "EXDEV" ? "skipped" : "failed", { key, reason: e?.code === "EXDEV" ? "cache is on another filesystem than the worktree (EXDEV)" : `materialize failed: ${msg(e)}` });
230
+ if (e?.code === "EXDEV") return done("skipped", { key, reason: "cache is on another filesystem than the worktree (EXDEV)" });
231
+ return { fallback: `materialize failed: ${msg(e)}` }; // installed by the caller, outside the key lock
219
232
  }
220
233
  };
221
234
  const install = async () => {
@@ -235,6 +248,13 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
235
248
  const why = publish(wt, d, key, ins.trees, snap);
236
249
  return done("miss-installed", { key, published: !why, reason: why });
237
250
  });
251
+ if (r.locked && r.value?.fallback) {
252
+ // #4522: a broken snapshot must not leave the lane without dependencies. Install outside the lock so other lanes on
253
+ // this key are not serialised behind it; never publish (the snapshot exists and is never replaced).
254
+ const ins = await install();
255
+ if (ins.failed) return { ...ins.failed, reason: `${r.value.fallback}; ${ins.failed.reason}` };
256
+ return done("materialize-fallback", { key, published: false, reason: r.value.fallback });
257
+ }
238
258
  if (r.locked) return r.value ?? done("failed", { key, reason: "snapshot manifest unreadable" });
239
259
  // The lock stayed busy past the wait: install in this lane without touching the cache.
240
260
  const ins = await install();
@@ -38,7 +38,7 @@ const FIXTURES = {
38
38
  const LOCKFILE = { pnpm: "pnpm-lock.yaml", npm: "package-lock.json", uv: "uv.lock", poetry: "poetry.lock" };
39
39
 
40
40
  /** Fake package managers: write what a real install leaves behind, including the absolute paths that need rewriting. */
41
- function fakeExec({ failInstall = false, installDelayMs = 0 } = {}) {
41
+ function fakeExec({ failInstall = false, failRepair = false, installDelayMs = 0 } = {}) {
42
42
  const calls = [];
43
43
  const venv = (cwd, lock) => {
44
44
  const v = join(cwd, ".venv");
@@ -65,6 +65,10 @@ function fakeExec({ failInstall = false, installDelayMs = 0 } = {}) {
65
65
  if (full && installDelayMs) await new Promise((r) => setTimeout(r, installDelayMs));
66
66
  if (full && failInstall) { put(join(cwd, cmd === "uv" || cmd === "poetry" ? ".venv/partial" : "node_modules/partial"), "x"); throw Object.assign(new Error("install failed"), { stderr: "ERR_PNPM_FROZEN_LOCKFILE" }); }
67
67
  if (cmd === "pnpm") {
68
+ if (args.includes("--offline") && failRepair) {
69
+ // pnpm prints its errors on stdout; execFile's error then carries an empty stderr (#4522).
70
+ throw Object.assign(new Error("Command failed: pnpm install --frozen-lockfile --offline"), { stderr: "", stdout: "Lockfile is up to date\n ERR_PNPM_TEST_REPAIR some cause\n" });
71
+ }
68
72
  if (args.includes("--offline")) return ""; // warm repair pass: nothing to do
69
73
  const lock = readFileSync(join(cwd, "pnpm-lock.yaml"), "utf8");
70
74
  put(join(cwd, "node_modules/.pnpm/lodash@4/node_modules/lodash/index.js"), `module.exports = ${JSON.stringify(lock)};\n`);
@@ -222,6 +226,43 @@ describe("depcache (#4220): shared dependency installs keyed by lock hash", () =
222
226
  assert.equal(r.published, true, "the stale lock was replaced");
223
227
  });
224
228
 
229
+ it("#4522 a snapshot whose repair fails falls back to a fresh install outside the lock, keeps the snapshot and records pnpm's stdout error", async () => {
230
+ const s = setup("pnpm");
231
+ const [ra] = await s.cache.prepare({ wt: s.lane("a"), repoKey: "r" });
232
+ assert.equal(ra.status, "miss-installed", ra.reason);
233
+ const [snap] = snapDirs(s.root, "pnpm");
234
+ const before = snapshotOf(snap);
235
+ const broken = makeDepCache({ root: s.root, exec: fakeExec({ failRepair: true }).exec, env: {}, pollMs: 5 });
236
+ const wt = s.lane("b");
237
+ const [rb] = await broken.prepare({ wt, repoKey: "r" });
238
+ assert.equal(rb.status, "materialize-fallback", rb.reason);
239
+ assert.equal(rb.published, false);
240
+ assert.match(rb.reason, /^materialize failed: ERR_PNPM_TEST_REPAIR {2}some cause/);
241
+ assert.ok(existsSync(join(wt, "node_modules/lodash/index.js")), "the lane has node_modules");
242
+ const after = snapshotOf(snap);
243
+ delete before["last-used"]; delete after["last-used"];
244
+ assert.deepEqual(after, before, "the snapshot is untouched");
245
+ assert.deepEqual(readdirSync(join(s.root, "r")).filter((n) => !/^pnpm-[0-9a-f]{16}$/.test(n)), [], "no lock or tmp left");
246
+
247
+ const both = makeDepCache({ root: s.root, exec: fakeExec({ failRepair: true, failInstall: true }).exec, env: {}, pollMs: 5 });
248
+ const wt2 = s.lane("c");
249
+ const [rc] = await both.prepare({ wt: wt2, repoKey: "r" });
250
+ assert.equal(rc.status, "failed");
251
+ assert.match(rc.reason, /^materialize failed: ERR_PNPM_TEST_REPAIR.*; install failed: ERR_PNPM_FROZEN_LOCKFILE/);
252
+ assert.ok(!existsSync(join(wt2, "node_modules")), "no partial tree");
253
+ });
254
+
255
+ it("#4522 a failed pnpm install records the ERR_PNPM line it printed on stdout", async () => {
256
+ const s = setup("pnpm");
257
+ const exec = async (cmd, args, o) => {
258
+ if (cmd === "pnpm" && !args.includes("--offline")) throw Object.assign(new Error("Command failed: pnpm install --frozen-lockfile"), { stderr: "", stdout: "Progress: resolved 1\n ERR_PNPM_OUTDATED_LOCKFILE Cannot install with frozen-lockfile\n\nNote that in CI environments this setting is true by default.\nA\nB\nC\n" });
259
+ return s.f.exec(cmd, args, o);
260
+ };
261
+ const [r] = await makeDepCache({ root: s.root, exec, env: {} }).prepare({ wt: s.lane("x"), repoKey: "r" });
262
+ assert.equal(r.status, "failed");
263
+ assert.match(r.reason, /^install failed: ERR_PNPM_OUTDATED_LOCKFILE {2}Cannot install with frozen-lockfile A B C$/);
264
+ });
265
+
225
266
  it("a failed install, a missing tool or another filesystem leaves no snapshot and no partial tree", async () => {
226
267
  const s = setup("pnpm", { failInstall: true });
227
268
  const wt = s.lane("x");
@@ -370,7 +370,7 @@ export function makeLeanIo({ repo }) {
370
370
  sequenceSnapshot: ({ cwd, base }) => snapshotLane({ git: (a) => run("git")(a, { cwd }), base }),
371
371
  sequenceVerify: ({ cwd, base, before, moves }) => verifyRenumber({ git: (a) => run("git")(a, { cwd }), base, before, moves }),
372
372
  /** #4386: the deterministic renumber, sequence-only base-sync resolution, and the host sequence lease at the merge gate. */
373
- sequenceRenumber: ({ cwd, base, laneRef, regenerate }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate }),
373
+ sequenceRenumber: ({ cwd, base, laneRef, regenerate, reserve }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate, reserve }),
374
374
  sequenceResolve: ({ cwd, files }) => resolveSequenceConflicts({ git: (a) => run("git")(a, { cwd }), files }),
375
375
  sequenceLease: makeSequenceLeaseIo(),
376
376
  /** #4406: the lane's scoped checks after a base merge (bin/lean/checks.mjs; [] on failure), and same-app duplicate exports (bin/lean/postmerge.mjs). */