forgedock 1.32.0 → 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 (69) hide show
  1. package/bin/engine/outcome-codes.mjs +7 -3
  2. package/bin/engine/phases.mjs +29 -8
  3. package/bin/engine-cli.mjs +84 -24
  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.mjs +9 -3
  8. package/bin/lean/batch.test.mjs +134 -3
  9. package/bin/lean/budget.mjs +7 -1
  10. package/bin/lean/budget.test.mjs +9 -1
  11. package/bin/lean/conformance.mjs +20 -7
  12. package/bin/lean/conformance.test.mjs +29 -2
  13. package/bin/lean/depcache.mjs +23 -3
  14. package/bin/lean/depcache.test.mjs +42 -1
  15. package/bin/lean/effects.mjs +29 -3
  16. package/bin/lean/execution.mjs +106 -14
  17. package/bin/lean/execution.test.mjs +101 -1
  18. package/bin/lean/hostkill.mjs +8 -0
  19. package/bin/lean/lean.mjs +65 -24
  20. package/bin/lean/lean.test.mjs +221 -10
  21. package/bin/lean/lightlane.mjs +38 -13
  22. package/bin/lean/lightlane.test.mjs +35 -1
  23. package/bin/lean/orchestrate.mjs +93 -26
  24. package/bin/lean/prompts.mjs +17 -7
  25. package/bin/lean/recovery.test.mjs +26 -0
  26. package/bin/lean/retry.mjs +15 -1
  27. package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
  28. package/bin/lean/sandbox-remote.mjs +40 -7
  29. package/bin/lean/schedd.mjs +50 -14
  30. package/bin/lean/schedd.test.mjs +133 -21
  31. package/bin/lean/seqlease.mjs +49 -2
  32. package/bin/lean/seqlease.test.mjs +14 -0
  33. package/bin/lean/sequence.mjs +54 -6
  34. package/bin/lean/sequence.test.mjs +83 -1
  35. package/bin/pool/agent.mjs +435 -0
  36. package/bin/pool/cli.mjs +110 -0
  37. package/bin/pool/docker.mjs +56 -0
  38. package/bin/pool/http.mjs +55 -0
  39. package/bin/pool/router.mjs +299 -0
  40. package/bin/pool/store.mjs +25 -0
  41. package/bin/pool/version.mjs +44 -0
  42. package/bin/runner.mjs +1 -0
  43. package/bin/stats/aggregate.mjs +6 -0
  44. package/bin/stats/index.mjs +14 -2
  45. package/bin/stats/local-source.mjs +1 -1
  46. package/bin/stats/pool.mjs +51 -0
  47. package/bin/stats/pool.test.mjs +39 -0
  48. package/bin/stats/query.mjs +2 -2
  49. package/bin/stats/stats.test.mjs +15 -1
  50. package/commands/orchestrate.md +3 -3
  51. package/docs/CONFIG.md +23 -9
  52. package/docs/OUTCOME-CODES.md +5 -3
  53. package/docs/STATS.md +7 -0
  54. package/package.json +2 -1
  55. package/pool/README.md +13 -0
  56. package/pool/image/Dockerfile +46 -0
  57. package/pool/image/claude-shim +8 -0
  58. package/pool/image/cs-cred +12 -0
  59. package/pool/image/entrypoint.sh +61 -0
  60. package/pool/image/gh-shim +6 -0
  61. package/pool/install/install-agent-linux.sh +53 -0
  62. package/pool/install/install-agent.ps1 +225 -0
  63. package/pool/install/install-agent.sh +101 -0
  64. package/pool/install/install-router.sh +67 -0
  65. package/pool/install/keepalive.ps1 +36 -0
  66. package/pool/install/uninstall-agent.ps1 +20 -0
  67. package/scripts/hotspots.sh +33 -21
  68. package/scripts/hotspots.test.sh +22 -3
  69. package/scripts/refresh-bot-token.test.sh +56 -0
@@ -19,8 +19,8 @@ import { mapRecord } from "../engine/telemetry-seam.mjs";
19
19
  import { readLog as readRepoLog } from "../engine/runlog.mjs";
20
20
  // #4432: the run log is keyed by repo + issue; every lane in this file runs in repo o/r.
21
21
  const readLog = (dir, issue, repo = "o/r") => readRepoLog(dir, issue, { repo });
22
- import { makeRunTmp, wrapLaneLimits, systemdRunAvailable, makeLeanRunner, requiredChecksFor, procsInDir, makeLeanIo } from "./effects.mjs";
23
- import { retrying, PUSH_TRANSIENT_RE } from "./retry.mjs";
22
+ import { makeRunTmp, wrapLaneLimits, systemdRunAvailable, makeLeanRunner, requiredChecksFor, procsInDir, makeLeanIo, makeGhCache } from "./effects.mjs";
23
+ import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE } from "./retry.mjs";
24
24
  import { checkAdmission, hostParallelism } from "./admission.mjs";
25
25
  import { OUTCOME_CODES, classifyNeedsHuman } from "../engine/outcome-codes.mjs";
26
26
  import * as refactorMod from "./refactor.mjs";
@@ -73,8 +73,10 @@ function world(o = {}) {
73
73
  w.writeStages = writeStages;
74
74
  w.io = {
75
75
  now: () => w.clock, sleep: async (ms) => { w.clock += ms; },
76
- gh: async (a) => {
76
+ gh: async (a, opts) => {
77
77
  const k = a.slice(0, 2).join(" ");
78
+ if (opts?.env) (w.ghEnvs ??= []).push(opts.env);
79
+ if (k === "pr view" && a.at(-1) === "state" && a.at(-2) === "--json") return JSON.stringify({ state: o.prState ?? "OPEN" });
78
80
  if (a[0] === "api" && a.includes("PATCH")) { w.patches.push(a); return "{}"; }
79
81
  if (a[0] === "api") return a.some((x) => /\/comments$/.test(x)) ? (w.issueComments ?? []).map((c) => JSON.stringify(c)).join("\n") : "";
80
82
  if (k === "pr list") return JSON.stringify(w.prList);
@@ -646,6 +648,13 @@ describe("core work-on", () => {
646
648
  };
647
649
  assert.equal(await code(world({ result: { status: "blocked", summary: "need creds", block: { kind: "human", why: "credentials" } } })), "builder.blocked");
648
650
  assert.equal(await code(world({ result: { status: "blocked", summary: "need creds" } })), "builder.block_untyped");
651
+ // #4542: a remote lane that names only a declared host capability is the recoverable env code; anything else stays human.
652
+ const rb = (summary, extra) => code(world({ result: { status: "blocked", summary, block: { kind: "human", why: "credentials" } } }), extra);
653
+ const remote = { placement: { where: "remote", reason: "mode remote" }, hostCaps: { hostCapabilities: ["sops"] } };
654
+ assert.equal(await rb("this lane has no sops/age key", remote), "env.capability_remote");
655
+ assert.equal(await rb("owner must create a third-party account", remote), "builder.blocked");
656
+ assert.equal(await rb("this lane has no sops/age key", { ...remote, hostCaps: undefined }), "builder.blocked");
657
+ assert.equal(await rb("this lane has no sops/age key", { ...remote, placement: { where: "local", reason: "x" } }), "builder.blocked");
649
658
  assert.equal(await code(world({ changed: [] })), "builder.no_commit");
650
659
  assert.equal(await code(world({ waits: [{ rc: 1, text: "lint failed" }] }), { mergeWait: "all" }), "ci.red");
651
660
  });
@@ -1614,6 +1623,20 @@ describe("hotspot prevention (forge#4195): plan facts, build rule, unexempted gr
1614
1623
  assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1500);
1615
1624
  });
1616
1625
 
1626
+ it("#4519 generated_paths: both prompts name the globs (even with the gate off), and an amendment adding one blocks", async () => {
1627
+ const G = ["web/public/openapi*.json"];
1628
+ for (const f of [facts([], { generated_paths: G }), facts([HOT], { enabled: false, generated_paths: G })]) {
1629
+ assert.match(hotspotPlanNote(f), /Generated paths \(forge\.yaml maintenance\.generated_paths, owned by the repo's generator\): web\/public\/openapi\*\.json\. Never list or amend them in/);
1630
+ assert.match(hotspotBuildRule(f), /Generated paths .*web\/public\/openapi\*\.json/);
1631
+ }
1632
+ assert.equal(hotspotPlanNote(facts([], { generated_paths: [] })), "", "no generated_paths: unchanged");
1633
+ const w = hworld(facts([], { generated_paths: G }), [facts([])], { amendments: [{ kind: "add-deliverable", path: "web/public/openapi.json", why: "sync the spec" }] });
1634
+ const r = await run(w);
1635
+ assert.equal(r.code, "conformance.unimplemented"); assert.equal(w.merges.length, 0);
1636
+ assert.match(builders(w)[0][4], /Generated paths .*openapi/);
1637
+ assert.match(nh(w), /add-deliverable web\/public\/openapi\.json: generated path/);
1638
+ });
1639
+
1617
1640
  it("the plan prompt carries the facts, the implement prompt the configured rule; growth within the limit merges; no extra runner calls", async () => {
1618
1641
  const w = hworld(facts([HOT, COLD], { file_lines: 2000, growth_lines: 150 }), [facts([SMALL])]);
1619
1642
  assert.equal((await run(w)).terminalReason, "merged");
@@ -2146,6 +2169,17 @@ describe("orchestrate", () => {
2146
2169
  assert.deepEqual(parseDeps("This is blocked by #2 and depends on #4\nsee **Depends on**: #5", 1), []);
2147
2170
  });
2148
2171
 
2172
+ it("#4506 parseDeps reads every #N to the end of a declaration line", () => {
2173
+ assert.deepEqual(parseDeps("Depends on #36648 (action results must reach …) and #36649 (the included action budget …)", 1), [36648, 36649]);
2174
+ assert.deepEqual(parseDeps("Depends on #36651 (executor test harness) and #36648.", 1), [36651, 36648]);
2175
+ assert.deepEqual(parseDeps("**Depends on** #2, #3 and #4", 1), [2, 3, 4]);
2176
+ assert.deepEqual(parseDeps("__Depends on__: #5 (x) and #6", 1), [5, 6]);
2177
+ assert.deepEqual(parseDeps("**Blocked by:** #7 (why) & #8", 1), [7, 8]);
2178
+ assert.deepEqual(parseDeps("- depends on: #9 (a), #10", 1), [9, 10]);
2179
+ assert.deepEqual(parseDeps("Depends on #11 and other/repo#12", 1), [11]);
2180
+ assert.deepEqual(parseDeps("This is blocked by #2 and depends on #4", 1), []);
2181
+ });
2182
+
2149
2183
  it("runs at most K at once", async () => {
2150
2184
  const h = harness({});
2151
2185
  const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6], parallel: 2, ...h });
@@ -3222,6 +3256,78 @@ describe("batch robustness (#4112-#4115)", () => {
3222
3256
  assert.ok(w.comments.some((c) => c.includes("FORGE:BASE_SYNC")));
3223
3257
  });
3224
3258
 
3259
+ // ---- #4507 ----
3260
+ const CORRUPT = "invalid character '{' looking for beginning of object key string";
3261
+ it("#4507 a gh JSON parse error at merge (corrupt gh cache) is retried and ends merged", async () => {
3262
+ const w = world({ mergeErrors: [CORRUPT] });
3263
+ const r = await run(w);
3264
+ assert.equal(r.terminalReason, "merged");
3265
+ assert.equal(w.mergeAttempts, 2);
3266
+ assert.equal(w.merges.length, 1);
3267
+ });
3268
+
3269
+ it("#4507 a merge error on a PR that is already MERGED records merged, not engine-error", async () => {
3270
+ const w = world({ mergeErrors: ["GraphQL: validation failed (422)"], prState: "MERGED" });
3271
+ const r = await run(w);
3272
+ assert.equal(r.terminalReason, "merged");
3273
+ assert.equal(w.mergeAttempts, 1);
3274
+ assert.equal(w.closed.length, 1);
3275
+ assert.ok(traj(w)[0].includes("is MERGED: recorded merged"));
3276
+ });
3277
+
3278
+ it("#4507 a persistent gh parse error at merge ends deferred merge.transient, never engine-error", async () => {
3279
+ const w = world({ mergeErrors: Array(40).fill(CORRUPT) });
3280
+ const r = await run(w, { maxMergeRetries: 2 });
3281
+ assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "merge.transient");
3282
+ assert.equal(w.mergeAttempts, 6, "each merge attempt is retried once with a fresh cache");
3283
+ });
3284
+
3285
+ it("#4507 with io.ghCache every engine gh call carries the run's own XDG_CACHE_HOME", async () => {
3286
+ const w = world();
3287
+ w.io.runTmp = { prune: async () => {}, make: async (_i, id) => `/rt/${id}`, remove: async () => true };
3288
+ let made = null, disposed = 0;
3289
+ w.io.ghCache = (root) => { made = root; return { env: () => ({ XDG_CACHE_HOME: `${root}/gh-cache` }), reset() {}, dispose() { disposed++; } }; };
3290
+ assert.equal((await run(w)).terminalReason, "merged");
3291
+ assert.match(made, /^\/rt\//);
3292
+ assert.ok(w.ghEnvs.length > 0 && w.ghEnvs.every((e) => e.XDG_CACHE_HOME === `${made}/gh-cache`));
3293
+ assert.equal(disposed, 1);
3294
+ });
3295
+
3296
+ it("#4507 ghCacheRetry retries exactly once on a parse error and rethrows other errors untouched", async () => {
3297
+ let n = 0, resets = 0;
3298
+ const cache = { env: () => ({ XDG_CACHE_HOME: `/c${resets}` }), reset: () => { resets++; } };
3299
+ const seen = [];
3300
+ assert.equal(await ghCacheRetry(async (env) => { seen.push(env.XDG_CACHE_HOME); if (n++ === 0) throw new Error(CORRUPT); return "ok"; }, cache), "ok");
3301
+ assert.deepEqual(seen, ["/c0", "/c1"]); assert.equal(resets, 1);
3302
+ n = 0;
3303
+ await assert.rejects(ghCacheRetry(async () => { n++; throw new Error(CORRUPT); }, null), /invalid character/);
3304
+ assert.equal(n, 2);
3305
+ n = 0;
3306
+ const other = new Error("HTTP 404: Not Found");
3307
+ await assert.rejects(ghCacheRetry(async () => { n++; throw other; }, cache), (e) => e === other);
3308
+ assert.equal(n, 1);
3309
+ });
3310
+
3311
+ it("#4507 makeGhCache: a per-root cache dir, reset swaps in a fresh dir, the no-root fallback is disposed", () => {
3312
+ const root = tmpDir("ghcache-");
3313
+ const c = makeGhCache({ root });
3314
+ const first = c.env().XDG_CACHE_HOME;
3315
+ assert.equal(first, join(root, "gh-cache")); assert.ok(existsSync(first));
3316
+ c.reset();
3317
+ const second = c.env().XDG_CACHE_HOME;
3318
+ assert.notEqual(second, first); assert.ok(existsSync(second)); assert.ok(!existsSync(first));
3319
+ const tmp = tmpDir("ghcache-tmp-");
3320
+ const f = makeGhCache({ tmp });
3321
+ assert.equal(f.env(), undefined);
3322
+ f.reset();
3323
+ const fb = f.env().XDG_CACHE_HOME;
3324
+ assert.ok(fb.startsWith(join(tmp, "forge-gh-cache-")) && existsSync(fb));
3325
+ f.dispose();
3326
+ assert.ok(!existsSync(fb));
3327
+ c.dispose();
3328
+ assert.ok(!existsSync(second)); assert.equal(c.env(), undefined, "no dir is re-created after the run tears down");
3329
+ });
3330
+
3225
3331
  // ---- #4114 ----
3226
3332
  it("#4114 a push failing once with 'remote unpack failed' then succeeding completes the run", async () => {
3227
3333
  const w = world({ pushErrors: ["error: remote unpack failed: unable to create temporary object directory"] });
@@ -3405,6 +3511,28 @@ describe("batch robustness (#4112-#4115)", () => {
3405
3511
  }
3406
3512
  });
3407
3513
 
3514
+ it("#4518 a builder paused for longer than maxMs still gets its full ceiling of active time", async () => {
3515
+ const { runCliBackendAsync } = await import("../runner.mjs");
3516
+ const { d, bin } = await fakeCli("paused", `${TOOL}\nsleep 1\n${RESULT}`);
3517
+ const control = {};
3518
+ const call = runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin, logger: { log() {} }, stream: true, idleMs: 0, maxMs: 1200, control });
3519
+ await new Promise((res) => setTimeout(res, 100)); control.pause();
3520
+ await new Promise((res) => setTimeout(res, 2200)); control.resume();
3521
+ assert.equal((await call).text, "all done");
3522
+ });
3523
+
3524
+ it("#4518 external SIGTERM (status 143) restarts the builder once, then ends deferred cli.host_killed, never engine-error", async () => {
3525
+ const kill = () => Object.assign(new Error("claude CLI exited with status 143. No output was captured"), { code: "CLI_BACKEND_FAILED", status: 143, signal: null });
3526
+ const w = world({ dirty: true }); const orig = w.io.runner; let n = 0;
3527
+ w.io.runner = async (c) => { if (c.role === "builder" && n++ === 1) throw kill(); return orig(c); };
3528
+ assert.equal((await run(w)).terminalReason, "merged");
3529
+ const w2 = world({ dirty: true });
3530
+ w2.io.runner = async (c) => { if (c.role === "builder") throw kill(); return { text: "" }; };
3531
+ const r = await run(w2);
3532
+ assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "cli.host_killed");
3533
+ assert.equal(w2.edits.some((e) => e.includes("needs-human")), false);
3534
+ });
3535
+
3408
3536
  // ---- #4405 ----
3409
3537
  const HEAVY_CHILD = (d) => `printf '#!/bin/sh\\nsleep 1.5\\n' > ${d}/vitest; chmod +x ${d}/vitest`;
3410
3538
  it("#4405 a silent session with a running heavy-step child is not idle-killed within the extended window", async () => {
@@ -3788,6 +3916,17 @@ describe("verify-by-CI (#4167): ci mode runs the heavy checks on CI and feeds fa
3788
3916
  assert.equal(r.terminalReason, "needs-human"); assert.equal(w.merges.length, 0);
3789
3917
  });
3790
3918
 
3919
+ it("#4527 base-sync re-review FAIL: the fix round's head is CI-verified before the retry review, which accepts validates left to CI and notes them", async () => {
3920
+ const CONFLICTING = { mergeable: "CONFLICTING", mergeStateStatus: "DIRTY" }, OKSTATE = { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" };
3921
+ const w = withCi(world({ validation: ciValidation, prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], reviews: [ok(), bad("src/foo.js:4 lost base change"), ok()] }));
3922
+ const r = await run(w, { verify: CI });
3923
+ assert.equal(r.terminalReason, "merged", r.reason);
3924
+ assert.equal(reviewers(w).length, 3);
3925
+ assert.equal(new Set(w.ci.runs.map((x) => x.headSha)).size, w.ci.runs.length, "every reviewed head was verified once");
3926
+ assert.equal(w.ci.runs[0].headSha, w.head, "the retry head is CI-verified");
3927
+ assert.match(traj(w)[0], /Validate V1 verified by CI at [0-9a-f]{0,12}/);
3928
+ });
3929
+
3791
3930
  it("docs-only lane: no push for CI, no dispatch; the decision is recorded", async () => {
3792
3931
  const w = withCi(world({ changed: ["README.md", "docs/guide.md"], validation: ciValidation }));
3793
3932
  const r = await run(w, { verify: CI, conformance: "shadow" });
@@ -5021,6 +5160,20 @@ describe("enumerating tests + sequence numbers at hand-off (#4322)", () => {
5021
5160
  assert.match(traj(w).at(-1), /renumbered deterministically \(no builder\): db\/0007_usage\.sql → db\/0008_usage\.sql/);
5022
5161
  });
5023
5162
 
5163
+ it("#4526: the renumber gets a reserve hook bound to (repo, lane, issue), and the run's reservations are dropped at finish", async () => {
5164
+ const w = dworld({ seq: [COLL, []] }), reserved = [], unreserved = [];
5165
+ w.io.sequenceLease.reserve = async (x) => { reserved.push(x); return { numbers: [9] }; };
5166
+ w.io.sequenceLease.unreserve = async (x) => { unreserved.push(x); };
5167
+ assert.equal((await run(w)).terminalReason, "merged");
5168
+ assert.equal(typeof w.renumbers[0].reserve, "function");
5169
+ assert.deepEqual(await w.renumbers[0].reserve({ dir: "db", floor: 7, count: 1 }), { numbers: [9] });
5170
+ assert.equal(reserved[0].dir, "db");
5171
+ assert.equal(reserved[0].floor, 7);
5172
+ assert.ok(reserved[0].repo && reserved[0].lane && reserved[0].issue, JSON.stringify(reserved[0]));
5173
+ assert.equal(unreserved.length, 1);
5174
+ assert.deepEqual(unreserved[0], { repo: reserved[0].repo, lane: reserved[0].lane, issue: reserved[0].issue });
5175
+ });
5176
+
5024
5177
  it("base-sync with conflicts only in sequence files: base side taken and renumbered by the engine, zero builder sessions, zero re-reviews", async () => {
5025
5178
  const w = dworld({ seq: [[], COLL, []], prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], conflictFiles: ["db/meta/_journal.json", "db/meta/0007_snapshot.json"] });
5026
5179
  assert.equal((await run(w)).terminalReason, "merged");
@@ -5315,10 +5468,10 @@ describe("#4392: implement turn budget", () => {
5315
5468
  const TB = { S: 25, M: 60, L: 100 }; // the CLI defaults (readCoreLimits); runCore alone is unbudgeted
5316
5469
  const stopWith = (digest) => (w) => { w.files[`${WT}/.forge-lean/result.json`] = JSON.stringify({ status: "budget-exceeded", summary: "too big", digest }); };
5317
5470
 
5318
- it("an overrun ends needs-human budget.exceeded with the builder's digest, checkpointed, recoverable once", async () => {
5471
+ it("an overrun ends budget-exceeded (#4505) with the builder's digest, checkpointed, recoverable once", async () => {
5319
5472
  const w = budgetWorld(stopWith("done: parser. remaining: 3 adapters. recommend split")); const dir = tmpDir("core-tb-");
5320
5473
  const r = await run(w, { dir, turnBudgets: TB });
5321
- assert.equal(r.terminalReason, "needs-human"); assert.equal(r.code, OUTCOME_CODES.BUDGET_EXCEEDED);
5474
+ assert.equal(r.terminalReason, "budget-exceeded"); assert.equal(r.code, OUTCOME_CODES.BUDGET_EXCEEDED);
5322
5475
  assert.match(r.detail, /size S, 25 turns/); assert.match(r.detail, /remaining: 3 adapters/);
5323
5476
  assert.equal(classifyNeedsHuman({ outcome: r.terminalReason, code: r.code }), "recoverable");
5324
5477
  assert.deepEqual(r.turns, { size: "S", budget: 25, build: 28, total: 31, exceeded: true });
@@ -5326,7 +5479,7 @@ describe("#4392: implement turn budget", () => {
5326
5479
  assert.equal(w.seen[2].resume, null, "the wrap-up is a fresh session");
5327
5480
  assert.match(w.seen[2].prompt, /The engine froze \.forge-lean\/contract\.json/); assert.match(w.seen[2].prompt, /at most 5 turns/);
5328
5481
  assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "commit" && c.some((x) => /checkpoint after turn budget/.test(x))), "work checkpointed");
5329
- assert.ok(w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN") && c.includes("remaining: 3 adapters")));
5482
+ assert.ok(!w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN")), "#4505: no human escalation");
5330
5483
  const log = readLog(dir, 7);
5331
5484
  assert.ok(log.some((e) => e.event === "TURN_BUDGET_EXCEEDED" && e.size === "S" && e.budget === 25 && e.turns === 25 && e.grant === 5));
5332
5485
  assert.deepEqual(log.find((e) => e.event === "RUN_TERMINAL").turns, r.turns);
@@ -5342,6 +5495,35 @@ describe("#4392: implement turn budget", () => {
5342
5495
  assert.equal(o2.code, OUTCOME_CODES.BUDGET_EXCEEDED); assert.match(o2.detail, /No builder digest; changes so far/);
5343
5496
  });
5344
5497
 
5498
+ const addNH = (w) => w.edits.filter((e) => e.join(" ") === "issue edit 7 -R o/r --add-label needs-human").length;
5499
+ it("#4505 a turn overrun ends budget-exceeded, not needs-human: no label, no NEEDS_HUMAN comment, recoverable", async () => {
5500
+ const w = budgetWorld(stopWith("done: parser. remaining: tests")); const dir = tmpDir("core-tb-");
5501
+ const r = await run(w, { dir, turnBudgets: TB });
5502
+ assert.equal(r.terminalReason, "budget-exceeded"); assert.equal(r.code, OUTCOME_CODES.BUDGET_EXCEEDED);
5503
+ assert.equal(r.branch, "feat/issue-7-lean", "the checkpoint branch rides on the result"); assert.match(r.detail, /remaining: tests/);
5504
+ assert.equal(addNH(w), 0, "no needs-human label"); assert.ok(!w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN")), "no Needs human comment");
5505
+ assert.equal(classifyNeedsHuman({ outcome: r.terminalReason, code: r.code }), "recoverable");
5506
+ const term = readLog(dir, 7).find((e) => e.event === "RUN_TERMINAL");
5507
+ assert.equal(term.reason, "budget-exceeded"); assert.equal(term.code, OUTCOME_CODES.BUDGET_EXCEEDED);
5508
+ assert.ok(w.comments.some((c) => c.includes("FORGE:TRAJECTORY") && /recoverable budget\.exceeded/.test(c)), "the trajectory records the recoverable end");
5509
+ });
5510
+
5511
+ it("#4505 a budget.exceeded recovery lane runs at the next size budget; non-final stays unlabelled, a final overrun is needs-human", async () => {
5512
+ const nonFinal = budgetWorld(stopWith("still more"));
5513
+ const r1 = await run(nonFinal, { dir: tmpDir("core-tb-"), turnBudgets: TB, recover: { code: OUTCOME_CODES.BUDGET_EXCEEDED, attempt: 1, final: false } });
5514
+ assert.deepEqual(nonFinal.seen.map((x) => x.turnMax), [0, 60, 12], "S lane retried at M: 60 turns, wrap-up grant 12");
5515
+ assert.equal(r1.turns.size, "M"); assert.equal(r1.terminalReason, "budget-exceeded"); assert.equal(r1.recovery, true);
5516
+ assert.equal(addNH(nonFinal), 0);
5517
+ assert.ok(builders(nonFinal).some((b) => /Recovery pass \(budget\.exceeded\): .*Continue from the checkpoint/.test(b[4])));
5518
+ const fin = budgetWorld(stopWith("still more"));
5519
+ const r2 = await run(fin, { dir: tmpDir("core-tb-"), turnBudgets: TB, recover: { code: OUTCOME_CODES.BUDGET_EXCEEDED, attempt: 1, final: true } });
5520
+ assert.equal(r2.terminalReason, "needs-human"); assert.equal(r2.code, OUTCOME_CODES.BUDGET_EXCEEDED);
5521
+ assert.equal(addNH(fin), 1, "the failed recovery is a human escalation"); assert.ok(fin.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN")));
5522
+ const other = budgetWorld(stopWith("still more")); // another code's recovery keeps the lane's size
5523
+ const r3 = await run(other, { dir: tmpDir("core-tb-"), turnBudgets: TB, recover: { code: "ci.red", attempt: 1, final: false } });
5524
+ assert.equal(r3.turns.size, "S"); assert.equal(r3.terminalReason, "budget-exceeded");
5525
+ });
5526
+
5345
5527
  it("size comes from a size: label over the contract; 0 disables the budget and nothing is capped", async () => {
5346
5528
  const w = world({ labels: [{ name: "size:L" }] }); const caps = []; const orig = w.io.runner;
5347
5529
  w.io.runner = async (c) => { if (c.role === "builder") caps.push(c.turnMax ?? 0); return orig(c); };
@@ -5896,6 +6078,36 @@ describe("#4450 light lane: one builder session, derived contract, upgrade to th
5896
6078
  assert.equal(typeof events(dir, "LIGHT_DONE")[0].cost_usd, "number");
5897
6079
  });
5898
6080
 
6081
+ it("#4502 a light lane that adds an undeclared package edge gets the structure gate: one builder resume, then merges", async () => {
6082
+ const EDGE = { from: "@s/db", to: "@s/core", kind: "dependencies", manifest: "packages/db/package.json" };
6083
+ const quiet = { package_edges_added: [], package_edges_removed: [], import_pairs: [], boundary_config_changed: { changed: false, files: [], loosens: false, tightens: false } };
6084
+ const w = lworld(), dir = tmpDir("light-struct-"); const q = [{ ...quiet, package_edges_added: [EDGE] }, quiet];
6085
+ w.io.structureDelta = async () => (q.length > 1 ? q.shift() : q[0]);
6086
+ const r = await run(w, { dir, ...ON });
6087
+ assert.equal(r.terminalReason, "merged");
6088
+ assert.equal(events(dir, "LIGHT_DONE").length, 1, "stayed light");
6089
+ assert.equal(builders(w).length, 2, "the light session plus one structure resume");
6090
+ assert.match(builders(w)[1][4], /@s\/db -> @s\/core/);
6091
+ assert.equal(events(dir, "STRUCTURE_DELTA").length >= 1, true);
6092
+ });
6093
+
6094
+ it("#4502 a typed budget error in the light step ends as in the full lane: no second builder session, no upgrade", async () => {
6095
+ const w = lworld(), dir = tmpDir("light-typed-"); const orig = w.io.runner;
6096
+ let tries = 0;
6097
+ w.io.runner = async (c) => { if (c.role === "builder") { tries++; throw Object.assign(new Error("turn budget spent"), { laneCode: OUTCOME_CODES.BUDGET_EXCEEDED }); } return orig(c); };
6098
+ const r = await run(w, { dir, ...ON });
6099
+ assert.equal(r.code, OUTCOME_CODES.BUDGET_EXCEEDED);
6100
+ assert.equal(tries, 1, "no upgrade session");
6101
+ assert.equal(events(dir, "LIGHT_UPGRADE").length, 0);
6102
+ });
6103
+
6104
+ it("#4502 the targeted light tests run with the configured precheck timeout", async () => {
6105
+ const w = lworld(), dir = tmpDir("light-timeout-"); const seen = [];
6106
+ w.io.runCommand = async (a) => { seen.push(a.timeoutMs); return { rc: 0, output: "" }; };
6107
+ await run(w, { dir, ...ON });
6108
+ assert.ok(seen.length && seen.every((t) => Number.isFinite(t) && t > 0 && t < 2 ** 40), `timeouts: ${seen}`);
6109
+ });
6110
+
5899
6111
  for (const [name, code, o] of [
5900
6112
  ["oversized diff", "diff_lines", { numstat: "80\t3\tsrc/foo.js\n20\t0\ttest/foo.test.js\n" }],
5901
6113
  ["non-red repro", "repro_not_red", { repro: { base: "PASSED", head: "RAN" } }],
@@ -5954,15 +6166,14 @@ describe("#4450 light lane: one builder session, derived contract, upgrade to th
5954
6166
  w.io.structureDelta = async () => { w.over.structure.push(w.calls.length); return { package_edges_added: [], package_edges_removed: [], import_pairs: [] }; };
5955
6167
  return w;
5956
6168
  };
5957
- it("#4451 a light lane that is not upgraded makes no context-pack, structure or hotspot calls", async () => {
6169
+ it("#4451/#4502 a light lane that is not upgraded makes no context-pack call but runs the structure and hotspot gates", async () => {
5958
6170
  const w = overhead(), dir = tmpDir("light-over-");
5959
6171
  const r = await run(w, { dir, ...ON, contextPack: { mode: "on" } });
5960
6172
  assert.equal(r.terminalReason, "merged");
5961
6173
  assert.equal(events(dir, "LIGHT_DONE").length, 1);
5962
- assert.deepEqual(w.over, { pack: [], hot: [], structure: [] });
6174
+ assert.deepEqual(w.over.pack, []);
6175
+ assert.ok(w.over.hot.length >= 1 && w.over.structure.length >= 1, "#4502: the full lane's gates run");
5963
6176
  assert.equal(events(dir, "CONTEXT_PACK").length, 0);
5964
- // only the merge-time record (local numstat, no structure-delta call) remains; no implement-time delta
5965
- assert.deepEqual(events(dir, "STRUCTURE_DELTA").map((e) => e.phase), ["merge"]);
5966
6177
  assert.equal(events(dir, "RUN_TERMINAL")[0].light, true);
5967
6178
  });
5968
6179
  it("#4451 an upgraded light lane computes the hotspots and the context pack before the plan call and runs the structure delta", async () => {
@@ -23,14 +23,14 @@ const one = (s, max = DETAIL_MAX) => {
23
23
 
24
24
  /** The one builder session of a light lane: failing test, fix, targeted tests, result.json with what the contract is derived from. */
25
25
  export function lightBuildPrompt(n, opts = {}) {
26
- return `Resolve issue #${n} (read it with gh issue view ${n} --comments) in ONE session; this is a light lane for a small, well-specified issue. There is no plan step and no frozen contract: the engine derives the contract from your diff and your test.
26
+ return `Resolve issue #${n} (read it with gh issue view ${n} --comments) in ONE session; this is a light lane for a small, well-specified issue. There is no plan step and no frozen contract: the engine derives the contract from the files the issue names and your test.
27
27
 
28
28
  ${hostRules(opts)}
29
29
 
30
30
  1. Write the failing regression test first and run it: it must fail on the current code for the reason the issue describes.
31
31
  2. Make the smallest fix that makes it pass. Keep the public API shape unchanged (signatures, sync/async, generator/function, exported names); the engine checks it.
32
32
  3. Run the targeted tests (the new test plus the existing tests of the files you changed), run the repo's formatter on the files you changed, and commit. Do not push or touch GitHub.
33
- 4. Run \`node ${FORGEDOCK_CLI} check\` in the worktree and fix until it passes.
33
+ 4. Re-run the targeted tests once more on the committed tree and make sure \`git status\` is clean (the engine runs its own gates afterwards; there is no contract file to check yet).
34
34
 
35
35
  When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comments):
36
36
  ### Approach
@@ -79,19 +79,22 @@ export function apiShapeReasons(r) {
79
79
  * issue sentence, else the issue title; deliverables: the changed non-test files; validate: the repro command plus each
80
80
  * targeted test command; repro_test: the builder's, else "none" with a reason. validateContract judges it like any other.
81
81
  */
82
- export function deriveLightContract({ title = "", issueText = "", result = null, files = [] } = {}) {
82
+ export function deriveLightContract({ title = "", issueText = "", result = null, files = [], predictedFiles = null } = {}) {
83
83
  const r = result && typeof result === "object" ? result : {};
84
84
  const said = typeof r.requirement === "string" ? r.requirement.trim() : "";
85
85
  const quote = said && inIssue(said, issueText) ? said : String(title ?? "").trim();
86
86
  const paths = [...new Set((Array.isArray(files) ? files : []).map((f) => String(f ?? "").trim()).filter((f) => f && f !== LEAN_DIR && !f.startsWith(`${LEAN_DIR}/`)))];
87
- const code = paths.filter((f) => classifyPath(f) !== "test");
87
+ const changed = paths.filter((f) => classifyPath(f) !== "test");
88
+ // #4502: deliverables are what the issue names (the diff cannot fail its own scope check); no named files: the diff, as before
89
+ const named = Array.isArray(predictedFiles) ? new Set(predictedFiles.map((f) => String(f ?? "").trim())) : null;
90
+ const code = named ? changed.filter((f) => named.has(f)) : changed;
88
91
  const t = r.repro_test;
89
92
  const repro = t && typeof t === "object" && !Array.isArray(t) && String(t.path ?? "").trim() && String(t.command ?? "").trim()
90
93
  ? { path: String(t.path).trim(), id: String(t.id ?? "").trim(), command: String(t.command).trim() } : null;
91
94
  const commands = [...new Set([repro?.command, ...(Array.isArray(r.tests) ? r.tests : []).map((x) => (x && typeof x === "object" ? String(x.command ?? "").trim() : ""))].filter(Boolean))];
92
95
  return {
93
96
  requirements: [{ id: "R1", quote, disposition: "deliver" }],
94
- deliverables: code.map((path, i) => ({ id: `D${i + 1}`, path, change: "light-lane fix (derived from the diff)", requirements: ["R1"] })),
97
+ deliverables: code.map((path, i) => ({ id: `D${i + 1}`, path, change: "light-lane fix (issue-named file)", requirements: ["R1"] })),
95
98
  validate: commands.map((command, i) => ({ id: `V${i + 1}`, command })),
96
99
  ...(repro ? { repro_test: repro } : { repro_test: "none", repro_test_reason: "the light-lane builder named no repro test" }),
97
100
  };
@@ -109,14 +112,28 @@ export function upgradeNote(reasons) {
109
112
  /**
110
113
  * #4451: the light targeted tests, concurrently through precheck.mjs runChecks (one heavy slot per running command, the
111
114
  * worker cap `parallelism` divided among them) with no round budget: every command runs, none is deferred to CI.
112
- * run({command, workers}) -> {rc, output}. Returns [{command, rc, output}] in input order.
115
+ * run({command, timeoutMs, workers}) -> {rc, output}. Returns [{command, rc, output}] in input order.
113
116
  */
114
- export async function runLightTests({ commands, parallelism = 1, run, acquire, now = () => Date.now() }) {
115
- // the per-command limit stays far below the budget, so runChecks never sees a budget cut: a timeout (rc 124) stays a failure
116
- const { results } = await runChecks({ commands, parallelism, budgetMs: Number.MAX_SAFE_INTEGER, perCommandMs: 2 ** 40, now, acquire, run: ({ command, workers }) => run({ command, workers }) });
117
+ export async function runLightTests({ commands, parallelism = 1, run, acquire, now = () => Date.now(), timeoutMs }) {
118
+ // #4502: the configured per-command timeout reaches the runner (else io.runCommand's 300 s default applies); the budget stays far above it,
119
+ // so runChecks never sees a budget cut: a timeout (rc 124) stays a failure
120
+ const per = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 2 ** 40;
121
+ const { results } = await runChecks({ commands, parallelism, budgetMs: Number.MAX_SAFE_INTEGER, perCommandMs: per, now, acquire, run: ({ command, timeoutMs: t, workers }) => run({ command, timeoutMs: t, workers }) });
117
122
  return results.map(({ command, rc, output }) => ({ command, rc, output }));
118
123
  }
119
124
 
125
+ /** Non-test changed files (engine dir skipped) that the issue does not name. */
126
+ function scopeCreep(files, predicted) {
127
+ const named = new Set(predicted.map((f) => String(f ?? "").trim()));
128
+ return (files ?? []).map((f) => String(f ?? "").trim()).filter((f) => f && f !== LEAN_DIR && !f.startsWith(`${LEAN_DIR}/`) && classifyPath(f) !== "test" && !named.has(f));
129
+ }
130
+
131
+ /** #4502: a typed builder error (budget, idle/timeout deferral, spawn failure, usage limit) belongs to the lane's own catch, not to an upgrade. */
132
+ export function isTypedBuilderError(e) {
133
+ if (!e || typeof e !== "object") return false;
134
+ return Boolean(e.laneCode || e.deferCode || e.spawnDeterministic === true || e.usageLimit || /^CLI_/.test(String(e.code ?? "")) || /usage limit/i.test(String(e.message ?? "")));
135
+ }
136
+
120
137
  /**
121
138
  * One light lane step. ctx callbacks (all async unless noted): build() runs the one builder session; readResult();
122
139
  * checkpoint() commits uncommitted work; changedFiles(); numstat() -> `git diff --numstat` text; runTests(commands) ->
@@ -124,7 +141,7 @@ export async function runLightTests({ commands, parallelism = 1, run, acquire, n
124
141
  * or null; apiShape() -> detectApiShape output or null; validate(contract) -> {ok, problems} (sync). Plus title, issueText,
125
142
  * riskPaths, cfg. Returns {upgrade:false, contract, files, repro, testFailures} or {upgrade:true, reasons, files, ...}, plus the
126
143
  * gate inputs of the FORGE:LITE receipt (#4452): diff (parseNumstat), apiShape (apiShapeReasons), testCommands, testRuns.
127
- * Never throws: an internal error is the upgrade reason light_error.
144
+ * An internal error is the upgrade reason light_error; a typed builder error (isTypedBuilderError) is rethrown.
128
145
  */
129
146
  export async function runLightStep(ctx) {
130
147
  const up = (reasons, extra = {}) => ({ upgrade: true, reasons, contract: null, files: [], repro: null, testFailures: 0, diff: null, apiShape: null, testCommands: [], testRuns: null, ...extra });
@@ -135,8 +152,13 @@ export async function runLightStep(ctx) {
135
152
  const files = await ctx.changedFiles();
136
153
  if (!files.length) return up([{ code: "no_commit", detail: `the light builder made no commit (status ${one(result?.status ?? "none", 40)})` }]);
137
154
  if (result?.status !== "fixed") return up([{ code: "not_fixed", detail: `light builder status ${one(result?.status ?? "none", 40)}${result?.summary ? `: ${one(result.summary)}` : ""}` }], { files });
138
- const contract = deriveLightContract({ title: ctx.title, issueText: ctx.issueText, result, files });
155
+ const predicted = Array.isArray(ctx.predictedFiles) ? ctx.predictedFiles : null;
156
+ const contract = deriveLightContract({ title: ctx.title, issueText: ctx.issueText, result, files, predictedFiles: predicted });
139
157
  const vc = ctx.validate(contract);
158
+ if (!vc?.ok && predicted && scopeCreep(files, predicted).length) { // the issue-named deliverables are empty: the diff left the issue's scope
159
+ const t = upgradeTriggers({ diff: parseNumstat(await ctx.numstat()), repro: null, testFailures: 0, riskPaths: ctx.riskPaths ?? [], apiShape: [], cfg: ctx.cfg ?? {} });
160
+ return up([{ code: "scope_creep", detail: `changed file(s) the issue does not name: ${one(scopeCreep(files, predicted).slice(0, 5).join(", "))}` }, ...t.reasons], { files });
161
+ }
140
162
  if (!vc?.ok) return up([{ code: "contract_invalid", detail: `derived contract: ${one((vc?.problems ?? [])[0] ?? "invalid")}` }], { files });
141
163
 
142
164
  let testFailures = 0;
@@ -155,10 +177,13 @@ export async function runLightStep(ctx) {
155
177
  const [repro, numstat, shape] = await Promise.all([ctx.reproOf(contract), ctx.numstat(), ctx.apiShape()]);
156
178
  const diff = parseNumstat(numstat), apiShape = apiShapeReasons(shape);
157
179
  const t = upgradeTriggers({ diff, repro, testFailures, riskPaths: ctx.riskPaths ?? [], apiShape, cfg: ctx.cfg ?? {} });
158
- const latest = deriveLightContract({ title: ctx.title, issueText: ctx.issueText, result: (await ctx.readResult()) ?? result, files: now });
159
- return { upgrade: t.upgrade, reasons: t.reasons, contract: t.upgrade ? null : (ctx.validate(latest)?.ok ? latest : contract), files: now, repro, testFailures,
180
+ const outside = predicted ? scopeCreep(now, predicted) : [];
181
+ const reasons = outside.length ? [...t.reasons, { code: "scope_creep", detail: `changed file(s) the issue does not name: ${one(outside.slice(0, 5).join(", "))}` }] : t.reasons;
182
+ const latest = deriveLightContract({ title: ctx.title, issueText: ctx.issueText, result: (await ctx.readResult()) ?? result, files: now, predictedFiles: predicted });
183
+ return { upgrade: t.upgrade || outside.length > 0, reasons, contract: t.upgrade || outside.length ? null : (ctx.validate(latest)?.ok ? latest : contract), files: now, repro, testFailures,
160
184
  diff, apiShape, testCommands: commands, testRuns: runs == null ? null : (runs ?? []).length };
161
185
  } catch (e) {
186
+ if (isTypedBuilderError(e)) throw e; // #4502: the lane's own catch ends it as in the full lane, no second builder session
162
187
  return up([{ code: "light_error", detail: one(e?.message ?? e) }]);
163
188
  }
164
189
  }
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { deriveLightContract, runLightStep, runLightTests, parseNumstat, apiShapeReasons, upgradeNote, lightBuildPrompt, renderLiteReceipt } from "./lightlane.mjs";
5
+ import { isTypedBuilderError, deriveLightContract, runLightStep, runLightTests, parseNumstat, apiShapeReasons, upgradeNote, lightBuildPrompt, renderLiteReceipt } from "./lightlane.mjs";
6
6
  import { validateContract } from "./conformance.mjs";
7
7
 
8
8
  const TITLE = "foo crashes on null input";
@@ -235,3 +235,37 @@ describe("renderLiteReceipt (#4452)", () => {
235
235
  for (const k of ["upgrade", "reasons", "contract", "files", "repro", "testFailures"]) assert.ok(k in r, k);
236
236
  });
237
237
  });
238
+
239
+ describe("#4502 light lane fixes", () => {
240
+ it("runLightStep rethrows typed builder errors; internal errors stay light_error", async () => {
241
+ for (const e of [{ laneCode: "budget.exceeded" }, { deferCode: "cli.idle" }, { code: "CLI_SPAWN_FAILED" }, { code: "CLI_TIMEOUT" }, { spawnDeterministic: true }, { usageLimit: true }]) {
242
+ const { ctx } = step({ ctx: { build: async () => { throw Object.assign(new Error("typed"), e); } } });
243
+ await assert.rejects(runLightStep(ctx), (x) => isTypedBuilderError(x) && x.message === "typed");
244
+ }
245
+ const { ctx } = step({ ctx: { build: async () => { throw new Error("git broke"); } } });
246
+ assert.deepEqual(codes(await runLightStep(ctx)), ["light_error"]);
247
+ assert.equal(isTypedBuilderError(null), false);
248
+ });
249
+ it("the light prompt no longer asks for a check that cannot pass", () => {
250
+ const p = lightBuildPrompt(7);
251
+ assert.doesNotMatch(p, /forgedock[^\n]* check/);
252
+ assert.doesNotMatch(p, /\bcheck\b[^\n]*until it passes/);
253
+ assert.match(p, /git status/);
254
+ });
255
+ it("deliverables come from the issue-named files; a diff file outside them upgrades with scope_creep", () => {
256
+ const c = deriveLightContract({ title: TITLE, issueText: ISSUE, result: RESULT, files: ["src/foo.js", "src/other.js", "test/foo.test.js"], predictedFiles: ["src/foo.js"] });
257
+ assert.deepEqual(c.deliverables.map((d) => d.path), ["src/foo.js"]);
258
+ });
259
+ it("scope_creep: a changed non-test file the issue does not name is an upgrade reason", async () => {
260
+ const { ctx } = step({ files: ["src/foo.js", "src/other.js", "test/foo.test.js"], numstat: "5\t1\tsrc/foo.js\n2\t0\tsrc/other.js\n20\t0\ttest/foo.test.js\n", ctx: { predictedFiles: ["src/foo.js"] } });
261
+ const r = await runLightStep(ctx);
262
+ assert.equal(r.upgrade, true); assert.ok(codes(r).includes("scope_creep"), JSON.stringify(r.reasons));
263
+ const ok = await runLightStep(step({ ctx: { predictedFiles: ["src/foo.js"] } }).ctx);
264
+ assert.equal(ok.upgrade, false, JSON.stringify(ok.reasons));
265
+ });
266
+ it("runLightTests forwards the configured timeout to the runner", async () => {
267
+ const seen = [];
268
+ await runLightTests({ commands: ["a", "b"], timeoutMs: 900000, run: async (x) => { seen.push(x.timeoutMs); return { rc: 0, output: "" }; } });
269
+ assert.deepEqual(seen, [900000, 900000]);
270
+ });
271
+ });