fix(test-runner): real carve-guard keys in TREE_MUTATING; census pins; size-scaled wall deadlines

TREE_MUTATING listed 'test/carve-guard-checks.test.ts' — a file that
has never existed (the real ratchet readers are
carve-guard-completeness and carve-section-ordering), so the intended
serialization was silently absent. New census pin tests fail on any
key that doesn't name a real free test file, and on a TEST_ROOTS
entry that stops contributing files. Full-suite wall deadlines now
scale with shard size (max(6min, files x 5s)) so a jobs=1 machine or
the ~130-file Windows shards can't false-timeout a healthy run;
explicit --wall-timeout disables scaling. Stale --parallel wording in
the dry-run message, jsdoc, and the TREE_MUTATING ordering comment
corrected to the shipped process-shard model.
This commit is contained in:
Garry Tan 2026-08-15 16:49:39 -07:00
parent 661b3d943f
commit 9e5fbd0a6a
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 96 additions and 22 deletions

View File

@ -95,7 +95,7 @@ const ROOT = path.resolve(import.meta.dir, '..');
// or local free run. Keep the two lists in sync. This list is the single
// source of truth for free-suite roots: package.json's `test` script routes
// through this runner rather than passing its own directory globs.
const TEST_ROOTS = [
export const TEST_ROOTS = [
'browse/test',
'test',
'make-pdf/test',
@ -206,6 +206,26 @@ export const FREE_TEST_TIMEOUT_MS = 30_000;
// minutes, not sat out — 15min of silence was pure diagnosis latency.
// Override per run with --wall-timeout <secs>.
export const DEFAULT_WALL_TIMEOUT_MS = 6 * 60_000;
/**
* Full-suite shards scale their wall deadline with shard size:
* max(DEFAULT_WALL_TIMEOUT_MS, files × PER_FILE_WALL_MS). The 6-min floor
* keeps wedge diagnosis fast on a typical ~70-file local shard, while a
* low-core machine (jobs=1 the whole suite in one shard) or the Windows
* lane (~130 files/shard) gets proportional headroom instead of a false
* timed-out kill of a healthy run. Explicit --wall-timeout disables scaling.
*/
export const PER_FILE_WALL_MS = 5_000;
export function wallTimeoutForShard(fileCount: number, baseMs = DEFAULT_WALL_TIMEOUT_MS): number {
return Math.max(baseMs, fileCount * PER_FILE_WALL_MS);
}
/**
* Full-suite parallelism: leave RESERVED_CPUS cores for the parent runner +
* OS, cap at MAX_FULL_SUITE_JOBS beyond ~6 concurrent bun processes the
* playwright-heavy shards contend on browser launches instead of finishing
* sooner (measured on an M-series dev box).
*/
export const MAX_FULL_SUITE_JOBS = 6;
export const RESERVED_CPUS = 2;
/**
* Files that crash or wedge Bun's --parallel WORKERS but run fine in a plain
@ -221,27 +241,27 @@ export const WORKER_HOSTILE: Record<string, string> = {
+ 'wedges the whole invocation past the wall clock. Passes serially.',
};
/**
* Tests that REGENERATE shared repo artifacts in place (skill SKILL.md files
* or the .agents/ host outputs). Any shard reading those files concurrently
* sees a moving target this one family produced today's exactly-doubled
* catalog estimate, golden-file drift, and the spec-sync mismatch. Full-suite
* mode runs them in ONE serial shard AFTER the parallel shards finish, so
* readers never race a mutator. (CI's --shards matrix is unaffected: each CI
* shard has its own checkout.) Each mutator restores default state itself.
*/
/**
* TREE-SERIAL files: run in ONE serial shard AFTER the parallel shards.
* Two kinds live here:
* - MUTATORS: tests that regenerate shared repo artifacts in place.
* - MUTATORS: tests that regenerate shared repo artifacts in place (skill
* SKILL.md files or the .agents/ host outputs). A shard reading those
* files concurrently sees a moving target this family produced an
* exactly-doubled catalog estimate, golden-file drift, and a spec-sync
* mismatch before serialization.
* - RATCHET READERS: tests that MEASURE the shared tree (parity caps,
* size budgets). Measuring while any concurrent test regenerates is
* undefined behavior two runs failed with byte-identical inflated
* skeletons while the tree was clean before and after, so rather than
* hunt every present and future mutator, the measurers get a quiet
* tree by construction.
* Serial order within the shard: readers are appended after mutators by the
* assignment below, and every mutator restores default state itself.
* Order within the serial shard is alphabetical (the file census is sorted
* and the serial shard is a filter over it) safety does NOT depend on
* mutators-before-readers ordering; it rests on every mutator restoring
* default state itself. (CI's --shards matrix is unaffected: each CI shard
* has its own checkout.)
* Keys are pinned against the live file census by test-free-shards.test.ts
* a renamed file fails the suite instead of silently dropping serialization.
*/
export const TREE_MUTATING: Record<string, string> = {
'test/catalog-mode-full.test.ts': 'regenerates ALL SKILL.md in full-catalog mode, then restores',
@ -254,7 +274,8 @@ export const TREE_MUTATING: Record<string, string> = {
// Ratchet readers (measure the tree; need it quiet):
'test/parity-suite.test.ts': 'RATCHET READER — parity caps measure live SKILL.md/section bytes',
'test/skill-size-budget.test.ts': 'RATCHET READER — per-skill and corpus size budgets measure the live tree',
'test/carve-guard-checks.test.ts': 'RATCHET READER — carve-guard skeleton checks measure the live tree',
'test/carve-guard-completeness.test.ts': 'RATCHET READER — registry-vs-disk parity reads live sections/manifest.json files',
'test/carve-section-ordering.test.ts': 'RATCHET READER — checkOrdering(ROOT) reads live skeletons and sections',
};
export function normalizeRelativePath(filePath: string): string {
@ -378,7 +399,12 @@ export function assignFilesToShards(files: string[], shardCount: number): string
}
export interface BuildShardArgsOptions {
/** Run test files in parallel worker processes (bun 1.3.13+, implies --isolate). */
/**
* Pass bun's --parallel (worker-per-file, implies --isolate). No production
* caller today full-suite mode uses N shard PROCESSES after the worker
* pathologies documented in main(); retained for a future re-attempt on a
* newer Bun (see WORKER_HOSTILE).
*/
parallel?: boolean;
rootDir?: string;
}
@ -402,6 +428,8 @@ type CliOptions = {
shardCount: number;
shardIndex: number | null;
wallTimeoutMs: number;
/** True when --wall-timeout was passed explicitly; full-suite mode only auto-scales the default. */
wallTimeoutExplicit: boolean;
};
function parseCliOptions(argv: string[]): CliOptions {
@ -412,6 +440,7 @@ function parseCliOptions(argv: string[]): CliOptions {
let shardCount = DEFAULT_SHARD_COUNT;
let shardIndex: number | null = null;
let wallTimeoutMs = DEFAULT_WALL_TIMEOUT_MS;
let wallTimeoutExplicit = false;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
@ -437,13 +466,14 @@ function parseCliOptions(argv: string[]): CliOptions {
const value = Number.parseInt(argv[index + 1] ?? '', 10);
if (!Number.isInteger(value) || value <= 0) throw new Error('--wall-timeout needs a positive integer (seconds)');
wallTimeoutMs = value * 1000;
wallTimeoutExplicit = true;
index += 1;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return { dryRun, listOnly, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs };
return { dryRun, listOnly, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit };
}
function formatShardSummary(shards: string[][]): string[] {
@ -748,7 +778,7 @@ export interface RunFreeShardOptions {
wallTimeoutMs?: number;
rootDir?: string;
env?: NodeJS.ProcessEnv;
/** Full-suite mode: single bun invocation with --parallel (per-file worker isolation). */
/** Pass bun's --parallel. No production caller today (see BuildShardArgsOptions.parallel). */
parallel?: boolean;
/** Override the spawned command. Tests inject fake pass/fail/slow commands. */
commandFor?: (files: string[]) => ShardCommand;
@ -885,7 +915,7 @@ export async function runFreeShard(
const consumeStream = (stream: NodeJS.ReadableStream, origin: StreamOrigin): Promise<void> =>
new Promise((resolve, reject) => {
stream.on('data', (chunk: Buffer | string) => {
classifier.write(chunk); // strict verdict ALWAYS sees the full stream
classifier.write(chunk, origin); // strict verdict ALWAYS sees the full stream
if (!logWriteFailed) logStream.write(chunk);
reporter.write(chunk, origin);
if (options.verbose) emitToConsole(typeof chunk === 'string' ? chunk : chunk.toString('utf8'), origin);
@ -1000,7 +1030,8 @@ async function main(): Promise<number> {
const occupied = shards.filter((s) => s.length > 0).length;
console.log(
`\nWould run ${files.length} files across ${shards.length} shards (${occupied} occupied). `
+ 'Without --shard, the full suite runs as ONE bun --parallel invocation instead.',
+ 'Without --shard, the full suite runs as N concurrent shard processes '
+ '(plus a serial tree-mutating shard) instead.',
);
for (const line of formatShardSummary(shards)) console.log(line);
return 0;
@ -1032,7 +1063,7 @@ async function main(): Promise<number> {
// proven spawn semantics, per-shard group-kill, per-shard logs, and a
// wedge only ever costs its own shard. WORKER_HOSTILE files are moot in
// process shards (no workers) and fold back into normal assignment.
const jobs = Math.max(1, Math.min(6, os.cpus().length - 2));
const jobs = Math.max(1, Math.min(MAX_FULL_SUITE_JOBS, os.cpus().length - RESERVED_CPUS));
// Phase split: tree-mutating tests run AFTER the parallel shards, in one
// serial shard, so no concurrent shard ever reads a half-regenerated tree.
const mutators = files.filter((f) => f in TREE_MUTATING);
@ -1041,16 +1072,18 @@ async function main(): Promise<number> {
const totalShards = jobs + (mutators.length > 0 ? 1 : 0);
console.log(`[test:free] full suite: ${readers.length} files across ${jobs} shard processes`
+ (mutators.length > 0 ? `, then ${mutators.length} tree-mutating file(s) serially` : ''));
const shardTimeout = (fileCount: number): number =>
options.wallTimeoutExplicit ? options.wallTimeoutMs : wallTimeoutForShard(fileCount, options.wallTimeoutMs);
const outcomes = await Promise.all(
shards.map((shardFiles, index) => runFreeShard(shardFiles, index + 1, totalShards, {
wallTimeoutMs: options.wallTimeoutMs,
wallTimeoutMs: shardTimeout(shardFiles.length),
verbose: options.verbose,
})),
);
let worst = Math.max(...outcomes.map((o) => exitCodeFor(o.status)));
if (mutators.length > 0) {
const mutatorOutcome = await runFreeShard(mutators, totalShards, totalShards, {
wallTimeoutMs: options.wallTimeoutMs,
wallTimeoutMs: shardTimeout(mutators.length),
verbose: options.verbose,
});
worst = Math.max(worst, exitCodeFor(mutatorOutcome.status));

View File

@ -15,6 +15,12 @@ import {
FreeRunReporter,
buildRunEpilogue,
FREE_TEST_TIMEOUT_MS,
DEFAULT_WALL_TIMEOUT_MS,
PER_FILE_WALL_MS,
wallTimeoutForShard,
TEST_ROOTS,
TREE_MUTATING,
WORKER_HOSTILE,
} from '../scripts/test-free-shards';
const ROOT = path.resolve(import.meta.dir, '..');
@ -477,3 +483,38 @@ describe('test-free-shards: output contract (log capture, quiet console, failure
expect(reporter.report().failures[0]).toEqual({ file: 'browse/test/x.test.ts', testName: 'boom' });
});
});
describe('test-free-shards: curated-list census pins', () => {
// A renamed test file must FAIL here, not silently drop its serialization
// (a phantom TREE_MUTATING key means the reader races regenerating shards
// again) or its serial-child quarantine (WORKER_HOSTILE).
test('every TREE_MUTATING and WORKER_HOSTILE key names a real free test file', () => {
const census = new Set(collectFreeTestFiles(ROOT));
const stale = [...Object.keys(TREE_MUTATING), ...Object.keys(WORKER_HOSTILE)]
.filter((key) => !census.has(key));
expect(stale).toEqual([]);
});
test('every TEST_ROOTS entry exists on disk and contributes at least one test file', () => {
const files = collectFreeTestFiles(ROOT);
for (const root of TEST_ROOTS) {
expect(fs.existsSync(path.join(ROOT, root))).toBe(true);
expect(files.some((f) => f.startsWith(`${root}/`))).toBe(true);
}
});
});
describe('test-free-shards: wall-timeout scaling', () => {
test('typical local shard keeps the 6-minute floor', () => {
expect(wallTimeoutForShard(70)).toBe(DEFAULT_WALL_TIMEOUT_MS);
});
test('oversized shards (jobs=1 machines, Windows lane) scale linearly past the floor', () => {
expect(wallTimeoutForShard(130)).toBe(130 * PER_FILE_WALL_MS);
expect(wallTimeoutForShard(420)).toBe(420 * PER_FILE_WALL_MS);
});
test('an explicit base above the scaled value wins', () => {
expect(wallTimeoutForShard(10, 10 * 60_000)).toBe(10 * 60_000);
});
});