forgedock 1.48.1 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine-cli.mjs +49 -19
- package/bin/forgedock.mjs +1 -1
- package/bin/lean/batch.mjs +9 -0
- package/bin/lean/batch.test.mjs +174 -2
- package/bin/lean/conformance.mjs +38 -9
- package/bin/lean/conformance.test.mjs +33 -1
- package/bin/lean/effects.mjs +16 -5
- package/bin/lean/execution.mjs +66 -20
- package/bin/lean/execution.test.mjs +133 -1
- package/bin/lean/heavy.mjs +14 -4
- package/bin/lean/heavy.test.mjs +55 -1
- package/bin/lean/lanecheck.mjs +44 -12
- package/bin/lean/lanecheck.test.mjs +23 -0
- package/bin/lean/lean.mjs +87 -24
- package/bin/lean/lean.test.mjs +90 -7
- package/bin/lean/live-execution.mjs +136 -0
- package/bin/lean/live-execution.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +77 -20
- package/bin/lean/prompts.mjs +15 -2
- package/bin/lean/review-trigger.mjs +25 -0
- package/bin/lean/review-trigger.test.mjs +28 -1
- package/bin/lean/sandbox-provider.contract.test.mjs +21 -0
- package/bin/lean/sandbox-remote.mjs +6 -2
- package/bin/lean/schedd.mjs +20 -3
- package/bin/lean/schedd.test.mjs +20 -3
- package/commands/orchestrate.md +2 -1
- package/docs/CONFIG.md +3 -1
- package/package.json +1 -1
- package/scripts/plan-validate-live.mjs +24 -7
package/bin/engine-cli.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { readExperiment } from "./experiment.mjs";
|
|
|
24
24
|
import { readBranches, resolveLane, isDefaultLane, makeLaneCache } from "./lean/lane.mjs";
|
|
25
25
|
import { parseVerify } from "./lean/verify.mjs";
|
|
26
26
|
import { normalizeKindModes } from "./lean/conformance.mjs";
|
|
27
|
+
import { parseEnforceDomains } from "./lean/review-trigger.mjs";
|
|
27
28
|
import { viewIssue } from "./lean/gh-assoc.mjs";
|
|
28
29
|
import { otherLaneOwner } from "./lean/lanelock.mjs";
|
|
29
30
|
import { parseExecution, detectNeeds, placementText } from "./lean/execution.mjs";
|
|
@@ -192,7 +193,7 @@ export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
|
|
|
192
193
|
}
|
|
193
194
|
/**
|
|
194
195
|
* forge.yaml `review.trigger` (shadow|enforce|off) and `review.risk_paths` (#4273): a comma-separated scalar or a block
|
|
195
|
-
* list (` risk_paths:` then ` - glob`). Returns {mode, riskPaths}; undefined when
|
|
196
|
+
* list (` risk_paths:` then ` - glob`); `review.enforce_domains` (#5099) the same way. Returns {mode, riskPaths, enforceDomains?}; undefined when none is set (the core defaults to
|
|
196
197
|
* shadow with the built-in risk patterns). An invalid mode reads as unset.
|
|
197
198
|
*/
|
|
198
199
|
export function readReviewTrigger(cwd = process.cwd()) {
|
|
@@ -202,19 +203,24 @@ export function readReviewTrigger(cwd = process.cwd()) {
|
|
|
202
203
|
try { sec = parseForgeYaml(raw)?.review ?? {}; } catch { /* unreadable: no trigger config */ }
|
|
203
204
|
const m = String(sec?.trigger ?? "").trim();
|
|
204
205
|
const mode = ["shadow", "enforce", "off"].includes(m) ? m : undefined;
|
|
205
|
-
const
|
|
206
|
-
|
|
206
|
+
const scalar = (v) => (typeof v === "string" ? v.split(",").map((x) => x.trim()).filter(Boolean) : []);
|
|
207
|
+
const lists = { risk_paths: scalar(sec?.risk_paths), enforce_domains: scalar(sec?.enforce_domains) }; // #5099: enforce_domains
|
|
208
|
+
const keyOf = (line) => /^\s+(risk_paths|enforce_domains):\s*(#.*)?$/.exec(line)?.[1] ?? null;
|
|
209
|
+
let inReview = false, listIndent = null, listKey = null;
|
|
207
210
|
for (const line of raw.split(/\r?\n/)) {
|
|
208
211
|
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
209
212
|
if (/^\S/.test(line)) { inReview = /^review:\s*(#.*)?$/.test(line); listIndent = null; continue; }
|
|
210
213
|
if (!inReview) continue;
|
|
211
214
|
const indent = line.length - line.trimStart().length;
|
|
212
|
-
if (listIndent === null) {
|
|
213
|
-
if (indent <= listIndent) {
|
|
215
|
+
if (listIndent === null) { listKey = keyOf(line); if (listKey) listIndent = indent; continue; }
|
|
216
|
+
if (indent <= listIndent) { listKey = keyOf(line); listIndent = listKey ? indent : null; continue; }
|
|
214
217
|
const li = /^\s+-\s+["']?([^"'#]+?)["']?\s*(#.*)?$/.exec(line);
|
|
215
|
-
if (li)
|
|
218
|
+
if (li) lists[listKey].push(li[1].trim());
|
|
216
219
|
}
|
|
217
|
-
|
|
220
|
+
const riskPaths = lists.risk_paths;
|
|
221
|
+
const enforceDomains = parseEnforceDomains(lists.enforce_domains);
|
|
222
|
+
if (!mode && !riskPaths.length && !enforceDomains.length) return undefined;
|
|
223
|
+
return { mode: mode ?? "shadow", riskPaths, ...(enforceDomains.length ? { enforceDomains } : {}) };
|
|
218
224
|
}
|
|
219
225
|
/**
|
|
220
226
|
* forge.yaml `review.verifier` (off|shadow|enforce) and `review.verifier_model`, else `engine.lean_verifier_model` (#4397).
|
|
@@ -1177,6 +1183,7 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
1177
1183
|
* `--resume <id>` continues after the dispatcher dies, re-adopting live lanes. `deferred` lanes are requeued by code,
|
|
1178
1184
|
* forge.yaml `orchestration.breaker` ({errors, window_min}, default 3 in 30) stops admissions on an engine-error
|
|
1179
1185
|
* burst, and `--set-parallel <id> K` changes K of a running batch.
|
|
1186
|
+
* `--set-max-remote <id> N` and `--set-placement <id> remote|hybrid|local` (#5138) change its execution live (live-execution.mjs).
|
|
1180
1187
|
* Maintenance budget (#4197), only with forge.yaml `maintenance.enabled: true` (or `deps.maintenance`): open issues carrying
|
|
1181
1188
|
* a `maintenance.labels` label join the batch, each issue is classified from its labels (maintenance; `priority:P<n>`,
|
|
1182
1189
|
* unlabelled = P2) and admission reserves `maintenance.share` of lane starts for maintenance (`when_idle` drains the rest).
|
|
@@ -1242,7 +1249,7 @@ async function envBatch({ argv, root, print = (l) => console.log(l) }) {
|
|
|
1242
1249
|
return { ok: true, batch: id };
|
|
1243
1250
|
}
|
|
1244
1251
|
|
|
1245
|
-
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] [--auto-children off|decomposed|epic|all] [--epic N] | --resume <id> [issue...] | --set-parallel <id> <K> | --clear-breaker <id> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...> | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY";
|
|
1252
|
+
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] [--auto-children off|decomposed|epic|all] [--epic N] | --resume <id> [issue...] | --set-parallel <id> <K> | --set-max-remote <id> <N> | --set-placement <id> remote|hybrid|local | --clear-breaker <id> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...> | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY";
|
|
1246
1253
|
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason", "--force", "--auto-children", "--epic"];
|
|
1247
1254
|
const issuesOf = (argv) => {
|
|
1248
1255
|
const issues = [];
|
|
@@ -1457,6 +1464,17 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1457
1464
|
console.log(`batch ${id}: parallel set to ${k}`);
|
|
1458
1465
|
return { ok: true, batch: id, parallel: k };
|
|
1459
1466
|
}
|
|
1467
|
+
if (argv.includes("--set-max-remote") || argv.includes("--set-placement")) {
|
|
1468
|
+
// #5138: through the daemon when one runs, else the batch's `execution` file directly (nothing hosts the batch).
|
|
1469
|
+
const liveExec = await import("./lean/live-execution.mjs");
|
|
1470
|
+
const { id, patch } = liveExec.parseSetExecution(argv);
|
|
1471
|
+
let sock = null, execution = null;
|
|
1472
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1473
|
+
if (sock) execution = (await schedd.request(sock, { op: "set-execution", id, ...patch }, undefined, { timeoutMs: schedd.REQUEST_MS }))?.execution ?? null;
|
|
1474
|
+
else { const batchMod = await import("./lean/batch.mjs"); execution = liveExec.applySetExecution(batchMod.openBatch(batchMod.batchRoot(), id), patch); }
|
|
1475
|
+
console.log(`batch ${id}: ${liveExec.formatOverride(patch)}`);
|
|
1476
|
+
return { ok: true, batch: id, execution };
|
|
1477
|
+
}
|
|
1460
1478
|
// #4408: --add, and the extra issues of --resume <id> N..., go to the batch (through the daemon when one runs).
|
|
1461
1479
|
const addVia = (id) => async (issues) => {
|
|
1462
1480
|
let sock = null;
|
|
@@ -1578,6 +1596,14 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1578
1596
|
sink.line(`batch ${b.id}: parallel set to ${k}`);
|
|
1579
1597
|
return { ok: true, batch: b.id, parallel: k };
|
|
1580
1598
|
}
|
|
1599
|
+
if (argv.includes("--set-max-remote") || argv.includes("--set-placement")) {
|
|
1600
|
+
// #5138: live max_remote / placement mode of a batch, re-read by its runner on every admission.
|
|
1601
|
+
const liveExec = await import("./lean/live-execution.mjs");
|
|
1602
|
+
const { id, patch } = liveExec.parseSetExecution(argv);
|
|
1603
|
+
const execution = liveExec.applySetExecution(batchMod.openBatch(root, id), patch);
|
|
1604
|
+
sink.line(`batch ${id}: ${liveExec.formatOverride(patch)}`);
|
|
1605
|
+
return { ok: true, batch: id, execution };
|
|
1606
|
+
}
|
|
1581
1607
|
if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root, print: (l) => sink.line(l) });
|
|
1582
1608
|
if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, stopRemote: argv.includes("--stop-remote"), print: (l) => sink.line(l) });
|
|
1583
1609
|
if (argv.includes("--requeue")) {
|
|
@@ -1741,7 +1767,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1741
1767
|
// #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
|
|
1742
1768
|
let gitDir = null;
|
|
1743
1769
|
try { gitDir = resolvePath(cwd, String(await leanIo.git(["rev-parse", "--git-common-dir"], { cwd })).trim()); } catch { gitDir = null; }
|
|
1744
|
-
const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env, prefetch: (n) => prefetchOf.get(n) ?? [] });
|
|
1770
|
+
const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env, prefetch: (n) => prefetchOf.get(n) ?? [], ...(deps.sandboxFetch ? { fetchFn: deps.sandboxFetch } : {}) });
|
|
1745
1771
|
const { makeLedger } = await import("./lean/ledger.mjs");
|
|
1746
1772
|
const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
|
|
1747
1773
|
const bud = exec.budget ?? {};
|
|
@@ -1766,13 +1792,16 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1766
1792
|
const emitSbx = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1767
1793
|
// #4256: hybrid places each lane from facts (pure placeLane); validation weight is a repo fact, read once per batch.
|
|
1768
1794
|
const relays = Array.isArray(provider.relays) ? provider.relays : [];
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1795
|
+
// #5112: the host-busy guard, shared by hybrid placement and the fallback_local capacity fallback.
|
|
1796
|
+
const hostHeadroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
|
|
1797
|
+
// #5138: placement and max_remote re-read the batch's live `execution` override (--set-placement / --set-max-remote).
|
|
1798
|
+
const liveExec = await import("./lean/live-execution.mjs");
|
|
1799
|
+
const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
|
|
1800
|
+
const computeWeight = () => deps.validationWeight ?? execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] });
|
|
1801
|
+
const weight = liveExec.lazyWeight({ initial: hybrid ? (await Promise.resolve().then(computeWeight).catch(() => null)) ?? { heavy: false, signals: [] } : null, compute: computeWeight });
|
|
1802
|
+
const place = (e, n) => (e.mode !== "hybrid" ? execMod.placeLane({ exec: e, needs: needsOf.get(n) ?? [], relays })
|
|
1803
|
+
: execMod.placeLane({ exec: e, verifyMode, labels: labelsOf.get(n) ?? [], weight: weight.get(), headroom: hostHeadroom(), needs: needsOf.get(n) ?? [], relays }));
|
|
1804
|
+
if (batch.readExecution()?.provider !== true) batch.setExecution({ provider: true });
|
|
1776
1805
|
// #4540: provider free slots, refreshed in the background (admission is sync); unknown (null) never blocks.
|
|
1777
1806
|
let capSample = null, onlineSample = null, capAt = 0, capLast = -Infinity, capFailed = false; // #5048: capFailed = last probe rejected // #4547: online = agents alive, null unknown
|
|
1778
1807
|
const capacity = () => {
|
|
@@ -1784,18 +1813,19 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1784
1813
|
}
|
|
1785
1814
|
return capSample === null && onlineSample === null ? null : { free: capSample, online: onlineSample, at: capAt };
|
|
1786
1815
|
};
|
|
1787
|
-
remote = {
|
|
1788
|
-
capacity,
|
|
1816
|
+
remote = liveExec.liveRemote({
|
|
1817
|
+
capacity, isRemote: () => true, identity: () => holdId, ...(exec.fallbackLocal ? { offlineFallback: true, headroom: hostHeadroom, ...(exec.capacityFallbackAfter ? { capacityFallbackAfter: exec.capacityFallbackAfter } : {}) } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
|
|
1789
1818
|
admit: (u) => execBudget.admit(u),
|
|
1790
1819
|
onUsage: (n, sb, rec) => {
|
|
1791
1820
|
const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
|
|
1792
1821
|
try { execBudget.record(`${batch.id}#${sb.id ?? n}#${startedAt}`, sb); } catch { /* the batch total still counts it */ }
|
|
1793
1822
|
Promise.resolve(emitSbx({ event: "SANDBOX_USAGE", issue: n, provider: sb.provider, sandbox_id: sb.id, minutes: sb.minutes, cost_usd: sb.cost_usd, batch: batch.id })).catch(() => {});
|
|
1794
1823
|
},
|
|
1795
|
-
};
|
|
1824
|
+
}, { exec, readOverride: batch.readExecution, place, onChange: (e) => sink.line(`execution: live placement ${e.mode}, max_remote ${e.maxRemote}`) });
|
|
1796
1825
|
sink.line(`execution: ${hybrid ? "hybrid" : "remote"} via ${provider.name}, max_remote ${exec.maxRemote}`);
|
|
1797
1826
|
} else {
|
|
1798
1827
|
lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env: laneEnv, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs, spawnFn: laneSpawn }));
|
|
1828
|
+
if (batch.readExecution()?.provider === true) batch.setExecution({ provider: false }); // #5138: a resume without a provider refuses live remote/hybrid
|
|
1799
1829
|
}
|
|
1800
1830
|
// #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on unless `--cancel --stop-remote` (#4612).
|
|
1801
1831
|
const { killLedgerPid } = await import("./lean/effects.mjs");
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3381,7 +3381,7 @@ function help() {
|
|
|
3381
3381
|
["npx forgedock run-issue <issue> [--lane <lane>] [--lane-override] [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline; the lane comes from the issue: milestone → milestone/<slug>, else staging; --lane is an assertion, never the default branch) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
|
|
3382
3382
|
["npx forgedock plan validate --live [--repo owner/repo] [--json]", "Re-run the bootstrap dependency-order rules on live GitHub state: each cross-milestone Depends-on edge is ok, held or invalid (exit 1 on invalid)"],
|
|
3383
3383
|
["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering; each issue on its own lane, cross-lane dependencies reported"],
|
|
3384
|
-
["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N... | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume), or print (secrets redacted), set or unset a batch's pinned lane env (applies to newly launched lanes)"],
|
|
3384
|
+
["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --set-max-remote <id> N | --set-placement <id> remote|hybrid|local | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N... | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, change its max_remote or placement mode live (remote/hybrid need a batch started with a remote provider; running lanes are not stopped), add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume), or print (secrets redacted), set or unset a batch's pinned lane env (applies to newly launched lanes)"],
|
|
3385
3385
|
["npx forgedock triage <issue...> [--lane L] [--dry-run]", "Already-fixed pre-filter: close issues a merged PR or green CI already resolved; print the rest"],
|
|
3386
3386
|
["npx forgedock probe [issue...] [--dry-run]", "Run declared acceptance probes for closed issues whose fix is on the default branch; a fail reopens the issue"],
|
|
3387
3387
|
["npx forgedock check [--base REF] [--json]", "Run the lane's frozen validate commands and change-scoped pre-PR checks locally in this worktree, as the engine will (no GitHub, no Actions)"],
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fsyncSync
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { dirname, join, resolve, sep } from "node:path";
|
|
22
|
+
import { sanitizeOverride, validateExecutionPatch } from "./live-execution.mjs";
|
|
22
23
|
|
|
23
24
|
export const RAM_FS = new Set(["tmpfs", "ramfs"]);
|
|
24
25
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
@@ -145,6 +146,14 @@ export function openBatch(root, id) {
|
|
|
145
146
|
if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
|
|
146
147
|
put(f("parallel"), `${k}\n`);
|
|
147
148
|
},
|
|
149
|
+
/** Live execution override (#5138): the `execution` file `{maxRemote?, mode?, provider?}`, read on every admission; null when missing or invalid. */
|
|
150
|
+
readExecution: () => sanitizeOverride(readJson(f("execution"))),
|
|
151
|
+
/** Merge a validated patch into the `execution` file. @returns the stored override. */
|
|
152
|
+
setExecution: (patch) => {
|
|
153
|
+
const next = { ...(sanitizeOverride(readJson(f("execution"))) ?? {}), ...validateExecutionPatch(patch) };
|
|
154
|
+
put(f("execution"), JSON.stringify(next) + "\n");
|
|
155
|
+
return next;
|
|
156
|
+
},
|
|
148
157
|
/** Issues added by `--add` (#4408): the `add` file, sorted unique positive integers; [] when missing or invalid. */
|
|
149
158
|
readAdded: () => readAdded(f("add")),
|
|
150
159
|
/**
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
9
|
import { resolveLane, makeLaneCache } from "./lane.mjs";
|
|
10
|
-
import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists } from "./orchestrate.mjs";
|
|
10
|
+
import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists, CAPACITY_FALLBACK_AFTER } from "./orchestrate.mjs";
|
|
11
11
|
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
|
|
12
12
|
import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
|
|
13
13
|
|
|
@@ -47,7 +47,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
47
47
|
const pids = () => Object.values(batch.store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean);
|
|
48
48
|
let lanePids = [];
|
|
49
49
|
try {
|
|
50
|
-
|
|
50
|
+
// The two-lane wait must not depend on host memory: the live local lane cap (#4777) reads forge.yaml from the dispatcher's cwd.
|
|
51
|
+
writeFileSync(join(work, "forge.yaml"), "engine:\n admission_enabled: false\n");
|
|
52
|
+
const child = spawn(process.execPath, [dispatcher], { stdio: "ignore", cwd: work });
|
|
51
53
|
const exited = new Promise((r) => child.on("exit", r));
|
|
52
54
|
await until(() => readFileSync(launches, "utf8").trim().split("\n").filter(Boolean).length === 2 && pids().length === 2);
|
|
53
55
|
lanePids = pids();
|
|
@@ -1065,6 +1067,108 @@ describe("#4547: opt-in local fallback in mode remote when no pool agent is onli
|
|
|
1065
1067
|
});
|
|
1066
1068
|
});
|
|
1067
1069
|
|
|
1070
|
+
describe("#5112: late dependents of a decomposed issue, edge re-read on --requeue, capacity fallback_local", () => {
|
|
1071
|
+
const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
|
|
1072
|
+
it("#5112 an issue added after its dependency decomposed waits on the sub-issues, not skipped", async () => {
|
|
1073
|
+
const bodies = { 1: "", 3: "Depends on #1", 5: "Depends on #1", 6: "", 11: "", 12: "" };
|
|
1074
|
+
let added = [];
|
|
1075
|
+
const started = [], finished = [], log = [], g = gate();
|
|
1076
|
+
const done = orchestrate({ issues: [1, 3], parallel: 4, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getAdded: () => added, onUpdate: (r) => log.push(r),
|
|
1077
|
+
runOne: async (n) => {
|
|
1078
|
+
started.push(n);
|
|
1079
|
+
if (n === 1) return { terminalReason: "decomposed", subissues: [11, 12] };
|
|
1080
|
+
if (n === 11 || n === 12) await g.p;
|
|
1081
|
+
finished.push(n);
|
|
1082
|
+
return { terminalReason: "merged", pr: 100 + n };
|
|
1083
|
+
} });
|
|
1084
|
+
await until(() => started.includes(11) && started.includes(12));
|
|
1085
|
+
added = [5];
|
|
1086
|
+
await until(() => log.some((r) => r.outcome === "added" && /#5/.test(r.detail ?? "")));
|
|
1087
|
+
added = [5, 6]; // an unrelated later --add re-reads the pending bodies: #3's edge to #1 must stay resolved
|
|
1088
|
+
await until(() => log.some((r) => r.outcome === "added" && /#6/.test(r.detail ?? "")));
|
|
1089
|
+
g.open();
|
|
1090
|
+
const rows = await done;
|
|
1091
|
+
const out = Object.fromEntries(rows.map((r) => [r.issue, r.outcome]));
|
|
1092
|
+
assert.equal(out[5], "merged", JSON.stringify(rows.find((r) => r.issue === 5)));
|
|
1093
|
+
assert.equal(out[3], "merged", JSON.stringify(rows.find((r) => r.issue === 3)));
|
|
1094
|
+
for (const n of [3, 5]) assert.ok(finished.indexOf(n) > finished.indexOf(11) && finished.indexOf(n) > finished.indexOf(12), `#${n} ran after the sub-issues`);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it("#5112 --requeue re-reads the edited Depends on edges", async () => {
|
|
1098
|
+
const bodies = { 1: "", 2: "Depends on #1", 3: "" };
|
|
1099
|
+
let entries = [];
|
|
1100
|
+
const started = [], log = [], decisions = [], g3 = gate();
|
|
1101
|
+
const done = orchestrate({ issues: [1, 2, 3], parallel: 3, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getRequeued: () => entries,
|
|
1102
|
+
decide: (d) => { decisions.push(d); }, onUpdate: (r) => log.push(r),
|
|
1103
|
+
runOne: async (n) => {
|
|
1104
|
+
started.push(n);
|
|
1105
|
+
if (n === 3) { await g3.p; return { terminalReason: "merged" }; }
|
|
1106
|
+
return n === 1 ? { terminalReason: "needs-human", detail: "builder.blocked: product-decision" } : { terminalReason: "merged" };
|
|
1107
|
+
} });
|
|
1108
|
+
await until(() => log.some((r) => r.issue === 2 && r.outcome === "skipped"));
|
|
1109
|
+
bodies[2] = ""; // the operator dropped the wrong edge
|
|
1110
|
+
entries = [{ issue: 2, reason: "edge dropped", by: "operator", at: "2026-11-05T00:00:00Z" }];
|
|
1111
|
+
await until(() => started.includes(2), 3000);
|
|
1112
|
+
g3.open();
|
|
1113
|
+
const rows = await done;
|
|
1114
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "needs-human"], [2, "merged"], [3, "merged"]]);
|
|
1115
|
+
assert.ok(decisions.some((d) => d.issue === 2 && /edges re-read: #2 -> none/.test(d.what)));
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
it("#5112 --requeue of a dependent skipped behind a decomposed issue waits on its sub-issues", async () => {
|
|
1119
|
+
const saved = { v: 1, order: [1, 2, 11], deps: { 1: [], 2: [1], 11: [] }, pending: [11],
|
|
1120
|
+
results: { 1: { issue: 1, outcome: "decomposed", subissues: [11] }, 2: { issue: 2, outcome: "skipped", detail: "dependency #1 ended decomposed" } } };
|
|
1121
|
+
let entries = [];
|
|
1122
|
+
const finished = [], g = gate();
|
|
1123
|
+
const done = orchestrate({ issues: [], parallel: 2, pollMs: 5, readBody: async (n) => (n === 2 ? "Depends on #1" : ""), getRequeued: () => entries,
|
|
1124
|
+
store: { load: () => saved, save: () => {} },
|
|
1125
|
+
runOne: async (n) => { if (n === 11) await g.p; finished.push(n); return { terminalReason: "merged" }; } });
|
|
1126
|
+
entries = [{ issue: 2, reason: "parent decomposed", by: "operator", at: "2026-11-05T00:00:00Z" }];
|
|
1127
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1128
|
+
g.open();
|
|
1129
|
+
const rows = await done;
|
|
1130
|
+
assert.equal(rows.find((r) => r.issue === 2)?.outcome, "merged");
|
|
1131
|
+
assert.deepEqual(finished, [11, 2]);
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
const runCap = async (remoteExtra) => {
|
|
1135
|
+
const c = { t: 1_000_000 };
|
|
1136
|
+
const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
|
|
1137
|
+
const where = [], decisions = [];
|
|
1138
|
+
let remoteTries = 0;
|
|
1139
|
+
const rows = await orchestrate({ issues: [1], parallel: 1, now: () => c.t, sleep, pollMs: 1, readBody: async () => "", decide: (d) => decisions.push(d),
|
|
1140
|
+
remote: { max: 1, isRemote: () => true, capacity: () => ({ free: null, online: 1, at: 1 }), ...remoteExtra },
|
|
1141
|
+
runOne: async (n, _ln, ctx = {}) => {
|
|
1142
|
+
where.push(ctx.placement ?? { where: "remote" });
|
|
1143
|
+
if (ctx.placement?.where === "local") return { terminalReason: "merged", pr: 101 };
|
|
1144
|
+
remoteTries++;
|
|
1145
|
+
return remoteTries >= 5 ? { terminalReason: "merged", pr: 101 } : { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 1, detail: "HTTP 503 capacity" };
|
|
1146
|
+
} });
|
|
1147
|
+
return { rows, where, remoteTries, decisions };
|
|
1148
|
+
};
|
|
1149
|
+
it("#5112 fallback_local runs a lane locally after N sandbox.capacity deferrals when the host has headroom", async () => {
|
|
1150
|
+
const { rows, where, remoteTries, decisions } = await runCap({ offlineFallback: true, capacityFallbackAfter: 2, headroom: () => ({ ok: true, detail: "" }) });
|
|
1151
|
+
assert.equal(rows[0].outcome, "merged");
|
|
1152
|
+
assert.equal(remoteTries, 2);
|
|
1153
|
+
assert.equal(where.at(-1).where, "local");
|
|
1154
|
+
assert.match(where.at(-1).reason, /pool at capacity after 2 deferrals/);
|
|
1155
|
+
assert.equal(decisions.filter((d) => d.issue === 1 && /capacity/.test(d.what)).length, 1);
|
|
1156
|
+
});
|
|
1157
|
+
it("#5112 a busy host (headroom not ok, or a throwing probe) or fallback_local off keeps the lane remote", async () => {
|
|
1158
|
+
for (const extra of [{ offlineFallback: true, capacityFallbackAfter: 2, headroom: () => ({ ok: false, detail: "mem" }) },
|
|
1159
|
+
{ offlineFallback: true, capacityFallbackAfter: 2, headroom: () => { throw new Error("probe"); } }, { capacityFallbackAfter: 2 }]) {
|
|
1160
|
+
const { where, remoteTries } = await runCap(extra);
|
|
1161
|
+
assert.equal(remoteTries, 5);
|
|
1162
|
+
assert.ok(where.every((w) => w.where !== "local"));
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
it("#5112 without capacityFallbackAfter the default CAPACITY_FALLBACK_AFTER applies", async () => {
|
|
1166
|
+
const { remoteTries, where } = await runCap({ offlineFallback: true });
|
|
1167
|
+
assert.equal(remoteTries, CAPACITY_FALLBACK_AFTER);
|
|
1168
|
+
assert.equal(where.at(-1).where, "local");
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1068
1172
|
describe("#4788: a pool offline for longer than N minutes is reported once", () => {
|
|
1069
1173
|
const run = async (onlineAt) => {
|
|
1070
1174
|
const c = { t: 1_000_000 };
|
|
@@ -1241,3 +1345,71 @@ describe("batch plan secrets (#4787)", () => {
|
|
|
1241
1345
|
assert.ok(fast < 10 * LAT, `first lane after ${fast} ms`);
|
|
1242
1346
|
});
|
|
1243
1347
|
});
|
|
1348
|
+
|
|
1349
|
+
describe("#5138: live max_remote and placement of a running batch", () => {
|
|
1350
|
+
const tick = () => new Promise((r) => setTimeout(r, 1));
|
|
1351
|
+
it("live max_remote raised from 1 to 2 admits a waiting remote lane while the first still runs", async () => {
|
|
1352
|
+
let m = 1, open;
|
|
1353
|
+
const gate = new Promise((r) => { open = r; });
|
|
1354
|
+
const live = new Set(), seen = [];
|
|
1355
|
+
const done = orchestrate({ issues: [1, 2], parallel: 2, pollMs: 1, readBody: async () => "", sleep: tick,
|
|
1356
|
+
remote: { get max() { return m; }, isRemote: () => true, capacity: () => null },
|
|
1357
|
+
runOne: async (n, _ln, ctx = {}) => { live.add(n); seen.push([n, ctx.placement?.where, [...live].sort()]); if (n === 1) await gate; live.delete(n); return { terminalReason: "merged", pr: 100 + n }; } });
|
|
1358
|
+
await until(() => live.has(1));
|
|
1359
|
+
for (let i = 0; i < 20; i++) await tick();
|
|
1360
|
+
assert.ok(!seen.some(([n]) => n === 2), "#2 waits: max_remote 1 is taken by #1");
|
|
1361
|
+
m = 2;
|
|
1362
|
+
await until(() => seen.some(([n]) => n === 2));
|
|
1363
|
+
assert.deepEqual(seen.find(([n]) => n === 2), [2, "remote", [1, 2]]);
|
|
1364
|
+
open();
|
|
1365
|
+
const rows = await done;
|
|
1366
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
|
|
1367
|
+
});
|
|
1368
|
+
it("live max_remote raised from 0 to 1 admits a waiting remote lane (nothing running)", async () => {
|
|
1369
|
+
let m = 0;
|
|
1370
|
+
const seen = [];
|
|
1371
|
+
const done = orchestrate({ issues: [1], parallel: 2, pollMs: 1, readBody: async () => "", sleep: tick,
|
|
1372
|
+
remote: { get max() { return m; }, isRemote: () => true, capacity: () => null },
|
|
1373
|
+
runOne: async (n, _ln, ctx = {}) => { seen.push([n, ctx.placement?.where]); return { terminalReason: "merged", pr: 100 + n }; } });
|
|
1374
|
+
for (let i = 0; i < 20; i++) await tick();
|
|
1375
|
+
assert.deepEqual(seen, [], "#1 waits under max_remote 0");
|
|
1376
|
+
m = 1;
|
|
1377
|
+
const rows = await done;
|
|
1378
|
+
assert.deepEqual(seen, [[1, "remote"]]);
|
|
1379
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged"]);
|
|
1380
|
+
});
|
|
1381
|
+
it("live max_remote lowered from 2 to 1 stops no running lane and holds new remote admissions", async () => {
|
|
1382
|
+
let m = 2;
|
|
1383
|
+
const live = new Set(), gates = new Map(), seen = [];
|
|
1384
|
+
const gate = (n) => { let open; const p = new Promise((r) => { open = r; }); gates.set(n, open); return p; };
|
|
1385
|
+
const done = orchestrate({ issues: [1, 2, 3], parallel: 3, pollMs: 1, readBody: async () => "", sleep: tick,
|
|
1386
|
+
remote: { get max() { return m; }, isRemote: () => true, capacity: () => null },
|
|
1387
|
+
runOne: async (n) => { live.add(n); seen.push([n, [...live].sort()]); if (n !== 3) await gate(n); live.delete(n); return { terminalReason: "merged", pr: 100 + n }; } });
|
|
1388
|
+
await until(() => live.size === 2);
|
|
1389
|
+
m = 1;
|
|
1390
|
+
gates.get(1)();
|
|
1391
|
+
for (let i = 0; i < 20; i++) await tick();
|
|
1392
|
+
assert.ok(!seen.some(([n]) => n === 3), "#3 waits while #2 holds the one remote slot");
|
|
1393
|
+
assert.ok(live.has(2), "#2 keeps running");
|
|
1394
|
+
gates.get(2)();
|
|
1395
|
+
const rows = await done;
|
|
1396
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged"]);
|
|
1397
|
+
assert.deepEqual(seen.find(([n]) => n === 3)[1], [3]);
|
|
1398
|
+
});
|
|
1399
|
+
it("--set-max-remote / --set-placement write the batch's execution file; bad values and provider-less remote refused", async () => {
|
|
1400
|
+
const root = tmpDir("batch-live-exec-");
|
|
1401
|
+
const b = openBatch(root, "b1");
|
|
1402
|
+
b.writePlan({ argv: ["1"] });
|
|
1403
|
+
await quiet(() => orchestrateFromCli(["--set-max-remote", "b1", "3"], { batchRoot: root }));
|
|
1404
|
+
assert.deepEqual(b.readExecution(), { maxRemote: 3 });
|
|
1405
|
+
await quiet(() => orchestrateFromCli(["--set-placement", "b1", "local"], { batchRoot: root }));
|
|
1406
|
+
assert.deepEqual(b.readExecution(), { maxRemote: 3, mode: "local" });
|
|
1407
|
+
await assert.rejects(orchestrateFromCli(["--set-placement", "b1", "hybrid"], { batchRoot: root }), /no remote provider/);
|
|
1408
|
+
await assert.rejects(orchestrateFromCli(["--set-max-remote", "b1", "-1"], { batchRoot: root }), /non-negative integer/);
|
|
1409
|
+
await assert.rejects(orchestrateFromCli(["--set-placement", "b1", "cloud"], { batchRoot: root }), /placement must be one of/);
|
|
1410
|
+
await assert.rejects(orchestrateFromCli(["--set-max-remote", "nope", "1"], { batchRoot: root }), /no batch nope/);
|
|
1411
|
+
b.setExecution({ provider: true });
|
|
1412
|
+
await quiet(() => orchestrateFromCli(["--set-placement", "b1", "hybrid"], { batchRoot: root }));
|
|
1413
|
+
assert.equal(b.readExecution().mode, "hybrid");
|
|
1414
|
+
});
|
|
1415
|
+
});
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -268,15 +268,19 @@ const tmpInputProblem = (id, p) => `${id}: reads ${p}, which the command does no
|
|
|
268
268
|
|
|
269
269
|
const E2E_TERM = String.raw`(?:e2e|end-to-end|integration|playwright|cypress|live[- ]stack)`;
|
|
270
270
|
const E2E_WORD_RE = new RegExp(String.raw`\b${E2E_TERM}\b`, "i");
|
|
271
|
-
const MOCKED_TERM = String.raw`(?:mock\w*|stub\w*|fake\w*|unit|vitest|jest|component tests?|route tests?)`;
|
|
272
|
-
const MOCKED_RE = new RegExp(String.raw`\b${MOCKED_TERM}`, "i");
|
|
271
|
+
const MOCKED_TERM = String.raw`(?:mock\w*|stub\w*|fake\w*|unit(?:[- ]?test\w*)?|(?:use[sd]?|using|via|by)\s+units|vitest|jest|component tests?|route tests?)`;
|
|
272
|
+
const MOCKED_RE = new RegExp(String.raw`\b${MOCKED_TERM}\b`, "i");
|
|
273
273
|
// #5116: a negated mock ("do not mock", "no stubs") or a fake clock is not a substitute for the e2e check.
|
|
274
274
|
const NEG_MOCK_RE = /\b(?:do(?:es)? not|don't|never|no|not|without)\s+(?:use\s+|any\s+)?(?:mock\w*|stub\w*|fake\w*)/gi;
|
|
275
|
+
// #5130: a negated weakener ("do not skip the e2e suite", "e2e tests are not replaced") keeps the e2e check.
|
|
276
|
+
const NEG_DROP_RE = /\b(?:do(?:es)? not|don't|never|not)\s+(?:be\s+|been\s+|being\s+)?(?:skip|drop|replac|swap|downgrad)\w*/gi;
|
|
275
277
|
const FAKE_CLOCK_RE = /\bfake[- ](?:timers?|clocks?)\b/gi;
|
|
276
278
|
// A weakener governs the e2e term only within a few words and the same clause (no . or ; between them).
|
|
277
|
-
const GAP = String.raw`(?:[\s,]+\w+){0,
|
|
278
|
-
|
|
279
|
-
const
|
|
279
|
+
const GAP = String.raw`(?:[\s,]+\w+){0,5}?[\s,]+`;
|
|
280
|
+
// #5145: a bare negator ("no new fixtures needed for e2e") stops at a preposition; the verb weakeners keep the wide GAP.
|
|
281
|
+
const NEG_GAP = String.raw`(?:[\s,]+(?!(?:for|to|of|in|on|at|by|with|from|via)\b)\w+){0,5}?[\s,]+`;
|
|
282
|
+
const DROP_BEFORE_RE = new RegExp(String.raw`\b(?:(?:no|not|without|never)${NEG_GAP}|(?:drop(?:ped|s|ping)?|skip(?:ped|s|ping)?|replac\w+|downgrad\w+|swap\w*)${GAP})${E2E_TERM}\b`, "i");
|
|
283
|
+
const DROP_AFTER_RE = new RegExp(String.raw`\b${E2E_TERM}${GAP}(?:replaced|dropped|skipped|swapped|downgraded|unnecessary|(?:not|no longer)\s+(?:needed|required|necessary|run\w*|used|executed))\b`, "i");
|
|
280
284
|
const INSTEAD_OF_RE = new RegExp(String.raw`\b${MOCKED_TERM}[^.;]{0,60}?\b(?:instead of|rather than|in lieu of)\b${GAP}${E2E_TERM}\b`, "i");
|
|
281
285
|
const PROBE_RE = /forge-probe|acceptance[- ]probe/i;
|
|
282
286
|
const NEG_PROBE_RE = /\b(?:no|not|without|never)\b[^.;]{0,30}(?:forge-probe|acceptance[- ]probe)/i;
|
|
@@ -292,7 +296,7 @@ export function weakenedE2eDecisions(c) {
|
|
|
292
296
|
return decisions.filter((k) => {
|
|
293
297
|
const choice = String(k?.choice ?? "");
|
|
294
298
|
if (!E2E_WORD_RE.test(choice)) return false;
|
|
295
|
-
const mocked = choice.replace(NEG_MOCK_RE, " ").replace(FAKE_CLOCK_RE, " ");
|
|
299
|
+
const mocked = choice.replace(NEG_MOCK_RE, " ").replace(FAKE_CLOCK_RE, " ").replace(NEG_DROP_RE, " ");
|
|
296
300
|
if (!MOCKED_RE.test(mocked) || !(DROP_BEFORE_RE.test(mocked) || DROP_AFTER_RE.test(mocked) || INSTEAD_OF_RE.test(mocked))) return false;
|
|
297
301
|
const own = `${choice} ${k?.justification ?? ""}`;
|
|
298
302
|
return !((PROBE_RE.test(own) && !NEG_PROBE_RE.test(own)) || /forge-probe/i.test(elsewhere(k?.id)));
|
|
@@ -1281,12 +1285,37 @@ export function followupItems(reqs, { now = Date.now() } = {}) {
|
|
|
1281
1285
|
});
|
|
1282
1286
|
}
|
|
1283
1287
|
|
|
1284
|
-
/**
|
|
1288
|
+
/**
|
|
1289
|
+
* #5141: the FORGE:FOLLOWUP_PENDING note for follow-ups the lane could not file before or after the merge. Each row keeps the
|
|
1290
|
+
* requirement text and the `FORGE:DEFERRED_FROM: #<n>` marker the follow-up script looks up, so a later pass files it once.
|
|
1291
|
+
*/
|
|
1292
|
+
export function renderPendingFollowups(rows, pr) {
|
|
1293
|
+
const flat = (s) => String(s ?? "").replace(/[\t\r\n]+/g, " ").trim();
|
|
1294
|
+
return [`## Follow-up pending`, `Merged by PR #${pr}, but the tracking issue could not be filed, so this issue stays open until it is. Still to file (idempotent by the marker):`, "",
|
|
1295
|
+
...rows.map((r) => `- ${flat(r.id)} (${flat(r.disposition)}): ${flat(r.quote)} | why: ${flat(r.why)} | \`${r.marker}\``)].join("\n");
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}; unmatched items omitted. Both sides get every
|
|
1300
|
+
* identity rule of scripts/file-deferred-followups.sh: trimmed (#4388), backticks stripped (#4984) and repeated leading
|
|
1301
|
+
* `R<n>: R<m>:` prefixes collapsed to the first (#5141: a quote starting with its own id looped on followup.unavailable).
|
|
1302
|
+
* An item still unmatched takes the one echoed row whose identity starts with `<item.id>:`; never when several do.
|
|
1303
|
+
*/
|
|
1285
1304
|
export function followupMap(out, items) {
|
|
1305
|
+
const norm = (k) => {
|
|
1306
|
+
let s = String(k).replace(/`/g, "").trim(), m;
|
|
1307
|
+
while ((m = /^(R\d+:)\s*R\d+:\s*(.*)$/s.exec(s))) s = `${m[1]} ${m[2]}`;
|
|
1308
|
+
return s;
|
|
1309
|
+
};
|
|
1286
1310
|
const echoed = new Map();
|
|
1287
|
-
const norm = (k) => String(k).replace(/`/g, "").trim(); // #4984: the script strips backticks from the label it echoes
|
|
1288
1311
|
for (const l of String(out ?? "").split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) echoed.set(norm(k), Number(n)); }
|
|
1289
|
-
|
|
1312
|
+
const byId = (id) => { const hits = [...echoed].filter(([k]) => k.startsWith(`${id}:`)); return hits.length === 1 ? hits[0][1] : undefined; };
|
|
1313
|
+
const res = {};
|
|
1314
|
+
for (const i of items) {
|
|
1315
|
+
const n = echoed.get(norm(i.key)) ?? (i.id ? byId(String(i.id)) : undefined);
|
|
1316
|
+
if (n !== undefined) res[i.key] = n;
|
|
1317
|
+
}
|
|
1318
|
+
return res;
|
|
1290
1319
|
}
|
|
1291
1320
|
|
|
1292
1321
|
/**
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { describe, it } from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
|
-
import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
|
|
6
|
+
import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, renderPendingFollowups, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
|
|
7
7
|
|
|
8
8
|
const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
|
|
9
9
|
const Q1 = "The scrape route must release the spend reservation on 429.";
|
|
@@ -903,6 +903,22 @@ describe("#4594 amend-requirement", () => {
|
|
|
903
903
|
it("the reviewer prompt tells it to compare amended_from with the amendment (#4996)", () => {
|
|
904
904
|
assert.match(readFileSync(new URL("./prompts.mjs", import.meta.url), "utf8"), /amended_from\.quote and amended_from\.why with amendment\.text/);
|
|
905
905
|
});
|
|
906
|
+
it("followupMap matches a key whose quote repeats its own R<n>: prefix, as the script collapses it (#5141)", () => {
|
|
907
|
+
const items = followupItems([{ id: "R6", disposition: "defer", bucket: "host", quote: "R6: Fleet: x in (browser, replay) over 48h.", why: "w" }, { id: "R1", disposition: "defer", bucket: "new-issue", quote: "R3: Named accounts", why: "w" }]);
|
|
908
|
+
assert.deepEqual(followupMap("R6: Fleet: x in (browser, replay) over 48h.\t901\nR1: Named accounts\t902\n", items), { [items[0].key]: 901, [items[1].key]: 902 });
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("renderPendingFollowups lists each unfiled follow-up with its text and the FORGE:DEFERRED_FROM marker (#5141)", () => {
|
|
912
|
+
const md = renderPendingFollowups([{ id: "R2", disposition: "defer", quote: "a\tb\nc", why: "w", marker: "FORGE:DEFERRED_FROM: #7" }], 12);
|
|
913
|
+
assert.match(md, /^## Follow-up pending\nMerged by PR #12/);
|
|
914
|
+
assert.match(md, /\n- R2 \(defer\): a b c \| why: w \| `FORGE:DEFERRED_FROM: #7`$/);
|
|
915
|
+
});
|
|
916
|
+
it("followupMap falls back to the single echoed row carrying the item id, never to an ambiguous one (#5141)", () => {
|
|
917
|
+
assert.deepEqual(followupMap("R2: drifted text\t7\n", [{ id: "R2", key: "R2: original text" }]), { "R2: original text": 7 });
|
|
918
|
+
assert.deepEqual(followupMap("R2: a\t7\nR2: b\t8\n", [{ id: "R2", key: "R2: c" }]), {});
|
|
919
|
+
assert.deepEqual(followupMap("R20: a\t7\n", [{ id: "R2", key: "R2: c" }]), {});
|
|
920
|
+
});
|
|
921
|
+
|
|
906
922
|
it("followupMap matches an item key whose backticks the script stripped (#4984)", () => {
|
|
907
923
|
assert.deepEqual(followupMap("R2: wire pd.py in\t20\n", [{ key: "R2: wire `pd.py` in" }]), { "R2: wire `pd.py` in": 20 });
|
|
908
924
|
});
|
|
@@ -1335,6 +1351,22 @@ describe("#5087 a plan decision may not weaken an e2e/integration criterion to m
|
|
|
1335
1351
|
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
|
|
1336
1352
|
}
|
|
1337
1353
|
});
|
|
1354
|
+
it("#5130 double negatives and word prefixes are not weakenings; trailing negations and distant weakeners are", () => {
|
|
1355
|
+
for (const choice of ["Do not skip the e2e suite; mock the mailer", "E2E tests are not replaced by mocks", "Skip the e2e run for billing units", "No integration with United Airlines API", "Skip e2e; ship the Unity build"]) {
|
|
1356
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), [], choice);
|
|
1357
|
+
}
|
|
1358
|
+
for (const choice of ["Mock everything; the e2e suite is not needed", "e2e is not run, jest mocks cover it", "e2e tests are not required, rely on mocked unit tests", "e2e suite no longer runs; mocks used", "Skip the slow flaky nightly e2e and use mocks", "Use stubbed unit tests, e2e not needed"]) {
|
|
1359
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
it("#5145 unittest/unittests and substitute units are mocks; a negator across a preposition is not a weakener", () => {
|
|
1363
|
+
for (const choice of ["Replace e2e with unittest cases", "Skip e2e, covered by unittests", "e2e dropped, use units", "Use unittest cases instead of the e2e check"]) {
|
|
1364
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
|
|
1365
|
+
}
|
|
1366
|
+
for (const choice of ["Keep playwright e2e coverage; add unit tests (no new fixtures needed for e2e)"]) {
|
|
1367
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), [], choice);
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1338
1370
|
it("leaves unrelated decisions and contracts without decisions alone", () => {
|
|
1339
1371
|
assert.deepEqual(weakenedE2eDecisions(C), []);
|
|
1340
1372
|
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice: "Run the Playwright e2e suite against the live stack", source: "doc" }, { id: "K3", choice: "Use unit tests with mocks for the parser", source: "spec" }] }), []);
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -94,11 +94,22 @@ export const ownedModelEnv = (subagentModel = null) => {
|
|
|
94
94
|
const m = sanitizeCliModel(subagentModel);
|
|
95
95
|
return { ...(m ? { CLAUDE_CODE_SUBAGENT_MODEL: m } : {}), CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "0" };
|
|
96
96
|
};
|
|
97
|
-
/**
|
|
98
|
-
export const
|
|
99
|
-
|
|
97
|
+
/** #5118: ForgeDock's pre-tool-use guard (bin/hooks/pre-tool-use.mjs, rule 6 blocks Claude attribution trailers). */
|
|
98
|
+
export const PRE_TOOL_USE_HOOK = fileURLToPath(new URL("../hooks/pre-tool-use.mjs", import.meta.url));
|
|
99
|
+
/** #5118: a lane container has no operator settings.json, so it carries neither the guard nor the attribution pins. */
|
|
100
|
+
export const inSandbox = (env = process.env) => env.IS_SANDBOX === "1" || Boolean(env.CS_SANDBOX_ID);
|
|
101
|
+
/**
|
|
102
|
+
* The one `--settings` of a lean session: the owned model env, the attribution pins (#5118: Claude Code's default
|
|
103
|
+
* Co-Authored-By trailer off, whatever the config dir holds), the re-read hooks for builder sessions (#4456), and in a
|
|
104
|
+
* sandbox the pre-tool-use guard on Bash (#5118: second line of defence; local lanes get it from operator settings).
|
|
105
|
+
*/
|
|
106
|
+
export const leanSettingsFlags = ({ subagentModel = null, reread = false, guard = false } = {}) => {
|
|
107
|
+
const hooks = reread ? rereadHooks() : {};
|
|
108
|
+
if (guard) hooks.PreToolUse = [...(hooks.PreToolUse ?? []), { matcher: "Bash", hooks: [{ type: "command", command: `${JSON.stringify(process.execPath)} ${JSON.stringify(PRE_TOOL_USE_HOOK)}` }] }];
|
|
109
|
+
return ["--settings", JSON.stringify({ env: ownedModelEnv(subagentModel), includeCoAuthoredBy: false, attribution: { commit: "", pr: "" }, ...(Object.keys(hooks).length ? { hooks } : {}) })];
|
|
110
|
+
};
|
|
100
111
|
|
|
101
|
-
export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable, subagentModel = null } = {}) {
|
|
112
|
+
export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable, subagentModel = null, sandboxed = inSandbox() } = {}) {
|
|
102
113
|
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, rereadNudge = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
|
|
103
114
|
const bin = resolveClaudeCliBinary(cwd) || "claude";
|
|
104
115
|
const caps = getClaudeCliCapabilities(cwd);
|
|
@@ -106,7 +117,7 @@ export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRun
|
|
|
106
117
|
const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : [])];
|
|
107
118
|
// #4843 owned models, in extraArgs so FORGEDOCK_FULL_SESSION=1 (which drops sessionFlags) keeps them (#4848 BUG-1);
|
|
108
119
|
// #4456 re-read hooks: builder sessions only, and like the other session flags not in a full session
|
|
109
|
-
const settings = leanSettingsFlags({ subagentModel, reread: rereadNudge && !readOnly && !noTools && process.env.FORGEDOCK_FULL_SESSION !== "1" });
|
|
120
|
+
const settings = leanSettingsFlags({ subagentModel, reread: rereadNudge && !readOnly && !noTools && process.env.FORGEDOCK_FULL_SESSION !== "1", guard: sandboxed });
|
|
110
121
|
const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
|
|
111
122
|
args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: [...(resume ? ["--resume", resume] : []), ...settings],
|
|
112
123
|
logger: { log() {} },
|