forgedock 1.29.1 → 1.31.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 (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +356 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -0,0 +1,205 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { formatFirstPassSummary } from "./fixround.mjs";
9
+ import { runChecks, formatPrecheckSummary, planPrecheck, selfCheck, selfCheckState, formatCheckTable, worktreeFingerprint, quietFailure } from "./precheck.mjs";
10
+ import { execFileSync } from "node:child_process";
11
+ import { readPrecheckBudget } from "../engine-cli.mjs";
12
+ import { orchestrate } from "./orchestrate.mjs";
13
+
14
+ /** A fake run: each call takes `cost[command]` ms of the shared clock (default 1000) and returns rc[command] (default 0). */
15
+ function harness({ cost = {}, rc = {} } = {}) {
16
+ const h = { t: 0, live: 0, peak: 0, calls: [] };
17
+ h.now = () => h.t;
18
+ h.run = async ({ command, timeoutMs }) => {
19
+ h.calls.push([command, timeoutMs]); h.peak = Math.max(h.peak, ++h.live);
20
+ await new Promise((r) => setImmediate(r));
21
+ const c = cost[command] ?? 1000;
22
+ h.t += Math.min(c, timeoutMs); h.live--;
23
+ return c > timeoutMs ? { rc: 124, output: "timed out" } : { rc: rc[command] ?? 0, output: `${command} out` };
24
+ };
25
+ return h;
26
+ }
27
+
28
+ describe("runChecks (#4331)", () => {
29
+ it("runs up to `parallelism` commands at once and returns results in input order with ms", async () => {
30
+ const h = harness();
31
+ const r = await runChecks({ commands: ["a", "b", "c", "d", "e"], parallelism: 3, budgetMs: 600000, perCommandMs: 300000, now: h.now, run: h.run });
32
+ assert.equal(h.peak, 3);
33
+ assert.deepEqual(r.results.map((x) => x.command), ["a", "b", "c", "d", "e"]);
34
+ assert.ok(r.results.every((x) => x.rc === 0 && Number.isFinite(x.ms) && x.ms >= 0));
35
+ assert.deepEqual(r.deferred, []);
36
+ assert.ok(r.ms > 0);
37
+ });
38
+
39
+ it("parallelism 1 is serial; 0, NaN or missing fall back to 1", async () => {
40
+ for (const p of [1, 0, NaN, undefined, "x"]) {
41
+ const h = harness();
42
+ await runChecks({ commands: ["a", "b", "c"], parallelism: p, budgetMs: 600000, perCommandMs: 300000, now: h.now, run: h.run });
43
+ assert.equal(h.peak, 1, String(p));
44
+ }
45
+ });
46
+
47
+ it("past the budget the remaining commands are deferred to CI, not failed; a running one gets what is left", async () => {
48
+ const h = harness();
49
+ const r = await runChecks({ commands: ["a", "b", "c"], parallelism: 1, budgetMs: 1500, perCommandMs: 300000, now: h.now, run: h.run });
50
+ assert.deepEqual(h.calls, [["a", 1500], ["b", 500]]);
51
+ assert.deepEqual(r.results.map((x) => [x.command, x.rc]), [["a", 0]], "b was cut by the budget (rc 124): deferred, not a failure");
52
+ assert.deepEqual(r.deferred, ["b", "c"]);
53
+ });
54
+
55
+ it("a command's own timeout (not the budget) stays a failure; a throw is rc 127", async () => {
56
+ const h = harness({ cost: { slow: 5000 } });
57
+ const r = await runChecks({ commands: ["slow", "boom", "bad"], parallelism: 2, budgetMs: 600000, perCommandMs: 2000, now: h.now,
58
+ run: (a) => (a.command === "boom" ? Promise.reject(new Error("spawn failed")) : a.command === "bad" ? Promise.resolve({ rc: 2, output: "lint" }) : h.run(a)) });
59
+ assert.deepEqual(r.results.map((x) => [x.command, x.rc]), [["slow", 124], ["boom", 127], ["bad", 2]]);
60
+ assert.match(r.results[1].output, /spawn failed/);
61
+ assert.deepEqual(r.deferred, []);
62
+ });
63
+
64
+ it("no commands: nothing runs", async () => {
65
+ let n = 0;
66
+ const r = await runChecks({ commands: [], parallelism: 4, budgetMs: 1000, perCommandMs: 1000, now: () => 0, run: async () => { n++; return { rc: 0 }; } });
67
+ assert.deepEqual([n, r.results, r.deferred, r.ms], [0, [], [], 0]);
68
+ });
69
+ });
70
+
71
+ describe("runChecks (#4344)", () => {
72
+ /** A fake heavy semaphore: counts live slots, their peak and releases; `wait` ms of the shared clock per acquire. */
73
+ const sem = (h, wait = 0) => { const s = { live: 0, peak: 0, acquired: 0, released: 0 };
74
+ s.acquire = async () => { s.acquired++; s.peak = Math.max(s.peak, ++s.live); h.t += wait; return { release: () => { s.live--; s.released++; } }; }; return s; };
75
+
76
+ it("#4344 divides the worker budget across concurrent commands and takes one heavy slot per running command", async () => {
77
+ for (const [n, cmds, share, peak] of [[8, ["a", "b", "c", "d", "e", "f", "g", "h"], 1, 8], [8, ["a", "b"], 4, 2], [6, ["a", "b", "c", "d"], 1, 4], [5, ["a", "b"], 2, 2], [4, ["a"], 4, 1]]) {
78
+ const h = harness(), s = sem(h), seen = [];
79
+ await runChecks({ commands: cmds, parallelism: n, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: s.acquire, run: (a) => { seen.push(a.workers); return h.run(a); } });
80
+ assert.ok(seen.every((w) => w === share), `N=${n} k=${cmds.length}: ${seen}`);
81
+ assert.ok(share * h.peak <= n, `round total ${share * h.peak} workers > N=${n}`);
82
+ assert.deepEqual([s.peak, s.acquired, s.released, s.live], [peak, cmds.length, cmds.length, 0], `N=${n} slots`);
83
+ }
84
+ });
85
+
86
+ it("#4344 FORGE_HOST_PARALLELISM=1: one command at a time, one worker, one slot", async () => {
87
+ const h = harness(), s = sem(h), seen = [];
88
+ await runChecks({ commands: ["a", "b", "c"], parallelism: 1, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: s.acquire, run: (a) => { seen.push(a.workers); return h.run(a); } });
89
+ assert.deepEqual([h.peak, s.peak, seen], [1, 1, [1, 1, 1]]);
90
+ });
91
+
92
+ it("#4344 the slot wait counts against the budget: a command whose wait exhausts it is deferred, its slot released", async () => {
93
+ const h = harness(), s = sem(h, 1000);
94
+ const r = await runChecks({ commands: ["a", "b"], parallelism: 1, budgetMs: 2500, perCommandMs: 300000, now: h.now, acquire: s.acquire, run: h.run });
95
+ assert.deepEqual(h.calls, [["a", 1500]]);
96
+ assert.deepEqual([r.results.map((x) => x.command), r.deferred], [["a"], ["b"]]);
97
+ assert.deepEqual([s.acquired, s.released], [2, 2]);
98
+ });
99
+
100
+ it("#4344 a throwing acquire runs the command ungated; a throwing release is ignored", async () => {
101
+ const h = harness();
102
+ const r1 = await runChecks({ commands: ["a"], parallelism: 2, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: async () => { throw new Error("registry"); }, run: h.run });
103
+ const r2 = await runChecks({ commands: ["b"], parallelism: 2, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: async () => ({ release: () => { throw new Error("x"); } }), run: h.run });
104
+ assert.deepEqual([...r1.results, ...r2.results].map((x) => [x.command, x.rc]), [["a", 0], ["b", 0]]);
105
+ });
106
+ });
107
+
108
+ describe("formatPrecheckSummary (#4331)", () => {
109
+ it("sums precheck_ms over the lanes that ran checks and counts deferrals; null when none did", () => {
110
+ assert.equal(formatPrecheckSummary([{ precheck_ms: 90000 }, { precheck_ms: 30000, precheck_deferred: 2 }, { issue: 3 }]), "precheck: 2.0m total over 2 lanes, 2 deferred to CI");
111
+ assert.equal(formatPrecheckSummary([{ precheck_ms: 6000 }]), "precheck: 0.1m total over 1 lane");
112
+ assert.equal(formatPrecheckSummary([{ issue: 1 }]), null);
113
+ assert.equal(formatPrecheckSummary(undefined), null);
114
+ });
115
+ it("orchestrate rows carry the lane's precheckMs/precheckDeferred as precheck_ms/precheck_deferred for the report line", async () => {
116
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "",
117
+ runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { precheckMs: 90000, precheckDeferred: 1 }, 2: { precheckMs: 30000, precheckDeferred: 0 } })[n] }) });
118
+ assert.deepEqual(rows.map((r) => [r.issue, r.precheck_ms, r.precheck_deferred]), [[1, 90000, 1], [2, 30000, undefined], [3, undefined, undefined]]);
119
+ assert.equal(formatPrecheckSummary(rows), "precheck: 2.0m total over 2 lanes, 1 deferred to CI");
120
+ });
121
+ it("#4362: orchestrate rows carry the lane's firstPass as first_pass for the batch first-pass line", async () => {
122
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "",
123
+ runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { firstPass: true }, 2: { firstPass: false } })[n] }) });
124
+ assert.deepEqual(rows.map((r) => [r.issue, r.first_pass]), [[1, true], [2, false], [3, undefined]]);
125
+ assert.equal(formatFirstPassSummary(rows), "first-pass: 50% (1/2 lanes that reached the build)");
126
+ });
127
+ });
128
+
129
+ describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", () => {
130
+ const withYaml = (text, fn) => { const d = tmpDir("precheck-"); writeFileSync(join(d, "forge.yaml"), text); return fn(d); };
131
+ it("reads the nested block and the flat key; invalid or absent is undefined", () => {
132
+ assert.equal(withYaml("lean:\n precheck:\n max_minutes: 7\n", readPrecheckBudget), 7);
133
+ assert.equal(withYaml("lean:\n precheck_max_minutes: 15\n repro_test: off\n", readPrecheckBudget), 15);
134
+ for (const bad of ["0", "-1", "abc", "121"]) assert.equal(withYaml(`lean:\n precheck_max_minutes: ${bad}\n`, readPrecheckBudget), undefined, bad);
135
+ assert.equal(withYaml("verify:\n mode: local\n", readPrecheckBudget), undefined);
136
+ assert.equal(readPrecheckBudget(tmpDir("precheck-")), undefined);
137
+ });
138
+ });
139
+
140
+ describe("forgedock check core (#4364)", () => {
141
+ const MAP = { version: 1, entries: [{ globs: ["src/**"], command: "npm test", cwd: "." }, { globs: ["docs/**"], command: "make docs", cwd: "." }] };
142
+
143
+ it("planPrecheck: the engine's selection and budget rule (max_minutes wins, else precheck_total_seconds, else 600 s)", () => {
144
+ const p = planPrecheck({ testMap: MAP, files: ["src/a.js"], learned: [" make lint ", "", "make docs"], verify: { precheckTotalSeconds: 120, precheckTimeoutSeconds: 30 } });
145
+ assert.deepEqual(p.commands, ["make lint", "npm test"]); assert.deepEqual(p.dropped, ["make docs"]);
146
+ assert.equal(p.selected, 1); assert.equal(p.budgetMs, 120000); assert.equal(p.perCommandMs, 30000);
147
+ assert.equal(planPrecheck({ learned: ["x"], maxMinutes: 2, verify: { precheckTotalSeconds: 999 } }).budgetMs, 120000);
148
+ assert.equal(planPrecheck({ learned: ["x"], maxMinutes: 3, verify: {} }).budgetMs, 180000);
149
+ assert.deepEqual(planPrecheck({ learned: ["x"], verify: {} }), { commands: ["x"], added: 0, dropped: [], source: "learned", selected: 0, budgetMs: 600000, perCommandMs: 300000 });
150
+ });
151
+
152
+ it("selfCheck runs validates then pre-PR commands; ok only when every run exits 0; ci mode leaves validates to CI", async () => {
153
+ const h = harness({ rc: { "make lint": 2 } });
154
+ const contract = { validate: [{ id: "V1", command: "node --test a" }, { id: "V2", command: " " }] };
155
+ const plan = { mode: "local", commands: ["npm test", "make lint"], budgetMs: 600000, perCommandMs: 300000 };
156
+ const r = await selfCheck({ contract, plan, now: h.now, run: h.run });
157
+ assert.deepEqual(h.calls.map((c) => c[0]), ["node --test a", "npm test", "make lint"]);
158
+ assert.deepEqual(r.validate.map((x) => [x.id, x.rc]), [["V1", 0]]); assert.deepEqual(r.commands.map((x) => x.rc), [0, 2]); assert.equal(r.ok, false);
159
+ const h2 = harness();
160
+ const r2 = await selfCheck({ contract, plan: { ...plan, mode: "ci" }, now: h2.now, run: h2.run });
161
+ assert.deepEqual(h2.calls.map((c) => c[0]), ["npm test", "make lint"]); assert.deepEqual(r2.validate, [{ id: "V1", command: "node --test a", status: "ci" }]); assert.equal(r2.ok, true);
162
+ const h3 = harness({ cost: { "npm test": 5000 } });
163
+ const r3 = await selfCheck({ contract: {}, plan: { commands: ["npm test", "make lint"], budgetMs: 4000, perCommandMs: 300000 }, now: h3.now, run: h3.run });
164
+ assert.deepEqual(r3.deferred, ["npm test", "make lint"]); assert.equal(r3.ok, true, "deferred to CI is not a failure, as in the engine");
165
+ });
166
+
167
+ it("selfCheckState and formatCheckTable", () => {
168
+ assert.equal(selfCheckState(null, "f"), "skipped"); assert.equal(selfCheckState({ fingerprint: "g", ok: true }, "f"), "stale");
169
+ assert.equal(selfCheckState({ fingerprint: "f", ok: false }, "f"), "red"); assert.equal(selfCheckState({ fingerprint: "f", ok: true }, "f"), "green");
170
+ const t = formatCheckTable({ ok: false, validate: [{ id: "V1", command: "v", rc: 0, ms: 1200 }], commands: [{ command: "make lint", rc: 2, ms: 10, output: `${"x".repeat(5000)}TAIL` }], deferred: ["slow"] }, 1000);
171
+ assert.match(t, /^PASS +1\.2s {2}V1 v$/m); assert.match(t, /^FAIL exit 2 .* make lint$/m); assert.match(t, /^DEFERRED .* slow$/m);
172
+ assert.match(t, /--- make lint \(exit 2\)\n…x+TAIL/); assert.ok(t.length < 1400, "failure output capped"); assert.match(t, /check: FAIL \(1 failing\)$/);
173
+ });
174
+
175
+ it("#4390 quietFailure: last 40 lines, failing ids across ecosystems, log pointer; short output unchanged", () => {
176
+ const out = ["FAILED tests/test_x.py::test_a - assert 1 == 2", "not ok 3 - parses dates", " FAIL src/a.test.ts > adds", "--- FAIL: TestSum (0.00s)", "test parser::empty ... FAILED", " ✕ renders header (5 ms)",
177
+ ...Array.from({ length: 100 }, (_, i) => `line ${i}`)].join("\n");
178
+ const q = quietFailure(out, { logPath: ".forge-lean/logs/check-V1.log" });
179
+ assert.match(q, /^…line 60\n/); assert.match(q, /line 99\nfailing tests:\n/);
180
+ for (const id of ["FAILED tests/test_x.py::test_a", "not ok 3 - parses dates", "FAIL src/a.test.ts > adds", "--- FAIL: TestSum", "test parser::empty ... FAILED", "✕ renders header"]) assert.ok(q.includes(` ${id}`), id);
181
+ assert.match(q, /\nfull log: \.forge-lean\/logs\/check-V1\.log$/);
182
+ assert.equal(quietFailure("ok\nall good\n"), "ok\nall good");
183
+ assert.equal(quietFailure(""), "(no output)");
184
+ assert.equal(quietFailure("x".repeat(50), { maxChars: 10 }), `…${"x".repeat(10)}`);
185
+ });
186
+
187
+ it("worktreeFingerprint: the same content committed or not is the same tree; an edit or new file changes it; .forge-lean is ignored", async () => {
188
+ const dir = tmpDir("selfcheck-fp-");
189
+ const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.invalid", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
190
+ const g = (a) => execFileSync("git", a, { cwd: dir, encoding: "utf8", env });
191
+ g(["init", "-q", "-b", "main"]); writeFileSync(join(dir, "a.js"), "1\n"); writeFileSync(join(dir, "gone.js"), "x\n"); g(["add", "-A"]); g(["commit", "-q", "-m", "base"]);
192
+ g(["branch", "base"]);
193
+ const fp = () => worktreeFingerprint({ git: async (a) => g(a), base: "base" });
194
+ const clean = await fp();
195
+ writeFileSync(join(dir, "a.js"), "2\n"); writeFileSync(join(dir, "new.js"), "n\n"); g(["rm", "-q", "gone.js"]);
196
+ const dirty = await fp(); assert.notEqual(dirty, clean);
197
+ execFileSync("mkdir", ["-p", join(dir, ".forge-lean")]); writeFileSync(join(dir, ".forge-lean", "result.json"), "{}");
198
+ g(["add", "-A"]); g(["reset", "-q", "--", ".forge-lean"]);
199
+ writeFileSync(join(dir, ".git", "info", "exclude"), ".forge-lean/\n");
200
+ g(["commit", "-q", "-m", "work"]);
201
+ assert.equal(await fp(), dirty, "committing the checked tree does not make the check stale");
202
+ writeFileSync(join(dir, "a.js"), "3\n");
203
+ assert.notEqual(await fp(), dirty);
204
+ });
205
+ });
@@ -10,12 +10,17 @@
10
10
  * 3. every repo path the issue names is gone from origin/<lane> -> candidate
11
11
  * A candidate gets ONE read-only, single-turn validation on a small model in the main checkout (no worktree). Only an
12
12
  * ALREADY-FIXED/INVALID verdict with evidence closes the issue; everything else, and every error, goes to the normal lane.
13
- * All effects are injected (gh, git, runner, transitionLabel, projector?) so it is unit-testable.
13
+ * Post-deploy gate (#4389), with `defaultBranch`: a `FORGE:DEFERRED_FROM: #P` follow-up whose checklist items are all
14
+ * post-deploy is parked (`parked: true`, label `awaiting-promotion`, no lane) while a merge SHA of #P is not an ancestor
15
+ * of origin/<default>, or while forge.yaml `post_deploy.precondition_cmd` exits 1. Anything unresolvable fails open.
16
+ * All effects are injected (gh, git, runner, transitionLabel, projector?, execCode?) so it is unit-testable.
14
17
  */
18
+ import { execFile } from "node:child_process";
15
19
  import { capComment, sanitizeMd, noComments } from "./render.mjs";
16
20
  import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prompts.mjs";
17
21
  import { isTrustedComment } from "../engine/phases.mjs";
18
22
  import { APP_SLUG } from "../init-repo.mjs";
23
+ import { probeResultOf } from "./probe.mjs";
19
24
 
20
25
  export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
21
26
  export const PREFILTER_MARKER = "FORGE:PREFILTER";
@@ -54,9 +59,20 @@ const isMarked = (c) => String(c?.body ?? "").trimStart().startsWith(`<!-- ${PRE
54
59
  * CONTRIBUTOR, never `[bot]`) plus the token's own user (`gh api user`; an App installation token cannot read it).
55
60
  */
56
61
  export async function ownLogins(gh) {
62
+ return (await resolveOwnLogins(gh)).logins;
63
+ }
64
+ /**
65
+ * #4318: {logins, resolved}. When `gh api user` fails (an App installation token), the App's own slug from `gh api app`
66
+ * (a differently named App); `resolved` is false when neither lookup answered, so the batch memo may ask once more.
67
+ */
68
+ export async function resolveOwnLogins(gh) {
57
69
  const out = new Set([bareLogin(APP_SLUG)]);
58
- try { const me = bareLogin(await gh(["api", "user", "--jq", ".login"])); if (me) out.add(me); } catch { /* installation token: the App slug only */ }
59
- return [...out];
70
+ let resolved = false;
71
+ try { const me = bareLogin(await gh(["api", "user", "--jq", ".login"])); if (me) { out.add(me); resolved = true; } } catch { /* installation token */ }
72
+ if (!resolved) {
73
+ try { const slug = bareLogin(await gh(["api", "app", "--jq", ".slug"])); if (slug) { out.add(slug); resolved = true; } } catch { /* the App slug only */ }
74
+ }
75
+ return { logins: [...out], resolved };
60
76
  }
61
77
  /**
62
78
  * #4305: why one green run is no proof for this issue, without an API call: flaky wording or a `flaky` label, or an
@@ -67,11 +83,15 @@ export function flakyReason(iss, own = [bareLogin(APP_SLUG)]) {
67
83
  if (FLAKY_RE.test(`${iss.title ?? ""}\n${noComments(String(iss.body ?? ""), " ")}`)) return { reason: "flaky", why: "issue says the failure is intermittent" };
68
84
  if ((iss.labels ?? []).some((l) => String(l?.name ?? l).toLowerCase() === "flaky")) return { reason: "flaky", why: "flaky label" };
69
85
  const mine = new Set((own ?? []).map(bareLogin));
86
+ // #4399: the latest pipeline FORGE:PROBE result is a fail: the fix did not hold in production, so no already-fixed close
87
+ const probe = (iss.comments ?? []).filter((c) => probeResultOf(c?.body) && (trusted(c) || mine.has(bareLogin(loginOf(c))))).at(-1);
88
+ if (probe && probeResultOf(probe.body).result === "fail") return { reason: "probe-failed", why: "acceptance probe failed after promotion" };
70
89
  if ((iss.comments ?? []).some((c) => isMarked(c) && (trusted(c) || mine.has(bareLogin(loginOf(c)))))) return { reason: "reopened", why: "reopened after a pre-filter close" };
71
90
  return null;
72
91
  }
73
92
  /** The own logins, looked up only when an otherwise-untrusted marker comment needs them (no call for most issues). */
74
- const ownFor = async (iss, resolve) => ((iss.comments ?? []).some((c) => isMarked(c) && !trusted(c)) ? resolve() : undefined);
93
+ const ownFor = async (iss, resolve) => ((iss.comments ?? []).some((c) => (isMarked(c) || probeResultOf(c?.body)) && !trusted(c)) ? resolve() : undefined);
94
+ const selfResolver = (selfLogins, gh) => (typeof selfLogins === "function" ? selfLogins : selfLogins ? async () => selfLogins : () => ownLogins(gh));
75
95
 
76
96
  const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
77
97
  /** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
@@ -104,7 +124,7 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
104
124
  const sha = latest.head_sha ?? "";
105
125
  // #4305: a workflow that failed and passed at the same SHA is flaky; the lookup is any branch (PR run vs push)
106
126
  // and only made when the issue itself gave no reason. An unreadable lookup throws: no CI signal.
107
- const own = await ownFor(iss, typeof selfLogins === "function" ? selfLogins : selfLogins ? async () => selfLogins : () => ownLogins(io.gh));
127
+ const own = await ownFor(iss, selfResolver(selfLogins, io.gh));
108
128
  let skip = flakyReason(iss, own);
109
129
  const same = { reason: "same-sha", why: `failed and passed at ${sha.slice(0, 12)}` };
110
130
  if (!skip && sha && failed.head_sha === sha) skip = same;
@@ -128,21 +148,166 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
128
148
  return sig;
129
149
  }
130
150
 
151
+ // #4389: post-deploy precondition gate. A follow-up filed by scripts/file-deferred-followups.sh whose items are all
152
+ // post-deploy checks cannot be done before its parent's fix is on the default branch: a git fact, so no lane learns it.
153
+ export const PRECONDITION_MARKER = "FORGE:PRECONDITION";
154
+ export const AWAITING_PROMOTION_LABEL = "awaiting-promotion";
155
+ const DEFERRED_FROM_RE = /<!--\s*FORGE:DEFERRED_FROM:\s*#(\d+)\s*-->/;
156
+ /** The parent #P of a deferred follow-up (`<!-- FORGE:DEFERRED_FROM: #P -->`), or null. */
157
+ export function deferredFrom(body) {
158
+ const m = DEFERRED_FROM_RE.exec(String(body ?? ""));
159
+ return m ? Number(m[1]) : null;
160
+ }
161
+ const CHECK_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
162
+ /** True when the body has checklist items and every one is a ``- [ ] `R#: …` — post-deploy: why`` line (conformance followupItems). */
163
+ export function postDeployOnly(body) {
164
+ const items = noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null);
165
+ return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t));
166
+ }
167
+ const SHA_RE = /^[0-9a-f]{7,40}$/i;
168
+ /**
169
+ * #4467: an issue comment the engine may trust, in REST shape (`user.type`) or `gh issue view` shape (`author.login`,
170
+ * `authorAssociation`): what isTrustedComment trusts, or the ForgeDock App, which `gh issue view` reports as a bare
171
+ * `rapiercraft-forgedock` CONTRIBUTOR with no type and no `[bot]` suffix (#4315).
172
+ */
173
+ export const isTrustedIssueComment = (c) => Boolean(c) && (trusted(c) || bareLogin(loginOf(c)) === bareLogin(APP_SLUG));
174
+ /** Merge commits of the merged PRs that claim to fix #parent; else the `merged at <sha>` of its trusted FORGE:TRAJECTORY. */
175
+ export async function parentMergeShas({ parent, repo, io }) {
176
+ const shas = new Set();
177
+ try {
178
+ const prs = JSON.parse(String(await io.gh(["pr", "list", "-R", repo, "--state", "merged", "--search", `#${parent}`, "--json", "number,title,body,mergeCommit", "--limit", "20"])) || "[]");
179
+ for (const p of prs) if ((claimsFix(p.title, parent) || claimsFix(p.body, parent)) && SHA_RE.test(p.mergeCommit?.oid ?? "")) shas.add(p.mergeCommit.oid);
180
+ } catch { /* unreadable: try the trajectory */ }
181
+ if (!shas.size) {
182
+ try {
183
+ const v = JSON.parse(String(await io.gh(["issue", "view", String(parent), "-R", repo, "--json", "comments"])));
184
+ for (const c of v.comments ?? []) {
185
+ const b = String(c?.body ?? "");
186
+ const m = b.includes("FORGE:TRAJECTORY") && isTrustedIssueComment(c) ? /merged at `?([0-9a-f]{7,40})\b/i.exec(b) : null;
187
+ if (m) shas.add(m[1]);
188
+ }
189
+ } catch { /* unreadable: no SHA */ }
190
+ }
191
+ return [...shas];
192
+ }
193
+ /** Default `io.execCode`: the exit code of an argv-only child (-1 when it could not run or timed out). */
194
+ function execCode(cmd, args, opts) {
195
+ return new Promise((res) => {
196
+ try { execFile(cmd, args, opts, (err) => res(err ? (typeof err.code === "number" ? err.code : -1) : 0)); } catch { res(-1); }
197
+ });
198
+ }
199
+ /**
200
+ * #4389: never throws, no model call. `defaultBranch` is a name or an (async) function resolving it (and fetching it).
201
+ * @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, detail:string}>}
202
+ * Fail-open: no merge SHA, a git answer other than yes/no, an unknown default branch or a hook error give "unresolved".
203
+ */
204
+ export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd = null }) {
205
+ const parent = deferredFrom(iss?.body);
206
+ const res = (state, detail, extra = {}) => ({ state, parent, shas: [], detail, ...extra });
207
+ if (!parent || !postDeployOnly(iss.body)) return res("not-applicable", "");
208
+ try {
209
+ const def = typeof defaultBranch === "function" ? await defaultBranch() : defaultBranch;
210
+ if (!def) return res("unresolved", "default branch unknown");
211
+ const shas = await parentMergeShas({ parent, repo, io });
212
+ if (!shas.length) return res("unresolved", `no merge SHA found for #${parent}`);
213
+ const off = [];
214
+ for (const sha of shas) {
215
+ let on;
216
+ try { await io.git(["merge-base", "--is-ancestor", sha, `origin/${def}`], { cwd }); on = true; } catch (e) { on = e?.code === 1 ? false : null; }
217
+ if (on === null) return res("unresolved", `${sha.slice(0, 12)} unknown to git`, { shas, default: def });
218
+ if (!on) off.push(sha);
219
+ }
220
+ if (off.length) return res("awaiting-promotion", `fix ${off.map((s) => s.slice(0, 12)).join(", ")} of #${parent} is not on ${def}${lane ? ` (lane ${lane})` : ""}`, { shas, default: def });
221
+ if (preconditionCmd) {
222
+ const code = await (io.execCode ?? execCode)("sh", ["-c", preconditionCmd, "precondition", String(iss.number)],
223
+ { cwd, timeout: 60000, env: { ...process.env, FORGE_ISSUE: String(iss.number) } });
224
+ if (code === 1) return res("awaiting-precondition", `fix of #${parent} is on ${def}; post_deploy.precondition_cmd says not ready`, { shas, default: def });
225
+ if (code !== 0) return res("unresolved", `post_deploy.precondition_cmd exited ${code}`, { shas, default: def });
226
+ }
227
+ return res("ready", `fix of #${parent} is on ${def}`, { shas, default: def });
228
+ } catch (e) {
229
+ return res("unresolved", `gate error (${String(e?.message ?? e).slice(0, 120)})`);
230
+ }
231
+ }
232
+ /**
233
+ * #4389: the default branch for the gate, resolved and fetched once, only when a gated issue asks: forge.yaml
234
+ * `branches.default`, else GitHub's default branch. Null when neither answers (the gate then fails open).
235
+ */
236
+ export function lazyDefaultBranch({ io, repo, cwd, configured = null }) {
237
+ let p = null;
238
+ return () => (p ??= (async () => {
239
+ let name = configured;
240
+ if (!name) { try { name = String(await io.gh(["repo", "view", repo, "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"])).trim() || null; } catch { name = null; } }
241
+ if (name) { try { await io.git(["fetch", "origin", name], { cwd }); } catch { /* offline: use the local ref */ } }
242
+ return name;
243
+ })());
244
+ }
245
+ /** #4389: label and explain a parked issue once (the comment only when the label was not there yet); best effort. */
246
+ async function markParked({ issue, repo, io, had, gate }) {
247
+ const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", AWAITING_PROMOTION_LABEL]);
248
+ try { await edit(); } catch {
249
+ try {
250
+ await io.gh(["label", "create", AWAITING_PROMOTION_LABEL, "-R", repo, "--force", "--color", "C5DEF5", "--description", "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
251
+ await edit();
252
+ } catch { /* the comment and the report still show it */ }
253
+ }
254
+ if (had) return;
255
+ const what = gate.state === "awaiting-promotion" ? "awaiting promotion" : "awaiting its post-deploy precondition";
256
+ const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\nEvery item here is a post-deploy check, so no lane was started. The next orchestrate or triage run checks again and launches this issue once the precondition holds.`;
257
+ try { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(body)]); } catch { /* the label still shows it */ }
258
+ }
259
+
260
+ /** #4389 batch-report lines: one `awaiting promotion:` line (and one `awaiting precondition:` line) for the parked rows, or "". */
261
+ export function formatParkedLine(rows) {
262
+ const parked = (rows ?? []).filter((r) => r?.prefilter?.parked);
263
+ const uniq = (xs) => [...new Set(xs.filter(Boolean))];
264
+ const out = [];
265
+ const promo = parked.filter((r) => r.prefilter.kind === "awaiting-promotion");
266
+ if (promo.length) {
267
+ const lanes = uniq(promo.map((r) => r.prefilter.lane)), defs = uniq(promo.map((r) => r.prefilter.default));
268
+ out.push(`awaiting promotion: ${promo.map((r) => `#${r.issue}`).join(" ")} (fixes on ${lanes.join(", ") || "the lane"}, not on ${defs.join(", ") || "the default branch"})`);
269
+ }
270
+ const pre = parked.filter((r) => r.prefilter.kind === "awaiting-precondition");
271
+ if (pre.length) out.push(`awaiting precondition: ${pre.map((r) => `#${r.issue}`).join(" ")} (post_deploy.precondition_cmd not ready)`);
272
+ return out.join("\n");
273
+ }
274
+
131
275
  /**
132
276
  * Pre-filter one issue. Never throws.
133
277
  * @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
134
278
  */
135
- export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null }) {
279
+ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null, defaultBranch = null, preconditionCmd = null }) {
136
280
  const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
137
281
  try {
138
282
  const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
139
283
  if (String(iss.state).toUpperCase() !== "OPEN") return { ...out, detail: "not open" };
140
- if ((iss.labels ?? []).some((l) => ["needs-human", "workflow:merged"].includes(l?.name ?? l))) return { ...out, detail: "labelled, left to the lane" };
284
+ const names = (iss.labels ?? []).map((l) => l?.name ?? l);
285
+ const labelled = names.some((l) => ["needs-human", "workflow:merged"].includes(l));
286
+ // #4389: a post-deploy follow-up is gated even when an earlier lane escalated it (needs-human); nothing else changes.
287
+ const gated = !!defaultBranch && !names.includes("workflow:merged") && deferredFrom(iss.body) != null && postDeployOnly(iss.body);
288
+ if (labelled && !gated) return { ...out, detail: "labelled, left to the lane" };
141
289
  if (io.projector) {
142
290
  let st = null;
143
291
  try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
144
292
  if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
145
293
  }
294
+ if (gated) {
295
+ const gate = await postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd });
296
+ const had = names.includes(AWAITING_PROMOTION_LABEL);
297
+ if (gate.state === "awaiting-promotion" || gate.state === "awaiting-precondition") {
298
+ if (!dryRun) await markParked({ issue, repo, io, had, gate });
299
+ return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, detail: gate.detail };
300
+ }
301
+ if (gate.state === "ready" && had && !dryRun) {
302
+ try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", AWAITING_PROMOTION_LABEL]); } catch { /* best effort */ }
303
+ }
304
+ if (labelled) return { ...out, detail: `labelled, left to the lane (post-deploy gate: ${gate.state}${gate.detail ? `, ${gate.detail}` : ""})` };
305
+ }
306
+ // #4399: an issue reopened by a failed acceptance probe goes to a lane, never back to an already-fixed close
307
+ if ((iss.comments ?? []).some((c) => probeResultOf(c?.body)?.result === "fail")
308
+ && flakyReason({ comments: iss.comments }, await ownFor(iss, selfResolver(selfLogins, io.gh)))?.reason === "probe-failed") {
309
+ return { ...out, detail: "acceptance probe failed after promotion (FORGE:PROBE): no already-fixed close; lane" };
310
+ }
146
311
  const sig = await collectSignals({ iss, repo, lane, io, cwd, selfLogins });
147
312
  const close = async (kind, body, detail) => {
148
313
  let note = "";
@@ -195,8 +360,14 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
195
360
 
196
361
  /** Per-issue hook for `orchestrate({ prefilter })`; `lane` is the issue's own resolved lane (#4125). */
197
362
  export function makePrefilter(opts) {
198
- let own = null; // #4315: one `gh api user` per batch, and only when a marker comment needs it
199
- const selfLogins = opts.selfLogins ?? (() => (own ??= ownLogins(opts.io.gh)));
363
+ // #4315: one identity lookup per batch, and only when a marker comment needs it; #4318: an unresolved one is retried once
364
+ let own = null, tries = 0;
365
+ const selfLogins = opts.selfLogins ?? (() => (own ??= (async () => {
366
+ tries++;
367
+ const r = await resolveOwnLogins(opts.io.gh);
368
+ if (!r.resolved && tries < 2) own = null;
369
+ return r.logins;
370
+ })()));
200
371
  return (issue, lane) => prefilterIssue({ ...opts, selfLogins, issue, ...(lane ? { lane } : {}) });
201
372
  }
202
373