Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"
}
}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions packages/engine/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 47 additions & 2 deletions packages/engine/src/__tests__/conformance/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ describe('node adapter conformance', () => {
id: string;
name: string;
capabilities: Array<ReturnType<typeof capability>>;
load?: number;
load?: number | null;
maxAgents?: number;
},
) {
Expand Down Expand Up @@ -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<Record<string, unknown>> };
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<Record<string, unknown>> };
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;
Expand Down
42 changes: 40 additions & 2 deletions packages/engine/src/__tests__/conformance/nodeProviders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> };
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');
Expand Down
44 changes: 44 additions & 0 deletions packages/engine/src/db/migrations/0034_node_load_reporting.sql
Original file line number Diff line number Diff line change
@@ -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
);
2 changes: 2 additions & 0 deletions packages/engine/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())`),
},
Expand Down Expand Up @@ -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'),
Expand Down
Loading
Loading