mandrel 2.53.0 → 2.55.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/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +158 -7
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +19 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/findings/route-finding.js +4 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +40 -16
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +8 -1
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +34 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -28,12 +28,14 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import path from 'node:path';
|
|
31
|
+
import { Worker } from 'node:worker_threads';
|
|
31
32
|
|
|
32
33
|
import { mainCheckoutRoot } from './config/temp-paths.js';
|
|
33
34
|
import {
|
|
34
35
|
acquireLockWithWait,
|
|
35
36
|
acquireSweepLock,
|
|
36
37
|
readLockHolderPid,
|
|
38
|
+
refreshLockSync,
|
|
37
39
|
} from './single-story-sweep/sweep-lock.js';
|
|
38
40
|
|
|
39
41
|
/** Environment escape hatch: set to `0`/`false`/`off`/`no` to disable. */
|
|
@@ -56,6 +58,13 @@ const DEFAULT_WAIT_MS = 20 * 60_000;
|
|
|
56
58
|
/** Poll interval while waiting. */
|
|
57
59
|
const DEFAULT_POLL_MS = 2_000;
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Mtime-refresh interval for the spawn heartbeat. A third of the staleness
|
|
63
|
+
* window, matching the sweep primitive's own divisor: two consecutive missed
|
|
64
|
+
* beats still leave a live holder looking live.
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_SPAWN_HEARTBEAT_MS = DEFAULT_STALE_MS / 3;
|
|
67
|
+
|
|
59
68
|
const FALSEY = /^(0|false|off|no)$/i;
|
|
60
69
|
|
|
61
70
|
/**
|
|
@@ -144,6 +153,170 @@ function beginLock({
|
|
|
144
153
|
return { lock: null, lockPath };
|
|
145
154
|
}
|
|
146
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The heartbeat body, run on a worker thread (Story #5278).
|
|
158
|
+
*
|
|
159
|
+
* Sleeps in `Atomics.wait` slices and refreshes the lockfile's mtime between
|
|
160
|
+
* them, stopping the moment the main thread flips the shared stop flag (and
|
|
161
|
+
* `Atomics.notify`s it), the lockfile stops being ours, or any I/O fails.
|
|
162
|
+
* Ownership is re-read from the file's first line on every beat for the same
|
|
163
|
+
* reason the in-process heartbeat does it: after a steal the file belongs to
|
|
164
|
+
* someone else, and bumping its mtime would keep *their* lock alive on our
|
|
165
|
+
* behalf.
|
|
166
|
+
*
|
|
167
|
+
* Inline source rather than a module of its own: it is nine lines of loop
|
|
168
|
+
* whose whole meaning is the lock it refreshes, and a separate worker entry
|
|
169
|
+
* point would be a second file that no reader of this one can see.
|
|
170
|
+
*/
|
|
171
|
+
const HEARTBEAT_WORKER_SOURCE = `
|
|
172
|
+
const fs = require('node:fs');
|
|
173
|
+
const { workerData } = require('node:worker_threads');
|
|
174
|
+
const { lockPath, ownerId, intervalMs, stopBuffer } = workerData;
|
|
175
|
+
const stop = new Int32Array(stopBuffer);
|
|
176
|
+
while (Atomics.load(stop, 0) === 0) {
|
|
177
|
+
Atomics.wait(stop, 0, 0, intervalMs);
|
|
178
|
+
if (Atomics.load(stop, 0) !== 0) break;
|
|
179
|
+
try {
|
|
180
|
+
if (String(fs.readFileSync(lockPath, 'utf8')).split('\\n', 1)[0] !== ownerId) break;
|
|
181
|
+
const now = new Date();
|
|
182
|
+
fs.utimesSync(lockPath, now, now);
|
|
183
|
+
} catch {
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Keep a held lock's mtime advancing while this thread is blocked inside a
|
|
191
|
+
* synchronous spawn (Story #5278).
|
|
192
|
+
*
|
|
193
|
+
* The primitive's own heartbeat is a `setInterval`, so it only fires when the
|
|
194
|
+
* holder's event loop gets a turn. `runCapture` spawns the suite with
|
|
195
|
+
* `spawnSync`: the loop stops turning for the entire run, the mtime freezes
|
|
196
|
+
* at acquisition time, and a sibling reading that mtime concludes — correctly,
|
|
197
|
+
* on the evidence available to it — that the holder died, breaks the lock, and
|
|
198
|
+
* starts a second full suite beside the first. That is the exact collision
|
|
199
|
+
* this lock exists to prevent, and it fires most reliably on the slowest
|
|
200
|
+
* suites, where it costs the most.
|
|
201
|
+
*
|
|
202
|
+
* A worker thread has its own event loop, unaffected by the main thread's
|
|
203
|
+
* blocking spawn, so it is the only place a refresh can happen at all here.
|
|
204
|
+
*
|
|
205
|
+
* **Best-effort, like everything else on this path.** A worker that cannot
|
|
206
|
+
* start (a runtime with threads disabled, a resource limit) leaves the
|
|
207
|
+
* pre-#5278 behaviour exactly as it was; it never throws and never delays the
|
|
208
|
+
* spawn it guards.
|
|
209
|
+
*
|
|
210
|
+
* @param {{ lockPath: string|null, ownerId?: string, heartbeatMs: number }} opts
|
|
211
|
+
* @returns {() => void} `stop()` — idempotent, safe when no worker started.
|
|
212
|
+
*/
|
|
213
|
+
function startSpawnHeartbeat({ lockPath, ownerId, heartbeatMs }) {
|
|
214
|
+
if (!(lockPath && typeof ownerId === 'string' && heartbeatMs > 0)) {
|
|
215
|
+
return () => {};
|
|
216
|
+
}
|
|
217
|
+
let worker = null;
|
|
218
|
+
let stop = null;
|
|
219
|
+
try {
|
|
220
|
+
const stopBuffer = new SharedArrayBuffer(4);
|
|
221
|
+
stop = new Int32Array(stopBuffer);
|
|
222
|
+
worker = new Worker(HEARTBEAT_WORKER_SOURCE, {
|
|
223
|
+
eval: true,
|
|
224
|
+
workerData: { lockPath, ownerId, intervalMs: heartbeatMs, stopBuffer },
|
|
225
|
+
});
|
|
226
|
+
// A heartbeat must never be the reason the process stays alive.
|
|
227
|
+
worker.unref();
|
|
228
|
+
worker.on('error', () => {});
|
|
229
|
+
} catch {
|
|
230
|
+
return () => {};
|
|
231
|
+
}
|
|
232
|
+
let stopped = false;
|
|
233
|
+
return () => {
|
|
234
|
+
if (stopped) return;
|
|
235
|
+
stopped = true;
|
|
236
|
+
try {
|
|
237
|
+
Atomics.store(stop, 0, 1);
|
|
238
|
+
Atomics.notify(stop, 0);
|
|
239
|
+
worker.terminate();
|
|
240
|
+
} catch {
|
|
241
|
+
// Already gone.
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Start the spawn heartbeat without ever letting it become a reason the suite
|
|
248
|
+
* does not run.
|
|
249
|
+
*
|
|
250
|
+
* The whole module's posture is that a lock defect may slow a suite down and
|
|
251
|
+
* may never skip one, and a heartbeat is the least load-bearing thing on the
|
|
252
|
+
* path — it exists only so a *rival* reads the mtime correctly. So a starter
|
|
253
|
+
* that throws (an environment without worker threads, a resource limit, an
|
|
254
|
+
* injected seam) resolves to "no heartbeat", never to a failed close.
|
|
255
|
+
*
|
|
256
|
+
* @param {Function} startFn
|
|
257
|
+
* @param {{ lockPath: string|null, ownerId?: string, heartbeatMs: number }} opts
|
|
258
|
+
* @returns {() => void}
|
|
259
|
+
*/
|
|
260
|
+
function safeStartHeartbeat(startFn, opts) {
|
|
261
|
+
try {
|
|
262
|
+
return startFn(opts) ?? (() => {});
|
|
263
|
+
} catch {
|
|
264
|
+
return () => {};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Stamp a held lock as current immediately before its critical section
|
|
270
|
+
* begins (Story #5278).
|
|
271
|
+
*
|
|
272
|
+
* A lock acquired at the end of a twenty-minute wait was created — or last
|
|
273
|
+
* heartbeat-refreshed — long before the spawn it is about to guard, and the
|
|
274
|
+
* synchronous caller's event loop is about to stop turning for the whole
|
|
275
|
+
* duration of that spawn. Refreshing here is the last chance to put a current
|
|
276
|
+
* mtime on the file.
|
|
277
|
+
*
|
|
278
|
+
* Routed through the primitive's own {@link refreshLockSync} rather than the
|
|
279
|
+
* holder's `refresh()` so an injected `acquireOnceFn` seam that returns a
|
|
280
|
+
* bare `{ acquired, release }` is refreshed identically to the real one.
|
|
281
|
+
*
|
|
282
|
+
* @param {{ ownerId?: string }|null} held
|
|
283
|
+
* @param {string|null} lockPath
|
|
284
|
+
* @param {object} fsImpl
|
|
285
|
+
*/
|
|
286
|
+
function refreshBeforeSpawn(held, lockPath, fsImpl) {
|
|
287
|
+
if (!(held?.acquired && typeof held.ownerId === 'string' && lockPath)) return;
|
|
288
|
+
refreshLockSync({ lockPath, ownerId: held.ownerId, fsImpl });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The post-wait re-probe (Story #5278).
|
|
293
|
+
*
|
|
294
|
+
* Waiting for the lock is waiting for *someone else's* full suite against
|
|
295
|
+
* this same checkout. By the time it finishes, the thing this caller was
|
|
296
|
+
* about to spawn the suite to establish may already be true — the holder
|
|
297
|
+
* deposited the stamp, or a sibling recorded the evidence. Spawning anyway
|
|
298
|
+
* pays for a whole suite to re-derive a fact that is already on disk, which
|
|
299
|
+
* is exactly the cost the lock exists to avoid.
|
|
300
|
+
*
|
|
301
|
+
* Consulted **only after a real wait**: an uncontended caller's freshness
|
|
302
|
+
* probe ran moments ago and nothing has happened since, so re-running it
|
|
303
|
+
* would be pure overhead on the hot path.
|
|
304
|
+
*
|
|
305
|
+
* @template T
|
|
306
|
+
* @param {(() => T|undefined)|undefined} skipIfSatisfied
|
|
307
|
+
* @param {boolean} waited
|
|
308
|
+
* @returns {{ satisfied: boolean, value?: T }}
|
|
309
|
+
*/
|
|
310
|
+
function probeAlreadySatisfied(skipIfSatisfied, waited) {
|
|
311
|
+
if (!(waited && typeof skipIfSatisfied === 'function')) {
|
|
312
|
+
return { satisfied: false };
|
|
313
|
+
}
|
|
314
|
+
const value = skipIfSatisfied();
|
|
315
|
+
return value === undefined
|
|
316
|
+
? { satisfied: false }
|
|
317
|
+
: { satisfied: true, value };
|
|
318
|
+
}
|
|
319
|
+
|
|
147
320
|
/**
|
|
148
321
|
* Block a synchronous caller for `ms` without a timer. `runCapture` spawns the
|
|
149
322
|
* suite with `spawnSync`, so its whole call stack is synchronous and there is
|
|
@@ -178,7 +351,13 @@ function sleepSync(ms) {
|
|
|
178
351
|
* sleepFn?: (ms: number) => void,
|
|
179
352
|
* acquireOnceFn?: typeof acquireSweepLock,
|
|
180
353
|
* lockPath?: string,
|
|
181
|
-
*
|
|
354
|
+
* skipIfSatisfied?: () => T|undefined,
|
|
355
|
+
* spawnHeartbeatMs?: number,
|
|
356
|
+
* startSpawnHeartbeatFn?: typeof startSpawnHeartbeat,
|
|
357
|
+
* }} opts `skipIfSatisfied` is the post-wait re-probe (Story #5278): after a
|
|
358
|
+
* contended wait it decides whether the thing this spawn would establish is
|
|
359
|
+
* already true, and a non-`undefined` return is returned in the spawn's
|
|
360
|
+
* place. Never consulted on the uncontended path.
|
|
182
361
|
* @param {() => T} spawn
|
|
183
362
|
* @returns {T}
|
|
184
363
|
*/
|
|
@@ -195,6 +374,9 @@ export function withFullSuiteLockSync(
|
|
|
195
374
|
sleepFn = sleepSync,
|
|
196
375
|
acquireOnceFn = acquireSweepLock,
|
|
197
376
|
lockPath: explicitLockPath,
|
|
377
|
+
skipIfSatisfied,
|
|
378
|
+
spawnHeartbeatMs = DEFAULT_SPAWN_HEARTBEAT_MS,
|
|
379
|
+
startSpawnHeartbeatFn = startSpawnHeartbeat,
|
|
198
380
|
},
|
|
199
381
|
spawn,
|
|
200
382
|
) {
|
|
@@ -208,10 +390,12 @@ export function withFullSuiteLockSync(
|
|
|
208
390
|
lockPath: explicitLockPath,
|
|
209
391
|
});
|
|
210
392
|
let held = lock;
|
|
393
|
+
let waited = false;
|
|
211
394
|
if (held === null && lockPath !== null) {
|
|
212
395
|
const deadline = nowFn() + Math.max(0, waitMs);
|
|
213
396
|
for (;;) {
|
|
214
397
|
if (nowFn() >= deadline) break;
|
|
398
|
+
waited = true;
|
|
215
399
|
sleepFn(Math.max(0, pollMs));
|
|
216
400
|
const attempt = acquireOnceFn({ lockPath, timeoutMs: staleMs, fsImpl });
|
|
217
401
|
if (attempt.acquired) {
|
|
@@ -222,7 +406,26 @@ export function withFullSuiteLockSync(
|
|
|
222
406
|
}
|
|
223
407
|
}
|
|
224
408
|
try {
|
|
225
|
-
|
|
409
|
+
const probe = probeAlreadySatisfied(skipIfSatisfied, waited);
|
|
410
|
+
if (probe.satisfied) {
|
|
411
|
+
log(
|
|
412
|
+
'[full-suite-lock] ⏭ the run we waited for already covered this tree — skipping the spawn.',
|
|
413
|
+
);
|
|
414
|
+
return probe.value;
|
|
415
|
+
}
|
|
416
|
+
refreshBeforeSpawn(held, lockPath, fsImpl);
|
|
417
|
+
// The main thread is about to stop turning for the whole spawn, so the
|
|
418
|
+
// holder's own interval heartbeat cannot fire; this one runs off-thread.
|
|
419
|
+
const stopHeartbeat = safeStartHeartbeat(startSpawnHeartbeatFn, {
|
|
420
|
+
lockPath: held?.acquired ? lockPath : null,
|
|
421
|
+
ownerId: held?.ownerId,
|
|
422
|
+
heartbeatMs: spawnHeartbeatMs,
|
|
423
|
+
});
|
|
424
|
+
try {
|
|
425
|
+
return spawn();
|
|
426
|
+
} finally {
|
|
427
|
+
stopHeartbeat();
|
|
428
|
+
}
|
|
226
429
|
} finally {
|
|
227
430
|
if (held?.acquired) held.release();
|
|
228
431
|
}
|
|
@@ -248,7 +451,20 @@ export function lockedCapture(runCaptureFn, config) {
|
|
|
248
451
|
const enabled = isFullSuiteLockEnabled({ config });
|
|
249
452
|
return (captureOpts = {}) =>
|
|
250
453
|
withFullSuiteLockSync(
|
|
251
|
-
{
|
|
454
|
+
{
|
|
455
|
+
cwd: captureOpts.cwd,
|
|
456
|
+
log: captureOpts.log,
|
|
457
|
+
enabled,
|
|
458
|
+
// Story #5278 — the capture path supplies its own freshness re-probe
|
|
459
|
+
// per call, because only it knows which scope ('full' /
|
|
460
|
+
// 'incremental') the stamp has to satisfy. A `true` means the suite
|
|
461
|
+
// we queued behind already stamped this tree, so this caller reports
|
|
462
|
+
// success (exit 0) without spawning a second one.
|
|
463
|
+
skipIfSatisfied:
|
|
464
|
+
typeof captureOpts.recheckFresh === 'function'
|
|
465
|
+
? () => (captureOpts.recheckFresh() ? 0 : undefined)
|
|
466
|
+
: undefined,
|
|
467
|
+
},
|
|
252
468
|
() => runCaptureFn(captureOpts),
|
|
253
469
|
);
|
|
254
470
|
}
|
|
@@ -264,7 +480,7 @@ export function lockedCapture(runCaptureFn, config) {
|
|
|
264
480
|
* @template T
|
|
265
481
|
* @param {Parameters<typeof withFullSuiteLockSync>[0] & {
|
|
266
482
|
* acquireWithWaitFn?: typeof acquireLockWithWait,
|
|
267
|
-
* }} opts
|
|
483
|
+
* }} opts `skipIfSatisfied` behaves exactly as in the sync wrapper.
|
|
268
484
|
* @param {() => Promise<T>} spawn
|
|
269
485
|
* @returns {Promise<T>}
|
|
270
486
|
*/
|
|
@@ -280,6 +496,7 @@ export async function withFullSuiteLockAsync(
|
|
|
280
496
|
acquireOnceFn = acquireSweepLock,
|
|
281
497
|
acquireWithWaitFn = acquireLockWithWait,
|
|
282
498
|
lockPath: explicitLockPath,
|
|
499
|
+
skipIfSatisfied,
|
|
283
500
|
},
|
|
284
501
|
spawn,
|
|
285
502
|
) {
|
|
@@ -293,17 +510,26 @@ export async function withFullSuiteLockAsync(
|
|
|
293
510
|
lockPath: explicitLockPath,
|
|
294
511
|
});
|
|
295
512
|
let held = lock;
|
|
513
|
+
let waited = false;
|
|
296
514
|
if (held === null && lockPath !== null) {
|
|
297
|
-
|
|
515
|
+
waited = true;
|
|
516
|
+
const attempt = await acquireWithWaitFn({
|
|
298
517
|
lockPath,
|
|
299
518
|
waitMs,
|
|
300
519
|
pollMs,
|
|
301
520
|
timeoutMs: staleMs,
|
|
302
521
|
fsImpl,
|
|
303
522
|
});
|
|
304
|
-
if (
|
|
523
|
+
if (attempt.acquired) held = attempt;
|
|
305
524
|
}
|
|
306
525
|
try {
|
|
526
|
+
const probe = probeAlreadySatisfied(skipIfSatisfied, waited);
|
|
527
|
+
if (probe.satisfied) {
|
|
528
|
+
log(
|
|
529
|
+
'[full-suite-lock] ⏭ the run we waited for already covered this tree — skipping the spawn.',
|
|
530
|
+
);
|
|
531
|
+
return probe.value;
|
|
532
|
+
}
|
|
307
533
|
return await spawn();
|
|
308
534
|
} finally {
|
|
309
535
|
if (held?.acquired) held.release();
|