clawmatrix 0.4.1 → 0.5.0
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/README.md +17 -21
- package/cli/bin/clawmatrix.mjs +300 -1
- package/package.json +8 -1
- package/src/acp-proxy.ts +122 -50
- package/src/{web.ts → api.ts} +646 -25
- package/src/audit.ts +37 -2
- package/src/auth.ts +5 -10
- package/src/automation.ts +625 -0
- package/src/cluster-service.ts +172 -16
- package/src/compat.ts +103 -0
- package/src/config.ts +75 -27
- package/src/connection.ts +215 -37
- package/src/crypto.ts +72 -5
- package/src/device-info.ts +21 -2
- package/src/file-transfer.ts +3 -2
- package/src/handoff.ts +90 -32
- package/src/health-tracker.ts +91 -356
- package/src/index.ts +421 -13
- package/src/kanban.ts +507 -0
- package/src/knowledge-sync.ts +158 -7
- package/src/local-tools.ts +65 -2
- package/src/log-replication.ts +198 -0
- package/src/model-proxy.ts +152 -60
- package/src/peer-approval.ts +3 -2
- package/src/peer-manager.ts +236 -44
- package/src/retry.ts +81 -0
- package/src/router.ts +152 -104
- package/src/sentinel.ts +85 -51
- package/src/store.ts +578 -0
- package/src/terminal.ts +17 -8
- package/src/tool-proxy.ts +6 -5
- package/src/tools/cluster-events.ts +6 -6
- package/src/tools/cluster-kanban.ts +345 -0
- package/src/tools/cluster-peers.ts +1 -1
- package/src/tools/cluster-query.ts +145 -0
- package/src/types.ts +95 -9
package/src/handoff.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { PeerManager } from "./peer-manager.ts";
|
|
2
2
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
3
3
|
import type { GatewayInfo } from "./tool-proxy.ts";
|
|
4
|
+
import type { Store } from "./store.ts";
|
|
5
|
+
import type { LogReplicator } from "./log-replication.ts";
|
|
4
6
|
import type {
|
|
5
7
|
HandoffRequest,
|
|
6
8
|
HandoffResponse,
|
|
@@ -12,6 +14,7 @@ import type {
|
|
|
12
14
|
HandoffInput,
|
|
13
15
|
HandoffStatus,
|
|
14
16
|
} from "./types.ts";
|
|
17
|
+
import { nanoid } from "nanoid";
|
|
15
18
|
import { TaskActivityBroadcaster } from "./task-activity.ts";
|
|
16
19
|
|
|
17
20
|
const DEFAULT_HANDOFF_TIMEOUT = 600_000; // 10 minutes (resets on each stream chunk)
|
|
@@ -30,6 +33,7 @@ interface PendingHandoff {
|
|
|
30
33
|
context?: string;
|
|
31
34
|
accumulated: string;
|
|
32
35
|
onStream?: (delta: string) => void;
|
|
36
|
+
excludeNodes: Set<string>;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
interface ActiveHandoff {
|
|
@@ -51,6 +55,10 @@ export class HandoffManager {
|
|
|
51
55
|
private inputRequiredTargets = new Map<string, string>(); // handoffId → targetNodeId
|
|
52
56
|
private staleCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
53
57
|
private taskActivity: TaskActivityBroadcaster;
|
|
58
|
+
/** Optional SQLite store for handoff history persistence. */
|
|
59
|
+
store: Store | null = null;
|
|
60
|
+
/** Optional log replicator for cross-node sync. */
|
|
61
|
+
logReplicator: LogReplicator | null = null;
|
|
54
62
|
// Multi-device sync: track which nodes are watching each handoff session (by sessionId)
|
|
55
63
|
private sessionWatchers = new Map<string, Set<string>>();
|
|
56
64
|
|
|
@@ -92,6 +100,27 @@ export class HandoffManager {
|
|
|
92
100
|
watchers.add(nodeId);
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
/** Record a completed/failed handoff to SQLite for history tracking. */
|
|
104
|
+
private recordHistory(taskId: string, from: string, agent: string, startedAt: number, status: string, error?: string) {
|
|
105
|
+
if (!this.store) return;
|
|
106
|
+
try {
|
|
107
|
+
this.store.insertHandoff({
|
|
108
|
+
nodeId: this.config.nodeId,
|
|
109
|
+
ts: Date.now(),
|
|
110
|
+
taskId,
|
|
111
|
+
fromNode: from,
|
|
112
|
+
toNode: this.config.nodeId,
|
|
113
|
+
agent,
|
|
114
|
+
duration: Date.now() - startedAt,
|
|
115
|
+
status,
|
|
116
|
+
error,
|
|
117
|
+
});
|
|
118
|
+
this.logReplicator?.notifyLocalInsert("handoff_history");
|
|
119
|
+
} catch {
|
|
120
|
+
// Non-fatal
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
95
124
|
/** Send a handoff frame to all session watchers except the specified node. */
|
|
96
125
|
private sendToOtherWatchers(sessionId: string, exclude: string, frame: HandoffStreamChunk | HandoffResponse | HandoffInputRequired) {
|
|
97
126
|
const watchers = this.sessionWatchers.get(sessionId);
|
|
@@ -148,7 +177,7 @@ export class HandoffManager {
|
|
|
148
177
|
): Promise<HandoffResponse["payload"]> {
|
|
149
178
|
if (options?.nodeId) {
|
|
150
179
|
// Direct node targeting (e.g. from web UI) — skip router resolution
|
|
151
|
-
return this.sendHandoff(options.nodeId, target, task, context, 0, options.onStream);
|
|
180
|
+
return this.sendHandoff(options.nodeId, target, task, context, 0, options.onStream, new Set());
|
|
152
181
|
}
|
|
153
182
|
|
|
154
183
|
const route = this.peerManager.router.resolveAgent(target);
|
|
@@ -160,7 +189,7 @@ export class HandoffManager {
|
|
|
160
189
|
throw new Error(`Target "${target}" resolves to self (${this.config.nodeId}). Cannot handoff to own node.`);
|
|
161
190
|
}
|
|
162
191
|
|
|
163
|
-
return this.sendHandoff(route.nodeId, target, task, context, MAX_RETRIES, options?.onStream);
|
|
192
|
+
return this.sendHandoff(route.nodeId, target, task, context, MAX_RETRIES, options?.onStream, new Set());
|
|
164
193
|
}
|
|
165
194
|
|
|
166
195
|
private sendHandoff(
|
|
@@ -170,13 +199,14 @@ export class HandoffManager {
|
|
|
170
199
|
context: string | undefined,
|
|
171
200
|
retriesLeft: number,
|
|
172
201
|
onStream?: (delta: string) => void,
|
|
202
|
+
excludeNodes: Set<string> = new Set(),
|
|
173
203
|
): Promise<HandoffResponse["payload"]> {
|
|
174
|
-
const id =
|
|
204
|
+
const id = nanoid();
|
|
175
205
|
|
|
176
206
|
return new Promise<HandoffResponse["payload"]>((resolve, reject) => {
|
|
177
|
-
const timer = this.createTimeout(id, targetNodeId, target, task, context, retriesLeft, resolve, reject, onStream);
|
|
207
|
+
const timer = this.createTimeout(id, targetNodeId, target, task, context, retriesLeft, resolve, reject, onStream, excludeNodes);
|
|
178
208
|
|
|
179
|
-
this.pending.set(id, { resolve, reject, timer, target, targetNodeId, retriesLeft, task, context, accumulated: "", onStream });
|
|
209
|
+
this.pending.set(id, { resolve, reject, timer, target, targetNodeId, retriesLeft, task, context, accumulated: "", onStream, excludeNodes });
|
|
180
210
|
|
|
181
211
|
const frame: HandoffRequest = {
|
|
182
212
|
type: "handoff_req",
|
|
@@ -192,11 +222,12 @@ export class HandoffManager {
|
|
|
192
222
|
this.pending.delete(id);
|
|
193
223
|
clearTimeout(timer);
|
|
194
224
|
|
|
195
|
-
// Retry immediately with another node
|
|
225
|
+
// Retry immediately with another node, excluding the failed one
|
|
196
226
|
if (retriesLeft > 0) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
227
|
+
excludeNodes.add(targetNodeId);
|
|
228
|
+
const nextRoute = this.peerManager.router.resolveAgent(target, excludeNodes);
|
|
229
|
+
if (nextRoute) {
|
|
230
|
+
this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1, onStream, excludeNodes)
|
|
200
231
|
.then(resolve)
|
|
201
232
|
.catch(reject);
|
|
202
233
|
return;
|
|
@@ -217,16 +248,18 @@ export class HandoffManager {
|
|
|
217
248
|
resolve: (result: HandoffResponse["payload"]) => void,
|
|
218
249
|
reject: (error: Error) => void,
|
|
219
250
|
onStream?: (delta: string) => void,
|
|
251
|
+
excludeNodes: Set<string> = new Set(),
|
|
220
252
|
): ReturnType<typeof setTimeout> {
|
|
221
253
|
return setTimeout(() => {
|
|
222
254
|
this.pending.delete(id);
|
|
223
255
|
this.peerManager.router.markFailed(id);
|
|
224
256
|
|
|
225
|
-
// Retry with failover
|
|
257
|
+
// Retry with failover, excluding the timed-out node
|
|
226
258
|
if (retriesLeft > 0) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
259
|
+
excludeNodes.add(targetNodeId);
|
|
260
|
+
const nextRoute = this.peerManager.router.resolveAgent(target, excludeNodes);
|
|
261
|
+
if (nextRoute) {
|
|
262
|
+
this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1, onStream, excludeNodes)
|
|
230
263
|
.then(resolve)
|
|
231
264
|
.catch(reject);
|
|
232
265
|
return;
|
|
@@ -262,6 +295,7 @@ export class HandoffManager {
|
|
|
262
295
|
pending.resolve,
|
|
263
296
|
pending.reject,
|
|
264
297
|
pending.onStream,
|
|
298
|
+
pending.excludeNodes,
|
|
265
299
|
);
|
|
266
300
|
}
|
|
267
301
|
|
|
@@ -310,6 +344,7 @@ export class HandoffManager {
|
|
|
310
344
|
// avoiding repeated Session Startup (bootstrap file loading).
|
|
311
345
|
const sessionId = payload.sessionId ?? `handoff-${from}`;
|
|
312
346
|
const sessionKey = `agent:${agent.id}:clawmatrix-handoff:${sessionId}`;
|
|
347
|
+
|
|
313
348
|
const message = payload.context
|
|
314
349
|
? `${payload.task}\n\nContext:\n${payload.context}`
|
|
315
350
|
: payload.task;
|
|
@@ -423,6 +458,9 @@ export class HandoffManager {
|
|
|
423
458
|
this.active.delete(id);
|
|
424
459
|
this.sessionWatchers.delete(activeEntry.sessionId);
|
|
425
460
|
|
|
461
|
+
// Record handoff history
|
|
462
|
+
this.recordHistory(id, from, agent, activeEntry.startedAt, "completed");
|
|
463
|
+
|
|
426
464
|
// Broadcast task completed
|
|
427
465
|
this.taskActivity.broadcast(id, "handoff", "completed", agent, activeEntry.startedAt);
|
|
428
466
|
|
|
@@ -452,10 +490,14 @@ export class HandoffManager {
|
|
|
452
490
|
this.active.delete(id);
|
|
453
491
|
this.sessionWatchers.delete(activeEntry.sessionId);
|
|
454
492
|
|
|
493
|
+
// Record handoff history
|
|
494
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
495
|
+
this.recordHistory(id, from, agent, activeEntry.startedAt, "failed", errorMsg);
|
|
496
|
+
|
|
455
497
|
// Broadcast task failed
|
|
456
498
|
this.taskActivity.broadcast(
|
|
457
499
|
id, "handoff", "failed", agent, activeEntry.startedAt,
|
|
458
|
-
|
|
500
|
+
errorMsg,
|
|
459
501
|
);
|
|
460
502
|
|
|
461
503
|
this.peerManager.sendTo(from, {
|
|
@@ -491,6 +533,35 @@ export class HandoffManager {
|
|
|
491
533
|
// Cache active entry lookup outside the hot loop — stable during the stream
|
|
492
534
|
const activeEntry = this.active.get(handoffId);
|
|
493
535
|
|
|
536
|
+
// Stream coalescing: micro-batch deltas to reduce per-frame overhead
|
|
537
|
+
let pendingDelta = "";
|
|
538
|
+
const COALESCE_INTERVAL = 10; // ms
|
|
539
|
+
const COALESCE_MAX_SIZE = 4096; // bytes
|
|
540
|
+
|
|
541
|
+
const flush = () => {
|
|
542
|
+
if (!pendingDelta) return;
|
|
543
|
+
const delta = pendingDelta;
|
|
544
|
+
pendingDelta = "";
|
|
545
|
+
const streamFrame: HandoffStreamChunk = {
|
|
546
|
+
type: "handoff_stream",
|
|
547
|
+
id: handoffId,
|
|
548
|
+
from: this.config.nodeId,
|
|
549
|
+
to,
|
|
550
|
+
timestamp: Date.now(),
|
|
551
|
+
payload: { delta, done: false, sessionId },
|
|
552
|
+
};
|
|
553
|
+
this.peerManager.sendTo(to, streamFrame);
|
|
554
|
+
if (sessionId) this.sendToOtherWatchers(sessionId, to, streamFrame);
|
|
555
|
+
// Broadcast progress to mobile nodes (throttled inside broadcaster already)
|
|
556
|
+
if (activeEntry) {
|
|
557
|
+
this.taskActivity.broadcast(
|
|
558
|
+
handoffId, "handoff", "progress", activeEntry.agent, activeEntry.startedAt,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const coalesceTimer = setInterval(flush, COALESCE_INTERVAL);
|
|
564
|
+
|
|
494
565
|
try {
|
|
495
566
|
while (true) {
|
|
496
567
|
const { done, value } = await reader.read();
|
|
@@ -510,24 +581,8 @@ export class HandoffManager {
|
|
|
510
581
|
const delta = parsed.choices?.[0]?.delta?.content;
|
|
511
582
|
if (delta) {
|
|
512
583
|
chunks.push(delta);
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
id: handoffId,
|
|
516
|
-
from: this.config.nodeId,
|
|
517
|
-
to,
|
|
518
|
-
timestamp: Date.now(),
|
|
519
|
-
payload: { delta, done: false, sessionId },
|
|
520
|
-
};
|
|
521
|
-
this.peerManager.sendTo(to, streamFrame);
|
|
522
|
-
if (sessionId) this.sendToOtherWatchers(sessionId, to, streamFrame);
|
|
523
|
-
|
|
524
|
-
// Broadcast progress to mobile nodes (throttled, detail is just
|
|
525
|
-
// a heartbeat — don't send token-level deltas as they're meaningless fragments)
|
|
526
|
-
if (activeEntry) {
|
|
527
|
-
this.taskActivity.broadcast(
|
|
528
|
-
handoffId, "handoff", "progress", activeEntry.agent, activeEntry.startedAt,
|
|
529
|
-
);
|
|
530
|
-
}
|
|
584
|
+
pendingDelta += delta;
|
|
585
|
+
if (pendingDelta.length >= COALESCE_MAX_SIZE) flush();
|
|
531
586
|
}
|
|
532
587
|
} catch {
|
|
533
588
|
// skip malformed SSE lines
|
|
@@ -535,6 +590,8 @@ export class HandoffManager {
|
|
|
535
590
|
}
|
|
536
591
|
}
|
|
537
592
|
} finally {
|
|
593
|
+
clearInterval(coalesceTimer);
|
|
594
|
+
flush();
|
|
538
595
|
reader.releaseLock();
|
|
539
596
|
}
|
|
540
597
|
|
|
@@ -590,6 +647,7 @@ export class HandoffManager {
|
|
|
590
647
|
resolve, reject, timer,
|
|
591
648
|
target: "", targetNodeId,
|
|
592
649
|
retriesLeft: 0, task: message, accumulated: "",
|
|
650
|
+
excludeNodes: new Set(),
|
|
593
651
|
});
|
|
594
652
|
|
|
595
653
|
this.peerManager.sendTo(targetNodeId, {
|