forgedock 1.40.0 → 1.42.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/outcome-codes.mjs +6 -3
- package/bin/engine-cli.mjs +40 -4
- package/bin/forgedock.mjs +22 -1
- package/bin/lean/batch.test.mjs +33 -1
- package/bin/lean/capwait.mjs +36 -0
- package/bin/lean/capwait.test.mjs +48 -0
- package/bin/lean/conformance.mjs +150 -2
- package/bin/lean/conformance.test.mjs +126 -0
- package/bin/lean/depcache.mjs +10 -2
- package/bin/lean/depcache.test.mjs +22 -17
- package/bin/lean/execution.mjs +21 -5
- package/bin/lean/execution.test.mjs +7 -1
- package/bin/lean/heavy.mjs +22 -1
- package/bin/lean/lanecheck.mjs +129 -0
- package/bin/lean/lanecheck.test.mjs +118 -0
- package/bin/lean/lanelock.mjs +31 -0
- package/bin/lean/lean.mjs +102 -30
- package/bin/lean/lean.test.mjs +173 -3
- package/bin/lean/orchestrate.mjs +42 -7
- package/bin/lean/phaseledger.mjs +116 -0
- package/bin/lean/phaseledger.test.mjs +72 -0
- package/bin/lean/prodwatch.mjs +12 -1
- package/bin/lean/prodwatch.test.mjs +32 -3
- package/bin/lean/prompts.mjs +19 -0
- package/bin/lean/render.mjs +10 -2
- package/bin/lean/sandbox-provider.contract.test.mjs +48 -0
- package/bin/lean/sandbox-remote.mjs +8 -1
- package/bin/lean/schedd.mjs +5 -1
- package/bin/lean/selftest-gate.mjs +46 -0
- package/bin/lean/selftest-gate.test.mjs +40 -0
- package/bin/lean/selftest-live.mjs +248 -0
- package/bin/lean/selftest-live.test.mjs +154 -0
- package/bin/lean/selftest-nightly.mjs +81 -0
- package/bin/lean/selftest-nightly.test.mjs +84 -0
- package/bin/lean/selftest-static.mjs +81 -0
- package/bin/lean/selftest-static.test.mjs +32 -0
- package/bin/pool/agent.mjs +16 -5
- package/bin/pool/router.mjs +17 -1
- package/bin/pool/warm.mjs +80 -0
- package/bin/review-staging.mjs +28 -6
- package/bin/stats/aggregate.mjs +2 -1
- package/bin/stats/efficiency.mjs +3 -1
- package/bin/stats/efficiency.test.mjs +4 -1
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/local-source.mjs +1 -0
- package/bin/stats/render.mjs +2 -0
- package/commands/deploy-pr.md +3 -3
- package/commands/review-pr-staging.md +2 -2
- package/commands/work-on/build.md +2 -2
- package/docs/CONFIG.md +33 -0
- package/docs/OUTCOME-CODES.md +1 -1
- package/docs/PROD-WATCH.md +1 -1
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/SELFTEST.md +34 -0
- package/docs/eval/conformance-labels-v1.json +13 -0
- package/package.json +1 -1
- package/pool/image/Dockerfile +2 -1
- package/pool/image/entrypoint.sh +44 -11
- package/pool/image/warm-refresh.sh +14 -0
- package/scripts/conformance-calibrate.mjs +132 -0
- package/scripts/file-deferred-followups.sh +21 -8
- package/scripts/file-deferred-followups.test.sh +27 -6
|
@@ -191,17 +191,20 @@ export function codeForTerminalReason(reason) {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
/** forge#4906: a validate command timed out (exit 124) on the engine's rerun: infrastructure, not an unimplemented contract item. Kept outside OUTCOME_CODES (the base enum guard lists that enum); recoverable and mechanical like the other env codes. */
|
|
195
|
+
export const ENV_VALIDATE_TIMEOUT = "env.validate_timeout";
|
|
196
|
+
|
|
194
197
|
/**
|
|
195
198
|
* Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
|
|
196
199
|
* (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
|
|
197
200
|
*/
|
|
198
|
-
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED]);
|
|
201
|
+
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED]);
|
|
199
202
|
/** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
|
|
200
203
|
export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
|
|
201
204
|
|
|
202
205
|
/**
|
|
203
206
|
* `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
|
|
204
|
-
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.capability_remote`, `budget.exceeded`, `contract.invalid` after a mid-run issue
|
|
207
|
+
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.validate_timeout`, `env.capability_remote`, `budget.exceeded`, `contract.invalid` after a mid-run issue
|
|
205
208
|
* edit (`issueEdited`) or when every problem was an unmatched quote (`quoteMismatch`), `ci-pending` on a conflicting PR (`conflicting`), and the non-human `budget-exceeded` turn-overrun end (forge#4505). Human: `builder.blocked`. Anything else
|
|
206
209
|
* (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
|
|
207
210
|
*/
|
|
@@ -227,7 +230,7 @@ function classifyNeedsHumanBase(row) {
|
|
|
227
230
|
}
|
|
228
231
|
|
|
229
232
|
/** forge#4725: mechanical/environment codes recover on their own budget (`recovery.mechanicalMaxAttempts`), apart from conformance failures. */
|
|
230
|
-
export const MECHANICAL_CODES = Object.freeze([OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.ENV_CAPABILITY_REMOTE]);
|
|
233
|
+
export const MECHANICAL_CODES = Object.freeze([OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.ENV_CAPABILITY_REMOTE]);
|
|
231
234
|
|
|
232
235
|
/** `mechanical` | `conformance`: the recovery budget class of a lane result row (a conflicting `ci-pending` is a base conflict). */
|
|
233
236
|
export function recoveryClassOf(row) {
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -23,7 +23,9 @@ import { makeEventEmitter, probeSweepEvents, experimentEvent } from "./engine/ev
|
|
|
23
23
|
import { readExperiment } from "./experiment.mjs";
|
|
24
24
|
import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
|
|
25
25
|
import { parseVerify } from "./lean/verify.mjs";
|
|
26
|
+
import { normalizeKindModes } from "./lean/conformance.mjs";
|
|
26
27
|
import { viewIssue } from "./lean/gh-assoc.mjs";
|
|
28
|
+
import { otherLaneOwner } from "./lean/lanelock.mjs";
|
|
27
29
|
import { parseExecution, detectNeeds, placementText } from "./lean/execution.mjs";
|
|
28
30
|
import { relayPrefetchCommands } from "./lean/relay.mjs";
|
|
29
31
|
import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lean/budget.mjs";
|
|
@@ -114,6 +116,17 @@ export function readConformanceMode(cwd = process.cwd()) {
|
|
|
114
116
|
return ["shadow", "enforce"].includes(v) ? v : undefined;
|
|
115
117
|
} catch { return undefined; }
|
|
116
118
|
}
|
|
119
|
+
/** forge.yaml `engine.conformance_kinds`: `"code=mode, code=mode"` (flat: the reader has no third level), mode enforce|shadow; invalid entries are dropped, undefined when none is left. */
|
|
120
|
+
export function readConformanceKinds(cwd = process.cwd()) {
|
|
121
|
+
try {
|
|
122
|
+
const k = normalizeKindModes(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.conformance_kinds);
|
|
123
|
+
return Object.keys(k).length ? k : undefined;
|
|
124
|
+
} catch { return undefined; }
|
|
125
|
+
}
|
|
126
|
+
/** forge.yaml `engine.conformance_mutation` (off|report, #4913): rerun each evidence test with the change reverted; undefined (off) when absent or invalid. */
|
|
127
|
+
export function readConformanceMutation(cwd = process.cwd()) {
|
|
128
|
+
try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.conformance_mutation ?? "").trim() === "report" ? "report" : undefined; } catch { return undefined; }
|
|
129
|
+
}
|
|
117
130
|
/** forge.yaml `engine.review_incomplete` (needs-human|degraded, #4160); undefined when absent or invalid (the core defaults to needs-human). */
|
|
118
131
|
export function readReviewIncomplete(cwd = process.cwd()) {
|
|
119
132
|
try {
|
|
@@ -940,7 +953,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
940
953
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
941
954
|
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
942
955
|
emitTelemetry: deps.emitTelemetry ?? composeEmit(makeEmitTelemetry({ repo: leanRepo }), makeEventEmitter({ repo: leanRepo, config: readEvents() }).emit),
|
|
943
|
-
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}), ...readRelayCaps(), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}) });
|
|
956
|
+
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readConformanceKinds() ? { conformanceKinds: readConformanceKinds() } : {}), ...(readConformanceMutation() ? { conformanceMutation: "report" } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}), ...readRelayCaps(), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}) });
|
|
944
957
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
945
958
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
946
959
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -1578,6 +1591,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1578
1591
|
migrateRunLogs(dir); // #4432: split legacy per-issue logs by repo before any lane runs (best effort, never throws)
|
|
1579
1592
|
const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists, formatAlertLines, estimateLaneMinutes, laneHistory, formatEstimateSummary } = await import("./lean/orchestrate.mjs");
|
|
1580
1593
|
const { formatPrecheckSummary } = await import("./lean/precheck.mjs");
|
|
1594
|
+
const { formatLedgerSummary } = await import("./lean/phaseledger.mjs");
|
|
1581
1595
|
const { formatFirstPassSummary } = await import("./lean/fixround.mjs");
|
|
1582
1596
|
const { formatDecompositionSummary } = await import("./lean/decompose.mjs");
|
|
1583
1597
|
const { formatShadowSummary } = await import("./lean/conformance.mjs");
|
|
@@ -1611,7 +1625,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1611
1625
|
const { runCore } = deps.leanModule;
|
|
1612
1626
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1613
1627
|
lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
1614
|
-
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...readRelayCaps(), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}) }) };
|
|
1628
|
+
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readConformanceKinds(cwd) ? { conformanceKinds: readConformanceKinds(cwd) } : {}), ...(readConformanceMutation(cwd) ? { conformanceMutation: "report" } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...readRelayCaps(), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}) }) };
|
|
1615
1629
|
} else {
|
|
1616
1630
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1617
1631
|
// #4690: lanes learn K (the default MemoryMax is derived from it); one line says so when no scope caps them.
|
|
@@ -1650,7 +1664,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1650
1664
|
const bud = exec.budget ?? {};
|
|
1651
1665
|
const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
|
|
1652
1666
|
...(Number.isFinite(exec.unreachableWindowMin) ? { unreachableWindowMs: exec.unreachableWindowMin * 60000 } : {}),
|
|
1653
|
-
...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {})
|
|
1667
|
+
...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}),
|
|
1668
|
+
logTail: (h) => { try { return readFileSync(batch.logFile(h.issue), "utf8").slice(-4000); } catch { return ""; } } }); // #4866: a clone failure in the lane log types sandbox.clone
|
|
1654
1669
|
remoteStop = remoteLanes.stopLane;
|
|
1655
1670
|
lanes = withPlacement(remoteLanes);
|
|
1656
1671
|
{
|
|
@@ -1813,11 +1828,19 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1813
1828
|
try { const { readLocalRuns } = await import("./stats/local-source.mjs"); history = laneHistory(readLocalRuns(dir), repo); } catch { history = {}; }
|
|
1814
1829
|
estimate = async (n, { body }) => estimateLaneMinutes({ labels: await labelNames(n), body, history });
|
|
1815
1830
|
}
|
|
1831
|
+
// #4866: lane preflight (pool auth/version, PATH shim loops, gh fields): failing checks hold admission, re-checked each pass.
|
|
1832
|
+
let preflight = deps.preflight === undefined ? null : deps.preflight;
|
|
1833
|
+
if (deps.preflight === undefined && !testDeps) {
|
|
1834
|
+
const laneCheck = await import("./lean/lanecheck.mjs");
|
|
1835
|
+
const hostIo = await laneCheck.hostLaneIo({ env });
|
|
1836
|
+
preflight = async () => (await laneCheck.runLaneChecks({ exec: execForNeeds, env, io: hostIo })).filter((c) => !c.ok);
|
|
1837
|
+
}
|
|
1816
1838
|
let rows;
|
|
1817
1839
|
try {
|
|
1818
1840
|
rows = await orchestrate({
|
|
1819
1841
|
...(estimate ? { estimate } : {}),
|
|
1820
1842
|
beforeAdmit,
|
|
1843
|
+
...(preflight ? { preflight } : {}),
|
|
1821
1844
|
...(orch.blockedLabel ? { getBlocked: async () => JSON.parse(await leanIo.gh(["issue", "list", "-R", repo, "--label", orch.blockedLabel, "--state", "open", "--limit", "200", "--json", "number"])).map((x) => x.number) } : {}), // #4773
|
|
1822
1845
|
trusted: (n) => trustedOf.get(n) === true,
|
|
1823
1846
|
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, getGates: batch.readGates, ...lanes,
|
|
@@ -1875,6 +1898,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1875
1898
|
try { heavyStats = deps.heavyStats !== undefined ? deps.heavyStats : (await import("./lean/heavy.mjs")).openHeavy(schedMod.schedDir()).stats(); } catch { heavyStats = null; }
|
|
1876
1899
|
const waitLine = formatWaitSummary(rows, heavyStats);
|
|
1877
1900
|
if (waitLine) report.push(waitLine);
|
|
1901
|
+
report.push(formatLedgerSummary(rows)); // #4908: per-phase wall time, n/a without ledger rows
|
|
1878
1902
|
const precheckLine = formatPrecheckSummary(rows); // #4331
|
|
1879
1903
|
if (precheckLine) report.push(precheckLine);
|
|
1880
1904
|
const firstPassLine = formatFirstPassSummary(rows); // #4362
|
|
@@ -1937,6 +1961,11 @@ export function makeDispatchCheck({ gh, repo }) {
|
|
|
1937
1961
|
}
|
|
1938
1962
|
if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
|
|
1939
1963
|
if (state === "CLOSED") return notPlanned ? { outcome: "invalid", detail: "issue closed (not planned)", final: true } : { outcome: "invalid", detail: "issue closed" }; // #4687: final = its dependents stay skipped
|
|
1964
|
+
// #4920: a live lease held by another lane, read from GitHub (any batch, any host, across restarts): no lane, no sandbox.
|
|
1965
|
+
// Release review #4931: never the open feat/issue-N-lean PR here. That branch is this issue's own lane, which a
|
|
1966
|
+
// relaunch or recovery lane resumes by design; a lane still running on it holds the lease.
|
|
1967
|
+
const owner = state === "OPEN" ? await otherLaneOwner({ gh, repo, issue: n, checkPr: false }).catch(() => null) : null;
|
|
1968
|
+
if (owner) return { outcome: "deferred", code: "lease.held", detail: owner.detail };
|
|
1940
1969
|
return null;
|
|
1941
1970
|
};
|
|
1942
1971
|
}
|
|
@@ -2003,6 +2032,12 @@ async function scheddDue() {
|
|
|
2003
2032
|
await sweepDue({ repo, cwd: process.cwd(), io: leanIo, onResult: probeEventSink(repo) });
|
|
2004
2033
|
await sweepParkedFollowups({ repo, io: leanIo }); // #4689: re-queue parked post-deploy follow-ups on the same pass
|
|
2005
2034
|
try { const prem = await import("./lean/needs-human-premise.mjs"); await prem.sweepPremises({ repo, io: { gh: (a) => leanIo.gh(a), pkgVersion: prem.registryPkgVersion } }); } catch { /* best effort (#4725) */ } // clear needs-human whose recorded premise now holds
|
|
2035
|
+
try { // #4894: opt-in nightly live selftest (forge.yaml selftest.nightly_interval_hours); best effort
|
|
2036
|
+
const { nightlyDue, launchNightly } = await import("./lean/selftest-nightly.mjs");
|
|
2037
|
+
// Detached child: the harness calls back into this daemon (orchestrate --add), so it must never run on its event loop.
|
|
2038
|
+
const launch = () => launchNightly({ cli: fileURLToPath(new URL("./forgedock.mjs", import.meta.url)), repo });
|
|
2039
|
+
await nightlyDue({ repo, cwd: process.cwd(), io: leanIo, launch });
|
|
2040
|
+
} catch { /* never stops the other sweeps */ }
|
|
2006
2041
|
}
|
|
2007
2042
|
|
|
2008
2043
|
/** #4689: re-evaluate the parked post-deploy follow-ups of the repo (promotion, due date, check now defined); never throws. */
|
|
@@ -2036,7 +2071,8 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
2036
2071
|
if (argv.length) throw new Error("usage: forgedock schedd [status | watchdog [--stall-sec N]]");
|
|
2037
2072
|
(deps.renice ?? (await import("./lean/schedd-health.mjs")).bestEffortRenice)(); // #4780: lanes must not starve the scheduler
|
|
2038
2073
|
process.title = "forgedock-schedd"; // distinctive name for OOM-killer avoid lists and ps (#4673)
|
|
2039
|
-
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue })
|
|
2074
|
+
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }),
|
|
2075
|
+
...(deps.daemonPreflight !== undefined ? { preflight: deps.daemonPreflight } : deps.batchDeps ? {} : { preflight: async () => { const lc = await import("./lean/lanecheck.mjs"); return (await lc.runLaneChecks({ exec: readExecution(process.cwd()), io: await lc.hostLaneIo() })).filter((c) => !c.ok); } }) });
|
|
2040
2076
|
if (!d.running) return d;
|
|
2041
2077
|
// #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
|
|
2042
2078
|
schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3362,6 +3362,7 @@ function help() {
|
|
|
3362
3362
|
["npx forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> [--before s|--after s] | --show <id>", "Unattended multi-milestone train: one durable state file (order, active set, lifecycle), typed STOP reasons, resumable; never edits forge.yaml"],
|
|
3363
3363
|
["npx forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]", "Fix guards: list the FORGE:GUARD records (test files + protected hunks of merged fixes) covering a path, or build them for already-merged lean lanes"],
|
|
3364
3364
|
["npx forgedock issue-watch run [--name <w>] [--repo owner/repo] [--once] [--dry-run] | status [--name <w>]", "Named, scoped, capped watches (forge.yaml issue_watch.watches) that claim matching open issues into lanes or one long-lived orchestrate batch, or (mode: release) pool them and cut themed releases; see docs/ISSUE-WATCH.md"],
|
|
3365
|
+
["npx forgedock selftest [--static] | --live --repo <owner/sandbox> [--lang node|python] [--pr <N>]", "Live contract harness: drives the real gh, git and orchestrate through a disposable sandbox repo (steps create, add, body, resume, merge, remote, cleanup) plus a static check of every gh --json field list; exits non-zero naming the failed step; --pr posts the FORGE:SELFTEST result the staging->main review requires; see docs/SELFTEST.md"],
|
|
3365
3366
|
["npx forgedock prod-watch run [--repo owner/repo] [--once] [--interval <s>] [--dry-run] | status", "Production-side watcher (forge.yaml prod_watch): read-only signal sources -> triage -> governor -> issues, kill-switch file, owner digest; see docs/PROD-WATCH.md"],
|
|
3366
3367
|
["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
|
|
3367
3368
|
["npx forgedock decisions list [--stale] [--refresh]", "List anchored decisions (FORGE:DECISION); --stale shows those with an anchor missing on the base"],
|
|
@@ -3381,6 +3382,7 @@ function help() {
|
|
|
3381
3382
|
["npx forgedock stats [--since <ver|date>] [--repo owner/repo] [--json] [--refresh]", "Version-over-version cost/time/flow/review/gate report from GitHub telemetry (no model)"],
|
|
3382
3383
|
["npx forgedock doctor", "Check installation health"],
|
|
3383
3384
|
["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block) and reclaim ForgeDock-owned leftovers (forge.yaml hygiene TTLs)"],
|
|
3385
|
+
["npx forgedock doctor --lanes", "Lane preflight: pool token, pool agent version, PATH shim loops, gh --json fields; prints every check pass/fail"],
|
|
3384
3386
|
["npx forgedock update", "Pull latest & reinstall"],
|
|
3385
3387
|
["npx forgedock uninstall", "Remove commands"],
|
|
3386
3388
|
["npx forgedock opencode install", "Install token-efficient native OpenCode commands"],
|
|
@@ -3952,7 +3954,7 @@ async function watch() {
|
|
|
3952
3954
|
const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3953
3955
|
const KNOWN_COMMANDS = new Set([
|
|
3954
3956
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
3955
|
-
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "issue-watch", "prod-watch", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "guide", "help", "--help", "-h",
|
|
3957
|
+
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "issue-watch", "prod-watch", "selftest", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "guide", "help", "--help", "-h",
|
|
3956
3958
|
"version", "--version", "-v",
|
|
3957
3959
|
]);
|
|
3958
3960
|
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
@@ -4223,6 +4225,16 @@ switch (command) {
|
|
|
4223
4225
|
}
|
|
4224
4226
|
break;
|
|
4225
4227
|
}
|
|
4228
|
+
case "selftest": {
|
|
4229
|
+
const { selftestFromCli } = await import("./lean/selftest-live.mjs");
|
|
4230
|
+
try {
|
|
4231
|
+
if (!(await selftestFromCli(restArgs)).ok) exitCode = 1;
|
|
4232
|
+
} catch (err) {
|
|
4233
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4234
|
+
exitCode = 1;
|
|
4235
|
+
}
|
|
4236
|
+
break;
|
|
4237
|
+
}
|
|
4226
4238
|
case "prod-watch": {
|
|
4227
4239
|
const { prodWatchFromCli } = await import("./lean/prodwatch.mjs");
|
|
4228
4240
|
try {
|
|
@@ -4354,6 +4366,15 @@ switch (command) {
|
|
|
4354
4366
|
await demo();
|
|
4355
4367
|
break;
|
|
4356
4368
|
case "doctor":
|
|
4369
|
+
if (restArgs.includes("--lanes")) {
|
|
4370
|
+
// #4866: the lane preflight on demand: every check with pass/fail; exit 1 when any fails
|
|
4371
|
+
const lc = await import("./lean/lanecheck.mjs");
|
|
4372
|
+
const { readExecution } = await import("./engine-cli.mjs");
|
|
4373
|
+
const checks = await lc.runLaneChecks({ exec: readExecution(process.cwd()), io: await lc.hostLaneIo() });
|
|
4374
|
+
console.log(lc.formatLaneChecks(checks));
|
|
4375
|
+
exitCode = checks.every((c) => c.ok) ? 0 : 1;
|
|
4376
|
+
break;
|
|
4377
|
+
}
|
|
4357
4378
|
exitCode = await doctor(restArgs.includes("--fix"));
|
|
4358
4379
|
break;
|
|
4359
4380
|
case "report": {
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -754,8 +754,40 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
|
|
|
754
754
|
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "invalid"], [2, "merged"]]);
|
|
755
755
|
});
|
|
756
756
|
|
|
757
|
+
it("#4920 makeDispatchCheck defers an issue another lane owns: a live foreign lease; an expired or unreadable one runs, and the issue's own open lane PR runs (#4931)", async () => {
|
|
758
|
+
const mk = (issue, opts) => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
|
|
759
|
+
if (a[0] === "issue" && a[a.length - 1] === "body") { if (opts.body instanceof Error) throw opts.body; return opts.body ?? "{}"; }
|
|
760
|
+
if (a[0] === "issue") return issue;
|
|
761
|
+
if (a.includes("open")) { if (opts.open instanceof Error) throw opts.open; return opts.open ?? "[]"; }
|
|
762
|
+
return "[]";
|
|
763
|
+
} });
|
|
764
|
+
const lease = (until) => JSON.stringify({ body: `x\n<!-- FORGE:STATE\n${JSON.stringify({ v: 1, issue: 9, lease: { by: "core_1", until, host: "sbx", pid: 1 } })}\n-->` });
|
|
765
|
+
const OPEN = '{"state":"OPEN"}';
|
|
766
|
+
const held = await mk(OPEN, { body: lease(Date.now() + 600000) })(9);
|
|
767
|
+
assert.equal(held.outcome, "deferred"); assert.equal(held.code, "lease.held"); assert.match(held.detail, /leased by core_1 until \d{4}-/);
|
|
768
|
+
assert.equal(await mk(OPEN, { body: lease(Date.now() - 1000) })(9), null, "expired lease: run");
|
|
769
|
+
// #4931: an open feat/issue-9-lean PR is this issue's own lane (ci.red / base.conflict recovery, a relaunch after
|
|
770
|
+
// engine-error or ci-pending resumes it), never another lane's: the lane runs
|
|
771
|
+
assert.equal(await mk(OPEN, { open: '[{"number":4919}]' })(9), null, "own open lane PR: run (resume it)");
|
|
772
|
+
assert.equal(await mk(OPEN, { body: new Error("down"), open: new Error("down") })(9), null, "unreadable: fail open");
|
|
773
|
+
assert.equal((await mk('{"state":"CLOSED"}', { body: lease(Date.now() + 600000) })(9)).outcome, "invalid", "closed wins");
|
|
774
|
+
});
|
|
775
|
+
it("#4920 a deferred dispatch check launches no lane and no sandbox, and a restarted batch (new orchestrate) defers again", async () => {
|
|
776
|
+
for (const round of [1, 2]) { // round 2: a fresh orchestrate call = a daemon restart; the answer is read from GitHub each time
|
|
777
|
+
const { now, sleep } = fakeClock();
|
|
778
|
+
let launched = 0;
|
|
779
|
+
const rows = await orchestrate({ issues: [5], readBody: async () => "", now, sleep, maxDeferrals: 1,
|
|
780
|
+
remote: { max: 1, place: () => ({ where: "remote", reason: "heavy" }) },
|
|
781
|
+
dispatchCheck: async () => ({ outcome: "deferred", code: "lease.held", detail: "issue 5 leased by core_1 until 2999-01-01T00:00:00.000Z" }),
|
|
782
|
+
runOne: async () => { launched++; return { terminalReason: "merged", cost_usd: 0 }; } });
|
|
783
|
+
assert.equal(launched, 0, `round ${round}: no lane, no sandbox`);
|
|
784
|
+
assert.equal(rows[0].outcome, "deferred", `round ${round}`);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
757
787
|
it("makeDispatchCheck: merged head-branch PR, closed issue, open issue, unreadable answers fail open", async () => {
|
|
758
|
-
const mk = (issue, prs, events = "") => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
|
|
788
|
+
const mk = (issue, prs, events = "", { body = "{}", open = "[]" } = {}) => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
|
|
789
|
+
if (a[0] === "issue" && a[a.length - 1] === "body") return body; // #4920: the lease lookup
|
|
790
|
+
if (a[0] === "pr" && a.includes("open")) return open; // #4920: the open lane PR lookup
|
|
759
791
|
if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state,stateReason"]); if (issue instanceof Error) throw issue; return issue; }
|
|
760
792
|
if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
|
|
761
793
|
assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Capacity wait (#4909): a lane whose sandbox launch was refused for capacity (`sandbox.capacity`) is admitted again only
|
|
5
|
+
* once the pool reports a free slot (`capacity() -> {free}`), not on a blind timer retry; the wait is recorded as queue
|
|
6
|
+
* time (`waits.queue`, ms). A pool that does not report `free` is unknown: the provider's retry-in applies as before.
|
|
7
|
+
*/
|
|
8
|
+
export function makeCapWait({ now = Date.now } = {}) {
|
|
9
|
+
const since = new Map(); // issue -> when its launch was refused for capacity
|
|
10
|
+
const waited = new Map(); // issue -> queue ms accumulated, not yet recorded on a row
|
|
11
|
+
const isFree = (cap) => Boolean(cap) && Number.isInteger(cap.free) && cap.free > 0;
|
|
12
|
+
const isFull = (cap) => Boolean(cap) && Number.isInteger(cap.free) && cap.free <= 0;
|
|
13
|
+
return {
|
|
14
|
+
/** A launch of `issue` was refused for capacity. */
|
|
15
|
+
refused(issue) { if (!since.has(issue)) since.set(issue, now()); },
|
|
16
|
+
/** True while a refused `issue` must keep waiting: the sampled pool reports no free slot. */
|
|
17
|
+
blocked(issue, cap) { return since.has(issue) && isFull(cap); },
|
|
18
|
+
/** `issue` was admitted: bank its wait as queue time. Returns the ms waited (0 when it was not refused). */
|
|
19
|
+
admitted(issue) {
|
|
20
|
+
if (!since.has(issue)) return 0;
|
|
21
|
+
const ms = Math.max(0, now() - since.get(issue));
|
|
22
|
+
since.delete(issue);
|
|
23
|
+
waited.set(issue, (waited.get(issue) ?? 0) + ms);
|
|
24
|
+
return ms;
|
|
25
|
+
},
|
|
26
|
+
/** `waits` ({phase: ms}) of a lane result with the banked queue wait added; the same object when none. */
|
|
27
|
+
take(issue, waits) {
|
|
28
|
+
const ms = waited.get(issue);
|
|
29
|
+
if (!ms) return waits;
|
|
30
|
+
waited.delete(issue);
|
|
31
|
+
const base = waits && typeof waits === "object" ? waits : {};
|
|
32
|
+
return { ...base, queue: (Number.isFinite(base.queue) ? base.queue : 0) + ms };
|
|
33
|
+
},
|
|
34
|
+
isFree,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/** #4909: capacity wait as queue time. */
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { makeCapWait } from "./capwait.mjs";
|
|
7
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
8
|
+
|
|
9
|
+
describe("makeCapWait (#4909)", () => {
|
|
10
|
+
it("blocks a refused issue only while the pool reports no free slot, and banks the wait as queue time", () => {
|
|
11
|
+
let t = 1000;
|
|
12
|
+
const w = makeCapWait({ now: () => t });
|
|
13
|
+
assert.equal(w.blocked(1, { free: 0 }), false, "never refused: not blocked");
|
|
14
|
+
w.refused(1);
|
|
15
|
+
assert.equal(w.blocked(1, { free: 0 }), true);
|
|
16
|
+
assert.equal(w.blocked(1, null), false, "unknown capacity falls back to the retry-in timer");
|
|
17
|
+
assert.equal(w.blocked(1, { online: 2 }), false);
|
|
18
|
+
t += 5000;
|
|
19
|
+
assert.equal(w.blocked(1, { free: 1 }), false);
|
|
20
|
+
assert.equal(w.admitted(1), 5000);
|
|
21
|
+
assert.deepEqual(w.take(1, { heavy: 7 }), { heavy: 7, queue: 5000 });
|
|
22
|
+
assert.equal(w.take(1, { heavy: 7 }).queue, undefined, "banked once");
|
|
23
|
+
assert.equal(w.take(2, undefined), undefined);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("at pool capacity a lane waits for a free slot (#4909)", () => {
|
|
28
|
+
it("a capacity-refused lane does not relaunch until free > 0; the wait is its queue time", async () => {
|
|
29
|
+
const c = { t: 1_000_000 };
|
|
30
|
+
let sleeps = 0, freeAt = Infinity;
|
|
31
|
+
const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; if (++sleeps >= freeAt) free = 1; };
|
|
32
|
+
let free = 1, launches = 0, refusedAt = null, relaunchFree = null;
|
|
33
|
+
const rows = await orchestrate({ issues: [1], parallel: 1, now: () => c.t, sleep, pollMs: 1000, readBody: async () => "",
|
|
34
|
+
remote: { max: 1, isRemote: () => true, capacity: () => ({ free, at: c.t }) },
|
|
35
|
+
runOne: async () => {
|
|
36
|
+
launches++;
|
|
37
|
+
if (launches === 1) { refusedAt = c.t; free = 0; freeAt = sleeps + 3; return { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 60, detail: "HTTP 503" }; }
|
|
38
|
+
relaunchFree = free;
|
|
39
|
+
return { terminalReason: "merged", pr: 101 };
|
|
40
|
+
} });
|
|
41
|
+
assert.equal(rows[0].outcome, "merged");
|
|
42
|
+
assert.equal(launches, 2);
|
|
43
|
+
assert.ok(relaunchFree > 0, "relaunched only with a free slot");
|
|
44
|
+
assert.ok(c.t - refusedAt < 60000, "not the blind 60s retry");
|
|
45
|
+
assert.ok(c.t - refusedAt >= 3000, "waited for the slot");
|
|
46
|
+
assert.ok(Number.isFinite(rows[0].waits?.queue) && rows[0].waits.queue >= 0, "wait recorded as queue time");
|
|
47
|
+
});
|
|
48
|
+
});
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* The reviewer supplies per-row judgements; the engine computes the verdict. A review without rows (a generic
|
|
8
8
|
* "PASS") is incomplete, and incomplete never merges.
|
|
9
9
|
*/
|
|
10
|
-
import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
10
|
+
import { OUTCOME_CODES, ENV_VALIDATE_TIMEOUT } from "../engine/outcome-codes.mjs";
|
|
11
11
|
import { noComments } from "./context.mjs";
|
|
12
12
|
import { activationFailures } from "./activation.mjs";
|
|
13
|
+
import * as nodeFs from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
13
15
|
|
|
14
16
|
export const DISPOSITIONS = Object.freeze(["deliver", "defer", "not-applicable", "post-deploy"]);
|
|
15
17
|
/** Dispositions the engine files as a tracked follow-up issue before anything merges. */
|
|
@@ -321,6 +323,8 @@ const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g,
|
|
|
321
323
|
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision"];
|
|
322
324
|
const GUARD_ID_RE = /^#?\d+$/;
|
|
323
325
|
const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
|
|
326
|
+
// #4916: near-synonyms builders emit for the per-guard `supersedes` amendment
|
|
327
|
+
const SUPERSEDES_SYNONYMS = ["supersede-guard", "supersede", "supersedes-guard"];
|
|
324
328
|
|
|
325
329
|
/** forge#4574: common builder slips: `type` for `kind`, amend-requirement/limitation as defer, an `R#` in `id` as the requirement. */
|
|
326
330
|
function normalizeAmendment(a) {
|
|
@@ -329,6 +333,7 @@ function normalizeAmendment(a) {
|
|
|
329
333
|
if (blank(n.kind) && !blank(n.type)) n.kind = n.type;
|
|
330
334
|
// #4594: amend-requirement with a revised `text` is a real amendment; without one it stays the slip for defer
|
|
331
335
|
if (Object.hasOwn(AMEND_SLIPS, n.kind) && !(n.kind === "amend-requirement" && !blank(n.text))) n.kind = AMEND_SLIPS[n.kind];
|
|
336
|
+
if (SUPERSEDES_SYNONYMS.includes(n.kind)) n.kind = "supersedes";
|
|
332
337
|
if (TRACKED.includes(n.kind) && blank(n.requirement) && /^R\d+$/.test(String(n.id ?? ""))) n.requirement = n.id;
|
|
333
338
|
return n;
|
|
334
339
|
}
|
|
@@ -378,6 +383,13 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
378
383
|
const ids = splitRequirementRefs(n.requirement);
|
|
379
384
|
if (ids.length > 1) { for (const id of ids) rows.push({ a: { ...n, requirement: id }, raw }); continue; }
|
|
380
385
|
}
|
|
386
|
+
if (n?.kind === "supersedes" && Array.isArray(n.guards) && n.guards.length) {
|
|
387
|
+
// #4916: one supersedes row per guard id; each still hits the GUARD_ID_RE check below
|
|
388
|
+
const { guards, ...rest } = n;
|
|
389
|
+
const ids = blank(rest.guard) ? guards : [rest.guard, ...guards];
|
|
390
|
+
for (const guard of ids) rows.push({ a: { ...rest, guard }, raw });
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
381
393
|
rows.push({ a: n, raw });
|
|
382
394
|
}
|
|
383
395
|
for (const { a, raw } of rows) {
|
|
@@ -524,6 +536,12 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
524
536
|
const rerun = validateRuns?.[v.id];
|
|
525
537
|
if (rerun?.rc === 0) continue;
|
|
526
538
|
if (rerun && cliUsageError(rerun)) { failures.push({ code: C.CONTRACT_INVALID_COMMAND, kind: "validate_command_invalid", detail: `validate ${v.id} (\`${v.command}\`) is a CLI usage error of the runner, a defect of the frozen command rather than of the change` }); continue; }
|
|
539
|
+
// #4906: the rerun timed out (even after the longer retry): host load, not a verdict on the change. A CI-verified head covers it; else an environment error.
|
|
540
|
+
if (rerun?.rc === 124) {
|
|
541
|
+
if (ciVerified) { ciCovered.push(v.id); continue; }
|
|
542
|
+
failures.push({ code: ENV_VALIDATE_TIMEOUT, kind: "validate_timeout", detail: `validate ${v.id} (\`${v.command}\`) timed out (exit 124) on the engine's rerun: infrastructure (host load), not a failed check` });
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
527
545
|
const miss = rerun ? missingTool(rerun, { repoHas, command: v.command }) : null;
|
|
528
546
|
if (miss && ciVerified) { ciCovered.push(v.id); continue; }
|
|
529
547
|
if (miss) failures.push({ code: C.ENV_TOOL_MISSING, kind: "validate_env", tool: miss.tool, detail: `validate ${v.id} (\`${v.command}\`) could not run: the host is missing ${KIND_NAME[miss.kind]} '${miss.tool}' (environment, not the change)` });
|
|
@@ -885,7 +903,10 @@ export function formatShadowSummary(rows) {
|
|
|
885
903
|
const falseB = sh.filter((c) => UNUSABLE_REVIEW.includes(c.would)).length;
|
|
886
904
|
const pct = (k) => (sh.length ? `${Math.round((k / sh.length) * 100)}%` : "n/a");
|
|
887
905
|
const risk = cs.filter((c) => c.risk === true).length;
|
|
888
|
-
|
|
906
|
+
const byKind = {};
|
|
907
|
+
for (const c of cs) for (const k of new Set(Array.isArray(c.kinds) ? c.kinds : [])) byKind[k] = (byKind[k] ?? 0) + 1;
|
|
908
|
+
const kinds = Object.entries(byKind).sort(([a], [b]) => a.localeCompare(b)).map(([k, n]) => `${k} ${n}/${cs.length} (${Math.round((n / cs.length) * 100)}%)`);
|
|
909
|
+
return `conformance: ${sh.length} shadow lane${sh.length === 1 ? "" : "s"}, would block ${would} (${pct(would)}), false-block ${falseB} (${pct(falseB)}: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: ${risk}${kinds.length ? `; per kind: ${kinds.join(", ")}` : ""}`;
|
|
889
910
|
}
|
|
890
911
|
|
|
891
912
|
/** #4664: follow-up classes, one issue each per parent (scripts/file-deferred-followups.sh); post-deploy is one rollup per release lane (#4724). */
|
|
@@ -971,3 +992,130 @@ export function structureNote(delta) {
|
|
|
971
992
|
const s = grow.length ? `this PR adds ${pairs} (files); ${config}` : `this PR loosens the repo's boundary config (${files})`;
|
|
972
993
|
return `Structure (report only): ${s}; flag only if the contract or the architecture doc says otherwise.`.replace(/[\r\n]+/g, " ");
|
|
973
994
|
}
|
|
995
|
+
|
|
996
|
+
/** engine.conformance_kinds: a failure code's own gate mode; "enforce" blocks, "shadow" is recorded only. */
|
|
997
|
+
export const KIND_MODES = Object.freeze(["enforce", "shadow"]);
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* The valid `{code: mode}` entries of a config map, or of forge.yaml's flat form `"code=mode, code=mode"` (the
|
|
1001
|
+
* forge.yaml reader has no third nesting level); anything else is dropped. {} when absent.
|
|
1002
|
+
*/
|
|
1003
|
+
export function normalizeKindModes(raw) {
|
|
1004
|
+
const out = {};
|
|
1005
|
+
if (typeof raw === "string") raw = Object.fromEntries(raw.split(",").map((x) => x.trim()).filter(Boolean).map((p) => p.split("=").map((x) => x.trim())));
|
|
1006
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) for (const [k, v] of Object.entries(raw)) if (String(k).trim() && KIND_MODES.includes(String(v).trim())) out[String(k).trim()] = String(v).trim();
|
|
1007
|
+
return out;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Split gate failures into those that block and those only recorded. A kind with its own mode follows it; every other
|
|
1012
|
+
* kind follows the lane: an enforce lane blocks it, a shadow lane blocks only the codes in `allow` (the previous reviewer's set).
|
|
1013
|
+
* @param {{code?: string}[]} failures
|
|
1014
|
+
* @param {{shadow?: boolean, kinds?: Record<string,string>|null, allow?: string[]}} [o]
|
|
1015
|
+
*/
|
|
1016
|
+
export function blockingFailures(failures, { shadow = false, kinds = null, allow = [] } = {}) {
|
|
1017
|
+
const block = [], record = [];
|
|
1018
|
+
for (const f of failures ?? []) {
|
|
1019
|
+
const own = kinds?.[f?.code];
|
|
1020
|
+
(own ? own === "enforce" : !shadow || allow.includes(f?.code)) ? block.push(f) : record.push(f);
|
|
1021
|
+
}
|
|
1022
|
+
return { block, record };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Deliver requirements with no executed evidence test: one row per requirement id. Evidence is a test deliverable linked to the
|
|
1027
|
+
* requirement (`deliverables[].requirements`) that a validate command names and that ran green (the engine's rerun, else the
|
|
1028
|
+
* builder's result, else CI). Requirements verified out of band are judged by their instrumentation and skipped.
|
|
1029
|
+
* @returns {{id: string, reason: string}[]}
|
|
1030
|
+
*/
|
|
1031
|
+
export function unverifiedRequirements(contract, { runs = [], reruns = null, ciCovered = [] } = {}) {
|
|
1032
|
+
const out = [];
|
|
1033
|
+
for (const r of contract?.requirements ?? []) {
|
|
1034
|
+
if (r.disposition !== "deliver" || isOutOfBand(r)) continue;
|
|
1035
|
+
const tests = evidenceTests(contract, r);
|
|
1036
|
+
if (!tests.length) { out.push({ id: r.id, reason: "no test deliverable linked to the requirement" }); continue; }
|
|
1037
|
+
if (!greenEvidence(contract, tests, { runs, reruns, ciCovered }).length) out.push({ id: r.id, reason: `no executed passing validate runs its test (${tests.join(", ")})` });
|
|
1038
|
+
}
|
|
1039
|
+
return out;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const evidenceTests = (contract, r) => (contract.deliverables ?? []).filter((d) => (d.requirements ?? []).includes(r.id) && isTestPath(d.path)).map((d) => String(d.path));
|
|
1043
|
+
/** The validate commands that name one of `tests` and ran green (the engine's rerun, else the builder's result, else CI). */
|
|
1044
|
+
function greenEvidence(contract, tests, { runs = [], reruns = null, ciCovered = [] } = {}) {
|
|
1045
|
+
const green = (v) => {
|
|
1046
|
+
const rerun = reruns?.[v.id];
|
|
1047
|
+
if (rerun) return rerun.rc === 0;
|
|
1048
|
+
return (Array.isArray(runs) ? runs : []).some((x) => x?.id === v.id && x.exit === 0) || ciCovered.includes(v.id);
|
|
1049
|
+
};
|
|
1050
|
+
return (contract.validate ?? []).filter((v) => tests.some((t) => String(v.command).includes(t) || String(v.command).includes(t.split("/").pop())) && green(v));
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** #4913 engine.conformance_mutation: the failure code of an evidence test that still passes with the change reverted. */
|
|
1054
|
+
export const MUTATION_SURVIVED = "conformance.mutation_survived";
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Deliver requirements whose executed evidence test can be mutation-checked: one row per requirement id with the green
|
|
1058
|
+
* evidence commands and the non-test files of the diff (the change to revert). Out-of-band requirements and requirements
|
|
1059
|
+
* with no green evidence are skipped; with no non-test file changed there is nothing to revert.
|
|
1060
|
+
* @returns {{id: string, commands: {id: string, command: string}[]}[]}
|
|
1061
|
+
*/
|
|
1062
|
+
export function mutationTargets(contract, evidence = {}) {
|
|
1063
|
+
const out = [];
|
|
1064
|
+
for (const r of contract?.requirements ?? []) {
|
|
1065
|
+
if (r.disposition !== "deliver" || isOutOfBand(r)) continue;
|
|
1066
|
+
const tests = evidenceTests(contract, r);
|
|
1067
|
+
const commands = tests.length ? greenEvidence(contract, tests, evidence).map((v) => ({ id: v.id, command: String(v.command) })) : [];
|
|
1068
|
+
if (commands.length) out.push({ id: r.id, commands });
|
|
1069
|
+
}
|
|
1070
|
+
return out;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** conformance.mutation_survived rows: a result still green with the change reverted. A skipped or killed result never fails. */
|
|
1074
|
+
export function mutationFailures(results) {
|
|
1075
|
+
return (results ?? []).filter((x) => x?.status === "survived").map((x) => ({
|
|
1076
|
+
code: MUTATION_SURVIVED, kind: "mutation_survived",
|
|
1077
|
+
detail: `${x.id} evidence test passes with the change reverted (\`${x.command}\`, exit 0): it does not prove the requirement`,
|
|
1078
|
+
}));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Rerun each target's evidence commands with the diff's non-test files reverted to the merge base (git apply -R of the
|
|
1083
|
+
* non-test diff), then restore them from HEAD in a finally. The commits stay in HEAD, so no work is lost. A dirty tree,
|
|
1084
|
+
* a failed revert, a timeout (124) or a missing runner (127) is skipped, never a failure.
|
|
1085
|
+
* @returns {{results: {id: string, command: string, status: "survived"|"killed"|"skipped", note?: string}[], skipped: string, ms: number}}
|
|
1086
|
+
*/
|
|
1087
|
+
export async function runMutationCheck({ targets, files, base, git, runCommand, cwd, env, capMs = 120000, tmp = tmpdir(), fs = nodeFs, now = Date.now }) {
|
|
1088
|
+
const t0 = now(), results = [];
|
|
1089
|
+
const nonTest = (files ?? []).filter((f) => !isTestPath(f));
|
|
1090
|
+
const skip = (note) => ({ results, skipped: note, ms: now() - t0 });
|
|
1091
|
+
if (!targets?.length) return skip("");
|
|
1092
|
+
if (!nonTest.length) return skip("no non-test file changed");
|
|
1093
|
+
if (String(await git(["status", "--porcelain"]).catch(() => "x")).trim()) return skip("working tree not clean");
|
|
1094
|
+
const patch = `${tmp}/mutation-${process.pid}.patch`;
|
|
1095
|
+
let reverted = false, restoreFailed = "";
|
|
1096
|
+
try {
|
|
1097
|
+
fs.writeFileSync(patch, await git(["diff", "--binary", `${base}...HEAD`, "--", ...nonTest]));
|
|
1098
|
+
try { await git(["apply", "-R", "--whitespace=nowarn", patch]); reverted = true; } catch { return skip("could not revert the change"); }
|
|
1099
|
+
for (const t of targets) for (const c of t.commands) {
|
|
1100
|
+
const left = capMs - (now() - t0);
|
|
1101
|
+
if (left <= 0) { results.push({ id: t.id, command: c.command, status: "skipped", note: "over the cap" }); continue; }
|
|
1102
|
+
let r;
|
|
1103
|
+
try { r = await runCommand({ command: c.command, cwd, timeoutMs: left, env }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
|
|
1104
|
+
const rc = Number.isInteger(r?.rc) ? r.rc : 1;
|
|
1105
|
+
results.push({ id: t.id, command: c.command, status: rc === 124 || rc === 127 ? "skipped" : rc === 0 ? "survived" : "killed" });
|
|
1106
|
+
}
|
|
1107
|
+
} finally {
|
|
1108
|
+
// Release review #4931: the tree was clean, so HEAD is the exact restore point. `checkout HEAD -- <files>` failed as a
|
|
1109
|
+
// whole on a file the change deleted (the revert re-creates it untracked) and restored nothing; reset + clean of
|
|
1110
|
+
// those paths does not, and a tree still dirty there afterwards throws (fail closed: never push the reverted change).
|
|
1111
|
+
if (reverted) {
|
|
1112
|
+
await git(["reset", "-q", "--hard", "HEAD"]).catch(() => {});
|
|
1113
|
+
await git(["clean", "-fdq", "--", ...nonTest]).catch(() => {});
|
|
1114
|
+
const left = await git(["status", "--porcelain", "--", ...nonTest]).catch(() => "unreadable");
|
|
1115
|
+
if (String(left).trim()) restoreFailed = String(left).trim();
|
|
1116
|
+
}
|
|
1117
|
+
try { fs.rmSync(patch, { force: true }); } catch { /* best effort */ }
|
|
1118
|
+
}
|
|
1119
|
+
if (restoreFailed) throw new Error(`mutation check could not restore the worktree: ${restoreFailed}`);
|
|
1120
|
+
return { results, skipped: "", ms: now() - t0 };
|
|
1121
|
+
}
|