From feeabeae21c901e4abb05818cb2678a864476a55 Mon Sep 17 00:00:00 2001 From: Miya Date: Tue, 15 Sep 2026 23:13:11 +0200 Subject: [PATCH 1/4] fix(sdk): omit stale load for offline relay nodes Session-Id: 01a09dbd-b8ff-7072-927d-2f9f2c403790 --- packages/sdk/src/__tests__/messaging.test.ts | 7 ++++--- .../src/__tests__/relaycast-translate.test.ts | 15 ++++++++++++++ .../sdk/src/messaging/relaycast-translate.ts | 12 ++++++++--- .../1610-sdk-node-load-liveness/case.json | 12 +++++++++++ .../probe.test.mts | 9 +++++++++ .../cases/1610-sdk-node-load-liveness/run.mjs | 20 +++++++++++++++++++ 6 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 packages/sdk/src/__tests__/relaycast-translate.test.ts create mode 100644 tests/relayflows/cases/1610-sdk-node-load-liveness/case.json create mode 100644 tests/relayflows/cases/1610-sdk-node-load-liveness/probe.test.mts create mode 100644 tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs diff --git a/packages/sdk/src/__tests__/messaging.test.ts b/packages/sdk/src/__tests__/messaging.test.ts index 5df00834dc..1e8f267e18 100644 --- a/packages/sdk/src/__tests__/messaging.test.ts +++ b/packages/sdk/src/__tests__/messaging.test.ts @@ -515,13 +515,13 @@ describe('RelaycastMessagingClient', () => { name: 'builder-2', status: 'offline', live: false, - activeAgents: 0, handlersLive: false, maxAgents: 4, load: 0, lastHeartbeatAt: '2026-06-16T09:55:00.000Z', createdAt: '2026-06-16T08:00:00.000Z', }); + expect(nodes[1].activeAgents).toBeUndefined(); expect(nodes[2]).toMatchObject({ name: 'builder-3', status: 'unknown', @@ -549,13 +549,14 @@ describe('RelaycastMessagingClient', () => { expect(toRelayNode({ name: 'builder-6', tags: ['factory'] }).repoKeys).toBeUndefined(); expect(toRelayNode({ name: 'builder-unbounded', max_agents: 0, load: null }).load).toBeUndefined(); - await expect(client.nodes.get('builder-2')).resolves.toMatchObject({ + const offlineNode = await client.nodes.get('builder-2'); + expect(offlineNode).toMatchObject({ name: 'builder-2', status: 'offline', live: false, - activeAgents: 0, load: 0, }); + expect(offlineNode.activeAgents).toBeUndefined(); }); it('delegates write operations through an agent client and normalizes responses', async () => { diff --git a/packages/sdk/src/__tests__/relaycast-translate.test.ts b/packages/sdk/src/__tests__/relaycast-translate.test.ts new file mode 100644 index 0000000000..e32cf248bb --- /dev/null +++ b/packages/sdk/src/__tests__/relaycast-translate.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { toRelayNode } from '../messaging/relaycast-translate.js'; + +describe('toRelayNode fleet liveness', () => { + it('omits stale active-agent load for an offline node', () => { + const node = toRelayNode({ status: 'offline', live: false, active_agents: 0 }); + expect(node.activeAgents).toBeUndefined(); + }); + + it('preserves measured zero for a live node', () => { + const node = toRelayNode({ status: 'online', live: true, active_agents: 0 }); + expect(node.activeAgents).toBe(0); + }); +}); diff --git a/packages/sdk/src/messaging/relaycast-translate.ts b/packages/sdk/src/messaging/relaycast-translate.ts index 270a37194e..51ac41f533 100644 --- a/packages/sdk/src/messaging/relaycast-translate.ts +++ b/packages/sdk/src/messaging/relaycast-translate.ts @@ -141,16 +141,22 @@ export function toRelayCapability(raw: unknown): RelayCapability { export function toRelayNode(raw: unknown): RelayNode { const node = (raw ?? {}) as Record; const rawStatus = readStr(node, 'status'); + const status = rawStatus === 'online' || rawStatus === 'offline' ? rawStatus : 'unknown'; + const live = readBoolean(node, 'live'); + // Relaycast may retain a stale numeric load after a provider goes offline. + // Preserve zero as a real measurement only while liveness is authoritative. + const activeAgents = + live === false || status === 'offline' ? undefined : readNumber(node, 'activeAgents', 'active_agents'); return { id: readStr(node, 'id', 'node_id'), nodeId: readStr(node, 'nodeId', 'node_id'), name: readStr(node, 'name') ?? '', - status: rawStatus === 'online' || rawStatus === 'offline' ? rawStatus : 'unknown', - live: readBoolean(node, 'live'), + status, + live, capabilities: Array.isArray(node.capabilities) ? node.capabilities.map(toRelayNodeCapability) : [], repoKeys: readRepoKeys(node), maxAgents: readNumber(node, 'maxAgents', 'max_agents'), - activeAgents: readNumber(node, 'activeAgents', 'active_agents'), + activeAgents, handlersLive: readBoolean(node, 'handlersLive', 'handlers_live'), load: readNumber(node, 'load'), lastHeartbeatAt: readStr(node, 'lastHeartbeatAt', 'last_heartbeat_at'), diff --git a/tests/relayflows/cases/1610-sdk-node-load-liveness/case.json b/tests/relayflows/cases/1610-sdk-node-load-liveness/case.json new file mode 100644 index 0000000000..32ef74c666 --- /dev/null +++ b/tests/relayflows/cases/1610-sdk-node-load-liveness/case.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "id": "1610-sdk-node-load-liveness", + "kind": "bugfix", + "title": "Do not report stale active-agent load for offline nodes", + "runner": { "command": ["node", "tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs"] }, + "timeoutSeconds": 900, + "expected": { + "base": { "outcome": "bug", "signature": "offline_node_load_reported_as_measurement" }, + "head": { "outcome": "fixed", "signature": "offline_node_load_omitted" } + } +} diff --git a/tests/relayflows/cases/1610-sdk-node-load-liveness/probe.test.mts b/tests/relayflows/cases/1610-sdk-node-load-liveness/probe.test.mts new file mode 100644 index 0000000000..a0369ba77e --- /dev/null +++ b/tests/relayflows/cases/1610-sdk-node-load-liveness/probe.test.mts @@ -0,0 +1,9 @@ +import { expect, it } from 'vitest'; +import { toRelayNode } from '../packages/sdk/src/messaging/relaycast-translate.js'; + +it('omits activeAgents for offline nodes and preserves live zero', () => { + const offline = toRelayNode({ status: 'offline', live: false, active_agents: 0 }); + expect(offline.activeAgents).toBeUndefined(); + const live = toRelayNode({ status: 'online', live: true, active_agents: 0 }); + expect(live.activeAgents).toBe(0); +}); diff --git a/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs b/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs new file mode 100644 index 0000000000..4b5dee79ad --- /dev/null +++ b/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { mkdir, copyFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const arm = process.env.RELAY_PR_PROOF_ARM; +const targetDir = process.env.RELAY_PR_PROOF_TARGET_DIR; +const resultPath = process.env.RELAY_PR_PROOF_RESULT_PATH; +if (!['base', 'head'].includes(arm) || !targetDir || !resultPath) throw new Error('RelayFlow proof environment is incomplete'); +const caseDir = path.dirname(fileURLToPath(import.meta.url)); +const proofDir = path.join(targetDir, '.relay-pr-proof'); +await mkdir(proofDir, { recursive: true }); +await copyFile(path.join(caseDir, 'probe.test.mts'), path.join(proofDir, 'node-load.test.mts')); +await writeFile(path.join(proofDir, 'vitest.config.mts'), "import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['.relay-pr-proof/node-load.test.mts'] } });\n"); +const vitest = path.join(targetDir, 'node_modules', 'vitest', 'vitest.mjs'); +const result = spawnSync(process.execPath, [vitest, 'run', '--config', path.join(proofDir, 'vitest.config.mts')], { cwd: targetDir, env: process.env, stdio: 'inherit' }); +const failed = result.status !== 0; +if ((arm === 'base') !== failed) throw new Error(`unexpected ${arm} result: ${result.status}`); +await writeFile(resultPath, JSON.stringify({ version: 1, caseId: '1610-sdk-node-load-liveness', arm, outcome: arm === 'base' ? 'bug' : 'fixed', signature: arm === 'base' ? 'offline_node_load_reported_as_measurement' : 'offline_node_load_omitted', details: 'SDK translation distinguishes unreachable liveness from measured zero.' }, null, 2) + '\n'); From d4d5b00eaef22febb0865462b0a47e0604c90afe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 15 Sep 2026 21:14:41 +0000 Subject: [PATCH 2/4] style: auto-format with Prettier --- .../cases/1610-sdk-node-load-liveness/run.mjs | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs b/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs index 4b5dee79ad..5de6aa1d6f 100644 --- a/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs +++ b/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs @@ -7,14 +7,36 @@ import { fileURLToPath } from 'node:url'; const arm = process.env.RELAY_PR_PROOF_ARM; const targetDir = process.env.RELAY_PR_PROOF_TARGET_DIR; const resultPath = process.env.RELAY_PR_PROOF_RESULT_PATH; -if (!['base', 'head'].includes(arm) || !targetDir || !resultPath) throw new Error('RelayFlow proof environment is incomplete'); +if (!['base', 'head'].includes(arm) || !targetDir || !resultPath) + throw new Error('RelayFlow proof environment is incomplete'); const caseDir = path.dirname(fileURLToPath(import.meta.url)); const proofDir = path.join(targetDir, '.relay-pr-proof'); await mkdir(proofDir, { recursive: true }); await copyFile(path.join(caseDir, 'probe.test.mts'), path.join(proofDir, 'node-load.test.mts')); -await writeFile(path.join(proofDir, 'vitest.config.mts'), "import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['.relay-pr-proof/node-load.test.mts'] } });\n"); +await writeFile( + path.join(proofDir, 'vitest.config.mts'), + "import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['.relay-pr-proof/node-load.test.mts'] } });\n" +); const vitest = path.join(targetDir, 'node_modules', 'vitest', 'vitest.mjs'); -const result = spawnSync(process.execPath, [vitest, 'run', '--config', path.join(proofDir, 'vitest.config.mts')], { cwd: targetDir, env: process.env, stdio: 'inherit' }); +const result = spawnSync( + process.execPath, + [vitest, 'run', '--config', path.join(proofDir, 'vitest.config.mts')], + { cwd: targetDir, env: process.env, stdio: 'inherit' } +); const failed = result.status !== 0; if ((arm === 'base') !== failed) throw new Error(`unexpected ${arm} result: ${result.status}`); -await writeFile(resultPath, JSON.stringify({ version: 1, caseId: '1610-sdk-node-load-liveness', arm, outcome: arm === 'base' ? 'bug' : 'fixed', signature: arm === 'base' ? 'offline_node_load_reported_as_measurement' : 'offline_node_load_omitted', details: 'SDK translation distinguishes unreachable liveness from measured zero.' }, null, 2) + '\n'); +await writeFile( + resultPath, + JSON.stringify( + { + version: 1, + caseId: '1610-sdk-node-load-liveness', + arm, + outcome: arm === 'base' ? 'bug' : 'fixed', + signature: arm === 'base' ? 'offline_node_load_reported_as_measurement' : 'offline_node_load_omitted', + details: 'SDK translation distinguishes unreachable liveness from measured zero.', + }, + null, + 2 + ) + '\n' +); From 3140c81af28c1ff8fe51f09504ba415a94c6f81d Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 16 Sep 2026 05:34:32 +0200 Subject: [PATCH 3/4] fix(sdk): require confirmed node liveness for load Session-Id: 01a09dbd-b8ff-7072-927d-2f9f2c403790 Session-Id: 01a09dbd-b8ff-7072-927d-2f9f2c403790 --- .../src/__tests__/relaycast-translate.test.ts | 5 ++ .../sdk/src/messaging/relaycast-translate.ts | 2 +- .../cases/1610-sdk-node-load-liveness/run.mjs | 48 ++++++++----------- 3 files changed, 26 insertions(+), 29 deletions(-) diff --git a/packages/sdk/src/__tests__/relaycast-translate.test.ts b/packages/sdk/src/__tests__/relaycast-translate.test.ts index e32cf248bb..6e3d5ce052 100644 --- a/packages/sdk/src/__tests__/relaycast-translate.test.ts +++ b/packages/sdk/src/__tests__/relaycast-translate.test.ts @@ -12,4 +12,9 @@ describe('toRelayNode fleet liveness', () => { const node = toRelayNode({ status: 'online', live: true, active_agents: 0 }); expect(node.activeAgents).toBe(0); }); + + it('omits active-agent load when liveness is unconfirmed', () => { + const node = toRelayNode({ status: 'online', active_agents: 4 }); + expect(node.activeAgents).toBeUndefined(); + }); }); diff --git a/packages/sdk/src/messaging/relaycast-translate.ts b/packages/sdk/src/messaging/relaycast-translate.ts index 51ac41f533..1d01478244 100644 --- a/packages/sdk/src/messaging/relaycast-translate.ts +++ b/packages/sdk/src/messaging/relaycast-translate.ts @@ -146,7 +146,7 @@ export function toRelayNode(raw: unknown): RelayNode { // Relaycast may retain a stale numeric load after a provider goes offline. // Preserve zero as a real measurement only while liveness is authoritative. const activeAgents = - live === false || status === 'offline' ? undefined : readNumber(node, 'activeAgents', 'active_agents'); + live !== true || status === 'offline' ? undefined : readNumber(node, 'activeAgents', 'active_agents'); return { id: readStr(node, 'id', 'node_id'), nodeId: readStr(node, 'nodeId', 'node_id'), diff --git a/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs b/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs index 5de6aa1d6f..7fef3327ae 100644 --- a/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs +++ b/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs @@ -1,42 +1,34 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; -import { mkdir, copyFile, writeFile } from 'node:fs/promises'; +import { access, mkdir, copyFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const arm = process.env.RELAY_PR_PROOF_ARM; const targetDir = process.env.RELAY_PR_PROOF_TARGET_DIR; const resultPath = process.env.RELAY_PR_PROOF_RESULT_PATH; -if (!['base', 'head'].includes(arm) || !targetDir || !resultPath) - throw new Error('RelayFlow proof environment is incomplete'); +if (!['base', 'head'].includes(arm) || !targetDir || !resultPath) throw new Error('RelayFlow proof environment is incomplete'); const caseDir = path.dirname(fileURLToPath(import.meta.url)); +async function pathExists(candidate) { + try { await access(candidate); return true; } catch { return false; } +} +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, env: process.env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + process.stdout.write(result.stdout ?? ''); + process.stderr.write(result.stderr ?? ''); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} failed with exit ${result.status}`); +} +// Modelled on the self-sufficient 1619-placement-liveness proof: the target +// checkout may be a sparse arm without node_modules, so install before +// resolving Vitest instead of assuming target/node_modules/vitest exists. +const vitest = path.join(targetDir, 'node_modules', 'vitest', 'vitest.mjs'); +if (!(await pathExists(vitest))) run('npm', ['ci', '--no-audit', '--no-fund'], targetDir); const proofDir = path.join(targetDir, '.relay-pr-proof'); await mkdir(proofDir, { recursive: true }); await copyFile(path.join(caseDir, 'probe.test.mts'), path.join(proofDir, 'node-load.test.mts')); -await writeFile( - path.join(proofDir, 'vitest.config.mts'), - "import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['.relay-pr-proof/node-load.test.mts'] } });\n" -); -const vitest = path.join(targetDir, 'node_modules', 'vitest', 'vitest.mjs'); -const result = spawnSync( - process.execPath, - [vitest, 'run', '--config', path.join(proofDir, 'vitest.config.mts')], - { cwd: targetDir, env: process.env, stdio: 'inherit' } -); +await writeFile(path.join(proofDir, 'vitest.config.mts'), "import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['.relay-pr-proof/node-load.test.mts'] } });\n"); +const result = spawnSync(process.execPath, [vitest, 'run', '--config', path.join(proofDir, 'vitest.config.mts'), '--reporter=verbose'], { cwd: targetDir, env: process.env, encoding: 'utf8', stdio: 'inherit' }); const failed = result.status !== 0; if ((arm === 'base') !== failed) throw new Error(`unexpected ${arm} result: ${result.status}`); -await writeFile( - resultPath, - JSON.stringify( - { - version: 1, - caseId: '1610-sdk-node-load-liveness', - arm, - outcome: arm === 'base' ? 'bug' : 'fixed', - signature: arm === 'base' ? 'offline_node_load_reported_as_measurement' : 'offline_node_load_omitted', - details: 'SDK translation distinguishes unreachable liveness from measured zero.', - }, - null, - 2 - ) + '\n' -); +await writeFile(resultPath, JSON.stringify({ version: 1, caseId: '1610-sdk-node-load-liveness', arm, outcome: arm === 'base' ? 'bug' : 'fixed', signature: arm === 'base' ? 'offline_node_load_reported_as_measurement' : 'offline_node_load_omitted', details: 'SDK translation distinguishes unreachable liveness from measured zero.' }, null, 2) + '\n'); From ce26c666404529fe4b95a15d3243e6b3373ef153 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 16 Sep 2026 03:35:56 +0000 Subject: [PATCH 4/4] style: auto-format with Prettier --- .../cases/1610-sdk-node-load-liveness/run.mjs | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs b/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs index 7fef3327ae..e3cc94a659 100644 --- a/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs +++ b/tests/relayflows/cases/1610-sdk-node-load-liveness/run.mjs @@ -7,13 +7,25 @@ import { fileURLToPath } from 'node:url'; const arm = process.env.RELAY_PR_PROOF_ARM; const targetDir = process.env.RELAY_PR_PROOF_TARGET_DIR; const resultPath = process.env.RELAY_PR_PROOF_RESULT_PATH; -if (!['base', 'head'].includes(arm) || !targetDir || !resultPath) throw new Error('RelayFlow proof environment is incomplete'); +if (!['base', 'head'].includes(arm) || !targetDir || !resultPath) + throw new Error('RelayFlow proof environment is incomplete'); const caseDir = path.dirname(fileURLToPath(import.meta.url)); async function pathExists(candidate) { - try { await access(candidate); return true; } catch { return false; } + try { + await access(candidate); + return true; + } catch { + return false; + } } function run(command, args, cwd) { - const result = spawnSync(command, args, { cwd, env: process.env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + const result = spawnSync(command, args, { + cwd, + env: process.env, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); process.stdout.write(result.stdout ?? ''); process.stderr.write(result.stderr ?? ''); if (result.error) throw result.error; @@ -27,8 +39,29 @@ if (!(await pathExists(vitest))) run('npm', ['ci', '--no-audit', '--no-fund'], t const proofDir = path.join(targetDir, '.relay-pr-proof'); await mkdir(proofDir, { recursive: true }); await copyFile(path.join(caseDir, 'probe.test.mts'), path.join(proofDir, 'node-load.test.mts')); -await writeFile(path.join(proofDir, 'vitest.config.mts'), "import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['.relay-pr-proof/node-load.test.mts'] } });\n"); -const result = spawnSync(process.execPath, [vitest, 'run', '--config', path.join(proofDir, 'vitest.config.mts'), '--reporter=verbose'], { cwd: targetDir, env: process.env, encoding: 'utf8', stdio: 'inherit' }); +await writeFile( + path.join(proofDir, 'vitest.config.mts'), + "import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['.relay-pr-proof/node-load.test.mts'] } });\n" +); +const result = spawnSync( + process.execPath, + [vitest, 'run', '--config', path.join(proofDir, 'vitest.config.mts'), '--reporter=verbose'], + { cwd: targetDir, env: process.env, encoding: 'utf8', stdio: 'inherit' } +); const failed = result.status !== 0; if ((arm === 'base') !== failed) throw new Error(`unexpected ${arm} result: ${result.status}`); -await writeFile(resultPath, JSON.stringify({ version: 1, caseId: '1610-sdk-node-load-liveness', arm, outcome: arm === 'base' ? 'bug' : 'fixed', signature: arm === 'base' ? 'offline_node_load_reported_as_measurement' : 'offline_node_load_omitted', details: 'SDK translation distinguishes unreachable liveness from measured zero.' }, null, 2) + '\n'); +await writeFile( + resultPath, + JSON.stringify( + { + version: 1, + caseId: '1610-sdk-node-load-liveness', + arm, + outcome: arm === 'base' ? 'bug' : 'fixed', + signature: arm === 'base' ? 'offline_node_load_reported_as_measurement' : 'offline_node_load_omitted', + details: 'SDK translation distinguishes unreachable liveness from measured zero.', + }, + null, + 2 + ) + '\n' +);