mandrel 2.40.0 → 2.42.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 (90) hide show
  1. package/.agents/README.md +2 -2
  2. package/.agents/agents/story-worker.md +24 -14
  3. package/.agents/docs/agentrc-reference.json +7 -2
  4. package/.agents/docs/configuration.md +5 -2
  5. package/.agents/instructions.md +5 -6
  6. package/.agents/rules/api-conventions.md +43 -7
  7. package/.agents/rules/ci-remediation.md +3 -14
  8. package/.agents/rules/gherkin-standards.md +21 -6
  9. package/.agents/rules/git-conventions.md +6 -5
  10. package/.agents/rules/security-baseline.md +6 -7
  11. package/.agents/rules/testing-standards.md +75 -198
  12. package/.agents/schemas/agentrc.schema.json +17 -2
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +68 -3
  15. package/.agents/scripts/coverage-capture.js +25 -8
  16. package/.agents/scripts/install-matrix-assert.js +2 -2
  17. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +6 -0
  18. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
  19. package/.agents/scripts/lib/baselines/git-base.js +74 -38
  20. package/.agents/scripts/lib/close-validation/gates.js +153 -25
  21. package/.agents/scripts/lib/close-validation/process.js +30 -1
  22. package/.agents/scripts/lib/close-validation/runner.js +5 -0
  23. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
  24. package/.agents/scripts/lib/config/quality.js +36 -21
  25. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  26. package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
  27. package/.agents/scripts/lib/crap-baseline-join.js +11 -7
  28. package/.agents/scripts/lib/full-suite-lock.js +311 -0
  29. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  30. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
  31. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
  32. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  33. package/.agents/scripts/lib/orchestration/dependency-candidates.js +139 -0
  34. package/.agents/scripts/lib/orchestration/epic-candidates.js +159 -0
  35. package/.agents/scripts/lib/orchestration/epic-checklist.js +103 -0
  36. package/.agents/scripts/lib/orchestration/epic-container.js +18 -2
  37. package/.agents/scripts/lib/orchestration/plan-context.js +97 -36
  38. package/.agents/scripts/lib/orchestration/plan-persist/cross-plan-links.js +80 -0
  39. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +192 -0
  40. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +5 -1
  41. package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
  42. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +14 -2
  43. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
  45. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
  46. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
  47. package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
  48. package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
  49. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
  50. package/.agents/scripts/plan-persist.js +60 -1
  51. package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +5 -10
  52. package/.agents/skills/core/browser-testing-with-devtools/reference.md +7 -46
  53. package/.agents/skills/core/code-review-and-quality/SKILL.md +0 -5
  54. package/.agents/skills/core/documentation-and-adrs/SKILL.md +0 -3
  55. package/.agents/skills/core/gates-and-baselines/SKILL.md +10 -137
  56. package/.agents/skills/core/gates-and-baselines/reference.md +103 -0
  57. package/.agents/skills/core/idea-refinement/SKILL.md +2 -18
  58. package/.agents/skills/core/security-and-hardening/SKILL.md +2 -4
  59. package/.agents/skills/core/security-and-hardening/reference.md +0 -70
  60. package/.agents/skills/skills.index.json +10 -70
  61. package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +4 -10
  62. package/.agents/skills/stack/qa/gherkin-authoring/reference.md +9 -113
  63. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +29 -154
  64. package/.agents/skills/stack/qa/qa-harness/SKILL.md +157 -98
  65. package/.agents/workflows/git-cleanup.md +3 -2
  66. package/.agents/workflows/git-deliver.md +3 -2
  67. package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
  68. package/.agents/workflows/helpers/deliver-digest.md +31 -10
  69. package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
  70. package/.agents/workflows/helpers/deliver-story.md +23 -21
  71. package/.agents/workflows/helpers/plan-reference.md +82 -2
  72. package/.agents/workflows/mandrel-plan.md +45 -45
  73. package/.agents/workflows/qa-assist.md +20 -17
  74. package/.agents/workflows/qa-explore.md +30 -29
  75. package/.agents/workflows/qa-run.md +2 -1
  76. package/docs/CHANGELOG.md +32 -0
  77. package/package.json +1 -1
  78. package/.agents/rules/changelog-style.md +0 -180
  79. package/.agents/rules/shell-conventions.md +0 -61
  80. package/.agents/scripts/lib/qa/coverage-verdict.js +0 -214
  81. package/.agents/skills/core/api-and-interface-design/SKILL.md +0 -55
  82. package/.agents/skills/core/api-and-interface-design/reference.md +0 -76
  83. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +0 -45
  84. package/.agents/skills/core/debugging-and-error-recovery/reference.md +0 -56
  85. package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +0 -54
  86. package/.agents/skills/core/idea-refinement/refinement-criteria.md +0 -155
  87. package/.agents/skills/core/idea-refinement/scripts/idea-refine.sh +0 -15
  88. package/.agents/skills/core/qa-coverage-mapping/SKILL.md +0 -105
  89. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +0 -152
  90. package/.agents/skills/stack/qa/vitest/SKILL.md +0 -22
@@ -39,6 +39,12 @@ const EXECUTION_SCHEMA = {
39
39
  'Per-command timeout (ms) for the long-running spawns delivery drives — the close-validation chain and the gate CLIs.',
40
40
  default: LIMITS_DEFAULTS.executionTimeoutMs,
41
41
  },
42
+ fullSuiteLock: {
43
+ type: 'boolean',
44
+ description:
45
+ 'Serialize full-suite spawns (`npm test` / `npm run test:coverage`) behind a host-level advisory lock, so two concurrent deliveries on one checkout do not run two suites against the same cores. Best-effort: a wait that expires spawns anyway, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable.',
46
+ default: true,
47
+ },
42
48
  },
43
49
  additionalProperties: false,
44
50
  };
@@ -11,16 +11,22 @@
11
11
  import path from 'node:path';
12
12
 
13
13
  /**
14
- * Run the incremental capture path when
15
- * `delivery.quality.gates.crap.incrementalCoverage.enabled` is true.
14
+ * Run the skip-aware capture path when
15
+ * `delivery.quality.gates.crap.incrementalCoverage.skipWhenUnchanged` is true
16
+ * (the default since Story #5173).
16
17
  *
17
18
  * **This does not shorten the capture run.** The changed-file set decides
18
19
  * *whether* to capture, never *what* the capture executes: when nothing under
19
20
  * `crap.targetDirs` changed there is no capture at all, and otherwise the
20
21
  * ordinary full `npm run test:coverage` runs. The saving that makes the mode
21
- * worth having is the skip; the other half is the CRAP join, which resolves
22
- * methods in untouched files from the committed baseline row
23
- * (`crap-baseline-join.js`) instead of demanding fresh coverage for them.
22
+ * worth having is the skip.
23
+ *
24
+ * Gated by `skipWhenUnchanged` alone (Story #5173). It MUST NOT consult
25
+ * `baselineJoin`: that switch governs the CRAP join
26
+ * (`crap-baseline-join.js`), which resolves methods in untouched files from
27
+ * the committed baseline row instead of demanding fresh coverage for them —
28
+ * a gate loosening, where the skip is a pure saving. The two are defaulted
29
+ * differently for exactly that reason, so neither may read the other.
24
30
  *
25
31
  * Returns the process exit code when incremental mode handled the run
26
32
  * (skip, capture, or a capture failure), or `null` when the caller should
@@ -54,7 +60,7 @@ export function tryIncrementalCapture({
54
60
  writeCaptureStampImpl,
55
61
  logger,
56
62
  }) {
57
- if (crap.incrementalCoverage?.enabled !== true) return null;
63
+ if (crap.incrementalCoverage?.skipWhenUnchanged !== true) return null;
58
64
 
59
65
  const ref = crap.incrementalCoverage.baseRef || args.ref;
60
66
  let changed = null;
@@ -176,14 +176,18 @@ export function resolveRawRow(mr, { requireCoverage, coverageAvailable }) {
176
176
  }
177
177
 
178
178
  /**
179
- * Incremental-mode join (Story #4981): resolve a file's raw method rows
180
- * against its committed CRAP-baseline rows instead of requiring fresh
181
- * coverage, for a file the diff did NOT touch.
179
+ * Baseline-join mode (Story #4981; gated by
180
+ * `incrementalCoverage.baselineJoin` since Story #5173): resolve a file's raw
181
+ * method rows against its committed CRAP-baseline rows instead of requiring
182
+ * fresh coverage, for a file the diff did NOT touch.
182
183
  *
183
- * Rationale: `coverage-capture`'s incremental mode only runs the consumer's
184
- * test suite scoped to the diff, so an untouched file's coverage entry may
185
- * legitimately be absent even though nothing about that file's methods
186
- * changed. Requiring a fresh join for it would either (a) skip-and-count
184
+ * Rationale: when the capture was skipped because nothing under
185
+ * `crap.targetDirs` changed, the coverage artifact on disk is whatever the
186
+ * last run left so an untouched file's coverage entry may legitimately be
187
+ * absent even though nothing about that file's methods changed. (The capture
188
+ * run itself is never narrowed: a capture that *does* happen is the ordinary
189
+ * full suite — Story #5065.) Requiring a fresh join for it would either
190
+ * (a) skip-and-count
187
191
  * every one of its methods under `requireCoverage: true`, weakening the
188
192
  * gate's signal for the vast majority of the tree on every run, or (b) score
189
193
  * them at an invented 0% under `requireCoverage: false`, manufacturing a
@@ -0,0 +1,311 @@
1
+ /**
2
+ * full-suite-lock.js — serialize the framework's full-suite spawns across
3
+ * concurrent processes on one host (Story #5173).
4
+ *
5
+ * A full `npm test` / `npm run test:coverage` is the most expensive thing this
6
+ * framework causes, and a multi-Story delivery runs several of them from
7
+ * sibling worktrees of the same checkout. Two suites racing on one host do not
8
+ * merely take twice as long — they contend for the same cores, and the
9
+ * coverage artifact they both write is a single shared path per worktree, so
10
+ * the loser's run is wasted work. This module makes the second spawn wait for
11
+ * the first instead.
12
+ *
13
+ * **It reuses the shipped advisory-lock primitive**
14
+ * (`single-story-sweep/sweep-lock.js`) rather than authoring a second
15
+ * lockfile: pid+mtime identity, stale takeover, heartbeat and owner-checked
16
+ * release are all already solved there, and a second implementation would be a
17
+ * second set of those bugs. `phases/post-land.js` is the other consumer.
18
+ *
19
+ * **Posture: best-effort, never load-bearing.** Failing to acquire — a
20
+ * contended wait that expires, an I/O error, an unresolvable lock home —
21
+ * falls through to spawning the suite anyway. The lock is a collision damper,
22
+ * not mutual exclusion; turning it load-bearing would let a stale lockfile
23
+ * fail a delivery, which is strictly worse than the contention it prevents.
24
+ *
25
+ * **It covers only the spawn.** Callers acquire immediately around the child
26
+ * process, never around the freshness/digest checks that precede it, so a
27
+ * capture that is already credited never waits.
28
+ */
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+
32
+ import { mainCheckoutRoot } from './config/temp-paths.js';
33
+ import {
34
+ acquireLockWithWait,
35
+ acquireSweepLock,
36
+ readLockHolderPid,
37
+ } from './single-story-sweep/sweep-lock.js';
38
+
39
+ /** Environment escape hatch: set to `0`/`false`/`off`/`no` to disable. */
40
+ export const FULL_SUITE_LOCK_ENV = 'MANDREL_FULL_SUITE_LOCK';
41
+
42
+ /**
43
+ * Lockfile name, resolved under the **git common dir's parent** so every
44
+ * linked worktree of one checkout contends on one file — the whole point of a
45
+ * host-level lock is that `.worktrees/story-A` and `.worktrees/story-B` must
46
+ * not each get their own.
47
+ */
48
+ const FULL_SUITE_LOCK_FILENAME = 'mandrel-full-suite.lock';
49
+
50
+ /** Stale-holder threshold. A suite legitimately runs for minutes. */
51
+ const DEFAULT_STALE_MS = 15 * 60_000;
52
+
53
+ /** Total bounded wait before giving up and spawning anyway. */
54
+ const DEFAULT_WAIT_MS = 20 * 60_000;
55
+
56
+ /** Poll interval while waiting. */
57
+ const DEFAULT_POLL_MS = 2_000;
58
+
59
+ const FALSEY = /^(0|false|off|no)$/i;
60
+
61
+ /**
62
+ * Is the full-suite lock enabled for this process?
63
+ *
64
+ * The environment wins over config so an operator can disable it for one
65
+ * invocation without editing `.agentrc.json`. Both hatches are one-way: they
66
+ * only ever turn the lock **off**, because an operator disabling a
67
+ * best-effort damper is always safe while forcing it on is not.
68
+ *
69
+ * @param {{ config?: object, env?: Record<string, string|undefined> }} [opts]
70
+ * @returns {boolean}
71
+ */
72
+ export function isFullSuiteLockEnabled({ config, env = process.env } = {}) {
73
+ const raw = env?.[FULL_SUITE_LOCK_ENV];
74
+ if (typeof raw === 'string' && FALSEY.test(raw.trim())) return false;
75
+ return config?.delivery?.execution?.fullSuiteLock !== false;
76
+ }
77
+
78
+ /**
79
+ * Resolve the one lockfile path shared by a checkout and all of its linked
80
+ * worktrees, or `null` when the checkout root cannot be resolved (not a git
81
+ * repo, git unavailable). A `null` disables the lock for that call rather
82
+ * than inventing a cwd-local path that would never actually collide with the
83
+ * sibling it is meant to serialize against.
84
+ *
85
+ * @param {{ cwd: string, mainCheckoutRootFn?: typeof mainCheckoutRoot }} opts
86
+ * @returns {string|null}
87
+ */
88
+ export function resolveFullSuiteLockPath({
89
+ cwd,
90
+ mainCheckoutRootFn = mainCheckoutRoot,
91
+ }) {
92
+ if (typeof cwd !== 'string' || cwd.length === 0) return null;
93
+ const root = mainCheckoutRootFn(cwd);
94
+ if (typeof root !== 'string' || root.length === 0) return null;
95
+ return path.join(root, '.git', FULL_SUITE_LOCK_FILENAME);
96
+ }
97
+
98
+ /**
99
+ * Emit the operator-facing wait line. Naming the holding pid is what keeps a
100
+ * multi-minute wait from reading as a hang — it is the difference between
101
+ * "nothing is happening" and "pid 4711 is running the suite; mine is next".
102
+ *
103
+ * @param {(m: string) => void} log
104
+ * @param {string} lockPath
105
+ * @param {object} fsImpl
106
+ */
107
+ function logWait(log, lockPath, fsImpl) {
108
+ const pid = readLockHolderPid(lockPath, fsImpl);
109
+ log(
110
+ `[full-suite-lock] ⏳ another full suite is already running on this host (holding pid ${pid ?? 'unknown'}) — waiting for it to finish before spawning.`,
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Shared preamble for both wrappers: decide whether to lock at all, take the
116
+ * uncontended fast path, and emit the wait line when a wait is about to
117
+ * happen.
118
+ *
119
+ * @returns {{ lock: object|null, lockPath: string|null }} `lock` is a held
120
+ * lock when the fast path won, `null` when the caller must wait (or when
121
+ * locking is off, in which case `lockPath` is `null` too).
122
+ */
123
+ function beginLock({
124
+ cwd,
125
+ enabled,
126
+ log,
127
+ staleMs,
128
+ fsImpl,
129
+ acquireOnceFn,
130
+ lockPath: explicitLockPath,
131
+ }) {
132
+ if (!enabled) return { lock: null, lockPath: null };
133
+ const lockPath = explicitLockPath ?? resolveFullSuiteLockPath({ cwd });
134
+ if (lockPath === null) return { lock: null, lockPath: null };
135
+ const first = acquireOnceFn({
136
+ lockPath,
137
+ timeoutMs: staleMs,
138
+ fsImpl,
139
+ });
140
+ if (first.acquired) return { lock: first, lockPath };
141
+ // A hard I/O error will not resolve by waiting — proceed unserialized.
142
+ if (first.reason === 'error') return { lock: null, lockPath: null };
143
+ logWait(log, lockPath, fsImpl);
144
+ return { lock: null, lockPath };
145
+ }
146
+
147
+ /**
148
+ * Block a synchronous caller for `ms` without a timer. `runCapture` spawns the
149
+ * suite with `spawnSync`, so its whole call stack is synchronous and there is
150
+ * no event loop to yield to; `Atomics.wait` on a throwaway buffer is the
151
+ * sanctioned way to sleep on that stack.
152
+ *
153
+ * @param {number} ms
154
+ */
155
+ function sleepSync(ms) {
156
+ if (!(ms > 0)) return;
157
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
158
+ }
159
+
160
+ /**
161
+ * Run `spawn` with the full-suite lock held, from a **synchronous** caller.
162
+ *
163
+ * Never throws on the lock's account and always runs `spawn` exactly once:
164
+ * every lock outcome — disabled, acquired, contended past the wait budget,
165
+ * I/O error — ends in the same call, so a lock defect can slow a suite down
166
+ * but can never skip or duplicate it.
167
+ *
168
+ * @template T
169
+ * @param {{
170
+ * cwd: string,
171
+ * enabled?: boolean,
172
+ * log?: (m: string) => void,
173
+ * waitMs?: number,
174
+ * pollMs?: number,
175
+ * staleMs?: number,
176
+ * fsImpl?: object,
177
+ * nowFn?: () => number,
178
+ * sleepFn?: (ms: number) => void,
179
+ * acquireOnceFn?: typeof acquireSweepLock,
180
+ * lockPath?: string,
181
+ * }} opts
182
+ * @param {() => T} spawn
183
+ * @returns {T}
184
+ */
185
+ export function withFullSuiteLockSync(
186
+ {
187
+ cwd,
188
+ enabled = true,
189
+ log = () => {},
190
+ waitMs = DEFAULT_WAIT_MS,
191
+ pollMs = DEFAULT_POLL_MS,
192
+ staleMs = DEFAULT_STALE_MS,
193
+ fsImpl = fs,
194
+ nowFn = Date.now,
195
+ sleepFn = sleepSync,
196
+ acquireOnceFn = acquireSweepLock,
197
+ lockPath: explicitLockPath,
198
+ },
199
+ spawn,
200
+ ) {
201
+ const { lock, lockPath } = beginLock({
202
+ cwd,
203
+ enabled,
204
+ log,
205
+ staleMs,
206
+ fsImpl,
207
+ acquireOnceFn,
208
+ lockPath: explicitLockPath,
209
+ });
210
+ let held = lock;
211
+ if (held === null && lockPath !== null) {
212
+ const deadline = nowFn() + Math.max(0, waitMs);
213
+ for (;;) {
214
+ if (nowFn() >= deadline) break;
215
+ sleepFn(Math.max(0, pollMs));
216
+ const attempt = acquireOnceFn({ lockPath, timeoutMs: staleMs, fsImpl });
217
+ if (attempt.acquired) {
218
+ held = attempt;
219
+ break;
220
+ }
221
+ if (attempt.reason === 'error') break;
222
+ }
223
+ }
224
+ try {
225
+ return spawn();
226
+ } finally {
227
+ if (held?.acquired) held.release();
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Decorate a capture runner so its spawn is serialized behind the host lock.
233
+ *
234
+ * The lock composes *over* `runCapture` rather than living inside it, for two
235
+ * reasons. `runCapture` has no config in scope — it is reached from pre-push
236
+ * and from unit tests as a pure spawn helper — and every decision that can
237
+ * avoid the suite (the changed-file skip, the digest/mtime freshness probe)
238
+ * happens in the capture paths *above* it. Wrapping the runner at the one
239
+ * production call site therefore puts the lock exactly around the spawn: an
240
+ * already-credited capture returns before the wrapper is ever invoked, so it
241
+ * never waits (AC-9).
242
+ *
243
+ * @param {Function} runCaptureFn The runner to wrap (`runCapture`).
244
+ * @param {object} [config] Resolved config; both escape hatches are read here.
245
+ * @returns {Function} A runner with the same `(opts) => exitCode` contract.
246
+ */
247
+ export function lockedCapture(runCaptureFn, config) {
248
+ const enabled = isFullSuiteLockEnabled({ config });
249
+ return (captureOpts = {}) =>
250
+ withFullSuiteLockSync(
251
+ { cwd: captureOpts.cwd, log: captureOpts.log, enabled },
252
+ () => runCaptureFn(captureOpts),
253
+ );
254
+ }
255
+
256
+ /**
257
+ * Run `spawn` with the full-suite lock held, from an **async** caller.
258
+ *
259
+ * Same contract as {@link withFullSuiteLockSync}, but it waits on the shipped
260
+ * promise-based `acquireLockWithWait` so it never blocks the event loop — the
261
+ * close-validation gate runner drives sibling gates on that loop, and a
262
+ * blocking wait there would stall them behind this one.
263
+ *
264
+ * @template T
265
+ * @param {Parameters<typeof withFullSuiteLockSync>[0] & {
266
+ * acquireWithWaitFn?: typeof acquireLockWithWait,
267
+ * }} opts
268
+ * @param {() => Promise<T>} spawn
269
+ * @returns {Promise<T>}
270
+ */
271
+ export async function withFullSuiteLockAsync(
272
+ {
273
+ cwd,
274
+ enabled = true,
275
+ log = () => {},
276
+ waitMs = DEFAULT_WAIT_MS,
277
+ pollMs = DEFAULT_POLL_MS,
278
+ staleMs = DEFAULT_STALE_MS,
279
+ fsImpl = fs,
280
+ acquireOnceFn = acquireSweepLock,
281
+ acquireWithWaitFn = acquireLockWithWait,
282
+ lockPath: explicitLockPath,
283
+ },
284
+ spawn,
285
+ ) {
286
+ const { lock, lockPath } = beginLock({
287
+ cwd,
288
+ enabled,
289
+ log,
290
+ staleMs,
291
+ fsImpl,
292
+ acquireOnceFn,
293
+ lockPath: explicitLockPath,
294
+ });
295
+ let held = lock;
296
+ if (held === null && lockPath !== null) {
297
+ const waited = await acquireWithWaitFn({
298
+ lockPath,
299
+ waitMs,
300
+ pollMs,
301
+ timeoutMs: staleMs,
302
+ fsImpl,
303
+ });
304
+ if (waited.acquired) held = waited;
305
+ }
306
+ try {
307
+ return await spawn();
308
+ } finally {
309
+ if (held?.acquired) held.release();
310
+ }
311
+ }