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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The versioned CLI help owns command semantics. To drive an app, start with the s
- In a new worktree, run `pnpm install --frozen-lockfile && pnpm build` before you test.
- Do not change other worktrees, devices, sessions, dirty files, or experiments. Do not use `git stash`; all worktrees share it.
- Do not copy `.env` into a worktree. For host credentials, source the main checkout's file by its absolute path.
- Give each parallel worker different files and a different device. `pnpm check:affected --run` and `pnpm check` run one at a time automatically.
- Give each parallel worker different files and a different device. `pnpm check:affected --run`, `pnpm check`, and heavy `pnpm gate` ids share one lock.
- The layering scan reads only tracked files. Stage new modules before you trust its result.
- When you rename a file, move its Fallow baseline entry. Do not regenerate unrelated baselines.

Expand Down
2 changes: 1 addition & 1 deletion docs/agents/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

Serialized validation requires macOS or Linux.
On Windows, use plan mode or focused checks.
Both broad commands acquire the same host lock.
Both broad commands and heavy `pnpm gate` ids acquire the same host lock.
Wait for the owner to finish, or cancel your waiter with Ctrl-C.
Do not add lock wrappers. Do not delete lock files.
A wait ends after two hours. Nested validation fails immediately.
Expand Down
28 changes: 20 additions & 8 deletions scripts/check-affected/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,17 @@ export type CheckSpec = {
// torture), and the instrumented coverage run stay authoritative on
// GitHub CI.
readonly localRunnable: boolean;
// Whether `pnpm gate` takes the host validation lock before it runs the check. Set it for a check that
// runs a whole-repo suite or spawns many workers.
readonly hostLocked: boolean;
};

function gate(id: CheckId, label: string, script: string, localRunnable = true): CheckSpec {
return { id, label, kind: { type: 'script', script }, localRunnable };
return { id, label, kind: { type: 'script', script }, localRunnable, hostLocked: false };
}

function heavyGate(id: CheckId, label: string, script: string, localRunnable = true): CheckSpec {
return { ...gate(id, label, script, localRunnable), hostLocked: true };
}

export const CHECK_CATALOG: readonly CheckSpec[] = [
Expand Down Expand Up @@ -62,12 +69,17 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [
label: 'Tests related by Vitest module graph',
kind: { type: 'vitest-related' },
localRunnable: true,
hostLocked: false,
},
gate('unit', 'Unit + smoke suite', 'check:unit'),
heavyGate('unit', 'Unit + smoke suite', 'check:unit'),
// CI owns the default coverage run; the scripts remain available for explicit local diagnosis.
gate('coverage', 'Changed-line coverage', 'check:coverage-changed', false),
gate('coverage-crap', 'Coverage-based CRAP verdict', 'check:coverage-crap', false),
gate('provider-integration', 'Provider-backed integration suite', 'test:integration:provider'),
heavyGate(
'provider-integration',
'Provider-backed integration suite',
'test:integration:provider',
),
gate(
'integration-progress',
'Integration architecture-progress gate',
Expand Down Expand Up @@ -97,7 +109,7 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [
//
// `unit-ci` is the CI form of the unit suite under coverage. Local affected checks use Vitest's
// related graph without instrumentation; CI owns the full coverage run and changed-line verdict.
gate('unit-ci', 'CI unit suite under coverage', 'test:coverage:ci', false),
heavyGate('unit-ci', 'CI unit suite under coverage', 'test:coverage:ci', false),
gate('affected-selector', 'Affected-check selector model', 'check:affected:test'),
gate('gate-manifest', 'Gate manifest — every gate owned and wired', 'check:gate-manifest'),
gate('gate-manifest-model', 'Gate manifest model', 'check:gate-manifest:test'),
Expand Down Expand Up @@ -153,12 +165,12 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [
'maestro:conformance:regenerate',
false,
),
gate('fuzz-parsers', 'Parser fuzz invariants', 'fuzz:parsers', false),
gate('mutation', 'Mutation sweep', 'mutation:run', false),
gate('mutation-affected', 'Affected mutation shard selection', 'mutation:affected', false),
heavyGate('fuzz-parsers', 'Parser fuzz invariants', 'fuzz:parsers', false),
heavyGate('mutation', 'Mutation sweep', 'mutation:run', false),
heavyGate('mutation-affected', 'Affected mutation shard selection', 'mutation:affected', false),
gate('mutation-check', 'Mutation score for an existing report', 'mutation:check', false),
gate('mutation-model', 'Mutation harness self-test', 'mutation:test'),
gate(
heavyGate(
'concurrency-torture',
'Session/lease/lock torture sweep',
'test:concurrency-torture',
Expand Down
53 changes: 16 additions & 37 deletions scripts/check-affected/command-execution.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { runCmdStreaming } from '@agent-device/host-kit/command';
import { VALIDATION_PARENT_ENV, type ValidationLock } from './host-lock.ts';
import { runLockedCommand, type ValidationLock } from './host-lock.ts';
import type { CheckResult, ValidationRun } from './execution.ts';
import type { TestReport } from './test-report.ts';

Expand All @@ -25,42 +24,23 @@ export async function executeValidationCommand(
() => run.report(`[running] ${result.id} ${Math.round((Date.now() - started) / 1000)}s`),
15_000,
);
let commandPid: number | undefined;
try {
lock.recordCommand(run.receiptPath, null);
const output = await runCmdStreaming(
process.execPath,
['--experimental-strip-types', path.join(import.meta.dirname, 'command.ts'), ...command],
{
cwd,
allowFailure: true,
captureOutput: false,
detached: true,
kill: { signal: 'SIGTERM', graceMs: 5000 },
signal: run.controller.signal,
stdin: 'run\n',
onSpawn: (child) => {
commandPid = child.pid;
if (commandPid) lock.recordCommand(run.receiptPath, commandPid);
},
env: {
...process.env,
[VALIDATION_PARENT_ENV]: run.receiptPath,
AGENT_DEVICE_VALIDATION_REPORT: reportPrefix,
},
onStdoutChunk: (chunk) => {
fs.writeSync(descriptor, chunk);
},
onStderrChunk: (chunk) => {
fs.writeSync(descriptor, chunk);
},
const exitCode = await runLockedCommand(lock, run.receiptPath, command, {
cwd,
env: { ...process.env, AGENT_DEVICE_VALIDATION_REPORT: reportPrefix },
signal: run.controller.signal,
onStdoutChunk: (chunk) => {
fs.writeSync(descriptor, chunk);
},
);
result.exitCode = output.exitCode;
result.status = output.exitCode === 0 ? 'passed' : 'failed';
run.report(`[${result.status}] ${result.id}; exit=${output.exitCode}`);
if (output.exitCode !== 0) reportFailureTail(logPath, run.report);
return output.exitCode;
onStderrChunk: (chunk) => {
fs.writeSync(descriptor, chunk);
},
});
result.exitCode = exitCode;
result.status = exitCode === 0 ? 'passed' : 'failed';
run.report(`[${result.status}] ${result.id}; exit=${exitCode}`);
if (exitCode !== 0) reportFailureTail(logPath, run.report);
return exitCode;
} catch (error) {
result.status = run.controller.signal.aborted ? 'cancelled' : 'failed';
result.reason = error instanceof Error ? error.message : String(error);
Expand All @@ -71,7 +51,6 @@ export async function executeValidationCommand(
result.durationMs = Date.now() - started;
collectTestReports(result, run.directory, reportPrefix);
run.save();
await lock.finishCommand(commandPid);
}
}

Expand Down
24 changes: 3 additions & 21 deletions scripts/check-affected/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { executeValidationCommand } from './command-execution.ts';
import { acquireValidationLock } from './host-lock.ts';
import { abortOnTerminationSignal } from './termination.ts';
import { gitValue, readCheckoutState, type CheckoutState } from './checkout-state.ts';
import type { TestReport } from './test-report.ts';
import type { CheckId, CheckPlan } from './model.ts';
Expand Down Expand Up @@ -118,22 +119,7 @@ function openValidationRun(cwd: string, args: ValidationArgs) {
const save = () => fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
const report = (line: string) => process.stderr.write(`${line}\n`);
const controller = new AbortController();
const cancellation = { code: 0 };
const interrupt = () => {
cancellation.code = 130;
controller.abort();
};
const terminate = () => {
cancellation.code = 143;
controller.abort();
};
const hangup = () => {
cancellation.code = 129;
controller.abort();
};
process.on('SIGHUP', hangup);
process.on('SIGINT', interrupt);
process.on('SIGTERM', terminate);
const { cancellation, dispose } = abortOnTerminationSignal(controller);
return {
receipt,
directory,
Expand All @@ -142,11 +128,7 @@ function openValidationRun(cwd: string, args: ValidationArgs) {
cancellation,
save,
report,
dispose: () => {
process.off('SIGHUP', hangup);
process.off('SIGINT', interrupt);
process.off('SIGTERM', terminate);
},
dispose,
};
}

Expand Down
48 changes: 47 additions & 1 deletion scripts/check-affected/host-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { acquireValidationLock, VALIDATION_PARENT_ENV } from './host-lock.ts';
import { acquireValidationLock, runLockedCommand, VALIDATION_PARENT_ENV } from './host-lock.ts';

test(
'host lock excludes a waiter, supports cancellation, then admits it after release',
Expand Down Expand Up @@ -72,3 +72,49 @@ test('unsupported hosts reject validation before writing lock state', async () =
fs.rmSync(directory, { recursive: true, force: true });
}
});

test(
'a locked command publishes ownership while it runs and clears it when it finishes',
{ skip: process.platform === 'win32' },
async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'validation-run-'));
const lock = path.join(directory, 'gate.lock');
const activePath = `${lock}.active.json`;
const env = { ...process.env };
delete env[VALIDATION_PARENT_ENV];
try {
const owner = await acquireValidationLock(new AbortController().signal, lock, () => {}, env);
const observed: string[] = [];
const exitCode = await runLockedCommand(
owner,
'/fixture/receipt.json',
[
process.execPath,
'-e',
`process.stdout.write(process.env.${VALIDATION_PARENT_ENV}); process.exit(3)`,
],
{
cwd: directory,
env,
signal: new AbortController().signal,
onStdoutChunk: (chunk) => {
const active = JSON.parse(fs.readFileSync(activePath, 'utf8')) as {
receipt: string;
pid: number;
activation: string;
};
observed.push(chunk, active.receipt, active.activation);
assert.ok(active.pid > 0);
},
onStderrChunk: () => {},
},
);
assert.equal(exitCode, 3);
assert.deepEqual(observed, ['/fixture/receipt.json', '/fixture/receipt.json', 'stdin']);
assert.equal(fs.existsSync(activePath), false);
await owner.release();
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
},
);
50 changes: 48 additions & 2 deletions scripts/check-affected/host-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { runCmdStreaming } from '@agent-device/host-kit/command';
import {
publishFileSync,
inspectProcessLock,
Expand All @@ -17,6 +18,7 @@ import {
} from '@agent-device/host-kit/process';

export const VALIDATION_PARENT_ENV = 'AGENT_DEVICE_VALIDATION_PARENT';
const COMMAND_RUNNER = path.join(import.meta.dirname, 'command.ts');
const HOST_LOCK_PATH = path.join(os.homedir(), '.cache', 'agent-device', 'validation.lock');

export type ValidationLock = {
Expand All @@ -29,15 +31,16 @@ export async function acquireValidationLock(
signal: AbortSignal,
lockDirPath = HOST_LOCK_PATH,
report: (message: string) => void = (message) => process.stderr.write(`${message}\n`),
env: NodeJS.ProcessEnv = process.env,
): Promise<ValidationLock> {
if (process.platform !== 'darwin' && process.platform !== 'linux') {
throw new Error(
'Serialized validation requires macOS or Linux. Use plan mode or focused checks on this host.',
);
}
if (process.env[VALIDATION_PARENT_ENV]) {
if (env[VALIDATION_PARENT_ENV]) {
throw new Error(
`Nested validation is not allowed. Parent receipt: ${process.env[VALIDATION_PARENT_ENV]}`,
`Nested validation is not allowed. Parent receipt: ${env[VALIDATION_PARENT_ENV]}`,
);
}
const owner = { ...readCurrentOwnerIdentity(), acquiredAtMs: Date.now() };
Expand Down Expand Up @@ -70,6 +73,49 @@ export async function acquireValidationLock(
}
}

// Owns the order orphan reclaim depends on: publish ownership, activate the command, then finish.
export async function runLockedCommand(
lock: ValidationLock,
receipt: string,
command: readonly string[],
options: {
cwd: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
onStdoutChunk: (chunk: string) => void;
onStderrChunk: (chunk: string) => void;
},
): Promise<number> {
const [executable, ...args] = command as [string, ...string[]];
let commandPid: number | undefined;
try {
lock.recordCommand(receipt, null);
const output = await runCmdStreaming(
process.execPath,
['--experimental-strip-types', COMMAND_RUNNER, executable, ...args],
{
cwd: options.cwd,
allowFailure: true,
captureOutput: false,
detached: true,
kill: { signal: 'SIGTERM', graceMs: 5000 },
signal: options.signal,
stdin: 'run\n',
onSpawn: (child) => {
commandPid = child.pid;
if (commandPid) lock.recordCommand(receipt, commandPid);
},
env: { ...options.env, [VALIDATION_PARENT_ENV]: receipt },
onStdoutChunk: options.onStdoutChunk,
onStderrChunk: options.onStderrChunk,
},
);
return output.exitCode;
} finally {
await lock.finishCommand(commandPid);
}
}

function recordValidationCommand(lockDirPath: string, receipt: string, pid: number | null): void {
publishFileSync({
destination: `${lockDirPath}.active.json`,
Expand Down
26 changes: 26 additions & 0 deletions scripts/check-affected/termination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const SIGNAL_EXIT_CODES = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 } as const;

// Aborts the controller on a termination signal and records the conventional shell exit code.
export function abortOnTerminationSignal(controller: AbortController): {
cancellation: { code: number };
dispose: () => void;
} {
const cancellation = { code: 0 };
const handlers = (Object.keys(SIGNAL_EXIT_CODES) as (keyof typeof SIGNAL_EXIT_CODES)[]).map(
(signal) =>
[
signal,
() => {
cancellation.code = SIGNAL_EXIT_CODES[signal];
controller.abort();
},
] as const,
);
for (const [signal, handler] of handlers) process.on(signal, handler);
return {
cancellation,
dispose: () => {
for (const [signal, handler] of handlers) process.off(signal, handler);
},
};
}
Loading
Loading