Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>.cases.mjs`, split across `<name>.shard<k>.test.mjs` by `tests/shard.mjs`; run a cases file directly to run all of its tests |

## License

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.6.8
1.6.9
8 changes: 4 additions & 4 deletions run.workflow.js
Original file line number Diff line number Diff line change
@@ -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' },
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 0 additions & 2 deletions src/prompts-phase.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down
11 changes: 11 additions & 0 deletions tests/check-policy-prompts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion tests/cleanup.test.mjs → tests/cleanup.cases.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions tests/cleanup.shard1.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
4 changes: 4 additions & 0 deletions tests/cleanup.shard2.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
4 changes: 4 additions & 0 deletions tests/cleanup.shard3.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
4 changes: 4 additions & 0 deletions tests/cleanup.shard4.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
4 changes: 4 additions & 0 deletions tests/cleanup.shard5.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
3 changes: 2 additions & 1 deletion tests/doctor.test.mjs → tests/doctor.cases.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 4 additions & 0 deletions tests/doctor.shard1.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
4 changes: 4 additions & 0 deletions tests/doctor.shard2.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
4 changes: 4 additions & 0 deletions tests/doctor.shard3.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
9 changes: 6 additions & 3 deletions tests/index.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
// 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
// <name>.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');
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 () => {
Expand Down
11 changes: 6 additions & 5 deletions tests/phase-prompts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => [
Expand All @@ -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: [] } });
Expand Down
14 changes: 9 additions & 5 deletions tests/run-tests.mjs
Original file line number Diff line number Diff line change
@@ -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...]
//
Expand All @@ -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}`);
Expand Down
24 changes: 21 additions & 3 deletions tests/run-tests.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
}
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion tests/setup-script.test.mjs → tests/setup-script.cases.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 4 additions & 0 deletions tests/setup-script.shard1.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
4 changes: 4 additions & 0 deletions tests/setup-script.shard2.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
4 changes: 4 additions & 0 deletions tests/setup-script.shard3.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
3 changes: 2 additions & 1 deletion tests/shadow.test.mjs → tests/shadow.cases.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 4 additions & 0 deletions tests/shadow.shard1.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
4 changes: 4 additions & 0 deletions tests/shadow.shard2.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
26 changes: 26 additions & 0 deletions tests/shard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Splits one slow test file across several processes. Its tests live in
// tests/<name>.cases.mjs, which imports test from here instead of node:test;
// each tests/<name>.shard<k>.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/<name>.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);
}
Loading
Loading