forgedock 1.31.0 → 1.32.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 +51 -9
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/lean/batch.test.mjs +44 -1
- package/bin/lean/context.mjs +12 -1
- package/bin/lean/context.test.mjs +18 -1
- package/bin/lean/effects.mjs +13 -2
- package/bin/lean/lean.mjs +67 -15
- package/bin/lean/lean.test.mjs +177 -1
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/orchestrate.mjs +45 -6
- package/bin/lean/precheck.test.mjs +5 -0
- package/bin/lean/prefilter.mjs +69 -8
- package/bin/lean/prefilter.test.mjs +109 -2
- package/bin/lean/prompts.mjs +3 -0
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +48 -0
- package/bin/lean/schedd.test.mjs +21 -0
- package/bin/stats/efficiency.mjs +22 -6
- package/bin/stats/efficiency.test.mjs +35 -2
- package/bin/stats/index.mjs +25 -3
- package/bin/stats/local-source.mjs +59 -5
- package/bin/stats/query.mjs +133 -3
- package/bin/stats/stats.test.mjs +181 -5
- package/docs/CONFIG.md +3 -3
- package/docs/STATS.md +13 -0
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
package/bin/engine-cli.mjs
CHANGED
|
@@ -125,7 +125,7 @@ const warnedContextPack = new Set();
|
|
|
125
125
|
const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
|
|
126
126
|
/**
|
|
127
127
|
* forge.yaml context pack (#4275), flat keys under `engine` (the parser flattens one level): `context_pack`
|
|
128
|
-
* (shadow|on|off), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
|
|
128
|
+
* (shadow|on|off|ab, #4444), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
|
|
129
129
|
* `context_pack_days`. undefined when none is set (the core defaults to shadow). An invalid mode warns through `warn`
|
|
130
130
|
* (stderr, once per message per process) and is dropped; the other keys are kept (#4298).
|
|
131
131
|
*/
|
|
@@ -133,8 +133,8 @@ export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
|
|
|
133
133
|
try {
|
|
134
134
|
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
|
|
135
135
|
let mode = String(e.context_pack ?? "").trim().toLowerCase();
|
|
136
|
-
if (mode && !["shadow", "on", "off"].includes(mode)) {
|
|
137
|
-
warn(`forge.yaml engine.context_pack: invalid mode "${mode}" (expected shadow|on|off); using the default, other context_pack_* keys kept`);
|
|
136
|
+
if (mode && !["shadow", "on", "off", "ab"].includes(mode)) {
|
|
137
|
+
warn(`forge.yaml engine.context_pack: invalid mode "${mode}" (expected shadow|on|off|ab); using the default, other context_pack_* keys kept`);
|
|
138
138
|
mode = "";
|
|
139
139
|
}
|
|
140
140
|
const out = { ...(mode ? { mode } : {}), ...(e.context_pack_sources != null ? { sources: String(e.context_pack_sources) } : {}),
|
|
@@ -229,6 +229,13 @@ export function readPreconditionCmd(cwd = process.cwd()) {
|
|
|
229
229
|
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
230
230
|
} catch { return null; }
|
|
231
231
|
}
|
|
232
|
+
/** #4425: forge.yaml `post_deploy.min_age` ("24h", "7d"; opt-in soak window for parked post-deploy follow-ups) in ms, or null. */
|
|
233
|
+
export async function readPostDeployMinAge(cwd = process.cwd()) {
|
|
234
|
+
try {
|
|
235
|
+
const { parseMinAge } = await import("./lean/prefilter.mjs");
|
|
236
|
+
return parseMinAge(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.post_deploy?.min_age);
|
|
237
|
+
} catch { return null; }
|
|
238
|
+
}
|
|
232
239
|
/** forge.yaml `lean.explore` (on|off) and `lean.explore_model` (#4391): the builder's explorer subagent; undefined when neither is set or valid. */
|
|
233
240
|
export function readExplore(cwd = process.cwd()) {
|
|
234
241
|
try {
|
|
@@ -842,7 +849,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
842
849
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
843
850
|
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
844
851
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
845
|
-
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
|
|
852
|
+
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
|
|
846
853
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
847
854
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
848
855
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -1019,7 +1026,8 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
1019
1026
|
* `forgedock orchestrate --cancel <batch-id>`
|
|
1020
1027
|
* Runs core work-on per issue with K concurrency (default 3), honouring `Depends on` / `Blocked by` among the given issues.
|
|
1021
1028
|
* A `dependency-open` issue is requeued behind its dependency; a `decomposed` one adds its sub-issues (#4124).
|
|
1022
|
-
* The already-fixed pre-filter (#4123) runs before each lane unless `--no-prefilter`.
|
|
1029
|
+
* The already-fixed pre-filter (#4123) runs before each lane unless `--no-prefilter`. Every start, requeues included, first
|
|
1030
|
+
* checks GitHub (#4481, `makeDispatchCheck`): a closed issue or a merged `feat/issue-N-lean` PR ends the row without a lane.
|
|
1023
1031
|
* Declared acceptance probes (#4399, `lean/probe.mjs`) are swept once at batch start unless `--no-probes`.
|
|
1024
1032
|
* Each issue gets its own lane, worked out from its milestone (#4125); `--lane` is an assertion checked per issue (a
|
|
1025
1033
|
* mismatch ends `lane-refused`). Dependency edges between lanes are printed before any lane starts. Prints a summary table.
|
|
@@ -1294,7 +1302,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1294
1302
|
const branches = readBranches(cwd);
|
|
1295
1303
|
// #4389: the post-deploy gate's default branch is resolved and fetched once, only when a gated issue asks
|
|
1296
1304
|
const prefilter = argv.includes("--no-prefilter") ? null
|
|
1297
|
-
: makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd) });
|
|
1305
|
+
: makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd), minAge: await readPostDeployMinAge(cwd) });
|
|
1298
1306
|
const fetched = new Set();
|
|
1299
1307
|
const laneOf = async (n) => {
|
|
1300
1308
|
const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride });
|
|
@@ -1310,7 +1318,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1310
1318
|
const { runCore } = deps.leanModule;
|
|
1311
1319
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1312
1320
|
lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
1313
|
-
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
|
|
1321
|
+
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
|
|
1314
1322
|
} else {
|
|
1315
1323
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1316
1324
|
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
@@ -1480,7 +1488,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1480
1488
|
beforeAdmit,
|
|
1481
1489
|
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, ...lanes,
|
|
1482
1490
|
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1483
|
-
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1491
|
+
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1484
1492
|
...(remote ? { remote } : {}),
|
|
1485
1493
|
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
1486
1494
|
...(recoveryOn ? { recovery: orch.recovery } : {}),
|
|
@@ -1491,6 +1499,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1491
1499
|
}),
|
|
1492
1500
|
// A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
|
|
1493
1501
|
relabelNeedsHuman: deps.relabelNeedsHuman ?? makeRelabelNeedsHuman({ gh: (a) => leanIo.gh(a), repo }),
|
|
1502
|
+
// #4481: a requeued or resumed issue that was closed or whose lane PR merged meanwhile is not run again
|
|
1503
|
+
dispatchCheck: deps.dispatchCheck !== undefined ? deps.dispatchCheck : makeDispatchCheck({ gh: (a) => leanIo.gh(a), repo }),
|
|
1494
1504
|
// #4387: an out-of-set dependency labelled needs-human is not pulled into the batch
|
|
1495
1505
|
issueLabels: labelNames,
|
|
1496
1506
|
// #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
|
|
@@ -1550,6 +1560,38 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1550
1560
|
return { rows, crossLane, batch: batch.id, reclaimed, ok };
|
|
1551
1561
|
}
|
|
1552
1562
|
|
|
1563
|
+
/**
|
|
1564
|
+
* #4481: the dispatch-time "already done" check: `{outcome:"merged", pr}` when the issue's own lane PR
|
|
1565
|
+
* (`feat/issue-N-lean`) merged, `{outcome:"invalid"}` when the issue is closed without one, else null. Anything
|
|
1566
|
+
* unreadable (no state, non-JSON, a non-array PR list, a gh error) is null: the lane runs.
|
|
1567
|
+
* Release review #4500: an OPEN issue reopened after that PR merged (a failed acceptance probe, #4399, or an operator
|
|
1568
|
+
* reopening it for rework) is new work, so the lane runs; an unreadable reopen history also runs the lane.
|
|
1569
|
+
*/
|
|
1570
|
+
export function makeDispatchCheck({ gh, repo }) {
|
|
1571
|
+
const json = async (args) => { try { return JSON.parse(String(await gh(args))); } catch { return null; } };
|
|
1572
|
+
/** Latest `reopened` event time (ms) on the issue, 0 when never reopened, null when unreadable. */
|
|
1573
|
+
const lastReopen = async (n) => {
|
|
1574
|
+
try {
|
|
1575
|
+
const out = String(await gh(["api", `repos/${repo}/issues/${n}/events`, "--paginate", "--jq", '.[] | select(.event == "reopened") | .created_at']));
|
|
1576
|
+
return out.split("\n").map((l) => Date.parse(l.trim())).filter(Number.isFinite).reduce((a, b) => Math.max(a, b), 0);
|
|
1577
|
+
} catch { return null; }
|
|
1578
|
+
};
|
|
1579
|
+
return async (n) => {
|
|
1580
|
+
const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state"]);
|
|
1581
|
+
const state = typeof iss?.state === "string" ? iss.state.toUpperCase() : null;
|
|
1582
|
+
const prs = await json(["pr", "list", "-R", repo, "--head", `feat/issue-${n}-lean`, "--state", "merged", "--json", "number,mergedAt", "--limit", "1"]);
|
|
1583
|
+
const pr = Array.isArray(prs) && Number.isInteger(prs[0]?.number) ? prs[0].number : null;
|
|
1584
|
+
if (pr !== null && state === "OPEN") {
|
|
1585
|
+
const mergedAt = Date.parse(prs[0]?.mergedAt ?? "");
|
|
1586
|
+
const reopened = await lastReopen(n);
|
|
1587
|
+
if (reopened === null || !Number.isFinite(mergedAt) || reopened > mergedAt) return null;
|
|
1588
|
+
}
|
|
1589
|
+
if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
|
|
1590
|
+
if (state === "CLOSED") return { outcome: "invalid", detail: "issue closed" };
|
|
1591
|
+
return null;
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1553
1595
|
/**
|
|
1554
1596
|
* #4318: the batch runner's needs-human relabel. Re-reads the issue first: closed, or a live FORGE:STATE lease (the runner
|
|
1555
1597
|
* never holds one, so it is another actor's) → "skipped". Label first: a failed label add → "failed" with no comment (a
|
|
@@ -1834,7 +1876,7 @@ export async function triageFromCli(argv, deps = {}) {
|
|
|
1834
1876
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1835
1877
|
const { prefilterIssue, formatPrefilterSummary, formatParkedLine, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
|
|
1836
1878
|
const branches = readBranches();
|
|
1837
|
-
const gate = { defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd() }; // #4389
|
|
1879
|
+
const gate = { defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd(), minAge: await readPostDeployMinAge() }; // #4389, #4425
|
|
1838
1880
|
const parallel = flag(argv, "--parallel") == null ? 8 : parseInt(flag(argv, "--parallel"), 10);
|
|
1839
1881
|
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
1840
1882
|
// #4141: bounded pool. Each lane is fetched once (memoized promise); output stays in input order.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
/**
|
|
5
|
+
* Re-read nudge (#4456): a Claude CLI PostToolUse hook the lean engine passes to builder sessions with `--settings`
|
|
6
|
+
* (bin/lean/effects.mjs rereadNudgeFlags). It counts reads per path for one session, using the #4410 telemetry's
|
|
7
|
+
* definition of a read (toolstats readPaths), and on exactly the REREAD_MIN-th read of a path it returns an
|
|
8
|
+
* `additionalContext` system reminder. Advisory only: it always exits 0 and an error produces no output.
|
|
9
|
+
* State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json (a per-run cache).
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { readPaths, REREAD_MIN } from "../lean/toolstats.mjs";
|
|
16
|
+
|
|
17
|
+
/** Paths one hook payload reads, resolved against the payload cwd so `sed -n … a.js` and Read `/abs/a.js` agree. */
|
|
18
|
+
export function payloadReads(payload) {
|
|
19
|
+
const inp = payload?.tool_input ?? {};
|
|
20
|
+
const path = inp.file_path ?? inp.path;
|
|
21
|
+
const u = { name: payload?.tool_name ?? null, path: typeof path === "string" ? path : null, cmd: typeof inp.command === "string" ? inp.command : null };
|
|
22
|
+
const cwd = typeof payload?.cwd === "string" && payload.cwd ? payload.cwd : null;
|
|
23
|
+
return [...new Set(readPaths(u).map((p) => (cwd ? resolve(cwd, p) : p)))];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Pure core: counts after this payload and the reminder to emit (null when no path just reached REREAD_MIN). */
|
|
27
|
+
export function nudgeFor(payload, counts = {}) {
|
|
28
|
+
const next = { ...counts }, hot = [];
|
|
29
|
+
for (const p of payloadReads(payload)) {
|
|
30
|
+
next[p] = (next[p] ?? 0) + 1;
|
|
31
|
+
if (next[p] === REREAD_MIN) hot.push(p);
|
|
32
|
+
}
|
|
33
|
+
const message = hot.length ? `${hot.join(", ")} ${hot.length > 1 ? "have" : "has"} now been read ${REREAD_MIN} times in this session: `
|
|
34
|
+
+ "use your notes or the excerpt already in context; if you need a different part, read only that line range." : null;
|
|
35
|
+
return { counts: next, message };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const stateFile = (sessionId, dir = join(tmpdir(), "forgedock-reread")) =>
|
|
39
|
+
join(dir, `${String(sessionId).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "session"}.json`);
|
|
40
|
+
|
|
41
|
+
/** One hook call: stdin JSON in, the hookSpecificOutput line (or nothing) out. Fails open. */
|
|
42
|
+
export function main(input, { dir } = {}) {
|
|
43
|
+
try {
|
|
44
|
+
const payload = JSON.parse(input);
|
|
45
|
+
if (!payload?.session_id) return "";
|
|
46
|
+
if (!payloadReads(payload).length) return "";
|
|
47
|
+
const file = stateFile(payload.session_id, dir);
|
|
48
|
+
let counts = {};
|
|
49
|
+
try { counts = JSON.parse(readFileSync(file, "utf8")) ?? {}; } catch { /* first read of this session */ }
|
|
50
|
+
const r = nudgeFor(payload, counts);
|
|
51
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
52
|
+
writeFileSync(file, JSON.stringify(r.counts));
|
|
53
|
+
return r.message ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: r.message } })}\n` : "";
|
|
54
|
+
} catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
60
|
+
let input = "";
|
|
61
|
+
process.stdin.setEncoding("utf8");
|
|
62
|
+
process.stdin.on("data", (c) => { input += c; });
|
|
63
|
+
process.stdin.on("end", () => { process.stdout.write(main(input)); process.exitCode = 0; });
|
|
64
|
+
process.stdin.on("error", () => { process.exitCode = 0; });
|
|
65
|
+
}
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { join } from "node:path";
|
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
9
|
import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
|
|
10
10
|
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
|
|
11
|
-
import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman } from "../engine-cli.mjs";
|
|
11
|
+
import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
|
|
12
12
|
|
|
13
13
|
// orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir, also for spawned dispatchers.
|
|
14
14
|
process.env.FORGEDOCK_SCHED_DIR = tmpDir("batch-sched-");
|
|
@@ -586,3 +586,46 @@ describe("#4454: orchestrate's shutdown signal (schedd stop)", () => {
|
|
|
586
586
|
assert.ok(Date.now() - t0 < 1000);
|
|
587
587
|
});
|
|
588
588
|
});
|
|
589
|
+
|
|
590
|
+
describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", () => {
|
|
591
|
+
it("a requeued issue whose PR merged meanwhile ends merged without a second lane", async () => {
|
|
592
|
+
const { now, sleep } = fakeClock();
|
|
593
|
+
let runs = 0, checks = 0;
|
|
594
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep,
|
|
595
|
+
dispatchCheck: async () => (++checks === 1 ? null : { outcome: "merged", pr: 77, detail: "PR #77 already merged" }),
|
|
596
|
+
runOne: async () => { runs++; return { terminalReason: "deferred", code: "host.busy", retryInSeconds: 60 }; } });
|
|
597
|
+
assert.deepEqual([runs, checks], [1, 2], "checked on every start, the requeue included");
|
|
598
|
+
assert.deepEqual([rows[0].outcome, rows[0].pr], ["merged", 77]);
|
|
599
|
+
assert.match(rows[0].detail, /skipped at dispatch: PR #77 already merged/);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("a closed issue ends invalid without a lane and its dependents still run; a throwing check runs the lane", async () => {
|
|
603
|
+
const ran = [];
|
|
604
|
+
const rows = await orchestrate({ issues: [1, 2], readBody: async (n) => (n === 2 ? "Depends on #1" : ""),
|
|
605
|
+
dispatchCheck: async (n) => (n === 1 ? { outcome: "invalid", detail: "issue closed" } : (() => { throw new Error("gh down"); })()),
|
|
606
|
+
runOne: async (n) => { ran.push(n); return { terminalReason: "merged", cost_usd: 0 }; } });
|
|
607
|
+
assert.deepEqual(ran, [2]);
|
|
608
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "invalid"], [2, "merged"]]);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("makeDispatchCheck: merged head-branch PR, closed issue, open issue, unreadable answers fail open", async () => {
|
|
612
|
+
const mk = (issue, prs, events = "") => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
|
|
613
|
+
if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state"]); if (issue instanceof Error) throw issue; return issue; }
|
|
614
|
+
if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
|
|
615
|
+
assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
|
|
616
|
+
if (prs instanceof Error) throw prs;
|
|
617
|
+
return prs;
|
|
618
|
+
} });
|
|
619
|
+
const merged = '[{"number":12,"mergedAt":"2026-10-23T08:00:00Z"}]';
|
|
620
|
+
assert.deepEqual(await mk('{"state":"OPEN"}', merged)(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "open, never reopened: the merge is still closing it");
|
|
621
|
+
assert.deepEqual(await mk('{"state":"OPEN"}', merged, "2026-10-22T08:00:00Z\n")(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "reopened before the merge");
|
|
622
|
+
assert.equal(await mk('{"state":"OPEN"}', merged, "2026-10-22T08:00:00Z\n2026-10-23T09:00:00Z\n")(9), null, "reopened after the merge (failed probe, rework): the lane runs");
|
|
623
|
+
assert.equal(await mk('{"state":"OPEN"}', merged, new Error("api down"))(9), null, "unreadable reopen history: the lane runs");
|
|
624
|
+
assert.equal(await mk('{"state":"OPEN"}', '[{"number":12}]')(9), null, "no mergedAt: the lane runs");
|
|
625
|
+
assert.deepEqual(await mk('{"state":"CLOSED"}', merged, new Error("not read"))(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "closed: no reopen lookup");
|
|
626
|
+
assert.deepEqual(await mk('{"state":"CLOSED"}', "[]")(9), { outcome: "invalid", detail: "issue closed" });
|
|
627
|
+
assert.equal(await mk('{"state":"OPEN"}', "[]")(9), null);
|
|
628
|
+
assert.equal(await mk('{"body":"","labels":[]}', '{"body":""}')(9), null, "a stub without state or a list: run");
|
|
629
|
+
assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
|
|
630
|
+
});
|
|
631
|
+
});
|
package/bin/lean/context.mjs
CHANGED
|
@@ -21,7 +21,8 @@ import { parseFactsComment, factsLines, FACTS_RANK } from "./facts.mjs";
|
|
|
21
21
|
// noComments lives in render.mjs (#4298: one comment-strip helper for context, conformance and prefilter).
|
|
22
22
|
export { noComments };
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** #4444: `ab` is the experiment mode: each issue gets a stable arm, `on` or `shadow` (contextArm). */
|
|
25
|
+
export const CONTEXT_MODES = Object.freeze(["shadow", "on", "off", "ab"]);
|
|
25
26
|
export const CONTEXT_SOURCES = Object.freeze(["graph", "attempts", "pitfalls", "ledger", "facts"]);
|
|
26
27
|
export const DEFAULT_CONTEXT_BYTES = 6144;
|
|
27
28
|
export const DEFAULT_LEDGER_DAYS = 30;
|
|
@@ -53,6 +54,16 @@ export function parseContextPackConfig({ mode, sources, bytes, days, pitfallLane
|
|
|
53
54
|
return { mode: CONTEXT_MODES.includes(m) ? m : "shadow", sources: picked, budgetBytes: pos(bytes, DEFAULT_CONTEXT_BYTES), days: pos(days, DEFAULT_LEDGER_DAYS), pitfallLanes: pos(pitfallLanes, DEFAULT_PITFALL_LANES) };
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* #4444: the effective mode of one lane. In `ab` the arm comes from a stable hash of `repo#issue`, so every rerun of an
|
|
59
|
+
* issue stays in its arm and the arms are random, matched cohorts per lane type; any other mode passes through.
|
|
60
|
+
*/
|
|
61
|
+
export function contextArm({ mode, repo = "", issue } = {}) {
|
|
62
|
+
if (mode !== "ab") return { mode, ab: false };
|
|
63
|
+
const byte = createHash("sha256").update(`${String(repo).toLowerCase()}#${issue}`).digest()[0];
|
|
64
|
+
return { mode: byte % 2 === 0 ? "on" : "shadow", ab: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
56
67
|
/** `**Parent**: #N` (decomposition sub-issue body line); null when absent. */
|
|
57
68
|
export function parentOf(body) {
|
|
58
69
|
const m = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?parent(?:\*\*|__)?[ \t]*:?(?:\*\*|__)?[ \t]*:?[ \t]*#(\d+)\b/im.exec(stripComments(body));
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
6
|
-
import { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
|
|
6
|
+
import { gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
|
|
7
7
|
|
|
8
8
|
const REPO = "o/r";
|
|
9
9
|
const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
|
|
@@ -358,6 +358,23 @@ describe("#4298 context-pack cache and shared names", () => {
|
|
|
358
358
|
writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack_pitfall_lanes: 20\n");
|
|
359
359
|
assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { pitfallLanes: 20 }, "#4375: the pitfall aging window");
|
|
360
360
|
});
|
|
361
|
+
|
|
362
|
+
it("#4444: ab is a valid mode, and contextArm gives each issue a stable on|shadow arm", async () => {
|
|
363
|
+
const { readContextPack } = await import("../engine-cli.mjs");
|
|
364
|
+
const { tmpDir } = await import("../tests/_tmp.mjs");
|
|
365
|
+
const { writeFileSync } = await import("node:fs");
|
|
366
|
+
const d = tmpDir("ctxab-"), warns = [];
|
|
367
|
+
writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack: ab\n");
|
|
368
|
+
assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { mode: "ab" });
|
|
369
|
+
assert.equal(warns.length, 0);
|
|
370
|
+
assert.equal(parseContextPackConfig({ mode: "AB" }).mode, "ab");
|
|
371
|
+
assert.equal(parseContextPackConfig({}).mode, "shadow", "the default stays shadow");
|
|
372
|
+
for (const mode of ["shadow", "on", "off"]) assert.deepEqual(contextArm({ mode, repo: "o/r", issue: 1 }), { mode, ab: false });
|
|
373
|
+
const arms = Array.from({ length: 40 }, (_, i) => contextArm({ mode: "ab", repo: "o/r", issue: i + 1 }));
|
|
374
|
+
assert.ok(arms.every((a) => a.ab && ["on", "shadow"].includes(a.mode)));
|
|
375
|
+
assert.ok(arms.some((a) => a.mode === "on") && arms.some((a) => a.mode === "shadow"), "both arms are populated");
|
|
376
|
+
assert.deepEqual(contextArm({ mode: "ab", repo: "O/R", issue: 7 }), contextArm({ mode: "ab", repo: "o/r", issue: 7 }), "stable per issue");
|
|
377
|
+
});
|
|
361
378
|
});
|
|
362
379
|
|
|
363
380
|
describe("#4375 recurring pitfalls in the context pack", () => {
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { scopedChecks } from "./checks.mjs";
|
|
|
23
23
|
import { makeSequenceLeaseIo } from "./seqlease.mjs";
|
|
24
24
|
import { followupMap } from "./conformance.mjs";
|
|
25
25
|
import { toolStats } from "./toolstats.mjs";
|
|
26
|
+
import { detectApiShape } from "./apishape.mjs";
|
|
26
27
|
|
|
27
28
|
const pexec = promisify(execFile);
|
|
28
29
|
const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
|
|
@@ -60,12 +61,18 @@ export function wrapLaneLimits(bin, args, limits) {
|
|
|
60
61
|
export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
|
|
61
62
|
: ["--disallowedTools", "Read", "Grep", "Glob", "Bash", "Edit", "Write", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "Task"]);
|
|
62
63
|
|
|
64
|
+
/** #4456: `--settings` registering the re-read nudge (bin/hooks/reread-nudge.mjs) as a PostToolUse hook on Read and Bash. */
|
|
65
|
+
export const REREAD_HOOK = fileURLToPath(new URL("../hooks/reread-nudge.mjs", import.meta.url));
|
|
66
|
+
export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) => ["--settings", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "Read|Bash",
|
|
67
|
+
hooks: [{ type: "command", command: `${JSON.stringify(node)} ${JSON.stringify(hook)}` }] }] } })];
|
|
68
|
+
|
|
63
69
|
export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
|
|
64
|
-
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = 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 }) => {
|
|
70
|
+
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 }) => {
|
|
65
71
|
const bin = resolveClaudeCliBinary(cwd) || "claude";
|
|
66
72
|
const caps = getClaudeCliCapabilities(cwd);
|
|
67
73
|
// #4391: custom subagents (the explorer) for the builder session; null leaves argv unchanged
|
|
68
|
-
const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : [])
|
|
74
|
+
const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : []),
|
|
75
|
+
...(rereadNudge && !readOnly && !noTools ? rereadNudgeFlags() : [])]; // #4456: builder sessions only
|
|
69
76
|
const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
|
|
70
77
|
args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
|
|
71
78
|
logger: { log() {} },
|
|
@@ -316,6 +323,10 @@ export function makeLeanIo({ repo }) {
|
|
|
316
323
|
async structureDelta({ cwd, base }) {
|
|
317
324
|
try { return JSON.parse((await pexec("bash", [script("structure-delta.sh"), `${base}...HEAD`], { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
|
|
318
325
|
},
|
|
326
|
+
/** #4450: public-API-shape changes of base...HEAD in cwd (bin/lean/apishape.mjs); any failure is {status: "unknown"} (an upgrade). */
|
|
327
|
+
async apiShape({ cwd, base }) {
|
|
328
|
+
try { return await detectApiShape({ cwd, base, head: "HEAD" }); } catch (e) { return { status: "unknown", changes: [], unknown: [{ file: "*", lang: null, reason: String(e?.message ?? e).slice(0, 200) }] }; }
|
|
329
|
+
},
|
|
319
330
|
/** #4375: the repo's learned-pitfall ledger, local only (~/.forge/pitfalls, FORGE_PITFALLS_DIR). */
|
|
320
331
|
pitfalls: makePitfallStore(),
|
|
321
332
|
/** forge#4275: the context pack's facts for one issue (bin/lean/context.mjs), from gh and git only; sources fail independently. */
|
package/bin/lean/lean.mjs
CHANGED
|
@@ -45,8 +45,10 @@ import { writeHeavyShims, sumWaits, isHeavyProcess } from "./heavy.mjs";
|
|
|
45
45
|
import { priorityFromLabels } from "./sched.mjs";
|
|
46
46
|
import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule } from "./explore.mjs";
|
|
47
47
|
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
48
|
-
import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
|
|
48
|
+
import { parseContextPackConfig, contextArm, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
|
|
49
49
|
import { buildResumeDigest } from "./resume.mjs";
|
|
50
|
+
import { lightEligibility } from "./light.mjs";
|
|
51
|
+
import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
|
|
50
52
|
|
|
51
53
|
/**
|
|
52
54
|
* #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
|
|
@@ -232,6 +234,7 @@ export async function runCore(o) {
|
|
|
232
234
|
...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name }; // #4410
|
|
233
235
|
if (phase === "implement") { builderTriggers.push({ trigger, restart_of: restartOf }); impl.cost += c.delta ?? 0; if (r?.model && r.model !== "default") impl.models.add(r.model); tb.note(num(r?.turns), stage === "implement"); }
|
|
234
236
|
ev(rec); await emit(rec);
|
|
237
|
+
modelMs += rec.duration_ms; // #4451: model time next to the lane's wall time
|
|
235
238
|
const u = r?.usage ?? {};
|
|
236
239
|
usage.cost_usd += c.delta ?? 0;
|
|
237
240
|
usage.input += u.input_tokens ?? 0; usage.output += u.output_tokens ?? 0;
|
|
@@ -273,7 +276,11 @@ export async function runCore(o) {
|
|
|
273
276
|
// Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
|
|
274
277
|
const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
|
|
275
278
|
const failureClasses = new Set(); // forge#4275: conformance failure codes seen this run (gate failures, shadow would-fail)
|
|
276
|
-
const
|
|
279
|
+
const ctxBase = parseContextPackConfig(o.contextPack ?? {});
|
|
280
|
+
const ctxArm = contextArm({ mode: ctxBase.mode, repo, issue }); // #4444: ab picks a stable on|shadow arm per issue
|
|
281
|
+
const ctxCfg = { ...ctxBase, mode: ctxArm.mode };
|
|
282
|
+
const ctxAb = ctxArm.ab ? { context_ab: true } : {};
|
|
283
|
+
if (ctxArm.ab) T.decisions.push(`Context pack A/B (#4444): arm ${ctxArm.mode}`);
|
|
277
284
|
const exploreCfg = parseExploreConfig(o.explore ?? {}); // #4391
|
|
278
285
|
if (exploreCfg.mode === "on") T.decisions.push(`Explorer subagent (on): model ${exploreCfg.model}`);
|
|
279
286
|
let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
|
|
@@ -282,6 +289,8 @@ export async function runCore(o) {
|
|
|
282
289
|
let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
|
|
283
290
|
let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
|
|
284
291
|
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
292
|
+
let light = false, modelMs = 0; // #4451: the light lane finished without an upgrade (skips structure/hotspot gates); summed session time
|
|
293
|
+
let lightVerdict = null, lightUpgrade = null; // #4453: the eligibility verdict and the upgrade reason codes, on the run result for the bench
|
|
285
294
|
let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
|
|
286
295
|
const runId = `${issue}-${t0}`;
|
|
287
296
|
let runTmp = null;
|
|
@@ -399,7 +408,7 @@ export async function runCore(o) {
|
|
|
399
408
|
// forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
|
|
400
409
|
const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
|
|
401
410
|
conformance_mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
|
|
402
|
-
context_pack: ctxCfg.mode, explore: exploreCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
|
|
411
|
+
context_pack: ctxCfg.mode, ...ctxAb, explore: exploreCfg.mode, wall_ms: now() - t0, model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
|
|
403
412
|
...(route ? { builder_tier: route.tier, builder_class: route.class, builder_escalated: escalated, builder_model: route.model ?? "default" } : {}) }; // forge#4275: the on/off A/B groups runs by these; #4394: per-tier stats
|
|
404
413
|
ev(term);
|
|
405
414
|
await emit(term);
|
|
@@ -424,7 +433,8 @@ export async function runCore(o) {
|
|
|
424
433
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
425
434
|
}
|
|
426
435
|
await releaseLease(reason, code);
|
|
427
|
-
done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0,
|
|
436
|
+
done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0, model_ms: modelMs,
|
|
437
|
+
light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
|
|
428
438
|
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
|
|
429
439
|
...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
|
|
430
440
|
...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
|
|
@@ -672,7 +682,7 @@ export async function runCore(o) {
|
|
|
672
682
|
const governor = startGovernor(control);
|
|
673
683
|
let spawned = null;
|
|
674
684
|
const onSpawn = (child) => { if (Number.isSafeInteger(child?.pid) && !finishing) { spawned = child.pid; ledgerDo((l) => l.register("pid", child.pid, { marker: basename(String(child.spawnfile ?? "")) || null })); } };
|
|
675
|
-
const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: model ?? laneModel(), resume, system: "", prompt, control, onSpawn, agents: explorerAgents(exploreCfg),
|
|
685
|
+
const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: model ?? laneModel(), resume, system: "", prompt, control, onSpawn, agents: explorerAgents(exploreCfg), rereadNudge: true,
|
|
676
686
|
idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}),
|
|
677
687
|
busy: sessionBusy, busyMs: Math.round(cliBusyMinutes * 60000),
|
|
678
688
|
onIdleExtend: (info) => ev({ event: "CLI_IDLE_EXTENDED", children: info.children, heavyQueue: info.heavyQueue }) }));
|
|
@@ -1117,7 +1127,7 @@ export async function runCore(o) {
|
|
|
1117
1127
|
const inPack = Array.isArray(p.pitfalls) ? p.pitfalls : [];
|
|
1118
1128
|
pitMatched = inPack.filter((x) => x.match === "files").length;
|
|
1119
1129
|
// #4375: which pitfalls were in the pack (signatures only, run log only: never telemetry or a comment)
|
|
1120
|
-
const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
|
|
1130
|
+
const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, ...ctxAb, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
|
|
1121
1131
|
hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
|
|
1122
1132
|
ev({ ...rec, pitfalls: inPack }); await emit(rec);
|
|
1123
1133
|
const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
|
|
@@ -1356,13 +1366,55 @@ export async function runCore(o) {
|
|
|
1356
1366
|
}
|
|
1357
1367
|
}
|
|
1358
1368
|
await label("building");
|
|
1359
|
-
|
|
1369
|
+
// #4450: the light lane (lean.light mode on, eligible, fresh lane): one builder session, derived contract, or an upgrade
|
|
1370
|
+
const elig = lightEligibility({ title: iss.title, body: iss.body ?? "", labels: iss.labels ?? [], predictedFiles: namedPaths(iss.body ?? ""), riskPaths, cfg: o.light ?? {} });
|
|
1371
|
+
ev({ event: "LIGHT_ELIGIBILITY", mode: elig.mode, verdict: elig.verdict, light: elig.light, reasons: elig.reasons });
|
|
1372
|
+
lightVerdict = elig.verdict;
|
|
1373
|
+
if (elig.mode !== "off") T.decisions.push(`Light lane ${elig.mode}: ${elig.verdict}${elig.reasons.length ? ` (${elig.reasons.map((x) => x.code).join(", ")})` : ""}`);
|
|
1374
|
+
// #4451: a light try skips the hotspots and the context pack; an upgrade computes them before the plan call
|
|
1375
|
+
const lightTry = elig.light && !frozen && !resumed && !salvagedRef && !recover;
|
|
1376
|
+
if (!lightTry) hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
|
|
1360
1377
|
enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
|
|
1361
1378
|
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1362
|
-
|
|
1379
|
+
let ctxPack = frozen || lightTry ? null : await contextPackStep();
|
|
1363
1380
|
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1381
|
+
let lightDone = false, lightCommits = false;
|
|
1382
|
+
if (lightTry) {
|
|
1383
|
+
liveKeys = ["implementation"]; stage = "implement";
|
|
1384
|
+
tb.setSize(laneSize({ labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }));
|
|
1385
|
+
const lr = await runLightStep({ title: iss.title, issueText, riskPaths, cfg: o.light ?? {}, validate: (c) => validateContract(c, issueText, { repro: reproOn() }),
|
|
1386
|
+
build: () => builder(++attemptN, lightBuildPrompt(issue, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList), null, true, undefined, { trigger: "build" }),
|
|
1387
|
+
readResult, checkpoint: () => checkpoint("light build"), changedFiles, reproOf, numstat: () => git(["diff", "--numstat", `${base}...HEAD`]),
|
|
1388
|
+
runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: cmds, parallelism: Number(verifyParallelism()) || 1, now, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
|
|
1389
|
+
run: ({ command, workers }) => { const env = laneEnv(); return io.runCommand({ command, cwd: wt, env: env.FORGE_HOST_PARALLELISM ? { ...env, FORGE_HOST_PARALLELISM: String(workers) } : env }); } }) : null),
|
|
1390
|
+
fixTests: (failed) => builder(++attemptN, precheckFailurePrompt(capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log })), vcfg.mode), sessionId, false, undefined, { mode: "resume", trigger: "precheck" }),
|
|
1391
|
+
apiShape: () => (io.apiShape ? io.apiShape({ cwd: wt, base }).catch(() => null) : null) });
|
|
1392
|
+
stage = "plan"; liveKeys = ["investigation"]; result = (await readResult()) ?? result;
|
|
1393
|
+
if (!lr.upgrade) {
|
|
1394
|
+
lightDone = true; light = true; frozen = lr.contract; hash = contractHash(contractJson(frozen));
|
|
1395
|
+
let body = renderContract(frozen, hash, { scope: scopeHash });
|
|
1396
|
+
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash });
|
|
1397
|
+
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
1398
|
+
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: 0, derived: "light" });
|
|
1399
|
+
ev({ event: "LIGHT_DONE", files: lr.files.length, test_failures: lr.testFailures, repro: lr.repro, cost_usd: usage.cost_usd });
|
|
1400
|
+
T.decisions.push(`Light lane: one builder session, contract ${hash} derived from the diff and the test`);
|
|
1401
|
+
try { await post("FORGE:LITE", renderLiteReceipt({ lr, eligibility: elig, costUsd: usage.cost_usd, contractHash: hash })); } catch { /* #4452: the receipt is best effort */ }
|
|
1402
|
+
} else {
|
|
1403
|
+
lightCommits = lr.files.length > 0;
|
|
1404
|
+
ev({ event: "LIGHT_UPGRADE", reasons: lr.reasons, cost_usd: usage.cost_usd });
|
|
1405
|
+
lightUpgrade = lr.reasons.map((x) => x.code);
|
|
1406
|
+
T.decisions.push(`Light lane upgraded to the full lane: ${lr.reasons.map((x) => x.code).join(", ")}`);
|
|
1407
|
+
try { await post("FORGE:LITE", renderLiteReceipt({ lr, eligibility: elig, costUsd: usage.cost_usd })); } catch { /* #4452: the receipt is best effort */ }
|
|
1408
|
+
const d = buildResumeDigest({ state: null, comments: trustedComments, contract: null, files: lr.files, lane });
|
|
1409
|
+
resumeNote = d.text + upgradeNote(lr.reasons);
|
|
1410
|
+
result = null; // the light result.json must not read as the plan's verdict
|
|
1411
|
+
if (io.fs.write) { try { await io.fs.write(`${wt}/${RESULT_FILE}`, "{}\n"); } catch { /* the plan overwrites it */ } }
|
|
1412
|
+
hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") }); // #4451: the full lane keeps its plan facts
|
|
1413
|
+
ctxPack = await contextPackStep();
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1364
1416
|
if (!frozen) {
|
|
1365
|
-
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote() + resumeNote, null, true, planPrompt, { trigger: "plan" });
|
|
1417
|
+
await builder(++attemptN, planPrompt() + (hasCommits || lightCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote() + resumeNote, null, true, planPrompt, { trigger: "plan" });
|
|
1366
1418
|
planned = true;
|
|
1367
1419
|
result = await readResult();
|
|
1368
1420
|
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
@@ -1427,9 +1479,9 @@ export async function runCore(o) {
|
|
|
1427
1479
|
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
1428
1480
|
T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
|
|
1429
1481
|
} else {
|
|
1430
|
-
T.validate = "resumed (existing branch)";
|
|
1482
|
+
T.validate = lightDone ? "light lane (no plan session)" : "resumed (existing branch)";
|
|
1431
1483
|
}
|
|
1432
|
-
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
1484
|
+
T.plan = [`contract ${hash}${lightDone ? " (derived, light lane)" : ""}`, `${frozen.requirements.length} requirement(s)`];
|
|
1433
1485
|
tb.setSize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) })); // #4392
|
|
1434
1486
|
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
1435
1487
|
// #4364: what `forgedock check` needs from the operator's forge.yaml (gitignored, so not in the worktree)
|
|
@@ -1443,7 +1495,7 @@ export async function runCore(o) {
|
|
|
1443
1495
|
const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + exploreBuildRule(exploreCfg) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote() + (planned ? "" : resumeNote);
|
|
1444
1496
|
liveKeys = ["implementation"];
|
|
1445
1497
|
stage = "implement";
|
|
1446
|
-
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true, undefined, { trigger: "build" });
|
|
1498
|
+
if (!lightDone) await builder(++attemptN, implementPrompt, planned ? sessionId : null, true, undefined, { trigger: "build" });
|
|
1447
1499
|
stage = "fix"; // later builder rounds fix precheck, CI, structure, docs or conformance findings
|
|
1448
1500
|
result = await readResult();
|
|
1449
1501
|
{ // #4374: the builder's self-review against the shared rubric, log only
|
|
@@ -1530,7 +1582,7 @@ export async function runCore(o) {
|
|
|
1530
1582
|
const d = await io.structureDelta({ cwd: wt, base }).catch(() => null);
|
|
1531
1583
|
return d && typeof d === "object" && Array.isArray(d.package_edges_added) ? d : null; // `{}`: no workspace packages
|
|
1532
1584
|
};
|
|
1533
|
-
let structure = await structureOf();
|
|
1585
|
+
let structure = light ? null : await structureOf(); // #4451: no structure delta on a light lane
|
|
1534
1586
|
if (structure) {
|
|
1535
1587
|
const pairs = structure.import_pairs ?? [], sum = (k) => pairs.reduce((n, p) => n + (Number(p?.[k]) || 0), 0);
|
|
1536
1588
|
const rec = { event: "STRUCTURE_DELTA", edges_added: structure.package_edges_added.length, edges_removed: (structure.package_edges_removed ?? []).length,
|
|
@@ -1872,7 +1924,7 @@ export async function runCore(o) {
|
|
|
1872
1924
|
}
|
|
1873
1925
|
const amendProblems = await amend();
|
|
1874
1926
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
|
|
1875
|
-
const hotspots = await hotspotsOf({ base, paths: files });
|
|
1927
|
+
const hotspots = light ? null : await hotspotsOf({ base, paths: files }); // #4451
|
|
1876
1928
|
const validateRuns = await rerunFailedValidates(effective);
|
|
1877
1929
|
const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
|
|
1878
1930
|
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen });
|
|
@@ -2234,7 +2286,7 @@ export async function runCore(o) {
|
|
|
2234
2286
|
* only: the single telemetry write is the run's one extra API call. Also feeds the trajectory's Structure table.
|
|
2235
2287
|
*/
|
|
2236
2288
|
async function recordStructure(pr) {
|
|
2237
|
-
const d = (await structureOf()) ?? {};
|
|
2289
|
+
const d = (light ? null : await structureOf()) ?? {}; // #4451
|
|
2238
2290
|
const numstat = String(await git(["diff", "--numstat", "--no-renames", `${base}...HEAD`]).catch(() => ""));
|
|
2239
2291
|
const top_growth = numstat.split("\n").map((l) => l.split("\t")).filter((x) => x.length >= 3 && /^\d+$/.test(x[0]) && Number(x[0]) > 0 && !x[2].startsWith(`${LEAN_DIR}/`))
|
|
2240
2292
|
.map(([a, , file]) => ({ file, lines: Number(a) })).sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file)).slice(0, 5);
|