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/engine-cli.mjs +49 -19
- package/bin/forgedock.mjs +1 -1
- package/bin/lean/batch.mjs +9 -0
- package/bin/lean/batch.test.mjs +174 -2
- package/bin/lean/conformance.mjs +38 -9
- package/bin/lean/conformance.test.mjs +33 -1
- package/bin/lean/effects.mjs +16 -5
- package/bin/lean/execution.mjs +66 -20
- package/bin/lean/execution.test.mjs +133 -1
- package/bin/lean/heavy.mjs +14 -4
- package/bin/lean/heavy.test.mjs +55 -1
- package/bin/lean/lanecheck.mjs +44 -12
- package/bin/lean/lanecheck.test.mjs +23 -0
- package/bin/lean/lean.mjs +87 -24
- package/bin/lean/lean.test.mjs +90 -7
- package/bin/lean/live-execution.mjs +136 -0
- package/bin/lean/live-execution.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +77 -20
- package/bin/lean/prompts.mjs +15 -2
- package/bin/lean/review-trigger.mjs +25 -0
- package/bin/lean/review-trigger.test.mjs +28 -1
- package/bin/lean/sandbox-provider.contract.test.mjs +21 -0
- package/bin/lean/sandbox-remote.mjs +6 -2
- package/bin/lean/schedd.mjs +20 -3
- package/bin/lean/schedd.test.mjs +20 -3
- package/commands/orchestrate.md +2 -1
- package/docs/CONFIG.md +3 -1
- package/package.json +1 -1
- package/scripts/plan-validate-live.mjs +24 -7
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
|
-
|
|
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
|
-
/**
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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) {
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -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)
|
|
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
|
|
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, "
|
|
990
|
-
assert.
|
|
991
|
-
assert.equal(w.
|
|
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
|
|
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.
|
|
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
|
+
});
|