forgedock 1.24.4 → 1.25.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.
@@ -0,0 +1,184 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
10
+ import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic } from "./batch.mjs";
11
+ import { orchestrateFromCli, runFromCli, readOrchestration } from "../engine-cli.mjs";
12
+
13
+ const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
14
+ const until = async (cond, ms = 15000) => {
15
+ const end = Date.now() + ms;
16
+ while (!cond()) { if (Date.now() > end) throw new Error("timed out waiting"); await new Promise((r) => setTimeout(r, 25)); }
17
+ };
18
+ // A fake clock: `sleep` advances it, so delays are asserted without waiting.
19
+ const fakeClock = () => { const c = { t: 1_000_000 }; return { c, now: () => c.t, sleep: async (ms) => { c.t += ms; } }; };
20
+
21
+ describe("durable batch runner (#4121)", () => {
22
+ it("killing the dispatcher mid-batch and --resume re-adopts live lanes and finishes without duplicate launches", async () => {
23
+ const work = tmpDir("batch-int-");
24
+ const root = join(work, "batches");
25
+ const launches = join(work, "launches"), release = join(work, "release");
26
+ writeFileSync(launches, "");
27
+ // Stub lane: records its launch, waits for the release file, then writes its result like `run-issue --result-file`.
28
+ const stub = join(work, "stub-lane.mjs");
29
+ writeFileSync(stub, `import { appendFileSync, existsSync, writeFileSync, renameSync } from "node:fs";
30
+ const a = process.argv.slice(2); const n = Number(a[1]); const rf = a[a.indexOf("--result-file") + 1];
31
+ appendFileSync(${JSON.stringify(launches)}, n + "\\n");
32
+ const t = setInterval(() => { if (!existsSync(${JSON.stringify(release)})) return; clearInterval(t);
33
+ writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "merged", pr: 100 + n, cost_usd: 0 })); renameSync(rf + ".tmp", rf); }, 20);
34
+ `);
35
+ const depsSrc = `{ batchRoot: ${JSON.stringify(root)}, batchId: "bt1", allowTmpfs: true, laneScript: ${JSON.stringify(stub)}, pollMs: 30,
36
+ orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\\n" },
37
+ leanIo: { gh: async (a) => a.includes("milestone") ? JSON.stringify({ milestone: null }) : a[0] === "repo" ? "main\\n" : JSON.stringify({ body: a[2] === "3" ? "Depends on #1" : "" }),
38
+ git: async (a) => (a[0] === "ls-remote" ? "abc\\trefs/heads/staging\\n" : "") } }`;
39
+ const dispatcher = join(work, "dispatcher.mjs");
40
+ writeFileSync(dispatcher, `import { orchestrateFromCli } from ${JSON.stringify(new URL("../engine-cli.mjs", import.meta.url).href)};
41
+ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], ${depsSrc});
42
+ `);
43
+ const batch = openBatch(root, "bt1");
44
+ const pids = () => Object.values(batch.store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean);
45
+ let lanePids = [];
46
+ try {
47
+ const child = spawn(process.execPath, [dispatcher], { stdio: "ignore" });
48
+ const exited = new Promise((r) => child.on("exit", r));
49
+ await until(() => readFileSync(launches, "utf8").trim().split("\n").filter(Boolean).length === 2 && pids().length === 2);
50
+ lanePids = pids();
51
+ child.kill("SIGKILL");
52
+ await exited;
53
+ // The lanes run in their own session: the dispatcher's death did not take them down.
54
+ for (const pid of lanePids) assert.equal(isLaneAlive(pid, null), true, `lane ${pid} survived`);
55
+ const deps = (await import(`data:text/javascript,export default ${encodeURIComponent(depsSrc)}`)).default;
56
+ const resumed = quiet(() => orchestrateFromCli(["--resume", "bt1"], deps));
57
+ setTimeout(() => writeFileSync(release, ""), 200);
58
+ const res = await resumed;
59
+ assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102], [3, "merged", 103]]);
60
+ assert.deepEqual(readFileSync(launches, "utf8").trim().split("\n").map(Number).sort(), [1, 2, 3]); // no duplicate launch
61
+ assert.deepEqual(batch.store.load().running, {});
62
+ } finally {
63
+ writeFileSync(release, "");
64
+ for (const pid of lanePids) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
65
+ }
66
+ });
67
+
68
+ it("deferred codes are requeued with their own delay; non-host deferrals stop at the cap", async () => {
69
+ const { now, sleep } = fakeClock();
70
+ const seq = [
71
+ { code: "host.busy", retryInSeconds: 60 },
72
+ { code: "rate_limited", resetAtEpochMs: null, retryInSeconds: 600 },
73
+ { code: "merge.transient" },
74
+ { code: "push.transient" },
75
+ { code: "cli.idle" },
76
+ ];
77
+ const starts = [];
78
+ const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3,
79
+ runOne: async () => { starts.push(now()); const d = seq.shift(); return { terminalReason: "deferred", detail: d.code, ...d }; } });
80
+ const gaps = starts.slice(1).map((t, i) => (t - starts[i]) / 1000);
81
+ assert.deepEqual(gaps, [60, 600, DEFER_BACKOFF_S * 2, DEFER_BACKOFF_S * 4]);
82
+ assert.equal(rows[0].outcome, "deferred");
83
+ assert.match(rows[0].detail, /gave up after 3 deferrals/);
84
+ // the usage-limit reset time and a lease expiry are honoured
85
+ assert.deepEqual(deferPolicy({ code: "rate_limited", resetAtEpochMs: 5000 }, { now: 1000 }), { delayMs: 4000, hold: true });
86
+ assert.equal(deferPolicy({ code: "lease-held", detail: "leased by x until 1970-01-01T00:01:40.000Z" }, { now: 0 }).delayMs, 105000);
87
+ assert.deepEqual(deferPolicy({ code: "host.busy" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
88
+ });
89
+
90
+ it("a usage limit holds every admission until its reset; a deferred issue's dependents wait instead of being skipped", async () => {
91
+ const { now, sleep, c } = fakeClock();
92
+ const t0 = c.t, starts = {};
93
+ let limited = true;
94
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, now, sleep, readBody: async (n) => (n === 3 ? "Blocked by #1" : ""),
95
+ runOne: async (n) => {
96
+ (starts[n] ??= []).push(now());
97
+ if (n === 1 && limited) { limited = false; return { terminalReason: "deferred", code: "rate_limited", resetAtEpochMs: t0 + 600_000 }; }
98
+ return { terminalReason: "merged" };
99
+ } });
100
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged"]);
101
+ assert.ok(starts[2][0] >= t0 + 600_000, "no admission during the hold");
102
+ assert.equal(starts[1].length, 2);
103
+ });
104
+
105
+ it("the breaker stops admissions after N engine-errors in M minutes and lets running lanes finish", async () => {
106
+ const started = [];
107
+ let releaseSlow;
108
+ const slow = new Promise((r) => { releaseSlow = r; });
109
+ // #3 runs slowly; #1 fails (relaunch queued behind #2, #4), then #2 fails: two errors in the window trip the breaker.
110
+ const rows = await orchestrate({ issues: [3, 1, 2, 4], parallel: 2, breaker: { errors: 2, window_min: 30 }, readBody: async () => "",
111
+ onUpdate: (r) => { if (r.outcome === "breaker") setTimeout(releaseSlow, 10); },
112
+ runOne: async (n) => {
113
+ started.push(n);
114
+ if (n === 3) { await slow; return { terminalReason: "merged", pr: 103 }; }
115
+ throw new Error(`boom ${n}`);
116
+ } });
117
+ assert.deepEqual(started, [3, 1, 2]);
118
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "held", "held", "held"]);
119
+ assert.match(rows[1].detail, /circuit breaker: 2 engine-errors within 30 min/);
120
+ });
121
+
122
+ it("an engine-error is relaunched once; a resume after the breaker continues the held issues", async () => {
123
+ const saved = { state: null };
124
+ const store = { load: () => saved.state, save: (s) => { saved.state = JSON.parse(JSON.stringify(s)); } };
125
+ const runs = [];
126
+ let fail = true;
127
+ const runOne = async (n) => { runs.push(n); if (fail) throw new Error("x"); return { terminalReason: "merged" }; };
128
+ let rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, breaker: { errors: 2, window_min: 30 }, store, readBody: async () => "", runOne });
129
+ assert.deepEqual(runs, [1, 2]); // #1's relaunch is queued behind #2, whose error trips the breaker
130
+ assert.deepEqual(rows.map((r) => r.outcome), ["held", "held", "held"]);
131
+ fail = false;
132
+ saved.state = { ...saved.state, breaker: null, errorTimes: [] }; // what --resume does
133
+ rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, breaker: { errors: 2, window_min: 30 }, store, readBody: async () => "", runOne });
134
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged"]);
135
+ assert.deepEqual(runs, [1, 2, 3, 1, 2]);
136
+ // without a breaker an engine-error is relaunched exactly once, then final
137
+ const again = [];
138
+ const [row] = await orchestrate({ issues: [9], readBody: async () => "", runOne: async (n) => { again.push(n); throw new Error("boom"); } });
139
+ assert.deepEqual([again, row.outcome], [[9, 9], "engine-error"]);
140
+ });
141
+
142
+ it("concurrency follows the live K", async () => {
143
+ let k = 1, active = 0, maxActive = 0;
144
+ const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6, 7], parallel: 1, pollMs: 5, getParallel: () => k, readBody: async () => "",
145
+ onUpdate: () => { k = 3; },
146
+ runOne: async () => { active++; maxActive = Math.max(maxActive, active); await new Promise((r) => setTimeout(r, 15)); active--; return { terminalReason: "merged" }; } });
147
+ assert.equal(maxActive, 3);
148
+ assert.equal(rows.length, 7);
149
+ });
150
+
151
+ it("batch files: atomic writes, live K via --set-parallel, tmpfs refused, pid reuse not adopted", async () => {
152
+ const root = tmpDir("batch-files-");
153
+ const b = openBatch(root, "b1");
154
+ b.writePlan({ argv: ["1"] });
155
+ await quiet(() => orchestrateFromCli(["--set-parallel", "b1", "7"], { batchRoot: root }));
156
+ assert.equal(b.readParallel(), 7);
157
+ await assert.rejects(orchestrateFromCli(["--set-parallel", "nope", "2"], { batchRoot: root }), /no batch nope/);
158
+ await assert.rejects(orchestrateFromCli(["--set-parallel", "b1", "0"], { batchRoot: root }), /positive integer/);
159
+ assert.throws(() => openBatch(root, "../x"), /invalid batch id/);
160
+ writeAtomic(join(root, "f.json"), "{}");
161
+ assert.equal(readFileSync(join(root, "f.json"), "utf8"), "{}");
162
+ const mounts = [{ mount: "/", type: "ext4" }, { mount: "/tmp", type: "tmpfs" }, { mount: "/home", type: "btrfs" }];
163
+ assert.equal(fsTypeOf("/tmp/x/y", mounts), "tmpfs");
164
+ assert.equal(fsTypeOf("/tmpx/y", mounts), "ext4");
165
+ assert.throws(() => assertDiskPath("/tmp/batches", mounts), /must be on disk/);
166
+ assert.doesNotThrow(() => assertDiskPath("/home/u/.forge/batches", mounts));
167
+ assert.equal(isLaneAlive(process.pid, "--definitely-not-in-my-cmdline"), false);
168
+ assert.equal(isLaneAlive(2 ** 22 + 12345, null), false);
169
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3 });
170
+ writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n");
171
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2 });
172
+ });
173
+
174
+ it("run-issue --result-file writes the lane result, also when the run throws", async () => {
175
+ const d = tmpDir("batch-rf-");
176
+ const rf = join(d, "lanes", "7.json");
177
+ const base = { dir: d, emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" } };
178
+ await quiet(() => runFromCli(["7", "--repo", "o/r", "--result-file", rf], { ...base, leanModule: { runCore: async () => ({ terminalReason: "merged", pr: 9 }) } }));
179
+ assert.deepEqual(JSON.parse(readFileSync(rf, "utf8")), { terminalReason: "merged", pr: 9 });
180
+ await assert.rejects(quiet(() => runFromCli(["7", "--repo", "o/r", "--result-file", rf], { ...base, leanModule: { runCore: async () => { throw new Error("kaboom"); } } })), /kaboom/);
181
+ assert.equal(JSON.parse(readFileSync(rf, "utf8")).terminalReason, "engine-error");
182
+ assert.ok(!existsSync(`${rf}.tmp-${process.pid}`));
183
+ });
184
+ });
@@ -0,0 +1,85 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Lane resolution for core work-on (#4125). The lane (base and merge branch) comes from the issue, not the caller:
5
+ * a milestone maps to forge.yaml `branches.feature_pattern` with `{slug}` from the milestone title, otherwise
6
+ * `branches.staging`. A requested `--lane` is an assertion; the default/production branch is never a lane; the branch
7
+ * must exist on origin. Every failure is fail-closed with a typed `lane.*` code. Reads are injected through `io`.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { parseForgeYaml } from "../forge-utils.mjs";
12
+ import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
13
+
14
+ /** The documented forge.yaml conventions (forge.yaml.example, scripts/classify-lane.sh); always verified on origin. */
15
+ export const DEFAULT_STAGING_BRANCH = "staging";
16
+ export const DEFAULT_FEATURE_PATTERN = "milestone/{slug}";
17
+ const LANE_RE = /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
18
+
19
+ /** Same slug as scripts/classify-lane.sh: lowercase, spaces to hyphens, keep [a-z0-9-], collapse and trim hyphens. */
20
+ export function milestoneSlug(title) {
21
+ return String(title ?? "").toLowerCase().replace(/ /g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
22
+ }
23
+
24
+ /** forge.yaml `branches` from the checkout: {default, staging, featurePattern}; missing keys are null. */
25
+ export function readBranches(cwd = process.cwd()) {
26
+ let b = {};
27
+ try { b = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.branches ?? {}; } catch { b = {}; }
28
+ const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
29
+ return { default: s(b.default), staging: s(b.staging), featurePattern: s(b.feature_pattern) };
30
+ }
31
+
32
+ /** True when `lane` is one of the default (production) branches. */
33
+ export function isDefaultLane(lane, defaults) {
34
+ return !!lane && (defaults ?? []).includes(lane);
35
+ }
36
+
37
+ /** The repo's default branches: forge.yaml `branches.default` plus GitHub's default branch. Throws when GitHub's is unreadable. */
38
+ export async function defaultBranches({ repo, io, branches = {} }) {
39
+ const gh = String(await io.gh(["repo", "view", repo, "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"])).trim();
40
+ if (!gh) throw new Error("GitHub reported no default branch");
41
+ return [...new Set([branches.default, gh].filter(Boolean))];
42
+ }
43
+
44
+ /**
45
+ * @returns {Promise<{ok:true, lane:string, expected:string, milestone:?string, overridden:boolean, defaults:string[]}
46
+ * | {ok:false, code:string, detail:string}>}
47
+ */
48
+ export async function resolveLane({ issue, repo, io, cwd, branches = {}, requested = null, override = false }) {
49
+ const refuse = (code, detail) => ({ ok: false, code, detail });
50
+ const staging = branches.staging || DEFAULT_STAGING_BRANCH;
51
+ const pattern = branches.featurePattern || DEFAULT_FEATURE_PATTERN;
52
+ if (!pattern.includes("{slug}")) return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `forge.yaml branches.feature_pattern "${pattern}" has no {slug}`);
53
+ let milestone = null, defaults;
54
+ try {
55
+ milestone = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "milestone"])))?.milestone?.title || null;
56
+ } catch (e) { return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `cannot read the milestone of #${issue}: ${String(e?.message ?? e).slice(0, 200)}`); }
57
+ try { defaults = await defaultBranches({ repo, io, branches }); } catch (e) {
58
+ return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `cannot read the default branch of ${repo}: ${String(e?.message ?? e).slice(0, 200)}`);
59
+ }
60
+ let expected = staging;
61
+ if (milestone) {
62
+ const slug = milestoneSlug(milestone);
63
+ if (!slug) return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `milestone "${milestone}" gives an empty branch slug; rename it to include ASCII letters or digits`);
64
+ expected = pattern.replace("{slug}", slug);
65
+ }
66
+ const why = milestone ? `milestone "${milestone}"` : "no milestone (branches.staging)";
67
+ if (isDefaultLane(requested, defaults)) {
68
+ return refuse(OUTCOME_CODES.LANE_DEFAULT_BRANCH, `--lane ${requested} is the default branch; the core path never targets it (promotion is a human-merged release PR). #${issue} belongs on ${expected} (${why})`);
69
+ }
70
+ if (isDefaultLane(expected, defaults)) {
71
+ return refuse(OUTCOME_CODES.LANE_DEFAULT_BRANCH, `#${issue} resolves to ${expected} (${why}), which is the default branch; set forge.yaml branches.staging to a non-default branch`);
72
+ }
73
+ let lane = expected, overridden = false;
74
+ if (requested && requested !== expected) {
75
+ if (!override) return refuse(OUTCOME_CODES.LANE_MISMATCH, `--lane ${requested} does not match #${issue}'s lane ${expected} (${why}); omit --lane, or pass --lane-override to force it`);
76
+ lane = requested; overridden = true;
77
+ }
78
+ if (!LANE_RE.test(lane) || lane.includes("..")) return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `invalid lane ${JSON.stringify(lane)}`);
79
+ let out;
80
+ try { out = String(await io.git(["ls-remote", "--heads", "origin", `refs/heads/${lane}`], { cwd })); } catch (e) {
81
+ return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `cannot check origin for ${lane}: ${String(e?.message ?? e).slice(0, 200)}`);
82
+ }
83
+ if (!out.trim()) return refuse(OUTCOME_CODES.LANE_BRANCH_MISSING, `lane branch ${lane} (${why}) does not exist on origin; create it first (e.g. /milestone start) or fix the milestone title`);
84
+ return { ok: true, lane, expected, milestone, overridden, defaults };
85
+ }
@@ -0,0 +1,91 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { writeFileSync } from "node:fs";
6
+ import { tmpDir } from "../tests/_tmp.mjs";
7
+ import { milestoneSlug, readBranches, resolveLane } from "./lane.mjs";
8
+
9
+ /** Fake gh/git: the issue's milestone, GitHub's default branch, and the branches on origin. */
10
+ function io({ milestone = null, ghDefault = "main", onOrigin = ["staging", "main"], ghFails = null, gitFails = false } = {}) {
11
+ const calls = [];
12
+ return {
13
+ calls,
14
+ gh: async (a) => {
15
+ calls.push(["gh", ...a]);
16
+ if (ghFails && a[0] === ghFails) throw new Error("HTTP 502");
17
+ if (a[0] === "issue" && a[1] === "view") return JSON.stringify({ milestone: milestone ? { title: milestone } : null });
18
+ if (a[0] === "repo" && a[1] === "view") return `${ghDefault}\n`;
19
+ throw new Error(`unexpected gh ${a.join(" ")}`);
20
+ },
21
+ git: async (a) => {
22
+ calls.push(["git", ...a]);
23
+ if (gitFails) throw new Error("network down");
24
+ const b = a.at(-1).replace(/^refs\/heads\//, "");
25
+ return onOrigin.includes(b) ? `abc\trefs/heads/${b}\n` : "";
26
+ },
27
+ };
28
+ }
29
+ const resolve = (o, extra = {}) => resolveLane({ issue: 5, repo: "o/r", io: io(o), cwd: "/repo", ...extra });
30
+
31
+ describe("lane resolution (#4125)", () => {
32
+ it("slug matches scripts/classify-lane.sh", () => {
33
+ assert.equal(milestoneSlug("Adaptive Unblocking: Control Loop"), "adaptive-unblocking-control-loop");
34
+ assert.equal(milestoneSlug("User Auth v2"), "user-auth-v2");
35
+ assert.equal(milestoneSlug(" -- Foo (Bar) -- "), "foo-bar");
36
+ assert.equal(milestoneSlug("🚀✨"), "");
37
+ });
38
+
39
+ it("a milestone issue resolves to feature_pattern with the slug; no milestone resolves to staging", async () => {
40
+ const m = await resolve({ milestone: "Adaptive Unblocking Control Loop", onOrigin: ["staging", "milestone/adaptive-unblocking-control-loop"] });
41
+ assert.deepEqual([m.ok, m.lane, m.overridden], [true, "milestone/adaptive-unblocking-control-loop", false]);
42
+ const s = await resolve({});
43
+ assert.deepEqual([s.ok, s.lane], [true, "staging"]);
44
+ });
45
+
46
+ it("forge.yaml branches.staging and feature_pattern are honoured", async () => {
47
+ const branches = { staging: "develop", featurePattern: "feature/{slug}" };
48
+ assert.equal((await resolve({ onOrigin: ["develop"] }, { branches })).lane, "develop");
49
+ assert.equal((await resolve({ milestone: "X Y", onOrigin: ["feature/x-y"] }, { branches })).lane, "feature/x-y");
50
+ const bad = await resolve({}, { branches: { featurePattern: "milestone/fixed" } });
51
+ assert.equal(bad.code, "lane.unresolved");
52
+ });
53
+
54
+ it("--lane is an assertion: a mismatch is refused, --lane-override accepts it", async () => {
55
+ const o = { milestone: "M1", onOrigin: ["staging", "milestone/m1"] };
56
+ const r = await resolve(o, { requested: "staging" });
57
+ assert.equal(r.ok, false); assert.equal(r.code, "lane.mismatch");
58
+ assert.match(r.detail, /--lane staging does not match #5's lane milestone\/m1/);
59
+ assert.equal((await resolve(o, { requested: "milestone/m1" })).ok, true);
60
+ const ov = await resolve(o, { requested: "staging", override: true });
61
+ assert.deepEqual([ov.ok, ov.lane, ov.expected, ov.overridden], [true, "staging", "milestone/m1", true]);
62
+ });
63
+
64
+ it("the default branch is never a lane, even with an override or when staging is configured as it", async () => {
65
+ for (const requested of ["main", "trunk"]) {
66
+ const r = await resolve({ ghDefault: "trunk" }, { requested, override: true, branches: { default: "main" } });
67
+ assert.equal(r.code, "lane.default_branch", requested);
68
+ }
69
+ assert.equal((await resolve({}, { branches: { staging: "main" } })).code, "lane.default_branch");
70
+ });
71
+
72
+ it("a missing lane branch fails closed with lane.branch_missing", async () => {
73
+ const r = await resolve({ milestone: "Not Created", onOrigin: ["staging"] });
74
+ assert.equal(r.code, "lane.branch_missing");
75
+ assert.match(r.detail, /milestone\/not-created/);
76
+ });
77
+
78
+ it("unreadable milestone, default branch or origin, and an empty slug, fail closed with lane.unresolved", async () => {
79
+ assert.equal((await resolve({ ghFails: "issue" })).code, "lane.unresolved");
80
+ assert.equal((await resolve({ ghFails: "repo" })).code, "lane.unresolved");
81
+ assert.equal((await resolve({ gitFails: true })).code, "lane.unresolved");
82
+ assert.equal((await resolve({ milestone: "🚀" })).code, "lane.unresolved");
83
+ });
84
+
85
+ it("readBranches reads forge.yaml branches; missing keys are null", () => {
86
+ const d = tmpDir("lane-");
87
+ writeFileSync(`${d}/forge.yaml`, 'branches:\n default: "main"\n staging: "develop"\n feature_pattern: "milestone/{slug}"\n');
88
+ assert.deepEqual(readBranches(d), { default: "main", staging: "develop", featurePattern: "milestone/{slug}" });
89
+ assert.deepEqual(readBranches(tmpDir("lane-")), { default: null, staging: null, featurePattern: null });
90
+ });
91
+ });
package/bin/lean/lean.mjs CHANGED
@@ -12,19 +12,48 @@ import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mj
12
12
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
13
13
  import { openDependency } from "../engine/phases.mjs";
14
14
  import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
15
+ import { resolveLane, isDefaultLane } from "./lane.mjs";
15
16
  import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
16
17
  import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, capComment, sanitizeMd } from "./render.mjs";
17
- import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, PLAN_FILE, IMPLEMENTATION_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
18
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, PLAN_FILE, IMPLEMENTATION_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, REVIEW_DIFF_MAX_BYTES, parseVerdict, blockCorrectionPrompt, HUMAN_BLOCK_REASONS } from "./prompts.mjs";
18
19
 
19
20
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
20
21
  export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
21
22
  const num = (v) => (Number.isFinite(v) ? v : null);
23
+ export const MAX_SUBISSUES = 8;
24
+
25
+ /**
26
+ * Typed builder block (#4124), from result.json `block`. Returns {kind:"dependency", issues} | {kind:"split", subissues}
27
+ * | {kind:"human", why} | {kind:"untyped", problem}. Only `human` with an allowed reason may end as needs-human.
28
+ */
29
+ export function classifyBlock(result, issue) {
30
+ const b = result?.block;
31
+ if (!b || typeof b !== "object" || Array.isArray(b)) return { kind: "untyped", problem: "it has no typed \"block\" object" };
32
+ if (b.kind === "dependency") {
33
+ const issues = [...new Set((Array.isArray(b.issues) ? b.issues : []).map((x) => Number(String(x).replace(/^#/, ""))))].filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
34
+ return issues.length ? { kind: "dependency", issues } : { kind: "untyped", problem: "the dependency block names no other issue number" };
35
+ }
36
+ if (b.kind === "split-required") {
37
+ const subs = (Array.isArray(b.subissues) ? b.subissues : []).filter((x) => x && typeof x.title === "string" && x.title.trim() && typeof x.body === "string");
38
+ if (!subs.length) return { kind: "untyped", problem: "the split-required block lists no sub-issues with a title and body" };
39
+ if (subs.length > MAX_SUBISSUES) return { kind: "untyped", problem: `the split-required block lists ${subs.length} sub-issues (at most ${MAX_SUBISSUES})` };
40
+ return { kind: "split", subissues: subs.map((x, i) => ({ title: x.title.trim(), body: x.body,
41
+ dependsOn: [...new Set((Array.isArray(x.depends_on) ? x.depends_on : []).filter((j) => Number.isInteger(j) && j >= 0 && j < i))] })) };
42
+ }
43
+ if (b.kind === "human") {
44
+ return HUMAN_BLOCK_REASONS.includes(b.why) ? { kind: "human", why: b.why }
45
+ : { kind: "untyped", problem: `"${String(b.why ?? "").slice(0, 80)}" is not a human-only reason (${HUMAN_BLOCK_REASONS.join(", ")})` };
46
+ }
47
+ return { kind: "untyped", problem: `block kind "${String(b.kind ?? "").slice(0, 40)}" is unknown` };
48
+ }
22
49
 
23
50
  /**
24
51
  * @param {object} o
25
52
  * @param {number} o.issue
26
53
  * @param {string} o.repo "owner/repo"
27
- * @param {string} o.lane base branch (staging|main)
54
+ * @param {string} [o.lane] requested lane: an assertion checked against the lane resolved from the issue (#4125)
55
+ * @param {boolean} [o.laneOverride] accept a requested lane that differs from the resolved one (never the default branch)
56
+ * @param {object} [o.branches] forge.yaml branches {default, staging, featurePattern} (lane.mjs readBranches)
28
57
  * @param {string} o.cwd main checkout (worktrees are added from here)
29
58
  * @param {string} o.dir run-log dir
30
59
  * @param {object} o.io injected effects: gh, git, runner, waitCiGreen, transitionLabel, fs, projector?, now?, sleep?
@@ -33,11 +62,11 @@ const num = (v) => (Number.isFinite(v) ? v : null);
33
62
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
34
63
  */
35
64
  export async function runCore(o) {
36
- const { issue, repo, lane, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
65
+ const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
37
66
  ciMaxMinutes = 360, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
38
67
  const now = io.now ?? (() => Date.now());
39
68
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
40
- const base = `origin/${lane}`;
69
+ let lane = null, base = null, defaults = []; // set by lane resolution, before the lease
41
70
  const branch = `feat/issue-${issue}-lean`;
42
71
  const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
43
72
  const t0 = now();
@@ -101,7 +130,7 @@ export async function runCore(o) {
101
130
  const code = codeOverride ?? codeForTerminalReason(reason);
102
131
  ev({ event: "RUN_TERMINAL", reason, code });
103
132
  await emit({ event: "RUN_TERMINAL", reason, code });
104
- if (["needs-human", "engine-error"].includes(reason)) {
133
+ if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed"].includes(reason)) {
105
134
  const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
106
135
  try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
107
136
  }
@@ -285,11 +314,75 @@ export async function runCore(o) {
285
314
  }
286
315
  const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
287
316
 
317
+ /** Live-checks a builder block (#4124). Dependency issues are re-read, so a closed or unknown number is not a block. */
318
+ async function resolveBlock(r) {
319
+ const b = classifyBlock(r, issue);
320
+ if (b.kind !== "dependency") return b;
321
+ const open = [], blockers = [];
322
+ for (const d of b.issues) {
323
+ let v;
324
+ try { v = JSON.parse(String(await ghc(["issue", "view", String(d), "-R", repo, "--json", "state,labels"]))); } catch (e) {
325
+ return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
326
+ }
327
+ if (String(v?.state).toUpperCase() !== "OPEN") continue;
328
+ open.push(d);
329
+ if ((v.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) blockers.push(d);
330
+ }
331
+ if (!open.length) return { kind: "untyped", problem: `its dependencies ${b.issues.map((n) => `#${n}`).join(", ")} are not open` };
332
+ return { kind: "dependency", issues: open, blockers };
333
+ }
334
+ /** Ends a run whose builder stayed blocked: only a typed human reason is needs-human. */
335
+ async function endBlocked(b, summary) {
336
+ const s = summary ?? "(no summary)";
337
+ const refs = (ns) => ns.map((n) => `#${n}`).join(", ");
338
+ if (b.kind === "human") return await needsHuman(`Builder is blocked (${b.why}): ${s}`, OUTCOME_CODES.BUILDER_BLOCKED);
339
+ if (b.kind === "unreadable") return await finish("dependency-unreadable", b.detail);
340
+ if (b.kind === "dependency") {
341
+ const blocked = b.blockers.length > 0;
342
+ const detail = blocked
343
+ ? `blocked by ${refs(b.blockers)} (needs-human); depends on ${refs(b.issues)}. ${s}`
344
+ : `depends on ${refs(b.issues)} which is still open; requeue behind it. ${s}`;
345
+ T.decisions.push(`Builder block: dependency on ${refs(b.issues)}${blocked ? ` (needs-human: ${refs(b.blockers)})` : ""}`);
346
+ try { await post("FORGE:DEPENDENCY", `## ${blocked ? "Blocked by" : "Waiting on"} ${refs(blocked ? b.blockers : b.issues)}\n${detail}\n\n_${usageLine()}_`); } catch { /* best effort */ }
347
+ return blocked
348
+ ? await finish("dependency-blocked", detail, { code: OUTCOME_CODES.DEPENDENCY_BLOCKED, dependencies: b.issues, blockers: b.blockers }, OUTCOME_CODES.DEPENDENCY_BLOCKED)
349
+ : await finish("dependency-open", detail, { code: OUTCOME_CODES.DEPENDENCY_OPEN, dependencies: b.issues }, OUTCOME_CODES.DEPENDENCY_OPEN);
350
+ }
351
+ if (b.kind === "split") {
352
+ const filed = [];
353
+ try {
354
+ for (const sub of b.subissues) {
355
+ const lines = [`**Parent**: #${issue}`, ...sub.dependsOn.map((j) => `**Depends on**: #${filed[j]}`)];
356
+ const out = String(await ghw(["issue", "create", "-R", repo, "--title", sub.title, "--body", `${sub.body.trim()}\n\n${lines.join("\n")}`]));
357
+ const n = Number(/\/issues\/(\d+)/.exec(out)?.[1]);
358
+ if (!Number.isSafeInteger(n)) throw new Error(`cannot parse the sub-issue number from: ${out.slice(0, 200)}`);
359
+ filed.push(n);
360
+ }
361
+ } catch (e) { return await finish("engine-error", `filing sub-issues failed after ${refs(filed) || "none"}: ${e?.message ?? e}`, { subissues: filed }); }
362
+ T.decisions.push(`Builder block: split into ${refs(filed)}`);
363
+ try { await post("FORGE:DECOMPOSED", `## Split into sub-issues\n${filed.map((n, i) => `- #${n} ${b.subissues[i].title}`).join("\n")}\n\n${s}`); } catch { /* best effort */ }
364
+ await label("decomposed");
365
+ return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed });
366
+ }
367
+ return await needsHuman(`Builder is blocked without a valid typed reason after one correction (${b.problem}): ${s}`, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED);
368
+ }
369
+
288
370
  // ---- host admission (#4105): before the lease, the worktree or any GitHub call ----
289
371
  if (io.host && o.admission?.enabled !== false) {
290
372
  const a = await checkAdmission({ cfg: o.admission, host: io.host });
291
373
  if (!a.ok) return { terminalReason: "deferred", code: OUTCOME_CODES.HOST_BUSY, detail: a.detail, retryInSeconds: ADMISSION_RETRY_SECONDS };
292
374
  }
375
+ // ---- lane (#4125): worked out from the issue before any lease, comment or worktree; fail closed ----
376
+ {
377
+ const r = await resolveLane({ issue, repo, io, cwd, branches, requested: requestedLane, override: laneOverride });
378
+ if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
379
+ lane = r.lane; base = `origin/${lane}`; defaults = r.defaults;
380
+ if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
381
+ }
382
+ /** Last guard before a PR is created or merged: the default branch is never a lane. */
383
+ const laneGuard = (target, what) => {
384
+ if (isDefaultLane(target, defaults)) throw Object.assign(new Error(`refusing to ${what} into the default branch ${target}`), { laneCode: OUTCOME_CODES.LANE_DEFAULT_BRANCH });
385
+ };
293
386
  // ---- admission: Depends-on refusal, then lease ----
294
387
  {
295
388
  let dep = null;
@@ -335,10 +428,15 @@ export async function runCore(o) {
335
428
  try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
336
429
  let resumed = null;
337
430
  try {
338
- const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid"])) || "[]");
431
+ const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid,baseRefName"])) || "[]");
339
432
  const remote = String(await io.git(["ls-remote", "--heads", "origin", branch], { cwd })).trim();
340
- if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null };
433
+ if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null };
341
434
  } catch { /* no resume info: start fresh */ }
435
+ // An open PR on another base (a hand-retargeted PR, or one opened before lane resolution) is never merged (#4125).
436
+ if (resumed?.base && resumed.base !== lane) {
437
+ return await needsHuman(`Open PR #${resumed.pr} targets \`${resumed.base}\`, but #${issue}'s lane is \`${lane}\`; retarget or close it before rerunning.`,
438
+ isDefaultLane(resumed.base, defaults) ? OUTCOME_CODES.LANE_DEFAULT_BRANCH : OUTCOME_CODES.LANE_MISMATCH);
439
+ }
342
440
  try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
343
441
  try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
344
442
  // Concurrent lanes in one repo race on .git/config.lock / index.lock during `worktree add`; retry with jitter.
@@ -366,8 +464,23 @@ export async function runCore(o) {
366
464
  }
367
465
  }
368
466
  };
369
- if (resumed) {
370
- await io.git(["fetch", "origin", branch], { cwd });
467
+ if (resumed) { try { await io.git(["fetch", "origin", branch], { cwd }); } catch { /* remote branch may not exist (PR-less resume) */ } }
468
+ // #4118: a local lane branch with commits not yet pushed (e.g. a reviewed commit whose push failed) must never be reset.
469
+ // Reuse it when it is ahead of the base and contains everything the remote branch has.
470
+ let keepLocal = false;
471
+ try {
472
+ const ahead = Number(String(await io.git(["rev-list", "--count", `${base}..refs/heads/${branch}`], { cwd })).trim());
473
+ if (ahead > 0) {
474
+ let containsRemote = true;
475
+ if (resumed) { try { await io.git(["merge-base", "--is-ancestor", `origin/${branch}`, `refs/heads/${branch}`], { cwd }); } catch { containsRemote = false; } }
476
+ keepLocal = containsRemote;
477
+ }
478
+ } catch { /* no local branch */ }
479
+ if (keepLocal) {
480
+ await addWorktree(["worktree", "add", wt, branch]);
481
+ resumed = resumed ?? { pr: null, local: true };
482
+ ev({ event: "LOCAL_BRANCH_REUSED", branch });
483
+ } else if (resumed) {
371
484
  await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`]);
372
485
  } else {
373
486
  await addWorktree(["worktree", "add", "-B", branch, wt, base]);
@@ -385,6 +498,13 @@ export async function runCore(o) {
385
498
  await label("building");
386
499
  await builder(++attemptN, builderPrompt(issue) + (fromCheckpoint ? CONTINUE_NOTE.replace("BASE", base) : ""), null, true);
387
500
  result = await readResult();
501
+ let block = result?.status === "blocked" ? await resolveBlock(result) : null;
502
+ if (block?.kind === "untyped" && sessionId) { // #4124: one corrective resume before any untyped block can reach needs-human
503
+ T.decisions.push(`Builder block rejected (${block.problem}); resumed once`);
504
+ await builder(++attemptN, blockCorrectionPrompt(block.problem), sessionId);
505
+ result = await readResult();
506
+ block = result?.status === "blocked" ? await resolveBlock(result) : null;
507
+ }
388
508
  const files0 = await changedFiles();
389
509
  const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
390
510
  const closing = ["invalid", "already-fixed"].includes(result?.status);
@@ -398,7 +518,7 @@ export async function runCore(o) {
398
518
  await label("invalid");
399
519
  return await finish("invalid", result.summary ?? result.status);
400
520
  }
401
- if (result?.status === "blocked") return await needsHuman(`Builder is blocked: ${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_BLOCKED);
521
+ if (block) return await endBlocked(block, result.summary);
402
522
  if (!files0.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
403
523
  await flushStages(result, ["plan", "implementation"]);
404
524
  T.plan = "posted"; T.implement = [`${files0.length} file(s)`, branch];
@@ -413,9 +533,11 @@ export async function runCore(o) {
413
533
  const doReview = async () => {
414
534
  for (let t = 0; t < 2; t++) {
415
535
  const diff = String(await git(["diff", `${base}...HEAD`]));
536
+ // #4129: an over-budget diff is bounded in the prompt; the stat keeps every changed file visible.
537
+ const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
416
538
  await renew();
417
539
  const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
418
- prompt: reviewerPrompt({ issue: iss, diff }) });
540
+ prompt: reviewerPrompt({ issue: iss, diff, stat, base }) });
419
541
  await renew();
420
542
  await attempt("review", ++reviewN, rr);
421
543
  const v = parseVerdict(rr?.text);
@@ -441,6 +563,7 @@ export async function runCore(o) {
441
563
  // ---- 5. push + PR ----
442
564
  const files = await changedFiles();
443
565
  await gitPush(["push", "-u", "origin", branch]);
566
+ laneGuard(lane, "open a PR");
444
567
  const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghw(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
445
568
  "--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), plan: await readStage(STAGES[1]), implementation: await readStage(STAGES[2]), result, gitFiles: files })])).trim();
446
569
  const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
@@ -568,6 +691,7 @@ export async function runCore(o) {
568
691
  }
569
692
  if (syncs > 0 && (await prConflicting()) === "conflict") continue; // the base moved again while waiting
570
693
  T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
694
+ laneGuard(resumed?.base ?? lane, "merge");
571
695
  try {
572
696
  await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
573
697
  break;
@@ -593,6 +717,10 @@ export async function runCore(o) {
593
717
  return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null }, OUTCOME_CODES.RATE_LIMITED);
594
718
  }
595
719
  if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode }, e.deferCode);
720
+ if (e?.laneCode) return await needsHuman(e.message, e.laneCode);
721
+ if (e?.code === "CLI_SPAWN_FAILED" || e?.spawnDeterministic === true) { // #4129: certain to fail again, so not engine-error (orchestrate would relaunch it)
722
+ return await needsHuman(`The claude CLI could not be started (${e.spawnErrno ?? "spawn error"}); a rerun fails the same way: ${String(e.message).slice(0, 400)}`, OUTCOME_CODES.CLI_SPAWN_FAILED);
723
+ }
596
724
  if (e?.pushRejected) return await needsHuman(`git push was rejected (no retry): ${String(e?.stderr ?? e?.message ?? e).slice(0, 400)}`);
597
725
  return finish("engine-error", e?.message ?? String(e));
598
726
  }