mcp-coordinator 0.1.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/LICENSE +21 -0
- package/README.md +92 -0
- package/dashboard/Dockerfile +19 -0
- package/dashboard/public/index.html +1178 -0
- package/dist/cli/config.d.ts +14 -0
- package/dist/cli/config.js +58 -0
- package/dist/cli/dashboard.d.ts +2 -0
- package/dist/cli/dashboard.js +14 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +13 -0
- package/dist/cli/server/index.d.ts +2 -0
- package/dist/cli/server/index.js +11 -0
- package/dist/cli/server/start.d.ts +2 -0
- package/dist/cli/server/start.js +57 -0
- package/dist/cli/server/status.d.ts +2 -0
- package/dist/cli/server/status.js +60 -0
- package/dist/cli/server/stop.d.ts +2 -0
- package/dist/cli/server/stop.js +59 -0
- package/dist/cli/version.d.ts +1 -0
- package/dist/cli/version.js +22 -0
- package/dist/src/agent-activity.d.ts +27 -0
- package/dist/src/agent-activity.js +70 -0
- package/dist/src/agent-registry.d.ts +10 -0
- package/dist/src/agent-registry.js +38 -0
- package/dist/src/auth.d.ts +22 -0
- package/dist/src/auth.js +91 -0
- package/dist/src/conflict-detector.d.ts +17 -0
- package/dist/src/conflict-detector.js +114 -0
- package/dist/src/consultation.d.ts +75 -0
- package/dist/src/consultation.js +332 -0
- package/dist/src/context-provider.d.ts +14 -0
- package/dist/src/context-provider.js +34 -0
- package/dist/src/database.d.ts +4 -0
- package/dist/src/database.js +194 -0
- package/dist/src/db-adapter.d.ts +15 -0
- package/dist/src/db-adapter.js +1 -0
- package/dist/src/dependency-map.d.ts +7 -0
- package/dist/src/dependency-map.js +76 -0
- package/dist/src/file-tracker.d.ts +21 -0
- package/dist/src/file-tracker.js +44 -0
- package/dist/src/impact-scorer.d.ts +31 -0
- package/dist/src/impact-scorer.js +112 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +26 -0
- package/dist/src/introspection.d.ts +24 -0
- package/dist/src/introspection.js +28 -0
- package/dist/src/logger.d.ts +20 -0
- package/dist/src/logger.js +55 -0
- package/dist/src/mqtt-bridge.d.ts +40 -0
- package/dist/src/mqtt-bridge.js +173 -0
- package/dist/src/mqtt-broker.d.ts +23 -0
- package/dist/src/mqtt-broker.js +99 -0
- package/dist/src/plan-quality.d.ts +11 -0
- package/dist/src/plan-quality.js +30 -0
- package/dist/src/quota/credential-reader.d.ts +21 -0
- package/dist/src/quota/credential-reader.js +86 -0
- package/dist/src/quota/quota-cache.d.ts +93 -0
- package/dist/src/quota/quota-cache.js +177 -0
- package/dist/src/quota/quota.d.ts +47 -0
- package/dist/src/quota/quota.js +117 -0
- package/dist/src/serve-http.d.ts +5 -0
- package/dist/src/serve-http.js +775 -0
- package/dist/src/server-setup.d.ts +34 -0
- package/dist/src/server-setup.js +453 -0
- package/dist/src/sse-emitter.d.ts +10 -0
- package/dist/src/sse-emitter.js +35 -0
- package/dist/src/types.d.ts +121 -0
- package/dist/src/types.js +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { silentLogger } from "./logger.js";
|
|
2
|
+
export class ConflictDetector {
|
|
3
|
+
consultation;
|
|
4
|
+
depMap;
|
|
5
|
+
fileTracker;
|
|
6
|
+
log;
|
|
7
|
+
constructor(consultation, depMap, fileTracker, logger) {
|
|
8
|
+
this.consultation = consultation;
|
|
9
|
+
this.depMap = depMap;
|
|
10
|
+
this.fileTracker = fileTracker;
|
|
11
|
+
this.log = logger || silentLogger;
|
|
12
|
+
}
|
|
13
|
+
detect(params) {
|
|
14
|
+
const conflicts = [];
|
|
15
|
+
// Include open, resolving, and recently resolved (auto-quorum) threads — exclude only cancelled
|
|
16
|
+
const allThreads = this.consultation.listThreads({});
|
|
17
|
+
const activeThreads = allThreads.filter((t) => t.status !== "cancelled");
|
|
18
|
+
for (const thread of activeThreads) {
|
|
19
|
+
if (thread.initiator_id === params.agent_id)
|
|
20
|
+
continue;
|
|
21
|
+
const threadModules = JSON.parse(thread.target_modules);
|
|
22
|
+
const threadFiles = JSON.parse(thread.target_files);
|
|
23
|
+
// 1. Module overlap
|
|
24
|
+
const moduleOverlap = params.target_modules.filter((m) => threadModules.includes(m));
|
|
25
|
+
if (moduleOverlap.length > 0) {
|
|
26
|
+
conflicts.push({
|
|
27
|
+
type: "module_overlap",
|
|
28
|
+
severity: "warning",
|
|
29
|
+
agent_id: thread.initiator_id,
|
|
30
|
+
agent_name: thread.subject,
|
|
31
|
+
description: `Module overlap on: ${moduleOverlap.join(", ")}`,
|
|
32
|
+
details: `Thread "${thread.subject}" (${thread.initiator_id}) targets same modules`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// 2. File overlap
|
|
36
|
+
const fileOverlap = params.target_files.filter((f) => threadFiles.includes(f));
|
|
37
|
+
if (fileOverlap.length > 0) {
|
|
38
|
+
conflicts.push({
|
|
39
|
+
type: "file_overlap",
|
|
40
|
+
severity: "warning",
|
|
41
|
+
agent_id: thread.initiator_id,
|
|
42
|
+
agent_name: thread.subject,
|
|
43
|
+
description: `File overlap on: ${fileOverlap.join(", ")}`,
|
|
44
|
+
details: `Thread "${thread.subject}" (${thread.initiator_id}) targets same files`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// 3. Dependency chain
|
|
48
|
+
for (const targetModule of params.target_modules) {
|
|
49
|
+
const info = this.depMap.getModuleInfo(targetModule);
|
|
50
|
+
if (!info)
|
|
51
|
+
continue;
|
|
52
|
+
for (const dep of info.depends_on) {
|
|
53
|
+
if (threadModules.includes(dep)) {
|
|
54
|
+
conflicts.push({
|
|
55
|
+
type: "dependency_chain",
|
|
56
|
+
severity: "info",
|
|
57
|
+
agent_id: thread.initiator_id,
|
|
58
|
+
agent_name: thread.subject,
|
|
59
|
+
description: `${targetModule} depends on ${dep} which is being modified`,
|
|
60
|
+
details: `Thread "${thread.subject}" modifies ${dep}, a dependency of ${targetModule}`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Reverse: someone depends on what we're modifying
|
|
65
|
+
const radius = this.depMap.getBlastRadius(targetModule);
|
|
66
|
+
this.log.debug({
|
|
67
|
+
module_id: targetModule,
|
|
68
|
+
direct_dependents: radius.direct_dependents,
|
|
69
|
+
indirect_dependents: radius.indirect_dependents,
|
|
70
|
+
}, "Blast radius calculated");
|
|
71
|
+
for (const dependent of [...radius.direct_dependents, ...radius.indirect_dependents]) {
|
|
72
|
+
if (threadModules.includes(dependent)) {
|
|
73
|
+
conflicts.push({
|
|
74
|
+
type: "dependency_chain",
|
|
75
|
+
severity: "info",
|
|
76
|
+
agent_id: thread.initiator_id,
|
|
77
|
+
agent_name: thread.subject,
|
|
78
|
+
description: `${dependent} depends on ${targetModule} which you are modifying`,
|
|
79
|
+
details: `Thread "${thread.subject}" works on ${dependent}, which depends on ${targetModule}`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// 4. Hot file overlap (from actual file activity, not just declared files)
|
|
86
|
+
for (const targetFile of params.target_files) {
|
|
87
|
+
const activity = this.fileTracker.checkFileConflict(targetFile, params.agent_id, 60);
|
|
88
|
+
if (activity.conflict) {
|
|
89
|
+
for (const otherAgent of activity.agents) {
|
|
90
|
+
// Avoid duplicating with file_overlap already detected
|
|
91
|
+
if (!conflicts.some(c => c.agent_id === otherAgent && c.type === "file_overlap")) {
|
|
92
|
+
conflicts.push({
|
|
93
|
+
type: "file_overlap",
|
|
94
|
+
severity: "warning",
|
|
95
|
+
agent_id: otherAgent,
|
|
96
|
+
agent_name: otherAgent,
|
|
97
|
+
description: `Hot file: ${targetFile} recently edited by ${otherAgent}`,
|
|
98
|
+
details: `File activity shows ${targetFile} was recently modified by ${otherAgent}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (conflicts.length > 0) {
|
|
105
|
+
this.log.warn({
|
|
106
|
+
agent_id: params.agent_id,
|
|
107
|
+
conflict_count: conflicts.length,
|
|
108
|
+
types: [...new Set(conflicts.map(c => c.type))],
|
|
109
|
+
modules: params.target_modules,
|
|
110
|
+
}, "Conflicts detected");
|
|
111
|
+
}
|
|
112
|
+
return conflicts;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type Logger } from "./logger.js";
|
|
2
|
+
import type { Thread, ThreadMessage, ActionSummary, MessageType, ResolutionType } from "./types.js";
|
|
3
|
+
export interface ResolutionEvent {
|
|
4
|
+
thread_id: string;
|
|
5
|
+
resolution_type: ResolutionType;
|
|
6
|
+
resolution_summary: string | null;
|
|
7
|
+
created_at: string;
|
|
8
|
+
resolved_at: string;
|
|
9
|
+
approved_by?: string;
|
|
10
|
+
approved_by_name?: string;
|
|
11
|
+
had_messages: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare class Consultation {
|
|
14
|
+
private onResolveCallback;
|
|
15
|
+
private log;
|
|
16
|
+
constructor(logger?: Logger);
|
|
17
|
+
onResolve(callback: (event: ResolutionEvent) => void): void;
|
|
18
|
+
emitResolution(threadId: string, type: ResolutionType, approvedBy?: string, approvedByName?: string): void;
|
|
19
|
+
announceWork(params: {
|
|
20
|
+
agent_id: string;
|
|
21
|
+
subject: string;
|
|
22
|
+
plan?: string;
|
|
23
|
+
target_modules: string[];
|
|
24
|
+
target_files: string[];
|
|
25
|
+
depends_on_files?: string[];
|
|
26
|
+
exports_affected?: string[];
|
|
27
|
+
keep_open?: boolean;
|
|
28
|
+
assigned_to?: string | null;
|
|
29
|
+
}): Thread;
|
|
30
|
+
postToThread(params: {
|
|
31
|
+
thread_id: string;
|
|
32
|
+
agent_id: string;
|
|
33
|
+
agent_name?: string;
|
|
34
|
+
type: MessageType;
|
|
35
|
+
content: string;
|
|
36
|
+
context_snapshot?: string;
|
|
37
|
+
in_reply_to?: string;
|
|
38
|
+
}): ThreadMessage;
|
|
39
|
+
proposeResolution(threadId: string, agentId: string, summary: string): void;
|
|
40
|
+
approveResolution(threadId: string, agentId: string, agentName?: string): void;
|
|
41
|
+
contestResolution(threadId: string, agentId: string, reason: string): void;
|
|
42
|
+
cancelThread(threadId: string, agentId: string, reason?: string): void;
|
|
43
|
+
closeThread(threadId: string, agentId: string, summary: string): void;
|
|
44
|
+
handleAgentDeparture(agentId: string): void;
|
|
45
|
+
checkTimeouts(): void;
|
|
46
|
+
getThread(threadId: string): Thread | null;
|
|
47
|
+
getThreadWithMessages(threadId: string): {
|
|
48
|
+
thread: Thread;
|
|
49
|
+
messages: ThreadMessage[];
|
|
50
|
+
} | null;
|
|
51
|
+
listThreads(filters: {
|
|
52
|
+
status?: string;
|
|
53
|
+
agent_id?: string;
|
|
54
|
+
module?: string;
|
|
55
|
+
/**
|
|
56
|
+
* When set, only return threads that are claimable by this agent:
|
|
57
|
+
* assigned_to IS NULL (open pool — anyone can take it)
|
|
58
|
+
* OR assigned_to = agent_id (directed to me)
|
|
59
|
+
* Workers use this to filter out dispatches for other agents without
|
|
60
|
+
* parsing the thread list themselves.
|
|
61
|
+
*/
|
|
62
|
+
assigned_to_me?: string;
|
|
63
|
+
}): Thread[];
|
|
64
|
+
getThreadUpdates(agentId: string, since?: string): ThreadMessage[];
|
|
65
|
+
logActionSummary(params: {
|
|
66
|
+
session_id: string;
|
|
67
|
+
agent_id: string;
|
|
68
|
+
file_path?: string;
|
|
69
|
+
summary: string;
|
|
70
|
+
}): ActionSummary;
|
|
71
|
+
getActionSummaries(agentId: string, since?: string): ActionSummary[];
|
|
72
|
+
getActionSummariesBySession(sessionId: string): ActionSummary[];
|
|
73
|
+
private postResolutionMessage;
|
|
74
|
+
private allRespondentsApproved;
|
|
75
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { getDb } from "./database.js";
|
|
3
|
+
import { silentLogger } from "./logger.js";
|
|
4
|
+
export class Consultation {
|
|
5
|
+
onResolveCallback = null;
|
|
6
|
+
log;
|
|
7
|
+
constructor(logger) {
|
|
8
|
+
this.log = logger || silentLogger;
|
|
9
|
+
}
|
|
10
|
+
onResolve(callback) {
|
|
11
|
+
this.onResolveCallback = callback;
|
|
12
|
+
}
|
|
13
|
+
emitResolution(threadId, type, approvedBy, approvedByName) {
|
|
14
|
+
const db = getDb();
|
|
15
|
+
const thread = this.getThread(threadId);
|
|
16
|
+
if (!thread)
|
|
17
|
+
return;
|
|
18
|
+
const messageCount = db.prepare("SELECT COUNT(*) as count FROM thread_messages WHERE thread_id = ?").get(threadId).count;
|
|
19
|
+
const durationMs = thread ? Date.now() - new Date(thread.created_at).getTime() : undefined;
|
|
20
|
+
this.log.info({
|
|
21
|
+
thread_id: threadId,
|
|
22
|
+
resolution_type: type,
|
|
23
|
+
approved_by: approvedBy,
|
|
24
|
+
duration_ms: durationMs,
|
|
25
|
+
}, "Thread resolved");
|
|
26
|
+
if (this.onResolveCallback) {
|
|
27
|
+
this.onResolveCallback({
|
|
28
|
+
thread_id: threadId,
|
|
29
|
+
resolution_type: type,
|
|
30
|
+
resolution_summary: thread.resolution_summary,
|
|
31
|
+
created_at: thread.created_at,
|
|
32
|
+
resolved_at: thread.resolved_at || new Date().toISOString(),
|
|
33
|
+
approved_by: approvedBy,
|
|
34
|
+
approved_by_name: approvedByName,
|
|
35
|
+
had_messages: messageCount > 0,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
announceWork(params) {
|
|
40
|
+
const db = getDb();
|
|
41
|
+
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 + "/")));
|
|
49
|
+
});
|
|
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);
|
|
59
|
+
this.log.info({
|
|
60
|
+
thread_id: id,
|
|
61
|
+
agent_id: params.agent_id,
|
|
62
|
+
subject: params.subject,
|
|
63
|
+
target_modules: params.target_modules,
|
|
64
|
+
auto_resolve: autoResolve,
|
|
65
|
+
respondent_count: respondentIds.length,
|
|
66
|
+
assigned_to: assignedTo,
|
|
67
|
+
}, "Thread opened");
|
|
68
|
+
return this.getThread(id);
|
|
69
|
+
}
|
|
70
|
+
postToThread(params) {
|
|
71
|
+
const db = getDb();
|
|
72
|
+
const thread = this.getThread(params.thread_id);
|
|
73
|
+
if (!thread)
|
|
74
|
+
throw new Error(`Thread ${params.thread_id} not found`);
|
|
75
|
+
// Cancelled threads are explicit aborts — reject posts.
|
|
76
|
+
// Resolved threads accept late posts (audit/enrichment) because the review
|
|
77
|
+
// phase races with auto-resolve: an ENRICHIT computed against an open
|
|
78
|
+
// thread may arrive milliseconds after the thread transitioned to resolved.
|
|
79
|
+
if (thread.status === "cancelled")
|
|
80
|
+
throw new Error(`Thread ${params.thread_id} is cancelled`);
|
|
81
|
+
const id = randomUUID();
|
|
82
|
+
// Simple token estimate: ~4 chars per token for English/French
|
|
83
|
+
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)
|
|
85
|
+
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
|
+
this.log.debug({
|
|
87
|
+
thread_id: params.thread_id,
|
|
88
|
+
agent_id: params.agent_id,
|
|
89
|
+
type: params.type,
|
|
90
|
+
content_length: params.content.length,
|
|
91
|
+
}, "Message posted to thread");
|
|
92
|
+
return db.prepare("SELECT * FROM thread_messages WHERE id = ?").get(id);
|
|
93
|
+
}
|
|
94
|
+
proposeResolution(threadId, agentId, summary) {
|
|
95
|
+
const db = getDb();
|
|
96
|
+
const thread = this.getThread(threadId);
|
|
97
|
+
if (!thread)
|
|
98
|
+
throw new Error(`Thread ${threadId} not found`);
|
|
99
|
+
if (thread.initiator_id !== agentId && thread.claimed_by !== agentId)
|
|
100
|
+
throw new Error("Only the initiator or the claimant can propose a resolution");
|
|
101
|
+
db.prepare("UPDATE threads SET status = 'resolving', resolution_summary = ? WHERE id = ?").run(summary, threadId);
|
|
102
|
+
// Post resolution message
|
|
103
|
+
this.postResolutionMessage(threadId, agentId, "resolution", summary);
|
|
104
|
+
}
|
|
105
|
+
approveResolution(threadId, agentId, agentName) {
|
|
106
|
+
const db = getDb();
|
|
107
|
+
const thread = this.getThread(threadId);
|
|
108
|
+
if (!thread)
|
|
109
|
+
throw new Error(`Thread ${threadId} not found`);
|
|
110
|
+
if (thread.status !== "resolving")
|
|
111
|
+
throw new Error(`Thread is ${thread.status}, not resolving`);
|
|
112
|
+
// Post approve message
|
|
113
|
+
this.postResolutionMessage(threadId, agentId, "approve", "Approved");
|
|
114
|
+
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);
|
|
118
|
+
this.emitResolution(threadId, "consensus", agentId, agentName || agentId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
contestResolution(threadId, agentId, reason) {
|
|
122
|
+
const db = getDb();
|
|
123
|
+
const thread = this.getThread(threadId);
|
|
124
|
+
if (!thread)
|
|
125
|
+
throw new Error(`Thread ${threadId} not found`);
|
|
126
|
+
if (thread.status !== "resolving")
|
|
127
|
+
throw new Error(`Thread is ${thread.status}, not resolving`);
|
|
128
|
+
// Post contest message
|
|
129
|
+
this.postResolutionMessage(threadId, agentId, "contest", reason);
|
|
130
|
+
this.log.debug({ thread_id: threadId, agent_id: agentId, reason }, "Resolution contested");
|
|
131
|
+
// Return to open with next round
|
|
132
|
+
const nextRound = thread.round + 1;
|
|
133
|
+
if (nextRound > thread.max_rounds) {
|
|
134
|
+
// Max rounds reached — force resolve
|
|
135
|
+
db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), threadId);
|
|
136
|
+
this.emitResolution(threadId, "max_rounds");
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
db.prepare("UPDATE threads SET status = 'open', round = ?, resolution_summary = NULL WHERE id = ?").run(nextRound, threadId);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
cancelThread(threadId, agentId, reason) {
|
|
143
|
+
const db = getDb();
|
|
144
|
+
const thread = this.getThread(threadId);
|
|
145
|
+
if (!thread)
|
|
146
|
+
throw new Error(`Thread ${threadId} not found`);
|
|
147
|
+
if (thread.initiator_id !== agentId)
|
|
148
|
+
throw new Error("Only the initiator can cancel");
|
|
149
|
+
db.prepare("UPDATE threads SET status = 'cancelled', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), threadId);
|
|
150
|
+
if (reason) {
|
|
151
|
+
this.postResolutionMessage(threadId, agentId, "context", `Cancelled: ${reason}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
closeThread(threadId, agentId, summary) {
|
|
155
|
+
const db = getDb();
|
|
156
|
+
const thread = this.getThread(threadId);
|
|
157
|
+
if (!thread) {
|
|
158
|
+
throw new Error(`Thread ${threadId} not found`);
|
|
159
|
+
}
|
|
160
|
+
if (thread.initiator_id !== agentId) {
|
|
161
|
+
throw new Error(`Only the initiator (${thread.initiator_id}) may close thread ${threadId}, not ${agentId}`);
|
|
162
|
+
}
|
|
163
|
+
if (thread.status !== "open" && thread.status !== "resolving") {
|
|
164
|
+
throw new Error(`Cannot close thread ${threadId} in status '${thread.status}'`);
|
|
165
|
+
}
|
|
166
|
+
db.prepare("UPDATE threads SET status = 'resolved', resolution_summary = ?, resolved_at = ? WHERE id = ?").run(summary, new Date().toISOString(), threadId);
|
|
167
|
+
this.emitResolution(threadId, "closed");
|
|
168
|
+
}
|
|
169
|
+
handleAgentDeparture(agentId) {
|
|
170
|
+
const db = getDb();
|
|
171
|
+
// Unclaim any tasks claimed by the departing agent
|
|
172
|
+
db.prepare("UPDATE threads SET claimed_by = NULL, claimed_at = NULL WHERE claimed_by = ? AND status = 'open'")
|
|
173
|
+
.run(agentId);
|
|
174
|
+
// Remove departed agent from expected_respondents of all open/resolving threads
|
|
175
|
+
const threads = db
|
|
176
|
+
.prepare("SELECT id, expected_respondents FROM threads WHERE status IN ('open', 'resolving')")
|
|
177
|
+
.all();
|
|
178
|
+
for (const thread of threads) {
|
|
179
|
+
const respondents = JSON.parse(thread.expected_respondents || "[]");
|
|
180
|
+
const updated = respondents.filter((r) => r !== agentId);
|
|
181
|
+
db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?").run(JSON.stringify(updated), thread.id);
|
|
182
|
+
// If resolving and all remaining approved, resolve
|
|
183
|
+
const t = this.getThread(thread.id);
|
|
184
|
+
if (t.status === "resolving" && this.allRespondentsApproved(thread.id)) {
|
|
185
|
+
db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), thread.id);
|
|
186
|
+
this.emitResolution(thread.id, "agent_departure");
|
|
187
|
+
}
|
|
188
|
+
// If open and no respondents left, auto-resolve
|
|
189
|
+
if (t.status === "open" && updated.length === 0) {
|
|
190
|
+
db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), thread.id);
|
|
191
|
+
this.emitResolution(thread.id, "agent_departure");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
checkTimeouts() {
|
|
196
|
+
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();
|
|
204
|
+
if (timedOut.length === 0)
|
|
205
|
+
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
|
+
this.log.info({ count: timedOut.length, thread_ids: timedOut.map(t => t.id) }, "Threads timed out");
|
|
215
|
+
for (const t of timedOut) {
|
|
216
|
+
this.emitResolution(t.id, "timeout");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
getThread(threadId) {
|
|
220
|
+
this.checkTimeouts();
|
|
221
|
+
const db = getDb();
|
|
222
|
+
return (db.prepare("SELECT * FROM threads WHERE id = ?").get(threadId) || null);
|
|
223
|
+
}
|
|
224
|
+
getThreadWithMessages(threadId) {
|
|
225
|
+
const thread = this.getThread(threadId);
|
|
226
|
+
if (!thread)
|
|
227
|
+
return null;
|
|
228
|
+
const db = getDb();
|
|
229
|
+
const messages = db
|
|
230
|
+
.prepare("SELECT * FROM thread_messages WHERE thread_id = ? ORDER BY created_at")
|
|
231
|
+
.all(threadId);
|
|
232
|
+
return { thread, messages };
|
|
233
|
+
}
|
|
234
|
+
listThreads(filters) {
|
|
235
|
+
this.checkTimeouts();
|
|
236
|
+
const db = getDb();
|
|
237
|
+
let sql = "SELECT * FROM threads WHERE 1=1";
|
|
238
|
+
const params = [];
|
|
239
|
+
if (filters.status) {
|
|
240
|
+
sql += " AND status = ?";
|
|
241
|
+
params.push(filters.status);
|
|
242
|
+
}
|
|
243
|
+
if (filters.agent_id) {
|
|
244
|
+
sql += " AND initiator_id = ?";
|
|
245
|
+
params.push(filters.agent_id);
|
|
246
|
+
}
|
|
247
|
+
if (filters.module) {
|
|
248
|
+
// Exact match against each array element via json_each — LIKE on the
|
|
249
|
+
// serialized JSON caused false positives where "src/api" matched
|
|
250
|
+
// "src/api-gateway" too.
|
|
251
|
+
sql += " AND EXISTS (SELECT 1 FROM json_each(target_modules) WHERE value = ?)";
|
|
252
|
+
params.push(filters.module);
|
|
253
|
+
}
|
|
254
|
+
if (filters.assigned_to_me) {
|
|
255
|
+
sql += " AND (assigned_to IS NULL OR assigned_to = ?)";
|
|
256
|
+
params.push(filters.assigned_to_me);
|
|
257
|
+
}
|
|
258
|
+
sql += " ORDER BY created_at DESC";
|
|
259
|
+
return db.prepare(sql).all(...params);
|
|
260
|
+
}
|
|
261
|
+
getThreadUpdates(agentId, since) {
|
|
262
|
+
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')
|
|
266
|
+
AND tm.agent_id != ?`;
|
|
267
|
+
const params = [agentId];
|
|
268
|
+
if (since) {
|
|
269
|
+
sql += " AND tm.created_at >= ?";
|
|
270
|
+
// Normalize ANY parseable ISO/date string (including timezone offsets
|
|
271
|
+
// like "+05:00", "-0800", fractional seconds) to SQLite CURRENT_TIMESTAMP
|
|
272
|
+
// format "YYYY-MM-DD HH:MM:SS" in UTC. The old regex-based normalization
|
|
273
|
+
// only handled the `.\d+$` suffix, which left "+05:00" in place and
|
|
274
|
+
// broke the comparison.
|
|
275
|
+
const date = new Date(since);
|
|
276
|
+
const normalized = isNaN(date.getTime())
|
|
277
|
+
? since
|
|
278
|
+
: date.toISOString().replace("T", " ").slice(0, 19);
|
|
279
|
+
params.push(normalized);
|
|
280
|
+
}
|
|
281
|
+
sql += " ORDER BY tm.created_at";
|
|
282
|
+
return db.prepare(sql).all(...params);
|
|
283
|
+
}
|
|
284
|
+
logActionSummary(params) {
|
|
285
|
+
const db = getDb();
|
|
286
|
+
const id = randomUUID();
|
|
287
|
+
db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
|
|
288
|
+
VALUES (?, ?, ?, ?, ?)`).run(id, params.session_id, params.agent_id, params.file_path || null, params.summary);
|
|
289
|
+
return db.prepare("SELECT * FROM action_summaries WHERE id = ?").get(id);
|
|
290
|
+
}
|
|
291
|
+
getActionSummaries(agentId, since) {
|
|
292
|
+
const db = getDb();
|
|
293
|
+
let sql = "SELECT * FROM action_summaries WHERE agent_id = ?";
|
|
294
|
+
const params = [agentId];
|
|
295
|
+
if (since) {
|
|
296
|
+
sql += " AND created_at > ?";
|
|
297
|
+
params.push(since);
|
|
298
|
+
}
|
|
299
|
+
sql += " ORDER BY created_at DESC";
|
|
300
|
+
return db.prepare(sql).all(...params);
|
|
301
|
+
}
|
|
302
|
+
getActionSummariesBySession(sessionId) {
|
|
303
|
+
const db = getDb();
|
|
304
|
+
return db
|
|
305
|
+
.prepare("SELECT * FROM action_summaries WHERE session_id = ? ORDER BY created_at")
|
|
306
|
+
.all(sessionId);
|
|
307
|
+
}
|
|
308
|
+
// ── Private helpers ──
|
|
309
|
+
postResolutionMessage(threadId, agentId, type, content) {
|
|
310
|
+
const db = getDb();
|
|
311
|
+
const thread = this.getThread(threadId);
|
|
312
|
+
const id = randomUUID();
|
|
313
|
+
db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
|
|
314
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(id, threadId, agentId, type, content, thread.round);
|
|
315
|
+
}
|
|
316
|
+
allRespondentsApproved(threadId) {
|
|
317
|
+
const db = getDb();
|
|
318
|
+
const thread = this.getThread(threadId);
|
|
319
|
+
const expected = JSON.parse(thread.expected_respondents || "[]");
|
|
320
|
+
if (expected.length === 0)
|
|
321
|
+
return true;
|
|
322
|
+
// Only count approvals from the CURRENT round. A contested resolution
|
|
323
|
+
// increments the round, and prior-round approvals must be re-collected
|
|
324
|
+
// for the new proposal.
|
|
325
|
+
const approvals = db
|
|
326
|
+
.prepare(`SELECT DISTINCT agent_id FROM thread_messages
|
|
327
|
+
WHERE thread_id = ? AND type = 'approve' AND round = ?`)
|
|
328
|
+
.all(threadId, thread.round);
|
|
329
|
+
const approvedIds = new Set(approvals.map((a) => a.agent_id));
|
|
330
|
+
return expected.every((id) => approvedIds.has(id));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AgentContext, ConsultationAnnounce } from "./types.js";
|
|
2
|
+
import type { AgentRegistry } from "./agent-registry.js";
|
|
3
|
+
import type { Consultation } from "./consultation.js";
|
|
4
|
+
import type { FileTracker } from "./file-tracker.js";
|
|
5
|
+
export interface ContextProvider {
|
|
6
|
+
getRelevantContext(agentId: string, query: ConsultationAnnounce): AgentContext;
|
|
7
|
+
}
|
|
8
|
+
export declare class SummaryContextProvider implements ContextProvider {
|
|
9
|
+
private registry;
|
|
10
|
+
private consultation;
|
|
11
|
+
private fileTracker;
|
|
12
|
+
constructor(registry: AgentRegistry, consultation: Consultation, fileTracker: FileTracker);
|
|
13
|
+
getRelevantContext(agentId: string, query: ConsultationAnnounce): AgentContext;
|
|
14
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export class SummaryContextProvider {
|
|
2
|
+
registry;
|
|
3
|
+
consultation;
|
|
4
|
+
fileTracker;
|
|
5
|
+
constructor(registry, consultation, fileTracker) {
|
|
6
|
+
this.registry = registry;
|
|
7
|
+
this.consultation = consultation;
|
|
8
|
+
this.fileTracker = fileTracker;
|
|
9
|
+
}
|
|
10
|
+
getRelevantContext(agentId, query) {
|
|
11
|
+
const agent = this.registry.get(agentId);
|
|
12
|
+
if (!agent) {
|
|
13
|
+
return { agent_id: agentId, modules: [], recent_files: [], action_summaries: [] };
|
|
14
|
+
}
|
|
15
|
+
const agentModules = JSON.parse(agent.modules);
|
|
16
|
+
// Filter to only overlapping modules
|
|
17
|
+
const overlapping = agentModules.filter((am) => query.target_modules.some((tm) => am === tm || am.startsWith(tm + "/") || tm.startsWith(am + "/")));
|
|
18
|
+
if (overlapping.length === 0) {
|
|
19
|
+
return { agent_id: agentId, modules: [], recent_files: [], action_summaries: [] };
|
|
20
|
+
}
|
|
21
|
+
// Get action summaries for this agent
|
|
22
|
+
const summaries = this.consultation.getActionSummaries(agentId);
|
|
23
|
+
// Get recent files from action summaries (agent writes these via MCP tool)
|
|
24
|
+
const recentFiles = summaries
|
|
25
|
+
.filter((s) => s.file_path)
|
|
26
|
+
.map((s) => s.file_path);
|
|
27
|
+
return {
|
|
28
|
+
agent_id: agentId,
|
|
29
|
+
modules: overlapping,
|
|
30
|
+
recent_files: [...new Set(recentFiles)],
|
|
31
|
+
action_summaries: summaries,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|