Skip to content

Commit 8b3ec71

Browse files
willwashburnclaude
andauthored
engine: export node-control provider-disconnect + fix dropped-provider aggregate leak (#244)
* engine: export handleProviderDisconnect + markNodeOffline from node-control Out-of-process node-control socket owners (the relaycast-cloud NodeDO) own their sockets but not the DB. On socket close they must flip the right liveness — one provider offline while the node stays online, or the whole node offline when the last connection drops — and that logic already lives in `engine/node.js` (`handleProviderDisconnect`, `markNodeOffline`) but was unreachable from the `@relaycast/engine/node-control` entrypoint, forcing the host to hand-roll a SQL mirror. Re-export both so the host delegates instead. Both encapsulate the full effect (provider row + agents + node aggregate + invocation reschedule); a conformance test asserts the public exports produce the provider-scoped, last-connection, and node-wide offline states. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: optional-chain stack teardown so a failed setup surfaces its own error Addresses review feedback on #244. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: zero a dropped provider's active_agents so the node aggregate is correct `markProviderOffline` and `markNodeOffline` left `node_providers.active_agents` at its last value, but `recomputeNodeAggregate` sums active_agents across ALL providers (online and offline). So after a provider's socket dropped with others still connected, `nodes.active_agents` kept counting the dropped provider's now- offline agents until it reconnected. Reset it to 0 on offline; a reconnect / heartbeat repopulates it from the provider's own report. Regression test in nodeControlExports asserts the node aggregate drops the dropped provider's count and restores it on the next heartbeat, and that markNodeOffline zeros every provider's count. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: exercise the real reconnect path in the aggregate restore assertion Close the dropped provider's socket (driving the disconnect through the adapter's own close path) and register a fresh socket with a new instance for the restore, instead of re-heartbeating the stale handle — matches how a provider actually reconnects. Addresses review feedback on #244. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b36831e commit 8b3ec71

4 files changed

Lines changed: 194 additions & 3 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import { and, eq } from 'drizzle-orm';
3+
import { handleProviderDisconnect, markNodeOffline } from '../../node-control.js';
4+
import { makeNodeStack, createWorkspace, FakeSocket, type TestStack } from './harness.js';
5+
import { nodeProviders, nodes } from '../../db/schema.js';
6+
7+
// The provider-disconnect lifecycle is exported from @relaycast/engine/node-control
8+
// so an out-of-process socket owner — the relaycast-cloud NodeDO — drives it on
9+
// socket close instead of hand-rolling the SQL (the mirror the node-providers spec
10+
// is removing). These assert the public exports produce the same DB liveness the
11+
// in-process adapter's own close path produces.
12+
describe('node-control provider-disconnect exports', () => {
13+
let stack: TestStack;
14+
beforeEach(() => { stack = makeNodeStack({ ttlMs: 60_000 }); });
15+
// Optional-chain so a failed beforeEach (undefined stack) surfaces its real
16+
// error instead of a TypeError from teardown.
17+
afterEach(() => stack?.close());
18+
19+
const db = () => stack.runtime.handle.db;
20+
21+
async function enrollNode(ws: { workspaceKey: string }, nodeId: string, name: string) {
22+
const res = await stack.app.request('/v1/nodes', {
23+
method: 'POST',
24+
headers: { 'content-type': 'application/json', authorization: `Bearer ${ws.workspaceKey}` },
25+
body: JSON.stringify({ node_id: nodeId, name, role: 'broker', capabilities: [], max_agents: 4, tags: ['test'], version: 'v0' }),
26+
});
27+
expect(res.status).toBe(201);
28+
}
29+
30+
async function attachProvider(
31+
workspaceId: string,
32+
nodeId: string,
33+
nodeName: string,
34+
providerName: string,
35+
capability: string,
36+
activeAgents = 0,
37+
instanceId = `${providerName}-i1`,
38+
) {
39+
const provider = { name: providerName, instance_id: instanceId };
40+
const sock = new FakeSocket();
41+
const handle = stack.runtime.realtime.attachNodeSocket(workspaceId, nodeId, sock);
42+
await handle.handleMessage(JSON.stringify({
43+
v: 1,
44+
id: `reg-${instanceId}`,
45+
type: 'node.register',
46+
name: nodeName,
47+
node_id: nodeId,
48+
provider,
49+
capabilities: [{ name: capability, kind: 'action' }],
50+
max_agents: 4,
51+
tags: ['test'],
52+
version: 'v1',
53+
resume_cursor: null,
54+
}));
55+
await heartbeat(handle, providerName, activeAgents, instanceId);
56+
return { sock, handle };
57+
}
58+
59+
function heartbeat(
60+
handle: { handleMessage(raw: string): Promise<void> },
61+
providerName: string,
62+
activeAgents: number,
63+
instanceId = `${providerName}-i1`,
64+
) {
65+
return handle.handleMessage(JSON.stringify({
66+
v: 1,
67+
type: 'node.heartbeat',
68+
provider: { name: providerName, instance_id: instanceId },
69+
load: 0,
70+
active_agents: activeAgents,
71+
handlers_live: true,
72+
}));
73+
}
74+
75+
function nodeActiveAgents(workspaceId: string, nodeId: string) {
76+
return db()
77+
.select({ activeAgents: nodes.activeAgents })
78+
.from(nodes)
79+
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)))
80+
.then((rows) => rows[0]?.activeAgents);
81+
}
82+
83+
function providerActiveAgents(workspaceId: string, nodeId: string, name: string) {
84+
return db()
85+
.select({ activeAgents: nodeProviders.activeAgents })
86+
.from(nodeProviders)
87+
.where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), eq(nodeProviders.name, name)))
88+
.then((rows) => rows[0]?.activeAgents);
89+
}
90+
91+
function providerStatuses(workspaceId: string, nodeId: string) {
92+
return db()
93+
.select({ name: nodeProviders.name, status: nodeProviders.status })
94+
.from(nodeProviders)
95+
.where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId)))
96+
.then((rows) => Object.fromEntries(rows.map((r) => [r.name, r.status])));
97+
}
98+
99+
function nodeStatus(workspaceId: string, nodeId: string) {
100+
return db()
101+
.select({ status: nodes.status })
102+
.from(nodes)
103+
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)))
104+
.then((rows) => rows[0]?.status);
105+
}
106+
107+
it('handleProviderDisconnect (remaining connections) flips only that provider offline; node stays online', async () => {
108+
const ws = await createWorkspace(stack.app, 'exp-provider-remaining');
109+
await enrollNode(ws, 'node_a', 'alpha');
110+
await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', 'run-etl');
111+
await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'rb', 'build');
112+
113+
await handleProviderDisconnect(db(), stack.runtime.realtime, ws.workspaceId, 'node_a', 'py', true);
114+
115+
expect(await providerStatuses(ws.workspaceId, 'node_a')).toEqual({ py: 'offline', rb: 'online' });
116+
expect(await nodeStatus(ws.workspaceId, 'node_a')).toBe('online');
117+
});
118+
119+
it('handleProviderDisconnect (no remaining connections) marks the whole node offline', async () => {
120+
const ws = await createWorkspace(stack.app, 'exp-provider-last');
121+
await enrollNode(ws, 'node_a', 'alpha');
122+
await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', 'run-etl');
123+
124+
await handleProviderDisconnect(db(), stack.runtime.realtime, ws.workspaceId, 'node_a', 'py', false);
125+
126+
expect(await providerStatuses(ws.workspaceId, 'node_a')).toEqual({ py: 'offline' });
127+
expect(await nodeStatus(ws.workspaceId, 'node_a')).toBe('offline');
128+
});
129+
130+
it('markNodeOffline flips the node and every provider offline', async () => {
131+
const ws = await createWorkspace(stack.app, 'exp-node-offline');
132+
await enrollNode(ws, 'node_a', 'alpha');
133+
await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', 'run-etl');
134+
await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'rb', 'build');
135+
136+
await markNodeOffline(db(), stack.runtime.realtime, ws.workspaceId, 'node_a');
137+
138+
expect(await providerStatuses(ws.workspaceId, 'node_a')).toEqual({ py: 'offline', rb: 'offline' });
139+
expect(await nodeStatus(ws.workspaceId, 'node_a')).toBe('offline');
140+
});
141+
142+
it('drops a disconnected provider from the node aggregate, and restores it on reconnect', async () => {
143+
const ws = await createWorkspace(stack.app, 'exp-aggregate');
144+
await enrollNode(ws, 'node_a', 'alpha');
145+
const py = await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', 'run-etl', 2);
146+
await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'rb', 'build', 3);
147+
// The node aggregate sums both providers' active agents.
148+
expect(await nodeActiveAgents(ws.workspaceId, 'node_a')).toBe(5);
149+
150+
// py's socket drops (others remain): its agents are gone, so the node
151+
// aggregate must no longer count them — recomputeNodeAggregate would resurrect
152+
// them if the provider row kept its stale activeAgents.
153+
await py.handle.handleClose();
154+
expect(await providerActiveAgents(ws.workspaceId, 'node_a', 'py')).toBe(0);
155+
expect(await nodeActiveAgents(ws.workspaceId, 'node_a')).toBe(3);
156+
157+
// Symmetric restore through a real reconnect: a fresh socket registers py
158+
// (new instance) and heartbeats, repopulating its count.
159+
await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', 'run-etl', 2, 'py-i2');
160+
expect(await providerActiveAgents(ws.workspaceId, 'node_a', 'py')).toBe(2);
161+
expect(await nodeActiveAgents(ws.workspaceId, 'node_a')).toBe(5);
162+
});
163+
164+
it('markNodeOffline zeros every provider active-agent count', async () => {
165+
const ws = await createWorkspace(stack.app, 'exp-node-offline-aggregate');
166+
await enrollNode(ws, 'node_a', 'alpha');
167+
await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'py', 'run-etl', 2);
168+
await attachProvider(ws.workspaceId, 'node_a', 'alpha', 'rb', 'build', 3);
169+
170+
await markNodeOffline(db(), stack.runtime.realtime, ws.workspaceId, 'node_a');
171+
172+
expect(await providerActiveAgents(ws.workspaceId, 'node_a', 'py')).toBe(0);
173+
expect(await providerActiveAgents(ws.workspaceId, 'node_a', 'rb')).toBe(0);
174+
expect(await nodeActiveAgents(ws.workspaceId, 'node_a')).toBe(0);
175+
});
176+
});

packages/engine/src/engine/node.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -476,10 +476,11 @@ export async function markNodeOffline(
476476
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
477477

478478
// Provider capability sets persist (offline nodes still show their manifest);
479-
// only their liveness flips.
479+
// only their liveness flips. Zero activeAgents too, so a later
480+
// recomputeNodeAggregate never resurrects a dropped provider's agent count.
480481
await db
481482
.update(nodeProviders)
482-
.set({ status: 'offline', handlersLive: false, load: 0, lastHeartbeatAt: new Date() })
483+
.set({ status: 'offline', handlersLive: false, load: 0, activeAgents: 0, lastHeartbeatAt: new Date() })
483484
.where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId)));
484485

485486
await db

packages/engine/src/engine/nodeProvider.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,12 @@ export async function heartbeatProvider(
154154
}
155155

156156
export async function markProviderOffline(db: Db, workspaceId: string, nodeId: string, name: string): Promise<void> {
157+
// A disconnected provider hosts no live agents; zero its activeAgents so
158+
// recomputeNodeAggregate (which sums across all providers) stops counting them
159+
// on the node. A reconnect/heartbeat repopulates it from the provider's report.
157160
await db
158161
.update(nodeProviders)
159-
.set({ status: 'offline', handlersLive: false, load: 0, lastHeartbeatAt: new Date() })
162+
.set({ status: 'offline', handlersLive: false, load: 0, activeAgents: 0, lastHeartbeatAt: new Date() })
160163
.where(and(
161164
eq(nodeProviders.workspaceId, workspaceId),
162165
eq(nodeProviders.nodeId, nodeId),

packages/engine/src/node-control.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
export {
22
handleNodeControlMessage,
3+
// Node-offline lifecycle for out-of-process socket owners (e.g. the
4+
// relaycast-cloud NodeDO): when a provider's control socket drops, drive
5+
// `handleProviderDisconnect` — it flips only that provider (and its agents)
6+
// offline while the node stays online, or, when it was the node's last
7+
// connection, marks the whole node offline. `markNodeOffline` is the node-wide
8+
// marker for the other lifecycle triggers a socket owner controls (a liveness
9+
// alarm lapse, an operator disconnect). Both encapsulate the provider-row,
10+
// agent, aggregate, and invocation-reschedule effects so a host never
11+
// hand-rolls them.
12+
handleProviderDisconnect,
13+
markNodeOffline,
314
type HandleNodeControlMessageArgs,
415
type NodeSocketLike,
516
} from './engine/node.js';

0 commit comments

Comments
 (0)