mcp-coordinator 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dashboard/public/index.html +23 -0
- package/dist/cli/server/backup.d.ts +7 -0
- package/dist/cli/server/backup.js +162 -0
- package/dist/cli/server/index.js +5 -0
- package/dist/cli/server/restore.d.ts +2 -0
- package/dist/cli/server/restore.js +117 -0
- package/dist/cli/server/start.js +33 -0
- package/dist/src/announce-workflow.d.ts +1 -0
- package/dist/src/announce-workflow.js +28 -0
- package/dist/src/consultation.d.ts +8 -0
- package/dist/src/consultation.js +8 -0
- package/dist/src/database.js +65 -0
- package/dist/src/db-adapter.d.ts +30 -0
- package/dist/src/db-adapter.js +32 -1
- package/dist/src/dependency-map.js +2 -2
- package/dist/src/file-tracker.d.ts +12 -0
- package/dist/src/file-tracker.js +35 -2
- package/dist/src/git-cochange-builder.d.ts +32 -0
- package/dist/src/git-cochange-builder.js +238 -0
- package/dist/src/http/handle-health.d.ts +23 -0
- package/dist/src/http/handle-health.js +112 -0
- package/dist/src/http/handle-rest.js +83 -2
- package/dist/src/http/utils.d.ts +0 -4
- package/dist/src/http/utils.js +16 -2
- package/dist/src/impact-scorer.d.ts +5 -1
- package/dist/src/impact-scorer.js +182 -55
- package/dist/src/metrics.d.ts +88 -0
- package/dist/src/metrics.js +195 -0
- package/dist/src/mqtt-bridge.d.ts +19 -0
- package/dist/src/mqtt-bridge.js +53 -5
- package/dist/src/path-normalize.d.ts +17 -0
- package/dist/src/path-normalize.js +38 -0
- package/dist/src/serve-http.js +76 -3
- package/dist/src/server-setup.d.ts +8 -0
- package/dist/src/server-setup.js +31 -3
- package/dist/src/sse-emitter.d.ts +6 -0
- package/dist/src/sse-emitter.js +50 -2
- package/dist/src/tools/consultation-tools.js +4 -2
- package/dist/src/tree-sitter-extractor.d.ts +36 -0
- package/dist/src/tree-sitter-extractor.js +354 -0
- package/dist/src/working-files-tracker.d.ts +42 -0
- package/dist/src/working-files-tracker.js +111 -0
- package/package.json +20 -1
package/dist/src/mqtt-bridge.js
CHANGED
|
@@ -6,6 +6,14 @@ export class MqttBridge {
|
|
|
6
6
|
onOfflineHandler = null;
|
|
7
7
|
listeners = new Map();
|
|
8
8
|
log;
|
|
9
|
+
agentId = "coordinator-internal";
|
|
10
|
+
/**
|
|
11
|
+
* P1: track the last threadId we retained on `coordinator/consultations/new`.
|
|
12
|
+
* The topic is fixed (not per-thread), so retain holds only the LAST event.
|
|
13
|
+
* `clearRetainedConsultation(threadId)` only clears when it matches, so a
|
|
14
|
+
* later consultation isn't accidentally wiped by a stale resolve callback.
|
|
15
|
+
*/
|
|
16
|
+
lastRetainedConsultationThreadId = null;
|
|
9
17
|
constructor(logger) {
|
|
10
18
|
this.log = logger || silentLogger;
|
|
11
19
|
}
|
|
@@ -14,11 +22,24 @@ export class MqttBridge {
|
|
|
14
22
|
const timeout = setTimeout(() => {
|
|
15
23
|
reject(new Error("MQTT connection timeout"));
|
|
16
24
|
}, 5000);
|
|
25
|
+
// P1 fix: LWT requires a stable agent identifier. Default to
|
|
26
|
+
// "coordinator-internal" which matches the auth identity used by
|
|
27
|
+
// serve-http for the embedded broker bridge.
|
|
28
|
+
this.agentId = config.agentId || "coordinator-internal";
|
|
17
29
|
this.client = mqtt.connect(config.url, {
|
|
18
|
-
clientId:
|
|
30
|
+
clientId: `${this.agentId}-${Date.now()}`,
|
|
19
31
|
clean: true,
|
|
20
32
|
username: config.username,
|
|
21
33
|
password: config.password,
|
|
34
|
+
// P1 fix: register Last Will & Testament so a crashed/disconnected
|
|
35
|
+
// bridge automatically broadcasts offline status. Without this the
|
|
36
|
+
// agent appears online indefinitely after an unexpected disconnect.
|
|
37
|
+
will: {
|
|
38
|
+
topic: `coordinator/agents/${this.agentId}/status`,
|
|
39
|
+
payload: Buffer.from(JSON.stringify({ status: "offline", reason: "lwt_unexpected" })),
|
|
40
|
+
qos: 1,
|
|
41
|
+
retain: false,
|
|
42
|
+
},
|
|
22
43
|
});
|
|
23
44
|
this.client.on("connect", () => {
|
|
24
45
|
clearTimeout(timeout);
|
|
@@ -80,17 +101,40 @@ export class MqttBridge {
|
|
|
80
101
|
publishConsultation(threadId, agentId, subject, targetModules) {
|
|
81
102
|
if (!this.client || !this.connected)
|
|
82
103
|
return;
|
|
83
|
-
|
|
104
|
+
// P1 fix: QoS 1 (at-least-once) so consultation events survive transient
|
|
105
|
+
// disconnects. retain=true so a coordinator/subscriber restart can rebuild
|
|
106
|
+
// the active state without an event-history replay.
|
|
107
|
+
this.lastRetainedConsultationThreadId = threadId;
|
|
108
|
+
this.client.publish("coordinator/consultations/new", JSON.stringify({ thread_id: threadId, agent_id: agentId, subject, target_modules: targetModules }), { qos: 1, retain: true });
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* P1 fix: clear the retained `coordinator/consultations/new` event when the
|
|
112
|
+
* matching thread resolves. The topic is fixed (not per-thread), so retain
|
|
113
|
+
* holds only the LAST consultation — clearing here means a coordinator
|
|
114
|
+
* restart after resolution doesn't re-broadcast a stale "new" event.
|
|
115
|
+
*
|
|
116
|
+
* No-op when the supplied threadId doesn't match the currently retained one
|
|
117
|
+
* (a newer consultation has already overwritten it).
|
|
118
|
+
*/
|
|
119
|
+
clearRetainedConsultation(threadId) {
|
|
120
|
+
if (!this.client || !this.connected)
|
|
121
|
+
return;
|
|
122
|
+
if (this.lastRetainedConsultationThreadId !== threadId)
|
|
123
|
+
return;
|
|
124
|
+
this.client.publish("coordinator/consultations/new", "", { qos: 1, retain: true });
|
|
125
|
+
this.lastRetainedConsultationThreadId = null;
|
|
84
126
|
}
|
|
85
127
|
publishMessage(threadId, agentId, type, content) {
|
|
86
128
|
if (!this.client || !this.connected)
|
|
87
129
|
return;
|
|
130
|
+
// QoS 0: high-frequency chat-style traffic, lossy-OK.
|
|
88
131
|
this.client.publish(`coordinator/consultations/${threadId}/messages`, JSON.stringify({ agent_id: agentId, type, content }));
|
|
89
132
|
}
|
|
90
133
|
publishResolution(threadId, status, summary) {
|
|
91
134
|
if (!this.client || !this.connected)
|
|
92
135
|
return;
|
|
93
|
-
|
|
136
|
+
// P1 fix: QoS 1 (at-least-once) — resolution is a state-change event.
|
|
137
|
+
this.client.publish(`coordinator/consultations/${threadId}/status`, JSON.stringify({ status, summary }), { qos: 1, retain: true });
|
|
94
138
|
}
|
|
95
139
|
publishBroadcast(agentId, message) {
|
|
96
140
|
if (!this.client || !this.connected)
|
|
@@ -105,12 +149,15 @@ export class MqttBridge {
|
|
|
105
149
|
publishTaskClaimed(threadId, claimedBy) {
|
|
106
150
|
if (!this.client || !this.connected)
|
|
107
151
|
return;
|
|
108
|
-
|
|
152
|
+
// P1 fix: QoS 1 — claim is a coordination state-change. Loss would mean
|
|
153
|
+
// multiple agents think a task is unclaimed.
|
|
154
|
+
this.client.publish(`coordinator/consultations/${threadId}/claimed`, JSON.stringify({ agent_id: claimedBy, claimed_by: claimedBy, claimed_at: new Date().toISOString() }), { qos: 1 });
|
|
109
155
|
}
|
|
110
156
|
publishTaskCompleted(threadId, completedBy, summary) {
|
|
111
157
|
if (!this.client || !this.connected)
|
|
112
158
|
return;
|
|
113
|
-
|
|
159
|
+
// P1 fix: QoS 1 — completion is a coordination state-change.
|
|
160
|
+
this.client.publish(`coordinator/consultations/${threadId}/completed`, JSON.stringify({ agent_id: completedBy, completed_by: completedBy, summary }), { qos: 1 });
|
|
114
161
|
}
|
|
115
162
|
/**
|
|
116
163
|
* Fanout a refreshed QuotaInfo to live subscribers (dashboard widget,
|
|
@@ -120,6 +167,7 @@ export class MqttBridge {
|
|
|
120
167
|
publishQuotaUpdate(info) {
|
|
121
168
|
if (!this.client || !this.connected)
|
|
122
169
|
return;
|
|
170
|
+
// QoS 0: high-frequency telemetry, lossy-OK (the next refresh overwrites).
|
|
123
171
|
this.client.publish("coordinator/quota/update", JSON.stringify(info));
|
|
124
172
|
}
|
|
125
173
|
// ── Agent listener methods (for integrated MCP tools) ──
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a file path for matching/correctness — NOT security.
|
|
3
|
+
*
|
|
4
|
+
* Returns POSIX (forward slash), repo-relative when repoRoot is provided,
|
|
5
|
+
* lower-cased when the path is Windows-style (drive letter prefix in repoRoot
|
|
6
|
+
* or input, or backslash in input). Collapses ./ and .. segments via
|
|
7
|
+
* path.posix.normalize.
|
|
8
|
+
*
|
|
9
|
+
* The lowercase pass is anchored to path SHAPE rather than `process.platform`
|
|
10
|
+
* so a Linux coordinator processing paths from a Windows agent (or a CI run
|
|
11
|
+
* exercising Windows-shaped fixtures) still produces consistent canonical
|
|
12
|
+
* forms.
|
|
13
|
+
*
|
|
14
|
+
* Throws when an absolute path falls outside repoRoot. Security path
|
|
15
|
+
* traversal checks are separate (see path-guard.ts:safeJoinUnderRoot).
|
|
16
|
+
*/
|
|
17
|
+
export declare function normalizePath(repoRoot: string | null, input: string): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a file path for matching/correctness — NOT security.
|
|
4
|
+
*
|
|
5
|
+
* Returns POSIX (forward slash), repo-relative when repoRoot is provided,
|
|
6
|
+
* lower-cased when the path is Windows-style (drive letter prefix in repoRoot
|
|
7
|
+
* or input, or backslash in input). Collapses ./ and .. segments via
|
|
8
|
+
* path.posix.normalize.
|
|
9
|
+
*
|
|
10
|
+
* The lowercase pass is anchored to path SHAPE rather than `process.platform`
|
|
11
|
+
* so a Linux coordinator processing paths from a Windows agent (or a CI run
|
|
12
|
+
* exercising Windows-shaped fixtures) still produces consistent canonical
|
|
13
|
+
* forms.
|
|
14
|
+
*
|
|
15
|
+
* Throws when an absolute path falls outside repoRoot. Security path
|
|
16
|
+
* traversal checks are separate (see path-guard.ts:safeJoinUnderRoot).
|
|
17
|
+
*/
|
|
18
|
+
export function normalizePath(repoRoot, input) {
|
|
19
|
+
const isWindowsStyle = (repoRoot != null && (/^[a-zA-Z]:/.test(repoRoot) || repoRoot.includes("\\"))) ||
|
|
20
|
+
/^[a-zA-Z]:/.test(input) ||
|
|
21
|
+
input.includes("\\");
|
|
22
|
+
let p = input.replace(/\\/g, "/");
|
|
23
|
+
if (repoRoot) {
|
|
24
|
+
const root = repoRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
25
|
+
if (path.isAbsolute(input) || /^[a-zA-Z]:/.test(input)) {
|
|
26
|
+
const lowerP = isWindowsStyle ? p.toLowerCase() : p;
|
|
27
|
+
const lowerRoot = isWindowsStyle ? root.toLowerCase() : root;
|
|
28
|
+
if (!lowerP.startsWith(lowerRoot + "/") && lowerP !== lowerRoot) {
|
|
29
|
+
throw new Error(`path is outside repoRoot: ${input}`);
|
|
30
|
+
}
|
|
31
|
+
p = p.slice(root.length).replace(/^\/+/, "");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
p = path.posix.normalize(p).replace(/^\.\//, "");
|
|
35
|
+
if (isWindowsStyle)
|
|
36
|
+
p = p.toLowerCase();
|
|
37
|
+
return p;
|
|
38
|
+
}
|
package/dist/src/serve-http.js
CHANGED
|
@@ -17,6 +17,8 @@ import { createLogger } from "./logger.js";
|
|
|
17
17
|
import { initAuth, authenticateRequest, createToken, refreshToken, revokeAgent, setAuthLogger, verifyToken } from "./auth.js";
|
|
18
18
|
import { safeJoinUnderRoot } from "./path-guard.js";
|
|
19
19
|
import { handleRest as handleRestExt } from "./http/handle-rest.js";
|
|
20
|
+
import { handleLivez, handleReadyz, handleHealth } from "./http/handle-health.js";
|
|
21
|
+
import { serveMetrics } from "./metrics.js";
|
|
20
22
|
import { parseBody as parseBodyShared, json as jsonShared } from "./http/utils.js";
|
|
21
23
|
import { getVersion } from "../cli/version.js";
|
|
22
24
|
const VERSION = getVersion();
|
|
@@ -83,7 +85,15 @@ async function handleRest(req, res) {
|
|
|
83
85
|
}
|
|
84
86
|
async function handleAuth(req, res) {
|
|
85
87
|
const url = req.url || "";
|
|
86
|
-
|
|
88
|
+
let body;
|
|
89
|
+
try {
|
|
90
|
+
body = await parseBody(req);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const e = err;
|
|
94
|
+
json(res, { error: e.message || "Invalid request" }, e.statusCode || 400);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
87
97
|
if (url === "/api/auth/register" && req.method === "POST") {
|
|
88
98
|
const { agent_name, registration_secret } = body;
|
|
89
99
|
if (!agent_name || !registration_secret) {
|
|
@@ -167,6 +177,16 @@ function writeSseEvent(res, event) {
|
|
|
167
177
|
const data = injectTimestamp(event.payload, event.created_at ?? new Date().toISOString());
|
|
168
178
|
res.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${data}\n\n`);
|
|
169
179
|
}
|
|
180
|
+
// P3: heartbeat interval in ms. Default 30s — well under nginx/Cloudflare's
|
|
181
|
+
// typical 60s idle SSE timeout, but infrequent enough to add negligible
|
|
182
|
+
// bandwidth (one ":keep-alive\n\n" comment is ~16 bytes).
|
|
183
|
+
const SSE_HEARTBEAT_MS = (() => {
|
|
184
|
+
const raw = process.env.COORDINATOR_SSE_HEARTBEAT_MS;
|
|
185
|
+
if (!raw)
|
|
186
|
+
return 30_000;
|
|
187
|
+
const n = parseInt(raw, 10);
|
|
188
|
+
return Number.isFinite(n) && n > 0 ? n : 30_000;
|
|
189
|
+
})();
|
|
170
190
|
function handleSse(req, res) {
|
|
171
191
|
res.writeHead(200, {
|
|
172
192
|
"Content-Type": "text/event-stream",
|
|
@@ -174,6 +194,8 @@ function handleSse(req, res) {
|
|
|
174
194
|
Connection: "keep-alive",
|
|
175
195
|
"Access-Control-Allow-Origin": "*",
|
|
176
196
|
});
|
|
197
|
+
services.metrics.incSseClients();
|
|
198
|
+
services.metrics.recordHttpRequest("/api/events", 200);
|
|
177
199
|
// Use Last-Event-ID for resumption, otherwise send last 50
|
|
178
200
|
const lastEventId = parseInt(req.headers["last-event-id"] || "0", 10);
|
|
179
201
|
const events = lastEventId > 0
|
|
@@ -186,7 +208,29 @@ function handleSse(req, res) {
|
|
|
186
208
|
const unsubscribe = services.sseEmitter.addListener((event) => {
|
|
187
209
|
writeSseEvent(res, event);
|
|
188
210
|
});
|
|
189
|
-
|
|
211
|
+
// P3: heartbeat. Browsers ignore the `:` comment line per the SSE spec,
|
|
212
|
+
// but it counts as activity for intermediate proxies that would otherwise
|
|
213
|
+
// kill an idle connection after ~60s. Wrapped in try/catch because once
|
|
214
|
+
// the socket is half-closed res.write throws synchronously.
|
|
215
|
+
const heartbeat = setInterval(() => {
|
|
216
|
+
try {
|
|
217
|
+
res.write(":keep-alive\n\n");
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Connection already torn down — req.on("close") will clean up shortly.
|
|
221
|
+
}
|
|
222
|
+
}, SSE_HEARTBEAT_MS);
|
|
223
|
+
// Don't keep the event loop alive solely for heartbeats; without unref()
|
|
224
|
+
// a still-open SSE connection at process shutdown delays exit.
|
|
225
|
+
if (typeof heartbeat.unref === "function")
|
|
226
|
+
heartbeat.unref();
|
|
227
|
+
req.on("close", () => {
|
|
228
|
+
// P3: clear the interval BEFORE unsubscribing so a heartbeat tick that
|
|
229
|
+
// fires between close and unsubscribe can't write to a dead socket.
|
|
230
|
+
clearInterval(heartbeat);
|
|
231
|
+
unsubscribe();
|
|
232
|
+
services.metrics.decSseClients();
|
|
233
|
+
});
|
|
190
234
|
}
|
|
191
235
|
export async function startServer(opts) {
|
|
192
236
|
const port = opts?.port ?? PORT;
|
|
@@ -272,8 +316,21 @@ export async function startServer(opts) {
|
|
|
272
316
|
}
|
|
273
317
|
return;
|
|
274
318
|
}
|
|
319
|
+
else if (url === "/livez") {
|
|
320
|
+
handleLivez(req, res);
|
|
321
|
+
services.metrics.recordHttpRequest("/livez", 200);
|
|
322
|
+
}
|
|
323
|
+
else if (url === "/readyz") {
|
|
324
|
+
handleReadyz(req, res, services);
|
|
325
|
+
services.metrics.recordHttpRequest("/readyz", res.statusCode || 0);
|
|
326
|
+
}
|
|
275
327
|
else if (url === "/health") {
|
|
276
|
-
|
|
328
|
+
handleHealth(req, res);
|
|
329
|
+
services.metrics.recordHttpRequest("/health", 200);
|
|
330
|
+
}
|
|
331
|
+
else if (url === "/metrics" && req.method === "GET") {
|
|
332
|
+
await serveMetrics(req, res, services, services.metrics);
|
|
333
|
+
services.metrics.recordHttpRequest("/metrics", 200);
|
|
277
334
|
}
|
|
278
335
|
else if (url === "/api/events" && req.method === "GET") {
|
|
279
336
|
handleSse(req, res);
|
|
@@ -330,15 +387,18 @@ export async function startServer(opts) {
|
|
|
330
387
|
const authResult = await authenticateRequest(req);
|
|
331
388
|
if (!authResult.ok) {
|
|
332
389
|
authLog.warn({ reason: authResult.error, url, ip: req.socket.remoteAddress }, "Auth rejected");
|
|
390
|
+
services.metrics.recordAuthRejected();
|
|
333
391
|
json(res, { error: authResult.error }, authResult.status);
|
|
334
392
|
return;
|
|
335
393
|
}
|
|
336
394
|
}
|
|
337
395
|
if (url.startsWith("/api/") && (req.method === "POST" || req.method === "GET")) {
|
|
338
396
|
await handleRest(req, res);
|
|
397
|
+
services.metrics.recordHttpRequest((url.split("?")[0] || ""), res.statusCode || 0);
|
|
339
398
|
}
|
|
340
399
|
else {
|
|
341
400
|
json(res, { error: "not found" }, 404);
|
|
401
|
+
services.metrics.recordHttpRequest((url.split("?")[0] || ""), 404);
|
|
342
402
|
}
|
|
343
403
|
}
|
|
344
404
|
}
|
|
@@ -380,10 +440,17 @@ export async function startServer(opts) {
|
|
|
380
440
|
url: `mqtt://127.0.0.1:${mqttTcpPort}`,
|
|
381
441
|
username: AUTH_ENABLED ? "coordinator-internal" : undefined,
|
|
382
442
|
password: internalToken,
|
|
443
|
+
// P1 fix: stable agent identity for LWT topic
|
|
444
|
+
// (`coordinator/agents/coordinator-internal/status`).
|
|
445
|
+
agentId: "coordinator-internal",
|
|
383
446
|
});
|
|
384
447
|
services.mqttBridge.onOffline((agentId) => {
|
|
385
448
|
services.registry.setOffline(agentId);
|
|
386
449
|
services.consultation.handleAgentDeparture(agentId);
|
|
450
|
+
// Clear in-flight working_files AFTER consultation cleanup so any future
|
|
451
|
+
// consultation logic that might inspect working_files state for this agent
|
|
452
|
+
// sees the pre-cleanup view.
|
|
453
|
+
services.workingFiles.clearForAgent(agentId);
|
|
387
454
|
services.sseEmitter.emit("agent_offline", { agent_id: agentId });
|
|
388
455
|
});
|
|
389
456
|
// Wait for the HTTP server to be actually listening before resolving the
|
|
@@ -449,6 +516,12 @@ export async function startServer(opts) {
|
|
|
449
516
|
catch (err) {
|
|
450
517
|
log.warn({ err }, "Error stopping timeout sweeper");
|
|
451
518
|
}
|
|
519
|
+
try {
|
|
520
|
+
services.workingFiles.stopSweeper();
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
log.warn({ err }, "Error stopping working-files sweeper");
|
|
524
|
+
}
|
|
452
525
|
try {
|
|
453
526
|
const { closeDb } = await import("./database.js");
|
|
454
527
|
closeDb?.();
|
|
@@ -11,6 +11,10 @@ import { SseEmitter } from "./sse-emitter.js";
|
|
|
11
11
|
import { MqttBridge } from "./mqtt-bridge.js";
|
|
12
12
|
import { AgentActivityTracker } from "./agent-activity.js";
|
|
13
13
|
import { QuotaCache } from "./quota/quota-cache.js";
|
|
14
|
+
import { WorkingFilesTracker } from "./working-files-tracker.js";
|
|
15
|
+
import { Metrics } from "./metrics.js";
|
|
16
|
+
import { TreeSitterExtractor } from "./tree-sitter-extractor.js";
|
|
17
|
+
import { GitCochangeBuilder } from "./git-cochange-builder.js";
|
|
14
18
|
import type { CoordinatorConfig } from "./types.js";
|
|
15
19
|
import { type Logger } from "./logger.js";
|
|
16
20
|
export interface CoordinatorServices {
|
|
@@ -22,11 +26,15 @@ export interface CoordinatorServices {
|
|
|
22
26
|
depMap: DependencyMapper;
|
|
23
27
|
fileTracker: FileTracker;
|
|
24
28
|
impactScorer: ImpactScorer;
|
|
29
|
+
workingFiles: WorkingFilesTracker;
|
|
25
30
|
introspection: IntrospectionManager;
|
|
26
31
|
contextProvider: SummaryContextProvider;
|
|
27
32
|
sseEmitter: SseEmitter;
|
|
28
33
|
mqttBridge: MqttBridge;
|
|
29
34
|
quotaCache: QuotaCache;
|
|
35
|
+
metrics: Metrics;
|
|
36
|
+
treeSitter: TreeSitterExtractor;
|
|
37
|
+
gitCochange: GitCochangeBuilder | null;
|
|
30
38
|
}
|
|
31
39
|
/** Create shared services (once at startup). */
|
|
32
40
|
export declare function createServices(config: CoordinatorConfig): CoordinatorServices;
|
package/dist/src/server-setup.js
CHANGED
|
@@ -18,6 +18,10 @@ import { SseEmitter } from "./sse-emitter.js";
|
|
|
18
18
|
import { MqttBridge } from "./mqtt-bridge.js";
|
|
19
19
|
import { AgentActivityTracker } from "./agent-activity.js";
|
|
20
20
|
import { QuotaCache } from "./quota/quota-cache.js";
|
|
21
|
+
import { WorkingFilesTracker } from "./working-files-tracker.js";
|
|
22
|
+
import { Metrics } from "./metrics.js";
|
|
23
|
+
import { TreeSitterExtractor } from "./tree-sitter-extractor.js";
|
|
24
|
+
import { GitCochangeBuilder } from "./git-cochange-builder.js";
|
|
21
25
|
import { createLogger } from "./logger.js";
|
|
22
26
|
import { getVersion } from "../cli/version.js";
|
|
23
27
|
const VERSION = getVersion();
|
|
@@ -25,17 +29,35 @@ const VERSION = getVersion();
|
|
|
25
29
|
export function createServices(config) {
|
|
26
30
|
initDatabase(config.dataDir);
|
|
27
31
|
const logger = createLogger();
|
|
32
|
+
const metrics = new Metrics();
|
|
28
33
|
const registry = new AgentRegistry();
|
|
29
34
|
const activityTracker = new AgentActivityTracker(registry);
|
|
30
35
|
const consultation = new Consultation(logger.child({ component: "consultation" }));
|
|
31
36
|
const depMap = new DependencyMapper();
|
|
32
37
|
const fileTracker = new FileTracker();
|
|
33
|
-
const
|
|
38
|
+
const workingFiles = new WorkingFilesTracker(logger.child({ component: "working-files" }), metrics);
|
|
39
|
+
workingFiles.startSweeper(parseInt(process.env.COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS || "60000", 10));
|
|
40
|
+
const impactScorer = new ImpactScorer(registry, fileTracker, consultation, workingFiles);
|
|
34
41
|
const introspection = new IntrospectionManager();
|
|
35
42
|
const conflictDetector = new ConflictDetector(consultation, depMap, fileTracker, logger.child({ component: "conflict" }));
|
|
36
43
|
const contextProvider = new SummaryContextProvider(registry, consultation, fileTracker);
|
|
37
44
|
const sseEmitter = new SseEmitter();
|
|
38
45
|
const mqttBridge = new MqttBridge(logger.child({ component: "mqtt" }));
|
|
46
|
+
const treeSitter = new TreeSitterExtractor(metrics);
|
|
47
|
+
treeSitter.load().catch(() => { });
|
|
48
|
+
const repoRoot = process.env.COORDINATOR_REPO_ROOT;
|
|
49
|
+
const gitCochange = repoRoot
|
|
50
|
+
? new GitCochangeBuilder({
|
|
51
|
+
repoRoot,
|
|
52
|
+
logger: logger.child({ component: "gitcc" }),
|
|
53
|
+
metrics,
|
|
54
|
+
sinceDays: parseInt(process.env.COORDINATOR_LAYER4_SINCE_DAYS || "7", 10),
|
|
55
|
+
maxCount: parseInt(process.env.COORDINATOR_LAYER4_MAX_COMMITS || "2000", 10),
|
|
56
|
+
refreshMs: parseInt(process.env.COORDINATOR_LAYER4_REFRESH_INTERVAL_MS || "1800000", 10),
|
|
57
|
+
retryMs: parseInt(process.env.COORDINATOR_LAYER4_RETRY_MS || "300000", 10),
|
|
58
|
+
})
|
|
59
|
+
: null;
|
|
60
|
+
gitCochange?.startScheduler();
|
|
39
61
|
// Quota cache — macOS-only for now, Linux/Windows stubs return 503 via the
|
|
40
62
|
// /api/quota handler so raids keep running without a quota guardrail there.
|
|
41
63
|
// onRefresh fans the new data out to dashboard (SSE) + any live listener (MQTT)
|
|
@@ -62,8 +84,9 @@ export function createServices(config) {
|
|
|
62
84
|
else if (event.type === "agent_offline")
|
|
63
85
|
quotaCache.onAgentInactive();
|
|
64
86
|
});
|
|
65
|
-
// Centralized resolution → SSE + MQTT
|
|
87
|
+
// Centralized resolution → SSE + MQTT + metrics
|
|
66
88
|
consultation.onResolve((event) => {
|
|
89
|
+
metrics.recordThreadResolved(event.resolution_type);
|
|
67
90
|
sseEmitter.emit("thread_resolved", {
|
|
68
91
|
thread_id: event.thread_id,
|
|
69
92
|
resolution_type: event.resolution_type,
|
|
@@ -77,10 +100,15 @@ export function createServices(config) {
|
|
|
77
100
|
if (event.resolution_type !== "auto_resolved") {
|
|
78
101
|
mqttBridge.publishResolution(event.thread_id, "resolved", event.resolution_summary || "");
|
|
79
102
|
}
|
|
103
|
+
// P1 fix: clear the retained `coordinator/consultations/new` event so a
|
|
104
|
+
// coordinator restart doesn't re-broadcast a consultation that's already
|
|
105
|
+
// been resolved. No-op when the retained slot holds a different (newer)
|
|
106
|
+
// thread.
|
|
107
|
+
mqttBridge.clearRetainedConsultation(event.thread_id);
|
|
80
108
|
});
|
|
81
109
|
return {
|
|
82
110
|
logger, registry, activityTracker, consultation, conflictDetector,
|
|
83
|
-
depMap, fileTracker, impactScorer, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache,
|
|
111
|
+
depMap, fileTracker, impactScorer, workingFiles, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache, metrics, treeSitter, gitCochange,
|
|
84
112
|
};
|
|
85
113
|
}
|
|
86
114
|
/** Create a new McpServer bound to the shared services (one per MCP session). */
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { CoordinatorEvent, EventType } from "./types.js";
|
|
2
2
|
type EventListener = (event: CoordinatorEvent) => void;
|
|
3
|
+
export declare const MAX_SSE_CLIENTS: number;
|
|
3
4
|
export declare class SseEmitter {
|
|
4
5
|
private listeners;
|
|
6
|
+
private rejectedCount;
|
|
5
7
|
emit(type: EventType, payload: Record<string, unknown>): void;
|
|
6
8
|
getEventsSince(lastId: number): CoordinatorEvent[];
|
|
7
9
|
addListener(listener: EventListener): () => void;
|
|
8
10
|
removeAllListeners(): void;
|
|
11
|
+
/** P3: introspection for tests + ops dashboards. */
|
|
12
|
+
listenerCount(): number;
|
|
13
|
+
/** P3: count of addListener calls refused due to MAX_SSE_CLIENTS. */
|
|
14
|
+
getRejectedCount(): number;
|
|
9
15
|
}
|
|
10
16
|
export {};
|
package/dist/src/sse-emitter.js
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { getDb } from "./database.js";
|
|
2
|
+
/**
|
|
3
|
+
* P3: bound the listener array so a runaway client (or DoS attempt) can't
|
|
4
|
+
* grow it without limit. Default 100 covers a small-to-mid swarm — enough
|
|
5
|
+
* headroom for a dashboard + every agent + a handful of CLI tailers, but
|
|
6
|
+
* not so large that a leak would silently exhaust memory. Override via
|
|
7
|
+
* COORDINATOR_MAX_SSE_CLIENTS for larger deployments.
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_MAX_SSE_CLIENTS = 100;
|
|
10
|
+
export const MAX_SSE_CLIENTS = (() => {
|
|
11
|
+
const raw = process.env.COORDINATOR_MAX_SSE_CLIENTS;
|
|
12
|
+
if (!raw)
|
|
13
|
+
return DEFAULT_MAX_SSE_CLIENTS;
|
|
14
|
+
const n = parseInt(raw, 10);
|
|
15
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_SSE_CLIENTS;
|
|
16
|
+
})();
|
|
17
|
+
const NOOP = () => { };
|
|
2
18
|
export class SseEmitter {
|
|
3
19
|
listeners = [];
|
|
20
|
+
// P3: track refusals so operators can see when the cap is being hit.
|
|
21
|
+
// Also lets tests assert "we refused without throwing" without scraping logs.
|
|
22
|
+
rejectedCount = 0;
|
|
4
23
|
emit(type, payload) {
|
|
5
24
|
const db = getDb();
|
|
6
25
|
const payloadStr = JSON.stringify(payload);
|
|
@@ -13,8 +32,22 @@ export class SseEmitter {
|
|
|
13
32
|
payload: payloadStr,
|
|
14
33
|
created_at: new Date().toISOString(),
|
|
15
34
|
};
|
|
16
|
-
|
|
17
|
-
|
|
35
|
+
// P3: async fan-out via setImmediate so a slow listener (e.g. a stalled
|
|
36
|
+
// SSE client whose socket buffer is full) cannot block siblings or the
|
|
37
|
+
// emit() caller. Snapshot the array first so a listener that unsubscribes
|
|
38
|
+
// mid-loop doesn't shift indices under us.
|
|
39
|
+
const snapshot = this.listeners.slice();
|
|
40
|
+
for (const listener of snapshot) {
|
|
41
|
+
setImmediate(() => {
|
|
42
|
+
try {
|
|
43
|
+
listener(event);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Listener errors must not crash the emitter or affect siblings.
|
|
47
|
+
// Drop silently — the SSE response writers swallow their own
|
|
48
|
+
// socket errors via the unsubscribe path on req.on("close").
|
|
49
|
+
}
|
|
50
|
+
});
|
|
18
51
|
}
|
|
19
52
|
}
|
|
20
53
|
getEventsSince(lastId) {
|
|
@@ -24,6 +57,13 @@ export class SseEmitter {
|
|
|
24
57
|
.all(lastId);
|
|
25
58
|
}
|
|
26
59
|
addListener(listener) {
|
|
60
|
+
// P3: refuse-with-no-op when the cap is reached. Returning a no-op
|
|
61
|
+
// keeps the caller's unsubscribe contract intact (no special-casing
|
|
62
|
+
// upstream) while preventing the array from growing past MAX_SSE_CLIENTS.
|
|
63
|
+
if (this.listeners.length >= MAX_SSE_CLIENTS) {
|
|
64
|
+
this.rejectedCount++;
|
|
65
|
+
return NOOP;
|
|
66
|
+
}
|
|
27
67
|
this.listeners.push(listener);
|
|
28
68
|
return () => {
|
|
29
69
|
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
@@ -32,4 +72,12 @@ export class SseEmitter {
|
|
|
32
72
|
removeAllListeners() {
|
|
33
73
|
this.listeners = [];
|
|
34
74
|
}
|
|
75
|
+
/** P3: introspection for tests + ops dashboards. */
|
|
76
|
+
listenerCount() {
|
|
77
|
+
return this.listeners.length;
|
|
78
|
+
}
|
|
79
|
+
/** P3: count of addListener calls refused due to MAX_SSE_CLIENTS. */
|
|
80
|
+
getRejectedCount() {
|
|
81
|
+
return this.rejectedCount;
|
|
82
|
+
}
|
|
35
83
|
}
|
|
@@ -30,7 +30,9 @@ export function registerConsultationTools(server, services, mcpLog) {
|
|
|
30
30
|
exports_affected: z.array(z.string()).optional(),
|
|
31
31
|
keep_open: z.boolean().optional().describe("Keep thread open even if no agents are concerned (for manual coordination like games or debates)"),
|
|
32
32
|
assigned_to: z.string().optional().describe("Directed-dispatch: only this agent_id will be allowed to claim the thread. Use for lead→worker handoffs in maitre/chaine/relais presets. Implies keep_open=true."),
|
|
33
|
-
|
|
33
|
+
target_symbols: z.array(z.string().max(256)).max(200).optional()
|
|
34
|
+
.describe("Qualified symbol names you intend to touch (e.g. 'UserService.getById'). Used by Layer 0.5 to annotate same-file overlaps."),
|
|
35
|
+
}, async ({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to, target_symbols }) => {
|
|
34
36
|
mcpLog.info({ tool: "announce_work", agent_id, subject, target_modules, target_files, assigned_to }, "Tool called");
|
|
35
37
|
const conflicts = conflictDetector.detect({ agent_id, target_modules, target_files });
|
|
36
38
|
const thread = consultation.announceWork({
|
|
@@ -41,7 +43,7 @@ export function registerConsultationTools(server, services, mcpLog) {
|
|
|
41
43
|
.run(JSON.stringify(conflicts), thread.id);
|
|
42
44
|
}
|
|
43
45
|
const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
|
|
44
|
-
agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
|
|
46
|
+
agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, target_symbols,
|
|
45
47
|
});
|
|
46
48
|
sseEmitter.emit("thread_opened", {
|
|
47
49
|
thread_id: thread.id, initiator: agent_id, subject, target_modules, conflicts,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Metrics } from "./metrics.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tree-sitter symbol extractor.
|
|
4
|
+
*
|
|
5
|
+
* Loads grammars asynchronously at boot. extract() runs synchronously per call
|
|
6
|
+
* so it slots into the existing synchronous file_activity ingest path.
|
|
7
|
+
*
|
|
8
|
+
* Naming table per language documented in the v0.6 spec:
|
|
9
|
+
* - top-level fn / arrow assigned to const → `name`
|
|
10
|
+
* - class member → `Class.method`
|
|
11
|
+
* - anonymous default export → `<file_basename>:default`
|
|
12
|
+
* - re-exports, anonymous IIFE → not emitted
|
|
13
|
+
*/
|
|
14
|
+
export declare class TreeSitterExtractor {
|
|
15
|
+
private grammars;
|
|
16
|
+
private ready;
|
|
17
|
+
private grammarsLoaded;
|
|
18
|
+
private totalGrammars;
|
|
19
|
+
private metrics?;
|
|
20
|
+
constructor(metrics?: Metrics);
|
|
21
|
+
load(): Promise<void>;
|
|
22
|
+
status(): {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
grammars_loaded: number;
|
|
25
|
+
total_grammars: number;
|
|
26
|
+
optional: true;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Extract qualified symbol names from `content`. Returns null on parse
|
|
30
|
+
* failure, unsupported extension, or grammar not loaded.
|
|
31
|
+
* Caps output at 200 entries (per spec).
|
|
32
|
+
*/
|
|
33
|
+
extract(filePath: string, content: string, _changedRanges: Array<[number, number]> | null): string[] | null;
|
|
34
|
+
private extToKey;
|
|
35
|
+
private walk;
|
|
36
|
+
}
|