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.
- package/README.md +2 -0
- package/bin/engine.mjs +48 -3
- package/bin/runner.mjs +20 -4
- package/commands/orchestrate/phase-4-execution.md +1 -1
- package/commands/review-pr-agents/protocols.md +19 -0
- package/commands/review-pr.md +7 -6
- package/commands/work-on/build/architect.md +1 -1
- package/commands/work-on/build/implement.md +9 -1
- package/commands/work-on/build/validate.md +1 -1
- package/commands/work-on/build.md +118 -8
- package/commands/work-on/close.md +9 -4
- package/commands/work-on/remediate.md +11 -4
- package/commands/work-on/review.md +11 -3
- package/commands/work-on.md +4 -4
- package/docs/CONFIG.md +5 -1
- package/docs/RELEASE-NOTES.md +15 -0
- package/docs/TELEMETRY.md +2 -2
- package/docs/WORK-ON-RUNTIME.md +11 -3
- package/package.json +1 -1
- package/scripts/architect-resume-match.test.sh +12 -3
- package/scripts/build-knowledge-index.mjs +8 -5
- package/scripts/builder-resume-match.test.sh +12 -3
- package/scripts/calibration.mjs +5 -2
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/ci-local/lib.mjs +11 -3
- package/scripts/ci-local/run.mjs +155 -49
- package/scripts/ci-local/scoping.mjs +27 -4
- package/scripts/ci-local.sh +3 -2
- package/scripts/ci-local.test.sh +189 -0
- package/scripts/classify-finding.sh +1 -1
- package/scripts/file-deferred-followups.sh +91 -30
- package/scripts/file-deferred-followups.test.sh +101 -8
- package/scripts/lib/telemetry.sh +2 -0
- package/scripts/review-finalize.sh +101 -12
- package/scripts/review-finalize.test.sh +136 -5
- package/scripts/review-roster.sh +18 -5
- package/scripts/review-roster.test.sh +57 -1
- package/scripts/selfreview-plan.sh +86 -0
- package/scripts/selfreview-plan.test.sh +90 -0
- package/scripts/spawn-depth-check.sh +5 -4
- package/scripts/spawn-depth-check.test.sh +3 -0
- package/scripts/telemetry.test.sh +8 -0
- package/scripts/transition-label.sh +26 -0
- package/scripts/verify-phase-trail.test.sh +6 -0
package/scripts/ci-local/run.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
48
|
+
const qdir = path.join(dir, 'queue');
|
|
49
|
+
fs.mkdirSync(qdir, { recursive: true });
|
|
35
50
|
const n = slotCount();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
|
263
|
+
if (svc.error) { res.status = 'deferred'; res.reason = `service containers: ${svc.error}`; return false; }
|
|
201
264
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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');
|
package/scripts/ci-local.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/scripts/ci-local.test.sh
CHANGED
|
@@ -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
|