forgedock 1.19.0 → 1.20.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 (44) hide show
  1. package/README.md +2 -0
  2. package/bin/engine.mjs +48 -3
  3. package/bin/runner.mjs +20 -4
  4. package/commands/orchestrate/phase-4-execution.md +1 -1
  5. package/commands/review-pr-agents/protocols.md +19 -0
  6. package/commands/review-pr.md +7 -6
  7. package/commands/work-on/build/architect.md +1 -1
  8. package/commands/work-on/build/implement.md +9 -1
  9. package/commands/work-on/build/validate.md +1 -1
  10. package/commands/work-on/build.md +118 -8
  11. package/commands/work-on/close.md +9 -4
  12. package/commands/work-on/remediate.md +11 -4
  13. package/commands/work-on/review.md +11 -3
  14. package/commands/work-on.md +4 -4
  15. package/docs/CONFIG.md +5 -1
  16. package/docs/RELEASE-NOTES.md +15 -0
  17. package/docs/TELEMETRY.md +2 -2
  18. package/docs/WORK-ON-RUNTIME.md +11 -3
  19. package/package.json +1 -1
  20. package/scripts/architect-resume-match.test.sh +12 -3
  21. package/scripts/build-knowledge-index.mjs +8 -5
  22. package/scripts/builder-resume-match.test.sh +12 -3
  23. package/scripts/calibration.mjs +5 -2
  24. package/scripts/check-spec-markers.sh +2 -0
  25. package/scripts/ci-local/lib.mjs +11 -3
  26. package/scripts/ci-local/run.mjs +155 -49
  27. package/scripts/ci-local/scoping.mjs +27 -4
  28. package/scripts/ci-local.sh +3 -2
  29. package/scripts/ci-local.test.sh +189 -0
  30. package/scripts/classify-finding.sh +1 -1
  31. package/scripts/file-deferred-followups.sh +91 -30
  32. package/scripts/file-deferred-followups.test.sh +101 -8
  33. package/scripts/lib/telemetry.sh +2 -0
  34. package/scripts/review-finalize.sh +101 -12
  35. package/scripts/review-finalize.test.sh +136 -5
  36. package/scripts/review-roster.sh +18 -5
  37. package/scripts/review-roster.test.sh +57 -1
  38. package/scripts/selfreview-plan.sh +86 -0
  39. package/scripts/selfreview-plan.test.sh +90 -0
  40. package/scripts/spawn-depth-check.sh +5 -4
  41. package/scripts/spawn-depth-check.test.sh +3 -0
  42. package/scripts/telemetry.test.sh +8 -0
  43. package/scripts/transition-label.sh +26 -0
  44. package/scripts/verify-phase-trail.test.sh +6 -0
@@ -29,30 +29,57 @@ function slotCount() {
29
29
  }
30
30
  function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
31
31
 
32
- async function acquireSlot(owner, deadline) {
32
+ let ticketSeq = 0;
33
+ // Is this slot dir free to take (absent, or held by a dead pid / for over 3 h)? A stale one is reaped.
34
+ function slotFree(slot) {
35
+ if (!fs.existsSync(slot)) return true;
36
+ let pid = 0; let ts = 0;
37
+ try { const [p, t] = fs.readFileSync(path.join(slot, 'owner'), 'utf8').split(' '); pid = Number(p); ts = Number(t); } catch { /* owner file not written yet */ }
38
+ const stale = pid ? !alive(pid) : (Date.now() - (fs.statSync(slot, { throwIfNoEntry: false })?.mtimeMs || 0) > 30000);
39
+ if (stale || (ts && Date.now() - ts > 3 * 3600 * 1000)) { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* raced */ } return !fs.existsSync(slot); }
40
+ return false;
41
+ }
42
+
43
+ // forge#4057: FIFO-fair slot acquisition. Every waiter files a ticket (`<ms>-<seq>-<pid>-<rand>`) under ci-local.lock/queue and
44
+ // only a ticket among the first N (N = number of free slots) may claim one, so an invocation can never be starved by polling
45
+ // races. A ticket whose pid is dead is reaped; the ticket is removed on acquire, on a deadline miss and on every exit path.
46
+ export async function acquireSlot(owner, deadline) {
33
47
  const dir = path.join(homeForgeDir(), 'ci-local.lock');
34
- fs.mkdirSync(dir, { recursive: true });
48
+ const qdir = path.join(dir, 'queue');
49
+ fs.mkdirSync(qdir, { recursive: true });
35
50
  const n = slotCount();
36
- for (;;) {
37
- for (let i = 0; i < n; i++) {
38
- const slot = path.join(dir, `slot-${i}`);
39
- try {
40
- fs.mkdirSync(slot);
41
- fs.writeFileSync(path.join(slot, 'owner'), `${process.pid} ${Date.now()} ${owner}\n`);
42
- const release = () => { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* gone */ } cleanups.delete(release); };
43
- cleanups.add(release);
44
- return release;
45
- } catch (e) {
46
- if (e.code !== 'EEXIST') throw e;
47
- let pid = 0; let ts = 0;
48
- try { const [p, t] = fs.readFileSync(path.join(slot, 'owner'), 'utf8').split(' '); pid = Number(p); ts = Number(t); } catch { /* owner file not written yet */ }
49
- const stale = pid ? !alive(pid) : (Date.now() - (fs.statSync(slot, { throwIfNoEntry: false })?.mtimeMs || 0) > 30000);
50
- if (stale || (ts && Date.now() - ts > 3 * 3600 * 1000)) { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* raced */ } i--; continue; }
51
+ const ticket = `${String(Date.now()).padStart(14, '0')}-${String(++ticketSeq).padStart(6, '0')}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
52
+ const tpath = path.join(qdir, ticket);
53
+ fs.writeFileSync(tpath, `${process.pid} ${owner}\n`, { flag: 'wx' });
54
+ const dropTicket = () => { try { fs.rmSync(tpath, { force: true }); } catch { /* gone */ } cleanups.delete(dropTicket); };
55
+ cleanups.add(dropTicket);
56
+ try {
57
+ for (;;) {
58
+ const free = [];
59
+ for (let i = 0; i < n; i++) if (slotFree(path.join(dir, `slot-${i}`))) free.push(i);
60
+ const live = [];
61
+ for (const name of fs.readdirSync(qdir).sort()) {
62
+ if (name !== ticket && !alive(Number(name.split('-')[2]))) { try { fs.rmSync(path.join(qdir, name), { force: true }); } catch { /* raced */ } continue; }
63
+ live.push(name);
64
+ }
65
+ const pos = live.indexOf(ticket);
66
+ if (pos === -1) { try { fs.writeFileSync(tpath, `${process.pid} ${owner}\n`, { flag: 'wx' }); } catch { /* re-filed by a racing reaper */ } }
67
+ else if (pos < free.length) {
68
+ for (const i of free) {
69
+ const slot = path.join(dir, `slot-${i}`);
70
+ try {
71
+ fs.mkdirSync(slot);
72
+ fs.writeFileSync(path.join(slot, 'owner'), `${process.pid} ${Date.now()} ${owner}\n`);
73
+ const release = () => { try { fs.rmSync(slot, { recursive: true, force: true }); } catch { /* gone */ } cleanups.delete(release); };
74
+ cleanups.add(release);
75
+ return release;
76
+ } catch (e) { if (e.code !== 'EEXIST') throw e; }
77
+ }
51
78
  }
79
+ if (Date.now() > deadline) return null;
80
+ await sleep(250);
52
81
  }
53
- if (Date.now() > deadline) return null;
54
- await sleep(250);
55
- }
82
+ } finally { dropTicket(); }
56
83
  }
57
84
 
58
85
  // ---- process execution ----
@@ -153,6 +180,16 @@ function changeHash(ctx) {
153
180
  if (!ctx.changeHash) ctx.changeHash = sha256(JSON.stringify([ctx.plan.merge_base, ctx.plan.changed_files.map((f) => `${f}:${fileHash(ctx.repo, f)}`)]));
154
181
  return ctx.changeHash;
155
182
  }
183
+ // forge#4060: service containers are an execution-environment input; they must enter every key derived from stepKey
184
+ // (single-flight, persisted result cache, cost probe). Order-insensitive; absent, undefined and [] fingerprint identically.
185
+ function servicesFp(job) {
186
+ const svcs = Array.isArray(job.services) ? job.services : [];
187
+ return JSON.stringify(svcs.map((s) => {
188
+ const v = s || {};
189
+ const env = v.env || {};
190
+ return [v.name, v.image, Object.keys(env).sort().map((k) => `${k}=${env[k]}`), (v.ports || []).map(String), v.options || ''];
191
+ }).sort((a, b) => JSON.stringify(a) < JSON.stringify(b) ? -1 : 1));
192
+ }
156
193
  function stepKey(step, job, ctx) {
157
194
  const sel = step.scope || {};
158
195
  const first = (shellSplit(step.command) || [])[0] || '';
@@ -161,7 +198,28 @@ function stepKey(step, job, ctx) {
161
198
  ? { files: sel.files.map((f) => `${f}:${fileHash(ctx.repo, f)}`), config: configFilesHash(ctx) }
162
199
  : { change: changeHash(ctx), selected: sel.files || sel.selector || '' };
163
200
  const env = { ...job.env, ...step.env };
164
- return sha256(JSON.stringify([step.command, step.cwd, step.shell, Object.keys(env).sort().map((k) => `${k}=${env[k]}`), toolVersion(path.basename(first)), process.version, inputs]));
201
+ return sha256(JSON.stringify([step.command, step.cwd, step.shell, Object.keys(env).sort().map((k) => `${k}=${env[k]}`), toolVersion(path.basename(first)), process.version, inputs, servicesFp(job)]));
202
+ }
203
+
204
+ function durKey(step) { return sha256(`${step.command}\0${step.cwd || ''}`).slice(0, 40); }
205
+
206
+ // forge#4057: cost hint for ordering. wide = some step runs unscoped or widened; est = expected execution ms (a cached step
207
+ // costs 0, otherwise the last recorded duration of that command, else a default). Cheap and narrow jobs go first so a budget
208
+ // hit leaves the most coverage proven.
209
+ function jobCost(job, ctx) {
210
+ let wide = 0; let est = 0;
211
+ for (const step of job.steps) {
212
+ if (step.noop || step.skip) continue;
213
+ if (step.builtin) continue;
214
+ if (!step.scope || step.scope.mode === 'full' || step.scope.widen_reason) wide = 1;
215
+ try {
216
+ if (step.kind !== 'install' && !ctx.noResultCache && fs.existsSync(path.join(cacheDir(ctx.o), stepKey(step, job, ctx) + '.json'))) continue;
217
+ } catch { /* unknown cost */ }
218
+ let d = 60000;
219
+ try { const v = Number(fs.readFileSync(path.join(cacheDir(ctx.o), 'dur', durKey(step)), 'utf8')); if (v >= 0) d = v; } catch { /* no history */ }
220
+ est += d;
221
+ }
222
+ return [wide, est];
165
223
  }
166
224
 
167
225
  // ---- one job ----
@@ -184,24 +242,30 @@ function absorbGhFiles(ghFiles, env) {
184
242
  }
185
243
 
186
244
  async function runJob(job, ctx) {
187
- const t0 = Date.now(); const res = { id: job.id, name: job.name, source: job.source, status: 'pass', steps: [], reason: '', duration_ms: 0 };
245
+ const t0 = Date.now(); const res = { id: job.id, name: job.name, source: job.source, status: 'pass', steps: [], reason: '', duration_ms: 0, wait_ms: 0 };
188
246
  const jobTimeout = (job.timeout_s || ctx.jobTimeoutS) * 1000;
189
247
  const log = (m) => { res.notes = (res.notes || []).concat(m); };
190
248
  const runnableSteps = job.steps.filter((s) => !s.noop);
191
249
  if (!runnableSteps.length) { res.status = 'pass'; res.reason = 'nothing to run'; res.duration_ms = Date.now() - t0; return res; }
192
- const needExec = runnableSteps.some((s) => !s.skip);
193
250
  let svc = null; let release = null;
194
- try {
195
- if (needExec) {
251
+ // forge#4040 (REG-5): the job timeout clock starts when the job starts executing, never while it waits for a slot.
252
+ // forge#4057: the slot is taken lazily, right before the first step that really executes, so a job made only of skipped,
253
+ // cached or deduplicated steps never queues for one. Time spent here is wait_ms (not duration_ms) and shifts tExec.
254
+ let tExec = Date.now();
255
+ const takeSlot = async () => {
256
+ const tw = Date.now();
257
+ try {
196
258
  release = await acquireSlot(job.id, ctx.deadline);
197
- if (!release) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted waiting for a machine slot'; return res; }
259
+ res.wait_ms += Date.now() - tw;
260
+ if (!release) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted waiting for a machine slot'; return false; }
198
261
  if (job.services && job.services.length) {
199
262
  svc = await startServices(job, log);
200
- if (svc.error) { res.status = 'deferred'; res.reason = `service containers: ${svc.error}`; return res; }
263
+ if (svc.error) { res.status = 'deferred'; res.reason = `service containers: ${svc.error}`; return false; }
201
264
  }
202
- }
203
- // forge#4040 (REG-5): the job timeout clock starts when the job starts executing, never while it waits for a slot.
204
- const tExec = Date.now();
265
+ return true;
266
+ } finally { tExec += Date.now() - tw; }
267
+ };
268
+ try {
205
269
  const ghFiles = { env: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.env`), path: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.path`), output: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.out`), summary: path.join(ctx.tmp, `${job.id.replace(/[^A-Za-z0-9]/g, '_')}.sum`) };
206
270
  for (const f of Object.values(ghFiles)) fs.writeFileSync(f, '');
207
271
  const carried = {}; let failedGroup = null;
@@ -212,17 +276,23 @@ async function runJob(job, ctx) {
212
276
  if (step.skip) { sr.status = 'skipped'; sr.reason = step.skip; continue; }
213
277
  if (failedGroup && step.group !== failedGroup) break;
214
278
  if (Date.now() > ctx.deadline) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted'; return res; }
215
- const s0 = Date.now();
279
+ let s0 = Date.now();
216
280
  if (step.builtin === 'portability') {
217
281
  const f = checkPortability({ repo: ctx.repo, from: ctx.plan.merge_base, changed: ctx.plan.changed_files, tracked: ctx.tracked });
218
282
  sr.duration_ms = Date.now() - s0;
219
283
  if (f.length) { sr.status = 'fail'; res.tail = f.slice(0, 40); res.status = 'fail'; res.failed_step = step.name; failedGroup = step.group || null; if (!failedGroup) break; }
220
284
  continue;
221
285
  }
222
- let key = '';
286
+ let key = ''; let fkey = '';
223
287
  const cacheable = step.kind !== 'install' && !ctx.noResultCache;
288
+ if (step.kind !== 'install') {
289
+ const base = stepKey(step, job, ctx);
290
+ // forge#4057: identical checks (command, cwd, shell, env, input hash, carried env) run once per invocation. A step that
291
+ // reads or writes the GitHub Actions side files has effects beyond its exit code, so it is never shared.
292
+ if (!(job.source === 'github-actions' && /GITHUB_(ENV|PATH|OUTPUT|STEP_SUMMARY)/.test(step.command))) fkey = sha256(base + JSON.stringify(carried));
293
+ if (cacheable) key = base;
294
+ }
224
295
  if (cacheable) {
225
- key = stepKey(step, job, ctx);
226
296
  const f = path.join(cacheDir(ctx.o), key + '.json');
227
297
  if (fs.existsSync(f)) { sr.cache = 'hit'; sr.duration_ms = 0; try { fs.utimesSync(f, new Date(), new Date()); } catch { /* ignore */ } continue; }
228
298
  sr.cache = 'miss';
@@ -233,14 +303,35 @@ async function runJob(job, ctx) {
233
303
  const env = jobEnv(job, step, ctx, ghFiles); Object.assign(env, carried);
234
304
  const cwd = path.resolve(ctx.repo, step.cwd || '.');
235
305
  if (!insideRepo(ctx.repo, cwd)) { res.status = 'deferred'; res.reason = 'step working-directory escapes the repository'; return res; }
236
- const tail = new Tail();
237
- const left = Math.max(1000, Math.min((step.timeout_s || ctx.jobTimeoutS) * 1000, jobTimeout - (Date.now() - tExec), ctx.deadline - Date.now()));
238
- const r = await exec([...argv, scriptFile], { cwd, env, timeoutMs: left, onData: (b) => tail.push(b) });
239
- sr.duration_ms = Date.now() - s0;
240
- if (job.source === 'github-actions') { const e2 = { ...env }; absorbGhFiles(ghFiles, e2); for (const k of Object.keys(e2)) if (e2[k] !== env[k]) carried[k] = e2[k]; }
241
- if (r.code === 0) { if (key) { try { fs.mkdirSync(cacheDir(ctx.o), { recursive: true }); fs.writeFileSync(path.join(cacheDir(ctx.o), key + '.json'), JSON.stringify({ cmd: step.command.slice(0, 300), ts: Date.now() })); } catch { /* cache is best effort */ } } continue; }
242
- if (r.error) tail.push(Buffer.from(`${r.error}\n`));
243
- const text = tail.text(60);
306
+ let prior = fkey ? ctx.flight.get(fkey) : null;
307
+ if (!prior && !release) {
308
+ if (!(await takeSlot())) return res;
309
+ s0 = Date.now();
310
+ prior = fkey ? ctx.flight.get(fkey) : null; // another job may have started the same check while this one queued
311
+ }
312
+ let r; let text; let left;
313
+ if (prior) {
314
+ // single-flight: wait for the first run of this exact check and reuse its result (a failure is shared, never re-run)
315
+ ({ r, text, left } = await prior);
316
+ sr.cache = 'dedup'; sr.duration_ms = 0;
317
+ } else {
318
+ const tail = new Tail();
319
+ left = Math.max(1000, Math.min((step.timeout_s || ctx.jobTimeoutS) * 1000, jobTimeout - (Date.now() - tExec), ctx.deadline - Date.now()));
320
+ const l0 = left;
321
+ const flight = (async () => { const r0 = await exec([...argv, scriptFile], { cwd, env, timeoutMs: l0, onData: (b) => tail.push(b) }); if (r0.error) tail.push(Buffer.from(`${r0.error}\n`)); return { r: r0, text: tail.text(60), left: l0 }; })();
322
+ if (fkey) ctx.flight.set(fkey, flight);
323
+ ({ r, text } = await flight);
324
+ sr.duration_ms = Date.now() - s0;
325
+ if (job.source === 'github-actions') { const e2 = { ...env }; absorbGhFiles(ghFiles, e2); for (const k of Object.keys(e2)) if (e2[k] !== env[k]) carried[k] = e2[k]; }
326
+ if (r.code === 0) {
327
+ try { // duration_ms in the entry and a per-command duration hint feed the scheduler's cost ordering
328
+ fs.mkdirSync(path.join(cacheDir(ctx.o), 'dur'), { recursive: true });
329
+ if (key) fs.writeFileSync(path.join(cacheDir(ctx.o), key + '.json'), JSON.stringify({ cmd: step.command.slice(0, 300), ts: Date.now(), duration_ms: sr.duration_ms }));
330
+ fs.writeFileSync(path.join(cacheDir(ctx.o), 'dur', durKey(step)), String(sr.duration_ms));
331
+ } catch { /* cache is best effort */ }
332
+ }
333
+ }
334
+ if (r.code === 0) continue;
244
335
  if (r.timedOut) { if (Date.now() > ctx.deadline) { res.status = 'deferred'; res.budget = true; res.reason = 'budget exhausted (job killed)'; return res; } sr.status = 'timeout'; res.status = 'degraded'; res.reason = `timed out after ${Math.round(left / 1000)}s (a timeout proves nothing about the code)`; res.tail = text; res.failed_step = step.name; return res; }
245
336
  if (step.kind === 'install') { res.status = 'deferred'; res.reason = `dependency install failed (offline?): ${step.command.slice(0, 60)}`; return res; }
246
337
  if (r.code === 127 && text.some((l) => /command not found|not found$|No such file or directory/.test(l)) && !step.continue_on_error) {
@@ -257,7 +348,7 @@ async function runJob(job, ctx) {
257
348
  } finally {
258
349
  if (svc && svc.finish) svc.finish();
259
350
  if (release) release();
260
- res.duration_ms = Date.now() - t0;
351
+ res.duration_ms = Math.max(0, Date.now() - t0 - res.wait_ms);
261
352
  }
262
353
  }
263
354
 
@@ -277,23 +368,34 @@ export async function runPlan(plan, o = {}) {
277
368
  plan, repo, o, tmp: tmpRoot, deadline: t0 + budgetS * 1000, jobTimeoutS: Number(process.env.FORGE_CI_LOCAL_JOB_TIMEOUT) || Math.min(900, budgetS),
278
369
  tracked: runSync('git', ['-C', repo, 'ls-files', '--cached', '--others', '--exclude-standard', '-z']).out.split('\0').filter(Boolean),
279
370
  noResultCache: process.env.FORGE_CI_LOCAL_NO_CACHE === '1',
371
+ flight: new Map(),
280
372
  };
281
373
  const runnable = plan.jobs.filter((j) => !j.not_applicable && !j.deferred_reason);
282
374
  const ids = new Set(runnable.map((j) => j.id));
283
375
  const results = new Map(); const running = new Map();
284
376
  const pending = new Set(runnable.map((j) => j.id));
285
377
  const byId = new Map(runnable.map((j) => [j.id, j]));
378
+ const costs = new Map();
379
+ const cost = (id) => { if (!costs.has(id)) { let c = [1, 0]; try { c = jobCost(byId.get(id), ctx); } catch { /* ordering is a hint */ } costs.set(id, c); } return costs.get(id); };
380
+ const order = new Map([...pending].map((id, i) => [id, i]));
286
381
  while (pending.size || running.size) {
382
+ const ready = [];
287
383
  for (const id of [...pending]) {
288
384
  const j = byId.get(id);
289
385
  const needs = (j.needs || []).filter((n) => ids.has(n));
290
386
  const blocked = needs.find((n) => results.has(n) && results.get(n).status !== 'pass');
291
- if (blocked) { pending.delete(id); const b = results.get(blocked); const r = { id, name: j.name, source: j.source, status: b.status === 'fail' ? 'skipped' : 'deferred', reason: b.status === 'fail' ? `needs ${blocked}, which failed` : `needs ${blocked}, which is not proven locally`, steps: [], duration_ms: 0 }; results.set(id, r); printJob(r); continue; }
387
+ if (blocked) { pending.delete(id); const b = results.get(blocked); const r = { id, name: j.name, source: j.source, status: b.status === 'fail' ? 'skipped' : 'deferred', reason: b.status === 'fail' ? `needs ${blocked}, which failed` : `needs ${blocked}, which is not proven locally`, steps: [], duration_ms: 0, wait_ms: 0 }; results.set(id, r); printJob(r); continue; }
292
388
  if (needs.some((n) => !results.has(n))) continue;
389
+ ready.push(id);
390
+ }
391
+ // narrow/scoped jobs first, then the cheapest; the widest or longest last (ties keep plan order). `needs` is honoured above.
392
+ ready.sort((x, y) => cost(x)[0] - cost(y)[0] || cost(x)[1] - cost(y)[1] || order.get(x) - order.get(y));
393
+ for (const id of ready) {
293
394
  if (running.size >= maxParallel) break;
294
- if (Date.now() > ctx.deadline) { pending.delete(id); results.set(id, { id, name: j.name, source: j.source, status: 'deferred', budget: true, reason: 'budget exhausted before the job started', steps: [], duration_ms: 0 }); continue; }
395
+ const j = byId.get(id);
396
+ if (Date.now() > ctx.deadline) { pending.delete(id); results.set(id, { id, name: j.name, source: j.source, status: 'deferred', budget: true, reason: 'budget exhausted before the job started', steps: [], duration_ms: 0, wait_ms: 0 }); continue; }
295
397
  pending.delete(id);
296
- const p = runJob(j, ctx).catch((e) => ({ id, name: j.name, source: j.source, status: 'deferred', reason: `runner error: ${e.message}`, steps: [], duration_ms: 0 })).then((r) => { results.set(id, r); running.delete(id); printJob(r); });
398
+ const p = runJob(j, ctx).catch((e) => ({ id, name: j.name, source: j.source, status: 'deferred', reason: `runner error: ${e.message}`, steps: [], duration_ms: 0, wait_ms: 0 })).then((r) => { results.set(id, r); running.delete(id); printJob(r); });
297
399
  running.set(id, p);
298
400
  }
299
401
  if (running.size) await Promise.race(running.values()); else if (pending.size) await sleep(10);
@@ -315,7 +417,7 @@ export async function runPlan(plan, o = {}) {
315
417
  if (s.scope.mode === 'scoped' || s.scope.mode === 'skip') narrowed++;
316
418
  if (s.scope.widen_reason) widened++;
317
419
  if (typeof s.scope.selected === 'number' && typeof s.scope.total === 'number') { selSel += s.scope.selected; selTotal += s.scope.total; }
318
- if (checks++ < 40) console.log(`CI_LOCAL_CHECK: job=${r.id} step=${n + 1} selector=${s.scope.selector || 'none'} selected=${s.scope.selected ?? ''} total=${s.scope.total ?? ''} cache=${s.cache} duration_ms=${s.duration_ms} widen=${(s.scope.widen_reason || '').replace(/\s+/g, '_') || 'none'}`);
420
+ if (checks++ < 40) console.log(`CI_LOCAL_CHECK: job=${r.id} step=${n + 1} selector=${s.scope.selector || 'none'} selected=${s.scope.selected ?? ''} total=${s.scope.total ?? ''} cache=${s.cache} duration_ms=${s.duration_ms} widen=${(s.scope.widen_reason || '').replace(/\s+/g, '_') || 'none'} wait_ms=${r.wait_ms || 0}`);
319
421
  }
320
422
  const degraded = all.filter((r) => r.status === 'degraded');
321
423
  const budgetHit = all.some((r) => r.budget) || (all.some((r) => r.status === 'deferred' && /budget exhausted/.test(r.reason || '')));
@@ -325,18 +427,22 @@ export async function runPlan(plan, o = {}) {
325
427
  const degReason = degraded.length ? 'job_timeout' : budgetHit ? (capped ? 'budget_capped_at_invocation_limit' : 'budget_exhausted') : '';
326
428
  const state = failed.length ? 'FAIL' : degReason ? 'DEGRADED' : 'PASS';
327
429
  const dur = Date.now() - t0;
430
+ const waitTotal = all.reduce((a, r) => a + (r.wait_ms || 0), 0);
431
+ const dedups = all.reduce((a, r) => a + r.steps.filter((x) => x.cache === 'dedup').length, 0);
328
432
  if (plan.no_applicable_jobs && !all.length) console.log('CI_LOCAL_NOTE: no_applicable_jobs (CI definitions exist but no job applies to this change)');
329
- console.log(`CI_LOCAL_RESULT: ${state} jobs_run=${run_.length} jobs_deferred=${defd.length} jobs_failed=${failed.length} jobs_degraded=${degraded.length} jobs_skipped=${skipped.length} checks=${checks} cache_hits=${hits} narrowed=${narrowed} widened=${widened} selected=${selSel} total=${selTotal} duration_ms=${dur}${Date.now() > ctx.deadline ? ' budget_exhausted=true' : ''}${plan.no_applicable_jobs && !all.length ? ' no_applicable_jobs=true' : ''}${degReason && state === 'DEGRADED' ? ` reason=${degReason}` : ''}`);
330
- if (o.resultFile) { try { fs.writeFileSync(o.resultFile, JSON.stringify({ state, mode: plan.config && plan.config.mode, jobs: all, plan_key: plan.plan_key, duration_ms: dur }, null, 2)); } catch { /* ignore */ } }
433
+ console.log(`CI_LOCAL_RESULT: ${state} jobs_run=${run_.length} jobs_deferred=${defd.length} jobs_failed=${failed.length} jobs_degraded=${degraded.length} jobs_skipped=${skipped.length} checks=${checks} cache_hits=${hits} narrowed=${narrowed} widened=${widened} selected=${selSel} total=${selTotal} duration_ms=${dur} wait_ms=${waitTotal} dedup=${dedups}${Date.now() > ctx.deadline ? ' budget_exhausted=true' : ''}${plan.no_applicable_jobs && !all.length ? ' no_applicable_jobs=true' : ''}${degReason && state === 'DEGRADED' ? ` reason=${degReason}` : ''}`);
434
+ if (o.resultFile) { try { fs.writeFileSync(o.resultFile, JSON.stringify({ state, mode: plan.config && plan.config.mode, jobs: all, plan_key: plan.plan_key, duration_ms: dur, wait_ms: waitTotal }, null, 2)); } catch { /* ignore */ } }
331
435
  try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
332
436
  return failed.length ? 1 : state === 'DEGRADED' ? 2 : 0;
333
437
  }
334
438
 
335
439
  function printJob(r) {
336
440
  const secs = (r.duration_ms / 1000).toFixed(1);
441
+ const waitS = ((r.wait_ms || 0) / 1000).toFixed(1);
442
+ const dedup = r.steps.filter((s) => s.cache === 'dedup').length;
337
443
  const stepsRun = r.steps.filter((s) => s.status !== 'skipped').length;
338
444
  const hit = r.steps.filter((s) => s.cache === 'hit').length;
339
- console.log(`CI_LOCAL_JOB: ${r.status === 'pass' ? 'pass' : r.status} ${r.id} | ${r.name} | ${secs}s | steps=${stepsRun}/${r.steps.length}${hit ? ` cached=${hit}` : ''}${r.reason ? ` | ${r.reason}` : ''}`);
445
+ console.log(`CI_LOCAL_JOB: ${r.status === 'pass' ? 'pass' : r.status} ${r.id} | ${r.name} | ${secs}s | steps=${stepsRun}/${r.steps.length}${hit ? ` cached=${hit}` : ''}${dedup ? ` dedup=${dedup}` : ''} wait=${waitS}s${r.reason ? ` | ${r.reason}` : ''}`);
340
446
  if (r.status === 'fail' || r.status === 'degraded') {
341
447
  console.log(`${r.status === 'fail' ? 'CI_LOCAL_FAIL' : 'CI_LOCAL_DEGRADED_STEP'}: ${r.id} | step: ${r.failed_step || '?'}${r.exit !== undefined ? ` | exit ${r.exit}` : ''}`);
342
448
  if (r.failed_command) console.log(`CI_LOCAL_FAIL_COMMAND: ${r.failed_command.split('\n')[0].slice(0, 300)}`);
@@ -242,8 +242,30 @@ function reachFile(sctx, f) {
242
242
  return out;
243
243
  }
244
244
 
245
+ // Every tracked, present test file in the repo: the universe the SEC-3 "reaches at least one test" check runs against.
246
+ function allTests(sctx) {
247
+ if (!sctx.cache.allTests) sctx.cache.allTests = sctx.tracked.filter((f) => isTestFile(f) && sctx.present.has(f));
248
+ return sctx.cache.allTests;
249
+ }
250
+
251
+ // The SEC-3 universe is limited to the step's own ecosystem: a test in another language (tests/utils.test.ts) never
252
+ // counts as covering a changed source of this one (pkg/utils.py), or a pytest step would skip instead of widen.
253
+ const ECO = { js: 'js', mjs: 'js', cjs: 'js', jsx: 'js', ts: 'js', tsx: 'js', mts: 'js', cts: 'js', py: 'py', pyi: 'py', sh: 'sh', bash: 'sh', bats: 'sh', rb: 'rb', php: 'php', go: 'go', java: 'jvm', kt: 'jvm', scala: 'jvm', rs: 'rs', cs: 'cs' };
254
+ const ecoOf = (f) => { const e = extOf(f); return ECO[e] || e; };
255
+ // #4068 review DRR-1: the per-ecosystem universe applies only to a step whose ecosystem has a changed source file. A step
256
+ // in another ecosystem (a node step on a Python-only change) keeps the repo-wide universe it had at a0d45ae, so it skips or
257
+ // narrows instead of widening to a full run on every single-language change.
258
+ function ecosystemTests(sctx, tests) {
259
+ const ecos = new Set(tests.map(ecoOf));
260
+ const touched = sctx.changed.some((f) => !isTestFile(f) && ecos.has(ecoOf(f)) && (CODE_EXT.has(extOf(f)) || /^(sh|bash)$/.test(extOf(f))));
261
+ if (!touched) return allTests(sctx);
262
+ return allTests(sctx).filter((t) => ecos.has(ecoOf(t)));
263
+ }
264
+
245
265
  // Which of testFiles can this change reach? { selected, total, widen } ; widen is a reason string or ''.
246
- export function impactedTests(sctx, testFiles) {
266
+ // `universe` is the repo-wide test set used only for the SEC-3 reach check: a step that runs ONE test must not widen just
267
+ // because the change does not reach that test, only because it reaches no test anywhere. Selection stays within testFiles.
268
+ export function impactedTests(sctx, testFiles, universe = testFiles) {
247
269
  const r = reach(sctx);
248
270
  if (r.widen) return { selected: [...testFiles], total: testFiles.length, widen: r.widen };
249
271
  const selected = new Set();
@@ -254,10 +276,11 @@ export function impactedTests(sctx, testFiles) {
254
276
  // never hide it. The reason names the file.
255
277
  const srcFiles = sctx.changed.filter((f) => !isTestFile(f) && (CODE_EXT.has(extOf(f)) || /^(sh|bash)$/.test(extOf(f))));
256
278
  const unreached = [];
279
+ if (universe !== testFiles) universe = [...new Set([...universe, ...testFiles])];
257
280
  for (const f of srcFiles) {
258
281
  const rf = reachFile(sctx, f);
259
282
  if (rf.widen) return { selected: [...testFiles], total: testFiles.length, widen: rf.widen };
260
- const hit = testFiles.some((t) => rf.files.has(t) && sctx.present.has(t)) || siblingTests(f, testFiles).length > 0;
283
+ const hit = universe.some((t) => rf.files.has(t) && sctx.present.has(t)) || siblingTests(f, universe).length > 0;
261
284
  if (!hit) unreached.push(f);
262
285
  }
263
286
  if (unreached.length) return { selected: [...testFiles], total: testFiles.length, widen: `empty-selection: no test reaches ${unreached.slice(0, 5).join(', ')}${unreached.length > 5 ? ` (+${unreached.length - 5} more)` : ''}` };
@@ -398,7 +421,7 @@ function scopeRunner(name, args, head, cmd, cwdRel, sctx, join) {
398
421
  if (sctx.widen) return full(cmd, sctx.widen, { selector: spec.sel, selected: candidates.length, total: candidates.length });
399
422
  const un = unmappedFile(sctx.changed.filter((f) => !isTestFile(f)), new Set([...spec.ext, ...SRC_EXT]));
400
423
  if (un) return full(cmd, `unknown-language: ${un}`, { selector: spec.sel, selected: candidates.length, total: candidates.length });
401
- const imp = impactedTests(sctx, candidates);
424
+ const imp = impactedTests(sctx, candidates, ecosystemTests(sctx, universe));
402
425
  if (imp.widen) return full(cmd, imp.widen, { selector: spec.sel, selected: candidates.length, total: candidates.length });
403
426
  if (!imp.selected.length) return skipNo(0, candidates.length, spec.sel, 'no test reaches the changed files');
404
427
  const sel = imp.selected.map((f) => fromCwd(cwdRel, f));
@@ -414,7 +437,7 @@ function scopeSingleTest(rest, cmd, cwdRel, sctx) {
414
437
  if (!isTestFile(r) || !sctx.present.has(r)) return null;
415
438
  if (rest.slice(rest.indexOf(fileTok) + 1).length) return full(cmd, '', { selector: 'unscoped:test-args' });
416
439
  if (sctx.widen) return full(cmd, sctx.widen, { selector: 'single-test', selected: 1, total: 1 });
417
- const imp = impactedTests(sctx, [r]);
440
+ const imp = impactedTests(sctx, [r], ecosystemTests(sctx, [r]));
418
441
  if (imp.widen) return full(cmd, imp.widen, { selector: 'single-test', selected: 1, total: 1 });
419
442
  if (!imp.selected.length) return skipNo(0, 1, 'single-test', `${r} not reached by the change`);
420
443
  return scoped(cmd, 1, 1, 'single-test');
@@ -76,7 +76,7 @@ field() { printf '%s\n' "$RES" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -n 1; }
76
76
  STATE=$(printf '%s\n' "$RES" | awk '{print $2}')
77
77
  JR=$(field jobs_run); JD=$(field jobs_deferred); JF=$(field jobs_failed); DUR=$(field duration_ms)
78
78
  forge_emit ci_local result="${STATE:-DEGRADED}" jobs_run="${JR:-0}" jobs_deferred="${JD:-0}" jobs_failed="${JF:-0}" \
79
- duration_ms="${DUR:-0}" fix_iterations="$ITER" checks="$(field checks)" cache_hits="$(field cache_hits)" \
79
+ duration_ms="${DUR:-0}" wait_ms="$(field wait_ms)" fix_iterations="$ITER" checks="$(field checks)" cache_hits="$(field cache_hits)" \
80
80
  narrowed="$(field narrowed)" widened="$(field widened)" selected="$(field selected)" total="$(field total)" >/dev/null 2>&1 || true
81
81
 
82
82
  # One event per scoped/widened/executed check, capped so a large plan never floods the telemetry comment.
@@ -90,7 +90,8 @@ while IFS= read -r line; do
90
90
  ch=$(printf '%s\n' "$line" | sed -n 's/.* cache=\([^ ]*\).*/\1/p')
91
91
  dm=$(printf '%s\n' "$line" | sed -n 's/.* duration_ms=\([^ ]*\).*/\1/p')
92
92
  wd=$(printf '%s\n' "$line" | sed -n 's/.* widen=\([^ ]*\).*/\1/p')
93
- forge_emit ci_local_check job="$job" selector="$sel" selected="${sc:-0}" total="${tt:-0}" cache="$ch" duration_ms="${dm:-0}" widen="$wd" >/dev/null 2>&1 || true
93
+ wm=$(printf '%s\n' "$line" | sed -n 's/.* wait_ms=\([^ ]*\).*/\1/p')
94
+ forge_emit ci_local_check job="$job" selector="$sel" selected="${sc:-0}" total="${tt:-0}" cache="$ch" duration_ms="${dm:-0}" wait_ms="${wm:-0}" widen="$wd" >/dev/null 2>&1 || true
94
95
  EMITTED=$((EMITTED + 1))
95
96
  done < <(grep '^CI_LOCAL_CHECK:' "$OUT")
96
97
 
@@ -915,5 +915,194 @@ OUT=$(PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch ma
915
915
  eq "FIX-3: the run passes with the job deferred" 0 "$RC"
916
916
  hasnt "FIX-3: no container was started" "$(cat "$FAKE_DOCKER_LOG")" "run -d"
917
917
 
918
+ # ------------------------------------------------------------------------------------------------ forge#4057: concurrency
919
+ # SEC-3 universe: a single-test step is judged against the repo-wide tests, not the one test it runs
920
+ r=$(blank); mkdir -p "$r/scripts"
921
+ echo 'echo a' > "$r/scripts/a.sh"; echo 'bash scripts/a.sh' > "$r/scripts/a.test.sh"
922
+ echo 'echo x' > "$r/scripts/x.test.sh"; echo 'echo z' > "$r/scripts/zz.sh"
923
+ commit_base "$r"
924
+ _scope() { # _scope <command> <changed files...>
925
+ node -e "
926
+ import('$ROOT/scripts/ci-local/scoping.mjs').then(m=>{const [cmd,...c]=process.argv.slice(1);const s=m.makeScopeCtx({repo:'$r',baseRef:'main',from:'main',changed:c});
927
+ const r=m.scopeCommand(cmd,'',s);console.log(JSON.stringify({action:r.action,widen:r.widen_reason||''}))})" "$@"; }
928
+ echo '# c' >> "$r/scripts/a.sh"
929
+ R=$(_scope 'bash scripts/x.test.sh' scripts/a.sh)
930
+ eq "4057: an unrelated x.test.sh is skipped when a.sh has a sibling a.test.sh" skip "$(printf '%s' "$R" | jq -r '.action')" # edge_e2_unrelated_test_skipped
931
+ R=$(_scope 'bash scripts/a.test.sh' scripts/a.sh)
932
+ eq "4057: the sibling a.test.sh still runs" run "$(printf '%s' "$R" | jq -r '.action')"
933
+ echo '# c' >> "$r/scripts/zz.sh"
934
+ R=$(_scope 'bash scripts/x.test.sh' scripts/zz.sh)
935
+ has "4057: SEC-3 widen still fires when a changed source reaches no test anywhere" "$(printf '%s' "$R" | jq -r '.widen')" "empty-selection" # edge_e1_sec3_widen_still_fires
936
+ eq "4057: ...and the step runs" run "$(printf '%s' "$R" | jq -r '.action')"
937
+ R=$(_scope 'bash scripts/x.test.sh' scripts/a.sh scripts/zz.sh)
938
+ has "4057: one unreached source file among several still widens a single-test step" "$(printf '%s' "$R" | jq -r '.widen')" "scripts/zz.sh"
939
+ # SEC-3 universe is per ecosystem: a test in another language never covers a changed source (polyglot under-block)
940
+ r=$(blank); mkdir -p "$r/pkg" "$r/tests"
941
+ echo 'def f(): return 1' > "$r/pkg/utils.py"; echo 'def test_core(): assert True' > "$r/tests/test_core.py"
942
+ echo 'test("u", () => {})' > "$r/tests/utils.test.ts"
943
+ commit_base "$r"
944
+ echo '# c' >> "$r/pkg/utils.py"
945
+ R=$(_scope 'pytest tests/test_core.py' pkg/utils.py)
946
+ eq "SEC-3 ecosystem: a pytest step is not skipped because an unrelated .ts test matches the changed .py" run "$(printf '%s' "$R" | jq -r '.action')"
947
+ has "SEC-3 ecosystem: ...it widens and names the unreached source" "$(printf '%s' "$R" | jq -r '.widen')" "pkg/utils.py"
948
+ R=$(_scope 'pytest' pkg/utils.py)
949
+ eq "SEC-3 ecosystem: a bare pytest runner step widens too" run "$(printf '%s' "$R" | jq -r '.action')"
950
+ R=$(_scope 'python3 tests/test_core.py' pkg/utils.py)
951
+ eq "SEC-3 ecosystem: a single python test step widens (no py test reaches the change)" run "$(printf '%s' "$R" | jq -r '.action')"
952
+ # DRR-1: a single-language change never widens another ecosystem's runner step (it skips/narrows as at a0d45ae)
953
+ r=$(blank); mkdir -p "$r/pkg" "$r/tests"
954
+ echo 'def f(): return 1' > "$r/pkg/utils.py"; printf 'from pkg import utils\ndef test_core(): assert utils.f()\n' > "$r/tests/test_core.py"
955
+ echo 'import test from "node:test"; test("a", () => {})' > "$r/tests/a.test.mjs"
956
+ commit_base "$r"
957
+ echo '# c' >> "$r/pkg/utils.py"
958
+ R=$(_scope 'node --test tests/a.test.mjs' pkg/utils.py)
959
+ eq "DRR-1: a node step is not widened by a Python-only change" skip "$(printf '%s' "$R" | jq -r '.action')"
960
+ eq "DRR-1: ...and carries no widen reason" "" "$(printf '%s' "$R" | jq -r '.widen')"
961
+ R=$(_scope 'pytest tests/test_core.py' pkg/utils.py)
962
+ eq "DRR-1: the pytest step still runs the reaching test" run "$(printf '%s' "$R" | jq -r '.action')"
963
+ eq "DRR-1: ...scoped, not widened" "" "$(printf '%s' "$R" | jq -r '.widen')"
964
+
965
+ # FIFO slots (tickets), dead-owner reaping, deadline while queued
966
+ _slot() { FORGE_CI_LOCAL_HOME="$SLOT_HOME" FORGE_CI_LOCAL_SLOTS=1 node --input-type=module -e "
967
+ import('$ROOT/scripts/ci-local/run.mjs').then(async (m) => {
968
+ const [id, hold, budget] = process.argv.slice(1).map(Number);
969
+ const t0 = Date.now();
970
+ const rel = await m.acquireSlot('t' + id, t0 + budget);
971
+ if (!rel) { console.log('timeout ' + id); return; }
972
+ console.log('acq ' + id + (process.env.SLOT_SHOW_WAIT === '1' ? ' wait=' + (Date.now() - t0) : '')); await new Promise((r) => setTimeout(r, hold)); rel();
973
+ })" "$@"; }
974
+ SLOT_HOME="$T/fifo-home"; mkdir -p "$SLOT_HOME"; LOG="$T/fifo.log"; : > "$LOG"
975
+ # hold (1500ms) exceeds the cumulative spawn spacing (3 x 0.3s), so invocations 2-4 all queue behind invocation 1
976
+ for i in 1 2 3 4; do SLOT_SHOW_WAIT=1 _slot "$i" 1500 30000 >> "$LOG" 2>&1 & sleep 0.3; done; wait
977
+ eq "4057: 4 invocations, 1 slot: acquired strictly in ticket order, none starved" "acq 1 acq 2 acq 3 acq 4" "$(sed 's/ wait=.*//' "$LOG" | tr '\n' ' ' | sed 's/ $//')" # edge_e4_fifo_order
978
+ W1=$(sed -n 's/^acq 1 wait=//p' "$LOG")
979
+ case "$W1" in ''|*[!0-9]*) bad "4059: invocation 1 wait not recorded ('$W1')" ;; *) [ "$W1" -lt 500 ] && ok || bad "4059: invocation 1 acquired immediately (wait ${W1}ms, want < 500)" ;; esac
980
+ for i in 2 3 4; do # edge_e2_queued_waits
981
+ Wi=$(sed -n "s/^acq $i wait=//p" "$LOG")
982
+ case "$Wi" in ''|*[!0-9]*) bad "4059: invocation $i wait not recorded ('$Wi')" ;; *) [ "$Wi" -ge 500 ] && ok || bad "4059: invocation $i queued behind the holder (wait ${Wi}ms, want >= 500)" ;; esac
983
+ done
984
+ eq "4057: no ticket is left behind" 0 "$(ls "$SLOT_HOME/ci-local.lock/queue" | wc -l | tr -d ' ')"
985
+ SLOT_HOME="$T/fifo-home2"; mkdir -p "$SLOT_HOME/ci-local.lock/slot-0" "$SLOT_HOME/ci-local.lock/queue"
986
+ ( : ) & DEAD=$!; wait "$DEAD" 2>/dev/null
987
+ printf '%s 1 ghost\n' "$DEAD" > "$SLOT_HOME/ci-local.lock/slot-0/owner"
988
+ : > "$SLOT_HOME/ci-local.lock/queue/00000000000001-000001-$DEAD-abcdef"
989
+ eq "4057: a dead slot owner and a dead earlier ticket are reaped, the queue is not blocked" "acq 9" "$(_slot 9 0 5000)" # edge_e3_dead_owner_reaped
990
+ eq "4057: no ticket left after a reaped acquire" 0 "$(ls "$SLOT_HOME/ci-local.lock/queue" | wc -l | tr -d ' ')"
991
+ mkdir -p "$SLOT_HOME/ci-local.lock/slot-0"; printf '%s %s000 live\n' "$$" "$(date +%s)" > "$SLOT_HOME/ci-local.lock/slot-0/owner"
992
+ eq "4057: the deadline while queued returns no slot" "timeout 8" "$(_slot 8 0 700)" # edge_e6_deadline_ticket_removed
993
+ eq "4057: ...and removes its ticket (no leak)" 0 "$(ls "$SLOT_HOME/ci-local.lock/queue" | wc -l | tr -d ' ')"
994
+
995
+ # single-flight dedup: identical check in two jobs runs once; a different env must not dedup
996
+ r=$(blank); mkdir -p "$r/.github/workflows"
997
+ cat > "$r/.github/workflows/ci.yml" <<'YML'
998
+ on: pull_request
999
+ jobs:
1000
+ a:
1001
+ runs-on: ubuntu-latest
1002
+ steps:
1003
+ - run: echo x >> counter.txt; sleep 1
1004
+ b:
1005
+ runs-on: ubuntu-latest
1006
+ steps:
1007
+ - run: echo x >> counter.txt; sleep 1
1008
+ c:
1009
+ runs-on: ubuntu-latest
1010
+ env:
1011
+ VARIANT: "1"
1012
+ steps:
1013
+ - run: echo x >> counter.txt; sleep 1
1014
+ YML
1015
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1016
+ OUT=$(FORGE_CI_LOCAL_NO_CACHE=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
1017
+ eq "4057: dedup run passes" 0 "$RC"
1018
+ eq "4057: identical step ran once, the different-env step ran too (2 executions of 3 jobs)" 2 "$(wc -l < "$r/counter.txt" | tr -d ' ')" # edge_e8_different_env_not_deduped
1019
+ has "4057: the duplicate job reports dedup" "$OUT" "dedup=1"
1020
+ has "4057: the result line carries the dedup count and wait_ms" "$OUT" "wait_ms="
1021
+ r=$(blank); mkdir -p "$r/.github/workflows"
1022
+ cat > "$r/.github/workflows/ci.yml" <<'YML'
1023
+ on: pull_request
1024
+ jobs:
1025
+ a:
1026
+ runs-on: ubuntu-latest
1027
+ steps:
1028
+ - run: echo y >> fail.txt; sleep 1; exit 3
1029
+ b:
1030
+ runs-on: ubuntu-latest
1031
+ steps:
1032
+ - run: echo y >> fail.txt; sleep 1; exit 3
1033
+ YML
1034
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1035
+ OUT=$(FORGE_CI_LOCAL_NO_CACHE=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
1036
+ eq "4057: a failing check shared by two jobs fails the gate" 1 "$RC"
1037
+ eq "4057: ...and was executed once, not re-run by the duplicate" 1 "$(wc -l < "$r/fail.txt" | tr -d ' ')" # edge_e5_failure_shared_once
1038
+ eq "4057: ...both jobs report the failure" 2 "$(printf '%s\n' "$OUT" | grep -c '^CI_LOCAL_JOB: fail ')"
1039
+
1040
+ # forge#4060: jobs with the same command but different service containers must not share a dedup result or a cached pass
1041
+ mksvc() { # $1 = repo, $2 = image of job b's postgres, $3 = job c's second-service ordering flag
1042
+ mkdir -p "$1/.github/workflows"
1043
+ cat > "$1/.github/workflows/ci.yml" <<YML
1044
+ on: pull_request
1045
+ jobs:
1046
+ a:
1047
+ runs-on: ubuntu-latest
1048
+ services:
1049
+ postgres:
1050
+ image: postgres:14
1051
+ steps:
1052
+ - run: echo x >> counter.txt; sleep 1
1053
+ b:
1054
+ runs-on: ubuntu-latest
1055
+ services:
1056
+ postgres:
1057
+ image: $2
1058
+ steps:
1059
+ - run: echo x >> counter.txt; sleep 1
1060
+ YML
1061
+ }
1062
+ r=$(blank); mksvc "$r" postgres:16
1063
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1064
+ : > "$FAKE_DOCKER_LOG"
1065
+ OUT=$(PATH="$T/bin:$PATH" FORGE_CI_LOCAL_NO_CACHE=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
1066
+ eq "4060: different-services run passes" 0 "$RC"
1067
+ eq "4060: same command with different services ran in both jobs, no shared flight result" 2 "$(wc -l < "$r/counter.txt" | tr -d ' ')" # edge_e4_different_services_not_deduped
1068
+ hasnt "4060: no job reports dedup" "$OUT" "dedup=1"
1069
+ r=$(blank); mksvc "$r" postgres:14
1070
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1071
+ OUT=$(PATH="$T/bin:$PATH" FORGE_CI_LOCAL_NO_CACHE=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
1072
+ eq "4060: identical services still dedup (one execution)" 1 "$(wc -l < "$r/counter.txt" | tr -d ' ')"
1073
+ has "4060: the identical-services duplicate reports dedup" "$OUT" "dedup=1"
1074
+ # persisted cache: a pass for services X is not a hit for services Y
1075
+ r=$(blank); mksvc "$r" postgres:16
1076
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1077
+ OUT=$(PATH="$T/bin:$PATH" FORGE_CI_LOCAL_NO_CACHE=0 FORGE_CI_LOCAL_SLOTS=2 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat --max-parallel 1 2>&1)
1078
+ eq "4060: persisted cache is not shared across different service sets" 2 "$(wc -l < "$r/counter.txt" | tr -d ' ')" # edge_e5_cache_not_shared
1079
+
1080
+ # wait_ms is reported apart from duration; cached jobs wait for nothing; cheap jobs go first once durations are known
1081
+ r=$(blank); mkdir -p "$r/.github/workflows"
1082
+ cat > "$r/.github/workflows/ci.yml" <<'YML'
1083
+ on: pull_request
1084
+ jobs:
1085
+ big:
1086
+ runs-on: ubuntu-latest
1087
+ steps:
1088
+ - run: sleep 1.5 # ord-big
1089
+ small:
1090
+ runs-on: ubuntu-latest
1091
+ steps:
1092
+ - run: echo ord-small
1093
+ YML
1094
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1095
+ OUT=$(FORGE_CI_LOCAL_NO_CACHE=0 FORGE_CI_LOCAL_SLOTS=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat --max-parallel 2 2>&1); RC=$?
1096
+ eq "4057: wait run passes" 0 "$RC"
1097
+ WM=$(printf '%s\n' "$OUT" | sed -n 's/^CI_LOCAL_RESULT:.* wait_ms=\([0-9]*\).*/\1/p')
1098
+ [ "${WM:-0}" -ge 500 ] && ok || bad "4057: one job queued for the single slot, so wait_ms is reported (got '${WM:-}')" "$OUT"
1099
+ has "4057: job lines carry wait=" "$OUT" " wait="
1100
+ OUT=$(FORGE_CI_LOCAL_NO_CACHE=0 FORGE_CI_LOCAL_SLOTS=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat --max-parallel 2 2>&1)
1101
+ has "4057: a fully cached job waits for no slot (wait=0.0s)" "$OUT" "cached=1 wait=0.0s" # edge_e7_cached_job_no_wait
1102
+ OUT=$(FORGE_CI_LOCAL_NO_CACHE=1 FORGE_CI_LOCAL_SLOTS=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat --max-parallel 1 2>&1)
1103
+ FIRST=$(printf '%s\n' "$OUT" | grep '^CI_LOCAL_JOB: pass' | head -n 1)
1104
+ has "4057: the cheapest known job runs first (small before big)" "$FIRST" "gha:ci:small"
1105
+ FORGE_TELEMETRY=off bash -c 'grep -q "wait_ms" "$0"' "$S" && ok || bad "4057: ci-local.sh forwards wait_ms to telemetry"
1106
+
918
1107
  echo "ci-local.test.sh: $pass passed, $fail failed"
919
1108
  [ "$fail" -eq 0 ]
@@ -56,7 +56,7 @@
56
56
  # lists the deferred issue numbers confirmed open, one per line; a deferred item
57
57
  # demotes only when its number is listed (closed, unreadable or absent: no demotion).
58
58
  # The caller lists a number only after verifying it is an open issue (not a PR) whose
59
- # body carries `FORGE:DEFERRED_FROM: #<merge issue>` and the path.
59
+ # body carries `FORGE:DEFERRED_FROM: #<merge issue>` and a checklist line for the exact path.
60
60
  # `--merge-issue <N>` is the PR's linked issue: `deferred → #N` never demotes (that
61
61
  # issue is always open during review, so it is no follow-up).
62
62
  # Never demoted: CRITICAL/HIGH, `not-affected` items, safety-exempt findings