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.
- package/README.md +270 -7
- package/dist/src/agents-paths.js +36 -0
- package/dist/src/brief/blocks.js +83 -0
- package/dist/src/brief/defaults.js +60 -0
- package/dist/src/brief/frontmatter.js +53 -0
- package/dist/src/brief/paths.js +10 -0
- package/dist/src/brief/reflection.js +27 -0
- package/dist/src/cli.js +62 -5
- package/dist/src/cluster/bus.js +102 -0
- package/dist/src/cluster/follower.js +137 -0
- package/dist/src/cluster/init.js +182 -0
- package/dist/src/cluster/leader.js +97 -0
- package/dist/src/cluster/log.js +49 -0
- package/dist/src/cluster/protocol.js +34 -0
- package/dist/src/cluster/services/bus.js +243 -0
- package/dist/src/cluster/services/embedding.js +12 -0
- package/dist/src/cluster/socket.js +86 -0
- package/dist/src/cluster/test-bus.js +175 -0
- package/dist/src/cluster_v2/connection-lifecycle.js +31 -0
- package/dist/src/cluster_v2/connection-lifecycle.test.js +24 -0
- package/dist/src/cluster_v2/connection.js +159 -0
- package/dist/src/cluster_v2/connection.test.js +55 -0
- package/dist/src/cluster_v2/events.js +102 -0
- package/dist/src/cluster_v2/index.js +2 -0
- package/dist/src/cluster_v2/observability.js +99 -0
- package/dist/src/cluster_v2/observability.test.js +46 -0
- package/dist/src/cluster_v2/rpc.js +389 -0
- package/dist/src/cluster_v2/rpc.test.js +110 -0
- package/dist/src/cluster_v2/runtime.failover.integration.test.js +156 -0
- package/dist/src/cluster_v2/runtime.js +531 -0
- package/dist/src/cluster_v2/runtime.lease-compromise.integration.test.js +91 -0
- package/dist/src/cluster_v2/runtime.lifecycle.integration.test.js +225 -0
- package/dist/src/cluster_v2/services/bus.integration.test.js +140 -0
- package/dist/src/cluster_v2/services/bus.js +450 -0
- package/dist/src/cluster_v2/services/discord/auth-store.js +82 -0
- package/dist/src/cluster_v2/services/discord/collector.js +569 -0
- package/dist/src/cluster_v2/services/discord/index.js +1 -0
- package/dist/src/cluster_v2/services/discord/oauth.js +87 -0
- package/dist/src/cluster_v2/services/discord/rpc-client.js +325 -0
- package/dist/src/cluster_v2/services/embedding.js +66 -0
- package/dist/src/cluster_v2/services/registry-cache.js +107 -0
- package/dist/src/cluster_v2/services/registry-cache.test.js +66 -0
- package/dist/src/cluster_v2/services/registry.js +36 -0
- package/dist/src/cluster_v2/services/twitter/collector.js +1055 -0
- package/dist/src/cluster_v2/services/twitter/index.js +1 -0
- package/dist/src/config/digest.js +78 -0
- package/dist/src/config/discord.js +143 -0
- package/dist/src/config/image-gen.js +48 -0
- package/dist/src/config/mono-pilot.js +31 -0
- package/dist/src/config/twitter.js +100 -0
- package/dist/src/extensions/cluster.js +311 -0
- package/dist/src/extensions/commands/build-memory.js +76 -0
- package/dist/src/extensions/commands/digest/backfill.js +779 -0
- package/dist/src/extensions/commands/digest/index.js +1133 -0
- package/dist/src/extensions/commands/image-model.js +214 -0
- package/dist/src/extensions/game/bus-injection.js +47 -0
- package/dist/src/extensions/game/identity.js +83 -0
- package/dist/src/extensions/game/mailbox.js +61 -0
- package/dist/src/extensions/game/system-prompt.js +134 -0
- package/dist/src/extensions/game/tools.js +28 -0
- package/dist/src/extensions/lifecycle.js +337 -0
- package/dist/src/extensions/mode-runtime.js +26 -2
- package/dist/src/extensions/mono-game.js +66 -0
- package/dist/src/extensions/mono-pilot.js +100 -18
- package/dist/src/extensions/nvim.js +47 -0
- package/dist/src/extensions/session-hints.js +60 -35
- package/dist/src/extensions/sftp.js +897 -0
- package/dist/src/extensions/status.js +676 -0
- package/dist/src/extensions/system-events.js +478 -0
- package/dist/src/extensions/system-prompt.js +24 -14
- package/dist/src/extensions/user-message.js +94 -50
- package/dist/src/lsp/client.js +235 -0
- package/dist/src/lsp/index.js +165 -0
- package/dist/src/lsp/runtime.js +67 -0
- package/dist/src/lsp/server.js +242 -0
- package/dist/src/mcp/config.js +112 -0
- package/dist/src/{utils/mcp-client.js → mcp/protocol.js} +1 -100
- package/dist/src/mcp/servers.js +90 -0
- package/dist/src/memory/build-memory.js +103 -0
- package/dist/src/memory/config/defaults.js +55 -0
- package/dist/src/memory/config/loader.js +29 -0
- package/dist/src/memory/config/paths.js +9 -0
- package/dist/src/memory/config/resolve.js +90 -0
- package/dist/src/memory/config/types.js +1 -0
- package/dist/src/memory/embeddings/batch-runner.js +39 -0
- package/dist/src/memory/embeddings/cache.js +47 -0
- package/dist/src/memory/embeddings/chunk-limits.js +26 -0
- package/dist/src/memory/embeddings/input-limits.js +48 -0
- package/dist/src/memory/embeddings/local.js +108 -0
- package/dist/src/memory/embeddings/types.js +1 -0
- package/dist/src/memory/index-manager.js +552 -0
- package/dist/src/memory/indexing/embeddings.js +67 -0
- package/dist/src/memory/indexing/files.js +180 -0
- package/dist/src/memory/indexing/index-file.js +105 -0
- package/dist/src/memory/log.js +38 -0
- package/dist/src/memory/paths.js +15 -0
- package/dist/src/memory/runtime/index.js +299 -0
- package/dist/src/memory/runtime/thread.js +116 -0
- package/dist/src/memory/search/fts.js +57 -0
- package/dist/src/memory/search/hybrid.js +50 -0
- package/dist/src/memory/search/text.js +30 -0
- package/dist/src/memory/search/vector.js +43 -0
- package/dist/src/memory/session/content-hash.js +7 -0
- package/dist/src/memory/session/entry.js +33 -0
- package/dist/src/memory/session/flush-policy.js +34 -0
- package/dist/src/memory/session/hook.js +191 -0
- package/dist/src/memory/session/paths.js +15 -0
- package/dist/src/memory/session/session-reader.js +88 -0
- package/dist/src/memory/session/transcript/content-hash.js +7 -0
- package/dist/src/memory/session/transcript/entry.js +28 -0
- package/dist/src/memory/session/transcript/flush.js +56 -0
- package/dist/src/memory/session/transcript/paths.js +28 -0
- package/dist/src/memory/session/transcript/reader.js +112 -0
- package/dist/src/memory/session/transcript/state.js +31 -0
- package/dist/src/memory/store/schema.js +89 -0
- package/dist/src/memory/store/sqlite.js +89 -0
- package/dist/src/memory/types.js +1 -0
- package/dist/src/memory/warm.js +25 -0
- package/dist/src/rules/discovery.js +41 -0
- package/dist/{tools → src/tools}/README.md +29 -3
- package/dist/{tools → src/tools}/apply-patch-description.md +8 -2
- package/dist/{tools → src/tools}/apply-patch.js +174 -104
- package/dist/{tools → src/tools}/apply-patch.test.js +52 -1
- package/dist/{tools/ask-question.js → src/tools/ask-user-question.js} +3 -3
- package/dist/src/tools/ast-grep.js +357 -0
- package/dist/src/tools/brief-write.js +122 -0
- package/dist/src/tools/bus-send.js +100 -0
- package/dist/{tools → src/tools}/call-mcp-tool.js +40 -124
- package/dist/src/tools/codex-apply-patch-description.md +52 -0
- package/dist/src/tools/codex-apply-patch.js +540 -0
- package/dist/{tools → src/tools}/delete.js +24 -0
- package/dist/src/tools/exit-plan-mode.js +83 -0
- package/dist/{tools → src/tools}/fetch-mcp-resource.js +56 -100
- package/dist/src/tools/generate-image.js +567 -0
- package/dist/{tools → src/tools}/glob.js +55 -1
- package/dist/{tools → src/tools}/list-mcp-resources.js +46 -57
- package/dist/{tools → src/tools}/list-mcp-tools.js +52 -63
- package/dist/src/tools/ls.js +48 -0
- package/dist/src/tools/lsp-diagnostics.js +67 -0
- package/dist/src/tools/lsp-symbols.js +54 -0
- package/dist/src/tools/mailbox.js +85 -0
- package/dist/src/tools/memory-get.js +90 -0
- package/dist/src/tools/memory-search.js +180 -0
- package/dist/{tools → src/tools}/plan-mode-reminder.md +3 -4
- package/dist/{tools → src/tools}/read-file.js +8 -19
- package/dist/{tools → src/tools}/rg.js +10 -20
- package/dist/{tools → src/tools}/shell.js +19 -42
- package/dist/{tools → src/tools}/subagent.js +255 -6
- package/dist/{tools → src/tools}/switch-mode.js +37 -6
- package/dist/{tools → src/tools}/web-fetch.js +105 -7
- package/dist/{tools → src/tools}/web-search.js +29 -1
- package/package.json +21 -9
- /package/dist/{tools → src/tools}/ask-mode-reminder.md +0 -0
- /package/dist/{tools → src/tools}/rg.test.js +0 -0
- /package/dist/{tools → src/tools}/semantic-search-description.md +0 -0
- /package/dist/{tools → src/tools}/semantic-search.js +0 -0
- /package/dist/{tools → src/tools}/shell-description.md +0 -0
- /package/dist/{tools → src/tools}/subagent-description.md +0 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { createClusterLogContext, logClusterEvent, RequestCounters, } from "./observability.js";
|
|
2
|
+
/** Wire protocol for cluster_v2 IPC over Unix domain sockets. */
|
|
3
|
+
export const CLUSTER_V2_PROTOCOL_VERSION = 3;
|
|
4
|
+
export function isPush(msg) {
|
|
5
|
+
return "type" in msg && msg.type === "push";
|
|
6
|
+
}
|
|
7
|
+
export function isResponse(msg) {
|
|
8
|
+
return !isPush(msg) && "id" in msg && !("method" in msg);
|
|
9
|
+
}
|
|
10
|
+
// --- Framing ---
|
|
11
|
+
/** Encode as [4-byte little-endian length][utf8 JSON payload]. */
|
|
12
|
+
export function encodeMessage(msg) {
|
|
13
|
+
const json = Buffer.from(JSON.stringify(msg), "utf8");
|
|
14
|
+
const header = Buffer.alloc(4);
|
|
15
|
+
header.writeUInt32LE(json.length, 0);
|
|
16
|
+
return Buffer.concat([header, json]);
|
|
17
|
+
}
|
|
18
|
+
/** Incremental decoder for framed JSON stream. */
|
|
19
|
+
export class MessageDecoder {
|
|
20
|
+
buf = Buffer.alloc(0);
|
|
21
|
+
feed(chunk) {
|
|
22
|
+
this.buf = Buffer.concat([this.buf, chunk]);
|
|
23
|
+
const messages = [];
|
|
24
|
+
while (this.buf.length >= 4) {
|
|
25
|
+
const len = this.buf.readUInt32LE(0);
|
|
26
|
+
if (this.buf.length < 4 + len) {
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
const json = this.buf.subarray(4, 4 + len).toString("utf8");
|
|
30
|
+
this.buf = this.buf.subarray(4 + len);
|
|
31
|
+
messages.push(JSON.parse(json));
|
|
32
|
+
}
|
|
33
|
+
return messages;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class ClusterRpcClient {
|
|
37
|
+
socket;
|
|
38
|
+
nextId = 1;
|
|
39
|
+
pending = new Map();
|
|
40
|
+
terminalStates = new Map();
|
|
41
|
+
counters = new RequestCounters();
|
|
42
|
+
decoder = new MessageDecoder();
|
|
43
|
+
closed = false;
|
|
44
|
+
from;
|
|
45
|
+
logContext;
|
|
46
|
+
disconnectHandlers = new Set();
|
|
47
|
+
pushHandlers = new Map();
|
|
48
|
+
constructor(socket, identity) {
|
|
49
|
+
this.socket = socket;
|
|
50
|
+
this.from = {
|
|
51
|
+
pid: process.pid,
|
|
52
|
+
agentId: identity?.agentId,
|
|
53
|
+
sessionId: identity?.sessionId,
|
|
54
|
+
};
|
|
55
|
+
this.logContext = createClusterLogContext({
|
|
56
|
+
agentId: identity?.agentId,
|
|
57
|
+
sessionId: identity?.sessionId,
|
|
58
|
+
scope: identity?.scope,
|
|
59
|
+
role: identity?.role ?? "rpc_client",
|
|
60
|
+
});
|
|
61
|
+
socket.on("data", (chunk) => {
|
|
62
|
+
const messages = this.decoder.feed(chunk);
|
|
63
|
+
for (const msg of messages) {
|
|
64
|
+
this.handleIncoming(msg);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
socket.on("error", () => {
|
|
68
|
+
logClusterEvent("warn", "rpc_socket_error", this.logContext);
|
|
69
|
+
this.abortAll("socket error");
|
|
70
|
+
});
|
|
71
|
+
socket.on("close", () => {
|
|
72
|
+
logClusterEvent("info", "rpc_socket_closed", this.logContext);
|
|
73
|
+
this.abortAll("socket closed");
|
|
74
|
+
for (const handler of this.disconnectHandlers) {
|
|
75
|
+
handler();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
onDisconnect(handler) {
|
|
80
|
+
this.disconnectHandlers.add(handler);
|
|
81
|
+
return () => {
|
|
82
|
+
this.disconnectHandlers.delete(handler);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Register push listener by method. Use "*" to receive all push methods.
|
|
87
|
+
*/
|
|
88
|
+
onPush(method, handler) {
|
|
89
|
+
const current = this.pushHandlers.get(method) ?? new Set();
|
|
90
|
+
current.add(handler);
|
|
91
|
+
this.pushHandlers.set(method, current);
|
|
92
|
+
return () => {
|
|
93
|
+
const set = this.pushHandlers.get(method);
|
|
94
|
+
if (!set)
|
|
95
|
+
return;
|
|
96
|
+
set.delete(handler);
|
|
97
|
+
if (set.size === 0) {
|
|
98
|
+
this.pushHandlers.delete(method);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
call(method, params, options) {
|
|
103
|
+
if (this.closed) {
|
|
104
|
+
return Promise.reject(new Error("client closed"));
|
|
105
|
+
}
|
|
106
|
+
const id = this.nextId++;
|
|
107
|
+
const timeoutMs = options?.timeoutMs ?? 30_000;
|
|
108
|
+
this.counters.start();
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
this.markTerminal(id, "timeout", method);
|
|
112
|
+
this.pending.delete(id);
|
|
113
|
+
this.assertClientCounters("timeout");
|
|
114
|
+
reject(new Error(`cluster_v2 RPC timeout: ${method}`));
|
|
115
|
+
}, timeoutMs);
|
|
116
|
+
let abortCleanup;
|
|
117
|
+
if (options?.signal) {
|
|
118
|
+
const onAbort = () => {
|
|
119
|
+
this.markTerminal(id, "aborted", method);
|
|
120
|
+
this.pending.delete(id);
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
this.assertClientCounters("aborted");
|
|
123
|
+
reject(new Error(`cluster_v2 RPC aborted: ${method}`));
|
|
124
|
+
};
|
|
125
|
+
if (options.signal.aborted) {
|
|
126
|
+
onAbort();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
130
|
+
abortCleanup = () => options.signal?.removeEventListener("abort", onAbort);
|
|
131
|
+
}
|
|
132
|
+
this.pending.set(id, {
|
|
133
|
+
resolve: (value) => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
abortCleanup?.();
|
|
136
|
+
resolve(value);
|
|
137
|
+
},
|
|
138
|
+
reject: (err) => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
abortCleanup?.();
|
|
141
|
+
reject(err);
|
|
142
|
+
},
|
|
143
|
+
timer,
|
|
144
|
+
method,
|
|
145
|
+
abortCleanup,
|
|
146
|
+
});
|
|
147
|
+
const request = {
|
|
148
|
+
id,
|
|
149
|
+
version: CLUSTER_V2_PROTOCOL_VERSION,
|
|
150
|
+
method,
|
|
151
|
+
params,
|
|
152
|
+
from: this.from,
|
|
153
|
+
};
|
|
154
|
+
this.socket.write(encodeMessage(request));
|
|
155
|
+
this.assertClientCounters("request_started");
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
close() {
|
|
159
|
+
if (this.closed) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this.closed = true;
|
|
163
|
+
logClusterEvent("info", "rpc_client_close", this.logContext);
|
|
164
|
+
this.socket.destroy();
|
|
165
|
+
this.abortAll("client closed");
|
|
166
|
+
}
|
|
167
|
+
handleIncoming(msg) {
|
|
168
|
+
if (isPush(msg)) {
|
|
169
|
+
this.dispatchPush(msg.method, msg.payload);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const response = msg;
|
|
173
|
+
const pending = this.pending.get(response.id);
|
|
174
|
+
if (!pending) {
|
|
175
|
+
if (!this.terminalStates.has(response.id)) {
|
|
176
|
+
logClusterEvent("warn", "response_without_pending", this.logContext, {
|
|
177
|
+
requestId: response.id,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
this.pending.delete(response.id);
|
|
183
|
+
if (response.error) {
|
|
184
|
+
this.markTerminal(response.id, "error", pending.method);
|
|
185
|
+
this.assertClientCounters("response_error");
|
|
186
|
+
pending.reject(new Error(response.error));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.markTerminal(response.id, "ok", pending.method);
|
|
190
|
+
this.assertClientCounters("response_ok");
|
|
191
|
+
pending.resolve(response.result);
|
|
192
|
+
}
|
|
193
|
+
dispatchPush(method, payload) {
|
|
194
|
+
const direct = this.pushHandlers.get(method);
|
|
195
|
+
if (direct) {
|
|
196
|
+
for (const handler of direct) {
|
|
197
|
+
handler(payload);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const wildcard = this.pushHandlers.get("*");
|
|
201
|
+
if (wildcard) {
|
|
202
|
+
for (const handler of wildcard) {
|
|
203
|
+
handler(payload);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
abortAll(reason) {
|
|
208
|
+
const terminalState = reason.includes("closed") ? "closed" : "error";
|
|
209
|
+
for (const [id, pending] of this.pending) {
|
|
210
|
+
this.markTerminal(id, terminalState, pending.method);
|
|
211
|
+
pending.reject(new Error(reason));
|
|
212
|
+
}
|
|
213
|
+
this.pending.clear();
|
|
214
|
+
this.assertClientCounters("abort_all");
|
|
215
|
+
}
|
|
216
|
+
markTerminal(id, state, method) {
|
|
217
|
+
if (id < 0) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const previous = this.terminalStates.get(id);
|
|
221
|
+
if (previous && previous !== state) {
|
|
222
|
+
logClusterEvent("warn", "request_terminalized_twice", this.logContext, {
|
|
223
|
+
requestId: id,
|
|
224
|
+
method,
|
|
225
|
+
fromState: previous,
|
|
226
|
+
toState: state,
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!previous) {
|
|
231
|
+
this.terminalStates.set(id, state);
|
|
232
|
+
this.counters.complete(state);
|
|
233
|
+
}
|
|
234
|
+
if (this.terminalStates.size > 2048) {
|
|
235
|
+
const oldest = this.terminalStates.keys().next().value;
|
|
236
|
+
if (typeof oldest === "number") {
|
|
237
|
+
this.terminalStates.delete(oldest);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
assertClientCounters(source) {
|
|
242
|
+
this.counters.assertConsistency(this.pending.size, this.logContext, source);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
export function bindRpcConnection(socket, handler, options) {
|
|
246
|
+
const maxInFlight = options?.backpressure?.maxInFlight ?? 64;
|
|
247
|
+
const maxQueue = options?.backpressure?.maxQueue ?? 256;
|
|
248
|
+
let inFlight = 0;
|
|
249
|
+
const queue = [];
|
|
250
|
+
const serverContext = createClusterLogContext({ role: "rpc_server" });
|
|
251
|
+
const counters = new RequestCounters();
|
|
252
|
+
const updateServerContext = (request) => {
|
|
253
|
+
if (request.from?.agentId) {
|
|
254
|
+
serverContext.agentId = request.from.agentId;
|
|
255
|
+
}
|
|
256
|
+
if (request.from?.sessionId) {
|
|
257
|
+
serverContext.sessionId = request.from.sessionId;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
const assertRequestAccounting = (source, expectedOutstanding = inFlight + queue.length) => {
|
|
261
|
+
counters.assertConsistency(expectedOutstanding, serverContext, source);
|
|
262
|
+
};
|
|
263
|
+
const assertBackpressureBounds = () => {
|
|
264
|
+
if (inFlight > maxInFlight || queue.length > maxQueue) {
|
|
265
|
+
logClusterEvent("warn", "rpc_backpressure_bounds_exceeded", serverContext, {
|
|
266
|
+
inFlight,
|
|
267
|
+
maxInFlight,
|
|
268
|
+
queued: queue.length,
|
|
269
|
+
maxQueue,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
const decoder = new MessageDecoder();
|
|
274
|
+
const state = new Map();
|
|
275
|
+
const connection = {
|
|
276
|
+
socket,
|
|
277
|
+
state,
|
|
278
|
+
sendPush: (method, payload) => {
|
|
279
|
+
if (socket.destroyed) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
socket.write(encodeMessage({
|
|
283
|
+
type: "push",
|
|
284
|
+
method,
|
|
285
|
+
payload,
|
|
286
|
+
}));
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
const executeRequest = (request) => {
|
|
290
|
+
updateServerContext(request);
|
|
291
|
+
inFlight++;
|
|
292
|
+
assertBackpressureBounds();
|
|
293
|
+
assertRequestAccounting("request_start");
|
|
294
|
+
void (async () => {
|
|
295
|
+
let terminalState = "ok";
|
|
296
|
+
try {
|
|
297
|
+
const result = await handler(request, connection);
|
|
298
|
+
if (!socket.destroyed) {
|
|
299
|
+
socket.write(encodeMessage({
|
|
300
|
+
id: request.id,
|
|
301
|
+
result,
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
terminalState = "error";
|
|
307
|
+
if (!socket.destroyed) {
|
|
308
|
+
socket.write(encodeMessage({
|
|
309
|
+
id: request.id,
|
|
310
|
+
error: error instanceof Error ? error.message : String(error),
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
counters.complete(terminalState);
|
|
316
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
317
|
+
while (inFlight < maxInFlight && queue.length > 0) {
|
|
318
|
+
const queued = queue.shift();
|
|
319
|
+
if (!queued) {
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
executeRequest(queued);
|
|
323
|
+
}
|
|
324
|
+
assertBackpressureBounds();
|
|
325
|
+
assertRequestAccounting("request_end");
|
|
326
|
+
}
|
|
327
|
+
})();
|
|
328
|
+
};
|
|
329
|
+
const enqueueOrExecute = (request) => {
|
|
330
|
+
updateServerContext(request);
|
|
331
|
+
counters.start();
|
|
332
|
+
assertRequestAccounting("request_received", inFlight + queue.length + 1);
|
|
333
|
+
if (queue.length > 0 || inFlight >= maxInFlight) {
|
|
334
|
+
if (queue.length >= maxQueue) {
|
|
335
|
+
counters.complete("error");
|
|
336
|
+
socket.write(encodeMessage({
|
|
337
|
+
id: request.id,
|
|
338
|
+
error: `server overloaded: queue limit ${maxQueue} reached`,
|
|
339
|
+
}));
|
|
340
|
+
assertRequestAccounting("queue_overload");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
queue.push(request);
|
|
344
|
+
assertBackpressureBounds();
|
|
345
|
+
assertRequestAccounting("request_queued");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
executeRequest(request);
|
|
349
|
+
};
|
|
350
|
+
socket.on("data", (chunk) => {
|
|
351
|
+
const messages = decoder.feed(chunk);
|
|
352
|
+
for (const msg of messages) {
|
|
353
|
+
if (isPush(msg)) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const request = msg;
|
|
357
|
+
if (request.version !== CLUSTER_V2_PROTOCOL_VERSION) {
|
|
358
|
+
const response = {
|
|
359
|
+
id: request.id,
|
|
360
|
+
error: `unsupported protocol version: ${request.version}`,
|
|
361
|
+
};
|
|
362
|
+
socket.write(encodeMessage(response));
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
enqueueOrExecute(request);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
socket.on("error", (error) => {
|
|
369
|
+
logClusterEvent("warn", "rpc_server_socket_error", serverContext, {
|
|
370
|
+
error: error instanceof Error ? error.message : String(error),
|
|
371
|
+
});
|
|
372
|
+
options?.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
373
|
+
});
|
|
374
|
+
socket.on("close", () => {
|
|
375
|
+
if (queue.length > 0) {
|
|
376
|
+
const dropped = queue.length;
|
|
377
|
+
for (let i = 0; i < dropped; i++) {
|
|
378
|
+
counters.complete("closed");
|
|
379
|
+
}
|
|
380
|
+
queue.length = 0;
|
|
381
|
+
logClusterEvent("warn", "rpc_server_queue_dropped_on_close", serverContext, {
|
|
382
|
+
dropped,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
assertRequestAccounting("socket_close");
|
|
386
|
+
options?.onClose?.(connection);
|
|
387
|
+
});
|
|
388
|
+
return connection;
|
|
389
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { bindRpcConnection, ClusterRpcClient, encodeMessage, MessageDecoder, } from "./rpc.js";
|
|
7
|
+
const activeServers = [];
|
|
8
|
+
const activeClients = [];
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
for (const client of activeClients.splice(0)) {
|
|
11
|
+
client.close();
|
|
12
|
+
}
|
|
13
|
+
for (const handle of activeServers.splice(0)) {
|
|
14
|
+
await new Promise((resolve) => handle.server.close(() => resolve()));
|
|
15
|
+
await rm(handle.dir, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
async function startRpcServer(handler, options) {
|
|
19
|
+
const dir = await mkdtemp(join(tmpdir(), "cluster-v2-rpc-test-"));
|
|
20
|
+
const socketPath = join(dir, "rpc.sock");
|
|
21
|
+
const server = net.createServer((socket) => {
|
|
22
|
+
bindRpcConnection(socket, handler, {
|
|
23
|
+
backpressure: {
|
|
24
|
+
maxInFlight: options?.maxInFlight,
|
|
25
|
+
maxQueue: options?.maxQueue,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
await new Promise((resolve, reject) => {
|
|
30
|
+
server.once("error", reject);
|
|
31
|
+
server.listen(socketPath, () => resolve());
|
|
32
|
+
});
|
|
33
|
+
const handle = { dir, socketPath, server };
|
|
34
|
+
activeServers.push(handle);
|
|
35
|
+
return handle;
|
|
36
|
+
}
|
|
37
|
+
async function connectClient(socketPath) {
|
|
38
|
+
const socket = await new Promise((resolve, reject) => {
|
|
39
|
+
const s = net.createConnection(socketPath);
|
|
40
|
+
s.once("connect", () => resolve(s));
|
|
41
|
+
s.once("error", reject);
|
|
42
|
+
});
|
|
43
|
+
const client = new ClusterRpcClient(socket, {
|
|
44
|
+
agentId: "rpc-test",
|
|
45
|
+
sessionId: "session-test",
|
|
46
|
+
scope: "rpc-test",
|
|
47
|
+
role: "rpc-test-client",
|
|
48
|
+
});
|
|
49
|
+
activeClients.push(client);
|
|
50
|
+
return client;
|
|
51
|
+
}
|
|
52
|
+
function sleep(ms) {
|
|
53
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
|
+
}
|
|
55
|
+
describe("cluster_v2 rpc", () => {
|
|
56
|
+
it("rejects timeout path deterministically", async () => {
|
|
57
|
+
const server = await startRpcServer(async () => {
|
|
58
|
+
await new Promise(() => {
|
|
59
|
+
// never resolves
|
|
60
|
+
});
|
|
61
|
+
return null;
|
|
62
|
+
});
|
|
63
|
+
const client = await connectClient(server.socketPath);
|
|
64
|
+
await expect(client.call("hang", {}, { timeoutMs: 40 })).rejects.toThrow("timeout");
|
|
65
|
+
});
|
|
66
|
+
it("ignores late duplicate response for same request id", async () => {
|
|
67
|
+
const dir = await mkdtemp(join(tmpdir(), "cluster-v2-rpc-dup-"));
|
|
68
|
+
const socketPath = join(dir, "rpc.sock");
|
|
69
|
+
const server = net.createServer((socket) => {
|
|
70
|
+
const decoder = new MessageDecoder();
|
|
71
|
+
socket.on("data", (chunk) => {
|
|
72
|
+
const messages = decoder.feed(chunk);
|
|
73
|
+
for (const msg of messages) {
|
|
74
|
+
if (!("method" in msg))
|
|
75
|
+
continue;
|
|
76
|
+
const req = msg;
|
|
77
|
+
const first = { id: req.id, result: { value: 1 } };
|
|
78
|
+
const duplicate = { id: req.id, result: { value: 2 } };
|
|
79
|
+
socket.write(encodeMessage(first));
|
|
80
|
+
socket.write(encodeMessage(duplicate));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
await new Promise((resolve, reject) => {
|
|
85
|
+
server.once("error", reject);
|
|
86
|
+
server.listen(socketPath, () => resolve());
|
|
87
|
+
});
|
|
88
|
+
activeServers.push({ dir, socketPath, server });
|
|
89
|
+
const client = await connectClient(socketPath);
|
|
90
|
+
const result = await client.call("dup", {}, { timeoutMs: 200 });
|
|
91
|
+
expect(result.value).toBe(1);
|
|
92
|
+
});
|
|
93
|
+
it("enforces backpressure bounds and returns overload error", async () => {
|
|
94
|
+
const server = await startRpcServer(async () => {
|
|
95
|
+
await sleep(80);
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}, { maxInFlight: 1, maxQueue: 1 });
|
|
98
|
+
const client = await connectClient(server.socketPath);
|
|
99
|
+
const p1 = client.call("slow", { n: 1 }, { timeoutMs: 500 });
|
|
100
|
+
const p2 = client.call("slow", { n: 2 }, { timeoutMs: 500 });
|
|
101
|
+
const p3 = client.call("slow", { n: 3 }, { timeoutMs: 500 });
|
|
102
|
+
const [r1, r2, r3] = await Promise.allSettled([p1, p2, p3]);
|
|
103
|
+
expect(r1.status).toBe("fulfilled");
|
|
104
|
+
expect(r2.status).toBe("fulfilled");
|
|
105
|
+
expect(r3.status).toBe("rejected");
|
|
106
|
+
if (r3.status === "rejected") {
|
|
107
|
+
expect(String(r3.reason?.message ?? r3.reason)).toContain("queue limit 1 reached");
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { tryListen } from "./connection.js";
|
|
3
|
+
import { bindRpcConnection, } from "./rpc.js";
|
|
4
|
+
import { createEmbeddingHandlers } from "./services/embedding.js";
|
|
5
|
+
import { createBusService } from "./services/bus.js";
|
|
6
|
+
import { ServiceRegistry } from "./services/registry.js";
|
|
7
|
+
import { closeClusterV2, initClusterV2 } from "./runtime.js";
|
|
8
|
+
function createMockEmbeddingProvider(model) {
|
|
9
|
+
return {
|
|
10
|
+
id: "local",
|
|
11
|
+
model,
|
|
12
|
+
embedQuery: async (text) => [text.length],
|
|
13
|
+
embedBatch: async (texts) => texts.map((text) => [text.length]),
|
|
14
|
+
dispose: async () => {
|
|
15
|
+
// no-op in tests
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
vi.mock("../memory/embeddings/local.js", () => ({
|
|
20
|
+
createLocalEmbeddingProvider: async (params) => createMockEmbeddingProvider(params.modelPath ?? "mock-local"),
|
|
21
|
+
}));
|
|
22
|
+
let externalLeader = null;
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
if (externalLeader) {
|
|
25
|
+
await externalLeader.close();
|
|
26
|
+
externalLeader = null;
|
|
27
|
+
}
|
|
28
|
+
await closeClusterV2();
|
|
29
|
+
});
|
|
30
|
+
function uniqueScope(prefix) {
|
|
31
|
+
return `${prefix}-${process.pid}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
|
32
|
+
}
|
|
33
|
+
async function sleep(ms) {
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
35
|
+
}
|
|
36
|
+
async function waitForRole(params, role, timeoutMs) {
|
|
37
|
+
const deadline = Date.now() + timeoutMs;
|
|
38
|
+
let last = null;
|
|
39
|
+
while (Date.now() < deadline) {
|
|
40
|
+
last = await initClusterV2(params);
|
|
41
|
+
if (last.role === role) {
|
|
42
|
+
return last;
|
|
43
|
+
}
|
|
44
|
+
await sleep(25);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`timed out waiting for role=${role}, last=${last?.role ?? "none"}`);
|
|
47
|
+
}
|
|
48
|
+
function registerDefaultServices(registry) {
|
|
49
|
+
const services = [
|
|
50
|
+
{ name: "embedding", version: "v2", capabilities: { methods: ["embedding.embedBatch"] } },
|
|
51
|
+
{
|
|
52
|
+
name: "bus",
|
|
53
|
+
version: "v2",
|
|
54
|
+
capabilities: {
|
|
55
|
+
methods: ["bus.register", "bus.subscribe", "bus.send", "bus.broadcast", "bus.roster"],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
for (const service of services) {
|
|
60
|
+
registry.register(service);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function startExternalLeader(scope) {
|
|
64
|
+
const connection = await tryListen({ scope });
|
|
65
|
+
if (!connection) {
|
|
66
|
+
throw new Error("failed to start external leader for test");
|
|
67
|
+
}
|
|
68
|
+
const registry = new ServiceRegistry();
|
|
69
|
+
registerDefaultServices(registry);
|
|
70
|
+
const busService = createBusService();
|
|
71
|
+
const provider = createMockEmbeddingProvider("external-leader");
|
|
72
|
+
const embeddingHandlers = createEmbeddingHandlers(provider);
|
|
73
|
+
const handlers = {
|
|
74
|
+
"cluster.ping": async () => "pong",
|
|
75
|
+
"registry.list": async () => registry.snapshot(),
|
|
76
|
+
"registry.resolve": async (request) => {
|
|
77
|
+
const name = request.params?.name;
|
|
78
|
+
if (!name || typeof name !== "string") {
|
|
79
|
+
throw new Error("registry.resolve requires string name");
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
revision: registry.getRevision(),
|
|
83
|
+
service: registry.resolve(name),
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
...embeddingHandlers,
|
|
87
|
+
...busService.handlers,
|
|
88
|
+
};
|
|
89
|
+
const sockets = new Set();
|
|
90
|
+
connection.server.on("connection", (socket) => {
|
|
91
|
+
sockets.add(socket);
|
|
92
|
+
socket.on("close", () => {
|
|
93
|
+
sockets.delete(socket);
|
|
94
|
+
});
|
|
95
|
+
bindRpcConnection(socket, async (request, rpcConnection) => {
|
|
96
|
+
const handler = handlers[request.method];
|
|
97
|
+
if (!handler) {
|
|
98
|
+
throw new Error(`unknown method: ${request.method}`);
|
|
99
|
+
}
|
|
100
|
+
return handler(request, rpcConnection);
|
|
101
|
+
}, {
|
|
102
|
+
onClose: (rpcConnection) => {
|
|
103
|
+
busService.onConnectionClosed(rpcConnection);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
const bus = busService.createLeaderHandle("leader-harness", "Leader Harness");
|
|
108
|
+
return {
|
|
109
|
+
connection,
|
|
110
|
+
bus,
|
|
111
|
+
close: async () => {
|
|
112
|
+
for (const socket of sockets) {
|
|
113
|
+
socket.destroy();
|
|
114
|
+
}
|
|
115
|
+
await connection.close();
|
|
116
|
+
bus.close();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
describe("cluster_v2 runtime failover integration", () => {
|
|
121
|
+
it("re-elects after leader crash and keeps embedding/bus usable", async () => {
|
|
122
|
+
const scope = uniqueScope("runtime-failover");
|
|
123
|
+
externalLeader = await startExternalLeader(scope);
|
|
124
|
+
const params = {
|
|
125
|
+
agentId: "follower-a",
|
|
126
|
+
displayName: "Follower A",
|
|
127
|
+
scope,
|
|
128
|
+
modelPath: "mock-local",
|
|
129
|
+
rpcTimeoutMs: 500,
|
|
130
|
+
getSessionId: () => "session-a",
|
|
131
|
+
};
|
|
132
|
+
const follower = await initClusterV2(params);
|
|
133
|
+
expect(follower.role).toBe("follower");
|
|
134
|
+
expect(follower.bus).not.toBeNull();
|
|
135
|
+
const beforeFailoverEmbedding = await follower.embedding.embedBatch(["x", "hello"]);
|
|
136
|
+
expect(beforeFailoverEmbedding).toEqual([[1], [5]]);
|
|
137
|
+
const followerMessages = [];
|
|
138
|
+
follower.bus?.onMessage((msg) => {
|
|
139
|
+
if (typeof msg.payload === "object" && msg.payload !== null && "text" in msg.payload) {
|
|
140
|
+
followerMessages.push(msg.payload);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
await externalLeader.bus.send("follower-a", { text: "before-failover" });
|
|
144
|
+
await sleep(30);
|
|
145
|
+
expect(followerMessages.some((m) => m.text === "before-failover")).toBe(true);
|
|
146
|
+
await externalLeader.close();
|
|
147
|
+
externalLeader = null;
|
|
148
|
+
const reElected = await waitForRole(params, "leader", 4000);
|
|
149
|
+
expect(reElected.role).toBe("leader");
|
|
150
|
+
expect(reElected.bus).not.toBeNull();
|
|
151
|
+
const afterFailoverEmbedding = await reElected.embedding.embedQuery("world");
|
|
152
|
+
expect(afterFailoverEmbedding).toEqual([5]);
|
|
153
|
+
const broadcastResult = await reElected.bus?.broadcast({ text: "after-failover" }, "public");
|
|
154
|
+
expect(typeof broadcastResult?.seq).toBe("number");
|
|
155
|
+
});
|
|
156
|
+
});
|