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
@@ -12,13 +12,15 @@ const CHECKS = JSON.stringify({ soak: { run: "echo ok", for: "7d", every: "6h" }
12
12
  const BODY = "## Acceptance Criteria\n\n- [ ] `R1: health row exists` — post-deploy: [check:soak] soak\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n";
13
13
  const rec = (o) => ({ authorAssociation: "OWNER", author: { login: "bot" }, body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "fix1234567890", default_sha: "rel1", result: "pass", at: new Date(T0).toISOString(), window_until: new Date(T0 + 7 * 24 * H).toISOString(), every_ms: 6 * H, ...o })}` });
14
14
 
15
- function world({ comments = [rec({})], head = "rel1", file = CHECKS, code = 0, state = "OPEN" } = {}) {
15
+ function world({ comments = [rec({})], head = "rel1", file = CHECKS, code = 0, state = "OPEN", regressions = [], failClose = false } = {}) {
16
16
  const w = { gh: [], git: [], spawned: [] };
17
17
  w.io = {
18
18
  gh: async (a) => {
19
19
  w.gh.push(a);
20
20
  const k = a.slice(0, 2).join(" ");
21
21
  if (k === "repo view") return "main\n";
22
+ if (k === "issue list" && a.includes("--search")) return JSON.stringify(regressions);
23
+ if (failClose && k === "issue close") throw new Error("rate limit");
22
24
  if (k === "issue list") return JSON.stringify([{ number: 41 }]);
23
25
  if (k === "issue view") return JSON.stringify({ number: 41, state, title: "", body: BODY, labels: [{ name: "post-deploy-monitoring" }], comments });
24
26
  if (k === "issue create") return "https://github.com/o/r/issues/77\n";
@@ -80,6 +82,30 @@ describe("post-deploy windowed monitoring (#4627)", () => {
80
82
  assert.match(cm[cm.indexOf("--body") + 1], /"result":"fail".*"regression":77/s);
81
83
  });
82
84
 
85
+ const reg = (n, o = {}) => ({ number: n, body: `<!-- FORGE:REGRESSION -->\n- Fix commit: \`${o.fix ?? "fix1234567890"}\`\n- Monitored follow-up: #${o.issue ?? 41}\n` });
86
+
87
+ it("#4653: a regression already filed for this follow-up and fix is reused, not duplicated", async () => {
88
+ const w = world({ head: "rel2", code: 1, regressions: [reg(90)] });
89
+ const rows = await sweep(w);
90
+ assert.equal(rows[0].outcome, "fail"); assert.equal(rows[0].regression, "90");
91
+ assert.equal(calls(w, "issue create").length, 0); assert.equal(calls(w, "issue close").length, 1);
92
+ const cm = calls(w, "issue comment")[0];
93
+ assert.match(cm[cm.indexOf("--body") + 1], /"regression":90/);
94
+ });
95
+
96
+ it("#4653: finish() failing after create does not file a second regression on the next sweep", async () => {
97
+ const w = world({ head: "rel2", code: 1, failClose: true });
98
+ assert.equal((await sweep(w))[0].outcome, "error"); assert.equal(calls(w, "issue create").length, 1);
99
+ const w2 = world({ head: "rel2", code: 1, regressions: [reg(77)] });
100
+ assert.equal((await sweep(w2))[0].regression, "77"); assert.equal(calls(w2, "issue create").length, 0);
101
+ });
102
+
103
+ it("#4653: a regression for another follow-up, fix commit or issue number prefix is not reused", async () => {
104
+ const w = world({ head: "rel2", code: 1, regressions: [reg(5, { issue: 4 }), reg(6, { issue: 410 }), reg(7, { fix: "other" })] });
105
+ const rows = await sweep(w);
106
+ assert.equal(rows[0].regression, "77"); assert.equal(calls(w, "issue create").length, 1);
107
+ });
108
+
83
109
  it("a follow-up whose latest record is a fail is never re-run or re-filed", async () => {
84
110
  const w = world({ head: "rel3", code: 1, comments: [rec({}), rec({ result: "fail", default_sha: "rel2", regression: 77 })] });
85
111
  const rows = await sweep(w);
@@ -25,7 +25,7 @@ import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prom
25
25
  import { isTrustedComment } from "../engine/phases.mjs";
26
26
  import { APP_SLUG } from "../init-repo.mjs";
27
27
  import { probeResultOf } from "./probe.mjs";
28
- import { AWAITING_MEASUREMENT_LABEL, MONITORING_LABEL, itemCheckIds, measureFollowup } from "./postdeploy-check.mjs";
28
+ import { AWAITING_MEASUREMENT_LABEL, MONITORING_LABEL, OWNER_ASK_MARKER, itemCheckIds, measureFollowup } from "./postdeploy-check.mjs";
29
29
 
30
30
  export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
31
31
  export const PREFILTER_MARKER = "FORGE:PREFILTER";
@@ -157,17 +157,38 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
157
157
  // post-deploy checks cannot be done before its parent's fix is on the default branch: a git fact, so no lane learns it.
158
158
  export const PRECONDITION_MARKER = "FORGE:PRECONDITION";
159
159
  export const AWAITING_PROMOTION_LABEL = "awaiting-promotion";
160
+ export const AWAITING_DUE_LABEL = "awaiting-due"; // #4689
160
161
  const DEFERRED_FROM_RE = /<!--\s*FORGE:DEFERRED_FROM:\s*#(\d+)\s*-->/;
161
162
  /** The parent #P of a deferred follow-up (`<!-- FORGE:DEFERRED_FROM: #P -->`), or null. */
162
163
  export function deferredFrom(body) {
163
164
  const m = DEFERRED_FROM_RE.exec(String(body ?? ""));
164
165
  return m ? Number(m[1]) : null;
165
166
  }
167
+ const DEFERRED_FROM_ALL_RE = new RegExp(DEFERRED_FROM_RE.source, "g");
168
+ /** #4724: every parent #P of a post-deploy rollup (one `<!-- FORGE:DEFERRED_FROM: #P -->` per `### #P` section), in order, unique; [] when none. */
169
+ export const deferredFromAll = (body) => [...new Set([...String(body ?? "").matchAll(DEFERRED_FROM_ALL_RE)].map((m) => Number(m[1])))];
166
170
  const CHECK_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
167
- /** True when the body has checklist items and every one is a ``- [ ] `R#: …` — post-deploy: why`` line (conformance followupItems). */
171
+ const checkItems = (body) => noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null);
172
+ const DECISION_ITEM_RE = /^`[^`]*`\s+[—-]+\s*decision:/; // #4689: an item only its owner can decide
173
+ /** True when the body has checklist items and every one is a ``- [ ] `R#: …` — post-deploy: why`` line (conformance followupItems), or a `decision:` item (#4689). */
168
174
  export function postDeployOnly(body) {
169
- const items = noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null);
170
- return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t));
175
+ const items = checkItems(body);
176
+ return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t) || DECISION_ITEM_RE.test(t));
177
+ }
178
+ /** #4689: true when a checklist item is a `decision:` item (owner ask, never a lane). */
179
+ export const hasDecisionItem = (body) => checkItems(body).some((t) => DECISION_ITEM_RE.test(t));
180
+ const DUE_RE = /\bdue:(\d{4})-(\d{2})-(\d{2})\b/;
181
+ /** #4689: the latest `due:YYYY-MM-DD` of the post-deploy items (ms, UTC midnight), or null when none states one. */
182
+ export function itemDueMs(body) {
183
+ const ds = checkItems(body).map((t) => POST_DEPLOY_ITEM_RE.exec(t)?.[1]).filter((t) => t != null).map((t) => DUE_RE.exec(t)).filter(Boolean)
184
+ .map((m) => Date.UTC(+m[1], +m[2] - 1, +m[3])).filter(Number.isFinite);
185
+ return ds.length ? Math.max(...ds) : null;
186
+ }
187
+ const ANCHOR_RE = /\bfrom:(\d{4})-(\d{2})-(\d{2})\b/;
188
+ /** #4724: the items' `due:` dates with their contract-time `from:` anchor (ms, UTC midnight; null when absent). */
189
+ export function itemDueAnchors(body) {
190
+ return checkItems(body).map((t) => POST_DEPLOY_ITEM_RE.exec(t)?.[1]).filter((t) => t != null).map((t) => [DUE_RE.exec(t), ANCHOR_RE.exec(t)]).filter(([d]) => d)
191
+ .map(([d, a]) => ({ dueMs: Date.UTC(+d[1], +d[2] - 1, +d[3]), fromMs: a ? Date.UTC(+a[1], +a[2] - 1, +a[3]) : null })).filter((x) => Number.isFinite(x.dueMs));
171
192
  }
172
193
  const SHA_RE = /^[0-9a-f]{7,40}$/i;
173
194
  /**
@@ -243,18 +264,23 @@ const fmtAge = (ms) => (ms % UNIT_MS.d === 0 && ms ? `${ms / UNIT_MS.d}d` : ms %
243
264
  /**
244
265
  * #4389: never throws, no model call. `defaultBranch` is a name or an (async) function resolving it (and fetching it).
245
266
  * #4425: `minAge` (ms, opt-in) parks `awaiting-min-age` until max(item window ?? minAge) after the fix landed on <default>.
246
- * @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-min-age"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, eligibleAt?:string, detail:string}>}
267
+ * @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-due"|"awaiting-min-age"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, eligibleAt?:string, detail:string}>}
247
268
  * Fail-open: no merge SHA, a git answer other than yes/no, an unknown default branch or a hook error give "unresolved".
248
269
  */
249
270
  export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd = null, minAge = null, now = null }) {
250
- const parent = deferredFrom(iss?.body);
271
+ const parents = deferredFromAll(iss?.body), parent = parents[0] ?? null; // #4724: a rollup has one parent per section
272
+ const ps = parents.map((p) => `#${p}`).join(", ");
251
273
  const res = (state, detail, extra = {}) => ({ state, parent, shas: [], detail, ...extra });
252
274
  if (!parent || !postDeployOnly(iss.body)) return res("not-applicable", "");
253
275
  try {
254
276
  const def = typeof defaultBranch === "function" ? await defaultBranch() : defaultBranch;
255
277
  if (!def) return res("unresolved", "default branch unknown");
256
- const shas = await parentMergeShas({ parent, repo, io });
257
- if (!shas.length) return res("unresolved", `no merge SHA found for #${parent}`);
278
+ const shas = [];
279
+ for (const p of parents) {
280
+ const found = await parentMergeShas({ parent: p, repo, io });
281
+ if (!found.length) return res("unresolved", `no merge SHA found for #${p}`);
282
+ shas.push(...found);
283
+ }
258
284
  const off = [];
259
285
  for (const sha of shas) {
260
286
  let on;
@@ -262,7 +288,22 @@ export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io,
262
288
  if (on === null) return res("unresolved", `${sha.slice(0, 12)} unknown to git`, { shas, default: def });
263
289
  if (!on) off.push(sha);
264
290
  }
265
- 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 });
291
+ if (off.length) return res("awaiting-promotion", `fix ${off.map((s) => s.slice(0, 12)).join(", ")} of ${ps} is not on ${def}${lane ? ` (lane ${lane})` : ""}`, { shas, default: def });
292
+ let dueMs = itemDueMs(iss.body); // #4689: an explicit due date (UTC midnight)
293
+ if (dueMs != null) {
294
+ // #4724: re-anchor a date item to when its fix actually landed on <def>: due + (landed - the contract-time anchor)
295
+ let landedMs = null;
296
+ for (const a of itemDueAnchors(iss.body)) {
297
+ if (a.fromMs == null) continue;
298
+ if (landedMs == null) { landedMs = 0; for (const sha of shas) landedMs = Math.max(landedMs, ((await landedAt({ sha, def, io, cwd })) ?? 0) * 1000); }
299
+ if (landedMs > a.fromMs) dueMs = Math.max(dueMs, a.dueMs + (landedMs - a.fromMs));
300
+ }
301
+ const at = now ?? io.now?.() ?? Date.now();
302
+ if (at < dueMs) {
303
+ const eligibleAt = new Date(dueMs).toISOString();
304
+ return res("awaiting-due", `fix of ${ps} is on ${def}; the checks are due ${eligibleAt.slice(0, 10)}`, { shas, default: def, eligibleAt });
305
+ }
306
+ }
266
307
  if (minAge != null) {
267
308
  const required = Math.max(0, ...itemWindowsMs(iss.body).map((w) => w ?? minAge));
268
309
  let landed = 0;
@@ -274,16 +315,16 @@ export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io,
274
315
  const at = now ?? io.now?.() ?? Date.now(), until = landed * 1000 + required;
275
316
  if (at < until) {
276
317
  const eligibleAt = new Date(until).toISOString();
277
- return res("awaiting-min-age", `fix of #${parent} on ${def} since ${new Date(landed * 1000).toISOString()}; min age ${fmtAge(required)} until ${eligibleAt}`, { shas, default: def, eligibleAt });
318
+ return res("awaiting-min-age", `fix of ${ps} on ${def} since ${new Date(landed * 1000).toISOString()}; min age ${fmtAge(required)} until ${eligibleAt}`, { shas, default: def, eligibleAt });
278
319
  }
279
320
  }
280
321
  if (preconditionCmd) {
281
322
  const code = await (io.execCode ?? execCode)("sh", ["-c", preconditionCmd, "precondition", String(iss.number)],
282
323
  { cwd, timeout: 60000, env: { ...process.env, FORGE_ISSUE: String(iss.number) } });
283
- if (code === 1) return res("awaiting-precondition", `fix of #${parent} is on ${def}; post_deploy.precondition_cmd says not ready`, { shas, default: def });
324
+ if (code === 1) return res("awaiting-precondition", `fix of ${ps} is on ${def}; post_deploy.precondition_cmd says not ready`, { shas, default: def });
284
325
  if (code !== 0) return res("unresolved", `post_deploy.precondition_cmd exited ${code}`, { shas, default: def });
285
326
  }
286
- return res("ready", `fix of #${parent} is on ${def}`, { shas, default: def });
327
+ return res("ready", `fix of ${ps} is on ${def}`, { shas, default: def });
287
328
  } catch (e) {
288
329
  return res("unresolved", `gate error (${String(e?.message ?? e).slice(0, 120)})`);
289
330
  }
@@ -301,21 +342,29 @@ export function lazyDefaultBranch({ io, repo, cwd, configured = null }) {
301
342
  return name;
302
343
  })());
303
344
  }
345
+ /** #4689: one typed `FORGE:OWNER_ASK` comment for the `decision:` items of a follow-up, posted once; best effort. */
346
+ async function ownerAsk({ iss, repo, io, dryRun }) {
347
+ if (dryRun || (iss.comments ?? []).some((c) => String(c?.body ?? "").includes(OWNER_ASK_MARKER) && isTrustedIssueComment(c))) return;
348
+ const asks = checkItems(iss.body).filter((t) => DECISION_ITEM_RE.test(t)).map((t) => `- ${sanitizeMd(t).slice(0, 300)}`);
349
+ try { await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", capComment(`<!-- ${OWNER_ASK_MARKER} -->\n## Owner ask\nNo lane was started: these items need a decision from the owner.\n\n${asks.join("\n")}`)]); } catch { /* the report still shows it */ }
350
+ }
304
351
  /** #4389: label and explain a parked issue once (the comment only when the label was not there yet); best effort. */
305
352
  async function markParked({ issue, repo, io, had, gate }) {
306
353
  const measure = gate.state === "awaiting-measurement"; // #4626
307
- const label = measure ? AWAITING_MEASUREMENT_LABEL : AWAITING_PROMOTION_LABEL;
308
- const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", label]);
354
+ const due = gate.state === "awaiting-due"; // #4689
355
+ const label = measure ? AWAITING_MEASUREMENT_LABEL : due ? AWAITING_DUE_LABEL : AWAITING_PROMOTION_LABEL;
356
+ const labels = due && gate.eligibleAt ? [label, `due:${gate.eligibleAt.slice(0, 10)}`] : [label];
357
+ const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", labels.join(",")]);
309
358
  try { await edit(); } catch {
310
359
  try {
311
- await io.gh(["label", "create", label, "-R", repo, "--force", "--color", "C5DEF5", "--description", measure ? "Post-deploy follow-up with no runnable default-branch check; never given a lane. Managed by ForgeDock." : "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
360
+ for (const l of labels) await io.gh(["label", "create", l, "-R", repo, "--force", "--color", "C5DEF5", "--description", measure ? "Post-deploy follow-up with no runnable default-branch check; never given a lane. Managed by ForgeDock." : due ? "Post-deploy follow-up parked until its due date; re-queued by the engine. Managed by ForgeDock." : "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
312
361
  await edit();
313
362
  } catch { /* the comment and the report still show it */ }
314
363
  }
315
364
  if (had) return;
316
- const what = measure ? "awaiting measurement" : gate.state === "awaiting-promotion" ? "awaiting promotion" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
365
+ const what = measure ? "awaiting measurement" : gate.state === "awaiting-promotion" ? "awaiting promotion" : due ? "awaiting its due date" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
317
366
  const tail = measure ? "Every item here is a post-deploy check, and at least one names no runnable check, so no lane was started and none will be. Name each one with `[check:<id>]` and add `<id>` to the checks file on the default branch, or measure it by hand."
318
- : "Every 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.";
367
+ : "Every item here is a post-deploy check, so no lane was started. The next orchestrate or triage run, or the daemon's parked sweep, checks again and releases this issue once the precondition holds.";
319
368
  const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\n${tail}`;
320
369
  try { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(body)]); } catch { /* the label still shows it */ }
321
370
  }
@@ -335,6 +384,10 @@ export function formatParkedLine(rows) {
335
384
  const defs = uniq(soak.map((r) => r.prefilter.default));
336
385
  out.push(`awaiting min age: ${soak.map((r) => `#${r.issue}`).join(" ")} (fix on ${defs.join(", ") || "the default branch"}, soak window not elapsed)`);
337
386
  }
387
+ const due = parked.filter((r) => r.prefilter.kind === "awaiting-due"); // #4689
388
+ if (due.length) out.push(`due date: ${due.map((r) => `#${r.issue}${r.prefilter.eligibleAt ? ` (${r.prefilter.eligibleAt.slice(0, 10)})` : ""}`).join(" ")} (re-queued on or after the date)`);
389
+ const ask = parked.filter((r) => r.prefilter.kind === "owner-ask");
390
+ if (ask.length) out.push(`owner ask: ${ask.map((r) => `#${r.issue}`).join(" ")} (a decision or measurement only the owner can give; no lane)`);
338
391
  const meas = parked.filter((r) => r.prefilter.kind === "awaiting-measurement"); // #4626
339
392
  if (meas.length) out.push(`awaiting measurement: ${meas.map((r) => `#${r.issue}`).join(" ")} (no runnable default-branch check; no lane)`);
340
393
  const failed = parked.filter((r) => r.prefilter.kind === "check-failed");
@@ -350,7 +403,7 @@ export function formatParkedLine(rows) {
350
403
  * Pre-filter one issue. Never throws.
351
404
  * @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
352
405
  */
353
- 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, minAge = null }) {
406
+ 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, minAge = null, gateOnly = false }) {
354
407
  const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
355
408
  try {
356
409
  const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
@@ -367,15 +420,20 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
367
420
  try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
368
421
  if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
369
422
  }
423
+ if (gated && hasDecisionItem(iss.body)) { // #4689: a decision item is the owner's: parked, one typed ask, no lane
424
+ await ownerAsk({ iss, repo, io, dryRun });
425
+ return { ...out, parked: true, kind: "owner-ask", parent: deferredFrom(iss.body), lane, detail: "a decision item needs the owner" };
426
+ }
370
427
  if (gated) {
371
428
  const gate = await postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd, minAge });
372
429
  const had = names.includes(AWAITING_PROMOTION_LABEL);
373
- if (["awaiting-promotion", "awaiting-min-age", "awaiting-precondition"].includes(gate.state)) {
374
- if (!dryRun) await markParked({ issue, repo, io, had, gate });
375
- return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, detail: gate.detail };
430
+ if (["awaiting-promotion", "awaiting-due", "awaiting-min-age", "awaiting-precondition"].includes(gate.state)) {
431
+ if (!dryRun) await markParked({ issue, repo, io, had: had || (gate.state === "awaiting-due" && names.includes(AWAITING_DUE_LABEL)), gate });
432
+ return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, ...(gate.eligibleAt ? { eligibleAt: gate.eligibleAt } : {}), detail: gate.detail };
376
433
  }
377
- if (gate.state === "ready" && had && !dryRun) {
378
- try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", AWAITING_PROMOTION_LABEL]); } catch { /* best effort */ }
434
+ const stale = names.filter((l) => l === AWAITING_PROMOTION_LABEL || l === AWAITING_DUE_LABEL || /^due:/.test(l)); // #4689: released: every wait label goes
435
+ if (gate.state === "ready" && stale.length && !dryRun) {
436
+ try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", stale.join(",")]); } catch { /* best effort */ }
379
437
  }
380
438
  if (gate.state === "ready") {
381
439
  const m = await measureFollowup({ iss, repo, parent: gate.parent, def: gate.default, shas: gate.shas, cwd, io, dryRun, isTrusted: isTrustedIssueComment, ...(io.spawnCheck ? { spawn: io.spawnCheck } : {}) });
@@ -387,8 +445,10 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
387
445
  if (m.state === "pass") return { ...out, closed: !dryRun, kind: "post-deploy-check", parent: gate.parent, lane, default: gate.default, detail: m.detail };
388
446
  if (m.state === "fail" || m.state === "recorded-fail") return { ...out, parked: true, kind: "check-failed", parent: gate.parent, lane, default: gate.default, detail: m.detail };
389
447
  }
448
+ if (gateOnly) return { ...out, detail: `released (post-deploy gate: ${gate.state})` }; // #4689: the sweep never runs the already-fixed path
390
449
  if (labelled) return { ...out, detail: `labelled, left to the lane (post-deploy gate: ${gate.state}${gate.detail ? `, ${gate.detail}` : ""})` };
391
450
  }
451
+ if (gateOnly) return { ...out, detail: "not a gated follow-up" };
392
452
  // #4399: an issue reopened by a failed acceptance probe goes to a lane, never back to an already-fixed close
393
453
  if ((iss.comments ?? []).some((c) => probeResultOf(c?.body)?.result === "fail")
394
454
  && flakyReason({ comments: iss.comments }, await ownFor(iss, selfResolver(selfLogins, io.gh)))?.reason === "probe-failed") {
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { prefilterIssue, flakyReason, makePrefilter, postDeployGate, postDeployOnly, deferredFrom, formatParkedLine, lazyDefaultBranch, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, isTrustedIssueComment, DEFAULT_PREFILTER_MODEL, parseMinAge, itemWindowsMs, landedAt } from "./prefilter.mjs";
5
+ import { prefilterIssue, flakyReason, makePrefilter, postDeployGate, postDeployOnly, deferredFrom, formatParkedLine, lazyDefaultBranch, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, isTrustedIssueComment, DEFAULT_PREFILTER_MODEL, parseMinAge, itemWindowsMs, landedAt, itemDueMs, hasDecisionItem, deferredFromAll, itemDueAnchors } from "./prefilter.mjs";
6
6
  import { writeFileSync } from "node:fs";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
8
  import { execFileSync } from "node:child_process";
@@ -516,6 +516,36 @@ describe("post_deploy.min_age soak window (#4425)", () => {
516
516
  assert.deepEqual(itemWindowsMs(followup([item("R1", "after 24h of traffic, then 7 days of retention"), item("R2", "only in prod")])), [7 * 24 * H * 1000, null]);
517
517
  });
518
518
 
519
+ describe("post-deploy rollup with several parents (#4724)", () => {
520
+ const M2 = "f".repeat(40);
521
+ const prs = [...parentPr, { number: 91, title: "fix(y): other (#50)", body: "", mergeCommit: { oid: M2 } }];
522
+ const rollup = (items) => `## Acceptance Criteria\n\n### #40\n<!-- FORGE:DEFERRED_FROM: #40 -->\n${items[0]}\n### #50\n<!-- FORGE:DEFERRED_FROM: #50 -->\n${items[1] ?? items[0]}\n`;
523
+ it("deferredFromAll lists every section parent once, in order", () => {
524
+ assert.deepEqual(deferredFromAll(rollup([item("R1", "x")])), [40, 50]);
525
+ assert.deepEqual(deferredFromAll("no marker"), []);
526
+ assert.equal(deferredFrom(rollup([item("R1", "x")])), 40);
527
+ });
528
+ it("the rollup waits while ANY parent is off the default branch, and names them all", async () => {
529
+ const w = soakWorld({ mergedPrs: prs, ancestry: { [MERGE]: 0, [M2]: 1 } });
530
+ const g = await gate(w, rollup([item("R1", "x")]));
531
+ assert.equal(g.state, "awaiting-promotion"); assert.match(g.detail, /#40, #50/); assert.deepEqual(g.shas, [MERGE, M2]);
532
+ });
533
+ it("is ready once every parent is on the default branch; a parent without a merge SHA is unresolved", async () => {
534
+ assert.equal((await gate(soakWorld({ mergedPrs: prs, ancestry: { [MERGE]: 0, [M2]: 0 } }), rollup([item("R1", "x")]))).state, "ready");
535
+ const g = await gate(soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } }), rollup([item("R1", "x")]));
536
+ assert.equal(g.state, "unresolved"); assert.match(g.detail, /#50/);
537
+ });
538
+ it("a date item is re-anchored to when its fix landed: due + (landed - from)", async () => {
539
+ const it1 = item("R1", "[bucket:date] due:2027-02-01 from:2027-01-01 later");
540
+ assert.deepEqual(itemDueAnchors(followup([it1])), [{ dueMs: Date.UTC(2027, 1, 1), fromMs: Date.UTC(2027, 0, 1) }]);
541
+ const w = soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
542
+ const g = await gate(w, followup([it1]), { now: Date.UTC(2027, 1, 10) }); // past the contract-time date, before the shifted one
543
+ assert.equal(g.state, "awaiting-due");
544
+ assert.equal(g.eligibleAt, new Date(Date.UTC(2027, 1, 1) + LANDED * 1000 - Date.UTC(2027, 0, 1)).toISOString());
545
+ assert.equal((await gate(soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } }), followup([it1]), { now: Date.UTC(2027, 2, 1) })).state, "ready");
546
+ });
547
+ });
548
+
519
549
  it("landedAt: the promotion merge time, or the SHA's own time when it is on the mainline", async () => {
520
550
  const w = soakWorld();
521
551
  assert.equal(await landedAt({ sha: MERGE, def: "main", io: w.io, cwd: "/repo" }), LANDED);
@@ -602,3 +632,45 @@ describe("#4467: isTrustedIssueComment", () => {
602
632
  assert.equal(isTrustedIssueComment(null), false);
603
633
  });
604
634
  });
635
+
636
+ describe("due-date and decision follow-ups (#4689)", () => {
637
+ const MERGE = "a".repeat(40);
638
+ const dueItem = (date) => `- [ ] \`R2: the 7-day window shows a health row\` — post-deploy: due:${date} only checkable after the window`;
639
+ const decision = "- [ ] `R3: pick the retention` — decision: owner chooses 30 or 90 days";
640
+ const body = (items) => `## Problem\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n`;
641
+ const parentPr = [{ number: 90, title: "fix(x): health row (#40)", body: "", mergeCommit: { oid: MERGE } }];
642
+ const at = (iso) => Date.parse(iso);
643
+
644
+ it("parses the due token (latest wins) and decision items; a decision-only body counts as verification-only", () => {
645
+ assert.equal(itemDueMs(body([dueItem("2026-11-03"), dueItem("2026-11-10")])), Date.UTC(2026, 10, 10));
646
+ assert.equal(itemDueMs(body(["- [ ] `R2: x` — post-deploy: no date"])), null);
647
+ assert.ok(postDeployOnly(body([decision]))); assert.ok(hasDecisionItem(body([decision])));
648
+ assert.ok(!hasDecisionItem(body([dueItem("2026-11-03")])));
649
+ });
650
+
651
+ it("before the due date the follow-up parks awaiting-due with a due:<date> label, no lane; after it, the labels are removed", async () => {
652
+ const w = world({ issues: { 41: { body: body([dueItem("2026-11-03")]) } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
653
+ w.io.now = () => at("2026-11-01T00:00:00Z");
654
+ const r = await pf(w, 41, { defaultBranch: "main" });
655
+ assert.equal(r.parked, true); assert.equal(r.kind, "awaiting-due"); assert.equal(r.eligibleAt, "2026-11-03T00:00:00.000Z");
656
+ assert.deepEqual(w.edits, [[41, "--add-label", "awaiting-due,due:2026-11-03"]]);
657
+ assert.equal(w.runner.length, 0);
658
+ assert.match(formatParkedLine([{ issue: 41, prefilter: r }]), /^due date: #41 \(2026-11-03\)/);
659
+ const w2 = world({ issues: { 41: { body: body([dueItem("2026-11-03")]), labels: [{ name: "awaiting-due" }, { name: "due:2026-11-03" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
660
+ w2.io.now = () => at("2026-11-03T00:00:01Z");
661
+ const r2 = await pf(w2, 41, { defaultBranch: "main", gateOnly: true });
662
+ assert.equal(r2.kind, "awaiting-measurement"); // released from the date; with no check on main it waits on its owner, never a lane
663
+ assert.deepEqual(w2.edits[0], [41, "--remove-label", "awaiting-due,due:2026-11-03"]);
664
+ assert.ok(w2.comments.some(([, b]) => /FORGE:OWNER_ASK/.test(b)));
665
+ assert.equal(w2.runner.length, 0);
666
+ });
667
+
668
+ it("a decision item is parked as one typed owner ask (posted once), never a lane, never needs-human", async () => {
669
+ const w = world({ issues: { 42: { body: body([decision]) } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
670
+ const r = await pf(w, 42, { defaultBranch: "main" });
671
+ assert.equal(r.parked, true); assert.equal(r.kind, "owner-ask"); assert.equal(w.runner.length, 0);
672
+ assert.equal(w.comments.length, 1); assert.match(w.comments[0][1], /FORGE:OWNER_ASK/);
673
+ assert.match(formatParkedLine([{ issue: 42, prefilter: r }]), /^owner ask: #42/);
674
+ assert.deepEqual(w.edits, []);
675
+ });
676
+ });
@@ -244,14 +244,14 @@ export async function probeIssue({ issue, repo, cwd = process.cwd(), io, default
244
244
  try { defaultSha = String(await io.git(["rev-parse", `origin/${defaultBranch}`], { cwd })).trim() || null; } catch { /* recorded as null */ }
245
245
  const r = await runProbe(d.probe, { cwd, env: probeEnv(config.env, env), mask: config.env.map((k) => env[k]), timeoutS: config.timeoutS, ...(spawn ? { spawn } : {}) });
246
246
  const detail = `exit ${r.exit ?? "timeout"} in ${r.ms}ms`;
247
- if (dryRun) return row(r.result, `${detail} (dry run: not recorded)`, { fix, output: r.output });
247
+ if (dryRun) return row(r.result, `${detail} (dry run: not recorded)`, { fix, defaultBranch, output: r.output });
248
248
  const body = renderProbeComment({ probe: d.probe, fix, defaultBranch, defaultSha, r });
249
249
  let note = "";
250
250
  if (r.result === "fail") {
251
251
  try { await io.gh(["issue", "reopen", String(issue), "-R", repo]); } catch (e) { note = `; reopen failed: ${String(e?.message ?? e).slice(0, 120)}`; }
252
252
  }
253
253
  await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", body]);
254
- return row(r.result, detail + note, { fix });
254
+ return row(r.result, detail + note, { fix, defaultBranch });
255
255
  }
256
256
 
257
257
  /** The default branch: forge.yaml `branches.default`, else GitHub's. */
@@ -125,7 +125,7 @@ describe("probe (#4399)", () => {
125
125
  const w = world({ comments: [], prs: [{ number: 3, title: "x", body: "mentions #7 only", mergeCommit: { oid: "1111111" }, mergedAt: "2026-10-02" },
126
126
  { number: 4, title: "fix", body: "Closes #7", mergeCommit: { oid: "2222222" }, mergedAt: "2026-10-01" }] });
127
127
  const r = await run(w);
128
- assert.equal(r.outcome, "pass"); assert.equal(r.fix, "2222222");
128
+ assert.equal(r.outcome, "pass"); assert.equal(r.fix, "2222222"); assert.ok(r.defaultBranch);
129
129
  });
130
130
 
131
131
  it("pass: records FORGE:PROBE with the result JSON, no reopen", async () => {
@@ -72,7 +72,10 @@ export function i18nCopiesPrompt(findings, mode = "local") {
72
72
  /** #4591: lane-added migrations the repo's generator did not produce. */
73
73
  export function migrationGeneratorPrompt(problems, mode = "local") {
74
74
  const rows = problems.slice(0, 30).map((p) => `- ${p}`).join("\n");
75
- return `These migration files were named by hand, not produced by the repo's migration generator:\n${rows}\n\nA made-up name or timestamp collides with another lane's migration. Delete each such file, then recreate it with the generator (hand-written SQL: the generator's \`--custom\` mode, e.g. \`drizzle-kit generate --custom --name=<name>\`, then put your SQL in the file it wrote), so the journal entry and snapshot come from it. Never name or number migration files by hand. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
75
+ const stale = problems.some((p) => /not above the base max when|is not after/.test(p))
76
+ ? ` Entries reported with a stale \`when\` are stamped older than the base's newest migration, so a newest-only migrator (drizzle \`migrate()\`) skips them on a persistent database: re-stamp each one (a \`when\` and name above the base journal's max, same SQL, snapshot regenerated by the generator) and keep \`when\` increasing in the journal.`
77
+ : "";
78
+ return `These migration problems were found (hand-named files the repo's migration generator did not produce, or journal timestamps older than the base's):\n${rows}\n\nA made-up name or timestamp collides with another lane's migration. Delete each such file, then recreate it with the generator (hand-written SQL: the generator's \`--custom\` mode, e.g. \`drizzle-kit generate --custom --name=<name>\`, then put your SQL in the file it wrote), so the journal entry and snapshot come from it. Never name or number migration files by hand.${stale} ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
76
79
  }
77
80
 
78
81
  /** #4390: byte cap of the diff hunks a context-ceiling restart carries (the stat is always kept). */
@@ -154,12 +157,13 @@ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
154
157
  If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"already-fixed|invalid","summary":"one line","evidence":["file:line ..."]} and stop. If it is blocked, write {"status":"blocked","summary":"",...} with a typed "block" (rules below) and stop.
155
158
 
156
159
  2. Otherwise write ${CONTRACT_FILE}, the frozen hand-off: a developer who never saw the issue must be able to implement and verify it from this file alone.
157
- {"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<defer, post-deploy>","check":"<post-deploy: id in .forge/post-deploy-checks.json>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
160
+ {"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<defer, post-deploy>","bucket":"<defer, post-deploy: post-deploy|verify-now|date:YYYY-MM-DD|owner:<action>|new-issue>","check":"<post-deploy: id in .forge/post-deploy-checks.json>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
158
161
  "deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
159
162
  "preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
160
163
  "validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change; a git diff compares against the merge base (origin/<lane>...HEAD), never the moving origin/<lane> tip>"}],
161
164
  "decisions":[{"id":"K1","choice":"<a non-obvious choice: a new dependency, a copied pattern, a boundary relied on>","justification":"<why, with file:line>","source":"doc|lint|precedent|spec"}]}
162
- Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". A deliver requirement whose result is only measured outside the lane (Lighthouse, field perf, a nightly or report-only suite) is "verify":"out_of_band" with "evidence" naming the job or workflow that measures it (delivered by its instrumentation). The engine tags such quotes itself; set "verify":"in_lane" when this change can check it. List every file you will change as a deliverable; tests are always allowed.
165
+ Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. Every defer or post-deploy row has one "bucket": post-deploy (needs a release), verify-now (parent already on the default branch), date:YYYY-MM-DD (absolute, never a relative wait), owner:<action> (credentials, spend, settings; disposition defer, never post-deploy) or new-issue (future work). One sentence is one row. An outcome criterion this change can ship as a test, metric or alert is "deliver". A requirement you can do in this change is "deliver". A deliver requirement whose result is only measured outside the lane (Lighthouse, field perf, a nightly or report-only suite) is "verify":"out_of_band" with "evidence" naming the job or workflow that measures it (delivered by its instrumentation). The engine tags such quotes itself; set "verify":"in_lane" when this change can check it. List every file you will change as a deliverable; tests are always allowed.
166
+ A [type:e2e] requirement needs a validate that runs a real e2e check (repo e2e set, Playwright, live stack), not a stubbed unit test; else post-deploy.
163
167
  A validate or repro command must not read a $TMPDIR file it did not create: each run gets a fresh TMPDIR.
164
168
  Scope is enforced by the engine: it checks the changed files against the deliverables plus typed amendments itself, so do not write a file-allowlist or denylist validate (git diff --name-only | grep); the engine drops such a validate at freeze.
165
169
  A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when none. Then stop.
@@ -171,7 +175,7 @@ ${BLOCK_RULES}`;
171
175
  * #4374: the conformance rubric, one definition shared verbatim by `reviewerPrompt` and the builder's self-review in
172
176
  * `builderImplementPrompt`, so the two cannot drift. Each question is answered yes (OK) or no; codebase-agnostic.
173
177
  */
174
- export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as written, delivering every requirement and acceptance criterion of the issue and every contract requirement with disposition deliver? An out-of-band requirement is delivered by its instrumentation (budget test or bench committed, job configured), not its measured result.
178
+ export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as written, delivering every requirement and acceptance criterion of the issue and every contract requirement with disposition deliver? An out-of-band requirement is delivered by its instrumentation (budget test or bench committed, job configured), not its measured result. A [type:e2e] requirement is delivered only when its validate command is a real end-to-end check, not a unit test with stubbed I/O.
175
179
  2. No regression: is the diff free of breakage of an existing caller, test or behaviour (a concrete path:line failure)?
176
180
  3. Contract: is every deliverable changed as described, every preserve item intact and every validate command passing?
177
181
  4. Scope: does the diff change only deliverable paths and tests?`;
@@ -184,13 +188,15 @@ export function builderImplementPrompt(mode = "local", { toolchain = null, spars
184
188
  ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
185
189
 
186
190
  The contract changes only through typed amendments in ${RESULT_FILE}, each with a why:
187
- - {"kind":"add-deliverable","path":"<file>","why":""}: a file the contract did not list.
188
- - {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: an issue requirement the contract missed.
189
- - {"kind":"defer|post-deploy","requirement":"R#","why":"","check":"<id>"}: a requirement that becomes separate work or a post-deploy check. Filed as a tracked follow-up. Not "type"; other kinds are rejected.
190
- - {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded; judged against text.
191
+ - {"kind":"add-deliverable","path":"<file>","why":""}: a file not listed.
192
+ - {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: a missed issue requirement.
193
+ - {"kind":"defer|post-deploy","requirement":"R#","why":"","check":"<id>"}: separate work or a post-deploy check. Filed as a tracked follow-up. Not "type"; unknown kinds are rejected. One requirement id per row (R2, R3 as two rows).
194
+ - {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded.
195
+ - {"kind":"decision","decision":{"choice":"","anchors":["<base path>"],"supersedes":"<id>"},"why":""}: supersedes changes an H-row.
191
196
  New keys: translate, never copy English; status entries in place.
192
197
  Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
193
198
  Never name or number migration files by hand; use the generator.
199
+ A [type:e2e] requirement needs a validate that runs a real e2e check (repo e2e set, Playwright, live stack), not a stubbed unit test; else post-deploy.
194
200
  A new workspace-package dependency in a package.json no deliverable names is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
195
201
  If the repository has scripts/check-devdocs-freshness.sh, a behaviour change under the paths it checks must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change. Run it against the base first; the engine runs it too.
196
202
 
@@ -371,7 +377,7 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
371
377
  You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
372
378
  - issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
373
379
  - amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. If it does not hold, mark the row partial with note "amendment: <what to change>".
374
- - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change. For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
380
+ - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change, including an outcome criterion (a metric, rate or log expectation) this change could ship as a test, metric or alert, or a read-only production check possible now (bucket verify-now). For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
375
381
  - post-deploy requirements: deferral_ok is false when the requirement has no \`check\` id, or its check is neither in .forge/post-deploy-checks.json on the default branch nor added by this change (the commands run from that file only).
376
382
  - scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
377
383
  - preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
@@ -11,7 +11,7 @@ const facts = (validation, files = ["src/a.ts"]) => `<!-- ${FACTS_MARKER} -->\n$
11
11
  const CONTRACT = {
12
12
  requirements: [
13
13
  { id: "R1", quote: "Retries keep the session.", disposition: "deliver" },
14
- { id: "R2", quote: "Measure it after deploy.", disposition: "post-deploy", why: "prod" },
14
+ { id: "R2", quote: "Measure it after deploy.", disposition: "post-deploy", why: "prod", bucket: "post-deploy" },
15
15
  { id: "R3", quote: "Log the retry count.", disposition: "deliver" },
16
16
  ],
17
17
  validate: [{ id: "V1", command: "npm test -- a.test.ts" }, { id: "V2", command: "npm run lint" }],
@@ -43,3 +43,31 @@ export async function ghCacheRetry(call, cache) {
43
43
  return await call(cache?.env?.());
44
44
  }
45
45
  }
46
+
47
+ /** #4662: an expired or revoked token (HTTP 401). 403 is a missing scope or a rate limit, which a refresh does not fix. */
48
+ export const GH_AUTH_RE = /HTTP 401|\b401\b[^\n]{0,40}(?:unauthorized|bad credentials)|bad credentials|requires authentication|authentication required|gh auth login|try authenticating/i;
49
+ export const GH_AUTH_RETRY_SECONDS = 120;
50
+
51
+ /** Runs scripts/refresh-bot-token.sh (or $FORGE_REFRESH_TOKEN_SCRIPT); resolves on exit 0, rejects otherwise. */
52
+ export async function runRefreshScript(env = process.env) {
53
+ const { execFile } = await import("node:child_process");
54
+ const script = env.FORGE_REFRESH_TOKEN_SCRIPT || new URL("../../scripts/refresh-bot-token.sh", import.meta.url).pathname;
55
+ return new Promise((res, rej) => execFile("bash", [script], { env, timeout: 60000 }, (err, _out, stderr) => (err ? rej(Object.assign(err, { stderr })) : res())));
56
+ }
57
+
58
+ /**
59
+ * #4662: one refresh-and-retry for an auth failure. `refresh` runs once per `state` (shared across calls, so a dead token
60
+ * does not spawn a refresh per gh call); when the call still fails with an auth error the error carries
61
+ * `deferCode: "gh.auth"` so the lane ends `deferred` (requeued in `retryInSeconds`), never `engine-error`.
62
+ */
63
+ export async function refreshAuthOnce(call, { refresh, state = { refreshed: false } }) {
64
+ try { return await call(); } catch (e) {
65
+ if (!GH_AUTH_RE.test(errText(e))) throw e;
66
+ if (!state.refreshed) {
67
+ state.refreshed = true;
68
+ try { await refresh(); state.ok = true; } catch { state.ok = false; }
69
+ if (state.ok) { try { const r = await call(); state.refreshed = false; return r; } catch (e2) { if (!GH_AUTH_RE.test(errText(e2))) throw e2; e = e2; } }
70
+ }
71
+ throw Object.assign(e, { deferCode: "gh.auth", deferDetail: `gh auth failed (HTTP 401) after a token refresh: ${errText(e).trim().split("\n")[0].slice(0, 200)}`, retryInSeconds: GH_AUTH_RETRY_SECONDS });
72
+ }
73
+ }
@@ -186,9 +186,7 @@ describe("remote adapter (#4254)", () => {
186
186
  const flaky = { ...fakeTransport(), status: async () => { throw new Error("ECONNRESET"); } };
187
187
  const q = remoteProvider({ batch, repo: "o/r", transport: flaky, env: {} });
188
188
  const h = await q.start({ issue: 6, args: [] });
189
- const seen = [];
190
- for (let i = 0; i < 5; i++) seen.push(await q.status(h));
191
- assert.deepEqual(seen, ["running", "running", "running", "running", "gone"]);
189
+ await assert.rejects(() => q.status(h), /ECONNRESET/); // #4672: a transport error propagates; providerLanes retries it for the unreachable window
192
190
  });
193
191
 
194
192
  it("httpTransport: bearer token, JSON protocol, 404 = gone/destroyed, 429 retryable; the token never in errors", async () => {
@@ -303,6 +301,13 @@ describe("pool provider (#4546)", () => {
303
301
  assert.equal(Buffer.from(spec.env.CS_FORGE_YAML_B64, "base64").toString(), "verify:\n mode: ci\n");
304
302
  });
305
303
 
304
+ it("#4685 a configured token_env that resolves empty falls back to the router token", async () => {
305
+ const { cfg } = setupPool();
306
+ const batch = openBatch(tmpDir("pool-b3-"), "bt");
307
+ const c = await launchOnce(() => providerByName("pool", { batch, repo: "o/r", cwd: tmpDir("pool-cwd3-"), exec: parseExecution({ mode: "remote", provider: "pool", token_env: "NO_SUCH_TOKEN_4685" }), env: {}, poolConfigPath: cfg }));
308
+ assert.equal(c.init.headers.authorization, "Bearer pool-token-789");
309
+ });
310
+
306
311
  it("an explicit endpoint wins; no forge.yaml adds no env; a missing router config is misconfigured", async () => {
307
312
  const { dir } = setupPool();
308
313
  const batch = openBatch(tmpDir("pool-b2-"), "bt");
@@ -23,7 +23,6 @@ import { scopedEnv } from "./execution.mjs";
23
23
  import { resolveCredential } from "./credentials.mjs";
24
24
 
25
25
  const REDACTED = "[redacted]";
26
- const STATUS_ERRORS = 5;
27
26
  /** Replace every value of `secrets` (4+ chars, longest first) in `text`. */
28
27
  export function redact(text, secrets = []) {
29
28
  let s = String(text ?? "");
@@ -58,6 +57,8 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
58
57
  };
59
58
  const fail = (what, r) => {
60
59
  const e = new Error(`sandbox ${what}: HTTP ${r.status}`);
60
+ e.status = r.status;
61
+ if ([502, 503, 504].includes(r.status)) e.transient = true; // #4672: a router restart or gateway blip, retried by the lane poll
61
62
  if (r.status === 429 || r.status === 503) { e.retryable = true; const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
62
63
  return e;
63
64
  };
@@ -113,7 +114,6 @@ export function gitIdentityEnv(identity, laneEnv = {}) {
113
114
  export function remoteProvider({ batch, repo, settings = {}, name = "remote", transport, env = process.env, credentials = (name) => resolveCredential(name, { env }), now = () => Date.now(), gitIdentity = hostGitIdentity }) {
114
115
  if (!transport) throw new Error("remote provider needs a transport");
115
116
  const idFor = (issue) => `fdrsbx-${safeSegment(batch.id)}-${issue}`;
116
- const errs = new Map(); // sandbox id -> consecutive status errors
117
117
  const known = new Set(); // secret values seen this process, for redaction
118
118
  const remember = (v) => { if (typeof v === "string" && v.length >= 4) known.add(v); };
119
119
  for (const k of settings.secrets ?? []) remember(credentials(k));
@@ -156,8 +156,8 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
156
156
  },
157
157
  async status(h) {
158
158
  let s;
159
- // A transient transport error is not an exit; STATUS_ERRORS in a row is (the lane then ends without a result).
160
- try { s = await transport.status(h.id); errs.delete(h.id); } catch { const k = (errs.get(h.id) ?? 0) + 1; errs.set(h.id, k); return k >= STATUS_ERRORS ? "gone" : "running"; }
159
+ // #4672: a transport error is not an exit; it propagates and providerLanes retries it for execution.unreachable_window_min.
160
+ s = await transport.status(h.id);
161
161
  await pullLogs(h);
162
162
  return s === "running" ? "running" : s === "gone" ? "gone" : "exited";
163
163
  },