forgedock 1.48.1 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lean/lean.mjs CHANGED
@@ -15,7 +15,7 @@ import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAUL
15
15
  import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
16
16
  import { createHash, randomBytes } from "node:crypto";
17
17
  import { otherLaneOwner } from "./lanelock.mjs";
18
- import { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, amendmentKindProblems, precheck, alreadyFixedOnBase, READONLY_PROD_RE, EXTERNAL_NEED_RE, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
18
+ import { applyValidateRecipes, deferChecklessPostDeploy, reproEnvMiss, resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, amendmentKindProblems, precheck, alreadyFixedOnBase, READONLY_PROD_RE, EXTERNAL_NEED_RE, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, renderPendingFollowups, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
19
19
  import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
20
20
  import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
21
21
  import { checksAt } from "./postdeploy-check.mjs";
@@ -32,7 +32,7 @@ import { runChecks, runWithTimeoutRetry, planPrecheck, worktreeFingerprint, self
32
32
  import { retrying, ghCacheRetry, refreshAuthOnce, runRefreshScript, GH_AUTH_RE, GH_AUTH_RETRY_SECONDS, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
33
33
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, parseAmendmentsComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
34
34
  import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
35
- import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
35
+ import { computeTrigger, riskPathHits, TRIGGER_MODES, pathDomains, parseEnforceDomains } from "./review-trigger.mjs";
36
36
  import { hostCapabilityBlock, contractHostNeeds } from "./execution.mjs";
37
37
 
38
38
  const MISSING_TOOL_RE = /command not found|not found in PATH|no such file or directory.*\b(?:docker|promtool)\b|\b(?:docker|promtool)\b[^\n]{0,80}(?:not (?:installed|found|available)|no such file|cannot connect|daemon)|cannot connect to the docker daemon|no docker daemon|no module named|ModuleNotFoundError|\bENOENT\b[^\n]{0,60}spawn/i;
@@ -205,7 +205,8 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
205
205
  * @param {"off"|"report"} [o.conformanceMutation] forge.yaml engine.conformance_mutation (#4913): rerun each evidence test with the change reverted; a survivor is conformance.mutation_survived (shadow unless conformance_kinds enforces it)
206
206
  * @param {Record<string,"enforce"|"shadow">} [o.conformanceKinds] forge.yaml engine.conformance_kinds: a failure code's own mode (enforce blocks, shadow is recorded only); others follow `conformance`
207
207
  * @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
208
- * @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs}
208
+ * @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs,
209
+ * enforceDomains: review.enforce_domains (#5099), a touched listed domain makes the lane enforce}
209
210
  * @param {object} [o.verifier] forge.yaml review.verifier/verifier_model (#4397): {mode: off (default)|shadow|enforce, model}
210
211
  * @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
211
212
  * @param {object} [o.precheck] {commands, maxMinutes}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240);
@@ -239,6 +240,7 @@ export async function runCore(o) {
239
240
  const remoteVerify = vcfg.mode !== "local";
240
241
  const triggerMode = TRIGGER_MODES.includes(o.reviewTrigger?.mode) ? o.reviewTrigger.mode : "shadow";
241
242
  const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
243
+ const enforceDomains = parseEnforceDomains(o.reviewTrigger?.enforceDomains ?? []); // #5099
242
244
  const verifierCfg = parseVerifierConfig(o.verifier ?? {});
243
245
  const routingCfg = parseBuilderRouting(o.builderRouting ?? {});
244
246
  // #4394: the build's model tier, routed from the frozen contract; a failed validate round on the mid tier escalates once
@@ -317,7 +319,8 @@ export async function runCore(o) {
317
319
  const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
318
320
  // forge#4275: the typed outcome code and failure classes ride on FORGE:STATE, so the next run's context pack reads them typed.
319
321
  const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort(), ...(reason === "merged" && mergedTo ? { mergedTo } : {}) } : {}; // #4711: where the change landed
320
- try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed } : {}); } catch { /* best effort */ }
322
+ const pending = pendingRecord().length ? { pendingFollowups: pendingRecord() } : {}; // #5141: unfiled follow-ups, filed later by the marker
323
+ try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed, ...pending } : pending); } catch { /* best effort */ }
321
324
  }
322
325
 
323
326
  // Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
@@ -336,6 +339,7 @@ export async function runCore(o) {
336
339
  let guardAddedMs = 0; // #4705 (R12): wall time the guard gate added, summed over the lane, carried on RUN_TERMINAL
337
340
  let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
338
341
  const kinds = normalizeKindModes(conformanceKinds); // engine.conformance_kinds: a failure code's own mode
342
+ let domains = [], domainsLogged = null; // #5099: review domains the diff touches (review-roster.sh path triggers)
339
343
  let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
340
344
  let done = null, finishing = null, wtCreated = false, salvagedRef = null;
341
345
  let light = false, modelMs = 0; // #4451: the light lane finished without an upgrade (the same structure/hotspot gates as the full lane, #4502); summed session time
@@ -354,6 +358,8 @@ export async function runCore(o) {
354
358
  let seqLease = null; // #4386: the host sequence lease, held from the merge-gate renumber through the merge
355
359
  const waits = {};
356
360
  const followupsFiled = []; // #5078: {issue, class, count} per tracking issue this lane filed into, for the batch report
361
+ const pendingFollowups = []; // #5141: {req, why} whose filing kept failing in-lane; filed after the merge, else recorded
362
+ const pendingRecord = () => pendingFollowups.filter((p) => !p.req.tracked).map((p) => ({ id: p.req.id, quote: String(p.req.quote ?? ""), disposition: p.req.disposition, why: p.why, marker: `FORGE:DEFERRED_FROM: #${issue}` }));
357
363
  let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
358
364
  const addWait = (phase, ms, queued = 0) => {
359
365
  if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
@@ -491,7 +497,7 @@ export async function runCore(o) {
491
497
  await releaseLease(reason, code, extra.mergedTo ?? null);
492
498
  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, phases: phaseLedger.snapshot(),
493
499
  ...(laneHead && reason !== "merged" ? { laneHead: { ...laneHead } } : {}), light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
494
- ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(followupsFiled.length ? { followups: followupsFiled.map((f) => ({ ...f })) } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
500
+ ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(followupsFiled.length ? { followups: followupsFiled.map((f) => ({ ...f })) } : {}), ...(pendingRecord().length ? { pendingFollowups: pendingRecord() } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
495
501
  ...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
496
502
  ...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
497
503
  ...(gateRan ? { conformance: { mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", would: shadowWould, risk: Boolean(riskEnforced), ...(Object.keys(kinds).length ? { kinds: [...failureClasses] } : {}) } } : {}), ...extra };
@@ -1401,25 +1407,54 @@ export async function runCore(o) {
1401
1407
  }
1402
1408
  return block ? await endBlocked(block, result.summary) : null;
1403
1409
  }
1404
- /** File (or append to) the parent's one batched follow-up issue for these requirements, then verify each tracking issue is open. */
1405
- async function track(reqs, why) {
1406
- const fail = (detail) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry` });
1410
+ /**
1411
+ * File (or append to) the parent's one batched follow-up issue for these requirements, then verify each tracking issue is open.
1412
+ * #5141: the whole step is retried in-lane (3 attempts, 5s then 15s): the script is idempotent (claim marker /
1413
+ * FORGE:DEFERRED_FROM lookup, checklist dedup, duplicate close), so only a persistent failure defers the run.
1414
+ * A malformed TSV (DEFERRED_TSV=INVALID) is deterministic and never retried. #5141: when the retries are spent the run
1415
+ * keeps going (keep): the untracked requirements join pendingFollowups, recorded in FORGE:STATE and filed after the merge.
1416
+ */
1417
+ async function track(reqs, why, { keep = true } = {}) {
1418
+ const fail = (detail, final = true) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry`, final });
1407
1419
  if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
1408
1420
  const items = followupItems(reqs, { now: now() });
1409
- let filed;
1410
- try { filed = await io.fileFollowups({ parent: issue, repo, items, milestone, defaultMilestone: o.followupsDefaultMilestone ?? null, lane, batchId: o.batchId ?? null, rollupLabel: o.followupsRollupLabel ?? null }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
1411
- for (const [i, r] of reqs.entries()) {
1412
- const n = Number(filed?.[items[i].key]);
1413
- if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
1414
- let st = null;
1415
- try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
1416
- if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
1417
- r.tracked = `#${n}`;
1418
- const f = followupsFiled.find((x) => x.issue === n && x.class === items[i].class); // #5078: grouped per issue and class
1419
- if (f) f.count += 1; else followupsFiled.push({ issue: n, class: items[i].class, count: 1 });
1420
- }
1421
- const buckets = Object.entries(bucketTally(reqs)).map(([k, c]) => `${k}:${c}`).join(","); // #4724
1422
- T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why}; buckets ${buckets})`);
1421
+ const attempt = async () => {
1422
+ let filed;
1423
+ try { filed = await io.fileFollowups({ parent: issue, repo, items, milestone, defaultMilestone: o.followupsDefaultMilestone ?? null, lane, batchId: o.batchId ?? null, rollupLabel: o.followupsRollupLabel ?? null }); } catch (e) {
1424
+ throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`, /DEFERRED_TSV=INVALID/.test(`${e?.stdout ?? ""}${e?.message ?? ""}`));
1425
+ }
1426
+ for (const [i, r] of reqs.entries()) {
1427
+ if (recorded.has(i)) continue; // tracked in an earlier attempt: keeps its r.tracked, never re-counted
1428
+ const n = Number(filed?.[items[i].key]);
1429
+ if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`, false);
1430
+ let st = null;
1431
+ try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
1432
+ if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`, false);
1433
+ recorded.add(i);
1434
+ r.tracked = `#${n}`;
1435
+ const f = followupsFiled.find((x) => x.issue === n && x.class === items[i].class); // #5078: grouped per issue and class
1436
+ if (f) f.count += 1; else followupsFiled.push({ issue: n, class: items[i].class, count: 1 });
1437
+ }
1438
+ };
1439
+ const recorded = new Set();
1440
+ const waits = [5000, 15000];
1441
+ for (let k = 0; ; k++) {
1442
+ try { await attempt(); break; } catch (e) {
1443
+ if (e.final || (k >= waits.length && !keep)) throw e;
1444
+ if (k >= waits.length) { // #5141: keep the merge-capable work; the close step files these after the merge
1445
+ const left = reqs.filter((_, i) => !recorded.has(i));
1446
+ for (const r of left) if (!pendingFollowups.some((p) => p.req === r)) pendingFollowups.push({ req: r, why });
1447
+ T.anomalies.push(`follow-up filing failed ${k + 1} times (${String(e.message).slice(0, 160)}): ${left.map((r) => r.id).join(", ")} pending, filed after the merge (FORGE:DEFERRED_FROM: #${issue})`);
1448
+ break;
1449
+ }
1450
+ T.decisions.push(`Follow-up filing attempt ${k + 1} failed (${String(e.message).slice(0, 160)}); retrying in-lane in ${waits[k] / 1000}s`);
1451
+ await sleep(waits[k]);
1452
+ }
1453
+ }
1454
+ const done = reqs.filter((r) => r.tracked);
1455
+ if (!done.length) return;
1456
+ const buckets = Object.entries(bucketTally(done)).map(([k, c]) => `${k}:${c}`).join(","); // #4724
1457
+ T.decisions.push(`Tracked ${done.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(done.map((r) => r.tracked))].join(", ")} (${why}; buckets ${buckets})`);
1423
1458
  }
1424
1459
  /**
1425
1460
  * #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
@@ -2211,7 +2246,7 @@ export async function runCore(o) {
2211
2246
  await renew();
2212
2247
  const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
2213
2248
  prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
2214
- : reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE, investigationVerdict: investigationVerdict(await io.fs.read(`${wt}/.forge-lean/investigation.md`).catch(() => null)) }) });
2249
+ : reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE, domains, investigationVerdict: investigationVerdict(await io.fs.read(`${wt}/.forge-lean/investigation.md`).catch(() => null)) }) });
2215
2250
  await renew();
2216
2251
  await attempt("review", ++reviewN, rr, null, "review");
2217
2252
  const parsed = parseReview(rr?.text);
@@ -2342,6 +2377,20 @@ export async function runCore(o) {
2342
2377
  ev({ event: "CONFORMANCE_RISK_ENFORCED", paths: hits });
2343
2378
  T.decisions.push(`Conformance enforced (review.risk_paths): ${hits.slice(0, 10).join(", ")}${hits.length > 10 ? ` and ${hits.length - 10} more` : ""}`);
2344
2379
  }
2380
+ // #5099: the domains the diff touches get domain checks in the review; a review.enforce_domains hit enforces (sticky)
2381
+ const pd = pathDomains(files);
2382
+ const dErr = "Review domains unavailable: scripts/review-roster.sh failed; reviewed without domain checks";
2383
+ if (pd === null && !T.anomalies.includes(dErr)) T.anomalies.push(dErr);
2384
+ domains = pd ?? [];
2385
+ const dHits = shadow ? domains.filter((d) => enforceDomains.includes(d)) : [];
2386
+ if (dHits.length) {
2387
+ shadow = false; riskEnforced = dHits.map((d) => `domain:${d}`);
2388
+ T.decisions.push(`Conformance enforced (review.enforce_domains): ${dHits.join(", ")}`);
2389
+ }
2390
+ if (domainsLogged !== domains.join(",")) {
2391
+ domainsLogged = domains.join(",");
2392
+ ev({ event: "REVIEW_DOMAINS", domains, enforced: dHits.length ? dHits : undefined, ...(pd === null ? { error: "roster.failed" } : {}) });
2393
+ }
2345
2394
  if (!amendFixed && sessionId && amendmentKindProblems(result?.amendments).length) { // #5089: one in-lane correction, outside the conformance resume budget
2346
2395
  amendFixed = true;
2347
2396
  const bad = amendmentKindProblems(result.amendments);
@@ -2998,8 +3047,22 @@ export async function runCore(o) {
2998
3047
  }
2999
3048
  try { await recordStructure(pr); } catch (e) { ev({ event: "STRUCTURE_DELTA_FAILED", error: String(e?.message ?? e).slice(0, 300) }); }
3000
3049
  await deleteMergedBranch();
3050
+ let keepOpen = false;
3051
+ if (pendingRecord().length) { // #5141: the close step files what the lane could not track before the PR (idempotent by the marker)
3052
+ const left = pendingFollowups.filter((p) => !p.req.tracked);
3053
+ for (const why of new Set(left.map((p) => p.why))) {
3054
+ try { await track(left.filter((p) => p.why === why).map((p) => p.req), `${why}; filed after the merge`); } catch (e) { T.anomalies.push(`follow-up filing after the merge failed: ${String(e?.message ?? e).slice(0, 160)}`); }
3055
+ }
3056
+ for (const p of left) if (p.req.tracked && !closing.tracked.includes(p.req.tracked)) closing.tracked.push(p.req.tracked);
3057
+ const still = pendingRecord();
3058
+ if (still.length) {
3059
+ keepOpen = true; // release review #5155 (#5156): nothing re-files a pending item, so the issue stays open as the durable record
3060
+ T.decisions.push(`Follow-up still unfiled after the merge: ${still.map((x) => x.id).join(", ")}; issue left open (FORGE:FOLLOWUP_PENDING, FORGE:STATE pendingFollowups) until it is tracked`);
3061
+ try { await post("FORGE:FOLLOWUP_PENDING", renderPendingFollowups(still, pr)); } catch { /* FORGE:STATE and the lane result carry them too */ }
3062
+ }
3063
+ }
3001
3064
  const partial = closing.reason === "partial";
3002
- await ghw(["issue", "close", String(issue), "-R", repo, "--reason", partial ? "not planned" : "completed"]).catch(() => {});
3065
+ if (!keepOpen) await ghw(["issue", "close", String(issue), "-R", repo, "--reason", partial ? "not planned" : "completed"]).catch(() => {});
3003
3066
  if (partial) {
3004
3067
  try { await post("FORGE:TRACKED", `## Closed as partial\nThe investigation's stated question is not answered in this change (PR #${pr}); carried forward in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
3005
3068
  } else if (closing.tracked.length) {
@@ -186,7 +186,9 @@ function world(o = {}) {
186
186
  transitionLabel: async (n, s) => { w.labels.push(s); w.seq.push(`label:${s}`); },
187
187
  ...(o.procs ? { proc: { inDir: async () => o.procs, alive: (pid) => w.alive.has(pid), kill: (pid, sig) => { w.kills.push([pid, sig]); w.seq.push(`kill-${sig}`); if (sig === "SIGKILL" || !o.stubborn) w.alive.delete(pid); } } } : {}),
188
188
  fs: { ...(o.wtExists ? { exists: async (p) => p.startsWith("/wt/lean-7") } : {}), stat: async (p) => (p in w.files ? { mtimeMs: w.mtimes[p] ?? 1 } : null), read: async (p) => { if (!(p in w.files)) throw new Error("ENOENT"); return w.files[p]; }, append: async (p, t) => { w.files[p] = (w.files[p] ?? "") + t; }, write: async (p, t) => { w.files[p] = t; }},
189
- fileFollowups: async ({ parent, items }) => { w.followups.push({ parent, items }); if (o.followupError) throw new Error(o.followupError); return Object.fromEntries(items.map((i) => [i.key, o.followupIssue ?? 900])); },
189
+ fileFollowups: async ({ parent, items }) => { w.followups.push({ parent, items }); if (o.followupError) throw Object.assign(new Error(o.followupError), { stdout: o.followupStdout ?? "" });
190
+ if (o.followupRet) return o.followupRet(w.followups.length, items);
191
+ if (w.followups.length <= (o.followupFailTimes ?? 0)) throw new Error("DEFERRED_LOOKUP=FAILED"); if (w.followups.length <= (o.followupMissingTimes ?? 0)) return {}; return Object.fromEntries(items.map((i) => [i.key, o.followupIssue ?? 900])); },
190
192
  };
191
193
  return w;
192
194
  }
@@ -982,21 +984,82 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
982
984
  });
983
985
  }
984
986
 
985
- it("a follow-up that cannot be filed defers the run (followup.unavailable): no PR, merge or close; never needs-human", async () => {
987
+ it("#5141 a follow-up that cannot be filed keeps the work: PR and merge proceed, the issue stays open with the unfiled item recorded, never needs-human (#5156)", async () => {
986
988
  const k = two("defer", { why: "w" });
987
989
  const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupError: "DEFERRED_LOOKUP=FAILED" });
990
+ w.leaseWrites = []; w.io.projector = { readState: async () => null, writeState: async (_i, st) => { w.leaseWrites.push(st); } };
988
991
  const r = await run(w);
989
- assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "followup.unavailable");
990
- assert.ok(!w.comments.some((c) => c.includes("FORGE:REVIEW")), "no verdict is published for a run that cannot link its tracking");
991
- assert.equal(w.prs.length, 0); assert.equal(w.merges.length, 0); assert.equal(w.closed.length, 0);
992
+ assert.equal(r.terminalReason, "merged"); assert.notEqual(r.code, "followup.unavailable");
993
+ assert.equal(w.prs.length, 1); assert.equal(w.merges.length, 1);
994
+ assert.equal(w.closed.length, 0, "release review #5155 (#5156): nothing re-files a pending item, so the issue is not closed over it");
995
+ assert.equal(w.followups.length, 6, "3 in-lane attempts before the PR, 3 more in the close step");
996
+ assert.deepEqual(r.pendingFollowups, [{ id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "accepted by the conformance review", marker: "FORGE:DEFERRED_FROM: #7" }]);
997
+ assert.deepEqual(w.leaseWrites.at(-1).pendingFollowups, r.pendingFollowups, "the issue's FORGE:STATE carries the unfiled item");
998
+ const note = w.comments.find((c) => c.includes("FORGE:FOLLOWUP_PENDING"));
999
+ assert.match(note, /R2 \(defer\): Existing callers of foo keep working\..*`FORGE:DEFERRED_FROM: #7`/);
992
1000
  assert.ok(!w.edits.some((e) => e.includes("--add-label") && e.includes("needs-human")));
993
1001
  });
994
1002
 
995
- it("a tracking issue that is not open is not a tracking issue (followup.unavailable)", async () => {
1003
+ it("#5141 a follow-up that keeps failing before the PR is filed by the close step after the merge", async () => {
1004
+ const k = two("defer", { why: "w" });
1005
+ const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupMissingTimes: 3 });
1006
+ const r = await run(w);
1007
+ assert.equal(r.terminalReason, "merged"); assert.equal(w.followups.length, 4); assert.equal(r.pendingFollowups, undefined);
1008
+ assert.ok(w.comments.some((c) => c.includes("FORGE:TRACKED") && c.includes("#900")));
1009
+ assert.ok(!w.comments.some((c) => c.includes("FORGE:FOLLOWUP_PENDING")));
1010
+ assert.match(traj(w).join("\n"), /Tracked R2 \(defer\) in #900 \(accepted by the conformance review; filed after the merge/);
1011
+ });
1012
+
1013
+ it("#5141 a follow-up filing that fails once is retried in-lane, not requeued: the run proceeds and tracks once", async () => {
1014
+ const k = two("defer", { why: "w" });
1015
+ const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupFailTimes: 1 });
1016
+ const r = await run(w);
1017
+ assert.notEqual(r.code, "followup.unavailable");
1018
+ assert.equal(w.followups.length, 2, "one retry of the whole filing step");
1019
+ assert.equal(w.prs.length, 1);
1020
+ });
1021
+
1022
+ it("#5141 a follow-up filing that returns no tracking issue is retried in-lane before deferring", async () => {
1023
+ const k = two("defer", { why: "w" });
1024
+ const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupMissingTimes: 2 });
1025
+ const r = await run(w);
1026
+ assert.notEqual(r.code, "followup.unavailable");
1027
+ assert.equal(w.followups.length, 3);
1028
+ });
1029
+
1030
+ it("#5141 a requirement tracked in an earlier attempt keeps its tracking issue; the retry tracks only the rest, once each", async () => {
1031
+ const base = two("defer", { why: "w" });
1032
+ const k = { ...base, requirements: [...base.requirements, { id: "R3", quote: "Old callers log a warning.", disposition: "defer", bucket: "post-deploy", why: "w" }] };
1033
+ let views900 = 0;
1034
+ const w = world({ contract: k, body: `${ISSUE_BODY}\n- Old callers log a warning.`, issueStates: { get 900() { views900 += 1; return "OPEN"; } }, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)],
1035
+ followupRet: (call, items) => (call === 1 ? { [items[0].key]: 900 } : { [items[0].key]: 900, [items[1].key]: 901 }) });
1036
+ const r = await run(w);
1037
+ assert.notEqual(r.code, "followup.unavailable");
1038
+ assert.equal(w.followups.length, 2);
1039
+ const t = traj(w).join("\n");
1040
+ assert.match(t, /Follow-up filing attempt 1 failed \(no tracking issue was returned for R3\); retrying in-lane in 5s/);
1041
+ assert.match(t, /Tracked R2 \(defer\), R3 \(defer\) in #900, #901/);
1042
+ assert.equal(views900, 1, "R2, verified open in attempt 1, is not re-verified or re-counted by the retry");
1043
+ });
1044
+
1045
+ it("#5141 a persistent follow-up failure is retried 3 times in-lane, then the run keeps going; a malformed TSV is not retried and defers", async () => {
1046
+ const k = two("defer", { why: "w" });
1047
+ const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupMissingTimes: 99 });
1048
+ const r = await run(w);
1049
+ assert.equal(r.terminalReason, "merged"); assert.equal(w.followups.length, 6); assert.equal(w.prs.length, 1);
1050
+ assert.equal(r.pendingFollowups[0].id, "R2");
1051
+ const w2 = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], followupError: "Command failed", followupStdout: "DEFERRED_TSV=INVALID\n" });
1052
+ const r2 = await run(w2);
1053
+ assert.equal(r2.code, "followup.unavailable"); assert.equal(w2.followups.length, 1); assert.equal(w2.prs.length, 0);
1054
+ });
1055
+
1056
+ it("a tracking issue that is not open is not a tracking issue: the item stays pending and is recorded", async () => {
996
1057
  const k = two("defer", { why: "w" });
997
1058
  const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)], issueStates: { 900: "CLOSED" } });
998
1059
  const r = await run(w);
999
- assert.equal(r.code, "followup.unavailable"); assert.equal(w.merges.length, 0);
1060
+ assert.equal(r.terminalReason, "merged"); assert.equal(r.pendingFollowups[0].id, "R2");
1061
+ assert.ok(w.comments.some((c) => c.includes("FORGE:FOLLOWUP_PENDING")));
1062
+ assert.equal(w.closed.length, 0, "left open while R2 is untracked (#5156)");
1000
1063
  });
1001
1064
 
1002
1065
  it("an existing PR gets the tracking links in its body before merge", async () => {
@@ -1659,6 +1722,24 @@ describe("conformance shadow mode (the default)", () => {
1659
1722
  assert.ok(traj(w)[0].includes("| Close | issue closed (partial) |"));
1660
1723
  });
1661
1724
 
1725
+ it("#5099 a web/ diff gets frontend domain checks in the reviewer prompt; review.enforce_domains makes the lane enforce", async () => {
1726
+ const k = { ...CONTRACT, preserve: [] }, changed = ["web/app/page.tsx", "test/foo.test.js"];
1727
+ const seenPrompts = (w) => { const out = [], r0 = w.io.runner; w.io.runner = async (c) => { if (c.role === "reviewer") out.push(c.prompt); return r0(c); }; return out; };
1728
+ const dir = tmpDir("dom-"), s = world({ contract: k, changed, reviews: [omission()] }), ps = seenPrompts(s);
1729
+ const rs = await runShadow(s, { dir });
1730
+ assert.equal(rs.terminalReason, "merged", "without enforce_domains the lane stays shadow");
1731
+ assert.match(ps[0], /<domain_checks>[\s\S]*regressions row[\s\S]*- frontend: [\s\S]*not-found\/error boundary/);
1732
+ assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "REVIEW_DOMAINS").map((e) => e.domains), [["frontend"]]);
1733
+ const plain = world({ contract: k, reviews: [ok({}, k)] }), pp = seenPrompts(plain);
1734
+ await runShadow(plain);
1735
+ assert.ok(!pp[0].includes("<domain_checks>"), "no domain touched: no domain block");
1736
+ const e = world({ contract: k, changed, reviews: [omission(), omission()] });
1737
+ const re = await runShadow(e, { reviewTrigger: { mode: "shadow", riskPaths: [], enforceDomains: ["frontend"] } });
1738
+ assert.equal(re.terminalReason, "needs-human"); assert.equal(e.merges.length, 0);
1739
+ assert.deepEqual(re.conformance, { mode: "enforce", would: null, risk: true });
1740
+ assert.ok(traj(e)[0].includes("Conformance enforced (review.enforce_domains): frontend"));
1741
+ });
1742
+
1662
1743
  it("#4398 an investigation whose answer the review marks deferred is tracked before the PR and closes partial, even though shadow merges it", async () => {
1663
1744
  const k = { ...CONTRACT, preserve: [] };
1664
1745
  const w = world({ title: "investigate: foo", contract: k, reviews: [conf({ requirements: [{ id: "R1", status: "deferred", evidence: "", validated_by: "", note: "question open" }] }, k)] });
@@ -5930,6 +6011,8 @@ describe("review trigger (#4273): shadow logs and always reviews, enforce skips
5930
6011
  assert.deepEqual(at('review:\n trigger: enforce\n risk_paths: "db/**, *.sql"\n'), { mode: "enforce", riskPaths: ["db/**", "*.sql"] });
5931
6012
  assert.deepEqual(at("review:\n tech_stack: x\n risk_paths:\n - \"infra/**\"\n - lib/acct/*.ts # c\n trigger: bogus\n"), { mode: "shadow", riskPaths: ["infra/**", "lib/acct/*.ts"] });
5932
6013
  assert.equal(at("engine:\n path: core\n"), undefined);
6014
+ assert.deepEqual(at('review:\n enforce_domains: "Frontend, bogus"\n'), { mode: "shadow", riskPaths: [], enforceDomains: ["frontend"] }, "#5099");
6015
+ assert.deepEqual(at("review:\n enforce_domains:\n - auth\n risk_paths:\n - db/**\n"), { mode: "shadow", riskPaths: ["db/**"], enforceDomains: ["auth"] }, "#5099 block lists");
5933
6016
  });
5934
6017
  });
5935
6018
 
@@ -0,0 +1,136 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Live execution control of a running batch (#5138): `forgedock orchestrate --set-max-remote <id> N` and
5
+ * `--set-placement <id> remote|hybrid|local`, like `--set-parallel` for K. The override lives in the batch's `execution`
6
+ * file (batch.mjs `readExecution`/`setExecution`, JSON `{maxRemote?, mode?, provider?}`); the runner re-reads it on every
7
+ * admission through the getters `liveRemote` puts on the `remote` object orchestrate gets (`max`, `fallbackLocal`, `place`).
8
+ * Running lanes are never stopped: a lower `max_remote` only holds new remote admissions. A batch that started without a
9
+ * remote provider (`provider` not true: mode local, or `max_remote` 0) refuses `remote`/`hybrid`, since its provider,
10
+ * ledger and execution budget are built once at start; start a new batch for that.
11
+ */
12
+
13
+ export const LIVE_MODES = Object.freeze(["remote", "hybrid", "local"]);
14
+ const LIGHT_WEIGHT = Object.freeze({ heavy: false, signals: [] });
15
+
16
+ /** Validate a patch `{maxRemote?, mode?, provider?}`; throws on a bad value. @returns the cleaned patch. */
17
+ export function validateExecutionPatch(patch = {}) {
18
+ const out = {};
19
+ if (patch.maxRemote !== undefined) {
20
+ const n = typeof patch.maxRemote === "string" && /^\d+$/.test(patch.maxRemote.trim()) ? parseInt(patch.maxRemote, 10) : patch.maxRemote;
21
+ if (!Number.isInteger(n) || n < 0) throw new Error("max_remote must be a non-negative integer");
22
+ out.maxRemote = n;
23
+ }
24
+ if (patch.mode !== undefined) {
25
+ if (!LIVE_MODES.includes(patch.mode)) throw new Error(`placement must be one of ${LIVE_MODES.join("|")}`);
26
+ out.mode = patch.mode;
27
+ }
28
+ if (patch.provider !== undefined) {
29
+ if (typeof patch.provider !== "boolean") throw new Error("provider must be a boolean");
30
+ out.provider = patch.provider;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** The fields of a stored override that are valid (anything else is dropped); null when there is none. */
36
+ export function sanitizeOverride(o) {
37
+ if (!o || typeof o !== "object" || Array.isArray(o)) return null;
38
+ const out = {};
39
+ if (Number.isInteger(o.maxRemote) && o.maxRemote >= 0) out.maxRemote = o.maxRemote;
40
+ if (LIVE_MODES.includes(o.mode)) out.mode = o.mode;
41
+ if (typeof o.provider === "boolean") out.provider = o.provider;
42
+ return out;
43
+ }
44
+
45
+ /** The pinned forge.yaml `exec` with the live override's `maxRemote` and `mode` applied. */
46
+ export function effectiveExecution(exec, override) {
47
+ const o = sanitizeOverride(override) ?? {};
48
+ return { ...(exec ?? {}), ...(o.maxRemote !== undefined ? { maxRemote: o.maxRemote } : {}), ...(o.mode !== undefined ? { mode: o.mode } : {}) };
49
+ }
50
+
51
+ /**
52
+ * `--set-max-remote <id> N` / `--set-placement <id> MODE` in argv → `{id, patch}`, or null when neither flag is there.
53
+ * Throws on a missing id or a bad value.
54
+ */
55
+ export function parseSetExecution(argv = []) {
56
+ const at = (flag) => argv.indexOf(flag);
57
+ let id = null;
58
+ const patch = {};
59
+ for (const [flag, key] of [["--set-max-remote", "maxRemote"], ["--set-placement", "mode"]]) {
60
+ const i = at(flag);
61
+ if (i < 0) continue;
62
+ const bid = argv[i + 1], val = argv[i + 2];
63
+ if (!bid || val === undefined) throw new Error(`usage: forgedock orchestrate ${flag} <id> ${key === "mode" ? LIVE_MODES.join("|") : "N"}`);
64
+ if (id !== null && id !== bid) throw new Error("--set-max-remote and --set-placement must name the same batch");
65
+ id = bid;
66
+ patch[key] = key === "maxRemote" ? (/^\d+$/.test(val) ? parseInt(val, 10) : NaN) : val;
67
+ }
68
+ return id === null ? null : { id, patch: validateExecutionPatch(patch) };
69
+ }
70
+
71
+ /**
72
+ * Apply a patch to `batch` (batch.mjs openBatch): refused for a missing batch, and `remote`/`hybrid` for a batch whose
73
+ * `execution` file does not record `provider: true`. @returns the stored override.
74
+ */
75
+ export function applySetExecution(batch, patch) {
76
+ if (!batch.exists()) throw new Error(`no batch ${batch.id}`);
77
+ const p = validateExecutionPatch(patch);
78
+ if ((p.mode === "remote" || p.mode === "hybrid") && batch.readExecution()?.provider !== true) {
79
+ throw new Error(`batch ${batch.id} has no remote provider; placement ${p.mode} needs one: start a new batch with forge.yaml execution`);
80
+ }
81
+ return batch.setExecution(p);
82
+ }
83
+
84
+ /** Text for a stored override: `max_remote 2, placement hybrid`. */
85
+ export function formatOverride(o) {
86
+ const s = sanitizeOverride(o) ?? {};
87
+ return [s.maxRemote !== undefined ? `max_remote ${s.maxRemote}` : null, s.mode ? `placement ${s.mode}` : null].filter(Boolean).join(", ") || "no change";
88
+ }
89
+
90
+ /**
91
+ * Hybrid validation weight (#4256) that is computed only when first needed: `initial` (already computed) or `compute()`
92
+ * started on the first `get()`; until it resolves (or when it fails) a light weight is used.
93
+ */
94
+ export function lazyWeight({ initial = null, compute = null } = {}) {
95
+ let w = initial, started = initial !== null;
96
+ return {
97
+ get: () => {
98
+ if (!started && typeof compute === "function") {
99
+ started = true;
100
+ Promise.resolve().then(compute).then((r) => { w = r ?? LIGHT_WEIGHT; }, () => { w = LIGHT_WEIGHT; });
101
+ }
102
+ return w ?? LIGHT_WEIGHT;
103
+ },
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Put live `max`, `fallbackLocal` and `place` getters on `base` (the `remote` object orchestrate gets; never spread it
109
+ * afterwards, a spread freezes the getters). `place(exec, n)` places one lane under the effective exec (execution.mjs
110
+ * `placeLane` with the remote or hybrid facts); mode `local` places every new lane on the host. `onChange(exec)` is
111
+ * called once each time the effective `{mode, maxRemote}` differs from the last one seen (the pinned one first).
112
+ */
113
+ export function liveRemote(base, { exec, readOverride = () => null, place, onChange = null }) {
114
+ const keyOf = (e) => `${e.mode}|${e.maxRemote}`;
115
+ let last = keyOf(effectiveExecution(exec, null));
116
+ const current = () => {
117
+ let o = null;
118
+ try { o = readOverride() ?? null; } catch { o = null; }
119
+ const e = effectiveExecution(exec, o);
120
+ const k = keyOf(e);
121
+ if (k !== last) { last = k; try { onChange?.(e); } catch { /* advisory */ } }
122
+ return e;
123
+ };
124
+ return Object.defineProperties(base, {
125
+ max: { get: () => current().maxRemote, enumerable: true, configurable: true },
126
+ fallbackLocal: { get: () => { const e = current(); return e.mode === "hybrid" && Number.isInteger(e.maxRemote) && e.maxRemote >= 1; }, enumerable: true, configurable: true },
127
+ place: {
128
+ value: (n) => {
129
+ const e = current();
130
+ if (e.mode === "local") return { where: "local", reason: "live placement: local" };
131
+ return place(e, n);
132
+ },
133
+ enumerable: true, configurable: true, writable: true,
134
+ },
135
+ });
136
+ }
@@ -0,0 +1,83 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { tmpDir } from "../tests/_tmp.mjs";
6
+ import { openBatch } from "./batch.mjs";
7
+ import { validateExecutionPatch, sanitizeOverride, effectiveExecution, parseSetExecution, applySetExecution, formatOverride, lazyWeight, liveRemote, LIVE_MODES } from "./live-execution.mjs";
8
+
9
+ describe("#5138 live execution control", () => {
10
+ it("validates patches: max_remote a non-negative integer, mode remote|hybrid|local", () => {
11
+ assert.deepEqual(LIVE_MODES, ["remote", "hybrid", "local"]);
12
+ assert.deepEqual(validateExecutionPatch({ maxRemote: 0, mode: "local" }), { maxRemote: 0, mode: "local" });
13
+ assert.deepEqual(validateExecutionPatch({ maxRemote: "3" }), { maxRemote: 3 });
14
+ for (const bad of [{ maxRemote: -1 }, { maxRemote: 1.5 }, { maxRemote: "x" }]) assert.throws(() => validateExecutionPatch(bad), /non-negative integer/);
15
+ assert.throws(() => validateExecutionPatch({ mode: "cloud" }), /placement must be one of/);
16
+ assert.throws(() => validateExecutionPatch({ provider: "yes" }), /boolean/);
17
+ assert.deepEqual(sanitizeOverride({ maxRemote: -2, mode: "x", provider: true }), { provider: true });
18
+ assert.equal(sanitizeOverride(null), null);
19
+ });
20
+
21
+ it("merges the override over the pinned exec", () => {
22
+ const exec = { mode: "remote", maxRemote: 4, provider: "container" };
23
+ assert.deepEqual(effectiveExecution(exec, null), exec);
24
+ assert.deepEqual(effectiveExecution(exec, { maxRemote: 1, mode: "hybrid" }), { mode: "hybrid", maxRemote: 1, provider: "container" });
25
+ assert.equal(formatOverride({ maxRemote: 2, mode: "hybrid" }), "max_remote 2, placement hybrid");
26
+ });
27
+
28
+ it("parses --set-max-remote / --set-placement", () => {
29
+ assert.equal(parseSetExecution(["--set-parallel", "b", "2"]), null);
30
+ assert.deepEqual(parseSetExecution(["--set-max-remote", "b1", "2"]), { id: "b1", patch: { maxRemote: 2 } });
31
+ assert.deepEqual(parseSetExecution(["--set-placement", "b1", "hybrid", "--set-max-remote", "b1", "0"]), { id: "b1", patch: { maxRemote: 0, mode: "hybrid" } });
32
+ assert.throws(() => parseSetExecution(["--set-max-remote", "b1", "-1"]), /non-negative/);
33
+ assert.throws(() => parseSetExecution(["--set-placement", "b1"]), /usage/);
34
+ assert.throws(() => parseSetExecution(["--set-placement", "b1", "x"]), /placement must be/);
35
+ assert.throws(() => parseSetExecution(["--set-placement", "b1", "local", "--set-max-remote", "b2", "1"]), /same batch/);
36
+ });
37
+
38
+ it("applySetExecution: missing batch and a provider-less batch going remote/hybrid are refused", () => {
39
+ const root = tmpDir("live-exec-");
40
+ assert.throws(() => applySetExecution(openBatch(root, "nope"), { mode: "local" }), /no batch nope/);
41
+ const b = openBatch(root, "b1");
42
+ b.writePlan({ argv: ["1"] });
43
+ for (const mode of ["remote", "hybrid"]) assert.throws(() => applySetExecution(b, { mode }), /no remote provider/);
44
+ assert.deepEqual(applySetExecution(b, { mode: "local", maxRemote: 0 }), { mode: "local", maxRemote: 0 });
45
+ b.setExecution({ provider: true });
46
+ assert.deepEqual(applySetExecution(b, { mode: "hybrid" }), { mode: "hybrid", maxRemote: 0, provider: true });
47
+ assert.deepEqual(b.readExecution(), { mode: "hybrid", maxRemote: 0, provider: true });
48
+ });
49
+
50
+ it("liveRemote getters follow the override file; local places on the host; onChange once per change", () => {
51
+ let o = null;
52
+ const changes = [], placed = [];
53
+ const r = liveRemote({ onBudget: "stop" }, { exec: { mode: "remote", maxRemote: 2 }, readOverride: () => o, onChange: (e) => changes.push(`${e.mode}/${e.maxRemote}`),
54
+ place: (e, n) => { placed.push([e.mode, n]); return { where: "remote", reason: `mode ${e.mode}` }; } });
55
+ assert.equal(r.max, 2);
56
+ assert.equal(r.fallbackLocal, false);
57
+ assert.deepEqual(r.place(1), { where: "remote", reason: "mode remote" });
58
+ o = { maxRemote: 5 };
59
+ assert.equal(r.max, 5);
60
+ o = { maxRemote: 5, mode: "hybrid" };
61
+ assert.equal(r.fallbackLocal, true);
62
+ r.place(2);
63
+ o = { maxRemote: 5, mode: "local" };
64
+ assert.deepEqual(r.place(3), { where: "local", reason: "live placement: local" });
65
+ assert.deepEqual(placed, [["remote", 1], ["hybrid", 2]]);
66
+ assert.deepEqual(changes, ["remote/5", "hybrid/5", "local/5"]);
67
+ assert.equal({ ...r }.onBudget, "stop");
68
+ });
69
+
70
+ it("lazyWeight is light until the computed weight resolves, and computes once", async () => {
71
+ let calls = 0;
72
+ const w = lazyWeight({ compute: async () => { calls++; return { heavy: true, signals: ["tsc"] }; } });
73
+ assert.deepEqual(w.get(), { heavy: false, signals: [] });
74
+ await new Promise((r) => setImmediate(r));
75
+ assert.deepEqual(w.get(), { heavy: true, signals: ["tsc"] });
76
+ w.get();
77
+ assert.equal(calls, 1);
78
+ assert.deepEqual(lazyWeight({ initial: { heavy: true, signals: [] } }).get(), { heavy: true, signals: [] });
79
+ const failing = lazyWeight({ compute: () => { throw new Error("scan"); } });
80
+ failing.get(); await new Promise((r) => setImmediate(r));
81
+ assert.deepEqual(failing.get(), { heavy: false, signals: [] });
82
+ });
83
+ });