forgedock 1.37.0 → 1.38.1

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 (81) hide show
  1. package/bin/engine/outcome-codes.mjs +12 -3
  2. package/bin/engine/telemetry-seam.mjs +5 -0
  3. package/bin/engine-cli.mjs +97 -18
  4. package/bin/forgedock.mjs +13 -2
  5. package/bin/lean/activation.mjs +76 -0
  6. package/bin/lean/activation.test.mjs +75 -0
  7. package/bin/lean/admission.mjs +17 -1
  8. package/bin/lean/batch.mjs +51 -21
  9. package/bin/lean/batch.test.mjs +95 -5
  10. package/bin/lean/conformance.mjs +53 -4
  11. package/bin/lean/conformance.test.mjs +24 -1
  12. package/bin/lean/decision-engine.mjs +217 -0
  13. package/bin/lean/decision-engine.test.mjs +159 -0
  14. package/bin/lean/effects.mjs +39 -13
  15. package/bin/lean/execution.mjs +74 -5
  16. package/bin/lean/execution.test.mjs +64 -1
  17. package/bin/lean/explore.test.mjs +1 -1
  18. package/bin/lean/guards.mjs +3 -2
  19. package/bin/lean/guards.test.mjs +24 -3
  20. package/bin/lean/heavy.mjs +15 -2
  21. package/bin/lean/heavy.test.mjs +31 -2
  22. package/bin/lean/issuewatch-release.mjs +245 -0
  23. package/bin/lean/issuewatch-release.test.mjs +244 -0
  24. package/bin/lean/issuewatch.mjs +386 -0
  25. package/bin/lean/issuewatch.test.mjs +280 -0
  26. package/bin/lean/lane.mjs +4 -3
  27. package/bin/lean/lane.test.mjs +12 -0
  28. package/bin/lean/lean.mjs +103 -16
  29. package/bin/lean/lean.test.mjs +127 -5
  30. package/bin/lean/needs-human-premise.mjs +177 -0
  31. package/bin/lean/needs-human-premise.test.mjs +136 -0
  32. package/bin/lean/orchestrate-blocked.test.mjs +71 -0
  33. package/bin/lean/orchestrate.mjs +93 -21
  34. package/bin/lean/postdeploy-check.mjs +88 -2
  35. package/bin/lean/postdeploy-check.test.mjs +78 -0
  36. package/bin/lean/prodwatch-sources.mjs +165 -0
  37. package/bin/lean/prodwatch-sources.test.mjs +108 -0
  38. package/bin/lean/prompts.mjs +9 -3
  39. package/bin/lean/recovery.test.mjs +14 -0
  40. package/bin/lean/relay.mjs +182 -0
  41. package/bin/lean/relay.test.mjs +214 -0
  42. package/bin/lean/sandbox-provider.contract.test.mjs +13 -1
  43. package/bin/lean/sandbox-remote.mjs +53 -9
  44. package/bin/lean/sched.test.mjs +30 -0
  45. package/bin/lean/schedd-health.mjs +69 -0
  46. package/bin/lean/schedd-health.test.mjs +51 -0
  47. package/bin/lean/schedd.mjs +29 -12
  48. package/bin/lean/schedd.test.mjs +40 -4
  49. package/bin/lean/sequence.mjs +57 -24
  50. package/bin/lean/sequence.test.mjs +71 -0
  51. package/bin/pool/agent.mjs +15 -1
  52. package/bin/pool/router.mjs +43 -5
  53. package/bin/runner.mjs +18 -1
  54. package/commands/deploy-pr.md +12 -0
  55. package/commands/forgedock-init.md +1 -0
  56. package/commands/orchestrate/phase-4-execution.md +1 -0
  57. package/commands/orchestrate.md +1 -0
  58. package/commands/review-pr.md +17 -13
  59. package/docs/CONFIG.md +73 -2
  60. package/docs/DECISIONS.md +32 -0
  61. package/docs/ISSUE-WATCH.md +95 -0
  62. package/docs/NORTHSTAR-TEMPLATE.md +21 -0
  63. package/docs/OUTCOME-CODES.md +2 -1
  64. package/docs/TELEMETRY.md +7 -0
  65. package/package.json +1 -2
  66. package/pool/README.md +1 -1
  67. package/pool/image/Dockerfile +2 -1
  68. package/pool/image/cs-relay +31 -0
  69. package/pool/image/entrypoint.sh +10 -0
  70. package/pool/install/install-schedd.sh +22 -0
  71. package/pool/install/unit-hardening.sh +6 -0
  72. package/scripts/postdeploy-rollup-link.sh +40 -0
  73. package/scripts/postdeploy-rollup-link.test.sh +46 -0
  74. package/scripts/refresh-bot-token.sh +122 -0
  75. package/scripts/review-delta.sh +14 -4
  76. package/scripts/review-delta.test.sh +21 -0
  77. package/scripts/review-finalize.sh +69 -25
  78. package/scripts/review-finalize.test.sh +32 -0
  79. package/scripts/review-pr-arguments.test.sh +10 -0
  80. package/scripts/review-pr-changed-verification.test.sh +54 -0
  81. package/scripts/review-pr-delta-wiring.test.sh +1 -1
@@ -20,7 +20,7 @@
20
20
  * before scheduling, so a live lane does not hold up the others.
21
21
  * `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
22
22
  * limit holds admissions for that account until its reset, and deferrals other than a busy resource (`host.busy`,
23
- * `sandbox.capacity`, #4528) stop after `maxDeferrals`.
23
+ * `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788) stop after `maxDeferrals`.
24
24
  * Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
25
25
  * host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. While held, the
26
26
  * batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset. An `engine-error` is
@@ -31,6 +31,8 @@
31
31
  * batch decision and one `added` row; while it is set, lanes running with an empty queue still wake every `pollMs`.
32
32
  * `getHeld()` (#4592, `--hold`/`--release`) is read on every pass: the issues it returns are never admitted (a running lane is not stopped);
33
33
  * when only held issues (and their dependents) remain and nothing runs, the batch ends with them `held` ("held by operator"), and a `--resume <id> <issue...>` naming them (or `--release`) re-admits them.
34
+ * `getBlocked()` (#4773, the `blocked` label) is read on every pass: the issues it returns are in the batch but not dispatched; removing the label
35
+ * releases them on the next pass (the batch polls while only blocked issues wait). `trusted(n)` gates `Blocked by`/`Depends on` edges to a trusted body author.
34
36
  *
35
37
  * Maintenance budget (#4197), off unless `maintenance: {share, whenIdle}` is passed: `classify(issue)` gives each issue
36
38
  * `{maintenance, priority}` (persisted as `classes`) and admission follows `pickNext`: P0/P1 features, then a maintenance
@@ -48,7 +50,8 @@
48
50
  * starts only when the batch is under its K and the registry grants it a host slot; `priorityOf(issue)` (persisted as
49
51
  * `priorities`) ranks it host-wide, and the batch's ready issues are tried in priority order. Ready issues it cannot
50
52
  * start yet stay offered in the registry (polled every `pollMs`) and are withdrawn once no longer ready; a settled lane
51
- * releases its slot, an adopted one re-records it.
53
+ * releases its slot, an adopted one re-records it. `sched.cap` may be a function, evaluated at each admission attempt
54
+ * (#4777: the live local lane cap from cores and memory; running lanes are never revoked, over-cap issues stay queued).
52
55
  * Per-issue lock (#4481), on with `sched.exclusive` (registry entries ask `exclusive: true`): when another batch holds a live slot for the same repo and issue (`tryAcquire` `conflict`, or
53
56
  * `registry.holder` for a lane placed remote), the issue is not started: it is retried after `pollMs` with one `deferred`
54
57
  * row naming that batch (no deferral count, no breaker), and admission goes on with the next ready issue.
@@ -78,7 +81,7 @@
78
81
  * recoverable (needs-human `ci.red` / `base.conflict` / `builder.block_untyped`, `contract.invalid` after a mid-run issue
79
82
  * edit, `ci-pending` whose PR `conflicting(issue, pr)` says conflicts, the non-human `budget-exceeded` turn overrun of #4505,
80
83
  * recovered at the next size budget) is not final while its persisted `recoveries` stay
81
- * under `maxAttempts`: it is requeued as a recovery lane (`ctx.recover = {code, attempt, final}`, `final` when attempt ==
84
+ * under its class budget (#4725: `maxAttempts`; mechanical/env codes `mechanicalMaxAttempts`, default 2, never below `maxAttempts`): it is requeued as a recovery lane (`ctx.recover = {code, attempt, final}`, `final` when attempt ==
82
85
  * `maxAttempts`) under the same admission as any lane, so its dependents stay queued. Only a final lane relabels needs-human
83
86
  * itself; a non-final attempt that ends needs-human/ci-pending/budget-exceeded without a successor is relabelled here, once (#4302),
84
87
  * as is a first-run budget-exceeded the batch does not recover (#4505: the lane never labels it). A recovery
@@ -101,7 +104,7 @@
101
104
  * the queue order except that decomposed sub-issues go first. A settled row carries `est_minutes` (estimated vs. actual).
102
105
  */
103
106
  import { parseDependsOn } from "../engine/phases.mjs";
104
- import { classifyNeedsHuman, recoveryCodeOf, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
107
+ import { classifyNeedsHuman, recoveryCodeOf, recoveryClassOf, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
105
108
  import { detectGateDeadlock, formatGateDeadlock } from "./gatewatch.mjs";
106
109
  import { recoveryEvidence } from "./render.mjs";
107
110
  import { renderAlerts } from "../stats/render.mjs";
@@ -120,7 +123,10 @@ export const DEFAULT_MAX_DEFERRALS = 3;
120
123
  export const ENGINE_ERROR_RELAUNCHES = 1;
121
124
  export const HOST_BUSY_RETRY_S = 60;
122
125
  // #4528: a busy host or a full sandbox pool is a wait, not a failure: requeued after its retry-in, never capped.
123
- const UNCAPPED_DEFERRALS = new Set(["host.busy", "sandbox.capacity"]);
126
+ // A lane that could not be resolved because gh/git failed (network blip) is the same kind of wait (#4791).
127
+ // #4788: an unreachable pool is a wait too (the lane already retried for unreachable_window_min); a stall is reported by poolStall.
128
+ const UNCAPPED_DEFERRALS = new Set(["host.busy", "sandbox.capacity", "sandbox.unreachable", "lane.unresolved"]);
129
+ export const POOL_OFFLINE_ALERT_MIN = 10;
124
130
  export const RATE_LIMIT_FALLBACK_S = 15 * 60;
125
131
  export const DEFER_BACKOFF_S = 120;
126
132
  export const DEFER_BACKOFF_MAX_S = 30 * 60;
@@ -240,7 +246,7 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
240
246
  export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
241
247
  store = null, adopt = null, getParallel = null, getAdded = null, getRequeued = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
242
248
  classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
243
- recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, getHeld = null, getGates = null, stopLane = null,
249
+ recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, issueState = null, getCancelled = null, getHeld = null, getBlocked = null, trusted = null, getGates = null, stopLane = null,
244
250
  estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null }) {
245
251
  const saved = store?.load?.() ?? null;
246
252
  const order = saved ? [...saved.order] : [];
@@ -282,6 +288,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
282
288
  const reg = sched?.registry ?? null;
283
289
  const priorities = toMap(saved?.priorities); // issue -> priority number; only with the registry
284
290
  const remoteMax = remote && Number.isInteger(remote.max) && remote.max >= 0 ? remote.max : 0;
291
+ let poolOfflineSince = null, poolStallNoted = false; // #4788: when sampled online first read 0, and whether the stall was reported
285
292
  let capAt, capAdmits = 0; // #4540: provider capacity sample and the remote admissions made since it
286
293
  const remoteRun = new Set(); // remote lanes running now (#4253): admitted against remoteMax, never the host cap
287
294
  let budgetLocal = false; // the execution budget refused a remote lane under on_budget local: the rest run on the host
@@ -289,7 +296,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
289
296
  const placeOf = (n) => {
290
297
  if (!remote) return null;
291
298
  // #4542: a lane recovering from a remote capability block runs on the host, which has the capability.
292
- if (recovering.get(n)?.code === OUTCOME_CODES.ENV_CAPABILITY_REMOTE) return { where: "local", reason: "host capability: recovering env.capability_remote" };
299
+ // #4785: a tool-missing recovery too: the sandbox that lacked the tool is not chosen again.
300
+ if ([OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.ENV_TOOL_MISSING].includes(recovering.get(n)?.code)) return { where: "local", reason: `host capability: recovering ${recovering.get(n).code}` };
293
301
  if (budgetLocal) return { where: "local", reason: `fallback to local: execution budget: ${budgetHit ?? "cap reached"}` };
294
302
  try {
295
303
  if (typeof remote.place === "function") {
@@ -305,6 +313,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
305
313
  const remoteCpm = Number(remote?.costPerMinute) > 0 ? Number(remote.costPerMinute) : 0;
306
314
  let budgetStop = null, budgetHit = null; // this pass's refusal under on_budget stop; the last refusal reason
307
315
  const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
316
+ // #4725: mechanical/env codes recover on their own budget (>= the conformance one); 0 maxAttempts still turns recovery off.
317
+ const mechMax = recoveryMax && Number.isInteger(recovery?.mechanicalMaxAttempts) && recovery.mechanicalMaxAttempts > 0 ? Math.max(recovery.mechanicalMaxAttempts, recoveryMax) : recoveryMax;
318
+ const maxFor = (cls) => (cls === "mechanical" ? mechMax : recoveryMax);
319
+ const classUsed = toMap(saved?.recoveryClasses); // `${issue}:${class}` -> recovery lanes launched in that class
308
320
  const recoveries = toMap(saved?.recoveries); // issue -> recovery lanes launched (#4267)
309
321
  const recovering = toMap(saved?.recovering); // issue -> {code, attempt, detail, outcome, ...} of the recovery lane queued or running
310
322
  const requeueApplied = new Set(saved?.requeueApplied ?? []); // #4550: `--requeue` entries already applied (batch `requeue` file)
@@ -322,7 +334,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
322
334
  pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
323
335
  prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
324
336
  ...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
325
- ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
337
+ ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering), recoveryClasses: toObj(classUsed) } : {}),
326
338
  ...(requeueApplied.size ? { requeueApplied: [...requeueApplied], requeued: toObj(requeuedInfo) } : {}),
327
339
  ...(relabelled.size ? { relabelled: [...relabelled] } : {}), ...(cancelled ? { cancelled } : {}),
328
340
  ...(estimate ? { estimates: toObj(estimates), enqueued: toObj(enqueued) } : {}), ...(heads.size ? { heads: [...heads] } : {}) });
@@ -347,15 +359,20 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
347
359
  try { v = Number(await estimate(n, { body })); } catch { v = null; }
348
360
  estimates.set(n, Number.isFinite(v) && v > 0 ? v : EST_DEFAULT_MIN.M);
349
361
  };
362
+ // #4773: a declaration line is an edge only when the body's author is trusted (the engine's admission applies the same rule).
363
+ const declared = async (n, body) => {
364
+ if (typeof trusted === "function") { let ok = false; try { ok = (await trusted(n)) === true; } catch { ok = false; } if (!ok) return []; }
365
+ return parseDeps(body, n);
366
+ };
350
367
  const readDeps = async (n) => {
351
368
  let body = "";
352
369
  try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
353
370
  if (drift?.holds) bodies.set(n, body);
354
- deps.set(n, parseDeps(body, n));
371
+ deps.set(n, await declared(n, body));
355
372
  await readEstimate(n, body);
356
373
  await classifyOne(n);
357
374
  await readPriority(n);
358
- if (resolveLane) { let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e) }; } lanes.set(n, l); }
375
+ if (resolveLane) { let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e), transient: true }; } lanes.set(n, l); }
359
376
  };
360
377
  const edges = (n) => (deps.get(n) ?? []).filter((d) => set.has(d));
361
378
  // A milestone issue waiting on a staging fix (or the reverse) never sees that fix on its own base: surface the edge.
@@ -401,6 +418,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
401
418
  // Host registry seams (#4191): `offered` issues are queued for a slot, `slotted` ones hold one.
402
419
  const offered = new Set(), slotted = new Set();
403
420
  let fellBack = false;
421
+ // #4777: `sched.cap` may be a function (the host-derived local lane cap), evaluated at every attempt so it stays live.
422
+ const liveCap = () => { try { return typeof sched.cap === "function" ? sched.cap() : sched.cap; } catch { return null; } };
404
423
  const keyOf = (n) => `${sched?.batch ?? batchId ?? "?"}#${n}`;
405
424
  const prioOf = (n) => priorities.get(n) ?? 2;
406
425
  // #4409: priority, then the head band (decomposed sub-issues; with an estimator also starved issues, oldest first), then
@@ -472,6 +491,15 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
472
491
  // #4540: the provider's free slots (`remote.capacity() -> {free, at}`, sync, null = unknown) cap admissions per sample `at`; the issue queues here instead of a 503.
473
492
  let cap = null;
474
493
  try { cap = remote.capacity?.() ?? null; } catch { cap = null; }
494
+ // #4788: zero live agents for longer than `remote.poolOfflineMin` (default 10): one loud batch event, none while online > 0.
495
+ if (cap && cap.online === 0) {
496
+ poolOfflineSince ??= now();
497
+ const limitMs = (posNum(remote.poolOfflineMin) ?? POOL_OFFLINE_ALERT_MIN) * 60000;
498
+ if (!poolStallNoted && now() - poolOfflineSince > limitMs) {
499
+ poolStallNoted = true;
500
+ update({ issue: null, outcome: "pool-offline", detail: `STALLED: no compute pool agent online for over ${Math.round(limitMs / 60000)} min; ${remote.offlineFallback ? "lanes fall back to the host" : "remote lanes wait (set execution.fallback_local or use mode local to run on the host)"}` });
501
+ }
502
+ } else if (cap && cap.online > 0) { poolOfflineSince = null; poolStallNoted = false; }
475
503
  // #4547: opt-in (`execution.fallback_local`): every pool agent offline or asleep (sampled online 0) runs the lane on the host.
476
504
  if (remote.offlineFallback && cap && cap.online === 0) {
477
505
  p = { where: "local", reason: `fallback to local: no pool agent online (${p.reason})` };
@@ -490,7 +518,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
490
518
  }
491
519
  }
492
520
  if (!reg) { if (p) placed.set(n, p); return true; }
493
- const r = reg.tryAcquire(keyOf(n), entryOf(n), sched.cap);
521
+ const r = reg.tryAcquire(keyOf(n), entryOf(n), liveCap());
494
522
  if (!r?.ok && r?.conflict != null) return busy(n, r.conflict, wanted);
495
523
  if (!r?.ok) { offered.add(n); wanted.add(n); return false; }
496
524
  offered.delete(n); slotted.add(n);
@@ -553,6 +581,11 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
553
581
  for (const d of depList) {
554
582
  if (["needs-human", "budget-exceeded"].includes(results.get(d)?.outcome)) { parked = d; break; } // #4505: an unrecovered overrun parks too
555
583
  if (set.has(d) || typeof issueLabels !== "function") continue;
584
+ if (typeof issueState === "function") { // #4725: a closed (completed) dependency is satisfied: its stale needs-human label is not a blocker
585
+ let st = "";
586
+ try { st = String((await issueState(d)) ?? "").toUpperCase(); } catch { st = ""; }
587
+ if (st === "CLOSED") continue;
588
+ }
556
589
  let names = [];
557
590
  try { names = (await issueLabels(d)) ?? []; } catch { names = []; }
558
591
  if (Array.isArray(names) && names.includes("needs-human")) { parked = d; break; }
@@ -641,22 +674,24 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
641
674
  if (row.outcome === "ci-pending" && typeof conflicting === "function" && Number.isInteger(row.pr)) {
642
675
  try { isConflicting = (await conflicting(n, row.pr)) === true; } catch { isConflicting = false; }
643
676
  }
644
- const facts = { ...row, issueEdited: r?.issueEdited === true, conflicting: isConflicting };
645
- const used = recoveries.get(n) ?? 0;
646
- if (classifyNeedsHuman(facts) === "recoverable" && used < recoveryMax) {
677
+ const facts = { ...row, issueEdited: r?.issueEdited === true, quoteMismatch: r?.quoteMismatch === true, conflicting: isConflicting };
678
+ if (prior) { const pk = `${n}:${recoveryClassOf({ code: prior.code })}`; classUsed.set(pk, Math.max(classUsed.get(pk) ?? 0, prior.attempt)); } // state saved before #4725 has no class counts
679
+ const cls = recoveryClassOf(facts), max = maxFor(cls), used = classUsed.get(`${n}:${cls}`) ?? 0;
680
+ if (classifyNeedsHuman(facts) === "recoverable" && used < max) {
647
681
  const code = recoveryCodeOf(facts), attempt = used + 1;
682
+ classUsed.set(`${n}:${cls}`, attempt);
648
683
  // #4315: the full lane detail is kept (never firstLine) for the relabel when the batch stops before this attempt
649
- recoveries.set(n, attempt); recovering.set(n, { code, attempt, cost_usd: Number(row.cost_usd) || 0, minutes: Number(row.minutes) || 0,
684
+ recoveries.set(n, (recoveries.get(n) ?? 0) + 1); recovering.set(n, { code, attempt, max, cost_usd: Number(row.cost_usd) || 0, minutes: Number(row.minutes) || 0,
650
685
  detail: String(row.detail ?? ""), outcome: row.outcome, ...(row.code ? { result_code: row.code } : {}), prev_attempt: prior?.attempt ?? 0 });
651
686
  requeue(n, 0); done(); save();
652
- decided({ issue: n, what: `recovery lane for ${code} (attempt ${attempt}/${recoveryMax}); dependents stay queued`, why: firstLine(row.detail) || code,
687
+ decided({ issue: n, what: `recovery lane for ${code} (attempt ${attempt}/${max}); dependents stay queued`, why: firstLine(row.detail) || code,
653
688
  alternative: "leave needs-human for the operator and skip the dependents" });
654
- update({ ...row, outcome: "recovering", detail: `recovering (${code}), attempt ${attempt}/${recoveryMax}: ${firstLine(row.detail)}` });
689
+ update({ ...row, outcome: "recovering", detail: `recovering (${code}), attempt ${attempt}/${max}: ${firstLine(row.detail)}` });
655
690
  return;
656
691
  }
657
692
  }
658
693
  // #4302: a non-final recovery attempt left needs-human/ci-pending unlabelled for its successor; none is launched, so relabel once.
659
- if (prior && prior.attempt < recoveryMax && ["needs-human", "ci-pending", "budget-exceeded"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
694
+ if (prior && prior.attempt < (prior.max ?? recoveryMax) && ["needs-human", "ci-pending", "budget-exceeded"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
660
695
  try { await relabelNeedsHuman({ issue: n, evidence: evidence() }); } catch { /* best effort */ }
661
696
  }
662
697
  // #4505: a first-run turn overrun is unlabelled by its lane; no recovery follows (off or cap used), so the runner labels it once.
@@ -684,8 +719,18 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
684
719
  };
685
720
 
686
721
  const track = (n, p) => { running.set(n, p.catch((e) => settle(n, { terminalReason: "engine-error", detail: e?.message ?? String(e) }, runRecs.get(n), null))); };
722
+ const laneRetries = new Map(); // #4791: transient lane-resolution failures per issue; never counted in `deferrals`
723
+ // A transient refusal (gh/git failed) re-resolves; the settled `deferred` frees the slot and requeues with backoff.
724
+ const retryLane = (n, rec) => track(n, (async () => {
725
+ let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e), transient: true }; }
726
+ lanes.set(n, l);
727
+ if (l.ok || !l.transient) { laneRetries.delete(n); return { terminalReason: "deferred", code: "lane.unresolved", retryInSeconds: 1, cost_usd: 0, detail: l.ok ? "lane resolved" : `${l.code}: ${l.detail}` }; }
728
+ const k = laneRetries.get(n) ?? 0; laneRetries.set(n, k + 1);
729
+ return { terminalReason: "deferred", code: "lane.unresolved", retryInSeconds: Math.min(DEFER_BACKOFF_S * 2 ** k, DEFER_BACKOFF_MAX_S), cost_usd: 0, detail: l.detail };
730
+ })().then((x) => settle(n, x, rec, null)));
687
731
  const start = (n) => {
688
732
  const ln = lanes.get(n);
733
+ if (ln && !ln.ok && ln.transient && resolveLane) { retryLane(n, runRecs.get(n) ?? { startedAt: now() }); return; }
689
734
  if (ln && !ln.ok) { // settled synchronously: nothing runs, no lease, no comment
690
735
  const row = { issue: n, outcome: "lane-refused", pr: null, cost_usd: 0, minutes: 0, detail: `${ln.code}: ${ln.detail}`, code: ln.code };
691
736
  results.set(n, row); save(); update(row);
@@ -717,7 +762,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
717
762
  else if (pf?.parked) r = { terminalReason: pf.kind, cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
718
763
  else {
719
764
  try {
720
- r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), ...(recovering.has(n) ? { recover: { code: recovering.get(n).code, attempt: recovering.get(n).attempt, final: recovering.get(n).attempt >= recoveryMax } } : {}), onStart: (info) => {
765
+ r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), ...(recovering.has(n) ? { recover: { code: recovering.get(n).code, attempt: recovering.get(n).attempt, final: recovering.get(n).attempt >= (recovering.get(n).max ?? recoveryMax) } } : {}), onStart: (info) => {
721
766
  Object.assign(rec, info ?? {}); save();
722
767
  if (cancelled) stop(n, rec); // #4376: launched while the cancel arrived
723
768
  if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
@@ -763,7 +808,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
763
808
  if (saved && resolveLane) {
764
809
  for (const n of order) {
765
810
  if (results.has(n) || running.has(n)) continue;
766
- let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e) }; }
811
+ let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e), transient: true }; }
767
812
  lanes.set(n, l);
768
813
  }
769
814
  noteCrossLane();
@@ -801,7 +846,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
801
846
  let body;
802
847
  try { body = await readBody(n); } catch { continue; }
803
848
  if (drift?.holds) bodies.set(n, body);
804
- deps.set(n, [...new Set([...(deps.get(n) ?? []), ...parseDeps(body, n)])]);
849
+ deps.set(n, [...new Set([...(deps.get(n) ?? []), ...(await declared(n, body))])]);
805
850
  }
806
851
  await addIssues(fresh);
807
852
  save();
@@ -872,6 +917,26 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
872
917
  update({ issue: null, outcome: "release", detail: fmt(freed) });
873
918
  }
874
919
  };
920
+ // #4773: re-read the issues carrying the blocked label; one decision and row per change. A failed read keeps the last set.
921
+ let blocked = new Set();
922
+ const blockedHit = new Set(); // blocked issues that were pending this pass
923
+ const refreshBlocked = async () => {
924
+ if (!getBlocked) return;
925
+ let got;
926
+ try { got = await getBlocked(); } catch { return; }
927
+ const next = new Set((Array.isArray(got) ? got : [...(got ?? [])]).filter((n) => Number.isSafeInteger(n) && n > 0));
928
+ const fmt = (ns) => ns.map((n) => `#${n}`).join(", ");
929
+ const on = [...next].filter((n) => !blocked.has(n) && set.has(n) && !results.has(n)), off = [...blocked].filter((n) => !next.has(n));
930
+ blocked = next;
931
+ if (on.length) {
932
+ decided({ issue: null, what: `blocked ${fmt(on)} (blocked label)`, why: "admitted to the batch, not dispatched while the label is set", alternative: "remove the label" });
933
+ update({ issue: null, outcome: "blocked", detail: fmt(on) });
934
+ }
935
+ if (off.length) {
936
+ decided({ issue: null, what: `unblocked ${fmt(off)} (blocked label removed)`, why: "the label is gone", alternative: "keep waiting" });
937
+ update({ issue: null, outcome: "unblocked", detail: fmt(off) });
938
+ }
939
+ };
875
940
  // #4656: a gate (hold list, settle, ship pre-flight) waiting on an issue the scheduler will not admit: one decision and row per (gate, issue).
876
941
  const gateSeen = new Map(), gateReported = new Set();
877
942
  const checkGates = () => {
@@ -990,10 +1055,13 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
990
1055
  driftHeld.clear();
991
1056
  integHeld.clear();
992
1057
  opHeldHit.clear();
1058
+ blockedHit.clear();
993
1059
  refreshHeld();
1060
+ await refreshBlocked();
994
1061
  checkGates();
995
1062
  const isReady = (n) => {
996
1063
  if (opHeld.has(n)) { opHeldHit.add(n); return false; }
1064
+ if (blocked.has(n)) { blockedHit.add(n); return false; } // #4773
997
1065
  if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
998
1066
  if (integration?.holds) {
999
1067
  let why = null;
@@ -1072,6 +1140,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1072
1140
  if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
1073
1141
  if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
1074
1142
  if ((getAdded || getRequeued) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
1143
+ if (getBlocked && pending.length && blockedHit.size) wake = Math.min(wake ?? pollMs, pollMs); // #4773: a removed label releases on the next tick
1075
1144
  if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
1076
1145
  if (syncParked && running.size && parkedPrs().length) wake = Math.min(wake ?? syncEveryMs, Math.max(0, syncEveryMs - (t - (lastParkedSync ?? t)))); // #4525
1077
1146
  if (running.size && watchable()) wake = Math.min(wake ?? WATCH_EVERY_MS, WATCH_EVERY_MS); // #4595: re-check the blockers of skipped rows
@@ -1082,6 +1151,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1082
1151
  else { const ac = new AbortController(); await race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
1083
1152
  } else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
1084
1153
  else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
1154
+ else if (pending.length && blockedHit.size) await nap(wake ?? pollMs); // #4773: only blocked issues wait: poll for the label's removal
1085
1155
  else if (pending.length && waits.length) await nap(Math.min(wake, getCancelled ? pollMs : Infinity)); // wake is poll-capped by a hold or a live K; #4454: nap wakes on a stop
1086
1156
  else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
1087
1157
  else if (pending.length && opHeldHit.size && !pending.some((n) => !opHeldHit.has(n) && isReady(n))) { if (await drainAdds()) continue; break; } // #4592: held by the operator: pending for a --resume
@@ -1149,6 +1219,8 @@ export function formatOutcomeLists(rows) {
1149
1219
  if (rq.length) out.push(`remediation queue (/work-on:remediate): ${rq.map((r) => `#${r.issue} (parked:${r.code}${r.pr ? ` PR #${r.pr}` : ""})`).join(" ")}`);
1150
1220
  const parked = formatParkedLine(rows); // #4389: one line for every parked post-deploy follow-up, never an escalation
1151
1221
  if (parked) out.push(parked);
1222
+ const decided = (rows ?? []).filter((r) => r?.decided); // #4766: settled by the decision engine, listed not hidden
1223
+ if (decided.length) out.push(["decided (north-star engine, veto with the forge:decision-veto label):", ...decided.map((r) => `- #${r.issue}: ${one(r.decided.choice)}${r.decided.shadow ? " (shadow)" : ""}`)].join("\n"));
1152
1224
  if (esc.length) out.push(["escalations (human decisions, asked once):", ...esc.map((r) => `- #${r.issue} (${r.code}): ${one(r.detail)}`)].join("\n"));
1153
1225
  return out;
1154
1226
  }
@@ -9,6 +9,7 @@
9
9
  * probe runner's minimal env): all pass -> a FORGE:PROBE record and the follow-up closes; a fail -> the record, and the
10
10
  * parent reopens with the output. An item without a runnable check is `awaiting-measurement`: no lane, ever.
11
11
  */
12
+ import { createHash } from "node:crypto";
12
13
  import { noComments } from "./render.mjs";
13
14
  import { MAX_TIMEOUT_S, OUTPUT_TAIL, PROBE_MARKER, probeEnv, probeResultOf, readProbeConfig, runProbe } from "./probe.mjs";
14
15
  import { parseMinAge } from "./prefilter.mjs";
@@ -18,6 +19,7 @@ export const CHECK_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
18
19
  export const AWAITING_MEASUREMENT_LABEL = "awaiting-measurement";
19
20
  export const MONITORING_LABEL = "post-deploy-monitoring";
20
21
  export const OWNER_ASK_MARKER = "FORGE:OWNER_ASK"; // #4689
22
+ export const ROLLOUT_ASK_MARKER = "FORGE:ROLLOUT_ASK"; // #4769: distinct, so it never silences the no-runnable-check ask
21
23
  export const DEFAULT_EVERY_MS = 6 * 3600e3;
22
24
  const ITEM_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
23
25
  const CHECK_TOKEN_RE = /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:\s*\[check:\s*([^\]\s]*)\s*\]/;
@@ -98,8 +100,30 @@ export async function addMonitoringLabel({ io, repo, issue }) {
98
100
  }
99
101
  }
100
102
 
101
- /** #4689: tick every unticked post-deploy checklist item of a follow-up body (the evidence is in the FORGE:PROBE comment). */
102
- export const tickItems = (body) => String(body ?? "").replace(/^(\s*[-*]\s+)\[ \](\s+`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:)/gm, "$1[x]$2");
103
+ /**
104
+ * #4689: tick every unticked post-deploy checklist item of a follow-up body (the evidence is in the FORGE:PROBE comment).
105
+ * #4755: with `ids` (a Set of check ids) only the items naming one of them are ticked.
106
+ */
107
+ export function tickItems(body, ids = null) {
108
+ const text = String(body ?? "");
109
+ const re = /^(\s*[-*]\s+)\[ \](\s+`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:)/;
110
+ if (!ids) return text.replace(new RegExp(re.source, "gm"), "$1[x]$2");
111
+ const per = itemCheckIds(text);
112
+ let i = 0;
113
+ return text.split("\n").map((l) => (ITEM_RE.test(l) ? (ids.has(per[i++]) ? l.replace(re, "$1[x]$2") : l) : l)).join("\n");
114
+ }
115
+
116
+ /** #4755: per checklist item `{id, done}`: its check id (null when none) and whether it is already ticked. */
117
+ const itemStates = (body) => {
118
+ const ids = itemCheckIds(body);
119
+ return noComments(String(body ?? ""), " ").split("\n").map((l) => ITEM_RE.exec(l)).filter(Boolean).map((m, i) => ({ id: ids[i], done: /^\s*[-*]\s+\[[xX]\]/.test(m[0]) }));
120
+ };
121
+ const ROLLUP_RE = /<!--\s*FORGE:POSTDEPLOY_ROLLUP:/;
122
+ /** #4755: the check ids a FORGE:PROBE record of `fix` reported failing. */
123
+ const failedIdsOf = (body, fix) => {
124
+ const line = String(body ?? "").split("\n").find((l) => /^\s*`?\{.*"result"/.test(l));
125
+ try { const j = JSON.parse(line.trim().replace(/^`|`$/g, "")); return j.fix === fix && j.result === "fail" ? (j.checks ?? []).filter((c) => c.result === "fail").map((c) => c.id) : []; } catch { return []; }
126
+ };
103
127
 
104
128
  /** #4689: one typed `FORGE:OWNER_ASK` comment for the items with no runnable check, posted once; best effort, nothing in a dry run. */
105
129
  async function postOwnerAsk({ iss, repo, io, dryRun, isTrusted, ids, missing, def }) {
@@ -108,6 +132,27 @@ async function postOwnerAsk({ iss, repo, io, dryRun, isTrusted, ids, missing, de
108
132
  try { await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", `<!-- ${OWNER_ASK_MARKER} -->\n## Owner ask\nNo lane was started and none will be. Needed from the owner: ${what}, or measure the item by hand.`]); } catch { /* the label still shows it */ }
109
133
  }
110
134
 
135
+ /** #4769: a check output `BLOCKED: ... <flag> = off ...` means the rollout is off, not that the change is broken: {flag, evidence} or null. */
136
+ export function blockedRollout(output) {
137
+ const m = /^BLOCKED:\s*(?:.*?\s)?([A-Za-z0-9_.:*-]+)\s*=\s*off\b.*$/m.exec(String(output ?? ""));
138
+ return m ? { flag: m[1], evidence: String(output).trim().slice(-OUTPUT_TAIL) } : null;
139
+ }
140
+ const evidenceHash = (text) => createHash("sha1").update(String(text).replace(/\d{4}-\d{2}-\d{2}[T ][\d:.]+Z?/g, "").trim()).digest("hex").slice(0, 12);
141
+ const ASK_RE = /FORGE:ROLLOUT_ASK rollout:(\S+) evidence:([0-9a-f]+)/g;
142
+ /** #4769: one typed owner ask per blocked flag, re-posted only when its evidence hash changes. Returns the flags newly raised. */
143
+ async function postRolloutAsks({ iss, repo, io, isTrusted, blocked, def }) {
144
+ const prior = new Map();
145
+ for (const c of iss.comments ?? []) if (isTrusted(c)) for (const m of String(c?.body ?? "").matchAll(ASK_RE)) prior.set(m[1], m[2]);
146
+ const raised = [];
147
+ for (const b of blocked) {
148
+ const hash = evidenceHash(b.evidence);
149
+ if (prior.get(b.flag) === hash) continue;
150
+ const body = `<!-- ${ROLLOUT_ASK_MARKER} rollout:${b.flag} evidence:${hash} -->\n## Owner ask: promote \`${b.flag}\`\nThe post-deploy check on \`${def}\` is blocked because \`${b.flag}\` is off. No lane was started and none will be. Decide: promote it (first cohort, guard metrics, shadow evidence below) or record a keep-off decision.\n\n${fenced(b.evidence)}`;
151
+ try { await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", body]); raised.push(b.flag); } catch { /* retried next pass */ }
152
+ }
153
+ return raised;
154
+ }
155
+
111
156
  /** #4689: one engineering issue per failing check (never a lane): the output, the fix and the follow-up. Best effort. */
112
157
  async function fileFailureIssues({ bad, iss, repo, io, parent, def, fix }) {
113
158
  for (const r of bad) {
@@ -118,6 +163,46 @@ async function fileFailureIssues({ bad, iss, repo, io, parent, def, fix }) {
118
163
  }
119
164
  }
120
165
 
166
+ /**
167
+ * #4755: a post-deploy rollup is worked per item, so one failing or check-less item never blocks the rest. Runs the unticked
168
+ * items that have a default-branch check (minus those a FORGE:PROBE record of this fix already failed), ticks the passing ones
169
+ * (the evidence is the PROBE record), files one issue per failing check, and closes the rollup only when every item is ticked.
170
+ */
171
+ async function measureItems({ iss, repo, def, shas, checks, cwd, io, dryRun, isTrusted, config, env, spawn, now }) {
172
+ const fix = [...shas].sort().join(",");
173
+ const todo = itemStates(iss.body).filter((t) => !t.done);
174
+ if (!todo.length) { if (!dryRun) await io.gh(["issue", "close", String(iss.number), "-R", repo, "--reason", "completed"]); return { state: "pass", detail: "every item is ticked" }; }
175
+ const failed = new Set((iss.comments ?? []).filter((c) => isTrusted(c)).flatMap((c) => failedIdsOf(c?.body, fix)));
176
+ const missing = todo.filter((t) => !t.id || !checks[t.id]).map((t) => t.id);
177
+ const ids = todo.filter((t) => t.id && checks[t.id] && !failed.has(t.id)).map((t) => t.id);
178
+ if (dryRun) return { state: "dry-run", detail: `checks ${[...new Set(ids)].join(", ") || "none"} not run (dry run)` };
179
+ if (missing.length) await postOwnerAsk({ iss, repo, io, dryRun, isTrusted, ids: todo.map((t) => t.id), missing, def });
180
+ const rows = ids.length ? await runNamedChecks({ ids, checks, cwd, config, env, spawn }) : [];
181
+ const blockedRows = rows.filter((r) => r.result === "fail" && blockedRollout(r.output)); // #4769
182
+ const good = rows.filter((r) => r.result === "pass"), bad = rows.filter((r) => r.result === "fail" && !blockedRows.includes(r));
183
+ const blocked = [...new Map(blockedRows.map((r) => [blockedRollout(r.output).flag, blockedRollout(r.output)])).values()];
184
+ if (blocked.length) await postRolloutAsks({ iss, repo, io, isTrusted, blocked, def });
185
+ if (good.length || bad.length) {
186
+ let defaultSha = null;
187
+ try { defaultSha = String(await io.git(["rev-parse", `origin/${def}`], { cwd })).trim() || null; } catch { /* recorded as null */ }
188
+ const body = [
189
+ `<!-- ${PROBE_MARKER} -->`, `## Post-deploy check: ${bad.length ? "fail" : "pass"}`,
190
+ JSON.stringify({ fix, default_sha: defaultSha, result: bad.length ? "fail" : "pass", at: new Date(now()).toISOString(), checks: rows.map((r) => ({ id: r.id, result: r.result, exit: r.exit, ms: r.ms })) }), "",
191
+ `Ran on \`${def}\` per rollup item; commands come from \`${CHECKS_FILE}\` on \`${def}\`. Passing items are ticked; only failing checks get an issue.`,
192
+ ...bad.flatMap((r) => ["", `**${r.id}** (exit ${r.exit ?? "timeout"})`, fenced(r.output)]),
193
+ ].join("\n").slice(0, OUTPUT_TAIL * 4);
194
+ await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", body]);
195
+ const ticked = tickItems(iss.body, new Set(good.map((r) => r.id)));
196
+ if (ticked !== iss.body) { try { await io.gh(["issue", "edit", String(iss.number), "-R", repo, "--body", ticked]); } catch { /* the PROBE record is the evidence */ } }
197
+ if (bad.length) await fileFailureIssues({ bad, iss, repo, io, parent: null, def, fix });
198
+ }
199
+ const left = todo.filter((t) => !(t.id && good.some((r) => r.id === t.id)));
200
+ if (!left.length) { await io.gh(["issue", "close", String(iss.number), "-R", repo, "--reason", "completed"]); return { state: "pass", detail: `checks ${[...new Set(good.map((r) => r.id))].join(", ")} passed` }; }
201
+ if (blocked.length && !bad.length && !failed.size && !missing.length) return { state: "awaiting-measurement", blocked: blocked.map((b) => b.flag), detail: `${good.length} item(s) ticked; blocked on rollout ${blocked.map((b) => b.flag).join(", ")} (owner ask)` };
202
+ if (missing.length) return { state: "awaiting-measurement", detail: `${good.length} item(s) ticked; no runnable check on ${def} for ${missing.map((m) => m ?? "an item without a check id").join(", ")} (${CHECKS_FILE})` };
203
+ return { state: bad.length ? "fail" : "recorded-fail", detail: `${good.length} item(s) ticked; failing: ${[...new Set([...bad.map((r) => r.id), ...failed])].join(", ")}` };
204
+ }
205
+
121
206
  /**
122
207
  * Measure a ready post-deploy follow-up. Never runs a command that is not in the default branch's checks file.
123
208
  * @returns {Promise<{state:"awaiting-measurement"|"pass"|"fail"|"recorded-fail"|"dry-run"|"monitoring", detail:string}>}
@@ -127,6 +212,7 @@ async function fileFailureIssues({ bad, iss, repo, io, parent, def, fix }) {
127
212
  export async function measureFollowup({ iss, repo, parent, def, shas = [], cwd, io, dryRun = false, isTrusted = () => false, config = readProbeConfig(cwd), env = process.env, spawn, now = () => Date.now() }) {
128
213
  const ids = itemCheckIds(iss.body);
129
214
  const checks = await checksAt(io.git, [`origin/${def}`], cwd);
215
+ if (ROLLUP_RE.test(String(iss.body ?? "")) && !ids.some((id) => id && checks[id]?.forMs)) return measureItems({ iss, repo, def, shas, checks, cwd, io, dryRun, isTrusted, config, env, spawn, now }); // #4755
130
216
  const missing = ids.filter((id) => !id || !checks[id]);
131
217
  if (!ids.length || missing.length) {
132
218
  await postOwnerAsk({ iss, repo, io, dryRun, isTrusted, ids, missing, def });
@@ -204,3 +204,81 @@ describe("verification outcome (#4689)", () => {
204
204
  assert.equal(w2.gh.filter((a) => a[1] === "comment").length, 0);
205
205
  });
206
206
  });
207
+
208
+ describe("post-deploy rollup is verified per item (#4755)", () => {
209
+ const rollup = (items) => `## Acceptance Criteria\n\n### #40\n<!-- FORGE:DEFERRED_FROM: #40 -->\n${items.join("\n")}\n\n<!-- FORGE:POSTDEPLOY_ROLLUP: default -->\n`;
210
+ const files = JSON.stringify({ bad: "echo nope", smoke: "echo ok", other: "echo ok" });
211
+ it("ticks the passing item, files one issue for the failure, one owner ask for the check-less item, stays open", async () => {
212
+ const w = world({ file: files });
213
+ const r = await run(w, rollup([item("R1", "[check:smoke] x"), item("R2", "[check:bad] x"), item("R3", "[check:gone] x")]));
214
+ assert.equal(r.state, "awaiting-measurement");
215
+ const edit = w.gh.find((a) => a[1] === "edit" && a.includes("--body"));
216
+ const text = edit[edit.indexOf("--body") + 1];
217
+ assert.match(text, /- \[x\] `R1:/);
218
+ assert.match(text, /- \[ \] `R2:/);
219
+ assert.match(text, /- \[ \] `R3:/);
220
+ assert.equal(w.gh.filter((a) => a[1] === "create").length, 1);
221
+ assert.equal(w.gh.filter((a) => a[1] === "comment" && /FORGE:OWNER_ASK/.test(a[a.indexOf("--body") + 1])).length, 1);
222
+ assert.ok(!w.gh.some((a) => a[1] === "close" || a[1] === "reopen"));
223
+ const probe = w.gh.find((a) => a[1] === "comment" && /FORGE:PROBE/.test(a[a.indexOf("--body") + 1]));
224
+ assert.match(probe[probe.indexOf("--body") + 1], /"id":"smoke","result":"pass"/);
225
+ });
226
+ it("a failing check does not block the passing items, and is not re-run for the same fix", async () => {
227
+ const w = world({ file: files });
228
+ const b = rollup([item("R1", "[check:smoke] x"), item("R2", "[check:bad] x")]);
229
+ assert.equal((await run(w, b)).state, "fail");
230
+ const probe = w.gh.find((a) => a[1] === "comment" && /FORGE:PROBE/.test(a[a.indexOf("--body") + 1]));
231
+ const ticked = w.gh.find((a) => a[1] === "edit")[w.gh.find((a) => a[1] === "edit").indexOf("--body") + 1];
232
+ const w2 = world({ file: files });
233
+ const r = await run(w2, ticked, { iss: { number: 41, body: ticked, comments: [{ body: probe[probe.indexOf("--body") + 1] }] }, isTrusted: () => true });
234
+ assert.equal(r.state, "recorded-fail");
235
+ assert.deepEqual(w2.spawned, []);
236
+ assert.equal(w2.gh.filter((a) => a[1] === "create").length, 0);
237
+ });
238
+ it("closes only when every item is ticked", async () => {
239
+ const w = world({ file: files });
240
+ assert.equal((await run(w, rollup([item("R1", "[check:smoke] x"), item("R2", "[check:other] x")]))).state, "pass");
241
+ assert.ok(w.gh.some((a) => a[1] === "close"));
242
+ assert.ok(!w.gh.some((a) => a[1] === "create"));
243
+ });
244
+ it("tickItems with an id set ticks only those items", () => {
245
+ const b = [item("R1", "[check:a] x"), item("R2", "[check:b] x")].join("\n");
246
+ assert.equal(tickItems(b, new Set(["b"])), `${item("R1", "[check:a] x")}\n${item("R2", "[check:b] x")}`.replace("- [ ] `R2", "- [x] `R2"));
247
+ });
248
+ });
249
+
250
+ describe("rollout-blocked rows raise one owner ask per flag (#4769)", () => {
251
+ const rollup = (items) => `## Acceptance Criteria\n\n### #40\n<!-- FORGE:DEFERRED_FROM: #40 -->\n${items.join("\n")}\n\n<!-- FORGE:POSTDEPLOY_ROLLUP: default -->\n`;
252
+ const checks = JSON.stringify({ a: "echo a", b: "echo b", ok: "echo ok" });
253
+ const blockedOut = (evidence) => ({ code: 1, stdout: `BLOCKED: clearance_exit_routing = off in the rollout controller; owner must promote\n${evidence}`, stderr: "" });
254
+ const b = rollup([item("R1", "[check:a] x"), item("R2", "[check:b] y"), item("R3", "[check:ok] z")]);
255
+ const spawn = (evidence) => (o) => (o.run === "echo ok" ? { code: 0, stdout: "ok", stderr: "" } : blockedOut(evidence));
256
+ const asks = (w) => w.gh.filter((a) => a[1] === "comment").map((a) => a[a.indexOf("--body") + 1]).filter((t) => /FORGE:ROLLOUT_ASK/.test(t));
257
+
258
+ it("two blocked items on one flag give one ask, no failure issue and no PROBE fail", async () => {
259
+ const w = world({ file: checks, spawn: spawn("hits=0") });
260
+ const r = await run(w, b);
261
+ assert.equal(r.state, "awaiting-measurement"); assert.deepEqual(r.blocked, ["clearance_exit_routing"]);
262
+ assert.equal(asks(w).length, 1);
263
+ assert.ok(!w.gh.some((a) => a[1] === "create"), "no failing-check issue");
264
+ assert.ok(!w.gh.some((a) => a[1] === "comment" && /Post-deploy check: fail/.test(a[a.indexOf("--body") + 1])));
265
+ });
266
+
267
+ it("the next release with the same evidence raises nothing; changed evidence raises it again", async () => {
268
+ const w1 = world({ file: checks, spawn: spawn("hits=0") });
269
+ await run(w1, b);
270
+ const posted = { body: asks(w1)[0] };
271
+ const w2 = world({ file: checks, spawn: spawn("hits=0") });
272
+ await run(w2, b, { iss: { number: 41, body: b, comments: [posted] }, isTrusted: () => true });
273
+ assert.equal(asks(w2).length, 0, "same evidence: no new ask");
274
+ const w3 = world({ file: checks, spawn: spawn("hits=7") });
275
+ await run(w3, b, { iss: { number: 41, body: b, comments: [posted] }, isTrusted: () => true });
276
+ assert.equal(asks(w3).length, 1, "changed evidence: re-raised");
277
+ });
278
+
279
+ it("a plain failing check is still a failure", async () => {
280
+ const w = world({ file: checks, spawn: () => ({ code: 1, stdout: "boom", stderr: "" }) });
281
+ const r = await run(w, rollup([item("R1", "[check:a] x")]));
282
+ assert.equal(r.state, "fail"); assert.equal(asks(w).length, 0);
283
+ });
284
+ });