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
|
@@ -33,7 +33,7 @@ import { deriveCloseNote } from './close-note.js';
|
|
|
33
33
|
import { runAutoMergePhase } from './phases/auto-merge.js';
|
|
34
34
|
import { runBaseSyncPhase } from './phases/base-sync.js';
|
|
35
35
|
import { runCloseValidationPhase } from './phases/close-validation.js';
|
|
36
|
-
import { parsePrNumber
|
|
36
|
+
import { parsePrNumber } from './phases/code-review.js';
|
|
37
37
|
import { runConfirmMergePhase } from './phases/confirm-merge.js';
|
|
38
38
|
import { runGraphqlPreflight } from './phases/graphql-preflight.js';
|
|
39
39
|
import { lockWaitPending } from './phases/lock-wait-pending.js';
|
|
@@ -45,11 +45,17 @@ import { handleCriticalReviewBlock } from './phases/review-block.js';
|
|
|
45
45
|
import { handleOverriddenReviewBlock } from './phases/review-override.js';
|
|
46
46
|
import { reapWorktreePhase } from './phases/worktree-reap.js';
|
|
47
47
|
import { runWrongTreeGuardPhase } from './phases/wrong-tree-guard.js';
|
|
48
|
+
import {
|
|
49
|
+
discardHeldReview,
|
|
50
|
+
reviewAfterPrOpen,
|
|
51
|
+
startHeldReview,
|
|
52
|
+
} from './review-overlap.js';
|
|
48
53
|
|
|
49
54
|
const progress = Logger.createProgress('single-story-close', { stderr: true });
|
|
50
55
|
|
|
51
56
|
/**
|
|
52
57
|
* Wall-clock seconds per named phase; each transition logs the phase it ends.
|
|
58
|
+
* Overlapping work `pause`s the clock and `record`s its own time.
|
|
53
59
|
*
|
|
54
60
|
* @param {() => number} [nowMs]
|
|
55
61
|
*/
|
|
@@ -57,11 +63,14 @@ function createPhaseTimer(nowMs = Date.now) {
|
|
|
57
63
|
const durations = {};
|
|
58
64
|
let current = null;
|
|
59
65
|
let since = 0;
|
|
66
|
+
const record = (phase, ms) => {
|
|
67
|
+
const seconds = Math.round(Math.max(0, ms) / 100) / 10;
|
|
68
|
+
durations[phase] = (durations[phase] ?? 0) + seconds;
|
|
69
|
+
progress('TIMING', `⏱ ${phase}: ${seconds}s`);
|
|
70
|
+
};
|
|
60
71
|
const end = () => {
|
|
61
72
|
if (current === null) return;
|
|
62
|
-
|
|
63
|
-
durations[current] = (durations[current] ?? 0) + seconds;
|
|
64
|
-
progress('TIMING', `⏱ ${current}: ${seconds}s`);
|
|
73
|
+
record(current, nowMs() - since);
|
|
65
74
|
current = null;
|
|
66
75
|
};
|
|
67
76
|
return {
|
|
@@ -71,6 +80,8 @@ function createPhaseTimer(nowMs = Date.now) {
|
|
|
71
80
|
current = phase;
|
|
72
81
|
since = nowMs();
|
|
73
82
|
},
|
|
83
|
+
record,
|
|
84
|
+
pause: end,
|
|
74
85
|
finish() {
|
|
75
86
|
end();
|
|
76
87
|
return Object.keys(durations).length > 0 ? { ...durations } : null;
|
|
@@ -257,6 +268,7 @@ function resolveWorktreePath({ cwd, config, storyId }) {
|
|
|
257
268
|
* @returns {Promise<{
|
|
258
269
|
* validationGates: Record<string, string>|null,
|
|
259
270
|
* lockWait: { waitedSeconds: number, expired: boolean }|null,
|
|
271
|
+
* suiteTimings: object|null,
|
|
260
272
|
* pending: boolean,
|
|
261
273
|
* }>} `validationGates` is null when skipped; `pending` means a full-suite
|
|
262
274
|
* lock wait expired and the gates deferred.
|
|
@@ -293,7 +305,12 @@ async function runPrePushPhases(ctx, deps) {
|
|
|
293
305
|
}
|
|
294
306
|
if (options.skipValidation) {
|
|
295
307
|
progress('VALIDATE', '⏭ Skipped (--skip-validation).');
|
|
296
|
-
return {
|
|
308
|
+
return {
|
|
309
|
+
validationGates: null,
|
|
310
|
+
lockWait: null,
|
|
311
|
+
suiteTimings: null,
|
|
312
|
+
pending: false,
|
|
313
|
+
};
|
|
297
314
|
}
|
|
298
315
|
setPhase('close-validation');
|
|
299
316
|
let validation;
|
|
@@ -308,8 +325,22 @@ async function runPrePushPhases(ctx, deps) {
|
|
|
308
325
|
progress,
|
|
309
326
|
runCloseValidation,
|
|
310
327
|
buildDefaultGates,
|
|
328
|
+
onPreGateStepsDone: () => {
|
|
329
|
+
ctx.heldReview = startHeldReview({
|
|
330
|
+
cwd,
|
|
331
|
+
storyId,
|
|
332
|
+
storyBranch,
|
|
333
|
+
baseBranch,
|
|
334
|
+
provider: deps.provider,
|
|
335
|
+
runCodeReviewFn: deps.runCodeReview,
|
|
336
|
+
gitSpawnFn: gitSpawn,
|
|
337
|
+
progress,
|
|
338
|
+
config: deps.config,
|
|
339
|
+
});
|
|
340
|
+
},
|
|
311
341
|
});
|
|
312
342
|
} catch (err) {
|
|
343
|
+
discardHeldReview(ctx.heldReview, 'validation failed', progress);
|
|
313
344
|
// The gate that died is all this run observed; the envelope claims no more.
|
|
314
345
|
if (typeof err?.closeGate === 'string') {
|
|
315
346
|
ctx.setObservedGates({ [err.closeGate]: 'failed' });
|
|
@@ -321,6 +352,7 @@ async function runPrePushPhases(ctx, deps) {
|
|
|
321
352
|
return {
|
|
322
353
|
validationGates: gates,
|
|
323
354
|
lockWait: validation?.lockWait ?? null,
|
|
355
|
+
suiteTimings: validation?.suiteTimings ?? null,
|
|
324
356
|
pending: validation?.pending === true,
|
|
325
357
|
};
|
|
326
358
|
}
|
|
@@ -396,10 +428,11 @@ async function openAndReviewPr(ctx, deps) {
|
|
|
396
428
|
const prNumber = parsePrNumber(prUrl);
|
|
397
429
|
// Already merged (landed between invocations): skip review and arm; confirm observes it.
|
|
398
430
|
if (alreadyMerged) {
|
|
431
|
+
discardHeldReview(ctx.heldReview, 'PR already merged', progress);
|
|
399
432
|
return { prUrl, prNumber, alreadyMerged: true };
|
|
400
433
|
}
|
|
401
|
-
|
|
402
|
-
|
|
434
|
+
const reviewOutcome = await reviewAfterPrOpen({
|
|
435
|
+
held: ctx.heldReview,
|
|
403
436
|
cwd,
|
|
404
437
|
storyId,
|
|
405
438
|
storyBranch,
|
|
@@ -410,6 +443,9 @@ async function openAndReviewPr(ctx, deps) {
|
|
|
410
443
|
runCodeReviewFn: deps.runCodeReview,
|
|
411
444
|
gitSpawnFn: gitSpawn,
|
|
412
445
|
progress,
|
|
446
|
+
setPhase: ctx.setPhase,
|
|
447
|
+
pauseTimer: ctx.phaseTimer.pause,
|
|
448
|
+
recordDuration: ctx.phaseTimer.record,
|
|
413
449
|
});
|
|
414
450
|
const reviewOverride = reviewOutcome.halted
|
|
415
451
|
? await resolveReviewHalt(ctx, deps, { prUrl, prNumber, reviewOutcome })
|
|
@@ -690,6 +726,7 @@ async function finishWithMergeWait(prCtx, deps) {
|
|
|
690
726
|
autoMergeEnabled: prCtx.autoMergeEnabled,
|
|
691
727
|
gates: prCtx.gates,
|
|
692
728
|
lockWait: prCtx.lockWait,
|
|
729
|
+
suiteTimings: prCtx.suiteTimings,
|
|
693
730
|
elapsedSeconds: elapsedSecondsSince(prCtx.startedAtMs),
|
|
694
731
|
});
|
|
695
732
|
const result = closeResult({
|
|
@@ -763,6 +800,7 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
|
|
|
763
800
|
},
|
|
764
801
|
gates: prCtx.gates,
|
|
765
802
|
lockWait: prCtx.lockWait,
|
|
803
|
+
suiteTimings: prCtx.suiteTimings,
|
|
766
804
|
nextCommand: NEXT_COMMANDS.confirmMerge(prCtx.storyId),
|
|
767
805
|
elapsedSeconds: elapsedSecondsSince(prCtx.startedAtMs),
|
|
768
806
|
});
|
|
@@ -891,6 +929,7 @@ async function prePushPhase(ctx, deps) {
|
|
|
891
929
|
deps,
|
|
892
930
|
);
|
|
893
931
|
if (!ctx.prePush.pending) return null;
|
|
932
|
+
discardHeldReview(ctx.heldReview, 'validation pending', progress);
|
|
894
933
|
return await finishDeferred(ctx.prePush.lockWait, {
|
|
895
934
|
storyId: ctx.storyId,
|
|
896
935
|
storyBranch: ctx.storyBranch,
|
|
@@ -991,6 +1030,7 @@ async function finishPhase(ctx, deps) {
|
|
|
991
1030
|
phaseTimer: ctx.phaseTimer,
|
|
992
1031
|
workerTokens: ctx.workerTokens,
|
|
993
1032
|
lockWait: ctx.prePush.lockWait,
|
|
1033
|
+
suiteTimings: ctx.prePush.suiteTimings,
|
|
994
1034
|
gates: closeEnvelopeGates(
|
|
995
1035
|
options,
|
|
996
1036
|
ctx.prePush.validationGates,
|
|
@@ -114,8 +114,9 @@ function compact(obj) {
|
|
|
114
114
|
* @param {string|null} [args.nextCommand]
|
|
115
115
|
* @param {number} args.elapsedSeconds
|
|
116
116
|
* @param {object|null} [args.waitBudget]
|
|
117
|
-
* @param {{ waitedSeconds: number, expired: boolean }|null} [args.lockWait]
|
|
117
|
+
* @param {{ waitedSeconds: number, expired: boolean, holder?: object }|null} [args.lockWait]
|
|
118
118
|
* Full-suite lock wait; `waitBudget` is merge-wait only.
|
|
119
|
+
* @param {{ lockWaitMs: number, hostWaitMs: number|null, testRunMs: number }|null} [args.suiteTimings]
|
|
119
120
|
* @param {Record<string, number>|null} [args.phaseDurations] Seconds per phase.
|
|
120
121
|
* @param {string} [args.timestamp]
|
|
121
122
|
* @param {{ schema: object|null, error: string|null }} [args.schemaSource]
|
|
@@ -138,6 +139,7 @@ export function buildTerminalEnvelope({
|
|
|
138
139
|
elapsedSeconds = 0,
|
|
139
140
|
waitBudget,
|
|
140
141
|
lockWait,
|
|
142
|
+
suiteTimings,
|
|
141
143
|
phaseDurations,
|
|
142
144
|
timestamp = new Date().toISOString(),
|
|
143
145
|
schemaSource,
|
|
@@ -160,6 +162,7 @@ export function buildTerminalEnvelope({
|
|
|
160
162
|
elapsedSeconds: Math.max(0, Number(elapsedSeconds) || 0),
|
|
161
163
|
waitBudget: waitBudget ?? null,
|
|
162
164
|
lockWait: lockWait ?? null,
|
|
165
|
+
suiteTimings: suiteTimings ?? null,
|
|
163
166
|
phaseDurations,
|
|
164
167
|
timestamp,
|
|
165
168
|
});
|
|
@@ -324,35 +327,24 @@ export function emitTerminalEnvelope(
|
|
|
324
327
|
}
|
|
325
328
|
|
|
326
329
|
/**
|
|
327
|
-
* Map a `runConfirmMergePhase` outcome onto the terminal envelope
|
|
330
|
+
* Map a `runConfirmMergePhase` outcome onto the terminal envelope; the other
|
|
331
|
+
* fields pass through.
|
|
328
332
|
*
|
|
329
333
|
* @returns {object} A validated `story-deliver-terminal` envelope.
|
|
330
334
|
*/
|
|
331
335
|
export function terminalFromWaitOutcome({
|
|
332
336
|
waitOutcome,
|
|
333
|
-
storyId,
|
|
334
|
-
storyBranch,
|
|
335
|
-
baseBranch,
|
|
336
337
|
prNumber,
|
|
337
338
|
prUrl,
|
|
338
339
|
autoMergeEnabled,
|
|
339
|
-
|
|
340
|
-
lockWait,
|
|
341
|
-
elapsedSeconds,
|
|
340
|
+
...common
|
|
342
341
|
}) {
|
|
342
|
+
const { storyId } = common;
|
|
343
343
|
const prBase = {
|
|
344
344
|
number: prNumber,
|
|
345
345
|
url: prUrl ?? null,
|
|
346
346
|
autoMergeEnabled: Boolean(autoMergeEnabled),
|
|
347
347
|
};
|
|
348
|
-
const common = {
|
|
349
|
-
storyId,
|
|
350
|
-
storyBranch,
|
|
351
|
-
baseBranch,
|
|
352
|
-
gates,
|
|
353
|
-
lockWait,
|
|
354
|
-
elapsedSeconds,
|
|
355
|
-
};
|
|
356
348
|
|
|
357
349
|
if (waitOutcome.terminal === 'landed') {
|
|
358
350
|
return buildTerminalEnvelope({
|
|
@@ -27,7 +27,7 @@ export function groupSpawnOptions(platform = process.platform) {
|
|
|
27
27
|
* @param {{ platform?: string, killFn?: (pid: number, signal: string) => void }} [opts]
|
|
28
28
|
* @returns {boolean}
|
|
29
29
|
*/
|
|
30
|
-
function killProcessGroup(
|
|
30
|
+
export function killProcessGroup(
|
|
31
31
|
child,
|
|
32
32
|
signal = 'SIGKILL',
|
|
33
33
|
{ platform = process.platform, killFn = process.kill.bind(process) } = {},
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
executeFastForward as defaultExecuteFastForward,
|
|
12
12
|
planCleanup as defaultPlanCleanup,
|
|
13
13
|
planFastForward as defaultPlanFastForward,
|
|
14
|
-
} from '../git
|
|
14
|
+
} from '../clean-git.js';
|
|
15
15
|
import { evaluateProtection as defaultEvaluateProtection } from './single-story-sweep/protection.js';
|
|
16
16
|
import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-sweep/sweep-lock.js';
|
|
17
17
|
|
|
@@ -175,7 +175,7 @@ export function sweepMergedStoryBranches(args = {}) {
|
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
177
|
* Content-merged candidates are report-only: content equivalence is weaker
|
|
178
|
-
* than a merged PR or ancestry, so only an operator-confirmed `/git
|
|
178
|
+
* than a merged PR or ancestry, so only an operator-confirmed `/clean-git`
|
|
179
179
|
* may reap them.
|
|
180
180
|
*/
|
|
181
181
|
function partitionContentMerged(candidates) {
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A full suite supervised as a process group. The suite may write
|
|
3
|
+
* `$MANDREL_SUITE_READY_FILE` when its tests start; the kill timer then
|
|
4
|
+
* re-arms a fresh `timeoutMs`, so a pre-test wait (bounded by `timeoutMs`
|
|
5
|
+
* too) never spends the test budget. Close parses the timing line back.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
groupSpawnOptions,
|
|
15
|
+
killProcessGroup,
|
|
16
|
+
superviseGroup,
|
|
17
|
+
TIMEOUT_EXIT_CODE,
|
|
18
|
+
} from './process-group.js';
|
|
19
|
+
|
|
20
|
+
/** @typedef {{ lockWaitMs: number, hostWaitMs: number|null, testRunMs: number }} SuiteTimings */
|
|
21
|
+
|
|
22
|
+
export const SUITE_READY_FILE_ENV = 'MANDREL_SUITE_READY_FILE';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_READY_POLL_MS = 250;
|
|
25
|
+
|
|
26
|
+
const NO_HOST_WAIT = 'n/a';
|
|
27
|
+
|
|
28
|
+
/** @param {{ dir?: string }} [opts] */
|
|
29
|
+
export function suiteReadyHandshake({ dir = os.tmpdir() } = {}) {
|
|
30
|
+
const name = `mandrel-suite-ready-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
31
|
+
const file = path.join(dir, name);
|
|
32
|
+
return { file, env: { [SUITE_READY_FILE_ENV]: file } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {SuiteTimings} timings */
|
|
36
|
+
export function formatSuiteTimings({ lockWaitMs, hostWaitMs, testRunMs }) {
|
|
37
|
+
const host = hostWaitMs === null ? NO_HOST_WAIT : Math.round(hostWaitMs);
|
|
38
|
+
return `⏲ suite timings: lockWaitMs=${Math.round(lockWaitMs)} hostWaitMs=${host} testRunMs=${Math.round(testRunMs)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} line
|
|
43
|
+
* @returns {SuiteTimings|null}
|
|
44
|
+
*/
|
|
45
|
+
export function parseSuiteTimings(line) {
|
|
46
|
+
const match =
|
|
47
|
+
/suite timings: lockWaitMs=(\d+) hostWaitMs=(\d+|n\/a) testRunMs=(\d+)/u.exec(
|
|
48
|
+
String(line ?? ''),
|
|
49
|
+
);
|
|
50
|
+
if (!match) return null;
|
|
51
|
+
return {
|
|
52
|
+
lockWaitMs: Number(match[1]),
|
|
53
|
+
hostWaitMs: match[2] === NO_HOST_WAIT ? null : Number(match[2]),
|
|
54
|
+
testRunMs: Number(match[3]),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fileExists(fsImpl, file) {
|
|
59
|
+
try {
|
|
60
|
+
return fsImpl.existsSync(file);
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function removeQuietly(fsImpl, file) {
|
|
67
|
+
try {
|
|
68
|
+
fsImpl.rmSync(file, { force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
// A leftover marker is harmless.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class SuiteClock {
|
|
75
|
+
constructor({ kill, timeoutMs, readyTimeoutMs, readyFile, fsImpl, nowFn }) {
|
|
76
|
+
Object.assign(this, { kill, timeoutMs, readyFile, fsImpl, nowFn });
|
|
77
|
+
this.timedOut = false;
|
|
78
|
+
this.startedAt = nowFn();
|
|
79
|
+
this.readyAt = null;
|
|
80
|
+
this.endedAt = null;
|
|
81
|
+
this.timer = null;
|
|
82
|
+
this.poller = null;
|
|
83
|
+
this.arm(readyTimeoutMs ?? timeoutMs);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
arm(boundMs) {
|
|
87
|
+
clearTimeout(this.timer);
|
|
88
|
+
if (!(Number.isFinite(boundMs) && boundMs > 0)) return;
|
|
89
|
+
this.timer = setTimeout(() => {
|
|
90
|
+
this.timedOut = true;
|
|
91
|
+
this.kill();
|
|
92
|
+
}, boundMs);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
watch(pollMs) {
|
|
96
|
+
this.poller = setInterval(() => this.checkReady(), pollMs);
|
|
97
|
+
this.poller.unref?.();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
checkReady() {
|
|
101
|
+
if (this.readyAt !== null || this.timedOut) return;
|
|
102
|
+
if (!fileExists(this.fsImpl, this.readyFile)) return;
|
|
103
|
+
this.readyAt = this.nowFn();
|
|
104
|
+
clearInterval(this.poller);
|
|
105
|
+
this.arm(this.timeoutMs);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
stop() {
|
|
109
|
+
this.endedAt ??= this.nowFn();
|
|
110
|
+
this.checkReady();
|
|
111
|
+
clearInterval(this.poller);
|
|
112
|
+
clearTimeout(this.timer);
|
|
113
|
+
removeQuietly(this.fsImpl, this.readyFile);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
timings() {
|
|
117
|
+
const end = this.endedAt ?? this.nowFn();
|
|
118
|
+
const testStart = this.readyAt ?? this.startedAt;
|
|
119
|
+
return {
|
|
120
|
+
hostWaitMs: this.readyAt === null ? null : this.readyAt - this.startedAt,
|
|
121
|
+
testRunMs: Math.max(0, end - testStart),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `hostWaitMs` is spawn → ready (null without a signal); `testRunMs` ends at
|
|
128
|
+
* exit. `readyTimeoutMs` (pre-ready bound) is a test seam over `timeoutMs`.
|
|
129
|
+
*
|
|
130
|
+
* @param {{ pid?: number, kill?: Function }} child
|
|
131
|
+
* @param {{ readyFile: string, timeoutMs?: number, readyTimeoutMs?: number, readyPollMs?: number, abortSignal?: AbortSignal, signalOnParentSignal?: string, fsImpl?: object, nowFn?: () => number }} opts
|
|
132
|
+
*/
|
|
133
|
+
export function superviseSuite(child, opts) {
|
|
134
|
+
const { readyPollMs = DEFAULT_READY_POLL_MS, fsImpl = fs } = opts;
|
|
135
|
+
const group = superviseGroup(child, {
|
|
136
|
+
abortSignal: opts.abortSignal,
|
|
137
|
+
signalOnParentSignal: opts.signalOnParentSignal,
|
|
138
|
+
});
|
|
139
|
+
const clock = new SuiteClock({
|
|
140
|
+
...opts,
|
|
141
|
+
fsImpl,
|
|
142
|
+
nowFn: opts.nowFn ?? Date.now,
|
|
143
|
+
kill: () => killProcessGroup(child, 'SIGKILL'),
|
|
144
|
+
});
|
|
145
|
+
clock.watch(readyPollMs);
|
|
146
|
+
return {
|
|
147
|
+
get timedOut() {
|
|
148
|
+
return clock.timedOut;
|
|
149
|
+
},
|
|
150
|
+
get timings() {
|
|
151
|
+
return clock.timings();
|
|
152
|
+
},
|
|
153
|
+
release() {
|
|
154
|
+
clock.stop();
|
|
155
|
+
group.release();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A bare suite gets SIGKILL on a parent signal; a gate with its own cleanup
|
|
162
|
+
* (a capture holding the lock) SIGTERM. A full-suite gate also gets the
|
|
163
|
+
* handshake and emits its timing line on release.
|
|
164
|
+
*
|
|
165
|
+
* @param {{ fullSuiteLock?: boolean, timeoutMs?: number, signal?: AbortSignal, env?: Record<string, string> }} opts
|
|
166
|
+
* @param {{ lockWaitMs?: number }} [lock]
|
|
167
|
+
*/
|
|
168
|
+
export function gateSupervision(opts, lock = {}) {
|
|
169
|
+
const base = {
|
|
170
|
+
timeoutMs: opts.timeoutMs,
|
|
171
|
+
abortSignal: opts.signal,
|
|
172
|
+
signalOnParentSignal: opts.fullSuiteLock ? 'SIGKILL' : 'SIGTERM',
|
|
173
|
+
};
|
|
174
|
+
if (!opts.fullSuiteLock) {
|
|
175
|
+
return { env: opts.env, supervise: (child) => superviseGroup(child, base) };
|
|
176
|
+
}
|
|
177
|
+
const handshake = suiteReadyHandshake();
|
|
178
|
+
return {
|
|
179
|
+
env: { ...opts.env, ...handshake.env },
|
|
180
|
+
supervise: (child, output) =>
|
|
181
|
+
reportOnRelease(
|
|
182
|
+
superviseSuite(child, { ...base, readyFile: handshake.file }),
|
|
183
|
+
(timings) =>
|
|
184
|
+
output.emit(
|
|
185
|
+
output.prefix +
|
|
186
|
+
formatSuiteTimings({
|
|
187
|
+
lockWaitMs: lock.lockWaitMs ?? 0,
|
|
188
|
+
...timings,
|
|
189
|
+
}),
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {ReturnType<typeof superviseSuite>} supervisor
|
|
197
|
+
* @param {(timings: object) => void} report
|
|
198
|
+
*/
|
|
199
|
+
function reportOnRelease(supervisor, report) {
|
|
200
|
+
return {
|
|
201
|
+
get timedOut() {
|
|
202
|
+
return supervisor.timedOut;
|
|
203
|
+
},
|
|
204
|
+
release() {
|
|
205
|
+
supervisor.release();
|
|
206
|
+
report(supervisor.timings);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolves the exit code, or `124` when the supervisor killed the suite.
|
|
213
|
+
*
|
|
214
|
+
* @param {{ cmd: string, args: string[], cwd: string, env?: Record<string, string>, timeoutMs?: number, readyTimeoutMs?: number, readyPollMs?: number, lockWaitMs?: number, spawnImpl?: typeof spawn, onTimings?: (timings: SuiteTimings) => void, onTimeout?: () => void }} opts
|
|
215
|
+
* @returns {Promise<number>}
|
|
216
|
+
*/
|
|
217
|
+
export function runSupervisedSuite(opts) {
|
|
218
|
+
const { cmd, args, cwd, env = {}, spawnImpl = spawn } = opts;
|
|
219
|
+
const handshake = suiteReadyHandshake();
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
const child = spawnImpl(cmd, args, {
|
|
222
|
+
cwd,
|
|
223
|
+
env: { ...process.env, ...env, ...handshake.env },
|
|
224
|
+
stdio: 'inherit',
|
|
225
|
+
shell: process.platform === 'win32',
|
|
226
|
+
...groupSpawnOptions(),
|
|
227
|
+
});
|
|
228
|
+
const supervisor = superviseSuite(child, {
|
|
229
|
+
...opts,
|
|
230
|
+
readyFile: handshake.file,
|
|
231
|
+
});
|
|
232
|
+
let settled = false;
|
|
233
|
+
const settle = (code) => {
|
|
234
|
+
if (settled) return;
|
|
235
|
+
settled = true;
|
|
236
|
+
supervisor.release();
|
|
237
|
+
opts.onTimings?.({
|
|
238
|
+
lockWaitMs: opts.lockWaitMs ?? 0,
|
|
239
|
+
...supervisor.timings,
|
|
240
|
+
});
|
|
241
|
+
if (supervisor.timedOut) opts.onTimeout?.();
|
|
242
|
+
resolve(supervisor.timedOut ? TIMEOUT_EXIT_CODE : code);
|
|
243
|
+
};
|
|
244
|
+
child.on('error', () => settle(1));
|
|
245
|
+
child.on('exit', (code) => settle(code ?? 1));
|
|
246
|
+
});
|
|
247
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem primitives for the temp-retention engine: sizing a tree and
|
|
3
|
+
* deleting one while sparing the never-purged basenames at any depth.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Never deleted, re-checked at the deletion site: `signals.ndjson` is read
|
|
10
|
+
* long after merge and its loss is silent and unrecoverable.
|
|
11
|
+
*/
|
|
12
|
+
export const KEEP_BASENAMES = Object.freeze(['signals.ndjson']);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `readdir` yielding `[]` for an absent or unreadable directory.
|
|
16
|
+
*
|
|
17
|
+
* @param {typeof import('node:fs/promises')} fsp
|
|
18
|
+
* @param {string} dir
|
|
19
|
+
* @returns {Promise<import('node:fs').Dirent[]>}
|
|
20
|
+
*/
|
|
21
|
+
export async function safeReaddir(fsp, dir) {
|
|
22
|
+
try {
|
|
23
|
+
return await fsp.readdir(dir, { withFileTypes: true });
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Recursive byte total; a vanished child is skipped.
|
|
31
|
+
*
|
|
32
|
+
* @param {typeof import('node:fs/promises')} fsp
|
|
33
|
+
* @param {string} target
|
|
34
|
+
* @returns {Promise<number>}
|
|
35
|
+
*/
|
|
36
|
+
export async function sizeOf(fsp, target) {
|
|
37
|
+
let total = 0;
|
|
38
|
+
const stack = [target];
|
|
39
|
+
while (stack.length > 0) {
|
|
40
|
+
const current = stack.pop();
|
|
41
|
+
let stats;
|
|
42
|
+
try {
|
|
43
|
+
stats = await fsp.stat(current);
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!stats.isDirectory()) {
|
|
48
|
+
total += stats.size;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
for (const child of await safeReaddir(fsp, current)) {
|
|
52
|
+
stack.push(path.join(current, child.name));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return total;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Paths of every never-purged basename at any depth under `target`.
|
|
60
|
+
*
|
|
61
|
+
* @param {typeof import('node:fs/promises')} fsp
|
|
62
|
+
* @param {string} target
|
|
63
|
+
* @returns {Promise<string[]>}
|
|
64
|
+
*/
|
|
65
|
+
async function findKeptDescendants(fsp, target) {
|
|
66
|
+
const kept = [];
|
|
67
|
+
const stack = [target];
|
|
68
|
+
while (stack.length > 0) {
|
|
69
|
+
const current = stack.pop();
|
|
70
|
+
for (const child of await safeReaddir(fsp, current)) {
|
|
71
|
+
const childPath = path.join(current, child.name);
|
|
72
|
+
if (child.isDirectory()) stack.push(childPath);
|
|
73
|
+
else if (KEEP_BASENAMES.includes(child.name)) kept.push(childPath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return kept;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Delete `target` while sparing every never-purged basename beneath it: a
|
|
81
|
+
* tree holding none goes in one `rm`; otherwise its children are removed
|
|
82
|
+
* one by one and the kept files (with their parent dirs) stay.
|
|
83
|
+
*
|
|
84
|
+
* @param {typeof import('node:fs/promises')} fsp
|
|
85
|
+
* @param {string} target
|
|
86
|
+
* @param {number} [knownBytes] Pre-computed size, spared a second walk.
|
|
87
|
+
* @returns {Promise<{ bytes: number, kept: string[] }>}
|
|
88
|
+
*/
|
|
89
|
+
export async function removeSparingKept(fsp, target, knownBytes) {
|
|
90
|
+
const stats = await fsp.stat(target);
|
|
91
|
+
if (!stats.isDirectory()) {
|
|
92
|
+
if (KEEP_BASENAMES.includes(path.basename(target))) {
|
|
93
|
+
return { bytes: 0, kept: [target] };
|
|
94
|
+
}
|
|
95
|
+
await fsp.rm(target, { force: true });
|
|
96
|
+
return { bytes: stats.size, kept: [] };
|
|
97
|
+
}
|
|
98
|
+
const kept = await findKeptDescendants(fsp, target);
|
|
99
|
+
if (kept.length === 0) {
|
|
100
|
+
const bytes = knownBytes ?? (await sizeOf(fsp, target));
|
|
101
|
+
await fsp.rm(target, { recursive: true, force: true });
|
|
102
|
+
return { bytes, kept };
|
|
103
|
+
}
|
|
104
|
+
let bytes = 0;
|
|
105
|
+
for (const child of await safeReaddir(fsp, target)) {
|
|
106
|
+
bytes += (await removeSparingKept(fsp, path.join(target, child.name)))
|
|
107
|
+
.bytes;
|
|
108
|
+
}
|
|
109
|
+
return { bytes, kept };
|
|
110
|
+
}
|