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.
- package/bin/engine/outcome-codes.mjs +12 -3
- package/bin/engine/telemetry-seam.mjs +5 -0
- package/bin/engine-cli.mjs +97 -18
- package/bin/forgedock.mjs +13 -2
- package/bin/lean/activation.mjs +76 -0
- package/bin/lean/activation.test.mjs +75 -0
- package/bin/lean/admission.mjs +17 -1
- package/bin/lean/batch.mjs +51 -21
- package/bin/lean/batch.test.mjs +95 -5
- package/bin/lean/conformance.mjs +53 -4
- package/bin/lean/conformance.test.mjs +24 -1
- package/bin/lean/decision-engine.mjs +217 -0
- package/bin/lean/decision-engine.test.mjs +159 -0
- package/bin/lean/effects.mjs +39 -13
- package/bin/lean/execution.mjs +74 -5
- package/bin/lean/execution.test.mjs +64 -1
- package/bin/lean/explore.test.mjs +1 -1
- package/bin/lean/guards.mjs +3 -2
- package/bin/lean/guards.test.mjs +24 -3
- package/bin/lean/heavy.mjs +15 -2
- package/bin/lean/heavy.test.mjs +31 -2
- package/bin/lean/issuewatch-release.mjs +245 -0
- package/bin/lean/issuewatch-release.test.mjs +244 -0
- package/bin/lean/issuewatch.mjs +386 -0
- package/bin/lean/issuewatch.test.mjs +280 -0
- package/bin/lean/lane.mjs +4 -3
- package/bin/lean/lane.test.mjs +12 -0
- package/bin/lean/lean.mjs +103 -16
- package/bin/lean/lean.test.mjs +127 -5
- package/bin/lean/needs-human-premise.mjs +177 -0
- package/bin/lean/needs-human-premise.test.mjs +136 -0
- package/bin/lean/orchestrate-blocked.test.mjs +71 -0
- package/bin/lean/orchestrate.mjs +93 -21
- package/bin/lean/postdeploy-check.mjs +88 -2
- package/bin/lean/postdeploy-check.test.mjs +78 -0
- package/bin/lean/prodwatch-sources.mjs +165 -0
- package/bin/lean/prodwatch-sources.test.mjs +108 -0
- package/bin/lean/prompts.mjs +9 -3
- package/bin/lean/recovery.test.mjs +14 -0
- package/bin/lean/relay.mjs +182 -0
- package/bin/lean/relay.test.mjs +214 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +13 -1
- package/bin/lean/sandbox-remote.mjs +53 -9
- package/bin/lean/sched.test.mjs +30 -0
- package/bin/lean/schedd-health.mjs +69 -0
- package/bin/lean/schedd-health.test.mjs +51 -0
- package/bin/lean/schedd.mjs +29 -12
- package/bin/lean/schedd.test.mjs +40 -4
- package/bin/lean/sequence.mjs +57 -24
- package/bin/lean/sequence.test.mjs +71 -0
- package/bin/pool/agent.mjs +15 -1
- package/bin/pool/router.mjs +43 -5
- package/bin/runner.mjs +18 -1
- package/commands/deploy-pr.md +12 -0
- package/commands/forgedock-init.md +1 -0
- package/commands/orchestrate/phase-4-execution.md +1 -0
- package/commands/orchestrate.md +1 -0
- package/commands/review-pr.md +17 -13
- package/docs/CONFIG.md +73 -2
- package/docs/DECISIONS.md +32 -0
- package/docs/ISSUE-WATCH.md +95 -0
- package/docs/NORTHSTAR-TEMPLATE.md +21 -0
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/TELEMETRY.md +7 -0
- package/package.json +1 -2
- package/pool/README.md +1 -1
- package/pool/image/Dockerfile +2 -1
- package/pool/image/cs-relay +31 -0
- package/pool/image/entrypoint.sh +10 -0
- package/pool/install/install-schedd.sh +22 -0
- package/pool/install/unit-hardening.sh +6 -0
- package/scripts/postdeploy-rollup-link.sh +40 -0
- package/scripts/postdeploy-rollup-link.test.sh +46 -0
- package/scripts/refresh-bot-token.sh +122 -0
- package/scripts/review-delta.sh +14 -4
- package/scripts/review-delta.test.sh +21 -0
- package/scripts/review-finalize.sh +69 -25
- package/scripts/review-finalize.test.sh +32 -0
- package/scripts/review-pr-arguments.test.sh +10 -0
- package/scripts/review-pr-changed-verification.test.sh +54 -0
- 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
|
|
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
|
|
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
|
|
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 };
|
package/bin/lean/lane.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|
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);
|