From 5efef2caa03b7915c31fb1107c5518552e7ab73c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 17 Aug 2026 11:38:50 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(cli,broker):=20fleet=20agent=20list=20?= =?UTF-8?q?=E2=80=94=20fleet-wide=20names=20+=20presence=20diagnostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the gap in relay#1553: `agent-relay node agent list --pretty` answers "which agents are on THIS broker"; there was no fleet-wide equivalent, so an operator had to guess `--node ` and the "no agent named X" failure was ambiguous across three unrelated causes. ## What `agent-relay fleet agent list [--pretty|--json] [--node ] [--all]` - Enumerates every reachable fleet node from `nodes.list()` and NEVER drops one from the output — a node appears with agent rows, with an ERROR row, or with a `count only` row, but not absent (that would recreate the exact ambiguity the command exists to remove). - For the local broker, joins two maps: * `/api/spawned` — the broker's live PTY worker map. * `/api/fleet-inventory` — new HTTP route exposing the in-process `fleet_inventory` snapshot the broker publishes to the engine via `inventory.sync`. Same data path as relay#1539. Each per-agent row's `PRESENCE` column names exactly which maps saw the agent: `live+inventory`, `live only` (the #1539 shape), or `inventory only`. - Joins the workspace agent registry (`agents.list()`) so a roster gap is visible per row (`+roster` when the identity is registered) and roster-only identities without any node placement land in a distinct section — the case observed in the #1553 cleanup census where `chief-broker-grok-capability-0817-cli` posted to a channel while being absent from every worker map. - Retries each per-node call once with jitter before rendering an ERROR row, matching the transient `Node 'X' is not reachable` behaviour measured on `chief-broker`. - Prints a legend explaining `○ idle` cannot separate "finished" from "working" — these harnesses park at their prompt. Same lesson the cleanup census surfaced; a fleet list that rendered `idle` without this caveat would inherit the same ambiguity that made bulk release unsafe. ## Scope boundary Remote node agent NAMES are not enumerable via the workspace API today, so remote nodes render as `count only` rows with the honest disclaimer. When a future engine-side per-node listing lands (see follow-up), those rows become full per-agent rows without a CLI change. ## Tests - Broker: two axum route tests for `GET /api/fleet-inventory` covering the forward path and the reply-channel-closed fallback. - CLI: 11 unit tests on the pure `buildRows` / `formatPretty` join logic — including must-fire tests that flip red when the `live only` or `inventory only` classifiers are removed, and must-not-fire tests that flip red when the classifier over-triggers. Sabotage runs captured in the PR description. Refs relay#1553, relay#1539. Co-Authored-By: Claude Opus 4.7 Session-Id: 0aa1406d-f4e3-4108-aa63-1dd8f5f83b1e --- crates/broker/src/listen_api.rs | 100 +++++ crates/broker/src/runtime/api.rs | 11 + .../cli/src/cli/commands/fleet-agent.test.ts | 343 ++++++++++++++++++ packages/cli/src/cli/commands/fleet-agent.ts | Bin 0 -> 14902 bytes packages/cli/src/cli/commands/fleet.ts | 143 ++++++++ packages/harness-driver/src/client.ts | 16 + packages/harness-driver/src/protocol.ts | 14 + packages/harness-driver/src/types.ts | 5 +- 8 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/cli/commands/fleet-agent.test.ts create mode 100644 packages/cli/src/cli/commands/fleet-agent.ts diff --git a/crates/broker/src/listen_api.rs b/crates/broker/src/listen_api.rs index 1eeee0a4b..be4d4560c 100644 --- a/crates/broker/src/listen_api.rs +++ b/crates/broker/src/listen_api.rs @@ -80,6 +80,14 @@ pub enum ListenApiRequest { List { reply: tokio::sync::oneshot::Sender>, }, + /// `GET /api/fleet-inventory` — snapshot of the in-process `fleet_inventory` + /// map (what the broker last published to the engine via `inventory.sync`). + /// Callers use this alongside `List` to detect the workers-vs-inventory + /// divergence documented in #1539 — an agent live in the PTY map that was + /// never (or is no longer) present in what the engine sees. + FleetInventory { + reply: tokio::sync::oneshot::Sender>, + }, Threads { reply: tokio::sync::oneshot::Sender>, }, @@ -445,6 +453,10 @@ fn listen_api_router_with_auth( .route("/api/session/renew", routing::post(listen_api_renew_lease)) .route("/api/spawn", routing::post(listen_api_spawn)) .route("/api/spawned", routing::get(listen_api_list)) + .route( + "/api/fleet-inventory", + routing::get(listen_api_fleet_inventory), + ) .route( "/api/spawned/{name}/model", routing::post(listen_api_set_model), @@ -1172,6 +1184,24 @@ async fn listen_api_list( } } +async fn listen_api_fleet_inventory( + axum::extract::State(state): axum::extract::State, +) -> axum::Json { + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + if state + .tx + .send(ListenApiRequest::FleetInventory { reply: reply_tx }) + .await + .is_err() + { + return axum::Json(json!({ "success": false, "agents": [] })); + } + match reply_rx.await { + Ok(Ok(val)) => axum::Json(val), + _ => axum::Json(json!({ "success": false, "agents": [] })), + } +} + #[derive(Debug, Deserialize)] struct ListenApiSetModelPayload { model: String, @@ -3893,6 +3923,76 @@ mod auth_tests { list_replier.await.expect("list replier should complete"); } + #[tokio::test] + async fn fleet_inventory_route_forwards_and_returns_agents() { + // Must-fire: when the runtime reply carries an agents array, the HTTP + // response mirrors it verbatim. This is the diagnostic surface for + // #1553 / #1539 — an agent present in this map but absent from + // `/api/spawned` is exactly the divergence the CLI must flag. + let (router, mut rx) = test_router(Some("secret")); + let replier = tokio::spawn(async move { + if let Some(ListenApiRequest::FleetInventory { reply }) = rx.recv().await { + let _ = reply.send(Ok(json!({ + "node_name": "test-node", + "agents": [ + { + "agent_id": "ag_1", + "name": "worker-a", + "invocation_id": "inv_1" + } + ] + }))); + } + }); + + let response = router + .oneshot( + Request::builder() + .uri("/api/fleet-inventory") + .method("GET") + .header("x-api-key", "secret") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["node_name"], "test-node"); + assert_eq!(body["agents"][0]["name"], "worker-a"); + assert_eq!(body["agents"][0]["agent_id"], "ag_1"); + + replier.await.expect("replier should complete"); + } + + #[tokio::test] + async fn fleet_inventory_route_returns_empty_agents_on_channel_close() { + // Must-not-fire: an unreachable runtime cannot invent phantom agents. + // Empty must not be conflated with "unknown" — the CLI relies on + // `success:false` + empty agents to distinguish this from a genuinely + // empty inventory. + let (router, rx) = test_router(Some("secret")); + drop(rx); + + let response = router + .oneshot( + Request::builder() + .uri("/api/fleet-inventory") + .method("GET") + .header("x-api-key", "secret") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["success"], false); + assert_eq!(body["agents"], json!([])); + } + #[tokio::test] async fn spawn_route_forwards_extended_fields() { let (router, mut rx) = test_router(Some("secret")); diff --git a/crates/broker/src/runtime/api.rs b/crates/broker/src/runtime/api.rs index ed7665e18..838319c5f 100644 --- a/crates/broker/src/runtime/api.rs +++ b/crates/broker/src/runtime/api.rs @@ -1354,6 +1354,17 @@ impl BrokerRuntime { super::delivery::pending_message_counts(delivery_states, pending_deliveries); let _ = reply.send(Ok(json!({ "agents": workers.list(&counts) }))); } + ListenApiRequest::FleetInventory { reply } => { + // Report the in-process `fleet_inventory` map: the same + // snapshot the broker publishes to the engine via + // `inventory.sync`. Callers join this against `List` to + // detect the workers-vs-inventory divergence (#1539). + let agents: Vec<&InventoryAgent> = fleet_inventory.values().collect(); + let _ = reply.send(Ok(json!({ + "node_name": fleet_node_name, + "agents": agents, + }))); + } ListenApiRequest::Threads { reply } => { let mut messages: Vec = recent_thread_messages.iter().cloned().collect(); match relaycast_http.get_all_dms(200).await { diff --git a/packages/cli/src/cli/commands/fleet-agent.test.ts b/packages/cli/src/cli/commands/fleet-agent.test.ts new file mode 100644 index 000000000..30db05563 --- /dev/null +++ b/packages/cli/src/cli/commands/fleet-agent.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from 'vitest'; + +import { buildRows, collectWithRetry, formatPretty } from './fleet-agent.js'; +import type { FleetInventoryAgent, ListAgent } from '@agent-relay/harness-driver'; +import type { RelayNode } from '@agent-relay/sdk'; + +const NOW = new Date('2026-08-17T09:00:00Z'); + +function node(overrides: Partial): RelayNode { + return { + name: 'unnamed', + status: 'online', + live: true, + capabilities: [], + ...overrides, + } as RelayNode; +} + +function liveAgent(name: string, overrides: Partial = {}): ListAgent { + return { + name, + runtime: 'pty', + channels: [], + ...overrides, + } as ListAgent; +} + +function inventoryAgent(name: string): FleetInventoryAgent { + return { agent_id: `ag_${name}`, name }; +} + +describe('buildRows — the diagnostic column exists', () => { + it('flags a live+inventory+roster row when all three surfaces agree', () => { + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveAgents: [ + liveAgent('worker-a', { + cli: 'claude', + current_state: 'working', + last_activity_at: NOW.toISOString(), + }), + ], + inventoryAgents: [inventoryAgent('worker-a')], + }, + ], + roster: [{ name: 'worker-a' }], + }, + NOW + ); + + expect(out.perNode).toHaveLength(1); + expect(out.perNode[0]).toMatchObject({ + node: 'sf-mini', + name: 'worker-a', + presence: 'live+inventory+roster', + }); + expect(out.unplacedRoster).toEqual([]); + }); + + it('must-fire: a live-only worker (in PTY map, missing from inventory) shows the #1539 signature', () => { + // This is the load-bearing test for the whole command: an agent alive on + // the broker but absent from the fleet_inventory snapshot the engine + // sees. Delete the `inventory only` branch in classifyPresence and this + // row's presence stops being distinguishable from the agreement case. + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveAgents: [liveAgent('lost-agent', { cli: 'claude' })], + inventoryAgents: [], + }, + ], + roster: [], + }, + NOW + ); + + const row = out.perNode.find((r) => r.name === 'lost-agent'); + expect(row).toBeDefined(); + expect(row?.presence).toBe('live only'); + + const rendered = formatPretty(out); + expect(rendered).toContain('lost-agent'); + expect(rendered).toContain('live only'); + // The legend must fire when a live-only row is present — an operator + // reading only the table without context would otherwise miss the shape. + expect(rendered).toContain('relay#1539 shape'); + }); + + it('must-fire: an inventory-only worker (published but no PTY) is visibly different', () => { + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveAgents: [], + inventoryAgents: [inventoryAgent('ghost-agent')], + }, + ], + roster: [], + }, + NOW + ); + + const row = out.perNode.find((r) => r.name === 'ghost-agent'); + expect(row?.presence).toBe('inventory only'); + // State column must not lie about liveness for inventory-only rows. + expect(row?.state).not.toContain('idle'); + expect(row?.state).not.toContain('working'); + }); + + it('must-not-fire: when the two maps agree, no divergence row is emitted', () => { + // Guard against the opposite failure: a false-positive divergence. + // Break the equality check (e.g. hard-code presence to 'live only') and + // this test flips red — proving the guard has teeth. + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveAgents: [liveAgent('worker-a', { cli: 'claude' })], + inventoryAgents: [inventoryAgent('worker-a')], + }, + ], + roster: [], + }, + NOW + ); + + for (const row of out.perNode) { + expect(row.presence).not.toBe('live only'); + expect(row.presence).not.toBe('inventory only'); + } + }); + + it('never drops a node — a failure surfaces as an error row, not omission', () => { + // A node with contribution.error must still appear in the table. The + // whole issue #1553 is founded on this invariant: an omitted node is + // indistinguishable from a node with zero agents, which is the exact + // ambiguity the command exists to remove. + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'chief-broker' }), + isLocal: false, + error: "Node 'chief-broker' is not reachable", + retried: true, + }, + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveAgents: [liveAgent('worker-a')], + inventoryAgents: [inventoryAgent('worker-a')], + }, + ], + roster: [], + }, + NOW + ); + + const errorRow = out.perNode.find((r) => r.node === 'chief-broker'); + expect(errorRow).toBeDefined(); + expect(errorRow?.state).toContain('ERROR'); + expect(errorRow?.note).toBe('retried'); + expect(out.errors).toHaveLength(1); + expect(out.errors[0]).toMatchObject({ node: 'chief-broker' }); + + // And the rendered table must include the node name in the error footer. + const rendered = formatPretty(out); + expect(rendered).toContain('chief-broker'); + expect(rendered).toContain('not reachable'); + }); + + it('remote nodes render as count-only rows and never as fake per-agent rows', () => { + // The exact defect #1553 exists to prevent. `chief-broker` reporting + // `activeAgents=0` while agents are alive on it is a known engine gap; + // this test proves we do not silently accept the count as reality. + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'finn-mini', activeAgents: 33 }), + isLocal: false, + }, + { + node: node({ name: 'chief-broker', activeAgents: 0 }), + isLocal: false, + }, + ], + roster: [], + }, + NOW + ); + + const finn = out.perNode.find((r) => r.node === 'finn-mini'); + expect(finn?.presence).toBe('count only'); + expect(finn?.name).toContain('33 agents'); + expect(finn?.name).toContain('names unavailable'); + + const chief = out.perNode.find((r) => r.node === 'chief-broker'); + expect(chief?.presence).toBe('count only'); + expect(chief?.name).toContain('0 agents'); + // Zero agents from the workspace API is not evidence the node is empty. + // The label must still say "names unavailable" so a reader cannot mistake + // this for a confirmed empty node. + expect(chief?.name).toContain('names unavailable'); + }); + + it('roster-only identities land in a distinct unplaced section', () => { + // The `-cli` messaging identity from the #1553 census: a live posting + // agent in the roster that shows up in no node worker map. It must not + // be attributed to any per-node row, only listed as roster-only. + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveAgents: [liveAgent('worker-a', { cli: 'claude' })], + inventoryAgents: [inventoryAgent('worker-a')], + }, + ], + roster: [{ name: 'worker-a' }, { name: 'chief-broker-grok-capability-0817-cli' }], + }, + NOW + ); + + const perNodeNames = out.perNode.map((r) => r.name); + expect(perNodeNames).not.toContain('chief-broker-grok-capability-0817-cli'); + expect(out.unplacedRoster).toHaveLength(1); + expect(out.unplacedRoster[0]).toMatchObject({ + node: '?', + name: 'chief-broker-grok-capability-0817-cli', + presence: 'roster only', + }); + }); + + it('sorts rows deterministically by (node, name) so scripted diffs are stable', () => { + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveAgents: [liveAgent('b-worker'), liveAgent('a-worker')], + inventoryAgents: [inventoryAgent('a-worker'), inventoryAgent('b-worker')], + }, + { + node: node({ name: 'finn-mini', activeAgents: 2 }), + isLocal: false, + }, + ], + roster: [], + }, + NOW + ); + + const order = out.perNode.map((r) => `${r.node}:${r.name}`); + expect(order).toEqual([ + 'finn-mini:<2 agents — names unavailable>', + 'sf-mini:a-worker', + 'sf-mini:b-worker', + ]); + }); +}); + +describe('formatPretty — legend behaviour', () => { + it('renders the idle legend when any row is idle, and does not otherwise', () => { + const withIdle = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveAgents: [liveAgent('worker-a', { current_state: 'idle', cli: 'claude' })], + inventoryAgents: [inventoryAgent('worker-a')], + }, + ], + roster: [], + }, + NOW + ); + const withoutIdle = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveAgents: [liveAgent('worker-a', { current_state: 'working', cli: 'claude' })], + inventoryAgents: [inventoryAgent('worker-a')], + }, + ], + roster: [], + }, + NOW + ); + + expect(formatPretty(withIdle)).toContain('does NOT prove'); + // Must-not-fire: the legend cannot appear when no row is idle. + expect(formatPretty(withoutIdle)).not.toContain('does NOT prove'); + }); +}); + +describe('collectWithRetry — a transient failure retries once', () => { + it('reports retried=true on second-attempt success', async () => { + let calls = 0; + const result = await collectWithRetry( + 'test', + async () => { + calls += 1; + if (calls === 1) throw new Error('transient'); + return 'ok'; + }, + { retries: 1, baseDelayMs: 0, sleep: async () => undefined } + ); + expect(result).toEqual({ ok: true, value: 'ok', retried: true }); + expect(calls).toBe(2); + }); + + it('surfaces the failure with retried=true when both attempts fail', async () => { + const result = await collectWithRetry( + 'test', + async () => { + throw new Error('permanent'); + }, + { retries: 1, baseDelayMs: 0, sleep: async () => undefined } + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('test: permanent'); + expect(result.retried).toBe(true); + } + }); +}); diff --git a/packages/cli/src/cli/commands/fleet-agent.ts b/packages/cli/src/cli/commands/fleet-agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..52990cc08ea7333f3755e3fd308722fdeb1e0a8a GIT binary patch literal 14902 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 literal 0 HcmV?d00001 diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 3a918d26b..0f1ea02a6 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -3,6 +3,14 @@ import { HarnessDriverClient } from '@agent-relay/harness-driver'; import { createWorkspaceClient, type RelayWorkspaceThinClient } from '@agent-relay/sdk'; import { withDefaults, type CoreDependencies } from './core.js'; +import { + buildRows, + collectWithRetry, + formatPretty, + readLocalBrokerMaps, + type FleetNodeContribution, + type RosterAgent, +} from './fleet-agent.js'; import { readBrokerConnection } from '../lib/broker-lifecycle.js'; import { declaredWorkforceMetadata } from '../lib/registration-metadata.js'; import { redactSecrets } from '../lib/redact.js'; @@ -109,6 +117,21 @@ export function registerFleetCommands( }); }); + // `fleet agent list` — the fleet-wide answer to `node agent list --pretty`. + // See relay#1553 for the gap this fills and packages/cli/src/cli/commands/ + // fleet-agent.ts for the join logic (three name spaces, per-node contributions). + const agent = group.command('agent').description('Inspect agents across the fleet'); + addSdkOptions( + agent + .command('list') + .description('List agents on every reachable fleet node, joined against the workspace roster') + .option('--pretty', 'Render as a human-readable table (default is JSON)') + .option('--node ', 'Scope to a single node (still enumerates it via nodes.list())') + .option('--all', 'Include offline/history nodes the way `fleet nodes --all` does') + ).action(async (options: Record) => { + await runFleetAgentList(deps, options); + }); + addSdkOptions( group .command('spawn') @@ -356,6 +379,126 @@ function warnIfInferredFromProjectSession( } } +/** + * Fan-out for `fleet agent list`. Reads `nodes.list()` for the roster of + * reachable fleet nodes, `agents.list()` for the workspace agent registry, + * and — when this machine has a running local broker — both the live worker + * map and the fleet_inventory snapshot from it. Each per-node call is + * retried once with jitter before being rendered as an error; a node is + * never dropped from the output. + * + * The pure join lives in {@link ./fleet-agent.ts} so it can be tested against + * fixtures without wiring up the SDK. + */ +async function runFleetAgentList( + deps: FleetCommandDependencies, + options: Record +): Promise { + await runSdk(deps.sdk, async () => { + warnIfInferredFromProjectSession(options, deps.warn); + const clientOptions = sdkOptionsFromOpts(options); + const relay = deps.sdk.createWorkspaceRelay(clientOptions); + + // Enumerate fleet nodes exactly the way `fleet nodes` does. `nodes.list()` + // failure is fatal — nothing to reconcile against. + const nodes = await relay.nodes.list({ + ...(typeof options.node === 'string' && options.node ? { name: options.node } : {}), + }); + const includeAll = options.all === true; + const visibleNodes = includeAll ? nodes : nodes.filter(isAvailableFleetNode); + + // The workspace roster is separately fetched; a failure here is degraded + // rather than fatal — the presence column just marks fewer rows as + // roster-matched and warns. + let roster: RosterAgent[] = []; + try { + // Default to online-only. `--all` opens it up to the workspace's + // full record set (>1600 today, most stale/offline) so a scripted diff + // has the option, without making the default output unreadable. This + // mirrors what `fleet nodes` does with node history. + const relayAgents = await relay.agents.list(includeAll ? {} : { status: 'online' }); + roster = relayAgents.map((entry) => ({ + name: entry.name, + ...(entry.status ? { status: entry.status } : {}), + ...(entry.lastSeenAt ? { lastSeenAt: entry.lastSeenAt } : {}), + ...(entry.metadata ? { metadata: entry.metadata } : {}), + })); + } catch (error) { + deps.warn( + `roster unavailable (${error instanceof Error ? error.message : String(error)}); ` + + 'PRESENCE column will not report roster membership.' + ); + } + + // Local broker (this machine): read /api/spawned and /api/fleet-inventory. + // The broker's node_name identifies which entry in `visibleNodes` is us. + const paths = deps.core.getProjectPaths(); + const conn = readBrokerConnection(paths.dataDir); + let localNodeName: string | undefined; + let localLive: Awaited> = []; + let localInventory: Awaited< + ReturnType + >['agents'] = []; + let localError: string | undefined; + let localRetried = false; + + if (conn) { + const client = new HarnessDriverClient({ baseUrl: conn.url, apiKey: conn.api_key }); + try { + const session = await client.getSession(); + localNodeName = session.node_name ?? undefined; + const result = await collectWithRetry('local broker', () => readLocalBrokerMaps(client)); + if (result.ok) { + localLive = result.value.liveAgents; + localInventory = result.value.inventoryAgents; + localRetried = result.retried; + } else { + localError = result.error; + localRetried = result.retried; + } + } catch (error) { + localError = `local broker: ${error instanceof Error ? error.message : String(error)}`; + } finally { + client.disconnect(); + } + } + + // Assemble per-node contributions. The local node (if we resolved its + // name) uses the two-map data; every other visible node gets a + // count-only contribution built from its `nodes.list()` record. + const contributions: FleetNodeContribution[] = visibleNodes.map((node) => { + if (localNodeName && node.name === localNodeName) { + if (localError) { + return { node, isLocal: true, error: localError, retried: localRetried }; + } + return { + node, + isLocal: true, + liveAgents: localLive, + inventoryAgents: localInventory, + retried: localRetried, + }; + } + return { node, isLocal: false }; + }); + + const now = new Date(); + const output = buildRows({ contributions, roster }, now); + + if (options.pretty === true) { + deps.log(formatPretty(output)); + return; + } + printJson(deps.sdk, { + generatedAt: now.toISOString(), + localNode: localNodeName ?? null, + perNode: output.perNode, + unplacedRoster: output.unplacedRoster, + errors: output.errors, + }); + }); +} + async function runFleetStatus( deps: FleetCommandDependencies, options: Record diff --git a/packages/harness-driver/src/client.ts b/packages/harness-driver/src/client.ts index 38494c9fe..779203198 100644 --- a/packages/harness-driver/src/client.ts +++ b/packages/harness-driver/src/client.ts @@ -45,6 +45,7 @@ import type { SpawnPtyInput, SendMessageInput, ListAgent, + FleetInventoryAgent, } from './types.js'; import { EventBus } from './event-bus.js'; import { SpawnedAgentHandle } from './agent-handle.js'; @@ -707,6 +708,21 @@ export class HarnessDriverClient { return result.agents; } + /** + * Snapshot of the broker's in-process `fleet_inventory` map — the same set + * the broker publishes to the engine via `inventory.sync`. Joined against + * `listAgents()` to detect the workers-vs-inventory divergence (#1539) — + * an agent live in the PTY map that was never (or is no longer) present in + * what the engine sees. + */ + async listFleetInventory(): Promise<{ nodeName: string | undefined; agents: FleetInventoryAgent[] }> { + const result = await this.transport.request<{ + node_name?: string; + agents: FleetInventoryAgent[]; + }>('/api/fleet-inventory'); + return { nodeName: result.node_name, agents: result.agents ?? [] }; + } + // ── PTY control ──────────────────────────────────────────────────── async sendInput(name: string, data: string): Promise<{ name: string; bytes_written: number }> { diff --git a/packages/harness-driver/src/protocol.ts b/packages/harness-driver/src/protocol.ts index 0b1124c50..f9a6e510b 100644 --- a/packages/harness-driver/src/protocol.ts +++ b/packages/harness-driver/src/protocol.ts @@ -251,6 +251,20 @@ export interface ListAgent { native_harness_capabilities?: Record; } +/** + * One entry in the broker's `fleet_inventory` map, as returned by + * `GET /api/fleet-inventory`. The broker publishes exactly this shape to the + * engine via `inventory.sync`; callers join it against `ListAgent` from + * `GET /api/spawned` to detect the workers-vs-inventory divergence + * documented in relay#1539. + */ +export interface FleetInventoryAgent { + agent_id: string; + name: string; + invocation_id?: string; + session_ref?: string; +} + export interface BrokerStatus { agent_count: number; agents: ListAgent[]; diff --git a/packages/harness-driver/src/types.ts b/packages/harness-driver/src/types.ts index c0c5eb299..457e4b634 100644 --- a/packages/harness-driver/src/types.ts +++ b/packages/harness-driver/src/types.ts @@ -130,6 +130,7 @@ export interface SendMessageInput { /** * Re-exported from the wire contract: `GET /api/spawned` and the `agents` * array of `GET /api/status` are the same broker payload, so they share one - * declaration. + * declaration. `FleetInventoryAgent` is the sibling contract for the + * `GET /api/fleet-inventory` snapshot the broker also publishes to the engine. */ -export type { ListAgent } from './protocol.js'; +export type { ListAgent, FleetInventoryAgent } from './protocol.js'; From cc2912917774f6d085d996f211a82b20896564a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 17 Aug 2026 09:40:44 +0000 Subject: [PATCH 2/5] style: auto-format with Prettier --- packages/cli/src/cli/commands/fleet-agent.ts | Bin 14902 -> 14868 bytes packages/cli/src/cli/commands/fleet.ts | 4 +--- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/cli/commands/fleet-agent.ts b/packages/cli/src/cli/commands/fleet-agent.ts index 52990cc08ea7333f3755e3fd308722fdeb1e0a8a..5427a3c040cd6db94547d1ab42f6eeeb7c3e2d90 100644 GIT binary patch delta 72 zcmdm1GNok0Ts0;IjmeAD{24Vje^FECoUCLJKe^G+X7eEfE|$sXO(HjIns%^=BWEPh=rUO~U8k#zj&soY$erM?c0Mm#X*#H0l delta 105 zcmbPIvaMvpTs2lM1t6F_U(KHx$lCl~O`Ve+ECdu3Gl-v@ZRogpqX8ESNOtmWlSoFc s%_629EV5uV$vK&+c_n%|nZ+fJ=|EPohNcckV)6z{85R(0@)=7H02-AYLI3~& diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 0f1ea02a6..2d4fbfc28 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -436,9 +436,7 @@ async function runFleetAgentList( const conn = readBrokerConnection(paths.dataDir); let localNodeName: string | undefined; let localLive: Awaited> = []; - let localInventory: Awaited< - ReturnType - >['agents'] = []; + let localInventory: Awaited>['agents'] = []; let localError: string | undefined; let localRetried = false; From 8677d85784da432439cc9f6950a9a93430a24407 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 17 Aug 2026 12:29:52 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix(cli,harness-driver):=20third-state=20di?= =?UTF-8?q?scipline=20=E2=80=94=20success=20flag,=20allSettled,=20syntheti?= =?UTF-8?q?c=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the 10 review threads on relay#1556. Three reviewers independently flagged the same root defect the command was built to prevent: dropping signals of the form "I could not answer" and rendering them as "there is nothing there". This commit is the third-state discipline applied where it was missing. `HarnessDriverClient.listFleetInventory` was returning `{agents: []}` verbatim when the broker responded `HTTP 200 {"success": false, "agents": []}` (which the broker emits when the runtime channel is closed / reply dropped). The CLI then classified every live worker as `live only` — the relay#1539 divergence signature — so a genuine broker outage was rendered as the exact bug this command exists to expose. Now throws `HarnessDriverProtocolError` with `code: 'fleet_inventory_unavailable'` when `success === false`, distinct from a transport error, and retryable. New test file `packages/harness-driver/src/list-fleet-inventory.test.ts` covers both directions: must-fire on the `success:false` reject; must-not-fire on a genuinely empty inventory (which continues to return `agents: []`). A 404 on `/api/fleet-inventory` (older brokers) was cancelling the already-resolved `/api/spawned` result and rendering the local machine as an ERROR row with zero agent names. `FleetNodeContribution` gains `liveError` / `inventoryError` fields so the two halves survive independently; `buildRows` only degrades to an ERROR row when BOTH halves fail. When only one half succeeds, per-agent rows carry `live only (inventory?)` / `inventory only (live?)` instead of the bare `live only` / `inventory only` divergence tags — because the divergence is unverified. Empty rows carry `empty (inventory?)` / `empty (live?)` and the row's NAME cell reads `<... total unknown>` so it cannot be misread as a confirmed empty node. Legend updated. When `visibleNodes` filters the local broker's node record out (marked offline, `handlersLive: false`, unregistered, or excluded by `--node`) the CLI now unshifts a synthetic local contribution rather than silently discarding the local live+inventory data. `RelayNode` is synthesized with the local broker's node name (or `AGENT_RELAY_BROKER_NAME`, or `(local broker)`). Same discipline as the "never drop a node" invariant applied to the local machine specifically. The `--help` output advertised `[--pretty|--json]` but the command only registered `--pretty`, so `fleet agent list --json` errored with "unknown option '--json'". Adding `--json` as an explicit option (JSON is still the default when neither flag is set). Repo requires curation for user-facing commands (AGENTS.md). - Harness-driver (vitest): 4 new tests on `listFleetInventory` covering `success:false`, empty inventory, populated inventory, and 404 fallback. - CLI (vitest): 7 new tests on `buildRows` / `readLocalBrokerMaps` covering partial-state preservation, synthetic-local contribution, and Promise.allSettled semantics. Both must-fire and must-not-fire bites verified via sabotage runs. - Broker (cargo): existing 2 fleet_inventory tests still pass. Total: 22 CLI+driver tests + 2 broker tests, all green. The CLI runs against the actual workspace (roster + `nodes.list()`), enumerates every fleet node (`sf-mini`, `finn-mini`, `chief-broker`, `daytona-1538-verify-0817b`, `cloud-3061-repro-0817`), and correctly renders each as `count only` when the local broker connection is unavailable — exit 0 captured directly. Refs relay#1553, relay#1539. Co-Authored-By: Claude Opus 4.7 Session-Id: 0aa1406d-f4e3-4108-aa63-1dd8f5f83b1e Session-Id: 0aa1406d-f4e3-4108-aa63-1dd8f5f83b1e --- CHANGELOG.md | 6 +- .../cli/src/cli/commands/fleet-agent.test.ts | 202 +++++++++++++++++- packages/cli/src/cli/commands/fleet-agent.ts | Bin 14868 -> 20877 bytes packages/cli/src/cli/commands/fleet.ts | 125 +++++++++-- packages/harness-driver/src/client.ts | 20 +- .../src/list-fleet-inventory.test.ts | 94 ++++++++ 6 files changed, 426 insertions(+), 21 deletions(-) create mode 100644 packages/harness-driver/src/list-fleet-inventory.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 89265c2a1..5d5571426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ 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 - Patch] +## [Unreleased - Minor] + +### Added + +- `agent-relay fleet agent list [--pretty|--json] [--node ] [--all]` — fleet-wide agent listing joined against the workspace roster. The `PRESENCE` column names exactly which surfaces observed each agent (`live+inventory+roster`, `live only` for the relay#1539 divergence signature, `inventory only`, `roster only`, `count only` for remote nodes) and carries `(inventory?)` / `(live?)` uncertainty markers when one of the two local broker maps was unqueryable. Every reachable node appears in the output — a node with no agents is never confused with a node that could not be reached. Backed by a new `GET /api/fleet-inventory` broker route that surfaces the in-process `fleet_inventory` snapshot (the same set the broker publishes to the engine via `inventory.sync`); the CLI treats a `success:false` envelope as unknown rather than an empty inventory. See relay#1553. ### Fixed diff --git a/packages/cli/src/cli/commands/fleet-agent.test.ts b/packages/cli/src/cli/commands/fleet-agent.test.ts index 30db05563..467bba1d2 100644 --- a/packages/cli/src/cli/commands/fleet-agent.test.ts +++ b/packages/cli/src/cli/commands/fleet-agent.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildRows, collectWithRetry, formatPretty } from './fleet-agent.js'; +import { buildRows, collectWithRetry, formatPretty, readLocalBrokerMaps } from './fleet-agent.js'; import type { FleetInventoryAgent, ListAgent } from '@agent-relay/harness-driver'; import type { RelayNode } from '@agent-relay/sdk'; @@ -273,6 +273,206 @@ describe('buildRows — the diagnostic column exists', () => { }); }); +describe('buildRows — partial local-broker reads never discard the surviving map', () => { + it("must-not-fire: inventory failure alone does NOT mislabel live agents as the '#1539 shape'", () => { + // Reviewer regression: when only `/api/fleet-inventory` fails (missing + // route on an older broker, or `success:false` from a closed runtime + // channel), the CLI must not label live agents as `live only` — the + // relay#1539 divergence signature — because the divergence is + // unverified. Reintroduce the pre-fix Promise.all behaviour (which + // treated the inventory failure as an empty inventory) and this test + // flips red. + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveAgents: [liveAgent('worker-a', { cli: 'claude' })], + // inventoryAgents intentionally unset — the failure branch. + inventoryError: 'HTTP 404: /api/fleet-inventory not found', + }, + ], + roster: [], + }, + NOW + ); + + const row = out.perNode.find((r) => r.name === 'worker-a'); + expect(row).toBeDefined(); + // Must be tagged with the uncertainty marker, NOT with the false #1539 + // divergence signature. + expect(row?.presence).toBe('live only (inventory?)'); + expect(row?.presence).not.toBe('live only'); + + // And the live name must still appear — this whole test class exists + // because the pre-fix code dropped it. + expect(row?.state).toContain('unknown'); + // ^ current_state was undefined; renderState returns '· unknown'. + + // Legend explains the `(?)` marker. + const rendered = formatPretty(out); + expect(rendered).toContain('worker-a'); + expect(rendered).toContain('(inventory?)'); + expect(rendered).toContain('treat that surface as unknown, not empty'); + }); + + it("must-not-fire: live failure alone does NOT mislabel inventory entries as the '#1539 shape'", () => { + // Symmetric guard: an operator reading a row where only `/api/spawned` + // failed must not be told the agent is `inventory only` (which would + // read as "published but no PTY"). The uncertainty marker prevents that. + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveError: 'ECONNREFUSED (broker gone away)', + inventoryAgents: [inventoryAgent('worker-a')], + }, + ], + roster: [], + }, + NOW + ); + + const row = out.perNode.find((r) => r.name === 'worker-a'); + expect(row?.presence).toBe('inventory only (live?)'); + expect(row?.presence).not.toBe('inventory only'); + }); + + it('must-fire: both halves failing still produces an ERROR row that names the node', () => { + // The one case where the local node degrades to an ERROR row. Under the + // pre-fix code, ANY half failing caused this — that was the bug. This + // test says: when both halves are genuinely gone, the error remains. + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveError: 'ECONNREFUSED', + inventoryError: 'ECONNREFUSED', + }, + ], + roster: [], + }, + NOW + ); + + const row = out.perNode.find((r) => r.node === 'sf-mini'); + expect(row?.state).toContain('ERROR'); + expect(row?.state).toContain('ECONNREFUSED'); + expect(out.errors).toHaveLength(1); + }); + + it('must-not-fire: partial-empty is labelled as unknown, not as a confirmed empty node', () => { + // With one half known-empty and the other unknown, the row must NOT + // read "<0 agents on this node>" as if the node were confirmed empty. + // A partial observation of empty is not a confirmed empty result. + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'sf-mini' }), + isLocal: true, + liveAgents: [], + inventoryError: 'HTTP 404: /api/fleet-inventory not found', + }, + ], + roster: [], + }, + NOW + ); + + const row = out.perNode.find((r) => r.node === 'sf-mini'); + expect(row?.presence).toBe('empty (inventory?)'); + // "total unknown" is the operator-facing warning that makes this row + // impossible to misread as a clean zero. + expect(row?.name).toContain('total unknown'); + expect(row?.name).not.toContain('<0 agents on this node>'); + }); +}); + +describe('buildRows — a synthetic local contribution is never dropped', () => { + // The fleet.ts fan-out is responsible for unshifting a synthetic local + // contribution when the local node is filtered out of `visibleNodes`. Here + // we lock in the invariant at the join layer: a caller that hands a local + // contribution to buildRows always sees it rendered, whatever the node + // record looks like. + it('renders a local-contribution row even when the node has no capabilities and status is unknown', () => { + const out = buildRows( + { + contributions: [ + { + node: { + // Simulating a synthesized RelayNode built when nodes.list() + // filtered the local machine out (e.g. handlersLive === false). + name: '(local broker)', + status: 'unknown', + capabilities: [], + } as RelayNode, + isLocal: true, + liveAgents: [liveAgent('worker-a', { cli: 'claude' })], + inventoryAgents: [inventoryAgent('worker-a')], + }, + ], + roster: [], + }, + NOW + ); + + expect(out.perNode.find((r) => r.name === 'worker-a')).toBeDefined(); + // NODE column is not empty; the fake name propagates so an operator can + // still search their logs. It must NOT read `?` (which is reserved for + // roster-only unplaced rows). + expect(out.perNode[0]?.node).toBe('(local broker)'); + }); +}); + +describe('readLocalBrokerMaps — Promise.allSettled semantics', () => { + it('preserves the live map when only inventory throws', async () => { + // Reviewer flag translated into a unit test at the harness-driver join + // point. The old Promise.all-based helper would let a listFleetInventory + // rejection cancel the resolved listAgents result; allSettled means + // both halves are preserved independently. + const stub = { + listAgents: async () => [ + liveAgent('worker-a', { cli: 'claude' }), + ], + listFleetInventory: async () => { + throw new Error('HTTP 404: /api/fleet-inventory not found'); + }, + }; + const result = await readLocalBrokerMaps( + stub as unknown as Parameters[0] + ); + expect(result.liveAgents).toHaveLength(1); + expect(result.liveError).toBeUndefined(); + expect(result.inventoryAgents).toBeUndefined(); + expect(result.inventoryError).toContain('/api/fleet-inventory not found'); + }); + + it('preserves the inventory map when only listAgents throws', async () => { + const stub = { + listAgents: async () => { + throw new Error('EHOSTUNREACH'); + }, + listFleetInventory: async () => ({ + nodeName: 'sf-mini', + agents: [inventoryAgent('worker-a')], + }), + }; + const result = await readLocalBrokerMaps( + stub as unknown as Parameters[0] + ); + expect(result.inventoryAgents).toHaveLength(1); + expect(result.inventoryError).toBeUndefined(); + expect(result.liveAgents).toBeUndefined(); + expect(result.liveError).toContain('EHOSTUNREACH'); + }); +}); + describe('formatPretty — legend behaviour', () => { it('renders the idle legend when any row is idle, and does not otherwise', () => { const withIdle = buildRows( diff --git a/packages/cli/src/cli/commands/fleet-agent.ts b/packages/cli/src/cli/commands/fleet-agent.ts index 5427a3c040cd6db94547d1ab42f6eeeb7c3e2d90..02cad77764c4c256120b337c118b3cbf66d781d9 100644 GIT binary patch delta 5916 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 delta 671 zcmZuv&1(}u6eqD5-Bv*iik8}EJ-8c}U8N|vX~jm{Mv#pLJxB;LNv39WG8<;531JEK zAJEJ5s2=ReTVbyrJa`fD;6?D}f8a^HI=eCL#W~HquiwX;7rVJvdhcUl#)FcNR=9}2 z3!4}oxr*N{e~xTvDnU{$M;^YL9pL=jO$?9iCG_~4lRFOuiv;K{18Ome-z(dyn-)Fr}5MI#pIWJIajnR zQN$?`c1m_Vg?C&WduL~vsAm99*}~_rk{tFoX7XU;{pDKDNp_b{v0}y)kn$u>eE9Ns8iB`R;0VolF>6mG!w%F^ob;#Q(gwjGBG`-6fk8B?MG76 zjxmfgK{4K($Jb%;L7Oww;#{+n$nAcpJzMV#{ACp@9xkxUni%huTX3-*BS4*&oF diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 2d4fbfc28..d92b1cfb7 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -1,6 +1,6 @@ import { InvalidArgumentError, type Command } from 'commander'; import { HarnessDriverClient } from '@agent-relay/harness-driver'; -import { createWorkspaceClient, type RelayWorkspaceThinClient } from '@agent-relay/sdk'; +import { createWorkspaceClient, type RelayWorkspaceThinClient, type RelayNode } from '@agent-relay/sdk'; import { withDefaults, type CoreDependencies } from './core.js'; import { @@ -125,7 +125,8 @@ export function registerFleetCommands( agent .command('list') .description('List agents on every reachable fleet node, joined against the workspace roster') - .option('--pretty', 'Render as a human-readable table (default is JSON)') + .option('--pretty', 'Render as a human-readable table') + .option('--json', 'Render JSON output (default; explicit so the flag advertised in --help works)') .option('--node ', 'Scope to a single node (still enumerates it via nodes.list())') .option('--all', 'Include offline/history nodes the way `fleet nodes --all` does') ).action(async (options: Record) => { @@ -379,6 +380,49 @@ function warnIfInferredFromProjectSession( } } +/** + * Assemble the local broker's contribution from the raw per-half results. + * `sessionError` is a hard failure (broker session lookup blew up) — it + * degrades to a full ERROR row so the operator can see the local machine + * itself is unreachable. `liveError` / `inventoryError` are partial and are + * preserved into the contribution so `buildRows` can render one map with a + * `(?)` marker on the missing half instead of dropping both. + */ +function buildLocalContribution( + node: RelayNode, + input: { + liveAgents?: Awaited>; + liveError?: string; + inventoryAgents?: Awaited< + ReturnType + >['agents']; + inventoryError?: string; + sessionError?: string; + retried?: boolean; + note?: string; + } +): FleetNodeContribution { + if (input.sessionError) { + return { + node, + isLocal: true, + error: input.note ? `${input.note}: ${input.sessionError}` : input.sessionError, + ...(input.retried ? { retried: true } : {}), + }; + } + return { + node, + isLocal: true, + ...(input.liveAgents !== undefined ? { liveAgents: input.liveAgents } : {}), + ...(input.liveError ? { liveError: input.liveError } : {}), + ...(input.inventoryAgents !== undefined + ? { inventoryAgents: input.inventoryAgents } + : {}), + ...(input.inventoryError ? { inventoryError: input.inventoryError } : {}), + ...(input.retried ? { retried: true } : {}), + }; +} + /** * Fan-out for `fleet agent list`. Reads `nodes.list()` for the roster of * reachable fleet nodes, `agents.list()` for the workspace agent registry, @@ -435,9 +479,14 @@ async function runFleetAgentList( const paths = deps.core.getProjectPaths(); const conn = readBrokerConnection(paths.dataDir); let localNodeName: string | undefined; - let localLive: Awaited> = []; - let localInventory: Awaited>['agents'] = []; - let localError: string | undefined; + let localLive: Awaited> | undefined; + let localInventory: + | Awaited>['agents'] + | undefined; + let localLiveError: string | undefined; + let localInventoryError: string | undefined; + /** Whole-session failure (session lookup blew up before either map ran). */ + let localSessionError: string | undefined; let localRetried = false; if (conn) { @@ -445,41 +494,81 @@ async function runFleetAgentList( try { const session = await client.getSession(); localNodeName = session.node_name ?? undefined; - const result = await collectWithRetry('local broker', () => readLocalBrokerMaps(client)); + // `readLocalBrokerMaps` uses Promise.allSettled so a failure in one + // half never discards the other. `collectWithRetry` retries the pair + // once as a unit; a per-half retry policy is more code for no + // observable win when both halves hit the same broker. + const result = await collectWithRetry('local broker', () => + readLocalBrokerMaps(client) + ); if (result.ok) { localLive = result.value.liveAgents; + localLiveError = result.value.liveError; localInventory = result.value.inventoryAgents; + localInventoryError = result.value.inventoryError; localRetried = result.retried; } else { - localError = result.error; + // Both halves failed as a unit — record it as a session error so + // the contribution below renders an explicit ERROR row rather than + // silently pretending both maps were empty. + localSessionError = result.error; localRetried = result.retried; } } catch (error) { - localError = `local broker: ${error instanceof Error ? error.message : String(error)}`; + localSessionError = `local broker: ${ + error instanceof Error ? error.message : String(error) + }`; } finally { client.disconnect(); } } - // Assemble per-node contributions. The local node (if we resolved its - // name) uses the two-map data; every other visible node gets a - // count-only contribution built from its `nodes.list()` record. + // Assemble per-node contributions. Every visible node produces one. const contributions: FleetNodeContribution[] = visibleNodes.map((node) => { if (localNodeName && node.name === localNodeName) { - if (localError) { - return { node, isLocal: true, error: localError, retried: localRetried }; - } - return { - node, - isLocal: true, + return buildLocalContribution(node, { liveAgents: localLive, + liveError: localLiveError, inventoryAgents: localInventory, + inventoryError: localInventoryError, + sessionError: localSessionError, retried: localRetried, - }; + }); } return { node, isLocal: false }; }); + // Guarantee the local machine appears somewhere in the output even if + // `nodes.list()` filtered its record out or the workspace never saw it. + // Dropping the local machine's contribution silently was one of the + // review findings on the first pass — this is the third-state discipline + // applied to the local node itself, not just to per-agent rows. + if ( + (localNodeName || localSessionError || conn) && + !contributions.some((c) => c.isLocal) + ) { + const syntheticNodeName = + localNodeName ?? + (process.env.AGENT_RELAY_BROKER_NAME?.trim() || undefined) ?? + '(local broker)'; + const syntheticNode: RelayNode = { + name: syntheticNodeName, + status: 'unknown', + capabilities: [], + }; + contributions.unshift( + buildLocalContribution(syntheticNode, { + liveAgents: localLive, + liveError: localLiveError, + inventoryAgents: localInventory, + inventoryError: localInventoryError, + sessionError: localSessionError, + retried: localRetried, + note: 'local broker not in visible node list', + }) + ); + } + const now = new Date(); const output = buildRows({ contributions, roster }, now); diff --git a/packages/harness-driver/src/client.ts b/packages/harness-driver/src/client.ts index 779203198..d8cdd24ce 100644 --- a/packages/harness-driver/src/client.ts +++ b/packages/harness-driver/src/client.ts @@ -714,12 +714,30 @@ export class HarnessDriverClient { * `listAgents()` to detect the workers-vs-inventory divergence (#1539) — * an agent live in the PTY map that was never (or is no longer) present in * what the engine sees. + * + * The broker envelope carries `success` alongside `agents`. When the broker + * cannot answer (runtime channel closed, reply dropped) it responds + * `{ "success": false, "agents": [] }` at HTTP 200. Returning that empty + * array to the caller would silently conflate "broker down" with "0 agents" + * — the same class of defect relay#1553 exists to prevent — so a + * `success: false` envelope is surfaced as an error instead. Callers that + * want to distinguish it from a transport-layer failure can match on + * `code === 'fleet_inventory_unavailable'`. */ async listFleetInventory(): Promise<{ nodeName: string | undefined; agents: FleetInventoryAgent[] }> { const result = await this.transport.request<{ + success?: boolean; node_name?: string; - agents: FleetInventoryAgent[]; + agents?: FleetInventoryAgent[]; }>('/api/fleet-inventory'); + if (result.success === false) { + throw new HarnessDriverProtocolError({ + code: 'fleet_inventory_unavailable', + message: + 'broker returned success:false for /api/fleet-inventory — the runtime channel is unavailable, so agent presence is unknown (not zero).', + retryable: true, + }); + } return { nodeName: result.node_name, agents: result.agents ?? [] }; } diff --git a/packages/harness-driver/src/list-fleet-inventory.test.ts b/packages/harness-driver/src/list-fleet-inventory.test.ts new file mode 100644 index 000000000..027f4edf8 --- /dev/null +++ b/packages/harness-driver/src/list-fleet-inventory.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; + +/** + * Unit tests for {@link HarnessDriverClient.listFleetInventory}. + * + * The broker's `GET /api/fleet-inventory` handler wraps the snapshot in an + * envelope with a `success` flag. When the broker's runtime channel is + * unavailable it returns `HTTP 200 { "success": false, "agents": [] }` + * (see `crates/broker/src/listen_api.rs::listen_api_fleet_inventory`). + * + * Dropping that flag would silently conflate two very different situations + * that a fleet-visibility tool absolutely must distinguish: + * - **broker down / cannot answer** → callers should treat as unknown and + * surface an error row. + * - **broker up, genuinely empty inventory** → callers should render `0`. + * + * Both are covered as independent bites below so a regression in either + * direction fails a test. + */ + +import { HarnessDriverClient } from './client.js'; +import { HarnessDriverProtocolError } from './transport.js'; + +function stubClient(body: unknown, status = 200): HarnessDriverClient { + const fetch = vi.fn( + async () => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) + ) as unknown as typeof globalThis.fetch; + return new HarnessDriverClient({ baseUrl: 'http://x', apiKey: 'k', fetch }); +} + +describe('listFleetInventory — success:false must not be conflated with empty inventory', () => { + it('must-fire: rejects with fleet_inventory_unavailable when the broker envelope reports failure', async () => { + // This is the bite that catches the defect three reviewers flagged on + // relay#1556: the CLI was rendering "broker cannot answer" as "0 agents" + // by dropping the success flag. Remove the guard and this test flips red. + const client = stubClient({ success: false, agents: [] }); + + let failure: unknown; + try { + await client.listFleetInventory(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(HarnessDriverProtocolError); + expect(failure).toMatchObject({ + code: 'fleet_inventory_unavailable', + retryable: true, + }); + expect((failure as Error).message).toMatch(/success:false/); + expect((failure as Error).message).toMatch(/not zero/); + }); + + it('must-not-fire: an actually empty inventory returns agents:[] without throwing', async () => { + // The other half of the required distinction: an operator with a healthy + // broker and no agents on the node must see a clean empty result, not an + // ERROR row. This is why the guard checks `success === false` explicitly + // rather than falsy — the successful envelope has no `success` field. + const client = stubClient({ node_name: 'sf-mini', agents: [] }); + + const result = await client.listFleetInventory(); + expect(result).toEqual({ nodeName: 'sf-mini', agents: [] }); + }); + + it('must-not-fire: a populated inventory returns its agents intact', async () => { + // Guard against a lazy fix that always throws. Preserve full agent + // payloads verbatim so the CLI can join on `name`. + const client = stubClient({ + success: true, + node_name: 'finn-mini', + agents: [ + { agent_id: 'ag_1', name: 'worker-a', invocation_id: 'inv_1' }, + { agent_id: 'ag_2', name: 'worker-b' }, + ], + }); + + const result = await client.listFleetInventory(); + expect(result.nodeName).toBe('finn-mini'); + expect(result.agents).toHaveLength(2); + expect(result.agents[0]).toMatchObject({ name: 'worker-a', invocation_id: 'inv_1' }); + }); + + it('surfaces the transport error when the broker route is missing (HTTP 404)', async () => { + // Older brokers do not expose `/api/fleet-inventory` at all. This must + // surface as a proper protocol error the CLI can distinguish from + // success:false (both are diagnostic — but only one implies "the endpoint + // exists but had trouble"), and it must not silently return empty agents. + const client = stubClient({ error: { code: 'not_found', message: 'no route' } }, 404); + await expect(client.listFleetInventory()).rejects.toBeInstanceOf(HarnessDriverProtocolError); + }); +}); From dc82b4be6bd4e261b7e1afbe105aecbe6cc930a9 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 17 Aug 2026 12:32:04 +0200 Subject: [PATCH 4/5] style: apply Prettier to third-state fix files Match the auto-format bot's rules on the files touched in the previous commit (cc2912917 auto-format style + c848e475b logic changes were rebased independently). Co-Authored-By: Claude Opus 4.7 Session-Id: 0aa1406d-f4e3-4108-aa63-1dd8f5f83b1e --- .../cli/src/cli/commands/fleet-agent.test.ts | 12 ++------ packages/cli/src/cli/commands/fleet-agent.ts | Bin 20877 -> 20834 bytes packages/cli/src/cli/commands/fleet.ts | 29 +++++------------- 3 files changed, 10 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/cli/commands/fleet-agent.test.ts b/packages/cli/src/cli/commands/fleet-agent.test.ts index 467bba1d2..51f5d16f3 100644 --- a/packages/cli/src/cli/commands/fleet-agent.test.ts +++ b/packages/cli/src/cli/commands/fleet-agent.test.ts @@ -437,16 +437,12 @@ describe('readLocalBrokerMaps — Promise.allSettled semantics', () => { // rejection cancel the resolved listAgents result; allSettled means // both halves are preserved independently. const stub = { - listAgents: async () => [ - liveAgent('worker-a', { cli: 'claude' }), - ], + listAgents: async () => [liveAgent('worker-a', { cli: 'claude' })], listFleetInventory: async () => { throw new Error('HTTP 404: /api/fleet-inventory not found'); }, }; - const result = await readLocalBrokerMaps( - stub as unknown as Parameters[0] - ); + const result = await readLocalBrokerMaps(stub as unknown as Parameters[0]); expect(result.liveAgents).toHaveLength(1); expect(result.liveError).toBeUndefined(); expect(result.inventoryAgents).toBeUndefined(); @@ -463,9 +459,7 @@ describe('readLocalBrokerMaps — Promise.allSettled semantics', () => { agents: [inventoryAgent('worker-a')], }), }; - const result = await readLocalBrokerMaps( - stub as unknown as Parameters[0] - ); + const result = await readLocalBrokerMaps(stub as unknown as Parameters[0]); expect(result.inventoryAgents).toHaveLength(1); expect(result.inventoryError).toBeUndefined(); expect(result.liveAgents).toBeUndefined(); diff --git a/packages/cli/src/cli/commands/fleet-agent.ts b/packages/cli/src/cli/commands/fleet-agent.ts index 02cad77764c4c256120b337c118b3cbf66d781d9..a284105e66a94ec7dc2c88b3f121812e83c6383a 100644 GIT binary patch delta 96 zcmeBO%=l;#P376P>Lam6070jJ%W9-iiWNq#- w*5{j?@60iIk7L|q0cXd}VNRD>K{AsyJt7&oHaB^&vw^vte$qhh62E;+0B+bIK>z>% diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index d92b1cfb7..5818088a0 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -393,9 +393,7 @@ function buildLocalContribution( input: { liveAgents?: Awaited>; liveError?: string; - inventoryAgents?: Awaited< - ReturnType - >['agents']; + inventoryAgents?: Awaited>['agents']; inventoryError?: string; sessionError?: string; retried?: boolean; @@ -415,9 +413,7 @@ function buildLocalContribution( isLocal: true, ...(input.liveAgents !== undefined ? { liveAgents: input.liveAgents } : {}), ...(input.liveError ? { liveError: input.liveError } : {}), - ...(input.inventoryAgents !== undefined - ? { inventoryAgents: input.inventoryAgents } - : {}), + ...(input.inventoryAgents !== undefined ? { inventoryAgents: input.inventoryAgents } : {}), ...(input.inventoryError ? { inventoryError: input.inventoryError } : {}), ...(input.retried ? { retried: true } : {}), }; @@ -480,9 +476,7 @@ async function runFleetAgentList( const conn = readBrokerConnection(paths.dataDir); let localNodeName: string | undefined; let localLive: Awaited> | undefined; - let localInventory: - | Awaited>['agents'] - | undefined; + let localInventory: Awaited>['agents'] | undefined; let localLiveError: string | undefined; let localInventoryError: string | undefined; /** Whole-session failure (session lookup blew up before either map ran). */ @@ -498,9 +492,7 @@ async function runFleetAgentList( // half never discards the other. `collectWithRetry` retries the pair // once as a unit; a per-half retry policy is more code for no // observable win when both halves hit the same broker. - const result = await collectWithRetry('local broker', () => - readLocalBrokerMaps(client) - ); + const result = await collectWithRetry('local broker', () => readLocalBrokerMaps(client)); if (result.ok) { localLive = result.value.liveAgents; localLiveError = result.value.liveError; @@ -515,9 +507,7 @@ async function runFleetAgentList( localRetried = result.retried; } } catch (error) { - localSessionError = `local broker: ${ - error instanceof Error ? error.message : String(error) - }`; + localSessionError = `local broker: ${error instanceof Error ? error.message : String(error)}`; } finally { client.disconnect(); } @@ -543,14 +533,9 @@ async function runFleetAgentList( // Dropping the local machine's contribution silently was one of the // review findings on the first pass — this is the third-state discipline // applied to the local node itself, not just to per-agent rows. - if ( - (localNodeName || localSessionError || conn) && - !contributions.some((c) => c.isLocal) - ) { + if ((localNodeName || localSessionError || conn) && !contributions.some((c) => c.isLocal)) { const syntheticNodeName = - localNodeName ?? - (process.env.AGENT_RELAY_BROKER_NAME?.trim() || undefined) ?? - '(local broker)'; + localNodeName ?? (process.env.AGENT_RELAY_BROKER_NAME?.trim() || undefined) ?? '(local broker)'; const syntheticNode: RelayNode = { name: syntheticNodeName, status: 'unknown', From d9e61913f0aad24e118bb45df1c7cfa51cb95f26 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 17 Aug 2026 21:26:51 +0200 Subject: [PATCH 5/5] fix(cli): honor fleet agent node scope --- CHANGELOG.md | 2 +- packages/cli/src/cli/bootstrap.test.ts | 1 + packages/cli/src/cli/commands/fleet.test.ts | 52 +++++++++++++++++++++ packages/cli/src/cli/commands/fleet.ts | 9 +++- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5571426..2d1389630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `agent-relay fleet agent list [--pretty|--json] [--node ] [--all]` — fleet-wide agent listing joined against the workspace roster. The `PRESENCE` column names exactly which surfaces observed each agent (`live+inventory+roster`, `live only` for the relay#1539 divergence signature, `inventory only`, `roster only`, `count only` for remote nodes) and carries `(inventory?)` / `(live?)` uncertainty markers when one of the two local broker maps was unqueryable. Every reachable node appears in the output — a node with no agents is never confused with a node that could not be reached. Backed by a new `GET /api/fleet-inventory` broker route that surfaces the in-process `fleet_inventory` snapshot (the same set the broker publishes to the engine via `inventory.sync`); the CLI treats a `success:false` envelope as unknown rather than an empty inventory. See relay#1553. +- `agent-relay fleet agent list [--pretty|--json] [--node ] [--all]` lists fleet-wide agents with their live, inventory, and roster presence, and reports unavailable node data explicitly. ### Fixed diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index fec705b22..feedb91f3 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -40,6 +40,7 @@ const expectedLeafCommands = [ 'reflex off', 'reflex status', // fleet (serve is a hidden error stub, filtered out below) + 'fleet agent list', 'fleet config', 'fleet disable', 'fleet enable', diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 786505f8d..ae9f08a31 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -30,6 +30,12 @@ vi.mock('@agent-relay/harness-driver', async (importOriginal) => ({ uptime_secs: 1, }; } + async listAgents() { + return []; + } + async listFleetInventory() { + return { nodeName: 'live-node', agents: [] }; + } disconnect() {} }, })); @@ -151,6 +157,52 @@ describe('fleet command support', () => { }); }); + it('fleet agent list --node does not synthesize the local broker for a remote target', async () => { + const nodes = { + list: vi.fn(async () => [ + { + name: 'finn-mini', + status: 'online', + live: true, + handlersLive: true, + capabilities: [], + tags: [], + }, + ]), + }; + const agents = { list: vi.fn(async () => []) }; + const logs: string[] = []; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + core: { + getProjectPaths: () => ({ projectRoot: '/p', dataDir: '/p/.agentworkforce/relay', teamDir: '/p' }), + exit: vi.fn(), + } as never, + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: vi.fn(() => ({ nodes, agents })) as never, + createWorkspace: vi.fn() as never, + log: (message: unknown) => logs.push(String(message)), + error: vi.fn(), + exit: vi.fn() as never, + }, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await program.parseAsync( + ['fleet', 'agent', 'list', '--node', 'finn-mini', '--workspace-key', 'rk_live_test'], + { from: 'user' } + ); + + expect(nodes.list).toHaveBeenCalledWith({ name: 'finn-mini' }); + const output = JSON.parse(logs[0]!); + expect(output.perNode.map((row: { node: string }) => row.node)).toEqual(['finn-mini']); + expect(output.perNode.some((row: { node: string }) => row.node === 'live-node')).toBe(false); + }); + it('fleet nodes hides offline and direct pseudo-nodes by default', async () => { const listedNodes = [ { diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 5818088a0..6c1f5c305 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -533,7 +533,14 @@ async function runFleetAgentList( // Dropping the local machine's contribution silently was one of the // review findings on the first pass — this is the third-state discipline // applied to the local node itself, not just to per-agent rows. - if ((localNodeName || localSessionError || conn) && !contributions.some((c) => c.isLocal)) { + const requestedNodeName = typeof options.node === 'string' && options.node ? options.node : undefined; + const localNodeIsInScope = + requestedNodeName === undefined || (localNodeName !== undefined && requestedNodeName === localNodeName); + if ( + localNodeIsInScope && + (localNodeName || localSessionError || conn) && + !contributions.some((c) => c.isLocal) + ) { const syntheticNodeName = localNodeName ?? (process.env.AGENT_RELAY_BROKER_NAME?.trim() || undefined) ?? '(local broker)'; const syntheticNode: RelayNode = {