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