forgedock 1.37.0 → 1.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/bin/engine/outcome-codes.mjs +12 -3
  2. package/bin/engine/telemetry-seam.mjs +5 -0
  3. package/bin/engine-cli.mjs +97 -18
  4. package/bin/forgedock.mjs +13 -2
  5. package/bin/lean/activation.mjs +76 -0
  6. package/bin/lean/activation.test.mjs +75 -0
  7. package/bin/lean/admission.mjs +17 -1
  8. package/bin/lean/batch.mjs +51 -21
  9. package/bin/lean/batch.test.mjs +95 -5
  10. package/bin/lean/conformance.mjs +53 -4
  11. package/bin/lean/conformance.test.mjs +24 -1
  12. package/bin/lean/decision-engine.mjs +217 -0
  13. package/bin/lean/decision-engine.test.mjs +159 -0
  14. package/bin/lean/effects.mjs +39 -13
  15. package/bin/lean/execution.mjs +74 -5
  16. package/bin/lean/execution.test.mjs +64 -1
  17. package/bin/lean/explore.test.mjs +1 -1
  18. package/bin/lean/guards.mjs +3 -2
  19. package/bin/lean/guards.test.mjs +24 -3
  20. package/bin/lean/heavy.mjs +15 -2
  21. package/bin/lean/heavy.test.mjs +31 -2
  22. package/bin/lean/issuewatch-release.mjs +245 -0
  23. package/bin/lean/issuewatch-release.test.mjs +244 -0
  24. package/bin/lean/issuewatch.mjs +386 -0
  25. package/bin/lean/issuewatch.test.mjs +280 -0
  26. package/bin/lean/lane.mjs +4 -3
  27. package/bin/lean/lane.test.mjs +12 -0
  28. package/bin/lean/lean.mjs +103 -16
  29. package/bin/lean/lean.test.mjs +127 -5
  30. package/bin/lean/needs-human-premise.mjs +177 -0
  31. package/bin/lean/needs-human-premise.test.mjs +136 -0
  32. package/bin/lean/orchestrate-blocked.test.mjs +71 -0
  33. package/bin/lean/orchestrate.mjs +93 -21
  34. package/bin/lean/postdeploy-check.mjs +88 -2
  35. package/bin/lean/postdeploy-check.test.mjs +78 -0
  36. package/bin/lean/prodwatch-sources.mjs +165 -0
  37. package/bin/lean/prodwatch-sources.test.mjs +108 -0
  38. package/bin/lean/prompts.mjs +9 -3
  39. package/bin/lean/recovery.test.mjs +14 -0
  40. package/bin/lean/relay.mjs +182 -0
  41. package/bin/lean/relay.test.mjs +214 -0
  42. package/bin/lean/sandbox-provider.contract.test.mjs +13 -1
  43. package/bin/lean/sandbox-remote.mjs +53 -9
  44. package/bin/lean/sched.test.mjs +30 -0
  45. package/bin/lean/schedd-health.mjs +69 -0
  46. package/bin/lean/schedd-health.test.mjs +51 -0
  47. package/bin/lean/schedd.mjs +29 -12
  48. package/bin/lean/schedd.test.mjs +40 -4
  49. package/bin/lean/sequence.mjs +57 -24
  50. package/bin/lean/sequence.test.mjs +71 -0
  51. package/bin/pool/agent.mjs +15 -1
  52. package/bin/pool/router.mjs +43 -5
  53. package/bin/runner.mjs +18 -1
  54. package/commands/deploy-pr.md +12 -0
  55. package/commands/forgedock-init.md +1 -0
  56. package/commands/orchestrate/phase-4-execution.md +1 -0
  57. package/commands/orchestrate.md +1 -0
  58. package/commands/review-pr.md +17 -13
  59. package/docs/CONFIG.md +73 -2
  60. package/docs/DECISIONS.md +32 -0
  61. package/docs/ISSUE-WATCH.md +95 -0
  62. package/docs/NORTHSTAR-TEMPLATE.md +21 -0
  63. package/docs/OUTCOME-CODES.md +2 -1
  64. package/docs/TELEMETRY.md +7 -0
  65. package/package.json +1 -2
  66. package/pool/README.md +1 -1
  67. package/pool/image/Dockerfile +2 -1
  68. package/pool/image/cs-relay +31 -0
  69. package/pool/image/entrypoint.sh +10 -0
  70. package/pool/install/install-schedd.sh +22 -0
  71. package/pool/install/unit-hardening.sh +6 -0
  72. package/scripts/postdeploy-rollup-link.sh +40 -0
  73. package/scripts/postdeploy-rollup-link.test.sh +46 -0
  74. package/scripts/refresh-bot-token.sh +122 -0
  75. package/scripts/review-delta.sh +14 -4
  76. package/scripts/review-delta.test.sh +21 -0
  77. package/scripts/review-finalize.sh +69 -25
  78. package/scripts/review-finalize.test.sh +32 -0
  79. package/scripts/review-pr-arguments.test.sh +10 -0
  80. package/scripts/review-pr-changed-verification.test.sh +54 -0
  81. package/scripts/review-pr-delta-wiring.test.sh +1 -1
@@ -0,0 +1,280 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
6
+ import { tmpDir } from "../tests/_tmp.mjs";
7
+ import { join } from "node:path";
8
+ import { unclaimedOrOwn, parseWatches, validateWatch, loadWatches, matchesSelectors, issueVersion, hasUnresolvedDefer, classifyIssue, laneFor, dagOrder, claimIssue, acquireLock, tick, runWatch, formatStatus, repoDir, watchDir, issueWatchFromCli } from "./issuewatch.mjs";
9
+ import { openBatch } from "./batch.mjs";
10
+
11
+ const tmp = () => tmpDir("iw-");
12
+ const iss = (n, o = {}) => ({ number: n, title: `t${n}`, body: "", labels: [], milestone: null, authorAssociation: "OWNER", author: { login: "me" }, createdAt: "2026-10-01T00:00:00Z", ...o });
13
+ const lbl = (...n) => n.map((name) => ({ name }));
14
+ const W = (o = {}) => validateWatch("w", o);
15
+
16
+ /** gh fake: `issues` open list, `closed` set of closed numbers, `bodies` for view/edit. */
17
+ function fakeIo(env, issues, { closed = new Set(), exit = () => 0, bodies = {} } = {}) {
18
+ const calls = [], launched = [], batchOps = [];
19
+ let clock = Date.parse("2026-10-30T00:00:00Z"), nb = 0;
20
+ const io = { env: { FORGEDOCK_WATCH_DIR: env }, calls, launched, batchOps, closed, bodies, tickClock: (ms) => { clock += ms; }, now: () => clock, sleep: async () => {},
21
+ gh: async (a) => {
22
+ calls.push(a);
23
+ if (a[1] === "list") return JSON.stringify(issues.filter((i) => !closed.has(i.number)));
24
+ if (a[1] === "view" && a.includes("state")) return JSON.stringify({ state: closed.has(+a[2]) ? "CLOSED" : "OPEN" });
25
+ if (a[1] === "view") return JSON.stringify({ body: bodies[a[2]] ?? "" });
26
+ if (a[1] === "edit") { bodies[a[2]] = a[a.indexOf("--body") + 1]; return ""; }
27
+ return "";
28
+ },
29
+ launch: (n, lane) => { launched.push([n, lane]); return { poll: () => ({ code: exit(n) }) }; },
30
+ batch: { add: (id, nums) => batchOps.push(["add", id, nums]), start: async (nums, k, lane) => { batchOps.push(["start", nums, k, lane]); return `b${++nb}`; }, reported: (id) => io.reported?.has(id) ?? false } };
31
+ return io;
32
+ }
33
+
34
+ test("parseWatches reads three nesting levels, lists, quotes and ISO times", () => {
35
+ const w = loadWatches(`project:\n name: x\nissue_watch:\n watches:\n nomilestone:\n milestone: none\n exclude_labels: [epic, post-deploy]\n include_followups: defer-only\n mode: batch\n parallel: 12\n release-1-4:\n version: "1.4"\n max_issues: 300\n until: 2026-11-15T00:00:00Z\nother:\n a: b\n`);
36
+ assert.deepEqual(Object.keys(w), ["nomilestone", "release-1-4"]);
37
+ assert.deepEqual(w.nomilestone.exclude_labels, ["epic", "post-deploy"]);
38
+ assert.equal(w.nomilestone.parallel, 12);
39
+ assert.equal(w["release-1-4"].max_issues, 300);
40
+ assert.equal(w["release-1-4"].until, "2026-11-15T00:00:00Z");
41
+ assert.equal(w["release-1-4"].version, "1.4");
42
+ assert.deepEqual(parseWatches("project:\n a: b\n"), {});
43
+ });
44
+
45
+ test("validateWatch rejects unknown keys, bad mode, regex, date, numbers", () => {
46
+ assert.throws(() => W({ bogus: 1 }), /unknown key/);
47
+ assert.throws(() => W({ mode: "x" }), /mode/);
48
+ assert.throws(() => W({ title_regex: "(" }), /title_regex/);
49
+ assert.throws(() => W({ until: "soon" }), /until/);
50
+ assert.throws(() => W({ max_issues: "0" }), /max_issues/);
51
+ assert.throws(() => W({ version: "1" }), /version/);
52
+ assert.equal(W().mode, "per-issue");
53
+ assert.equal(W().max_issues, null);
54
+ });
55
+
56
+ test("selectors: milestone slug / none, labels, exclude_labels, title_regex, since", () => {
57
+ const m = iss(1, { milestone: { title: "Release 1.4" } });
58
+ assert.ok(matchesSelectors(m, W({ milestone: "release-1.4" }))); // slug form
59
+ assert.ok(!matchesSelectors(m, W({ milestone: "other" })));
60
+ assert.ok(!matchesSelectors(m, W({ milestone: "none" })));
61
+ assert.ok(matchesSelectors(iss(2), W({ milestone: "none" })));
62
+ assert.ok(!matchesSelectors(iss(2), W({ milestone: "some" })));
63
+ assert.ok(matchesSelectors(iss(3, { labels: lbl("a", "b") }), W({ labels: ["a"] })));
64
+ assert.ok(!matchesSelectors(iss(3, { labels: lbl("a") }), W({ labels: ["a", "b"] })));
65
+ assert.ok(!matchesSelectors(iss(4, { labels: lbl("epic") }), W({ exclude_labels: ["epic"] })));
66
+ assert.ok(matchesSelectors(iss(4, { labels: lbl("x") }), W({ exclude_labels: ["epic"] })));
67
+ assert.ok(matchesSelectors(iss(5, { title: "fix: a" }), W({ title_regex: "^fix:" })));
68
+ assert.ok(!matchesSelectors(iss(5, { title: "feat: a" }), W({ title_regex: "^fix:" })));
69
+ assert.ok(matchesSelectors(iss(6), W({ since: "2026-09-01T00:00:00Z" })));
70
+ assert.ok(!matchesSelectors(iss(6), W({ since: "2026-10-02T00:00:00Z" })));
71
+ });
72
+
73
+ test("version: from the milestone title, from a version:x.y label, title wins", () => {
74
+ assert.equal(issueVersion(iss(1, { milestone: { title: "v1.4 hardening" } })), "1.4");
75
+ assert.equal(issueVersion(iss(2, { labels: lbl("version:1.5") })), "1.5");
76
+ assert.equal(issueVersion(iss(3, { milestone: { title: "Release 1.4" }, labels: lbl("version:1.5") })), "1.4");
77
+ assert.equal(issueVersion(iss(4)), null);
78
+ const w = W({ version: "1.4" });
79
+ assert.ok(matchesSelectors(iss(1, { milestone: { title: "Release 1.4" } }), w));
80
+ assert.ok(matchesSelectors(iss(2, { labels: lbl("version:1.4") }), w));
81
+ assert.ok(!matchesSelectors(iss(3, { labels: lbl("version:1.5") }), w));
82
+ });
83
+
84
+ test("search is passed to gh", async () => {
85
+ const dir = tmp(), io = fakeIo(dir, [iss(1)]);
86
+ await tick({ watch: W({ search: "label:bug sort:created-asc" }), repo: "o/r", io, state: { claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, phase: "running" } });
87
+ const list = io.calls.find((a) => a[1] === "list");
88
+ assert.equal(list[list.indexOf("--search") + 1], "label:bug sort:created-asc");
89
+ });
90
+
91
+ test("lane: milestone → pattern, else staging, pin allowed, default branch refused", () => {
92
+ const b = { staging: "staging", featurePattern: "milestone/{slug}" };
93
+ assert.equal(laneFor(iss(1, { milestone: { title: "Release 1.4" } }), W(), { branches: b, defaults: ["main"] }), "milestone/release-14");
94
+ assert.equal(laneFor(iss(2), W(), { branches: b, defaults: ["main"] }), "staging");
95
+ assert.equal(laneFor(iss(2), W({ lane: "dev" }), { branches: b, defaults: ["main"] }), "dev");
96
+ assert.throws(() => laneFor(iss(2), W({ lane: "main" }), { branches: b, defaults: ["main"] }), /default branch/);
97
+ });
98
+
99
+ test("follow-ups: defer-only claims bot follow-ups with an unchecked defer item; plain bot issues and post-deploy-only excluded", () => {
100
+ const bot = (body, title = "follow-up: 1 deferred item(s)") => iss(9, { title, body, authorAssociation: "CONTRIBUTOR", author: { login: "forge[bot]" } });
101
+ const defer = "- [ ] `R1: x` — defer: separate work\n";
102
+ const pd = "- [ ] `R2: y` — post-deploy: check later\n";
103
+ assert.ok(hasUnresolvedDefer(defer));
104
+ assert.ok(!hasUnresolvedDefer("- [x] `R1: x` — defer: done\n"));
105
+ assert.ok(!hasUnresolvedDefer(pd));
106
+ assert.equal(classifyIssue(bot(defer), W({ include_followups: "defer-only" })), "claim");
107
+ assert.equal(classifyIssue(bot(defer + pd), W({ include_followups: "defer-only" })), "claim");
108
+ assert.equal(classifyIssue(bot(defer), W()), null);
109
+ assert.equal(classifyIssue(bot(defer, "chore: bot issue"), W({ include_followups: "defer-only" })), null);
110
+ assert.equal(classifyIssue(bot(pd), W({ include_followups: "defer-only" })), null);
111
+ assert.equal(classifyIssue(bot(pd), W({ include_followups: "defer-only", postdeploy_rollup: "50" })), "fold");
112
+ assert.equal(classifyIssue(iss(1, { authorAssociation: "NONE" }), W()), null);
113
+ // release #4820 AUTH-1: a FORGE:DEFERRED marker in the body never makes an outsider a bot
114
+ const spoof = iss(11, { title: "follow-up: 1 deferred item(s)", body: `<!-- FORGE:DEFERRED_FROM: #1 -->\n${defer}`, authorAssociation: "NONE", author: { login: "mallory" } });
115
+ assert.equal(classifyIssue(spoof, W({ include_followups: "defer-only" })), null);
116
+ assert.equal(classifyIssue({ ...spoof, body: `<!-- FORGE:DEFERRED_FROM: #1 -->\n${pd}` }, W({ include_followups: "defer-only", postdeploy_rollup: "50" })), null);
117
+ // the App as gh reports it (bare login, CONTRIBUTOR) still counts as the bot
118
+ const app = iss(12, { title: "follow-up: 1 deferred item(s)", body: defer, authorAssociation: "CONTRIBUTOR", author: { login: "rapiercraft-forgedock" } });
119
+ assert.equal(classifyIssue(app, W({ include_followups: "defer-only" })), "claim");
120
+ });
121
+
122
+ test("postdeploy_rollup folds a post-deploy-only follow-up into the release checklist once", async () => {
123
+ const dir = tmp();
124
+ const f = iss(9, { title: "follow-up: 1 deferred item(s)", body: "- [ ] `R2: y` — post-deploy: check\n", authorAssociation: "CONTRIBUTOR", author: { login: "forge[bot]" } });
125
+ const io = fakeIo(dir, [f], { bodies: { 50: "## Release\n- [ ] ship\n" } });
126
+ const watch = W({ include_followups: "defer-only", postdeploy_rollup: "50" });
127
+ const state = { claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, phase: "running" };
128
+ await tick({ watch, repo: "o/r", io, state });
129
+ await tick({ watch, repo: "o/r", io, state });
130
+ assert.match(io.bodies[50], /- \[ \] #9 follow-up: 1 deferred item\(s\) — post-deploy/);
131
+ assert.equal(io.bodies[50].match(/#9\b/g).length, 1);
132
+ assert.deepEqual(io.launched, []);
133
+ assert.deepEqual(state.claimed, []);
134
+ });
135
+
136
+ test("two named watches on one repo never double-claim", async () => {
137
+ const dir = tmp();
138
+ const issues = [1, 2, 3, 4].map((n) => iss(n));
139
+ const mk = () => ({ claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, phase: "running" });
140
+ const a = { watch: validateWatch("a", { max_inflight: "3" }), io: fakeIo(dir, issues), state: mk() };
141
+ const b = { watch: validateWatch("b", { max_inflight: "3" }), io: fakeIo(dir, issues), state: mk() };
142
+ await Promise.all([a, b].map((x) => tick({ ...x, repo: "o/r", handles: new Map() })));
143
+ await Promise.all([a, b].map((x) => tick({ ...x, repo: "o/r", handles: new Map() })));
144
+ const all = [...a.state.claimed, ...b.state.claimed];
145
+ assert.equal(new Set(all).size, all.length);
146
+ assert.deepEqual([...all].sort(), [1, 2, 3, 4]);
147
+ assert.equal(claimIssue(repoDir("o/r", { FORGEDOCK_WATCH_DIR: dir }), 1, "c"), false);
148
+ });
149
+
150
+ test("release #4820 BUG-1: a throwing batch.start loses no claim; the same watch re-claims on the next pass", async () => {
151
+ const dir = tmp();
152
+ const mk = () => ({ claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, phase: "running" });
153
+ const watch = validateWatch("w", { mode: "batch", max_inflight: "3" });
154
+ const io = fakeIo(dir, [iss(1), iss(2)]);
155
+ const start = io.batch.start;
156
+ io.batch.start = async () => { throw new Error("schedd unreachable"); };
157
+ await assert.rejects(tick({ watch, repo: "o/r", io, state: mk() }), /schedd unreachable/); // status.json never saved
158
+ io.batch.start = start;
159
+ const state = await tick({ watch, repo: "o/r", io, state: mk() }); // restart from the last saved (empty) state
160
+ assert.deepEqual([...state.claimed].sort(), [1, 2]);
161
+ assert.deepEqual(io.batchOps, [["start", [1, 2], watch.parallel, "staging"]]);
162
+ assert.equal(claimIssue(repoDir("o/r", { FORGEDOCK_WATCH_DIR: dir }), 1, "other"), false, "another watch still cannot take it");
163
+ });
164
+
165
+ test("max_issues stops claiming at N, drains, then finishes", async () => {
166
+ const dir = tmp();
167
+ const io = fakeIo(dir, [1, 2, 3, 4, 5].map((n) => iss(n)), { exit: () => 0 });
168
+ const watch = W({ max_issues: "3", max_inflight: "2" });
169
+ const state = await runWatch({ watch, repo: "o/r", io, dir: watchDir("o/r", "w", { FORGEDOCK_WATCH_DIR: dir }), once: false, intervalMs: 1 });
170
+ assert.equal(state.claimed.length, 3);
171
+ assert.equal(state.phase, "done");
172
+ assert.equal(io.launched.length, 3);
173
+ assert.match(formatStatus(state, watch, io.now()), /claims: 3\/3/);
174
+ });
175
+
176
+ test("max_issues drain keeps retrying: a deferred (75) lane relaunches until it merges", async () => {
177
+ const dir = tmp();
178
+ let n75 = 1;
179
+ const io = fakeIo(dir, [iss(1), iss(2)], { exit: () => (n75-- > 0 ? 75 : 0) });
180
+ const state = await runWatch({ watch: W({ max_issues: "1" }), repo: "o/r", io, dir: watchDir("o/r", "w", { FORGEDOCK_WATCH_DIR: dir }), intervalMs: 1 });
181
+ assert.equal(io.launched.length, 2); // launch + one retry
182
+ assert.deepEqual(state.merged, [1]);
183
+ assert.equal(state.claimed.length, 1);
184
+ });
185
+
186
+ test("until stops claiming and drains; a failed lane is counted", async () => {
187
+ const dir = tmp();
188
+ const io = fakeIo(dir, [iss(1), iss(2)], { exit: () => 1 });
189
+ const watch = W({ until: "2026-10-30T00:10:00Z", max_inflight: "1" });
190
+ const state = { claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, phase: "running" };
191
+ const handles = new Map();
192
+ await tick({ watch, repo: "o/r", io, state, handles });
193
+ assert.deepEqual(state.inflight, [1]);
194
+ io.tickClock(20 * 60 * 1000);
195
+ await tick({ watch, repo: "o/r", io, state, handles });
196
+ assert.deepEqual(state.failed, [1]);
197
+ assert.equal(state.phase, "done");
198
+ assert.deepEqual(state.claimed, [1]); // nothing claimed after until
199
+ });
200
+
201
+ test("no cap: keeps claiming across passes (forever)", async () => {
202
+ const dir = tmp();
203
+ const io = fakeIo(dir, [iss(1)], { exit: () => 0 });
204
+ const watch = W();
205
+ const state = { claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, phase: "running" };
206
+ const handles = new Map();
207
+ for (let i = 0; i < 3; i++) await tick({ watch, repo: "o/r", io, state, handles });
208
+ io.closed.add(1);
209
+ await tick({ watch, repo: "o/r", io, state, handles });
210
+ assert.equal(state.phase, "running");
211
+ assert.equal(watch.max_issues, null);
212
+ });
213
+
214
+ test("lock is per (repo, name): a live holder blocks, another name does not, a dead holder is taken over", () => {
215
+ const d = tmp(), a = join(d, "a"), b = join(d, "b");
216
+ mkdirSync(a); mkdirSync(b);
217
+ acquireLock(a, { pid: 111, host: "h", alive: () => true });
218
+ assert.throws(() => acquireLock(a, { pid: 222, host: "h", alive: () => true }), /already running/);
219
+ acquireLock(b, { pid: 222, host: "h", alive: () => true });
220
+ acquireLock(a, { pid: 222, host: "h", alive: () => false });
221
+ });
222
+
223
+ test("batch mode adds to one batch, rolls to a new one after the report, and orders by Depends on", async () => {
224
+ const dir = tmp();
225
+ const issues = [iss(1, { body: "Depends on #2" }), iss(2), iss(3)];
226
+ const io = fakeIo(dir, issues);
227
+ const watch = W({ mode: "batch", parallel: "7", max_inflight: "2" });
228
+ const state = { claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, phase: "running", batch: null };
229
+ await tick({ watch, repo: "o/r", io, state });
230
+ assert.deepEqual(io.batchOps[0], ["start", [2, 1], 7, "staging"]); // dependency first
231
+ io.closed.add(2); io.closed.add(1);
232
+ await tick({ watch, repo: "o/r", io, state });
233
+ assert.deepEqual(io.batchOps[1], ["add", "b1", [3]]);
234
+ io.reported = new Set(["b1"]);
235
+ io.closed.add(3);
236
+ await tick({ watch, repo: "o/r", io, state });
237
+ assert.equal(state.batch, null);
238
+ assert.deepEqual(state.verified.sort(), [1, 2, 3]);
239
+ io.reported = new Set();
240
+ issues.push(iss(4));
241
+ await tick({ watch, repo: "o/r", io, state });
242
+ assert.deepEqual(io.batchOps[2], ["start", [4], 7, "staging"]); // a new batch
243
+ assert.deepEqual(dagOrder([iss(1, { body: "Depends on: #2, #3" }), iss(2), iss(3)]).map((i) => i.number), [2, 3, 1]);
244
+ });
245
+
246
+ test("batch mode with the real batch file: addIssues lands in the batch's add file", async () => {
247
+ const root = tmp();
248
+ const b = openBatch(root, "b1"); mkdirSync(b.dir, { recursive: true });
249
+ b.writePlan({ issues: [1] }); b.store.save({ order: [1] });
250
+ const io = fakeIo(tmp(), [iss(5)]);
251
+ io.batch.add = (id, nums) => openBatch(root, id).addIssues(nums);
252
+ const state = { claimed: [], inflight: [], merged: [], verified: [], failed: [], folded: [], retries: {}, lanes: {}, phase: "running", batch: "b1" };
253
+ await tick({ watch: W({ mode: "batch" }), repo: "o/r", io, state });
254
+ assert.deepEqual(b.readAdded(), [5]);
255
+ });
256
+
257
+ test("status prints selector, claims, inflight, merged, verified, failed, time remaining; CLI status/run", async () => {
258
+ const dir = tmp(), cwd = tmp();
259
+ writeFileSync(join(cwd, "forge.yaml"), "branches:\n default: main\nissue_watch:\n watches:\n r:\n version: \"1.4\"\n max_issues: 5\n until: 2026-10-30T02:00:00Z\n");
260
+ const io = fakeIo(dir, [iss(1, { labels: lbl("version:1.4") })], { exit: () => 0 });
261
+ io.gh = ((orig) => async (a) => (a[0] === "repo" ? (a.includes("nameWithOwner") ? JSON.stringify({ nameWithOwner: "o/r" }) : "main") : orig(a)))(io.gh);
262
+ const out = [];
263
+ await issueWatchFromCli(["run", "--name", "r", "--once"], { cwd, env: { FORGEDOCK_WATCH_DIR: dir }, io, print: (l) => out.push(l) });
264
+ assert.ok(existsSync(join(watchDir("o/r", "r", { FORGEDOCK_WATCH_DIR: dir }), "status.json")));
265
+ out.length = 0;
266
+ await issueWatchFromCli(["status", "--name", "r"], { cwd, env: { FORGEDOCK_WATCH_DIR: dir }, io, print: (l) => out.push(l) });
267
+ const t = out.join("\n");
268
+ assert.match(t, /selector: version=1\.4/);
269
+ assert.match(t, /claims: 1\/5\s+inflight: 0\s+merged: 1\s+verified: 0|claims: 1\/5\s+inflight: 1/);
270
+ assert.match(t, /time remaining: 120m/);
271
+ await assert.rejects(issueWatchFromCli(["bogus"], { cwd, env: { FORGEDOCK_WATCH_DIR: dir }, io }), /usage/);
272
+ });
273
+
274
+ test("release #4820 CONC-1: release mode keeps this watch's own registry claims eligible; others' stay excluded", () => {
275
+ const reg = { 1: "r", 2: "other" };
276
+ assert.equal(unclaimedOrOwn(reg, 1, "r"), true, "own claim left by a crash is re-eligible");
277
+ assert.equal(unclaimedOrOwn(reg, 2, "r"), false, "another watch's claim is not");
278
+ assert.equal(unclaimedOrOwn(reg, 3, "r"), true);
279
+ assert.equal(unclaimedOrOwn(null, 3, "r"), true);
280
+ });
package/bin/lean/lane.mjs CHANGED
@@ -47,15 +47,16 @@ export async function defaultBranches({ repo, io, branches = {} }) {
47
47
  */
48
48
  export async function resolveLane({ issue, repo, io, cwd, branches = {}, requested = null, override = false }) {
49
49
  const refuse = (code, detail) => ({ ok: false, code, detail });
50
+ const transient = (detail) => ({ ...refuse(OUTCOME_CODES.LANE_UNRESOLVED, detail), transient: true }); // #4791: a gh/git command failed; retry
50
51
  const staging = branches.staging || DEFAULT_STAGING_BRANCH;
51
52
  const pattern = branches.featurePattern || DEFAULT_FEATURE_PATTERN;
52
53
  if (!pattern.includes("{slug}")) return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `forge.yaml branches.feature_pattern "${pattern}" has no {slug}`);
53
54
  let milestone = null, defaults;
54
55
  try {
55
56
  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
+ } catch (e) { return transient(`cannot read the milestone of #${issue}: ${String(e?.message ?? e).slice(0, 200)}`); }
57
58
  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
+ return transient(`cannot read the default branch of ${repo}: ${String(e?.message ?? e).slice(0, 200)}`);
59
60
  }
60
61
  let expected = staging;
61
62
  if (milestone) {
@@ -78,7 +79,7 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
78
79
  if (!LANE_RE.test(lane) || lane.includes("..")) return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `invalid lane ${JSON.stringify(lane)}`);
79
80
  let out;
80
81
  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
+ return transient(`cannot check origin for ${lane}: ${String(e?.message ?? e).slice(0, 200)}`);
82
83
  }
83
84
  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
85
  return { ok: true, lane, expected, milestone, overridden, defaults };
@@ -88,4 +88,16 @@ describe("lane resolution (#4125)", () => {
88
88
  assert.deepEqual(readBranches(d), { default: "main", staging: "develop", featurePattern: "milestone/{slug}" });
89
89
  assert.deepEqual(readBranches(tmpDir("lane-")), { default: null, staging: null, featurePattern: null });
90
90
  });
91
+
92
+ it("#4791 a failed gh/git command is transient; a genuine refusal is not", async () => {
93
+ for (const o of [{ ghFails: "issue" }, { ghFails: "repo" }, { gitFails: true }]) {
94
+ const r = await resolve(o);
95
+ assert.deepEqual([r.ok, r.code, r.transient], [false, "lane.unresolved", true]);
96
+ }
97
+ const missing = await resolve({ onOrigin: ["main"] });
98
+ assert.equal(missing.ok, false);
99
+ assert.ok(!missing.transient);
100
+ const slug = await resolve({ milestone: "🚀" });
101
+ assert.deepEqual([slug.code, !!slug.transient], ["lane.unresolved", false]);
102
+ });
91
103
  });
package/bin/lean/lean.mjs CHANGED
@@ -13,10 +13,11 @@ import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mj
13
13
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
14
14
  import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
15
15
  import { createHash } from "node:crypto";
16
- import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
16
+ import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
17
17
  import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
18
18
  import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
19
19
  import { checksAt } from "./postdeploy-check.mjs";
20
+ import { readRollouts, detectNewOffKeys } from "./activation.mjs";
20
21
  import { preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
21
22
  import { resolveLane, isDefaultLane, isShippedLane, DEFAULT_STAGING_BRANCH } from "./lane.mjs";
22
23
  import { splitGate, rationaleLine } from "./decompose.mjs";
@@ -30,12 +31,12 @@ import { retrying, ghCacheRetry, refreshAuthOnce, runRefreshScript, GH_AUTH_RE,
30
31
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
31
32
  import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
32
33
  import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
33
- import { hostCapabilityBlock } from "./execution.mjs";
34
+ import { hostCapabilityBlock, contractHostNeeds } from "./execution.mjs";
34
35
 
35
36
  const MISSING_TOOL_RE = /command not found|not found in PATH|no such file or directory.*\b(?:docker|promtool)\b|\b(?:docker|promtool)\b[^\n]{0,80}(?:not (?:installed|found|available)|no such file|cannot connect|daemon)|cannot connect to the docker daemon|no docker daemon|no module named|ModuleNotFoundError|\bENOENT\b[^\n]{0,60}spawn/i;
36
37
  /** #4685: true when a builder's block summary names a missing tool or module (environment), not a credential. */
37
38
  export function missingToolBlock(summary) { return MISSING_TOOL_RE.test(String(summary ?? "")); }
38
- import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
39
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, sequenceSyncPrompt, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
39
40
  import { postMergeCommands } from "./postmerge.mjs";
40
41
  import { guardAfterMerge, withHistoryRows } from "./guards.mjs";
41
42
  import { loadGuards, selectGuards, runGuards, supersededIds, supersessionComment } from "./guardgate.mjs";
@@ -58,8 +59,9 @@ import { readDecisionIndex, withDecisionRows, recordDecisions } from "./decision
58
59
  import { parseContextPackConfig, contextArm, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
59
60
  import { isExternalKill } from "./hostkill.mjs";
60
61
  import { buildResumeDigest } from "./resume.mjs";
62
+ import { parsePredicate, evalPredicate, premiseAnnotation, registerAsk, registryPkgVersion, closedEdgeNote, onlyAsk, fileRemainder } from "./needs-human-premise.mjs";
61
63
  import { lightEligibility } from "./light.mjs";
62
- import { untrackToolState, unstageToolState } from "./toolstate.mjs";
64
+ import { untrackToolState, unstageToolState, isToolState } from "./toolstate.mjs";
63
65
  import { ensureSignedOff } from "./signoff.mjs";
64
66
  import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
65
67
 
@@ -71,14 +73,15 @@ export const RECOVER_HOW = Object.freeze({
71
73
  [OUTCOME_CODES.CI_RED]: "the PR's required checks failed. Read the failed checks' logs (`gh run view <run-id> --log-failed`, or the repo's declared check commands), fix the causes, re-run those checks locally and push new commits only (never rewrite history)",
72
74
  [OUTCOME_CODES.BASE_CONFLICT]: "the PR conflicts with origin/BASE. Merge origin/BASE, resolve every conflict keeping both sides' intent, re-run the validate commands and push new commits only",
73
75
  [OUTCOME_CODES.BUILDER_BLOCK_UNTYPED]: "an earlier run escalated an in-scope technical choice. Do not block on it: pick the option most consistent with the issue, the repo's conventions and prior merged decisions, and name the alternative you did not take in your summary",
74
- [OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
76
+ [OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned, or its requirement quotes did not match the issue text. Plan against the current issue text and quote each requirement verbatim from it",
75
77
  [OUTCOME_CODES.BUDGET_EXCEEDED]: "an earlier run spent the implement turn budget for this issue's size; its digest is in the earlier run's detail and the branch holds its checkpointed work. Continue from the checkpoint against the frozen contract without starting over; if the remaining work cannot fit the budget, stop with a typed split-required block naming the sub-issues instead of grinding",
78
+ [OUTCOME_CODES.CONTRACT_INVALID_COMMAND]: "a frozen validate command was rejected by its runner's CLI (vitest 3 rejects -q). The change may already be correct: rerun the validate with the runner's own flags (vitest: --reporter=dot) and amend the command, do not rewrite the deliverables",
76
79
  [OUTCOME_CODES.ENV_TOOL_MISSING]: "a validate command could not run because the host lacks the tool or module named in the earlier run's detail; the change may already be correct. Do not edit the deliverables for it and never install anything globally: run the same check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts), record that exit under the same validate id and name the command you used in your summary",
77
80
  [OUTCOME_CODES.ENV_CAPABILITY_REMOTE]: "the earlier run ran in a remote sandbox that lacks a host-only capability (SOPS/age key, ops credentials, read-only prod monitor SSH); this host has it. Do the work that needed it now, with the host's own tools and credentials, and never print or commit a secret in the clear",
78
81
  [OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
79
82
  });
80
83
  /** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
81
- const ENV_CODES = Object.freeze([OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]);
84
+ const ENV_CODES = Object.freeze([OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.CONTRACT_INVALID_COMMAND]);
82
85
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
83
86
  /** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
84
87
  export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
@@ -149,7 +152,8 @@ export function classifyBlock(result, issue) {
149
152
  return { kind: "split", rationale: gate.rationale, subissues, serialized: serializeShared(subissues) };
150
153
  }
151
154
  if (b.kind === "human") {
152
- return HUMAN_BLOCK_REASONS.includes(b.why) ? { kind: "human", why: b.why }
155
+ const remainder = (Array.isArray(b.remainder) ? b.remainder : []).filter((x) => x && typeof x.title === "string" && x.title.trim() && typeof x.body === "string").slice(0, MAX_SUBISSUES).map((x) => ({ title: x.title.trim(), body: x.body })); // #4730
156
+ return HUMAN_BLOCK_REASONS.includes(b.why) ? { kind: "human", why: b.why, ...(remainder.length ? { remainder } : {}) }
153
157
  : { kind: "untyped", problem: `"${String(b.why ?? "").slice(0, 80)}" is not a human-only reason (${HUMAN_BLOCK_REASONS.join(", ")})` };
154
158
  }
155
159
  return { kind: "untyped", problem: `block kind "${String(b.kind ?? "").slice(0, 40)}" is unknown` };
@@ -198,6 +202,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
198
202
  * @param {string} [o.laneInstructions] forge.yaml engine.lane_instructions (#4395): repo-relative file appended to fresh sessions instead of CLAUDE.md
199
203
  * @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
200
204
  * @param {object} [o.hostCaps] {hostCapabilities, hostCapabilityPatterns}: forge.yaml execution.host_capabilities (#4542); a remote credentials block naming one is env.capability_remote
205
+ * @param {string[]} [o.relayCaps] capabilities the dispatching host relays to this sandbox (#4778, env CS_RELAY_CAPS): no env.capability_remote end before building for them
201
206
  * @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
202
207
  * @param {object} [o.explore] {mode: on|off, model}: forge.yaml lean.explore / lean.explore_model (#4391, default off): the explorer subagent
203
208
  * @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
@@ -480,9 +485,34 @@ export async function runCore(o) {
480
485
  }
481
486
  /** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
482
487
  const laneRecoveryEvidence = (reason, code, detail) => recoveryEvidence({ code: recover.code, attempt: recover.attempt, outcome: reason, resultCode: code, detail });
483
- async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
488
+ let closedDeps = []; // #4725: `Depends on` edges the live check found closed: satisfied, flagged in the needs-human comment
489
+ /** #4730: an issue whose only content is an ask already registered on #existing is closed as a duplicate, linked; any extra content keeps it open. */
490
+ async function duplicateAskIssue(existing, asks) {
491
+ try {
492
+ const v = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "title,body"])));
493
+ if (!asks.some((a) => a && onlyAsk(v.title, v.body, a))) return false;
494
+ await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned", "--comment", `Duplicate owner ask: its only content is the ask already recorded on #${existing}. Answering #${existing} covers this issue.`]);
495
+ await label("invalid");
496
+ T.decisions.push(`Closed as a duplicate of the owner ask on #${existing}`);
497
+ return true;
498
+ } catch { return false; }
499
+ }
500
+ async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, { ask: askText, note: askNote = "", ...extra } = {}) {
501
+ // #4725: a recorded premise (`Premise: <kind>:<arg>`) that already holds is no blocker: no label, the lane runs again.
502
+ const predicate = parsePredicate(why);
503
+ if (predicate && (!recover || recover.final) && await evalPredicate(predicate, { repo, io: { gh: ghc, pkgVersion: io.pkgVersion ?? registryPkgVersion } }) === true) {
504
+ try { await post("FORGE:DECISION", `## Decision: no needs-human\n**What**: the recorded premise \`${predicate.kind}:${predicate.arg}\` already holds, so the label was not applied.\n**Why**: the blocker named an external fact that has since changed.\n**Alternative**: label needs-human and wait for the sweep to clear it.`); } catch { /* best effort */ }
505
+ return finish("deferred", `premise ${predicate.kind}:${predicate.arg} holds; re-queued instead of needs-human`, { retryInSeconds: 30 });
506
+ }
484
507
  if (!recover || recover.final) try {
485
- await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
508
+ let link = "";
509
+ if (code === OUTCOME_CODES.BUILDER_BLOCKED) { // #4725: one owner ask, linked across issues
510
+ const reg = await registerAsk({ repo, io: { gh: ghc }, issue, ask: why });
511
+ if (reg.existing && (!recover || recover.final) && await duplicateAskIssue(reg.existing, [why, askText])) return finish("invalid", `only content is the owner ask of #${reg.existing}; closed as a duplicate`); // #4730
512
+ link = reg.existing ? `Same owner ask as #${reg.existing} (one ask, linked): answering it unblocks this issue too.` : reg.entry ?? "";
513
+ }
514
+ const notes = [premiseAnnotation(predicate), closedEdgeNote(closedDeps), link, askNote].filter(Boolean).join("\n");
515
+ await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${notes ? `\n\n${notes}` : ""}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
486
516
  await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
487
517
  } catch { /* best effort */ }
488
518
  return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
@@ -812,7 +842,7 @@ export async function runCore(o) {
812
842
  // #4153 / #4337: a decomposed parent, open or closed, resolves to its open sub-issues; it is satisfied once they all
813
843
  // close. Without a usable trail the parent itself is the dependency, even when closed (fail closed).
814
844
  const decomposed = isDecomposedRecord(v) && ["OPEN", "CLOSED"].includes(String(v?.state).toUpperCase());
815
- if (!decomposed && String(v?.state).toUpperCase() !== "OPEN") continue;
845
+ if (!decomposed && String(v?.state).toUpperCase() !== "OPEN") { if (String(v?.state).toUpperCase() === "CLOSED" && !closedDeps.includes(d)) closedDeps.push(d); continue; }
816
846
  const subs = decomposed ? await decomposedSubissues(d, io) : null;
817
847
  const recs = [];
818
848
  if (!subs) recs.push([d, { ...v, state: "OPEN" }]);
@@ -846,7 +876,18 @@ export async function runCore(o) {
846
876
  // #4685: a tool the sandbox lacks (docker, promtool, a Python module) is an environment gap the host recovers, never a credential.
847
877
  return await needsHuman(`Remote sandbox lacks a validate tool (Builder is blocked (credentials)): ${s}\n\nNot a credential: the lane is recovered where the tool exists.`, OUTCOME_CODES.ENV_TOOL_MISSING);
848
878
  }
849
- if (b.kind === "human") return await needsHuman(`Builder is blocked (${b.why}): ${s}`, OUTCOME_CODES.BUILDER_BLOCKED);
879
+ if (b.kind === "human") {
880
+ // #4730: the agent-doable remainder is re-queued as its own lane; only the owner step keeps needs-human.
881
+ let note = "";
882
+ if (b.remainder?.length) {
883
+ try {
884
+ const filed = await fileRemainder({ repo, io: { gh: ghc, create: ghw }, issue, items: b.remainder, milestone });
885
+ T.decisions.push(`Builder block: remainder re-queued as ${refs(filed)}; #${issue} keeps only the owner step`);
886
+ note = `The agent-doable remainder was re-queued as ${refs(filed)}; this issue keeps only the owner step.`;
887
+ } catch (e) { return await finish("engine-error", `filing the remainder issues failed: ${e?.message ?? e}`); }
888
+ }
889
+ return await needsHuman(`Builder is blocked (${b.why}): ${s}`, OUTCOME_CODES.BUILDER_BLOCKED, { ask: s, note });
890
+ }
850
891
  if (b.kind === "unreadable") return await finish("dependency-unreadable", b.detail);
851
892
  if (b.kind === "dependency") {
852
893
  const blocked = b.blockers.length > 0;
@@ -1042,12 +1083,16 @@ export async function runCore(o) {
1042
1083
  }
1043
1084
  };
1044
1085
  if (resumed) { try { await io.git(["fetch", "origin", branch], { cwd }); } catch { /* remote branch may not exist (PR-less resume) */ } }
1086
+ // #4786: an open-PR-less remote branch whose only changes vs the base are tool state (.forge-lean/ scratch) holds no work: plan fresh, never resume its stale contract.
1087
+ const toolOnly = async (ref) => { const n = String(await io.git(["diff", "--name-only", `${base}...${ref}`], { cwd })).split("\n").map((x) => x.trim()).filter(Boolean); return n.every((f) => isToolState(f)); };
1088
+ let scratchOnly = false;
1089
+ if (resumed && !resumed.pr) { try { scratchOnly = await toolOnly(`origin/${branch}`); } catch { /* unreadable: resume as before */ } }
1045
1090
  // #4118: a local lane branch with commits not yet pushed (e.g. a reviewed commit whose push failed) must never be reset.
1046
1091
  // Reuse it when it is ahead of the base and contains everything the remote branch has.
1047
1092
  let keepLocal = false;
1048
1093
  try {
1049
1094
  const ahead = Number(String(await io.git(["rev-list", "--count", `${base}..refs/heads/${branch}`], { cwd })).trim());
1050
- if (ahead > 0) {
1095
+ if (ahead > 0 && !(await toolOnly(`refs/heads/${branch}`).catch(() => false))) { // #4786: a scratch-only local branch is not reused
1051
1096
  let containsRemote = true;
1052
1097
  if (resumed) { try { await io.git(["merge-base", "--is-ancestor", `origin/${branch}`, `refs/heads/${branch}`], { cwd }); } catch { containsRemote = false; } }
1053
1098
  keepLocal = containsRemote;
@@ -1061,6 +1106,13 @@ export async function runCore(o) {
1061
1106
  ev({ event: "LOCAL_BRANCH_REUSED", branch });
1062
1107
  } else if (resumed) {
1063
1108
  wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`], `origin/${branch}`);
1109
+ if (scratchOnly) { // from origin/<branch> so the later plain push stays fast-forward; the scratch goes out in a normal commit
1110
+ await io.git(["rm", "-r", "-f", "-q", "--ignore-unmatch", "--", LEAN_DIR], { cwd: wt }).catch(() => {});
1111
+ if (String(await io.git(["diff", "--cached", "--name-only"], { cwd: wt }).catch(() => "")).trim()) await io.git(["commit", "--no-verify", "-m", "chore(forge): drop stale .forge-lean scratch from the lane branch"], { cwd: wt }).catch(() => {});
1112
+ resumed = null;
1113
+ ev({ event: "SCRATCH_BRANCH_DROPPED", branch });
1114
+ T.decisions.push(`Lane branch ${branch} held only ${LEAN_DIR}/ scratch: its stale checkpoint was dropped and the issue is planned fresh`);
1115
+ }
1064
1116
  } else {
1065
1117
  wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, base], base);
1066
1118
  }
@@ -1140,7 +1192,7 @@ export async function runCore(o) {
1140
1192
  // #4160: files the branch touched (three-dot) whose content still differs from the base tip (two-dot); a file merged
1141
1193
  // in from the base by a base-sync equals the base and drops out of the scope check.
1142
1194
  const changedFiles = async () => {
1143
- const touched = names(await git(["diff", "--name-only", `${base}...HEAD`]));
1195
+ const touched = names(await git(["diff", "--name-only", `${base}...HEAD`])).filter((f) => !isToolState(f)); // #4786
1144
1196
  if (!touched.length) return touched;
1145
1197
  const differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
1146
1198
  return touched.filter((f) => differs.has(f));
@@ -1550,7 +1602,7 @@ export async function runCore(o) {
1550
1602
  }
1551
1603
  }
1552
1604
  await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
1553
- if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID, issueEdited ? { issueEdited: true } : {});
1605
+ if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID, { ...(issueEdited ? { issueEdited: true } : {}), ...(vc.problems.every((x) => /: quote is not verbatim/.test(x)) ? { quoteMismatch: true } : {}) });
1554
1606
  frozen = historyRows(contract); // #4694 guards
1555
1607
  frozen = withDecisionRows(frozen, readDecisionIndex(repo)); // #4695: anchored decisions on the deliverable paths; one index read
1556
1608
  const hRows = (frozen.preserve ?? []).filter((p) => p.source === "decision");
@@ -1566,6 +1618,11 @@ export async function runCore(o) {
1566
1618
  } else {
1567
1619
  T.validate = lightDone ? "light lane (no plan session)" : "resumed (existing branch)";
1568
1620
  }
1621
+ if (o.placement?.where === "remote" && !recover) { // #4785: the frozen validate commands need a tool only the host has
1622
+ const relayed = new Set(o.relayCaps ?? []); // #4778: a relayed capability is served to the sandbox, so the lane builds there
1623
+ const hn = contractHostNeeds(frozen, o.hostCaps && { ...o.hostCaps, hostCapabilities: (o.hostCaps.hostCapabilities ?? []).filter((c) => !relayed.has(c)) });
1624
+ if (hn.caps.length) return await needsHuman(`Remote sandbox lacks host capability ${hn.caps.join(", ")}, which the frozen contract's validate command(s) ${hn.ids.join(", ")} need.\n\nThe dispatching host has it; the lane is recovered locally with the frozen contract.`, OUTCOME_CODES.ENV_CAPABILITY_REMOTE);
1625
+ }
1569
1626
  T.plan = [`contract ${hash}${lightDone ? " (derived, light lane)" : ""}`, `${frozen.requirements.length} requirement(s)`];
1570
1627
  { // #4392, #4505: next size on a budget recovery; #4587: a big L contract and an L recovery get extra turns
1571
1628
  const sz = recoverySize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }), recover);
@@ -1740,7 +1797,21 @@ export async function runCore(o) {
1740
1797
  ev({ event: "SEQUENCE_COLLISION", trigger, files: collisions.map((c) => c.lane) });
1741
1798
  const taken = collisions.filter((c) => c.base.length).map((c) => `${c.lane} vs ${c.base.join(", ")}`).join("; ");
1742
1799
  if (io.sequenceRenumber) {
1743
- const det = (await io.sequenceRenumber({ cwd: wt, base, ...(laneRef ? { laneRef } : {}), regenerate: o.sequenceRegenerate ?? null, reserve: io.sequenceLease?.reserve ? (x) => io.sequenceLease.reserve({ repo, lane, issue, ...x }) : null }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? { unsupported: "no result" };
1800
+ const renumber = async (ref) => (await io.sequenceRenumber({ cwd: wt, base, ...(ref ? { laneRef: ref } : {}), regenerate: o.sequenceRegenerate ?? null, reserve: io.sequenceLease?.reserve ? (x) => io.sequenceLease.reserve({ repo, lane, issue, ...x }) : null, openNumbers: io.sequenceOpenNumbers ? (x) => io.sequenceOpenNumbers({ cwd: wt, lane, ...x }) : null }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? { unsupported: "no result" };
1801
+ let det = await renumber(laneRef);
1802
+ if (det.nonSequence?.length && !laneRef) { // #4732: base-sync first (sequence files take the base side, the builder resolves the rest), then renumber deterministically
1803
+ const pre = String(await git(["rev-parse", "HEAD"])).trim(), left = async () => String(await git(["diff", "--name-only", "--diff-filter=U"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
1804
+ let synced = true;
1805
+ try { await git(["merge", "--no-edit", base]); } catch {
1806
+ const rs = await io.sequenceResolve?.({ cwd: wt, files: await left(), partial: true }).catch(() => null);
1807
+ if (rs?.remaining?.length) {
1808
+ T.decisions.push(`Sequence renumber (${trigger}): base-sync conflicts in ${rs.remaining.join(", ")}; builder resolves, then the engine renumbers`);
1809
+ await fixRound("sequence", sequenceSyncPrompt(base, rs.remaining, vcfg.mode), [], "sequence", `base-sync conflict: ${rs.remaining.join(", ")}`);
1810
+ } else synced = Boolean(rs?.ok);
1811
+ if (!synced || (await left()).length) { try { await git(["merge", "--abort"]); } catch { /* none */ } try { await git(["reset", "-q", "--hard", pre]); } catch { /* best effort */ } synced = false; }
1812
+ }
1813
+ if (synced) { det = await renumber(pre); if (det.ok) ev({ event: "SEQUENCE_SYNC_FIRST", trigger, files: det.moves?.length ?? 0 }); }
1814
+ }
1744
1815
  if (det.ok && det.moves?.length) {
1745
1816
  ev({ event: "SEQUENCE_RENUMBERED", trigger, mode: "deterministic", moves: det.moves });
1746
1817
  T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${taken}; renumbered deterministically (no builder): ${det.moves.map((m) => `${m.from} → ${m.to}`).join(", ")}`);
@@ -2058,6 +2129,13 @@ export async function runCore(o) {
2058
2129
  if (!r || r.exit === undefined || r.exit === 0) continue;
2059
2130
  let x;
2060
2131
  try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: String(v.command), cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
2132
+ // #4761: a runner usage error (vitest -q) is the frozen command's defect: try the normalised command once
2133
+ const fixed = cliUsageError(x ?? {}) ? normalizeRunnerCommand(v.command) : null;
2134
+ if (fixed && fixed !== String(v.command)) {
2135
+ try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: fixed, cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
2136
+ const note = `Validate ${v.id} hit a runner usage error; reran normalised (\`${fixed}\`): exit ${x?.rc}`;
2137
+ if (!T.decisions.includes(note)) T.decisions.push(note);
2138
+ }
2061
2139
  out[v.id] = { rc: Number.isInteger(x?.rc) ? x.rc : 1, output: String(x?.output ?? "") };
2062
2140
  }
2063
2141
  if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
@@ -2081,6 +2159,11 @@ export async function runCore(o) {
2081
2159
  return r.failures;
2082
2160
  }
2083
2161
  /** Precheck then review, on the current result and changed files (also the base-sync re-review). */
2162
+ /** #4769: default-off keys the diff adds to the forge.yaml `rollouts` registry; [] when none is declared. */
2163
+ async function newOffKeysOf() {
2164
+ const cfg = readRollouts(wt);
2165
+ return cfg ? detectNewOffKeys(cfg, String(await git(["diff", "-U0", `${base}...HEAD`, "--", ...cfg.files]).catch(() => ""))) : [];
2166
+ }
2084
2167
  async function gate() {
2085
2168
  await refreshIssue("conformance");
2086
2169
  result = (await readResult()) ?? result;
@@ -2106,7 +2189,7 @@ export async function runCore(o) {
2106
2189
  const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
2107
2190
  // #4626: post-deploy check ids resolve against the lane base's checks file or this PR's
2108
2191
  const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
2109
- const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures });
2192
+ const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures, newOffKeys: await newOffKeysOf() });
2110
2193
  // #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
2111
2194
  if (pc.superseded?.length) {
2112
2195
  ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
@@ -2186,6 +2269,10 @@ export async function runCore(o) {
2186
2269
  T.anomalies.push("Environment error: the repro harness could not run the repro test on head");
2187
2270
  return await needsHuman(`Environment error (not a conformance failure): the repro harness could not run the repro test on head; no builder resume was spent and nothing merged:\n${failures.map((f) => `- ${f.code}: ${f.detail}`).join("\n")}\n\nRun the repro command through the repo's own toolchain and installed dependencies, then rerun.`, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE);
2188
2271
  }
2272
+ if (failures.every((f) => f.code === OUTCOME_CODES.CONTRACT_INVALID_COMMAND)) {
2273
+ T.anomalies.push("Contract defect: a frozen validate command is a runner CLI usage error");
2274
+ return await needsHuman(`Contract defect (not a conformance failure): a frozen validate command is rejected by its runner's CLI, so no builder resume was spent and nothing merged:\n${failures.map((f) => `- ${f.code}: ${f.detail}`).join("\n")}\n\nRecovery re-plans the command (vitest: --reporter=dot, not -q).`, OUTCOME_CODES.CONTRACT_INVALID_COMMAND);
2275
+ }
2189
2276
  const tools = [...new Set(failures.filter((f) => f.tool).map((f) => f.tool))].join(", ");
2190
2277
  T.anomalies.push(`Environment error: validate command(s) could not run, the host is missing ${tools}`);
2191
2278
  return await needsHuman(`Environment error (not a conformance failure): the host is missing ${tools}, so validate command(s) could not run; no builder resume was spent and nothing merged:\n${failures.map((f) => `- ${f.code}: ${f.detail}`).join("\n")}\n\nInstall it on the host or run the check through the repo's own toolchain, then rerun.`, OUTCOME_CODES.ENV_TOOL_MISSING);