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.
Files changed (85) hide show
  1. package/.agents/agents/acceptance-critic.md +2 -2
  2. package/.agents/agents/story-worker.md +15 -11
  3. package/.agents/docs/agentrc-reference.json +5 -2
  4. package/.agents/docs/configuration.md +38 -2
  5. package/.agents/docs/workflows.md +4 -2
  6. package/.agents/instructions.md +2 -1
  7. package/.agents/rules/git-conventions-reference.md +5 -5
  8. package/.agents/rules/git-conventions.md +1 -1
  9. package/.agents/schemas/agentrc.schema.json +20 -2
  10. package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
  11. package/.agents/schemas/validation-evidence.schema.json +3 -1
  12. package/.agents/scripts/boot-sweep.js +97 -9
  13. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  14. package/.agents/scripts/clean-temp.js +54 -0
  15. package/.agents/scripts/clean-worktrees.js +593 -0
  16. package/.agents/scripts/coverage-capture.js +65 -9
  17. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  18. package/.agents/scripts/evidence-gate.js +106 -8
  19. package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
  20. package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
  21. package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
  22. package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
  23. package/.agents/scripts/lib/child-exec.js +39 -1
  24. package/.agents/scripts/lib/clean-temp.js +440 -0
  25. package/.agents/scripts/lib/close-validation/gates.js +59 -19
  26. package/.agents/scripts/lib/close-validation/process.js +23 -24
  27. package/.agents/scripts/lib/close-validation/runner.js +71 -40
  28. package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
  29. package/.agents/scripts/lib/config/quality.js +7 -1
  30. package/.agents/scripts/lib/config/temp-paths.js +15 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
  32. package/.agents/scripts/lib/coverage-baseline.js +78 -5
  33. package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
  34. package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
  35. package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
  36. package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
  37. package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
  38. package/.agents/scripts/lib/coverage-capture.js +121 -81
  39. package/.agents/scripts/lib/full-suite-lock.js +49 -46
  40. package/.agents/scripts/lib/full-suite-queue.js +83 -8
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  42. package/.agents/scripts/lib/observability/source-classifier.js +4 -1
  43. package/.agents/scripts/lib/orchestration/code-review.js +15 -3
  44. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  45. package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
  46. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  47. package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
  48. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
  49. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
  50. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
  51. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
  53. package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
  54. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
  55. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
  56. package/.agents/scripts/lib/process-group.js +1 -1
  57. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  58. package/.agents/scripts/lib/supervised-suite.js +247 -0
  59. package/.agents/scripts/lib/temp-removal.js +110 -0
  60. package/.agents/scripts/lib/temp-retention.js +122 -73
  61. package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
  62. package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
  63. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  64. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  65. package/.agents/scripts/quality-preview.js +112 -14
  66. package/.agents/scripts/single-story-init.js +120 -17
  67. package/.agents/scripts/stories-wave-tick.js +47 -0
  68. package/.agents/scripts/story-review-compute.js +207 -0
  69. package/.agents/scripts/update-coverage-baseline.js +15 -10
  70. package/.agents/scripts/update-crap-baseline.js +12 -2
  71. package/.agents/scripts/update-maintainability-baseline.js +12 -2
  72. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  73. package/.agents/workflows/clean-temp.md +67 -0
  74. package/.agents/workflows/clean-worktrees.md +63 -0
  75. package/.agents/workflows/git-deliver.md +1 -1
  76. package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
  77. package/.agents/workflows/helpers/code-review.md +7 -5
  78. package/.agents/workflows/helpers/deliver-digest.md +39 -36
  79. package/.agents/workflows/helpers/deliver-reference.md +115 -5
  80. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  81. package/.agents/workflows/helpers/deliver-story.md +2 -1
  82. package/docs/CHANGELOG.md +39 -0
  83. package/lib/cli/registry.js +125 -18
  84. package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
  85. 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, runStoryScopeReview } from './phases/code-review.js';
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
- const seconds = Math.round((nowMs() - since) / 100) / 10;
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 { validationGates: null, lockWait: null, pending: false };
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
- ctx.setPhase('code-review');
402
- const reviewOutcome = await runStoryScopeReview({
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
- gates,
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-cleanup.js';
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-cleanup`
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
+ }