@botcord/daemon 0.2.75 → 0.2.76
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/dist/cloud-auth.d.ts +47 -0
- package/dist/cloud-auth.js +51 -0
- package/dist/cloud-daemon.d.ts +43 -0
- package/dist/cloud-daemon.js +252 -0
- package/dist/cloud-mode.d.ts +45 -0
- package/dist/cloud-mode.js +55 -0
- package/dist/cloud-settle.d.ts +81 -0
- package/dist/cloud-settle.js +100 -0
- package/dist/daemon-singleton.d.ts +26 -0
- package/dist/daemon-singleton.js +91 -0
- package/dist/daemon.d.ts +1 -1
- package/dist/daemon.js +15 -6
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +15 -4
- package/dist/gateway/channels/botcord.d.ts +1 -1
- package/dist/gateway/channels/botcord.js +48 -5
- package/dist/gateway/dispatcher.d.ts +34 -1
- package/dist/gateway/dispatcher.js +277 -20
- package/dist/gateway/gateway.d.ts +9 -1
- package/dist/gateway/gateway.js +4 -1
- package/dist/gateway/runtime-errors.d.ts +6 -0
- package/dist/gateway/runtime-errors.js +14 -0
- package/dist/gateway/runtimes/claude-code.d.ts +8 -0
- package/dist/gateway/runtimes/claude-code.js +92 -4
- package/dist/gateway/runtimes/deepseek-tui.js +19 -5
- package/dist/gateway/transcript.d.ts +1 -1
- package/dist/gateway/types.d.ts +33 -0
- package/dist/index.js +71 -80
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +39 -1
- package/dist/status-render.js +17 -0
- package/package.json +2 -2
- package/src/__tests__/cloud-auth.test.ts +42 -0
- package/src/__tests__/cloud-daemon.test.ts +237 -0
- package/src/__tests__/cloud-mode.test.ts +65 -0
- package/src/__tests__/cloud-settle.test.ts +287 -0
- package/src/__tests__/daemon-singleton.test.ts +89 -0
- package/src/__tests__/doctor.test.ts +34 -0
- package/src/__tests__/runtime-discovery.test.ts +90 -0
- package/src/__tests__/status-render.test.ts +34 -0
- package/src/cloud-auth.ts +78 -0
- package/src/cloud-daemon.ts +338 -0
- package/src/cloud-mode.ts +70 -0
- package/src/cloud-settle.ts +182 -0
- package/src/daemon-singleton.ts +122 -0
- package/src/daemon.ts +18 -5
- package/src/doctor.ts +18 -5
- package/src/gateway/__tests__/botcord-channel.test.ts +74 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +101 -1
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +19 -0
- package/src/gateway/__tests__/dispatcher.test.ts +120 -0
- package/src/gateway/channels/botcord.ts +54 -7
- package/src/gateway/dispatcher.ts +354 -21
- package/src/gateway/gateway.ts +16 -1
- package/src/gateway/runtime-errors.ts +15 -0
- package/src/gateway/runtimes/claude-code.ts +98 -2
- package/src/gateway/runtimes/deepseek-tui.ts +23 -5
- package/src/gateway/transcript.ts +1 -1
- package/src/gateway/types.ts +34 -0
- package/src/index.ts +83 -74
- package/src/provision.ts +45 -1
- package/src/status-render.ts +24 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface SingletonLogger {
|
|
2
|
+
info(message: string, meta?: Record<string, unknown>): void;
|
|
3
|
+
warn(message: string, meta?: Record<string, unknown>): void;
|
|
4
|
+
}
|
|
5
|
+
export declare function readPid(pidPath?: string): number | null;
|
|
6
|
+
export declare function pidAlive(pid: number): boolean;
|
|
7
|
+
export declare function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean>;
|
|
8
|
+
export declare function stopExistingDaemonForRestart(pid: number, opts?: {
|
|
9
|
+
pidPath?: string;
|
|
10
|
+
currentPid?: number;
|
|
11
|
+
logger?: SingletonLogger;
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
export declare function stopDaemonFromPidFileForRestart(opts?: {
|
|
14
|
+
pidPath?: string;
|
|
15
|
+
currentPid?: number;
|
|
16
|
+
logger?: SingletonLogger;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
export declare function ensureNoOtherDaemonFromPidFile(opts?: {
|
|
19
|
+
pidPath?: string;
|
|
20
|
+
currentPid?: number;
|
|
21
|
+
}): number | null;
|
|
22
|
+
export declare function writeCurrentPid(opts?: {
|
|
23
|
+
pidPath?: string;
|
|
24
|
+
currentPid?: number;
|
|
25
|
+
}): void;
|
|
26
|
+
export declare function removePidFile(pidPath?: string): void;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { PID_PATH } from "./config.js";
|
|
3
|
+
const noopLogger = {
|
|
4
|
+
info() {
|
|
5
|
+
// noop
|
|
6
|
+
},
|
|
7
|
+
warn() {
|
|
8
|
+
// noop
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
export function readPid(pidPath = PID_PATH) {
|
|
12
|
+
if (!existsSync(pidPath))
|
|
13
|
+
return null;
|
|
14
|
+
const raw = readFileSync(pidPath, "utf8").trim();
|
|
15
|
+
const pid = Number(raw);
|
|
16
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
17
|
+
}
|
|
18
|
+
export function pidAlive(pid) {
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, 0);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function waitForPidExit(pid, timeoutMs) {
|
|
28
|
+
const deadline = Date.now() + timeoutMs;
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
if (!pidAlive(pid))
|
|
31
|
+
return true;
|
|
32
|
+
await delay(100);
|
|
33
|
+
}
|
|
34
|
+
return !pidAlive(pid);
|
|
35
|
+
}
|
|
36
|
+
export async function stopExistingDaemonForRestart(pid, opts = {}) {
|
|
37
|
+
const pidPath = opts.pidPath ?? PID_PATH;
|
|
38
|
+
const currentPid = opts.currentPid ?? process.pid;
|
|
39
|
+
const logger = opts.logger ?? noopLogger;
|
|
40
|
+
if (pid === currentPid)
|
|
41
|
+
return;
|
|
42
|
+
logger.info("existing daemon found; restarting", { pid });
|
|
43
|
+
try {
|
|
44
|
+
process.kill(pid, "SIGTERM");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
removePidFile(pidPath);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!(await waitForPidExit(pid, 5_000))) {
|
|
51
|
+
logger.warn("existing daemon did not stop after SIGTERM; sending SIGKILL", { pid });
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, "SIGKILL");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
await waitForPidExit(pid, 2_000);
|
|
59
|
+
}
|
|
60
|
+
removePidFile(pidPath);
|
|
61
|
+
}
|
|
62
|
+
export async function stopDaemonFromPidFileForRestart(opts = {}) {
|
|
63
|
+
const pidPath = opts.pidPath ?? PID_PATH;
|
|
64
|
+
const existing = readPid(pidPath);
|
|
65
|
+
if (existing && pidAlive(existing)) {
|
|
66
|
+
await stopExistingDaemonForRestart(existing, opts);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function ensureNoOtherDaemonFromPidFile(opts = {}) {
|
|
70
|
+
const pidPath = opts.pidPath ?? PID_PATH;
|
|
71
|
+
const currentPid = opts.currentPid ?? process.pid;
|
|
72
|
+
const existing = readPid(pidPath);
|
|
73
|
+
if (existing && existing !== currentPid && pidAlive(existing)) {
|
|
74
|
+
return existing;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
export function writeCurrentPid(opts = {}) {
|
|
79
|
+
writeFileSync(opts.pidPath ?? PID_PATH, String(opts.currentPid ?? process.pid), { mode: 0o600 });
|
|
80
|
+
}
|
|
81
|
+
export function removePidFile(pidPath = PID_PATH) {
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(pidPath);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// ignore
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function delay(ms) {
|
|
90
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
91
|
+
}
|
package/dist/daemon.d.ts
CHANGED
|
@@ -64,7 +64,7 @@ export interface RuntimeSnapshotSink {
|
|
|
64
64
|
* failure is non-fatal (the Hub will re-query via `list_runtimes` on demand
|
|
65
65
|
* or wait for the next daemon restart). Exported for unit tests.
|
|
66
66
|
*/
|
|
67
|
-
export declare function pushRuntimeSnapshot(sink: RuntimeSnapshotSink): boolean;
|
|
67
|
+
export declare function pushRuntimeSnapshot(sink: RuntimeSnapshotSink, liveSnapshot?: GatewayRuntimeSnapshot): boolean;
|
|
68
68
|
/** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
|
|
69
69
|
export interface DaemonRuntimeOptions {
|
|
70
70
|
config: DaemonConfig;
|
package/dist/daemon.js
CHANGED
|
@@ -7,7 +7,7 @@ import { ensureAgentWorkspace } from "./agent-workspace.js";
|
|
|
7
7
|
import { ControlChannel } from "./control-channel.js";
|
|
8
8
|
import { toGatewayConfig } from "./daemon-config-map.js";
|
|
9
9
|
import { log as daemonLog } from "./log.js";
|
|
10
|
-
import { adoptDiscoveredOpenclawAgents, collectRuntimeSnapshot, createProvisioner, } from "./provision.js";
|
|
10
|
+
import { adoptDiscoveredOpenclawAgents, attachRuntimeHealth, collectRuntimeSnapshot, createProvisioner, } from "./provision.js";
|
|
11
11
|
import { openclawAutoProvisionEnabled } from "./openclaw-discovery.js";
|
|
12
12
|
import { SnapshotWriter } from "./snapshot-writer.js";
|
|
13
13
|
import { createDaemonSystemContextBuilder } from "./system-context.js";
|
|
@@ -146,8 +146,9 @@ export function createDaemonChannel(chCfg, deps) {
|
|
|
146
146
|
* failure is non-fatal (the Hub will re-query via `list_runtimes` on demand
|
|
147
147
|
* or wait for the next daemon restart). Exported for unit tests.
|
|
148
148
|
*/
|
|
149
|
-
export function pushRuntimeSnapshot(sink) {
|
|
150
|
-
const
|
|
149
|
+
export function pushRuntimeSnapshot(sink, liveSnapshot) {
|
|
150
|
+
const base = collectRuntimeSnapshot();
|
|
151
|
+
const snap = liveSnapshot ? attachRuntimeHealth(base, liveSnapshot) : base;
|
|
151
152
|
const ok = sink.send({
|
|
152
153
|
id: `rt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
153
154
|
type: CONTROL_FRAME_TYPES.RUNTIME_SNAPSHOT,
|
|
@@ -353,7 +354,15 @@ export async function startDaemon(opts) {
|
|
|
353
354
|
}));
|
|
354
355
|
}
|
|
355
356
|
};
|
|
356
|
-
|
|
357
|
+
let controlChannel = null;
|
|
358
|
+
let gateway;
|
|
359
|
+
const pushLiveRuntimeSnapshot = () => {
|
|
360
|
+
if (!controlChannel)
|
|
361
|
+
return;
|
|
362
|
+
const pushed = pushRuntimeSnapshot(controlChannel, gateway.snapshot());
|
|
363
|
+
logger.info("control-channel: live runtime_snapshot push", { ok: pushed });
|
|
364
|
+
};
|
|
365
|
+
gateway = new Gateway({
|
|
357
366
|
config: gwConfig,
|
|
358
367
|
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
359
368
|
createChannel: (chCfg) => createDaemonChannel(chCfg, {
|
|
@@ -367,6 +376,7 @@ export async function startDaemon(opts) {
|
|
|
367
376
|
buildMemoryContext,
|
|
368
377
|
onInbound,
|
|
369
378
|
onOutbound,
|
|
379
|
+
onRuntimeCircuitBreakerChange: pushLiveRuntimeSnapshot,
|
|
370
380
|
composeUserTurn: composeBotCordUserTurn,
|
|
371
381
|
attentionGate,
|
|
372
382
|
resolveHubUrl,
|
|
@@ -416,7 +426,6 @@ export async function startDaemon(opts) {
|
|
|
416
426
|
// Control channel is optional — daemon still runs (data-plane only)
|
|
417
427
|
// when user-auth hasn't been set up yet. Operators can `login` later
|
|
418
428
|
// without restarting, but for P0 we require a restart to pick it up.
|
|
419
|
-
let controlChannel = null;
|
|
420
429
|
if (userAuth?.current && !opts.disableControlChannel) {
|
|
421
430
|
logger.info("control-channel: enabling", {
|
|
422
431
|
userId: userAuth.current.userId,
|
|
@@ -455,7 +464,7 @@ export async function startDaemon(opts) {
|
|
|
455
464
|
// Plan §8.5 P0 — push one runtime snapshot immediately after connect
|
|
456
465
|
// so Hub's `daemon_instances.runtimes_json` is populated for the
|
|
457
466
|
// dashboard even before any user action. No periodic refresh in P0.
|
|
458
|
-
const pushed = pushRuntimeSnapshot(controlChannel);
|
|
467
|
+
const pushed = pushRuntimeSnapshot(controlChannel, gateway.snapshot());
|
|
459
468
|
logger.info("control-channel: initial runtime_snapshot push", {
|
|
460
469
|
ok: pushed,
|
|
461
470
|
});
|
package/dist/doctor.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RuntimeProbeEntry } from "./adapters/runtimes.js";
|
|
2
2
|
import type { DaemonConfig } from "./config.js";
|
|
3
|
+
import { type ClaudeAuthProbeResult } from "./gateway/runtimes/claude-code.js";
|
|
3
4
|
/** Summary of a single channel's readiness, printable by the doctor command. */
|
|
4
5
|
export interface ChannelProbeResult {
|
|
5
6
|
id: string;
|
|
@@ -50,6 +51,7 @@ export interface DoctorRuntimeEndpoint {
|
|
|
50
51
|
/** Augmented runtime entry that may carry endpoint probe results. */
|
|
51
52
|
export interface DoctorRuntimeEntry extends RuntimeProbeEntry {
|
|
52
53
|
endpoints?: DoctorRuntimeEndpoint[];
|
|
54
|
+
auth?: ClaudeAuthProbeResult;
|
|
53
55
|
}
|
|
54
56
|
/** Input for the rendered doctor output. */
|
|
55
57
|
export interface DoctorInput {
|
|
@@ -107,9 +109,10 @@ export declare function renderDoctor(input: DoctorInput): string;
|
|
|
107
109
|
* Thin orchestrator: runs runtime + channel probes and returns the rendered
|
|
108
110
|
* text. Keeps `index.ts` free of probe wiring.
|
|
109
111
|
*/
|
|
110
|
-
export declare function runDoctor(runtimes:
|
|
112
|
+
export declare function runDoctor(runtimes: DoctorRuntimeEntry[], channels: ChannelProbeConfig[], opts: {
|
|
111
113
|
credentialsPath: (accountId: string) => string;
|
|
112
114
|
fileReader: DoctorFileReader;
|
|
113
115
|
fetcher: DoctorHttpFetcher;
|
|
114
116
|
timeoutMs?: number;
|
|
117
|
+
authCheck?: boolean;
|
|
115
118
|
}): Promise<DoctorInput>;
|
package/dist/doctor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveBootAgents } from "./agent-discovery.js";
|
|
2
|
+
import { probeClaudeAuth } from "./gateway/runtimes/claude-code.js";
|
|
2
3
|
/**
|
|
3
4
|
* Build the implicit channel list for a daemon config. One channel per
|
|
4
5
|
* configured or discovered agent, keyed by agentId (matches
|
|
@@ -74,12 +75,11 @@ export async function probeChannel(ch, opts) {
|
|
|
74
75
|
result.hubMessage = "skipped (no hub URL)";
|
|
75
76
|
return result;
|
|
76
77
|
}
|
|
77
|
-
// Probe `/` — the hub is ASGI and
|
|
78
|
-
//
|
|
79
|
-
// through to hubOk=false.
|
|
78
|
+
// Probe `/` — the hub is ASGI and may answer 404 when the host is still
|
|
79
|
+
// reachable. Network errors fall through to hubOk=false.
|
|
80
80
|
const probeUrl = `${result.hubUrl.replace(/\/+$/, "")}/`;
|
|
81
81
|
const http = await opts.fetcher(probeUrl, opts.timeoutMs);
|
|
82
|
-
if (http.ok) {
|
|
82
|
+
if (http.ok || http.status === 404) {
|
|
83
83
|
result.hubOk = true;
|
|
84
84
|
result.hubMessage = `reachable (HTTP ${http.status})`;
|
|
85
85
|
}
|
|
@@ -159,6 +159,10 @@ export function renderDoctor(input) {
|
|
|
159
159
|
if (!e.result.available && e.installHint) {
|
|
160
160
|
lines.push(` → ${e.installHint}`);
|
|
161
161
|
}
|
|
162
|
+
if (e.auth) {
|
|
163
|
+
const authStatus = e.auth.checked ? (e.auth.ok ? "ok" : "failed") : "skipped";
|
|
164
|
+
lines.push(` auth ${authStatus}: ${e.auth.message}`);
|
|
165
|
+
}
|
|
162
166
|
if (e.endpoints && e.endpoints.length > 0) {
|
|
163
167
|
for (const ep of e.endpoints) {
|
|
164
168
|
const mark = ep.reachable ? "✓" : "✗";
|
|
@@ -204,6 +208,13 @@ export function renderDoctor(input) {
|
|
|
204
208
|
* text. Keeps `index.ts` free of probe wiring.
|
|
205
209
|
*/
|
|
206
210
|
export async function runDoctor(runtimes, channels, opts) {
|
|
211
|
+
if (opts.authCheck) {
|
|
212
|
+
for (const runtime of runtimes) {
|
|
213
|
+
if (runtime.id === "claude-code" && runtime.result.available) {
|
|
214
|
+
runtime.auth = probeClaudeAuth();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
207
218
|
const channelResults = await probeChannels({
|
|
208
219
|
channels,
|
|
209
220
|
credentialsPath: opts.credentialsPath,
|
|
@@ -51,7 +51,7 @@ export interface BotCordChannelOptions {
|
|
|
51
51
|
credentialsPath?: string;
|
|
52
52
|
/** Override the Hub base URL. Defaults to the `hubUrl` stored in credentials. */
|
|
53
53
|
hubBaseUrl?: string;
|
|
54
|
-
/**
|
|
54
|
+
/** Periodic inbox polling fallback. Set to 0 to disable. Defaults to 30s. */
|
|
55
55
|
pollIntervalMs?: number;
|
|
56
56
|
/** Test hook: supply a pre-built client instead of loading credentials from disk. */
|
|
57
57
|
client?: BotCordChannelClient;
|
|
@@ -59,6 +59,10 @@ function isOwnerTrust(msg) {
|
|
|
59
59
|
return true;
|
|
60
60
|
if (msg.source_type === "dashboard_user_chat")
|
|
61
61
|
return true;
|
|
62
|
+
// Cloud Agent run tasks are Hub-issued on the user's behalf, same
|
|
63
|
+
// trust posture as owner chat.
|
|
64
|
+
if (msg.source_type === "cloud_agent_run")
|
|
65
|
+
return true;
|
|
62
66
|
return false;
|
|
63
67
|
}
|
|
64
68
|
/**
|
|
@@ -89,11 +93,16 @@ function normalizeInbox(msg, options) {
|
|
|
89
93
|
return null;
|
|
90
94
|
// `message` is the normal conversational envelope; `contact_request` is
|
|
91
95
|
// a lightweight inbound asking the agent to notify its owner (the
|
|
92
|
-
// composer appends the notify-owner hint)
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
96
|
+
// composer appends the notify-owner hint); `cloud_run` carries a
|
|
97
|
+
// Cloud Agent run task with embedded run_id + budget (the cloud
|
|
98
|
+
// daemon's runtime adapter reads them from `raw.envelope.payload.cloud_run`
|
|
99
|
+
// and reports usage back via /internal/cloud-agents/.../settle when the
|
|
100
|
+
// run completes). All other envelope types (notification, system,
|
|
101
|
+
// contact_added/removed, …) are still filtered out — they belong in
|
|
102
|
+
// a separate push-notification path that daemon does not yet implement.
|
|
103
|
+
if (env.type !== "message" &&
|
|
104
|
+
env.type !== "contact_request" &&
|
|
105
|
+
env.type !== "cloud_run")
|
|
97
106
|
return null;
|
|
98
107
|
if (!msg.room_id)
|
|
99
108
|
return null;
|
|
@@ -348,6 +357,7 @@ export function createBotCordChannel(options) {
|
|
|
348
357
|
let ws = null;
|
|
349
358
|
let reconnectTimer = null;
|
|
350
359
|
let keepaliveTimer = null;
|
|
360
|
+
let pollTimer = null;
|
|
351
361
|
let reconnectAttempt = 0;
|
|
352
362
|
let connectionSeq = 0;
|
|
353
363
|
let consecutiveAuthFailures = 0;
|
|
@@ -371,6 +381,10 @@ export function createBotCordChannel(options) {
|
|
|
371
381
|
clearInterval(keepaliveTimer);
|
|
372
382
|
keepaliveTimer = null;
|
|
373
383
|
}
|
|
384
|
+
if (pollTimer) {
|
|
385
|
+
clearInterval(pollTimer);
|
|
386
|
+
pollTimer = null;
|
|
387
|
+
}
|
|
374
388
|
}
|
|
375
389
|
function markStatus(patch) {
|
|
376
390
|
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
@@ -574,6 +588,17 @@ export function createBotCordChannel(options) {
|
|
|
574
588
|
});
|
|
575
589
|
log.info("botcord ws authenticated", { agentId: msg.agent_id });
|
|
576
590
|
void fireInbox("ws_auth_ok");
|
|
591
|
+
const pollIntervalMs = options.pollIntervalMs ?? 30_000;
|
|
592
|
+
if (pollTimer)
|
|
593
|
+
clearInterval(pollTimer);
|
|
594
|
+
if (pollIntervalMs > 0) {
|
|
595
|
+
pollTimer = setInterval(() => {
|
|
596
|
+
if (ws === socket && socket.readyState === WebSocket.OPEN) {
|
|
597
|
+
void fireInbox("poll_interval");
|
|
598
|
+
}
|
|
599
|
+
}, pollIntervalMs);
|
|
600
|
+
pollTimer.unref?.();
|
|
601
|
+
}
|
|
577
602
|
if (keepaliveTimer)
|
|
578
603
|
clearInterval(keepaliveTimer);
|
|
579
604
|
keepaliveTimer = setInterval(() => {
|
|
@@ -912,6 +937,15 @@ function normalizeBlockForHub(block, seq) {
|
|
|
912
937
|
return { kind: "thinking", seq, payload };
|
|
913
938
|
}
|
|
914
939
|
// "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
|
|
940
|
+
if (isTerminalRuntimeBlock(raw)) {
|
|
941
|
+
payload.terminal = true;
|
|
942
|
+
payload.details = formatBlockDetails(raw);
|
|
943
|
+
const event = typeof raw?.event === "string" ? raw.event : undefined;
|
|
944
|
+
const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
|
|
945
|
+
if (event || embedded)
|
|
946
|
+
payload.event = event ?? embedded;
|
|
947
|
+
return { kind: "other", seq, payload };
|
|
948
|
+
}
|
|
915
949
|
if (raw?.type === "result") {
|
|
916
950
|
if (typeof raw.result === "string")
|
|
917
951
|
payload.text = raw.result;
|
|
@@ -922,6 +956,15 @@ function normalizeBlockForHub(block, seq) {
|
|
|
922
956
|
}
|
|
923
957
|
return { kind: "other", seq, payload };
|
|
924
958
|
}
|
|
959
|
+
function isTerminalRuntimeBlock(raw) {
|
|
960
|
+
const event = typeof raw?.event === "string" ? raw.event : undefined;
|
|
961
|
+
const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
|
|
962
|
+
const terminal = event ?? embedded;
|
|
963
|
+
return (terminal === "turn.completed" ||
|
|
964
|
+
terminal === "turn.finished" ||
|
|
965
|
+
terminal === "turn.done" ||
|
|
966
|
+
terminal === "done");
|
|
967
|
+
}
|
|
925
968
|
function formatBlockDetails(raw) {
|
|
926
969
|
if (!raw || typeof raw !== "object")
|
|
927
970
|
return "";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GatewayLogger } from "./log.js";
|
|
2
2
|
import { type SessionStore } from "./session-store.js";
|
|
3
3
|
import { type TranscriptWriter } from "./transcript.js";
|
|
4
|
-
import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
|
|
4
|
+
import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, RuntimeRunResult, RuntimeCircuitBreakerSnapshot, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
|
|
5
5
|
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
6
6
|
export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
|
|
7
7
|
/** Constructor options for `Dispatcher`. */
|
|
@@ -12,6 +12,8 @@ export interface DispatcherOptions {
|
|
|
12
12
|
sessionStore: SessionStore;
|
|
13
13
|
log: GatewayLogger;
|
|
14
14
|
turnTimeoutMs?: number;
|
|
15
|
+
runtimeAuthFailureThreshold?: number;
|
|
16
|
+
runtimeAuthFailureCooldownMs?: number;
|
|
15
17
|
/**
|
|
16
18
|
* Live reference to the Gateway's managed-route map. Dispatcher reads
|
|
17
19
|
* `values()` on every `resolveRoute` call so hot-add/remove take effect
|
|
@@ -50,6 +52,24 @@ export interface DispatcherOptions {
|
|
|
50
52
|
* and suppressed so observer failures never break the turn.
|
|
51
53
|
*/
|
|
52
54
|
onOutbound?: OutboundObserver;
|
|
55
|
+
onRuntimeCircuitBreakerChange?: () => void;
|
|
56
|
+
/**
|
|
57
|
+
* Optional observer fired exactly once per turn after ``runtime.run``
|
|
58
|
+
* resolves (or throws / times out). Receives the inbound message, the
|
|
59
|
+
* raw runtime result (may be undefined on throw), the elapsed wall
|
|
60
|
+
* time in milliseconds, and any thrown error. The cloud daemon hooks
|
|
61
|
+
* this to settle ``cloud_run`` envelopes against the Hub's usage
|
|
62
|
+
* ledger; local daemons leave it unset.
|
|
63
|
+
*
|
|
64
|
+
* Errors thrown by the observer are logged and swallowed — settle
|
|
65
|
+
* failures must never break the agent reply path.
|
|
66
|
+
*/
|
|
67
|
+
onTurnComplete?: (event: {
|
|
68
|
+
message: GatewayInboundMessage;
|
|
69
|
+
result?: RuntimeRunResult;
|
|
70
|
+
wallTimeMs: number;
|
|
71
|
+
error?: unknown;
|
|
72
|
+
}) => Promise<void> | void;
|
|
53
73
|
/**
|
|
54
74
|
* Optional attention gate (PR3, design §4.2). Resolved AFTER `onInbound`
|
|
55
75
|
* runs and BEFORE the runtime turn enqueues, so working memory / activity
|
|
@@ -92,10 +112,14 @@ export declare class Dispatcher {
|
|
|
92
112
|
private readonly sessionStore;
|
|
93
113
|
private readonly log;
|
|
94
114
|
private readonly turnTimeoutMs;
|
|
115
|
+
private readonly runtimeAuthFailureThreshold;
|
|
116
|
+
private readonly runtimeAuthFailureCooldownMs;
|
|
95
117
|
private readonly buildSystemContext?;
|
|
96
118
|
private readonly buildMemoryContext?;
|
|
97
119
|
private readonly onInbound?;
|
|
98
120
|
private readonly onOutbound?;
|
|
121
|
+
private readonly onTurnComplete?;
|
|
122
|
+
private readonly onRuntimeCircuitBreakerChange?;
|
|
99
123
|
private readonly composeUserTurn?;
|
|
100
124
|
private readonly managedRoutes?;
|
|
101
125
|
private readonly attentionGate?;
|
|
@@ -103,6 +127,7 @@ export declare class Dispatcher {
|
|
|
103
127
|
private readonly transcript;
|
|
104
128
|
private readonly queues;
|
|
105
129
|
private readonly deferredMultimodal;
|
|
130
|
+
private readonly runtimeAuthFailures;
|
|
106
131
|
/**
|
|
107
132
|
* Last `/hub/typing` ping timestamp per (accountId, conversationId).
|
|
108
133
|
* Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
|
|
@@ -114,10 +139,18 @@ export declare class Dispatcher {
|
|
|
114
139
|
handle(envelope: GatewayInboundEnvelope): Promise<void>;
|
|
115
140
|
/** Snapshot of currently running turns keyed by queue key. */
|
|
116
141
|
turns(): Record<string, TurnStatusSnapshot>;
|
|
142
|
+
runtimeCircuitBreakers(): Record<string, RuntimeCircuitBreakerSnapshot>;
|
|
117
143
|
private safeAck;
|
|
118
144
|
private getQueue;
|
|
119
145
|
private deferMultimodal;
|
|
120
146
|
private takeDeferredMultimodal;
|
|
147
|
+
private runtimeAuthBreakerKey;
|
|
148
|
+
private openRuntimeAuthBreaker;
|
|
149
|
+
private pruneExpiredRuntimeAuthBreakers;
|
|
150
|
+
private recordRuntimeAuthFailure;
|
|
151
|
+
private clearRuntimeAuthFailures;
|
|
152
|
+
private notifyRuntimeCircuitBreakerChange;
|
|
153
|
+
private skipRuntimeForAuthBreaker;
|
|
121
154
|
private runCancelPrevious;
|
|
122
155
|
/**
|
|
123
156
|
* Serial mode with coalesce-on-drain semantics:
|