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/lean/lean.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { prepareToolchain } from "./toolchain.mjs";
|
|
|
25
25
|
import { toolStats } from "./toolstats.mjs";
|
|
26
26
|
import { readTestMap, readTestMapAtRef, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
|
|
27
27
|
import { runChecks, planPrecheck, worktreeFingerprint, selfCheckState, quietFailure, CHECK_PLAN_FILE, SELF_CHECK_FILE, CHECK_LOG_DIR } from "./precheck.mjs";
|
|
28
|
-
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
28
|
+
import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
29
29
|
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
|
|
30
30
|
import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
|
|
31
31
|
import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
|
|
@@ -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
|
|
@@ -214,10 +216,11 @@ export async function runCore(o) {
|
|
|
214
216
|
|
|
215
217
|
const ev = (e) => appendEvent(dir, issue, e, { repo }); // #4432: the run log is keyed by repo + issue
|
|
216
218
|
const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
|
|
217
|
-
|
|
219
|
+
let ghCache = null; // #4507: the run's own gh HTTP cache (set once the run tmp dir exists)
|
|
220
|
+
const ghc = (args) => ghCacheRetry((env) => io.gh(args, env ? { env } : undefined), ghCache);
|
|
218
221
|
const sleepFn = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
219
222
|
/** gh writes (comment, pr create, issue edit/close): retried for 5xx and secondary rate limits (#4114). */
|
|
220
|
-
const ghw = (args) => retrying(() =>
|
|
223
|
+
const ghw = (args) => retrying(() => ghc(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
|
|
221
224
|
const git = (args) => io.git(args, { cwd: wt });
|
|
222
225
|
|
|
223
226
|
const builderTriggers = []; // #4362: implement-attempt triggers in order, for the lane's first-pass flag
|
|
@@ -232,6 +235,7 @@ export async function runCore(o) {
|
|
|
232
235
|
...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name }; // #4410
|
|
233
236
|
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
237
|
ev(rec); await emit(rec);
|
|
238
|
+
modelMs += rec.duration_ms; // #4451: model time next to the lane's wall time
|
|
235
239
|
const u = r?.usage ?? {};
|
|
236
240
|
usage.cost_usd += c.delta ?? 0;
|
|
237
241
|
usage.input += u.input_tokens ?? 0; usage.output += u.output_tokens ?? 0;
|
|
@@ -273,7 +277,11 @@ export async function runCore(o) {
|
|
|
273
277
|
// Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
|
|
274
278
|
const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
|
|
275
279
|
const failureClasses = new Set(); // forge#4275: conformance failure codes seen this run (gate failures, shadow would-fail)
|
|
276
|
-
const
|
|
280
|
+
const ctxBase = parseContextPackConfig(o.contextPack ?? {});
|
|
281
|
+
const ctxArm = contextArm({ mode: ctxBase.mode, repo, issue }); // #4444: ab picks a stable on|shadow arm per issue
|
|
282
|
+
const ctxCfg = { ...ctxBase, mode: ctxArm.mode };
|
|
283
|
+
const ctxAb = ctxArm.ab ? { context_ab: true } : {};
|
|
284
|
+
if (ctxArm.ab) T.decisions.push(`Context pack A/B (#4444): arm ${ctxArm.mode}`);
|
|
277
285
|
const exploreCfg = parseExploreConfig(o.explore ?? {}); // #4391
|
|
278
286
|
if (exploreCfg.mode === "on") T.decisions.push(`Explorer subagent (on): model ${exploreCfg.model}`);
|
|
279
287
|
let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
|
|
@@ -282,6 +290,8 @@ export async function runCore(o) {
|
|
|
282
290
|
let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
|
|
283
291
|
let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
|
|
284
292
|
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
293
|
+
let light = false, modelMs = 0; // #4451: the light lane finished without an upgrade (skips structure/hotspot gates); summed session time
|
|
294
|
+
let lightVerdict = null, lightUpgrade = null; // #4453: the eligibility verdict and the upgrade reason codes, on the run result for the bench
|
|
285
295
|
let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
|
|
286
296
|
const runId = `${issue}-${t0}`;
|
|
287
297
|
let runTmp = null;
|
|
@@ -318,6 +328,7 @@ export async function runCore(o) {
|
|
|
318
328
|
}
|
|
319
329
|
// Never discard work silently: an unmerged lane commits and pushes what is left before its worktree goes.
|
|
320
330
|
const saved = !wtCreated || reason === "merged" || (await checkpoint(`exit (${reason})`));
|
|
331
|
+
try { ghCache?.dispose?.(); } catch { /* best effort */ }
|
|
321
332
|
if (runTmp) {
|
|
322
333
|
try { if ((await io.runTmp.remove(runTmp)) !== false) ledgerDo((l) => l.release("tmp", runTmp)); } catch { /* best effort */ }
|
|
323
334
|
runTmp = null;
|
|
@@ -399,7 +410,7 @@ export async function runCore(o) {
|
|
|
399
410
|
// forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
|
|
400
411
|
const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
|
|
401
412
|
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() } : {}),
|
|
413
|
+
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
414
|
...(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
415
|
ev(term);
|
|
405
416
|
await emit(term);
|
|
@@ -424,7 +435,8 @@ export async function runCore(o) {
|
|
|
424
435
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
425
436
|
}
|
|
426
437
|
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,
|
|
438
|
+
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,
|
|
439
|
+
light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
|
|
428
440
|
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
|
|
429
441
|
...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
|
|
430
442
|
...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
|
|
@@ -672,7 +684,7 @@ export async function runCore(o) {
|
|
|
672
684
|
const governor = startGovernor(control);
|
|
673
685
|
let spawned = null;
|
|
674
686
|
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),
|
|
687
|
+
const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: model ?? laneModel(), resume, system: "", prompt, control, onSpawn, agents: explorerAgents(exploreCfg), rereadNudge: true,
|
|
676
688
|
idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}),
|
|
677
689
|
busy: sessionBusy, busyMs: Math.round(cliBusyMinutes * 60000),
|
|
678
690
|
onIdleExtend: (info) => ev({ event: "CLI_IDLE_EXTENDED", children: info.children, heavyQueue: info.heavyQueue }) }));
|
|
@@ -880,6 +892,7 @@ export async function runCore(o) {
|
|
|
880
892
|
try { runTmp = await io.runTmp.make(issue, runId); } catch { runTmp = null; }
|
|
881
893
|
if (runTmp && runTmp !== planned) ledgerDo((l) => l.register("tmp", runTmp));
|
|
882
894
|
}
|
|
895
|
+
ghCache = io.ghCache?.(runTmp) ?? null;
|
|
883
896
|
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
884
897
|
try {
|
|
885
898
|
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
@@ -1117,7 +1130,7 @@ export async function runCore(o) {
|
|
|
1117
1130
|
const inPack = Array.isArray(p.pitfalls) ? p.pitfalls : [];
|
|
1118
1131
|
pitMatched = inPack.filter((x) => x.match === "files").length;
|
|
1119
1132
|
// #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,
|
|
1133
|
+
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
1134
|
hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
|
|
1122
1135
|
ev({ ...rec, pitfalls: inPack }); await emit(rec);
|
|
1123
1136
|
const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
|
|
@@ -1356,13 +1369,55 @@ export async function runCore(o) {
|
|
|
1356
1369
|
}
|
|
1357
1370
|
}
|
|
1358
1371
|
await label("building");
|
|
1359
|
-
|
|
1372
|
+
// #4450: the light lane (lean.light mode on, eligible, fresh lane): one builder session, derived contract, or an upgrade
|
|
1373
|
+
const elig = lightEligibility({ title: iss.title, body: iss.body ?? "", labels: iss.labels ?? [], predictedFiles: namedPaths(iss.body ?? ""), riskPaths, cfg: o.light ?? {} });
|
|
1374
|
+
ev({ event: "LIGHT_ELIGIBILITY", mode: elig.mode, verdict: elig.verdict, light: elig.light, reasons: elig.reasons });
|
|
1375
|
+
lightVerdict = elig.verdict;
|
|
1376
|
+
if (elig.mode !== "off") T.decisions.push(`Light lane ${elig.mode}: ${elig.verdict}${elig.reasons.length ? ` (${elig.reasons.map((x) => x.code).join(", ")})` : ""}`);
|
|
1377
|
+
// #4451: a light try skips the hotspots and the context pack; an upgrade computes them before the plan call
|
|
1378
|
+
const lightTry = elig.light && !frozen && !resumed && !salvagedRef && !recover;
|
|
1379
|
+
if (!lightTry) hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
|
|
1360
1380
|
enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
|
|
1361
1381
|
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1362
|
-
|
|
1382
|
+
let ctxPack = frozen || lightTry ? null : await contextPackStep();
|
|
1363
1383
|
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1384
|
+
let lightDone = false, lightCommits = false;
|
|
1385
|
+
if (lightTry) {
|
|
1386
|
+
liveKeys = ["implementation"]; stage = "implement";
|
|
1387
|
+
tb.setSize(laneSize({ labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }));
|
|
1388
|
+
const lr = await runLightStep({ title: iss.title, issueText, riskPaths, cfg: o.light ?? {}, validate: (c) => validateContract(c, issueText, { repro: reproOn() }),
|
|
1389
|
+
build: () => builder(++attemptN, lightBuildPrompt(issue, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList), null, true, undefined, { trigger: "build" }),
|
|
1390
|
+
readResult, checkpoint: () => checkpoint("light build"), changedFiles, reproOf, numstat: () => git(["diff", "--numstat", `${base}...HEAD`]),
|
|
1391
|
+
runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: cmds, parallelism: Number(verifyParallelism()) || 1, now, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
|
|
1392
|
+
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),
|
|
1393
|
+
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" }),
|
|
1394
|
+
apiShape: () => (io.apiShape ? io.apiShape({ cwd: wt, base }).catch(() => null) : null) });
|
|
1395
|
+
stage = "plan"; liveKeys = ["investigation"]; result = (await readResult()) ?? result;
|
|
1396
|
+
if (!lr.upgrade) {
|
|
1397
|
+
lightDone = true; light = true; frozen = lr.contract; hash = contractHash(contractJson(frozen));
|
|
1398
|
+
let body = renderContract(frozen, hash, { scope: scopeHash });
|
|
1399
|
+
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash });
|
|
1400
|
+
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
1401
|
+
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: 0, derived: "light" });
|
|
1402
|
+
ev({ event: "LIGHT_DONE", files: lr.files.length, test_failures: lr.testFailures, repro: lr.repro, cost_usd: usage.cost_usd });
|
|
1403
|
+
T.decisions.push(`Light lane: one builder session, contract ${hash} derived from the diff and the test`);
|
|
1404
|
+
try { await post("FORGE:LITE", renderLiteReceipt({ lr, eligibility: elig, costUsd: usage.cost_usd, contractHash: hash })); } catch { /* #4452: the receipt is best effort */ }
|
|
1405
|
+
} else {
|
|
1406
|
+
lightCommits = lr.files.length > 0;
|
|
1407
|
+
ev({ event: "LIGHT_UPGRADE", reasons: lr.reasons, cost_usd: usage.cost_usd });
|
|
1408
|
+
lightUpgrade = lr.reasons.map((x) => x.code);
|
|
1409
|
+
T.decisions.push(`Light lane upgraded to the full lane: ${lr.reasons.map((x) => x.code).join(", ")}`);
|
|
1410
|
+
try { await post("FORGE:LITE", renderLiteReceipt({ lr, eligibility: elig, costUsd: usage.cost_usd })); } catch { /* #4452: the receipt is best effort */ }
|
|
1411
|
+
const d = buildResumeDigest({ state: null, comments: trustedComments, contract: null, files: lr.files, lane });
|
|
1412
|
+
resumeNote = d.text + upgradeNote(lr.reasons);
|
|
1413
|
+
result = null; // the light result.json must not read as the plan's verdict
|
|
1414
|
+
if (io.fs.write) { try { await io.fs.write(`${wt}/${RESULT_FILE}`, "{}\n"); } catch { /* the plan overwrites it */ } }
|
|
1415
|
+
hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") }); // #4451: the full lane keeps its plan facts
|
|
1416
|
+
ctxPack = await contextPackStep();
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1364
1419
|
if (!frozen) {
|
|
1365
|
-
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote() + resumeNote, null, true, planPrompt, { trigger: "plan" });
|
|
1420
|
+
await builder(++attemptN, planPrompt() + (hasCommits || lightCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote() + resumeNote, null, true, planPrompt, { trigger: "plan" });
|
|
1366
1421
|
planned = true;
|
|
1367
1422
|
result = await readResult();
|
|
1368
1423
|
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
@@ -1427,9 +1482,9 @@ export async function runCore(o) {
|
|
|
1427
1482
|
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
1428
1483
|
T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
|
|
1429
1484
|
} else {
|
|
1430
|
-
T.validate = "resumed (existing branch)";
|
|
1485
|
+
T.validate = lightDone ? "light lane (no plan session)" : "resumed (existing branch)";
|
|
1431
1486
|
}
|
|
1432
|
-
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
1487
|
+
T.plan = [`contract ${hash}${lightDone ? " (derived, light lane)" : ""}`, `${frozen.requirements.length} requirement(s)`];
|
|
1433
1488
|
tb.setSize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) })); // #4392
|
|
1434
1489
|
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
1435
1490
|
// #4364: what `forgedock check` needs from the operator's forge.yaml (gitignored, so not in the worktree)
|
|
@@ -1443,7 +1498,7 @@ export async function runCore(o) {
|
|
|
1443
1498
|
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
1499
|
liveKeys = ["implementation"];
|
|
1445
1500
|
stage = "implement";
|
|
1446
|
-
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true, undefined, { trigger: "build" });
|
|
1501
|
+
if (!lightDone) await builder(++attemptN, implementPrompt, planned ? sessionId : null, true, undefined, { trigger: "build" });
|
|
1447
1502
|
stage = "fix"; // later builder rounds fix precheck, CI, structure, docs or conformance findings
|
|
1448
1503
|
result = await readResult();
|
|
1449
1504
|
{ // #4374: the builder's self-review against the shared rubric, log only
|
|
@@ -1530,7 +1585,7 @@ export async function runCore(o) {
|
|
|
1530
1585
|
const d = await io.structureDelta({ cwd: wt, base }).catch(() => null);
|
|
1531
1586
|
return d && typeof d === "object" && Array.isArray(d.package_edges_added) ? d : null; // `{}`: no workspace packages
|
|
1532
1587
|
};
|
|
1533
|
-
let structure = await structureOf();
|
|
1588
|
+
let structure = light ? null : await structureOf(); // #4451: no structure delta on a light lane
|
|
1534
1589
|
if (structure) {
|
|
1535
1590
|
const pairs = structure.import_pairs ?? [], sum = (k) => pairs.reduce((n, p) => n + (Number(p?.[k]) || 0), 0);
|
|
1536
1591
|
const rec = { event: "STRUCTURE_DELTA", edges_added: structure.package_edges_added.length, edges_removed: (structure.package_edges_removed ?? []).length,
|
|
@@ -1872,7 +1927,7 @@ export async function runCore(o) {
|
|
|
1872
1927
|
}
|
|
1873
1928
|
const amendProblems = await amend();
|
|
1874
1929
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
|
|
1875
|
-
const hotspots = await hotspotsOf({ base, paths: files });
|
|
1930
|
+
const hotspots = light ? null : await hotspotsOf({ base, paths: files }); // #4451
|
|
1876
1931
|
const validateRuns = await rerunFailedValidates(effective);
|
|
1877
1932
|
const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
|
|
1878
1933
|
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen });
|
|
@@ -2081,6 +2136,10 @@ export async function runCore(o) {
|
|
|
2081
2136
|
}
|
|
2082
2137
|
return "unknown";
|
|
2083
2138
|
};
|
|
2139
|
+
/** #4507: true only when the PR reads MERGED; unreadable counts as not merged. */
|
|
2140
|
+
const prMerged = async () => {
|
|
2141
|
+
try { return String(JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "state"])) || "{}").state ?? "").toUpperCase() === "MERGED"; } catch { return false; }
|
|
2142
|
+
};
|
|
2084
2143
|
/** #4158: MERGEABLE but BLOCKED is a base branch policy block (ruleset / required checks pending), not a conflict. */
|
|
2085
2144
|
const policyBlocked = async () => {
|
|
2086
2145
|
let j;
|
|
@@ -2234,7 +2293,7 @@ export async function runCore(o) {
|
|
|
2234
2293
|
* only: the single telemetry write is the run's one extra API call. Also feeds the trajectory's Structure table.
|
|
2235
2294
|
*/
|
|
2236
2295
|
async function recordStructure(pr) {
|
|
2237
|
-
const d = (await structureOf()) ?? {};
|
|
2296
|
+
const d = (light ? null : await structureOf()) ?? {}; // #4451
|
|
2238
2297
|
const numstat = String(await git(["diff", "--numstat", "--no-renames", `${base}...HEAD`]).catch(() => ""));
|
|
2239
2298
|
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
2299
|
.map(([a, , file]) => ({ file, lines: Number(a) })).sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file)).slice(0, 5);
|
|
@@ -2372,6 +2431,12 @@ export async function runCore(o) {
|
|
|
2372
2431
|
break;
|
|
2373
2432
|
} catch (e) {
|
|
2374
2433
|
const msg = `${e?.stderr ?? ""}${e?.message ?? ""}`;
|
|
2434
|
+
if (await prMerged()) { // #4507: the merge went through despite the error
|
|
2435
|
+
T.decisions.push(`gh pr merge errored but PR #${pr} is MERGED: recorded merged (${msg.split("\n")[0].slice(0, 120)})`);
|
|
2436
|
+
try { seqLease?.release(); } catch { /* fail-open */ }
|
|
2437
|
+
seqLease = null;
|
|
2438
|
+
break;
|
|
2439
|
+
}
|
|
2375
2440
|
if (MERGE_TRANSIENT_RE.test(msg) && !/not mergeable|merge conflict/i.test(msg)) {
|
|
2376
2441
|
// "Base branch was modified", GraphQL and 5xx are transient: back off, then the loop top re-reads mergeable and the head.
|
|
2377
2442
|
if (++mergeRetries > maxMergeRetries) return await finish("deferred", `gh pr merge kept failing transiently (${mergeRetries - 1} retries): ${msg.slice(0, 300)}`, { pr, code: OUTCOME_CODES.MERGE_TRANSIENT }, OUTCOME_CODES.MERGE_TRANSIENT);
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -19,8 +19,8 @@ import { mapRecord } from "../engine/telemetry-seam.mjs";
|
|
|
19
19
|
import { readLog as readRepoLog } from "../engine/runlog.mjs";
|
|
20
20
|
// #4432: the run log is keyed by repo + issue; every lane in this file runs in repo o/r.
|
|
21
21
|
const readLog = (dir, issue, repo = "o/r") => readRepoLog(dir, issue, { repo });
|
|
22
|
-
import { makeRunTmp, wrapLaneLimits, systemdRunAvailable, makeLeanRunner, requiredChecksFor, procsInDir, makeLeanIo } from "./effects.mjs";
|
|
23
|
-
import { retrying, PUSH_TRANSIENT_RE } from "./retry.mjs";
|
|
22
|
+
import { makeRunTmp, wrapLaneLimits, systemdRunAvailable, makeLeanRunner, requiredChecksFor, procsInDir, makeLeanIo, makeGhCache } from "./effects.mjs";
|
|
23
|
+
import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE } from "./retry.mjs";
|
|
24
24
|
import { checkAdmission, hostParallelism } from "./admission.mjs";
|
|
25
25
|
import { OUTCOME_CODES, classifyNeedsHuman } from "../engine/outcome-codes.mjs";
|
|
26
26
|
import * as refactorMod from "./refactor.mjs";
|
|
@@ -73,15 +73,17 @@ function world(o = {}) {
|
|
|
73
73
|
w.writeStages = writeStages;
|
|
74
74
|
w.io = {
|
|
75
75
|
now: () => w.clock, sleep: async (ms) => { w.clock += ms; },
|
|
76
|
-
gh: async (a) => {
|
|
76
|
+
gh: async (a, opts) => {
|
|
77
77
|
const k = a.slice(0, 2).join(" ");
|
|
78
|
+
if (opts?.env) (w.ghEnvs ??= []).push(opts.env);
|
|
79
|
+
if (k === "pr view" && a.at(-1) === "state" && a.at(-2) === "--json") return JSON.stringify({ state: o.prState ?? "OPEN" });
|
|
78
80
|
if (a[0] === "api" && a.includes("PATCH")) { w.patches.push(a); return "{}"; }
|
|
79
81
|
if (a[0] === "api") return a.some((x) => /\/comments$/.test(x)) ? (w.issueComments ?? []).map((c) => JSON.stringify(c)).join("\n") : "";
|
|
80
82
|
if (k === "pr list") return JSON.stringify(w.prList);
|
|
81
83
|
if (k === "repo view") return `${o.defaultBranch ?? "main"}\n`;
|
|
82
84
|
if (k === "issue view" && a.includes("milestone")) { w.seq.push("lane"); return JSON.stringify({ milestone: o.milestone ? { title: o.milestone } : null }); }
|
|
83
85
|
if (k === "issue view" && o.issueBodies && a[2] === "7" && a.some((x) => String(x).startsWith("number,title,body,state,comments"))) { const b = o.issueBodies.length > 1 ? o.issueBodies.shift() : o.issueBodies[0]; return JSON.stringify({ number: 7, title: o.title ?? "foo breaks", body: b, state: "OPEN", comments: o.viewComments ?? [] }); }
|
|
84
|
-
if (k === "issue view") return JSON.stringify({ number: Number(a[2]), title: a[2] === "7" && o.title ? o.title : "foo breaks", body: ISSUE_BODY, state: w.issueStates?.[a[2]] ?? "OPEN", comments: [], ...(o.labels && a[2] === "7" ? { labels: o.labels } : {}) });
|
|
86
|
+
if (k === "issue view") return JSON.stringify({ number: Number(a[2]), title: a[2] === "7" && o.title ? o.title : "foo breaks", body: a[2] === "7" && o.body ? o.body : ISSUE_BODY, state: w.issueStates?.[a[2]] ?? "OPEN", comments: [], ...(o.labels && a[2] === "7" ? { labels: o.labels } : {}) });
|
|
85
87
|
if (k === "pr edit") { w.prEdits.push(a); return ""; }
|
|
86
88
|
if (k === "label create") { w.labelCreates = [...(w.labelCreates ?? []), a]; return ""; }
|
|
87
89
|
if (k === "issue comment" && w.commentErrors.length) throw new Error(w.commentErrors.shift());
|
|
@@ -2146,6 +2148,17 @@ describe("orchestrate", () => {
|
|
|
2146
2148
|
assert.deepEqual(parseDeps("This is blocked by #2 and depends on #4\nsee **Depends on**: #5", 1), []);
|
|
2147
2149
|
});
|
|
2148
2150
|
|
|
2151
|
+
it("#4506 parseDeps reads every #N to the end of a declaration line", () => {
|
|
2152
|
+
assert.deepEqual(parseDeps("Depends on #36648 (action results must reach …) and #36649 (the included action budget …)", 1), [36648, 36649]);
|
|
2153
|
+
assert.deepEqual(parseDeps("Depends on #36651 (executor test harness) and #36648.", 1), [36651, 36648]);
|
|
2154
|
+
assert.deepEqual(parseDeps("**Depends on** #2, #3 and #4", 1), [2, 3, 4]);
|
|
2155
|
+
assert.deepEqual(parseDeps("__Depends on__: #5 (x) and #6", 1), [5, 6]);
|
|
2156
|
+
assert.deepEqual(parseDeps("**Blocked by:** #7 (why) & #8", 1), [7, 8]);
|
|
2157
|
+
assert.deepEqual(parseDeps("- depends on: #9 (a), #10", 1), [9, 10]);
|
|
2158
|
+
assert.deepEqual(parseDeps("Depends on #11 and other/repo#12", 1), [11]);
|
|
2159
|
+
assert.deepEqual(parseDeps("This is blocked by #2 and depends on #4", 1), []);
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2149
2162
|
it("runs at most K at once", async () => {
|
|
2150
2163
|
const h = harness({});
|
|
2151
2164
|
const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6], parallel: 2, ...h });
|
|
@@ -3222,6 +3235,78 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
3222
3235
|
assert.ok(w.comments.some((c) => c.includes("FORGE:BASE_SYNC")));
|
|
3223
3236
|
});
|
|
3224
3237
|
|
|
3238
|
+
// ---- #4507 ----
|
|
3239
|
+
const CORRUPT = "invalid character '{' looking for beginning of object key string";
|
|
3240
|
+
it("#4507 a gh JSON parse error at merge (corrupt gh cache) is retried and ends merged", async () => {
|
|
3241
|
+
const w = world({ mergeErrors: [CORRUPT] });
|
|
3242
|
+
const r = await run(w);
|
|
3243
|
+
assert.equal(r.terminalReason, "merged");
|
|
3244
|
+
assert.equal(w.mergeAttempts, 2);
|
|
3245
|
+
assert.equal(w.merges.length, 1);
|
|
3246
|
+
});
|
|
3247
|
+
|
|
3248
|
+
it("#4507 a merge error on a PR that is already MERGED records merged, not engine-error", async () => {
|
|
3249
|
+
const w = world({ mergeErrors: ["GraphQL: validation failed (422)"], prState: "MERGED" });
|
|
3250
|
+
const r = await run(w);
|
|
3251
|
+
assert.equal(r.terminalReason, "merged");
|
|
3252
|
+
assert.equal(w.mergeAttempts, 1);
|
|
3253
|
+
assert.equal(w.closed.length, 1);
|
|
3254
|
+
assert.ok(traj(w)[0].includes("is MERGED: recorded merged"));
|
|
3255
|
+
});
|
|
3256
|
+
|
|
3257
|
+
it("#4507 a persistent gh parse error at merge ends deferred merge.transient, never engine-error", async () => {
|
|
3258
|
+
const w = world({ mergeErrors: Array(40).fill(CORRUPT) });
|
|
3259
|
+
const r = await run(w, { maxMergeRetries: 2 });
|
|
3260
|
+
assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "merge.transient");
|
|
3261
|
+
assert.equal(w.mergeAttempts, 6, "each merge attempt is retried once with a fresh cache");
|
|
3262
|
+
});
|
|
3263
|
+
|
|
3264
|
+
it("#4507 with io.ghCache every engine gh call carries the run's own XDG_CACHE_HOME", async () => {
|
|
3265
|
+
const w = world();
|
|
3266
|
+
w.io.runTmp = { prune: async () => {}, make: async (_i, id) => `/rt/${id}`, remove: async () => true };
|
|
3267
|
+
let made = null, disposed = 0;
|
|
3268
|
+
w.io.ghCache = (root) => { made = root; return { env: () => ({ XDG_CACHE_HOME: `${root}/gh-cache` }), reset() {}, dispose() { disposed++; } }; };
|
|
3269
|
+
assert.equal((await run(w)).terminalReason, "merged");
|
|
3270
|
+
assert.match(made, /^\/rt\//);
|
|
3271
|
+
assert.ok(w.ghEnvs.length > 0 && w.ghEnvs.every((e) => e.XDG_CACHE_HOME === `${made}/gh-cache`));
|
|
3272
|
+
assert.equal(disposed, 1);
|
|
3273
|
+
});
|
|
3274
|
+
|
|
3275
|
+
it("#4507 ghCacheRetry retries exactly once on a parse error and rethrows other errors untouched", async () => {
|
|
3276
|
+
let n = 0, resets = 0;
|
|
3277
|
+
const cache = { env: () => ({ XDG_CACHE_HOME: `/c${resets}` }), reset: () => { resets++; } };
|
|
3278
|
+
const seen = [];
|
|
3279
|
+
assert.equal(await ghCacheRetry(async (env) => { seen.push(env.XDG_CACHE_HOME); if (n++ === 0) throw new Error(CORRUPT); return "ok"; }, cache), "ok");
|
|
3280
|
+
assert.deepEqual(seen, ["/c0", "/c1"]); assert.equal(resets, 1);
|
|
3281
|
+
n = 0;
|
|
3282
|
+
await assert.rejects(ghCacheRetry(async () => { n++; throw new Error(CORRUPT); }, null), /invalid character/);
|
|
3283
|
+
assert.equal(n, 2);
|
|
3284
|
+
n = 0;
|
|
3285
|
+
const other = new Error("HTTP 404: Not Found");
|
|
3286
|
+
await assert.rejects(ghCacheRetry(async () => { n++; throw other; }, cache), (e) => e === other);
|
|
3287
|
+
assert.equal(n, 1);
|
|
3288
|
+
});
|
|
3289
|
+
|
|
3290
|
+
it("#4507 makeGhCache: a per-root cache dir, reset swaps in a fresh dir, the no-root fallback is disposed", () => {
|
|
3291
|
+
const root = tmpDir("ghcache-");
|
|
3292
|
+
const c = makeGhCache({ root });
|
|
3293
|
+
const first = c.env().XDG_CACHE_HOME;
|
|
3294
|
+
assert.equal(first, join(root, "gh-cache")); assert.ok(existsSync(first));
|
|
3295
|
+
c.reset();
|
|
3296
|
+
const second = c.env().XDG_CACHE_HOME;
|
|
3297
|
+
assert.notEqual(second, first); assert.ok(existsSync(second)); assert.ok(!existsSync(first));
|
|
3298
|
+
const tmp = tmpDir("ghcache-tmp-");
|
|
3299
|
+
const f = makeGhCache({ tmp });
|
|
3300
|
+
assert.equal(f.env(), undefined);
|
|
3301
|
+
f.reset();
|
|
3302
|
+
const fb = f.env().XDG_CACHE_HOME;
|
|
3303
|
+
assert.ok(fb.startsWith(join(tmp, "forge-gh-cache-")) && existsSync(fb));
|
|
3304
|
+
f.dispose();
|
|
3305
|
+
assert.ok(!existsSync(fb));
|
|
3306
|
+
c.dispose();
|
|
3307
|
+
assert.ok(!existsSync(second)); assert.equal(c.env(), undefined, "no dir is re-created after the run tears down");
|
|
3308
|
+
});
|
|
3309
|
+
|
|
3225
3310
|
// ---- #4114 ----
|
|
3226
3311
|
it("#4114 a push failing once with 'remote unpack failed' then succeeding completes the run", async () => {
|
|
3227
3312
|
const w = world({ pushErrors: ["error: remote unpack failed: unable to create temporary object directory"] });
|
|
@@ -4819,6 +4904,22 @@ describe("context pack modes (forge#4275)", () => {
|
|
|
4819
4904
|
assert.deepEqual([m.leaseWrites.at(-1).terminalCode, m.leaseWrites.at(-1).failureClasses], [OUTCOME_CODES.COMPLETED, []]);
|
|
4820
4905
|
});
|
|
4821
4906
|
|
|
4907
|
+
it("#4444 ab: the lane runs its issue's arm, logs it as context_pack and tags context_ab", async () => {
|
|
4908
|
+
const { contextArm } = await import("./context.mjs");
|
|
4909
|
+
const arm = contextArm({ mode: "ab", repo: "o/r", issue: 7 }).mode;
|
|
4910
|
+
const w = withPack(), dir = tmpDir("core-");
|
|
4911
|
+
const r = await run(w, { dir, contextPack: { mode: "ab" } });
|
|
4912
|
+
assert.equal(r.terminalReason, "merged");
|
|
4913
|
+
const plan = builders(w)[0][4];
|
|
4914
|
+
assert.equal(/Context pack \(hints, not proof/.test(plan), arm === "on");
|
|
4915
|
+
const log = readLog(dir, 7);
|
|
4916
|
+
const cp = log.find((e) => e.event === "CONTEXT_PACK"), term = log.find((e) => e.event === "RUN_TERMINAL");
|
|
4917
|
+
assert.deepEqual([cp.mode, cp.context_ab, term.context_pack, term.context_ab], [arm, true, arm, true]);
|
|
4918
|
+
const plain = withPack(), d2 = tmpDir("core-");
|
|
4919
|
+
await run(plain, { dir: d2, contextPack: { mode: "shadow" } });
|
|
4920
|
+
assert.equal("context_ab" in readLog(d2, 7).find((e) => e.event === "RUN_TERMINAL"), false);
|
|
4921
|
+
});
|
|
4922
|
+
|
|
4822
4923
|
it("forge.yaml engine.context_pack* is read flat; invalid mode is ignored", () => {
|
|
4823
4924
|
const d = tmpDir("ctxcfg-");
|
|
4824
4925
|
writeFileSync(join(d, "forge.yaml"), "engine:\n context_pack: on\n context_pack_sources: graph,attempts\n context_pack_bytes: 4096\n");
|
|
@@ -5835,3 +5936,163 @@ describe("#4395 lane profile", () => {
|
|
|
5835
5936
|
assert.equal(resumed.baselineTokens, null);
|
|
5836
5937
|
});
|
|
5837
5938
|
});
|
|
5939
|
+
|
|
5940
|
+
describe("#4450 light lane: one builder session, derived contract, upgrade to the full lane", () => {
|
|
5941
|
+
const RT = { path: "test/foo.test.js", id: "foo handles null", command: "node --test test/foo.test.js" };
|
|
5942
|
+
const BODY = "foo crashes on null input in src/foo.js.\n\nExpected behaviour: foo returns null.\n\n- foo must handle a null argument without throwing.\n- Existing callers of foo keep working.";
|
|
5943
|
+
const RESULT = { status: "fixed", summary: "fixed foo", requirement: "foo must handle a null argument without throwing.", repro_test: RT, tests: [{ command: RT.command, result: "pass", fails_without_fix: true }] };
|
|
5944
|
+
const lworld = (o = {}) => {
|
|
5945
|
+
const w = world({ labels: [{ name: "bug" }], body: BODY, result: RESULT, contract: { ...CONTRACT, repro_test: RT }, ...o });
|
|
5946
|
+
w.numstat = o.numstat ?? "5\t1\tsrc/foo.js\n20\t0\ttest/foo.test.js\n";
|
|
5947
|
+
w.rgCalls = []; w.ran = [];
|
|
5948
|
+
w.io.redGreen = async (x) => { w.rgCalls.push(x.command); return o.repro ?? { base: "FAILED", head: "RAN" }; };
|
|
5949
|
+
w.io.runCommand = async (a) => { w.ran.push(a.command); return { rc: 0, output: "" }; };
|
|
5950
|
+
w.io.apiShape = async () => o.api ?? { status: "none", changes: [], unknown: [] };
|
|
5951
|
+
return w;
|
|
5952
|
+
};
|
|
5953
|
+
const events = (dir, name) => readLog(dir, 7).filter((e) => e.event === name);
|
|
5954
|
+
const ON = { light: { mode: "on" } };
|
|
5955
|
+
|
|
5956
|
+
it("nothing triggers: exactly one builder session (no plan) plus the review; the derived contract is frozen and posted", async () => {
|
|
5957
|
+
const w = lworld(), dir = tmpDir("light-");
|
|
5958
|
+
const r = await run(w, { dir, ...ON });
|
|
5959
|
+
assert.equal(r.terminalReason, "merged");
|
|
5960
|
+
assert.equal(builders(w).length, 1, "one builder session");
|
|
5961
|
+
assert.ok(!/PLANNING ONLY/.test(builders(w)[0][4]));
|
|
5962
|
+
assert.match(builders(w)[0][4], /failing regression test first/);
|
|
5963
|
+
assert.equal(reviewers(w).length, 1, "plus the review call");
|
|
5964
|
+
const contract = w.comments.find((c) => c.includes("<!-- FORGE:CONTRACT -->"));
|
|
5965
|
+
assert.match(contract, /\| R1 \| foo must handle a null argument without throwing\. \| deliver \|/);
|
|
5966
|
+
assert.match(contract, /"repro_test"/);
|
|
5967
|
+
assert.equal(events(dir, "LIGHT_ELIGIBILITY")[0].verdict, "eligible");
|
|
5968
|
+
assert.equal(events(dir, "LIGHT_DONE").length, 1);
|
|
5969
|
+
assert.equal(events(dir, "LIGHT_UPGRADE").length, 0);
|
|
5970
|
+
assert.deepEqual(events(dir, "CONTRACT_FROZEN").map((e) => e.derived), ["light"]);
|
|
5971
|
+
assert.ok(w.ran.includes(RT.command), "the engine ran the targeted tests");
|
|
5972
|
+
assert.ok(w.rgCalls.length >= 1, "red/green repro gate ran");
|
|
5973
|
+
const lite = w.comments.filter((c) => c.includes("<!-- FORGE:LITE -->"));
|
|
5974
|
+
assert.equal(lite.length, 1, "#4452: one FORGE:LITE receipt");
|
|
5975
|
+
assert.match(lite[0], /\*\*Outcome:\*\* light/);
|
|
5976
|
+
assert.match(lite[0], /\*\*Diff:\*\* 2 file\(s\), \+25\/-1/);
|
|
5977
|
+
assert.match(lite[0], /\*\*Red\/green:\*\* base FAILED → head RAN/);
|
|
5978
|
+
assert.match(lite[0], /targeted tests: 1 command\(s\), passed/);
|
|
5979
|
+
assert.match(lite[0], /\*\*Cost:\*\* \$\d+\.\d{4} \(true per-session cost/);
|
|
5980
|
+
assert.match(lite[0], /\*\*Eligibility:\*\* eligible \(mode on\)/);
|
|
5981
|
+
assert.equal(typeof events(dir, "LIGHT_DONE")[0].cost_usd, "number");
|
|
5982
|
+
});
|
|
5983
|
+
|
|
5984
|
+
for (const [name, code, o] of [
|
|
5985
|
+
["oversized diff", "diff_lines", { numstat: "80\t3\tsrc/foo.js\n20\t0\ttest/foo.test.js\n" }],
|
|
5986
|
+
["non-red repro", "repro_not_red", { repro: { base: "PASSED", head: "RAN" } }],
|
|
5987
|
+
["API change", "api_shape", { api: { status: "changed", changes: [{ file: "src/foo.js", symbol: "foo", change: "sync-async", before: "def", after: "async def" }], unknown: [] } }],
|
|
5988
|
+
["risk path", "risk_path", { changed: ["src/auth/session.js", "test/foo.test.js"], numstat: "4\t0\tsrc/auth/session.js\n" }],
|
|
5989
|
+
]) {
|
|
5990
|
+
it(`${name}: upgrades to the full lane with the digest, keeping the light commits`, async () => {
|
|
5991
|
+
const w = lworld(o), dir = tmpDir("light-up-");
|
|
5992
|
+
await run(w, { dir, ...ON });
|
|
5993
|
+
const up = events(dir, "LIGHT_UPGRADE");
|
|
5994
|
+
assert.equal(up.length, 1);
|
|
5995
|
+
assert.ok(up[0].reasons.some((x) => x.code === code), JSON.stringify(up[0].reasons));
|
|
5996
|
+
const b = builders(w);
|
|
5997
|
+
assert.ok(b.length >= 3, "light session, then the full lane's plan and implement");
|
|
5998
|
+
assert.ok(!/PLANNING ONLY/.test(b[0][4]));
|
|
5999
|
+
assert.match(b[1][4], /PLANNING ONLY/);
|
|
6000
|
+
assert.match(b[1][4], /This is a rerun: the branch already holds commits/);
|
|
6001
|
+
assert.match(b[1][4], /Resume digest of the last run on this branch/);
|
|
6002
|
+
assert.match(b[1][4], new RegExp(`Light-lane upgrade[\\s\\S]*- ${code}:`));
|
|
6003
|
+
const later = w.calls.slice(w.calls.indexOf(b[0]), w.calls.indexOf(b[2])).filter((c) => c[0] === "git"); // light session -> full implement
|
|
6004
|
+
assert.ok(!later.some((c) => ["reset", "rebase", "clean"].includes(c[1]) || (c[1] === "checkout" && c.includes("-B")) || (c[1] === "branch" && c.includes("-D")) || (c[1] === "push" && c.some((x) => /^--force|^-f$/.test(String(x))))), "the light work is never discarded");
|
|
6005
|
+
assert.equal(events(dir, "LIGHT_DONE").length, 0);
|
|
6006
|
+
const lite = w.comments.filter((c) => c.includes("<!-- FORGE:LITE -->"));
|
|
6007
|
+
assert.equal(lite.length, 1, "#4452: the upgraded light lane still posts its receipt");
|
|
6008
|
+
assert.match(lite[0], new RegExp(`\\*\\*Upgrade reasons:\\*\\*[\\s\\S]*- ${code}:`));
|
|
6009
|
+
});
|
|
6010
|
+
}
|
|
6011
|
+
|
|
6012
|
+
it("#4453 the run result carries light, the eligibility verdict and the upgrade reason codes (for the three-way bench)", async () => {
|
|
6013
|
+
const lit = await run(lworld(), { dir: tmpDir("light-res-"), ...ON });
|
|
6014
|
+
assert.equal(lit.light, true); assert.equal(lit.light_eligibility, "eligible"); assert.ok(!("light_upgrade" in lit));
|
|
6015
|
+
const up = await run(lworld({ repro: { base: "PASSED", head: "RAN" } }), { dir: tmpDir("light-res-up-"), ...ON });
|
|
6016
|
+
assert.equal(up.light, false); assert.equal(up.light_eligibility, "eligible"); assert.ok(up.light_upgrade.includes("repro_not_red"), JSON.stringify(up.light_upgrade));
|
|
6017
|
+
const off = await run(lworld(), { dir: tmpDir("light-res-off-"), light: { mode: "off" } });
|
|
6018
|
+
assert.equal(off.light, false); assert.equal(off.light_eligibility, "off"); assert.ok(!("light_upgrade" in off));
|
|
6019
|
+
assert.equal(typeof off.cost_usd, "number"); assert.ok(Number.isFinite(off.wall_ms) && Number.isFinite(off.model_ms), "#4451 fields unchanged");
|
|
6020
|
+
});
|
|
6021
|
+
|
|
6022
|
+
it("shadow (the default) only reports the verdict: the lane keeps plan -> implement", async () => {
|
|
6023
|
+
const w = lworld(), dir = tmpDir("light-sh-");
|
|
6024
|
+
assert.equal((await run(w, { dir })).terminalReason, "merged");
|
|
6025
|
+
const e = events(dir, "LIGHT_ELIGIBILITY")[0];
|
|
6026
|
+
assert.equal(e.mode, "shadow"); assert.equal(e.verdict, "eligible"); assert.equal(e.light, false);
|
|
6027
|
+
assert.equal(builders(w).length, 2);
|
|
6028
|
+
assert.match(builders(w)[0][4], /PLANNING ONLY/);
|
|
6029
|
+
assert.ok(!/Light-lane upgrade/.test(builders(w)[0][4]));
|
|
6030
|
+
assert.ok(!w.comments.some((c) => c.includes("<!-- FORGE:LITE -->")), "#4452: no receipt outside a light lane");
|
|
6031
|
+
});
|
|
6032
|
+
|
|
6033
|
+
/** #4451: lworld plus counting fakes for the overhead the light lane skips; each call notes its position in w.calls */
|
|
6034
|
+
const overhead = (o = {}) => {
|
|
6035
|
+
const w = lworld(o);
|
|
6036
|
+
w.over = { pack: [], hot: [], structure: [] };
|
|
6037
|
+
w.io.contextFacts = async () => { w.over.pack.push(w.calls.length); return { sources: {}, items: [] }; };
|
|
6038
|
+
w.io.hotspots = async (x) => { w.over.hot.push(w.calls.length); return { enabled: true, files: [], base: x.base ?? null }; };
|
|
6039
|
+
w.io.structureDelta = async () => { w.over.structure.push(w.calls.length); return { package_edges_added: [], package_edges_removed: [], import_pairs: [] }; };
|
|
6040
|
+
return w;
|
|
6041
|
+
};
|
|
6042
|
+
it("#4451 a light lane that is not upgraded makes no context-pack, structure or hotspot calls", async () => {
|
|
6043
|
+
const w = overhead(), dir = tmpDir("light-over-");
|
|
6044
|
+
const r = await run(w, { dir, ...ON, contextPack: { mode: "on" } });
|
|
6045
|
+
assert.equal(r.terminalReason, "merged");
|
|
6046
|
+
assert.equal(events(dir, "LIGHT_DONE").length, 1);
|
|
6047
|
+
assert.deepEqual(w.over, { pack: [], hot: [], structure: [] });
|
|
6048
|
+
assert.equal(events(dir, "CONTEXT_PACK").length, 0);
|
|
6049
|
+
// only the merge-time record (local numstat, no structure-delta call) remains; no implement-time delta
|
|
6050
|
+
assert.deepEqual(events(dir, "STRUCTURE_DELTA").map((e) => e.phase), ["merge"]);
|
|
6051
|
+
assert.equal(events(dir, "RUN_TERMINAL")[0].light, true);
|
|
6052
|
+
});
|
|
6053
|
+
it("#4451 an upgraded light lane computes the hotspots and the context pack before the plan call and runs the structure delta", async () => {
|
|
6054
|
+
const w = overhead({ numstat: "80\t3\tsrc/foo.js\n20\t0\ttest/foo.test.js\n" }), dir = tmpDir("light-over-up-");
|
|
6055
|
+
await run(w, { dir, ...ON, contextPack: { mode: "on" } });
|
|
6056
|
+
assert.equal(events(dir, "LIGHT_UPGRADE").length, 1);
|
|
6057
|
+
const plan = w.calls.indexOf(builders(w)[1]);
|
|
6058
|
+
// each fake notes w.calls.length when called, i.e. the index the next call takes: <= plan means before the plan call
|
|
6059
|
+
assert.ok(w.over.hot.some((i) => i <= plan) && w.over.pack.some((i) => i <= plan), JSON.stringify({ plan, ...w.over }));
|
|
6060
|
+
assert.ok(w.over.hot.every((i) => i > w.calls.indexOf(builders(w)[0])), "not before the light session");
|
|
6061
|
+
assert.ok(w.over.structure.length >= 1);
|
|
6062
|
+
assert.equal(events(dir, "RUN_TERMINAL")[0].light, false);
|
|
6063
|
+
});
|
|
6064
|
+
it("#4451 a full lane (light off) keeps its context-pack, structure and hotspot calls", async () => {
|
|
6065
|
+
const w = overhead(), dir = tmpDir("light-over-off-");
|
|
6066
|
+
await run(w, { dir, light: { mode: "off" }, contextPack: { mode: "on" } });
|
|
6067
|
+
assert.ok(w.over.pack.length >= 1 && w.over.hot.length >= 1 && w.over.structure.length >= 1, JSON.stringify(w.over));
|
|
6068
|
+
});
|
|
6069
|
+
it("#4451 the light targeted tests run concurrently, never more than FORGE_HOST_PARALLELISM in flight", async () => {
|
|
6070
|
+
const cmds = ["node --test t1.js", "node --test t2.js", "node --test t3.js", "node --test t4.js"];
|
|
6071
|
+
const w = lworld({ result: { ...RESULT, tests: cmds.map((command) => ({ command, result: "pass" })) } }), dir = tmpDir("light-par-");
|
|
6072
|
+
let live = 0, max = 0;
|
|
6073
|
+
const seen = [];
|
|
6074
|
+
w.io.runCommand = async (a) => { live++; max = Math.max(max, live); seen.push(a.env?.FORGE_HOST_PARALLELISM); await new Promise((r) => setTimeout(r, 5)); live--; return { rc: 0, output: "" }; };
|
|
6075
|
+
const prev = process.env.FORGE_HOST_PARALLELISM;
|
|
6076
|
+
process.env.FORGE_HOST_PARALLELISM = "2";
|
|
6077
|
+
try { await run(w, { dir, ...ON }); } finally { if (prev === undefined) delete process.env.FORGE_HOST_PARALLELISM; else process.env.FORGE_HOST_PARALLELISM = prev; }
|
|
6078
|
+
assert.equal(events(dir, "LIGHT_DONE").length, 1);
|
|
6079
|
+
assert.equal(max, 2, "two in flight at once, capped at the host parallelism");
|
|
6080
|
+
assert.ok(seen.every((x) => x === undefined || x === "1"), `each command gets its worker share: ${seen}`);
|
|
6081
|
+
});
|
|
6082
|
+
it("#4451 lane telemetry records wall time and model time separately", async () => {
|
|
6083
|
+
const w = lworld(), dir = tmpDir("light-time-");
|
|
6084
|
+
const r = await run(w, { dir, ...ON });
|
|
6085
|
+
const model = events(dir, "PHASE_ATTEMPT_USAGE").reduce((n, e) => n + e.duration_ms, 0);
|
|
6086
|
+
assert.ok(model > 0);
|
|
6087
|
+
assert.equal(r.model_ms, model);
|
|
6088
|
+
assert.ok(Number.isFinite(r.wall_ms));
|
|
6089
|
+
const term = w.telemetry.find((t) => t.event === "RUN_TERMINAL");
|
|
6090
|
+
assert.equal(term.model_ms, model); assert.ok(Number.isFinite(term.wall_ms));
|
|
6091
|
+
});
|
|
6092
|
+
|
|
6093
|
+
it("a resumed lane never goes light, even in mode on", async () => {
|
|
6094
|
+
const w = lworld({ remote: "abc\trefs/heads/feat/issue-7-lean\n" }), dir = tmpDir("light-res-");
|
|
6095
|
+
await run(w, { dir, ...ON });
|
|
6096
|
+
assert.equal(events(dir, "LIGHT_DONE").length + events(dir, "LIGHT_UPGRADE").length, 0);
|
|
6097
|
+
});
|
|
6098
|
+
});
|