mcp-coordinator 0.2.1 → 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.
- package/README.md +846 -846
- package/dashboard/Dockerfile +19 -19
- package/dashboard/public/index.html +1178 -1178
- package/dist/cli/dashboard.js +9 -5
- package/dist/cli/server/start.js +24 -1
- package/dist/cli/server/status.js +16 -23
- package/dist/src/agent-activity.js +6 -6
- package/dist/src/agent-registry.js +6 -6
- package/dist/src/announce-workflow.d.ts +52 -0
- package/dist/src/announce-workflow.js +91 -0
- package/dist/src/consultation.d.ts +14 -0
- package/dist/src/consultation.js +110 -45
- package/dist/src/database.js +126 -126
- package/dist/src/dependency-map.js +3 -3
- package/dist/src/file-tracker.js +8 -8
- package/dist/src/http/handle-rest.d.ts +23 -0
- package/dist/src/http/handle-rest.js +374 -0
- package/dist/src/http/utils.d.ts +15 -0
- package/dist/src/http/utils.js +39 -0
- package/dist/src/introspection.js +1 -1
- package/dist/src/mqtt-bridge.d.ts +2 -0
- package/dist/src/mqtt-bridge.js +2 -0
- package/dist/src/mqtt-broker.d.ts +16 -0
- package/dist/src/mqtt-broker.js +16 -1
- package/dist/src/path-guard.d.ts +14 -0
- package/dist/src/path-guard.js +44 -0
- package/dist/src/reset-guard.d.ts +16 -0
- package/dist/src/reset-guard.js +24 -0
- package/dist/src/serve-http.d.ts +31 -1
- package/dist/src/serve-http.js +154 -445
- package/dist/src/server-setup.js +15 -364
- package/dist/src/tools/agents-tools.d.ts +8 -0
- package/dist/src/tools/agents-tools.js +46 -0
- package/dist/src/tools/consultation-tools.d.ts +21 -0
- package/dist/src/tools/consultation-tools.js +170 -0
- package/dist/src/tools/dependencies-tools.d.ts +8 -0
- package/dist/src/tools/dependencies-tools.js +27 -0
- package/dist/src/tools/files-tools.d.ts +8 -0
- package/dist/src/tools/files-tools.js +28 -0
- package/dist/src/tools/mqtt-tools.d.ts +9 -0
- package/dist/src/tools/mqtt-tools.js +33 -0
- package/dist/src/tools/status-tools.d.ts +8 -0
- package/dist/src/tools/status-tools.js +63 -0
- package/package.json +81 -80
package/dist/cli/dashboard.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
}
|
package/dist/cli/server/start.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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;
|
package/dist/src/consultation.js
CHANGED
|
@@ -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
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 =
|
|
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
|
-
//
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|