From 42638cedfc1c898cc051b20b25ffdcd3c0872c49 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 20 Aug 2026 13:37:14 +0200 Subject: [PATCH 1/4] Fix remote fleet agent inventory --- .../active/traj_rb4zzwul9nse/trajectory.json | 95 +++++++++++ .gitattributes | 1 + CHANGELOG.md | 4 + crates/broker/src/fleet_wire.rs | 9 + crates/broker/src/node_control.rs | 50 +++++- crates/broker/src/runtime/api.rs | 10 ++ crates/broker/src/runtime/fleet.rs | 14 +- crates/broker/src/runtime/maintenance.rs | 5 + .../cli/src/cli/commands/fleet-agent.test.ts | 159 +++++++++++++++--- packages/cli/src/cli/commands/fleet-agent.ts | Bin 19664 -> 24296 bytes packages/cli/src/cli/commands/fleet.test.ts | 82 ++++++++- packages/cli/src/cli/commands/fleet.ts | 100 +++++++---- 12 files changed, 465 insertions(+), 64 deletions(-) create mode 100644 .agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json diff --git a/.agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json b/.agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json new file mode 100644 index 000000000..8a7f98014 --- /dev/null +++ b/.agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json @@ -0,0 +1,95 @@ +{ + "id": "traj_rb4zzwul9nse", + "version": 1, + "task": { + "title": "Fix relay#1585 fleet agent list remote names and node filtering", + "source": { + "system": "plain", + "id": "relay#1585" + } + }, + "status": "active", + "startedAt": "2026-08-20T10:27:01.836Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-08-20T10:30:50.236Z" + } + ], + "chapters": [ + { + "id": "chap_np8mxgbaxt1p", + "title": "Work", + "agentName": "default", + "startedAt": "2026-08-20T10:30:50.236Z", + "events": [ + { + "ts": 1787221850240, + "type": "decision", + "content": "Use Relaycast node-agent bindings as the remote-name source: Use Relaycast node-agent bindings as the remote-name source", + "raw": { + "question": "Use Relaycast node-agent bindings as the remote-name source", + "chosen": "Use Relaycast node-agent bindings as the remote-name source", + "alternatives": [], + "reasoning": "Brokers already publish inventory.sync on connect, inventory change, and every 60s; Relaycast exposes active bindings through nodes.listAgents(name). Relay currently omits this facade method and therefore degrades every remote node to count-only. The workspace roster is neither necessary nor trustworthy for liveness." + }, + "significance": "high" + }, + { + "ts": 1787223225407, + "type": "decision", + "content": "Use the existing authenticated terminal tunnel for a node-local live inventory snapshot: Use the existing authenticated terminal tunnel for a node-local live inventory snapshot", + "raw": { + "question": "Use the existing authenticated terminal tunnel for a node-local live inventory snapshot", + "chosen": "Use the existing authenticated terminal tunnel for a node-local live inventory snapshot", + "alternatives": [], + "reasoning": "Live proof falsified node-agent bindings: sf-mini has 231 historical bindings and one of four current PTYs is absent because provider identity reconciliation depends on overloaded D1. A reserved read-only terminal session can carry workers.list directly from the broker through the already-authenticated node tunnel, requiring neither SSH, a public broker port, registry reads, nor an engine API change." + }, + "significance": "high" + }, + { + "ts": 1787224407608, + "type": "decision", + "content": "Publish live worker names in reserved heartbeat capabilities: Publish live worker names in reserved heartbeat capabilities", + "raw": { + "question": "Publish live worker names in reserved heartbeat capabilities", + "chosen": "Publish live worker names in reserved heartbeat capabilities", + "alternatives": [], + "reasoning": "A real temporary sf-mini broker proved Relaycast terminal session creation pre-validates an active agent binding, so the terminal tunnel cannot bootstrap node inventory independently. Heartbeats already carry node capabilities and activeAgents; encoding the broker-owned WorkerName set there adds no request, registry read, or provider registration dependency, and nodes.list already returns the data." + }, + "significance": "high" + }, + { + "ts": 1787225814887, + "type": "reflection", + "content": "Mechanism investigation falsified both the workspace roster and existing node-agent bindings as live truth. A reserved live-name capability on the normal broker heartbeat proved exact against sf-mini OS PTYs through spawn and release, while count/name transition skew was rendered explicitly degraded.", + "raw": { + "focalPoints": [ + "mechanism", + "remote-proof", + "degraded-state" + ], + "adjustments": "Kept names independent of provider registration and D1; added regression coverage for both mismatch directions", + "confidence": 0.94 + }, + "significance": "high", + "tags": [ + "focal:mechanism", + "focal:remote-proof", + "focal:degraded-state", + "confidence:0.94" + ] + } + ] + } + ], + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "b3339a42f23fe067a488771f70176957161952ed", + "endRef": "b3339a42f23fe067a488771f70176957161952ed" + } +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index 9e29d3d84..eed6d9376 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ packages/cli/src/cli/commands/integration-cleanup-journal.ts text diff +packages/cli/src/cli/commands/fleet-agent.ts text diff diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd962366..c17aeb1b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `get_observer_url` MCP tool does the same for an orchestrating agent, so a lead can hand the user a follow-along link without shelling out. - `@agent-relay/sdk` exports `createObserverToken`, `listObserverTokens`, and `revokeObserverToken`. +### Fixed + +- `agent-relay fleet agent list` now names remote-node agents from each broker's live worker map, makes `--node` exclude other nodes and roster-only records, and labels unavailable or inconsistent inventory as degraded. + ## [11.8.0] - 2026-08-19 ### Added diff --git a/crates/broker/src/fleet_wire.rs b/crates/broker/src/fleet_wire.rs index decc34c33..db105f154 100644 --- a/crates/broker/src/fleet_wire.rs +++ b/crates/broker/src/fleet_wire.rs @@ -88,6 +88,12 @@ pub struct FleetCapability { pub metadata: Option>, } +/// Reserved node capability carrying the broker's live WorkerName set in its +/// `metadata.names` array. It rides the existing heartbeat descriptor refresh, +/// so it does not depend on per-agent provider registration or a separate +/// control-plane write. +pub const LIVE_AGENT_CAPABILITY_NAME: &str = "relay:live-agents:v1"; + /// Provider identity carried on connection-scoped node frames. `name` is the /// provider's stable identity — persistence, capability-conflict checks, and the /// engine's routing key. `instance_id` is the connection epoch: re-registering @@ -168,6 +174,9 @@ pub struct NodeHeartbeat { // the engine's server-stamped single source of truth for liveness. pub name: String, pub node_id: String, + // The broker appends reserved live-agent capabilities on every heartbeat; + // their names come directly from its worker registry, independently of + // provider registration and inventory.sync. pub capabilities: Vec, pub max_agents: u32, pub version: String, diff --git a/crates/broker/src/node_control.rs b/crates/broker/src/node_control.rs index 4e27d5e7c..02707eaa9 100644 --- a/crates/broker/src/node_control.rs +++ b/crates/broker/src/node_control.rs @@ -20,7 +20,7 @@ use crate::{ ActionResult, ActionResultError, ActionResultPayload, AgentDeregister, AgentRegister, BrokerToRelaycast, Deliver, DeliveryAck, FleetCapability, FleetProviderIdentity, InventoryAgent, InventorySync, NodeHeartbeat, NodeRegister, RelaycastToBroker, - FLEET_WIRE_VERSION, + FLEET_WIRE_VERSION, LIVE_AGENT_CAPABILITY_NAME, }, protocol::NodeManifest, types::RelaycastDeliveryReceipt, @@ -466,11 +466,13 @@ pub(crate) struct FleetLoadSnapshot { pub(crate) active_agents: u32, pub(crate) max_agents: u32, pub(crate) handlers_live: bool, + pub(crate) active_agent_names: Vec, } impl FleetLoadSnapshot { - /// Build a heartbeat carrying the live load/liveness AND the node roster - /// snapshot (name/node_id/capabilities/version) so the relaycast engine can + /// Build a heartbeat carrying the live load/liveness, the broker-owned + /// WorkerName set, AND the node roster snapshot + /// (name/node_id/capabilities/version) so the relaycast engine can /// keep this node's descriptor fresh from the steady-state heartbeat without /// a fresh `node.register`. /// @@ -495,13 +497,37 @@ impl FleetLoadSnapshot { } else { Some((self.active_agents as f64 / self.max_agents as f64).clamp(0.0, 1.0)) }; + let mut capabilities: Vec<_> = node + .capabilities + .iter() + .filter(|capability| capability.name != LIVE_AGENT_CAPABILITY_NAME) + .cloned() + .collect(); + let mut active_agent_names = self.active_agent_names.clone(); + active_agent_names.sort(); + active_agent_names.dedup(); + capabilities.push(FleetCapability { + name: LIVE_AGENT_CAPABILITY_NAME.to_string(), + kind: Some("capacity".to_string()), + global: None, + queue: None, + metadata: Some(BTreeMap::from([( + "names".to_string(), + serde_json::Value::Array( + active_agent_names + .into_iter() + .map(serde_json::Value::String) + .collect(), + ), + )])), + }); NodeHeartbeat { v: FLEET_WIRE_VERSION, id: None, provider: node.provider.clone(), name: node.name.clone(), node_id: node.node_id.clone(), - capabilities: node.capabilities.clone(), + capabilities, max_agents: self.max_agents, version: node.version.clone(), load, @@ -3718,6 +3744,7 @@ mod tests { active_agents: 1, max_agents: 4, handlers_live: true, + active_agent_names: vec!["agent-a".to_string()], }; let server = tokio::spawn(async move { @@ -3798,6 +3825,7 @@ mod tests { active_agents: 0, max_agents: 4, handlers_live: true, + active_agent_names: Vec::new(), }; let server = tokio::spawn(async move { @@ -4169,14 +4197,28 @@ mod tests { active_agents: 3, max_agents: 4, handlers_live: true, + active_agent_names: vec!["worker-b".to_string(), "worker-a".to_string()], } .heartbeat(®ister); assert_eq!(measured.load, Some(0.75)); + let live_agent_capability = measured + .capabilities + .iter() + .find(|capability| capability.name == LIVE_AGENT_CAPABILITY_NAME) + .expect("heartbeat should publish live WorkerNames"); + assert_eq!( + live_agent_capability + .metadata + .as_ref() + .and_then(|metadata| metadata.get("names")), + Some(&serde_json::json!(["worker-a", "worker-b"])) + ); let unbounded = FleetLoadSnapshot { active_agents: 25, max_agents: 0, handlers_live: true, + active_agent_names: Vec::new(), } .heartbeat(®ister); assert_eq!(unbounded.load, Some(0.0)); diff --git a/crates/broker/src/runtime/api.rs b/crates/broker/src/runtime/api.rs index d1b797f38..4535da971 100644 --- a/crates/broker/src/runtime/api.rs +++ b/crates/broker/src/runtime/api.rs @@ -965,6 +965,11 @@ impl BrokerRuntime { super::fleet::publish_fleet_load_snapshot( fleet_control_tx, u32::try_from(workers.workers.len()).unwrap_or(u32::MAX), + workers + .workers + .keys() + .map(|name| name.as_str().to_string()) + .collect(), fleet_max_agents, fleet_handlers_live, true, @@ -1067,6 +1072,11 @@ impl BrokerRuntime { super::fleet::publish_fleet_load_snapshot( fleet_control_tx, u32::try_from(workers.workers.len()).unwrap_or(u32::MAX), + workers + .workers + .keys() + .map(|name| name.as_str().to_string()) + .collect(), fleet_max_agents, fleet_handlers_live, true, diff --git a/crates/broker/src/runtime/fleet.rs b/crates/broker/src/runtime/fleet.rs index 9d9370370..00e4f8500 100644 --- a/crates/broker/src/runtime/fleet.rs +++ b/crates/broker/src/runtime/fleet.rs @@ -1489,12 +1489,19 @@ impl BrokerRuntime { async fn publish_fleet_load(&self, heartbeat_now: bool) { let active_agents = u32::try_from(self.workers.workers.len()).unwrap_or(u32::MAX); + let active_agent_names = self + .workers + .workers + .keys() + .map(|name| name.as_str().to_string()) + .collect(); // The broker provider's capacity handlers (spawn/release) are live for as // long as its connection is up, so `handlers_live` is unconditionally true // here — a connected broker can always place work. publish_fleet_load_snapshot( &self.fleet_control_tx, active_agents, + active_agent_names, self.fleet_max_agents, true, heartbeat_now, @@ -1823,6 +1830,7 @@ pub(super) async fn register_node_agent_token( pub(super) async fn publish_fleet_load_snapshot( fleet_control_tx: &mpsc::Sender, active_agents: u32, + active_agent_names: Vec, max_agents: u32, handlers_live: bool, heartbeat_now: bool, @@ -1832,6 +1840,7 @@ pub(super) async fn publish_fleet_load_snapshot( active_agents, max_agents, handlers_live, + active_agent_names, })) { tracing::warn!(error = %error, "fleet load update queue is unavailable; periodic heartbeat will retry"); @@ -3271,7 +3280,7 @@ mod tests { tokio::time::timeout( Duration::from_millis(50), - publish_fleet_load_snapshot(&tx, 1, 4, true, true), + publish_fleet_load_snapshot(&tx, 1, vec!["worker-a".to_string()], 4, true, true), ) .await .expect("load publication must not stall the runtime API actor"); @@ -4012,11 +4021,12 @@ mod tests { async fn publish_fleet_load_snapshot_emits_immediate_heartbeat_after_release() { let (tx, mut rx) = mpsc::channel(4); - publish_fleet_load_snapshot(&tx, 1, 4, true, true).await; + publish_fleet_load_snapshot(&tx, 1, vec!["worker-a".to_string()], 4, true, true).await; match rx.recv().await { Some(FleetControlCommand::UpdateLoad(load)) => { assert_eq!(load.active_agents, 1); + assert_eq!(load.active_agent_names, vec!["worker-a"]); assert_eq!(load.max_agents, 4); assert!(load.handlers_live); } diff --git a/crates/broker/src/runtime/maintenance.rs b/crates/broker/src/runtime/maintenance.rs index 9019130bd..2d69f1547 100644 --- a/crates/broker/src/runtime/maintenance.rs +++ b/crates/broker/src/runtime/maintenance.rs @@ -729,6 +729,11 @@ impl BrokerRuntime { super::fleet::publish_fleet_load_snapshot( fleet_control_tx, u32::try_from(workers.workers.len()).unwrap_or(u32::MAX), + workers + .workers + .keys() + .map(|name| name.as_str().to_string()) + .collect(), fleet_max_agents, fleet_handlers_live, true, diff --git a/packages/cli/src/cli/commands/fleet-agent.test.ts b/packages/cli/src/cli/commands/fleet-agent.test.ts index 3cb5b0b78..08ab6d07b 100644 --- a/packages/cli/src/cli/commands/fleet-agent.test.ts +++ b/packages/cli/src/cli/commands/fleet-agent.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; -import { buildRows, collectWithRetry, formatPretty, readLocalBrokerMaps } from './fleet-agent.js'; +import { + buildRows, + collectWithRetry, + formatPretty, + LIVE_AGENT_CAPABILITY_NAME, + readLocalBrokerMaps, + readRemoteLiveAgents, + type RemoteLiveAgent, +} from './fleet-agent.js'; import type { FleetInventoryAgent, ListAgent } from '@agent-relay/harness-driver'; import type { RelayNode } from '@agent-relay/sdk'; @@ -29,6 +37,56 @@ function inventoryAgent(name: string): FleetInventoryAgent { return { agent_id: `ag_${name}`, name }; } +function remoteAgent(name: string): RemoteLiveAgent { + return { name }; +} + +describe('readRemoteLiveAgents', () => { + it('decodes the versioned heartbeat marker and exact WorkerNames', () => { + const result = readRemoteLiveAgents( + node({ + name: 'sf-mini', + capabilities: [ + { + name: LIVE_AGENT_CAPABILITY_NAME, + kind: 'capacity', + metadata: { names: ['worker-z', 'å-worker'] }, + }, + ], + }) + ); + + expect(result).toEqual({ + supported: true, + agents: [{ name: 'å-worker' }, { name: 'worker-z' }], + }); + }); + + it('treats an absent marker as an unsupported old broker', () => { + expect(readRemoteLiveAgents(node({ name: 'sf-mini', capabilities: [] }))).toEqual({ + supported: false, + agents: [], + }); + }); + + it('reports malformed names instead of decoding them authoritatively', () => { + expect( + readRemoteLiveAgents( + node({ + name: 'sf-mini', + capabilities: [ + { + name: LIVE_AGENT_CAPABILITY_NAME, + kind: 'capacity', + metadata: { names: [null] }, + }, + ], + }) + ) + ).toMatchObject({ supported: true, agents: [], warning: expect.stringContaining('1 malformed') }); + }); +}); + describe('buildRows — the diagnostic column exists', () => { it('flags a live+inventory+roster row when all three surfaces agree', () => { const out = buildRows( @@ -180,20 +238,84 @@ describe('buildRows — the diagnostic column exists', () => { 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. + it('remote nodes render names from their heartbeat-published live WorkerName set', () => { + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'finn-mini', activeAgents: 2, lastHeartbeatAt: NOW.toISOString() }), + isLocal: false, + remoteAgents: [remoteAgent('worker-b'), remoteAgent('worker-a')], + }, + ], + roster: [], + }, + NOW + ); + + expect(out.perNode.map((row) => row.name)).toEqual(['worker-a', 'worker-b']); + expect(out.perNode.every((row) => row.presence === 'remote live')).toBe(true); + expect(formatPretty(out)).not.toContain('names unavailable'); + }); + + it('labels a failed remote inventory query as degraded rather than authoritative count-only output', () => { + const out = buildRows( + { + contributions: [ + { + node: node({ name: 'finn-mini', activeAgents: 2 }), + isLocal: false, + remoteError: 'remote broker does not support live inventory snapshots', + retried: true, + }, + ], + roster: [], + }, + NOW + ); + + expect(out.perNode[0]).toMatchObject({ + node: 'finn-mini', + state: '· remote degraded', + presence: 'count only (degraded)', + }); + expect(out.perNode[0]?.name).toContain('names unavailable: live-name heartbeat unavailable'); + expect(formatPretty(out)).toContain('names may be incomplete'); + }); + + it('keeps known remote names but exposes a heartbeat/inventory count mismatch', () => { const out = buildRows( { contributions: [ { - node: node({ name: 'finn-mini', activeAgents: 33 }), + node: node({ name: 'finn-mini', activeAgents: 2, lastHeartbeatAt: NOW.toISOString() }), isLocal: false, + remoteAgents: [remoteAgent('worker-a')], }, + ], + roster: [], + }, + NOW + ); + + expect(out.perNode.map((row) => row.name)).toEqual([ + '<1 additional agent — names unavailable: broker/heartbeat mismatch>', + 'worker-a', + ]); + expect(out.perNode.find((row) => row.name === 'worker-a')?.note).toContain( + 'heartbeat reports 2, broker returned 1' + ); + expect(out.perNode.find((row) => row.name.startsWith('<'))?.state).toBe('· remote degraded'); + }); + + it('labels names as degraded when the control-plane count updates before the next broker heartbeat', () => { + const out = buildRows( + { + contributions: [ { - node: node({ name: 'chief-broker', activeAgents: 0 }), + node: node({ name: 'sf-mini', activeAgents: 0, lastHeartbeatAt: NOW.toISOString() }), isLocal: false, + remoteAgents: [remoteAgent('worker-releasing')], }, ], roster: [], @@ -201,18 +323,13 @@ describe('buildRows — the diagnostic column exists', () => { 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'); + expect(out.perNode).toHaveLength(1); + expect(out.perNode[0]).toMatchObject({ + name: 'worker-releasing', + presence: 'remote live', + note: 'degraded: heartbeat reports 0, broker returned 1', + }); + expect(formatPretty(out)).toContain('degraded: heartbeat reports 0, broker returned 1'); }); it('roster-only identities land in a distinct unplaced section', () => { @@ -257,6 +374,7 @@ describe('buildRows — the diagnostic column exists', () => { { node: node({ name: 'finn-mini', activeAgents: 2 }), isLocal: false, + remoteAgents: [remoteAgent('a-remote'), remoteAgent('b-remote')], }, ], roster: [], @@ -266,7 +384,8 @@ describe('buildRows — the diagnostic column exists', () => { const order = out.perNode.map((r) => `${r.node}:${r.name}`); expect(order).toEqual([ - 'finn-mini:<2 agents — names unavailable>', + 'finn-mini:a-remote', + 'finn-mini:b-remote', 'sf-mini:a-worker', 'sf-mini:b-worker', ]); diff --git a/packages/cli/src/cli/commands/fleet-agent.ts b/packages/cli/src/cli/commands/fleet-agent.ts index e5f5c172a796b44fb42b2be1cf393b0d756f4c07..004d9304e3194e4c5aa2716a571cbe6147ee2d56 100644 GIT binary patch delta 4280 zcmbtX-ESL35Lba##YnACOF^Y+$EEH068oIARcV|$Q5uq}Qi)2{QlSvlYu`FKINw=& z=L9#k4DUQ379k<=KL83ZJX9)zM?gG-cqk8uC;kB>U}kskBOfhDSW50=Z)RtHGxM9- zA3xdi`~5wiFF!F1ScgZ@VnTS_1up;wf=67e;ZqR0ya1yBxZY(=&-da!gaO2>9GW70 zn+q$#k3@(0u*#W;o1DdEcq{Y*lx8c;3!)gmW-&y#jaHR@h_VeoGCii{R?D7@C+|8p;e)goz-|2=T#t&mI+N{Ol2JeJ1 zzm{l)jRF8UDIZ)te6bahMc1yrK7VKS%KYNZJ9D$wXD?s9cJ<~PcNS+~orgMDg8Qsr z^HD*CDZ6TG6Y`4#&kwigy}_BQbVt1|S-{;IG{ey6ESSO_jpTM~mX>L8or%B;R%U7t z#R6}p3WGwSIywrk@D@Uz!?zNy6O^|@@irfdMRWrq9;e}I_Ay)(r3`)tMymw_O1l>z zycjTn#%{wUDv?|b4McAj@z$pM-Cgu?%cyt8_=y;`nICcTgjO*6ZOj2Qvkv1^1$pZ< z3~gG(w@}iE=kuqnEOydY9*=B=B!JgOfi%?|3L6yb^~VIv#+(UaFX$&ThZqjD zCKkzSZG~X#1Yv|rxm`LuW-g1_jM^_E%e5uNSs_^8@gj9CCDL|cE#zDutHXqr#9|5M z-4h3fq$US}5r?o1sV3vL-Y)KUdA6whwsd}2aPJ-z69srrz(md}ixQ)9HYHXKDI$5Z z$gn(A00ZT!E|17zuA8dUuFyLD2_E-EV2n-ky{x2@MAulgMc64R>&iz!-Pll{hSG*I z83wj3AD*iWwW#Abr7U@-VceKAlfps}nqsLz_N+Juk z(QrX$D<;+i%z)LmpavGEp*3h|%V^&OoEmHlYk zg|W?__kXwdHKUKxe5JnQGYSHARin$a_rXF+3~ZpD1(>z zGSKzH1@(AgQvP=6tMjBEm8xwP=>N+L>R}Qel&HGQ(|;6Hm&wg94^Qv2F)LX>h+$%S zvEMgM3*(~vt8yXm6A`X&UVQPLr{o7v9wz*uSP)B9c+DQsx1*ubsRDIO+g{-EHZ|Fd zid1!DZxbBX1b63DE3KQ7qJ-ACZV}I|0+g{7m}=Q8(Sb6z_0IqqzN^@X$?+oXg3Da4LLJfJ#6wdNkEGzLEg8H%a2D(@^7nfJeQnm zF+mF}2m7r2q4@kVCOt-#yP9y)*(T>~PI91{sMhj`l+Yj;x|Gg|$}nPq7khX498OBq zd$%W%k;v;#h;Fz3V9+(~GsjpM+$H4i?K>1wN?S&vueP=ITD;>$9TvA%RbJ4XN1Z** zWH@uiq#eD^XT0O{U?pBnl1wWjP_N^VNWFYXeqB8E>{1HuGSd0dvMnDJN7T$_SP!Xw zsgZ<_;=A2=RB1jPG>nd{9jIoNc(7Gd#>ToLBBdKW9F&J|@d!4HX0Ez)Qi=sGC;0+GY`Fy8Ww*0Mp{G9QZ%4Qe16^D*z8AqHrE+5*z)c@bk zd&gcH>Pme~dQb0`+&g(3t0lstL0T-8-Ncfb0O)c-L%buOt{zNca;9+!2Oh0w065+u zGlT)2s#JK=CHY*jG=hg0HP%$KWKQb!Kk{GwQT+GcqK4RwJT_bypDUB(C)|w#4cNK!A)cYBcybe{Iv<)dxp-jSLybe tz89?`?I|mF8CC@jt3k`dQ~i{2v%~r{#d-mui|0%3&=?4?d3&z^^gl28aAE)e delta 857 zcmZ{iL2DC16vs^)6;}`srl6_yc`(^XHfialG&Z%^3WA7eR1k!n?oN`SyE9>Cl3Fim zKY@oi34%941r-()5k2G+coRJ4B!ZuyBEH$gRs}E1uy5X*|Nr-YUq56%J<7bkH_;b7 z+5+9C;FCbJ9$jfsu9Xjg=yDBBDPjlC1Axy(NL3|bO1mY$0u5~06mmm#NI+rb`V|NT zRltP?%DXX@L{sQc>DfLYb}DcZQ#Bf=MM$=45Y+86UMfP0O$9_!h{!sPA{qiwpt?aD za8{d2R7p65Qgk|46B3$aBbl0fHJLm<`XiHkJ~lk)j8xlkiIe-1&E(CulhgGB-kDMv zR@bhA+sDJDIdBKt-rxzI*ICFw^r(~!?K#ydxWnz|-f;V68HPJgz2VL?(>nDoha>vf zF|Lg(Nh4oBblHtWK%z3}P2t6)1Co-kWc3kHjJG00yD!TJvve*_!DeU1%<4D7zyu~;zs((_ez_k%#3=9B=S)5%?dW0cVRMKN4?f*Nu zaTvONmqN%?5D|s}0;V8%3j_N-P4KkYI#2@bS*Lg}Al$|tGi84OjXQb^n&|BTmBLG- zR~1t(%pZp3Wtgow=4bV^S-3be;a@OL(KWrwoFgKz6NfreN$p-DvACq#D7Rx!Whq!$viEack+wFL@L%#Q*>R diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index ae9f08a31..793ad8641 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -43,6 +43,15 @@ vi.mock('@agent-relay/harness-driver', async (importOriginal) => ({ import { registerFleetCommands } from './fleet.js'; import { writeProjectWorkspaceKey } from '../lib/project-workspace-key.js'; +const LIVE_AGENT_CAPABILITY_NAME = 'relay:live-agents:v1'; +const liveAgentCapabilities = (...names: string[]) => [ + { + name: LIVE_AGENT_CAPABILITY_NAME, + kind: 'capacity', + metadata: { names }, + }, +]; + describe('fleet command support', () => { it.each([ ['config', 'get', undefined], @@ -157,7 +166,7 @@ describe('fleet command support', () => { }); }); - it('fleet agent list --node does not synthesize the local broker for a remote target', async () => { + it('must-fire: fleet agent list --node returns the named remote node agents', async () => { const nodes = { list: vi.fn(async () => [ { @@ -165,7 +174,8 @@ describe('fleet command support', () => { status: 'online', live: true, handlersLive: true, - capabilities: [], + activeAgents: 1, + capabilities: liveAgentCapabilities('finn-worker'), tags: [], }, ]), @@ -193,16 +203,82 @@ describe('fleet command support', () => { }); await program.parseAsync( - ['fleet', 'agent', 'list', '--node', 'finn-mini', '--workspace-key', 'rk_live_test'], + ['fleet', 'agent', 'list', '--node', 'finn-mini', '--json', '--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.map((row: { name: string }) => row.name)).toEqual(['finn-worker']); expect(output.perNode.some((row: { node: string }) => row.node === 'live-node')).toBe(false); }); + it('must-not-fire: fleet agent list --node excludes other nodes and roster-only rows', async () => { + const nodes = { + // Deliberately return an extra node even though the query names finn-mini: + // the CLI must enforce the filter rather than trust a remote API to do it. + list: vi.fn(async () => [ + { + name: 'finn-mini', + status: 'online', + live: true, + handlersLive: true, + activeAgents: 1, + capabilities: liveAgentCapabilities('finn-mini-worker'), + tags: [], + }, + { + name: 'sf-mini', + status: 'online', + live: true, + handlersLive: true, + activeAgents: 1, + capabilities: liveAgentCapabilities('sf-mini-worker'), + tags: [], + }, + ]), + }; + const agents = { + list: vi.fn(async () => [ + { name: 'sf-mini-worker', status: 'online' }, + { name: 'historical-roster-sediment', status: 'online' }, + ]), + }; + 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' }); + expect(agents.list).not.toHaveBeenCalled(); + const output = JSON.parse(logs[0]!); + expect(output.perNode.map((row: { node: string }) => row.node)).toEqual(['finn-mini']); + expect(output.perNode.map((row: { name: string }) => row.name)).toEqual(['finn-mini-worker']); + expect(output.unplacedRoster).toEqual([]); + }); + 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 6c1f5c305..0134abb4d 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -7,6 +7,7 @@ import { buildRows, collectWithRetry, formatPretty, + readRemoteLiveAgents, readLocalBrokerMaps, type FleetNodeContribution, type RosterAgent, @@ -127,7 +128,7 @@ export function registerFleetCommands( .description('List agents on every reachable fleet node, joined against the workspace roster') .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('--node ', 'Return only this node and its live broker agents') .option('--all', 'Include offline/history nodes the way `fleet nodes --all` does') ).action(async (options: Record) => { await runFleetAgentList(deps, options); @@ -423,9 +424,9 @@ function buildLocalContribution( * 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. + * map and the fleet_inventory snapshot from it. Remote live names arrive in + * the node heartbeat capabilities returned by the same `nodes.list()` call. + * 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. @@ -438,36 +439,44 @@ async function runFleetAgentList( warnIfInferredFromProjectSession(options, deps.warn); const clientOptions = sdkOptionsFromOpts(options); const relay = deps.sdk.createWorkspaceRelay(clientOptions); + const requestedNodeName = typeof options.node === 'string' && options.node ? options.node : undefined; // 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 } : {}), + ...(requestedNodeName ? { name: requestedNodeName } : {}), }); const includeAll = options.all === true; - const visibleNodes = includeAll ? nodes : nodes.filter(isAvailableFleetNode); + const visibleNodes = (includeAll ? nodes : nodes.filter(isAvailableFleetNode)).filter( + (node) => !requestedNodeName || node.name === requestedNodeName + ); // 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.' - ); + // A targeted query must be a real filter, not a node row followed by + // unrelated workspace sediment. It also avoids an unnecessary D1 roster + // read on the path operators use to inspect one remote node. + if (!requestedNodeName) { + 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. @@ -513,27 +522,48 @@ async function runFleetAgentList( } } - // Assemble per-node contributions. Every visible node produces one. - const contributions: FleetNodeContribution[] = visibleNodes.map((node) => { + // Assemble per-node contributions. Brokers encode the live WorkerName set + // in reserved heartbeat capabilities. This path is independent of agent + // registration and adds no API call beyond nodes.list(): no workspace + // roster and no node-binding read. + const contributions: FleetNodeContribution[] = []; + for (const node of visibleNodes) { if (localNodeName && node.name === localNodeName) { - return buildLocalContribution(node, { - liveAgents: localLive, - liveError: localLiveError, - inventoryAgents: localInventory, - inventoryError: localInventoryError, - sessionError: localSessionError, - retried: localRetried, + contributions.push( + buildLocalContribution(node, { + liveAgents: localLive, + liveError: localLiveError, + inventoryAgents: localInventory, + inventoryError: localInventoryError, + sessionError: localSessionError, + retried: localRetried, + }) + ); + continue; + } + + const remote = readRemoteLiveAgents(node); + if (remote.supported) { + contributions.push({ + node, + isLocal: false, + remoteAgents: remote.agents, + ...(remote.warning ? { remoteWarning: remote.warning } : {}), + }); + } else { + contributions.push({ + node, + isLocal: false, + remoteError: 'broker heartbeat does not publish live agent names', }); } - 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. - const requestedNodeName = typeof options.node === 'string' && options.node ? options.node : undefined; const localNodeIsInScope = requestedNodeName === undefined || (localNodeName !== undefined && requestedNodeName === localNodeName); if ( From 2ae901b14e48ddbf3da8db46f99f00166c9e9d19 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 20 Aug 2026 13:44:22 +0200 Subject: [PATCH 2/4] Cover live agent heartbeat in fleet E2E --- .../active/traj_rb4zzwul9nse/trajectory.json | 12 ++++++++++++ tests/e2e/fleet/fleet-e2e.test.ts | 12 ++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json b/.agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json index 8a7f98014..e36703397 100644 --- a/.agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json +++ b/.agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json @@ -80,6 +80,18 @@ "focal:degraded-state", "confidence:0.94" ] + }, + { + "ts": 1787226256409, + "type": "decision", + "content": "Extended the existing real two-node E2E capability contract for relay:live-agents:v1: Extended the existing real two-node E2E capability contract for relay:live-agents:v1", + "raw": { + "question": "Extended the existing real two-node E2E capability contract for relay:live-agents:v1", + "chosen": "Extended the existing real two-node E2E capability contract for relay:live-agents:v1", + "alternatives": [], + "reasoning": "Fleet E2E correctly caught the new heartbeat capability as an unacknowledged protocol surface; asserting metadata.names is initially empty turns the repair into end-to-end coverage rather than merely loosening the expected array" + }, + "significance": "high" } ] } diff --git a/tests/e2e/fleet/fleet-e2e.test.ts b/tests/e2e/fleet/fleet-e2e.test.ts index 91180c823..4bff18bb1 100644 --- a/tests/e2e/fleet/fleet-e2e.test.ts +++ b/tests/e2e/fleet/fleet-e2e.test.ts @@ -251,11 +251,12 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { expect(b.handlers_live).toBe(true); // The aggregate is the union of the broker provider's capacity (its pinned // spawn: + release, plus the relay:delivery-cursor-v1 marker it - // advertises for restart-safe mailbox resume) and the fleet provider's - // action capabilities. + // advertises for restart-safe mailbox resume and the relay:live-agents:v1 + // heartbeat snapshot) and the fleet provider's action capabilities. expect(a.capabilities.map((c) => c.name).sort()).toEqual([ 'echo', 'relay:delivery-cursor-v1', + 'relay:live-agents:v1', 'release', 'spawn:claude', 'spawn:pool', @@ -264,11 +265,18 @@ describe.skipIf(!pre.ok)('two-node fleet scenario matrix', () => { expect(b.capabilities.map((c) => c.name).sort()).toEqual([ 'ping', 'relay:delivery-cursor-v1', + 'relay:live-agents:v1', 'release', 'spawn:codex', 'spawn:pool', 'work', ]); + expect(a.capabilities.find((c) => c.name === 'relay:live-agents:v1')?.metadata).toEqual({ + names: [], + }); + expect(b.capabilities.find((c) => c.name === 'relay:live-agents:v1')?.metadata).toEqual({ + names: [], + }); }); it('negative auth: a node whose broker presents a bogus token never comes online', async () => { From e418a9c721d740c8a5a7e81051582fa9b0b2c1d7 Mon Sep 17 00:00:00 2001 From: Miya Date: Thu, 20 Aug 2026 13:58:24 +0200 Subject: [PATCH 3/4] Complete fleet agent inventory trajectory --- .../2026-08/traj_rb4zzwul9nse.trace.json | 384 ++++++++++++++++++ .../2026-08/traj_rb4zzwul9nse/summary.md | 55 +++ .../traj_rb4zzwul9nse/trajectory.json | 33 +- 3 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_rb4zzwul9nse.trace.json create mode 100644 .agentworkforce/trajectories/completed/2026-08/traj_rb4zzwul9nse/summary.md rename .agentworkforce/trajectories/{active => completed/2026-08}/traj_rb4zzwul9nse/trajectory.json (79%) diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_rb4zzwul9nse.trace.json b/.agentworkforce/trajectories/completed/2026-08/traj_rb4zzwul9nse.trace.json new file mode 100644 index 000000000..fda4a05e0 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_rb4zzwul9nse.trace.json @@ -0,0 +1,384 @@ +{ + "version": "1.0.0", + "id": "31716bb0-037d-4446-9b00-e71b9ad7365b", + "timestamp": "2026-08-20T11:58:02.171Z", + "trajectory": "traj_rb4zzwul9nse", + "files": [ + { + "path": ".agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 107, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": ".gitattributes", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 2, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": "CHANGELOG.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 5, + "end_line": 15, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": "crates/broker/src/fleet_wire.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 88, + "end_line": 99, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 174, + "end_line": 182, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": "crates/broker/src/node_control.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 20, + "end_line": 26, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 466, + "end_line": 478, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 497, + "end_line": 533, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 3744, + "end_line": 3750, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 3825, + "end_line": 3831, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 4197, + "end_line": 4224, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": "crates/broker/src/runtime/api.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 965, + "end_line": 975, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 1072, + "end_line": 1082, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": "crates/broker/src/runtime/fleet.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1489, + "end_line": 1507, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 1830, + "end_line": 1836, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 1840, + "end_line": 1846, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 3280, + "end_line": 3286, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 4021, + "end_line": 4032, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": "crates/broker/src/runtime/maintenance.rs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 729, + "end_line": 739, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/commands/fleet-agent.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 14, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 37, + "end_line": 92, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 238, + "end_line": 321, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 323, + "end_line": 335, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 374, + "end_line": 380, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 384, + "end_line": 391, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/commands/fleet-agent.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 9, + "end_line": 17, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 36, + "end_line": 85, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 90, + "end_line": 101, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 138, + "end_line": 147, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 185, + "end_line": 196, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 232, + "end_line": 238, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 264, + "end_line": 353, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 545, + "end_line": 558, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/commands/fleet.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 43, + "end_line": 57, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 166, + "end_line": 172, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 174, + "end_line": 181, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 203, + "end_line": 284, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/commands/fleet.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 7, + "end_line": 13, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 128, + "end_line": 134, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 424, + "end_line": 432, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 439, + "end_line": 482, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 522, + "end_line": 569, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + }, + { + "path": "tests/e2e/fleet/fleet-e2e.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 251, + "end_line": 262, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + }, + { + "start_line": 265, + "end_line": 282, + "revision": "eabfba00fee66862ae428df9437a5cd116f95184" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_rb4zzwul9nse/summary.md b/.agentworkforce/trajectories/completed/2026-08/traj_rb4zzwul9nse/summary.md new file mode 100644 index 000000000..60ffda310 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_rb4zzwul9nse/summary.md @@ -0,0 +1,55 @@ +# Trajectory: Fix relay#1585 fleet agent list remote names and node filtering + +> **Status:** ✅ Completed +> **Task:** relay#1585 +> **Confidence:** 96% +> **Started:** August 20, 2026 at 12:27 PM +> **Completed:** August 20, 2026 at 01:58 PM + +--- + +## Summary + +Fixed relay#1585 by publishing exact broker live WorkerNames in the existing node heartbeat, decoding them for remote fleet agent output, enforcing strict targeted filtering without roster reads, and labeling all unavailable or inconsistent states degraded. Proved the mechanism on a disposable real sf-mini broker/PTY and hardened unit plus two-node E2E coverage. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Use Relaycast node-agent bindings as the remote-name source +- **Chose:** Use Relaycast node-agent bindings as the remote-name source +- **Reasoning:** Brokers already publish inventory.sync on connect, inventory change, and every 60s; Relaycast exposes active bindings through nodes.listAgents(name). Relay currently omits this facade method and therefore degrades every remote node to count-only. The workspace roster is neither necessary nor trustworthy for liveness. + +### Use the existing authenticated terminal tunnel for a node-local live inventory snapshot +- **Chose:** Use the existing authenticated terminal tunnel for a node-local live inventory snapshot +- **Reasoning:** Live proof falsified node-agent bindings: sf-mini has 231 historical bindings and one of four current PTYs is absent because provider identity reconciliation depends on overloaded D1. A reserved read-only terminal session can carry workers.list directly from the broker through the already-authenticated node tunnel, requiring neither SSH, a public broker port, registry reads, nor an engine API change. + +### Publish live worker names in reserved heartbeat capabilities +- **Chose:** Publish live worker names in reserved heartbeat capabilities +- **Reasoning:** A real temporary sf-mini broker proved Relaycast terminal session creation pre-validates an active agent binding, so the terminal tunnel cannot bootstrap node inventory independently. Heartbeats already carry node capabilities and activeAgents; encoding the broker-owned WorkerName set there adds no request, registry read, or provider registration dependency, and nodes.list already returns the data. + +### Extended the existing real two-node E2E capability contract for relay:live-agents:v1 +- **Chose:** Extended the existing real two-node E2E capability contract for relay:live-agents:v1 +- **Reasoning:** Fleet E2E correctly caught the new heartbeat capability as an unacknowledged protocol surface; asserting metadata.names is initially empty turns the repair into end-to-end coverage rather than merely loosening the expected array + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Use Relaycast node-agent bindings as the remote-name source: Use Relaycast node-agent bindings as the remote-name source +- Use the existing authenticated terminal tunnel for a node-local live inventory snapshot: Use the existing authenticated terminal tunnel for a node-local live inventory snapshot +- Publish live worker names in reserved heartbeat capabilities: Publish live worker names in reserved heartbeat capabilities +- Mechanism investigation falsified both the workspace roster and existing node-agent bindings as live truth. A reserved live-name capability on the normal broker heartbeat proved exact against sf-mini OS PTYs through spawn and release, while count/name transition skew was rendered explicitly degraded. +- Extended the existing real two-node E2E capability contract for relay:live-agents:v1: Extended the existing real two-node E2E capability contract for relay:live-agents:v1 + +--- + +## Artifacts + +**Commits:** eabfba00f, 462325e5f +**Files changed:** 13 diff --git a/.agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json b/.agentworkforce/trajectories/completed/2026-08/traj_rb4zzwul9nse/trajectory.json similarity index 79% rename from .agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json rename to .agentworkforce/trajectories/completed/2026-08/traj_rb4zzwul9nse/trajectory.json index e36703397..075febeaf 100644 --- a/.agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json +++ b/.agentworkforce/trajectories/completed/2026-08/traj_rb4zzwul9nse/trajectory.json @@ -8,8 +8,9 @@ "id": "relay#1585" } }, - "status": "active", + "status": "completed", "startedAt": "2026-08-20T10:27:01.836Z", + "completedAt": "2026-08-20T11:58:01.765Z", "agents": [ { "name": "default", @@ -23,6 +24,7 @@ "title": "Work", "agentName": "default", "startedAt": "2026-08-20T10:30:50.236Z", + "endedAt": "2026-08-20T11:58:01.765Z", "events": [ { "ts": 1787221850240, @@ -96,12 +98,35 @@ ] } ], - "commits": [], - "filesChanged": [], + "retrospective": { + "summary": "Fixed relay#1585 by publishing exact broker live WorkerNames in the existing node heartbeat, decoding them for remote fleet agent output, enforcing strict targeted filtering without roster reads, and labeling all unavailable or inconsistent states degraded. Proved the mechanism on a disposable real sf-mini broker/PTY and hardened unit plus two-node E2E coverage.", + "approach": "Standard approach", + "confidence": 0.96 + }, + "commits": [ + "eabfba00f", + "462325e5f" + ], + "filesChanged": [ + ".agentworkforce/trajectories/active/traj_rb4zzwul9nse/trajectory.json", + ".gitattributes", + "CHANGELOG.md", + "crates/broker/src/fleet_wire.rs", + "crates/broker/src/node_control.rs", + "crates/broker/src/runtime/api.rs", + "crates/broker/src/runtime/fleet.rs", + "crates/broker/src/runtime/maintenance.rs", + "packages/cli/src/cli/commands/fleet-agent.test.ts", + "packages/cli/src/cli/commands/fleet-agent.ts", + "packages/cli/src/cli/commands/fleet.test.ts", + "packages/cli/src/cli/commands/fleet.ts", + "tests/e2e/fleet/fleet-e2e.test.ts" + ], "projectId": "AgentWorkforce/relay", "tags": [], "_trace": { "startRef": "b3339a42f23fe067a488771f70176957161952ed", - "endRef": "b3339a42f23fe067a488771f70176957161952ed" + "endRef": "eabfba00fee66862ae428df9437a5cd116f95184", + "traceId": "31716bb0-037d-4446-9b00-e71b9ad7365b" } } \ No newline at end of file From d032b856629b7403d8140a15dc41e3d0a9fa6722 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 20 Aug 2026 15:18:08 +0200 Subject: [PATCH 4/4] Label roster-skipped --node listing and split changelog Warn when `fleet agent list --node ` skips the workspace roster fetch, so the PRESENCE column's `remote live` value on those rows is labelled as node-local liveness only rather than reading as a confirmed non-membership. Same finding on both fleet.ts:461 and fleet.ts:479. Split the changelog entry into three impact-first bullets and drop the "from each broker's live worker map" implementation backstory to match the repository's changelog conventions. Co-Authored-By: Claude Opus 4.7 Session-Id: 4ecdc4d9-be27-48cc-b3f5-ba84b1f6866d --- CHANGELOG.md | 4 +++- packages/cli/src/cli/commands/fleet.test.ts | 6 +++++- packages/cli/src/cli/commands/fleet.ts | 10 ++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c17aeb1b3..f24b0fbb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `agent-relay fleet agent list` now names remote-node agents from each broker's live worker map, makes `--node` exclude other nodes and roster-only records, and labels unavailable or inconsistent inventory as degraded. +- `agent-relay fleet agent list` names live agents on remote fleet nodes instead of dropping their rows. +- `agent-relay fleet agent list --node ` is now an exact local filter that excludes other nodes and roster-only records. +- `agent-relay fleet agent list` labels missing, malformed, or count-mismatched remote inventory as `count only (degraded)` rather than rendering a bare count. ## [11.8.0] - 2026-08-19 diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 793ad8641..5d8cd7ea6 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -246,6 +246,7 @@ describe('fleet command support', () => { ]), }; const logs: string[] = []; + const warnings: string[] = []; const program = new Command(); program.exitOverride(); registerFleetCommands(program, { @@ -262,7 +263,7 @@ describe('fleet command support', () => { exit: vi.fn() as never, }, log: () => undefined, - warn: () => undefined, + warn: (...args: unknown[]) => warnings.push(args.join(' ')), error: () => undefined, }); @@ -277,6 +278,9 @@ describe('fleet command support', () => { expect(output.perNode.map((row: { node: string }) => row.node)).toEqual(['finn-mini']); expect(output.perNode.map((row: { name: string }) => row.name)).toEqual(['finn-mini-worker']); expect(output.unplacedRoster).toEqual([]); + // A targeted --node listing skips the roster fetch. Label that skip so + // the reader does not read the missing roster check as a negative result. + expect(warnings.join('\n')).toMatch(/roster not queried for a targeted --node listing/); }); it('fleet nodes hides offline and direct pseudo-nodes by default', async () => { diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 0134abb4d..fd1a7a5cc 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -477,6 +477,16 @@ async function runFleetAgentList( 'PRESENCE column will not report roster membership.' ); } + } else { + // A targeted `--node` listing intentionally skips the workspace roster + // fetch, so the PRESENCE column cannot label roster membership on the + // returned rows. Say so explicitly; a silent absence would look like a + // confirmed negative result and let the same agent appear with + // different PRESENCE values across `--node` and non-`--node` runs. + deps.warn( + 'roster not queried for a targeted --node listing; PRESENCE reports node-local liveness only ' + + 'and does not prove absence from the workspace roster.' + ); } // Local broker (this machine): read /api/spawned and /api/fleet-inventory.