diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_0h8yn88qklhh/summary.md b/.agentworkforce/trajectories/completed/2026-08/traj_0h8yn88qklhh/summary.md new file mode 100644 index 00000000..1ad1bb2c --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_0h8yn88qklhh/summary.md @@ -0,0 +1,31 @@ +# Trajectory: Persist and expose unmeasured fleet node load + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** August 6, 2026 at 11:57 AM +> **Completed:** August 6, 2026 at 11:59 AM + +--- + +## Summary + +Made fleet load explicitly unreported for unbounded or partially measured nodes, preserved finite [0,1] utilization, corrected max_agents=0 aggregate semantics, and updated official SDKs plus migration/tests. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Define load as bounded managed-agent capacity utilization; max_agents=0 remains unlimited and therefore has no load denominator +- **Chose:** Define load as bounded managed-agent capacity utilization; max_agents=0 remains unlimited and therefore has no load denominator +- **Reasoning:** The broker and Relaycast admission already define 0 as unlimited. CPU, memory, and queue pressure are different metrics; substituting idle for an undefined denominator is the observed defect. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Define load as bounded managed-agent capacity utilization; max_agents=0 remains unlimited and therefore has no load denominator: Define load as bounded managed-agent capacity utilization; max_agents=0 remains unlimited and therefore has no load denominator diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_0h8yn88qklhh/trajectory.json b/.agentworkforce/trajectories/completed/2026-08/traj_0h8yn88qklhh/trajectory.json new file mode 100644 index 00000000..9659da20 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_0h8yn88qklhh/trajectory.json @@ -0,0 +1,53 @@ +{ + "id": "traj_0h8yn88qklhh", + "version": 1, + "task": { + "title": "Persist and expose unmeasured fleet node load" + }, + "status": "completed", + "startedAt": "2026-08-06T09:57:48.460Z", + "completedAt": "2026-08-06T09:59:59.888Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-08-06T09:57:56.833Z" + } + ], + "chapters": [ + { + "id": "chap_56f45e96iwd5", + "title": "Work", + "agentName": "default", + "startedAt": "2026-08-06T09:57:56.833Z", + "endedAt": "2026-08-06T09:59:59.888Z", + "events": [ + { + "ts": 1786010276834, + "type": "decision", + "content": "Define load as bounded managed-agent capacity utilization; max_agents=0 remains unlimited and therefore has no load denominator: Define load as bounded managed-agent capacity utilization; max_agents=0 remains unlimited and therefore has no load denominator", + "raw": { + "question": "Define load as bounded managed-agent capacity utilization; max_agents=0 remains unlimited and therefore has no load denominator", + "chosen": "Define load as bounded managed-agent capacity utilization; max_agents=0 remains unlimited and therefore has no load denominator", + "alternatives": [], + "reasoning": "The broker and Relaycast admission already define 0 as unlimited. CPU, memory, and queue pressure are different metrics; substituting idle for an undefined denominator is the observed defect." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Made fleet load explicitly unreported for unbounded or partially measured nodes, preserved finite [0,1] utilization, corrected max_agents=0 aggregate semantics, and updated official SDKs plus migration/tests.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relaycast", + "tags": [], + "_trace": { + "startRef": "45beff3f47aba960137583af0639460cb8c0848f", + "endRef": "45beff3f47aba960137583af0639460cb8c0848f" + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c81e106..5f0440a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ Packages without a separate changelog are covered by the cross-package notes bel ## [Unreleased] +### Fixed + +- Fleet node rosters now return `load: null` when capacity utilization is not reported, instead of turning an unbounded node into a confidently idle `load: 0`. Finite-capacity nodes continue to report normalized managed-agent utilization in `[0,1]`. + ## [6.3.2] - 2026-08-02 ### Fixed diff --git a/openapi.yaml b/openapi.yaml index 072f15d2..18d08595 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -732,6 +732,10 @@ components: type: boolean load: type: number + nullable: true + minimum: 0 + maximum: 1 + description: Managed-agent capacity utilization. Null when the node does not report a finite capacity denominator. active_agents: type: integer max_agents: diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index c1d82fd8..f6bb26c1 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -9,6 +9,10 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Fixed + +- Node heartbeats accept absent/null `load`, migration `0034` tracks whether load was actually reported, and `GET /v1/nodes` returns null when any constituent provider is unmeasured. Placement no longer ranks unknown load as measured idle, and any unbounded provider keeps the aggregate node capacity unbounded. + ## [6.3.2] - 2026-08-02 ### Fixed diff --git a/packages/engine/src/__tests__/conformance/node.test.ts b/packages/engine/src/__tests__/conformance/node.test.ts index a90bf3bf..9a0f8cce 100644 --- a/packages/engine/src/__tests__/conformance/node.test.ts +++ b/packages/engine/src/__tests__/conformance/node.test.ts @@ -438,7 +438,7 @@ describe('node adapter conformance', () => { id: string; name: string; capabilities: Array>; - load?: number; + load?: number | null; maxAgents?: number; }, ) { @@ -497,13 +497,58 @@ describe('node adapter conformance', () => { await handle.handleMessage(JSON.stringify({ v: 1, type: 'node.heartbeat', - load: opts.load ?? 0, + ...(opts.load !== null ? { load: opts.load ?? 0 } : {}), active_agents: 0, handlers_live: true, })); return { sock, handle }; } + it('reports unbounded node load as unavailable while preserving active agents', async () => { + const ws = await createWorkspace(stack.app, 'fleet-unreported-load-ws'); + const unbounded = await enrollAndAttachNode(ws, { + id: 'node_unbounded', + name: 'unbounded', + capabilities: [capability('spawn:codex', 'spawn')], + maxAgents: 0, + load: null, + }); + + const roster = await stack.app.request('/v1/nodes?name=unbounded', { + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + expect(roster.status).toBe(200); + const body = await roster.json() as { data: Array> }; + expect(body.data[0]).toMatchObject({ + name: 'unbounded', + load: null, + active_agents: 0, + max_agents: 0, + }); + + const [stored] = await stack.runtime.handle.db + .select({ load: nodes.load, loadReported: nodes.loadReported }) + .from(nodes) + .where(and(eq(nodes.workspaceId, ws.workspaceId), eq(nodes.id, 'node_unbounded'))); + expect(stored).toEqual({ load: 0, loadReported: false }); + + // Older brokers sent a literal zero for the same unbounded state. The + // engine knows the denominator is absent and must keep treating it as + // unreported during the rolling upgrade. + await unbounded.handle.handleMessage(JSON.stringify({ + v: 1, + type: 'node.heartbeat', + load: 0, + active_agents: 25, + handlers_live: true, + })); + const legacyRoster = await stack.app.request('/v1/nodes?name=unbounded', { + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + const legacyBody = await legacyRoster.json() as { data: Array> }; + expect(legacyBody.data[0]).toMatchObject({ load: null, active_agents: 25, max_agents: 0 }); + }); + it('drives node control directly without the websocket route wrapper', async () => { const ws = await createWorkspace(stack.app, 'node-control-direct-dispatch'); const db = stack.runtime.handle.db; diff --git a/packages/engine/src/__tests__/conformance/nodeProviders.test.ts b/packages/engine/src/__tests__/conformance/nodeProviders.test.ts index f34b1135..576f83ef 100644 --- a/packages/engine/src/__tests__/conformance/nodeProviders.test.ts +++ b/packages/engine/src/__tests__/conformance/nodeProviders.test.ts @@ -62,17 +62,55 @@ describe('node providers', () => { nodeName: string, providerName: string | undefined, capabilities: Cap[], - opts: { instanceId?: string; maxAgents?: number } = {}, + opts: { instanceId?: string; maxAgents?: number; load?: number | null } = {}, ) { const provider = providerName ? { name: providerName, instance_id: opts.instanceId ?? `${providerName}-i1` } : undefined; const { sock, handle } = attachSocket(workspaceId, nodeId); await handle.handleMessage(registerFrame(nodeId, nodeName, provider, capabilities, opts.maxAgents)); await handle.handleMessage(JSON.stringify({ - v: 1, type: 'node.heartbeat', ...(provider ? { provider } : {}), load: 0, active_agents: 0, handlers_live: true, + v: 1, + type: 'node.heartbeat', + ...(provider ? { provider } : {}), + ...(opts.load !== null ? { load: opts.load ?? 0 } : {}), + active_agents: 0, + handlers_live: true, })); return { sock, handle }; } + it('keeps a mixed finite and unbounded provider aggregate unlimited with unreported load', async () => { + const ws = await createWorkspace(stack.app, 'np-unbounded-load'); + await enrollNode(ws, 'node_a', 'alpha'); + await attachProvider( + ws.workspaceId, + 'node_a', + 'alpha', + 'finite', + [{ name: 'run-etl', kind: 'action' }], + { maxAgents: 4, load: 0.5 }, + ); + await attachProvider( + ws.workspaceId, + 'node_a', + 'alpha', + 'unbounded', + [{ name: 'spawn:codex', kind: 'capacity' }], + { maxAgents: 0, load: 0 }, + ); + + const [node] = await stack.runtime.handle.db + .select({ maxAgents: nodes.maxAgents, load: nodes.load, loadReported: nodes.loadReported }) + .from(nodes) + .where(and(eq(nodes.workspaceId, ws.workspaceId), eq(nodes.id, 'node_a'))); + expect(node).toEqual({ maxAgents: 0, load: 0, loadReported: false }); + + const roster = await stack.app.request('/v1/nodes?name=alpha', { + headers: { authorization: `Bearer ${ws.workspaceKey}` }, + }); + const body = await roster.json() as { data: Array> }; + expect(body.data[0]).toMatchObject({ max_agents: 0, load: null }); + }); + it('keys a registration with no provider field to the synthetic default provider', async () => { const ws = await createWorkspace(stack.app, 'np-default'); await enrollNode(ws, 'node_a', 'alpha'); diff --git a/packages/engine/src/db/migrations/0034_node_load_reporting.sql b/packages/engine/src/db/migrations/0034_node_load_reporting.sql new file mode 100644 index 00000000..91f12f1b --- /dev/null +++ b/packages/engine/src/db/migrations/0034_node_load_reporting.sql @@ -0,0 +1,44 @@ +-- A numeric zero must mean measured idle, never "no measurement available". +-- Keep the existing numeric columns for SQLite/D1 compatibility and carry +-- measurement presence explicitly alongside them. +ALTER TABLE nodes ADD COLUMN load_reported INTEGER NOT NULL DEFAULT 0; +ALTER TABLE node_providers ADD COLUMN load_reported INTEGER NOT NULL DEFAULT 0; + +-- Existing finite-capacity heartbeats computed active_agents/max_agents and +-- therefore contain real measurements. max_agents=0 is the established +-- unlimited sentinel, so its historic load=0 values remain unreported. +UPDATE node_providers SET load_reported = 1 WHERE max_agents > 0; + +-- Provider capacity uses the same sentinel: any unbounded provider makes its +-- aggregate broker node unbounded. Correct aggregates written by the prior +-- additive-zero behavior before deciding whether their load was measured. +UPDATE nodes +SET max_agents = 0 +WHERE EXISTS ( + SELECT 1 + FROM node_providers + WHERE node_providers.workspace_id = nodes.workspace_id + AND node_providers.node_id = nodes.id + AND node_providers.max_agents = 0 +); + +-- A broker-node max is measured only when every provider ratio is measured. +-- Direct/http nodes historically wrote placeholder zeroes, so leave them +-- unreported until a new heartbeat explicitly supplies a measurement. +UPDATE nodes +SET load_reported = 1 +WHERE role = 'broker' + AND max_agents > 0 + AND EXISTS ( + SELECT 1 + FROM node_providers + WHERE node_providers.workspace_id = nodes.workspace_id + AND node_providers.node_id = nodes.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM node_providers + WHERE node_providers.workspace_id = nodes.workspace_id + AND node_providers.node_id = nodes.id + AND node_providers.load_reported = 0 + ); diff --git a/packages/engine/src/db/schema.ts b/packages/engine/src/db/schema.ts index 27cf3a9c..41512d5c 100644 --- a/packages/engine/src/db/schema.ts +++ b/packages/engine/src/db/schema.ts @@ -119,6 +119,7 @@ export const nodes = sqliteTable( status: text('status').notNull().default('offline'), handlersLive: integer('handlers_live', { mode: 'boolean' }).notNull().default(false), load: real('load').notNull().default(0), + loadReported: integer('load_reported', { mode: 'boolean' }).notNull().default(false), lastHeartbeatAt: integer('last_heartbeat_at', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), }, @@ -158,6 +159,7 @@ export const nodeProviders = sqliteTable( maxAgents: integer('max_agents').notNull().default(0), activeAgents: integer('active_agents').notNull().default(0), load: real('load').notNull().default(0), + loadReported: integer('load_reported', { mode: 'boolean' }).notNull().default(false), handlersLive: integer('handlers_live', { mode: 'boolean' }).notNull().default(false), status: text('status').notNull().default('offline'), version: text('version').notNull().default('unknown'), diff --git a/packages/engine/src/engine/node.ts b/packages/engine/src/engine/node.ts index 3ce93381..453b2651 100644 --- a/packages/engine/src/engine/node.ts +++ b/packages/engine/src/engine/node.ts @@ -124,7 +124,7 @@ function publicNode(row: NodeRow) { status: live ? 'online' : 'offline', live, handlers_live: live && row.handlersLive, - load: row.load, + load: row.loadReported ? row.load : null, active_agents: row.activeAgents, max_agents: row.maxAgents, last_heartbeat_at: row.lastHeartbeatAt?.toISOString() ?? null, @@ -263,6 +263,8 @@ export async function createNodeToken( version: data.version ?? existing.version, status: 'offline', handlersLive: false, + load: 0, + loadReported: false, }) .where(eq(nodes.id, existing.id)) .returning(); @@ -287,6 +289,7 @@ export async function createNodeToken( status: 'offline', handlersLive: false, load: 0, + loadReported: false, activeAgents: 0, createdAt: now, }) @@ -383,6 +386,8 @@ export async function registerNode( version: message.version, status: 'online', handlersLive: false, + load: 0, + loadReported: false, lastHeartbeatAt: now, }) .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, authenticatedNodeId))) @@ -454,7 +459,8 @@ export async function heartbeatNode( .set({ ...rosterUpdate, status: 'online', - load: message.load, + load: message.load ?? 0, + loadReported: message.load !== undefined && message.load !== null, activeAgents: 1, handlersLive: false, lastHeartbeatAt: new Date(), @@ -530,6 +536,7 @@ export async function markNodeOffline( status: 'offline', handlersLive: false, load: 0, + loadReported: false, activeAgents: 0, lastHeartbeatAt: new Date(), }) @@ -540,7 +547,7 @@ export async function markNodeOffline( // recomputeNodeAggregate never resurrects a dropped provider's agent count. await db .update(nodeProviders) - .set({ status: 'offline', handlersLive: false, load: 0, activeAgents: 0, lastHeartbeatAt: new Date() }) + .set({ status: 'offline', handlersLive: false, load: 0, loadReported: false, activeAgents: 0, lastHeartbeatAt: new Date() }) .where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId))); await db @@ -843,6 +850,7 @@ async function ensureDirectNodeForAgentInTx( version: 'implicit', handlersLive: false, load: 0, + loadReported: false, }; if (opts.online) { update.status = 'online'; @@ -888,6 +896,7 @@ async function ensureDirectNodeForAgentInTx( status: opts.online ? 'online' : 'offline', handlersLive: false, load: 0, + loadReported: false, lastHeartbeatAt: opts.online ? now : null, createdAt: now, }) @@ -909,6 +918,7 @@ async function ensureDirectNodeForAgentInTx( version: 'implicit', handlersLive: false, load: 0, + loadReported: false, }; if (opts.online) { update.status = 'online'; @@ -955,6 +965,7 @@ export async function markDirectNodeOfflineForAgent( status: 'offline', handlersLive: false, load: 0, + loadReported: false, lastHeartbeatAt: new Date(), }) .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId))); diff --git a/packages/engine/src/engine/nodeProvider.ts b/packages/engine/src/engine/nodeProvider.ts index 3064dd26..dcf9fb6a 100644 --- a/packages/engine/src/engine/nodeProvider.ts +++ b/packages/engine/src/engine/nodeProvider.ts @@ -105,6 +105,8 @@ export async function upsertProvider( capabilities: data.capabilities, maxAgents: data.maxAgents, version: data.version, + load: 0, + loadReported: false, handlersLive: data.handlersLive, status: 'online', lastHeartbeatAt: now, @@ -122,6 +124,7 @@ export async function upsertProvider( maxAgents: data.maxAgents, activeAgents: 0, load: 0, + loadReported: false, handlersLive: data.handlersLive, status: 'online', version: data.version, @@ -135,12 +138,19 @@ export async function heartbeatProvider( workspaceId: string, nodeId: string, name: string, - data: { load: number; activeAgents: number; handlersLive: boolean }, + data: { load?: number | null; activeAgents: number; handlersLive: boolean }, ): Promise { + const provider = await getProvider(db, workspaceId, nodeId, name); + // Legacy providers sent load=0 for max_agents=0. The zero has no finite + // denominator, so the engine must not revive it as a measured idle value. + const reportedLoad = provider !== null && provider.maxAgents > 0 && typeof data.load === 'number' + ? data.load + : undefined; await db .update(nodeProviders) .set({ - load: data.load, + load: reportedLoad ?? 0, + loadReported: reportedLoad !== undefined, activeAgents: data.activeAgents, handlersLive: data.handlersLive, status: 'online', @@ -159,7 +169,7 @@ export async function markProviderOffline(db: Db, workspaceId: string, nodeId: s // on the node. A reconnect/heartbeat repopulates it from the provider's report. await db .update(nodeProviders) - .set({ status: 'offline', handlersLive: false, load: 0, activeAgents: 0, lastHeartbeatAt: new Date() }) + .set({ status: 'offline', handlersLive: false, load: 0, loadReported: false, activeAgents: 0, lastHeartbeatAt: new Date() }) .where(and( eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), @@ -306,9 +316,17 @@ export async function recomputeNodeAggregate( } } - const maxAgents = providers.reduce((sum, p) => sum + p.maxAgents, 0); + // A single unbounded provider makes the aggregate node unbounded. Treating + // its sentinel 0 as additive zero would falsely cap a mixed node at the sum + // of only its finite providers. + const maxAgents = providers.some((p) => p.maxAgents === 0) + ? 0 + : providers.reduce((sum, p) => sum + p.maxAgents, 0); const activeAgents = providers.reduce((sum, p) => sum + p.activeAgents, 0); - const load = providers.reduce((max, p) => Math.max(max, p.load), 0); + // A max is only a measurement when every constituent is measured; one + // unknown provider could be busier than every reported provider. + const loadReported = providers.length > 0 && providers.every((p) => p.loadReported); + const load = loadReported ? Math.max(...providers.map((p) => p.load)) : 0; const handlersLive = online.some((p) => p.handlersLive); // Node liveness must reflect an online provider's heartbeat. An offline // provider's disconnect timestamp is fresh, so including it here could keep a @@ -323,6 +341,7 @@ export async function recomputeNodeAggregate( maxAgents, activeAgents, load, + loadReported, handlersLive, status: online.length > 0 ? 'online' : 'offline', lastHeartbeatAt: lastHeartbeatAt ?? new Date(), diff --git a/packages/engine/src/engine/placement.ts b/packages/engine/src/engine/placement.ts index b62315d0..bfa2cfb4 100644 --- a/packages/engine/src/engine/placement.ts +++ b/packages/engine/src/engine/placement.ts @@ -88,6 +88,16 @@ export function nodeHasCapacity(node: Pick 0 ? target.trim() : undefined; } @@ -248,7 +258,7 @@ export async function claimSpawnNode( node.handlersLive && nodeHasCapacity(node), ) - .sort((a, b) => (a.load - b.load) || (a.activeAgents - b.activeAgents) || a.name.localeCompare(b.name)); + .sort(compareNodeCapacityLoad); for (const node of eligible) { const reserved = await reserveNodeCapacity(tx, workspaceId, node.id); @@ -350,7 +360,7 @@ export async function chooseNodeForAction( node.handlersLive && nodeHasCapacity(node), ) - .sort((a, b) => (a.load - b.load) || (a.activeAgents - b.activeAgents) || a.name.localeCompare(b.name)); + .sort(compareNodeCapacityLoad); const node = eligible[0]; if (!node) { diff --git a/packages/sdk-python/src/relay_sdk/models.py b/packages/sdk-python/src/relay_sdk/models.py index 3fb50316..fe3f8360 100644 --- a/packages/sdk-python/src/relay_sdk/models.py +++ b/packages/sdk-python/src/relay_sdk/models.py @@ -520,7 +520,7 @@ class NodeRosterEntry(BaseModel): status: str live: bool handlers_live: bool - load: float + load: float | None active_agents: int max_agents: int last_heartbeat_at: str | None = None diff --git a/packages/sdk-python/src/relay_sdk/node.py b/packages/sdk-python/src/relay_sdk/node.py index 3219e4fe..7c7cbfd0 100644 --- a/packages/sdk-python/src/relay_sdk/node.py +++ b/packages/sdk-python/src/relay_sdk/node.py @@ -613,15 +613,15 @@ async def _heartbeat_loop(self) -> None: try: while True: await asyncio.sleep(self._heartbeat_interval) - await self._send_frame( - { - "type": "node.heartbeat", - "provider": self._provider_identity(), - "load": 0, - "active_agents": 0, - "handlers_live": True, - } - ) + heartbeat = { + "type": "node.heartbeat", + "provider": self._provider_identity(), + "active_agents": 0, + "handlers_live": True, + } + if self._max_agents > 0: + heartbeat["load"] = 0 + await self._send_frame(heartbeat) except asyncio.CancelledError: raise diff --git a/packages/sdk-python/src/relay_sdk/ws.py b/packages/sdk-python/src/relay_sdk/ws.py index 8f2a7c6c..a707a825 100644 --- a/packages/sdk-python/src/relay_sdk/ws.py +++ b/packages/sdk-python/src/relay_sdk/ws.py @@ -282,7 +282,7 @@ async def _ping_loop(self, node_registration: dict[str, Any] | None = None) -> N "type": "node.heartbeat", "node_id": node_registration.get("node_id"), "name": node_registration.get("name"), - "load": 0, + "load": 1, "active_agents": 1, "handlers_live": False, }) diff --git a/packages/sdk-python/tests/test_node.py b/packages/sdk-python/tests/test_node.py index 04e6baed..cfecbcdf 100644 --- a/packages/sdk-python/tests/test_node.py +++ b/packages/sdk-python/tests/test_node.py @@ -333,7 +333,7 @@ async def test_heartbeat_is_provider_scoped_with_no_last_heartbeat_at(): await wait_until(lambda: len(conn.sent_of_type("node.heartbeat")) >= 1) hb = conn.sent_of_type("node.heartbeat")[-1] assert hb["provider"] == {"name": "py", "instance_id": node._instance_id} - assert hb["load"] == 0 + assert "load" not in hb assert hb["active_agents"] == 0 assert hb["handlers_live"] is True assert "last_heartbeat_at" not in hb diff --git a/packages/sdk-rust/CHANGELOG.md b/packages/sdk-rust/CHANGELOG.md index 40f96413..719a5d51 100644 --- a/packages/sdk-rust/CHANGELOG.md +++ b/packages/sdk-rust/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi ## [Unreleased] +### Changed + +- `NodeRosterEntry.load` is now `Option`, matching the API's explicit unreported state; direct-agent heartbeats report their measured full utilization. + ## [4.2.0] - 2026-06-24 ### Added diff --git a/packages/sdk-rust/src/types.rs b/packages/sdk-rust/src/types.rs index 8a1ba15e..2b10234f 100644 --- a/packages/sdk-rust/src/types.rs +++ b/packages/sdk-rust/src/types.rs @@ -1879,7 +1879,7 @@ pub struct NodeRosterEntry { pub status: String, pub live: bool, pub handlers_live: bool, - pub load: f64, + pub load: Option, pub active_agents: i64, pub max_agents: i64, #[serde(default)] diff --git a/packages/sdk-rust/src/ws.rs b/packages/sdk-rust/src/ws.rs index fadfd299..0fbfc87e 100644 --- a/packages/sdk-rust/src/ws.rs +++ b/packages/sdk-rust/src/ws.rs @@ -482,7 +482,7 @@ impl WsClient { serde_json::json!({ "v": 1, "type": "node.heartbeat", - "load": 0, + "load": 1, "active_agents": 1, "handlers_live": false, "node_id": registration.node_id, diff --git a/packages/sdk-swift/CHANGELOG.md b/packages/sdk-swift/CHANGELOG.md index f201e49f..0b5771ce 100644 --- a/packages/sdk-swift/CHANGELOG.md +++ b/packages/sdk-swift/CHANGELOG.md @@ -6,6 +6,8 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight ## [Unreleased] +- `NodeRosterEntry.load` is optional; provider heartbeats omit load for unbounded capacity, while direct-agent heartbeats report their measured full utilization. + ## [6.1.0] - 2026-07-16 - Allowed agent models to decode the hosted lifecycle statuses used during realtime connection setup. diff --git a/packages/sdk-swift/Sources/Relaycast/Models.swift b/packages/sdk-swift/Sources/Relaycast/Models.swift index 7f11419b..fb6b48e7 100644 --- a/packages/sdk-swift/Sources/Relaycast/Models.swift +++ b/packages/sdk-swift/Sources/Relaycast/Models.swift @@ -1674,7 +1674,7 @@ public struct NodeRosterEntry: Codable, Equatable, Sendable { public let status: String public let live: Bool public let handlersLive: Bool - public let load: Double + public let load: Double? public let activeAgents: Int public let maxAgents: Int public let lastHeartbeatAt: String? @@ -1793,7 +1793,7 @@ public struct CreateNodeResponse: Codable, Equatable, Sendable { public let status: String public let live: Bool public let handlersLive: Bool - public let load: Double + public let load: Double? public let activeAgents: Int public let maxAgents: Int public let lastHeartbeatAt: String? diff --git a/packages/sdk-swift/Sources/Relaycast/NodeProvider.swift b/packages/sdk-swift/Sources/Relaycast/NodeProvider.swift index f3334887..0b3177be 100644 --- a/packages/sdk-swift/Sources/Relaycast/NodeProvider.swift +++ b/packages/sdk-swift/Sources/Relaycast/NodeProvider.swift @@ -629,14 +629,16 @@ public actor NodeProvider { private func sendHeartbeat() async { guard let transport, registered, let instanceID else { return } - let payload: [String: JSONValue] = [ + var payload: [String: JSONValue] = [ "v": .int(1), "type": .string("node.heartbeat"), "provider": .object(["name": .string(providerName), "instance_id": .string(instanceID)]), - "load": .int(0), "active_agents": .int(0), "handlers_live": .bool(true) ] + if maxAgents > 0 { + payload["load"] = .int(0) + } guard let text = try? encodeFrame(payload) else { return } try? await transport.send(text) } diff --git a/packages/sdk-swift/Sources/Relaycast/WsClient.swift b/packages/sdk-swift/Sources/Relaycast/WsClient.swift index 06cc6329..43eefdeb 100644 --- a/packages/sdk-swift/Sources/Relaycast/WsClient.swift +++ b/packages/sdk-swift/Sources/Relaycast/WsClient.swift @@ -394,7 +394,7 @@ public final class WsClient: @unchecked Sendable { let heartbeat: [String: JSONValue] = [ "v": 1, "type": "node.heartbeat", - "load": 0, + "load": 1, "active_agents": 1, "handlers_live": false, "node_id": .string(registration.nodeId), diff --git a/packages/sdk-swift/Tests/RelaycastTests/NodeProviderTests.swift b/packages/sdk-swift/Tests/RelaycastTests/NodeProviderTests.swift index 62a8d6dc..fcea09ba 100644 --- a/packages/sdk-swift/Tests/RelaycastTests/NodeProviderTests.swift +++ b/packages/sdk-swift/Tests/RelaycastTests/NodeProviderTests.swift @@ -414,7 +414,7 @@ final class NodeProviderTests: XCTestCase { let heartbeat = await transport.sentOfType("node.heartbeat").last! guard case .object(let provider)? = heartbeat["provider"] else { return XCTFail("missing provider") } XCTAssertEqual(provider["name"], .string("py")) - XCTAssertEqual(heartbeat["load"], .int(0)) + XCTAssertNil(heartbeat["load"]) XCTAssertEqual(heartbeat["active_agents"], .int(0)) XCTAssertEqual(heartbeat["handlers_live"], .bool(true)) XCTAssertNil(heartbeat["last_heartbeat_at"]) diff --git a/packages/sdk-typescript/CHANGELOG.md b/packages/sdk-typescript/CHANGELOG.md index 5abf7a3f..b969c06a 100644 --- a/packages/sdk-typescript/CHANGELOG.md +++ b/packages/sdk-typescript/CHANGELOG.md @@ -9,6 +9,10 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Changed + +- `NodeRosterEntry.load` is now `number | null`; provider heartbeats omit load when `maxAgents` is unbounded, while direct-agent heartbeats report their measured full utilization. + ## [6.3.0] - 2026-07-28 ### Added diff --git a/packages/sdk-typescript/src/__tests__/node-provider.test.ts b/packages/sdk-typescript/src/__tests__/node-provider.test.ts index 65bd6c3f..c27469fa 100644 --- a/packages/sdk-typescript/src/__tests__/node-provider.test.ts +++ b/packages/sdk-typescript/src/__tests__/node-provider.test.ts @@ -215,7 +215,8 @@ describe('NodeProviderClient', () => { await vi.advanceTimersByTimeAsync(1_000); const hb = sock.sentOfType('node.heartbeat').at(-1)!; - expect(hb).toMatchObject({ provider: { name: 'py' }, load: 0, active_agents: 0, handlers_live: true }); + expect(hb).toMatchObject({ provider: { name: 'py' }, active_agents: 0, handlers_live: true }); + expect(hb).not.toHaveProperty('load'); expect(hb).not.toHaveProperty('last_heartbeat_at'); }); diff --git a/packages/sdk-typescript/src/node-provider.ts b/packages/sdk-typescript/src/node-provider.ts index 2e6286c1..21a22f34 100644 --- a/packages/sdk-typescript/src/node-provider.ts +++ b/packages/sdk-typescript/src/node-provider.ts @@ -563,7 +563,7 @@ export class NodeProviderClient { this.sendFrame({ type: 'node.heartbeat', provider: this.providerIdentity(), - load: 0, + ...(this.maxAgents > 0 ? { load: 0 } : {}), active_agents: 0, handlers_live: true, }); diff --git a/packages/sdk-typescript/src/types.ts b/packages/sdk-typescript/src/types.ts index bfb94551..1e52f475 100644 --- a/packages/sdk-typescript/src/types.ts +++ b/packages/sdk-typescript/src/types.ts @@ -342,7 +342,8 @@ export interface NodeRosterEntry { status: 'online' | 'offline' | string; live: boolean; handlersLive: boolean; - load: number; + /** Managed-agent capacity utilization in [0,1], or null when unreported. */ + load: number | null; activeAgents: number; maxAgents: number; lastHeartbeatAt: string | null; diff --git a/packages/sdk-typescript/src/ws.ts b/packages/sdk-typescript/src/ws.ts index e39d0561..26ea077b 100644 --- a/packages/sdk-typescript/src/ws.ts +++ b/packages/sdk-typescript/src/ws.ts @@ -612,7 +612,7 @@ export class WsClient { v: 1, type: 'node.heartbeat', ...(nodeId ? { node_id: nodeId } : {}), - load: 0, + load: 1, active_agents: 1, handlers_live: false, }); diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md index e4cf256e..1f306ac5 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -9,6 +9,10 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Changed + +- `node.heartbeat.load` may be absent or null when capacity utilization is unreported; numeric values are finite utilization ratios in `[0,1]`. + ## [6.3.0] - 2026-07-28 ### Added diff --git a/packages/types/src/__tests__/fleet-wire-fixtures.test.ts b/packages/types/src/__tests__/fleet-wire-fixtures.test.ts index bba1b137..4396ab54 100644 --- a/packages/types/src/__tests__/fleet-wire-fixtures.test.ts +++ b/packages/types/src/__tests__/fleet-wire-fixtures.test.ts @@ -120,6 +120,22 @@ describe('fleet wire fixtures', () => { }); } + it('accepts unreported heartbeat load and rejects values outside [0,1]', () => { + const heartbeat = { + v: 1 as const, + type: 'node.heartbeat' as const, + active_agents: 25, + handlers_live: true, + }; + expect(parseFleetBrokerToRelaycastMessage(heartbeat)).toEqual(heartbeat); + expect(parseFleetBrokerToRelaycastMessage({ ...heartbeat, load: null })).toEqual({ + ...heartbeat, + load: null, + }); + expect(() => parseFleetBrokerToRelaycastMessage({ ...heartbeat, load: -0.01 })).toThrow(); + expect(() => parseFleetBrokerToRelaycastMessage({ ...heartbeat, load: 1.01 })).toThrow(); + }); + it('accepts action.result error variants as the same message type', () => { expect( parseFleetBrokerToRelaycastMessage({ diff --git a/packages/types/src/fleet-wire.ts b/packages/types/src/fleet-wire.ts index dcce76cf..65c0ce06 100644 --- a/packages/types/src/fleet-wire.ts +++ b/packages/types/src/fleet-wire.ts @@ -138,7 +138,9 @@ export const FleetNodeHeartbeatMessageSchema = z // synthetic `default` provider. Load/active_agents/handlers_live describe // the sending provider, and the node figures aggregate across providers. provider: FleetProviderIdentitySchema.optional(), - load: z.number().finite().nonnegative(), + // Capacity utilization is absent/null when the provider has no finite + // max_agents denominator. Accept both forms during the additive migration. + load: z.number().finite().min(0).max(1).nullable().optional(), active_agents: z.number().int().nonnegative(), handlers_live: z.boolean(), // Roster snapshot carried for liveness: lets the engine refresh a node's