clawmatrix 0.4.2 → 0.5.1

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/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 = crypto.randomUUID();
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
- const nextRoute = this.peerManager.router.resolveAgent(target);
198
- if (nextRoute && nextRoute.nodeId !== targetNodeId) {
199
- this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1, onStream)
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
- const nextRoute = this.peerManager.router.resolveAgent(target);
228
- if (nextRoute && nextRoute.nodeId !== targetNodeId) {
229
- this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1, onStream)
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
- err instanceof Error ? err.message : String(err),
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
- const streamFrame: HandoffStreamChunk = {
514
- type: "handoff_stream",
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, {