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.
Files changed (44) hide show
  1. package/README.md +14 -0
  2. package/dashboard/public/index.html +23 -0
  3. package/dist/cli/server/backup.d.ts +7 -0
  4. package/dist/cli/server/backup.js +162 -0
  5. package/dist/cli/server/index.js +5 -0
  6. package/dist/cli/server/restore.d.ts +2 -0
  7. package/dist/cli/server/restore.js +117 -0
  8. package/dist/cli/server/start.js +33 -0
  9. package/dist/src/announce-workflow.d.ts +1 -0
  10. package/dist/src/announce-workflow.js +28 -0
  11. package/dist/src/consultation.d.ts +8 -0
  12. package/dist/src/consultation.js +8 -0
  13. package/dist/src/database.js +65 -0
  14. package/dist/src/db-adapter.d.ts +30 -0
  15. package/dist/src/db-adapter.js +32 -1
  16. package/dist/src/dependency-map.js +2 -2
  17. package/dist/src/file-tracker.d.ts +12 -0
  18. package/dist/src/file-tracker.js +35 -2
  19. package/dist/src/git-cochange-builder.d.ts +32 -0
  20. package/dist/src/git-cochange-builder.js +238 -0
  21. package/dist/src/http/handle-health.d.ts +23 -0
  22. package/dist/src/http/handle-health.js +112 -0
  23. package/dist/src/http/handle-rest.js +83 -2
  24. package/dist/src/http/utils.d.ts +0 -4
  25. package/dist/src/http/utils.js +16 -2
  26. package/dist/src/impact-scorer.d.ts +5 -1
  27. package/dist/src/impact-scorer.js +182 -55
  28. package/dist/src/metrics.d.ts +88 -0
  29. package/dist/src/metrics.js +195 -0
  30. package/dist/src/mqtt-bridge.d.ts +19 -0
  31. package/dist/src/mqtt-bridge.js +53 -5
  32. package/dist/src/path-normalize.d.ts +17 -0
  33. package/dist/src/path-normalize.js +38 -0
  34. package/dist/src/serve-http.js +76 -3
  35. package/dist/src/server-setup.d.ts +8 -0
  36. package/dist/src/server-setup.js +31 -3
  37. package/dist/src/sse-emitter.d.ts +6 -0
  38. package/dist/src/sse-emitter.js +50 -2
  39. package/dist/src/tools/consultation-tools.js +4 -2
  40. package/dist/src/tree-sitter-extractor.d.ts +36 -0
  41. package/dist/src/tree-sitter-extractor.js +354 -0
  42. package/dist/src/working-files-tracker.d.ts +42 -0
  43. package/dist/src/working-files-tracker.js +111 -0
  44. package/package.json +20 -1
@@ -1,6 +1,7 @@
1
1
  import type { AgentRegistry } from "./agent-registry.js";
2
2
  import type { FileTracker } from "./file-tracker.js";
3
3
  import type { Consultation } from "./consultation.js";
4
+ import type { WorkingFilesTracker } from "./working-files-tracker.js";
4
5
  export interface ImpactScore {
5
6
  agent_id: string;
6
7
  agent_name: string;
@@ -19,13 +20,16 @@ interface AnnounceParams {
19
20
  target_files: string[];
20
21
  depends_on_files?: string[];
21
22
  exports_affected?: string[];
23
+ target_symbols?: string[];
22
24
  }
23
25
  export declare class ImpactScorer {
24
26
  private registry;
25
27
  private fileTracker;
26
28
  private consultation?;
27
- constructor(registry: AgentRegistry, fileTracker: FileTracker, consultation?: Consultation | undefined);
29
+ private workingFiles?;
30
+ constructor(registry: AgentRegistry, fileTracker: FileTracker, consultation?: Consultation | undefined, workingFiles?: WorkingFilesTracker | undefined);
28
31
  score(params: AnnounceParams): ImpactScore[];
29
32
  categorize(params: AnnounceParams): CategorizedImpact;
33
+ private getRecentSymbolsForFile;
30
34
  }
31
35
  export {};
@@ -1,83 +1,171 @@
1
+ import { getDb } from "./database.js";
2
+ // Layer 0 (announced-intent) recency window. Resolved threads older than this
3
+ // are excluded — yesterday's resolved work shouldn't trigger today's scoring.
4
+ // Aligned with file-tracker's default conflict window per the audit guidance.
5
+ const LAYER_0_WINDOW_MINUTES = 30;
6
+ // Layer 1 / 2 (file-activity) recency window. Preserved at 60 minutes to keep
7
+ // strict behavioral parity with the original scorer (the prior implementation
8
+ // hard-coded 60 in the checkFileConflict calls). Performance optimizations
9
+ // must not change scoring outcomes for existing callers.
10
+ const FILE_ACTIVITY_WINDOW_MINUTES = 60;
1
11
  export class ImpactScorer {
2
12
  registry;
3
13
  fileTracker;
4
14
  consultation;
5
- constructor(registry, fileTracker, consultation) {
15
+ workingFiles;
16
+ constructor(registry, fileTracker, consultation, workingFiles) {
6
17
  this.registry = registry;
7
18
  this.fileTracker = fileTracker;
8
19
  this.consultation = consultation;
20
+ this.workingFiles = workingFiles;
9
21
  }
10
22
  score(params) {
11
23
  const onlineAgents = this.registry
12
24
  .listOnline()
13
25
  .filter((a) => a.id !== params.agent_id);
26
+ if (onlineAgents.length === 0)
27
+ return [];
28
+ // O1: cache parsed agent.modules JSON ONCE per scoring call.
29
+ // Previously each agent's modules were JSON.parse'd inside the hot path
30
+ // (Layer 3), which is O(A) parses for A agents. With Layer 0 also reading
31
+ // thread.target_files / depends_on_files per agent, the original code
32
+ // re-parsed agent state up to ~4·A times per call.
33
+ const moduleCache = new Map();
34
+ for (const a of onlineAgents) {
35
+ moduleCache.set(a.id, JSON.parse(a.modules));
36
+ }
37
+ // O3: pre-compute file → set<agent_id> for every file we'll inspect.
38
+ // Replaces N `checkFileConflict` calls (each = 1 SQL round-trip) with a
39
+ // single batched query, and turns the inner per-agent file check into
40
+ // an O(1) Set.has() lookup.
41
+ const filesToIndex = [
42
+ ...params.target_files,
43
+ ...(params.depends_on_files || []),
44
+ ];
45
+ const fileToAgents = filesToIndex.length > 0
46
+ ? this.fileTracker.getFileToAgentsIndex(filesToIndex, params.agent_id, FILE_ACTIVITY_WINDOW_MINUTES)
47
+ : new Map();
48
+ const inFlightToAgents = this.workingFiles
49
+ ? this.workingFiles.getIndex(filesToIndex, params.agent_id)
50
+ : new Map();
51
+ // Pre-load symbols_touched for the target_files × online_agents matrix once,
52
+ // keyed by (file_path, agent_id). Avoids N*M DB roundtrips inside the score loop.
53
+ let symbolsByFileAgent = null;
54
+ if (params.target_symbols && params.target_symbols.length > 0 && params.target_files.length > 0) {
55
+ const db = getDb();
56
+ const placeholders = params.target_files.map(() => "?").join(",");
57
+ const rows = db.prepare(`SELECT agent_id, file_path, symbols_touched
58
+ FROM file_activity
59
+ WHERE file_path IN (${placeholders})
60
+ AND symbols_touched IS NOT NULL
61
+ AND id IN (
62
+ SELECT MAX(id) FROM file_activity
63
+ WHERE file_path IN (${placeholders})
64
+ AND symbols_touched IS NOT NULL
65
+ GROUP BY agent_id, file_path
66
+ )`).all(...params.target_files, ...params.target_files);
67
+ symbolsByFileAgent = new Map();
68
+ for (const r of rows) {
69
+ try {
70
+ const arr = JSON.parse(r.symbols_touched);
71
+ symbolsByFileAgent.set(`${r.file_path}|${r.agent_id}`, arr);
72
+ }
73
+ catch { /* malformed JSON: ignore */ }
74
+ }
75
+ }
76
+ // O2: bound the resolved-thread query to a recency window. Without this,
77
+ // listThreads({status:'resolved'}) returns ALL historical resolved threads
78
+ // (unbounded growth). The Layer 0 filter only keeps threads where the
79
+ // initiator is the currently-evaluated agent, but the SQL still scanned
80
+ // every row before the JS filter ran. Since-bound at the SQL layer.
81
+ let activeThreadsByAgent = null;
82
+ if (this.consultation) {
83
+ const allActive = [
84
+ ...this.consultation.listThreads({ status: "open" }),
85
+ ...this.consultation.listThreads({ status: "resolving" }),
86
+ ...this.consultation.listThreads({ status: "resolved", since_minutes: LAYER_0_WINDOW_MINUTES }),
87
+ ];
88
+ // Group by initiator_id so the per-agent loop is O(threads-for-this-agent)
89
+ // rather than O(all-active-threads). Avoids an outer-product scan over
90
+ // (agents × threads) when both sets are large.
91
+ activeThreadsByAgent = new Map();
92
+ for (const t of allActive) {
93
+ const list = activeThreadsByAgent.get(t.initiator_id);
94
+ if (list) {
95
+ list.push(t);
96
+ }
97
+ else {
98
+ activeThreadsByAgent.set(t.initiator_id, [t]);
99
+ }
100
+ }
101
+ }
14
102
  return onlineAgents.map((agent) => {
15
- const agentModules = JSON.parse(agent.modules);
103
+ const agentModules = moduleCache.get(agent.id);
16
104
  const reasons = [];
17
105
  let maxScore = 0;
18
106
  // Layer 0: Announced intent overlap (checks active threads from this agent).
19
- // Resolved threads older than LAYER_0_RESOLVED_WINDOW_MS are excluded —
20
- // yesterday's resolved work shouldn't trigger today's scoring.
21
- if (this.consultation) {
22
- const LAYER_0_RESOLVED_WINDOW_MS = 30 * 60 * 1000; // 30 minutes
23
- const now = Date.now();
24
- // SQLite datetime('now') returns UTC without a TZ suffix. new Date(str)
25
- // parses it as local time by default causing a local-offset skew.
26
- // Normalize by treating the space as T and appending Z.
27
- const parseSqliteUtc = (s) => {
28
- const iso = /[Tt]/.test(s) ? s : s.replace(" ", "T");
29
- return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + "Z").getTime();
30
- };
31
- const activeThreads = [
32
- ...this.consultation.listThreads({ status: "open" }),
33
- ...this.consultation.listThreads({ status: "resolving" }),
34
- ...this.consultation
35
- .listThreads({ status: "resolved" })
36
- .filter((t) => {
37
- if (!t.resolved_at)
38
- return true;
39
- const resolvedAt = parseSqliteUtc(t.resolved_at);
40
- return !isNaN(resolvedAt) && now - resolvedAt <= LAYER_0_RESOLVED_WINDOW_MS;
41
- }),
42
- ].filter((t) => t.initiator_id === agent.id);
43
- for (const thread of activeThreads) {
44
- const threadFiles = JSON.parse(thread.target_files || "[]");
45
- const threadDeps = JSON.parse(thread.depends_on_files || "[]");
46
- // 0a: My target_files ∩ their target_files → score 100
47
- const fileOverlap = params.target_files.filter((f) => threadFiles.includes(f));
48
- if (fileOverlap.length > 0) {
49
- maxScore = Math.max(maxScore, 100);
50
- reasons.push(`announced same file: ${fileOverlap.join(", ")} (thread ${thread.id.slice(0, 8)})`);
51
- }
52
- // 0b: My depends_on ∩ their target_files → score 80 (they modify what I depend on)
53
- if (params.depends_on_files) {
54
- const depOverlap = params.depends_on_files.filter((f) => threadFiles.includes(f));
55
- if (depOverlap.length > 0) {
107
+ if (activeThreadsByAgent) {
108
+ const agentThreads = activeThreadsByAgent.get(agent.id);
109
+ if (agentThreads) {
110
+ for (const thread of agentThreads) {
111
+ const threadFiles = JSON.parse(thread.target_files || "[]");
112
+ const threadDeps = JSON.parse(thread.depends_on_files || "[]");
113
+ // 0a: My target_files their target_files score 100
114
+ const fileOverlap = params.target_files.filter((f) => threadFiles.includes(f));
115
+ if (fileOverlap.length > 0) {
116
+ maxScore = Math.max(maxScore, 100);
117
+ reasons.push(`announced same file: ${fileOverlap.join(", ")} (thread ${thread.id.slice(0, 8)})`);
118
+ }
119
+ // 0b: My depends_on ∩ their target_files → score 80 (they modify what I depend on)
120
+ if (params.depends_on_files) {
121
+ const depOverlap = params.depends_on_files.filter((f) => threadFiles.includes(f));
122
+ if (depOverlap.length > 0) {
123
+ maxScore = Math.max(maxScore, 80);
124
+ reasons.push(`modifies my dependency: ${depOverlap.join(", ")} (thread ${thread.id.slice(0, 8)})`);
125
+ }
126
+ }
127
+ // 0c: My target_files ∩ their depends_on → score 80 (I modify what they depend on)
128
+ const reverseDepOverlap = params.target_files.filter((f) => threadDeps.includes(f));
129
+ if (reverseDepOverlap.length > 0) {
56
130
  maxScore = Math.max(maxScore, 80);
57
- reasons.push(`modifies my dependency: ${depOverlap.join(", ")} (thread ${thread.id.slice(0, 8)})`);
131
+ reasons.push(`they depend on my target: ${reverseDepOverlap.join(", ")} (thread ${thread.id.slice(0, 8)})`);
58
132
  }
59
133
  }
60
- // 0c: My target_files ∩ their depends_on → score 80 (I modify what they depend on)
61
- const reverseDepOverlap = params.target_files.filter((f) => threadDeps.includes(f));
62
- if (reverseDepOverlap.length > 0) {
63
- maxScore = Math.max(maxScore, 80);
64
- reasons.push(`they depend on my target: ${reverseDepOverlap.join(", ")} (thread ${thread.id.slice(0, 8)})`);
65
- }
66
134
  }
67
135
  }
68
- // Layer 1: Same file recently modified (score 100)
136
+ // Layer 1: Same file recently modified (file_activity) OR currently in flight (working_files).
69
137
  for (const targetFile of params.target_files) {
70
- const conflict = this.fileTracker.checkFileConflict(targetFile, params.agent_id, 60);
71
- if (conflict.agents.includes(agent.id)) {
138
+ const recentAgents = fileToAgents.get(targetFile);
139
+ const inFlightAgents = inFlightToAgents.get(targetFile);
140
+ if (recentAgents && recentAgents.has(agent.id)) {
72
141
  maxScore = Math.max(maxScore, 100);
73
- reasons.push(`same file: ${targetFile}`);
142
+ let annotated = false;
143
+ if (params.target_symbols && params.target_symbols.length > 0) {
144
+ const theirSymbols = symbolsByFileAgent?.get(`${targetFile}|${agent.id}`) || null;
145
+ if (theirSymbols && theirSymbols.length > 0) {
146
+ const mine = new Set(params.target_symbols);
147
+ const theirs = new Set(theirSymbols);
148
+ const overlap = [...mine].some(s => theirs.has(s));
149
+ if (!overlap) {
150
+ reasons.push(`same file: ${targetFile}; disjoint symbols: you=[${[...mine].join(",")}], them=[${[...theirs].join(",")}] — verify shared module state`);
151
+ annotated = true;
152
+ }
153
+ }
154
+ }
155
+ if (!annotated) {
156
+ reasons.push(`same file (recent): ${targetFile}`);
157
+ }
158
+ }
159
+ if (inFlightAgents && inFlightAgents.has(agent.id)) {
160
+ maxScore = Math.max(maxScore, 100);
161
+ reasons.push(`same file (in flight): ${targetFile}`);
74
162
  }
75
163
  }
76
164
  // Layer 2: Depends-on file recently modified (score 80)
77
165
  if (params.depends_on_files) {
78
166
  for (const depFile of params.depends_on_files) {
79
- const conflict = this.fileTracker.checkFileConflict(depFile, params.agent_id, 60);
80
- if (conflict.agents.includes(agent.id)) {
167
+ const agentsForFile = fileToAgents.get(depFile);
168
+ if (agentsForFile && agentsForFile.has(agent.id)) {
81
169
  maxScore = Math.max(maxScore, 80);
82
170
  reasons.push(`depends on: ${depFile}`);
83
171
  }
@@ -89,9 +177,34 @@ export class ImpactScorer {
89
177
  maxScore = Math.max(maxScore, 30);
90
178
  reasons.push(`module overlap: ${overlapping.join(", ")}`);
91
179
  }
92
- // Layer 4 (future): Git co-change analysis
93
- // Score 60 for >50% co-change ratio, 40 for >20%
94
- // Requires git history analysis not implemented in v3 prototype
180
+ // Layer 4: git co-change. For each target_file F, find rows in git_cochange where
181
+ // (LEAST(F,partner), GREATEST(F,partner)) match. If the OTHER agent recently
182
+ // touched the partner file, apply the co-change score.
183
+ const db = getDb();
184
+ for (const targetFile of params.target_files) {
185
+ const rows = db.prepare(`SELECT file_a, file_b, count, total_commits FROM git_cochange
186
+ WHERE file_a = ? OR file_b = ?`).all(targetFile, targetFile);
187
+ for (const r of rows) {
188
+ const partner = r.file_a === targetFile ? r.file_b : r.file_a;
189
+ const ratio = r.count / Math.max(r.total_commits, 1);
190
+ let layer4Score = 0;
191
+ if (ratio > 0.5)
192
+ layer4Score = 60;
193
+ else if (ratio > 0.2)
194
+ layer4Score = 40;
195
+ if (layer4Score === 0)
196
+ continue;
197
+ // Did the OTHER agent touch the partner file recently?
198
+ const partnerActivity = db.prepare(`SELECT 1 FROM file_activity
199
+ WHERE file_path = ? AND agent_id = ?
200
+ AND created_at > datetime('now', '-60 minutes')
201
+ LIMIT 1`).get(partner, agent.id);
202
+ if (partnerActivity) {
203
+ maxScore = Math.max(maxScore, layer4Score);
204
+ reasons.push(`co-change: ${targetFile} ↔ ${partner} (ratio ${ratio.toFixed(2)})`);
205
+ }
206
+ }
207
+ }
95
208
  return {
96
209
  agent_id: agent.id,
97
210
  agent_name: agent.name,
@@ -109,4 +222,18 @@ export class ImpactScorer {
109
222
  pass: scores.filter((s) => s.score < 30),
110
223
  };
111
224
  }
225
+ getRecentSymbolsForFile(filePath, agentId) {
226
+ const db = getDb();
227
+ const row = db.prepare(`SELECT symbols_touched FROM file_activity
228
+ WHERE agent_id = ? AND file_path = ? AND symbols_touched IS NOT NULL
229
+ ORDER BY id DESC LIMIT 1`).get(agentId, filePath);
230
+ if (!row || !row.symbols_touched)
231
+ return null;
232
+ try {
233
+ return JSON.parse(row.symbols_touched);
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ }
112
239
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Prometheus /metrics endpoint for mcp-coordinator (v0.4 Operability fix).
3
+ *
4
+ * Audit gap: README claims "Production-ready" but only exposed /health stub.
5
+ * This module wires prom-client counters/gauges keyed off the events that
6
+ * already flow through the coordinator (announces, resolutions, MQTT
7
+ * publishes, REST requests, auth rejections) plus a snapshot of the live
8
+ * system state (agents, threads, MQTT listeners, SSE clients).
9
+ *
10
+ * Design notes:
11
+ * - Uses a per-instance `Registry` (not the global default registry) so that
12
+ * multiple Coordinator instances in the same process (essaim's orchestrator
13
+ * pattern) get isolated metric counters instead of cross-contaminating.
14
+ * - Gauges are pulled lazily from a `services` snapshot at scrape time via
15
+ * `gaugeSnapshot()` — cheaper than maintaining mirror state and guarantees
16
+ * the value matches the DB (no drift from missed events).
17
+ * - SSE clients and MQTT listeners aren't exposed as public counts on those
18
+ * classes today. Callers update those gauges directly via `setSseClients`
19
+ * / `setMqttListeners` from the request lifecycle (see integration patch).
20
+ */
21
+ import type { IncomingMessage, ServerResponse } from "http";
22
+ import { Registry, Counter, Gauge } from "prom-client";
23
+ import type { CoordinatorServices } from "./server-setup.js";
24
+ export type AnnounceResult = "thread_opened" | "auto_resolved";
25
+ export type ResolutionType = "consensus" | "timeout" | "auto_resolved" | "agent_departure" | "max_rounds" | "closed";
26
+ export interface MetricsOptions {
27
+ /**
28
+ * If true, also collect Node.js process metrics (CPU, memory, event-loop
29
+ * lag, etc.) via prom-client's collectDefaultMetrics. Default: true.
30
+ */
31
+ collectDefault?: boolean;
32
+ }
33
+ export declare class Metrics {
34
+ readonly registry: Registry;
35
+ readonly announces: Counter<"result">;
36
+ readonly threadsResolved: Counter<"type">;
37
+ readonly mqttPublishes: Counter<string>;
38
+ readonly httpRequests: Counter<"route" | "status">;
39
+ readonly authRejected: Counter<string>;
40
+ readonly agentsOnline: Gauge<string>;
41
+ readonly threadsOpen: Gauge<string>;
42
+ readonly threadsResolving: Gauge<string>;
43
+ readonly mqttListenersActive: Gauge<string>;
44
+ readonly sseClientsActive: Gauge<string>;
45
+ readonly workingFilesActive: Gauge<string>;
46
+ readonly gitCochangePairs: Gauge<string>;
47
+ readonly workingFilesStarts: Counter<"result">;
48
+ readonly treeSitterParseFailures: Counter<string>;
49
+ readonly gitCochangeBuilds: Counter<"outcome">;
50
+ constructor(opts?: MetricsOptions);
51
+ recordAnnounce(result: AnnounceResult): void;
52
+ recordThreadResolved(type: ResolutionType): void;
53
+ recordMqttPublish(): void;
54
+ recordHttpRequest(route: string, status: number): void;
55
+ recordAuthRejected(): void;
56
+ setSseClients(n: number): void;
57
+ incSseClients(): void;
58
+ decSseClients(): void;
59
+ setMqttListeners(n: number): void;
60
+ /**
61
+ * Snapshot the gauges that derive from durable state (agents/threads).
62
+ * Called at scrape time so the values are fresh without us having to mirror
63
+ * every state transition. Safe to call repeatedly.
64
+ *
65
+ * Reads via the DB directly because AgentRegistry/Consultation don't yet
66
+ * expose count getters — adding them would touch unrelated modules.
67
+ */
68
+ gaugeSnapshot(services: CoordinatorServices): void;
69
+ /**
70
+ * Render the current registry as Prometheus text exposition format.
71
+ * Returns the body string + the content-type to set on the response.
72
+ */
73
+ render(): Promise<{
74
+ body: string;
75
+ contentType: string;
76
+ }>;
77
+ }
78
+ /**
79
+ * HTTP handler for GET /metrics. Refreshes the derived gauges from a
80
+ * services snapshot, then writes the prom-client text exposition.
81
+ *
82
+ * Wire from serve-http.ts:
83
+ * if (url === "/metrics" && req.method === "GET") {
84
+ * await serveMetrics(req, res, services, metrics);
85
+ * return;
86
+ * }
87
+ */
88
+ export declare function serveMetrics(_req: IncomingMessage, res: ServerResponse, services: CoordinatorServices, metrics: Metrics): Promise<void>;
@@ -0,0 +1,195 @@
1
+ import { Registry, Counter, Gauge, collectDefaultMetrics, } from "prom-client";
2
+ import { getDb } from "./database.js";
3
+ export class Metrics {
4
+ registry;
5
+ // Counters
6
+ announces;
7
+ threadsResolved;
8
+ mqttPublishes;
9
+ httpRequests;
10
+ authRejected;
11
+ // Gauges
12
+ agentsOnline;
13
+ threadsOpen;
14
+ threadsResolving;
15
+ mqttListenersActive;
16
+ sseClientsActive;
17
+ workingFilesActive;
18
+ gitCochangePairs;
19
+ // v0.6 counters
20
+ workingFilesStarts;
21
+ treeSitterParseFailures;
22
+ gitCochangeBuilds;
23
+ constructor(opts = {}) {
24
+ this.registry = new Registry();
25
+ if (opts.collectDefault !== false) {
26
+ collectDefaultMetrics({ register: this.registry });
27
+ }
28
+ this.announces = new Counter({
29
+ name: "mcp_coordinator_announces_total",
30
+ help: "Total announce_work calls, partitioned by outcome",
31
+ labelNames: ["result"],
32
+ registers: [this.registry],
33
+ });
34
+ this.threadsResolved = new Counter({
35
+ name: "mcp_coordinator_threads_resolved_total",
36
+ help: "Total threads resolved, partitioned by resolution type",
37
+ labelNames: ["type"],
38
+ registers: [this.registry],
39
+ });
40
+ this.mqttPublishes = new Counter({
41
+ name: "mcp_coordinator_mqtt_publishes_total",
42
+ help: "Total MQTT publishes by the coordinator bridge",
43
+ registers: [this.registry],
44
+ });
45
+ this.httpRequests = new Counter({
46
+ name: "mcp_coordinator_http_requests_total",
47
+ help: "Total HTTP requests handled, partitioned by route + status",
48
+ labelNames: ["route", "status"],
49
+ registers: [this.registry],
50
+ });
51
+ this.authRejected = new Counter({
52
+ name: "mcp_coordinator_auth_rejected_total",
53
+ help: "Total authentication rejections",
54
+ registers: [this.registry],
55
+ });
56
+ this.agentsOnline = new Gauge({
57
+ name: "mcp_coordinator_agents_online",
58
+ help: "Current number of agents reporting status=online",
59
+ registers: [this.registry],
60
+ });
61
+ this.threadsOpen = new Gauge({
62
+ name: "mcp_coordinator_threads_open",
63
+ help: "Current number of threads in status=open",
64
+ registers: [this.registry],
65
+ });
66
+ this.threadsResolving = new Gauge({
67
+ name: "mcp_coordinator_threads_resolving",
68
+ help: "Current number of threads in status=resolving",
69
+ registers: [this.registry],
70
+ });
71
+ this.mqttListenersActive = new Gauge({
72
+ name: "mcp_coordinator_mqtt_listeners_active",
73
+ help: "Current number of registered MQTT consultation listeners",
74
+ registers: [this.registry],
75
+ });
76
+ this.sseClientsActive = new Gauge({
77
+ name: "mcp_coordinator_sse_clients_active",
78
+ help: "Current number of connected SSE clients",
79
+ registers: [this.registry],
80
+ });
81
+ this.workingFilesActive = new Gauge({
82
+ name: "mcp_coordinator_working_files_active",
83
+ help: "Current working_files row count",
84
+ registers: [this.registry],
85
+ });
86
+ this.workingFilesStarts = new Counter({
87
+ name: "mcp_coordinator_working_files_starts_total",
88
+ help: "Total working_files start calls",
89
+ labelNames: ["result"],
90
+ registers: [this.registry],
91
+ });
92
+ this.treeSitterParseFailures = new Counter({
93
+ name: "mcp_coordinator_tree_sitter_parse_failures_total",
94
+ help: "Tree-sitter parse failures",
95
+ registers: [this.registry],
96
+ });
97
+ this.gitCochangeBuilds = new Counter({
98
+ name: "mcp_coordinator_git_cochange_builds_total",
99
+ help: "git_cochange build attempts",
100
+ labelNames: ["outcome"],
101
+ registers: [this.registry],
102
+ });
103
+ this.gitCochangePairs = new Gauge({
104
+ name: "mcp_coordinator_git_cochange_pairs_total",
105
+ help: "Current git_cochange row count",
106
+ registers: [this.registry],
107
+ });
108
+ }
109
+ // ── Counter helpers (named methods make hook points obvious) ──
110
+ recordAnnounce(result) {
111
+ this.announces.inc({ result }, 1);
112
+ }
113
+ recordThreadResolved(type) {
114
+ this.threadsResolved.inc({ type }, 1);
115
+ }
116
+ recordMqttPublish() {
117
+ this.mqttPublishes.inc(1);
118
+ }
119
+ recordHttpRequest(route, status) {
120
+ this.httpRequests.inc({ route, status: String(status) }, 1);
121
+ }
122
+ recordAuthRejected() {
123
+ this.authRejected.inc(1);
124
+ }
125
+ // ── Gauge setters (called from request lifecycle, see integration patch) ──
126
+ setSseClients(n) {
127
+ this.sseClientsActive.set(n);
128
+ }
129
+ incSseClients() {
130
+ this.sseClientsActive.inc(1);
131
+ }
132
+ decSseClients() {
133
+ this.sseClientsActive.dec(1);
134
+ }
135
+ setMqttListeners(n) {
136
+ this.mqttListenersActive.set(n);
137
+ }
138
+ /**
139
+ * Snapshot the gauges that derive from durable state (agents/threads).
140
+ * Called at scrape time so the values are fresh without us having to mirror
141
+ * every state transition. Safe to call repeatedly.
142
+ *
143
+ * Reads via the DB directly because AgentRegistry/Consultation don't yet
144
+ * expose count getters — adding them would touch unrelated modules.
145
+ */
146
+ gaugeSnapshot(services) {
147
+ try {
148
+ this.agentsOnline.set(services.registry.listOnline().length);
149
+ }
150
+ catch {
151
+ // Registry not initialised yet (test bootstrap race) — leave gauge at 0.
152
+ }
153
+ try {
154
+ const db = getDb();
155
+ const open = db
156
+ .prepare("SELECT COUNT(*) as c FROM threads WHERE status = 'open'")
157
+ .get();
158
+ const resolving = db
159
+ .prepare("SELECT COUNT(*) as c FROM threads WHERE status = 'resolving'")
160
+ .get();
161
+ this.threadsOpen.set(open.c);
162
+ this.threadsResolving.set(resolving.c);
163
+ }
164
+ catch {
165
+ // DB not initialised — leave gauges at their last-set values.
166
+ }
167
+ }
168
+ /**
169
+ * Render the current registry as Prometheus text exposition format.
170
+ * Returns the body string + the content-type to set on the response.
171
+ */
172
+ async render() {
173
+ const body = await this.registry.metrics();
174
+ return { body, contentType: this.registry.contentType };
175
+ }
176
+ }
177
+ /**
178
+ * HTTP handler for GET /metrics. Refreshes the derived gauges from a
179
+ * services snapshot, then writes the prom-client text exposition.
180
+ *
181
+ * Wire from serve-http.ts:
182
+ * if (url === "/metrics" && req.method === "GET") {
183
+ * await serveMetrics(req, res, services, metrics);
184
+ * return;
185
+ * }
186
+ */
187
+ export async function serveMetrics(_req, res, services, metrics) {
188
+ metrics.gaugeSnapshot(services);
189
+ const { body, contentType } = await metrics.render();
190
+ res.writeHead(200, {
191
+ "Content-Type": contentType,
192
+ "Cache-Control": "no-cache",
193
+ });
194
+ res.end(body);
195
+ }
@@ -10,16 +10,35 @@ export declare class MqttBridge {
10
10
  private onOfflineHandler;
11
11
  private listeners;
12
12
  private log;
13
+ private agentId;
14
+ /**
15
+ * P1: track the last threadId we retained on `coordinator/consultations/new`.
16
+ * The topic is fixed (not per-thread), so retain holds only the LAST event.
17
+ * `clearRetainedConsultation(threadId)` only clears when it matches, so a
18
+ * later consultation isn't accidentally wiped by a stale resolve callback.
19
+ */
20
+ private lastRetainedConsultationThreadId;
13
21
  constructor(logger?: Logger);
14
22
  connect(config: {
15
23
  url: string;
16
24
  username?: string;
17
25
  password?: string;
26
+ agentId?: string;
18
27
  }): Promise<void>;
19
28
  isConnected(): boolean;
20
29
  onOffline(handler: (agentId: string) => void): void;
21
30
  registerAgent(agentId: string, name: string): void;
22
31
  publishConsultation(threadId: string, agentId: string, subject: string, targetModules: string[]): void;
32
+ /**
33
+ * P1 fix: clear the retained `coordinator/consultations/new` event when the
34
+ * matching thread resolves. The topic is fixed (not per-thread), so retain
35
+ * holds only the LAST consultation — clearing here means a coordinator
36
+ * restart after resolution doesn't re-broadcast a stale "new" event.
37
+ *
38
+ * No-op when the supplied threadId doesn't match the currently retained one
39
+ * (a newer consultation has already overwritten it).
40
+ */
41
+ clearRetainedConsultation(threadId: string): void;
23
42
  publishMessage(threadId: string, agentId: string, type: string, content: string): void;
24
43
  publishResolution(threadId: string, status: string, summary: string): void;
25
44
  publishBroadcast(agentId: string, message: string): void;