forgedock 1.43.0 → 1.44.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 (58) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/telemetry-seam.mjs +1 -1
  3. package/bin/engine-cli.mjs +59 -10
  4. package/bin/forgedock.mjs +50 -24
  5. package/bin/gc.mjs +3 -1
  6. package/bin/hooks/pre-tool-use.mjs +54 -3
  7. package/bin/journey.mjs +2 -2
  8. package/bin/lean/autochildren.mjs +13 -5
  9. package/bin/lean/autochildren.test.mjs +36 -18
  10. package/bin/lean/batch.mjs +3 -1
  11. package/bin/lean/batch.test.mjs +83 -0
  12. package/bin/lean/conformance.mjs +72 -13
  13. package/bin/lean/conformance.test.mjs +100 -9
  14. package/bin/lean/decision-engine.mjs +1 -1
  15. package/bin/lean/decision-engine.test.mjs +11 -0
  16. package/bin/lean/effects.mjs +1 -1
  17. package/bin/lean/execution.test.mjs +9 -6
  18. package/bin/lean/gatewatch.test.mjs +5 -5
  19. package/bin/lean/issuewatch.mjs +2 -2
  20. package/bin/lean/lane.mjs +22 -4
  21. package/bin/lean/lane.test.mjs +33 -1
  22. package/bin/lean/lean.mjs +65 -17
  23. package/bin/lean/lean.test.mjs +84 -2
  24. package/bin/lean/orchestrate-blocked.test.mjs +28 -0
  25. package/bin/lean/orchestrate-children.test.mjs +16 -4
  26. package/bin/lean/orchestrate.mjs +58 -11
  27. package/bin/lean/pmap.mjs +17 -0
  28. package/bin/lean/pmap.test.mjs +27 -0
  29. package/bin/lean/probe.mjs +1 -1
  30. package/bin/lean/prodwatch-triage.mjs +6 -0
  31. package/bin/lean/prodwatch-triage.test.mjs +14 -1
  32. package/bin/lean/prodwatch.mjs +1 -3
  33. package/bin/lean/recovery.test.mjs +21 -4
  34. package/bin/lean/retry.mjs +11 -0
  35. package/bin/lean/retry.test.mjs +16 -0
  36. package/bin/lean/sched.mjs +25 -2
  37. package/bin/lean/sched.test.mjs +58 -1
  38. package/bin/lean/schedd.mjs +38 -5
  39. package/bin/lean/schedd.test.mjs +109 -0
  40. package/bin/lean/selftest-live.mjs +57 -32
  41. package/bin/lean/selftest-live.test.mjs +48 -6
  42. package/bin/lean/selftest-nightly.mjs +8 -4
  43. package/bin/lean/selftest-nightly.test.mjs +24 -2
  44. package/bin/lean/selftest-static.mjs +18 -11
  45. package/bin/lean/selftest-static.test.mjs +8 -0
  46. package/bin/lean/signoff.mjs +14 -2
  47. package/bin/lean/signoff.test.mjs +45 -1
  48. package/bin/pool/agent.mjs +1 -1
  49. package/bin/pool/cli.mjs +29 -1
  50. package/bin/runner.mjs +1 -1
  51. package/commands/orchestrate.md +5 -2
  52. package/docs/CONFIG.md +9 -4
  53. package/docs/DECISIONS.md +5 -1
  54. package/docs/OUTCOME-CODES.md +3 -2
  55. package/docs/TELEMETRY.md +1 -1
  56. package/package.json +1 -1
  57. package/scripts/ci-local/run.mjs +1 -1
  58. package/scripts/plan-validate-live.mjs +15 -8
@@ -172,6 +172,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
172
172
  const run = Promise.resolve().then(() => runBatch({ argv, cwd, env, sink, keepBreaker, signal: ac.signal })).then((result) => {
173
173
  const report = ent.dir ? openBatch(root, ent.id).readReport()?.report ?? null : null;
174
174
  emit({ type: "done", result, report });
175
+ if (ent.id) rehostLater(ent.id); // #4917
175
176
  }, (e) => {
176
177
  const message = e?.message ?? String(e);
177
178
  if (e?.code === "batch.stopped") { log(`schedd: batch ${ent.id ?? argv.join(" ")} stopped (lanes keep running; resumed by the next daemon)`); if (!started) reject(new Error(message)); return; }
@@ -180,6 +181,28 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
180
181
  runs.set(run, ac);
181
182
  });
182
183
 
184
+ // #4917: a batch that ended with a deferred issue still waiting (no report) is re-hosted once its retry-at is near, without a restart
185
+ const rehostTimers = new Set();
186
+ const rehostLater = (id) => {
187
+ if (closing) return;
188
+ let b, st;
189
+ try { b = openBatch(root, id); if (b.readReport() || b.readCancel() || !b.pendingIssues().length) return; st = b.store.load(); } catch { return; }
190
+ if (st?.breaker && !st.breaker.transport) return; // a tripped breaker holds the batch for an operator --resume (a re-host would stop again at once)
191
+ const at = Math.min(...b.pendingIssues().map((n) => Number(st?.notBefore?.[n]) || Infinity));
192
+ if (!Number.isFinite(at)) return; // nothing waits on the clock: a deliberate hold stays for --resume
193
+ if (at <= Date.now()) return; // the run that just ended already saw this retry-at pass and did not admit: re-hosting would loop every second
194
+ const t = setTimeout(() => {
195
+ rehostTimers.delete(t);
196
+ if (closing || hosted.has(id) || starting.has(id)) return;
197
+ log(`schedd: re-hosting batch ${id} (pending issues waiting on a retry)`);
198
+ const started = host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true });
199
+ starting.set(id, started);
200
+ started.catch((e) => log(`schedd: re-host ${id} failed: ${e?.message ?? e}`)).finally(() => starting.delete(id));
201
+ }, Math.min(60_000, Math.max(1000, at - Date.now())));
202
+ t.unref?.();
203
+ rehostTimers.add(t);
204
+ };
205
+
183
206
  /** #4775: wait for a batch start at most `admitMs`; the start itself goes on (a second host() would duplicate the batch). */
184
207
  const bound = (p, what) => {
185
208
  let t;
@@ -204,7 +227,15 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
204
227
  };
205
228
 
206
229
  const handle = async (req, send) => {
207
- if (req.op === "status") return send({ type: "status", pid, lastTick, batches: statusBatches() }, true);
230
+ if (req.op === "status") {
231
+ // #4917: a batch dir that still holds pending issues but is not hosted (finalised early, or never resumed)
232
+ const unhosted = [];
233
+ for (const id of listBatches(root)) {
234
+ if (hosted.has(id) || starting.has(id)) continue;
235
+ try { const pending = openBatch(root, id).pendingIssues(); if (pending.length) unhosted.push({ id, pending }); } catch { /* advisory */ }
236
+ }
237
+ return send({ type: "status", pid, lastTick, batches: statusBatches(), unhosted }, true);
238
+ }
208
239
  if (req.op === "set-parallel") {
209
240
  const b = openBatch(root, req.id);
210
241
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
@@ -215,8 +246,8 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
215
246
  const b = openBatch(root, req.id);
216
247
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
217
248
  const { added, skipped, requeued = [] } = b.addIssues(req.issues ?? []);
218
- // #4687: a re-queued skipped issue reopens a finished, unhosted batch like --requeue does
219
- const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
249
+ // #4687/#5000/#4917: a re-queued skipped issue or a newly added one reopens a finished, unhosted batch like --requeue does
250
+ const reopen = (added.length > 0 || requeued.length > 0 || b.unrunAdds().length > 0 || b.unrunRequeues().length > 0) && !hosted.has(req.id) && !starting.has(req.id);
220
251
  if (reopen) await hostBounded({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, null, `batch ${req.id}`);
221
252
  return send({ type: "ok", added, skipped, requeued, hosted: hosted.has(req.id), reopened: reopen }, true);
222
253
  }
@@ -225,7 +256,8 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
225
256
  const b = openBatch(root, req.id);
226
257
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
227
258
  const { requeued, refused } = b.requeue(req.issues ?? [], { reason: req.reason, by: req.by });
228
- const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
259
+ // #4917: a refused repeat requeue still reopens when an earlier entry was never applied
260
+ const reopen = (requeued.length > 0 || b.unrunRequeues().length > 0) && !hosted.has(req.id) && !starting.has(req.id);
229
261
  if (reopen) await hostBounded({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, null, `batch ${req.id}`);
230
262
  return send({ type: "ok", requeued, refused, hosted: hosted.has(req.id), reopened: reopen }, true);
231
263
  }
@@ -306,6 +338,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
306
338
  clearInterval(lockTimer);
307
339
  clearInterval(lagTimer);
308
340
  clearInterval(beatTimer);
341
+ for (const t of rehostTimers) clearTimeout(t);
309
342
  if (dueTimer) clearInterval(dueTimer);
310
343
  for (const c of conns) c.destroy();
311
344
  const stopping = [...runs.keys()];
@@ -448,7 +481,7 @@ export function defaultSpawn({ dir = schedDir(), script = SCHEDD_SCRIPT, env = p
448
481
  mkdirSync(dir, { recursive: true });
449
482
  const out = openSync(scheddPaths(dir).log, "a");
450
483
  try {
451
- const child = spawn(process.execPath, [script, "schedd"], { env, detached: true, stdio: ["ignore", out, out] });
484
+ const child = spawn(process.execPath, [script, "schedd"], { env, detached: true, windowsHide: true, stdio: ["ignore", out, out] });
452
485
  child.on?.("error", () => { /* the connect retry reports it */ });
453
486
  child.unref?.();
454
487
  } finally { closeSync(out); }
@@ -249,6 +249,55 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
249
249
  } finally { await h.cleanup(); }
250
250
  });
251
251
 
252
+ it("#5000: add to a finished unhosted batch reopens it through the daemon and runs the new issue", async () => {
253
+ const h = host();
254
+ try {
255
+ await h.daemon();
256
+ const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: "OPEN", labels: [] }) : "o/r\n") } };
257
+ await withRoot(h, () => capture(async () => {
258
+ writeFileSync(h.release, "");
259
+ await orchestrateFromCli(["1", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client);
260
+ const [id] = listBatches(h.root);
261
+ const b = openBatch(h.root, id);
262
+ const res = await orchestrateFromCli(["--add", id, "6"], client);
263
+ assert.deepEqual(res.added, [6]);
264
+ assert.equal(res.reopened, true, "the finished batch is hosted again");
265
+ await until(() => h.launched().includes(6));
266
+ await until(() => b.store.load()?.results?.[6]?.outcome === "merged");
267
+ assert.deepEqual(listBatches(h.root), [id], "no second batch");
268
+ }));
269
+ } finally { await h.cleanup(); }
270
+ });
271
+
272
+ it("#4917: a held (pending) issue is flagged unhosted by status once its report is gone; --requeue re-hosts it and says so", async () => {
273
+ const h = host();
274
+ try {
275
+ await h.daemon();
276
+ const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: "OPEN", labels: [] }) : "o/r\n") } };
277
+ await withRoot(h, () => capture(async () => {
278
+ const run = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
279
+ await until(() => h.launched().length === 1 && listBatches(h.root).length === 1);
280
+ const [id] = listBatches(h.root);
281
+ const b = openBatch(h.root, id);
282
+ b.holdIssues([2]);
283
+ writeFileSync(h.release, "");
284
+ const res = await run;
285
+ assert.equal(res.rows.find((r) => r.issue === 2).outcome, "held");
286
+ assert.deepEqual(b.pendingIssues(), [2]);
287
+ b.clearReport(); // a batch finalised before the fix, or one never resumed
288
+ const st = await request(await connectDaemon({ dir: h.sched, spawnDaemon: null }), { op: "status" });
289
+ assert.deepEqual(st.unhosted, [{ id, pending: [2] }]);
290
+ // finalised by an older version: a report plus unapplied requeue entries still reopens, and the CLI says so
291
+ b.releaseIssues([2]);
292
+ b.store.save({ ...b.store.load(), results: { ...b.store.load().results, 1: { issue: 1, outcome: "needs-human", pr: null, cost_usd: 0, minutes: 0 } } });
293
+ b.writeReport({ rows: [{ issue: 1, outcome: "needs-human" }] });
294
+ const rq = await capture(() => orchestrateFromCli(["--requeue", id, "1"], client));
295
+ assert.deepEqual(rq.res.requeued, [1]);
296
+ assert.ok(rq.lines.some((l) => l.includes("reopened and hosted by the schedd")), rq.lines.join("\n"));
297
+ }));
298
+ } finally { await h.cleanup(); }
299
+ });
300
+
252
301
  it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
253
302
  const h = host();
254
303
  try {
@@ -350,6 +399,66 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
350
399
  } finally { await h.cleanup(); }
351
400
  });
352
401
 
402
+ it("#4917: a batch that ends unreported with a deferred issue waiting is re-hosted by the running daemon once its retry-at is near", async () => {
403
+ const dir = tmpDir("schedd-rehost-"), root = join(dir, "batches"), id = "b20261031-000000-4917";
404
+ const calls = [];
405
+ const runBatch = async ({ argv, sink }) => {
406
+ calls.push(argv);
407
+ const b = openBatch(root, id);
408
+ if (argv[0] !== "--resume") {
409
+ b.writePlan({ daemon: true, repo: "o/r" });
410
+ b.store.save({ v: 1, order: [1], results: {}, running: {}, pending: [1], notBefore: { 1: Date.now() + 1200 } });
411
+ } else b.store.save({ ...b.store.load(), pending: [], results: { 1: { issue: 1, outcome: "merged" } } });
412
+ sink.batch({ id, dir: b.dir });
413
+ return {};
414
+ };
415
+ const d = await startDaemon({ dir, root, runBatch, log: () => {}, resumeOnStart: false });
416
+ try {
417
+ await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["1"], cwd: dir, env: {} });
418
+ await until(() => calls.length === 2, 8000);
419
+ assert.deepEqual(calls[1], ["--resume", id]);
420
+ } finally { await d.close(); }
421
+ });
422
+
423
+ it("#4917: a resume that leaves the waiting issue unadmitted (breaker tripped, retry-at passed) is not re-hosted again", async () => {
424
+ const dir = tmpDir("schedd-rehost-loop-"), root = join(dir, "batches"), id = "b20261031-000000-4918";
425
+ const calls = [];
426
+ const runBatch = async ({ argv, sink }) => {
427
+ calls.push(argv);
428
+ const b = openBatch(root, id);
429
+ // first run: deferred issue 1 waits 300 ms; every resume ends at once (a tripped breaker), leaving it pending with a past retry-at
430
+ if (argv[0] !== "--resume") { b.writePlan({ daemon: true, repo: "o/r" }); b.store.save({ v: 1, order: [1], results: {}, running: {}, pending: [1], notBefore: { 1: Date.now() + 300 } }); }
431
+ sink.batch({ id, dir: b.dir });
432
+ return {};
433
+ };
434
+ const d = await startDaemon({ dir, root, runBatch, log: () => {}, resumeOnStart: false });
435
+ try {
436
+ await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["1"], cwd: dir, env: {} });
437
+ await until(() => calls.length >= 2, 8000);
438
+ await new Promise((r) => setTimeout(r, 3500));
439
+ assert.equal(calls.length, 2, `re-hosted ${calls.length - 1} times; a past retry-at the resume did not act on must not loop`);
440
+ } finally { await d.close(); }
441
+ });
442
+
443
+ it("#4917: a batch whose breaker tripped (not transport) is not re-hosted by the timer", async () => {
444
+ const dir = tmpDir("schedd-rehost-brk-"), root = join(dir, "batches"), id = "b20261031-000000-4919";
445
+ const calls = [];
446
+ const runBatch = async ({ argv, sink }) => {
447
+ calls.push(argv);
448
+ const b = openBatch(root, id);
449
+ b.writePlan({ daemon: true, repo: "o/r" });
450
+ b.store.save({ v: 1, order: [1], results: {}, running: {}, pending: [1], notBefore: { 1: Date.now() + 300 }, breaker: { at: Date.now(), errors: 3 } });
451
+ sink.batch({ id, dir: b.dir });
452
+ return {};
453
+ };
454
+ const d = await startDaemon({ dir, root, runBatch, log: () => {}, resumeOnStart: false });
455
+ try {
456
+ await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["1"], cwd: dir, env: {} });
457
+ await new Promise((r) => setTimeout(r, 2000));
458
+ assert.equal(calls.length, 1, "a tripped breaker holds the batch for an operator --resume");
459
+ } finally { await d.close(); }
460
+ });
461
+
353
462
  it("#4454: close() stops every hosted batch loop before releasing the lock; no batch admits a lane after close()", async () => {
354
463
  const dir = tmpDir("schedd-stop-"), root = join(dir, "batches"), lock = scheddPaths(dir).lock;
355
464
  const started = { a: [], b: [] }, stoppedWithLock = {};
@@ -10,10 +10,14 @@ import { fileURLToPath } from "node:url";
10
10
  import { spawn, spawnSync } from "node:child_process";
11
11
  import { batchRoot, listBatches, openBatch } from "./batch.mjs";
12
12
  import { staticCheck, formatStatic, realGh } from "./selftest-static.mjs";
13
+ import { pollUntil } from "./retry.mjs";
13
14
  import { postMarker } from "./selftest-gate.mjs";
14
15
  import { reportNightly } from "./selftest-nightly.mjs";
15
16
  import { parseForgeYaml } from "../forge-utils.mjs";
16
17
 
18
+ const BATCH_WAIT_TRIES = 60, BATCH_WAIT_MS = 1000;
19
+ const DEFAULT_TIMEOUT_MS = 45 * 60 * 1000, DEFAULT_POLL_MS = 5000, GH_MAX_BUFFER = 64 * 1024 * 1024;
20
+
17
21
  export class StepError extends Error {
18
22
  constructor(step, message) { super(message); this.step = step; }
19
23
  }
@@ -33,20 +37,31 @@ export const FIXTURES = {
33
37
  },
34
38
  };
35
39
 
40
+ /**
41
+ * The fixture with every path and title made unique to this run (#4952): a merged run leaves its files on the sandbox
42
+ * default branch, and the next run's "the test fails on the current code" premise must still hold.
43
+ */
44
+ export function uniqueFixture(fx, lang, runId) {
45
+ const re = lang === "python" ? /`((?:test_)?[ab]\.py)`/g : /`src\/([ab](?:\.test)?\.js)`/g;
46
+ const dir = (m, f) => (lang === "python" ? `\`${runId}/${f}\`` : `\`src/${runId}/${f}\``);
47
+ const one = (i) => ({ title: `${i.title} (${runId})`, body: i.body.replace(re, dir) });
48
+ return { files: fx.files, a: one(fx.a), b: one(fx.b) };
49
+ }
50
+
51
+ /** Whether a batch plan is the one this harness started: the sandbox repo and every issue it passed (#4952). */
52
+ export const isOwnBatch = (plan, repo, issues) =>
53
+ String(plan?.repo ?? "").toLowerCase() === repo.toLowerCase() && issues.every((n) => (plan?.argv ?? []).map(String).includes(String(n)));
54
+
36
55
  /** The issue body with its FORGE:STATE annotation removed. */
37
56
  export const stripState = (body) => String(body ?? "").replace(/<!--\s*FORGE:STATE[\s\S]*?-->/g, "").trim();
38
57
 
39
58
  const num = (out) => { const m = /\/issues\/(\d+)/.exec(String(out)) ?? /^(\d+)$/m.exec(String(out)); if (!m) throw new Error(`no issue number in: ${String(out).slice(0, 120)}`); return Number(m[1]); };
40
59
 
41
60
  async function waitFor(ctx, step, what, cond, onTick) {
42
- const { io, timeoutMs } = ctx, t0 = io.now();
43
- for (;;) {
44
- if (onTick) await onTick();
45
- const v = await cond();
46
- if (v) return v;
47
- if (io.now() - t0 > timeoutMs) fail(step, `timed out waiting for ${what}`);
48
- await io.sleep(ctx.pollMs);
49
- }
61
+ const { io, timeoutMs, pollMs } = ctx;
62
+ const v = await pollUntil(async () => { if (onTick) await onTick(); return cond(); }, { timeoutMs, pollMs, sleep: io.sleep, now: io.now });
63
+ if (!v) fail(step, `timed out waiting for ${what}`);
64
+ return v;
50
65
  }
51
66
 
52
67
  /** Step "body": every issue body still holds its creation text (#4860). */
@@ -59,16 +74,16 @@ async function checkBodies(ctx, step = "body") {
59
74
 
60
75
  export const STEPS = [
61
76
  { name: "create", run: async (ctx) => {
62
- const { io, repo, fx } = ctx;
77
+ const { io, repo } = ctx, fx = ctx.fx = uniqueFixture(ctx.fx, ctx.lang, ctx.runId);
63
78
  for (const [path, content] of Object.entries(fx.files)) {
64
79
  await io.gh(["api", "-X", "PUT", `repos/${repo}/contents/${path}`, "-f", `message=selftest fixture ${path}`, "-f", `content=${Buffer.from(content).toString("base64")}`]).catch(() => { /* already present */ });
65
80
  }
66
- const mk = async (t, body) => num(await io.gh(["issue", "create", "-R", repo, "--title", t, "--body", body]));
81
+ // Tracked the moment it exists: if B's create fails, A is still cleaned up.
82
+ const mk = async (t, body) => { const n = num(await io.gh(["issue", "create", "-R", repo, "--title", t, "--body", body])); ctx.created.push(n); return n; };
67
83
  const a = await mk(fx.a.title, fx.a.body);
68
84
  const b = await mk(fx.b.title, `${fx.b.body}\n\n**Depends on**: #${a}`);
69
85
  ctx.issues = { A: a, B: b };
70
86
  ctx.bodies = { [a]: fx.a.body, [b]: fx.b.body };
71
- ctx.created.push(a, b);
72
87
  } },
73
88
  { name: "add", run: async (ctx) => {
74
89
  const { io, repo, issues } = ctx;
@@ -84,12 +99,13 @@ export const STEPS = [
84
99
  const { io, issues } = ctx;
85
100
  const pid = async () => (await io.orchestrate.state(ctx.run.id))?.running?.[issues.A]?.pid ?? null;
86
101
  const before = await waitFor(ctx, "resume", `a live lane for #${issues.A}`, pid);
102
+ // The batch runs inside schedd: killing the client stops nothing, so the batch itself is cancelled (#4952).
87
103
  await ctx.run.kill();
104
+ await io.orchestrate.stop(ctx.run.id);
105
+ await waitFor(ctx, "resume", `the lane of #${issues.A} (pid ${before}) to stop after the cancel`, async () => ((await pid()) ? null : { stopped: true }));
88
106
  ctx.run = await io.orchestrate.resume(ctx.run.id);
89
- await io.sleep(ctx.pollMs);
90
- const st = await io.orchestrate.state(ctx.run.id), after = st?.running?.[issues.A]?.pid ?? null;
91
- const done = st?.results?.[issues.A];
92
- if (!done && after !== before) fail("resume", `#${issues.A} was launched again after --resume (pid ${before} -> ${after}), not re-adopted`);
107
+ const st = await waitFor(ctx, "resume", `the batch to pick #${issues.A} up again after --resume`, async () => { const s = await io.orchestrate.state(ctx.run.id); return s?.results?.[issues.A] || s?.running?.[issues.A]?.pid ? s : null; });
108
+ if (!st.results?.[issues.A] && st.running?.[issues.A]?.pid === before) fail("resume", `#${issues.A} still has its pre-cancel lane (pid ${before}) after --resume: the batch was never stopped`);
93
109
  await checkBodies(ctx, "body");
94
110
  } },
95
111
  { name: "merge", run: async (ctx) => {
@@ -127,26 +143,31 @@ export const STEPS = [
127
143
  * Run the steps in order; the first failure stops them, cleanup always runs.
128
144
  * @returns {{ok:boolean, step:string|null, message:string|null, notes:string[], cleanupErrors:string[]}}
129
145
  */
130
- export async function runSelftestLive({ repo, lang = "node", io, timeoutMs = 45 * 60 * 1000, pollMs = 5000 }) {
146
+ export async function runSelftestLive({ repo, lang = "node", io, timeoutMs = DEFAULT_TIMEOUT_MS, pollMs = DEFAULT_POLL_MS }) {
131
147
  const fx = FIXTURES[lang];
132
148
  if (!fx) return { ok: false, step: "setup", message: `unknown fixture language ${lang} (node|python)`, notes: [], cleanupErrors: [] };
133
- const ctx = { io, repo, fx, timeoutMs, pollMs, issues: {}, bodies: {}, created: [], notes: [], run: null };
149
+ const ctx = { io, repo, fx, lang, runId: `r${io.now?.().toString(36) ?? "0"}`, timeoutMs, pollMs, issues: {}, bodies: {}, created: [], notes: [], run: null };
134
150
  let out = { ok: true, step: null, message: null };
135
151
  for (const s of STEPS) {
136
152
  try { await s.run(ctx); } catch (e) { out = { ok: false, step: e.step ?? s.name, message: String(e.message ?? e) }; break; }
137
153
  }
138
154
  const cleanupErrors = [];
139
155
  const tryC = async (what, f) => { try { await f(); } catch (e) { cleanupErrors.push(`${what}: ${String(e.message ?? e).slice(0, 120)}`); } };
140
- if (ctx.run) await tryC("stop orchestrate", () => ctx.run.kill());
156
+ if (ctx.run) {
157
+ await tryC("kill orchestrate client", () => ctx.run.kill());
158
+ await tryC("stop batch", () => io.orchestrate.stop(ctx.run.id));
159
+ }
141
160
  for (const n of ctx.created) {
142
161
  await tryC(`close #${n}`, () => io.gh(["issue", "close", String(n), "-R", repo]));
143
- await tryC(`delete branches of #${n}`, async () => {
144
- const refs = String(await io.gh(["api", `repos/${repo}/git/matching-refs/heads/forge/issue-${n}`, "--jq", ".[].ref"])).split("\n").filter(Boolean);
145
- for (const ref of refs) await io.gh(["api", "-X", "DELETE", `repos/${repo}/git/${ref}`]);
146
- });
147
- await tryC(`close PRs of #${n}`, async () => {
148
- const prs = String(await io.gh(["pr", "list", "-R", repo, "--search", `#${n} in:title,body`, "--state", "open", "--json", "number", "--jq", ".[].number"])).split("\n").filter(Boolean);
149
- for (const p of prs) await io.gh(["pr", "close", p, "-R", repo, "--delete-branch"]);
162
+ // The prefix search over-matches (issue-5 lists issue-50): keep only this issue's own branches, then find PRs by head.
163
+ const own = new RegExp(`^refs/heads/forge/issue-${n}(?![0-9])`);
164
+ const lines = async (args) => String(await io.gh(args)).split("\n").filter(Boolean);
165
+ await tryC(`clean up branches of #${n}`, async () => {
166
+ const refs = (await lines(["api", `repos/${repo}/git/matching-refs/heads/forge/issue-${n}`, "--jq", ".[].ref"])).filter((r) => own.test(r));
167
+ for (const ref of refs) {
168
+ for (const p of await lines(["pr", "list", "-R", repo, "--head", ref.replace(/^refs\/heads\//, ""), "--state", "open", "--json", "number", "--jq", ".[].number"])) await io.gh(["pr", "close", p, "-R", repo]);
169
+ await io.gh(["api", "-X", "DELETE", `repos/${repo}/git/${ref}`]);
170
+ }
150
171
  });
151
172
  }
152
173
  return { ...out, notes: ctx.notes, cleanupErrors };
@@ -158,29 +179,33 @@ const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "forgedock.mjs")
158
179
  export function realIo(env = process.env, { pool = false } = {}) {
159
180
  let self;
160
181
  const root = batchRoot(env);
161
- const detached = (args) => { const p = spawn(process.execPath, [CLI, ...args], { detached: true, stdio: "ignore", env }); p.unref(); return { kill: async () => { try { process.kill(-p.pid, "SIGTERM"); } catch { /* gone */ } } }; };
162
- const waitBatch = async (before) => { for (let i = 0; i < 60; i++) { const id = listBatches(root).find((b) => !before.has(b)); if (id) return id; await new Promise((r) => setTimeout(r, 1000)); } throw new Error("orchestrate did not create a batch within 60s"); };
182
+ const detached = (args) => { const p = spawn(process.execPath, [CLI, ...args], { detached: true, windowsHide: true, stdio: "ignore", env }); p.unref(); return { kill: async () => { try { process.kill(-p.pid, "SIGTERM"); } catch { /* gone */ } } }; };
183
+ // The batch of THIS start: new on disk, for the sandbox repo, and naming the issue we passed. A concurrent real batch is never adopted (#4952).
184
+ const ownBatch = (id, repo, issues) => { try { return isOwnBatch(openBatch(root, id).readPlan(), repo, issues); } catch { return false; } };
185
+ const waitBatch = async (before, repo, issues) => { const id = await pollUntil(() => listBatches(root).find((b) => !before.has(b) && ownBatch(b, repo, issues)), { timeoutMs: BATCH_WAIT_TRIES * BATCH_WAIT_MS, pollMs: BATCH_WAIT_MS }); if (id) return id; throw new Error(`orchestrate did not create a batch for ${repo} ${issues.join(",")} within ${BATCH_WAIT_TRIES * BATCH_WAIT_MS / 1000}s`); };
163
186
  return (self = {
164
187
  now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
165
- gh: async (args) => { const r = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (r.status !== 0) throw new Error(r.stderr.trim() || `gh exited ${r.status}`); return r.stdout; },
188
+ gh: async (args) => { const r = spawnSync("gh", args, { encoding: "utf8", maxBuffer: GH_MAX_BUFFER }); if (r.status !== 0) throw new Error(r.stderr.trim() || `gh exited ${r.status}`); return r.stdout; },
166
189
  cli: async (args) => { const r = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8", env }); return { code: r.status ?? 1, stdout: r.stdout, stderr: r.stderr }; },
167
190
  cliVersion: async () => JSON.parse(readFileSync(join(dirname(CLI), "..", "package.json"), "utf8")).version,
168
191
  orchestrate: {
169
192
  start: async (repo, issues, parallel) => {
170
193
  const before = new Set(listBatches(root)), h = detached(["orchestrate", ...issues.map(String), "--parallel", String(parallel), "--repo", repo]);
171
- return { id: await waitBatch(before), kill: h.kill };
194
+ return { id: await waitBatch(before, repo, issues), kill: h.kill };
172
195
  },
196
+ // The batch lives in schedd, not in the client: `--cancel` is what stops it (its lanes tear down, the rest is held for --resume).
197
+ stop: async (id) => { const r = await self.cli(["orchestrate", "--cancel", id]); if (r.code !== 0) throw new Error(`orchestrate --cancel ${id} exited ${r.code}: ${String(r.stderr || r.stdout).slice(0, 160)}`); },
173
198
  resume: async (id) => ({ id, ...detached(["orchestrate", "--resume", id]) }),
174
199
  state: async (id) => openBatch(root, id).store.load(),
175
200
  },
176
201
  // Step 6: one lane through the pool. The issue's FORGE:STATE records the placement and the lane's FORGE:TELEMETRY rows
177
202
  // carry the engine's forge_version (the sandbox's, not the CLI's: the #4818 stale-image defect).
178
203
  remote: pool ? { run: async ({ repo, issue, timeoutMs, pollMs }) => {
179
- const run = await self.orchestrate.start(repo, [issue], 1), t0 = Date.now();
204
+ const run = await self.orchestrate.start(repo, [issue], 1);
180
205
  let row = null;
181
206
  try {
182
- while (!row && Date.now() - t0 < timeoutMs) { row = (await self.orchestrate.state(run.id))?.results?.[issue] ?? null; if (!row) await self.sleep(pollMs); }
183
- } finally { await run.kill(); }
207
+ row = await pollUntil(async () => (await self.orchestrate.state(run.id))?.results?.[issue] ?? null, { timeoutMs, pollMs, sleep: self.sleep, now: self.now });
208
+ } finally { await run.kill(); await self.orchestrate.stop(run.id).catch(() => {}); }
184
209
  if (!row) throw new Error(`#${issue} produced no result within ${Math.round(timeoutMs / 1000)}s`);
185
210
  const v = JSON.parse(await self.gh(["issue", "view", String(issue), "-R", repo, "--json", "body,comments"]));
186
211
  const text = `${v.body}\n${v.comments.map((c) => c.body).join("\n")}`;
@@ -5,11 +5,11 @@ import assert from "node:assert/strict";
5
5
  import { writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
- import { runSelftestLive, stripState, selftestFromCli, STEPS } from "./selftest-live.mjs";
8
+ import { runSelftestLive, stripState, selftestFromCli, STEPS, FIXTURES, uniqueFixture, isOwnBatch } from "./selftest-live.mjs";
9
9
 
10
10
  /** A fake sandbox world. `d` toggles a defect: add4859 (--add skips), wipe4860 (body wiped), double (resume relaunches), orderBA, noRemoteAuth. */
11
11
  function world(d = {}) {
12
- const w = { issues: {}, closed: [], calls: [], killed: 0, pid: 100, results: {}, order: [], deps: {}, running: {}, clock: 0, pool: !!d.pool };
12
+ const w = { issues: {}, closed: [], calls: [], killed: 0, stops: [], pid: 100, results: {}, order: [], deps: {}, running: {}, clock: 0, pool: !!d.pool };
13
13
  const gh = async (a) => {
14
14
  w.calls.push(a.join(" "));
15
15
  if (a[0] === "api" && a[1] === "-X" && a[2] === "PUT") return "{}";
@@ -36,7 +36,9 @@ function world(d = {}) {
36
36
  },
37
37
  orchestrate: {
38
38
  start: async (repo, issues) => { w.order.push(...issues); w.running[issues[0]] = { pid: ++w.pid }; w.a = issues[0]; return { id: "b1", kill: async () => { w.killed++; } }; },
39
- resume: async (id) => { w.resumedAt = w.tick ?? 0; if (d.double) w.running[w.a] = { pid: ++w.pid }; return { id, kill: async () => { w.killed++; } }; },
39
+ // stop = `orchestrate --cancel`: the lane is torn down (d.nostop: the defect where only the client dies and the lane lives on)
40
+ stop: async (id) => { w.stops.push(id); if (!d.nostop) delete w.running[w.a]; },
41
+ resume: async (id) => { w.resumedAt = w.tick ?? 0; w.running[w.a] = { pid: d.nostop ? w.running[w.a]?.pid ?? ++w.pid : ++w.pid }; return { id, kill: async () => { w.killed++; } }; },
40
42
  state: async () => ({ order: w.order, deps: w.deps, running: w.running, results: w.results }),
41
43
  },
42
44
  remote: w.pool ? { run: async ({ issue }) => { w.remoteIssue = issue; return { where: d.local ? "local" : "remote", authenticated: !d.noRemoteAuth, engineVersion: d.oldEngine ? "1.37.0" : "1.39.0" }; } } : null,
@@ -73,9 +75,14 @@ describe("selftest --live steps (#4865)", () => {
73
75
  const { r } = await run({ wipe4860: true });
74
76
  assert.equal(r.ok, false); assert.equal(r.step, "body"); assert.match(r.message, /lost its body text/);
75
77
  });
76
- it("a lane launched twice after --resume fails the resume step", async () => {
77
- const { r } = await run({ double: true });
78
- assert.equal(r.step, "resume"); assert.match(r.message, /launched again/);
78
+ it("resume really stops the batch (not just the client) and cleanup stops it again (#4952)", async () => {
79
+ const { r, w } = await run({});
80
+ assert.equal(r.ok, true, JSON.stringify(r));
81
+ assert.deepEqual(w.stops, ["b1", "b1"], "once in the resume step, once in cleanup");
82
+ });
83
+ it("a stop that leaves the lane running fails the resume step instead of passing vacuously (#4952)", async () => {
84
+ const { r } = await run({ nostop: true }, { timeoutMs: 20000 });
85
+ assert.equal(r.step, "resume"); assert.match(r.message, /to stop after the cancel/);
79
86
  });
80
87
  it("B merging before A fails the merge step", async () => {
81
88
  const { r } = await run({ orderBA: true });
@@ -89,6 +96,41 @@ describe("selftest --live steps (#4865)", () => {
89
96
  const ok = await run({ pool: true });
90
97
  assert.equal(ok.w.remoteIssue, 12); assert.deepEqual(ok.w.closed, ["10", "11", "12"]);
91
98
  });
99
+ it("fixtures are run-unique, so a merged run leaves nothing the next run trips on (#4952)", async () => {
100
+ const x = uniqueFixture(FIXTURES.node, "node", "r1"), y = uniqueFixture(FIXTURES.python, "python", "r1");
101
+ assert.match(x.a.body, /`src\/r1\/a\.js`.*`src\/r1\/a\.test\.js`/); assert.match(x.a.title, /\(r1\)/);
102
+ assert.match(y.b.body, /`r1\/b\.py`.*`r1\/test_b\.py`/);
103
+ const { w } = await run({});
104
+ assert.match(w.issues[10].body, /src\/r[0-9a-z]+\/a\.js/);
105
+ });
106
+ it("the created issue is tracked at once: A is closed when B's create fails (#4952)", async () => {
107
+ const { w, io } = world({}), gh = io.gh; let creates = 0;
108
+ io.gh = async (a) => { if (a[0] === "issue" && a[1] === "create" && ++creates === 2) throw new Error("rate limited"); return gh(a); };
109
+ const r = await runSelftestLive({ repo: "o/sandbox", io, pollMs: 1, timeoutMs: 60000 });
110
+ assert.equal(r.step, "create"); assert.deepEqual(w.closed, ["10"]);
111
+ });
112
+ it("cleanup matches branches exactly and finds PRs by the head branch it created (#4952)", async () => {
113
+ const { w, io } = world({ add4859: true }), gh = io.gh, log = [];
114
+ io.gh = async (a) => {
115
+ if (a[0] === "api" && a[1].includes("matching-refs/heads/forge/issue-10")) return "refs/heads/forge/issue-10\nrefs/heads/forge/issue-100\nrefs/heads/forge/issue-10-x\n";
116
+ if (a[0] === "api" && a[2] === "DELETE") { log.push(`del ${a[3].split("git/")[1]}`); return ""; }
117
+ if (a[0] === "api") return "";
118
+ if (a[0] === "pr" && a[1] === "list") { log.push(`list ${a[a.indexOf("--head") + 1]}`); return a.includes("forge/issue-10") ? "7\n" : ""; }
119
+ if (a[0] === "pr" && a[1] === "close") { log.push(`close ${a[2]}`); return ""; }
120
+ return gh(a);
121
+ };
122
+ const r = await runSelftestLive({ repo: "o/sandbox", io, pollMs: 1, timeoutMs: 60000 });
123
+ assert.deepEqual(r.cleanupErrors, []);
124
+ assert.deepEqual(log, ["list forge/issue-10", "close 7", "del refs/heads/forge/issue-10", "list forge/issue-10-x", "del refs/heads/forge/issue-10-x"]);
125
+ assert.ok(!w.calls.some((c) => /search/.test(c)), "no text search for PRs");
126
+ });
127
+ it("own batch is picked by repo and issues: a foreign concurrent batch is never adopted (#4952)", () => {
128
+ const mine = { repo: "o/Sandbox", argv: ["orchestrate", "10", "11", "--repo", "o/Sandbox"] };
129
+ assert.ok(isOwnBatch(mine, "o/sandbox", [10, 11]));
130
+ assert.ok(!isOwnBatch({ repo: "o/real", argv: ["orchestrate", "10", "11"] }, "o/sandbox", [10, 11]));
131
+ assert.ok(!isOwnBatch({ repo: "o/sandbox", argv: ["orchestrate", "7"] }, "o/sandbox", [10, 11]));
132
+ assert.ok(!isOwnBatch(null, "o/sandbox", [10]));
133
+ });
92
134
  it("unknown fixture language fails at setup", async () => {
93
135
  assert.equal((await runSelftestLive({ repo: "o/r", lang: "ruby", io: {} })).step, "setup");
94
136
  });
@@ -9,9 +9,9 @@ import { spawn } from "node:child_process";
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { schedDir } from "./sched.mjs";
12
+ import { neutralize, findMarked } from "./prodwatch-triage.mjs";
12
13
 
13
14
  export const NIGHTLY_MARKER = "<!-- FORGE:SELFTEST_NIGHTLY -->";
14
- const neutralize = (s) => String(s ?? "").replace(/</g, "&lt;").replace(/>/g, "&gt;");
15
15
  /** One last-run file per repo: two repos on one host never share (and so never skip) each other's schedule. */
16
16
  const stateFile = (repo) => `selftest-nightly-${String(repo).replace(/[^\w.-]+/g, "_")}.json`;
17
17
 
@@ -37,7 +37,7 @@ function writeLast(dir, repo, ts) {
37
37
  */
38
38
  export function launchNightly({ cli, repo, cwd = process.cwd(), spawnFn = spawn }) {
39
39
  try {
40
- const c = spawnFn(process.execPath, [cli, "selftest", "--live", "--nightly-report", repo], { cwd, detached: true, stdio: "ignore" });
40
+ const c = spawnFn(process.execPath, [cli, "selftest", "--live", "--nightly-report", repo], { cwd, detached: true, windowsHide: true, stdio: "ignore" });
41
41
  c.on("error", () => {});
42
42
  c.unref();
43
43
  return c;
@@ -49,8 +49,12 @@ export async function reportNightly({ repo, io, now = () => Date.now(), result }
49
49
  const step = result?.step ?? "unknown";
50
50
  const title = `selftest --live failed at step ${step} (nightly)`;
51
51
  const body = `The nightly \`forgedock selftest --live\` failed at step **${neutralize(step)}**.\n\n${neutralize(String(result?.message ?? "").slice(0, 2000))}\n\nLast failure: ${new Date(now()).toISOString()}\n\n${NIGHTLY_MARKER}\n`;
52
- const open = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--search", `"FORGE:SELFTEST_NIGHTLY" in:body`, "--limit", "30", "--json", "number,body"])))
53
- .find((i) => String(i.body ?? "").includes(NIGHTLY_MARKER));
52
+ // #4952: the marker is public text, so only an issue authored by the actor running the harness is edited; an unreadable
53
+ // login trusts nobody (a new issue is filed instead).
54
+ let me = "";
55
+ try { me = String(await io.gh(["api", "user", "--jq", ".login"])).trim().toLowerCase(); } catch { /* untrusted */ }
56
+ const issues = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--search", `"FORGE:SELFTEST_NIGHTLY" in:body`, "--limit", "30", "--json", "number,body,author"])));
57
+ const open = findMarked(issues, NIGHTLY_MARKER, (i) => me && String(i.author?.login ?? "").toLowerCase() === me);
54
58
  if (open) {
55
59
  await io.gh(["issue", "edit", String(open.number), "-R", repo, "--title", title, "--body", body]);
56
60
  return { issue: open.number, action: "edit" };
@@ -8,7 +8,7 @@ import { nightlyConfig, nightlyDue, launchNightly, NIGHTLY_MARKER } from "./self
8
8
 
9
9
  const setup = (yaml) => { const cwd = tmpDir("nt-"), stateDir = tmpDir("ns-"); if (yaml) writeFileSync(join(cwd, "forge.yaml"), yaml); return { cwd, stateDir }; };
10
10
  const YAML = "selftest:\n sandbox_repo: a/b\n nightly_interval_hours: 24\n";
11
- const fakeIo = (open = []) => { const calls = []; return { calls, gh: async (a) => { calls.push(a); if (a[1] === "list") return JSON.stringify(open); if (a[1] === "create") return "https://github.com/o/r/issues/77\n"; return ""; } }; };
11
+ const fakeIo = (open = []) => { const calls = []; return { calls, gh: async (a) => { calls.push(a); if (a[0] === "api") return "bot\n"; if (a[1] === "list") return JSON.stringify(open); if (a[1] === "create") return "https://github.com/o/r/issues/77\n"; return ""; } }; };
12
12
 
13
13
  test("nightlyConfig: unset, zero and other sections are off", () => {
14
14
  assert.equal(nightlyConfig(""), null);
@@ -42,7 +42,7 @@ test("failure files one issue naming the step; the second failure edits it", asy
42
42
  assert.match(create.join(" "), /step body/); assert.ok(create.join(" ").includes(NIGHTLY_MARKER));
43
43
  assert.ok(!create[create.indexOf("--body") + 1].includes("<!-- x"));
44
44
  t += 25 * 3600_000;
45
- const io2 = fakeIo([{ number: 77, body: `x\n${NIGHTLY_MARKER}` }]);
45
+ const io2 = fakeIo([{ number: 77, body: `x\n${NIGHTLY_MARKER}`, author: { login: "Bot" } }]);
46
46
  const r2 = await nightlyDue({ ...s, repo: "o/r", io: io2, now: () => t, runLive: async () => ({ ok: false, step: "add", message: "m" }) });
47
47
  assert.deepEqual([r2.action, r2.issue], ["edit", 77]);
48
48
  assert.ok(!io2.calls.some((a) => a[1] === "create"));
@@ -82,3 +82,25 @@ test("launchNightly: detached, unref'd, and an async spawn error never escapes (
82
82
  assert.doesNotThrow(() => child.emit("error", Object.assign(new Error("spawn EAGAIN"), { code: "EAGAIN" })));
83
83
  assert.equal(launchNightly({ cli: "c", repo: "o/r", spawnFn: () => { throw new Error("EMFILE"); } }), null);
84
84
  });
85
+
86
+ test("the dedupe never edits a marker-bearing issue authored by someone else (#4952)", async () => {
87
+ const s = setup(YAML), io = fakeIo([{ number: 5, body: `evil\n${NIGHTLY_MARKER}`, author: { login: "mallory" } }, { number: 6, body: `no marker`, author: { login: "bot" } }]);
88
+ const r = await nightlyDue({ ...s, repo: "o/r", io, runLive: async () => ({ ok: false, step: "add", message: "m" }) });
89
+ assert.deepEqual([r.action, r.issue], ["create", 77]);
90
+ assert.ok(!io.calls.some((a) => a[1] === "edit"));
91
+ assert.ok(io.calls.find((a) => a[1] === "list").join(" ").includes("number,body,author"));
92
+ });
93
+
94
+ test("an unreadable login trusts nobody: a new issue is filed", async () => {
95
+ const s = setup(YAML), io = fakeIo([{ number: 5, body: NIGHTLY_MARKER, author: { login: "bot" } }]), gh = io.gh;
96
+ io.gh = async (a) => { if (a[0] === "api") throw new Error("no token user"); return gh(a); };
97
+ const r = await nightlyDue({ ...s, repo: "o/r", io, runLive: async () => ({ ok: false, step: "add", message: "m" }) });
98
+ assert.equal(r.action, "create");
99
+ });
100
+
101
+ test("a marker quoted mid-body by the trusted author is not a dedupe hit (shared trailer rule)", async () => {
102
+ const s = setup(YAML), io = fakeIo([{ number: 5, body: `quote: ${NIGHTLY_MARKER}\nmore text`, author: { login: "bot" } }]);
103
+ const r = await nightlyDue({ ...s, repo: "o/r", io, runLive: async () => ({ ok: false, step: "add", message: "m" }) });
104
+ assert.equal(r.action, "create");
105
+ assert.ok(!io.calls.some((a) => a[1] === "edit"));
106
+ });