forgedock 1.35.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.
Files changed (51) hide show
  1. package/README.md +78 -324
  2. package/bin/engine/events.mjs +152 -0
  3. package/bin/engine/events.test.mjs +128 -0
  4. package/bin/engine/telemetry-seam.mjs +4 -3
  5. package/bin/engine-cli.mjs +113 -18
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +1 -1
  8. package/bin/hooks/reread-nudge.mjs +15 -4
  9. package/bin/lean/batch.mjs +42 -2
  10. package/bin/lean/batch.test.mjs +101 -1
  11. package/bin/lean/conformance.mjs +14 -3
  12. package/bin/lean/conformance.test.mjs +26 -0
  13. package/bin/lean/effects.mjs +3 -0
  14. package/bin/lean/execution.mjs +2 -0
  15. package/bin/lean/i18n.mjs +65 -0
  16. package/bin/lean/i18n.test.mjs +47 -0
  17. package/bin/lean/lane.mjs +5 -2
  18. package/bin/lean/lean.mjs +37 -4
  19. package/bin/lean/lean.test.mjs +124 -5
  20. package/bin/lean/orchestrate.mjs +49 -5
  21. package/bin/lean/postdeploy-check.mjs +149 -0
  22. package/bin/lean/postdeploy-check.test.mjs +146 -0
  23. package/bin/lean/postdeploy-monitor.mjs +87 -0
  24. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  25. package/bin/lean/precheck.mjs +6 -4
  26. package/bin/lean/precheck.test.mjs +17 -2
  27. package/bin/lean/prefilter.mjs +29 -4
  28. package/bin/lean/prefilter.test.mjs +13 -5
  29. package/bin/lean/probe.mjs +2 -1
  30. package/bin/lean/prompts.mjs +10 -2
  31. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  32. package/bin/lean/sandbox-remote.mjs +7 -0
  33. package/bin/lean/schedd.mjs +24 -4
  34. package/bin/lean/schedd.test.mjs +54 -0
  35. package/bin/stats/aggregate.mjs +1 -1
  36. package/bin/stats/experiment.mjs +44 -0
  37. package/bin/stats/github-source.mjs +2 -1
  38. package/bin/stats/index.mjs +42 -2
  39. package/bin/stats/release.mjs +75 -0
  40. package/bin/stats/release.test.mjs +68 -0
  41. package/commands/orchestrate.md +8 -3
  42. package/docs/CONFIG.md +49 -0
  43. package/docs/EVENTS.md +68 -0
  44. package/docs/STATS.md +31 -0
  45. package/docs/TELEMETRY.md +3 -1
  46. package/docs/site/install.md +89 -0
  47. package/package.json +1 -1
  48. package/scripts/bench-lightlane.mjs +2 -1
  49. package/scripts/eval-gate-scorecard.mjs +7 -0
  50. package/scripts/file-deferred-followups.sh +1 -0
  51. package/scripts/file-deferred-followups.test.sh +8 -0
@@ -0,0 +1,152 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Versioned outbound lifecycle events (#4630, docs/EVENTS.md). The engine hands the same run-log shaped records it gives
5
+ * the telemetry seam to `makeEventEmitter`, which maps them to a stable envelope, appends it to a local JSONL file and,
6
+ * when forge.yaml `events.webhook_url` and the secret env var are both set, POSTs it signed with HMAC-SHA256. No model call.
7
+ * The emitter NEVER throws; FORGE_EVENTS=off disables it. Values are ids, counts, codes and costs only.
8
+ */
9
+ import { createHmac, randomBytes } from "node:crypto";
10
+ import { appendFileSync, mkdirSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ export const EVENT_SCHEMA = "forgedock.event";
15
+ export const EVENT_VERSION = 1;
16
+ export const DEFAULT_SECRET_ENV = "FORGE_EVENTS_SECRET";
17
+ export const WEBHOOK_TIMEOUT_MS = 5000;
18
+
19
+ /** type -> data keys (documented in docs/EVENTS.md; the contract test pins them). */
20
+ export const EVENT_TYPES = {
21
+ "lane.picked_up": ["lane", "path"],
22
+ "contract.frozen": ["hash", "requirements", "deferred", "derived"],
23
+ "pr.opened": [],
24
+ "pr.merged": ["milestone", "code"],
25
+ "release.promoted": ["fix", "default_branch"],
26
+ "probe.passed": ["detail", "fix"],
27
+ "probe.failed": ["detail", "fix"],
28
+ "regression.detected": ["subject", "metric", "detail"],
29
+ "experiment.verdict": ["experiment", "verdict", "detail"],
30
+ };
31
+
32
+ const scalar = (v) => (typeof v === "number" ? Number.isFinite(v) : typeof v === "boolean" || (typeof v === "string" && v !== ""));
33
+ const round6 = (n) => Math.round(n * 1e6) / 1e6;
34
+ const pickData = (type, src) => Object.fromEntries(EVENT_TYPES[type].filter((k) => scalar(src?.[k])).map((k) => [k, typeof src[k] === "string" ? src[k].slice(0, 200) : src[k]]));
35
+
36
+ /** Envelope for one event; null for an unknown type. `ctx` = {repo, issue, run, pr, cost_usd, wall_ms}. */
37
+ export function buildEvent(type, data = {}, ctx = {}, now = new Date()) {
38
+ if (!Object.hasOwn(EVENT_TYPES, type)) return null;
39
+ const int = (v) => (Number.isSafeInteger(v) && v > 0 ? v : null);
40
+ return {
41
+ schema: EVENT_SCHEMA, v: EVENT_VERSION, id: `${Math.floor(now.getTime() / 1000)}-${randomBytes(6).toString("hex")}`, type,
42
+ ts: now.toISOString().replace(/\.\d+Z$/, "Z"), repo: ctx.repo ?? null, issue: int(ctx.issue), pr: int(ctx.pr), run: ctx.run ?? null,
43
+ ...(Number.isFinite(ctx.cost_usd) ? { cost_usd: round6(ctx.cost_usd) } : {}), ...(Number.isFinite(ctx.wall_ms) ? { wall_ms: Math.round(ctx.wall_ms) } : {}),
44
+ data: pickData(type, data),
45
+ };
46
+ }
47
+
48
+ /** Probe sweep row ({issue, outcome, detail, fix}) -> event type + data; null unless pass or fail. */
49
+ export function probeEvent(row) {
50
+ if (row?.outcome !== "pass" && row?.outcome !== "fail") return null;
51
+ return { type: row.outcome === "pass" ? "probe.passed" : "probe.failed", data: { detail: row.detail, fix: row.fix }, ctx: { issue: row.issue } };
52
+ }
53
+
54
+ /** Monitor sweep row ({issue, outcome, detail, regression}) -> regression.detected; null unless a fail that filed a regression issue. */
55
+ export function regressionEvent(row) {
56
+ if (row?.outcome !== "fail" || !Number.isSafeInteger(row.regression) || row.regression < 1) return null;
57
+ return { type: "regression.detected", data: { subject: `post-deploy check of #${row.issue}`, metric: "post_deploy_check", detail: row.detail }, ctx: { issue: row.issue } };
58
+ }
59
+
60
+ /** Experiment report ({id, targets, targets_met}) -> experiment.verdict (met | missed | inconclusive); null without an id. */
61
+ export function experimentEvent(report) {
62
+ if (!report || typeof report.id !== "string" || !report.id) return null;
63
+ const passes = Object.values(report.targets ?? {}).map((t) => t?.pass);
64
+ const verdict = report.targets_met === true ? "met" : passes.includes(false) ? "missed" : "inconclusive";
65
+ return { type: "experiment.verdict", data: { experiment: report.id, verdict, detail: `${passes.length} target(s)` }, ctx: {} };
66
+ }
67
+
68
+ /** A fix reached the default branch -> release.promoted; null without a fix. Reserved: no detection emits it yet. */
69
+ export function promotedEvent({ fix, default_branch } = {}) {
70
+ return typeof fix === "string" && fix ? { type: "release.promoted", data: { fix, default_branch }, ctx: {} } : null;
71
+ }
72
+
73
+ /**
74
+ * Pure mapping of one run-log record to {type, data, ctx} or null. `cost` is the lane's running spend, kept by the caller
75
+ * from PHASE_ATTEMPT_USAGE (`cost_usd_delta` when present, else `cost_usd`).
76
+ */
77
+ export function mapRunRecord(rec, { cost = 0 } = {}) {
78
+ if (!rec || typeof rec !== "object") return null;
79
+ switch (rec.event) {
80
+ case "RUN_START": return { type: "lane.picked_up", data: rec, ctx: {} };
81
+ case "CONTRACT_FROZEN": return { type: "contract.frozen", data: rec, ctx: {} };
82
+ case "PR_OPENED": return { type: "pr.opened", data: {}, ctx: { pr: rec.pr } };
83
+ case "RUN_TERMINAL":
84
+ if (rec.reason !== "merged") return null;
85
+ return { type: "pr.merged", data: rec, ctx: { pr: rec.pr, cost_usd: cost, wall_ms: rec.wall_ms } };
86
+ default: return null;
87
+ }
88
+ }
89
+
90
+ /** HMAC-SHA256 hex of `${ts}.${body}`; the receiver recomputes it from X-Forge-Timestamp and the raw body. */
91
+ export const sign = (secret, ts, body) => createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
92
+
93
+ /** https, or http only for a loopback host; anything else is not a usable webhook. */
94
+ export function webhookUrlOk(u) {
95
+ try {
96
+ const url = new URL(u);
97
+ return url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname));
98
+ } catch { return false; }
99
+ }
100
+
101
+ /** POST one envelope; resolves true on 2xx. Never throws. */
102
+ export async function postWebhook(event, { url, secret, fetchFn = globalThis.fetch, timeoutMs = WEBHOOK_TIMEOUT_MS, now = Date.now } = {}) {
103
+ try {
104
+ if (!url || !secret || !webhookUrlOk(url) || typeof fetchFn !== "function") return false;
105
+ const body = JSON.stringify(event);
106
+ const ts = String(Math.floor(now() / 1000));
107
+ const res = await fetchFn(url, { method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs),
108
+ headers: { "content-type": "application/json", "x-forge-event": event.type, "x-forge-timestamp": ts, "x-forge-signature": `sha256=${sign(secret, ts, body)}` }, body });
109
+ return Boolean(res?.ok);
110
+ } catch { return false; }
111
+ }
112
+
113
+ /** Local sink path: `dir` (config or FORGE_EVENTS_DIR), else ~/.forge/events; one file per repo. */
114
+ export const sinkPath = (repo, dir) => join(dir || join(homedir(), ".forge", "events"), `${String(repo || "unknown").replace(/[^\w.-]/g, "_")}.jsonl`);
115
+
116
+ /**
117
+ * @param {{repo?: string|null, config?: {webhookUrl?: string, secretEnv?: string, dir?: string}, env?: object, fetchFn?: Function, now?: () => Date, run?: string}} o
118
+ * @returns {{emit: (rec: object) => Promise<void>, emitEvent: (type: string, data?: object, ctx?: object) => Promise<object|null>}}
119
+ */
120
+ export function makeEventEmitter({ repo = null, config = {}, env = process.env, fetchFn, now = () => new Date(), run = null } = {}) {
121
+ let cost = 0, runId = run;
122
+ const off = () => (env.FORGE_EVENTS || "on") === "off";
123
+ async function emitEvent(type, data = {}, ctx = {}) {
124
+ try {
125
+ if (off()) return null;
126
+ const ev = buildEvent(type, data, { repo, run: runId, ...ctx }, now());
127
+ if (!ev) return null;
128
+ try {
129
+ const file = sinkPath(repo, env.FORGE_EVENTS_DIR || config.dir);
130
+ mkdirSync(join(file, ".."), { recursive: true });
131
+ appendFileSync(file, `${JSON.stringify(ev)}\n`);
132
+ } catch { /* a lost local line costs completeness only */ }
133
+ const secret = env[config.secretEnv || DEFAULT_SECRET_ENV];
134
+ if (config.webhookUrl && secret) await postWebhook(ev, { url: config.webhookUrl, secret, fetchFn, now: () => now().getTime() });
135
+ return ev;
136
+ } catch { return null; }
137
+ }
138
+ async function emit(rec) {
139
+ try {
140
+ if (rec?.event === "PHASE_ATTEMPT_USAGE") {
141
+ const c = Number.isFinite(rec.cost_usd_delta) ? rec.cost_usd_delta : rec.cost_usd;
142
+ if (Number.isFinite(c)) cost += c;
143
+ return;
144
+ }
145
+ if (rec?.event === "RUN_START" && typeof rec.run === "string") runId = rec.run;
146
+ const m = mapRunRecord(rec, { cost });
147
+ if (!m) return;
148
+ await emitEvent(m.type, m.data, { issue: rec.issue, ...m.ctx });
149
+ } catch { /* events never fail a run */ }
150
+ }
151
+ return { emit, emitEvent };
152
+ }
@@ -0,0 +1,128 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Contract test for the outbound event stream (#4630, docs/EVENTS.md): changing a type, key or the signature is a breaking change.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { readFileSync, existsSync, writeFileSync } from "node:fs";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { join } from "node:path";
9
+ import { createHmac } from "node:crypto";
10
+ import { EVENT_TYPES, EVENT_VERSION, buildEvent, mapRunRecord, makeEventEmitter, probeEvent, regressionEvent, experimentEvent, promotedEvent, sign, webhookUrlOk, postWebhook } from "./events.mjs";
11
+
12
+ const tmp = () => tmpDir("forge-events-");
13
+ const fixed = () => new Date("2026-10-26T12:00:00Z");
14
+ const lines = (dir) => readFileSync(join(dir, "o_r.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
15
+
16
+ describe("event contract (v1)", () => {
17
+ it("documents exactly the named lifecycle types", () => {
18
+ assert.deepEqual(Object.keys(EVENT_TYPES), ["lane.picked_up", "contract.frozen", "pr.opened", "pr.merged", "release.promoted", "probe.passed", "probe.failed", "regression.detected", "experiment.verdict"]);
19
+ });
20
+
21
+ it("every envelope has the same top-level keys and only documented data keys", () => {
22
+ for (const [type, keys] of Object.entries(EVENT_TYPES)) {
23
+ const data = Object.fromEntries(keys.map((k) => [k, "x"]));
24
+ const e = buildEvent(type, { ...data, secret: "no" }, { repo: "o/r", issue: 3, pr: 4, run: "core_3_1", cost_usd: 1.23456789, wall_ms: 10.4 }, fixed());
25
+ assert.deepEqual(Object.keys(e), ["schema", "v", "id", "type", "ts", "repo", "issue", "pr", "run", "cost_usd", "wall_ms", "data"]);
26
+ assert.equal(e.schema, "forgedock.event"); assert.equal(e.v, EVENT_VERSION); assert.equal(e.ts, "2026-10-26T12:00:00Z");
27
+ assert.equal(e.cost_usd, 1.234568); assert.equal(e.wall_ms, 10);
28
+ assert.deepEqual(Object.keys(e.data), keys);
29
+ }
30
+ assert.equal(buildEvent("nope", {}, {}, fixed()), null);
31
+ });
32
+
33
+ it("maps run-log records and sums attempt cost onto pr.merged", async () => {
34
+ const dir = tmp();
35
+ const { emit } = makeEventEmitter({ repo: "o/r", config: { dir }, env: {}, now: fixed });
36
+ await emit({ event: "RUN_START", run: "core_9_5", issue: 9, lane: "staging", path: "core" });
37
+ await emit({ event: "CONTRACT_FROZEN", issue: 9, hash: "abc", requirements: 2, deferred: 1 });
38
+ await emit({ event: "PHASE_ATTEMPT_USAGE", issue: 9, cost_usd: 5, cost_usd_delta: 0.5 });
39
+ await emit({ event: "PHASE_ATTEMPT_USAGE", issue: 9, cost_usd: 0.25 });
40
+ await emit({ event: "PR_OPENED", issue: 9, pr: 40 });
41
+ await emit({ event: "RUN_TERMINAL", issue: 9, reason: "needs-human", wall_ms: 1 });
42
+ await emit({ event: "RUN_TERMINAL", issue: 9, reason: "merged", pr: 40, wall_ms: 90000, code: "ok", milestone: "m1" });
43
+ const got = lines(dir);
44
+ assert.deepEqual(got.map((e) => e.type), ["lane.picked_up", "contract.frozen", "pr.opened", "pr.merged"]);
45
+ assert.deepEqual(got[0].data, { lane: "staging", path: "core" });
46
+ assert.equal(got[0].run, "core_9_5"); assert.equal(got[3].run, "core_9_5");
47
+ assert.deepEqual(got[1].data, { hash: "abc", requirements: 2, deferred: 1 });
48
+ assert.equal(got[2].pr, 40);
49
+ assert.equal(got[3].cost_usd, 0.75); assert.equal(got[3].wall_ms, 90000); assert.equal(got[3].issue, 9);
50
+ assert.equal(mapRunRecord({ event: "CI_PENDING_WAIT" }), null);
51
+ });
52
+
53
+ it("maps probe rows: only pass and fail", () => {
54
+ assert.equal(probeEvent({ issue: 1, outcome: "awaiting-promotion" }), null);
55
+ assert.equal(probeEvent({ issue: 1, outcome: "pass", detail: "exit 0", fix: "abc" }).type, "probe.passed");
56
+ assert.equal(probeEvent({ issue: 1, outcome: "fail" }).type, "probe.failed");
57
+ });
58
+ });
59
+
60
+ describe("regression, experiment and promoted builders (#4632)", () => {
61
+ it("regressionEvent: only a fail row that filed a regression issue", () => {
62
+ assert.equal(regressionEvent({ issue: 5, outcome: "pass" }), null);
63
+ assert.equal(regressionEvent({ issue: 5, outcome: "fail" }), null);
64
+ const r = regressionEvent({ issue: 5, outcome: "fail", regression: 7, detail: "broke" });
65
+ assert.equal(r.type, "regression.detected"); assert.equal(r.ctx.issue, 5);
66
+ assert.deepEqual(r.data, { subject: "post-deploy check of #5", metric: "post_deploy_check", detail: "broke" });
67
+ });
68
+ it("experimentEvent: met, missed, inconclusive", () => {
69
+ const t = (...p) => Object.fromEntries(p.map((pass, i) => [`t${i}`, { pass }]));
70
+ assert.equal(experimentEvent({ id: "e", targets: t(true), targets_met: true }).data.verdict, "met");
71
+ assert.equal(experimentEvent({ id: "e", targets: t(true, false), targets_met: false }).data.verdict, "missed");
72
+ assert.equal(experimentEvent({ id: "e", targets: t(null), targets_met: false }).data.verdict, "inconclusive");
73
+ assert.equal(experimentEvent({}), null);
74
+ });
75
+ it("promotedEvent needs a fix; types and keys stay pinned", () => {
76
+ assert.equal(promotedEvent({}), null);
77
+ assert.deepEqual(promotedEvent({ fix: "abc", default_branch: "main" }).data, { fix: "abc", default_branch: "main" });
78
+ assert.deepEqual(EVENT_TYPES["regression.detected"], ["subject", "metric", "detail"]);
79
+ assert.deepEqual(EVENT_TYPES["experiment.verdict"], ["experiment", "verdict", "detail"]);
80
+ });
81
+ it("a regression row is written to the sink", async () => {
82
+ const dir = tmp();
83
+ const p = regressionEvent({ issue: 5, outcome: "fail", regression: 7, detail: "d" });
84
+ await makeEventEmitter({ repo: "o/r", config: { dir }, env: {} }).emitEvent(p.type, p.data, p.ctx);
85
+ const ev = JSON.parse(readFileSync(join(dir, "o_r.jsonl"), "utf8"));
86
+ assert.equal(ev.type, "regression.detected"); assert.equal(ev.issue, 5);
87
+ });
88
+ });
89
+
90
+ describe("webhook", () => {
91
+ const cfg = { webhookUrl: "https://hooks.example/x", secretEnv: "MY_SECRET", dir: undefined };
92
+ it("signs `${timestamp}.${body}` with HMAC-SHA256 from the env secret", async () => {
93
+ const calls = [];
94
+ const fetchFn = async (url, init) => { calls.push([url, init]); return { ok: true }; };
95
+ const { emitEvent } = makeEventEmitter({ repo: "o/r", config: { ...cfg, dir: tmp() }, env: { MY_SECRET: "s3" }, fetchFn, now: fixed });
96
+ const ev = await emitEvent("pr.opened", {}, { issue: 1, pr: 2 });
97
+ assert.equal(calls.length, 1);
98
+ const [url, init] = calls[0];
99
+ assert.equal(url, cfg.webhookUrl); assert.equal(init.method, "POST");
100
+ assert.equal(init.body, JSON.stringify(ev));
101
+ const ts = init.headers["x-forge-timestamp"];
102
+ assert.equal(ts, String(Math.floor(fixed().getTime() / 1000)));
103
+ assert.equal(init.headers["x-forge-signature"], `sha256=${createHmac("sha256", "s3").update(`${ts}.${init.body}`).digest("hex")}`);
104
+ assert.equal(init.headers["x-forge-event"], "pr.opened");
105
+ assert.equal(sign("s3", ts, init.body), init.headers["x-forge-signature"].slice(7));
106
+ });
107
+
108
+ it("sends nothing without the secret, with a non-https URL, or when off", async () => {
109
+ let n = 0;
110
+ const fetchFn = async () => { n++; return { ok: true }; };
111
+ for (const [config, env] of [[cfg, {}], [{ ...cfg, webhookUrl: "http://evil.example/x" }, { MY_SECRET: "s" }], [cfg, { MY_SECRET: "s", FORGE_EVENTS: "off" }]]) {
112
+ await makeEventEmitter({ repo: "o/r", config: { ...config, dir: tmp() }, env, fetchFn }).emitEvent("pr.opened", {}, { issue: 1 });
113
+ }
114
+ assert.equal(n, 0);
115
+ assert.equal(webhookUrlOk("http://localhost:3000/h"), true); assert.equal(webhookUrlOk("ftp://x"), false);
116
+ });
117
+
118
+ it("off writes no file; sink and webhook failures never throw", async () => {
119
+ const dir = tmp();
120
+ await makeEventEmitter({ repo: "o/r", config: { dir }, env: { FORGE_EVENTS: "off" } }).emitEvent("pr.opened", {}, { issue: 1 });
121
+ assert.equal(existsSync(join(dir, "o_r.jsonl")), false);
122
+ const boom = async () => { throw new Error("net"); };
123
+ writeFileSync(join(dir, "f"), ""); const bad = makeEventEmitter({ repo: "o/r", config: { ...cfg, dir: join(dir, "f", "x") }, env: { MY_SECRET: "s" }, fetchFn: boom });
124
+ assert.ok(await bad.emitEvent("pr.opened", {}, { issue: 1 }));
125
+ await bad.emit({ event: "PR_OPENED", pr: 1, issue: 1 });
126
+ assert.equal(await postWebhook({ type: "x" }, { url: cfg.webhookUrl, secret: "s", fetchFn: boom }), false);
127
+ });
128
+ });
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * forge#3835: the engine's `emitTelemetry` seam writer. Maps the run-log shaped records the engine hands to the
5
5
  * seam onto FORGE:TELEMETRY events so `forgedock stats` can rebuild Cost, Time and outcome codes from GitHub alone:
6
- * PHASE_ATTEMPT_USAGE -> `phase_attempt` (attempt, model, backend, turns, duration_ms, cost_usd, cost_usd_delta, cost_usd_session, *_tokens;
6
+ * PHASE_ATTEMPT_USAGE -> `phase_attempt` (experiment + arm when forge.yaml declares one, forge#4629; attempt, model, backend, turns, duration_ms, cost_usd, cost_usd_delta, cost_usd_session, *_tokens;
7
7
  * lean adds baseline_tokens and lane_profile, forge#4395)
8
8
  * RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms; lean adds milestone, conformance_mode, shadow_would, the pr and
9
9
  * builder_tier/class/model/escalated 0|1, forge#4394)
@@ -59,14 +59,15 @@ export function mapRecord(rec, wallMs) {
59
59
  cost_usd_session: Number.isFinite(rec.cost_usd_session) ? Math.round(rec.cost_usd_session * 1e6) / 1e6 : undefined,
60
60
  input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
61
61
  cache_read_tokens: tok(rec.usage, "cache_read_input_tokens"), cache_creation_tokens: tok(rec.usage, "cache_creation_input_tokens"),
62
- baseline_tokens: rec.baseline_tokens, lane_profile: rec.lane_profile,
62
+ baseline_tokens: rec.baseline_tokens, lane_profile: rec.lane_profile, experiment: rec.experiment, arm: rec.arm, // #4629
63
63
  }) };
64
64
  }
65
65
  if (rec.event === "RUN_TERMINAL") {
66
66
  return { event: "run_terminal", phase: null, ...prOf(rec), payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs,
67
67
  milestone: rec.milestone, conformance_mode: rec.conformance_mode, shadow_would: rec.shadow_would,
68
68
  builder_tier: rec.builder_tier, builder_class: rec.builder_class, builder_model: rec.builder_model, // #4394: the model per merge
69
- builder_escalated: typeof rec.builder_escalated === "boolean" ? Number(rec.builder_escalated) : undefined }) };
69
+ builder_escalated: typeof rec.builder_escalated === "boolean" ? Number(rec.builder_escalated) : undefined,
70
+ experiment: rec.experiment, arm: rec.arm }) }; // #4629
70
71
  }
71
72
  if (rec.event === "SANDBOX_USAGE") {
72
73
  return { event: "sandbox_usage", phase: null, payload: compact({ provider: rec.provider, sandbox_id: rec.sandbox_id, minutes: rec.minutes,
@@ -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`,
@@ -876,8 +903,8 @@ export async function runFromCli(argv, deps = {}) {
876
903
  try {
877
904
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
878
905
  onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
879
- emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
880
- 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() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}) });
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() } : {}) });
881
908
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
882
909
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
883
910
  if (interrupted) return lres; // the signal handler reported and exits
@@ -1075,6 +1102,7 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
1075
1102
  * daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
1076
1103
  * `--add <id> N...` (#4408) adds issues to that batch instead of starting a second one: they run under its K and DAG
1077
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.
1078
1106
  * `--cancel <id>` (#4376) stops that one batch, never the shared daemon: it admits nothing more, its running local lanes get
1079
1107
  * SIGTERM (their teardown saves the work), remote lanes run to their result and the rest end `held`; `--resume <id>` continues it.
1080
1108
  */
@@ -1088,8 +1116,8 @@ export async function orchestrateFromCli(argv, deps = {}) {
1088
1116
  const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
1089
1117
  const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
1090
1118
 
1091
- 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> | --hold <id> <issue...> | --release <id> <issue...>";
1092
- 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"];
1093
1121
  const issuesOf = (argv) => {
1094
1122
  const issues = [];
1095
1123
  for (let i = 0; i < argv.length; i++) {
@@ -1169,20 +1197,55 @@ async function holdBatch({ op, id, tokens, root, viaDaemon = null, print = (l) =
1169
1197
  return { ok: true, batch: id, changed: res.changed ?? [] };
1170
1198
  }
1171
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
+
1172
1235
  /**
1173
1236
  * `--cancel <id>` (#4376): through the daemon (`viaDaemon`, null when none runs) or the batch's `cancel` file directly.
1174
1237
  * A finished batch (report written) is left alone.
1175
1238
  */
1176
- 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) }) {
1177
1240
  if (!id || String(id).startsWith("--")) throw new Error(ORCH_USAGE);
1178
1241
  let res = viaDaemon ? await viaDaemon() : null;
1179
1242
  if (!res) {
1180
1243
  const b = (await import("./lean/batch.mjs")).openBatch(root, id);
1181
1244
  if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
1182
1245
  if (b.readReport()) res = { cancelled: false, finished: true };
1183
- else { b.requestCancel({ reason: "operator" }); res = { cancelled: true }; }
1246
+ else { b.requestCancel({ reason: "operator", stopRemote }); res = { cancelled: true }; }
1184
1247
  }
1185
- 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)`
1186
1249
  : `batch ${id}: already finished`);
1187
1250
  return { ok: true, batch: id, cancelled: !!res.cancelled };
1188
1251
  }
@@ -1218,6 +1281,11 @@ async function orchestrateClient(argv, deps = {}) {
1218
1281
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1219
1282
  return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
1220
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
+ };
1221
1289
  const holdVia = (id) => async (op, issues) => {
1222
1290
  let sock = null;
1223
1291
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
@@ -1226,12 +1294,16 @@ async function orchestrateClient(argv, deps = {}) {
1226
1294
  const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
1227
1295
  if (argv.includes("--cancel")) {
1228
1296
  const id = flag(argv, "--cancel");
1229
- return cancelBatch({ id, root: await batchRootOf(), viaDaemon: async () => {
1297
+ return cancelBatch({ id, root: await batchRootOf(), stopRemote: argv.includes("--stop-remote"), viaDaemon: async () => {
1230
1298
  let sock = null;
1231
1299
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1232
- return sock ? schedd.request(sock, { op: "cancel", id }) : null;
1300
+ return sock ? schedd.request(sock, { op: "cancel", id, stopRemote: argv.includes("--stop-remote") }) : null;
1233
1301
  } });
1234
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
+ }
1235
1307
  for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
1236
1308
  const id = flag(argv, `--${op}`);
1237
1309
  return holdBatch({ op, id, tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root: await batchRootOf(), viaDaemon: holdVia(id) });
@@ -1310,7 +1382,11 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1310
1382
  sink.line(`batch ${b.id}: parallel set to ${k}`);
1311
1383
  return { ok: true, batch: b.id, parallel: k };
1312
1384
  }
1313
- 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
+ }
1314
1390
  for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
1315
1391
  return holdBatch({ op, id: flag(argv, `--${op}`), tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root, print: (l) => sink.line(l) });
1316
1392
  }
@@ -1376,7 +1452,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1376
1452
  // #4399: declared acceptance probes of closed issues whose fix reached the default branch; never fails the batch
1377
1453
  const sweep = deps.sweepProbes !== undefined ? deps.sweepProbes : (await import("./lean/probe.mjs")).sweepProbes;
1378
1454
  if (sweep && !argv.includes("--no-probes")) {
1379
- 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)})`); }
1380
1456
  }
1381
1457
  const { makePrefilter, formatPrefilterSummary, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
1382
1458
  const branches = readBranches(cwd);
@@ -1400,7 +1476,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1400
1476
  const { runCore } = deps.leanModule;
1401
1477
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1402
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,
1403
- 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) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(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) } : {}) }) };
1404
1480
  } else {
1405
1481
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1406
1482
  // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
@@ -1417,6 +1493,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1417
1493
  const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
1418
1494
  execForNeeds = exec ?? null;
1419
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
1420
1497
  if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
1421
1498
  // #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
1422
1499
  let gitDir = null;
@@ -1427,6 +1504,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1427
1504
  const bud = exec.budget ?? {};
1428
1505
  const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
1429
1506
  ...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
1507
+ remoteStop = remoteLanes.stopLane;
1430
1508
  lanes = withPlacement(remoteLanes);
1431
1509
  {
1432
1510
  // #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
@@ -1472,9 +1550,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1472
1550
  } else {
1473
1551
  lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
1474
1552
  }
1475
- // #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).
1476
1554
  const { killLedgerPid } = await import("./lean/effects.mjs");
1477
- const stopLane = deps.stopLane ?? ((n, rec) => (rec?.pid && !rec.remote && !rec.handle ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
1555
+ const stopLane = deps.stopLane ?? ((n, rec) => (rec?.remote || rec?.handle ? remoteStop?.(n, rec) ?? false : rec?.pid ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
1478
1556
  lanes = { ...lanes, stopLane };
1479
1557
  }
1480
1558
  const orch = deps.orchestration ?? readOrchestration(cwd);
@@ -1580,7 +1658,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1580
1658
  rows = await orchestrate({
1581
1659
  ...(estimate ? { estimate } : {}),
1582
1660
  beforeAdmit,
1583
- issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, getHeld: batch.readHeld, ...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,
1584
1662
  breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
1585
1663
  ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1586
1664
  ...(remote ? { remote } : {}),
@@ -1728,6 +1806,15 @@ export function formatReclaimed(r) {
1728
1806
  return `reclaimed: ${r.items} item(s), ${size} (preflight ${r.preflight}, admissions ${r.admissions})`;
1729
1807
  }
1730
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
+
1731
1818
  /**
1732
1819
  * `forgedock schedd` (foreground; for a systemd-user unit: `ExecStart=forgedock schedd`) · `forgedock schedd status` (#4192)
1733
1820
  */
@@ -1742,7 +1829,7 @@ export async function scheddFromCli(argv, deps = {}) {
1742
1829
  return { running: true, ...st };
1743
1830
  }
1744
1831
  if (argv.length) throw new Error("usage: forgedock schedd [status]");
1745
- 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 }) });
1746
1833
  if (!d.running) return d;
1747
1834
  // #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
1748
1835
  schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
@@ -2037,13 +2124,14 @@ export async function triageFromCli(argv, deps = {}) {
2037
2124
  }
2038
2125
 
2039
2126
  /**
2040
- * `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)
2041
2128
  * Runs the declared acceptance probes (`lean/probe.mjs`) of the given issues, or of the recently closed issues that
2042
2129
  * declare one, once their fix is on the default branch; records FORGE:PROBE and reopens on a fail. No model call.
2043
2130
  */
2044
2131
  export async function probeFromCli(argv, deps = {}) {
2045
2132
  const valued = ["--repo", "--limit"];
2046
2133
  const issues = [];
2134
+ const due = argv.includes("--due"); // #4627
2047
2135
  for (let i = 0; i < argv.length; i++) {
2048
2136
  if (valued.includes(argv[i])) { i++; continue; }
2049
2137
  if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
@@ -2054,8 +2142,15 @@ export async function probeFromCli(argv, deps = {}) {
2054
2142
  const limit = flag(argv, "--limit") == null ? 50 : parseInt(flag(argv, "--limit"), 10);
2055
2143
  if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer");
2056
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
+ }
2057
2152
  const { sweepProbes, formatProbeSummary } = await import("./lean/probe.mjs");
2058
- 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 } : {}) });
2059
2154
  for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
2060
2155
  console.log(formatProbeSummary(rows) || "probes: nothing to run");
2061
2156
  return { rows };