forgedock 1.36.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 (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -0,0 +1,26 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /** #4662: bot token lifetime from the stamp scripts/refresh-bot-token.sh writes (~/.forge/bot-token.json). Pure; `doctor` renders it. */
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { homedir } from "node:os";
7
+
8
+ export const MIN_REMAINING_MS = 10 * 60 * 1000;
9
+ export const MAX_AGE_MS = 50 * 60 * 1000;
10
+
11
+ export const stampPath = (env = process.env) => join(env.FORGE_HOME_DIR || join(homedir(), ".forge"), "bot-token.json");
12
+
13
+ /** @returns {{configured:boolean, ok:boolean, remainingMs?:number, ageMs?:number, refreshedAt?:string, reason?:string}} */
14
+ export function botTokenStatus({ now = Date.now(), env = process.env, read = (p) => readFileSync(p, "utf8") } = {}) {
15
+ let raw;
16
+ try { raw = read(stampPath(env)); } catch { return { configured: false, ok: true }; }
17
+ let s;
18
+ try { s = JSON.parse(raw); } catch { return { configured: true, ok: false, reason: "bot-token.json is not valid JSON" }; }
19
+ const refreshed = Date.parse(s?.refreshedAt), expires = Date.parse(s?.expiresAt);
20
+ if (!Number.isFinite(refreshed) || !Number.isFinite(expires)) return { configured: true, ok: false, reason: "bot-token.json has no refreshedAt/expiresAt" };
21
+ const remainingMs = expires - now, ageMs = now - refreshed;
22
+ const out = { configured: true, remainingMs, ageMs, refreshedAt: s.refreshedAt };
23
+ if (remainingMs < MIN_REMAINING_MS) return { ...out, ok: false, reason: remainingMs <= 0 ? "token expired" : "less than 10 min of token lifetime remain" };
24
+ if (ageMs > MAX_AGE_MS) return { ...out, ok: false, reason: "no refresh in the last 50 min" };
25
+ return { ...out, ok: true };
26
+ }
@@ -65,11 +65,20 @@ export function experimentEvent(report) {
65
65
  return { type: "experiment.verdict", data: { experiment: report.id, verdict, detail: `${passes.length} target(s)` }, ctx: {} };
66
66
  }
67
67
 
68
- /** A fix reached the default branch -> release.promoted; null without a fix. Reserved: no detection emits it yet. */
68
+ /** A fix reached the default branch -> release.promoted; null without a fix. Emitted by the probe sweep (probeSweepEvents). */
69
69
  export function promotedEvent({ fix, default_branch } = {}) {
70
70
  return typeof fix === "string" && fix ? { type: "release.promoted", data: { fix, default_branch }, ctx: {} } : null;
71
71
  }
72
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
+
73
82
  /**
74
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
75
84
  * from PHASE_ATTEMPT_USAGE (`cost_usd_delta` when present, else `cost_usd`).
@@ -7,7 +7,7 @@ import { readFileSync, existsSync, writeFileSync } from "node:fs";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
8
  import { join } from "node:path";
9
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";
10
+ import { EVENT_TYPES, EVENT_VERSION, buildEvent, mapRunRecord, makeEventEmitter, probeEvent, probeSweepEvents, regressionEvent, experimentEvent, promotedEvent, sign, webhookUrlOk, postWebhook } from "./events.mjs";
11
11
 
12
12
  const tmp = () => tmpDir("forge-events-");
13
13
  const fixed = () => new Date("2026-10-26T12:00:00Z");
@@ -57,6 +57,20 @@ describe("event contract (v1)", () => {
57
57
  });
58
58
  });
59
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
+
60
74
  describe("regression, experiment and promoted builders (#4632)", () => {
61
75
  it("regressionEvent: only a fail row that filed a regression issue", () => {
62
76
  assert.equal(regressionEvent({ issue: 5, outcome: "pass" }), null);
@@ -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
  /**
@@ -63,7 +63,7 @@ export function mapRecord(rec, wallMs) {
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
69
  builder_escalated: typeof rec.builder_escalated === "boolean" ? Number(rec.builder_escalated) : undefined,
@@ -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" });