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.
- package/bin/engine/outcome-codes.mjs +7 -3
- package/bin/engine/phases.mjs +29 -8
- package/bin/engine-cli.mjs +84 -24
- package/bin/forgedock.mjs +21 -1
- package/bin/lean/basesync.mjs +98 -0
- package/bin/lean/basesync.test.mjs +151 -0
- package/bin/lean/batch.mjs +9 -3
- package/bin/lean/batch.test.mjs +134 -3
- package/bin/lean/budget.mjs +7 -1
- package/bin/lean/budget.test.mjs +9 -1
- package/bin/lean/conformance.mjs +20 -7
- package/bin/lean/conformance.test.mjs +29 -2
- package/bin/lean/depcache.mjs +23 -3
- package/bin/lean/depcache.test.mjs +42 -1
- package/bin/lean/effects.mjs +29 -3
- package/bin/lean/execution.mjs +106 -14
- package/bin/lean/execution.test.mjs +101 -1
- package/bin/lean/hostkill.mjs +8 -0
- package/bin/lean/lean.mjs +65 -24
- package/bin/lean/lean.test.mjs +221 -10
- package/bin/lean/lightlane.mjs +38 -13
- package/bin/lean/lightlane.test.mjs +35 -1
- package/bin/lean/orchestrate.mjs +93 -26
- package/bin/lean/prompts.mjs +17 -7
- package/bin/lean/recovery.test.mjs +26 -0
- package/bin/lean/retry.mjs +15 -1
- package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
- package/bin/lean/sandbox-remote.mjs +40 -7
- package/bin/lean/schedd.mjs +50 -14
- package/bin/lean/schedd.test.mjs +133 -21
- package/bin/lean/seqlease.mjs +49 -2
- package/bin/lean/seqlease.test.mjs +14 -0
- package/bin/lean/sequence.mjs +54 -6
- package/bin/lean/sequence.test.mjs +83 -1
- package/bin/pool/agent.mjs +435 -0
- package/bin/pool/cli.mjs +110 -0
- package/bin/pool/docker.mjs +56 -0
- package/bin/pool/http.mjs +55 -0
- package/bin/pool/router.mjs +299 -0
- package/bin/pool/store.mjs +25 -0
- package/bin/pool/version.mjs +44 -0
- package/bin/runner.mjs +1 -0
- package/bin/stats/aggregate.mjs +6 -0
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/local-source.mjs +1 -1
- package/bin/stats/pool.mjs +51 -0
- package/bin/stats/pool.test.mjs +39 -0
- package/bin/stats/query.mjs +2 -2
- package/bin/stats/stats.test.mjs +15 -1
- package/commands/orchestrate.md +3 -3
- package/docs/CONFIG.md +23 -9
- package/docs/OUTCOME-CODES.md +5 -3
- package/docs/STATS.md +7 -0
- package/package.json +2 -1
- package/pool/README.md +13 -0
- package/pool/image/Dockerfile +46 -0
- package/pool/image/claude-shim +8 -0
- package/pool/image/cs-cred +12 -0
- package/pool/image/entrypoint.sh +61 -0
- package/pool/image/gh-shim +6 -0
- package/pool/install/install-agent-linux.sh +53 -0
- package/pool/install/install-agent.ps1 +225 -0
- package/pool/install/install-agent.sh +101 -0
- package/pool/install/install-router.sh +67 -0
- package/pool/install/keepalive.ps1 +36 -0
- package/pool/install/uninstall-agent.ps1 +20 -0
- package/scripts/hotspots.sh +33 -21
- package/scripts/hotspots.test.sh +22 -3
- package/scripts/refresh-bot-token.test.sh +56 -0
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -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
|
|
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, "
|
|
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")
|
|
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
|
|
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
|
|
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 () => {
|
package/bin/lean/lightlane.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
|
116
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
159
|
-
|
|
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
|
+
});
|