forgedock 1.19.0 → 1.21.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 (53) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/phases.mjs +4 -2
  3. package/bin/engine/telemetry-seam.mjs +1 -1
  4. package/bin/engine-cli.mjs +40 -1
  5. package/bin/engine.mjs +48 -3
  6. package/bin/forgedock.mjs +1 -1
  7. package/bin/lean/effects.mjs +51 -0
  8. package/bin/lean/lean.mjs +352 -0
  9. package/bin/lean/lean.test.mjs +525 -0
  10. package/bin/lean/prompts.mjs +62 -0
  11. package/bin/lean/risk.mjs +49 -0
  12. package/bin/runner.mjs +99 -6
  13. package/commands/orchestrate/phase-4-execution.md +1 -1
  14. package/commands/review-pr-agents/protocols.md +19 -0
  15. package/commands/review-pr.md +7 -6
  16. package/commands/work-on/build/architect.md +1 -1
  17. package/commands/work-on/build/implement.md +9 -1
  18. package/commands/work-on/build/validate.md +14 -7
  19. package/commands/work-on/build.md +118 -8
  20. package/commands/work-on/close.md +9 -4
  21. package/commands/work-on/remediate.md +11 -4
  22. package/commands/work-on/review.md +12 -4
  23. package/commands/work-on.md +4 -4
  24. package/docs/CONFIG.md +8 -1
  25. package/docs/RELEASE-NOTES.md +21 -0
  26. package/docs/TELEMETRY.md +2 -2
  27. package/docs/WORK-ON-RUNTIME.md +11 -3
  28. package/package.json +2 -2
  29. package/scripts/architect-resume-match.test.sh +12 -3
  30. package/scripts/build-knowledge-index.mjs +8 -5
  31. package/scripts/builder-resume-match.test.sh +12 -3
  32. package/scripts/calibration.mjs +5 -2
  33. package/scripts/check-spec-markers.sh +2 -0
  34. package/scripts/ci-local/lib.mjs +11 -3
  35. package/scripts/ci-local/run.mjs +155 -49
  36. package/scripts/ci-local/scoping.mjs +27 -4
  37. package/scripts/ci-local.sh +3 -2
  38. package/scripts/ci-local.test.sh +189 -0
  39. package/scripts/classify-finding.sh +1 -1
  40. package/scripts/file-deferred-followups.sh +91 -30
  41. package/scripts/file-deferred-followups.test.sh +101 -8
  42. package/scripts/lib/telemetry.sh +2 -0
  43. package/scripts/review-finalize.sh +101 -12
  44. package/scripts/review-finalize.test.sh +136 -5
  45. package/scripts/review-roster.sh +18 -5
  46. package/scripts/review-roster.test.sh +57 -1
  47. package/scripts/selfreview-plan.sh +86 -0
  48. package/scripts/selfreview-plan.test.sh +90 -0
  49. package/scripts/spawn-depth-check.sh +5 -4
  50. package/scripts/spawn-depth-check.test.sh +3 -0
  51. package/scripts/telemetry.test.sh +8 -0
  52. package/scripts/transition-label.sh +26 -0
  53. package/scripts/verify-phase-trail.test.sh +6 -0
@@ -173,12 +173,14 @@ function readForgeYaml(repoPath) {
173
173
  const yamlPath = join(repoPath, 'forge.yaml');
174
174
  if (!existsSync(yamlPath)) return null;
175
175
  try {
176
- const result = spawnSync('yq', ['-r', '.project.owner + "/" + .project.repo', yamlPath], {
176
+ const result = spawnSync('yq', ['-r', '.project.owner, .project.repo', yamlPath], {
177
177
  encoding: 'utf8',
178
178
  timeout: 5000,
179
179
  });
180
- if (result.status === 0 && result.stdout.trim()) {
181
- return result.stdout.trim();
180
+ if (result.status === 0) {
181
+ const [owner, name] = result.stdout.split('\n').map(l => l.trim());
182
+ // Both halves must be real values: a partial forge.yaml must not yield "/" or "null/x"
183
+ if (owner && name && owner !== 'null' && name !== 'null') return `${owner}/${name}`;
182
184
  }
183
185
  } catch (_) {
184
186
  // ignore
@@ -1576,7 +1578,7 @@ async function main() {
1576
1578
  repo = readForgeYaml(repoPath);
1577
1579
  }
1578
1580
  if (!repo) {
1579
- process.stderr.write('[ledger] ERROR: Cannot determine GitHub repo. Pass --repo owner/repo or ensure forge.yaml is present.\n');
1581
+ process.stderr.write('[ledger] ERROR: Cannot determine GitHub repo. Pass --repo owner/repo or ensure forge.yaml has project.owner and project.repo (yq required).\n');
1580
1582
  process.exit(1);
1581
1583
  }
1582
1584
 
@@ -1760,6 +1762,7 @@ async function main() {
1760
1762
  }
1761
1763
 
1762
1764
  main().catch(e => {
1763
- process.stderr.write(`[ledger] FATAL: ${e.message}\n${e.stack}\n`);
1765
+ const showStack = process.argv.includes('--verbose') || !!process.env.FORGE_DEBUG;
1766
+ process.stderr.write(`[ledger] FATAL: ${e && e.message ? e.message : e}\n${showStack && e && e.stack ? e.stack + '\n' : ''}`);
1764
1767
  process.exit(1);
1765
1768
  });
@@ -14,7 +14,7 @@ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
14
14
  bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
15
15
  eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
16
16
 
17
- SELECT_JQ='[.[][] | select((.body // "") | startswith("<!-- FORGE:BUILDER -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:BUILDER:COMPLETE -->")), other: (.body | test("<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|ARCHITECT)"))}]'
17
+ SELECT_JQ='[.[][] | select((.body // "") | startswith("<!-- FORGE:BUILDER -->")) | select((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR"))) | {id: .id, complete: (.body | contains("<!-- FORGE:BUILDER:COMPLETE -->")), other: (.body | test("\\n[ \\t]*<!-- FORGE:(INVESTIGATOR|CONTEXT|CONTRACT|ARCHITECT)"))}]'
18
18
  DELETE_JQ='[.[] | select(.complete | not)] | if length == 1 and (.[0].other | not) then .[0].id else empty end'
19
19
 
20
20
  # c <id> <assoc> <type> <body>
@@ -27,7 +27,7 @@ PLAN=$'<!-- FORGE:BUILDER -->\n## Implementation\n\n<!-- FORGE:BUILDER:COMPLETE
27
27
  PARTIAL=$'<!-- FORGE:BUILDER -->\n## Implementation (in progress)'
28
28
  INVEST=$'<!-- FORGE:INVESTIGATOR -->\nThe build phase later posts FORGE:BUILDER and FORGE:BUILDER:COMPLETE.'
29
29
  CTX=$'<!-- FORGE:CONTEXT -->\nmentions <!-- FORGE:BUILDER -->'
30
- MIXED=$'<!-- FORGE:BUILDER -->\nquoted <!-- FORGE:INVESTIGATOR -->'
30
+ MIXED=$'<!-- FORGE:BUILDER -->\nquoted:\n<!-- FORGE:INVESTIGATOR -->'
31
31
 
32
32
  eq "INVESTIGATOR mentioning FORGE:BUILDER is not selected" "$(arch "$(c 1 NONE Bot "$INVEST")" | jq length)" 0
33
33
  eq "INVESTIGATOR mentioning FORGE:BUILDER is never deleted" "$(del_id "$(c 1 NONE Bot "$INVEST")")" ""
@@ -38,7 +38,7 @@ eq "single partial comment -> delete its id" "$(del_id "$(c 4 NONE Bot "$PARTIAL
38
38
  eq "two partial comments -> delete nothing" "$(del_id "$(c 4 NONE Bot "$PARTIAL")"$'\n'"$(c 5 NONE Bot "$PARTIAL")")" ""
39
39
  eq "untrusted author partial ignored" "$(del_id "$(c 6 NONE User "$PARTIAL")")" ""
40
40
  eq "untrusted author complete ignored" "$(n_complete "$(c 6 NONE User "$PLAN")")" 0
41
- eq "partial carrying another FORGE marker is not deleted" "$(del_id "$(c 7 NONE Bot "$MIXED")")" ""
41
+ eq "partial with another FORGE marker at a line start is not deleted" "$(del_id "$(c 7 NONE Bot "$MIXED")")" ""
42
42
  eq "investigator + partial comment -> only the comment id" "$(del_id "$(c 1 NONE Bot "$INVEST")"$'\n'"$(c 8 NONE Bot "$PARTIAL")")" 8
43
43
  eq "paginated concatenated arrays" "$(printf '[%s]\n[%s]' "$(c 1 NONE Bot "$INVEST")" "$(c 9 NONE Bot "$PLAN")" | jq -s "$SELECT_JQ" | jq '[.[] | select(.complete)] | length')" 1
44
44
 
@@ -46,6 +46,15 @@ ARCHC=$'<!-- FORGE:ARCHITECT -->\nplan; the builder will post <!-- FORGE:BUILDER
46
46
  eq "ARCHITECT mentioning the marker is not selected" "$(arch "$(c 10 NONE Bot "$ARCHC")" | jq length)" 0
47
47
  eq "unparsable input fails closed" "$(printf 'not json' | jq -s "$SELECT_JQ" 2>/dev/null | jq -r "$DELETE_JQ" 2>/dev/null)" ""
48
48
 
49
+ INLINE=$'<!-- FORGE:BUILDER -->\n## Approach\nFollowed the plan in `<!-- FORGE:ARCHITECT -->` and <!-- FORGE:INVESTIGATOR --> notes.'
50
+ CRLF=$'<!-- FORGE:BUILDER -->\r\n<!-- FORGE:ARCHITECT -->\r\nbody'
51
+ eq "partial quoting a marker inline is picked and deleted e1_inline_picked" "$(del_id "$(c 11 NONE Bot "$INLINE")")" 11
52
+ eq "inline quoting partial is selected as non-other e1_inline_not_other" "$(arch "$(c 11 NONE Bot "$INLINE")" | jq -r '.[0].other')" false
53
+ eq "marker at the start of a later line is refused e2_line_start_refused" "$(arch "$(c 12 NONE Bot "$MIXED")" | jq -r '.[0].other')" true
54
+ eq "own first-line marker never counts as other e4_own_marker_not_other" "$(arch "$(c 13 NONE Bot "$PARTIAL")" | jq -r '.[0].other')" false
55
+ eq "two inline-quoting partials still delete nothing e3_two_partials_none" "$(del_id "$(c 11 NONE Bot "$INLINE")"$'\n'"$(c 14 NONE Bot "$INLINE")")" ""
56
+ eq "CRLF body with a marker line is still refused e5_crlf_refused" "$(del_id "$(c 15 NONE Bot "$CRLF")")" ""
57
+
49
58
  # Spec drift guard: every mutating site carries this exact predicate and no unanchored selector.
50
59
  for f in commands/work-on/build/implement.md commands/work-on/build.md commands/work-on/build/validate.md; do
51
60
  SPEC="$ROOT/$f"
@@ -143,10 +143,13 @@ export function readForgeYaml(repoPath) {
143
143
  const configPath = join(repoPath, 'forge.yaml');
144
144
  if (!existsSync(configPath)) return null;
145
145
  try {
146
- const result = spawnSync('yq', ['-r', '.project.owner + "/" + .project.repo', configPath], {
146
+ const result = spawnSync('yq', ['-r', '.project.owner, .project.repo', configPath], {
147
147
  encoding: 'utf8', timeout: 5000,
148
148
  });
149
- if (result.status === 0 && result.stdout.trim()) return result.stdout.trim();
149
+ if (result.status === 0) {
150
+ const [owner, name] = result.stdout.split('\n').map(l => l.trim());
151
+ if (owner && name && owner !== 'null' && name !== 'null') return `${owner}/${name}`;
152
+ }
150
153
  } catch (_) { /* ignore */ }
151
154
  return null;
152
155
  }
@@ -177,6 +177,8 @@ MARKER_REGISTRY="
177
177
  REVIEW_TIER
178
178
  REVIEW_STARTED
179
179
  SECURITY_AUDIT
180
+ SELFREVIEW
181
+ SELFREVIEW_REPAIR
180
182
  SIGNAL_RESOLVED
181
183
  SIGNAL_UNRESOLVED
182
184
  SIZE_OVERRIDE
@@ -29,10 +29,18 @@ export function commandExists(name) {
29
29
  return r.code === 0;
30
30
  }
31
31
 
32
+ // Memoized per process: stepKey runs for every step (twice: cost ordering and execution), and an unmemoized
33
+ // `<tool> --version` is a synchronous spawn that blocks the scheduler and burns the budget (#4068 review CONC-1).
34
+ const TOOL_VERSIONS = new Map();
32
35
  export function toolVersion(name) {
33
- if (!/^[A-Za-z0-9._+-]+$/.test(name) || !commandExists(name)) return '';
34
- const r = run(name, ['--version'], { timeout: 5000 });
35
- return (r.out || r.err).split('\n')[0].slice(0, 120);
36
+ if (TOOL_VERSIONS.has(name)) return TOOL_VERSIONS.get(name);
37
+ let v = '';
38
+ if (/^[A-Za-z0-9._+-]+$/.test(name) && commandExists(name)) {
39
+ const r = run(name, ['--version'], { timeout: 5000 });
40
+ v = (r.out || r.err).split('\n')[0].slice(0, 120);
41
+ }
42
+ TOOL_VERSIONS.set(name, v);
43
+ return v;
36
44
  }
37
45
 
38
46
  export function hostClass() {
@@ -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