diff --git a/.github/workflows/relayflow-pr-proof.yml b/.github/workflows/relayflow-pr-proof.yml index 6dbf27530f..ee3abd7640 100644 --- a/.github/workflows/relayflow-pr-proof.yml +++ b/.github/workflows/relayflow-pr-proof.yml @@ -13,6 +13,10 @@ on: description: Pull request number to prove required: true type: number + expected_head_sha: + description: Exact current PR head SHA to prove (required for a fresh manual dispatch) + required: true + type: string permissions: actions: read @@ -47,9 +51,14 @@ jobs: id: status env: GITHUB_TOKEN: ${{ github.token }} + # Do not interpolate workflow-dispatch input into `run`: Actions + # expands expressions before the shell parses this script. The Node + # guard validates the value after this quoted environment expansion. + EXPECTED_HEAD_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.expected_head_sha || '' }} run: >- node scripts/pr-proof/report-status.mjs start --event "$GITHUB_EVENT_PATH" + --expected-head-sha "$EXPECTED_HEAD_SHA" --github-output "$GITHUB_OUTPUT" - name: Classify PR and validate declared case diff --git a/CHANGELOG.md b/CHANGELOG.md index db252d610e..82f04e4a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cloud Daytona Fleet provisioning now requires and returns the exact provider sandbox UUID alongside the stable Cloud sandbox ID, enabling ID-bound inspection and cleanup after interrupted launches. +- Node startup and forced shutdown restrict orphan broker cleanup to the selected state directory and exclude PTY workers, preventing an isolated node from stopping other projects' agents. + ## [12.0.0] - 2026-09-10 ### Added diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index fc46d658e1..d8a094f1c7 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -980,6 +980,148 @@ describe('registerCoreCommands', () => { } ); + it('up with an isolated state directory never kills brokers or workers from an ancestor project', async () => { + const fs = createFsMock(); + const runningPids = new Set([222, 333, 444, 555, 666, 777, 9001, 4242]); + let now = 0; + const execCommand = vi.fn(async (command: string) => { + if (command === 'ps aux') { + return { + stdout: [ + 'USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND', + 'user 222 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /tmp/project/bin/agent-relay-broker init --name project --persist', + 'user 333 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /opt/bin/agent-relay-broker init --state-dir /tmp/project/peer-state --persist', + 'user 444 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /tmp/project/bin/agent-relay-broker pty --agent-name chief -- claude up --state-dir /tmp/project/candidate-state', + 'user 555 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /tmp/project/bin/agent-relay node up --state-dir /tmp/project/peer-state', + 'user 666 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /bin/zsh -c agent-relay up --state-dir /tmp/project/candidate-state', + 'user 777 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /opt/bin/agent-relay-broker init --state-dir /tmp/project/candidate-state --state-dir', + ].join('\n'), + stderr: '', + }; + } + return { stdout: 'fcwd\nn/tmp/project\n', stderr: '' }; + }); + const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + if (runningPids.has(pid)) return; + throw new Error('not running'); + } + runningPids.delete(pid); + }); + const { program } = createHarness({ + fs, + execCommand, + killImpl, + spawnedProcess: createSpawnedProcessMock({ pid: 9001 }), + nowImpl: vi.fn(() => now), + sleepImpl: vi.fn(async (ms: number) => { + now += ms; + fs.writeFileSync('/tmp/project/candidate-state/connection.json', connectionFile(4242)); + }), + }); + const exitCode = await runCommand(program, [ + 'up', + '--background', + '--state-dir', + '/tmp/project/candidate-state', + ]); + expect(exitCode).toBe(0); + expect(killImpl.mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]); + expect([...runningPids]).toEqual([222, 333, 444, 555, 666, 777, 9001, 4242]); + }); + + it('down --force with an isolated state directory kills only its broker and fails closed on ambiguity', async () => { + const runningPids = new Set([222, 333, 444, 555, 666]); + const execCommand = vi.fn(async (command: string) => { + if (command === 'ps aux') { + return { + stdout: [ + 'USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND', + 'user 222 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /opt/bin/agent-relay-broker init --state-dir /tmp/project/candidate-state --persist', + 'user 333 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /opt/bin/agent-relay-broker init --state-dir /tmp/project/peer-state --persist', + 'user 444 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /tmp/project/bin/agent-relay-broker pty --agent-name chief -- claude', + 'user 555 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /opt/bin/agent-relay-broker init --state-dir /tmp/project/candidate-state --state-dir /tmp/project/peer-state', + 'user 666 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /bin/zsh -c agent-relay up --state-dir /tmp/project/candidate-state', + ].join('\n'), + stderr: '', + }; + } + throw new Error(`unexpected command: ${command}`); + }); + const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + if (runningPids.has(pid)) return; + throw new Error('not running'); + } + runningPids.delete(pid); + }); + let now = 0; + const { program, deps } = createHarness({ + execCommand, + killImpl, + nowImpl: vi.fn(() => now), + sleepImpl: vi.fn(async (ms: number) => { + now += ms; + }), + }); + + const exitCode = await runCommand(program, [ + 'down', + '--force', + '--state-dir', + '/tmp/project/candidate-state', + ]); + + expect(exitCode).toBeUndefined(); + expect(killImpl).toHaveBeenCalledWith(222, 'SIGTERM'); + expect(killImpl).not.toHaveBeenCalledWith(333, 'SIGTERM'); + expect(killImpl).not.toHaveBeenCalledWith(444, 'SIGTERM'); + expect(killImpl).not.toHaveBeenCalledWith(555, 'SIGTERM'); + expect(killImpl).not.toHaveBeenCalledWith(666, 'SIGTERM'); + expect([...runningPids]).toEqual([333, 444, 555, 666]); + expect(deps.log).toHaveBeenCalledWith('Cleaned up (was not running)'); + }); + + it('down --force resolves a relative state directory from a nested broker cwd', async () => { + const runningPids = new Set([222, 333]); + const execCommand = vi.fn(async (command: string) => { + if (command === 'ps aux') { + return { + stdout: [ + 'USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND', + 'user 222 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /opt/bin/agent-relay-broker init --state-dir candidate-state --persist', + 'user 333 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /opt/bin/agent-relay-broker init --state-dir /tmp/project/peer-state --persist', + ].join('\n'), + stderr: '', + }; + } + if (command === 'lsof -nP -a -p 222 -d cwd -Fn') { + return { stdout: 'fcwd\nn/tmp/project/nested\n', stderr: '' }; + } + throw new Error(`unexpected command: ${command}`); + }); + const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + if (runningPids.has(pid)) return; + throw new Error('not running'); + } + runningPids.delete(pid); + }); + const { program } = createHarness({ execCommand, killImpl }); + + const exitCode = await runCommand(program, [ + 'down', + '--force', + '--state-dir', + '/tmp/project/nested/candidate-state', + ]); + + expect(exitCode).toBeUndefined(); + expect(killImpl).toHaveBeenCalledWith(222, 'SIGTERM'); + expect(killImpl).not.toHaveBeenCalledWith(333, 'SIGTERM'); + expect([...runningPids]).toEqual([333]); + }); + it('down --force only kills actual orphaned broker executables for the project', async () => { const runningPids = new Set([222, 444, 666]); const execCommand = vi.fn(async (command: string) => { @@ -994,6 +1136,8 @@ describe('registerCoreCommands', () => { 'khaliqgant 555 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /opt/bin/agent-relay-broker init --state-dir /tmp/project-other/.agentworkforce/relay --persist', 'khaliqgant 666 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /Users/test/.agentworkforce/relay/bin/agent-relay up', 'khaliqgant 777 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /Users/test/.agentworkforce/relay/bin/agent-relay status --wait-for=30', + 'khaliqgant 888 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /tmp/project/bin/agent-relay-broker pty --agent-name chief -- claude', + 'khaliqgant 999 0.0 0.0 1 1 ?? S 1:00PM 0:00.01 /tmp/project/bin/agent-relay-broker init --state-dir /tmp/project/peer-state --persist', ].join('\n'), stderr: '', }; @@ -1036,6 +1180,8 @@ describe('registerCoreCommands', () => { expect(killImpl).not.toHaveBeenCalledWith(333, 'SIGTERM'); expect(killImpl).not.toHaveBeenCalledWith(555, 'SIGTERM'); expect(killImpl).not.toHaveBeenCalledWith(777, 'SIGTERM'); + expect(killImpl).not.toHaveBeenCalledWith(888, 'SIGTERM'); + expect(killImpl).not.toHaveBeenCalledWith(999, 'SIGTERM'); expect(deps.warn).toHaveBeenCalledWith('Killing orphaned broker process (pid: 222)'); expect(deps.warn).toHaveBeenCalledWith('Killing orphaned broker process (pid: 444)'); expect(deps.warn).toHaveBeenCalledWith('Killing orphaned broker process (pid: 666)'); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index 1a8b1a1c1e..9452e1afe2 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -1016,11 +1016,18 @@ function isAttachedBrokerCliCommand(command: string): boolean { if (!/(?:^|\s)up(?:\s|$)/.test(command) || /(?:^|\s)--background(?:\s|=|$)/.test(command)) { return false; } - return /(?:^|\s)(?:\S*agent-relay(?:\.js)?|\S*agent-relay-[^\s]+)(?:\s|$)/.test(command); + const words = command.trim().split(/\s+/); + const executable = commandExecutableBasename(command); + // A shell or worker prompt mentioning `agent-relay up` is not the CLI. + const cli = ['node', 'bun'].includes(executable) ? path.basename(words[1] ?? '') : executable; + return /^(?:agent-relay(?:\.js)?|agent-relay-(?:darwin|linux|win32)[\w.-]*)$/.test(cli); } function isBrokerProcessCommand(command: string): boolean { - return isBrokerExecutableCommand(command) || isAttachedBrokerCliCommand(command); + // PTY workers use the same executable as their broker. They are never + // orphan broker candidates, even when their executable lives in this project. + if (isBrokerExecutableCommand(command)) return command.trim().split(/\s+/)[1] === 'init'; + return isAttachedBrokerCliCommand(command); } function escapeRegExp(value: string): string { @@ -1029,27 +1036,36 @@ function escapeRegExp(value: string): string { function commandHasBrokerName(command: string, brokerName: string): boolean { const escapedName = escapeRegExp(brokerName); - return new RegExp(`(?:^|\\s)--name(?:\\s+|=)${escapedName}(?:\\s|$)`).test(command); + return new RegExp(`(?:^|\\s)--(?:name|broker-name|instance-name)(?:\\s+|=)${escapedName}(?:\\s|$)`).test( + command + ); } -function commandHasProjectRoot(command: string, projectRoot: string): boolean { - const escapedRoot = escapeRegExp(path.resolve(projectRoot)); - return new RegExp(`(?:^|\\s|=|["'])${escapedRoot}(?:$|\\s|["']|${escapeRegExp(path.sep)})`).test(command); +function commandStateDirectory(command: string): string | null | undefined { + const flags = [...command.matchAll(/(?:^|\s)--state-dir(?=\s|=|$)/g)]; + if (flags.length === 0) return undefined; + if (flags.length !== 1) return null; + const matches = [...command.matchAll(/(?:^|\s)--state-dir(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/g)]; + // Ambiguous or incomplete process listings cannot establish ownership. + if (matches.length !== 1) return null; + return matches[0][1] ?? matches[0][2] ?? matches[0][3] ?? null; } async function processCwdMatchesProjectRoot( processInfo: ProcessInfo, projectRoot: string, deps: CoreDependencies -): Promise { +): Promise { try { const cwdDetails = await deps.execCommand(`lsof -nP -a -p ${processInfo.pid} -d cwd -Fn`); - return cwdDetails.stdout + const matchingCwd = cwdDetails.stdout .split('\n') .filter((line) => line.startsWith('n')) - .some((line) => path.resolve(line.slice(1)) === projectRoot); + .map((line) => path.resolve(line.slice(1))) + .find((cwd) => cwd === projectRoot || cwd.startsWith(`${projectRoot}${path.sep}`)); + return matchingCwd ?? null; } catch { - return false; + return null; } } @@ -1074,15 +1090,21 @@ async function terminateProcess(pid: number, deps: CoreDependencies, force: bool } async function killOrphanedBrokerProcesses( - projectRoot: string, + paths: CoreProjectPaths, deps: CoreDependencies, - options?: { force?: boolean } + options?: { force?: boolean; brokerName?: string } ): Promise<{ matchedCount: number; killedCount: number }> { let matchedCount = 0; let killedCount = 0; try { - const resolvedProjectRoot = path.resolve(projectRoot); - const brokerName = path.basename(resolvedProjectRoot) || 'project'; + const resolvedProjectRoot = path.resolve(paths.projectRoot); + const stateDirectory = path.resolve(paths.dataDir); + const usesDefaultStateDirectory = + stateDirectory === path.join(resolvedProjectRoot, '.agentworkforce/relay'); + const brokerName = + options?.brokerName ?? + deps.env.AGENT_RELAY_BROKER_NAME ?? + (path.basename(resolvedProjectRoot) || 'project'); const candidates: ProcessInfo[] = []; try { const processList = await deps.execCommand('ps aux'); @@ -1092,20 +1114,25 @@ async function killOrphanedBrokerProcesses( .filter((process): process is ProcessInfo => process !== null) .filter((process) => isBrokerProcessCommand(process.command)); - const matchedPids = new Set(); - for (const processInfo of relayProcesses) { - if (commandHasProjectRoot(processInfo.command, resolvedProjectRoot)) { - candidates.push(processInfo); - matchedPids.add(processInfo.pid); - } - } - for (const processInfo of relayProcesses) { - if (matchedPids.has(processInfo.pid)) { + const declaredStateDirectory = commandStateDirectory(processInfo.command); + if (declaredStateDirectory !== undefined) { + if (declaredStateDirectory === null) continue; + if (path.isAbsolute(declaredStateDirectory)) { + if (path.resolve(declaredStateDirectory) === stateDirectory) candidates.push(processInfo); + } else { + const processCwd = await processCwdMatchesProjectRoot(processInfo, resolvedProjectRoot, deps); + if (processCwd && path.resolve(processCwd, declaredStateDirectory) === stateDirectory) + candidates.push(processInfo); + } continue; } - const cwdMatches = await processCwdMatchesProjectRoot(processInfo, resolvedProjectRoot, deps); - if (!cwdMatches) continue; + // A custom state directory must never adopt a process using the default + // directory. An executable or arbitrary argument under an ancestor + // project (including the user's home directory) proves no ownership. + if (!usesDefaultStateDirectory) continue; + const processCwd = await processCwdMatchesProjectRoot(processInfo, resolvedProjectRoot, deps); + if (processCwd !== resolvedProjectRoot) continue; if ( isBrokerExecutableCommand(processInfo.command) && !commandHasBrokerName(processInfo.command, brokerName) @@ -1113,7 +1140,6 @@ async function killOrphanedBrokerProcesses( continue; } candidates.push(processInfo); - matchedPids.add(processInfo.pid); } } catch { // Expected if ps is unavailable; fall through to no matches. @@ -1161,7 +1187,8 @@ async function waitForProcessExit(pid: number, timeoutMs: number, deps: CoreDepe async function recoverHalfStartedBroker( paths: CoreProjectPaths, - deps: CoreDependencies + deps: CoreDependencies, + brokerName?: string ): Promise<'running' | 'recovered' | 'clear' | 'blocked'> { deps.fs.mkdirSync(paths.dataDir, { recursive: true }); const readiness = await waitForBrokerReadiness(paths, deps, 0, true); @@ -1185,7 +1212,7 @@ async function recoverHalfStartedBroker( return 'recovered'; } - const orphanCleanup = await killOrphanedBrokerProcesses(paths.projectRoot, deps, { force: true }); + const orphanCleanup = await killOrphanedBrokerProcesses(paths, deps, { force: true, brokerName }); if (orphanCleanup.matchedCount > 0) { if (orphanCleanup.killedCount < orphanCleanup.matchedCount) { deps.error( @@ -1660,7 +1687,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): } if (options.background) { - const preflight = await recoverHalfStartedBroker(paths, deps); + const preflight = await recoverHalfStartedBroker(paths, deps, options.brokerName); if (preflight === 'running') { const pid = readBrokerPid(paths.dataDir, deps); deps.error( @@ -1894,7 +1921,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): // Kill any orphaned broker processes for this project that lost their PID // files (e.g. user deleted .agentworkforce/relay/ while broker was running). vlog(deps, options.verbose, 'Checking for orphaned broker processes...'); - await killOrphanedBrokerProcesses(paths.projectRoot, deps); + await killOrphanedBrokerProcesses(paths, deps, { brokerName: options.brokerName }); const started = await startBrokerWithPortFallback( paths, @@ -2100,7 +2127,7 @@ export async function runDownCommand(options: DownOptions, deps: CoreDependencie const conn = readBrokerConnectionFromFs(deps.fs, paths.dataDir); if (!conn) { if (options.force) { - await killOrphanedBrokerProcesses(paths.projectRoot, deps, { force: true }); + await killOrphanedBrokerProcesses(paths, deps, { force: true }); cleanupBrokerFiles(paths, deps); deps.log('Cleaned up (was not running)'); } else { diff --git a/scripts/pr-proof/report-status.mjs b/scripts/pr-proof/report-status.mjs index b706f5ebe5..399e28543b 100644 --- a/scripts/pr-proof/report-status.mjs +++ b/scripts/pr-proof/report-status.mjs @@ -63,6 +63,23 @@ export async function resolvePullRequest({ payload, repository, apiUrl, token, f return { number, headSha }; } +/** + * A GitHub Actions rerun retains the original event payload and workflow SHA. + * Manual proof dispatches therefore carry the caller's observed current head + * and refuse to publish a pending status if that head has moved meanwhile. + */ +export function assertExpectedHeadSha(expectedHeadSha, actualHeadSha) { + if (!expectedHeadSha) return; + if (!SHA_RE.test(expectedHeadSha)) { + throw new Error('Expected PR proof head SHA must be a full lowercase SHA'); + } + if (expectedHeadSha !== actualHeadSha) { + throw new Error( + `PR head changed before proof dispatch: expected ${expectedHeadSha}, resolved ${actualHeadSha}; dispatch a fresh proof for the resolved head` + ); + } +} + function targetUrl(env) { const server = env.GITHUB_SERVER_URL?.replace(/\/$/, ''); const repository = env.GITHUB_REPOSITORY; @@ -148,12 +165,14 @@ export async function main() { if (command === 'start') { const eventPath = option('--event', env.GITHUB_EVENT_PATH); const outputPath = option('--github-output', env.GITHUB_OUTPUT); + const expectedHeadSha = option('--expected-head-sha'); const repository = validateRepository(env.GITHUB_REPOSITORY?.trim()); const token = env.GITHUB_TOKEN?.trim(); const apiUrl = (env.GITHUB_API_URL ?? 'https://api.github.com').replace(/\/$/, ''); if (!eventPath || !token) throw new Error('GITHUB_EVENT_PATH and GITHUB_TOKEN are required'); const payload = JSON.parse(await readFile(eventPath, 'utf8')); const resolved = await resolvePullRequest({ payload, repository, apiUrl, token, fetchImpl: fetch }); + assertExpectedHeadSha(expectedHeadSha, resolved.headSha); await publishCommitStatus({ sha: resolved.headSha, state: 'pending', diff --git a/scripts/pr-proof/report-status.test.mjs b/scripts/pr-proof/report-status.test.mjs new file mode 100644 index 0000000000..198c3f75d3 --- /dev/null +++ b/scripts/pr-proof/report-status.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { assertExpectedHeadSha } from './report-status.mjs'; + +const CURRENT = 'a'.repeat(40); + +test('manual proof dispatch accepts the exact current PR head', () => { + assert.doesNotThrow(() => assertExpectedHeadSha(CURRENT, CURRENT)); +}); + +test('manual proof dispatch refuses a stale or malformed expected head before publishing status', () => { + assert.throws( + () => assertExpectedHeadSha('b'.repeat(40), CURRENT), + /PR head changed before proof dispatch/ + ); + assert.throws(() => assertExpectedHeadSha('not-a-sha', CURRENT), /Expected PR proof head SHA/); +}); diff --git a/tests/relayflows/cases/1736-scoped-orphan-cleanup/case.json b/tests/relayflows/cases/1736-scoped-orphan-cleanup/case.json new file mode 100644 index 0000000000..0005dc5464 --- /dev/null +++ b/tests/relayflows/cases/1736-scoped-orphan-cleanup/case.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "id": "1736-scoped-orphan-cleanup", + "kind": "bugfix", + "title": "Restrict isolated node cleanup to its selected state directory", + "runner": { + "command": ["node", "tests/relayflows/cases/1736-scoped-orphan-cleanup/run.mjs"] + }, + "timeoutSeconds": 900, + "expected": { + "base": { + "outcome": "bug", + "signature": "isolated_cleanup_kills_peer_or_worker" + }, + "head": { + "outcome": "fixed", + "signature": "isolated_cleanup_preserves_peer_and_worker" + } + } +} diff --git a/tests/relayflows/cases/1736-scoped-orphan-cleanup/run.mjs b/tests/relayflows/cases/1736-scoped-orphan-cleanup/run.mjs new file mode 100644 index 0000000000..d47bf67910 --- /dev/null +++ b/tests/relayflows/cases/1736-scoped-orphan-cleanup/run.mjs @@ -0,0 +1,240 @@ +import { execFileSync, spawn, spawnSync } from 'node:child_process'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const CASE_ID = '1736-scoped-orphan-cleanup'; +const targetDir = requiredDirectory('RELAY_PR_PROOF_TARGET_DIR'); +const harnessDir = requiredDirectory('RELAY_PR_PROOF_HARNESS_DIR'); +const resultPath = requiredValue('RELAY_PR_PROOF_RESULT_PATH'); +const arm = requiredValue('RELAY_PR_PROOF_ARM'); + +if (arm !== 'base' && arm !== 'head') { + throw new Error(`RELAY_PR_PROOF_ARM must be base or head, received ${JSON.stringify(arm)}.`); +} + +const expectedSha = + arm === 'base' ? process.env.RELAY_PR_PROOF_BASE_SHA : process.env.RELAY_PR_PROOF_HEAD_SHA; +if (!expectedSha) throw new Error(`Missing expected ${arm} SHA.`); +const targetSha = execFileSync('git', ['-C', targetDir, 'rev-parse', 'HEAD'], { + encoding: 'utf8', +}).trim(); +if (targetSha !== expectedSha) { + throw new Error(`Target checkout ${targetSha} does not match exact ${arm} SHA ${expectedSha}.`); +} + +const runnerPath = fileURLToPath(import.meta.url); +if (!isWithin(harnessDir, runnerPath)) { + throw new Error('The RelayFlow runner must execute from the exact-head harness checkout.'); +} + +const fixtureRoot = path.join(targetDir, '.relayflow-1736-scoped-orphan-cleanup'); +const helperPath = path.join(fixtureRoot, 'agent-relay-broker'); +const candidateState = path.join(fixtureRoot, 'candidate-state'); +const peerState = path.join(fixtureRoot, 'peer-state'); +const projectName = path.basename(targetDir); +const children = []; + +try { + run('npm', ['ci', '--ignore-scripts'], targetDir, 'workspace dependency installation'); + run('npm', ['run', 'build:session'], targetDir, 'session package build'); + run('npm', ['run', 'build:config'], targetDir, 'configuration package build'); + run('npm', ['run', 'build:cloud'], targetDir, 'Cloud package build'); + run('npm', ['run', 'build:utils'], targetDir, 'utilities package build'); + run('npm', ['run', 'build:policy'], targetDir, 'policy package build'); + run('npm', ['run', 'build:sdk'], targetDir, 'SDK package build'); + run('npm', ['run', 'build:harness-driver'], targetDir, 'harness driver package build'); + run('npm', ['run', 'build:harnesses'], targetDir, 'harnesses package build'); + run('npm', ['run', 'build:fleet'], targetDir, 'fleet package build'); + run('npm', ['run', 'build:cli'], targetDir, 'CLI package build'); + + await mkdir(fixtureRoot, { recursive: true }); + compileHelper(); + + children.push( + spawnFixture(['init', '--state-dir', candidateState, '--name', projectName, '--persist'], 'selected'), + spawnFixture(['init', '--state-dir', peerState, '--name', projectName, '--persist'], 'peer'), + spawnFixture(['init', '--name', projectName, '--persist'], 'default'), + spawnFixture(['pty', '--agent-name', 'chief', '--', 'claude'], 'pty'), + spawnFixture( + ['init', '--state-dir', candidateState, '--state-dir', peerState, '--name', projectName], + 'ambiguous' + ) + ); + children.push(spawnShellMention()); + await waitForFixtureProcesses(); + + const cliPath = path.join(targetDir, 'packages/cli/dist/cli/index.js'); + const down = spawnSync( + process.execPath, + [cliPath, 'node', 'down', '--force', '--state-dir', candidateState], + { + cwd: targetDir, + env: { + ...process.env, + AGENT_RELAY_PROJECT: targetDir, + AGENT_RELAY_TELEMETRY_DISABLED: '1', + HOME: path.join(fixtureRoot, 'home'), + }, + encoding: 'utf8', + timeout: 30_000, + } + ); + if (down.error) throw new Error(`node down could not start: ${down.error.message}`); + if (down.status !== 0) { + throw new Error(`node down failed with ${down.status}: ${down.stderr}`); + } + + await waitForExit(children[0]); + const selectedStopped = !isAlive(children[0]); + const survivors = children.slice(1).filter(isAlive); + const nonSelectedStopped = children.slice(1).some((child) => !isAlive(child)); + const headObserved = selectedStopped && survivors.length === children.length - 1; + const baseObserved = selectedStopped && nonSelectedStopped; + const outcome = arm === 'base' ? (baseObserved ? 'bug' : null) : headObserved ? 'fixed' : null; + const signature = + arm === 'base' ? 'isolated_cleanup_kills_peer_or_worker' : 'isolated_cleanup_preserves_peer_and_worker'; + if (!outcome) { + throw new Error( + `Unexpected scoped cleanup observation: ${JSON.stringify({ + arm, + selectedStopped, + survivorLabels: survivors.map((child) => child.label), + stdout: down.stdout, + stderr: down.stderr, + })}.` + ); + } + + await mkdir(path.dirname(resultPath), { recursive: true }); + await writeFile( + resultPath, + `${JSON.stringify({ + version: 1, + caseId: CASE_ID, + arm, + outcome, + signature, + details: + arm === 'base' + ? 'The base cleanup matched project-root paths rather than the selected state directory and signalled an unrelated peer, default broker, PTY worker, shell mention, or ambiguous process.' + : 'The head cleanup stopped only the broker declaring the selected state directory and preserved the peer broker, default broker, PTY worker, shell mention, and ambiguous process.', + })}\n`, + 'utf8' + ); +} finally { + for (const child of children) stopFixture(child); + await rm(fixtureRoot, { recursive: true, force: true }); +} + +function compileHelper() { + const source = '#include \nint main(void) { for (;;) pause(); }\n'; + const completed = spawnSync('cc', ['-O2', '-x', 'c', '-o', helperPath, '-'], { + input: source, + cwd: fixtureRoot, + stdio: ['pipe', 'ignore', 'pipe'], + encoding: 'utf8', + }); + if (completed.error || completed.status !== 0) { + throw new Error(`fixture helper compilation failed: ${completed.error?.message ?? completed.stderr}`); + } +} + +function spawnFixture(args, label) { + const child = spawn(helperPath, args, { + cwd: targetDir, + detached: true, + stdio: 'ignore', + }); + child.label = label; + return child; +} + +function spawnShellMention() { + const shell = spawn( + '/bin/sh', + ['-c', `${path.join(targetDir, 'bin', 'agent-relay')} up --state-dir ${candidateState}; sleep 600`], + { + cwd: targetDir, + detached: true, + stdio: 'ignore', + } + ); + shell.label = 'shell-mention'; + return shell; +} + +async function waitForFixtureProcesses() { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const listing = spawnSync('ps', ['aux'], { encoding: 'utf8' }).stdout ?? ''; + if (children.every((child) => isAlive(child) && listing.includes(childCommandMarker(child)))) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('fixture processes did not all appear in ps aux'); +} + +function childCommandMarker(child) { + return child.label === 'shell-mention' ? path.join(targetDir, 'bin', 'agent-relay') : helperPath; +} + +async function waitForExit(child) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline && isAlive(child)) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +function isAlive(child) { + try { + process.kill(child.pid, 0); + return true; + } catch { + return false; + } +} + +function stopFixture(child) { + if (!child?.pid) return; + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + try { + process.kill(child.pid, 'SIGKILL'); + } catch {} + } +} + +function run(command, args, cwd, label) { + const completed = spawnSync(command, args, { + cwd, + env: process.env, + stdio: ['ignore', 'inherit', 'inherit'], + timeout: 15 * 60 * 1000, + }); + if (completed.error) throw new Error(`${label} could not start: ${completed.error.message}`); + if (completed.status !== 0) { + throw new Error( + `${label} failed with ${completed.signal ? `signal ${completed.signal}` : `exit code ${completed.status}`}` + ); + } +} + +function requiredValue(name) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Missing required environment variable ${name}.`); + return value; +} + +function requiredDirectory(name) { + return path.resolve(requiredValue(name)); +} + +function isWithin(directory, candidate) { + const relative = path.relative(directory, candidate); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) + ); +}