mandrel 2.66.0 → 2.68.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/acceptance-critic.md +2 -2
- package/.agents/agents/story-worker.md +15 -11
- package/.agents/docs/agentrc-reference.json +5 -2
- package/.agents/docs/configuration.md +38 -2
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +2 -1
- package/.agents/rules/git-conventions-reference.md +5 -5
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/schemas/agentrc.schema.json +20 -2
- package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
- package/.agents/scripts/clean-temp.js +54 -0
- package/.agents/scripts/clean-worktrees.js +593 -0
- package/.agents/scripts/coverage-capture.js +65 -9
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/evidence-gate.js +106 -8
- package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
- package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
- package/.agents/scripts/lib/child-exec.js +39 -1
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/close-validation/gates.js +59 -19
- package/.agents/scripts/lib/close-validation/process.js +23 -24
- package/.agents/scripts/lib/close-validation/runner.js +71 -40
- package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
- package/.agents/scripts/lib/config/quality.js +7 -1
- package/.agents/scripts/lib/config/temp-paths.js +15 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
- package/.agents/scripts/lib/coverage-baseline.js +78 -5
- package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
- package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
- package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
- package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
- package/.agents/scripts/lib/coverage-capture.js +121 -81
- package/.agents/scripts/lib/full-suite-lock.js +49 -46
- package/.agents/scripts/lib/full-suite-queue.js +83 -8
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +4 -1
- package/.agents/scripts/lib/orchestration/code-review.js +15 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
- package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
- package/.agents/scripts/lib/process-group.js +1 -1
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/supervised-suite.js +247 -0
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/quality-preview.js +112 -14
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/scripts/stories-wave-tick.js +47 -0
- package/.agents/scripts/story-review-compute.js +207 -0
- package/.agents/scripts/update-coverage-baseline.js +15 -10
- package/.agents/scripts/update-crap-baseline.js +12 -2
- package/.agents/scripts/update-maintainability-baseline.js +12 -2
- package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
- package/.agents/workflows/clean-temp.md +67 -0
- package/.agents/workflows/clean-worktrees.md +63 -0
- package/.agents/workflows/git-deliver.md +1 -1
- package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
- package/.agents/workflows/helpers/code-review.md +7 -5
- package/.agents/workflows/helpers/deliver-digest.md +39 -36
- package/.agents/workflows/helpers/deliver-reference.md +115 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +2 -1
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/registry.js +125 -18
- package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
- package/package.json +1 -1
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getQuality } from '../config/quality.js';
|
|
7
|
-
import {
|
|
7
|
+
import { COVERAGE_TIMEOUT_EXIT_CODE } from '../coverage-capture.js';
|
|
8
|
+
import { LOCK_WAIT_EXPIRED_EXIT_CODE } from '../full-suite-lock.js';
|
|
8
9
|
import { gitSpawn } from '../git-utils.js';
|
|
9
10
|
import {
|
|
10
11
|
recordPass as defaultRecordPass,
|
|
@@ -16,13 +17,49 @@ import {
|
|
|
16
17
|
isFormatterEligible,
|
|
17
18
|
listChangedFilesForFormatGate,
|
|
18
19
|
} from './commands.js';
|
|
19
|
-
import { DEFAULT_GATES, partitionGates } from './gates.js';
|
|
20
|
+
import { DEFAULT_GATES, GATE_TIMEOUT_HINT, partitionGates } from './gates.js';
|
|
20
21
|
import { defaultGateRunner } from './process.js';
|
|
21
22
|
import { runProjectionAdvisories as defaultRunProjections } from './projections/advisories.js';
|
|
22
23
|
import { defaultGetHeadSha } from './projections/head-sha.js';
|
|
23
24
|
|
|
24
25
|
/** @typedef {import('./gates.js').Gate} Gate */
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Classify a non-zero gate exit and log it as what it is: `75` is an expired
|
|
29
|
+
* full-suite lock wait (nothing ran), `124` a killed suite (no verdict), and
|
|
30
|
+
* anything else a real failure with the gate's own hint. Returns the
|
|
31
|
+
* `failed[]` entry, carrying `outcome` so callers never re-derive it; its
|
|
32
|
+
* `gate.hint` is the one that fits the outcome (none when deferred), so a
|
|
33
|
+
* caller replaying it never prints the failing-tests hint for a non-failure.
|
|
34
|
+
*
|
|
35
|
+
* @param {{ gate: Gate, status: number, cwd: string, log: (m: string) => void }} args
|
|
36
|
+
* @returns {{ gate: Gate, status: number, cwd: string, outcome: 'deferred'|'timeout'|'failed' }}
|
|
37
|
+
*/
|
|
38
|
+
function reportGateExit({ gate, status, cwd, log }) {
|
|
39
|
+
if (status === LOCK_WAIT_EXPIRED_EXIT_CODE) {
|
|
40
|
+
log(
|
|
41
|
+
`[close-validation] ⏸ ${gate.name} deferred (exit ${status}) — the full-suite lock wait expired, so nothing ran in ${cwd}`,
|
|
42
|
+
);
|
|
43
|
+
const { hint: _failureHint, ...deferredGate } = gate;
|
|
44
|
+
return { gate: deferredGate, status, cwd, outcome: 'deferred' };
|
|
45
|
+
}
|
|
46
|
+
if (status === COVERAGE_TIMEOUT_EXIT_CODE) {
|
|
47
|
+
log(
|
|
48
|
+
`[close-validation] ⏱ ${gate.name} timed out (exit ${status}) in ${cwd}`,
|
|
49
|
+
);
|
|
50
|
+
log(`[close-validation] hint: ${GATE_TIMEOUT_HINT}`);
|
|
51
|
+
return {
|
|
52
|
+
gate: { ...gate, hint: GATE_TIMEOUT_HINT },
|
|
53
|
+
status,
|
|
54
|
+
cwd,
|
|
55
|
+
outcome: 'timeout',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
log(`[close-validation] ✖ ${gate.name} failed (exit ${status}) in ${cwd}`);
|
|
59
|
+
if (gate.hint) log(`[close-validation] hint: ${gate.hint}`);
|
|
60
|
+
return { gate, status, cwd, outcome: 'failed' };
|
|
61
|
+
}
|
|
62
|
+
|
|
26
63
|
function applyChangedFileScope({ gate, spawnCwd, log }) {
|
|
27
64
|
// A skip decided when the gate list was built wins over everything.
|
|
28
65
|
if (gate.skip) {
|
|
@@ -95,10 +132,10 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
|
|
|
95
132
|
* getTreeFingerprint?: (cwd: string) => string|null,
|
|
96
133
|
* recordPass?: typeof defaultRecordPass,
|
|
97
134
|
* shouldSkip?: typeof defaultShouldSkip,
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
135
|
+
* }} opts An expired full-suite lock wait spawns nothing (here or in any gate
|
|
136
|
+
* child) and reports `LOCK_WAIT_EXPIRED_EXIT_CODE`.
|
|
137
|
+
* @returns {{ ok: boolean, failed: Array<{ gate: Gate, status: number, cwd: string, outcome: 'deferred'|'timeout'|'failed' }>, skipped: Array<{ gate: Gate, reason: string }> }}
|
|
138
|
+
* `outcome` classifies the exit: `deferred` (75), `timeout` (124), else `failed`.
|
|
102
139
|
*/
|
|
103
140
|
export async function runCloseValidation({
|
|
104
141
|
cwd,
|
|
@@ -120,10 +157,9 @@ export async function runCloseValidation({
|
|
|
120
157
|
defaultTreeFingerprint(resolvedCwd, gitSpawn),
|
|
121
158
|
recordPass = defaultRecordPass,
|
|
122
159
|
shouldSkip = defaultShouldSkip,
|
|
123
|
-
deferOnLockExpiry = false,
|
|
124
160
|
} = {}) {
|
|
125
161
|
const failed = [];
|
|
126
|
-
const lockOpts = fullSuiteLockOptions({ config
|
|
162
|
+
const lockOpts = fullSuiteLockOptions({ config });
|
|
127
163
|
const skipped = [];
|
|
128
164
|
const evidenceActive = useEvidence && storyId != null && standalone;
|
|
129
165
|
const evidenceStoreOpts = { cwd, standalone };
|
|
@@ -228,7 +264,7 @@ export async function runCloseValidation({
|
|
|
228
264
|
execution = applyChangedFileScope({ gate, spawnCwd, log });
|
|
229
265
|
} catch (err) {
|
|
230
266
|
if (!firstIndepFailure) {
|
|
231
|
-
firstIndepFailure = { gate, status: 1
|
|
267
|
+
firstIndepFailure = { gate, status: 1 };
|
|
232
268
|
log(
|
|
233
269
|
`[close-validation] ✖ ${gate.name} failed to resolve changed-file scope: ${err?.message ?? err}`,
|
|
234
270
|
);
|
|
@@ -271,7 +307,7 @@ export async function runCloseValidation({
|
|
|
271
307
|
}
|
|
272
308
|
if (result.status !== 0) {
|
|
273
309
|
if (!firstIndepFailure) {
|
|
274
|
-
firstIndepFailure = { gate, status: result.status
|
|
310
|
+
firstIndepFailure = { gate, status: result.status };
|
|
275
311
|
ac.abort();
|
|
276
312
|
}
|
|
277
313
|
return;
|
|
@@ -287,13 +323,8 @@ export async function runCloseValidation({
|
|
|
287
323
|
await Promise.all(indepTasks);
|
|
288
324
|
|
|
289
325
|
if (firstIndepFailure) {
|
|
290
|
-
|
|
291
|
-
log
|
|
292
|
-
`[close-validation] ✖ ${firstIndepFailure.gate.name} failed (exit ${firstIndepFailure.status}) in ${spawnCwd}`,
|
|
293
|
-
);
|
|
294
|
-
if (firstIndepFailure.gate.hint) {
|
|
295
|
-
log(`[close-validation] hint: ${firstIndepFailure.gate.hint}`);
|
|
296
|
-
}
|
|
326
|
+
const { gate, status } = firstIndepFailure;
|
|
327
|
+
failed.push(reportGateExit({ gate, status, cwd: spawnCwd, log }));
|
|
297
328
|
return { ok: false, failed, skipped };
|
|
298
329
|
}
|
|
299
330
|
|
|
@@ -324,29 +355,36 @@ export async function runCloseValidation({
|
|
|
324
355
|
}
|
|
325
356
|
|
|
326
357
|
/**
|
|
327
|
-
* The `fullSuiteLock` gate shares coverage capture's timeout
|
|
328
|
-
* fails instead of holding the host
|
|
329
|
-
*
|
|
358
|
+
* The `fullSuiteLock` gate shares coverage capture's timeout
|
|
359
|
+
* (`coverage.timeoutMs`) so a hung suite fails instead of holding the host
|
|
360
|
+
* lock; the same figure bounds its lock wait (`resolveFullSuiteLockBudget`).
|
|
330
361
|
*
|
|
331
|
-
* @param {{ config: object|null
|
|
362
|
+
* @param {{ config: object|null }} args
|
|
332
363
|
* @returns {{ forGate: (gate: object) => object }}
|
|
333
364
|
*/
|
|
334
|
-
function fullSuiteLockOptions({ config
|
|
365
|
+
function fullSuiteLockOptions({ config }) {
|
|
335
366
|
const timeoutMs = getQuality(config).coverage?.timeoutMs;
|
|
336
|
-
const deferEnv = deferOnLockExpiry
|
|
337
|
-
? { [FULL_SUITE_LOCK_EXPIRY_ENV]: 'defer' }
|
|
338
|
-
: null;
|
|
339
367
|
return {
|
|
340
368
|
forGate(gate) {
|
|
341
|
-
const env = deferEnv ? { ...gate.env, ...deferEnv } : gate.env;
|
|
342
369
|
return {
|
|
343
|
-
...(env ? { env } : {}),
|
|
344
|
-
...(gate.fullSuiteLock ?
|
|
370
|
+
...(gate.env ? { env: gate.env } : {}),
|
|
371
|
+
...(gate.fullSuiteLock ? fullSuiteGateOptions(gate, timeoutMs) : {}),
|
|
345
372
|
};
|
|
346
373
|
},
|
|
347
374
|
};
|
|
348
375
|
}
|
|
349
376
|
|
|
377
|
+
/**
|
|
378
|
+
* A full-suite gate's kill bound, and the command its lock-expiry line names.
|
|
379
|
+
*
|
|
380
|
+
* @param {{ cmd: string, args?: string[] }} gate
|
|
381
|
+
* @param {number} timeoutMs
|
|
382
|
+
*/
|
|
383
|
+
function fullSuiteGateOptions(gate, timeoutMs) {
|
|
384
|
+
const rerunCommand = [gate.cmd, ...(gate.args ?? [])].join(' ');
|
|
385
|
+
return { timeoutMs, lockOptions: { rerunCommand } };
|
|
386
|
+
}
|
|
387
|
+
|
|
350
388
|
/**
|
|
351
389
|
* Mutates the caller's `failed` / `skipped` accumulators.
|
|
352
390
|
*
|
|
@@ -368,21 +406,18 @@ async function runSerialGates(
|
|
|
368
406
|
dispatchGate,
|
|
369
407
|
},
|
|
370
408
|
) {
|
|
371
|
-
const failGate = (gate, status
|
|
372
|
-
failed.push({ gate, status, cwd: spawnCwd });
|
|
373
|
-
log(message);
|
|
374
|
-
if (gate.hint) log(`[close-validation] hint: ${gate.hint}`);
|
|
409
|
+
const failGate = (gate, status) => {
|
|
410
|
+
failed.push(reportGateExit({ gate, status, cwd: spawnCwd, log }));
|
|
375
411
|
};
|
|
376
412
|
for (const gate of serial) {
|
|
377
413
|
let execution;
|
|
378
414
|
try {
|
|
379
415
|
execution = applyChangedFileScope({ gate, spawnCwd, log });
|
|
380
416
|
} catch (err) {
|
|
381
|
-
|
|
382
|
-
gate,
|
|
383
|
-
1,
|
|
417
|
+
log(
|
|
384
418
|
`[close-validation] ✖ ${gate.name} failed to resolve changed-file scope: ${err?.message ?? err}`,
|
|
385
419
|
);
|
|
420
|
+
failGate(gate, 1);
|
|
386
421
|
return;
|
|
387
422
|
}
|
|
388
423
|
if (execution.skip) {
|
|
@@ -414,11 +449,7 @@ async function runSerialGates(
|
|
|
414
449
|
configHash,
|
|
415
450
|
);
|
|
416
451
|
if (result.status !== 0) {
|
|
417
|
-
failGate(
|
|
418
|
-
gate,
|
|
419
|
-
result.status,
|
|
420
|
-
`[close-validation] ✖ ${gate.name} failed (exit ${result.status}) in ${spawnCwd}`,
|
|
421
|
-
);
|
|
452
|
+
failGate(gate, result.status);
|
|
422
453
|
return;
|
|
423
454
|
}
|
|
424
455
|
log(`[close-validation] ✓ ${gate.name}`);
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
import { COVERAGE_GATE_DEFAULTS } from '../quality.js';
|
|
4
4
|
import { gateBase, SAFE_STRING } from './shared.js';
|
|
5
5
|
|
|
6
|
+
/** Schema bounds on `coverage.timeoutMs`: one minute to two hours. */
|
|
7
|
+
export const COVERAGE_TIMEOUT_BOUNDS = Object.freeze({
|
|
8
|
+
min: 60_000,
|
|
9
|
+
max: 7_200_000,
|
|
10
|
+
});
|
|
11
|
+
|
|
6
12
|
export const COVERAGE_GATE = {
|
|
7
13
|
type: 'object',
|
|
8
14
|
description:
|
|
@@ -21,6 +27,21 @@ export const COVERAGE_GATE = {
|
|
|
21
27
|
'Repo-relative path to the Istanbul `coverage-final.json` the capture step writes and the gate reads.',
|
|
22
28
|
default: COVERAGE_GATE_DEFAULTS.coveragePath,
|
|
23
29
|
},
|
|
30
|
+
captureScope: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
enum: ['full', 'affected'],
|
|
33
|
+
description:
|
|
34
|
+
'What coverage-capture runs. `full` (default) runs `npm run test:coverage`. `affected` runs the consumer-owned `npm run test:coverage:affected` with the base ref in `MANDREL_COVERAGE_BASE_REF`, merges its rows over the prior artifact and stamps it `affected`; baseline rows the scoped run did not measure are treated as unmeasured, never removed. Falls back to `full` with a warning when the script is absent. Meant for consumers whose CI already enforces coverage on the full suite.',
|
|
35
|
+
default: COVERAGE_GATE_DEFAULTS.captureScope,
|
|
36
|
+
},
|
|
37
|
+
timeoutMs: {
|
|
38
|
+
type: 'integer',
|
|
39
|
+
minimum: COVERAGE_TIMEOUT_BOUNDS.min,
|
|
40
|
+
maximum: COVERAGE_TIMEOUT_BOUNDS.max,
|
|
41
|
+
description:
|
|
42
|
+
'Kill bound (ms) for one full-suite run — the coverage capture, the close-validation full-suite gate and the full-suite lock-wait budget all read it. The clock starts at spawn, never while queued on the host lock; a suite that signals `MANDREL_SUITE_READY_FILE` gets a fresh bound for its test phase, so worst-case wall is lock wait + 2 × timeoutMs. On expiry the run exits 124 so callers can tell a hang from a failure.',
|
|
43
|
+
default: COVERAGE_GATE_DEFAULTS.timeoutMs,
|
|
44
|
+
},
|
|
24
45
|
},
|
|
25
46
|
additionalProperties: false,
|
|
26
47
|
};
|
|
@@ -66,6 +66,8 @@ export const COVERAGE_GATE_DEFAULTS = Object.freeze({
|
|
|
66
66
|
tolerance: Object.freeze({ kind: 'absolute', value: 0 }),
|
|
67
67
|
floors: DEFAULT_COVERAGE_FLOORS,
|
|
68
68
|
coveragePath: 'coverage/coverage-final.json',
|
|
69
|
+
// `affected` runs the consumer's `test:coverage:affected` instead.
|
|
70
|
+
captureScope: 'full',
|
|
69
71
|
// On expiry `runCapture` exits 124 so callers can tell a hang from a failure.
|
|
70
72
|
timeoutMs: 600_000,
|
|
71
73
|
});
|
|
@@ -101,6 +103,8 @@ const COVERAGE_GATE_KEYS = new Set([
|
|
|
101
103
|
'tolerance',
|
|
102
104
|
'floors',
|
|
103
105
|
'coveragePath',
|
|
106
|
+
'captureScope',
|
|
107
|
+
'timeoutMs',
|
|
104
108
|
]);
|
|
105
109
|
|
|
106
110
|
const MI_GATE_KEYS = new Set([
|
|
@@ -265,6 +269,7 @@ function resolveCoverageGate(userBlock) {
|
|
|
265
269
|
enabled: defaults.enabled,
|
|
266
270
|
baselinePath: defaults.baselinePath,
|
|
267
271
|
coveragePath: defaults.coveragePath,
|
|
272
|
+
captureScope: defaults.captureScope,
|
|
268
273
|
tolerance: toleranceScalar(defaults.tolerance, 0),
|
|
269
274
|
timeoutMs: defaults.timeoutMs,
|
|
270
275
|
};
|
|
@@ -274,11 +279,12 @@ function resolveCoverageGate(userBlock) {
|
|
|
274
279
|
enabled: userBlock.enabled ?? defaults.enabled,
|
|
275
280
|
baselinePath: userBlock.baselinePath ?? defaults.baselinePath,
|
|
276
281
|
coveragePath: userBlock.coveragePath ?? defaults.coveragePath,
|
|
282
|
+
captureScope: userBlock.captureScope ?? defaults.captureScope,
|
|
277
283
|
tolerance: toleranceScalar(
|
|
278
284
|
userBlock.tolerance,
|
|
279
285
|
toleranceScalar(defaults.tolerance, 0),
|
|
280
286
|
),
|
|
281
|
-
timeoutMs: defaults.timeoutMs,
|
|
287
|
+
timeoutMs: userBlock.timeoutMs ?? defaults.timeoutMs,
|
|
282
288
|
};
|
|
283
289
|
}
|
|
284
290
|
|
|
@@ -214,6 +214,21 @@ export function storyTerminalEnvelopePath(sid, config) {
|
|
|
214
214
|
return path.join(orchestrationLogDir(config), storyTerminalEnvelopeName(sid));
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The worker's held Story-scope review, beside the terminal envelope so the
|
|
219
|
+
* close that adopts it reads it from the same anchored directory.
|
|
220
|
+
*
|
|
221
|
+
* @param {number} sid
|
|
222
|
+
* @param {object} [config]
|
|
223
|
+
* @returns {string}
|
|
224
|
+
*/
|
|
225
|
+
export function storyReviewDepositPath(sid, config) {
|
|
226
|
+
return path.join(
|
|
227
|
+
orchestrationLogDir(config),
|
|
228
|
+
`story-review-${storyId(sid)}.json`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
217
232
|
const runId = (id) => {
|
|
218
233
|
if (!Number.isInteger(id) || id <= 0) {
|
|
219
234
|
throw new Error(`[temp-paths] runId must be a positive integer; got ${id}`);
|
|
@@ -25,7 +25,7 @@ const EXECUTION_SCHEMA = {
|
|
|
25
25
|
fullSuiteLock: {
|
|
26
26
|
type: 'boolean',
|
|
27
27
|
description:
|
|
28
|
-
'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.
|
|
28
|
+
'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. The lock queues, it never overlaps: a wait that expires with a live holder spawns nothing and exits 75 (resumable), naming the holder. A dead or non-heartbeating holder is taken over, and a broken lockfile proceeds unserialized, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable.',
|
|
29
29
|
default: true,
|
|
30
30
|
},
|
|
31
31
|
},
|
|
@@ -283,8 +283,9 @@ const TEMP_RETENTION_SCHEMA = {
|
|
|
283
283
|
description:
|
|
284
284
|
'Story #4794. Auto-purge of spent temp artifacts once their Story lands. ' +
|
|
285
285
|
'Classification is an allowlist: only the declared classes below are ever ' +
|
|
286
|
-
'deleted, so
|
|
287
|
-
'size and left alone
|
|
286
|
+
'deleted, so unrecognized files under tempRoot are reported with their ' +
|
|
287
|
+
'size and left alone (`/clean-temp` is the operator path for them). ' +
|
|
288
|
+
'signals.ndjson is never purged by any path.',
|
|
288
289
|
properties: {
|
|
289
290
|
enabled: {
|
|
290
291
|
type: 'boolean',
|
|
@@ -326,6 +327,14 @@ const TEMP_RETENTION_SCHEMA = {
|
|
|
326
327
|
'Age-floored only; the current run is always excluded.',
|
|
327
328
|
default: true,
|
|
328
329
|
},
|
|
330
|
+
scratch: {
|
|
331
|
+
type: 'boolean',
|
|
332
|
+
description:
|
|
333
|
+
'<tempRoot>/scratch/ — agent-authored scratch. ' +
|
|
334
|
+
'`scratch/story-<id>/` is purged when that Story lands; any ' +
|
|
335
|
+
'other `scratch/` entry is age-floored.',
|
|
336
|
+
default: true,
|
|
337
|
+
},
|
|
329
338
|
},
|
|
330
339
|
additionalProperties: false,
|
|
331
340
|
},
|
|
@@ -7,6 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import picomatch from 'picomatch';
|
|
9
9
|
import { write, writeFile } from './baselines/writer.js';
|
|
10
|
+
import { captureStampPath } from './coverage-capture.js';
|
|
10
11
|
|
|
11
12
|
const COVERAGE_FINAL_PATH = 'coverage/coverage-final.json';
|
|
12
13
|
export const COVERAGE_BASELINE_PATH = 'baselines/coverage.json';
|
|
@@ -202,20 +203,64 @@ export function axisToleranceFor(
|
|
|
202
203
|
return Math.max(baseTolerance, eventResolution * NOISE_EVENT_HEADROOM);
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
/** The capture stamp's scope; `full` when absent, unreadable or unscoped. */
|
|
207
|
+
export function readArtifactCaptureScope(
|
|
208
|
+
cwd,
|
|
209
|
+
coveragePath = COVERAGE_FINAL_PATH,
|
|
210
|
+
fsImpl = fs,
|
|
211
|
+
) {
|
|
212
|
+
try {
|
|
213
|
+
const stamp = JSON.parse(
|
|
214
|
+
fsImpl.readFileSync(captureStampPath(cwd, coveragePath), 'utf8'),
|
|
215
|
+
);
|
|
216
|
+
return typeof stamp?.scope === 'string' ? stamp.scope : 'full';
|
|
217
|
+
} catch {
|
|
218
|
+
return 'full';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Narrow a refresh scope (`null` = full) to measured files, keeping any file
|
|
224
|
+
* `mustMeasure` names so its missing row fails the refresh.
|
|
225
|
+
*/
|
|
226
|
+
function narrowScopeToMeasured(scopeFiles, measuredFiles, mustMeasure) {
|
|
227
|
+
if (scopeFiles === null) return [...measuredFiles];
|
|
228
|
+
const measured = new Set(measuredFiles);
|
|
229
|
+
return scopeFiles.filter((file) => measured.has(file) || mustMeasure(file));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Baseline rows absent from `current`. From a full artifact they are removed
|
|
234
|
+
* files. From an `affected` artifact they are unmeasured and not reported,
|
|
235
|
+
* except a changed file: that one fails closed as new, as does a changed
|
|
236
|
+
* file with no baseline row the scoped run skipped.
|
|
237
|
+
*/
|
|
238
|
+
function classifyAbsent(current, baseline, { artifactScope, changedFiles }) {
|
|
239
|
+
const absent = Object.keys(baseline).filter((f) => current[f] === undefined);
|
|
240
|
+
if (artifactScope !== 'affected') {
|
|
241
|
+
return { removedFiles: absent.map((file) => ({ file })), unmeasured: [] };
|
|
242
|
+
}
|
|
243
|
+
const unmeasured = (changedFiles ?? [])
|
|
244
|
+
.filter((file) => current[file] === undefined)
|
|
245
|
+
.map((file) => ({ file, current: null, reason: 'unmeasured' }));
|
|
246
|
+
return { removedFiles: [], unmeasured };
|
|
247
|
+
}
|
|
248
|
+
|
|
205
249
|
/**
|
|
206
250
|
* Classify files: `regressions` (an axis dropped beyond tolerance) and
|
|
207
251
|
* `newFiles` (else untested code lands at 0%) fail the CLI; `removedFiles`
|
|
208
|
-
* and `improvements` are reported only.
|
|
252
|
+
* and `improvements` are reported only. `opts.artifactScope: 'affected'`
|
|
253
|
+
* with the in-scope `opts.changedFiles` reads absent rows as unmeasured.
|
|
209
254
|
*/
|
|
210
255
|
export function compareScores(
|
|
211
256
|
current,
|
|
212
257
|
baseline,
|
|
213
258
|
tolerance = COVERAGE_TOLERANCE,
|
|
259
|
+
opts = {},
|
|
214
260
|
) {
|
|
215
261
|
const regressions = [];
|
|
216
262
|
const newFiles = [];
|
|
217
263
|
const improvements = [];
|
|
218
|
-
const removedFiles = [];
|
|
219
264
|
|
|
220
265
|
for (const [file, scores] of Object.entries(current)) {
|
|
221
266
|
const base = baseline[file];
|
|
@@ -252,9 +297,37 @@ export function compareScores(
|
|
|
252
297
|
improvements.push({ file });
|
|
253
298
|
}
|
|
254
299
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
300
|
+
const { removedFiles, unmeasured } = classifyAbsent(current, baseline, opts);
|
|
301
|
+
newFiles.push(...unmeasured);
|
|
258
302
|
|
|
259
303
|
return { regressions, newFiles, improvements, removedFiles };
|
|
260
304
|
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* `refreshBaseline` scope options. Under an `affected` artifact the scope is
|
|
308
|
+
* narrowed to measured files, so the scope merge preserves a row the scoped
|
|
309
|
+
* run skipped instead of deleting it. A changed file `inCoverageScope` names
|
|
310
|
+
* stays in scope and must produce a row: skipping it fails closed.
|
|
311
|
+
*/
|
|
312
|
+
export async function resolveCoverageRefreshScope({
|
|
313
|
+
cwd,
|
|
314
|
+
fullScope,
|
|
315
|
+
diffScopeRef,
|
|
316
|
+
readCaptureScope,
|
|
317
|
+
listMeasured,
|
|
318
|
+
inCoverageScope,
|
|
319
|
+
deriveDiffFiles,
|
|
320
|
+
}) {
|
|
321
|
+
if (readCaptureScope(cwd) !== 'affected') {
|
|
322
|
+
if (fullScope) return { fullScope: true };
|
|
323
|
+
return diffScopeRef ? { baseRef: diffScopeRef } : {};
|
|
324
|
+
}
|
|
325
|
+
const diff = fullScope
|
|
326
|
+
? null
|
|
327
|
+
: await deriveDiffFiles(diffScopeRef ?? 'origin/main');
|
|
328
|
+
return {
|
|
329
|
+
scopeFiles: narrowScopeToMeasured(diff, listMeasured(cwd), inCoverageScope),
|
|
330
|
+
requireRowsForScopeFiles: true,
|
|
331
|
+
requiredScopeFilePredicate: inCoverageScope,
|
|
332
|
+
};
|
|
333
|
+
}
|