machine-bridge-mcp 1.2.1 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ import { isLiveDaemonAttachment, withDaemonLastSeenAt, type DaemonRole } from "./daemon-liveness.ts";
2
+ import { sanitizeMetadataText } from "./http.ts";
3
+ import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy.ts";
4
+
5
+ export interface DaemonAttachment {
6
+ role: DaemonRole;
7
+ connectedAt: string;
8
+ lastSeenAt?: string;
9
+ probeId?: string;
10
+ policy?: DaemonPolicy;
11
+ tools?: string[];
12
+ }
13
+
14
+ interface WebSocketContext {
15
+ getWebSockets(): WebSocket[];
16
+ }
17
+
18
+ export class DaemonSocketRegistry {
19
+ constructor(private readonly context: WebSocketContext) {}
20
+
21
+ attachment(socket: WebSocket): DaemonAttachment | undefined {
22
+ const raw = socket.deserializeAttachment();
23
+ if (!raw || typeof raw !== "object") return undefined;
24
+ const candidate = raw as Partial<DaemonAttachment>;
25
+ if (!["candidate", "probing", "expired", "daemon"].includes(String(candidate.role))) return undefined;
26
+ const policy = sanitizeDaemonPolicy(candidate.policy);
27
+ return {
28
+ role: candidate.role as DaemonRole,
29
+ connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
30
+ lastSeenAt: sanitizeMetadataText(candidate.lastSeenAt, 64),
31
+ probeId: sanitizeProbeId(candidate.probeId),
32
+ policy,
33
+ tools: sanitizeDaemonTools(candidate.tools, policy),
34
+ };
35
+ }
36
+
37
+ readyAttachment(socket: WebSocket): DaemonAttachment | undefined {
38
+ const attachment = this.attachment(socket);
39
+ return attachment?.role === "daemon" ? attachment : undefined;
40
+ }
41
+
42
+ candidateSockets(): WebSocket[] { return this.openSockets("candidate"); }
43
+ probingSockets(): WebSocket[] { return this.openSockets("probing"); }
44
+ readyRoleSockets(): WebSocket[] { return this.openSockets("daemon").sort((left, right) => this.connectedAt(right) - this.connectedAt(left)); }
45
+ readySockets(now = Date.now()): WebSocket[] {
46
+ return this.readyRoleSockets().filter((socket) => isLiveDaemonAttachment(this.readyAttachment(socket), now));
47
+ }
48
+
49
+ nonReadySockets(): WebSocket[] {
50
+ return this.context.getWebSockets().filter((socket) => this.attachment(socket)?.role !== "daemon" && socket.readyState === WebSocket.OPEN);
51
+ }
52
+
53
+ beginCandidate(socket: WebSocket, connectedAt = new Date().toISOString()): void {
54
+ socket.serializeAttachment({ role: "candidate", connectedAt } satisfies DaemonAttachment);
55
+ }
56
+
57
+ beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; policy: DaemonPolicy; tools: string[] }): void {
58
+ socket.serializeAttachment({
59
+ role: "probing",
60
+ connectedAt: values.connectedAt,
61
+ lastSeenAt: values.connectedAt,
62
+ probeId: values.probeId,
63
+ policy: values.policy,
64
+ tools: values.tools,
65
+ } satisfies DaemonAttachment);
66
+ }
67
+
68
+ promote(socket: WebSocket, lastSeenAt = new Date().toISOString()): DaemonAttachment | undefined {
69
+ const attachment = this.attachment(socket);
70
+ if (attachment?.role !== "probing") return undefined;
71
+ const ready = { ...attachment, role: "daemon" as const, lastSeenAt };
72
+ delete ready.probeId;
73
+ socket.serializeAttachment(ready satisfies DaemonAttachment);
74
+ return ready;
75
+ }
76
+
77
+ touch(socket: WebSocket, lastSeenAt = new Date().toISOString()): DaemonAttachment | undefined {
78
+ const attachment = this.attachment(socket);
79
+ if (!attachment || (attachment.role !== "probing" && attachment.role !== "daemon")) return undefined;
80
+ const touched = withDaemonLastSeenAt(attachment, lastSeenAt);
81
+ socket.serializeAttachment(touched satisfies DaemonAttachment);
82
+ return touched;
83
+ }
84
+
85
+ expire(socket: WebSocket): void {
86
+ const attachment = this.attachment(socket);
87
+ if (!attachment) return;
88
+ socket.serializeAttachment({
89
+ role: "expired",
90
+ connectedAt: attachment.connectedAt,
91
+ lastSeenAt: attachment.lastSeenAt,
92
+ } satisfies DaemonAttachment);
93
+ }
94
+
95
+ private openSockets(role: DaemonRole): WebSocket[] {
96
+ return this.context.getWebSockets().filter((socket) => this.attachment(socket)?.role === role && socket.readyState === WebSocket.OPEN);
97
+ }
98
+
99
+ private connectedAt(socket: WebSocket): number {
100
+ return Date.parse(this.attachment(socket)?.connectedAt ?? "") || 0;
101
+ }
102
+ }
103
+
104
+ function sanitizeProbeId(value: unknown): string | undefined {
105
+ if (typeof value !== "string" || !/^probe_[A-Za-z0-9_-]{8,240}$/.test(value)) return undefined;
106
+ return value;
107
+ }
@@ -1,23 +1,33 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
  import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
3
3
  import { PendingCallRegistrationError, PendingCallRegistry } from "./pending-calls.ts";
4
+ import {
5
+ DAEMON_HELLO_TIMEOUT_MS,
6
+ DAEMON_LIVENESS_TIMEOUT_MS,
7
+ DAEMON_READY_TIMEOUT_MS,
8
+ daemonLastSeenMs,
9
+ daemonLivenessDeadlineMs,
10
+ daemonReadyDeadlineMs,
11
+ isFreshDaemonCandidate,
12
+ } from "./daemon-liveness.ts";
13
+ import { DaemonSocketRegistry } from "./daemon-sockets.ts";
4
14
  import { mcpClientRequestKey, resolveMcpSession } from "./mcp-session.ts";
5
15
  import { daemonToolTimeoutMs } from "./tool-timeout.ts";
6
16
  import { WorkerObservability } from "./observability.ts";
7
17
  import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors.ts";
8
- import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy.ts";
18
+ import { sanitizeDaemonPolicy, sanitizeDaemonTools } from "./policy.ts";
9
19
  import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from "./access.ts";
10
20
  import { OAuthController, type AuthorizedToken, type OAuthControllerEnv } from "./oauth-controller.ts";
11
21
  import { accountAuthoritySnapshot, decorateProjectOverview, describeDaemonCeiling } from "./authority.ts";
12
22
  import { serverInfoTool, workspaceTools } from "./tool-catalog.ts";
13
23
  import { OFFLINE_ACCESS_SCOPE, randomToken, safeEqual } from "./oauth-state.ts";
14
24
  import {
15
- HttpError, applyCors, baseUrl, bearerToken, corsPreflight, json, methodNotAllowed, sanitizeMetadataText,
25
+ HttpError, applyCors, baseUrl, bearerToken, corsPreflight, json, methodNotAllowed,
16
26
  parseJsonRequest, workerErrorClass,
17
27
  } from "./http.ts";
18
28
 
19
29
  const SERVER_NAME = String(serverMetadata.name);
20
- const SERVER_VERSION = "1.2.1";
30
+ const SERVER_VERSION = "1.2.5";
21
31
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
22
32
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
23
33
  const JSONRPC_VERSION = "2.0";
@@ -25,7 +35,6 @@ const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
25
35
  const MAX_BODY_BYTES = 16 * 1024 * 1024;
26
36
  const MAX_PENDING_CALLS = 32;
27
37
  const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
28
- const DAEMON_HELLO_TIMEOUT_MS = 10_000;
29
38
 
30
39
  interface BridgeEnv extends OAuthControllerEnv {
31
40
  BRIDGE: DurableObjectNamespace<BridgeRoom>;
@@ -45,23 +54,18 @@ interface JsonRpcRequest {
45
54
  params?: unknown;
46
55
  }
47
56
 
48
- interface DaemonAttachment {
49
- role: "candidate" | "expired" | "daemon";
50
- connectedAt: string;
51
- policy?: DaemonPolicy;
52
- tools?: string[];
53
- }
54
-
55
57
  const MCP_INSTRUCTIONS = serverMetadata.instructions.map((value) => String(value)).join("\n");
56
58
 
57
59
  export class BridgeRoom extends DurableObject<BridgeEnv> {
58
60
  private readonly pending = new PendingCallRegistry(MAX_PENDING_CALLS);
59
61
  private readonly observability = new WorkerObservability();
60
62
  private readonly oauth: OAuthController;
63
+ private readonly daemonRegistry: DaemonSocketRegistry;
61
64
 
62
65
  constructor(ctx: DurableObjectState, env: BridgeEnv) {
63
66
  super(ctx, env);
64
67
  this.oauth = new OAuthController(ctx, env, SERVER_NAME);
68
+ this.daemonRegistry = new DaemonSocketRegistry(ctx);
65
69
  }
66
70
 
67
71
  async fetch(request: Request): Promise<Response> {
@@ -156,7 +160,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
156
160
  }
157
161
  const body = parsed;
158
162
 
159
- const socketAttachment = this.socketAttachment(ws);
163
+ const socketAttachment = this.daemonRegistry.attachment(ws);
160
164
  if (!socketAttachment) {
161
165
  closeWebSocketQuietly(ws, 1008, "missing daemon attachment");
162
166
  return;
@@ -168,60 +172,87 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
168
172
  }
169
173
 
170
174
  if (body.type === "hello") {
171
- if (socketAttachment.role === "daemon") {
175
+ if (socketAttachment.role !== "candidate") {
172
176
  rejectDaemonMessage(ws, "duplicate_hello", 1002, "duplicate daemon hello");
173
177
  return;
174
178
  }
175
- const previousDaemons = this.daemonSockets().filter((socket) => socket !== ws);
176
- if (socketAttachment.role === "candidate") {
177
- if (!isFreshDaemonCandidate(socketAttachment.connectedAt)) {
178
- closeWebSocketQuietly(ws, 1008, "stale daemon candidate");
179
- await this.scheduleCandidateAlarm();
180
- return;
181
- }
179
+ if (!isFreshDaemonCandidate(socketAttachment.connectedAt)) {
180
+ closeWebSocketQuietly(ws, 1008, "stale daemon candidate");
181
+ await this.scheduleSocketAlarms();
182
+ return;
182
183
  }
183
184
  const daemonPolicy = sanitizeDaemonPolicy(body.policy);
184
- ws.serializeAttachment({
185
- role: "daemon",
186
- connectedAt: new Date().toISOString(),
185
+ const authenticatedAt = new Date().toISOString();
186
+ const probeId = randomToken("probe");
187
+ this.daemonRegistry.beginProbe(ws, {
188
+ connectedAt: authenticatedAt,
189
+ probeId,
187
190
  policy: daemonPolicy,
188
191
  tools: sanitizeDaemonTools(body.tools, daemonPolicy),
189
- } satisfies DaemonAttachment);
192
+ });
190
193
  this.observability.socketAuthenticated();
191
- await this.scheduleCandidateAlarm();
192
194
  try {
193
195
  ws.send(JSON.stringify({ type: "hello_ack", server: SERVER_NAME, version: SERVER_VERSION }));
196
+ ws.send(JSON.stringify({ type: "relay_probe", id: probeId }));
194
197
  } catch {
195
- ws.serializeAttachment({
196
- role: "expired",
197
- connectedAt: socketAttachment.connectedAt,
198
- } satisfies DaemonAttachment);
199
- closeWebSocketQuietly(ws, 1011, "daemon hello acknowledgement failed");
198
+ this.daemonRegistry.expire(ws);
199
+ closeWebSocketQuietly(ws, 1011, "daemon readiness probe failed");
200
+ await this.scheduleSocketAlarms();
200
201
  return;
201
202
  }
202
- for (const socket of previousDaemons) {
203
- closeWebSocketQuietly(socket, 1012, "replaced by authenticated daemon");
204
- }
203
+ await this.scheduleSocketAlarms();
205
204
  return;
206
205
  }
207
206
 
208
- if (socketAttachment.role !== "daemon") {
207
+ if (socketAttachment.role === "candidate") {
209
208
  closeWebSocketQuietly(ws, 1008, "daemon hello required");
210
209
  return;
211
210
  }
212
211
 
213
212
  if (body.type === "heartbeat" || body.type === "ping") {
213
+ await this.touchDaemonSocket(ws);
214
214
  ws.send(JSON.stringify({ type: "pong", ts: body.ts ?? Date.now() }));
215
215
  return;
216
216
  }
217
217
 
218
+ if (socketAttachment.role === "probing") {
219
+ if (body.type !== "relay_probe_result" || typeof body.id !== "string" || body.id !== socketAttachment.probeId) {
220
+ rejectDaemonMessage(ws, "invalid_relay_probe_result", 1002, "invalid daemon readiness result");
221
+ return;
222
+ }
223
+ const readyAt = new Date().toISOString();
224
+ if (!this.daemonRegistry.promote(ws, readyAt)) {
225
+ rejectDaemonMessage(ws, "invalid_relay_readiness_state", 1002, "invalid daemon readiness state");
226
+ return;
227
+ }
228
+ try {
229
+ ws.send(JSON.stringify({ type: "ready_ack", server: SERVER_NAME, version: SERVER_VERSION }));
230
+ } catch {
231
+ this.invalidateDaemonSocket(ws, "daemon readiness acknowledgement failed", "daemon ready timeout", "daemon_ready_timeout");
232
+ return;
233
+ }
234
+ this.observability.socketReady();
235
+ for (const previous of this.daemonRegistry.readyRoleSockets().filter((socket) => socket !== ws)) {
236
+ closeWebSocketQuietly(previous, 1012, "replaced by verified daemon");
237
+ }
238
+ return;
239
+ }
240
+
241
+ if (socketAttachment.role !== "daemon") {
242
+ closeWebSocketQuietly(ws, 1008, "daemon readiness required");
243
+ return;
244
+ }
245
+
218
246
  if (body.type !== "tool_result" || typeof body.id !== "string") {
219
247
  rejectDaemonMessage(ws, "unknown_message_type", 1002, "unknown daemon message type");
220
248
  return;
221
249
  }
222
250
 
223
- if (body.ok === false) this.pending.reject(body.id, daemonToolError(body.error), ws);
224
- else this.pending.resolve(body.id, ws, body.result);
251
+ await this.touchDaemonSocket(ws);
252
+ const matched = body.ok === false
253
+ ? this.pending.reject(body.id, daemonToolError(body.error), ws)
254
+ : this.pending.resolve(body.id, ws, body.result);
255
+ if (!matched) this.observability.unmatchedResult();
225
256
  }
226
257
 
227
258
  async webSocketClose(ws: WebSocket): Promise<void> {
@@ -235,8 +266,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
235
266
 
236
267
  private async cleanupDaemonSocket(ws: WebSocket, message: string): Promise<void> {
237
268
  this.observability.socketDisconnected();
238
- await this.scheduleCandidateAlarm();
239
269
  this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
270
+ await this.scheduleSocketAlarms();
240
271
  }
241
272
 
242
273
  private async handleMcp(request: Request, base: string): Promise<Response> {
@@ -269,19 +300,31 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
269
300
 
270
301
  const session = await resolveMcpSession(request, body.method, this.oauth.identityKey(), authorized.tokenKey);
271
302
  if (session.kind === "invalid") return json(rpcError(body.id, -32001, "MCP session not found"), 404);
272
- const response = await this.dispatchJsonRpc(body, base, authorized, session.kind === "active" ? session.sessionId : "");
303
+ const response = await this.dispatchJsonRpc(
304
+ body,
305
+ base,
306
+ authorized,
307
+ session.kind === "active" ? session.sessionId : "",
308
+ request.signal,
309
+ );
273
310
  if (response === null) return new Response(null, { status: 202 });
274
311
  return session.kind === "initialize" ? json(response, 200, { "mcp-session-id": session.sessionId }) : json(response);
275
312
  }
276
313
 
277
- private async dispatchJsonRpc(request: JsonRpcRequest, base: string, authorized: AuthorizedToken, sessionId: string): Promise<Record<string, unknown> | null> {
314
+ private async dispatchJsonRpc(
315
+ request: JsonRpcRequest,
316
+ base: string,
317
+ authorized: AuthorizedToken,
318
+ sessionId: string,
319
+ signal?: AbortSignal,
320
+ ): Promise<Record<string, unknown> | null> {
278
321
  if (request.method === "initialize") {
279
322
  const requested = asObject(request.params).protocolVersion;
280
323
  const protocolVersion = typeof requested === "string" && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])
281
324
  ? requested
282
325
  : MCP_PROTOCOL_VERSION;
283
326
  const bootstrap = this.daemonToolEnabled("session_bootstrap")
284
- ? await this.callDaemonTool("session_bootstrap", { path: "." }, authorized).catch(() => null)
327
+ ? await this.callDaemonTool("session_bootstrap", { path: "." }, authorized, undefined, signal).catch(() => null)
285
328
  : null;
286
329
  const localInstructions = sessionInstructionText(bootstrap);
287
330
  return rpcResult(request.id, {
@@ -310,7 +353,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
310
353
  const name = requiredString(params, "name");
311
354
  const args = asObject(params.arguments);
312
355
  try {
313
- const result = await this.callTool(name, args, base, authorized, mcpClientRequestKey(authorized.tokenKey, sessionId, request.id));
356
+ const result = await this.callTool(
357
+ name,
358
+ args,
359
+ base,
360
+ authorized,
361
+ mcpClientRequestKey(authorized.tokenKey, sessionId, request.id),
362
+ signal,
363
+ );
314
364
  return rpcResult(request.id, textToolResult(result));
315
365
  } catch (error) {
316
366
  return rpcResult(request.id, textToolResult({ error: publicWorkerToolError(error) }, true));
@@ -318,7 +368,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
318
368
  }
319
369
  return rpcError(request.id, -32601, `Method not found: ${request.method}`);
320
370
  }
321
- private async callTool(name: string, args: Record<string, unknown>, base: string, authorized: AuthorizedToken, requestKey?: string): Promise<unknown> {
371
+ private async callTool(
372
+ name: string,
373
+ args: Record<string, unknown>,
374
+ base: string,
375
+ authorized: AuthorizedToken,
376
+ requestKey?: string,
377
+ signal?: AbortSignal,
378
+ ): Promise<unknown> {
322
379
  if (name === "server_info") {
323
380
  const { daemon, tools, authorization } = this.authorityContext(authorized);
324
381
  return {
@@ -332,7 +389,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
332
389
  daemon,
333
390
  worker: {
334
391
  pending_calls: this.pending.snapshot(),
335
- daemon_candidates: this.candidateSockets().length,
392
+ daemon_candidates: this.daemonRegistry.candidateSockets().length,
393
+ daemon_probes: this.daemonRegistry.probingSockets().length,
394
+ sockets_live: {
395
+ authenticated: this.daemonRegistry.readyRoleSockets().length + this.daemonRegistry.probingSockets().length,
396
+ ready: this.daemonRegistry.readySockets().length,
397
+ probing: this.daemonRegistry.probingSockets().length,
398
+ candidates: this.daemonRegistry.candidateSockets().length,
399
+ },
336
400
  observability: this.observability.snapshot(),
337
401
  },
338
402
  tools,
@@ -351,14 +415,21 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
351
415
  if (workspaceTools.some((tool) => tool.name === name)) {
352
416
  if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
353
417
  if (!accountRoleAllowsTool(authorized.role, name)) throw new WorkerToolError("authorization_denied", "tool is not allowed for this account role");
354
- const result = await this.callDaemonTool(name, args, authorized, requestKey);
418
+ const result = await this.callDaemonTool(name, args, authorized, requestKey, signal);
355
419
  return name === "project_overview" ? decorateProjectOverview(result, { accountId: authorized.accountId,
356
420
  accountVersion: authorized.accountVersion, role: authorized.role }) : result;
357
421
  }
358
422
  throw new Error(`unknown tool: ${name}`);
359
423
  }
360
- private async callDaemonTool(name: string, args: Record<string, unknown>, authorized: AuthorizedToken, requestKey?: string): Promise<unknown> {
361
- const socket = this.daemonSockets()[0];
424
+ private async callDaemonTool(
425
+ name: string,
426
+ args: Record<string, unknown>,
427
+ authorized: AuthorizedToken,
428
+ requestKey?: string,
429
+ signal?: AbortSignal,
430
+ ): Promise<unknown> {
431
+ this.reclaimStaleDaemonSockets();
432
+ const socket = this.daemonRegistry.readySockets()[0];
362
433
  if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
363
434
  const id = randomToken("call");
364
435
  const timeoutMs = daemonToolTimeoutMs(name, args);
@@ -372,8 +443,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
372
443
  timeoutMs,
373
444
  onTimeout: (record) => {
374
445
  sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
446
+ const silentForMs = Date.now() - daemonLastSeenMs(this.daemonRegistry.readyAttachment(record.socket));
447
+ if (!Number.isFinite(silentForMs) || silentForMs > 45_000) {
448
+ this.invalidateDaemonSocket(record.socket, "daemon became unresponsive", "daemon liveness timeout");
449
+ }
375
450
  return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
376
451
  },
452
+ signal,
453
+ onAbort: (record) => {
454
+ sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
455
+ return new WorkerToolError("cancelled", "MCP client stopped waiting for the tool result");
456
+ },
377
457
  });
378
458
  } catch (error) {
379
459
  if (error instanceof PendingCallRegistrationError) {
@@ -382,13 +462,16 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
382
462
  throw error;
383
463
  }
384
464
  this.observability.callStarted(name);
385
- try {
386
- socket.send(JSON.stringify({
387
- type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs,
388
- authorization: { account_id: authorized.accountId, account_version: authorized.accountVersion, role: authorized.role },
389
- }));
390
- } catch {
391
- this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
465
+ if (!signal?.aborted) {
466
+ try {
467
+ socket.send(JSON.stringify({
468
+ type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs,
469
+ authorization: { account_id: authorized.accountId, account_version: authorized.accountVersion, role: authorized.role },
470
+ }));
471
+ } catch {
472
+ this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
473
+ this.invalidateDaemonSocket(socket, "failed to send daemon tool call", "daemon send failed");
474
+ }
392
475
  }
393
476
  try {
394
477
  const value = await result;
@@ -413,7 +496,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
413
496
  const supplied = request.headers.get("X-Bridge-Token") ?? "";
414
497
  if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
415
498
 
416
- for (const socket of this.nonDaemonSockets()) {
499
+ for (const socket of this.daemonRegistry.nonReadySockets()) {
417
500
  closeWebSocketQuietly(socket, 1012, "replaced by newer daemon candidate");
418
501
  }
419
502
 
@@ -421,11 +504,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
421
504
  const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
422
505
  this.ctx.acceptWebSocket(server);
423
506
  this.observability.socketCandidate();
424
- server.serializeAttachment({
425
- role: "candidate",
426
- connectedAt: new Date().toISOString(),
427
- } satisfies DaemonAttachment);
428
- await this.ctx.storage.setAlarm(Date.now() + DAEMON_HELLO_TIMEOUT_MS);
507
+ this.daemonRegistry.beginCandidate(server);
508
+ await this.scheduleSocketAlarms();
429
509
  server.send(JSON.stringify({ type: "welcome", server: SERVER_NAME, version: SERVER_VERSION }));
430
510
  return new Response(null, { status: 101, webSocket: client });
431
511
  }
@@ -446,79 +526,80 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
446
526
  return this.daemonAdvertisedTools().has(name);
447
527
  }
448
528
  private daemonAdvertisedTools(): Set<string> {
449
- const socket = this.daemonSockets()[0];
529
+ this.reclaimStaleDaemonSockets();
530
+ const socket = this.daemonRegistry.readySockets()[0];
450
531
  if (!socket) return new Set();
451
- const attachment = this.daemonAttachment(socket);
532
+ const attachment = this.daemonRegistry.readyAttachment(socket);
452
533
  if (!attachment?.tools) return new Set();
453
534
  return new Set(attachment.tools);
454
535
  }
455
536
 
456
- private daemonSockets(): WebSocket[] {
457
- return this.ctx.getWebSockets()
458
- .filter((socket) => this.daemonAttachment(socket) && socket.readyState === WebSocket.OPEN)
459
- .sort((left, right) => {
460
- const leftTime = Date.parse(this.daemonAttachment(left)?.connectedAt ?? "") || 0;
461
- const rightTime = Date.parse(this.daemonAttachment(right)?.connectedAt ?? "") || 0;
462
- return rightTime - leftTime;
463
- });
464
- }
465
-
466
- private candidateSockets(): WebSocket[] {
467
- return this.ctx.getWebSockets().filter((socket) => this.socketAttachment(socket)?.role === "candidate" && socket.readyState === WebSocket.OPEN);
468
- }
469
-
470
- private nonDaemonSockets(): WebSocket[] {
471
- return this.ctx.getWebSockets().filter((socket) => {
472
- const role = this.socketAttachment(socket)?.role;
473
- return role !== "daemon" && socket.readyState === WebSocket.OPEN;
474
- });
537
+ private async touchDaemonSocket(ws: WebSocket): Promise<void> {
538
+ if (!this.daemonRegistry.touch(ws)) return;
539
+ await this.scheduleSocketAlarms();
475
540
  }
476
541
 
477
- private socketAttachment(socket: WebSocket): DaemonAttachment | undefined {
478
- const raw = socket.deserializeAttachment();
479
- if (!raw || typeof raw !== "object") return undefined;
480
- const candidate = raw as Partial<DaemonAttachment>;
481
- if (candidate.role !== "candidate" && candidate.role !== "expired" && candidate.role !== "daemon") return undefined;
482
- const policy = sanitizeDaemonPolicy(candidate.policy);
483
- return {
484
- role: candidate.role,
485
- connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
486
- policy,
487
- tools: sanitizeDaemonTools(candidate.tools, policy),
488
- };
542
+ private invalidateDaemonSocket(
543
+ ws: WebSocket,
544
+ message: string,
545
+ closeReason: string,
546
+ errorCode = "daemon_liveness_timeout",
547
+ ): void {
548
+ this.daemonRegistry.expire(ws);
549
+ this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
550
+ sendWebSocketQuietly(ws, { type: "error", error: errorCode });
551
+ closeWebSocketQuietly(ws, 1008, closeReason);
489
552
  }
490
553
 
491
- private daemonAttachment(socket: WebSocket): DaemonAttachment | undefined {
492
- const attachment = this.socketAttachment(socket);
493
- return attachment?.role === "daemon" ? attachment : undefined;
554
+ private reclaimStaleDaemonSockets(now = Date.now()): void {
555
+ for (const socket of this.daemonRegistry.readyRoleSockets()) {
556
+ const deadline = daemonLivenessDeadlineMs(this.daemonRegistry.readyAttachment(socket));
557
+ if (Number.isFinite(deadline) && deadline > now) continue;
558
+ this.invalidateDaemonSocket(socket, "daemon became unresponsive", "daemon liveness timeout");
559
+ }
494
560
  }
495
561
 
496
562
  async alarm(): Promise<void> {
497
563
  const now = Date.now();
498
564
  let nextDeadline = Number.POSITIVE_INFINITY;
499
- for (const socket of this.candidateSockets()) {
500
- const attachment = this.socketAttachment(socket);
565
+ for (const socket of this.daemonRegistry.candidateSockets()) {
566
+ const attachment = this.daemonRegistry.attachment(socket);
501
567
  const connectedAt = Date.parse(attachment?.connectedAt ?? "");
502
568
  const deadline = connectedAt + DAEMON_HELLO_TIMEOUT_MS;
503
569
  if (!Number.isFinite(connectedAt) || deadline <= now) {
504
- socket.serializeAttachment({
505
- role: "expired",
506
- connectedAt: attachment?.connectedAt ?? new Date(0).toISOString(),
507
- } satisfies DaemonAttachment);
570
+ this.daemonRegistry.expire(socket);
508
571
  sendWebSocketQuietly(socket, { type: "error", error: "daemon_hello_timeout" });
509
572
  closeWebSocketQuietly(socket, 1008, "daemon hello timeout");
510
573
  continue;
511
574
  }
512
575
  nextDeadline = Math.min(nextDeadline, deadline);
513
576
  }
577
+ for (const socket of this.daemonRegistry.probingSockets()) {
578
+ const attachment = this.daemonRegistry.attachment(socket);
579
+ const readyDeadline = daemonReadyDeadlineMs(attachment);
580
+ const liveDeadline = daemonLivenessDeadlineMs(attachment);
581
+ if (!Number.isFinite(readyDeadline) || !Number.isFinite(liveDeadline) || Math.min(readyDeadline, liveDeadline) <= now) {
582
+ this.invalidateDaemonSocket(socket, "daemon did not complete end-to-end readiness verification", "daemon ready timeout", "daemon_ready_timeout");
583
+ continue;
584
+ }
585
+ nextDeadline = Math.min(nextDeadline, readyDeadline, liveDeadline);
586
+ }
587
+ for (const socket of this.daemonRegistry.readyRoleSockets()) {
588
+ const deadline = daemonLivenessDeadlineMs(this.daemonRegistry.readyAttachment(socket));
589
+ if (!Number.isFinite(deadline) || deadline <= now) {
590
+ this.invalidateDaemonSocket(socket, "daemon became unresponsive", "daemon liveness timeout");
591
+ continue;
592
+ }
593
+ nextDeadline = Math.min(nextDeadline, deadline);
594
+ }
514
595
  if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(nextDeadline);
515
596
  else await this.ctx.storage.deleteAlarm();
516
597
  }
517
598
 
518
- private async scheduleCandidateAlarm(): Promise<void> {
599
+ private async scheduleSocketAlarms(): Promise<void> {
519
600
  let nextDeadline = Number.POSITIVE_INFINITY;
520
- for (const socket of this.candidateSockets()) {
521
- const attachment = this.socketAttachment(socket);
601
+ for (const socket of this.daemonRegistry.candidateSockets()) {
602
+ const attachment = this.daemonRegistry.attachment(socket);
522
603
  const connectedAt = Date.parse(attachment?.connectedAt ?? "");
523
604
  if (!Number.isFinite(connectedAt)) {
524
605
  closeWebSocketQuietly(socket, 1008, "invalid daemon candidate timestamp");
@@ -526,19 +607,42 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
526
607
  }
527
608
  nextDeadline = Math.min(nextDeadline, connectedAt + DAEMON_HELLO_TIMEOUT_MS);
528
609
  }
610
+ for (const socket of this.daemonRegistry.probingSockets()) {
611
+ const attachment = this.daemonRegistry.attachment(socket);
612
+ const readyDeadline = daemonReadyDeadlineMs(attachment);
613
+ const liveDeadline = daemonLivenessDeadlineMs(attachment);
614
+ if (!Number.isFinite(readyDeadline) || !Number.isFinite(liveDeadline)) {
615
+ this.invalidateDaemonSocket(socket, "daemon readiness state is invalid", "daemon ready timeout", "daemon_ready_timeout");
616
+ continue;
617
+ }
618
+ nextDeadline = Math.min(nextDeadline, readyDeadline, liveDeadline);
619
+ }
620
+ for (const socket of this.daemonRegistry.readyRoleSockets()) {
621
+ const deadline = daemonLivenessDeadlineMs(this.daemonRegistry.readyAttachment(socket));
622
+ if (!Number.isFinite(deadline)) {
623
+ this.invalidateDaemonSocket(socket, "daemon became unresponsive", "invalid daemon liveness timestamp");
624
+ continue;
625
+ }
626
+ nextDeadline = Math.min(nextDeadline, deadline);
627
+ }
529
628
  if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(Math.max(Date.now(), nextDeadline));
530
629
  else await this.ctx.storage.deleteAlarm();
531
630
  }
532
631
 
533
632
  private daemonStatus(detail: boolean): Record<string, unknown> {
534
- const sockets = this.daemonSockets();
535
- const attachment = sockets[0] ? this.daemonAttachment(sockets[0]) : undefined;
633
+ this.reclaimStaleDaemonSockets();
634
+ const sockets = this.daemonRegistry.readySockets();
635
+ const attachment = sockets[0] ? this.daemonRegistry.readyAttachment(sockets[0]) : undefined;
536
636
  const tools = attachment?.tools ?? [];
537
637
  const base = {
538
638
  connected: sockets.length > 0,
539
639
  count: sockets.length,
540
640
  tool_count: tools.length,
541
641
  connected_at: attachment?.connectedAt ?? null,
642
+ last_seen_at: attachment?.lastSeenAt ?? attachment?.connectedAt ?? null,
643
+ readiness_verified: sockets.length > 0,
644
+ readiness_timeout_ms: DAEMON_READY_TIMEOUT_MS,
645
+ liveness_timeout_ms: DAEMON_LIVENESS_TIMEOUT_MS,
542
646
  };
543
647
  if (!detail) return base;
544
648
  return {
@@ -672,13 +776,6 @@ function textToolResult(value: unknown, isError = false): Record<string, unknown
672
776
  return result;
673
777
  }
674
778
 
675
- function isFreshDaemonCandidate(connectedAt: string): boolean {
676
- const timestamp = Date.parse(connectedAt);
677
- if (!Number.isFinite(timestamp)) return false;
678
- const age = Date.now() - timestamp;
679
- return age >= 0 && age <= DAEMON_HELLO_TIMEOUT_MS;
680
- }
681
-
682
779
  function asObject(value: unknown): Record<string, unknown> {
683
780
  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
684
781
  return value as Record<string, unknown>;