mandrel 2.41.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 (33) hide show
  1. package/.agents/agents/story-worker.md +24 -14
  2. package/.agents/docs/agentrc-reference.json +7 -2
  3. package/.agents/docs/configuration.md +5 -2
  4. package/.agents/schemas/agentrc.schema.json +17 -2
  5. package/.agents/schemas/validation-evidence.schema.json +3 -1
  6. package/.agents/scripts/acceptance-eval.js +68 -3
  7. package/.agents/scripts/coverage-capture.js +25 -8
  8. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
  9. package/.agents/scripts/lib/baselines/git-base.js +74 -38
  10. package/.agents/scripts/lib/close-validation/gates.js +153 -25
  11. package/.agents/scripts/lib/close-validation/process.js +30 -1
  12. package/.agents/scripts/lib/close-validation/runner.js +5 -0
  13. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
  14. package/.agents/scripts/lib/config/quality.js +36 -21
  15. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  16. package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
  17. package/.agents/scripts/lib/crap-baseline-join.js +11 -7
  18. package/.agents/scripts/lib/full-suite-lock.js +311 -0
  19. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  20. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
  21. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
  22. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  23. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
  24. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
  25. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
  26. package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
  27. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
  28. package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
  29. package/.agents/workflows/helpers/deliver-digest.md +31 -10
  30. package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
  31. package/.agents/workflows/helpers/deliver-story.md +23 -21
  32. package/docs/CHANGELOG.md +18 -0
  33. package/package.json +1 -1
@@ -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
+ }