forgedock 1.31.0 → 1.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/phases.mjs +28 -7
- package/bin/engine-cli.mjs +79 -22
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/lean/batch.mjs +9 -3
- package/bin/lean/batch.test.mjs +75 -2
- package/bin/lean/context.mjs +12 -1
- package/bin/lean/context.test.mjs +18 -1
- package/bin/lean/effects.mjs +41 -4
- package/bin/lean/execution.mjs +4 -2
- package/bin/lean/lean.mjs +83 -18
- package/bin/lean/lean.test.mjs +265 -4
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/orchestrate.mjs +75 -15
- 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/retry.mjs +15 -1
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +48 -0
- package/bin/lean/schedd.mjs +50 -14
- package/bin/lean/schedd.test.mjs +153 -20
- 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/scripts/refresh-bot-token.test.sh +56 -0
package/bin/engine/phases.mjs
CHANGED
|
@@ -309,19 +309,40 @@ async function readIssueRecord(n, io) {
|
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
// A declaration line: optional list marker, optional bold, "Depends on" or "Blocked by" (any case), optional colon
|
|
312
|
-
// inside or after the bold, then
|
|
313
|
-
|
|
312
|
+
// inside or after the bold, then a list of `#N` (#4121, #4506). The list is `#N` items joined by `,` `&` `and` `or`
|
|
313
|
+
// (each item may carry a parenthetical note, whose own `#N` are prose); any other text ends it, so a trailing
|
|
314
|
+
// "see #99", "Fixes #12", a code span or a sentence never adds an edge (release review #4515).
|
|
315
|
+
const DEPENDS_ON_RE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?(?:depends on|blocked by)[ \t]*:?(?:\*\*|__)?[ \t]*:?[ \t]*(#\d+\b[^\n]*)/gim;
|
|
316
|
+
const DEP_ITEM_RE = /^(?<![\w/])#(\d+)\b(?:[ \t]*\([^()\n]*\))?/;
|
|
317
|
+
const DEP_JOIN_RE = /^[ \t]*(?:,[ \t]*(?:(?:and|or)\b[ \t]*)?|&[ \t]*|(?:and|or)\b[ \t]*)/i;
|
|
318
|
+
|
|
319
|
+
/** The `#N` list at the start of a declaration's tail: items and joiners only, stopping at the first other text. */
|
|
320
|
+
function dependsOnList(tail) {
|
|
321
|
+
const out = [];
|
|
322
|
+
let rest = tail;
|
|
323
|
+
for (;;) {
|
|
324
|
+
const item = DEP_ITEM_RE.exec(rest);
|
|
325
|
+
if (!item) break;
|
|
326
|
+
out.push(Number(item[1]));
|
|
327
|
+
rest = rest.slice(item[0].length);
|
|
328
|
+
const join = DEP_JOIN_RE.exec(rest);
|
|
329
|
+
if (!join || !/^#\d/.test(rest.slice(join[0].length))) break;
|
|
330
|
+
rest = rest.slice(join[0].length);
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
314
334
|
|
|
315
335
|
/**
|
|
316
336
|
* Issue numbers an issue body declares it waits on: `**Depends on**: #X`, `**Depends on** #X, #Y`, `Depends on #X`,
|
|
317
|
-
* `- Depends on: #X and #Y`, `Blocked by #X`. Only declaration lines count
|
|
318
|
-
*
|
|
337
|
+
* `- Depends on: #X and #Y`, `Depends on #X (why) and #Y (why)`, `Blocked by #X`. Only declaration lines count, and on
|
|
338
|
+
* them only the leading `#N` list; a mid-line or trailing mention is prose. Shared by the engine's admission
|
|
339
|
+
* (`openDependency`) and `forgedock orchestrate` so both agree on what blocks what.
|
|
319
340
|
* @returns {number[]} unique, positive, never `issue` itself
|
|
320
341
|
*/
|
|
321
342
|
export function parseDependsOn(body, issue = null) {
|
|
322
343
|
const out = new Set();
|
|
323
344
|
for (const m of String(body ?? "").replace(/\r\n/g, "\n").matchAll(DEPENDS_ON_RE)) {
|
|
324
|
-
for (const
|
|
345
|
+
for (const n of dependsOnList(m[1])) out.add(n);
|
|
325
346
|
}
|
|
326
347
|
return [...out].filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
|
|
327
348
|
}
|
|
@@ -329,8 +350,8 @@ export function parseDependsOn(body, issue = null) {
|
|
|
329
350
|
/**
|
|
330
351
|
* forge#3932: does the issue declare a `Depends on` / `Blocked by` dependency that is still OPEN?
|
|
331
352
|
* Honoured only when the issue BODY's author is trusted (`isTrustedComment`: OWNER/MEMBER/COLLABORATOR,
|
|
332
|
-
* Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for a declaration line `parseDependsOn`
|
|
333
|
-
* prose is ignored). A self-reference is ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
|
|
353
|
+
* Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for a declaration line's leading `#N` list as `parseDependsOn` reads it (mid-line
|
|
354
|
+
* and trailing prose is ignored). A self-reference is ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
|
|
334
355
|
* when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
|
|
335
356
|
* An empty read of the issue itself means "no body to trust" and yields `null`.
|
|
336
357
|
*/
|
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.
|
|
@@ -1191,19 +1199,34 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1191
1199
|
}
|
|
1192
1200
|
msg = { op: "submit", argv: sub, cwd: process.cwd(), env: schedd.batchEnv(process.env) };
|
|
1193
1201
|
}
|
|
1194
|
-
|
|
1202
|
+
let sock = await connect(), req = msg, reattached = false, losses = 0;
|
|
1195
1203
|
let id = resumeId;
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1204
|
+
const onMsg = (m) => {
|
|
1205
|
+
if (m.type === "batch") id = m.id;
|
|
1206
|
+
else if (m.type === "row") { if (!(reattached && m.replay)) console.log(formatRowLine(m.row)); }
|
|
1207
|
+
else if (m.type === "line") console.log(m.text);
|
|
1208
|
+
else if (m.type === "done" && m.replay && m.report) console.log(m.report);
|
|
1209
|
+
};
|
|
1210
|
+
// #4503: when the daemon dies mid-batch, reconnect (spawning one unless `spawnDaemon: null`) and re-attach, within `reconnectMs`.
|
|
1211
|
+
const reconnectMs = deps.reconnectMs ?? 60_000, sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1212
|
+
for (;;) {
|
|
1213
|
+
try {
|
|
1214
|
+
return (await schedd.request(sock, req, onMsg)).result;
|
|
1215
|
+
} catch (e) {
|
|
1216
|
+
if (!id || !(/closed the connection/.test(e?.message ?? "") || ["ECONNRESET", "EPIPE"].includes(e?.code))) throw e;
|
|
1217
|
+
const giveUp = () => new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
|
|
1218
|
+
if (++losses > 10) throw giveUp();
|
|
1219
|
+
console.log(`schedd connection lost; reconnecting to batch ${id}`);
|
|
1220
|
+
const end = Date.now() + reconnectMs;
|
|
1221
|
+
for (sock = null; !sock;) {
|
|
1222
|
+
try { sock = await connect(); } catch {
|
|
1223
|
+
if (Date.now() >= end) throw giveUp();
|
|
1224
|
+
await sleep(250);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
req = { op: "attach", id };
|
|
1228
|
+
reattached = true;
|
|
1229
|
+
}
|
|
1207
1230
|
}
|
|
1208
1231
|
}
|
|
1209
1232
|
|
|
@@ -1294,7 +1317,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1294
1317
|
const branches = readBranches(cwd);
|
|
1295
1318
|
// #4389: the post-deploy gate's default branch is resolved and fetched once, only when a gated issue asks
|
|
1296
1319
|
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) });
|
|
1320
|
+
: makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd), minAge: await readPostDeployMinAge(cwd) });
|
|
1298
1321
|
const fetched = new Set();
|
|
1299
1322
|
const laneOf = async (n) => {
|
|
1300
1323
|
const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride });
|
|
@@ -1310,7 +1333,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1310
1333
|
const { runCore } = deps.leanModule;
|
|
1311
1334
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1312
1335
|
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) } : {}) }) };
|
|
1336
|
+
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
1337
|
} else {
|
|
1315
1338
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1316
1339
|
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
@@ -1342,7 +1365,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1342
1365
|
const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
|
|
1343
1366
|
lanes = withPlacement({
|
|
1344
1367
|
runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
|
|
1345
|
-
adopt: (n, rec) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec),
|
|
1368
|
+
adopt: (n, rec, ctx) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec, ctx),
|
|
1346
1369
|
});
|
|
1347
1370
|
}
|
|
1348
1371
|
// #4255: hard execution budget per batch and per host UTC day; every settled sandbox is recorded and reported.
|
|
@@ -1480,7 +1503,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1480
1503
|
beforeAdmit,
|
|
1481
1504
|
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, ...lanes,
|
|
1482
1505
|
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)) } : {}),
|
|
1506
|
+
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1484
1507
|
...(remote ? { remote } : {}),
|
|
1485
1508
|
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
1486
1509
|
...(recoveryOn ? { recovery: orch.recovery } : {}),
|
|
@@ -1491,6 +1514,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1491
1514
|
}),
|
|
1492
1515
|
// A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
|
|
1493
1516
|
relabelNeedsHuman: deps.relabelNeedsHuman ?? makeRelabelNeedsHuman({ gh: (a) => leanIo.gh(a), repo }),
|
|
1517
|
+
// #4481: a requeued or resumed issue that was closed or whose lane PR merged meanwhile is not run again
|
|
1518
|
+
dispatchCheck: deps.dispatchCheck !== undefined ? deps.dispatchCheck : makeDispatchCheck({ gh: (a) => leanIo.gh(a), repo }),
|
|
1494
1519
|
// #4387: an out-of-set dependency labelled needs-human is not pulled into the batch
|
|
1495
1520
|
issueLabels: labelNames,
|
|
1496
1521
|
// #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
|
|
@@ -1550,6 +1575,38 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1550
1575
|
return { rows, crossLane, batch: batch.id, reclaimed, ok };
|
|
1551
1576
|
}
|
|
1552
1577
|
|
|
1578
|
+
/**
|
|
1579
|
+
* #4481: the dispatch-time "already done" check: `{outcome:"merged", pr}` when the issue's own lane PR
|
|
1580
|
+
* (`feat/issue-N-lean`) merged, `{outcome:"invalid"}` when the issue is closed without one, else null. Anything
|
|
1581
|
+
* unreadable (no state, non-JSON, a non-array PR list, a gh error) is null: the lane runs.
|
|
1582
|
+
* Release review #4500: an OPEN issue reopened after that PR merged (a failed acceptance probe, #4399, or an operator
|
|
1583
|
+
* reopening it for rework) is new work, so the lane runs; an unreadable reopen history also runs the lane.
|
|
1584
|
+
*/
|
|
1585
|
+
export function makeDispatchCheck({ gh, repo }) {
|
|
1586
|
+
const json = async (args) => { try { return JSON.parse(String(await gh(args))); } catch { return null; } };
|
|
1587
|
+
/** Latest `reopened` event time (ms) on the issue, 0 when never reopened, null when unreadable. */
|
|
1588
|
+
const lastReopen = async (n) => {
|
|
1589
|
+
try {
|
|
1590
|
+
const out = String(await gh(["api", `repos/${repo}/issues/${n}/events`, "--paginate", "--jq", '.[] | select(.event == "reopened") | .created_at']));
|
|
1591
|
+
return out.split("\n").map((l) => Date.parse(l.trim())).filter(Number.isFinite).reduce((a, b) => Math.max(a, b), 0);
|
|
1592
|
+
} catch { return null; }
|
|
1593
|
+
};
|
|
1594
|
+
return async (n) => {
|
|
1595
|
+
const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state"]);
|
|
1596
|
+
const state = typeof iss?.state === "string" ? iss.state.toUpperCase() : null;
|
|
1597
|
+
const prs = await json(["pr", "list", "-R", repo, "--head", `feat/issue-${n}-lean`, "--state", "merged", "--json", "number,mergedAt", "--limit", "1"]);
|
|
1598
|
+
const pr = Array.isArray(prs) && Number.isInteger(prs[0]?.number) ? prs[0].number : null;
|
|
1599
|
+
if (pr !== null && state === "OPEN") {
|
|
1600
|
+
const mergedAt = Date.parse(prs[0]?.mergedAt ?? "");
|
|
1601
|
+
const reopened = await lastReopen(n);
|
|
1602
|
+
if (reopened === null || !Number.isFinite(mergedAt) || reopened > mergedAt) return null;
|
|
1603
|
+
}
|
|
1604
|
+
if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
|
|
1605
|
+
if (state === "CLOSED") return { outcome: "invalid", detail: "issue closed" };
|
|
1606
|
+
return null;
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1553
1610
|
/**
|
|
1554
1611
|
* #4318: the batch runner's needs-human relabel. Re-reads the issue first: closed, or a live FORGE:STATE lease (the runner
|
|
1555
1612
|
* never holds one, so it is another actor's) → "skipped". Label first: a failed label add → "failed" with no comment (a
|
|
@@ -1834,7 +1891,7 @@ export async function triageFromCli(argv, deps = {}) {
|
|
|
1834
1891
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1835
1892
|
const { prefilterIssue, formatPrefilterSummary, formatParkedLine, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
|
|
1836
1893
|
const branches = readBranches();
|
|
1837
|
-
const gate = { defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd() }; // #4389
|
|
1894
|
+
const gate = { defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd(), minAge: await readPostDeployMinAge() }; // #4389, #4425
|
|
1838
1895
|
const parallel = flag(argv, "--parallel") == null ? 8 : parseInt(flag(argv, "--parallel"), 10);
|
|
1839
1896
|
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
1840
1897
|
// #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.mjs
CHANGED
|
@@ -181,7 +181,10 @@ export async function waitLane({ batch, issue, pid, alive = isLaneAlive, sleep =
|
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Lane seams for `orchestrate()`: `runOne` launches a detached lane, `adopt` re-attaches to a live one (else null). A lane
|
|
186
|
+
* that already ended resolves at once with its result file; a live one calls `ctx.onLive()` before it is awaited (#4504).
|
|
187
|
+
*/
|
|
185
188
|
export function detachedLanes({ batch, laneArgs, script, cwd, env, bin, alive = isLaneAlive, pollMs, spawnFn }) {
|
|
186
189
|
return {
|
|
187
190
|
runOne: async (n, lane, ctx = {}) => {
|
|
@@ -189,9 +192,12 @@ export function detachedLanes({ batch, laneArgs, script, cwd, env, bin, alive =
|
|
|
189
192
|
ctx.onStart?.({ pid });
|
|
190
193
|
return waitLane({ batch, issue: n, pid, alive, pollMs });
|
|
191
194
|
},
|
|
192
|
-
adopt: async (n, rec) => {
|
|
195
|
+
adopt: async (n, rec, ctx = {}) => {
|
|
193
196
|
const rf = batch.resultFile(n);
|
|
194
|
-
if (!existsSync(rf)
|
|
197
|
+
if (!existsSync(rf)) {
|
|
198
|
+
if (!alive(rec.pid, rf)) return null;
|
|
199
|
+
ctx.onLive?.();
|
|
200
|
+
}
|
|
195
201
|
return waitLane({ batch, issue: n, pid: rec.pid, alive, pollMs });
|
|
196
202
|
},
|
|
197
203
|
};
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -7,8 +7,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
7
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
|
-
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
|
|
11
|
-
import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman } from "../engine-cli.mjs";
|
|
10
|
+
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.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,76 @@ 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
|
+
});
|
|
632
|
+
|
|
633
|
+
describe("#4504: a resume settles lanes that ended while no dispatcher ran", () => {
|
|
634
|
+
it("#4504: a resume settles a dead lane's result and requeues a dead lane without one before scheduling, while an earlier live lane still runs", async () => {
|
|
635
|
+
const dir = tmpDir("batch-4504-");
|
|
636
|
+
const batch = { resultFile: (n) => join(dir, `${n}.json`), logFile: (n) => join(dir, `${n}.log`) };
|
|
637
|
+
writeFileSync(batch.resultFile(2), JSON.stringify({ terminalReason: "merged", pr: 102 }));
|
|
638
|
+
const { adopt } = detachedLanes({ batch, laneArgs: () => [], script: "x", alive: (pid) => pid === 11, pollMs: 5 });
|
|
639
|
+
let saved = { v: 1, order: [1, 2, 3, 4], deps: { 1: [], 2: [], 3: [], 4: [] }, lanes: {}, crossSeen: [], results: {},
|
|
640
|
+
running: { 1: { startedAt: 1, pid: 11 }, 2: { startedAt: 1, pid: 22 }, 3: { startedAt: 1, pid: 33 } }, pending: [4],
|
|
641
|
+
notBefore: {}, requeues: {}, deferrals: {}, engineErrors: {}, prefilter: {}, errorTimes: [], breaker: null };
|
|
642
|
+
const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
|
|
643
|
+
const launched = [];
|
|
644
|
+
const done = orchestrate({ issues: [], parallel: 3, pollMs: 5, readBody: async () => "", store, adopt,
|
|
645
|
+
runOne: async (n) => { launched.push(n); return { terminalReason: "merged", pr: 100 + n }; } });
|
|
646
|
+
let rows;
|
|
647
|
+
try {
|
|
648
|
+
await until(() => launched.includes(3) && launched.includes(4) && saved.results?.["2"], 3000);
|
|
649
|
+
assert.equal(saved.results["2"].outcome, "merged", "the dead lane's result is settled");
|
|
650
|
+
assert.equal(saved.results["2"].pr, 102);
|
|
651
|
+
assert.ok(saved.running["1"], "the live lane is still running");
|
|
652
|
+
assert.equal(saved.running["2"], undefined, "the settled lane left running");
|
|
653
|
+
assert.ok(!launched.includes(1) && !launched.includes(2), "neither adopted lane is relaunched");
|
|
654
|
+
} finally {
|
|
655
|
+
writeFileSync(batch.resultFile(1), JSON.stringify({ terminalReason: "merged", pr: 101 })); // the live lane ends
|
|
656
|
+
rows = await done;
|
|
657
|
+
}
|
|
658
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102], [3, "merged", 103], [4, "merged", 104]]);
|
|
659
|
+
assert.deepEqual(launched.sort(), [3, 4], "the dead lane without a result is requeued and run once");
|
|
660
|
+
});
|
|
661
|
+
});
|
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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
|
|
4
4
|
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
6
|
+
import { readFileSync, appendFileSync, mkdirSync, mkdtempSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
7
7
|
import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
|
|
8
8
|
import { promises as fsp } from "node:fs";
|
|
9
9
|
import { dirname, join, resolve, sep } from "node:path";
|
|
@@ -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() {} },
|
|
@@ -227,10 +234,36 @@ export function runCheckCommand({ command, cwd, timeoutMs = 300000, env = null }
|
|
|
227
234
|
});
|
|
228
235
|
}
|
|
229
236
|
|
|
237
|
+
/**
|
|
238
|
+
* #4507: a per-run gh HTTP cache (XDG_CACHE_HOME on the gh child only), so concurrent lanes never share a cache entry.
|
|
239
|
+
* `reset()` drops the current dir (only one this cache created) and switches to a fresh one; with no `root` the dirs
|
|
240
|
+
* are mkdtemp dirs under `tmp`, removed by `dispose()`.
|
|
241
|
+
*/
|
|
242
|
+
export function makeGhCache({ root = null, tmp = tmpdir() } = {}) {
|
|
243
|
+
let n = 0, dir = root ? join(root, "gh-cache") : null, disposed = false;
|
|
244
|
+
const made = new Set();
|
|
245
|
+
const drop = (d) => { if (d && made.has(d)) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } made.delete(d); } };
|
|
246
|
+
return {
|
|
247
|
+
env() {
|
|
248
|
+
if (!dir || disposed) return undefined; // after dispose (run teardown) gh falls back to its default cache
|
|
249
|
+
if (!made.has(dir)) { try { mkdirSync(dir, { recursive: true, mode: 0o700 }); made.add(dir); } catch { return undefined; } }
|
|
250
|
+
return { XDG_CACHE_HOME: dir };
|
|
251
|
+
},
|
|
252
|
+
reset() {
|
|
253
|
+
drop(dir);
|
|
254
|
+
if (disposed) return;
|
|
255
|
+
if (root) { dir = join(root, `gh-cache-${++n}`); return; }
|
|
256
|
+
try { dir = mkdtempSync(join(tmp, "forge-gh-cache-")); made.add(dir); } catch { dir = null; }
|
|
257
|
+
},
|
|
258
|
+
dispose() { disposed = true; for (const d of [...made]) drop(d); },
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
230
262
|
export function makeLeanIo({ repo }) {
|
|
231
263
|
const base = makeIo();
|
|
232
|
-
const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
|
|
264
|
+
const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000, ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}) })).stdout;
|
|
233
265
|
return {
|
|
266
|
+
ghCache: (root) => makeGhCache({ root }),
|
|
234
267
|
gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), heavy: makeHeavyIo({ host: makeHostProbe() }), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
|
|
235
268
|
/** Shared dependency cache (#4220): the repo's own package manager, argv only, bounded by the cache's timeout. */
|
|
236
269
|
deps: makeDepCache({ exec: async (cmd, args, { cwd, env, timeoutMs } = {}) => String((await pexec(cmd, args, { cwd, env: { ...process.env, ...(env ?? {}) }, timeout: timeoutMs ?? 0, maxBuffer: 100 * 1024 * 1024 })).stdout) }),
|
|
@@ -316,6 +349,10 @@ export function makeLeanIo({ repo }) {
|
|
|
316
349
|
async structureDelta({ cwd, base }) {
|
|
317
350
|
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
351
|
},
|
|
352
|
+
/** #4450: public-API-shape changes of base...HEAD in cwd (bin/lean/apishape.mjs); any failure is {status: "unknown"} (an upgrade). */
|
|
353
|
+
async apiShape({ cwd, base }) {
|
|
354
|
+
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) }] }; }
|
|
355
|
+
},
|
|
319
356
|
/** #4375: the repo's learned-pitfall ledger, local only (~/.forge/pitfalls, FORGE_PITFALLS_DIR). */
|
|
320
357
|
pitfalls: makePitfallStore(),
|
|
321
358
|
/** 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/execution.mjs
CHANGED
|
@@ -411,7 +411,7 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
411
411
|
await tear(led, h); // every path, after the result is in (tear never throws)
|
|
412
412
|
return started ? withUsage(r, h) : r; // a sandbox that never started used nothing
|
|
413
413
|
},
|
|
414
|
-
adopt: async (n, rec) => {
|
|
414
|
+
adopt: async (n, rec, ctx = {}) => {
|
|
415
415
|
const saved = rec?.handle;
|
|
416
416
|
if (!saved?.id || saved.provider !== provider.name) return null;
|
|
417
417
|
// Usage runs from the sandbox's real start (persisted on the handle), not from this adopt.
|
|
@@ -420,7 +420,9 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
420
420
|
led?.register("sandbox", h.id, { provider: provider.name, handle: { provider: h.provider, id: h.id, issue: h.issue, ...(Number.isFinite(h.startedAt) ? { startedAt: h.startedAt } : {}) } }); // now owned by this dispatcher
|
|
421
421
|
let r;
|
|
422
422
|
try {
|
|
423
|
-
|
|
423
|
+
const ended = await provider.fetchResult(h);
|
|
424
|
+
if (!ended && (await provider.status(h)) !== "running") r = null; // relaunched
|
|
425
|
+
else { if (!ended) ctx.onLive?.(); r = await wait(h); } // #4504: a live sandbox is awaited in the background
|
|
424
426
|
} catch (e) {
|
|
425
427
|
r = { terminalReason: "engine-error", detail: `sandbox ${h.id}: ${errText(e)}` };
|
|
426
428
|
}
|