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/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
- const ghc = (args) => io.gh(args);
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(() => io.gh(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
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 ctxCfg = parseContextPackConfig(o.contextPack ?? {});
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
- hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
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
- const ctxPack = frozen ? null : await contextPackStep();
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);
@@ -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
+ });