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/engine-cli.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import { readLog, readLogFile, deriveState, runLogPath, migrateRunLogs } from ".
|
|
|
16
16
|
import { assertOpenCodeNativeRuntime } from "./runner.mjs";
|
|
17
17
|
import { parseForgeYaml } from "./forge-utils.mjs";
|
|
18
18
|
import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
|
|
19
|
+
import { makeEventEmitter, probeEvent, regressionEvent, experimentEvent } from "./engine/events.mjs";
|
|
20
|
+
import { readExperiment } from "./experiment.mjs";
|
|
19
21
|
import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
|
|
20
22
|
import { parseVerify } from "./lean/verify.mjs";
|
|
21
23
|
import { parseExecution, detectNeeds } from "./lean/execution.mjs";
|
|
@@ -123,6 +125,31 @@ export function readLaneInstructions(cwd = process.cwd()) {
|
|
|
123
125
|
}
|
|
124
126
|
const warnedContextPack = new Set();
|
|
125
127
|
const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
|
|
128
|
+
/** Run each emitter in order; one failing never blocks the next (#4630). */
|
|
129
|
+
function composeEmit(...fns) { return async (rec) => { for (const f of fns) { try { await f(rec); } catch { /* telemetry and events never fail a run */ } } }; }
|
|
130
|
+
/**
|
|
131
|
+
* forge.yaml `events:` (#4630): `webhook_url`, `webhook_secret_env` (the NAME of the env var holding the HMAC secret; the secret itself is
|
|
132
|
+
* never read from forge.yaml) and `dir` (local JSONL directory). Returns {webhookUrl, secretEnv, dir}; {} when none is set.
|
|
133
|
+
*/
|
|
134
|
+
export function readEvents(cwd = process.cwd()) {
|
|
135
|
+
try {
|
|
136
|
+
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.events ?? {};
|
|
137
|
+
const v = (k) => String(e[k] ?? "").trim();
|
|
138
|
+
return { ...(v("webhook_url") ? { webhookUrl: v("webhook_url") } : {}), ...(/^[A-Za-z_][A-Za-z0-9_]*$/.test(v("webhook_secret_env")) ? { secretEnv: v("webhook_secret_env") } : {}), ...(v("dir") ? { dir: v("dir") } : {}) };
|
|
139
|
+
} catch { return {}; }
|
|
140
|
+
}
|
|
141
|
+
/** The probe sweep's `onResult`: one probe.passed / probe.failed event per recorded pass or fail row. Never throws. */
|
|
142
|
+
function probeEventSink(repo) {
|
|
143
|
+
const events = makeEventEmitter({ repo, config: readEvents() });
|
|
144
|
+
return async (row) => { for (const p of [probeEvent(row), regressionEvent(row)]) if (p) await events.emitEvent(p.type, p.data, p.ctx); };
|
|
145
|
+
}
|
|
146
|
+
/** `forgedock stats --experiment <id> --emit-event` (#4632): one experiment.verdict event for a met or missed report; inconclusive emits nothing. Never throws. */
|
|
147
|
+
export async function experimentEventSink(repo, report) {
|
|
148
|
+
try {
|
|
149
|
+
const p = experimentEvent(report);
|
|
150
|
+
if (p && p.data.verdict !== "inconclusive") await makeEventEmitter({ repo, config: readEvents() }).emitEvent(p.type, p.data, p.ctx);
|
|
151
|
+
} catch { /* events never fail stats */ }
|
|
152
|
+
}
|
|
126
153
|
/**
|
|
127
154
|
* forge.yaml context pack (#4275), flat keys under `engine` (the parser flattens one level): `context_pack`
|
|
128
155
|
* (shadow|on|off|ab, #4444), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
|
|
@@ -214,6 +241,19 @@ export function readBuilderRouting(cwd = process.cwd()) {
|
|
|
214
241
|
const midModel = /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : readLeanSetting("builder_mid_model", cwd);
|
|
215
242
|
return mode || midModel ? { ...(mode ? { mode } : {}), ...(midModel ? { midModel } : {}) } : undefined;
|
|
216
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* forge.yaml `lean.base_merge_*` (#4588): deterministic base-merge resolution of sibling-lane conflicts. Comma lists of globs
|
|
246
|
+
* `union_globs` (append-only), `json_globs` (JSON maps, key union), `generated_globs` + `generated_command` (base side taken,
|
|
247
|
+
* then regenerated) (flat keys only). undefined when nothing is configured.
|
|
248
|
+
*/
|
|
249
|
+
export function readBaseMerge(cwd = process.cwd()) {
|
|
250
|
+
let l = {};
|
|
251
|
+
try { l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}; } catch { return undefined; }
|
|
252
|
+
const list = (v) => String(v ?? "").replace(/^\[|\]$/g, "").split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
253
|
+
const cfg = { unionGlobs: list(l.base_merge_union_globs), jsonGlobs: list(l.base_merge_json_globs), generatedGlobs: list(l.base_merge_generated_globs), generatedCommand: String(l.base_merge_generated_command ?? "").trim() };
|
|
254
|
+
return cfg.unionGlobs.length || cfg.jsonGlobs.length || cfg.generatedGlobs.length ? cfg : undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
217
257
|
/**
|
|
218
258
|
* forge.yaml `lean.sequence.regenerate` (#4386): the command that regenerates a schema-changing migration's snapshot
|
|
219
259
|
* after a deterministic renumber. The one-level parser flattens the nested block to `lean.regenerate`; flat
|
|
@@ -323,7 +363,9 @@ export function readMergeWait(cwd = process.cwd()) {
|
|
|
323
363
|
export function readVerify(cwd = process.cwd()) {
|
|
324
364
|
try {
|
|
325
365
|
const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.verify;
|
|
326
|
-
|
|
366
|
+
if (!sec || typeof sec !== "object") return undefined;
|
|
367
|
+
const invariants = readInvariants(cwd), flaky = readYamlList(cwd, "verify", "flaky");
|
|
368
|
+
return parseVerify({ ...sec, ...(invariants ? { invariants } : {}), ...(flaky ? { flaky } : {}) });
|
|
327
369
|
} catch { return undefined; }
|
|
328
370
|
}
|
|
329
371
|
/** #4542: forge.yaml `execution.host_capabilities` (+ patterns) for a lane's remote-block mapping; undefined when none are declared. */
|
|
@@ -350,20 +392,28 @@ export function readExecution(cwd = process.cwd()) {
|
|
|
350
392
|
* scalars only. undefined when absent, empty or unreadable.
|
|
351
393
|
*/
|
|
352
394
|
export function readPrecheckCommands(cwd = process.cwd()) {
|
|
395
|
+
return readYamlList(cwd, "learned", "test_commands");
|
|
396
|
+
}
|
|
397
|
+
/** #4585: forge.yaml `verify.invariants`, a block list of repo-wide check commands; undefined when absent or empty. */
|
|
398
|
+
export function readInvariants(cwd = process.cwd()) {
|
|
399
|
+
return readYamlList(cwd, "verify", "invariants");
|
|
400
|
+
}
|
|
401
|
+
function readYamlList(cwd, section, key) {
|
|
353
402
|
let raw;
|
|
354
403
|
try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
|
|
355
404
|
const out = [];
|
|
356
|
-
|
|
405
|
+
const head = new RegExp(`^${section}:\\s*(#.*)?$`), keyRe = new RegExp(`^\\s+${key}:\\s*(#.*)?$`);
|
|
406
|
+
let inSection = false, listIndent = null;
|
|
357
407
|
for (const line of raw.split(/\r?\n/)) {
|
|
358
408
|
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
359
|
-
if (/^\S/.test(line)) {
|
|
360
|
-
if (!
|
|
409
|
+
if (/^\S/.test(line)) { inSection = head.test(line); listIndent = null; continue; }
|
|
410
|
+
if (!inSection) continue;
|
|
361
411
|
const indent = line.length - line.trimStart().length;
|
|
362
412
|
if (listIndent === null) {
|
|
363
|
-
if (
|
|
413
|
+
if (keyRe.test(line)) listIndent = indent;
|
|
364
414
|
continue;
|
|
365
415
|
}
|
|
366
|
-
if (indent <= listIndent) { listIndent =
|
|
416
|
+
if (indent <= listIndent) { listIndent = keyRe.test(line) ? indent : null; continue; }
|
|
367
417
|
const m = /^\s+-\s+(.*)$/.exec(line);
|
|
368
418
|
if (!m) continue;
|
|
369
419
|
let v = m[1].trim();
|
|
@@ -853,8 +903,8 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
853
903
|
try {
|
|
854
904
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
855
905
|
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
856
|
-
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
857
|
-
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() } : {}), 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() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
|
|
906
|
+
emitTelemetry: deps.emitTelemetry ?? composeEmit(makeEmitTelemetry({ repo: leanRepo }), makeEventEmitter({ repo: leanRepo, config: readEvents() }).emit),
|
|
907
|
+
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() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}) });
|
|
858
908
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
859
909
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
860
910
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -1052,6 +1102,7 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
1052
1102
|
* daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
|
|
1053
1103
|
* `--add <id> N...` (#4408) adds issues to that batch instead of starting a second one: they run under its K and DAG
|
|
1054
1104
|
* (a running batch picks them up on its next pass, a stopped one on its next `--resume`); `--resume <id> N...` adds N too.
|
|
1105
|
+
* `--requeue <id> N... [--reason]` (#4550) returns a terminal issue (and the dependents skipped behind it) to pending after an operator decision.
|
|
1055
1106
|
* `--cancel <id>` (#4376) stops that one batch, never the shared daemon: it admits nothing more, its running local lanes get
|
|
1056
1107
|
* SIGTERM (their teardown saves the work), remote lanes run to their result and the rest end `held`; `--resume <id>` continues it.
|
|
1057
1108
|
*/
|
|
@@ -1065,8 +1116,8 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
1065
1116
|
const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
|
|
1066
1117
|
const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
|
|
1067
1118
|
|
|
1068
|
-
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --add <id> <issue...> | --cancel <id>";
|
|
1069
|
-
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
|
|
1119
|
+
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...>";
|
|
1120
|
+
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason"];
|
|
1070
1121
|
const issuesOf = (argv) => {
|
|
1071
1122
|
const issues = [];
|
|
1072
1123
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -1123,20 +1174,78 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
|
|
|
1123
1174
|
return out;
|
|
1124
1175
|
}
|
|
1125
1176
|
|
|
1177
|
+
/** #4592: a resume's release line, only when it freed something. */
|
|
1178
|
+
const onlyReleased = (l, out = (t) => console.log(t)) => { if (!l.endsWith("(none)")) out(l); };
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* `--hold <id> N...` / `--release <id> N...` (#4592): the batch's `hold` file, through the daemon (`viaDaemon(op, issues)`, null
|
|
1182
|
+
* when none runs) or directly. A held issue is not admitted (a running lane is not stopped); `--release` (or a `--resume` that
|
|
1183
|
+
* names it) lets it run again. @returns {{ok, batch, changed:number[]}}
|
|
1184
|
+
*/
|
|
1185
|
+
async function holdBatch({ op, id, tokens, root, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1186
|
+
const issues = tokens.filter((t) => /^#?\d+$/.test(String(t))).map((t) => parseInt(String(t).replace("#", ""), 10));
|
|
1187
|
+
if (!id || String(id).startsWith("--") || !issues.length) throw new Error(ORCH_USAGE);
|
|
1188
|
+
let res = viaDaemon ? await viaDaemon(op, issues) : null;
|
|
1189
|
+
if (!res) {
|
|
1190
|
+
const b = (await import("./lean/batch.mjs")).openBatch(root, id);
|
|
1191
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1192
|
+
res = { changed: op === "hold" ? b.holdIssues(issues) : b.releaseIssues(issues) };
|
|
1193
|
+
}
|
|
1194
|
+
const list = (res.changed ?? []).map((n) => `#${n}`).join(" ") || "(none)";
|
|
1195
|
+
print(op === "hold" ? `batch ${id}: held ${list}; forgedock orchestrate --release ${id} <issue...> (or --resume ${id} <issue...>) re-admits them`
|
|
1196
|
+
: `batch ${id}: released ${list}`);
|
|
1197
|
+
return { ok: true, batch: id, changed: res.changed ?? [] };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* `--requeue <id> N... [--reason "..."]` (#4550): after an operator decision, a terminal issue of the batch (and the dependents
|
|
1202
|
+
* skipped behind it) goes back to pending. Closed issues and issues still labelled `needs-human` are refused before anything is
|
|
1203
|
+
* written. Through the daemon (`viaDaemon`, null when none runs: the batch's `requeue` file, and a finished batch's report is
|
|
1204
|
+
* cleared so `--resume` runs it again). The runner applies the entries and posts the FORGE:DECISION.
|
|
1205
|
+
*/
|
|
1206
|
+
async function requeueBatch({ id, tokens, reason, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1207
|
+
if (!id || String(id).startsWith("--") || !tokens.length) throw new Error(ORCH_USAGE);
|
|
1208
|
+
const batchMod = await import("./lean/batch.mjs");
|
|
1209
|
+
const b = batchMod.openBatch(root, id);
|
|
1210
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1211
|
+
const repo = b.readPlan()?.repo ?? null;
|
|
1212
|
+
const refused = [], open = [];
|
|
1213
|
+
for (const t of tokens) {
|
|
1214
|
+
if (!/^#?\d+$/.test(String(t))) { refused.push({ issue: t, reason: "not an integer" }); continue; }
|
|
1215
|
+
const n = parseInt(String(t).replace("#", ""), 10);
|
|
1216
|
+
if (open.includes(n) || refused.some((x) => x.issue === n)) continue;
|
|
1217
|
+
let v = null;
|
|
1218
|
+
try { v = JSON.parse(await io.gh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", "state,labels"])); } catch { v = null; }
|
|
1219
|
+
if (!v) refused.push({ issue: n, reason: "unreadable" });
|
|
1220
|
+
else if (v.state !== "OPEN") refused.push({ issue: n, reason: "closed" });
|
|
1221
|
+
else if ((v.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) refused.push({ issue: n, reason: "still labelled needs-human: record the decision and remove the label first" });
|
|
1222
|
+
else open.push(n);
|
|
1223
|
+
}
|
|
1224
|
+
const why = reason || "operator decision", by = "operator";
|
|
1225
|
+
let res = { requeued: [], refused: [] };
|
|
1226
|
+
if (open.length) {
|
|
1227
|
+
res = (viaDaemon ? await viaDaemon(open, why, by) : null) ?? b.requeue(open, { reason: why, by });
|
|
1228
|
+
if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // no daemon: the next --resume reruns it
|
|
1229
|
+
}
|
|
1230
|
+
const out = { ok: true, batch: id, requeued: res.requeued ?? [], refused: [...(res.refused ?? []), ...refused] };
|
|
1231
|
+
print(`batch ${id}: ${out.requeued.map((n) => `requeued #${n}`).join(" ") || "requeued (none)"}${out.refused.length ? `; refused ${out.refused.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
|
|
1232
|
+
return out;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1126
1235
|
/**
|
|
1127
1236
|
* `--cancel <id>` (#4376): through the daemon (`viaDaemon`, null when none runs) or the batch's `cancel` file directly.
|
|
1128
1237
|
* A finished batch (report written) is left alone.
|
|
1129
1238
|
*/
|
|
1130
|
-
async function cancelBatch({ id, root, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1239
|
+
async function cancelBatch({ id, root, stopRemote = false, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1131
1240
|
if (!id || String(id).startsWith("--")) throw new Error(ORCH_USAGE);
|
|
1132
1241
|
let res = viaDaemon ? await viaDaemon() : null;
|
|
1133
1242
|
if (!res) {
|
|
1134
1243
|
const b = (await import("./lean/batch.mjs")).openBatch(root, id);
|
|
1135
1244
|
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1136
1245
|
if (b.readReport()) res = { cancelled: false, finished: true };
|
|
1137
|
-
else { b.requestCancel({ reason: "operator" }); res = { cancelled: true }; }
|
|
1246
|
+
else { b.requestCancel({ reason: "operator", stopRemote }); res = { cancelled: true }; }
|
|
1138
1247
|
}
|
|
1139
|
-
print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown, the rest stay held (forgedock orchestrate --resume ${id} continues)`
|
|
1248
|
+
print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown${stopRemote ? " (remote sandboxes too, #4612)" : ""}, the rest stay held (forgedock orchestrate --resume ${id} continues)`
|
|
1140
1249
|
: `batch ${id}: already finished`);
|
|
1141
1250
|
return { ok: true, batch: id, cancelled: !!res.cancelled };
|
|
1142
1251
|
}
|
|
@@ -1172,15 +1281,33 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1172
1281
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1173
1282
|
return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
|
|
1174
1283
|
};
|
|
1284
|
+
const requeueVia = (id) => async (issues, reason, by) => {
|
|
1285
|
+
let sock = null;
|
|
1286
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1287
|
+
return sock ? schedd.request(sock, { op: "requeue", id, issues, reason, by }) : null;
|
|
1288
|
+
};
|
|
1289
|
+
const holdVia = (id) => async (op, issues) => {
|
|
1290
|
+
let sock = null;
|
|
1291
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1292
|
+
return sock ? schedd.request(sock, { op, id, issues }) : null;
|
|
1293
|
+
};
|
|
1175
1294
|
const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
|
|
1176
1295
|
if (argv.includes("--cancel")) {
|
|
1177
1296
|
const id = flag(argv, "--cancel");
|
|
1178
|
-
return cancelBatch({ id, root: await batchRootOf(), viaDaemon: async () => {
|
|
1297
|
+
return cancelBatch({ id, root: await batchRootOf(), stopRemote: argv.includes("--stop-remote"), viaDaemon: async () => {
|
|
1179
1298
|
let sock = null;
|
|
1180
1299
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1181
|
-
return sock ? schedd.request(sock, { op: "cancel", id }) : null;
|
|
1300
|
+
return sock ? schedd.request(sock, { op: "cancel", id, stopRemote: argv.includes("--stop-remote") }) : null;
|
|
1182
1301
|
} });
|
|
1183
1302
|
}
|
|
1303
|
+
if (argv.includes("--requeue")) {
|
|
1304
|
+
const i = argv.indexOf("--requeue"), id = argv[i + 1];
|
|
1305
|
+
return requeueBatch({ id, tokens: operandsOf(argv.slice(i + 2)), reason: flag(argv, "--reason"), root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: requeueVia(id) });
|
|
1306
|
+
}
|
|
1307
|
+
for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
|
|
1308
|
+
const id = flag(argv, `--${op}`);
|
|
1309
|
+
return holdBatch({ op, id, tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root: await batchRootOf(), viaDaemon: holdVia(id) });
|
|
1310
|
+
}
|
|
1184
1311
|
if (argv.includes("--add")) {
|
|
1185
1312
|
const i = argv.indexOf("--add"), id = argv[i + 1];
|
|
1186
1313
|
if (!id || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
|
|
@@ -1190,9 +1317,10 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1190
1317
|
const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
|
|
1191
1318
|
if (resumeExtra.length) {
|
|
1192
1319
|
await addToBatch({ id: resumeId, tokens: resumeExtra, root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: addVia(resumeId) });
|
|
1320
|
+
await holdBatch({ op: "release", id: resumeId, tokens: resumeExtra, root: await batchRootOf(), viaDaemon: holdVia(resumeId), print: (l) => onlyReleased(l) }); // #4592: a named, held issue is re-admitted
|
|
1193
1321
|
}
|
|
1194
1322
|
let msg;
|
|
1195
|
-
if (resumeId) msg = { op: "attach", id: resumeId };
|
|
1323
|
+
if (resumeId) msg = { op: "attach", id: resumeId, resume: true };
|
|
1196
1324
|
else {
|
|
1197
1325
|
if (!issuesOf(argv).length) throw new Error(ORCH_USAGE);
|
|
1198
1326
|
parallelOf(argv);
|
|
@@ -1254,7 +1382,14 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1254
1382
|
sink.line(`batch ${b.id}: parallel set to ${k}`);
|
|
1255
1383
|
return { ok: true, batch: b.id, parallel: k };
|
|
1256
1384
|
}
|
|
1257
|
-
if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, print: (l) => sink.line(l) });
|
|
1385
|
+
if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, stopRemote: argv.includes("--stop-remote"), print: (l) => sink.line(l) });
|
|
1386
|
+
if (argv.includes("--requeue")) {
|
|
1387
|
+
const i = argv.indexOf("--requeue");
|
|
1388
|
+
return requeueBatch({ id: argv[i + 1], tokens: operandsOf(argv.slice(i + 2)), reason: flag(argv, "--reason"), root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
|
|
1389
|
+
}
|
|
1390
|
+
for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
|
|
1391
|
+
return holdBatch({ op, id: flag(argv, `--${op}`), tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root, print: (l) => sink.line(l) });
|
|
1392
|
+
}
|
|
1258
1393
|
if (argv.includes("--add")) {
|
|
1259
1394
|
const i = argv.indexOf("--add");
|
|
1260
1395
|
if (!argv[i + 1] || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
|
|
@@ -1264,6 +1399,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1264
1399
|
const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
|
|
1265
1400
|
if (resumeExtra.length) {
|
|
1266
1401
|
await addToBatch({ id: resumeId, tokens: resumeExtra, root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
|
|
1402
|
+
await holdBatch({ op: "release", id: resumeId, tokens: resumeExtra, root, print: (l) => onlyReleased(l, (t) => sink.line(t)) }); // #4592
|
|
1267
1403
|
}
|
|
1268
1404
|
let batch, plan = null;
|
|
1269
1405
|
if (resumeId) {
|
|
@@ -1316,7 +1452,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1316
1452
|
// #4399: declared acceptance probes of closed issues whose fix reached the default branch; never fails the batch
|
|
1317
1453
|
const sweep = deps.sweepProbes !== undefined ? deps.sweepProbes : (await import("./lean/probe.mjs")).sweepProbes;
|
|
1318
1454
|
if (sweep && !argv.includes("--no-probes")) {
|
|
1319
|
-
try { const s = (await import("./lean/probe.mjs")).formatProbeSummary(await sweep({ repo, cwd, io: leanIo })); if (s) sink.line(s); } catch (e) { sink.line(`probes: sweep failed (${String(e?.message ?? e).slice(0, 160)})`); }
|
|
1455
|
+
try { const s = (await import("./lean/probe.mjs")).formatProbeSummary(await sweep({ repo, cwd, io: leanIo, onResult: probeEventSink(repo) })); if (s) sink.line(s); } catch (e) { sink.line(`probes: sweep failed (${String(e?.message ?? e).slice(0, 160)})`); }
|
|
1320
1456
|
}
|
|
1321
1457
|
const { makePrefilter, formatPrefilterSummary, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
|
|
1322
1458
|
const branches = readBranches(cwd);
|
|
@@ -1340,7 +1476,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1340
1476
|
const { runCore } = deps.leanModule;
|
|
1341
1477
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1342
1478
|
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,
|
|
1343
|
-
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) } : {}), 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) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
|
|
1479
|
+
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) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}) }) };
|
|
1344
1480
|
} else {
|
|
1345
1481
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1346
1482
|
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
@@ -1357,6 +1493,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1357
1493
|
const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
|
|
1358
1494
|
execForNeeds = exec ?? null;
|
|
1359
1495
|
hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
|
|
1496
|
+
let remoteStop = null; // #4612: the sandbox provider's checkpoint stop, used only when the cancel asks for it
|
|
1360
1497
|
if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
|
|
1361
1498
|
// #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
|
|
1362
1499
|
let gitDir = null;
|
|
@@ -1367,6 +1504,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1367
1504
|
const bud = exec.budget ?? {};
|
|
1368
1505
|
const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
|
|
1369
1506
|
...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
|
|
1507
|
+
remoteStop = remoteLanes.stopLane;
|
|
1370
1508
|
lanes = withPlacement(remoteLanes);
|
|
1371
1509
|
{
|
|
1372
1510
|
// #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
|
|
@@ -1412,9 +1550,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1412
1550
|
} else {
|
|
1413
1551
|
lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
|
|
1414
1552
|
}
|
|
1415
|
-
// #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on.
|
|
1553
|
+
// #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on unless `--cancel --stop-remote` (#4612).
|
|
1416
1554
|
const { killLedgerPid } = await import("./lean/effects.mjs");
|
|
1417
|
-
const stopLane = deps.stopLane ?? ((n, rec) => (rec?.
|
|
1555
|
+
const stopLane = deps.stopLane ?? ((n, rec) => (rec?.remote || rec?.handle ? remoteStop?.(n, rec) ?? false : rec?.pid ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
|
|
1418
1556
|
lanes = { ...lanes, stopLane };
|
|
1419
1557
|
}
|
|
1420
1558
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
@@ -1520,7 +1658,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1520
1658
|
rows = await orchestrate({
|
|
1521
1659
|
...(estimate ? { estimate } : {}),
|
|
1522
1660
|
beforeAdmit,
|
|
1523
|
-
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, ...lanes,
|
|
1661
|
+
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, ...lanes,
|
|
1524
1662
|
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1525
1663
|
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1526
1664
|
...(remote ? { remote } : {}),
|
|
@@ -1668,6 +1806,15 @@ export function formatReclaimed(r) {
|
|
|
1668
1806
|
return `reclaimed: ${r.items} item(s), ${size} (preflight ${r.preflight}, admissions ${r.admissions})`;
|
|
1669
1807
|
}
|
|
1670
1808
|
|
|
1809
|
+
/** #4627: the daemon's timer callback: re-run the due windowed post-deploy checks of the cwd repo. No repo, no work. */
|
|
1810
|
+
async function scheddDue() {
|
|
1811
|
+
const io = makeIo();
|
|
1812
|
+
const repo = await resolveDefaultRepo(io);
|
|
1813
|
+
if (!repo) return;
|
|
1814
|
+
const { sweepDue } = await import("./lean/postdeploy-monitor.mjs");
|
|
1815
|
+
await sweepDue({ repo, cwd: process.cwd(), io: (await import("./lean/effects.mjs")).makeLeanIo({ repo }), onResult: probeEventSink(repo) });
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1671
1818
|
/**
|
|
1672
1819
|
* `forgedock schedd` (foreground; for a systemd-user unit: `ExecStart=forgedock schedd`) · `forgedock schedd status` (#4192)
|
|
1673
1820
|
*/
|
|
@@ -1682,7 +1829,7 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
1682
1829
|
return { running: true, ...st };
|
|
1683
1830
|
}
|
|
1684
1831
|
if (argv.length) throw new Error("usage: forgedock schedd [status]");
|
|
1685
|
-
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }) });
|
|
1832
|
+
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }) });
|
|
1686
1833
|
if (!d.running) return d;
|
|
1687
1834
|
// #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
|
|
1688
1835
|
schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
|
|
@@ -1757,7 +1904,7 @@ export async function checkFromCli(argv, deps = {}) {
|
|
|
1757
1904
|
const plan = await readJson(pre.CHECK_PLAN_FILE);
|
|
1758
1905
|
const base = o.base ?? plan?.base;
|
|
1759
1906
|
if (!base) throw Object.assign(new Error(`check: no check plan from the engine; pass --base REF\n${usage}`), { exitCode: 2 });
|
|
1760
|
-
const verify = plan ? { mode: plan.mode, precheckTotalSeconds: plan.precheckTotalSeconds, precheckTimeoutSeconds: plan.precheckTimeoutSeconds } : { ...parseVerify({}), ...(readVerify(repo) ?? {}) };
|
|
1907
|
+
const verify = plan ? { mode: plan.mode, precheckTotalSeconds: plan.precheckTotalSeconds, precheckTimeoutSeconds: plan.precheckTimeoutSeconds, invariants: Array.isArray(plan.invariants) ? plan.invariants : [] } : { ...parseVerify({}), ...(readVerify(repo) ?? {}) };
|
|
1761
1908
|
const learned = plan ? plan.learned : readPrecheckCommands(repo) ?? [];
|
|
1762
1909
|
const maxMinutes = plan ? plan.maxMinutes : readPrecheckBudget(repo);
|
|
1763
1910
|
// the engine's changed files (lean.mjs changedFiles: base...HEAD still differing from base), plus uncommitted work
|
|
@@ -1977,13 +2124,14 @@ export async function triageFromCli(argv, deps = {}) {
|
|
|
1977
2124
|
}
|
|
1978
2125
|
|
|
1979
2126
|
/**
|
|
1980
|
-
* `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N]` (#4399)
|
|
2127
|
+
* `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N] [--due]` (#4399; `--due` #4627: re-run due windowed post-deploy checks)
|
|
1981
2128
|
* Runs the declared acceptance probes (`lean/probe.mjs`) of the given issues, or of the recently closed issues that
|
|
1982
2129
|
* declare one, once their fix is on the default branch; records FORGE:PROBE and reopens on a fail. No model call.
|
|
1983
2130
|
*/
|
|
1984
2131
|
export async function probeFromCli(argv, deps = {}) {
|
|
1985
2132
|
const valued = ["--repo", "--limit"];
|
|
1986
2133
|
const issues = [];
|
|
2134
|
+
const due = argv.includes("--due"); // #4627
|
|
1987
2135
|
for (let i = 0; i < argv.length; i++) {
|
|
1988
2136
|
if (valued.includes(argv[i])) { i++; continue; }
|
|
1989
2137
|
if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
|
|
@@ -1994,8 +2142,15 @@ export async function probeFromCli(argv, deps = {}) {
|
|
|
1994
2142
|
const limit = flag(argv, "--limit") == null ? 50 : parseInt(flag(argv, "--limit"), 10);
|
|
1995
2143
|
if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer");
|
|
1996
2144
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
2145
|
+
if (due) {
|
|
2146
|
+
const { sweepDue, formatDueSummary } = await import("./lean/postdeploy-monitor.mjs");
|
|
2147
|
+
const rows = await sweepDue({ repo, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), onResult: probeEventSink(repo), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
|
|
2148
|
+
for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
|
|
2149
|
+
console.log(formatDueSummary(rows) || "monitoring: nothing due");
|
|
2150
|
+
return { rows };
|
|
2151
|
+
}
|
|
1997
2152
|
const { sweepProbes, formatProbeSummary } = await import("./lean/probe.mjs");
|
|
1998
|
-
const rows = await sweepProbes({ repo, cwd: process.cwd(), io: leanIo, issues: [...new Set(issues)], limit, dryRun: argv.includes("--dry-run"), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
|
|
2153
|
+
const rows = await sweepProbes({ repo, cwd: process.cwd(), io: leanIo, issues: [...new Set(issues)], limit, dryRun: argv.includes("--dry-run"), onResult: probeEventSink(repo), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
|
|
1999
2154
|
for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
|
|
2000
2155
|
console.log(formatProbeSummary(rows) || "probes: nothing to run");
|
|
2001
2156
|
return { rows };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#4629: live A/B experiments. forge.yaml `experiment:` section (flat keys, the parser has one nesting level):
|
|
5
|
+
* id: light-vs-full arms: light,full ratio: 1,1 (weights, default equal)
|
|
6
|
+
* metrics: usd_median,wall_p50_ms,first_pass_rate,needs_human_rate (default all)
|
|
7
|
+
* targets: light.usd_median<=1.2xfull,light.first_pass_rate>=0.8 min_n: 10
|
|
8
|
+
* The arm is a hash of (id, issue), so a re-run or resume keeps its arm. Pure; the stats report is bin/stats/experiment.mjs.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { parseForgeYaml } from "./forge-utils.mjs";
|
|
14
|
+
|
|
15
|
+
export const EXPERIMENT_METRICS = ["usd_median", "wall_p50_ms", "first_pass_rate", "needs_human_rate"];
|
|
16
|
+
export const BENCH_SCHEMA = "forge.bench.v1";
|
|
17
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
18
|
+
const TARGET = /^([A-Za-z0-9][A-Za-z0-9._-]*)\.([a-z_0-9]+)(<=|>=)(\d+(?:\.\d+)?)(?:x([A-Za-z0-9][A-Za-z0-9._-]*))?$/;
|
|
19
|
+
const list = (v) => String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
20
|
+
|
|
21
|
+
/** Parsed declaration {id, arms, ratio, metrics, targets, minN}, or null when absent or invalid. */
|
|
22
|
+
export function parseExperiment(sec, { defaultMinN = 10 } = {}) {
|
|
23
|
+
if (!sec || typeof sec !== "object") return null;
|
|
24
|
+
const id = String(sec.id ?? "").trim(), arms = list(sec.arms);
|
|
25
|
+
if (!NAME.test(id) || arms.length < 2 || new Set(arms).size !== arms.length || !arms.every((a) => NAME.test(a))) return null;
|
|
26
|
+
const ratio = sec.ratio == null || sec.ratio === "" ? arms.map(() => 1) : list(sec.ratio).map((x) => (/^[1-9]\d*$/.test(x) ? Number(x) : NaN));
|
|
27
|
+
if (ratio.length !== arms.length || ratio.some(Number.isNaN)) return null;
|
|
28
|
+
const metrics = sec.metrics ? list(sec.metrics) : [...EXPERIMENT_METRICS];
|
|
29
|
+
if (!metrics.length || metrics.some((m) => !EXPERIMENT_METRICS.includes(m))) return null;
|
|
30
|
+
const targets = [];
|
|
31
|
+
for (const t of list(sec.targets)) {
|
|
32
|
+
const m = TARGET.exec(t);
|
|
33
|
+
if (!m || !arms.includes(m[1]) || !metrics.includes(m[2]) || (m[5] && !arms.includes(m[5]))) return null;
|
|
34
|
+
targets.push({ arm: m[1], metric: m[2], op: m[3], limit: Number(m[4]), vs: m[5] ?? null, text: t });
|
|
35
|
+
}
|
|
36
|
+
const mn = String(sec.min_n ?? "").trim();
|
|
37
|
+
if (mn && !/^\d+$/.test(mn)) return null;
|
|
38
|
+
return { id, arms, ratio, metrics, targets, minN: mn ? Number(mn) : defaultMinN };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** forge.yaml `experiment:` of `cwd`, or null. */
|
|
42
|
+
export function readExperiment(cwd = process.cwd()) {
|
|
43
|
+
try { return parseExperiment(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.experiment); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Deterministic arm of an issue: sha1(id:issue) mod the ratio total, walked over the arms in declared order. */
|
|
47
|
+
export function assignArm(exp, issue) {
|
|
48
|
+
const total = exp.ratio.reduce((a, b) => a + b, 0);
|
|
49
|
+
let pick = createHash("sha1").update(`${exp.id}:${issue}`).digest().readUInt32BE(0) % total;
|
|
50
|
+
for (let i = 0; i < exp.arms.length; i++) { if (pick < exp.ratio[i]) return exp.arms[i]; pick -= exp.ratio[i]; }
|
|
51
|
+
return exp.arms[0];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The shared result record of the bench harness, the eval gate and `stats --experiment`: targets are {pass: true|false|null, value, limit}. */
|
|
55
|
+
export const benchRecord = ({ source, arms = {}, targets = {} }) => ({ schema: BENCH_SCHEMA, source, arms, targets });
|
package/bin/forgedock.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
renameSync,
|
|
14
14
|
unlinkSync,
|
|
15
15
|
} from "fs";
|
|
16
|
+
import { gitignoreAdditions } from "./lean/toolstate.mjs";
|
|
16
17
|
import { execSync, execFileSync, spawnSync } from "child_process";
|
|
17
18
|
import { homedir } from "os";
|
|
18
19
|
import https from "https";
|
|
@@ -1025,6 +1026,20 @@ async function statusScreen(c) {
|
|
|
1025
1026
|
* Independent of FORGEDOCK_BACKEND, which controls
|
|
1026
1027
|
* the separate `forgedock run` engine backend.
|
|
1027
1028
|
*/
|
|
1029
|
+
/** #4590: append the tool-state deny-list (.forge-lean/, tool caches) to the repo's .gitignore; append-only, idempotent. */
|
|
1030
|
+
function ensureToolStateIgnored(c) {
|
|
1031
|
+
try {
|
|
1032
|
+
const file = join(c.cwd, ".gitignore");
|
|
1033
|
+
const text = existsSync(file) ? readFileSync(file, "utf-8") : "";
|
|
1034
|
+
let next = false;
|
|
1035
|
+
try { next = Boolean(JSON.parse(readFileSync(join(c.cwd, "package.json"), "utf-8"))?.dependencies?.next); } catch { /* no package.json */ }
|
|
1036
|
+
const add = gitignoreAdditions(text, { next });
|
|
1037
|
+
if (!add.length) return;
|
|
1038
|
+
writeFileSync(file, `${text}${text && !text.endsWith("\n") ? "\n" : ""}# ForgeDock lane scratch and tool caches\n${add.join("\n")}\n`);
|
|
1039
|
+
c.stdout.write(` .gitignore: added ${add.join(", ")}\n`);
|
|
1040
|
+
} catch { /* .gitignore is best effort */ }
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1028
1043
|
async function initFlow(c) {
|
|
1029
1044
|
const outputPath = join(c.cwd, "forge.yaml");
|
|
1030
1045
|
const hasExisting = existsSync(outputPath);
|
|
@@ -1078,6 +1093,7 @@ async function initFlow(c) {
|
|
|
1078
1093
|
stagingBranch: draft.branches.staging.value,
|
|
1079
1094
|
});
|
|
1080
1095
|
atomicWriteFile(outputPath, content);
|
|
1096
|
+
ensureToolStateIgnored(c);
|
|
1081
1097
|
celebrate(c, { written: true, todoCount: 0, isMinimal: true });
|
|
1082
1098
|
await maybeOfferDemo(c);
|
|
1083
1099
|
return 0;
|
|
@@ -1107,12 +1123,14 @@ async function initFlow(c) {
|
|
|
1107
1123
|
if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
1108
1124
|
const lowKeys = manualLowConfidenceKeys(draft, description, v);
|
|
1109
1125
|
const { todoCount } = writeForgeYaml(v, lowKeys, outputPath);
|
|
1126
|
+
ensureToolStateIgnored(c);
|
|
1110
1127
|
celebrate(c, { written: true, todoCount });
|
|
1111
1128
|
await maybeOfferDemo(c);
|
|
1112
1129
|
return 0;
|
|
1113
1130
|
}
|
|
1114
1131
|
const { draft, description } = await setupAfterRead(c, await read(c));
|
|
1115
1132
|
const reviewed = await review(c, draft, description);
|
|
1133
|
+
if (!reviewed.aborted) ensureToolStateIgnored(c);
|
|
1116
1134
|
celebrate(c, reviewed);
|
|
1117
1135
|
if (!reviewed.aborted) await maybeOfferDemo(c);
|
|
1118
1136
|
return reviewed.aborted ? 1 : 0;
|
|
@@ -3297,7 +3315,7 @@ function help() {
|
|
|
3297
3315
|
["npx forgedock bootstrap <idea|docs> [--dry-run] [--spike <path>] | --plan <plan.json> [--dry-run]", "Headless /bootstrap: idea to orchestrate-ready backlog (--dry-run previews milestones, titles, DAG and estimated agent cost without GitHub writes); --plan validates and creates (or previews) a plan, and re-running resumes from GitHub"],
|
|
3298
3316
|
["npx forgedock run-issue <issue> [--lane <lane>] [--lane-override] [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline; the lane comes from the issue: milestone → milestone/<slug>, else staging; --lane is an assertion, never the default branch) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
|
|
3299
3317
|
["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering; each issue on its own lane, cross-lane dependencies reported"],
|
|
3300
|
-
["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --cancel <id>", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume)"],
|
|
3318
|
+
["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N...", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume)"],
|
|
3301
3319
|
["npx forgedock triage <issue...> [--lane L] [--dry-run]", "Already-fixed pre-filter: close issues a merged PR or green CI already resolved; print the rest"],
|
|
3302
3320
|
["npx forgedock probe [issue...] [--dry-run]", "Run declared acceptance probes for closed issues whose fix is on the default branch; a fail reopens the issue"],
|
|
3303
3321
|
["npx forgedock check [--base REF] [--json]", "Run the lane's frozen validate commands and change-scoped pre-PR checks locally in this worktree, as the engine will (no GitHub, no Actions)"],
|
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
* (bin/lean/effects.mjs rereadNudgeFlags). It counts reads per path for one session, using the #4410 telemetry's
|
|
7
7
|
* definition of a read (toolstats readPaths), and on exactly the REREAD_MIN-th read of a path it returns an
|
|
8
8
|
* `additionalContext` system reminder. Advisory only: it always exits 0 and an error produces no output.
|
|
9
|
-
*
|
|
9
|
+
* #4580: the same script is also a PreToolUse guard on Read: a full-file Read (no offset/limit) of a path already read
|
|
10
|
+
* REREAD_MIN-1 times whose mtime is unchanged since its last read is denied with a reason that points at notes or a line
|
|
11
|
+
* range. Ranged reads and Bash never deny, and any error allows. A changed mtime resets the path's count.
|
|
12
|
+
* #4622: the guard counts only full-file Reads ({path: count} in <session_id>.full.json), so ranged Reads and Bash samples never
|
|
13
|
+
* make a first full Read look like a re-read; the nudge still counts every read.
|
|
14
|
+
* State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json plus {path: mtimeMs} in <session_id>.mtime.json (per-run caches).
|
|
10
15
|
*/
|
|
11
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
17
|
import { tmpdir } from "node:os";
|
|
13
18
|
import { dirname, join, resolve } from "node:path";
|
|
14
19
|
import { fileURLToPath } from "node:url";
|
|
@@ -38,18 +43,52 @@ export function nudgeFor(payload, counts = {}) {
|
|
|
38
43
|
export const stateFile = (sessionId, dir = join(tmpdir(), "forgedock-reread")) =>
|
|
39
44
|
join(dir, `${String(sessionId).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "session"}.json`);
|
|
40
45
|
|
|
46
|
+
const mtimeOf = (p) => { try { return statSync(p).mtimeMs; } catch { return null; } };
|
|
47
|
+
const loadJson = (file) => { try { const v = JSON.parse(readFileSync(file, "utf8")); return v && typeof v === "object" ? v : {}; } catch { return {}; } };
|
|
48
|
+
|
|
49
|
+
/** Paths a payload reads whole: a Read with no offset/limit (what the guard counts, #4622). */
|
|
50
|
+
export function fullReads(payload) {
|
|
51
|
+
const inp = payload?.tool_input ?? {};
|
|
52
|
+
return payload?.tool_name === "Read" && inp.offset == null && inp.limit == null ? payloadReads(payload) : [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Pure core of the guard: the deny reason for a payload, or null. `counts` are full-read counts; `mtimeNow(path)` is the file's current mtime. */
|
|
56
|
+
export function guardFor(payload, counts = {}, mtimes = {}, mtimeNow = mtimeOf) {
|
|
57
|
+
const inp = payload?.tool_input ?? {};
|
|
58
|
+
if (payload?.tool_name !== "Read" || inp.offset != null || inp.limit != null) return null;
|
|
59
|
+
for (const p of payloadReads(payload)) {
|
|
60
|
+
const now = mtimeNow(p);
|
|
61
|
+
if (now != null && (counts[p] ?? 0) >= REREAD_MIN - 1 && mtimes[p] === now)
|
|
62
|
+
return `${p} has already been read ${counts[p]} times in this session and has not changed: use your notes or the excerpt already in context; `
|
|
63
|
+
+ "if you need a different part, read only that line range (offset and limit).";
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
41
68
|
/** One hook call: stdin JSON in, the hookSpecificOutput line (or nothing) out. Fails open. */
|
|
42
69
|
export function main(input, { dir } = {}) {
|
|
43
70
|
try {
|
|
44
71
|
const payload = JSON.parse(input);
|
|
45
72
|
if (!payload?.session_id) return "";
|
|
46
73
|
if (!payloadReads(payload).length) return "";
|
|
47
|
-
const file = stateFile(payload.session_id, dir);
|
|
48
|
-
|
|
49
|
-
|
|
74
|
+
const file = stateFile(payload.session_id, dir), mfile = file.replace(/\.json$/, ".mtime.json");
|
|
75
|
+
const ffile = file.replace(/\.json$/, ".full.json");
|
|
76
|
+
const counts = loadJson(file), mtimes = loadJson(mfile), full = loadJson(ffile);
|
|
77
|
+
if (payload.hook_event_name === "PreToolUse") {
|
|
78
|
+
const reason = guardFor(payload, full, mtimes);
|
|
79
|
+
return reason ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } })}\n` : "";
|
|
80
|
+
}
|
|
81
|
+
for (const p of payloadReads(payload)) {
|
|
82
|
+
const now = mtimeOf(p);
|
|
83
|
+
if (now != null && mtimes[p] !== now) { delete counts[p]; delete full[p]; } // the file changed (or is new): earlier reads no longer count
|
|
84
|
+
if (now != null) mtimes[p] = now;
|
|
85
|
+
}
|
|
86
|
+
for (const p of fullReads(payload)) full[p] = (full[p] ?? 0) + 1;
|
|
50
87
|
const r = nudgeFor(payload, counts);
|
|
51
88
|
mkdirSync(dirname(file), { recursive: true });
|
|
52
89
|
writeFileSync(file, JSON.stringify(r.counts));
|
|
90
|
+
writeFileSync(mfile, JSON.stringify(mtimes));
|
|
91
|
+
writeFileSync(ffile, JSON.stringify(full));
|
|
53
92
|
return r.message ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: r.message } })}\n` : "";
|
|
54
93
|
} catch {
|
|
55
94
|
return "";
|