mono-pilot 0.2.9 → 0.2.12

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.
Files changed (158) hide show
  1. package/README.md +270 -7
  2. package/dist/src/agents-paths.js +36 -0
  3. package/dist/src/brief/blocks.js +83 -0
  4. package/dist/src/brief/defaults.js +60 -0
  5. package/dist/src/brief/frontmatter.js +53 -0
  6. package/dist/src/brief/paths.js +10 -0
  7. package/dist/src/brief/reflection.js +27 -0
  8. package/dist/src/cli.js +62 -5
  9. package/dist/src/cluster/bus.js +102 -0
  10. package/dist/src/cluster/follower.js +137 -0
  11. package/dist/src/cluster/init.js +182 -0
  12. package/dist/src/cluster/leader.js +97 -0
  13. package/dist/src/cluster/log.js +49 -0
  14. package/dist/src/cluster/protocol.js +34 -0
  15. package/dist/src/cluster/services/bus.js +243 -0
  16. package/dist/src/cluster/services/embedding.js +12 -0
  17. package/dist/src/cluster/socket.js +86 -0
  18. package/dist/src/cluster/test-bus.js +175 -0
  19. package/dist/src/cluster_v2/connection-lifecycle.js +31 -0
  20. package/dist/src/cluster_v2/connection-lifecycle.test.js +24 -0
  21. package/dist/src/cluster_v2/connection.js +159 -0
  22. package/dist/src/cluster_v2/connection.test.js +55 -0
  23. package/dist/src/cluster_v2/events.js +102 -0
  24. package/dist/src/cluster_v2/index.js +2 -0
  25. package/dist/src/cluster_v2/observability.js +99 -0
  26. package/dist/src/cluster_v2/observability.test.js +46 -0
  27. package/dist/src/cluster_v2/rpc.js +389 -0
  28. package/dist/src/cluster_v2/rpc.test.js +110 -0
  29. package/dist/src/cluster_v2/runtime.failover.integration.test.js +156 -0
  30. package/dist/src/cluster_v2/runtime.js +531 -0
  31. package/dist/src/cluster_v2/runtime.lease-compromise.integration.test.js +91 -0
  32. package/dist/src/cluster_v2/runtime.lifecycle.integration.test.js +225 -0
  33. package/dist/src/cluster_v2/services/bus.integration.test.js +140 -0
  34. package/dist/src/cluster_v2/services/bus.js +450 -0
  35. package/dist/src/cluster_v2/services/discord/auth-store.js +82 -0
  36. package/dist/src/cluster_v2/services/discord/collector.js +569 -0
  37. package/dist/src/cluster_v2/services/discord/index.js +1 -0
  38. package/dist/src/cluster_v2/services/discord/oauth.js +87 -0
  39. package/dist/src/cluster_v2/services/discord/rpc-client.js +325 -0
  40. package/dist/src/cluster_v2/services/embedding.js +66 -0
  41. package/dist/src/cluster_v2/services/registry-cache.js +107 -0
  42. package/dist/src/cluster_v2/services/registry-cache.test.js +66 -0
  43. package/dist/src/cluster_v2/services/registry.js +36 -0
  44. package/dist/src/cluster_v2/services/twitter/collector.js +1055 -0
  45. package/dist/src/cluster_v2/services/twitter/index.js +1 -0
  46. package/dist/src/config/digest.js +78 -0
  47. package/dist/src/config/discord.js +143 -0
  48. package/dist/src/config/image-gen.js +48 -0
  49. package/dist/src/config/mono-pilot.js +31 -0
  50. package/dist/src/config/twitter.js +100 -0
  51. package/dist/src/extensions/cluster.js +311 -0
  52. package/dist/src/extensions/commands/build-memory.js +76 -0
  53. package/dist/src/extensions/commands/digest/backfill.js +779 -0
  54. package/dist/src/extensions/commands/digest/index.js +1133 -0
  55. package/dist/src/extensions/commands/image-model.js +214 -0
  56. package/dist/src/extensions/game/bus-injection.js +47 -0
  57. package/dist/src/extensions/game/identity.js +83 -0
  58. package/dist/src/extensions/game/mailbox.js +61 -0
  59. package/dist/src/extensions/game/system-prompt.js +134 -0
  60. package/dist/src/extensions/game/tools.js +28 -0
  61. package/dist/src/extensions/lifecycle.js +337 -0
  62. package/dist/src/extensions/mode-runtime.js +26 -2
  63. package/dist/src/extensions/mono-game.js +66 -0
  64. package/dist/src/extensions/mono-pilot.js +100 -18
  65. package/dist/src/extensions/nvim.js +47 -0
  66. package/dist/src/extensions/session-hints.js +60 -35
  67. package/dist/src/extensions/sftp.js +897 -0
  68. package/dist/src/extensions/status.js +676 -0
  69. package/dist/src/extensions/system-events.js +478 -0
  70. package/dist/src/extensions/system-prompt.js +24 -14
  71. package/dist/src/extensions/user-message.js +94 -50
  72. package/dist/src/lsp/client.js +235 -0
  73. package/dist/src/lsp/index.js +165 -0
  74. package/dist/src/lsp/runtime.js +67 -0
  75. package/dist/src/lsp/server.js +242 -0
  76. package/dist/src/mcp/config.js +112 -0
  77. package/dist/src/{utils/mcp-client.js → mcp/protocol.js} +1 -100
  78. package/dist/src/mcp/servers.js +90 -0
  79. package/dist/src/memory/build-memory.js +103 -0
  80. package/dist/src/memory/config/defaults.js +55 -0
  81. package/dist/src/memory/config/loader.js +29 -0
  82. package/dist/src/memory/config/paths.js +9 -0
  83. package/dist/src/memory/config/resolve.js +90 -0
  84. package/dist/src/memory/config/types.js +1 -0
  85. package/dist/src/memory/embeddings/batch-runner.js +39 -0
  86. package/dist/src/memory/embeddings/cache.js +47 -0
  87. package/dist/src/memory/embeddings/chunk-limits.js +26 -0
  88. package/dist/src/memory/embeddings/input-limits.js +48 -0
  89. package/dist/src/memory/embeddings/local.js +108 -0
  90. package/dist/src/memory/embeddings/types.js +1 -0
  91. package/dist/src/memory/index-manager.js +552 -0
  92. package/dist/src/memory/indexing/embeddings.js +67 -0
  93. package/dist/src/memory/indexing/files.js +180 -0
  94. package/dist/src/memory/indexing/index-file.js +105 -0
  95. package/dist/src/memory/log.js +38 -0
  96. package/dist/src/memory/paths.js +15 -0
  97. package/dist/src/memory/runtime/index.js +299 -0
  98. package/dist/src/memory/runtime/thread.js +116 -0
  99. package/dist/src/memory/search/fts.js +57 -0
  100. package/dist/src/memory/search/hybrid.js +50 -0
  101. package/dist/src/memory/search/text.js +30 -0
  102. package/dist/src/memory/search/vector.js +43 -0
  103. package/dist/src/memory/session/content-hash.js +7 -0
  104. package/dist/src/memory/session/entry.js +33 -0
  105. package/dist/src/memory/session/flush-policy.js +34 -0
  106. package/dist/src/memory/session/hook.js +191 -0
  107. package/dist/src/memory/session/paths.js +15 -0
  108. package/dist/src/memory/session/session-reader.js +88 -0
  109. package/dist/src/memory/session/transcript/content-hash.js +7 -0
  110. package/dist/src/memory/session/transcript/entry.js +28 -0
  111. package/dist/src/memory/session/transcript/flush.js +56 -0
  112. package/dist/src/memory/session/transcript/paths.js +28 -0
  113. package/dist/src/memory/session/transcript/reader.js +112 -0
  114. package/dist/src/memory/session/transcript/state.js +31 -0
  115. package/dist/src/memory/store/schema.js +89 -0
  116. package/dist/src/memory/store/sqlite.js +89 -0
  117. package/dist/src/memory/types.js +1 -0
  118. package/dist/src/memory/warm.js +25 -0
  119. package/dist/src/rules/discovery.js +41 -0
  120. package/dist/{tools → src/tools}/README.md +29 -3
  121. package/dist/{tools → src/tools}/apply-patch-description.md +8 -2
  122. package/dist/{tools → src/tools}/apply-patch.js +174 -104
  123. package/dist/{tools → src/tools}/apply-patch.test.js +52 -1
  124. package/dist/{tools/ask-question.js → src/tools/ask-user-question.js} +3 -3
  125. package/dist/src/tools/ast-grep.js +357 -0
  126. package/dist/src/tools/brief-write.js +122 -0
  127. package/dist/src/tools/bus-send.js +100 -0
  128. package/dist/{tools → src/tools}/call-mcp-tool.js +40 -124
  129. package/dist/src/tools/codex-apply-patch-description.md +52 -0
  130. package/dist/src/tools/codex-apply-patch.js +540 -0
  131. package/dist/{tools → src/tools}/delete.js +24 -0
  132. package/dist/src/tools/exit-plan-mode.js +83 -0
  133. package/dist/{tools → src/tools}/fetch-mcp-resource.js +56 -100
  134. package/dist/src/tools/generate-image.js +567 -0
  135. package/dist/{tools → src/tools}/glob.js +55 -1
  136. package/dist/{tools → src/tools}/list-mcp-resources.js +46 -57
  137. package/dist/{tools → src/tools}/list-mcp-tools.js +52 -63
  138. package/dist/src/tools/ls.js +48 -0
  139. package/dist/src/tools/lsp-diagnostics.js +67 -0
  140. package/dist/src/tools/lsp-symbols.js +54 -0
  141. package/dist/src/tools/mailbox.js +85 -0
  142. package/dist/src/tools/memory-get.js +90 -0
  143. package/dist/src/tools/memory-search.js +180 -0
  144. package/dist/{tools → src/tools}/plan-mode-reminder.md +3 -4
  145. package/dist/{tools → src/tools}/read-file.js +8 -19
  146. package/dist/{tools → src/tools}/rg.js +10 -20
  147. package/dist/{tools → src/tools}/shell.js +19 -42
  148. package/dist/{tools → src/tools}/subagent.js +255 -6
  149. package/dist/{tools → src/tools}/switch-mode.js +37 -6
  150. package/dist/{tools → src/tools}/web-fetch.js +105 -7
  151. package/dist/{tools → src/tools}/web-search.js +29 -1
  152. package/package.json +21 -9
  153. /package/dist/{tools → src/tools}/ask-mode-reminder.md +0 -0
  154. /package/dist/{tools → src/tools}/rg.test.js +0 -0
  155. /package/dist/{tools → src/tools}/semantic-search-description.md +0 -0
  156. /package/dist/{tools → src/tools}/semantic-search.js +0 -0
  157. /package/dist/{tools → src/tools}/shell-description.md +0 -0
  158. /package/dist/{tools → src/tools}/subagent-description.md +0 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Message bus service handler for the cluster leader.
3
+ * Manages agent route table, message routing, presence, and channel subscriptions.
4
+ *
5
+ * Also exports createLeaderBus() for in-process loopback (leader participates in bus
6
+ * without going through a socket).
7
+ */
8
+ import { encodeMessage, } from "../protocol.js";
9
+ import { clusterLog } from "../log.js";
10
+ const agents = new Map();
11
+ let messageSeq = 0;
12
+ function broadcastPresence(agentId, status, exclude) {
13
+ const payload = { agentId, displayName: agents.get(agentId)?.displayName, status };
14
+ for (const [id, agent] of agents) {
15
+ if (id !== exclude)
16
+ agent.push("presence", payload);
17
+ }
18
+ }
19
+ function socketPush(socket) {
20
+ return (method, payload) => {
21
+ if (!socket.destroyed) {
22
+ socket.write(encodeMessage({ type: "push", method, payload }));
23
+ }
24
+ };
25
+ }
26
+ function registerAgent(agentId, push, role, channels, displayName) {
27
+ const existing = agents.get(agentId);
28
+ if (existing)
29
+ existing.push = () => { };
30
+ const defaultChannels = ["public", `private:${agentId}`];
31
+ const allChannels = [...defaultChannels, ...(channels ?? [])];
32
+ agents.set(agentId, { agentId, displayName, role, push, channels: new Set(allChannels) });
33
+ clusterLog.info("agent registered", { agentId, channels: allChannels });
34
+ broadcastPresence(agentId, "joined", agentId);
35
+ for (const [id, existing] of agents) {
36
+ if (id !== agentId) {
37
+ push("presence", { agentId: id, displayName: existing.displayName, status: "joined" });
38
+ }
39
+ }
40
+ return allChannels;
41
+ }
42
+ // --- ServiceHandler for follower RPC requests ---
43
+ export function createBusHandler() {
44
+ return {
45
+ methods: ["register", "subscribe", "send", "broadcast", "roster"],
46
+ async handle(req, ctx) {
47
+ switch (req.method) {
48
+ case "register": {
49
+ const { agentId, channels, displayName } = req.params;
50
+ if (!agentId) {
51
+ ctx.respond({ error: "register requires agentId" });
52
+ return;
53
+ }
54
+ const allChannels = registerAgent(agentId, socketPush(ctx.socket), "follower", channels, displayName);
55
+ ctx.setRegisteredId(agentId);
56
+ ctx.respond({ result: { agentId, channels: allChannels } });
57
+ return;
58
+ }
59
+ case "subscribe": {
60
+ const registeredId = ctx.getRegisteredId();
61
+ if (!registeredId) {
62
+ ctx.respond({ error: "must register before subscribe" });
63
+ return;
64
+ }
65
+ const agent = agents.get(registeredId);
66
+ if (!agent) {
67
+ ctx.respond({ error: "agent not found in route table" });
68
+ return;
69
+ }
70
+ const { channels } = req.params;
71
+ for (const ch of channels)
72
+ agent.channels.add(ch);
73
+ clusterLog.debug("subscribe", { agentId: registeredId, channels });
74
+ ctx.respond({ result: { channels: [...agent.channels] } });
75
+ return;
76
+ }
77
+ case "send": {
78
+ const registeredId = ctx.getRegisteredId();
79
+ if (!registeredId) {
80
+ ctx.respond({ error: "must register before send" });
81
+ return;
82
+ }
83
+ const { to, channel, payload } = req.params;
84
+ const target = agents.get(to);
85
+ if (!target) {
86
+ ctx.respond({ error: `agent not found: ${to}` });
87
+ return;
88
+ }
89
+ const seq = ++messageSeq;
90
+ target.push("message", {
91
+ from: registeredId,
92
+ fromName: agents.get(registeredId)?.displayName,
93
+ channel,
94
+ payload,
95
+ seq,
96
+ });
97
+ clusterLog.debug("send", { from: registeredId, to, seq });
98
+ ctx.respond({ result: { seq } });
99
+ return;
100
+ }
101
+ case "broadcast": {
102
+ const registeredId = ctx.getRegisteredId();
103
+ if (!registeredId) {
104
+ ctx.respond({ error: "must register before broadcast" });
105
+ return;
106
+ }
107
+ const { channel, payload } = req.params;
108
+ const targetChannel = channel ?? "public";
109
+ const seq = ++messageSeq;
110
+ const fromName = agents.get(registeredId)?.displayName;
111
+ const pushPayload = {
112
+ from: registeredId,
113
+ fromName,
114
+ channel: targetChannel,
115
+ payload,
116
+ seq,
117
+ };
118
+ let delivered = 0;
119
+ for (const [id, agent] of agents) {
120
+ if (id === registeredId)
121
+ continue;
122
+ if (agent.channels.has(targetChannel)) {
123
+ agent.push("message", pushPayload);
124
+ delivered++;
125
+ }
126
+ }
127
+ clusterLog.debug("broadcast", { from: registeredId, channel: targetChannel, seq, delivered });
128
+ ctx.respond({ result: { seq, delivered } });
129
+ return;
130
+ }
131
+ case "roster": {
132
+ const roster = [...agents.entries()].map(([id, a]) => ({
133
+ agentId: id,
134
+ displayName: a.displayName,
135
+ role: a.role,
136
+ channels: [...a.channels],
137
+ }));
138
+ ctx.respond({ result: { agents: roster } });
139
+ return;
140
+ }
141
+ }
142
+ },
143
+ onDisconnect(agentId) {
144
+ clusterLog.info("follower left", { agentId });
145
+ agents.delete(agentId);
146
+ broadcastPresence(agentId, "left");
147
+ },
148
+ };
149
+ }
150
+ // --- Leader loopback: in-process BusHandle without socket ---
151
+ export function createLeaderBus(agentId, displayName) {
152
+ let messageHandlers = [];
153
+ let presenceHandlers = [];
154
+ let closed = false;
155
+ const push = (method, payload) => {
156
+ if (closed)
157
+ return;
158
+ if (method === "message") {
159
+ for (const h of messageHandlers)
160
+ h(payload);
161
+ }
162
+ else if (method === "presence") {
163
+ for (const h of presenceHandlers)
164
+ h(payload);
165
+ }
166
+ };
167
+ registerAgent(agentId, push, "leader", undefined, displayName);
168
+ return {
169
+ async send(to, payload, channel) {
170
+ const target = agents.get(to);
171
+ if (!target)
172
+ throw new Error(`agent not found: ${to}`);
173
+ const seq = ++messageSeq;
174
+ target.push("message", { from: agentId, fromName: agents.get(agentId)?.displayName, channel, payload, seq });
175
+ clusterLog.debug("send (leader)", { from: agentId, to, seq });
176
+ return { seq };
177
+ },
178
+ async broadcast(payload, channel) {
179
+ const targetChannel = channel ?? "public";
180
+ const seq = ++messageSeq;
181
+ const pushPayload = {
182
+ from: agentId,
183
+ fromName: agents.get(agentId)?.displayName,
184
+ channel: targetChannel,
185
+ payload,
186
+ seq,
187
+ };
188
+ let delivered = 0;
189
+ for (const [id, agent] of agents) {
190
+ if (id === agentId)
191
+ continue;
192
+ if (agent.channels.has(targetChannel)) {
193
+ agent.push("message", pushPayload);
194
+ delivered++;
195
+ }
196
+ }
197
+ clusterLog.debug("broadcast (leader)", { from: agentId, channel: targetChannel, seq, delivered });
198
+ return { seq, delivered };
199
+ },
200
+ async subscribe(chs) {
201
+ const entry = agents.get(agentId);
202
+ if (entry)
203
+ for (const ch of chs)
204
+ entry.channels.add(ch);
205
+ return { channels: entry ? [...entry.channels] : [] };
206
+ },
207
+ async roster() {
208
+ return {
209
+ agents: [...agents.entries()].map(([id, a]) => ({
210
+ agentId: id,
211
+ displayName: a.displayName,
212
+ role: a.role,
213
+ channels: [...a.channels],
214
+ })),
215
+ };
216
+ },
217
+ async resolveTarget(target) {
218
+ const { agents } = await this.roster();
219
+ const byId = agents.find((agent) => agent.agentId === target);
220
+ if (byId)
221
+ return { agentId: byId.agentId, displayName: byId.displayName };
222
+ const matches = agents.filter((agent) => agent.displayName?.trim() && agent.displayName.trim() === target);
223
+ if (matches.length === 1) {
224
+ return { agentId: matches[0].agentId, displayName: matches[0].displayName };
225
+ }
226
+ if (matches.length === 0) {
227
+ throw new Error(`No agent found for "${target}". Use /cluster who to list agents.`);
228
+ }
229
+ const ids = matches.map((agent) => agent.agentId).join(", ");
230
+ throw new Error(`DisplayName "${target}" is not unique. Candidates: ${ids}. Use agentId instead.`);
231
+ },
232
+ onMessage(handler) { messageHandlers.push(handler); },
233
+ onPresence(handler) { presenceHandlers.push(handler); },
234
+ close() {
235
+ closed = true;
236
+ messageHandlers = [];
237
+ presenceHandlers = [];
238
+ agents.delete(agentId);
239
+ broadcastPresence(agentId, "left");
240
+ clusterLog.debug("leader bus closed", { agentId });
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,12 @@
1
+ import { clusterLog } from "../log.js";
2
+ export function createEmbeddingHandler(provider) {
3
+ return {
4
+ methods: ["embed"],
5
+ async handle(req, ctx) {
6
+ const { texts } = req.params;
7
+ clusterLog.debug("embed request", { count: texts.length, reqId: req.id, ...req.from });
8
+ const vectors = await provider.embedBatch(texts);
9
+ ctx.respond({ result: { vectors } });
10
+ },
11
+ };
12
+ }
@@ -0,0 +1,86 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { unlinkSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import net from "node:net";
5
+ import lockfile from "proper-lockfile";
6
+ import { clusterLog } from "./log.js";
7
+ const MONO_PILOT_DIR = join(homedir(), ".mono-pilot");
8
+ const SOCKET_PATH = join(MONO_PILOT_DIR, "cluster.sock");
9
+ const LOCK_FILE = join(MONO_PILOT_DIR, "cluster.leader");
10
+ export function getSocketPath() {
11
+ return SOCKET_PATH;
12
+ }
13
+ /**
14
+ * Try to connect to an existing cluster leader.
15
+ * Returns the socket on success, or null if no leader is listening.
16
+ */
17
+ export function tryConnect() {
18
+ return new Promise((resolve) => {
19
+ const socket = net.createConnection(SOCKET_PATH);
20
+ const timeout = setTimeout(() => {
21
+ socket.destroy();
22
+ resolve(null);
23
+ }, 2000);
24
+ socket.on("connect", () => {
25
+ clearTimeout(timeout);
26
+ resolve(socket);
27
+ });
28
+ socket.on("error", () => {
29
+ clearTimeout(timeout);
30
+ resolve(null);
31
+ });
32
+ });
33
+ }
34
+ /**
35
+ * Try to become leader: acquire file lock, clean stale socket, listen.
36
+ * Uses proper-lockfile (mkdir + mtime heartbeat) for atomic, crash-safe locking.
37
+ */
38
+ export async function tryListen() {
39
+ mkdirSync(MONO_PILOT_DIR, { recursive: true });
40
+ writeFileSync(LOCK_FILE, "", { flag: "a" });
41
+ try {
42
+ await lockfile.lock(LOCK_FILE, {
43
+ stale: 10000, // lock considered stale after 10s without heartbeat
44
+ retries: 0,
45
+ onCompromised: (err) => {
46
+ clusterLog.warn("leader lock compromised", { error: String(err) });
47
+ },
48
+ });
49
+ }
50
+ catch {
51
+ clusterLog.debug("leader lock held by another process");
52
+ return null;
53
+ }
54
+ // We hold the lock — safe to clean stale socket file
55
+ try {
56
+ unlinkSync(SOCKET_PATH);
57
+ }
58
+ catch { }
59
+ return new Promise((resolve) => {
60
+ const server = net.createServer();
61
+ server.on("error", (err) => {
62
+ clusterLog.debug("listen failed", { code: err.code });
63
+ try {
64
+ lockfile.unlockSync(LOCK_FILE);
65
+ }
66
+ catch { }
67
+ resolve(null);
68
+ });
69
+ server.listen(SOCKET_PATH, () => {
70
+ resolve(server);
71
+ });
72
+ });
73
+ }
74
+ /**
75
+ * Clean up socket and release lock on leader shutdown.
76
+ */
77
+ export function cleanupSocket() {
78
+ try {
79
+ unlinkSync(SOCKET_PATH);
80
+ }
81
+ catch { }
82
+ try {
83
+ lockfile.unlockSync(LOCK_FILE);
84
+ }
85
+ catch { }
86
+ }
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Smoke test for the cluster message bus.
4
+ *
5
+ * Starts a leader (with dummy embedding provider), connects two followers
6
+ * as "alice" and "bob", tests register/send/broadcast/presence.
7
+ *
8
+ * Run: npx tsx src/cluster/test-bus.ts
9
+ */
10
+ import { tryBecomeLeader } from "./leader.js";
11
+ import { createEmbeddingHandler } from "./services/embedding.js";
12
+ import { createBusHandler } from "./services/bus.js";
13
+ import { tryFollowLeader } from "./follower.js";
14
+ import { connectBus } from "./bus.js";
15
+ import { cleanupSocket } from "./socket.js";
16
+ const dummyProvider = {
17
+ id: "local",
18
+ model: "test",
19
+ embedQuery: async () => [0],
20
+ embedBatch: async (texts) => texts.map(() => [0]),
21
+ };
22
+ let passed = 0;
23
+ let failed = 0;
24
+ function assert(condition, label) {
25
+ if (condition) {
26
+ console.log(` ✅ ${label}`);
27
+ passed++;
28
+ }
29
+ else {
30
+ console.log(` ❌ ${label}`);
31
+ failed++;
32
+ }
33
+ }
34
+ async function sleep(ms) {
35
+ return new Promise((r) => setTimeout(r, ms));
36
+ }
37
+ async function main() {
38
+ console.log("=== Cluster Message Bus Test ===\n");
39
+ // --- Start leader ---
40
+ console.log("Starting leader...");
41
+ const services = [createEmbeddingHandler(dummyProvider), createBusHandler()];
42
+ const leader = await tryBecomeLeader(services);
43
+ assert(leader !== null, "leader started");
44
+ if (!leader) {
45
+ process.exit(1);
46
+ }
47
+ // --- Connect Alice ---
48
+ console.log("\nConnecting Alice...");
49
+ const aliceHandle = await tryFollowLeader("test", { agentId: "alice" });
50
+ assert(aliceHandle !== null, "alice connected");
51
+ if (!aliceHandle) {
52
+ await leader.close();
53
+ process.exit(1);
54
+ }
55
+ // --- Connect Bob ---
56
+ console.log("\nConnecting Bob...");
57
+ const bobHandle = await tryFollowLeader("test", { agentId: "bob" });
58
+ assert(bobHandle !== null, "bob connected");
59
+ if (!bobHandle) {
60
+ aliceHandle.close();
61
+ await leader.close();
62
+ process.exit(1);
63
+ }
64
+ // --- Register on bus ---
65
+ console.log("\nRegistering on bus...");
66
+ const aliceBus = await connectBus(aliceHandle.client, "alice", undefined, ["public"]);
67
+ assert(true, "alice registered");
68
+ const presenceEvents = [];
69
+ // Alice registers a presence handler to capture bob's join + bob's leave later
70
+ aliceBus.onPresence((evt) => { presenceEvents.push(evt); });
71
+ const bobBus = await connectBus(bobHandle.client, "bob", undefined, ["public"]);
72
+ assert(true, "bob registered");
73
+ // Bob registers presence handler synchronously after connectBus —
74
+ // buffered events flush on nextTick, so this handler will catch them.
75
+ const bobPresenceOnJoin = [];
76
+ bobBus.onPresence((evt) => { bobPresenceOnJoin.push(evt); });
77
+ await sleep(50);
78
+ // --- Test: late joiner sees existing agents ---
79
+ console.log("\nTest: late joiner sees existing agents...");
80
+ // Alice should have received bob's join
81
+ const aliceSawBobJoin = presenceEvents.some((e) => e.agentId === "bob" && e.status === "joined");
82
+ assert(aliceSawBobJoin, "alice received bob's presence:joined");
83
+ // Bob should know alice was already there
84
+ const bobSawAlice = bobPresenceOnJoin.some((e) => e.agentId === "alice" && e.status === "joined");
85
+ assert(bobSawAlice, "bob received alice's presence:joined on connect");
86
+ // Wire up remaining handlers
87
+ const aliceMessages = [];
88
+ const bobMessages = [];
89
+ aliceBus.onMessage((msg) => { aliceMessages.push(msg); });
90
+ bobBus.onMessage((msg) => { bobMessages.push(msg); });
91
+ // --- Test: send (alice → bob) ---
92
+ console.log("\nTest: send (alice → bob)...");
93
+ const sendResult = await aliceBus.send("bob", { text: "你昨晚在哪?" });
94
+ assert(typeof sendResult.seq === "number", `send returned seq=${sendResult.seq}`);
95
+ await sleep(50); // let push arrive
96
+ assert(bobMessages.length === 1, `bob received 1 message (got ${bobMessages.length})`);
97
+ if (bobMessages[0]) {
98
+ assert(bobMessages[0].from === "alice", `message from alice`);
99
+ assert(bobMessages[0].payload?.text === "你昨晚在哪?", `message payload correct`);
100
+ }
101
+ // --- Test: send (bob → alice) ---
102
+ console.log("\nTest: send (bob → alice)...");
103
+ await bobBus.send("alice", { text: "我在图书馆" });
104
+ await sleep(50);
105
+ assert(aliceMessages.length === 1, `alice received 1 message (got ${aliceMessages.length})`);
106
+ if (aliceMessages[0]) {
107
+ assert(aliceMessages[0].from === "bob", `message from bob`);
108
+ assert(aliceMessages[0].payload?.text === "我在图书馆", `message payload correct`);
109
+ }
110
+ // --- Test: broadcast ---
111
+ console.log("\nTest: broadcast (alice → public)...");
112
+ const bcResult = await aliceBus.broadcast({ text: "大家注意!" });
113
+ assert(typeof bcResult.seq === "number", `broadcast returned seq=${bcResult.seq}`);
114
+ assert(bcResult.delivered === 1, `broadcast delivered to 1 (got ${bcResult.delivered})`);
115
+ await sleep(50);
116
+ assert(bobMessages.length === 2, `bob received broadcast (total ${bobMessages.length})`);
117
+ if (bobMessages[1]) {
118
+ assert(bobMessages[1].from === "alice", `broadcast from alice`);
119
+ assert(bobMessages[1].channel === "public", `broadcast channel is public`);
120
+ }
121
+ // Alice should NOT receive her own broadcast
122
+ assert(aliceMessages.length === 1, `alice did not receive own broadcast (still ${aliceMessages.length})`);
123
+ // --- Test: subscribe + channel routing ---
124
+ console.log("\nTest: subscribe + channel routing...");
125
+ await bobBus.subscribe(["secret"]);
126
+ await aliceBus.broadcast({ text: "秘密消息" }, "secret");
127
+ await sleep(50);
128
+ // Alice is not subscribed to "secret", so she won't get it either
129
+ // Bob IS subscribed to "secret"
130
+ assert(bobMessages.length === 3, `bob received secret channel message (total ${bobMessages.length})`);
131
+ if (bobMessages[2]) {
132
+ assert(bobMessages[2].channel === "secret", `message on secret channel`);
133
+ }
134
+ // --- Test: send to non-existent agent ---
135
+ // --- Test: private channel (auto-subscribed) ---
136
+ console.log("\nTest: private channel (auto-subscribed)...");
137
+ const bobBefore = bobMessages.length;
138
+ const aliceBefore = aliceMessages.length;
139
+ await aliceBus.broadcast({ text: "只给 bob 看" }, "private:bob");
140
+ await sleep(50);
141
+ assert(bobMessages.length === bobBefore + 1, `bob received private:bob message`);
142
+ if (bobMessages[bobMessages.length - 1]) {
143
+ assert(bobMessages[bobMessages.length - 1].channel === "private:bob", `channel is private:bob`);
144
+ }
145
+ assert(aliceMessages.length === aliceBefore, `alice did NOT receive private:bob message`);
146
+ // --- Test: send to non-existent agent ---
147
+ console.log("\nTest: send to non-existent agent...");
148
+ try {
149
+ await aliceBus.send("charlie", { text: "hello?" });
150
+ assert(false, "should have thrown");
151
+ }
152
+ catch (err) {
153
+ assert(err.message.includes("not found"), `error: ${err.message}`);
154
+ }
155
+ // --- Test: presence on disconnect ---
156
+ console.log("\nTest: presence on disconnect...");
157
+ presenceEvents.length = 0;
158
+ bobBus.close();
159
+ bobHandle.close();
160
+ await sleep(100);
161
+ const leftEvent = presenceEvents.find((e) => e.agentId === "bob" && e.status === "left");
162
+ assert(leftEvent !== undefined, `alice received bob's presence:left`);
163
+ // --- Cleanup ---
164
+ console.log("\nCleaning up...");
165
+ aliceBus.close();
166
+ aliceHandle.close();
167
+ await leader.close();
168
+ console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
169
+ process.exit(failed > 0 ? 1 : 0);
170
+ }
171
+ main().catch((err) => {
172
+ console.error("Test crashed:", err);
173
+ cleanupSocket();
174
+ process.exit(1);
175
+ });
@@ -0,0 +1,31 @@
1
+ const ALLOWED_TRANSITIONS = {
2
+ disconnected: new Set(["connecting", "closed"]),
3
+ connecting: new Set(["connected", "disconnected", "closed"]),
4
+ connected: new Set(["reconnecting", "disconnected", "closed"]),
5
+ reconnecting: new Set(["connecting", "disconnected", "closed"]),
6
+ closed: new Set(["connecting"]),
7
+ };
8
+ export class ConnectionLifecycleTracker {
9
+ current;
10
+ constructor(initialState = "disconnected") {
11
+ this.current = initialState;
12
+ }
13
+ state() {
14
+ return this.current;
15
+ }
16
+ transition(next, label) {
17
+ if (next === this.current) {
18
+ return;
19
+ }
20
+ const allowed = ALLOWED_TRANSITIONS[this.current];
21
+ if (!allowed.has(next)) {
22
+ throw new Error(`[cluster_v2/lifecycle] invalid transition ${this.current} -> ${next} at ${label}`);
23
+ }
24
+ this.current = next;
25
+ }
26
+ assertOpenImpliesConnected(socketOpen, label) {
27
+ if (socketOpen && this.current !== "connected") {
28
+ throw new Error(`[cluster_v2/lifecycle] socket invariant violated at ${label}: state=${this.current}, socketOpen=${socketOpen}`);
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ConnectionLifecycleTracker } from "./connection-lifecycle.js";
3
+ describe("cluster_v2 connection lifecycle tracker", () => {
4
+ it("accepts valid lifecycle transitions", () => {
5
+ const tracker = new ConnectionLifecycleTracker("disconnected");
6
+ tracker.transition("connecting", "start_connect");
7
+ tracker.transition("connected", "connect_ok");
8
+ tracker.transition("reconnecting", "leader_lost");
9
+ tracker.transition("connecting", "retry_connect");
10
+ tracker.transition("connected", "retry_ok");
11
+ tracker.transition("closed", "shutdown");
12
+ expect(tracker.state()).toBe("closed");
13
+ });
14
+ it("rejects invalid lifecycle transitions", () => {
15
+ const tracker = new ConnectionLifecycleTracker("disconnected");
16
+ expect(() => tracker.transition("connected", "skip_connecting")).toThrow("invalid transition");
17
+ });
18
+ it("enforces socketOpen => connected invariant", () => {
19
+ const tracker = new ConnectionLifecycleTracker("connecting");
20
+ expect(() => tracker.assertOpenImpliesConnected(true, "pre_connect_socket_open")).toThrow("socket invariant violated");
21
+ tracker.transition("connected", "connect_ok");
22
+ expect(() => tracker.assertOpenImpliesConnected(true, "connected_socket_open")).not.toThrow();
23
+ });
24
+ });