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.
- package/bin/engine/outcome-codes.mjs +11 -0
- package/bin/engine/phases.mjs +22 -6
- package/bin/engine/runlog.mjs +1 -1
- package/bin/engine-cli.mjs +186 -22
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +17 -5
- package/bin/lean/batch.mjs +147 -0
- package/bin/lean/batch.test.mjs +184 -0
- package/bin/lean/lane.mjs +85 -0
- package/bin/lean/lane.test.mjs +91 -0
- package/bin/lean/lean.mjs +139 -11
- package/bin/lean/lean.test.mjs +390 -12
- package/bin/lean/orchestrate.mjs +229 -29
- package/bin/lean/prefilter.mjs +148 -0
- package/bin/lean/prefilter.test.mjs +210 -0
- package/bin/lean/prompts.mjs +80 -3
- package/bin/lean/retry.mjs +2 -2
- package/bin/runner.mjs +25 -3
- package/commands/orchestrate.md +62 -21
- package/commands/review-pr-staging.md +28 -3
- package/docs/CONFIG.md +2 -0
- package/docs/OUTCOME-CODES.md +11 -4
- package/package.json +1 -1
- package/scripts/code-index.sh +61 -40
- package/scripts/code-index.test.sh +73 -0
- package/scripts/review-pr-staging-scope.test.sh +36 -1
- package/scripts/review-prepare.sh +16 -3
- package/scripts/review-prepare.test.sh +16 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|