From f1bda4e95934f55a045d669345d9ba17f27e5b54 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:02:44 -0700 Subject: [PATCH 1/3] test: split the slowest test files into shards and run more files at a time --- README.md | 2 +- tests/{cleanup.test.mjs => cleanup.cases.mjs} | 3 +- tests/cleanup.shard1.test.mjs | 4 + tests/cleanup.shard2.test.mjs | 4 + tests/cleanup.shard3.test.mjs | 4 + tests/cleanup.shard4.test.mjs | 4 + tests/cleanup.shard5.test.mjs | 4 + tests/{doctor.test.mjs => doctor.cases.mjs} | 3 +- tests/doctor.shard1.test.mjs | 4 + tests/doctor.shard2.test.mjs | 4 + tests/doctor.shard3.test.mjs | 4 + tests/index.js | 9 +- tests/run-tests.mjs | 14 +- tests/run-tests.test.mjs | 24 +++- ...script.test.mjs => setup-script.cases.mjs} | 3 +- tests/setup-script.shard1.test.mjs | 4 + tests/setup-script.shard2.test.mjs | 4 + tests/setup-script.shard3.test.mjs | 4 + tests/{shadow.test.mjs => shadow.cases.mjs} | 3 +- tests/shadow.shard1.test.mjs | 4 + tests/shadow.shard2.test.mjs | 4 + tests/shard.mjs | 26 ++++ tests/shard.test.mjs | 126 ++++++++++++++++++ ...-checks.test.mjs => task-checks.cases.mjs} | 3 +- tests/task-checks.shard1.test.mjs | 4 + tests/task-checks.shard2.test.mjs | 4 + 26 files changed, 259 insertions(+), 17 deletions(-) rename tests/{cleanup.test.mjs => cleanup.cases.mjs} (99%) create mode 100644 tests/cleanup.shard1.test.mjs create mode 100644 tests/cleanup.shard2.test.mjs create mode 100644 tests/cleanup.shard3.test.mjs create mode 100644 tests/cleanup.shard4.test.mjs create mode 100644 tests/cleanup.shard5.test.mjs rename tests/{doctor.test.mjs => doctor.cases.mjs} (99%) create mode 100644 tests/doctor.shard1.test.mjs create mode 100644 tests/doctor.shard2.test.mjs create mode 100644 tests/doctor.shard3.test.mjs rename tests/{setup-script.test.mjs => setup-script.cases.mjs} (99%) create mode 100644 tests/setup-script.shard1.test.mjs create mode 100644 tests/setup-script.shard2.test.mjs create mode 100644 tests/setup-script.shard3.test.mjs rename tests/{shadow.test.mjs => shadow.cases.mjs} (99%) create mode 100644 tests/shadow.shard1.test.mjs create mode 100644 tests/shadow.shard2.test.mjs create mode 100644 tests/shard.mjs create mode 100644 tests/shard.test.mjs rename tests/{task-checks.test.mjs => task-checks.cases.mjs} (99%) create mode 100644 tests/task-checks.shard1.test.mjs create mode 100644 tests/task-checks.shard2.test.mjs diff --git a/README.md b/README.md index a9a83ce..fe5ae54 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,7 @@ Other things to know: | `scripts/` | derive-lanes, worth-it, setup, ledger, shadow, run-report, active-run, task-checks, run-checks, await-checks, audit, tool-outcomes, stop-cause, and other helpers | | `hooks/` | The SessionStart and notice hooks | | `agents/` | The `parallel-lanes-worker` agent definition | -| `tests/` | `node tests/run-tests.mjs` runs every `tests/*.test.mjs`, four files at a time (`--concurrency N` or `PL_TEST_CONCURRENCY` to change) | +| `tests/` | `node tests/run-tests.mjs` runs every `tests/*.test.mjs`, the CPU count less 4 files at a time (4 to 12; 4 inside a lane check), `--concurrency N` or `PL_TEST_CONCURRENCY` to change. The slowest files keep their tests in `.cases.mjs`, split across `.shard.test.mjs` by `tests/shard.mjs`; run a cases file directly to run all of its tests | ## License diff --git a/tests/cleanup.test.mjs b/tests/cleanup.cases.mjs similarity index 99% rename from tests/cleanup.test.mjs rename to tests/cleanup.cases.mjs index 60ac64b..30c60b7 100644 --- a/tests/cleanup.test.mjs +++ b/tests/cleanup.cases.mjs @@ -4,7 +4,8 @@ // finished git-mode run, with a run folder and an index.json written with // _history.write_index. -import { test, after } from 'node:test'; +import { after } from 'node:test'; +import { test } from './shard.mjs'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; diff --git a/tests/cleanup.shard1.test.mjs b/tests/cleanup.shard1.test.mjs new file mode 100644 index 0000000..18c08a3 --- /dev/null +++ b/tests/cleanup.shard1.test.mjs @@ -0,0 +1,4 @@ +// Shard 1 of 5 of tests/cleanup.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 0, count: 5 }; +await import('./cleanup.cases.mjs'); diff --git a/tests/cleanup.shard2.test.mjs b/tests/cleanup.shard2.test.mjs new file mode 100644 index 0000000..85dfd28 --- /dev/null +++ b/tests/cleanup.shard2.test.mjs @@ -0,0 +1,4 @@ +// Shard 2 of 5 of tests/cleanup.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 1, count: 5 }; +await import('./cleanup.cases.mjs'); diff --git a/tests/cleanup.shard3.test.mjs b/tests/cleanup.shard3.test.mjs new file mode 100644 index 0000000..d9bc3fb --- /dev/null +++ b/tests/cleanup.shard3.test.mjs @@ -0,0 +1,4 @@ +// Shard 3 of 5 of tests/cleanup.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 2, count: 5 }; +await import('./cleanup.cases.mjs'); diff --git a/tests/cleanup.shard4.test.mjs b/tests/cleanup.shard4.test.mjs new file mode 100644 index 0000000..55253bd --- /dev/null +++ b/tests/cleanup.shard4.test.mjs @@ -0,0 +1,4 @@ +// Shard 4 of 5 of tests/cleanup.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 3, count: 5 }; +await import('./cleanup.cases.mjs'); diff --git a/tests/cleanup.shard5.test.mjs b/tests/cleanup.shard5.test.mjs new file mode 100644 index 0000000..d93732d --- /dev/null +++ b/tests/cleanup.shard5.test.mjs @@ -0,0 +1,4 @@ +// Shard 5 of 5 of tests/cleanup.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 4, count: 5 }; +await import('./cleanup.cases.mjs'); diff --git a/tests/doctor.test.mjs b/tests/doctor.cases.mjs similarity index 99% rename from tests/doctor.test.mjs rename to tests/doctor.cases.mjs index b03ac35..4c23ce9 100644 --- a/tests/doctor.test.mjs +++ b/tests/doctor.cases.mjs @@ -1,7 +1,8 @@ // doctor: the checks that block a launch (hard failures), run against temp // git repos with a local bare repo as origin (no network). -import { test, after } from 'node:test'; +import { after } from 'node:test'; +import { test } from './shard.mjs'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; diff --git a/tests/doctor.shard1.test.mjs b/tests/doctor.shard1.test.mjs new file mode 100644 index 0000000..f4d0b7e --- /dev/null +++ b/tests/doctor.shard1.test.mjs @@ -0,0 +1,4 @@ +// Shard 1 of 3 of tests/doctor.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 0, count: 3 }; +await import('./doctor.cases.mjs'); diff --git a/tests/doctor.shard2.test.mjs b/tests/doctor.shard2.test.mjs new file mode 100644 index 0000000..be0ab24 --- /dev/null +++ b/tests/doctor.shard2.test.mjs @@ -0,0 +1,4 @@ +// Shard 2 of 3 of tests/doctor.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 1, count: 3 }; +await import('./doctor.cases.mjs'); diff --git a/tests/doctor.shard3.test.mjs b/tests/doctor.shard3.test.mjs new file mode 100644 index 0000000..1dcc277 --- /dev/null +++ b/tests/doctor.shard3.test.mjs @@ -0,0 +1,4 @@ +// Shard 3 of 3 of tests/doctor.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 2, count: 3 }; +await import('./doctor.cases.mjs'); diff --git a/tests/index.js b/tests/index.js index 10df9f6..6e000c0 100644 --- a/tests/index.js +++ b/tests/index.js @@ -1,8 +1,10 @@ // Entry point for `node --test tests/`. Node 24 does not expand a directory // argument into test files; it loads the directory as a module, which // resolves to this file. Load every *.test.mjs file beside it, one after -// another in this one process. tests/run-tests.mjs is the faster command: it -// runs each file in its own process, four at a time. +// another in this one process; a sharded file (shard.mjs) loads once, as its +// .cases.mjs, instead of through its shard wrappers. tests/run-tests.mjs +// is the faster command: it runs each file in its own process, several at a +// time. 'use strict'; const { readdirSync } = require('node:fs'); @@ -10,7 +12,8 @@ const { join } = require('node:path'); const { pathToFileURL } = require('node:url'); const files = readdirSync(__dirname) - .filter((name) => name.endsWith('.test.mjs')) + .filter((name) => (name.endsWith('.test.mjs') && !/\.shard\d+\.test\.mjs$/.test(name)) + || name.endsWith('.cases.mjs')) .sort(); (async () => { diff --git a/tests/run-tests.mjs b/tests/run-tests.mjs index 77d451a..b407cfd 100644 --- a/tests/run-tests.mjs +++ b/tests/run-tests.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node // Runs every *.test.mjs in this directory with node --test, naming each file, -// so each runs in its own process and a few run at once. The default is 4 at -// a time; PL_TEST_CONCURRENCY or --concurrency N changes it. Other arguments -// go to node --test unchanged (e.g. --test-name-pattern=...). Exits with -// node's exit code; 2 on a usage error. +// so each runs in its own process and several run at once. The default is the +// CPU count less 4, between 4 and 12; inside a parallel-lanes lane check +// (PL_PHASE=lane), where several lanes may run the suite at once, it is 4. +// PL_TEST_CONCURRENCY or --concurrency N changes it. Other arguments go to +// node --test unchanged (e.g. --test-name-pattern=...). Exits with node's +// exit code; 2 on a usage error. // // node tests/run-tests.mjs [--concurrency N] [node --test options...] // @@ -12,12 +14,14 @@ // on Windows, 2026-10-09). import { spawn } from 'node:child_process'; import { readdirSync } from 'node:fs'; +import { availableParallelism } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const TESTS = dirname(fileURLToPath(import.meta.url)); const ROOT = dirname(TESTS); -const DEFAULT_CONCURRENCY = '4'; +const DEFAULT_CONCURRENCY = String(process.env.PL_PHASE === 'lane' + ? 4 : Math.min(12, Math.max(4, availableParallelism() - 4))); function usage(message) { console.error(`run-tests: ${message}`); diff --git a/tests/run-tests.test.mjs b/tests/run-tests.test.mjs index 7f71620..bb5bde1 100644 --- a/tests/run-tests.test.mjs +++ b/tests/run-tests.test.mjs @@ -7,6 +7,7 @@ import { test, after } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { availableParallelism } from 'node:os'; import { join } from 'node:path'; import { SKILL_DIR } from './harness.mjs'; import { tempDir } from './platform.mjs'; @@ -47,7 +48,7 @@ function project(files, extra = {}) { function runner(p, args = [], env = {}) { const res = spawnSync(process.execPath, [join(p.tests, 'run-tests.mjs'), ...args], { - cwd: p.root, encoding: 'utf8', env: { ...process.env, PL_TEST_CONCURRENCY: '', ...env }, + cwd: p.root, encoding: 'utf8', env: { ...process.env, PL_TEST_CONCURRENCY: '', PL_PHASE: '', ...env }, }); return { code: res.status, stdout: res.stdout, stderr: res.stderr }; } @@ -74,13 +75,30 @@ test('run-tests runs every *.test.mjs beside it, each in its own process, and no assert.equal(existsSync(join(TMP, 'helper-ran')), false, 'a file not named *.test.mjs was run'); }); -test('run-tests runs four test files at a time by default', () => { +test('inside a lane check (PL_PHASE=lane) run-tests runs four test files at a time by default', () => { const p = project({ a: { ms: 1500 }, b: { ms: 1500 }, c: { ms: 1500 }, d: { ms: 1500 }, e: { ms: 1500 } }); - const res = runner(p); + const res = runner(p, [], { PL_PHASE: 'lane' }); assert.equal(res.code, 0, res.stdout + res.stderr); assert.equal(maxOverlap(records(p)), 4, JSON.stringify(records(p))); }); +// Several lanes may run the whole suite at once, so a lane check keeps 4; +// anywhere else the suite runs alone and takes CPUs - 4, between 4 and 12 (more than 12 was no faster +// on a 20-core Windows machine, 2026-10-17: 428 s at 8, 405 s at 12, 418 s at 16). +test('run alone, run-tests runs min(12, max(4, CPUs - 4)) test files at a time by default', () => { + const names = 'abcdefghijklmn'.split(''); + const p = project(Object.fromEntries(names.map((n) => [n, { ms: 2000 }]))); + const res = runner(p); + assert.equal(res.code, 0, res.stdout + res.stderr); + const want = Math.min(names.length, Math.min(12, Math.max(4, availableParallelism() - 4))); + assert.equal(maxOverlap(records(p)), want, JSON.stringify(records(p))); + for (const phase of ['integrate', 'verify']) { + const q = project(Object.fromEntries(names.map((n) => [n, { ms: 2000 }]))); + assert.equal(runner(q, [], { PL_PHASE: phase }).code, 0); + assert.equal(maxOverlap(records(q)), want, `${phase}: ${JSON.stringify(records(q))}`); + } +}); + test('PL_TEST_CONCURRENCY sets how many files run at once, and --concurrency overrides it', () => { const files = { a: { ms: 1000 }, b: { ms: 1000 }, c: { ms: 1000 } }; const serial = project(files); diff --git a/tests/setup-script.test.mjs b/tests/setup-script.cases.mjs similarity index 99% rename from tests/setup-script.test.mjs rename to tests/setup-script.cases.mjs index 3044a12..30a1c89 100644 --- a/tests/setup-script.test.mjs +++ b/tests/setup-script.cases.mjs @@ -1,4 +1,5 @@ -import { test, after } from 'node:test'; +import { after } from 'node:test'; +import { test } from './shard.mjs'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; diff --git a/tests/setup-script.shard1.test.mjs b/tests/setup-script.shard1.test.mjs new file mode 100644 index 0000000..6e92695 --- /dev/null +++ b/tests/setup-script.shard1.test.mjs @@ -0,0 +1,4 @@ +// Shard 1 of 3 of tests/setup-script.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 0, count: 3 }; +await import('./setup-script.cases.mjs'); diff --git a/tests/setup-script.shard2.test.mjs b/tests/setup-script.shard2.test.mjs new file mode 100644 index 0000000..29dcdd0 --- /dev/null +++ b/tests/setup-script.shard2.test.mjs @@ -0,0 +1,4 @@ +// Shard 2 of 3 of tests/setup-script.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 1, count: 3 }; +await import('./setup-script.cases.mjs'); diff --git a/tests/setup-script.shard3.test.mjs b/tests/setup-script.shard3.test.mjs new file mode 100644 index 0000000..bee3fca --- /dev/null +++ b/tests/setup-script.shard3.test.mjs @@ -0,0 +1,4 @@ +// Shard 3 of 3 of tests/setup-script.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 2, count: 3 }; +await import('./setup-script.cases.mjs'); diff --git a/tests/shadow.test.mjs b/tests/shadow.cases.mjs similarity index 99% rename from tests/shadow.test.mjs rename to tests/shadow.cases.mjs index b05712f..4305b70 100644 --- a/tests/shadow.test.mjs +++ b/tests/shadow.cases.mjs @@ -1,4 +1,5 @@ -import { test, after } from 'node:test'; +import { after } from 'node:test'; +import { test } from './shard.mjs'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; diff --git a/tests/shadow.shard1.test.mjs b/tests/shadow.shard1.test.mjs new file mode 100644 index 0000000..25d7944 --- /dev/null +++ b/tests/shadow.shard1.test.mjs @@ -0,0 +1,4 @@ +// Shard 1 of 2 of tests/shadow.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 0, count: 2 }; +await import('./shadow.cases.mjs'); diff --git a/tests/shadow.shard2.test.mjs b/tests/shadow.shard2.test.mjs new file mode 100644 index 0000000..626fdef --- /dev/null +++ b/tests/shadow.shard2.test.mjs @@ -0,0 +1,4 @@ +// Shard 2 of 2 of tests/shadow.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 1, count: 2 }; +await import('./shadow.cases.mjs'); diff --git a/tests/shard.mjs b/tests/shard.mjs new file mode 100644 index 0000000..5d9e527 --- /dev/null +++ b/tests/shard.mjs @@ -0,0 +1,26 @@ +// Splits one slow test file across several processes. Its tests live in +// tests/.cases.mjs, which imports test from here instead of node:test; +// each tests/.shard.test.mjs sets globalThis.PL_TEST_SHARD to +// { index: k - 1, count: n } and imports the cases file, and this test() +// registers only every n-th test, starting at that index. run-tests runs the +// shard files like any others, a few processes at once. Without a shard +// setting (node --test tests/.cases.mjs, or tests/index.js) every test +// is registered. +import { test as nodeTest } from 'node:test'; + +const shard = globalThis.PL_TEST_SHARD; +if (shard !== undefined) { + const ok = shard !== null && typeof shard === 'object' + && Number.isInteger(shard.count) && shard.count >= 1 + && Number.isInteger(shard.index) && shard.index >= 0 && shard.index < shard.count; + if (!ok) throw new Error(`PL_TEST_SHARD must be { index, count } with 0 <= index < count, not ${JSON.stringify(shard)}`); +} + +let registered = 0; + +export function test(...args) { + const n = registered; + registered += 1; + if (shard !== undefined && n % shard.count !== shard.index) return undefined; + return nodeTest(...args); +} diff --git a/tests/shard.test.mjs b/tests/shard.test.mjs new file mode 100644 index 0000000..c08d9d7 --- /dev/null +++ b/tests/shard.test.mjs @@ -0,0 +1,126 @@ +// tests/shard.mjs: a slow test file's cases live in .cases.mjs and +// import test from shard.mjs; each .shard.test.mjs sets its shard and +// imports the cases, so run-tests runs the shards in separate processes. Run +// directly, or through tests/index.js, the cases file registers every test. +// Each case copies shard.mjs (and index.js) into a temporary tests/ directory +// beside fixture files whose tests record which process ran them. + +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { SKILL_DIR } from './harness.mjs'; +import { tempDir } from './platform.mjs'; + +const TMP = tempDir('pl-shard-'); +after(() => rmSync(TMP, { recursive: true, force: true })); + +let count = 0; + +// A tests/ directory with shard.mjs and index.js, a demo.cases.mjs of five +// tests t0..t4 (each writes out/.), and `shards` wrapper files. +function project(shards) { + const root = join(TMP, `p${++count}`); + const tests = join(root, 'tests'); + const out = join(root, 'out'); + mkdirSync(tests, { recursive: true }); + mkdirSync(out); + copyFileSync(join(SKILL_DIR, 'tests', 'shard.mjs'), join(tests, 'shard.mjs')); + copyFileSync(join(SKILL_DIR, 'tests', 'index.js'), join(tests, 'index.js')); + const record = (name) => ` writeFileSync(${JSON.stringify(out)} + '/${name}.' + process.pid, '');`; + writeFileSync(join(tests, 'demo.cases.mjs'), [ + "import { writeFileSync } from 'node:fs';", + "import { test } from './shard.mjs';", + ...[0, 1, 2, 3, 4].map((i) => `test('t${i}', () => {\n${record(`t${i}`)}\n});`), + '', + ].join('\n')); + writeFileSync(join(tests, 'plain.test.mjs'), [ + "import { writeFileSync } from 'node:fs';", + "import { test } from 'node:test';", + `test('plain', () => {\n${record('plain')}\n});`, + '', + ].join('\n')); + for (let k = 1; k <= shards; k++) { + writeFileSync(join(tests, `demo.shard${k}.test.mjs`), + `globalThis.PL_TEST_SHARD = { index: ${k - 1}, count: ${shards} };\nawait import('./demo.cases.mjs');\n`); + } + return { root, tests, out }; +} + +function nodeTest(cwd, args) { + const env = { ...process.env }; + delete env.NODE_TEST_CONTEXT; + const r = spawnSync(process.execPath, ['--test', ...args], { cwd, encoding: 'utf8', env }); + return { code: r.status, out: r.stdout + r.stderr }; +} + +// name -> [pid, ...] of the processes that ran it. +function ran(p) { + const runs = {}; + for (const f of readdirSync(p.out)) { + const [name, pid] = f.split('.'); + (runs[name] ||= []).push(pid); + } + return runs; +} + +test('each shard runs every count-th test, from its index, in its own process', () => { + const p = project(2); + const r = nodeTest(p.root, ['tests/demo.shard1.test.mjs', 'tests/demo.shard2.test.mjs']); + assert.equal(r.code, 0, r.out); + const runs = ran(p); + assert.deepEqual(Object.keys(runs).sort(), ['t0', 't1', 't2', 't3', 't4']); + for (const pids of Object.values(runs)) assert.equal(pids.length, 1, JSON.stringify(runs)); + assert.equal(new Set([runs.t0[0], runs.t2[0], runs.t4[0]]).size, 1, JSON.stringify(runs)); + assert.equal(runs.t1[0], runs.t3[0], JSON.stringify(runs)); + assert.notEqual(runs.t0[0], runs.t1[0], JSON.stringify(runs)); +}); + +test('the cases file run directly registers every test', () => { + const p = project(3); + const r = nodeTest(p.root, ['tests/demo.cases.mjs']); + assert.equal(r.code, 0, r.out); + assert.deepEqual(Object.keys(ran(p)).sort(), ['t0', 't1', 't2', 't3', 't4']); +}); + +test('node --test tests/ (index.js) runs each sharded test once and the plain files too', () => { + const p = project(3); + const r = nodeTest(p.root, ['tests/']); + assert.equal(r.code, 0, r.out); + const runs = ran(p); + assert.deepEqual(Object.keys(runs).sort(), ['plain', 't0', 't1', 't2', 't3', 't4']); + for (const pids of Object.values(runs)) assert.equal(pids.length, 1, JSON.stringify(runs)); +}); + +test('a malformed shard setting fails the file instead of running every test', () => { + const p = project(0); + writeFileSync(join(p.tests, 'demo.shard1.test.mjs'), + "globalThis.PL_TEST_SHARD = { index: 2, count: 2 };\nawait import('./demo.cases.mjs');\n"); + const r = nodeTest(p.root, ['tests/demo.shard1.test.mjs']); + assert.notEqual(r.code, 0, r.out); + assert.match(r.out, /PL_TEST_SHARD/); + assert.deepEqual(readdirSync(p.out), []); +}); + +test('every shard wrapper in this repo names an existing cases file and the shards cover it', () => { + const dir = join(SKILL_DIR, 'tests'); + const wrappers = readdirSync(dir).filter((f) => /\.shard\d+\.test\.mjs$/.test(f)); + const byBase = {}; + for (const f of wrappers) { + const text = readFileSync(join(dir, f), 'utf8'); + const m = text.match(/PL_TEST_SHARD = \{ index: (\d+), count: (\d+) \};\nawait import\('\.\/([a-z0-9-]+)\.cases\.mjs'\);/); + assert.ok(m, `${f}: unexpected wrapper text`); + const base = f.replace(/\.shard\d+\.test\.mjs$/, ''); + assert.equal(m[3], base, f); + assert.equal(Number(f.match(/\.shard(\d+)\./)[1]), Number(m[1]) + 1, f); + (byBase[base] ||= { count: Number(m[2]), seen: [] }).seen.push(Number(m[1])); + } + for (const [base, { count: n, seen }] of Object.entries(byBase)) { + assert.deepEqual(seen.sort((a, b) => a - b), [...Array(n).keys()], base); + assert.ok(readdirSync(dir).includes(`${base}.cases.mjs`), base); + } + for (const f of readdirSync(dir).filter((x) => x.endsWith('.cases.mjs'))) { + assert.ok(byBase[f.replace(/\.cases\.mjs$/, '')], `${f} has no shard wrappers`); + } +}); diff --git a/tests/task-checks.test.mjs b/tests/task-checks.cases.mjs similarity index 99% rename from tests/task-checks.test.mjs rename to tests/task-checks.cases.mjs index 38c0e48..e3cdca1 100644 --- a/tests/task-checks.test.mjs +++ b/tests/task-checks.cases.mjs @@ -5,7 +5,8 @@ // check; the three offered lists differ only in t.mjs's argument, so the // recorded results show which list ran. -import { test, after } from 'node:test'; +import { after } from 'node:test'; +import { test } from './shard.mjs'; import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; diff --git a/tests/task-checks.shard1.test.mjs b/tests/task-checks.shard1.test.mjs new file mode 100644 index 0000000..435ae90 --- /dev/null +++ b/tests/task-checks.shard1.test.mjs @@ -0,0 +1,4 @@ +// Shard 1 of 2 of tests/task-checks.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 0, count: 2 }; +await import('./task-checks.cases.mjs'); diff --git a/tests/task-checks.shard2.test.mjs b/tests/task-checks.shard2.test.mjs new file mode 100644 index 0000000..739bf07 --- /dev/null +++ b/tests/task-checks.shard2.test.mjs @@ -0,0 +1,4 @@ +// Shard 2 of 2 of tests/task-checks.cases.mjs (tests/shard.mjs): run-tests runs each shard in +// its own process. +globalThis.PL_TEST_SHARD = { index: 1, count: 2 }; +await import('./task-checks.cases.mjs'); From 1d47b435c624f2dec63174c29a5b7ff9da73d975 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:12:50 -0700 Subject: [PATCH 2/3] feat: every agent keeps scratch files in its own mktemp -d folder --- run.workflow.js | 4 ++-- src/prompts-phase.js | 2 -- src/rules.js | 2 ++ tests/check-policy-prompts.test.mjs | 11 +++++++++++ tests/phase-prompts.test.mjs | 11 ++++++----- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/run.workflow.js b/run.workflow.js index 2b5f9e9..1a1387a 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -71,6 +71,8 @@ function agentRules() { 'Read a file right before you edit or overwrite it (and again after anything else may have changed it): ' + 'an Edit or Write on a file not read first, or changed since, fails and costs a retry. ' + 'Read it with the Read tool: output of grep, sed or cat through Bash does not count.', + 'Put any scratch file or copy (redirected test output, helper scripts) in your own folder made with mktemp -d,' + + " outside the checkout, the project and the session's scratchpad, and remove it when done: agents running at the same time share that scratchpad.", ].join('\n'); } @@ -3429,8 +3431,6 @@ function finalReviewFrame(m, intro, focus, minors = [], other = false) { ...fixMessagesLines(), 'Run only the focused checks a finding needs; do not run the full project commands.' + (finalChecks(m).length > 0 ? ' Verify runs every check at the delivered revision after the review.' : ''), - 'Put any scratch copy or file in your own folder made with mktemp -d, outside the checkout,' - + " the project and the session's scratchpad, and remove it when done.", phaseRules(m), '', 'Return findings = [{severity ("critical", "important", or "minor"), file, line (0 when no single line', diff --git a/src/prompts-phase.js b/src/prompts-phase.js index 3fcf7ef..5ee8a10 100644 --- a/src/prompts-phase.js +++ b/src/prompts-phase.js @@ -1065,8 +1065,6 @@ function finalReviewFrame(m, intro, focus, minors = [], other = false) { ...fixMessagesLines(), 'Run only the focused checks a finding needs; do not run the full project commands.' + (finalChecks(m).length > 0 ? ' Verify runs every check at the delivered revision after the review.' : ''), - 'Put any scratch copy or file in your own folder made with mktemp -d, outside the checkout,' - + " the project and the session's scratchpad, and remove it when done.", phaseRules(m), '', 'Return findings = [{severity ("critical", "important", or "minor"), file, line (0 when no single line', diff --git a/src/rules.js b/src/rules.js index 93f149b..2c453b5 100644 --- a/src/rules.js +++ b/src/rules.js @@ -56,6 +56,8 @@ function agentRules() { 'Read a file right before you edit or overwrite it (and again after anything else may have changed it): ' + 'an Edit or Write on a file not read first, or changed since, fails and costs a retry. ' + 'Read it with the Read tool: output of grep, sed or cat through Bash does not count.', + 'Put any scratch file or copy (redirected test output, helper scripts) in your own folder made with mktemp -d,' + + " outside the checkout, the project and the session's scratchpad, and remove it when done: agents running at the same time share that scratchpad.", ].join('\n'); } diff --git a/tests/check-policy-prompts.test.mjs b/tests/check-policy-prompts.test.mjs index 0f493eb..ec8973b 100644 --- a/tests/check-policy-prompts.test.mjs +++ b/tests/check-policy-prompts.test.mjs @@ -237,6 +237,17 @@ test('the fix fallback is not the implement fallback', () => { // ---- 1.6.6: agents write long files with Edit and Write (spec 4.6) +// r168: seven implementers wrote red.txt and green.txt in the session's +// scratchpad, and two of them overwrote each other's file within a minute. +const SCRATCH_RULE = 'Put any scratch file or copy (redirected test output, helper scripts) in your own folder made with mktemp -d, outside the checkout, the project and the session\'s scratchpad, and remove it when done: agents running at the same time share that scratchpad.'; + +test('every prompt keeps scratch files in its own mktemp -d folder, once', () => { + for (const [name, p] of Object.entries(everyPrompt())) { + assert.equal(p.split('\n').filter((l) => l === SCRATCH_RULE).length, 1, `${name}: ${p}`); + } + assert.ok(agentRules().split('\n').includes(SCRATCH_RULE), agentRules()); +}); + test('agentRules names the Bash command limit and how to add to a file', () => { const text = flat(agentRules()); assert.ok(text.includes('7,000 characters'), text); diff --git a/tests/phase-prompts.test.mjs b/tests/phase-prompts.test.mjs index 8620515..d8c23c2 100644 --- a/tests/phase-prompts.test.mjs +++ b/tests/phase-prompts.test.mjs @@ -449,8 +449,9 @@ const { finalReviewPrompt, combinedFinalReviewPrompt } = await loadHelpers(['fin const FOCUSED = 'Run only the focused checks a finding needs; do not run the full project commands.'; const VERIFY_RUNS = ' Verify runs every check at the delivered revision after the review.'; -const SCRATCH = 'Put any scratch copy or file in your own folder made with mktemp -d, outside the checkout,' - + " the project and the session's scratchpad, and remove it when done."; +// From 1.6.9 the scratch rule is in agentRules, so every agent gets it, the +// final reviewers included. +const SCRATCH = 'Put any scratch file or copy (redirected test output, helper scripts) in your own folder made with mktemp -d, outside the checkout, the project and the session\'s scratchpad, and remove it when done: agents running at the same time share that scratchpad.'; test('final reviewers run focused checks in their own scratch folder', () => { const finalPrompts = (m) => [ @@ -462,10 +463,10 @@ test('final reviewers run focused checks in their own scratch folder', () => { for (const [name, p] of finalPrompts(m)) { const lines = p.split('\n'); assert.ok(lines.includes(FOCUSED + VERIFY_RUNS), `${name}: ${p}`); - assert.ok(lines.includes(SCRATCH), `${name}: ${p}`); - // Both come before the phase rules. + assert.equal(lines.filter((l) => l === SCRATCH).length, 1, `${name}: ${p}`); + // The focused-checks line comes before the phase rules, which hold the scratch rule. const rulesAt = p.indexOf('Never amend, rebase, reset, or force-update a branch.'); - assert.ok(rulesAt > p.indexOf(SCRATCH) && p.indexOf(SCRATCH) > p.indexOf(FOCUSED), `${name}: order`); + assert.ok(p.indexOf(SCRATCH) > rulesAt && rulesAt > p.indexOf(FOCUSED), `${name}: order`); } // No project checks: nothing for verify to run, so no verify sentence. const bare = manifest({ commands: { setup: [], test: [], lint: [], build: [] } }); From 45d13f990a15952658ab9b5a0040da535025ad0e Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:13:52 -0700 Subject: [PATCH 3/3] chore: release 1.6.9 --- VERSION | 2 +- run.workflow.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index d8c5e72..15d45d4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.8 +1.6.9 diff --git a/run.workflow.js b/run.workflow.js index 1a1387a..0884445 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -1,6 +1,6 @@ export const meta = { name: 'parallel-lanes', - description: 'parallel-lanes v1.6.8: Execute an implementation plan as parallel lanes of tasks with per-task review, then integrate.', + description: 'parallel-lanes v1.6.9: Execute an implementation plan as parallel lanes of tasks with per-task review, then integrate.', phases: [ { title: 'Pre-flight', detail: 'plan and spec conflicts' }, { title: 'Prelude', detail: 'shared tasks on the feature branch' }, @@ -6806,7 +6806,7 @@ function noHead(r, label) { // The skill version this script was built from (scripts/build stamps VERSION // here and in the meta description): the version actually running. Logged // first and returned with every result, dry runs included. -const VERSION = '1.6.8'; +const VERSION = '1.6.9'; log(`parallel-lanes v${VERSION} loaded`); if (args !== null && typeof args === 'object' && args.dry_run === true) {