forgedock 1.35.0 → 1.37.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 (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -0,0 +1,161 @@
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. Emitted by the probe sweep (probeSweepEvents). */
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
+ * Events for one probe-sweep row. A probe pass/fail row proves the fix is on the default branch (probeIssue gates on onDefault) and is
75
+ * recorded once per fix, so it also yields release.promoted. Monitor rows carry no defaultBranch and never do (they repeat per window).
76
+ */
77
+ export function probeSweepEvents(row) {
78
+ const promoted = row?.defaultBranch && (row.outcome === "pass" || row.outcome === "fail") ? promotedEvent({ fix: row.fix, default_branch: row.defaultBranch }) : null;
79
+ return [probeEvent(row), regressionEvent(row), promoted && { ...promoted, ctx: { issue: row.issue } }].filter(Boolean);
80
+ }
81
+
82
+ /**
83
+ * Pure mapping of one run-log record to {type, data, ctx} or null. `cost` is the lane's running spend, kept by the caller
84
+ * from PHASE_ATTEMPT_USAGE (`cost_usd_delta` when present, else `cost_usd`).
85
+ */
86
+ export function mapRunRecord(rec, { cost = 0 } = {}) {
87
+ if (!rec || typeof rec !== "object") return null;
88
+ switch (rec.event) {
89
+ case "RUN_START": return { type: "lane.picked_up", data: rec, ctx: {} };
90
+ case "CONTRACT_FROZEN": return { type: "contract.frozen", data: rec, ctx: {} };
91
+ case "PR_OPENED": return { type: "pr.opened", data: {}, ctx: { pr: rec.pr } };
92
+ case "RUN_TERMINAL":
93
+ if (rec.reason !== "merged") return null;
94
+ return { type: "pr.merged", data: rec, ctx: { pr: rec.pr, cost_usd: cost, wall_ms: rec.wall_ms } };
95
+ default: return null;
96
+ }
97
+ }
98
+
99
+ /** HMAC-SHA256 hex of `${ts}.${body}`; the receiver recomputes it from X-Forge-Timestamp and the raw body. */
100
+ export const sign = (secret, ts, body) => createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
101
+
102
+ /** https, or http only for a loopback host; anything else is not a usable webhook. */
103
+ export function webhookUrlOk(u) {
104
+ try {
105
+ const url = new URL(u);
106
+ return url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname));
107
+ } catch { return false; }
108
+ }
109
+
110
+ /** POST one envelope; resolves true on 2xx. Never throws. */
111
+ export async function postWebhook(event, { url, secret, fetchFn = globalThis.fetch, timeoutMs = WEBHOOK_TIMEOUT_MS, now = Date.now } = {}) {
112
+ try {
113
+ if (!url || !secret || !webhookUrlOk(url) || typeof fetchFn !== "function") return false;
114
+ const body = JSON.stringify(event);
115
+ const ts = String(Math.floor(now() / 1000));
116
+ const res = await fetchFn(url, { method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs),
117
+ headers: { "content-type": "application/json", "x-forge-event": event.type, "x-forge-timestamp": ts, "x-forge-signature": `sha256=${sign(secret, ts, body)}` }, body });
118
+ return Boolean(res?.ok);
119
+ } catch { return false; }
120
+ }
121
+
122
+ /** Local sink path: `dir` (config or FORGE_EVENTS_DIR), else ~/.forge/events; one file per repo. */
123
+ export const sinkPath = (repo, dir) => join(dir || join(homedir(), ".forge", "events"), `${String(repo || "unknown").replace(/[^\w.-]/g, "_")}.jsonl`);
124
+
125
+ /**
126
+ * @param {{repo?: string|null, config?: {webhookUrl?: string, secretEnv?: string, dir?: string}, env?: object, fetchFn?: Function, now?: () => Date, run?: string}} o
127
+ * @returns {{emit: (rec: object) => Promise<void>, emitEvent: (type: string, data?: object, ctx?: object) => Promise<object|null>}}
128
+ */
129
+ export function makeEventEmitter({ repo = null, config = {}, env = process.env, fetchFn, now = () => new Date(), run = null } = {}) {
130
+ let cost = 0, runId = run;
131
+ const off = () => (env.FORGE_EVENTS || "on") === "off";
132
+ async function emitEvent(type, data = {}, ctx = {}) {
133
+ try {
134
+ if (off()) return null;
135
+ const ev = buildEvent(type, data, { repo, run: runId, ...ctx }, now());
136
+ if (!ev) return null;
137
+ try {
138
+ const file = sinkPath(repo, env.FORGE_EVENTS_DIR || config.dir);
139
+ mkdirSync(join(file, ".."), { recursive: true });
140
+ appendFileSync(file, `${JSON.stringify(ev)}\n`);
141
+ } catch { /* a lost local line costs completeness only */ }
142
+ const secret = env[config.secretEnv || DEFAULT_SECRET_ENV];
143
+ if (config.webhookUrl && secret) await postWebhook(ev, { url: config.webhookUrl, secret, fetchFn, now: () => now().getTime() });
144
+ return ev;
145
+ } catch { return null; }
146
+ }
147
+ async function emit(rec) {
148
+ try {
149
+ if (rec?.event === "PHASE_ATTEMPT_USAGE") {
150
+ const c = Number.isFinite(rec.cost_usd_delta) ? rec.cost_usd_delta : rec.cost_usd;
151
+ if (Number.isFinite(c)) cost += c;
152
+ return;
153
+ }
154
+ if (rec?.event === "RUN_START" && typeof rec.run === "string") runId = rec.run;
155
+ const m = mapRunRecord(rec, { cost });
156
+ if (!m) return;
157
+ await emitEvent(m.type, m.data, { issue: rec.issue, ...m.ctx });
158
+ } catch { /* events never fail a run */ }
159
+ }
160
+ return { emit, emitEvent };
161
+ }
@@ -0,0 +1,142 @@
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, probeSweepEvents, 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("probeSweepEvents emits release.promoted (#4644)", () => {
61
+ it("probe pass/fail rows with defaultBranch add release.promoted", () => {
62
+ const ev = probeSweepEvents({ issue: 3, outcome: "pass", fix: "abc", defaultBranch: "main", detail: "ok" });
63
+ assert.deepEqual(ev.map((e) => e.type), ["probe.passed", "release.promoted"]);
64
+ assert.deepEqual(ev[1].data, { fix: "abc", default_branch: "main" }); assert.equal(ev[1].ctx.issue, 3);
65
+ assert.equal(probeSweepEvents({ issue: 3, outcome: "fail", fix: "abc", defaultBranch: "main" }).length, 2);
66
+ });
67
+ it("monitor rows, awaiting-promotion and errors do not", () => {
68
+ assert.deepEqual(probeSweepEvents({ issue: 3, outcome: "pass", fix: "abc" }).map((e) => e.type), ["probe.passed"]);
69
+ assert.deepEqual(probeSweepEvents({ issue: 3, outcome: "awaiting-promotion", fix: "abc", defaultBranch: "main" }), []);
70
+ assert.deepEqual(probeSweepEvents({ issue: 3, outcome: "error" }), []);
71
+ });
72
+ });
73
+
74
+ describe("regression, experiment and promoted builders (#4632)", () => {
75
+ it("regressionEvent: only a fail row that filed a regression issue", () => {
76
+ assert.equal(regressionEvent({ issue: 5, outcome: "pass" }), null);
77
+ assert.equal(regressionEvent({ issue: 5, outcome: "fail" }), null);
78
+ const r = regressionEvent({ issue: 5, outcome: "fail", regression: 7, detail: "broke" });
79
+ assert.equal(r.type, "regression.detected"); assert.equal(r.ctx.issue, 5);
80
+ assert.deepEqual(r.data, { subject: "post-deploy check of #5", metric: "post_deploy_check", detail: "broke" });
81
+ });
82
+ it("experimentEvent: met, missed, inconclusive", () => {
83
+ const t = (...p) => Object.fromEntries(p.map((pass, i) => [`t${i}`, { pass }]));
84
+ assert.equal(experimentEvent({ id: "e", targets: t(true), targets_met: true }).data.verdict, "met");
85
+ assert.equal(experimentEvent({ id: "e", targets: t(true, false), targets_met: false }).data.verdict, "missed");
86
+ assert.equal(experimentEvent({ id: "e", targets: t(null), targets_met: false }).data.verdict, "inconclusive");
87
+ assert.equal(experimentEvent({}), null);
88
+ });
89
+ it("promotedEvent needs a fix; types and keys stay pinned", () => {
90
+ assert.equal(promotedEvent({}), null);
91
+ assert.deepEqual(promotedEvent({ fix: "abc", default_branch: "main" }).data, { fix: "abc", default_branch: "main" });
92
+ assert.deepEqual(EVENT_TYPES["regression.detected"], ["subject", "metric", "detail"]);
93
+ assert.deepEqual(EVENT_TYPES["experiment.verdict"], ["experiment", "verdict", "detail"]);
94
+ });
95
+ it("a regression row is written to the sink", async () => {
96
+ const dir = tmp();
97
+ const p = regressionEvent({ issue: 5, outcome: "fail", regression: 7, detail: "d" });
98
+ await makeEventEmitter({ repo: "o/r", config: { dir }, env: {} }).emitEvent(p.type, p.data, p.ctx);
99
+ const ev = JSON.parse(readFileSync(join(dir, "o_r.jsonl"), "utf8"));
100
+ assert.equal(ev.type, "regression.detected"); assert.equal(ev.issue, 5);
101
+ });
102
+ });
103
+
104
+ describe("webhook", () => {
105
+ const cfg = { webhookUrl: "https://hooks.example/x", secretEnv: "MY_SECRET", dir: undefined };
106
+ it("signs `${timestamp}.${body}` with HMAC-SHA256 from the env secret", async () => {
107
+ const calls = [];
108
+ const fetchFn = async (url, init) => { calls.push([url, init]); return { ok: true }; };
109
+ const { emitEvent } = makeEventEmitter({ repo: "o/r", config: { ...cfg, dir: tmp() }, env: { MY_SECRET: "s3" }, fetchFn, now: fixed });
110
+ const ev = await emitEvent("pr.opened", {}, { issue: 1, pr: 2 });
111
+ assert.equal(calls.length, 1);
112
+ const [url, init] = calls[0];
113
+ assert.equal(url, cfg.webhookUrl); assert.equal(init.method, "POST");
114
+ assert.equal(init.body, JSON.stringify(ev));
115
+ const ts = init.headers["x-forge-timestamp"];
116
+ assert.equal(ts, String(Math.floor(fixed().getTime() / 1000)));
117
+ assert.equal(init.headers["x-forge-signature"], `sha256=${createHmac("sha256", "s3").update(`${ts}.${init.body}`).digest("hex")}`);
118
+ assert.equal(init.headers["x-forge-event"], "pr.opened");
119
+ assert.equal(sign("s3", ts, init.body), init.headers["x-forge-signature"].slice(7));
120
+ });
121
+
122
+ it("sends nothing without the secret, with a non-https URL, or when off", async () => {
123
+ let n = 0;
124
+ const fetchFn = async () => { n++; return { ok: true }; };
125
+ for (const [config, env] of [[cfg, {}], [{ ...cfg, webhookUrl: "http://evil.example/x" }, { MY_SECRET: "s" }], [cfg, { MY_SECRET: "s", FORGE_EVENTS: "off" }]]) {
126
+ await makeEventEmitter({ repo: "o/r", config: { ...config, dir: tmp() }, env, fetchFn }).emitEvent("pr.opened", {}, { issue: 1 });
127
+ }
128
+ assert.equal(n, 0);
129
+ assert.equal(webhookUrlOk("http://localhost:3000/h"), true); assert.equal(webhookUrlOk("ftp://x"), false);
130
+ });
131
+
132
+ it("off writes no file; sink and webhook failures never throw", async () => {
133
+ const dir = tmp();
134
+ await makeEventEmitter({ repo: "o/r", config: { dir }, env: { FORGE_EVENTS: "off" } }).emitEvent("pr.opened", {}, { issue: 1 });
135
+ assert.equal(existsSync(join(dir, "o_r.jsonl")), false);
136
+ const boom = async () => { throw new Error("net"); };
137
+ writeFileSync(join(dir, "f"), ""); const bad = makeEventEmitter({ repo: "o/r", config: { ...cfg, dir: join(dir, "f", "x") }, env: { MY_SECRET: "s" }, fetchFn: boom });
138
+ assert.ok(await bad.emitEvent("pr.opened", {}, { issue: 1 }));
139
+ await bad.emit({ event: "PR_OPENED", pr: 1, issue: 1 });
140
+ assert.equal(await postWebhook({ type: "x" }, { url: cfg.webhookUrl, secret: "s", fetchFn: boom }), false);
141
+ });
142
+ });
@@ -32,17 +32,20 @@ export const OUTCOME_CODES = Object.freeze({
32
32
  REMEDIATE_UNFIXABLE: "remediate.unfixable",
33
33
  REMEDIATE_INPR_REQUESTED: "remediate.inpr_requested",
34
34
  CI_RED: "ci.red",
35
+ CI_DCO: "ci.dco", // forge#4651: a failed DCO check (or no identity to sign off the lane's commits); never recoverable: a new commit cannot fix history
35
36
  CI_TIMEOUT: "ci.timeout",
36
37
  BASE_CONFLICT: "base.conflict",
37
38
  RATE_LIMITED: "rate_limited",
38
39
  HOST_BUSY: "host.busy",
39
40
  GH_UNAVAILABLE: "gh.unavailable", // forge#4157: `gh` unreachable after retries during the --repo identity check; run deferred, nothing posted
41
+ GH_AUTH: "gh.auth", // forge#4662: gh answered HTTP 401 and one token refresh did not fix it; run deferred, a rerun resumes
40
42
  MERGE_TRANSIENT: "merge.transient",
41
43
  PUSH_TRANSIENT: "push.transient",
42
44
  CLI_IDLE: "cli.idle",
43
45
  RUN_INTERRUPTED: "run.interrupted", // forge#4179: SIGTERM/SIGINT on a core run; teardown ran from the ledger, lane requeued
44
46
  PATH_SWITCH_CONFLICT: "path.switch_conflict",
45
47
  ENGINE_ERROR: "engine.error",
48
+ ENGINE_VALIDATE_RESULT_LOST: "engine.validate_result_lost", // forge#4708: a validate result recorded before a CI fix round is missing after it and could not be carried forward
46
49
  INFRA_DISK_QUOTA: "infra.disk_quota",
47
50
  INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
48
51
  CLI_TIMEOUT: "cli.timeout",
@@ -51,9 +54,12 @@ export const OUTCOME_CODES = Object.freeze({
51
54
  ENV_SETUP_FAILED: "env.setup_failed", // forge#4163: forge.yaml env.setup failed before the builder; an environment error, not a build failure
52
55
  ENV_TOOL_MISSING: "env.tool_missing", // forge#4323: a validate command could not run because a host tool or module is missing; an environment error, not a conformance failure
53
56
  ENV_REPRO_UNRUNNABLE: "env.repro_unrunnable", // forge#4334: the repro harness could not run the repro test on head; an environment error, not a conformance failure
57
+ ENV_LANE_LIMIT: "env.lane_limit", // forge#4690: the lane hit its own scope limit (TasksMax or MemoryMax) and was stopped; an environment error, never an engine-error, so the breaker ignores it
54
58
  ENV_CAPABILITY_REMOTE: "env.capability_remote", // forge#4542: a remote sandbox lane blocked only on a host-only capability (SOPS key, ops credentials, prod monitor SSH) the dispatching host has; recovered locally
55
59
  SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start; needs-human, not relaunched
56
60
  SANDBOX_CAPACITY: "sandbox.capacity", // forge#4254: the sandbox provider had no capacity; deferred with backoff
61
+ SANDBOX_AUTH: "sandbox.auth", // forge#4685: the sandbox provider refused or lacked the credential (HTTP 401/403, no token); deferred, never needs-human
62
+ SANDBOX_UNREACHABLE: "sandbox.unreachable", // forge#4672: the sandbox provider stayed unreachable past execution.unreachable_window_min; deferred, the sandbox is kept for adopt
57
63
  LEASE_HELD: "lease.held",
58
64
  DEPENDENCY_OPEN: "dependency.open",
59
65
  DEPENDENCY_UNREADABLE: "dependency.unreadable",
@@ -79,11 +85,13 @@ export const OUTCOME_CODES = Object.freeze({
79
85
  REVIEW_INCONSISTENT: "review.inconsistent",
80
86
  CONFORMANCE_OMISSION: "conformance.omission",
81
87
  CONFORMANCE_UNIMPLEMENTED: "conformance.unimplemented",
88
+ CONFORMANCE_E2E_UNVERIFIED: "conformance.e2e_unverified",
82
89
  CONFORMANCE_SCOPE_CREEP: "conformance.scope_creep",
83
90
  CONFORMANCE_UNTRACKED_DEFERRAL: "conformance.untracked_deferral",
84
91
  CONFORMANCE_UNJUSTIFIED_DEFERRAL: "conformance.unjustified_deferral",
85
92
  CONFORMANCE_PRESERVE_VIOLATED: "conformance.preserve_violated",
86
93
  CONFORMANCE_REGRESSION: "conformance.regression",
94
+ CONFORMANCE_GUARD_BROKEN: "conformance.guard_broken", // forge#4693: the base version of a merged fix's guarding test fails against the lane's tree
87
95
  STRUCTURE_UNDECLARED_EDGE: "structure.undeclared_edge", // forge#4159: a workspace package.json edge the contract does not name
88
96
  DOCS_STALE: "docs.stale", // the target repo's devdocs freshness rule still fails after one builder resume
89
97
  MAINTENANCE_HOTSPOT_GROWTH: "maintenance.hotspot_growth", // forge#4195: an over-threshold file grew past growth_lines without hotspot-exempt
@@ -196,7 +204,17 @@ export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
196
204
  * edit (`issueEdited`), `ci-pending` on a conflicting PR (`conflicting`), and the non-human `budget-exceeded` turn-overrun end (forge#4505). Human: `builder.blocked`. Anything else
197
205
  * (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
198
206
  */
199
- export function classifyNeedsHuman(row) {
207
+ export function classifyNeedsHuman(row, { parked = false } = {}) {
208
+ const cls = classifyNeedsHumanBase(row);
209
+ // forge#4656: opt-in `parked` class: a needs-human row ended on a mechanical terminal code (not a human block, not recoverable).
210
+ return parked && cls === "terminal" && isTerminalCodeRow(row) ? "parked" : cls;
211
+ }
212
+
213
+ function isTerminalCodeRow(row) {
214
+ return (row.outcome ?? row.terminalReason) === "needs-human" && typeof row.code === "string" && row.code !== "" && row.code !== UNCLASSIFIED;
215
+ }
216
+
217
+ function classifyNeedsHumanBase(row) {
200
218
  if (!row || typeof row !== "object") return "terminal";
201
219
  const outcome = row.outcome ?? row.terminalReason;
202
220
  if (outcome === "ci-pending") return row.conflicting === true ? "recoverable" : "terminal";
@@ -211,3 +229,23 @@ export function classifyNeedsHuman(row) {
211
229
  export function recoveryCodeOf(row) {
212
230
  return (row?.outcome ?? row?.terminalReason) === "ci-pending" ? OUTCOME_CODES.BASE_CONFLICT : normalizeCode(row?.code);
213
231
  }
232
+
233
+ /** forge#4656: the label of a human-owned wait (an owner and an unblock condition are required in its comment). */
234
+ export const NEEDS_HUMAN_EXTERNAL_LABEL = "needs-human:external";
235
+ /** forge#4656: the single remediation queue (`/work-on:remediate`) a parked issue moves to once recovery attempts run out. */
236
+ export const REMEDIATION_QUEUE_LABEL = "remediation-queue";
237
+ /** forge#4656: `parked:<terminal-code>` label of a mechanical park. */
238
+ export function parkedLabel(code) {
239
+ return `parked:${String(code ?? UNCLASSIFIED).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").slice(0, 40) || UNCLASSIFIED}`;
240
+ }
241
+
242
+ /**
243
+ * forge#4656 migration: classify a legacy bare `needs-human` issue from its last FORGE terminal annotation text.
244
+ * `{ kind: "parked", code }` when the text names a mechanical terminal code, else `{ kind: "external" }` (the default).
245
+ */
246
+ export function classifyLegacyNeedsHuman(annotation) {
247
+ const text = String(annotation ?? "");
248
+ const m = /\b(ci\.red|ci\.timeout|base\.conflict|review_incomplete|builder\.block_untyped|conformance\.[a-z_]+|contract\.invalid|env\.[a-z_]+)\b/i.exec(text);
249
+ if (m) return { kind: "parked", code: m[1].toLowerCase() };
250
+ return { kind: "external" };
251
+ }
@@ -14,6 +14,7 @@
14
14
  import { PHASE_MARKERS } from "../../packages/protocol/src/phases.js";
15
15
  // forge#3833: closed outcome-code enum; every code below comes from structured evidence, never `detail` prose.
16
16
  import { OUTCOME_CODES as OC } from "./outcome-codes.mjs";
17
+ import { parseState } from "./state.mjs";
17
18
 
18
19
  // forge#2261: "engine-error" is a distinct terminal reason for engine/tool-level
19
20
  // failures (e.g. an exhausted retry loop where the runner itself never once
@@ -355,7 +356,16 @@ export function parseDependsOn(body, issue = null) {
355
356
  * when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
356
357
  * An empty read of the issue itself means "no body to trust" and yields `null`.
357
358
  */
358
- export async function openDependency(issue, io) {
359
+ export async function openDependency(issue, io, { lane = null } = {}) {
360
+ const satisfied = [];
361
+ const prsOnLane = lane ? laneMergedPrs(lane, io) : null;
362
+ /** An open dependency whose change is already on `lane` is satisfied (records the merge commit). */
363
+ const onLane = async (n, rec = null) => {
364
+ if (!prsOnLane) return false;
365
+ const hit = await dependencyOnLane(n, lane, await prsOnLane, io, rec);
366
+ if (hit) satisfied.push({ issue: n, ...hit });
367
+ return !!hit;
368
+ };
359
369
  let rec;
360
370
  try { rec = await readIssueRecord(issue, io); } catch (e) {
361
371
  return { unreadable: true, detail: `issue ${issue} could not be read for its **Depends on** line: ${e?.message || e}` };
@@ -374,21 +384,71 @@ export async function openDependency(issue, io) {
374
384
  // when all close). No usable trail (none, untrusted, revoked, unreadable) keeps the parent itself open (fail closed).
375
385
  if (isDecomposedRecord(d) && (st === "open" || st === "closed")) {
376
386
  const subs = await decomposedSubissues(dep, io);
377
- if (!subs) { open.push(dep); continue; }
387
+ if (!subs) { if (!(st === "open" && await onLane(dep, d))) open.push(dep); continue; }
378
388
  for (const s of subs) {
379
389
  let sr;
380
390
  try { sr = await readIssueRecord(s, io); } catch (e) {
381
391
  return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} could not be read: ${e?.message || e}` };
382
392
  }
383
393
  const ss = typeof sr?.state === "string" ? sr.state.toLowerCase() : "";
384
- if (ss === "open") open.push(s);
394
+ if (ss === "open") { if (!(await onLane(s, sr))) open.push(s); }
385
395
  else if (ss !== "closed") return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} has no readable state` };
386
396
  }
387
397
  }
388
- else if (st === "open") open.push(dep);
398
+ else if (st === "open") { if (!(await onLane(dep, d))) open.push(dep); }
389
399
  else if (st !== "closed") return { unreadable: true, detail: `dependency #${dep} of issue ${issue} has no readable state` };
390
400
  }
391
- return open.length ? { open: [...new Set(open)].filter((n) => n !== Number(issue)) } : null;
401
+ const still = [...new Set(open)].filter((n) => n !== Number(issue));
402
+ if (still.length) return { open: still, ...(satisfied.length ? { satisfied } : {}) };
403
+ return satisfied.length ? { satisfied } : null;
404
+ }
405
+
406
+ /** Merged PRs on base `lane` (null when the list cannot be read: fail closed, nothing is satisfied). */
407
+ export async function laneMergedPrs(lane, io) {
408
+ try {
409
+ const out = String((await io.gh(["pr", "list", "--state", "merged", "--base", lane, "--limit", "200", "--json", "number,mergeCommit,closingIssuesReferences,title,body"])) ?? "").trim();
410
+ const j = out ? JSON.parse(out) : [];
411
+ return Array.isArray(j) ? j : null;
412
+ } catch { return null; }
413
+ }
414
+
415
+ /**
416
+ * #4711: the `mergedTo` {lane, pr, sha} fact on a trusted issue record's FORGE:STATE, when it names `lane`, the PR reads MERGED with that
417
+ * merge commit, and the commit is an ancestor of `origin/<lane>` ({ pr, sha }), else null. Any lookup error is "not on the lane".
418
+ */
419
+ async function mergedToByState(lane, io, rec) {
420
+ if (!rec || typeof rec.body !== "string" || !isTrustedComment(rec)) return null;
421
+ const m = parseState(rec.body)?.mergedTo;
422
+ if (!m || m.lane !== lane || !Number.isInteger(m.pr) || typeof m.sha !== "string" || !/^[0-9a-f]{7,64}$/i.test(m.sha)) return null;
423
+ try {
424
+ const pr = JSON.parse(String((await io.gh(["pr", "view", String(m.pr), "--json", "state,mergeCommit,baseRefName"])) ?? "{}"));
425
+ if (String(pr.state).toUpperCase() !== "MERGED" || pr.mergeCommit?.oid !== m.sha || (pr.baseRefName && pr.baseRefName !== lane)) return null;
426
+ try { await io.git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
427
+ await io.git(["merge-base", "--is-ancestor", m.sha, `origin/${lane}`]);
428
+ return { pr: m.pr, sha: m.sha };
429
+ } catch { return null; }
430
+ }
431
+
432
+ /**
433
+ * #4686: the PR that closes or references issue `n` and whose merge commit is an ancestor of `origin/<lane>`
434
+ * ({ pr, sha }), else null. Judged by commit ancestry, not issue state; any lookup error is "not on the lane".
435
+ */
436
+ export async function dependencyOnLane(n, lane, prs, io, rec = null) {
437
+ const stated = await mergedToByState(lane, io, rec);
438
+ if (stated) return stated;
439
+ if (!Array.isArray(prs)) return null;
440
+ const ref = new RegExp(`(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?|references?|part of)[ \\t:]+(?:[\\w.-]+/[\\w.-]+)?#${n}(?!\\d)`, "i");
441
+ const cands = prs.filter((p) => typeof p?.mergeCommit?.oid === "string" && p.mergeCommit.oid
442
+ && ((p.closingIssuesReferences ?? []).some((r) => Number(r?.number) === n) || ref.test(`${p.title ?? ""}\n${p.body ?? ""}`)));
443
+ if (!cands.length) return null;
444
+ try { await io.git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
445
+ for (const p of cands) {
446
+ try {
447
+ await io.git(["merge-base", "--is-ancestor", p.mergeCommit.oid, `origin/${lane}`]);
448
+ return { pr: p.number, sha: p.mergeCommit.oid };
449
+ } catch { /* not on the lane */ }
450
+ }
451
+ return null;
392
452
  }
393
453
 
394
454
  /**
@@ -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
- return { event: "run_terminal", phase: null, ...prOf(rec), payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs,
66
+ return { event: "run_terminal", phase: null, ...prOf(rec), payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs, guard_added_ms: rec.guard_added_ms, // #4705
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,
@@ -80,6 +80,10 @@ describe("engine telemetry seam", () => {
80
80
  assert.deepEqual(p2, { reason: "merged", wall_ms: 0, milestone: "M", conformance_mode: "shadow", shadow_would: "conforms" });
81
81
  assert.equal(mapRecord({ event: "RUN_TERMINAL", reason: "merged", pr: 0 }, 1).pr, undefined, "no pr 0 in ctx");
82
82
  });
83
+ it("#4705: RUN_TERMINAL carries the guard gate's added wall time beside wall_ms", () => {
84
+ const m = mapRecord({ event: "RUN_TERMINAL", reason: "merged", wall_ms: 1000, guard_added_ms: 40 }, 1);
85
+ assert.equal(m.payload.guard_added_ms, 40);
86
+ });
83
87
  it("forge#4255: SANDBOX_USAGE maps to sandbox_usage with provider, minutes and cost", async () => {
84
88
  const { calls, emit } = harness();
85
89
  await emit({ event: "SANDBOX_USAGE", issue: 7, provider: "container", sandbox_id: "fdsbx-b-7", minutes: 12.5, cost_usd: 0.1234567891, batch: "b1" });