machine-bridge-mcp 1.2.2 → 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.
- package/CHANGELOG.md +28 -1
- package/README.md +2 -0
- package/SECURITY.md +1 -1
- package/browser-extension/manifest.json +2 -2
- package/docs/AGENT_CONTEXT.md +1 -1
- package/docs/ARCHITECTURE.md +13 -12
- package/docs/AUDIT.md +12 -0
- package/docs/ENGINEERING.md +1 -1
- package/docs/LOGGING.md +6 -6
- package/docs/OPERATIONS.md +6 -3
- package/docs/TESTING.md +3 -3
- package/docs/UPGRADING.md +3 -3
- package/package.json +1 -1
- package/src/local/capability-ranking.mjs +3 -2
- package/src/local/relay-connection.mjs +83 -17
- package/src/local/runtime.mjs +31 -11
- package/src/worker/daemon-liveness.ts +63 -0
- package/src/worker/daemon-sockets.ts +107 -0
- package/src/worker/index.ts +153 -97
- package/src/worker/observability.ts +2 -1
package/src/worker/index.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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.
|
|
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.
|
|
163
|
+
const socketAttachment = this.daemonRegistry.attachment(ws);
|
|
160
164
|
if (!socketAttachment) {
|
|
161
165
|
closeWebSocketQuietly(ws, 1008, "missing daemon attachment");
|
|
162
166
|
return;
|
|
@@ -168,58 +172,83 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
if (body.type === "hello") {
|
|
171
|
-
if (socketAttachment.role
|
|
175
|
+
if (socketAttachment.role !== "candidate") {
|
|
172
176
|
rejectDaemonMessage(ws, "duplicate_hello", 1002, "duplicate daemon hello");
|
|
173
177
|
return;
|
|
174
178
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
203
|
-
closeWebSocketQuietly(socket, 1012, "replaced by authenticated daemon");
|
|
204
|
-
}
|
|
203
|
+
await this.scheduleSocketAlarms();
|
|
205
204
|
return;
|
|
206
205
|
}
|
|
207
206
|
|
|
208
|
-
if (socketAttachment.role
|
|
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
|
|
|
251
|
+
await this.touchDaemonSocket(ws);
|
|
223
252
|
const matched = body.ok === false
|
|
224
253
|
? this.pending.reject(body.id, daemonToolError(body.error), ws)
|
|
225
254
|
: this.pending.resolve(body.id, ws, body.result);
|
|
@@ -237,8 +266,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
237
266
|
|
|
238
267
|
private async cleanupDaemonSocket(ws: WebSocket, message: string): Promise<void> {
|
|
239
268
|
this.observability.socketDisconnected();
|
|
240
|
-
await this.scheduleCandidateAlarm();
|
|
241
269
|
this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
|
|
270
|
+
await this.scheduleSocketAlarms();
|
|
242
271
|
}
|
|
243
272
|
|
|
244
273
|
private async handleMcp(request: Request, base: string): Promise<Response> {
|
|
@@ -360,7 +389,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
360
389
|
daemon,
|
|
361
390
|
worker: {
|
|
362
391
|
pending_calls: this.pending.snapshot(),
|
|
363
|
-
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
|
+
},
|
|
364
400
|
observability: this.observability.snapshot(),
|
|
365
401
|
},
|
|
366
402
|
tools,
|
|
@@ -392,7 +428,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
392
428
|
requestKey?: string,
|
|
393
429
|
signal?: AbortSignal,
|
|
394
430
|
): Promise<unknown> {
|
|
395
|
-
|
|
431
|
+
this.reclaimStaleDaemonSockets();
|
|
432
|
+
const socket = this.daemonRegistry.readySockets()[0];
|
|
396
433
|
if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
|
|
397
434
|
const id = randomToken("call");
|
|
398
435
|
const timeoutMs = daemonToolTimeoutMs(name, args);
|
|
@@ -406,6 +443,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
406
443
|
timeoutMs,
|
|
407
444
|
onTimeout: (record) => {
|
|
408
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
|
+
}
|
|
409
450
|
return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
|
|
410
451
|
},
|
|
411
452
|
signal,
|
|
@@ -429,6 +470,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
429
470
|
}));
|
|
430
471
|
} catch {
|
|
431
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");
|
|
432
474
|
}
|
|
433
475
|
}
|
|
434
476
|
try {
|
|
@@ -454,7 +496,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
454
496
|
const supplied = request.headers.get("X-Bridge-Token") ?? "";
|
|
455
497
|
if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
|
|
456
498
|
|
|
457
|
-
for (const socket of this.
|
|
499
|
+
for (const socket of this.daemonRegistry.nonReadySockets()) {
|
|
458
500
|
closeWebSocketQuietly(socket, 1012, "replaced by newer daemon candidate");
|
|
459
501
|
}
|
|
460
502
|
|
|
@@ -462,11 +504,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
462
504
|
const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
|
|
463
505
|
this.ctx.acceptWebSocket(server);
|
|
464
506
|
this.observability.socketCandidate();
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
connectedAt: new Date().toISOString(),
|
|
468
|
-
} satisfies DaemonAttachment);
|
|
469
|
-
await this.ctx.storage.setAlarm(Date.now() + DAEMON_HELLO_TIMEOUT_MS);
|
|
507
|
+
this.daemonRegistry.beginCandidate(server);
|
|
508
|
+
await this.scheduleSocketAlarms();
|
|
470
509
|
server.send(JSON.stringify({ type: "welcome", server: SERVER_NAME, version: SERVER_VERSION }));
|
|
471
510
|
return new Response(null, { status: 101, webSocket: client });
|
|
472
511
|
}
|
|
@@ -487,79 +526,80 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
487
526
|
return this.daemonAdvertisedTools().has(name);
|
|
488
527
|
}
|
|
489
528
|
private daemonAdvertisedTools(): Set<string> {
|
|
490
|
-
|
|
529
|
+
this.reclaimStaleDaemonSockets();
|
|
530
|
+
const socket = this.daemonRegistry.readySockets()[0];
|
|
491
531
|
if (!socket) return new Set();
|
|
492
|
-
const attachment = this.
|
|
532
|
+
const attachment = this.daemonRegistry.readyAttachment(socket);
|
|
493
533
|
if (!attachment?.tools) return new Set();
|
|
494
534
|
return new Set(attachment.tools);
|
|
495
535
|
}
|
|
496
536
|
|
|
497
|
-
private
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
.sort((left, right) => {
|
|
501
|
-
const leftTime = Date.parse(this.daemonAttachment(left)?.connectedAt ?? "") || 0;
|
|
502
|
-
const rightTime = Date.parse(this.daemonAttachment(right)?.connectedAt ?? "") || 0;
|
|
503
|
-
return rightTime - leftTime;
|
|
504
|
-
});
|
|
537
|
+
private async touchDaemonSocket(ws: WebSocket): Promise<void> {
|
|
538
|
+
if (!this.daemonRegistry.touch(ws)) return;
|
|
539
|
+
await this.scheduleSocketAlarms();
|
|
505
540
|
}
|
|
506
541
|
|
|
507
|
-
private
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
private socketAttachment(socket: WebSocket): DaemonAttachment | undefined {
|
|
519
|
-
const raw = socket.deserializeAttachment();
|
|
520
|
-
if (!raw || typeof raw !== "object") return undefined;
|
|
521
|
-
const candidate = raw as Partial<DaemonAttachment>;
|
|
522
|
-
if (candidate.role !== "candidate" && candidate.role !== "expired" && candidate.role !== "daemon") return undefined;
|
|
523
|
-
const policy = sanitizeDaemonPolicy(candidate.policy);
|
|
524
|
-
return {
|
|
525
|
-
role: candidate.role,
|
|
526
|
-
connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
|
|
527
|
-
policy,
|
|
528
|
-
tools: sanitizeDaemonTools(candidate.tools, policy),
|
|
529
|
-
};
|
|
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);
|
|
530
552
|
}
|
|
531
553
|
|
|
532
|
-
private
|
|
533
|
-
const
|
|
534
|
-
|
|
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
|
+
}
|
|
535
560
|
}
|
|
536
561
|
|
|
537
562
|
async alarm(): Promise<void> {
|
|
538
563
|
const now = Date.now();
|
|
539
564
|
let nextDeadline = Number.POSITIVE_INFINITY;
|
|
540
|
-
for (const socket of this.candidateSockets()) {
|
|
541
|
-
const attachment = this.
|
|
565
|
+
for (const socket of this.daemonRegistry.candidateSockets()) {
|
|
566
|
+
const attachment = this.daemonRegistry.attachment(socket);
|
|
542
567
|
const connectedAt = Date.parse(attachment?.connectedAt ?? "");
|
|
543
568
|
const deadline = connectedAt + DAEMON_HELLO_TIMEOUT_MS;
|
|
544
569
|
if (!Number.isFinite(connectedAt) || deadline <= now) {
|
|
545
|
-
|
|
546
|
-
role: "expired",
|
|
547
|
-
connectedAt: attachment?.connectedAt ?? new Date(0).toISOString(),
|
|
548
|
-
} satisfies DaemonAttachment);
|
|
570
|
+
this.daemonRegistry.expire(socket);
|
|
549
571
|
sendWebSocketQuietly(socket, { type: "error", error: "daemon_hello_timeout" });
|
|
550
572
|
closeWebSocketQuietly(socket, 1008, "daemon hello timeout");
|
|
551
573
|
continue;
|
|
552
574
|
}
|
|
553
575
|
nextDeadline = Math.min(nextDeadline, deadline);
|
|
554
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
|
+
}
|
|
555
595
|
if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(nextDeadline);
|
|
556
596
|
else await this.ctx.storage.deleteAlarm();
|
|
557
597
|
}
|
|
558
598
|
|
|
559
|
-
private async
|
|
599
|
+
private async scheduleSocketAlarms(): Promise<void> {
|
|
560
600
|
let nextDeadline = Number.POSITIVE_INFINITY;
|
|
561
|
-
for (const socket of this.candidateSockets()) {
|
|
562
|
-
const attachment = this.
|
|
601
|
+
for (const socket of this.daemonRegistry.candidateSockets()) {
|
|
602
|
+
const attachment = this.daemonRegistry.attachment(socket);
|
|
563
603
|
const connectedAt = Date.parse(attachment?.connectedAt ?? "");
|
|
564
604
|
if (!Number.isFinite(connectedAt)) {
|
|
565
605
|
closeWebSocketQuietly(socket, 1008, "invalid daemon candidate timestamp");
|
|
@@ -567,19 +607,42 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
567
607
|
}
|
|
568
608
|
nextDeadline = Math.min(nextDeadline, connectedAt + DAEMON_HELLO_TIMEOUT_MS);
|
|
569
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
|
+
}
|
|
570
628
|
if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(Math.max(Date.now(), nextDeadline));
|
|
571
629
|
else await this.ctx.storage.deleteAlarm();
|
|
572
630
|
}
|
|
573
631
|
|
|
574
632
|
private daemonStatus(detail: boolean): Record<string, unknown> {
|
|
575
|
-
|
|
576
|
-
const
|
|
633
|
+
this.reclaimStaleDaemonSockets();
|
|
634
|
+
const sockets = this.daemonRegistry.readySockets();
|
|
635
|
+
const attachment = sockets[0] ? this.daemonRegistry.readyAttachment(sockets[0]) : undefined;
|
|
577
636
|
const tools = attachment?.tools ?? [];
|
|
578
637
|
const base = {
|
|
579
638
|
connected: sockets.length > 0,
|
|
580
639
|
count: sockets.length,
|
|
581
640
|
tool_count: tools.length,
|
|
582
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,
|
|
583
646
|
};
|
|
584
647
|
if (!detail) return base;
|
|
585
648
|
return {
|
|
@@ -713,13 +776,6 @@ function textToolResult(value: unknown, isError = false): Record<string, unknown
|
|
|
713
776
|
return result;
|
|
714
777
|
}
|
|
715
778
|
|
|
716
|
-
function isFreshDaemonCandidate(connectedAt: string): boolean {
|
|
717
|
-
const timestamp = Date.parse(connectedAt);
|
|
718
|
-
if (!Number.isFinite(timestamp)) return false;
|
|
719
|
-
const age = Date.now() - timestamp;
|
|
720
|
-
return age >= 0 && age <= DAEMON_HELLO_TIMEOUT_MS;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
779
|
function asObject(value: unknown): Record<string, unknown> {
|
|
724
780
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
725
781
|
return value as Record<string, unknown>;
|
|
@@ -6,7 +6,7 @@ export class WorkerObservability {
|
|
|
6
6
|
private readonly startedAt = performance.now();
|
|
7
7
|
private readonly requests = { total: 0, successful: 0, client_error: 0, server_error: 0 };
|
|
8
8
|
private readonly calls = { started: 0, completed: 0, failed: 0, cancelled: 0, timed_out: 0, unmatched_results: 0 };
|
|
9
|
-
private readonly sockets = { candidates: 0, authenticated: 0, disconnected: 0, protocol_errors: 0 };
|
|
9
|
+
private readonly sockets = { candidates: 0, authenticated: 0, ready: 0, disconnected: 0, protocol_errors: 0 };
|
|
10
10
|
private readonly errors = new Map<string, number>();
|
|
11
11
|
private readonly tools = new Map<string, { started: number; completed: number; failed: number; active: number }>();
|
|
12
12
|
|
|
@@ -43,6 +43,7 @@ export class WorkerObservability {
|
|
|
43
43
|
|
|
44
44
|
socketCandidate(): void { this.sockets.candidates += 1; }
|
|
45
45
|
socketAuthenticated(): void { this.sockets.authenticated += 1; }
|
|
46
|
+
socketReady(): void { this.sockets.ready += 1; }
|
|
46
47
|
socketDisconnected(): void { this.sockets.disconnected += 1; }
|
|
47
48
|
socketProtocolError(code: string): void {
|
|
48
49
|
this.sockets.protocol_errors += 1;
|