Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
df441ba
fix(cli): honor --state-dir across node agent and node down/status
claude Oct 2, 2026
e801a48
fix(cli): keep exact --state-dir authoritative and recover nested orp…
claude Oct 2, 2026
fb7ba95
fix(cli): drop superseded legacy broker identity records on launch
claude Oct 2, 2026
fc96112
fix(cli): address review on --state-dir broker selection
claude Oct 2, 2026
0bad794
fix(cli): precise state-dir diagnostics and nested-broker precedence
claude Oct 2, 2026
6346e34
test(cli): isolate missing-connection message test in a temp dir
claude Oct 2, 2026
84a29f9
fix(verify): restore the shared install after a RelayFlow corpus case…
claude Oct 2, 2026
782921d
fix(verify): never let a failed install restore mask a corpus case fa…
claude Oct 2, 2026
19a8eea
fix(verify): restore corpus installs non-destructively within the com…
claude Oct 2, 2026
e3715dd
fix(verify): bound the corpus restore by the command's real timeout
claude Oct 2, 2026
5748334
Merge remote-tracking branch 'origin/main' into claude/gallant-clarke…
claude Oct 2, 2026
435af9a
fix(cli): rank state-dir evidence so stale node-dir files cannot shad…
claude Oct 2, 2026
64bbdde
fix(cli): verify identity records before using them to pick a state dir
claude Oct 2, 2026
9377ba9
fix(cli): a stale connection file no longer outranks a live nested br…
claude Oct 2, 2026
8ba3073
Merge origin/main into claude/gallant-clarke-a8atc9
claude Oct 2, 2026
aef3610
fix(cli): a reused-pid connection file no longer outranks a verified …
claude Oct 2, 2026
e69f44e
Merge origin/main into claude/gallant-clarke-a8atc9
claude Oct 3, 2026
d4ccce0
Merge origin/main (v13.1.0) into claude/gallant-clarke-a8atc9
claude Oct 3, 2026
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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,19 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Minor]

### Added

- `node agent list|spawn|new|release|set-model` and `node tail` accept `--state-dir`, `--broker-url`, and `--api-key`, so brokers started with `node up --state-dir` — such as fleet nodes — can be managed from any directory. Without these flags, `RELAY_BROKER_URL` / `RELAY_BROKER_API_KEY` or the enclosing project's broker is used.

### Fixed

- `--state-dir` on `node agent` commands, `node status`, and `node down` also accepts a fleet node directory whose broker state lives in `state/`.
- An explicit `--state-dir` on `node agent` commands is no longer overridden by `RELAY_BROKER_URL` / `RELAY_BROKER_API_KEY`.
- "No broker connection" errors name the path searched and whether it was the project default or `--state-dir`, instead of implying a running broker is down.
- `node down --force --state-dir <dir>` can verify and stop brokers started from another working directory.
- `node status` and `node down` name a missing broker identity record and how to create one.

## [13.1.0] - 2026-10-03

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ agent-relay node agent attach <name> --mode view
agent-relay node agent release <name>
```

Local-broker `node agent` subcommands and `node tail` accept `--state-dir <dir>`, `--broker-url <url>`, and `--api-key <key>` to target a broker other than the current project's — for example a fleet node started with `node up --state-dir`. `--state-dir` may name the broker state dir or a fleet node directory whose broker state lives in `state/`. An explicit `--state-dir` reads only that broker's `connection.json` (`--broker-url` may still override its URL), ignoring `RELAY_BROKER_URL` / `RELAY_BROKER_API_KEY`. Without flags, a nonblank `RELAY_BROKER_URL` or `RELAY_BROKER_API_KEY` selects the local broker; otherwise the enclosing project's broker is used, except that flag-free `attach` and `message flush|hold|auto` may auto-route to a live fleet placement. Remote routes differ: `attach --node` rejects these flags, `attach --ssh-host` rejects `--broker-url` / `--api-key`, and `message … --node` ignores them.

`node agent spawn` and `node agent new` accept `--runtime auto|native|pty`. `auto` is the default and keeps experimental dual-runtime adapters on PTY. Claude Code, Codex, and OpenCode support explicit native or PTY selection; Pi and Deep Agents are experimental native-only harnesses and require `--runtime native`.

For AI SDK native harnesses, attach renders structured activity, text, tools, approvals, files, usage, and lifecycle events. Add `--json` for NDJSON, `--reasoning` for reasoning events, or `--diagnostics` for sidecar diagnostics. Native harness `drive` is line-oriented and acknowledged; native harness `passthrough` is unsupported because no terminal stream exists. PTY attach behavior is unchanged.
Expand Down
262 changes: 261 additions & 1 deletion packages/cli/src/cli/commands/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1136,7 +1136,9 @@ describe('registerCoreCommands', () => {
const paths = { ...deps.getProjectPaths(), dataDir: stateDir };
await persistBrokerIdentity(paths, 222, 'custom-node', deps);
const filename = brokerIdentityPath(paths, deps, 'custom-node');
expect(filename).toMatch(/^\/tmp\/project\/\.agentworkforce\/relay\/broker-identity-/);
// Records live in the broker state dir, so `down --state-dir` finds them from any cwd.
expect(nodePath.dirname(filename)).toBe(stateDir);
expect(nodePath.basename(filename)).toMatch(/^broker-identity-/);
expect(deps.fs.existsSync(filename)).toBe(true);
const stateArg =
stateDir === nodePath.resolve('nested/candidate-state') ? 'nested/candidate-state' : stateDir;
Expand Down Expand Up @@ -1616,6 +1618,213 @@ describe('registerCoreCommands', () => {
expect(deps.killProcess).toHaveBeenCalledWith(222, 'SIGTERM');
});

it.each([
['the state dir itself', '/srv/dogpatch-node/state'],
['the fleet node directory (relay#1575)', '/srv/dogpatch-node'],
])(
'down --force verifies a broker started from another cwd via %s (relay#1820)',
async (_label, stateArg) => {
const stateDir = '/srv/dogpatch-node/state';
let running = true;
const fs = createFsMock({ [`${stateDir}/connection.json`]: connectionFile(222) });
const { program, deps } = createHarness({
fs,
execCommand: identityCommand(222, `${stateDir}/broker-dogpatch-mini.lock`),
killImpl: vi.fn((_pid, signal) => {
if (signal === 'SIGTERM') running = false;
if (signal === 0 && !running) throw new Error('not running');
}),
});
// `node up` ran under launchd with a different working directory, so its
// project root differs from the operator's.
const launchPaths = { ...deps.getProjectPaths(), projectRoot: '/Users/op', dataDir: stateDir };
expect(await persistBrokerIdentity(launchPaths, 222, 'dogpatch-mini', deps)).toBeDefined();
expect(nodePath.dirname(brokerIdentityPath(launchPaths, deps, 'dogpatch-mini'))).toBe(stateDir);

await runCommand(program, ['down', '--force', '--state-dir', stateArg]);

expect(deps.killProcess).toHaveBeenCalledWith(222, 'SIGTERM');
expect(deps.log).toHaveBeenCalledWith('Stopped');
expect(fs.existsSync(brokerIdentityPath(launchPaths, deps, 'dogpatch-mini'))).toBe(false);
}
);

it('down --force via the node directory recovers a nested broker whose connection file is gone', async () => {
const stateDir = '/srv/orphan-node/state';
const running = new Set([222, 333]);
const execCommand = identityCommand(222, `${stateDir}/broker-orphan.lock`);
const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => {
if (signal === 0) {
if (!running.has(pid)) throw new Error('not running');
return;
}
running.delete(pid);
});
const { program, deps } = createHarness({ execCommand, killImpl });
const paths = { ...deps.getProjectPaths(), dataDir: stateDir };
await persistBrokerIdentity(paths, 222, 'orphan', deps);
expect(deps.fs.existsSync(`${stateDir}/connection.json`)).toBe(false);

await runCommand(program, ['down', '--force', '--state-dir', '/srv/orphan-node']);

expect(killImpl).toHaveBeenCalledWith(222, 'SIGTERM');
expect([...running]).toEqual([333]);
});

it('a new launch supersedes a stale record left by an earlier release', async () => {
const stateDir = '/srv/upgraded-node/state';
let running = true;
const fs = createFsMock({ [`${stateDir}/connection.json`]: connectionFile(222) });
const { program, deps } = createHarness({
fs,
execCommand: identityCommand(222, `${stateDir}/broker-upgraded.lock`),
killImpl: vi.fn((_pid, signal) => {
if (signal === 'SIGTERM') running = false;
if (signal === 0 && !running) throw new Error('not running');
}),
});
const paths = { ...deps.getProjectPaths(), dataDir: stateDir };
await persistBrokerIdentity(paths, 222, 'upgraded', deps);
const current = brokerIdentityPath(paths, deps, 'upgraded');
const legacy = nodePath.join(paths.projectRoot, '.agentworkforce', 'relay', nodePath.basename(current));
// A record an earlier release left for a previous launch of this broker.
fs.writeFileSync(legacy, fs.readFileSync(current, 'utf-8').replace('"pid":222', '"pid":111'));

await persistBrokerIdentity(paths, 222, 'upgraded', deps);
expect(fs.existsSync(legacy)).toBe(false);

await runCommand(program, ['down', '--force', '--state-dir', stateDir]);
expect(deps.log).toHaveBeenCalledWith('Stopped');
expect(readBrokerIdentities(paths, deps)).toEqual([]);
expect(fs.existsSync(`${stateDir}/connection.json`)).toBe(false);
});

it('down --force ignores stale lock files in the node directory when the nested broker is live', async () => {
const nodeDir = '/srv/stale-lock-node';
const stateDir = `${nodeDir}/state`;
const running = new Set([222]);
const execCommand = identityCommand(222, `${stateDir}/broker-nested.lock`);
const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => {
if (signal === 0) {
if (!running.has(pid)) throw new Error('not running');
return;
}
running.delete(pid);
});
const fs = createFsMock({ [`${nodeDir}/broker-old.lock`]: '' });
const { program, deps } = createHarness({ fs, execCommand, killImpl });
await persistBrokerIdentity({ ...deps.getProjectPaths(), dataDir: stateDir }, 222, 'nested', deps);
expect(fs.existsSync(`${stateDir}/connection.json`)).toBe(false);

await runCommand(program, ['down', '--force', '--state-dir', nodeDir]);

expect(killImpl).toHaveBeenCalledWith(222, 'SIGTERM');
expect([...running]).toEqual([]);
});

it('down --force ignores a node-dir identity whose pid was reused by another process', async () => {
const nodeDir = '/srv/reused-pid-node';
const stateDir = `${nodeDir}/state`;
const running = new Set([222, 333]);
const nested = identityCommand(222, `${stateDir}/broker-nested.lock`);
const old = identityCommand(333, `${nodeDir}/broker-old.lock`);
let pidReused = false;
const execCommand = vi.fn(async (command: string) => {
if (!command.includes('-p 333 ')) return nested(command);
// After the record was written, pid 333 belongs to a different process.
if (pidReused && command.includes('lstart'))
return { stdout: 'Fri Oct 2 09:00:00 2026\n', stderr: '' };
return old(command);
});
const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => {
if (signal === 0) {
if (!running.has(pid)) throw new Error('not running');
return;
}
running.delete(pid);
});
const { program, deps } = createHarness({ execCommand, killImpl });
await persistBrokerIdentity({ ...deps.getProjectPaths(), dataDir: nodeDir }, 333, 'old', deps);
await persistBrokerIdentity({ ...deps.getProjectPaths(), dataDir: stateDir }, 222, 'nested', deps);
pidReused = true;

await runCommand(program, ['down', '--force', '--state-dir', nodeDir]);

expect(killImpl).toHaveBeenCalledWith(222, 'SIGTERM');
expect(killImpl).not.toHaveBeenCalledWith(333, 'SIGTERM');
expect([...running]).toEqual([333]);
});

it('down --force ignores a node-dir connection file whose pid was reused by another process', async () => {
const nodeDir = '/srv/reused-conn-node';
const stateDir = `${nodeDir}/state`;
const running = new Set([222, 333]);
const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => {
if (signal === 0) {
if (!running.has(pid)) throw new Error('not running');
return;
}
running.delete(pid);
});
// The stale node-dir file names pid 333, which now belongs to an unrelated process.
const fs = createFsMock({
[`${nodeDir}/connection.json`]: connectionFile(333),
[`${stateDir}/connection.json`]: connectionFile(222),
});
const { program, deps } = createHarness({
fs,
execCommand: identityCommand(222, `${stateDir}/broker-nested.lock`),
killImpl,
});
await persistBrokerIdentity({ ...deps.getProjectPaths(), dataDir: stateDir }, 222, 'nested', deps);

await runCommand(program, ['down', '--force', '--state-dir', nodeDir]);

expect(killImpl).toHaveBeenCalledWith(222, 'SIGTERM');
expect(killImpl).not.toHaveBeenCalledWith(333, 'SIGTERM');
expect([...running]).toEqual([333]);
});

it('down still verifies records written under the project by earlier releases', async () => {
const stateDir = '/srv/legacy-node/state';
let running = true;
const fs = createFsMock({ [`${stateDir}/connection.json`]: connectionFile(222) });
const { program, deps } = createHarness({
fs,
execCommand: identityCommand(222, `${stateDir}/broker-legacy.lock`),
killImpl: vi.fn((_pid, signal) => {
if (signal === 'SIGTERM') running = false;
if (signal === 0 && !running) throw new Error('not running');
}),
});
const paths = { ...deps.getProjectPaths(), dataDir: stateDir };
await persistBrokerIdentity(paths, 222, 'legacy', deps);
// Move the record to where earlier releases wrote it.
const current = brokerIdentityPath(paths, deps, 'legacy');
const legacy = nodePath.join(paths.projectRoot, '.agentworkforce', 'relay', nodePath.basename(current));
fs.writeFileSync(legacy, fs.readFileSync(current, 'utf-8'));
fs.unlinkSync(current);

await runCommand(program, ['down', '--force', '--state-dir', stateDir]);

expect(deps.killProcess).toHaveBeenCalledWith(222, 'SIGTERM');
expect(fs.existsSync(legacy)).toBe(false);
});

it('down names the missing identity record and the migration path', async () => {
const stateDir = '/srv/direct-init/state';
const fs = createFsMock({ [`${stateDir}/connection.json`]: connectionFile(3030) });
const { program, deps } = createHarness({ fs });
expect(await runCommand(program, ['down', '--force', '--state-dir', stateDir])).toBe(1);
expect(vi.mocked(deps.killProcess).mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]);
expect(deps.error).toHaveBeenCalledWith(
expect.stringContaining(`No identity record for the running broker (pid: 3030) in ${stateDir}`)
);
expect(deps.error).toHaveBeenCalledWith(
expect.stringContaining(`agent-relay node up --state-dir ${stateDir}`)
);
});

it('different names retain independent records and forced cleanup verifies each', async () => {
const running = new Set([222, 333]);
const first = identityCommand(222, '/tmp/project/.agentworkforce/relay/broker-first.lock');
Expand Down Expand Up @@ -2223,6 +2432,57 @@ describe('registerCoreCommands', () => {
expect(deps.log).toHaveBeenCalledWith('Not running');
});

it('status --state-dir accepts a fleet node directory and flags a missing identity record', async () => {
const stateDir = '/srv/sf-mini-node/state';
const fs = createFsMock({ [`${stateDir}/connection.json`]: connectionFile(4242) });
sdkStatusClient.getStatus.mockResolvedValueOnce({ agent_count: 1, pending_delivery_count: 0 });
sdkStatusClient.getSession.mockResolvedValueOnce({ workspace_key: 'rk_live_teststatus123' });
const { program, deps } = createHarness({ fs });

expect(await runCommand(program, ['status', '--state-dir', '/srv/sf-mini-node'])).toBeUndefined();

expect(deps.log).toHaveBeenCalledWith('Status: RUNNING');
expect(deps.log).toHaveBeenCalledWith('PID: 4242');
expect(deps.warn).toHaveBeenCalledWith(
expect.stringContaining(`Restart it once with \`agent-relay node up --state-dir ${stateDir}\``)
);
});

it('status --state-dir prefers a live nested broker over stale lock files in the node directory', async () => {
const nodeDir = '/srv/stale-node';
const fs = createFsMock({
[`${nodeDir}/broker-old.lock`]: '',
[`${nodeDir}/state/connection.json`]: connectionFile(4242),
});
sdkStatusClient.getStatus.mockResolvedValueOnce({ agent_count: 1, pending_delivery_count: 0 });
sdkStatusClient.getSession.mockResolvedValueOnce({ workspace_key: 'rk_live_teststatus123' });
const { program, deps } = createHarness({ fs });

expect(await runCommand(program, ['status', '--state-dir', nodeDir])).toBeUndefined();

expect(deps.log).toHaveBeenCalledWith('Status: RUNNING');
expect(deps.log).toHaveBeenCalledWith('PID: 4242');
});

it('status --state-dir prefers a live nested broker over a stale connection file in the node directory', async () => {
const nodeDir = '/srv/stale-conn-node';
const fs = createFsMock({
[`${nodeDir}/connection.json`]: connectionFile(999),
[`${nodeDir}/state/connection.json`]: connectionFile(4242),
});
sdkStatusClient.getStatus.mockResolvedValueOnce({ agent_count: 1, pending_delivery_count: 0 });
sdkStatusClient.getSession.mockResolvedValueOnce({ workspace_key: 'rk_live_teststatus123' });
const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => {
if (signal === 0 && pid === 999) throw new Error('not running');
});
const { program, deps } = createHarness({ fs, killImpl });

expect(await runCommand(program, ['status', '--state-dir', nodeDir])).toBeUndefined();

expect(deps.log).toHaveBeenCalledWith('Status: RUNNING');
expect(deps.log).toHaveBeenCalledWith('PID: 4242');
});

it('status checks broker status and prints metrics', async () => {
const connectionPath = '/tmp/project/.agentworkforce/relay/connection.json';
const fs = createFsMock({
Expand Down
Loading
Loading