clawmatrix 0.2.7 → 0.2.9
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/package.json +1 -1
- package/src/acp-proxy.ts +574 -207
- package/src/cluster-service.ts +24 -2
- package/src/compat.ts +36 -1
- package/src/handoff.ts +10 -3
- package/src/health-tracker.ts +581 -0
- package/src/model-proxy.ts +16 -1
- package/src/peer-manager.ts +61 -38
- package/src/router.ts +41 -0
- package/src/sentinel.ts +29 -7
- package/src/types.ts +10 -1
- package/src/web.ts +33 -0
package/src/cluster-service.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { AcpProxy, readAllSessionStoresFromDisk } from "./acp-proxy.ts";
|
|
|
16
16
|
import { TerminalManager } from "./terminal.ts";
|
|
17
17
|
import { WebHandler } from "./web.ts";
|
|
18
18
|
import { KnowledgeSync } from "./knowledge-sync.ts";
|
|
19
|
+
import { HealthTracker } from "./health-tracker.ts";
|
|
19
20
|
import { SentinelManager } from "./sentinel-manager.ts";
|
|
20
21
|
import type {
|
|
21
22
|
AnyClusterFrame,
|
|
@@ -28,6 +29,7 @@ import type {
|
|
|
28
29
|
HandoffInputRequired,
|
|
29
30
|
HandoffInput,
|
|
30
31
|
KnowledgeSyncFrame,
|
|
32
|
+
HealthSyncFrame,
|
|
31
33
|
ModelRequest,
|
|
32
34
|
ModelResponse,
|
|
33
35
|
ModelStreamChunk,
|
|
@@ -85,6 +87,7 @@ export class ClusterRuntime {
|
|
|
85
87
|
readonly acpProxy: AcpProxy | null;
|
|
86
88
|
readonly terminalManager: TerminalManager;
|
|
87
89
|
knowledgeSync: KnowledgeSync | null = null;
|
|
90
|
+
healthTracker: HealthTracker;
|
|
88
91
|
webHandler: WebHandler | null = null;
|
|
89
92
|
private sentinelManager: SentinelManager | null = null;
|
|
90
93
|
private logger: PluginLogger;
|
|
@@ -104,6 +107,10 @@ export class ClusterRuntime {
|
|
|
104
107
|
const acpEnabled = config.acp?.enabled || (openclawConfig as Record<string, any>).acp?.enabled;
|
|
105
108
|
this.acpProxy = acpEnabled ? new AcpProxy(config, this.peerManager, openclawConfig as Record<string, unknown>, gatewayInfo) : null;
|
|
106
109
|
this.terminalManager = new TerminalManager(config, this.peerManager);
|
|
110
|
+
this.healthTracker = new HealthTracker({
|
|
111
|
+
nodeId: config.nodeId,
|
|
112
|
+
peerManager: this.peerManager,
|
|
113
|
+
});
|
|
107
114
|
}
|
|
108
115
|
|
|
109
116
|
start() {
|
|
@@ -115,11 +122,15 @@ export class ClusterRuntime {
|
|
|
115
122
|
this.peerManager.on("peerConnected", (nodeId) => {
|
|
116
123
|
this.logger.info(`[clawmatrix] Peer connected: ${nodeId}`);
|
|
117
124
|
this.refreshDiscoveredModels();
|
|
125
|
+
this.healthTracker.recordPeerOnline(nodeId, "direct");
|
|
126
|
+
this.healthTracker.initPeerSync(nodeId);
|
|
118
127
|
});
|
|
119
128
|
|
|
120
129
|
this.peerManager.on("peerDisconnected", (nodeId) => {
|
|
121
130
|
this.logger.info(`[clawmatrix] Peer disconnected: ${nodeId}`);
|
|
122
131
|
this.refreshDiscoveredModels();
|
|
132
|
+
this.healthTracker.recordPeerOffline(nodeId);
|
|
133
|
+
this.healthTracker.removePeerSync(nodeId);
|
|
123
134
|
});
|
|
124
135
|
|
|
125
136
|
this.peerManager.on("peerCapabilitiesChanged", () => {
|
|
@@ -132,6 +143,7 @@ export class ClusterRuntime {
|
|
|
132
143
|
this.peerManager.setHttpHandler((req, res) => this.webHandler!.handle(req, res));
|
|
133
144
|
// Enable satellite tool routing through WebHandler
|
|
134
145
|
this.toolProxy.setSatelliteHandler(this.webHandler);
|
|
146
|
+
this.webHandler.setHealthTracker(this.healthTracker);
|
|
135
147
|
this.logger.info(`[clawmatrix] Web dashboard enabled on listen port`);
|
|
136
148
|
}
|
|
137
149
|
|
|
@@ -166,8 +178,9 @@ export class ClusterRuntime {
|
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
// Auto-detect ACP agents if ACP is enabled but no agents are explicitly configured
|
|
169
|
-
|
|
170
|
-
|
|
181
|
+
// Check both ClawMatrix and OpenClaw configs (consistent with acpProxy creation above)
|
|
182
|
+
if (this.acpProxy && (!this.config.acp?.agents || this.config.acp.agents.length === 0)) {
|
|
183
|
+
AcpProxy.detectAvailableAgents(this.config.acp?.commands).then((detected) => {
|
|
171
184
|
if (detected.length > 0) {
|
|
172
185
|
this.logger.info(`[clawmatrix] Auto-detected ACP agents: ${detected.map((a) => a.id).join(", ")}`);
|
|
173
186
|
this.peerManager.updateAcpAgents(detected);
|
|
@@ -177,6 +190,11 @@ export class ClusterRuntime {
|
|
|
177
190
|
});
|
|
178
191
|
}
|
|
179
192
|
|
|
193
|
+
// Health tracker (always enabled, no config gate)
|
|
194
|
+
this.healthTracker.start().catch((err) => {
|
|
195
|
+
this.logger.error(`[clawmatrix] Health tracker failed to start: ${err}`);
|
|
196
|
+
});
|
|
197
|
+
|
|
180
198
|
// Start subsystems
|
|
181
199
|
this.peerManager.start();
|
|
182
200
|
this.modelProxy.start();
|
|
@@ -213,6 +231,7 @@ export class ClusterRuntime {
|
|
|
213
231
|
// NOTE: intentionally do NOT stop sentinel here.
|
|
214
232
|
// Sentinel must survive gateway shutdown — that's its entire purpose.
|
|
215
233
|
// It will be replaced by killOldSentinel() on next gateway start.
|
|
234
|
+
await this.healthTracker.stop();
|
|
216
235
|
await this.knowledgeSync?.stop();
|
|
217
236
|
this.webHandler?.destroy();
|
|
218
237
|
this.handoffManager.destroy();
|
|
@@ -316,6 +335,9 @@ export class ClusterRuntime {
|
|
|
316
335
|
this.logger.error(`[clawmatrix] Knowledge sync error: ${err}`);
|
|
317
336
|
});
|
|
318
337
|
break;
|
|
338
|
+
case "health_sync":
|
|
339
|
+
this.healthTracker.handleSyncMessage(frame as HealthSyncFrame);
|
|
340
|
+
break;
|
|
319
341
|
case "acp_req":
|
|
320
342
|
if (this.acpProxy) {
|
|
321
343
|
this.acpProxy.handleRequest(frame as AcpTaskRequest).catch((err) => {
|
package/src/compat.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { spawn as cpSpawn } from "node:child_process";
|
|
8
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { open, readFile, stat, writeFile } from "node:fs/promises";
|
|
9
9
|
import { createRequire } from "node:module";
|
|
10
10
|
|
|
11
11
|
export interface SpawnResult {
|
|
@@ -167,6 +167,41 @@ export async function readFileText(path: string): Promise<string> {
|
|
|
167
167
|
return readFile(path, "utf-8");
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/** Read at most `bytes` bytes from the beginning of a file as text. */
|
|
171
|
+
export async function readFileHead(path: string, bytes: number): Promise<string> {
|
|
172
|
+
const fh = await open(path, "r");
|
|
173
|
+
try {
|
|
174
|
+
const buf = Buffer.alloc(bytes);
|
|
175
|
+
const { bytesRead } = await fh.read(buf, 0, bytes, 0);
|
|
176
|
+
return buf.toString("utf-8", 0, bytesRead);
|
|
177
|
+
} finally {
|
|
178
|
+
await fh.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Read at most `bytes` bytes from the end of a file as text. */
|
|
183
|
+
export async function readFileTail(path: string, bytes: number): Promise<string> {
|
|
184
|
+
const info = await stat(path);
|
|
185
|
+
const size = info.size;
|
|
186
|
+
if (size === 0) return "";
|
|
187
|
+
const readBytes = Math.min(bytes, size);
|
|
188
|
+
const offset = size - readBytes;
|
|
189
|
+
const fh = await open(path, "r");
|
|
190
|
+
try {
|
|
191
|
+
const buf = Buffer.alloc(readBytes);
|
|
192
|
+
const { bytesRead } = await fh.read(buf, 0, readBytes, offset);
|
|
193
|
+
const text = buf.toString("utf-8", 0, bytesRead);
|
|
194
|
+
// Drop the first (possibly partial) line if we didn't read from start
|
|
195
|
+
if (offset > 0) {
|
|
196
|
+
const nl = text.indexOf("\n");
|
|
197
|
+
return nl >= 0 ? text.slice(nl + 1) : text;
|
|
198
|
+
}
|
|
199
|
+
return text;
|
|
200
|
+
} finally {
|
|
201
|
+
await fh.close();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
170
205
|
/** Write text to a file (replaces Bun.write()). */
|
|
171
206
|
export async function writeFileText(path: string, content: string): Promise<void> {
|
|
172
207
|
await writeFile(path, content, "utf-8");
|
package/src/handoff.ts
CHANGED
|
@@ -91,6 +91,7 @@ export class HandoffManager {
|
|
|
91
91
|
for (const [id, entry] of this.active) {
|
|
92
92
|
if (entry.status === "input_required" && entry.inputRequiredAt && now - entry.inputRequiredAt > INPUT_REQUIRED_TTL) {
|
|
93
93
|
this.active.delete(id);
|
|
94
|
+
this.sessionWatchers.delete(entry.sessionId);
|
|
94
95
|
// Notify the requester that the handoff timed out
|
|
95
96
|
this.peerManager.sendTo(entry.from, {
|
|
96
97
|
type: "handoff_res",
|
|
@@ -110,6 +111,7 @@ export class HandoffManager {
|
|
|
110
111
|
// (e.g. cancel arrived during input_required when no process was running)
|
|
111
112
|
if (entry.status === "canceled") {
|
|
112
113
|
this.active.delete(id);
|
|
114
|
+
this.sessionWatchers.delete(entry.sessionId);
|
|
113
115
|
}
|
|
114
116
|
}
|
|
115
117
|
// Clean stale inputRequiredTargets (requester side)
|
|
@@ -355,6 +357,7 @@ export class HandoffManager {
|
|
|
355
357
|
|
|
356
358
|
if (activeEntry.status === "canceled") {
|
|
357
359
|
this.active.delete(id);
|
|
360
|
+
this.sessionWatchers.delete(activeEntry.sessionId);
|
|
358
361
|
return;
|
|
359
362
|
}
|
|
360
363
|
|
|
@@ -401,11 +404,13 @@ export class HandoffManager {
|
|
|
401
404
|
|
|
402
405
|
if (activeEntry.status === "canceled") {
|
|
403
406
|
this.active.delete(id);
|
|
407
|
+
this.sessionWatchers.delete(activeEntry.sessionId);
|
|
404
408
|
return;
|
|
405
409
|
}
|
|
406
410
|
|
|
407
411
|
activeEntry.status = "completed";
|
|
408
412
|
this.active.delete(id);
|
|
413
|
+
this.sessionWatchers.delete(activeEntry.sessionId);
|
|
409
414
|
|
|
410
415
|
// Broadcast task completed
|
|
411
416
|
this.taskActivity.broadcast(id, "handoff", "completed", agent, activeEntry.startedAt);
|
|
@@ -429,10 +434,12 @@ export class HandoffManager {
|
|
|
429
434
|
} catch (err) {
|
|
430
435
|
if (activeEntry.status === "canceled") {
|
|
431
436
|
this.active.delete(id);
|
|
437
|
+
this.sessionWatchers.delete(activeEntry.sessionId);
|
|
432
438
|
return;
|
|
433
439
|
}
|
|
434
440
|
activeEntry.status = "failed";
|
|
435
441
|
this.active.delete(id);
|
|
442
|
+
this.sessionWatchers.delete(activeEntry.sessionId);
|
|
436
443
|
|
|
437
444
|
// Broadcast task failed
|
|
438
445
|
this.taskActivity.broadcast(
|
|
@@ -468,7 +475,7 @@ export class HandoffManager {
|
|
|
468
475
|
|
|
469
476
|
const reader = body.getReader();
|
|
470
477
|
const decoder = new TextDecoder();
|
|
471
|
-
|
|
478
|
+
const chunks: string[] = [];
|
|
472
479
|
let buffer = "";
|
|
473
480
|
|
|
474
481
|
try {
|
|
@@ -489,7 +496,7 @@ export class HandoffManager {
|
|
|
489
496
|
const parsed = JSON.parse(data);
|
|
490
497
|
const delta = parsed.choices?.[0]?.delta?.content;
|
|
491
498
|
if (delta) {
|
|
492
|
-
|
|
499
|
+
chunks.push(delta);
|
|
493
500
|
const streamFrame: HandoffStreamChunk = {
|
|
494
501
|
type: "handoff_stream",
|
|
495
502
|
id: handoffId,
|
|
@@ -531,7 +538,7 @@ export class HandoffManager {
|
|
|
531
538
|
this.peerManager.sendTo(to, doneFrame);
|
|
532
539
|
if (sessionId) this.sendToOtherWatchers(sessionId, to, doneFrame);
|
|
533
540
|
|
|
534
|
-
return
|
|
541
|
+
return chunks.join("");
|
|
535
542
|
}
|
|
536
543
|
|
|
537
544
|
/** Handle incoming input_required from remote (requester side). */
|