mcp-coordinator 0.2.0 → 0.3.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 +846 -835
  2. package/dashboard/Dockerfile +19 -19
  3. package/dashboard/public/index.html +1178 -1178
  4. package/dist/cli/dashboard.js +9 -5
  5. package/dist/cli/server/start.js +24 -1
  6. package/dist/cli/server/status.js +16 -23
  7. package/dist/src/agent-activity.js +6 -6
  8. package/dist/src/agent-registry.js +6 -6
  9. package/dist/src/announce-workflow.d.ts +52 -0
  10. package/dist/src/announce-workflow.js +91 -0
  11. package/dist/src/consultation.d.ts +14 -0
  12. package/dist/src/consultation.js +110 -45
  13. package/dist/src/database.js +126 -126
  14. package/dist/src/dependency-map.js +3 -3
  15. package/dist/src/file-tracker.js +8 -8
  16. package/dist/src/http/handle-rest.d.ts +23 -0
  17. package/dist/src/http/handle-rest.js +374 -0
  18. package/dist/src/http/utils.d.ts +15 -0
  19. package/dist/src/http/utils.js +39 -0
  20. package/dist/src/introspection.js +1 -1
  21. package/dist/src/mqtt-bridge.d.ts +2 -0
  22. package/dist/src/mqtt-bridge.js +2 -0
  23. package/dist/src/mqtt-broker.d.ts +16 -0
  24. package/dist/src/mqtt-broker.js +16 -1
  25. package/dist/src/path-guard.d.ts +14 -0
  26. package/dist/src/path-guard.js +44 -0
  27. package/dist/src/reset-guard.d.ts +16 -0
  28. package/dist/src/reset-guard.js +24 -0
  29. package/dist/src/serve-http.d.ts +31 -1
  30. package/dist/src/serve-http.js +154 -445
  31. package/dist/src/server-setup.js +15 -364
  32. package/dist/src/tools/agents-tools.d.ts +8 -0
  33. package/dist/src/tools/agents-tools.js +46 -0
  34. package/dist/src/tools/consultation-tools.d.ts +21 -0
  35. package/dist/src/tools/consultation-tools.js +170 -0
  36. package/dist/src/tools/dependencies-tools.d.ts +8 -0
  37. package/dist/src/tools/dependencies-tools.js +27 -0
  38. package/dist/src/tools/files-tools.d.ts +8 -0
  39. package/dist/src/tools/files-tools.js +28 -0
  40. package/dist/src/tools/mqtt-tools.d.ts +9 -0
  41. package/dist/src/tools/mqtt-tools.js +33 -0
  42. package/dist/src/tools/status-tools.d.ts +8 -0
  43. package/dist/src/tools/status-tools.js +63 -0
  44. package/package.json +81 -80
@@ -1,14 +1,18 @@
1
1
  import { Command } from "commander";
2
- import { exec } from "child_process";
2
+ import { spawn } from "child_process";
3
3
  export function createDashboardCommand() {
4
4
  return new Command("dashboard")
5
5
  .description("Open the real-time dashboard")
6
6
  .action(() => {
7
7
  const url = "http://localhost:3100/dashboard";
8
8
  console.log(`Dashboard: ${url}`);
9
- const cmd = process.platform === "darwin"
10
- ? `open "${url}"`
11
- : `xdg-open "${url}" 2>/dev/null`;
12
- exec(cmd, () => { });
9
+ // Use spawn with an argv array (no shell) so the URL is never
10
+ // interpolated into a shell command — eliminates command-injection risk.
11
+ const opener = process.platform === "darwin" ? "open"
12
+ : process.platform === "win32" ? "explorer.exe"
13
+ : "xdg-open";
14
+ const child = spawn(opener, [url], { stdio: "ignore", detached: true });
15
+ child.on("error", () => { });
16
+ child.unref();
13
17
  });
14
18
  }
@@ -24,10 +24,33 @@ export function createServerStartCommand() {
24
24
  const isBun = typeof globalThis.Bun !== "undefined";
25
25
  const cmd = isBun ? process.execPath : process.execPath;
26
26
  const args = isBun ? ["server", "start"] : [process.argv[1], "server", "start"];
27
+ // Only forward the env vars the daemon actually needs.
28
+ // Each var is read explicitly (no Object.keys / dynamic indexing into
29
+ // process.env) so the daemon can't inherit unrelated parent-process
30
+ // secrets such as AWS_*, GITHUB_TOKEN, OPENAI_API_KEY, etc.
31
+ const childEnv = {
32
+ PATH: process.env.PATH ?? "",
33
+ HOME: process.env.HOME ?? process.env.USERPROFILE ?? "",
34
+ PORT: String(port),
35
+ COORDINATOR_DATA_DIR: dataDir,
36
+ };
37
+ const fwd = (key, value) => {
38
+ if (value !== undefined)
39
+ childEnv[key] = value;
40
+ };
41
+ fwd("NODE_ENV", process.env.NODE_ENV);
42
+ fwd("LOG_LEVEL", process.env.LOG_LEVEL);
43
+ fwd("COORDINATOR_AUTH_ENABLED", process.env.COORDINATOR_AUTH_ENABLED);
44
+ fwd("COORDINATOR_JWT_SECRET", process.env.COORDINATOR_JWT_SECRET);
45
+ fwd("COORDINATOR_JWT_EXPIRY", process.env.COORDINATOR_JWT_EXPIRY);
46
+ fwd("COORDINATOR_REGISTRATION_SECRET", process.env.COORDINATOR_REGISTRATION_SECRET);
47
+ fwd("COORDINATOR_ADMIN_SECRET", process.env.COORDINATOR_ADMIN_SECRET);
48
+ fwd("COORDINATOR_MQTT_TCP_PORT", process.env.COORDINATOR_MQTT_TCP_PORT);
49
+ fwd("COORDINATOR_MQTT_WS_PATH", process.env.COORDINATOR_MQTT_WS_PATH);
27
50
  const child = spawn(cmd, args, {
28
51
  detached: true,
29
52
  stdio: ["ignore", logFd, logFd],
30
- env: { ...process.env, PORT: String(port), COORDINATOR_DATA_DIR: dataDir },
53
+ env: childEnv,
31
54
  });
32
55
  // Write PID file
33
56
  writeFileSync(join(configDir, "server.pid"), String(child.pid));
@@ -1,12 +1,22 @@
1
1
  import { Command } from "commander";
2
2
  import { readFileSync, existsSync } from "fs";
3
3
  import { join } from "path";
4
- import { execSync } from "child_process";
5
4
  import { getConfigDir, loadConfig } from "../config.js";
5
+ async function fetchJson(url, init) {
6
+ try {
7
+ const response = await fetch(url, { ...init, signal: AbortSignal.timeout(3000) });
8
+ if (!response.ok)
9
+ return null;
10
+ return (await response.json());
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
6
16
  export function createServerStatusCommand() {
7
17
  return new Command("status")
8
18
  .description("Show coordinator status")
9
- .action(() => {
19
+ .action(async () => {
10
20
  const configDir = getConfigDir();
11
21
  const pidPath = join(configDir, "server.pid");
12
22
  const config = loadConfig();
@@ -26,28 +36,11 @@ export function createServerStatusCommand() {
26
36
  console.log("Coordinator: stopped (stale PID file)");
27
37
  return;
28
38
  }
29
- // Health check
30
- let health = {};
31
- try {
32
- const raw = execSync(`curl -s --max-time 3 http://localhost:${port}/health`, {
33
- encoding: "utf-8",
34
- stdio: ["pipe", "pipe", "pipe"],
35
- });
36
- health = JSON.parse(raw);
37
- }
38
- catch { }
39
- if (health.status === "ok") {
40
- let status = {};
41
- try {
42
- const raw = execSync(`curl -s --max-time 3 -X POST http://localhost:${port}/api/status`, {
43
- encoding: "utf-8",
44
- stdio: ["pipe", "pipe", "pipe"],
45
- });
46
- status = JSON.parse(raw);
47
- }
48
- catch { }
39
+ const health = await fetchJson(`http://localhost:${port}/health`);
40
+ if (health?.status === "ok") {
41
+ const status = await fetchJson(`http://localhost:${port}/api/status`, { method: "POST" });
49
42
  console.log(`Coordinator: running (PID ${pid}, port ${port})`);
50
- if (status.online !== undefined) {
43
+ if (status?.online !== undefined) {
51
44
  console.log(`Agents: ${status.online} online`);
52
45
  console.log(`Threads: ${status.open_threads} open`);
53
46
  }
@@ -59,12 +59,12 @@ export class AgentActivityTracker {
59
59
  // ── Private ──
60
60
  upsert(agentId, status, file, thread) {
61
61
  const db = getDb();
62
- db.prepare(`INSERT INTO agent_activity_status (agent_id, activity_status, current_file, current_thread, last_activity_at)
63
- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
64
- ON CONFLICT(agent_id) DO UPDATE SET
65
- activity_status = excluded.activity_status,
66
- current_file = excluded.current_file,
67
- current_thread = excluded.current_thread,
62
+ db.prepare(`INSERT INTO agent_activity_status (agent_id, activity_status, current_file, current_thread, last_activity_at)
63
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
64
+ ON CONFLICT(agent_id) DO UPDATE SET
65
+ activity_status = excluded.activity_status,
66
+ current_file = excluded.current_file,
67
+ current_thread = excluded.current_thread,
68
68
  last_activity_at = CURRENT_TIMESTAMP`).run(agentId, status, file, thread);
69
69
  }
70
70
  }
@@ -2,12 +2,12 @@ import { getDb } from "./database.js";
2
2
  export class AgentRegistry {
3
3
  register(agentId, name, modules) {
4
4
  const db = getDb();
5
- db.prepare(`INSERT INTO agents (id, name, modules, status, registered_at, last_seen_at)
6
- VALUES (?, ?, ?, 'online', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
7
- ON CONFLICT(id) DO UPDATE SET
8
- name = excluded.name,
9
- modules = excluded.modules,
10
- status = 'online',
5
+ db.prepare(`INSERT INTO agents (id, name, modules, status, registered_at, last_seen_at)
6
+ VALUES (?, ?, ?, 'online', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
7
+ ON CONFLICT(id) DO UPDATE SET
8
+ name = excluded.name,
9
+ modules = excluded.modules,
10
+ status = 'online',
11
11
  last_seen_at = CURRENT_TIMESTAMP`).run(agentId, name, JSON.stringify(modules));
12
12
  return this.get(agentId);
13
13
  }
@@ -0,0 +1,52 @@
1
+ import type { Thread } from "./types.js";
2
+ import type { CoordinatorServices } from "./server-setup.js";
3
+ import type { CategorizedImpact } from "./impact-scorer.js";
4
+ import { type PlanQualityResult } from "./plan-quality.js";
5
+ /**
6
+ * S2 fix: shared `announce_work` orchestration.
7
+ *
8
+ * The MCP tool handler (`server-setup.ts`) and the REST endpoint
9
+ * (`serve-http.ts`) historically duplicated ~95 lines of code that
10
+ * scored impact, overrode respondents, auto-resolved when alone,
11
+ * and emitted impact/introspection/plan-quality SSE events.
12
+ *
13
+ * This module extracts that common orchestration. Both transports
14
+ * call `runCommonAnnounceFlow()` after creating the thread; each
15
+ * transport then adds its own pre-step (MCP: conflict detection)
16
+ * and post-step (MCP: MQTT publish + context gathering, REST: JSON
17
+ * response) so existing SSE/response contracts are preserved.
18
+ *
19
+ * Why not unify the response/SSE shapes too? The MCP and REST
20
+ * `thread_opened` event payloads have DIFFERENT field sets today
21
+ * (e.g. MCP uses `initiator`, REST uses `agent_id` + `agent_name`).
22
+ * essaim (and other consumers) may depend on those exact shapes.
23
+ * Behavioral unification is a separate, riskier change deferred to
24
+ * a later major.
25
+ */
26
+ export interface CommonFlowResult {
27
+ /** The same thread you passed in, refreshed after the override+auto-resolve. */
28
+ updated: Thread;
29
+ /** Impact scoring across all online agents. */
30
+ categorized: CategorizedImpact;
31
+ /** Concerned agent IDs (== updated.expected_respondents). */
32
+ respondents: string[];
33
+ /** Plan quality assessment (callers may emit downgrade event). */
34
+ planQuality: PlanQualityResult;
35
+ }
36
+ export interface CommonFlowParams {
37
+ agent_id: string;
38
+ subject: string;
39
+ plan?: string;
40
+ target_modules: string[];
41
+ target_files: string[];
42
+ depends_on_files?: string[];
43
+ exports_affected?: string[];
44
+ keep_open?: boolean;
45
+ }
46
+ /**
47
+ * Run the common post-`announceWork` orchestration. Mutates the thread row
48
+ * (overrides `expected_respondents`, may transition to `resolved`) and emits
49
+ * SSE events for impact scoring + introspection. Pure side-effects on the
50
+ * services + DB; returns metadata callers use to build their own responses.
51
+ */
52
+ export declare function runCommonAnnounceFlow(services: CoordinatorServices, threadId: string, params: CommonFlowParams): CommonFlowResult;
@@ -0,0 +1,91 @@
1
+ import { getDb } from "./database.js";
2
+ import { assessPlanQuality } from "./plan-quality.js";
3
+ /**
4
+ * Run the common post-`announceWork` orchestration. Mutates the thread row
5
+ * (overrides `expected_respondents`, may transition to `resolved`) and emits
6
+ * SSE events for impact scoring + introspection. Pure side-effects on the
7
+ * services + DB; returns metadata callers use to build their own responses.
8
+ */
9
+ export function runCommonAnnounceFlow(services, threadId, params) {
10
+ const { registry, consultation, impactScorer, introspection, sseEmitter } = services;
11
+ // 1. Score impact: categorize all online agents into concerned / gray_zone / pass.
12
+ const categorized = impactScorer.categorize({
13
+ agent_id: params.agent_id,
14
+ target_modules: params.target_modules,
15
+ target_files: params.target_files,
16
+ depends_on_files: params.depends_on_files,
17
+ exports_affected: params.exports_affected,
18
+ });
19
+ // 2. Override expected_respondents on the thread with the scored set.
20
+ // Auto-resolve only when truly alone — if peers are online but not concerned
21
+ // (e.g., they haven't announced yet), keep the thread open so a subsequent
22
+ // announce can match via Layer 0. Thread will timeout naturally if no one joins.
23
+ const db = getDb();
24
+ const concernedIds = categorized.concerned.map((s) => s.agent_id);
25
+ db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
26
+ .run(JSON.stringify(concernedIds), threadId);
27
+ const otherOnlineCount = registry.listOnline().filter((a) => a.id !== params.agent_id).length;
28
+ const shouldAutoResolve = concernedIds.length === 0 && otherOnlineCount === 0;
29
+ const currentThread = consultation.getThread(threadId);
30
+ if (shouldAutoResolve && currentThread.status === "open" && !params.keep_open) {
31
+ db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?")
32
+ .run(new Date().toISOString(), threadId);
33
+ consultation.emitResolution(threadId, "auto_resolved");
34
+ }
35
+ // 3. Emit impact_scored SSE events for every scored agent.
36
+ for (const s of [...categorized.concerned, ...categorized.gray_zone, ...categorized.pass]) {
37
+ sseEmitter.emit("impact_scored", {
38
+ thread_id: threadId,
39
+ agent_id: s.agent_id,
40
+ agent_name: s.agent_name,
41
+ score: s.score,
42
+ reasons: s.reasons,
43
+ category: scoredCategory(s),
44
+ });
45
+ }
46
+ // 4. Create introspection records and emit introspection_requested for gray_zone agents.
47
+ for (const s of categorized.gray_zone) {
48
+ introspection.create({ thread_id: threadId, agent_id: s.agent_id, score: s.score, reasons: s.reasons });
49
+ sseEmitter.emit("introspection_requested", {
50
+ thread_id: threadId,
51
+ agent_id: s.agent_id,
52
+ agent_name: s.agent_name,
53
+ score: s.score,
54
+ reasons: s.reasons,
55
+ });
56
+ }
57
+ // 5. Plan quality downgrade event — both transports emit this when a plan
58
+ // was provided but quality was insufficient. Callers can re-emit if their
59
+ // payload shape differs (MCP vs REST agent_name source).
60
+ const planQuality = assessPlanQuality(params.plan);
61
+ if (params.plan && planQuality.mode === "discovery") {
62
+ sseEmitter.emit("impact_scored", {
63
+ thread_id: threadId,
64
+ agent_id: params.agent_id,
65
+ agent_name: registry.get(params.agent_id)?.name || params.agent_id,
66
+ score: planQuality.score,
67
+ reasons: [planDowngradeReason(planQuality)],
68
+ category: "plan_quality",
69
+ });
70
+ }
71
+ const updated = consultation.getThread(threadId);
72
+ const respondents = JSON.parse(updated.expected_respondents || "[]");
73
+ return { updated, categorized, respondents, planQuality };
74
+ }
75
+ function scoredCategory(s) {
76
+ if (s.score >= 90)
77
+ return "concerned";
78
+ if (s.score >= 30)
79
+ return "gray_zone";
80
+ return "pass";
81
+ }
82
+ function planDowngradeReason(pq) {
83
+ const flags = [];
84
+ if (!pq.checks.mentions_files)
85
+ flags.push("no files");
86
+ if (!pq.checks.concrete_approach)
87
+ flags.push("vague approach");
88
+ if (!pq.checks.sufficient_detail)
89
+ flags.push("too short");
90
+ return `plan downgraded: score ${pq.score}/3 — ${flags.join(" ")}`.trim();
91
+ }
@@ -13,8 +13,22 @@ export interface ResolutionEvent {
13
13
  export declare class Consultation {
14
14
  private onResolveCallback;
15
15
  private log;
16
+ private timeoutSweeperHandle;
16
17
  constructor(logger?: Logger);
17
18
  onResolve(callback: (event: ResolutionEvent) => void): void;
19
+ /**
20
+ * B2 fix: replace the side-effect-on-read timeout check with an explicit
21
+ * background sweeper. Each tick atomically claims and resolves any thread
22
+ * past its deadline, then emits resolution events outside the transaction.
23
+ *
24
+ * Default tick interval: 30 seconds. Tests can pass a shorter interval to
25
+ * exercise the sweeper, or call checkTimeouts() explicitly.
26
+ *
27
+ * Safe to call multiple times — second call is a no-op until the previous
28
+ * sweeper is stopped.
29
+ */
30
+ startTimeoutSweeper(intervalMs?: number): void;
31
+ stopTimeoutSweeper(): void;
18
32
  emitResolution(threadId: string, type: ResolutionType, approvedBy?: string, approvedByName?: string): void;
19
33
  announceWork(params: {
20
34
  agent_id: string;
@@ -4,12 +4,45 @@ import { silentLogger } from "./logger.js";
4
4
  export class Consultation {
5
5
  onResolveCallback = null;
6
6
  log;
7
+ timeoutSweeperHandle = null;
7
8
  constructor(logger) {
8
9
  this.log = logger || silentLogger;
9
10
  }
10
11
  onResolve(callback) {
11
12
  this.onResolveCallback = callback;
12
13
  }
14
+ /**
15
+ * B2 fix: replace the side-effect-on-read timeout check with an explicit
16
+ * background sweeper. Each tick atomically claims and resolves any thread
17
+ * past its deadline, then emits resolution events outside the transaction.
18
+ *
19
+ * Default tick interval: 30 seconds. Tests can pass a shorter interval to
20
+ * exercise the sweeper, or call checkTimeouts() explicitly.
21
+ *
22
+ * Safe to call multiple times — second call is a no-op until the previous
23
+ * sweeper is stopped.
24
+ */
25
+ startTimeoutSweeper(intervalMs = 30000) {
26
+ if (this.timeoutSweeperHandle)
27
+ return;
28
+ this.timeoutSweeperHandle = setInterval(() => {
29
+ try {
30
+ this.checkTimeouts();
31
+ }
32
+ catch (err) {
33
+ this.log.warn({ err }, "Timeout sweeper iteration failed");
34
+ }
35
+ }, intervalMs);
36
+ // Don't keep the event loop alive just for the sweeper.
37
+ if (typeof this.timeoutSweeperHandle.unref === "function")
38
+ this.timeoutSweeperHandle.unref();
39
+ }
40
+ stopTimeoutSweeper() {
41
+ if (this.timeoutSweeperHandle) {
42
+ clearInterval(this.timeoutSweeperHandle);
43
+ this.timeoutSweeperHandle = null;
44
+ }
45
+ }
13
46
  emitResolution(threadId, type, approvedBy, approvedByName) {
14
47
  const db = getDb();
15
48
  const thread = this.getThread(threadId);
@@ -39,23 +72,31 @@ export class Consultation {
39
72
  announceWork(params) {
40
73
  const db = getDb();
41
74
  const id = randomUUID();
42
- // Find expected respondents: online agents (not initiator) whose modules overlap
43
- const onlineAgents = db
44
- .prepare("SELECT id, modules FROM agents WHERE status = 'online' AND id != ?")
45
- .all(params.agent_id);
46
- const respondents = onlineAgents.filter((agent) => {
47
- const agentModules = JSON.parse(agent.modules);
48
- return params.target_modules.some((tm) => agentModules.some((am) => am === tm || am.startsWith(tm + "/") || tm.startsWith(am + "/")));
75
+ // B1 fix: SELECT respondents + INSERT thread must be atomic w.r.t. agent
76
+ // registry mutations (registerAgent / setOffline). Without a transaction,
77
+ // a race between announce and a concurrent setOffline produces a thread
78
+ // whose expected_respondents contains an agent that just went away — the
79
+ // thread then stays open forever waiting for an absent voter.
80
+ const tx = db.transaction(() => {
81
+ const onlineAgents = db
82
+ .prepare("SELECT id, modules FROM agents WHERE status = 'online' AND id != ?")
83
+ .all(params.agent_id);
84
+ const respondents = onlineAgents.filter((agent) => {
85
+ const agentModules = JSON.parse(agent.modules);
86
+ return params.target_modules.some((tm) => agentModules.some((am) => am === tm || am.startsWith(tm + "/") || tm.startsWith(am + "/")));
87
+ });
88
+ const respondentIds = respondents.map((r) => r.id);
89
+ // Directed dispatch skips module-based auto-resolve: if the thread is
90
+ // explicitly aimed at an agent, we keep it open for them regardless of
91
+ // what the module scorer finds.
92
+ const assignedTo = params.assigned_to ?? null;
93
+ const keepOpen = params.keep_open || assignedTo !== null;
94
+ const autoResolve = respondentIds.length === 0 && !keepOpen;
95
+ db.prepare(`INSERT INTO threads (id, initiator_id, subject, plan, target_modules, target_files, status, expected_respondents, resolved_at, depends_on_files, exports_affected, timeout_seconds, assigned_to)
96
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.agent_id, params.subject, params.plan || null, JSON.stringify(params.target_modules), JSON.stringify(params.target_files), autoResolve ? "resolved" : "open", JSON.stringify(respondentIds), autoResolve ? new Date().toISOString() : null, JSON.stringify(params.depends_on_files || []), JSON.stringify(params.exports_affected || []), keepOpen ? 0 : 600, assignedTo);
97
+ return { autoResolve, respondentIds, assignedTo };
49
98
  });
50
- const respondentIds = respondents.map((r) => r.id);
51
- // Directed dispatch skips module-based auto-resolve: if the thread is
52
- // explicitly aimed at an agent, we keep it open for them regardless of
53
- // what the module scorer finds.
54
- const assignedTo = params.assigned_to ?? null;
55
- const keepOpen = params.keep_open || assignedTo !== null;
56
- const autoResolve = respondentIds.length === 0 && !keepOpen;
57
- db.prepare(`INSERT INTO threads (id, initiator_id, subject, plan, target_modules, target_files, status, expected_respondents, resolved_at, depends_on_files, exports_affected, timeout_seconds, assigned_to)
58
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.agent_id, params.subject, params.plan || null, JSON.stringify(params.target_modules), JSON.stringify(params.target_files), autoResolve ? "resolved" : "open", JSON.stringify(respondentIds), autoResolve ? new Date().toISOString() : null, JSON.stringify(params.depends_on_files || []), JSON.stringify(params.exports_affected || []), keepOpen ? 0 : 600, assignedTo);
99
+ const { autoResolve, respondentIds, assignedTo } = tx();
59
100
  this.log.info({
60
101
  thread_id: id,
61
102
  agent_id: params.agent_id,
@@ -81,7 +122,7 @@ export class Consultation {
81
122
  const id = randomUUID();
82
123
  // Simple token estimate: ~4 chars per token for English/French
83
124
  const tokenEstimate = Math.ceil(params.content.length / 4);
84
- db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, agent_name, type, content, context_snapshot, in_reply_to, round, token_estimate)
125
+ db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, agent_name, type, content, context_snapshot, in_reply_to, round, token_estimate)
85
126
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.thread_id, params.agent_id, params.agent_name || null, params.type, params.content, params.context_snapshot || null, params.in_reply_to || null, thread.round, tokenEstimate);
86
127
  this.log.debug({
87
128
  thread_id: params.thread_id,
@@ -109,12 +150,23 @@ export class Consultation {
109
150
  throw new Error(`Thread ${threadId} not found`);
110
151
  if (thread.status !== "resolving")
111
152
  throw new Error(`Thread is ${thread.status}, not resolving`);
112
- // Post approve message
113
- this.postResolutionMessage(threadId, agentId, "approve", "Approved");
153
+ // B1 fix: post + check + transition must be atomic, else two concurrent
154
+ // approvals each see "all approved" and fire emitResolution twice. Using
155
+ // a transaction + UPDATE ... WHERE status='resolving' (CAS) ensures only
156
+ // the first transaction wins the consensus race; the loser's UPDATE
157
+ // affects 0 rows and emit is suppressed.
158
+ const tx = db.transaction(() => {
159
+ this.postResolutionMessage(threadId, agentId, "approve", "Approved");
160
+ if (!this.allRespondentsApproved(threadId))
161
+ return false;
162
+ const res = db
163
+ .prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND status = 'resolving'")
164
+ .run(new Date().toISOString(), threadId);
165
+ return res.changes > 0;
166
+ });
167
+ const wonRace = tx();
114
168
  this.log.debug({ thread_id: threadId, agent_id: agentId }, "Resolution approved");
115
- // Check if all expected respondents have approved
116
- if (this.allRespondentsApproved(threadId)) {
117
- db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), threadId);
169
+ if (wonRace) {
118
170
  this.emitResolution(threadId, "consensus", agentId, agentName || agentId);
119
171
  }
120
172
  }
@@ -194,30 +246,43 @@ export class Consultation {
194
246
  }
195
247
  checkTimeouts() {
196
248
  const db = getDb();
197
- // Get threads that will be timed out (before updating them)
198
- const timedOut = db.prepare(`
199
- SELECT id FROM threads
200
- WHERE status IN ('open', 'resolving')
201
- AND timeout_seconds > 0
202
- AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
203
- `).all();
249
+ // B2 fix: SELECT-then-UPDATE wrapped in a transaction so two concurrent
250
+ // sweepers can't both claim the same thread. The UPDATE returns
251
+ // db.changes (rows affected) — we use the SELECT only to know which
252
+ // thread IDs to emit for. The transaction makes both observe the same
253
+ // snapshot.
254
+ const tx = db.transaction(() => {
255
+ const timedOut = db.prepare(`
256
+ SELECT id FROM threads
257
+ WHERE status IN ('open', 'resolving')
258
+ AND timeout_seconds > 0
259
+ AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
260
+ `).all();
261
+ if (timedOut.length === 0)
262
+ return [];
263
+ db.prepare(`
264
+ UPDATE threads SET status = 'resolved',
265
+ resolution_summary = 'Résolu par timeout — pas de réponse dans le délai',
266
+ resolved_at = CURRENT_TIMESTAMP
267
+ WHERE status IN ('open', 'resolving')
268
+ AND timeout_seconds > 0
269
+ AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
270
+ `).run();
271
+ return timedOut;
272
+ });
273
+ const timedOut = tx();
204
274
  if (timedOut.length === 0)
205
275
  return;
206
- db.prepare(`
207
- UPDATE threads SET status = 'resolved',
208
- resolution_summary = 'Résolu par timeout — pas de réponse dans le délai',
209
- resolved_at = CURRENT_TIMESTAMP
210
- WHERE status IN ('open', 'resolving')
211
- AND timeout_seconds > 0
212
- AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
213
- `).run();
214
276
  this.log.info({ count: timedOut.length, thread_ids: timedOut.map(t => t.id) }, "Threads timed out");
277
+ // Emit OUTSIDE the transaction so listeners can re-enter the DB safely.
215
278
  for (const t of timedOut) {
216
279
  this.emitResolution(t.id, "timeout");
217
280
  }
218
281
  }
219
282
  getThread(threadId) {
220
- this.checkTimeouts();
283
+ // B2 fix: timeout sweeping moved to startTimeoutSweeper() background timer.
284
+ // Reads no longer mutate state. Tests that need synchronous timeout
285
+ // resolution should call checkTimeouts() explicitly.
221
286
  const db = getDb();
222
287
  return (db.prepare("SELECT * FROM threads WHERE id = ?").get(threadId) || null);
223
288
  }
@@ -232,7 +297,7 @@ export class Consultation {
232
297
  return { thread, messages };
233
298
  }
234
299
  listThreads(filters) {
235
- this.checkTimeouts();
300
+ // B2 fix: removed checkTimeouts() side-effect; sweeper handles it.
236
301
  const db = getDb();
237
302
  let sql = "SELECT * FROM threads WHERE 1=1";
238
303
  const params = [];
@@ -260,9 +325,9 @@ export class Consultation {
260
325
  }
261
326
  getThreadUpdates(agentId, since) {
262
327
  const db = getDb();
263
- let sql = `SELECT tm.* FROM thread_messages tm
264
- JOIN threads t ON tm.thread_id = t.id
265
- WHERE t.status IN ('open', 'resolving')
328
+ let sql = `SELECT tm.* FROM thread_messages tm
329
+ JOIN threads t ON tm.thread_id = t.id
330
+ WHERE t.status IN ('open', 'resolving')
266
331
  AND tm.agent_id != ?`;
267
332
  const params = [agentId];
268
333
  if (since) {
@@ -284,7 +349,7 @@ export class Consultation {
284
349
  logActionSummary(params) {
285
350
  const db = getDb();
286
351
  const id = randomUUID();
287
- db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
352
+ db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
288
353
  VALUES (?, ?, ?, ?, ?)`).run(id, params.session_id, params.agent_id, params.file_path || null, params.summary);
289
354
  return db.prepare("SELECT * FROM action_summaries WHERE id = ?").get(id);
290
355
  }
@@ -310,7 +375,7 @@ export class Consultation {
310
375
  const db = getDb();
311
376
  const thread = this.getThread(threadId);
312
377
  const id = randomUUID();
313
- db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
378
+ db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
314
379
  VALUES (?, ?, ?, ?, ?, ?)`).run(id, threadId, agentId, type, content, thread.round);
315
380
  }
316
381
  allRespondentsApproved(threadId) {
@@ -323,7 +388,7 @@ export class Consultation {
323
388
  // increments the round, and prior-round approvals must be re-collected
324
389
  // for the new proposal.
325
390
  const approvals = db
326
- .prepare(`SELECT DISTINCT agent_id FROM thread_messages
391
+ .prepare(`SELECT DISTINCT agent_id FROM thread_messages
327
392
  WHERE thread_id = ? AND type = 'approve' AND round = ?`)
328
393
  .all(threadId, thread.round);
329
394
  const approvedIds = new Set(approvals.map((a) => a.agent_id));