forgedock 1.34.0 → 1.36.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 (61) hide show
  1. package/README.md +78 -324
  2. package/bin/engine/events.mjs +152 -0
  3. package/bin/engine/events.test.mjs +128 -0
  4. package/bin/engine/telemetry-seam.mjs +4 -3
  5. package/bin/engine-cli.mjs +181 -26
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +19 -1
  8. package/bin/hooks/reread-nudge.mjs +44 -5
  9. package/bin/lean/batch.mjs +66 -3
  10. package/bin/lean/batch.test.mjs +176 -1
  11. package/bin/lean/budget.mjs +14 -3
  12. package/bin/lean/budget.test.mjs +11 -1
  13. package/bin/lean/conflictres.mjs +93 -0
  14. package/bin/lean/conflictres.test.mjs +93 -0
  15. package/bin/lean/conformance.mjs +30 -9
  16. package/bin/lean/conformance.test.mjs +51 -0
  17. package/bin/lean/effects.mjs +14 -3
  18. package/bin/lean/execution.mjs +2 -0
  19. package/bin/lean/i18n.mjs +65 -0
  20. package/bin/lean/i18n.test.mjs +47 -0
  21. package/bin/lean/lane.mjs +16 -0
  22. package/bin/lean/lean.mjs +147 -36
  23. package/bin/lean/lean.test.mjs +319 -8
  24. package/bin/lean/orchestrate.mjs +130 -13
  25. package/bin/lean/postdeploy-check.mjs +149 -0
  26. package/bin/lean/postdeploy-check.test.mjs +146 -0
  27. package/bin/lean/postdeploy-monitor.mjs +87 -0
  28. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  29. package/bin/lean/precheck.mjs +58 -8
  30. package/bin/lean/precheck.test.mjs +77 -3
  31. package/bin/lean/prefilter.mjs +29 -4
  32. package/bin/lean/prefilter.test.mjs +13 -5
  33. package/bin/lean/probe.mjs +2 -1
  34. package/bin/lean/prompts.mjs +36 -6
  35. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  36. package/bin/lean/sandbox-remote.mjs +7 -0
  37. package/bin/lean/schedd.mjs +34 -5
  38. package/bin/lean/schedd.test.mjs +84 -0
  39. package/bin/lean/sequence.mjs +26 -0
  40. package/bin/lean/sequence.test.mjs +41 -2
  41. package/bin/lean/toolstate.mjs +77 -0
  42. package/bin/lean/toolstate.test.mjs +83 -0
  43. package/bin/lean/verify.mjs +18 -2
  44. package/bin/lean/verify.test.mjs +29 -3
  45. package/bin/stats/aggregate.mjs +1 -1
  46. package/bin/stats/experiment.mjs +44 -0
  47. package/bin/stats/github-source.mjs +2 -1
  48. package/bin/stats/index.mjs +42 -2
  49. package/bin/stats/release.mjs +75 -0
  50. package/bin/stats/release.test.mjs +68 -0
  51. package/commands/orchestrate.md +8 -3
  52. package/docs/CONFIG.md +61 -2
  53. package/docs/EVENTS.md +68 -0
  54. package/docs/STATS.md +31 -0
  55. package/docs/TELEMETRY.md +3 -1
  56. package/docs/site/install.md +89 -0
  57. package/package.json +1 -1
  58. package/scripts/bench-lightlane.mjs +2 -1
  59. package/scripts/eval-gate-scorecard.mjs +7 -0
  60. package/scripts/file-deferred-followups.sh +1 -0
  61. package/scripts/file-deferred-followups.test.sh +8 -0
@@ -7,6 +7,7 @@
7
7
  * `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124). A dependency that is parked
8
8
  * (ended needs-human in this batch, or an out-of-set issue labelled needs-human per `issueLabels(n)`) is never pulled in:
9
9
  * the issue ends `skipped` (`dependency #N needs-human`) and its dependents with it (#4387).
10
+ * A skipped dependent whose blocker later closes or merges outside its lane is re-admitted (#4595, needs `dispatchCheck`).
10
11
  * No investigation, synthesis or agent spawning. `runOne(issue)` and `readBody(issue)` are injected.
11
12
  * Optional `prefilter(issue, lane)` (#4123) runs in the lane slot before `runOne`: when it closed the issue, no lane starts.
12
13
  * When it parked the issue (#4389, `parked: true`), no lane starts either and the row ends with the parked kind.
@@ -28,6 +29,8 @@
28
29
  * `getAdded()` (#4408, `forgedock orchestrate --add`) is read on every pass too: issues it returns that are not in the batch
29
30
  * join it through `addIssues` (the still-pending issues' `Depends on` are re-read first, so edges hold both ways), with one
30
31
  * batch decision and one `added` row; while it is set, lanes running with an empty queue still wake every `pollMs`.
32
+ * `getHeld()` (#4592, `--hold`/`--release`) is read on every pass: the issues it returns are never admitted (a running lane is not stopped);
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.
31
34
  *
32
35
  * Maintenance budget (#4197), off unless `maintenance: {share, whenIdle}` is passed: `classify(issue)` gives each issue
33
36
  * `{maintenance, priority}` (persisted as `classes`) and admission follows `pickNext`: P0/P1 features, then a maintenance
@@ -101,9 +104,11 @@ import { recoveryEvidence } from "./render.mjs";
101
104
  import { renderAlerts } from "../stats/render.mjs";
102
105
  import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
103
106
  import { laneSize, SIZES } from "./budget.mjs";
107
+ import { requeueKey, REQUEUABLE } from "./batch.mjs";
104
108
 
105
109
  // A dependency that finished like this no longer blocks its dependents.
106
110
  const SATISFIED = new Set(["merged", "invalid"]);
111
+ const WATCH_EVERY_MS = 60_000; // #4595
107
112
  // Times one issue may be requeued behind a dependency before its dependency-open row is final.
108
113
  export const MAX_REQUEUES = 2;
109
114
  // Deferrals other than a busy resource (UNCAPPED_DEFERRALS) one issue may take before its deferred row is final.
@@ -230,9 +235,9 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
230
235
  * @returns {Promise<Array<{issue:number, outcome:string, pr:?number, cost_usd:number, minutes:number, detail?:string}>>}
231
236
  */
232
237
  export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
233
- store = null, adopt = null, getParallel = null, getAdded = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
238
+ store = null, adopt = null, getParallel = null, getAdded = null, getRequeued = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
234
239
  classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
235
- recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, stopLane = null,
240
+ recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, getHeld = null, stopLane = null,
236
241
  estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS }) {
237
242
  const saved = store?.load?.() ?? null;
238
243
  const order = saved ? [...saved.order] : [];
@@ -257,6 +262,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
257
262
  const holds = limits ?? memoryLimits(now);
258
263
  const heldUntil = () => { try { const id = identity(); const u = holds.holdUntil(id); return Number.isFinite(u) && u > now() ? u : null; } catch { return null; } };
259
264
  let tripped = saved?.breaker ?? null;
265
+ let opHeld = new Set(); // #4592: operator-held issues this pass (the batch `hold` file is the authority)
266
+ const opHeldHit = new Set(); // held issues that were pending this pass
260
267
  // #4376: operator cancel (the batch `cancel` file through `getCancelled`); persisted, but the file stays the authority.
261
268
  let cancelled = getCancelled ? (saved?.cancelled ?? null) : null;
262
269
  const budget = maintenance && Number(maintenance.share) > 0 ? { share: Number(maintenance.share), whenIdle: !!maintenance.whenIdle } : null;
@@ -295,6 +302,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
295
302
  const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
296
303
  const recoveries = toMap(saved?.recoveries); // issue -> recovery lanes launched (#4267)
297
304
  const recovering = toMap(saved?.recovering); // issue -> {code, attempt, detail, outcome, ...} of the recovery lane queued or running
305
+ const requeueApplied = new Set(saved?.requeueApplied ?? []); // #4550: `--requeue` entries already applied (batch `requeue` file)
306
+ const requeuedInfo = toMap(saved?.requeued); // #4550: issue -> {reason, by, at, prior: [earlier rows]}
298
307
  const relabelled = new Set(saved?.relabelled ?? []); // #4315: queued recoveries relabelled needs-human when a batch stopped
299
308
  // #4267: decisions are recorded, never awaited: a failing or slow recorder cannot block the batch.
300
309
  const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
@@ -308,6 +317,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
308
317
  prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
309
318
  ...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
310
319
  ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
320
+ ...(requeueApplied.size ? { requeueApplied: [...requeueApplied], requeued: toObj(requeuedInfo) } : {}),
311
321
  ...(relabelled.size ? { relabelled: [...relabelled] } : {}), ...(cancelled ? { cancelled } : {}),
312
322
  ...(estimate ? { estimates: toObj(estimates), enqueued: toObj(enqueued) } : {}), ...(heads.size ? { heads: [...heads] } : {}) });
313
323
  };
@@ -736,7 +746,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
736
746
  }
737
747
  save();
738
748
 
739
- const heldDetail = (n) => cancelled ? "cancelled by operator" : tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
749
+ const heldDetail = (n) => cancelled ? "cancelled by operator" : opHeld.has(n) ? "held by operator" : tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
740
750
  // #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
741
751
  // lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
742
752
  // #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
@@ -776,13 +786,67 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
776
786
  update({ issue: null, outcome: "added", detail: list });
777
787
  return fresh.length;
778
788
  };
779
- // #4376: one SIGTERM per running local lane (their teardown saves the work); remote lanes run to their result.
789
+ // #4550: `--requeue` entries (batch `requeue` file) not applied yet: a terminal issue and the dependents skipped behind it
790
+ // return to pending, the prior rows kept in history; edges stay, so a dependent still waits for the issue to merge.
791
+ const drainRequeues = async () => {
792
+ if (!getRequeued) return 0;
793
+ let got = [];
794
+ try { got = getRequeued() ?? []; } catch { got = []; }
795
+ const fresh = (Array.isArray(got) ? got : []).filter((e) => Number.isSafeInteger(e?.issue) && !requeueApplied.has(requeueKey(e)));
796
+ let n = 0;
797
+ for (const e of fresh) {
798
+ requeueApplied.add(requeueKey(e));
799
+ const row = results.get(e.issue);
800
+ if (!row || !REQUEUABLE(row.outcome) || running.has(e.issue)) continue;
801
+ const moved = [e.issue], priors = new Map([[e.issue, row]]);
802
+ results.delete(e.issue);
803
+ for (let i = 0; i < moved.length; i++) { // dependents skipped behind a requeued issue (fixpoint)
804
+ for (const [m, r] of [...results]) {
805
+ if (r?.outcome !== "skipped" || Number(/^dependency #(\d+)/.exec(r.detail ?? "")?.[1]) !== moved[i]) continue;
806
+ priors.set(m, r); results.delete(m); moved.push(m);
807
+ }
808
+ }
809
+ for (const m of moved) {
810
+ for (const x of [requeues, deferrals, engineErrors, recoveries, recovering]) x.delete(m);
811
+ relabelled.delete(m);
812
+ if (m === e.issue) requeuedInfo.set(m, { reason: e.reason, by: e.by, at: e.at, prior: [...(requeuedInfo.get(m)?.prior ?? []), priors.get(m)] });
813
+ if (!pending.includes(m)) requeue(m, 0);
814
+ }
815
+ save();
816
+ const list = moved.filter((m) => m !== e.issue).map((m) => `#${m}`);
817
+ decided({ issue: e.issue, what: `requeued by ${e.by ?? "operator"} (forgedock orchestrate --requeue); prior outcome ${row.outcome}${list.length ? `; dependents back to pending: ${list.join(", ")}` : ""}`,
818
+ why: e.reason || "operator decision", alternative: "start a second batch" });
819
+ update({ issue: e.issue, outcome: "requeued", detail: `${e.reason || "operator decision"} (was ${row.outcome})` });
820
+ n++;
821
+ }
822
+ if (fresh.length) save();
823
+ return n;
824
+ };
825
+ // #4376: one SIGTERM per running local lane (their teardown saves the work); remote lanes run to their result unless the cancel carries `stopRemote` (#4612).
780
826
  const stopSent = new Set();
781
827
  function stop(n, rec) {
782
- if (!stopLane || rec?.remote || stopSent.has(n)) return;
828
+ if (!stopLane || (rec?.remote && cancelled?.stopRemote !== true) || stopSent.has(n)) return; // #4612: remote lanes only when the cancel asked for it
783
829
  stopSent.add(n);
784
830
  try { Promise.resolve(stopLane(n, rec)).catch(() => {}); } catch { /* best effort */ }
785
831
  }
832
+ // #4592: re-read the hold set; one decision and one row when it changes.
833
+ const refreshHeld = () => {
834
+ if (!getHeld) return;
835
+ let got = [];
836
+ try { got = getHeld() ?? []; } catch { got = opHeld.size ? [...opHeld] : []; }
837
+ const next = new Set((Array.isArray(got) ? got : []).filter((n) => Number.isSafeInteger(n) && n > 0));
838
+ const fmt = (ns) => ns.map((n) => `#${n}`).join(", ");
839
+ const held = [...next].filter((n) => !opHeld.has(n)), freed = [...opHeld].filter((n) => !next.has(n));
840
+ opHeld = next;
841
+ if (held.length) {
842
+ decided({ issue: null, what: `held ${fmt(held)} (forgedock orchestrate --hold)`, why: "operator request", alternative: "cancel the batch" });
843
+ update({ issue: null, outcome: "hold", detail: fmt(held) });
844
+ }
845
+ if (freed.length) {
846
+ decided({ issue: null, what: `released ${fmt(freed)} (forgedock orchestrate --release)`, why: "operator request", alternative: "resume the batch" });
847
+ update({ issue: null, outcome: "release", detail: fmt(freed) });
848
+ }
849
+ };
786
850
  const checkCancel = () => {
787
851
  if (!getCancelled) return;
788
852
  let c = null;
@@ -811,25 +875,72 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
811
875
  halted = true;
812
876
  return Object.assign(new Error("batch stopped: schedd shutting down"), { code: "batch.stopped" });
813
877
  };
878
+ // #4595: a skipped row whose blocker (`dependency #B ...`) was finished outside the lane is re-admitted: B is re-checked
879
+ // through `dispatchCheck` (at most every WATCH_EVERY_MS, and once before the batch would end); fail-open.
880
+ let lastWatch = null;
881
+ const readmitted = new Set(); // "n:b": re-admit n on blocker b once; a lane that re-skips on b stays skipped
882
+ const blockerOf = (n) => { const r = results.get(n); const m = r?.outcome === "skipped" ? /^dependency #(\d+) /.exec(r.detail ?? "") : null; return m ? Number(m[1]) : null; };
883
+ const watchable = () => !!dispatchCheck && [...results.keys()].some((n) => blockerOf(n) !== null);
884
+ const watchBlockers = async (final) => {
885
+ if (!watchable() || cancelled || tripped) return;
886
+ const t0 = now();
887
+ if (!final && lastWatch !== null && t0 - lastWatch < WATCH_EVERY_MS) return;
888
+ lastWatch = t0;
889
+ const resolved = new Set();
890
+ const roots = new Set([...results.keys()].filter((n) => !readmitted.has(`${n}:${blockerOf(n)}`)).map(blockerOf).filter((b) => b !== null && blockerOf(b) === null));
891
+ for (const b of roots) {
892
+ let g = null;
893
+ try { g = (await dispatchCheck(b)) ?? null; } catch { g = null; }
894
+ if (!g || !SATISFIED.has(g.outcome)) continue;
895
+ resolved.add(b);
896
+ const row = results.get(b);
897
+ if (row) { results.set(b, { ...row, outcome: g.outcome, pr: Number.isInteger(g.pr) ? g.pr : row.pr, detail: g.detail ?? row.detail }); update(results.get(b)); }
898
+ }
899
+ const back = new Set();
900
+ for (let grew = true; grew;) {
901
+ grew = false;
902
+ for (const n of results.keys()) {
903
+ const b = blockerOf(n);
904
+ if (b !== null && !back.has(n) && !readmitted.has(`${n}:${b}`) && (resolved.has(b) || back.has(b))) { back.add(n); grew = true; }
905
+ }
906
+ }
907
+ for (const n of back) {
908
+ const b = blockerOf(n);
909
+ readmitted.add(`${n}:${b}`);
910
+ results.delete(n);
911
+ pending.push(n);
912
+ decided({ issue: n, what: `re-admitted: dependency #${b} merged or closed after this issue was skipped`, why: "the blocker was finished outside this batch lane",
913
+ alternative: "leave it skipped until the next batch" });
914
+ }
915
+ if (back.size) save();
916
+ };
814
917
  const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
815
918
  try {
816
919
  for (;;) {
817
920
  if (signal?.aborted) throw halt();
818
921
  await drainAdds();
922
+ await drainRequeues();
819
923
  checkCancel();
924
+ await watchBlockers(!pending.length && !running.size);
820
925
  if (!pending.length && !running.size) break;
821
926
  // Resolve skips first: a dependency that finished unsatisfied (or never can) skips its dependents.
822
- for (let i = pending.length - 1; i >= 0; i--) {
823
- const n = pending[i];
824
- if (results.has(n)) { pending.splice(i, 1); continue; }
825
- const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
826
- if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); }
927
+ for (let again = true; again;) { // #4595: to a fixpoint, so a chain's tail is skipped on its blocker, not as a "cycle"
928
+ again = false;
929
+ for (let i = pending.length - 1; i >= 0; i--) {
930
+ const n = pending[i];
931
+ if (results.has(n)) { pending.splice(i, 1); continue; }
932
+ const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
933
+ if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); again = true; }
934
+ }
827
935
  }
828
936
  const t = now();
829
937
  const holdUntil = heldUntil();
830
938
  let started = 0;
831
939
  driftHeld.clear();
940
+ opHeldHit.clear();
941
+ refreshHeld();
832
942
  const isReady = (n) => {
943
+ if (opHeld.has(n)) { opHeldHit.add(n); return false; }
833
944
  if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
834
945
  if (!drift?.holds) return true;
835
946
  let pkg = null;
@@ -898,10 +1009,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
898
1009
  if (holdUntil) waits.push(holdUntil);
899
1010
  let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
900
1011
  if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
901
- if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
1012
+ if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
902
1013
  if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
903
- if (getAdded && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408: an add while only lanes run
1014
+ if ((getAdded || getRequeued) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
1015
+ if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
904
1016
  if (syncParked && running.size && parkedPrs().length) wake = Math.min(wake ?? syncEveryMs, Math.max(0, syncEveryMs - (t - (lastParkedSync ?? t)))); // #4525
1017
+ if (running.size && watchable()) wake = Math.min(wake ?? WATCH_EVERY_MS, WATCH_EVERY_MS); // #4595: re-check the blockers of skipped rows
905
1018
  if (tripped) wake = null;
906
1019
  if (getCancelled && running.size && !cancelled) wake = Math.min(wake ?? pollMs, pollMs); // #4376: notice a cancel while lanes run
907
1020
  if (running.size) {
@@ -911,6 +1024,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
911
1024
  else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
912
1025
  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
913
1026
  else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
1027
+ 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
914
1028
  else if (pending.length && driftHeld.size) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch): pending for a later batch
915
1029
  else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
916
1030
  for (const n of pending.splice(0)) skip(n, "dependency cycle");
@@ -925,7 +1039,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
925
1039
  if (reg) withdrawExcept(new Set());
926
1040
  await relabelQueued();
927
1041
  save();
928
- return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
1042
+ const withRequeue = (r) => (r && requeuedInfo.has(r.issue) ? { ...r, requeued: { reason: requeuedInfo.get(r.issue).reason, by: requeuedInfo.get(r.issue).by, prior: requeuedInfo.get(r.issue).prior.map((p) => ({ outcome: p.outcome, detail: p.detail ?? null, pr: p.pr ?? null })) } } : r);
1043
+ return order.map((n) => withRequeue(results.get(n)) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
929
1044
  ...(recovering.has(n) ? { recovery: { code: recovering.get(n).code, attempts: recoveries.get(n) ?? recovering.get(n).attempt, outcome: recovering.get(n).outcome ?? "needs-human",
930
1045
  ...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
931
1046
  }
@@ -954,6 +1069,8 @@ export const OUTCOME_LINE_MAX = 200;
954
1069
  export function formatOutcomeLists(rows) {
955
1070
  const out = [];
956
1071
  const one = (x) => String(x ?? "").split("\n")[0].slice(0, OUTCOME_LINE_MAX);
1072
+ const requeued = (rows ?? []).filter((r) => r?.requeued); // #4550: operator --requeue after a decision; the row is the latest outcome
1073
+ if (requeued.length) out.push(`requeued: ${requeued.map((r) => `#${r.issue} (${one(r.requeued.reason)})`).join(" ")}`);
957
1074
  const rec = (rows ?? []).filter((r) => r?.recovered);
958
1075
  if (rec.length) out.push(`recovered: ${rec.map((r) => `#${r.issue} (${r.recovered})`).join(" ")}`);
959
1076
  for (const r of rows ?? []) {
@@ -0,0 +1,149 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Named post-deploy checks (#4626). A post-deploy requirement names a check id (`[check:<id>]` in its follow-up item);
5
+ * the command lives in the checks file on the default branch, `.forge/post-deploy-checks.json`:
6
+ * { "<id>": { "run": "sh command", "pass": "exit 0 | /regex/flags", "timeout": 120 } } (a bare string is `run`)
7
+ * and is read with `git show origin/<default>:<file>`: reviewed default-branch code, never an issue body or comment
8
+ * (#4468). When the #4389 gate reports a follow-up ready, `measureFollowup` runs every item's check (no model call, the
9
+ * probe runner's minimal env): all pass -> a FORGE:PROBE record and the follow-up closes; a fail -> the record, and the
10
+ * parent reopens with the output. An item without a runnable check is `awaiting-measurement`: no lane, ever.
11
+ */
12
+ import { noComments } from "./render.mjs";
13
+ import { MAX_TIMEOUT_S, OUTPUT_TAIL, PROBE_MARKER, probeEnv, probeResultOf, readProbeConfig, runProbe } from "./probe.mjs";
14
+ import { parseMinAge } from "./prefilter.mjs";
15
+
16
+ export const CHECKS_FILE = ".forge/post-deploy-checks.json";
17
+ export const CHECK_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
18
+ export const AWAITING_MEASUREMENT_LABEL = "awaiting-measurement";
19
+ export const MONITORING_LABEL = "post-deploy-monitoring";
20
+ export const DEFAULT_EVERY_MS = 6 * 3600e3;
21
+ const ITEM_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
22
+ const CHECK_TOKEN_RE = /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:\s*\[check:\s*([^\]\s]*)\s*\]/;
23
+
24
+ /**
25
+ * The checks file's runnable entries `{id: {run, pass, timeoutS, forMs?, everyMs?}}`; malformed input or entries are dropped.
26
+ * `for` (a window such as "7d") makes the check windowed (#4627); `every` (default 6h) is its cadence and needs a `for`.
27
+ */
28
+ export function parseChecks(text) {
29
+ let j;
30
+ try { j = JSON.parse(String(text ?? "")); } catch { return {}; }
31
+ if (!j || typeof j !== "object" || Array.isArray(j)) return {};
32
+ const out = {};
33
+ for (const [id, v] of Object.entries(j)) {
34
+ const spec = typeof v === "string" ? { run: v } : v;
35
+ if (!CHECK_ID_RE.test(id) || !spec || typeof spec.run !== "string" || !spec.run.trim()) continue;
36
+ let pass = { kind: "exit" };
37
+ const p = String(spec.pass ?? "").trim();
38
+ if (p && !/^exit\s+0$/i.test(p)) {
39
+ const r = /^\/(.+)\/([a-z]*)$/.exec(p);
40
+ try { if (!r) continue; new RegExp(r[1], r[2]); } catch { continue; }
41
+ pass = { kind: "regex", re: r[1], flags: r[2] };
42
+ }
43
+ const t = spec.timeout == null ? null : Number(spec.timeout);
44
+ if (t != null && !(Number.isFinite(t) && t > 0)) continue;
45
+ const win = spec.for == null ? null : parseMinAge(spec.for);
46
+ const cad = spec.every == null ? null : parseMinAge(spec.every);
47
+ if ((spec.for != null && !win) || (spec.every != null && !cad) || (spec.every != null && spec.for == null)) continue;
48
+ out[id] = { run: spec.run.trim(), pass, timeoutS: t == null ? null : Math.min(Math.floor(t) || 1, MAX_TIMEOUT_S), ...(win ? { forMs: win, everyMs: cad ?? DEFAULT_EVERY_MS } : {}) };
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /** Per checklist item of a follow-up body: its `[check:<id>]` id, or null when it names none (or a malformed one). */
54
+ export function itemCheckIds(body) {
55
+ return noComments(String(body ?? ""), " ").split("\n").map((l) => ITEM_RE.exec(l)?.[1]).filter((t) => t != null)
56
+ .map((t) => { const m = CHECK_TOKEN_RE.exec(t); return m && CHECK_ID_RE.test(m[1]) ? m[1] : null; });
57
+ }
58
+
59
+ /** The checks file at each git ref (`git show <ref>:<file>`); a missing file or ref contributes nothing. */
60
+ export async function checksAt(git, refs, cwd) {
61
+ const out = {};
62
+ for (const ref of refs) {
63
+ try { Object.assign(out, parseChecks(String(await git(["show", `${ref}:${CHECKS_FILE}`], { cwd })))); } catch { /* no file at this ref */ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ const fenced = (s) => { const f = "`".repeat(Math.max(3, ...[...String(s).matchAll(/`+/g)].map((m) => m[0].length + 1))); return `${f}\n${String(s).trim() || "(no output)"}\n${f}`; };
69
+
70
+ /** Run each distinct check id with the probe runner (minimal env, timeout, masked output): `[{id, result, exit, ms, output}]`. */
71
+ export async function runNamedChecks({ ids, checks, cwd, config, env, spawn }) {
72
+ const rows = [];
73
+ for (const id of [...new Set(ids)]) {
74
+ const r = await runProbe(checks[id], { cwd, env: probeEnv(config.env, env), mask: config.env.map((k) => env[k]), timeoutS: config.timeoutS, ...(spawn ? { spawn } : {}) });
75
+ rows.push({ id, ...r });
76
+ }
77
+ return rows;
78
+ }
79
+
80
+ /** The windowed-monitoring fields of a FORGE:PROBE record body (`at`, `window_until`, `every_ms`, `regression`, ...), or null. */
81
+ export function monitorRecord(body) {
82
+ const base = probeResultOf(body);
83
+ if (!base) return null;
84
+ const line = String(body).split("\n").find((l) => /^\s*`?\{.*"result"/.test(l));
85
+ try {
86
+ const j = JSON.parse(line.trim().replace(/^`|`$/g, ""));
87
+ const at = Date.parse(j.at), until = Date.parse(j.window_until);
88
+ return Number.isFinite(at) ? { ...base, at, defaultSha: j.default_sha ?? null, everyMs: Number(j.every_ms) || DEFAULT_EVERY_MS, until: Number.isFinite(until) ? until : null, regression: j.regression ?? null } : null;
89
+ } catch { return null; }
90
+ }
91
+
92
+ /** The post-deploy-monitoring label, created on first use; best effort. */
93
+ export async function addMonitoringLabel({ io, repo, issue }) {
94
+ const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", MONITORING_LABEL]);
95
+ try { await edit(); } catch {
96
+ try { await io.gh(["label", "create", MONITORING_LABEL, "-R", repo, "--force", "--color", "C5DEF5", "--description", "Post-deploy follow-up whose windowed check re-runs on later releases. Managed by ForgeDock."]); await edit(); } catch { /* the record still shows it */ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Measure a ready post-deploy follow-up. Never runs a command that is not in the default branch's checks file.
102
+ * @returns {Promise<{state:"awaiting-measurement"|"pass"|"fail"|"recorded-fail"|"dry-run"|"monitoring", detail:string}>}
103
+ * When every item's check declares a window (#4627) a pass records the release and the window end and the follow-up stays
104
+ * open (`monitoring`); `sweepDue` (postdeploy-monitor.mjs) re-runs it.
105
+ */
106
+ 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() }) {
107
+ const ids = itemCheckIds(iss.body);
108
+ const checks = await checksAt(io.git, [`origin/${def}`], cwd);
109
+ const missing = ids.filter((id) => !id || !checks[id]);
110
+ if (!ids.length || missing.length) return { state: "awaiting-measurement", detail: `no runnable check on ${def} for ${ids.length ? `${missing.map((m) => m ?? "an item without a check id").join(", ")}` : "any item"} (${CHECKS_FILE})` };
111
+ const fix = [...shas].sort().join(",");
112
+ const prior = (iss.comments ?? []).filter((c) => isTrusted(c)).map((c) => probeResultOf(c?.body)).filter(Boolean).reverse().find((p) => p.fix === fix);
113
+ if (prior?.result === "fail") return { state: "recorded-fail", detail: `check failed for ${fix.slice(0, 12)} (FORGE:PROBE); waiting for a new fix of #${parent}` };
114
+ const windowed = ids.every((id) => checks[id].forMs);
115
+ const monitored = (iss.comments ?? []).filter((c) => isTrusted(c)).map((c) => monitorRecord(c?.body)).filter(Boolean).find((p) => p.fix === fix && p.result === "pass" && p.until);
116
+ if (windowed && monitored) return { state: "monitoring", detail: `windowed checks ${[...new Set(ids)].join(", ")} monitored until ${new Date(monitored.until).toISOString()}` };
117
+ if (dryRun) return { state: "dry-run", detail: `checks ${[...new Set(ids)].join(", ")} not run (dry run)` };
118
+ let defaultSha = null;
119
+ try { defaultSha = String(await io.git(["rev-parse", `origin/${def}`], { cwd })).trim() || null; } catch { /* recorded as null */ }
120
+ const rows = [];
121
+ if (prior?.result !== "pass") {
122
+ rows.push(...await runNamedChecks({ ids, checks, cwd, config, env, spawn }));
123
+ const ok = rows.every((r) => r.result === "pass");
124
+ const at = now();
125
+ const win = windowed && ok ? { at: new Date(at).toISOString(), window_until: new Date(at + Math.max(...ids.map((id) => checks[id].forMs))).toISOString(), every_ms: Math.min(...ids.map((id) => checks[id].everyMs)) } : {};
126
+ const body = [
127
+ `<!-- ${PROBE_MARKER} -->`, `## Post-deploy check: ${ok ? "pass" : "fail"}`,
128
+ JSON.stringify({ fix, default_sha: defaultSha, result: ok ? "pass" : "fail", ...win, checks: rows.map((r) => ({ id: r.id, result: r.result, exit: r.exit, ms: r.ms })) }), "",
129
+ `Ran after the fix of #${parent} reached \`${def}\`; commands come from \`${CHECKS_FILE}\` on \`${def}\`.${ok ? (win.at ? ` Monitored until \`${win.window_until}\`, re-run on later releases.` : " The follow-up is closed.") : " The parent is reopened."}`,
130
+ ...rows.filter((r) => r.result === "fail").flatMap((r) => ["", `**${r.id}** (exit ${r.exit ?? "timeout"})`, fenced(r.output)]),
131
+ ].join("\n").slice(0, OUTPUT_TAIL * 4);
132
+ await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", body]);
133
+ if (!ok) {
134
+ const bad = rows.filter((r) => r.result === "fail");
135
+ let note = "";
136
+ try { await io.gh(["issue", "reopen", String(parent), "-R", repo]); } catch (e) { note = ` (reopen failed: ${String(e?.message ?? e).slice(0, 120)})`; }
137
+ try {
138
+ await io.gh(["issue", "comment", String(parent), "-R", repo, "--body", `Post-deploy check ${bad.map((r) => `\`${r.id}\``).join(", ")} failed for the fix on \`${def}\` (follow-up #${iss.number}); reopened.\n\n${bad.map((r) => fenced(r.output)).join("\n\n")}`.slice(0, OUTPUT_TAIL * 4)]);
139
+ } catch { /* the follow-up comment carries the output */ }
140
+ return { state: "fail", detail: `check ${bad.map((r) => r.id).join(", ")} failed; #${parent} reopened${note}` };
141
+ }
142
+ }
143
+ if (windowed) {
144
+ await addMonitoringLabel({ io, repo, issue: iss.number });
145
+ return { state: "monitoring", detail: `checks ${[...new Set(ids)].join(", ")} passed; monitored on later releases` };
146
+ }
147
+ await io.gh(["issue", "close", String(iss.number), "-R", repo, "--reason", "completed"]);
148
+ return { state: "pass", detail: `checks ${[...new Set(ids)].join(", ")} passed` };
149
+ }
@@ -0,0 +1,146 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { parseChecks, itemCheckIds, measureFollowup, monitorRecord, CHECK_ID_RE } from "./postdeploy-check.mjs";
6
+ import { postDeployOnly } from "./prefilter.mjs";
7
+ import { validateContract, applyAmendments, precheck, followupItems } from "./conformance.mjs";
8
+ import { PROBE_MARKER } from "./probe.mjs";
9
+
10
+ const item = (id, why) => `- [ ] \`${id}: some quote here\` — post-deploy: ${why}`;
11
+ const body = (items) => `## Acceptance Criteria\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n`;
12
+ const CHECKS = JSON.stringify({ smoke: { run: "echo ok", timeout: 5 }, bad: "echo nope", "bad id!": "x", rx: { run: "echo up", pass: "/up/" }, norun: {} });
13
+
14
+ function world({ file = CHECKS, spawn } = {}) {
15
+ const w = { gh: [], git: [], spawned: [] };
16
+ w.io = {
17
+ gh: async (a) => { w.gh.push(a); return ""; },
18
+ git: async (a) => { w.git.push(a); if (a[0] === "show") { if (file == null) throw new Error("missing"); return file; } return "deadbeef\n"; },
19
+ };
20
+ w.spawn = async (o) => { w.spawned.push(o.run); return spawn ? spawn(o) : { code: o.run === "echo nope" ? 1 : 0, stdout: o.run === "echo nope" ? "boom" : "ok", stderr: "" }; };
21
+ return w;
22
+ }
23
+ const run = (w, bodyText, extra = {}) => measureFollowup({ iss: { number: 41, body: bodyText, comments: [] }, repo: "o/r", parent: 40, def: "main", shas: ["abc"], cwd: "/repo", io: w.io, config: { env: [], timeoutS: 5 }, env: {}, spawn: w.spawn, ...extra });
24
+
25
+ describe("post-deploy named checks (#4626)", () => {
26
+ it("parses the checks file; malformed entries are dropped", () => {
27
+ const c = parseChecks(CHECKS);
28
+ assert.deepEqual(Object.keys(c).sort(), ["bad", "rx", "smoke"]);
29
+ assert.equal(c.smoke.timeoutS, 5); assert.equal(c.rx.pass.kind, "regex");
30
+ assert.deepEqual(parseChecks("not json"), {});
31
+ assert.ok(CHECK_ID_RE.test("login-smoke") && !CHECK_ID_RE.test("Bad Id"));
32
+ });
33
+
34
+ it("postDeployOnly still matches items with and without a [check:id] token", () => {
35
+ assert.ok(postDeployOnly(body([item("R1", "[check:smoke] soak"), item("R2", "only in prod")])));
36
+ });
37
+ it("extracts [check:id] per item; an item without one yields null", () => {
38
+ assert.deepEqual(itemCheckIds(body([item("R1", "[check:smoke] soak"), item("R2", "only in prod")])), ["smoke", null]);
39
+ });
40
+
41
+ it("pass: runs the default-branch command, records FORGE:PROBE, closes the follow-up, no reopen", async () => {
42
+ const w = world();
43
+ const r = await run(w, body([item("R1", "[check:smoke] x")]));
44
+ assert.equal(r.state, "pass");
45
+ assert.deepEqual(w.spawned, ["echo ok"]);
46
+ assert.ok(w.git.some((a) => a.join(" ") === "show origin/main:.forge/post-deploy-checks.json"));
47
+ const cm = w.gh.find((a) => a[1] === "comment");
48
+ assert.match(cm[cm.indexOf("--body") + 1], new RegExp(PROBE_MARKER));
49
+ assert.ok(w.gh.some((a) => a[1] === "close" && a[2] === "41"));
50
+ assert.ok(!w.gh.some((a) => a[1] === "reopen"));
51
+ });
52
+
53
+ it("fail: reopens the parent with the output and does not close", async () => {
54
+ const w = world();
55
+ const r = await run(w, body([item("R1", "[check:bad] x")]));
56
+ assert.equal(r.state, "fail");
57
+ assert.ok(w.gh.some((a) => a[1] === "reopen" && a[2] === "40"));
58
+ assert.ok(w.gh.some((a) => a[1] === "comment" && a[2] === "40" && a[a.indexOf("--body") + 1].includes("boom")));
59
+ assert.ok(!w.gh.some((a) => a[1] === "close"));
60
+ });
61
+
62
+ it("no runnable check: awaiting-measurement, nothing runs, no model call", async () => {
63
+ for (const [file, b] of [[CHECKS, body([item("R1", "[check:ghost] x")])], [null, body([item("R1", "[check:smoke] x")])], [CHECKS, body([item("R1", "x"), item("R2", "[check:smoke] y")])]]) {
64
+ const w = world({ file });
65
+ assert.equal((await run(w, b)).state, "awaiting-measurement");
66
+ assert.deepEqual(w.spawned, []); assert.deepEqual(w.gh, []);
67
+ }
68
+ });
69
+
70
+ it("a command declared in the issue body never runs", async () => {
71
+ const w = world();
72
+ const b = body([item("R1", "[check:ghost] x")]) + "\n```forge-probe\nrun: rm -rf /\n```\n";
73
+ assert.equal((await run(w, b)).state, "awaiting-measurement");
74
+ assert.deepEqual(w.spawned, []);
75
+ });
76
+
77
+ it("migration: the six open follow-up shapes (no check id) all park awaiting-measurement", async () => {
78
+ for (const n of [4415, 4421, 4438, 4488, 4495, 4498]) {
79
+ const w = world();
80
+ const r = await run(w, body([item(`R${n % 7}`, "only checkable after the next deploy")]));
81
+ assert.equal(r.state, "awaiting-measurement", `#${n}`);
82
+ assert.deepEqual(w.spawned, []);
83
+ }
84
+ });
85
+
86
+ it("a recorded fail for the same fix is not re-run", async () => {
87
+ const w = world();
88
+ const c = { body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "abc", result: "fail" })}` };
89
+ const r = await run(w, body([item("R1", "[check:smoke] x")]), { iss: { number: 41, body: body([item("R1", "[check:smoke] x")]), comments: [c] }, isTrusted: () => true });
90
+ assert.equal(r.state, "recorded-fail"); assert.deepEqual(w.spawned, []);
91
+ });
92
+
93
+ it("contract: post-deploy needs a check id; amendment too; precheck rejects an unknown id; followup item names it", () => {
94
+ const Q = "The deploy-time health row must exist after promotion.";
95
+ const c = { requirements: [{ id: "R1", quote: Q, disposition: "post-deploy", why: "w" }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] };
96
+ assert.ok(validateContract(c, Q).problems.some((p) => /needs a check id/.test(p)));
97
+ assert.ok(!validateContract({ ...c, requirements: [{ ...c.requirements[0], check: "smoke" }] }, Q).problems.some((p) => /check id/.test(p)));
98
+ const frozen = { ...c, requirements: [{ id: "R1", quote: Q, disposition: "deliver" }] };
99
+ assert.ok(applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w" }], Q).problems.some((p) => /check id/.test(p)));
100
+ const ok = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "smoke" }], Q);
101
+ assert.equal(ok.contract.requirements[0].check, "smoke");
102
+ const pc = precheck({ contract: { ...c, requirements: [{ ...c.requirements[0], check: "ghost" }] }, result: { requirements: [] }, changedFiles: [], checkIds: new Set(["smoke"]) });
103
+ assert.ok(pc.failures.some((f) => f.kind === "check_unknown"));
104
+ assert.match(followupItems([{ ...c.requirements[0], check: "smoke" }])[0].why, /^post-deploy: \[check:smoke\] w/);
105
+ });
106
+ });
107
+
108
+ describe("windowed post-deploy checks (#4627)", () => {
109
+ const WIN = JSON.stringify({ soak: { run: "echo ok", for: "7d", every: "6h" }, soakd: { run: "echo ok", for: "2d" }, noFor: { run: "echo ok", every: "6h" }, badFor: { run: "echo ok", for: "soon" }, zero: { run: "echo ok", for: "0" }, plain: "echo ok" });
110
+ const NOW = Date.parse("2026-10-26T00:00:00Z");
111
+ it("parses for/every; every defaults to 6h; every without for or a bad duration drops the entry", () => {
112
+ const c = parseChecks(WIN);
113
+ assert.deepEqual(Object.keys(c).sort(), ["plain", "soak", "soakd"]);
114
+ assert.equal(c.soak.forMs, 7 * 86400e3); assert.equal(c.soak.everyMs, 6 * 3600e3);
115
+ assert.equal(c.soakd.everyMs, 6 * 3600e3);
116
+ assert.equal(c.plain.forMs, undefined);
117
+ });
118
+ it("first pass records the release and window, labels, and keeps the follow-up open", async () => {
119
+ const w = world({ file: WIN });
120
+ const r = await run(w, body([item("R1", "[check:soak] x")]), { now: () => NOW });
121
+ assert.equal(r.state, "monitoring");
122
+ assert.ok(!w.gh.some((a) => a[1] === "close") && !w.gh.some((a) => a[1] === "reopen"));
123
+ assert.ok(w.gh.some((a) => a[1] === "edit" && a.includes("post-deploy-monitoring")));
124
+ const cm = w.gh.find((a) => a[1] === "comment");
125
+ const rec = monitorRecord(cm[cm.indexOf("--body") + 1]);
126
+ assert.equal(rec.result, "pass"); assert.equal(rec.until, NOW + 7 * 86400e3); assert.equal(rec.everyMs, 6 * 3600e3); assert.equal(rec.defaultSha, "deadbeef");
127
+ });
128
+ it("a recorded windowed pass is not re-measured by the pre-filter path", async () => {
129
+ const w = world({ file: WIN });
130
+ const rec = { body: `<!-- ${PROBE_MARKER} -->\n${JSON.stringify({ fix: "abc", result: "pass", at: new Date(NOW).toISOString(), window_until: new Date(NOW + 1e9).toISOString() })}` };
131
+ const b = body([item("R1", "[check:soak] x")]);
132
+ const r = await run(w, b, { iss: { number: 41, body: b, comments: [rec] }, isTrusted: () => true });
133
+ assert.equal(r.state, "monitoring"); assert.deepEqual(w.spawned, []);
134
+ });
135
+ it("a non-windowed check still closes on pass (part 1 unchanged)", async () => {
136
+ const w = world({ file: WIN });
137
+ assert.equal((await run(w, body([item("R1", "[check:plain] x")]))).state, "pass");
138
+ assert.ok(w.gh.some((a) => a[1] === "close"));
139
+ });
140
+ it("a first-run fail of a windowed check reopens the parent as before", async () => {
141
+ const w = world({ file: JSON.stringify({ soak: { run: "echo nope", for: "7d" } }), spawn: () => ({ code: 1, stdout: "boom", stderr: "" }) });
142
+ assert.equal((await run(w, body([item("R1", "[check:soak] x")]))).state, "fail");
143
+ assert.ok(w.gh.some((a) => a[1] === "reopen" && a[2] === "40"));
144
+ });
145
+ });
146
+