agent-comms 1.25.4 → 1.25.6
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/dist/core/mesh-store.d.ts +22 -0
- package/dist/core/mesh-store.d.ts.map +1 -1
- package/dist/core/mesh-store.js +202 -75
- package/dist/core/mesh-store.js.map +1 -1
- package/dist/core/store.d.ts.map +1 -1
- package/dist/core/store.js +7 -0
- package/dist/core/store.js.map +1 -1
- package/dist/core/types.d.ts +4 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +4 -0
- package/dist/core/types.js.map +1 -1
- package/dist/core/wire-protocol.d.ts +7 -0
- package/dist/core/wire-protocol.d.ts.map +1 -1
- package/dist/core/wire-protocol.js.map +1 -1
- package/dist/test/downtime-replay.integration.test.d.ts +7 -0
- package/dist/test/downtime-replay.integration.test.d.ts.map +1 -0
- package/dist/test/downtime-replay.integration.test.js +113 -0
- package/dist/test/downtime-replay.integration.test.js.map +1 -0
- package/dist/test/downtime-replay.test.d.ts +5 -0
- package/dist/test/downtime-replay.test.d.ts.map +1 -0
- package/dist/test/downtime-replay.test.js +100 -0
- package/dist/test/downtime-replay.test.js.map +1 -0
- package/dist/test/state-sync-convergence.test.d.ts +7 -0
- package/dist/test/state-sync-convergence.test.d.ts.map +1 -0
- package/dist/test/state-sync-convergence.test.js +140 -0
- package/dist/test/state-sync-convergence.test.js.map +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/ExaDev/agent-comms)
|
|
4
4
|
[](https://www.npmjs.com/package/agent-comms)
|
|
5
|
-
[](https://github.com/ExaDev/agent-comms/releases/tag/v1.25.6)
|
|
6
6
|
[](https://github.com/ExaDev/agent-comms/actions)
|
|
7
7
|
|
|
8
8
|
Cross-harness communication mesh for LLM agents: rooms, DMs, presence, and visibility over TCP with zero filesystem dependencies.
|
|
@@ -34,7 +34,7 @@ graph LR
|
|
|
34
34
|
B_Bridge -- "channel notification" --> B_LLM
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
All state is held in memory and synchronised between peers. Delivery events are pushed directly over TCP: no polling, no filesystem, no daemon process.
|
|
37
|
+
All state is held in memory and synchronised between peers. Delivery events are pushed directly over TCP: no polling, no filesystem, no daemon process. Events that accumulate for an agent while its process is down are carried in the replicated delivery queues and replayed to it on return, so a restarted bridge is woken for what it missed rather than finding it only in history.
|
|
38
38
|
|
|
39
39
|
### Coordinator pattern
|
|
40
40
|
|
|
@@ -55,6 +55,15 @@ export declare class MeshStore implements CommsStore {
|
|
|
55
55
|
private handlePeerJoined;
|
|
56
56
|
private handleIntroduction;
|
|
57
57
|
private handlePeerConnected;
|
|
58
|
+
/**
|
|
59
|
+
* Merge a peer's state snapshot into the local state. Agents and rooms
|
|
60
|
+
* accept the incoming copy when it carries a revision at least as high as
|
|
61
|
+
* the local one, so a peer holding stale entities converges when it
|
|
62
|
+
* receives a fresher snapshot, while its own stale copies are rejected by
|
|
63
|
+
* peers that stayed current (#27). Message and DM histories are
|
|
64
|
+
* append-only: add unseen entries and union read receipts.
|
|
65
|
+
*/
|
|
66
|
+
applyStateSync(state: SerialisedState): void;
|
|
58
67
|
private handleDataMessage;
|
|
59
68
|
private handleBecomeCoordinator;
|
|
60
69
|
private handlePeerDisconnected;
|
|
@@ -80,9 +89,22 @@ export declare class MeshStore implements CommsStore {
|
|
|
80
89
|
startDataServerOnly(): Promise<void>;
|
|
81
90
|
/** Returns the TransportEvents object that bridges should pass to the transport constructor. */
|
|
82
91
|
get events(): TransportEvents;
|
|
92
|
+
/** Append to a target agent's delivery queue, bounded oldest-first (#28). */
|
|
93
|
+
private queueDelivery;
|
|
94
|
+
/** Bump an entity's sync revision; call before broadcasting a local mutation. */
|
|
95
|
+
private bump;
|
|
83
96
|
private applyPatch;
|
|
84
97
|
private broadcastPatch;
|
|
85
98
|
private deliverLocallyAndBroadcast;
|
|
99
|
+
/**
|
|
100
|
+
* Fire onDelivery for an event targeting this peer's own agent, deduped
|
|
101
|
+
* against events already delivered in this process. Used both for live
|
|
102
|
+
* deliveries and for replays of events that accumulated while this
|
|
103
|
+
* process was down (#28): the dedup set is per-process, so a replayed
|
|
104
|
+
* event this process never saw fires, and one it already handled does
|
|
105
|
+
* not.
|
|
106
|
+
*/
|
|
107
|
+
private fireLocalDelivery;
|
|
86
108
|
private deliverToRoom;
|
|
87
109
|
private notifyRoomsOfStatus;
|
|
88
110
|
private notifyRoomsOfNameChange;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mesh-store.d.ts","sourceRoot":"","sources":["../../src/core/mesh-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAOH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAGlD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAe,cAAc,EAAY,MAAM,oBAAoB,CAAC;AAChF,OAAO,KAAK,EAEV,aAAa,EACb,eAAe,EAChB,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EACV,aAAa,EAEb,aAAa,EAEb,SAAS,EACT,cAAc,EACd,gBAAgB,EAChB,IAAI,EACJ,WAAW,EACX,QAAQ,EACR,iBAAiB,EACjB,UAAU,EACX,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"mesh-store.d.ts","sourceRoot":"","sources":["../../src/core/mesh-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAOH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAGlD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAe,cAAc,EAAY,MAAM,oBAAoB,CAAC;AAChF,OAAO,KAAK,EAEV,aAAa,EACb,eAAe,EAChB,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EACV,aAAa,EAEb,aAAa,EAEb,SAAS,EACT,cAAc,EACd,gBAAgB,EAChB,IAAI,EACJ,WAAW,EACX,QAAQ,EACR,iBAAiB,EACjB,UAAU,EACX,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,gBAAgB,CAAC;AA2CnE,qBAAa,SAAU,YAAW,UAAU;IAC1C,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IAEjC,OAAO,CAAC,MAAM,CAAoC;IAClD,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,QAAQ,CAAoC;IACpD,OAAO,CAAC,GAAG,CAAkC;IAC7C,OAAO,CAAC,cAAc,CAAsC;IAC5D,OAAO,CAAC,aAAa,CAAqC;IAE1D,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,QAAQ,CAA+B;IAC/C,OAAO,CAAC,eAAe,CAA6C;IACpE,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,qBAAqB,CAAuC;IAEpE,0DAA0D;IAC1D,IAAI,SAAS,IAAI,OAAO,CAIvB;IAGD,OAAO,CAAC,yBAAyB,CAG7B;IAEJ,UAAU,EACN,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,GACjE,SAAS,CAAC;IAEd,uEAAuE;IACvE,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,CAAC;IAEvE,6DAA6D;IAC7D,SAAS,IAAI,eAAe;IAU5B,OAAO,CAAC,oBAAoB,CAAqB;IACjD,OAAO,CAAC,iBAAiB,CAAqB;IAE9C,SAAS,EAAE,gBAAgB,CAAC;IAC5B,UAAU,EAAE,iBAAiB,CAAC;gBAElB,eAAe,GAAE,MAAiC;IAiC9D,gFAAgF;IAChF,YAAY,CAAC,SAAS,EAAE,aAAa,GAAG,IAAI;IAQtC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAiE3B,OAAO,CAAC,cAAc;IAOtB,OAAO,CAAC,gBAAgB;YAKV,kBAAkB;YA0BlB,mBAAmB;IAcjC;;;;;;;OAOG;IACH,cAAc,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI;YAwE9B,iBAAiB;YAWjB,uBAAuB;IAcrC,OAAO,CAAC,sBAAsB;IAQ9B,OAAO,CAAC,uBAAuB;IAgC/B,2CAA2C;IACrC,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAU3D,2CAA2C;IACrC,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAU3E,8DAA8D;IAC9D,sBAAsB,IAAI;QACxB,YAAY,EAAE,MAAM,CAAC;QACrB,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,EAAE,MAAM,CAAC;KACrB,EAAE;IAOH;;4EAEwE;IACxE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsB1D;kFAC8E;IACxE,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC;IAc1C,gGAAgG;IAChG,IAAI,MAAM,IAAI,eAAe,CA2B5B;IAED,6EAA6E;IAC7E,OAAO,CAAC,aAAa;IASrB,iFAAiF;IACjF,OAAO,CAAC,IAAI;YASE,UAAU;YA8GV,cAAc;YAOd,0BAA0B;IA0BxC;;;;;;;OAOG;IACH,OAAO,CAAC,iBAAiB;YAwCX,aAAa;YAab,mBAAmB;YAgBnB,uBAAuB;YAoBvB,kBAAkB;IAkBhC,OAAO,CAAC,iBAAiB;YAoBX,QAAQ;IAkCtB,OAAO,CAAC,gBAAgB;IA2BlB,YAAY,CAChB,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;IAKhC,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAStE,aAAa,CAAC,IAAI,EAAE;QACxB,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC;QACZ,UAAU,EAAE,UAAU,CAAC;QACvB,IAAI,EAAE,MAAM,EAAE,CAAC;KAChB,GAAG,OAAO,CAAC,aAAa,CAAC;IAqCpB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC;IAKxD,WAAW,CACf,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,CACZ,IAAI,CAAC,aAAa,EAAE,MAAM,GAAG,YAAY,GAAG,QAAQ,GAAG,MAAM,GAAG,KAAK,CAAC,CACvE,GACA,OAAO,CAAC,aAAa,CAAC;IAuBnB,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;IAUzD,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAuB1C,UAAU,CAAC,IAAI,EAAE;QACrB,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,QAAQ,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,CAAC,EAAE,OAAO,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC;IAwBX,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,SAAS,CAAC;IAK9C,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;IAW/C,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsExD,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmCzD,YAAY,CAChB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC;IAyBV,aAAa,CACjB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC;IA0BV,YAAY,CAChB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC;IAcV,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA2B3D,eAAe,CACnB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,MAAM,EAChB,iBAAiB,CAAC,EAAE,iBAAiB,GACpC,OAAO,CAAC,WAAW,CAAC;IAyCjB,gBAAgB,CACpB,MAAM,EAAE,MAAM,EACd,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC,WAAW,EAAE,CAAC;IAWnB,MAAM,CACV,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,MAAM,EACf,iBAAiB,CAAC,EAAE,iBAAiB,GACpC,OAAO,CAAC,SAAS,CAAC;IAkCf,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAI7D,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;IAqB9D,OAAO,CAAC,eAAe;IAOvB,OAAO,CAAC,cAAc;YAOR,gBAAgB;IAuC9B,OAAO,CAAC,cAAc;IAatB,qEAAqE;IAC/D,aAAa,CAAC,KAAK,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI3E,6CAA6C;IAC7C,aAAa,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,cAAc;IAQzC,WAAW,CACf,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,CAAC;IAYZ,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/C,aAAa,IAAI,YAAY,EAAE;IAI/B,oBAAoB,IAAI,gBAAgB,EAAE;IAqBpC,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAItE,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlD,QAAQ,IAAI,OAAO,EAAE;YAQP,qBAAqB;YAYrB,kBAAkB;YAalB,oBAAoB;YAqBpB,iBAAiB;YA8BjB,kBAAkB;IAoBhC,OAAO,CAAC,sBAAsB;IAY9B,OAAO,CAAC,iCAAiC;IAgBnC,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAuBhC"}
|
package/dist/core/mesh-store.js
CHANGED
|
@@ -24,6 +24,32 @@ import { FederationManager } from "./federation.js";
|
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
const DEFAULT_COORDINATOR_PORT = 19876;
|
|
26
26
|
const COORDINATOR_HOST = "127.0.0.1";
|
|
27
|
+
/**
|
|
28
|
+
* Bound on pending delivery events held per target agent. Events beyond the
|
|
29
|
+
* bound drop oldest-first: a long-offline agent's queue cannot grow without
|
|
30
|
+
* limit in memory or in synced snapshots (#28).
|
|
31
|
+
*/
|
|
32
|
+
const MAX_QUEUED_DELIVERIES_PER_AGENT = 100;
|
|
33
|
+
/**
|
|
34
|
+
* Merge an incoming append-only message history into the local one: add
|
|
35
|
+
* entries the local list does not have and union read receipts on the ones
|
|
36
|
+
* it does. Local ordering is preserved; unseen entries are appended.
|
|
37
|
+
*/
|
|
38
|
+
function mergeMessageHistories(local, incoming) {
|
|
39
|
+
const byId = new Map(local.map((m) => [m.id, m]));
|
|
40
|
+
for (const msg of incoming) {
|
|
41
|
+
const existing = byId.get(msg.id);
|
|
42
|
+
if (existing === undefined) {
|
|
43
|
+
local.push(msg);
|
|
44
|
+
byId.set(msg.id, msg);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const reader of msg.readBy) {
|
|
48
|
+
if (!existing.readBy.includes(reader))
|
|
49
|
+
existing.readBy.push(reader);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
27
53
|
// ---------------------------------------------------------------------------
|
|
28
54
|
// MeshStore
|
|
29
55
|
// ---------------------------------------------------------------------------
|
|
@@ -59,6 +85,7 @@ export class MeshStore {
|
|
|
59
85
|
rooms: Object.fromEntries(this.rooms),
|
|
60
86
|
messages: Object.fromEntries(this.messages),
|
|
61
87
|
dms: Object.fromEntries(this.dms),
|
|
88
|
+
deliveryQueues: Object.fromEntries(this.deliveryQueues),
|
|
62
89
|
};
|
|
63
90
|
}
|
|
64
91
|
lastLocalDeliveryKey;
|
|
@@ -175,44 +202,94 @@ export class MeshStore {
|
|
|
175
202
|
async handlePeerConnected(handle, _info) {
|
|
176
203
|
// If we have state and the peer doesn't, send state sync
|
|
177
204
|
if (this.agents.size > 0) {
|
|
178
|
-
const state =
|
|
179
|
-
agents: Object.fromEntries(this.agents),
|
|
180
|
-
rooms: Object.fromEntries(this.rooms),
|
|
181
|
-
messages: Object.fromEntries(this.messages),
|
|
182
|
-
dms: Object.fromEntries(this.dms),
|
|
183
|
-
};
|
|
205
|
+
const state = this.serialise();
|
|
184
206
|
await this.transport.send(handle, {
|
|
185
207
|
method: "state_sync",
|
|
186
208
|
state,
|
|
187
209
|
});
|
|
188
210
|
}
|
|
189
211
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
212
|
+
/**
|
|
213
|
+
* Merge a peer's state snapshot into the local state. Agents and rooms
|
|
214
|
+
* accept the incoming copy when it carries a revision at least as high as
|
|
215
|
+
* the local one, so a peer holding stale entities converges when it
|
|
216
|
+
* receives a fresher snapshot, while its own stale copies are rejected by
|
|
217
|
+
* peers that stayed current (#27). Message and DM histories are
|
|
218
|
+
* append-only: add unseen entries and union read receipts.
|
|
219
|
+
*/
|
|
220
|
+
applyStateSync(state) {
|
|
221
|
+
const incoming = {
|
|
222
|
+
agents: new Map(Object.entries(state.agents)),
|
|
223
|
+
rooms: new Map(Object.entries(state.rooms)),
|
|
224
|
+
messages: new Map(Object.entries(state.messages)),
|
|
225
|
+
dms: new Map(Object.entries(state.dms)),
|
|
226
|
+
};
|
|
227
|
+
{
|
|
199
228
|
for (const [id, agent] of incoming.agents) {
|
|
200
|
-
|
|
201
|
-
|
|
229
|
+
const existingVersion = this.agents.get(id)?.version;
|
|
230
|
+
if (existingVersion !== undefined && agent.version < existingVersion) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (existingVersion === agent.version) {
|
|
234
|
+
const existing = this.agents.get(id);
|
|
235
|
+
if (existing) {
|
|
236
|
+
for (const r of existing.subscribedRooms) {
|
|
237
|
+
if (!agent.subscribedRooms.includes(r))
|
|
238
|
+
agent.subscribedRooms.push(r);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
this.agents.set(id, agent);
|
|
202
243
|
}
|
|
203
244
|
for (const [id, room] of incoming.rooms) {
|
|
204
|
-
|
|
205
|
-
|
|
245
|
+
const existingVersion = this.rooms.get(id)?.version;
|
|
246
|
+
if (existingVersion !== undefined && room.version < existingVersion) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (existingVersion === room.version) {
|
|
250
|
+
const existing = this.rooms.get(id);
|
|
251
|
+
if (existing) {
|
|
252
|
+
for (const m of existing.members) {
|
|
253
|
+
if (!room.members.includes(m))
|
|
254
|
+
room.members.push(m);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
this.rooms.set(id, room);
|
|
206
259
|
}
|
|
207
260
|
for (const [id, msgs] of incoming.messages) {
|
|
208
|
-
|
|
261
|
+
const existing = this.messages.get(id);
|
|
262
|
+
if (existing === undefined) {
|
|
209
263
|
this.messages.set(id, msgs);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
mergeMessageHistories(existing, msgs);
|
|
210
267
|
}
|
|
211
268
|
for (const [id, dmMsgs] of incoming.dms) {
|
|
212
|
-
|
|
269
|
+
const existing = this.dms.get(id);
|
|
270
|
+
if (existing === undefined) {
|
|
213
271
|
this.dms.set(id, dmMsgs);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
mergeMessageHistories(existing, dmMsgs);
|
|
275
|
+
}
|
|
276
|
+
for (const [agentId, events] of Object.entries(state.deliveryQueues)) {
|
|
277
|
+
const seen = new Set((this.deliveryQueues.get(agentId) ?? []).map((e) => JSON.stringify(e)));
|
|
278
|
+
for (const event of events) {
|
|
279
|
+
if (seen.has(JSON.stringify(event)))
|
|
280
|
+
continue;
|
|
281
|
+
this.queueDelivery(agentId, event);
|
|
282
|
+
// A returning peer replays its own pending queue: events pushed
|
|
283
|
+
// while its process was down fire onDelivery now (#28).
|
|
284
|
+
this.fireLocalDelivery(agentId, event);
|
|
285
|
+
}
|
|
214
286
|
}
|
|
215
287
|
}
|
|
288
|
+
}
|
|
289
|
+
async handleDataMessage(handle, msg) {
|
|
290
|
+
if (msg.method === "state_sync") {
|
|
291
|
+
this.applyStateSync(msg.state);
|
|
292
|
+
}
|
|
216
293
|
else if (msg.method === "state_update") {
|
|
217
294
|
await this.applyPatch(msg.patch);
|
|
218
295
|
}
|
|
@@ -250,9 +327,7 @@ export class MeshStore {
|
|
|
250
327
|
name: request.name,
|
|
251
328
|
fingerprint: request.fingerprint,
|
|
252
329
|
};
|
|
253
|
-
|
|
254
|
-
arr.push(event);
|
|
255
|
-
this.deliveryQueues.set(this.peerId, arr);
|
|
330
|
+
this.queueDelivery(this.peerId, event);
|
|
256
331
|
if (this.onDelivery) {
|
|
257
332
|
void this.onDelivery(this.peerId, event);
|
|
258
333
|
}
|
|
@@ -343,25 +418,42 @@ export class MeshStore {
|
|
|
343
418
|
},
|
|
344
419
|
};
|
|
345
420
|
}
|
|
421
|
+
/** Append to a target agent's delivery queue, bounded oldest-first (#28). */
|
|
422
|
+
queueDelivery(agentId, event) {
|
|
423
|
+
const arr = this.deliveryQueues.get(agentId) ?? [];
|
|
424
|
+
arr.push(event);
|
|
425
|
+
if (arr.length > MAX_QUEUED_DELIVERIES_PER_AGENT) {
|
|
426
|
+
arr.splice(0, arr.length - MAX_QUEUED_DELIVERIES_PER_AGENT);
|
|
427
|
+
}
|
|
428
|
+
this.deliveryQueues.set(agentId, arr);
|
|
429
|
+
}
|
|
430
|
+
/** Bump an entity's sync revision; call before broadcasting a local mutation. */
|
|
431
|
+
bump(entity) {
|
|
432
|
+
entity.version += 1;
|
|
433
|
+
return entity;
|
|
434
|
+
}
|
|
346
435
|
// -----------------------------------------------------------------------
|
|
347
436
|
// State patch application
|
|
348
437
|
// -----------------------------------------------------------------------
|
|
349
438
|
async applyPatch(patch) {
|
|
350
439
|
switch (patch.type) {
|
|
351
440
|
case "agent_upsert": {
|
|
352
|
-
// Merge subscribedRooms to avoid losing local room memberships.
|
|
353
441
|
const existingAgent = this.agents.get(patch.agent.id);
|
|
354
|
-
if (existingAgent
|
|
355
|
-
|
|
442
|
+
if (existingAgent !== undefined &&
|
|
443
|
+
patch.agent.version < existingAgent.version) {
|
|
444
|
+
// Stale copy from a peer that missed updates (#27).
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
const merged = patch.agent;
|
|
448
|
+
if (existingAgent?.version === patch.agent.version) {
|
|
449
|
+
// Concurrent mutations from the same base: keep subscriptions
|
|
450
|
+
// gained locally. A strictly higher version replaces the record.
|
|
356
451
|
for (const r of existingAgent.subscribedRooms) {
|
|
357
452
|
if (!merged.subscribedRooms.includes(r))
|
|
358
453
|
merged.subscribedRooms.push(r);
|
|
359
454
|
}
|
|
360
|
-
this.agents.set(merged.id, merged);
|
|
361
|
-
}
|
|
362
|
-
else {
|
|
363
|
-
this.agents.set(patch.agent.id, patch.agent);
|
|
364
455
|
}
|
|
456
|
+
this.agents.set(merged.id, merged);
|
|
365
457
|
break;
|
|
366
458
|
}
|
|
367
459
|
case "agent_offline": {
|
|
@@ -373,20 +465,23 @@ export class MeshStore {
|
|
|
373
465
|
break;
|
|
374
466
|
}
|
|
375
467
|
case "room_upsert": {
|
|
376
|
-
// Merge members rather than overwriting — last-write-wins can lose
|
|
377
|
-
// members added locally when a remote patch arrives with a stale list.
|
|
378
468
|
const existing = this.rooms.get(patch.room.id);
|
|
379
|
-
if (existing) {
|
|
380
|
-
|
|
469
|
+
if (existing !== undefined && patch.room.version < existing.version) {
|
|
470
|
+
// Stale copy from a peer that missed updates (#27).
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
const merged = patch.room;
|
|
474
|
+
if (existing?.version === patch.room.version) {
|
|
475
|
+
// Concurrent mutations from the same base: union members so
|
|
476
|
+
// simultaneous joins both survive. A strictly higher version
|
|
477
|
+
// replaces the record wholesale, which is what heals a leave or
|
|
478
|
+
// edit a lagging peer missed.
|
|
381
479
|
for (const m of existing.members) {
|
|
382
480
|
if (!merged.members.includes(m))
|
|
383
481
|
merged.members.push(m);
|
|
384
482
|
}
|
|
385
|
-
this.rooms.set(merged.id, merged);
|
|
386
|
-
}
|
|
387
|
-
else {
|
|
388
|
-
this.rooms.set(patch.room.id, patch.room);
|
|
389
483
|
}
|
|
484
|
+
this.rooms.set(merged.id, merged);
|
|
390
485
|
break;
|
|
391
486
|
}
|
|
392
487
|
case "room_delete":
|
|
@@ -405,9 +500,7 @@ export class MeshStore {
|
|
|
405
500
|
break;
|
|
406
501
|
}
|
|
407
502
|
case "delivery": {
|
|
408
|
-
|
|
409
|
-
arr.push(patch.event);
|
|
410
|
-
this.deliveryQueues.set(patch.agentId, arr);
|
|
503
|
+
this.queueDelivery(patch.agentId, patch.event);
|
|
411
504
|
if (patch.agentId === this.peerId && this.onDelivery) {
|
|
412
505
|
// Deduplicate against local deliveries
|
|
413
506
|
const eventKey = JSON.stringify(patch.event);
|
|
@@ -461,9 +554,7 @@ export class MeshStore {
|
|
|
461
554
|
}
|
|
462
555
|
async deliverLocallyAndBroadcast(agentId, event) {
|
|
463
556
|
// Local delivery
|
|
464
|
-
|
|
465
|
-
arr.push(event);
|
|
466
|
-
this.deliveryQueues.set(agentId, arr);
|
|
557
|
+
this.queueDelivery(agentId, event);
|
|
467
558
|
// Auto-emit delivered status for messages
|
|
468
559
|
if (event.type === "room_message") {
|
|
469
560
|
await this.emitDeliveryStatus(event.message.id, agentId, "delivered", event.message.room);
|
|
@@ -471,40 +562,63 @@ export class MeshStore {
|
|
|
471
562
|
else if (event.type === "dm") {
|
|
472
563
|
await this.emitDeliveryStatus(event.message.id, agentId, "delivered");
|
|
473
564
|
}
|
|
474
|
-
|
|
475
|
-
// Deduplicate: skip if this exact event was already delivered locally.
|
|
476
|
-
// The mesh can echo delivery patches through multiple peer paths,
|
|
477
|
-
// causing applyPatch to fire onDelivery for the same event.
|
|
478
|
-
const eventKey = JSON.stringify(event);
|
|
479
|
-
if (this.localDeliveryKeys.has(eventKey))
|
|
480
|
-
return;
|
|
481
|
-
this.localDeliveryKeys.add(eventKey);
|
|
482
|
-
// Prevent unbounded growth — evict oldest when cap reached
|
|
483
|
-
if (this.localDeliveryKeys.size > 50) {
|
|
484
|
-
const oldest = this.localDeliveryKeys.values().next().value;
|
|
485
|
-
if (oldest !== undefined)
|
|
486
|
-
this.localDeliveryKeys.delete(oldest);
|
|
487
|
-
}
|
|
488
|
-
void this.onDelivery(agentId, event);
|
|
489
|
-
// Auto-mark read — scheduled as a macrotask to yield to the event
|
|
490
|
-
// loop (see matching comment in applyPatch for rationale).
|
|
491
|
-
const timer = setTimeout(() => {
|
|
492
|
-
if (this.isShutDown)
|
|
493
|
-
return;
|
|
494
|
-
if (event.type === "room_message") {
|
|
495
|
-
void this.markRead(event.message.id, agentId, event.message.room);
|
|
496
|
-
}
|
|
497
|
-
else if (event.type === "dm") {
|
|
498
|
-
void this.markRead(event.message.id, agentId);
|
|
499
|
-
}
|
|
500
|
-
}, 0);
|
|
501
|
-
if (!this.isShutDown)
|
|
502
|
-
this.pendingMarkReadTimers.push(timer);
|
|
503
|
-
}
|
|
565
|
+
this.fireLocalDelivery(agentId, event);
|
|
504
566
|
// Remote delivery
|
|
505
567
|
const patch = { type: "delivery", agentId, event };
|
|
506
568
|
await this.broadcastPatch(patch);
|
|
507
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* Fire onDelivery for an event targeting this peer's own agent, deduped
|
|
572
|
+
* against events already delivered in this process. Used both for live
|
|
573
|
+
* deliveries and for replays of events that accumulated while this
|
|
574
|
+
* process was down (#28): the dedup set is per-process, so a replayed
|
|
575
|
+
* event this process never saw fires, and one it already handled does
|
|
576
|
+
* not.
|
|
577
|
+
*/
|
|
578
|
+
fireLocalDelivery(agentId, event) {
|
|
579
|
+
if (agentId !== this.peerId || !this.onDelivery)
|
|
580
|
+
return;
|
|
581
|
+
// A room message or DM this agent has already read was already pushed
|
|
582
|
+
// and consumed: read receipts mutate the event between snapshots, so a
|
|
583
|
+
// plain structural key would miss and re-fire on the next sync (#28).
|
|
584
|
+
if ((event.type === "room_message" || event.type === "dm") &&
|
|
585
|
+
event.message.readBy.includes(agentId)) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const eventKey = JSON.stringify(event);
|
|
589
|
+
if (this.localDeliveryKeys.has(eventKey))
|
|
590
|
+
return;
|
|
591
|
+
this.localDeliveryKeys.add(eventKey);
|
|
592
|
+
// Prevent unbounded growth — evict oldest when cap reached
|
|
593
|
+
if (this.localDeliveryKeys.size > 50) {
|
|
594
|
+
const oldest = this.localDeliveryKeys.values().next().value;
|
|
595
|
+
if (oldest !== undefined)
|
|
596
|
+
this.localDeliveryKeys.delete(oldest);
|
|
597
|
+
}
|
|
598
|
+
void this.onDelivery(agentId, event);
|
|
599
|
+
// Delivered to this process, so no longer pending for it. Peers that
|
|
600
|
+
// never fired the event keep their copies, which is what a restart
|
|
601
|
+
// replays from (#28). Key-based match: replayed events are JSON clones.
|
|
602
|
+
const queued = this.deliveryQueues.get(agentId);
|
|
603
|
+
if (queued !== undefined) {
|
|
604
|
+
const idx = queued.findIndex((e) => JSON.stringify(e) === eventKey);
|
|
605
|
+
if (idx !== -1)
|
|
606
|
+
queued.splice(idx, 1);
|
|
607
|
+
}
|
|
608
|
+
// Auto-mark read — scheduled as a macrotask to yield to the event loop.
|
|
609
|
+
const timer = setTimeout(() => {
|
|
610
|
+
if (this.isShutDown)
|
|
611
|
+
return;
|
|
612
|
+
if (event.type === "room_message") {
|
|
613
|
+
void this.markRead(event.message.id, agentId, event.message.room);
|
|
614
|
+
}
|
|
615
|
+
else if (event.type === "dm") {
|
|
616
|
+
void this.markRead(event.message.id, agentId);
|
|
617
|
+
}
|
|
618
|
+
}, 0);
|
|
619
|
+
if (!this.isShutDown)
|
|
620
|
+
this.pendingMarkReadTimers.push(timer);
|
|
621
|
+
}
|
|
508
622
|
async deliverToRoom(roomId, event, excludeAgent) {
|
|
509
623
|
const room = this.rooms.get(roomId);
|
|
510
624
|
if (!room)
|
|
@@ -649,6 +763,7 @@ export class MeshStore {
|
|
|
649
763
|
const id = this.peerId;
|
|
650
764
|
const agent = {
|
|
651
765
|
id,
|
|
766
|
+
version: 1,
|
|
652
767
|
name: opts.name,
|
|
653
768
|
harness: opts.harness,
|
|
654
769
|
cwd: opts.cwd,
|
|
@@ -679,6 +794,7 @@ export class MeshStore {
|
|
|
679
794
|
const oldStatus = agent.status;
|
|
680
795
|
const oldName = agent.name;
|
|
681
796
|
Object.assign(agent, patch);
|
|
797
|
+
this.bump(agent);
|
|
682
798
|
this.agents.set(id, agent);
|
|
683
799
|
await this.broadcastPatch({ type: "agent_upsert", agent });
|
|
684
800
|
if (patch.name !== undefined && patch.name !== oldName) {
|
|
@@ -709,6 +825,7 @@ export class MeshStore {
|
|
|
709
825
|
// Other stores learn about it via the agent_offline mesh patch.
|
|
710
826
|
const isOwner = id === this.peerId;
|
|
711
827
|
agent.status = "offline";
|
|
828
|
+
this.bump(agent);
|
|
712
829
|
this.agents.set(id, agent);
|
|
713
830
|
if (isOwner) {
|
|
714
831
|
await this.notifyRoomsOfStatus(id, "offline");
|
|
@@ -725,6 +842,7 @@ export class MeshStore {
|
|
|
725
842
|
throw new CommsError(`Room ${id} already exists`, "ROOM_EXISTS");
|
|
726
843
|
const room = {
|
|
727
844
|
id,
|
|
845
|
+
version: 1,
|
|
728
846
|
name: opts.name,
|
|
729
847
|
type: opts.type,
|
|
730
848
|
owner: opts.owner,
|
|
@@ -771,10 +889,12 @@ export class MeshStore {
|
|
|
771
889
|
if (!room.members.includes(agentId))
|
|
772
890
|
room.members.push(agentId);
|
|
773
891
|
}
|
|
892
|
+
this.bump(room);
|
|
774
893
|
this.rooms.set(roomId, room);
|
|
775
894
|
const agent = this.agents.get(agentId);
|
|
776
895
|
if (agent && !agent.subscribedRooms.includes(roomId)) {
|
|
777
896
|
agent.subscribedRooms.push(roomId);
|
|
897
|
+
this.bump(agent);
|
|
778
898
|
this.agents.set(agentId, agent);
|
|
779
899
|
await this.broadcastPatch({ type: "agent_upsert", agent });
|
|
780
900
|
}
|
|
@@ -814,6 +934,7 @@ export class MeshStore {
|
|
|
814
934
|
if (!room)
|
|
815
935
|
throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
|
|
816
936
|
room.members = room.members.filter((id) => id !== agentId);
|
|
937
|
+
this.bump(room);
|
|
817
938
|
this.rooms.set(roomId, room);
|
|
818
939
|
const agent = this.agents.get(agentId);
|
|
819
940
|
if (agent) {
|
|
@@ -844,6 +965,7 @@ export class MeshStore {
|
|
|
844
965
|
if (!room.invited.includes(targetId) && !room.members.includes(targetId)) {
|
|
845
966
|
room.invited.push(targetId);
|
|
846
967
|
}
|
|
968
|
+
this.bump(room);
|
|
847
969
|
this.rooms.set(roomId, room);
|
|
848
970
|
await this.broadcastPatch({ type: "room_upsert", room });
|
|
849
971
|
const inviter = this.agents.get(inviterId);
|
|
@@ -863,6 +985,7 @@ export class MeshStore {
|
|
|
863
985
|
if (!room.invited.includes(agentId))
|
|
864
986
|
throw new CommsError(`Agent ${agentId} was not invited to ${roomId}`, "NOT_INVITED");
|
|
865
987
|
room.invited = room.invited.filter((id) => id !== agentId);
|
|
988
|
+
this.bump(room);
|
|
866
989
|
this.rooms.set(roomId, room);
|
|
867
990
|
await this.broadcastPatch({ type: "room_upsert", room });
|
|
868
991
|
const decliner = this.agents.get(agentId);
|
|
@@ -882,6 +1005,7 @@ export class MeshStore {
|
|
|
882
1005
|
throw new CommsError("Only the room owner can kick", "NOT_OWNER");
|
|
883
1006
|
room.members = room.members.filter((id) => id !== targetId);
|
|
884
1007
|
room.invited = room.invited.filter((id) => id !== targetId);
|
|
1008
|
+
this.bump(room);
|
|
885
1009
|
this.rooms.set(roomId, room);
|
|
886
1010
|
await this.broadcastPatch({ type: "room_upsert", room });
|
|
887
1011
|
}
|
|
@@ -1048,6 +1172,7 @@ export class MeshStore {
|
|
|
1048
1172
|
this.agents.delete(id);
|
|
1049
1173
|
this.peerInfo.delete(id);
|
|
1050
1174
|
this.identityCache.delete(id);
|
|
1175
|
+
this.deliveryQueues.delete(id);
|
|
1051
1176
|
}
|
|
1052
1177
|
}
|
|
1053
1178
|
isProcessAlive(pid) {
|
|
@@ -1167,6 +1292,7 @@ export class MeshStore {
|
|
|
1167
1292
|
const remoteId = `fed:${agentId}`;
|
|
1168
1293
|
if (!room.members.includes(remoteId)) {
|
|
1169
1294
|
room.members.push(remoteId);
|
|
1295
|
+
this.bump(room);
|
|
1170
1296
|
this.rooms.set(roomId, room);
|
|
1171
1297
|
await this.broadcastPatch({ type: "room_upsert", room });
|
|
1172
1298
|
}
|
|
@@ -1183,6 +1309,7 @@ export class MeshStore {
|
|
|
1183
1309
|
return;
|
|
1184
1310
|
const remoteId = `fed:${agentId}`;
|
|
1185
1311
|
room.members = room.members.filter((m) => m !== remoteId);
|
|
1312
|
+
this.bump(room);
|
|
1186
1313
|
this.rooms.set(roomId, room);
|
|
1187
1314
|
await this.broadcastPatch({ type: "room_upsert", room });
|
|
1188
1315
|
await this.deliverToRoom(roomId, {
|