forgedock 1.34.0 → 1.36.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/README.md +78 -324
- package/bin/engine/events.mjs +152 -0
- package/bin/engine/events.test.mjs +128 -0
- package/bin/engine/telemetry-seam.mjs +4 -3
- package/bin/engine-cli.mjs +181 -26
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +19 -1
- package/bin/hooks/reread-nudge.mjs +44 -5
- package/bin/lean/batch.mjs +66 -3
- package/bin/lean/batch.test.mjs +176 -1
- package/bin/lean/budget.mjs +14 -3
- package/bin/lean/budget.test.mjs +11 -1
- package/bin/lean/conflictres.mjs +93 -0
- package/bin/lean/conflictres.test.mjs +93 -0
- package/bin/lean/conformance.mjs +30 -9
- package/bin/lean/conformance.test.mjs +51 -0
- package/bin/lean/effects.mjs +14 -3
- package/bin/lean/execution.mjs +2 -0
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/lane.mjs +16 -0
- package/bin/lean/lean.mjs +147 -36
- package/bin/lean/lean.test.mjs +319 -8
- package/bin/lean/orchestrate.mjs +130 -13
- package/bin/lean/postdeploy-check.mjs +149 -0
- package/bin/lean/postdeploy-check.test.mjs +146 -0
- package/bin/lean/postdeploy-monitor.mjs +87 -0
- package/bin/lean/postdeploy-monitor.test.mjs +121 -0
- package/bin/lean/precheck.mjs +58 -8
- package/bin/lean/precheck.test.mjs +77 -3
- package/bin/lean/prefilter.mjs +29 -4
- package/bin/lean/prefilter.test.mjs +13 -5
- package/bin/lean/probe.mjs +2 -1
- package/bin/lean/prompts.mjs +36 -6
- package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
- package/bin/lean/sandbox-remote.mjs +7 -0
- package/bin/lean/schedd.mjs +34 -5
- package/bin/lean/schedd.test.mjs +84 -0
- package/bin/lean/sequence.mjs +26 -0
- package/bin/lean/sequence.test.mjs +41 -2
- package/bin/lean/toolstate.mjs +77 -0
- package/bin/lean/toolstate.test.mjs +83 -0
- package/bin/lean/verify.mjs +18 -2
- package/bin/lean/verify.test.mjs +29 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +42 -2
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/orchestrate.md +8 -3
- package/docs/CONFIG.md +61 -2
- package/docs/EVENTS.md +68 -0
- package/docs/STATS.md +31 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +8 -0
package/bin/lean/verify.mjs
CHANGED
|
@@ -33,7 +33,8 @@ const OK = ["success", "neutral", "skipped"];
|
|
|
33
33
|
* maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, duplicateExports, e2e}. Invalid values fall back to the defaults; mode defaults to local (today's
|
|
34
34
|
* behaviour); checkRun (`check_run`, #4270) is true only for the literal `true`; checks (#4387) is the optional allow-list
|
|
35
35
|
* of check names that gate CI verification, or null; e2e is the e2e merge gate (#4403, e2e.mjs parseE2e). #4406: postMerge (`post_merge`) is the command run on
|
|
36
|
-
* the merged tree at base-sync before the push (null: the lane's scoped typecheck); duplicateExports is false only for the literal `false`.
|
|
36
|
+
* the merged tree at base-sync before the push (null: the lane's scoped typecheck); duplicateExports is false only for the literal `false`. #4585: invariants (`invariants`, a block list read by engine-cli
|
|
37
|
+
* readVerify) are repo-wide check commands that run in every pre-PR round whichever tests were selected; [] when none.
|
|
37
38
|
*/
|
|
38
39
|
export function parseVerify(sec = {}) {
|
|
39
40
|
const s = sec && typeof sec === "object" ? sec : {};
|
|
@@ -54,8 +55,12 @@ export function parseVerify(sec = {}) {
|
|
|
54
55
|
const names = rawChecks.map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
55
56
|
const checks = names.length ? [...new Set(names)] : null;
|
|
56
57
|
const postMerge = str(s.post_merge) || null;
|
|
58
|
+
// a flow list (`[a, "b c"]`) arrives as one scalar from the one-level reader: strip the brackets and quotes
|
|
59
|
+
const rawFlaky = Array.isArray(s.flaky) ? s.flaky : str(s.flaky).trim().replace(/^\[(.*)\]$/s, "$1").split(",");
|
|
60
|
+
const flaky = [...new Set(rawFlaky.map((x) => String(x ?? "").trim().replace(/^(["'])(.*)\1$/s, "$2").trim()).filter(Boolean))];
|
|
57
61
|
const duplicateExports = str(s.duplicate_exports).toLowerCase() !== "false";
|
|
58
|
-
|
|
62
|
+
const invariants = [...new Set((Array.isArray(s.invariants) ? s.invariants : []).map((x) => String(x ?? "").trim()).filter(Boolean))];
|
|
63
|
+
return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, flaky, duplicateExports, invariants, e2e: parseE2e(s) };
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
|
|
@@ -91,6 +96,17 @@ export async function collectFailedLogs(io, failedChecks) {
|
|
|
91
96
|
}
|
|
92
97
|
return capLogs(out);
|
|
93
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* #4586: split failures [{job, log}] by `verify.flaky` ids: a failure is flaky when its job name equals an id. Flaky
|
|
101
|
+
* failures are reported only; `own` is what may block or reach the builder. Never by log text: a job whose log names a
|
|
102
|
+
* flaky test can also hold a real failure, and an Actions run attaches its failed logs to its first failed check (#4614).
|
|
103
|
+
*/
|
|
104
|
+
export function splitFlaky(failures, flaky = []) {
|
|
105
|
+
const ids = (flaky ?? []).filter(Boolean);
|
|
106
|
+
const own = [], known = [];
|
|
107
|
+
for (const f of failures ?? []) (ids.includes(f?.job) ? known : own).push(f);
|
|
108
|
+
return { own, flaky: known };
|
|
109
|
+
}
|
|
94
110
|
const RANK = { failure: 2, pending: 1, success: 0 };
|
|
95
111
|
/**
|
|
96
112
|
* #4387: one check per name at its latest attempt: the highest check-run id (ids grow monotonically, so a rerun or a later
|
package/bin/lean/verify.test.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, verifyReport, DEFAULT_SKIP_PATHS, LOG_CAP, VERIFY_CHECK_NAME, VERIFY_TEXT_CAP, latestPerName, ciGate, rerunFailedOnce, rerunTarget } from "./verify.mjs";
|
|
5
|
+
import { splitFlaky, parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, verifyReport, DEFAULT_SKIP_PATHS, LOG_CAP, VERIFY_CHECK_NAME, VERIFY_TEXT_CAP, latestPerName, ciGate, rerunFailedOnce, rerunTarget } from "./verify.mjs";
|
|
6
6
|
import { precheck } from "./conformance.mjs";
|
|
7
7
|
import { parseE2e } from "./e2e.mjs";
|
|
8
8
|
|
|
@@ -23,9 +23,9 @@ function fake(o = {}) {
|
|
|
23
23
|
|
|
24
24
|
describe("verify-by-CI config (#4167)", () => {
|
|
25
25
|
it("parseVerify defaults to local and rejects invalid values", () => {
|
|
26
|
-
assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, duplicateExports: true, e2e: parseE2e({}) });
|
|
26
|
+
assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, flaky: [], duplicateExports: true, invariants: [], e2e: parseE2e({}) });
|
|
27
27
|
const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
|
|
28
|
-
assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, duplicateExports: true, e2e: parseE2e({}) });
|
|
28
|
+
assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, flaky: [], duplicateExports: true, invariants: [], e2e: parseE2e({}) });
|
|
29
29
|
assert.equal(parseVerify({ mode: "remote" }).mode, "local");
|
|
30
30
|
assert.equal(parseVerify({ workflow: "x; rm -rf /" }).workflow, null);
|
|
31
31
|
assert.equal(parseVerify({ max_rounds: "-1" }).maxRounds, 2);
|
|
@@ -64,6 +64,14 @@ describe("verify-by-CI config (#4167)", () => {
|
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
describe("verify.invariants (#4585)", () => {
|
|
68
|
+
it("parseVerify keeps a trimmed, unique, non-empty invariants list; default []", () => {
|
|
69
|
+
assert.deepEqual(parseVerify({}).invariants, []);
|
|
70
|
+
assert.deepEqual(parseVerify({ invariants: [" a ", "a", "", "b c"] }).invariants, ["a", "b c"]);
|
|
71
|
+
assert.deepEqual(parseVerify({ invariants: "a,b" }).invariants, [], "only a block list counts");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
67
75
|
describe("runCiVerify (#4167)", () => {
|
|
68
76
|
it("docs-only: skipped without a push or any CI call", async () => {
|
|
69
77
|
const f = fake();
|
|
@@ -159,6 +167,24 @@ describe("CI verification gate, latest attempt and flake rerun (#4387)", () => {
|
|
|
159
167
|
});
|
|
160
168
|
});
|
|
161
169
|
|
|
170
|
+
describe("verify.flaky (#4586)", () => {
|
|
171
|
+
it("parses an array or comma list, trimmed and deduped; default empty", () => {
|
|
172
|
+
assert.deepEqual(parseVerify({}).flaky, []);
|
|
173
|
+
assert.deepEqual(parseVerify({ flaky: " a, b ,a," }).flaky, ["a", "b"]);
|
|
174
|
+
assert.deepEqual(parseVerify({ flaky: ["x", " y"] }).flaky, ["x", "y"]);
|
|
175
|
+
});
|
|
176
|
+
it("parses a flow list scalar, quotes stripped", () => {
|
|
177
|
+
assert.deepEqual(parseVerify({ flaky: "[a, \"b c\", 'd']" }).flaky, ["a", "b c", "d"]);
|
|
178
|
+
});
|
|
179
|
+
it("splitFlaky matches by exact job name only, never log text (#4614)", () => {
|
|
180
|
+
const fs = [{ job: "memory-erasure", log: "" }, { job: "test", log: "FAIL pdf timing\nFAIL real regression" }, { job: "lint", log: "boom" }];
|
|
181
|
+
const r = splitFlaky(fs, ["memory-erasure", "pdf timing"]);
|
|
182
|
+
assert.deepEqual(r.flaky.map((f) => f.job), ["memory-erasure"]);
|
|
183
|
+
assert.deepEqual(r.own.map((f) => f.job), ["test", "lint"]);
|
|
184
|
+
assert.equal(splitFlaky(fs, []).own.length, 3);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
162
188
|
describe("merge-time fix rounds and declared check commands (#4240)", () => {
|
|
163
189
|
it("collectFailedLogs fetches one log per Actions run and caps the total", async () => {
|
|
164
190
|
const fetched = [];
|
package/bin/stats/aggregate.mjs
CHANGED
|
@@ -86,7 +86,7 @@ function costSection(runs) {
|
|
|
86
86
|
n, cacheReadMean: mean(cr), cacheReadMedian: percentile(cr, 50), perPhase,
|
|
87
87
|
outputMean: n ? sum(runs.map((r) => sum(Object.values(r.phases).map((p) => p.out ?? 0)))) / n : null,
|
|
88
88
|
perMergedIssue: merged ? sum(cr) / merged : null,
|
|
89
|
-
usdN: usd.length, usdMean: mean(usd), usdTotal: usd.length ? sum(usd) : null,
|
|
89
|
+
usdN: usd.length, usdMean: mean(usd), usdMedian: percentile(usd, 50), usdTotal: usd.length ? sum(usd) : null,
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#4629: `forgedock stats --experiment <id>`. Finished runs tagged with the experiment id, grouped by arm; each metric
|
|
5
|
+
* is null below the declared min n (release.mjs rule), a target is null when a value it needs is null, never a pass.
|
|
6
|
+
*/
|
|
7
|
+
import { aggregate } from "./aggregate.mjs";
|
|
8
|
+
import { releaseMetrics } from "./release.mjs";
|
|
9
|
+
import { benchRecord } from "../experiment.mjs";
|
|
10
|
+
|
|
11
|
+
const isNum = (x) => typeof x === "number" && Number.isFinite(x);
|
|
12
|
+
|
|
13
|
+
/** One target against the per-arm metrics: absolute limit, or limit x the other arm's value. */
|
|
14
|
+
export function evalTarget(t, byArm) {
|
|
15
|
+
const value = byArm[t.arm]?.[t.metric], base = t.vs ? byArm[t.vs]?.[t.metric] : 1;
|
|
16
|
+
const limit = isNum(base) ? t.limit * base : null;
|
|
17
|
+
const pass = isNum(value) && isNum(limit) ? (t.op === "<=" ? value <= limit : value >= limit) : null;
|
|
18
|
+
return { pass, value: isNum(value) ? value : null, limit };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function experimentReport(runs, exp) {
|
|
22
|
+
const mine = runs.filter((r) => r.finished && r.experiment === exp.id);
|
|
23
|
+
const arms = {}, byArm = {};
|
|
24
|
+
for (const arm of exp.arms) {
|
|
25
|
+
const rs = mine.filter((r) => r.arm === arm).map((r) => ({ ...r, version: "0.0.0" })); // one group per arm
|
|
26
|
+
const g = aggregate({ runs: rs }, { minN: exp.minN }).groups[0];
|
|
27
|
+
const m = g ? releaseMetrics([g], { minN: exp.minN }).versions[0] : null;
|
|
28
|
+
byArm[arm] = Object.fromEntries(exp.metrics.map((k) => [k, m?.[k] ?? null]));
|
|
29
|
+
arms[arm] = { n: rs.length, ...byArm[arm] };
|
|
30
|
+
}
|
|
31
|
+
const targets = Object.fromEntries(exp.targets.map((t) => [t.text, evalTarget(t, byArm)]));
|
|
32
|
+
const verdicts = Object.values(targets);
|
|
33
|
+
return { id: exp.id, minN: exp.minN, ...benchRecord({ source: "stats", arms, targets }),
|
|
34
|
+
targets_met: verdicts.length > 0 && verdicts.every((v) => v.pass === true) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const fmt = (v) => (v == null ? "n/a" : Number.isInteger(v) ? String(v) : v.toFixed(2));
|
|
38
|
+
export function renderExperiment(r) {
|
|
39
|
+
const keys = Object.keys(Object.values(r.arms)[0] ?? {}).filter((k) => k !== "n");
|
|
40
|
+
const line = (c) => `| ${c.join(" | ")} |`;
|
|
41
|
+
const rows = Object.entries(r.arms).map(([a, v]) => line([a, String(v.n), ...keys.map((k) => fmt(v[k]))]));
|
|
42
|
+
const tg = Object.entries(r.targets).map(([t, v]) => `${t}: ${v.pass == null ? "n/a" : v.pass ? "met" : "missed"} (${fmt(v.value)} vs ${fmt(v.limit)})`);
|
|
43
|
+
return [`Experiment ${r.id} (min n ${r.minN}; n/a below it)`, line(["arm", "n", ...keys]), line(["---", "---", ...keys.map(() => "---")]), ...rows, ...tg, `targets met: ${r.targets_met}`].join("\n");
|
|
44
|
+
}
|
|
@@ -77,9 +77,10 @@ export function runsFromRecords(records) {
|
|
|
77
77
|
const key = `${r.repo}#${r.issue}#${r.run ?? ""}`;
|
|
78
78
|
const run = byRun.get(key) ?? { source: "github", repo: r.repo ?? null, version: r.forge_version ?? null, issue: r.issue, run: r.run ?? null,
|
|
79
79
|
finished: false, hydrated: false, usageEvents: 0, reason: null, code: null, remediated: false, startTs: null, endTs: null, wallMs: null,
|
|
80
|
-
phases: {}, builderAttempts: [], _first: null };
|
|
80
|
+
phases: {}, builderAttempts: [], experiment: null, arm: null, _first: null };
|
|
81
81
|
byRun.set(key, run);
|
|
82
82
|
if (!run._first || r.ts < run._first) run._first = r.ts;
|
|
83
|
+
if (r.experiment != null && r.arm != null) { run.experiment = String(r.experiment); run.arm = String(r.arm); } // #4629
|
|
83
84
|
if (r.event === "run_terminal") {
|
|
84
85
|
run.finished = true; run.reason = r.reason ?? null; run.code = r.code ?? null; run.endTs = r.ts ?? null; run.wallMs = num(r.wall_ms);
|
|
85
86
|
continue;
|
package/bin/stats/index.mjs
CHANGED
|
@@ -16,6 +16,9 @@ import { parseForgeYaml } from "../forge-utils.mjs";
|
|
|
16
16
|
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
17
17
|
import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers, exploreAb, renderExploreAb, laneProfileReport, renderLaneProfiles } from "./query.mjs";
|
|
18
18
|
import { batchEfficiency, windowEfficiency, compareEfficiency, formatEfficiencyLines, formatCompareLines, parseWindows } from "./efficiency.mjs";
|
|
19
|
+
import { releaseMetrics, renderReleaseMetrics, metricsComment, postMetrics } from "./release.mjs";
|
|
20
|
+
import { experimentReport, renderExperiment } from "./experiment.mjs";
|
|
21
|
+
import { readExperiment } from "../experiment.mjs";
|
|
19
22
|
import { poolUtilisation, formatPoolLines } from "./pool.mjs";
|
|
20
23
|
import { batchRoot } from "../lean/batch.mjs";
|
|
21
24
|
|
|
@@ -23,6 +26,8 @@ const here = dirname(fileURLToPath(import.meta.url));
|
|
|
23
26
|
|
|
24
27
|
export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--refresh]
|
|
25
28
|
[--compact] [--no-local] [--no-cache] [--min-n N] [--release-times FILE]
|
|
29
|
+
forgedock stats --experiment <id> [--require-met] [--min-n N] [--repo <owner/repo>] [--json]
|
|
30
|
+
forgedock stats --by version [--post-metrics <pr>] [--min-n N] [--repo <owner/repo>] [--json]
|
|
26
31
|
forgedock stats --pool [--window 7d,30d] [--repo <owner/repo>] [--json]
|
|
27
32
|
forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--compare <date|version>] [--offline] [--repo <owner/repo>] [--json]
|
|
28
33
|
|
|
@@ -42,6 +47,14 @@ delta against the previous version and a low-n flag for samples under 10. Missin
|
|
|
42
47
|
--release-times FILE "<version> <epoch-seconds>" lines used to estimate versions of unversioned records
|
|
43
48
|
(default: git tags v* of the ForgeDock checkout)
|
|
44
49
|
|
|
50
|
+
Per-release regression rule (forge#4628):
|
|
51
|
+
--require-met with --experiment: exit 1 unless every target is met (#4634; for a named post-deploy check)
|
|
52
|
+
--experiment <id> arms of the forge.yaml experiment: section (arms, ratio, metrics, targets, min_n) against its targets; n/a below min n
|
|
53
|
+
--emit-event with --experiment: emit one experiment.verdict event (met or missed; docs/EVENTS.md)
|
|
54
|
+
--by version median cost per run, wall p50, first-pass rate and needs-human rate of each release against the
|
|
55
|
+
previous one; a metric under --min-n runs is null and its verdict null, never a pass
|
|
56
|
+
--post-metrics <pr> post (or update) the <!-- FORGE:METRICS v1 --> snapshot comment on that release PR
|
|
57
|
+
|
|
45
58
|
ALERTS (forge#4354; local run logs only, skipped with --no-local): fail-soft rates over the last 50 lanes of the repo
|
|
46
59
|
(DEPS_PREPARED failed per ecosystem, deferred-to-ci, repeated TOOLCHAIN_WARNING, outcome codes whose share doubled);
|
|
47
60
|
thresholds under forge.yaml stats.alerts.*. Nothing is printed when none fired or under 10 lanes.
|
|
@@ -81,8 +94,11 @@ Lane profile baseline (forge#4436; local run logs only, every run of each issue,
|
|
|
81
94
|
--issues <list> pin the report to these issues (comma list), for a before/after on the same issue set
|
|
82
95
|
`;
|
|
83
96
|
|
|
97
|
+
/** #4634: 0, or 1 when --require-met and the experiment's targets are not all met (a named post-deploy check gates on it). */
|
|
98
|
+
export function experimentExit(opts, report) { return opts.requireMet && report.targets_met !== true ? 1 : 0; }
|
|
99
|
+
|
|
84
100
|
export function parseArgs(argv) {
|
|
85
|
-
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, laneProfile: false, issues: null, efficiency: false, pool: false, batch: null, windows: null, compare: null, offline: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
101
|
+
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, laneProfile: false, issues: null, efficiency: false, pool: false, batch: null, windows: null, compare: null, by: null, experiment: null, emitEvent: false, requireMet: false, postMetrics: null, offline: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
86
102
|
const val = (i, name) => {
|
|
87
103
|
const v = argv[i + 1];
|
|
88
104
|
if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
|
|
@@ -112,8 +128,13 @@ export function parseArgs(argv) {
|
|
|
112
128
|
try { if (!v || !Object.keys(parseSince(v)).length) throw bad(); } catch { throw bad(); }
|
|
113
129
|
o.compare = v;
|
|
114
130
|
}
|
|
131
|
+
else if (name === "--by") { const v = take(); if (v !== "version") throw new Error("--by must be version"); o.by = v; }
|
|
132
|
+
else if (name === "--experiment") { const v = take(); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v)) throw new Error("--experiment must be an experiment id"); o.experiment = v; }
|
|
133
|
+
else if (name === "--emit-event") o.emitEvent = true;
|
|
134
|
+
else if (name === "--post-metrics") { const v = take(); if (!/^[1-9]\d*$/.test(v)) throw new Error("--post-metrics must be a PR number"); o.postMetrics = Number(v); }
|
|
135
|
+
else if (name === "--require-met") o.requireMet = true;
|
|
115
136
|
else if (name === "--offline") o.offline = true;
|
|
116
|
-
else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
|
|
137
|
+
else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); o.minNSet = true; }
|
|
117
138
|
else if (name === "--json") o.json = true;
|
|
118
139
|
else if (name === "--refresh") o.refresh = true;
|
|
119
140
|
else if (name === "--compact") o.compact = true;
|
|
@@ -125,6 +146,7 @@ export function parseArgs(argv) {
|
|
|
125
146
|
if (o.batch && !o.efficiency) throw new Error("--batch and --window need --efficiency");
|
|
126
147
|
if (o.windows && !o.efficiency && !o.pool) throw new Error("--batch and --window need --efficiency");
|
|
127
148
|
if ((o.compare || o.offline) && !o.efficiency) throw new Error("--compare and --offline need --efficiency");
|
|
149
|
+
if (o.postMetrics && !o.by) throw new Error("--post-metrics needs --by version");
|
|
128
150
|
return o;
|
|
129
151
|
}
|
|
130
152
|
|
|
@@ -287,6 +309,12 @@ export async function buildReport(opts, deps = {}) {
|
|
|
287
309
|
}
|
|
288
310
|
const report = aggregate({ runs: [...model.runs, ...local], events: model.events, issues: model.issues },
|
|
289
311
|
{ minN: opts.minN, releaseTimes, ...parseSince(opts.since) });
|
|
312
|
+
if (opts.experiment) { // forge#4629
|
|
313
|
+
const exp = deps.experiment ?? readExperiment();
|
|
314
|
+
if (!exp || exp.id !== opts.experiment) throw new Error(`experiment ${opts.experiment} is not declared in forge.yaml (experiment: id, arms, ...)`);
|
|
315
|
+
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, query: { experiment: experimentReport([...model.runs, ...local], { ...exp, minN: opts.minNSet ? opts.minN : exp.minN }) } };
|
|
316
|
+
}
|
|
317
|
+
if (opts.by) return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, query: { release_metrics: releaseMetrics(report.groups, { minN: opts.minN, repo }) } };
|
|
290
318
|
// forge#4354: fail-soft alerts from the local run logs only (no gh call)
|
|
291
319
|
const alerts = opts.local ? computeAlerts(deps.localLanes ?? readLanes(runsDir(env)), { repo, ...(deps.alertConfig ?? readAlertConfig()) }) : [];
|
|
292
320
|
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report, alerts };
|
|
@@ -299,6 +327,10 @@ export async function runStats(argv, deps = {}) {
|
|
|
299
327
|
return 2;
|
|
300
328
|
}
|
|
301
329
|
if (opts.help) { process.stdout.write(HELP); return 0; }
|
|
330
|
+
if (opts.requireMet && !opts.experiment) {
|
|
331
|
+
process.stderr.write(`forgedock stats: --require-met needs --experiment <id>\n`);
|
|
332
|
+
return 2;
|
|
333
|
+
}
|
|
302
334
|
opts.repo = resolveRepo(opts.repo);
|
|
303
335
|
if (!opts.repo || !isValidRepo(opts.repo)) {
|
|
304
336
|
process.stderr.write(`forgedock stats: could not resolve a valid owner/repo (pass --repo owner/repo)\n`);
|
|
@@ -306,6 +338,14 @@ export async function runStats(argv, deps = {}) {
|
|
|
306
338
|
}
|
|
307
339
|
try {
|
|
308
340
|
const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
|
|
341
|
+
if (report.query?.release_metrics) {
|
|
342
|
+
const rm = report.query.release_metrics;
|
|
343
|
+
if (opts.postMetrics) await postMetrics({ gh: deps.gh ?? defaultGh, repo: opts.repo, pr: opts.postMetrics, body: metricsComment(rm) });
|
|
344
|
+
process.stdout.write(`${opts.json ? renderJson(report) : renderReleaseMetrics(rm)}\n`);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
if (report.query?.experiment && opts.emitEvent) await (deps.emitExperiment ?? (async (r, x) => (await import("../engine-cli.mjs")).experimentEventSink(r, x)))(opts.repo, report.query.experiment);
|
|
348
|
+
if (report.query?.experiment) { process.stdout.write(`${opts.json ? renderJson(report) : renderExperiment(report.query.experiment)}\n`); return experimentExit(opts, report.query.experiment); }
|
|
309
349
|
if (report.query?.lane_profile && !opts.json) { process.stdout.write(`${renderLaneProfiles(report.query.lane_profile)}\n`); return 0; }
|
|
310
350
|
if (report.query?.tiers && !opts.json) { process.stdout.write(`${renderTiers(report.query.tiers)}\n`); return 0; }
|
|
311
351
|
if (report.query?.pool && !opts.json) { process.stdout.write(`${formatPoolLines(report.query.pool).join("\n")}\n`); return 0; }
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#4628: per-release regression rule over the `aggregate()` groups. Each metric is null when its own sample is
|
|
5
|
+
* below minN; a verdict is null when either side is null. There is no "pass" value, only regressed|improved|flat|null.
|
|
6
|
+
*/
|
|
7
|
+
import { DEFAULT_MIN_N, UNKNOWN, compareVersions } from "./aggregate.mjs";
|
|
8
|
+
|
|
9
|
+
export const METRICS_MARKER = "<!-- FORGE:METRICS v1 -->";
|
|
10
|
+
const isNum = (x) => typeof x === "number" && Number.isFinite(x);
|
|
11
|
+
// lower is better for all but the first-pass rate
|
|
12
|
+
const METRICS = [
|
|
13
|
+
{ key: "usd_median", label: "median cost/run", lowerBetter: true, flat: 0.05 },
|
|
14
|
+
{ key: "wall_p50_ms", label: "wall p50", lowerBetter: true, flat: 0.05 },
|
|
15
|
+
{ key: "first_pass_rate", label: "first-pass rate", lowerBetter: false, flat: 0.02 },
|
|
16
|
+
{ key: "needs_human_rate", label: "needs-human rate", lowerBetter: true, flat: 0.02 },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** The metric values of one group, each null below minN (its own n). */
|
|
20
|
+
function valuesOf(g, minN) {
|
|
21
|
+
const pick = (n, v) => (isNum(n) && n >= minN && isNum(v) ? v : null);
|
|
22
|
+
return {
|
|
23
|
+
n: g.n,
|
|
24
|
+
usd_median: pick(g.cost?.usdN, g.cost?.usdMedian),
|
|
25
|
+
wall_p50_ms: pick(g.time?.wall?.n, g.time?.wall?.p50),
|
|
26
|
+
first_pass_rate: pick(g.firstPass?.n, g.firstPass?.rate),
|
|
27
|
+
needs_human_rate: pick(g.flow?.n, g.flow?.needsHumanPct),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Relative change for cost and time, absolute for rates; flat inside the tolerance. */
|
|
32
|
+
function verdict(m, cur, prev) {
|
|
33
|
+
if (!isNum(cur) || !isNum(prev)) return null;
|
|
34
|
+
const rate = m.key.endsWith("_rate");
|
|
35
|
+
const d = rate ? cur - prev : prev === 0 ? (cur === 0 ? 0 : Infinity) : (cur - prev) / prev;
|
|
36
|
+
if (Math.abs(d) <= m.flat) return "flat";
|
|
37
|
+
return (d < 0) === m.lowerBetter ? "improved" : "regressed";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Known versions of one repo, oldest first, each with its metrics and the verdicts against the previous one. */
|
|
41
|
+
export function releaseMetrics(groups, { minN = DEFAULT_MIN_N, repo = null } = {}) {
|
|
42
|
+
const known = groups.filter((g) => g.version !== UNKNOWN && (repo == null || g.repo === repo))
|
|
43
|
+
.sort((a, b) => compareVersions(a.version, b.version));
|
|
44
|
+
const versions = known.map((g, i) => {
|
|
45
|
+
const metrics = valuesOf(g, minN), prev = i ? valuesOf(known[i - 1], minN) : null;
|
|
46
|
+
const verdicts = Object.fromEntries(METRICS.map((m) => [m.key, prev ? verdict(m, metrics[m.key], prev[m.key]) : null]));
|
|
47
|
+
return { version: g.version, against: i ? known[i - 1].version : null, ...metrics, verdicts };
|
|
48
|
+
});
|
|
49
|
+
return { repo, minN, versions };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fmt = (key, v) => (v == null ? "n/a" : key === "usd_median" ? `$${v.toFixed(2)}` : key === "wall_p50_ms" ? `${(v / 60000).toFixed(1)}m` : `${(v * 100).toFixed(0)}%`);
|
|
53
|
+
|
|
54
|
+
export function renderReleaseMetrics(rm) {
|
|
55
|
+
const rows = rm.versions.map((v) => [v.version, String(v.n), ...METRICS.map((m) => `${fmt(m.key, v[m.key])} (${v.verdicts[m.key] ?? "n/a"})`)]);
|
|
56
|
+
const head = ["version", "n", ...METRICS.map((m) => m.label)];
|
|
57
|
+
const line = (r) => `| ${r.join(" | ")} |`;
|
|
58
|
+
return [`Release metrics (min n ${rm.minN}; n/a below it)`, line(head), line(head.map(() => "---")), ...rows.map(line)].join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The FORGE:METRICS comment: marker, table and the snapshot as fenced JSON. */
|
|
62
|
+
export function metricsComment(rm) {
|
|
63
|
+
return `${METRICS_MARKER}\n${renderReleaseMetrics(rm)}\n\n\`\`\`json\n${JSON.stringify(rm)}\n\`\`\`\n`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Create the FORGE:METRICS comment on the PR, or update the existing one (argv-based gh). */
|
|
67
|
+
export async function postMetrics({ gh, repo, pr, body }) {
|
|
68
|
+
const raw = await gh(["api", "--paginate", `repos/${repo}/issues/${pr}/comments`]);
|
|
69
|
+
const pages = String(raw ?? "").trim().replace(/\]\s*\[/g, ",");
|
|
70
|
+
const list = pages ? JSON.parse(pages.startsWith("[") ? pages : `[${pages}]`) : [];
|
|
71
|
+
const mine = list.filter((c) => typeof c?.body === "string" && c.body.startsWith(METRICS_MARKER)).pop();
|
|
72
|
+
if (mine) await gh(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${mine.id}`, "-f", `body=${body}`]);
|
|
73
|
+
else await gh(["api", "-X", "POST", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`]);
|
|
74
|
+
return mine ? "updated" : "created";
|
|
75
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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 { aggregate } from "./aggregate.mjs";
|
|
6
|
+
import { parseArgs } from "./index.mjs";
|
|
7
|
+
import { METRICS_MARKER, metricsComment, postMetrics, releaseMetrics, renderReleaseMetrics } from "./release.mjs";
|
|
8
|
+
|
|
9
|
+
const trig = (fp) => [{ trigger: "build" }, ...(fp ? [] : [{ trigger: "review" }])];
|
|
10
|
+
function runs(version, n, { usd, wall, fp, human }) {
|
|
11
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
12
|
+
source: "github", repo: "o/r", version, issue: i + (version === "1.2.0" ? 100 : 0),
|
|
13
|
+
finished: true, usageEvents: 1, reason: i < human ? "needs-human" : "merged", wallMs: wall, endTs: "2026-10-01T00:00:00Z",
|
|
14
|
+
phases: { build: { usd, cr: 1, ms: 1 } }, builderAttempts: trig(i < fp),
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
const groupsFor = (all, minN = 10) => aggregate({ runs: all }, { minN }).groups;
|
|
18
|
+
|
|
19
|
+
describe("releaseMetrics (#4628)", () => {
|
|
20
|
+
const prev = runs("1.1.0", 12, { usd: 2, wall: 600000, fp: 6, human: 1 });
|
|
21
|
+
it("compares each metric with the previous release and never reports a pass", () => {
|
|
22
|
+
const cur = runs("1.2.0", 12, { usd: 3, wall: 300000, fp: 11, human: 1 });
|
|
23
|
+
const rm = releaseMetrics(groupsFor([...prev, ...cur]), { minN: 10 });
|
|
24
|
+
const v = rm.versions[1];
|
|
25
|
+
assert.equal(v.against, "1.1.0");
|
|
26
|
+
assert.deepEqual(v.verdicts, { usd_median: "regressed", wall_p50_ms: "improved", first_pass_rate: "improved", needs_human_rate: "flat" });
|
|
27
|
+
assert.equal(rm.versions[0].verdicts.usd_median, null);
|
|
28
|
+
assert.ok(!JSON.stringify(rm).includes("pass\""));
|
|
29
|
+
});
|
|
30
|
+
it("is null below min n, and the verdict is null when either side is", () => {
|
|
31
|
+
const cur = runs("1.2.0", 3, { usd: 9, wall: 1, fp: 0, human: 3 });
|
|
32
|
+
const rm = releaseMetrics(groupsFor([...prev, ...cur]), { minN: 10 });
|
|
33
|
+
const v = rm.versions[1];
|
|
34
|
+
assert.deepEqual([v.usd_median, v.wall_p50_ms, v.first_pass_rate, v.needs_human_rate], [null, null, null, null]);
|
|
35
|
+
assert.deepEqual(Object.values(v.verdicts), [null, null, null, null]);
|
|
36
|
+
assert.match(renderReleaseMetrics(rm), /n\/a/);
|
|
37
|
+
});
|
|
38
|
+
it("skips unknown versions", () => {
|
|
39
|
+
const rm = releaseMetrics(groupsFor(runs(null, 12, { usd: 1, wall: 1, fp: 1, human: 0 })), { minN: 1 });
|
|
40
|
+
assert.deepEqual(rm.versions, []);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("FORGE:METRICS comment (#4628)", () => {
|
|
45
|
+
const rm = releaseMetrics([], { minN: 10, repo: "o/r" });
|
|
46
|
+
it("carries the marker and the JSON snapshot", () => {
|
|
47
|
+
const b = metricsComment(rm);
|
|
48
|
+
assert.ok(b.startsWith(METRICS_MARKER));
|
|
49
|
+
assert.deepEqual(JSON.parse(/```json\n(.*)\n```/s.exec(b)[1]), rm);
|
|
50
|
+
});
|
|
51
|
+
it("creates the comment, then updates it", async () => {
|
|
52
|
+
const calls = [];
|
|
53
|
+
const mk = (list) => (args) => { calls.push(args.join(" ")); return args[0] === "api" && args.length === 3 ? JSON.stringify(list) : ""; };
|
|
54
|
+
assert.equal(await postMetrics({ gh: mk([{ id: 1, body: "x" }]), repo: "o/r", pr: 7, body: "b" }), "created");
|
|
55
|
+
assert.match(calls.at(-1), /-X POST repos\/o\/r\/issues\/7\/comments/);
|
|
56
|
+
assert.equal(await postMetrics({ gh: mk([{ id: 5, body: `${METRICS_MARKER}\nold` }]), repo: "o/r", pr: 7, body: "b" }), "updated");
|
|
57
|
+
assert.match(calls.at(-1), /-X PATCH repos\/o\/r\/issues\/comments\/5/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("--by / --post-metrics parsing (#4628)", () => {
|
|
62
|
+
it("accepts version and a PR number; rejects the rest", () => {
|
|
63
|
+
assert.equal(parseArgs(["--by", "version", "--post-metrics", "12"]).postMetrics, 12);
|
|
64
|
+
assert.throws(() => parseArgs(["--by", "repo"]), /--by must be version/);
|
|
65
|
+
assert.throws(() => parseArgs(["--post-metrics", "12"]), /needs --by version/);
|
|
66
|
+
assert.throws(() => parseArgs(["--by", "version", "--post-metrics", "x"]), /PR number/);
|
|
67
|
+
});
|
|
68
|
+
});
|
package/commands/orchestrate.md
CHANGED
|
@@ -102,7 +102,10 @@ remaining maintenance once no feature is pending; otherwise the rest ends `held
|
|
|
102
102
|
`--resume`; a batch started without the daemon still needs `forgedock orchestrate --resume <id>`. Change K of a running batch with
|
|
103
103
|
`forgedock orchestrate --set-parallel <id> K`. Issues filed while a batch runs join it with
|
|
104
104
|
`forgedock orchestrate --add <id> N...` (or `--resume <id> N...`), under its K and DAG; never start a second batch
|
|
105
|
-
for them.
|
|
105
|
+
for them. After a decision (a `FORGE:DECISION` posted and `needs-human` removed), a blocked issue that already ended in the batch
|
|
106
|
+
(`needs-human`, `invalid`, `skipped`, `dependency-*`, `ci-pending`) returns to it with
|
|
107
|
+
`forgedock orchestrate --requeue <id> N... --reason "..."` (refused while it is labelled `needs-human` or closed; skipped dependents
|
|
108
|
+
return to pending with it; a finished batch is reopened); `--add` skips such an issue as `already in batch`. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy and a full sandbox pool after their
|
|
106
109
|
retry-in, uncapped, a usage limit holds admissions for that account (backend identity) until the reset, across every batch on
|
|
107
110
|
the host; `forgedock limits show|clear <id>|--all` lists or drops holds; transient merge/push/idle failures back off; other
|
|
108
111
|
deferrals stop after `orchestration.max_deferrals`), relaunches an `engine-error` once, and stops admitting after
|
|
@@ -137,7 +140,8 @@ When a lane finishes (agent lanes; the batch runner applies the same rules itsel
|
|
|
137
140
|
ran (no lease, comment or worktree). Report the code and its detail (for example a missing `milestone/<slug>`
|
|
138
141
|
branch), and mark the issue's dependents skipped. Do not retry it with a different `--lane`.
|
|
139
142
|
- `dependency-blocked`: its dependency is open and labelled `needs-human`. Report it as blocked by that issue,
|
|
140
|
-
do not label it `needs-human`, and mark its dependents skipped.
|
|
143
|
+
do not label it `needs-human`, and mark its dependents skipped. Once the blocker is cleared, `forgedock orchestrate --requeue <id> N...`
|
|
144
|
+
returns the issue and its skipped dependents to the batch.
|
|
141
145
|
- `decomposed`: the builder split the issue and the engine filed sub-issues (listed in its output, each with
|
|
142
146
|
`**Parent**` and `**Depends on**` lines). Add them to the DAG, launch them, and let the parent's dependents
|
|
143
147
|
wait on the sub-issues.
|
|
@@ -159,7 +163,8 @@ When a lane finishes (agent lanes; the batch runner applies the same rules itsel
|
|
|
159
163
|
- `needs-human` with a **human code** (`builder.blocked`: product-decision, spend, credentials, promotion, customer): an
|
|
160
164
|
external action agents cannot take, or a product decision too critical for agents. It is never auto-recovered. Read
|
|
161
165
|
the issue's last comment for the reason, mark its dependents skipped, and add it to the escalation list (step 4). Only
|
|
162
|
-
explicit or logical edges skip a dependent
|
|
166
|
+
explicit or logical edges skip a dependent (after you decide the block in scope, clear the label and run
|
|
167
|
+
`forgedock orchestrate --requeue <id> N... --reason "..."`, never a second batch); an issue that merely overlaps the same file keeps running.
|
|
163
168
|
- `needs-human` with a **terminal code** that is neither recoverable nor human (for example a `conformance.*` code, or
|
|
164
169
|
`contract.invalid` without a mid-run issue edit): the runner does not recover it and it is not an escalation. The
|
|
165
170
|
batch report lists it as `unrecovered: #N (<code>: <detail>)`, like a recovery that did not merge. Mark its dependents
|
package/docs/CONFIG.md
CHANGED
|
@@ -567,7 +567,7 @@ engine:
|
|
|
567
567
|
| `cli_idle_minutes` | number | No | Core builder idle timeout (#4112): the builder CLI runs with `--output-format stream-json` and is killed (code `cli.idle`) when it shows no stream activity for this many minutes. The run log records the last tool call and the live child processes. Default 15. |
|
|
568
568
|
| `cli_busy_minutes` | number | No | Extended idle window for a core builder (#4405). When the idle timeout is reached but the session has a live heavy step (a heavy-shim step, test runner, linter or type checker child) or a queued or running heavy-queue entry of its own processes, the kill waits until `cli_idle_minutes` + this many minutes of silence. The run log records `CLI_IDLE_EXTENDED` once per silent stretch, and a later `CLI_IDLE_KILL` carries `heavyQueue` ({queued, running}) and `extended: true`. A silent session with no such child is still killed at `cli_idle_minutes`. Default 45 (the heavy queue's 30-minute maximum wait plus the 15-minute idle default). |
|
|
569
569
|
| `cli_max_minutes` | number | No | Absolute ceiling for a core builder session (code `cli.timeout`), replacing the fixed 60-minute kill. A session that keeps making progress is never idle-killed. Default 180. On either kill, uncommitted work is committed as `wip(forge): checkpoint after <reason>` and the lane branch is pushed (with the push retry), the builder retries once from the checkpoint, and a second kill ends `deferred`; a rerun resumes from the branch. |
|
|
570
|
-
| `turn_budget_s` / `turn_budget_m` / `turn_budget_l` | number | No | Core implement turn budget per lane size (#4392). Size comes from a `size:S|M|L` issue label, else the frozen contract's distinct deliverable paths (S ≤ 2, M ≤ 5, else L). The build call and its context/timeout restarts share the budget; at the limit the session is stopped, checkpointed, and one fresh wrap-up session of max(5, budget/5) turns either finishes or writes `status: budget-exceeded` with a digest. A stop or overrun ends with the non-human `budget-exceeded` reason and code `budget.exceeded` (no `needs-human` label, forge#4505), which `forgedock orchestrate` recovers once (continue from the checkpoint or split); a batch that does not recover it labels it `needs-human` once. Lane results carry `turns: {size, budget, build, total, exceeded}`, and the batch report prints `turns by size`. Defaults 25 / 60 / 100; 0 disables that size. |
|
|
570
|
+
| `turn_budget_s` / `turn_budget_m` / `turn_budget_l` | number | No | Core implement turn budget per lane size (#4392). Size comes from a `size:S|M|L` issue label, else the frozen contract's distinct deliverable paths (S ≤ 2, M ≤ 5, else L). The build call and its context/timeout restarts share the budget; at the limit the session is stopped, checkpointed, and one fresh wrap-up session of max(5, budget/5) turns either finishes or writes `status: budget-exceeded` with a digest. A stop or overrun ends with the non-human `budget-exceeded` reason and code `budget.exceeded` (no `needs-human` label, forge#4505), which `forgedock orchestrate` recovers once (continue from the checkpoint or split); a batch that does not recover it labels it `needs-human` once. Lane results carry `turns: {size, budget, build, total, exceeded}`, and the batch report prints `turns by size`. Defaults 25 / 60 / 100; 0 disables that size. An L lane's budget scales with the contract: +10 turns per distinct deliverable path above 5 (capped at +2x `turn_budget_l`), and a `budget.exceeded` recovery of an L lane gets a further +50% of `turn_budget_l` (#4587). |
|
|
571
571
|
| `lane_limits` | map | No | Cap each core lane's whole process tree (#4115), under `pipeline:` or `engine:`: `memory_max` (for example `3G`) and `cpu_quota` (for example `150%`). When set and `systemd-run --user --scope` works (probed once), the builder CLI is spawned as `systemd-run --user --scope -q -p MemoryMax=... -p CPUQuota=... -- claude ...`; elsewhere the caps are ignored. Write it nested or flat (`lane_limits_memory_max`, `lane_limits_cpu_quota`). While a builder runs the engine polls host load and memory every 15 s against the `admission` thresholds: over them it SIGSTOPs the lane's process group, under them SIGCONT, and never pauses longer than 10 minutes. |
|
|
572
572
|
| `admission` | map | No | Host-resource admission for the core work-on (#4105), checked before the lease, worktree or any GitHub call. When a check fails the run ends `deferred` (code `host.busy`, exit 75) with a last line such as `issue #N → deferred (host busy: mem 1.2G < 2G; retry in 60s)`; no lease is taken and nothing is posted. Keys: `enabled` (default `true`; `false` disables admission), `min_free_mem_mb` (default 2048; `MemAvailable` from `/proc/meminfo`, else `os.freemem()`), `min_free_tmp_mb` (default 1024; free space of the run TMPDIR filesystem, `~/.forge/tmp`, and of `/tmp`, via `statfs`), `max_load_per_core` (default 4; 1-minute load average divided by cores). Write it nested (`engine:` then `admission:` with the keys indented) or flat as `admission_enabled`, `admission_min_free_mem_mb`, `admission_min_free_tmp_mb`, `admission_max_load_per_core`; the parser reads one nested level into `engine`. `/orchestrate` requeues a host-busy lane and lowers K by 1 for 5 minutes. |
|
|
573
573
|
| `merge_wait` | string | No | Core work-on merge gate. `required` (default): wait only for the status checks the base branch requires: the union of classic branch protection (`gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`, contexts and checks) and repository rulesets (`gh api repos/{o}/{r}/rules/branches/{base}`, `required_status_checks` rules); with neither (404 or empty) merge right after review PASS. A merge rejected by base branch policy (PR `MERGEABLE` but `BLOCKED`) is waited on and retried within the CI budget, then left `ci-pending`; it is never base-synced and never `base.conflict`. A failing required check is `needs-human`; a pending one is polled with backoff and never `needs-human`. `all`: wait for every check (`scripts/wait-ci-green.sh`). `none`: merge right after review. Always `--match-head-commit` at the reviewed head. The policy used is recorded in the `FORGE:WORK-ON` comment. |
|
|
@@ -594,6 +594,52 @@ engine:
|
|
|
594
594
|
|
|
595
595
|
---
|
|
596
596
|
|
|
597
|
+
## `post_deploy` (OPTIONAL)
|
|
598
|
+
|
|
599
|
+
Deferred follow-ups whose items are all post-deploy checks are parked (`awaiting-promotion`, no lane) until the parent's fix is on `branches.default`. Keys: `precondition_cmd` (exit 0 ready, 1 parked) and `min_age` (soak window, e.g. `24h`).
|
|
600
|
+
|
|
601
|
+
### Named checks (#4626)
|
|
602
|
+
|
|
603
|
+
A post-deploy requirement carries a check id (`[check:<id>]` in the follow-up item). The command is read from `.forge/post-deploy-checks.json` on the default branch (`git show origin/<default>:...`), never from an issue body or comment:
|
|
604
|
+
|
|
605
|
+
```json
|
|
606
|
+
{ "login-smoke": { "run": "scripts/smoke.sh", "pass": "exit 0", "timeout": 120 } }
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
`pass` is `exit 0` (default) or a `/regex/flags` over the output; `timeout` is seconds. When the gate reports the follow-up ready, the check runs with no model call: pass closes the follow-up with a `FORGE:PROBE` record, fail reopens the parent with the output. An item with no check id, or an id the default branch does not define, gets the `awaiting-measurement` label and never a lane. A contract's check id must be on the default branch or added by the same PR.
|
|
610
|
+
|
|
611
|
+
Closing an experiment follow-up (#4634): add a check that runs `forgedock stats --experiment <id> --require-met` (exit 0 only when every target of the forge.yaml `experiment:` is met; n/a below min n is not met), and put `[check:<check id>]` in the follow-up item:
|
|
612
|
+
|
|
613
|
+
```json
|
|
614
|
+
{ "exp-light-vs-full": { "run": "forgedock stats --experiment light-vs-full --require-met" } }
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Met targets pass the check and close the follow-up; with `for`/`every` it is re-measured through the window. The probe env is minimal: list the GitHub token variable in the forge.yaml `acceptance_probe.env` allowlist so stats can read telemetry.
|
|
618
|
+
|
|
619
|
+
### Windowed monitoring (#4627)
|
|
620
|
+
|
|
621
|
+
A check may declare `for` (the window, `30m`/`24h`/`7d`/`2w`) and `every` (the cadence, default `6h`; needs `for`); an entry with an invalid duration, or `every` without `for`, is ignored:
|
|
622
|
+
|
|
623
|
+
```json
|
|
624
|
+
{ "login-smoke": { "run": "scripts/smoke.sh", "for": "7d", "every": "6h" } }
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
When every item's check is windowed, the first pass records the release (the `origin/<default>` sha) and the window end in a `FORGE:PROBE` record, labels the follow-up `post-deploy-monitoring` and leaves it open (no lane, no second measurement by the pre-filter). It re-runs, with the command read from the default branch each time, when `every` has elapsed or `origin/<default>` moved (a later release). At the window end it closes. A fail after a pass opens a regression issue (`FORGE:REGRESSION`) naming the fix commit, the last passing release and the breaking release, and closes the follow-up; the parent is not reopened. A fail on the first run behaves as above (the parent reopens). No model call.
|
|
628
|
+
|
|
629
|
+
Triggers: `forgedock probe --due [--dry-run] [--repo owner/repo]` runs the due ones once; the `forgedock schedd` daemon runs them on a timer while it is up (`FORGEDOCK_SCHEDD_DUE_MS`, default 15 minutes).
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
## `events` (OPTIONAL)
|
|
633
|
+
|
|
634
|
+
Outbound lifecycle events (#4630). The local JSONL sink is always on; the webhook needs both keys. See [EVENTS.md](EVENTS.md) for the schema and signature.
|
|
635
|
+
|
|
636
|
+
```yaml
|
|
637
|
+
events:
|
|
638
|
+
webhook_url: https://hooks.example.com/forgedock # https, or http on localhost; unset = no webhook
|
|
639
|
+
webhook_secret_env: FORGE_EVENTS_SECRET # NAME of the env var holding the HMAC secret (default shown); never put the secret here
|
|
640
|
+
dir: ~/.forge/events # local JSONL directory
|
|
641
|
+
```
|
|
642
|
+
|
|
597
643
|
## `lean` (OPTIONAL)
|
|
598
644
|
|
|
599
645
|
Settings of the core work-on (`bin/lean/`) that change its contract.
|
|
@@ -612,6 +658,10 @@ lean:
|
|
|
612
658
|
max_files: 2 # max predicted / changed non-test files
|
|
613
659
|
sequence:
|
|
614
660
|
regenerate: pnpm --filter @x/db db:generate # optional snapshot generator after a deterministic renumber
|
|
661
|
+
base_merge_union_globs: "src/index.ts, **/messages.ts" # optional #4588: append-only files, both sides' added lines kept
|
|
662
|
+
base_merge_json_globs: "i18n/translation-status.json" # optional #4588: JSON maps, 3-way key union
|
|
663
|
+
base_merge_generated_globs: "i18n/generated/**" # optional #4588: base side taken, then regenerated
|
|
664
|
+
base_merge_generated_command: "pnpm i18n:generate" # optional #4588: the regenerate command
|
|
615
665
|
```
|
|
616
666
|
|
|
617
667
|
| Field | Type | Required | Description |
|
|
@@ -625,6 +675,7 @@ lean:
|
|
|
625
675
|
| `light.max_lines` | number | No | Upgrade trigger (#4448): a light lane whose non-test diff (added + deleted lines) exceeds this moves to the full lane. Default 40. The other triggers: more than `max_files` non-test changed files, a repro that is not red on the base, two failed test runs, a touched risk path, or a public-API-shape change (#4449). Flat `lean.light_max_lines` wins. |
|
|
626
676
|
| `light.max_files` | number | No | Max predicted non-test files for eligibility and max changed non-test files before an upgrade (#4448). Default 2. Flat `lean.light_max_files` wins. |
|
|
627
677
|
| `sequence.regenerate` | string | No | Command run in the lane worktree (`sh -c`) after the engine renumbers a schema-changing sequence-numbered migration (#4386), to regenerate its snapshot, e.g. `pnpm --filter @x/db db:generate`. The flat form `lean.sequence_regenerate` also works and wins. When a lane-added numbered file (`NNNN_*.sql`, a drizzle `meta/_journal.json` + `meta/NNNN_snapshot.json`, a numbered ADR) shares its number with the base, the engine renumbers it without a builder session: `git mv` to max(base)+1…, the document's own number in its heading, references to the old name repo-wide, the base journal plus the lane's entries, and a data-only migration's snapshot cloned from the base tip with a new `id`/`prevId`. A schema-changing migration without this command, or a migration in another language (`.py`, `.ts`), falls back to the builder renumber, and the trajectory records why. When the generator writes no snapshot for a hand-written migration (no schema diff), the engine reruns it with ` --custom` appended (drizzle-kit's empty migration), keeps its snapshot and restores the lane's SQL byte-identical, so the command must forward trailing args (a pnpm/yarn script, or `npm run x --`). A base-sync whose conflicts are only in such files is resolved the same way, with no re-review. At the merge gate a lane with numbered files holds a host sequence lease per (repo, lane branch, directory) from its last renumber check through the merge, so only one numbered-file PR is in the renumber→CI→merge window at a time. |
|
|
678
|
+
| `base_merge_union_globs` / `base_merge_json_globs` / `base_merge_generated_globs` / `base_merge_generated_command` | string | No | Deterministic resolution of sibling-lane conflicts when the lane merges its base (#4588, `bin/lean/conflictres.mjs`; flat keys only). Comma-separated globs (`*` within a segment, `**` across; no `/` matches the file name anywhere). `union_globs`: hunks where both sides only added lines keep both. `json_globs`: a 3-way key union of a JSON object (a key both sides changed differently is refused). `generated_globs` + `generated_command`: the base side is taken, then the command runs once in the lane worktree (`sh -c`) and the files are re-added. A file that cannot be fully resolved, and every sequence/migration file, goes on as before (sequence resolver, then the builder). Default: none, so nothing changes. |
|
|
628
679
|
|
|
629
680
|
## `stats` (OPTIONAL)
|
|
630
681
|
|
|
@@ -667,8 +718,11 @@ verify:
|
|
|
667
718
|
precheck_total_seconds: 600 # optional total budget of one pre-PR check round (default 600)
|
|
668
719
|
check_run: true # optional; post the forgedock/verify check run (default false)
|
|
669
720
|
checks: "lint,test" # optional; the check names that gate CI verification (default: the required checks)
|
|
721
|
+
flaky: "memory-erasure,e2e-pdf" # optional; known-flaky CI job names: reported, never block or feed the builder
|
|
670
722
|
post_merge: pnpm -r typecheck # optional; run on the merged tree at base-sync before the push (default: the lane's scoped typecheck)
|
|
671
723
|
duplicate_exports: false # optional; turn off the same-app duplicate-export check (default true)
|
|
724
|
+
invariants: # optional block list; repo-wide check commands that always run before the PR
|
|
725
|
+
- pnpm check:registry
|
|
672
726
|
e2e: auto # optional e2e merge gate: auto (default) | always | never
|
|
673
727
|
e2e_label: full-ci # PR label that makes the repo's CI run e2e (label path)
|
|
674
728
|
e2e_checks: "e2e" # check names of the e2e jobs the label path waits on
|
|
@@ -685,20 +739,24 @@ verify:
|
|
|
685
739
|
| `paths_input` | string | No | Name of a `workflow_dispatch` input that receives the changed paths, so the workflow can scope its jobs to the change. |
|
|
686
740
|
| `skip_paths` | string | No | Comma-separated globs. A glob without `/` matches the basename anywhere; `**` spans directories. When every changed file matches, the lane skips CI verification (a docs-only lane runs no CI matrix). |
|
|
687
741
|
| `max_rounds` | integer | No | Builder fix rounds for red CI (0–10, default 2). Also bounds the merge-time fix rounds and the declared-check fix rounds below, in every mode. |
|
|
688
|
-
| `precheck_timeout_seconds` | integer | No | Timeout of each declared check command (1–3600, default 300); a
|
|
742
|
+
| `precheck_timeout_seconds` | integer | No | Timeout of each declared check command (1–3600, default 300); a command that hits its own timeout is retried once with a load-scaled timeout (#4589) when the total budget leaves room, else it is deferred to CI. |
|
|
689
743
|
| `precheck_total_seconds` | integer | No | Total budget of one round of pre-PR check commands (1–7200, default 600; it was 1800 before 1.30.0). `lean.precheck.max_minutes` takes precedence. Commands not started within the budget are deferred to CI, not failed. |
|
|
690
744
|
| `checks` | string | No | Comma-separated check names that gate CI verification without `workflow` (forge#4387). Overrides the required-check set; other checks are reported, not gating. |
|
|
745
|
+
| `invariants` | list | No | Block list of cheap repo-wide check commands (#4585), e.g. the suites that list every table, export, key or namespace (cross-tenant registry, export and purge coverage, i18n status, boundary tests). They run in the pre-PR check round in every verify mode, whichever tests the test map selected: never scoped away by `.forge/test-map.json`, deduplicated against the learned commands, and subject to the same round budget and per-command timeout. A failure goes to the builder in the precheck fix round like a failed declared check. The plan and implement prompts also carry an invariants checklist built from the list (a new table, export or key must join every registry that enumerates its kind). `forgedock check` and the light lane run them too. Absent: nothing changes. |
|
|
691
746
|
| `check_run` | boolean | No | Default `false` (no Checks API call, behaviour unchanged). `true`: after each push of a reviewed head (PR open, base-sync, merge-time fix round) the engine posts one completed `forgedock/verify` check run on that head (forge#4270), `success` iff every deterministic gate the lane already ran passed: declared check commands, undeclared package edges, docs freshness, contract scope, validate ids, requirement status, hotspot growth, amendments. The output carries each gate's id, pass/fail and evidence as JSON; the run log records a `VERIFY` event. Report only: ForgeDock never makes it required and never waits on it (it is ignored by its own CI waits); requiring it is the repository's branch-protection choice. Creating check runs needs a token that may (a GitHub App with `checks: write`); a Checks API failure is logged as an anomaly and never fails the lane. |
|
|
692
747
|
| `post_merge` | string | No | Command run in the lane worktree on the merged tree after each base-sync merge, before the push (forge#4406). Default: the typecheck rows of the lane's change-scoped checks (`bin/lean/checks.mjs`), in `local` and `hybrid` mode only; `ci` mode runs no host typecheck unless `post_merge` is set. A failure resumes the builder with the output, up to `max_rounds` rounds, and the fixed head is re-reviewed; still failing is a trajectory anomaly and CI stays the gate. |
|
|
693
748
|
| `duplicate_exports` | boolean | No | Default `true`: at hand-off and after each base-sync merge, names the lane newly exports that another non-test module of the same app or package (nearest `package.json`/`pyproject.toml`/`setup.py`/`go.mod`/`Cargo.toml`) already declares go back to the builder once ("rename or reuse", both paths); one left over is an anomaly (forge#4406). Re-exports, default exports and names 3+ other modules declare (framework conventions) are ignored. `false` turns the check off. |
|
|
694
749
|
| `e2e` | string | No | e2e merge gate (forge#4403). `auto` (default): run e2e when the diff touches `e2e_paths` or a contract requirement quote has `[type:e2e]`. `always`: on every lane except docs-only ones. `never`: today's behaviour. Without `e2e_label` or `e2e_workflow` no e2e can run, so every mode keeps today's behaviour (a lane that changed specs records a decision saying so). |
|
|
695
750
|
| `e2e_label` | string | No | Label added to the PR (`gh pr edit --add-label`) so tiered CI runs its e2e jobs. The label must already exist in the repo. |
|
|
751
|
+
| `flaky` | list | No | Comma-separated string, flow list (`[a, b]`) or block list of CI job names (#4586). A failed CI job whose name equals an id is reported (never matched by log text: a log naming a flaky test can also hold a real failure) (anomaly, `CI_FLAKY_REPORTED`) and never blocks the lane or reaches the builder; the engine's CI verification and e2e gate honour it. A check that is required by branch protection still blocks the merge. Independently of this list, the failed jobs of a red CI verification or required check are rerun once per head before a fix round (`max_rounds`) is counted; only a failure that repeats counts. |
|
|
696
752
|
| `e2e_checks` | string | No | Comma-separated check-run names of the e2e jobs the label path waits on. Default: every check on the head except `forgedock/verify`. A check run concluded `skipped` (the unlabelled tier) counts as not run. |
|
|
697
753
|
| `e2e_workflow` | string | No | Workflow file name or id dispatched on the lane branch instead of the label (`workflow_dispatch`); the engine waits for its run of the reviewed head. Wins over `e2e_label`. |
|
|
698
754
|
| `e2e_paths_input` | string | No | `workflow_dispatch` input of `e2e_workflow` that receives the changed spec paths (newline-separated). |
|
|
699
755
|
| `e2e_paths` | string | No | Comma-separated globs of e2e specs (same glob rules as `skip_paths`). Default `e2e/**,**/e2e/**,*.e2e.*,*.e2e-spec.*,cypress/**,playwright/**`. |
|
|
700
756
|
| `e2e_blocking` | string | No | `changed` (default): only failures of the lane's own specs block the merge; other e2e failures are reported as an anomaly. `all`: every e2e failure blocks. |
|
|
701
757
|
|
|
758
|
+
**English-copy check (forge#4596).** At hand-off and after each base-sync merge, keys the lane added to a non-`en` JSON locale catalog (`messages/ta.json`, `locales/ta/common.json`) whose value equals the same key in the `en` counterpart go back to the builder once to translate (placeholders kept, status entries inserted in place, never re-sorted); a copy left over is an anomaly. Always on, deterministic, no cost without catalogs. The repo's own `i18n:check` is not run by the engine: list it in `lean.precheck.commands` to run it before the PR.
|
|
759
|
+
|
|
702
760
|
**Flow** (`ci`/`hybrid`, `bin/lean/verify.mjs` `runCiVerify`):
|
|
703
761
|
|
|
704
762
|
1. After the build and before every conformance review, the engine pushes the lane branch (a plain push, never forced) and runs CI on the head. A base-sync that moves the head is verified again.
|
|
@@ -1428,6 +1486,7 @@ These variables are read from the operator's runner environment, not from `forge
|
|
|
1428
1486
|
| `FORGE_TRAIL_QG_SINCE` | `2026-10-07T03:40:12Z` | ISO-8601 UTC cutoff for the legacy `FORGE:QUALITY_GATE` grace. An issue whose trusted `FORGE:BUILDER:COMPLETE` comment was last updated before this time has `QUALITY_GATE` waived. Set to an empty string to disable the grace. A malformed or future value (including a runner clock earlier than this default) fails closed: the verifier exits 2, an infrastructure block rather than `MISSING`. |
|
|
1429
1487
|
| `FORGEDOCK_ALLOW_API_BACKEND` | unset | Set to `1` (host owner approval) together with forge.yaml `execution.agent_backend: api` to let `--backend auto` use the paid API backend when no `claude` CLI is available. Without both, `auto` never selects `api` (#4257). |
|
|
1430
1488
|
| `FORGE_TELEMETRY` | `on` | Set to `off` to disable gate telemetry. Gate scripts append one structured record per decision to a trusted `FORGE:TELEMETRY v1` comment; emission is best-effort and never changes a gate's output or exit code. See [TELEMETRY.md](TELEMETRY.md). |
|
|
1489
|
+
| `FORGE_EVENTS` | `on` | Set to `off` to disable the lifecycle event stream (local JSONL and webhook). `FORGE_EVENTS_DIR` overrides the JSONL directory; the webhook secret is read from the env var named by `events.webhook_secret_env` (default `FORGE_EVENTS_SECRET`). See [EVENTS.md](EVENTS.md). |
|
|
1431
1490
|
|
|
1432
1491
|
### Install root resolution
|
|
1433
1492
|
|