agent-working-memory 0.5.6 → 0.6.1
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 +78 -43
- package/dist/adapters/claude-code.d.ts +4 -0
- package/dist/adapters/claude-code.d.ts.map +1 -0
- package/dist/adapters/claude-code.js +218 -0
- package/dist/adapters/claude-code.js.map +1 -0
- package/dist/adapters/codex.d.ts +4 -0
- package/dist/adapters/codex.d.ts.map +1 -0
- package/dist/adapters/codex.js +226 -0
- package/dist/adapters/codex.js.map +1 -0
- package/dist/adapters/common.d.ts +34 -0
- package/dist/adapters/common.d.ts.map +1 -0
- package/dist/adapters/common.js +145 -0
- package/dist/adapters/common.js.map +1 -0
- package/dist/adapters/cursor.d.ts +4 -0
- package/dist/adapters/cursor.d.ts.map +1 -0
- package/dist/adapters/cursor.js +138 -0
- package/dist/adapters/cursor.js.map +1 -0
- package/dist/adapters/http.d.ts +4 -0
- package/dist/adapters/http.d.ts.map +1 -0
- package/dist/adapters/http.js +88 -0
- package/dist/adapters/http.js.map +1 -0
- package/dist/adapters/index.d.ts +7 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +21 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/types.d.ts +65 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +4 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +40 -1
- package/dist/api/routes.js.map +1 -1
- package/dist/cli.js +504 -230
- package/dist/cli.js.map +1 -1
- package/dist/coordination/events.d.ts +59 -0
- package/dist/coordination/events.d.ts.map +1 -0
- package/dist/coordination/events.js +28 -0
- package/dist/coordination/events.js.map +1 -0
- package/dist/coordination/index.d.ts +10 -1
- package/dist/coordination/index.d.ts.map +1 -1
- package/dist/coordination/index.js +87 -3
- package/dist/coordination/index.js.map +1 -1
- package/dist/coordination/mcp-tools.d.ts.map +1 -1
- package/dist/coordination/mcp-tools.js +10 -5
- package/dist/coordination/mcp-tools.js.map +1 -1
- package/dist/coordination/peer-decisions.d.ts +40 -0
- package/dist/coordination/peer-decisions.d.ts.map +1 -0
- package/dist/coordination/peer-decisions.js +82 -0
- package/dist/coordination/peer-decisions.js.map +1 -0
- package/dist/coordination/plugin-loader.d.ts +18 -0
- package/dist/coordination/plugin-loader.d.ts.map +1 -0
- package/dist/coordination/plugin-loader.js +55 -0
- package/dist/coordination/plugin-loader.js.map +1 -0
- package/dist/coordination/plugin.d.ts +40 -0
- package/dist/coordination/plugin.d.ts.map +1 -0
- package/dist/coordination/plugin.js +22 -0
- package/dist/coordination/plugin.js.map +1 -0
- package/dist/coordination/routes.d.ts +2 -1
- package/dist/coordination/routes.d.ts.map +1 -1
- package/dist/coordination/routes.js +1027 -65
- package/dist/coordination/routes.js.map +1 -1
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +104 -12
- package/dist/coordination/schema.js.map +1 -1
- package/dist/coordination/schemas.d.ts +105 -5
- package/dist/coordination/schemas.d.ts.map +1 -1
- package/dist/coordination/schemas.js +87 -1
- package/dist/coordination/schemas.js.map +1 -1
- package/dist/coordination/stale.d.ts +2 -0
- package/dist/coordination/stale.d.ts.map +1 -1
- package/dist/coordination/stale.js +7 -1
- package/dist/coordination/stale.js.map +1 -1
- package/dist/coordination/types.d.ts +252 -0
- package/dist/coordination/types.d.ts.map +1 -0
- package/dist/coordination/types.js +8 -0
- package/dist/coordination/types.js.map +1 -0
- package/dist/coordination/write-mutex.d.ts +26 -0
- package/dist/coordination/write-mutex.d.ts.map +1 -0
- package/dist/coordination/write-mutex.js +63 -0
- package/dist/coordination/write-mutex.js.map +1 -0
- package/dist/core/embeddings.d.ts +2 -0
- package/dist/core/embeddings.d.ts.map +1 -1
- package/dist/core/embeddings.js +4 -0
- package/dist/core/embeddings.js.map +1 -1
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +135 -26
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +42 -12
- package/dist/engine/consolidation.js.map +1 -1
- package/dist/engine/retraction.d.ts +3 -1
- package/dist/engine/retraction.d.ts.map +1 -1
- package/dist/engine/retraction.js +19 -6
- package/dist/engine/retraction.js.map +1 -1
- package/dist/index.js +82 -16
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +113 -6
- package/dist/mcp.js.map +1 -1
- package/dist/storage/sqlite.d.ts +24 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +88 -7
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types/engram.d.ts +24 -0
- package/dist/types/engram.d.ts.map +1 -1
- package/dist/types/engram.js.map +1 -1
- package/package.json +3 -1
- package/src/adapters/claude-code.ts +234 -0
- package/src/adapters/codex.ts +262 -0
- package/src/adapters/common.ts +172 -0
- package/src/adapters/cursor.ts +150 -0
- package/src/adapters/http.ts +100 -0
- package/src/adapters/index.ts +31 -0
- package/src/adapters/types.ts +75 -0
- package/src/api/routes.ts +50 -1
- package/src/cli.ts +561 -239
- package/src/coordination/events.ts +90 -0
- package/src/coordination/index.ts +102 -3
- package/src/coordination/mcp-tools.ts +10 -5
- package/src/coordination/peer-decisions.ts +105 -0
- package/src/coordination/plugin-loader.ts +60 -0
- package/src/coordination/plugin.ts +44 -0
- package/src/coordination/routes.ts +1353 -92
- package/src/coordination/schema.ts +91 -12
- package/src/coordination/schemas.ts +104 -1
- package/src/coordination/stale.ts +11 -2
- package/src/coordination/types.ts +311 -0
- package/src/coordination/write-mutex.ts +69 -0
- package/src/core/embeddings.ts +5 -0
- package/src/engine/activation.ts +138 -26
- package/src/engine/consolidation.ts +44 -12
- package/src/engine/retraction.ts +22 -6
- package/src/index.ts +76 -14
- package/src/mcp.ts +142 -9
- package/src/storage/sqlite.ts +92 -7
- package/src/types/engram.ts +28 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* All tables use coord_ prefix to avoid collision with AWM core tables.
|
|
7
7
|
*/
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
|
-
import { checkinSchema, checkoutSchema, pulseSchema, assignCreateSchema, assignmentQuerySchema, assignmentClaimSchema, assignmentUpdateSchema, assignmentIdParamSchema, lockAcquireSchema, lockReleaseSchema, commandCreateSchema, commandWaitQuerySchema, findingCreateSchema, findingsQuerySchema, findingIdParamSchema, eventsQuerySchema, staleQuerySchema, workersQuerySchema, } from './schemas.js';
|
|
9
|
+
import { checkinSchema, checkoutSchema, pulseSchema, nextSchema, assignCreateSchema, assignmentQuerySchema, assignmentClaimSchema, assignmentUpdateSchema, assignmentIdParamSchema, assignmentsListSchema, reassignSchema, lockAcquireSchema, lockReleaseSchema, commandCreateSchema, commandWaitQuerySchema, findingCreateSchema, findingsQuerySchema, findingIdParamSchema, findingUpdateSchema, decisionsQuerySchema, decisionCreateSchema, eventsQuerySchema, staleQuerySchema, workersQuerySchema, agentIdParamSchema, timelineQuerySchema, channelRegisterSchema, channelDeregisterSchema, channelPushSchema, } from './schemas.js';
|
|
10
10
|
import { detectStale, cleanupStale } from './stale.js';
|
|
11
11
|
/** Pretty timestamp for coordination logs. */
|
|
12
12
|
function ts() {
|
|
@@ -16,11 +16,69 @@ function ts() {
|
|
|
16
16
|
function coordLog(msg) {
|
|
17
17
|
console.log(`${ts()} [coord] ${msg}`);
|
|
18
18
|
}
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Optional session-token check.
|
|
21
|
+
* If X-Session-Token header is present and doesn't match the stored token → returns false (caller should 403).
|
|
22
|
+
* If header is absent, or no token stored (old agent row) → returns true (pass through).
|
|
23
|
+
*/
|
|
24
|
+
function sessionTokenOk(db, agentId, req) {
|
|
25
|
+
const provided = req.headers['x-session-token'];
|
|
26
|
+
if (!provided)
|
|
27
|
+
return true;
|
|
28
|
+
const row = db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(agentId);
|
|
29
|
+
if (!row || !row.session_token)
|
|
30
|
+
return true; // not found or no token stored — backward compat
|
|
31
|
+
return row.session_token === provided;
|
|
32
|
+
}
|
|
33
|
+
export function registerCoordinationRoutes(app, db, store, eventBus) {
|
|
34
|
+
// Request logging — one line per request with method, url, status, response time
|
|
35
|
+
app.addHook('onRequest', async (request) => {
|
|
36
|
+
request._startTime = Date.now();
|
|
37
|
+
});
|
|
21
38
|
app.addHook('onResponse', async (request, reply) => {
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
const ms = Date.now() - (request._startTime ?? Date.now());
|
|
40
|
+
// Skip noisy polling endpoints at 2xx to reduce log spam
|
|
41
|
+
const isPolling = (request.url === '/next' || request.url === '/pulse' || request.url === '/health') && reply.statusCode < 300;
|
|
42
|
+
if (!isPolling) {
|
|
43
|
+
coordLog(`${request.method} ${request.url} ${reply.statusCode} ${ms}ms`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
// Pulse coalescing — skip DB write if last pulse was <10s ago
|
|
47
|
+
const PULSE_COALESCE_MS = 10_000;
|
|
48
|
+
const lastPulseTime = new Map();
|
|
49
|
+
// Rate limiting — 300 requests/minute per agent (sliding window)
|
|
50
|
+
// Hive agents poll frequently + synapse-push polls /events every 2s
|
|
51
|
+
const RATE_LIMIT = 300;
|
|
52
|
+
const RATE_WINDOW_MS = 60_000;
|
|
53
|
+
const rateBuckets = new Map();
|
|
54
|
+
// Cleanup stale buckets every 5 minutes
|
|
55
|
+
setInterval(() => {
|
|
56
|
+
const cutoff = Date.now() - RATE_WINDOW_MS;
|
|
57
|
+
for (const [key, timestamps] of rateBuckets) {
|
|
58
|
+
const fresh = timestamps.filter(t => t > cutoff);
|
|
59
|
+
if (fresh.length === 0)
|
|
60
|
+
rateBuckets.delete(key);
|
|
61
|
+
else
|
|
62
|
+
rateBuckets.set(key, fresh);
|
|
63
|
+
}
|
|
64
|
+
}, 300_000).unref();
|
|
65
|
+
app.addHook('preHandler', async (request, reply) => {
|
|
66
|
+
if (request.url === '/health')
|
|
67
|
+
return; // exempt
|
|
68
|
+
// Identify agent by name from body or query, or agentId
|
|
69
|
+
const body = request.body;
|
|
70
|
+
const query = request.query;
|
|
71
|
+
const key = (body?.name ?? body?.agentId ?? query?.agentId ?? query?.name ?? request.ip);
|
|
72
|
+
if (!key)
|
|
73
|
+
return;
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const cutoff = now - RATE_WINDOW_MS;
|
|
76
|
+
const timestamps = rateBuckets.get(key) ?? [];
|
|
77
|
+
const recent = timestamps.filter(t => t > cutoff);
|
|
78
|
+
recent.push(now);
|
|
79
|
+
rateBuckets.set(key, recent);
|
|
80
|
+
if (recent.length > RATE_LIMIT) {
|
|
81
|
+
return reply.code(429).send({ error: `rate limit exceeded — max ${RATE_LIMIT} requests/minute` });
|
|
24
82
|
}
|
|
25
83
|
});
|
|
26
84
|
// ─── Checkin ────────────────────────────────────────────────────
|
|
@@ -28,33 +86,105 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
28
86
|
const parsed = checkinSchema.safeParse(req.body);
|
|
29
87
|
if (!parsed.success)
|
|
30
88
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
31
|
-
const { name, role, pid, metadata, capabilities, workspace } = parsed.data;
|
|
89
|
+
const { name, role, pid, metadata, capabilities, workspace, channelUrl } = parsed.data;
|
|
32
90
|
const capsJson = capabilities ? JSON.stringify(capabilities) : null;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
91
|
+
// Look up ANY existing agent with same name+workspace — including dead ones (upsert)
|
|
92
|
+
// Falls back to name-only to handle workspace changes between sessions
|
|
93
|
+
let existing = workspace
|
|
94
|
+
? db.prepare(`SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`).get(name, workspace)
|
|
95
|
+
: db.prepare(`SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`).get(name);
|
|
96
|
+
if (!existing) {
|
|
97
|
+
existing = db.prepare(`SELECT id, status FROM coord_agents WHERE name = ? ORDER BY last_seen DESC LIMIT 1`).get(name);
|
|
98
|
+
}
|
|
36
99
|
if (existing) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
100
|
+
const wasDead = existing.status === 'dead';
|
|
101
|
+
// Issue a fresh token on reconnect; reuse existing token for live heartbeats
|
|
102
|
+
const sessionToken = wasDead ? randomUUID() : (db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(existing.id).session_token ?? randomUUID());
|
|
103
|
+
db.prepare(`UPDATE coord_agents SET last_seen = datetime('now'), status = CASE WHEN status = 'dead' THEN 'idle' ELSE status END, pid = COALESCE(?, pid), capabilities = COALESCE(?, capabilities), workspace = COALESCE(?, workspace), session_token = ? WHERE id = ?`).run(pid ?? null, capsJson, workspace ?? null, sessionToken, existing.id);
|
|
104
|
+
const eventType = wasDead ? 'reconnected' : 'heartbeat';
|
|
105
|
+
const detail = wasDead ? `${name} reconnected (was dead)` : `heartbeat from ${name}`;
|
|
106
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`).run(existing.id, eventType, detail);
|
|
107
|
+
if (wasDead)
|
|
108
|
+
coordLog(`${name} reconnected (reusing UUID ${existing.id.slice(0, 8)})`);
|
|
109
|
+
// Auto-register channel session if channelUrl provided
|
|
110
|
+
if (channelUrl) {
|
|
111
|
+
db.prepare(`
|
|
112
|
+
INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
|
|
113
|
+
VALUES (?, ?, datetime('now'), 'connected')
|
|
114
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
115
|
+
channel_id = excluded.channel_id,
|
|
116
|
+
connected_at = datetime('now'),
|
|
117
|
+
status = 'connected',
|
|
118
|
+
push_count = 0,
|
|
119
|
+
last_push_at = NULL
|
|
120
|
+
`).run(existing.id, channelUrl);
|
|
121
|
+
coordLog(`channel auto-registered: ${name} (${existing.id.slice(0, 8)}) → ${channelUrl}`);
|
|
122
|
+
}
|
|
123
|
+
const action = wasDead ? 'reconnected' : 'heartbeat';
|
|
124
|
+
const status = wasDead ? 'idle' : existing.status;
|
|
125
|
+
return reply.send({ agentId: existing.id, sessionToken, action, status, workspace });
|
|
40
126
|
}
|
|
41
127
|
const id = randomUUID();
|
|
42
|
-
|
|
128
|
+
const sessionToken = randomUUID();
|
|
129
|
+
db.prepare(`INSERT INTO coord_agents (id, name, role, pid, status, metadata, capabilities, workspace, session_token) VALUES (?, ?, ?, ?, 'idle', ?, ?, ?, ?)`).run(id, name, role ?? 'worker', pid ?? null, metadata ? JSON.stringify(metadata) : null, capsJson, workspace ?? null, sessionToken);
|
|
43
130
|
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`).run(id, `${name} joined as ${role ?? 'worker'}${workspace ? ' [' + workspace + ']' : ''}${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
|
|
131
|
+
// Auto-register channel session if channelUrl provided
|
|
132
|
+
if (channelUrl) {
|
|
133
|
+
db.prepare(`
|
|
134
|
+
INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
|
|
135
|
+
VALUES (?, ?, datetime('now'), 'connected')
|
|
136
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
137
|
+
channel_id = excluded.channel_id,
|
|
138
|
+
connected_at = datetime('now'),
|
|
139
|
+
status = 'connected',
|
|
140
|
+
push_count = 0,
|
|
141
|
+
last_push_at = NULL
|
|
142
|
+
`).run(id, channelUrl);
|
|
143
|
+
coordLog(`channel auto-registered: ${name} (${id.slice(0, 8)}) → ${channelUrl}`);
|
|
144
|
+
}
|
|
44
145
|
coordLog(`${name} registered (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
|
|
45
|
-
|
|
146
|
+
eventBus?.emit('agent.checkin', { agentId: id, name, role: role ?? 'worker', workspace: workspace ?? undefined });
|
|
147
|
+
return reply.code(201).send({ agentId: id, sessionToken, action: 'registered', status: 'idle', workspace });
|
|
148
|
+
});
|
|
149
|
+
// ─── Shutdown (graceful coordination teardown) ─────────────────
|
|
150
|
+
app.post('/shutdown', async (_req, reply) => {
|
|
151
|
+
// Mark all live agents as dead
|
|
152
|
+
const alive = db.prepare(`SELECT id, name FROM coord_agents WHERE status != 'dead'`).all();
|
|
153
|
+
const shutdownTx = db.transaction(() => {
|
|
154
|
+
for (const agent of alive) {
|
|
155
|
+
db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(agent.id);
|
|
156
|
+
db.prepare(`UPDATE coord_agents SET status = 'dead', current_task = NULL WHERE id = ?`).run(agent.id);
|
|
157
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'shutdown', 'graceful shutdown')`).run(agent.id);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
shutdownTx();
|
|
161
|
+
// Flush WAL before caller terminates the process
|
|
162
|
+
try {
|
|
163
|
+
db.pragma('wal_checkpoint(TRUNCATE)');
|
|
164
|
+
}
|
|
165
|
+
catch { /* non-fatal if DB is closing */ }
|
|
166
|
+
coordLog(`Graceful shutdown: ${alive.length} agent(s) marked offline`);
|
|
167
|
+
return reply.send({ ok: true, agents_marked_offline: alive.length });
|
|
46
168
|
});
|
|
47
169
|
app.post('/checkout', async (req, reply) => {
|
|
48
170
|
const parsed = checkoutSchema.safeParse(req.body);
|
|
49
171
|
if (!parsed.success)
|
|
50
172
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
51
173
|
const { agentId } = parsed.data;
|
|
52
|
-
db
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
174
|
+
if (!sessionTokenOk(db, agentId, req))
|
|
175
|
+
return reply.code(403).send({ error: 'invalid session token' });
|
|
176
|
+
// Atomic transaction: delete locks + channel session + update agent + event
|
|
177
|
+
const checkoutTx = db.transaction(() => {
|
|
178
|
+
db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(agentId);
|
|
179
|
+
db.prepare(`DELETE FROM coord_channel_sessions WHERE agent_id = ?`).run(agentId);
|
|
180
|
+
db.prepare(`UPDATE coord_agents SET status = 'dead', last_seen = datetime('now') WHERE id = ?`).run(agentId);
|
|
181
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'checkout', 'agent signed off')`).run(agentId);
|
|
182
|
+
});
|
|
183
|
+
checkoutTx();
|
|
184
|
+
// Look up agent name for logging (outside tx — read-only)
|
|
56
185
|
const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId);
|
|
57
186
|
coordLog(`${agent?.name ?? agentId} checked out`);
|
|
187
|
+
eventBus?.emit('agent.checkout', { agentId, name: agent?.name ?? agentId });
|
|
58
188
|
return reply.send({ ok: true });
|
|
59
189
|
});
|
|
60
190
|
// ─── Pulse (lightweight heartbeat — no event row) ──────────────
|
|
@@ -63,44 +193,306 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
63
193
|
if (!parsed.success)
|
|
64
194
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
65
195
|
const { agentId } = parsed.data;
|
|
196
|
+
if (!sessionTokenOk(db, agentId, req))
|
|
197
|
+
return reply.code(403).send({ error: 'invalid session token' });
|
|
198
|
+
// Coalesce: skip DB write if last pulse was <10s ago
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
const lastTime = lastPulseTime.get(agentId) ?? 0;
|
|
201
|
+
if (now - lastTime < PULSE_COALESCE_MS) {
|
|
202
|
+
return reply.send({ ok: true, coalesced: true });
|
|
203
|
+
}
|
|
204
|
+
lastPulseTime.set(agentId, now);
|
|
66
205
|
db.prepare(`UPDATE coord_agents SET last_seen = datetime('now') WHERE id = ?`).run(agentId);
|
|
67
206
|
return reply.send({ ok: true });
|
|
68
207
|
});
|
|
208
|
+
// ─── Next (combined checkin + commands + assignment poll) ───────
|
|
209
|
+
app.post('/next', async (req, reply) => {
|
|
210
|
+
const parsed = nextSchema.safeParse(req.body);
|
|
211
|
+
if (!parsed.success)
|
|
212
|
+
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
213
|
+
const { name, workspace, role, capabilities, channelUrl } = parsed.data;
|
|
214
|
+
const capsJson = capabilities ? JSON.stringify(capabilities) : null;
|
|
215
|
+
// Step 1: Upsert agent (checkin / heartbeat) — including dead agents (reuse UUID)
|
|
216
|
+
// Try exact name+workspace match first, then fall back to name-only to handle
|
|
217
|
+
// workspace changes between sessions (prevents orphaned assignments on old UUID)
|
|
218
|
+
let existing = workspace
|
|
219
|
+
? db.prepare(`SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`).get(name, workspace)
|
|
220
|
+
: db.prepare(`SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`).get(name);
|
|
221
|
+
// Fallback: name-only lookup if exact match failed (handles workspace change, e.g. NULL→PERSONAL)
|
|
222
|
+
if (!existing) {
|
|
223
|
+
existing = db.prepare(`SELECT id, status FROM coord_agents WHERE name = ? ORDER BY last_seen DESC LIMIT 1`).get(name);
|
|
224
|
+
}
|
|
225
|
+
let agentId;
|
|
226
|
+
let sessionToken;
|
|
227
|
+
if (existing) {
|
|
228
|
+
agentId = existing.id;
|
|
229
|
+
const wasDead = existing.status === 'dead';
|
|
230
|
+
// Fresh token on reconnect; reuse existing on heartbeat
|
|
231
|
+
const existingToken = db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(agentId).session_token;
|
|
232
|
+
sessionToken = wasDead ? randomUUID() : (existingToken ?? randomUUID());
|
|
233
|
+
db.prepare(`UPDATE coord_agents SET last_seen = datetime('now'), status = CASE WHEN status = 'dead' THEN 'idle' ELSE status END, capabilities = COALESCE(?, capabilities), workspace = COALESCE(?, workspace), session_token = ? WHERE id = ?`).run(capsJson, workspace ?? null, sessionToken, agentId);
|
|
234
|
+
const eventType = wasDead ? 'reconnected' : 'heartbeat';
|
|
235
|
+
const detail = wasDead ? `${name} reconnected via /next` : `heartbeat from ${name}`;
|
|
236
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`).run(agentId, eventType, detail);
|
|
237
|
+
if (wasDead)
|
|
238
|
+
coordLog(`${name} reconnected via /next (reusing UUID ${agentId.slice(0, 8)})`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
agentId = randomUUID();
|
|
242
|
+
sessionToken = randomUUID();
|
|
243
|
+
db.prepare(`INSERT INTO coord_agents (id, name, role, pid, status, metadata, capabilities, workspace, session_token) VALUES (?, ?, ?, NULL, 'idle', NULL, ?, ?, ?)`).run(agentId, name, role ?? 'worker', capsJson, workspace ?? null, sessionToken);
|
|
244
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`).run(agentId, `${name} joined as ${role ?? 'worker'} via /next`);
|
|
245
|
+
coordLog(`${name} registered via /next (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
|
|
246
|
+
}
|
|
247
|
+
// Auto-register channel session if channelUrl provided
|
|
248
|
+
if (channelUrl) {
|
|
249
|
+
db.prepare(`
|
|
250
|
+
INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
|
|
251
|
+
VALUES (?, ?, datetime('now'), 'connected')
|
|
252
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
253
|
+
channel_id = excluded.channel_id,
|
|
254
|
+
connected_at = datetime('now'),
|
|
255
|
+
status = 'connected',
|
|
256
|
+
push_count = 0,
|
|
257
|
+
last_push_at = NULL
|
|
258
|
+
`).run(agentId, channelUrl);
|
|
259
|
+
coordLog(`channel auto-registered via /next: ${name} (${agentId.slice(0, 8)}) → ${channelUrl}`);
|
|
260
|
+
}
|
|
261
|
+
// Step 2: Get active commands
|
|
262
|
+
const activeCommands = workspace
|
|
263
|
+
? db.prepare(`SELECT id, command, reason, issued_by, issued_at, workspace
|
|
264
|
+
FROM coord_commands WHERE cleared_at IS NULL AND (workspace = ? OR workspace IS NULL)
|
|
265
|
+
ORDER BY issued_at DESC`).all(workspace)
|
|
266
|
+
: db.prepare(`SELECT id, command, reason, issued_by, issued_at, workspace
|
|
267
|
+
FROM coord_commands WHERE cleared_at IS NULL
|
|
268
|
+
ORDER BY issued_at DESC`).all();
|
|
269
|
+
// Step 3: Get or auto-claim assignment
|
|
270
|
+
let assignment = db.prepare(`SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`).get(agentId);
|
|
271
|
+
// Cross-UUID fallback: check if this agent name has assignments under a different UUID
|
|
272
|
+
// (happens when POST /assign resolved worker_name to a stale/alternate UUID)
|
|
273
|
+
if (!assignment) {
|
|
274
|
+
const altIds = db.prepare(`SELECT id FROM coord_agents WHERE name = ? AND id != ? AND status != 'dead'`).all(name, agentId);
|
|
275
|
+
for (const alt of altIds) {
|
|
276
|
+
const altActive = db.prepare(`SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`).get(alt.id);
|
|
277
|
+
if (altActive) {
|
|
278
|
+
// Migrate assignment to the current agent UUID
|
|
279
|
+
db.prepare(`UPDATE coord_assignments SET agent_id = ? WHERE id = ?`).run(agentId, altActive.id);
|
|
280
|
+
db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`).run(altActive.id, agentId);
|
|
281
|
+
altActive.agent_id = agentId;
|
|
282
|
+
coordLog(`assignment ${altActive.id.slice(0, 8)} migrated from alt UUID ${alt.id.slice(0, 8)} to ${agentId.slice(0, 8)} (same agent: ${name})`);
|
|
283
|
+
assignment = altActive;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (!assignment) {
|
|
289
|
+
const agentWorkspace = workspace ?? null;
|
|
290
|
+
// Priority-ordered dispatch: higher priority first, then FIFO.
|
|
291
|
+
// Skip assignments blocked by incomplete dependencies.
|
|
292
|
+
const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
|
|
293
|
+
// First, check for tasks reserved specifically for this agent
|
|
294
|
+
const reserved = db.prepare(`SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id = ? ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`).get(agentId);
|
|
295
|
+
// Then fall back to truly unassigned tasks (agent_id IS NULL)
|
|
296
|
+
const pending = reserved ?? (agentWorkspace
|
|
297
|
+
? db.prepare(`SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id IS NULL AND (workspace = ? OR workspace IS NULL) ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`).get(agentWorkspace)
|
|
298
|
+
: db.prepare(`SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id IS NULL ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`).get());
|
|
299
|
+
if (pending) {
|
|
300
|
+
const claimed = db.prepare(`UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`).run(agentId, pending.id);
|
|
301
|
+
if (claimed.changes > 0) {
|
|
302
|
+
db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`).run(pending.id, agentId);
|
|
303
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_claimed', ?)`).run(agentId, `auto-claimed assignment ${pending.id} via /next`);
|
|
304
|
+
assignment = db.prepare(`SELECT * FROM coord_assignments WHERE id = ?`).get(pending.id);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// If agent has an active assignment, ensure status is 'working'
|
|
309
|
+
if (assignment) {
|
|
310
|
+
db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ? AND status != 'working'`).run(assignment.id, agentId);
|
|
311
|
+
}
|
|
312
|
+
// Read current agent status after all mutations
|
|
313
|
+
const agentRow = db.prepare(`SELECT status FROM coord_agents WHERE id = ?`).get(agentId);
|
|
314
|
+
// Deliver queued mailbox messages (persistent messages that survived disconnects/restarts)
|
|
315
|
+
const mailbox = db.prepare(`SELECT id, message, source, created_at FROM coord_mailbox
|
|
316
|
+
WHERE worker_name = ? AND delivered_at IS NULL
|
|
317
|
+
AND (workspace = ? OR workspace IS NULL)
|
|
318
|
+
ORDER BY created_at ASC LIMIT 10`).all(name, workspace ?? null);
|
|
319
|
+
if (mailbox.length > 0) {
|
|
320
|
+
const ids = mailbox.map(m => m.id);
|
|
321
|
+
db.prepare(`UPDATE coord_mailbox SET delivered_at = datetime('now') WHERE id IN (${ids.map(() => '?').join(',')})`).run(...ids);
|
|
322
|
+
coordLog(`mailbox: delivered ${mailbox.length} queued message(s) to ${name}`);
|
|
323
|
+
}
|
|
324
|
+
return reply.send({
|
|
325
|
+
agentId,
|
|
326
|
+
sessionToken,
|
|
327
|
+
status: agentRow.status,
|
|
328
|
+
assignment: assignment ?? null,
|
|
329
|
+
commands: activeCommands,
|
|
330
|
+
mailbox: mailbox.length > 0 ? mailbox.map(m => ({ message: m.message, source: m.source, queued_at: m.created_at })) : undefined,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
69
333
|
// ─── Assignments ────────────────────────────────────────────────
|
|
70
334
|
app.post('/assign', async (req, reply) => {
|
|
71
335
|
const parsed = assignCreateSchema.safeParse(req.body);
|
|
72
336
|
if (!parsed.success)
|
|
73
337
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
74
|
-
const {
|
|
75
|
-
|
|
76
|
-
|
|
338
|
+
const { task, description, workspace, priority, blocked_by, worker_name, context } = parsed.data;
|
|
339
|
+
let { agentId } = parsed.data;
|
|
340
|
+
// Resolve worker_name → agentId if agentId not provided
|
|
341
|
+
if (!agentId && worker_name) {
|
|
342
|
+
let found = workspace
|
|
343
|
+
? db.prepare(`SELECT id FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`).get(worker_name, workspace)
|
|
344
|
+
: db.prepare(`SELECT id FROM coord_agents WHERE name = ? AND workspace IS NULL AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`).get(worker_name);
|
|
345
|
+
// Fallback: name-only lookup (handles workspace changes)
|
|
346
|
+
if (!found) {
|
|
347
|
+
found = db.prepare(`SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`).get(worker_name);
|
|
348
|
+
}
|
|
349
|
+
if (!found) {
|
|
350
|
+
return reply.code(404).send({ error: `worker not found: ${worker_name}` });
|
|
351
|
+
}
|
|
352
|
+
agentId = found.id;
|
|
353
|
+
}
|
|
354
|
+
// Reject if agent already has an active assignment
|
|
77
355
|
if (agentId) {
|
|
78
|
-
db.prepare(`
|
|
356
|
+
const active = db.prepare(`SELECT id, task FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') LIMIT 1`).get(agentId);
|
|
357
|
+
if (active) {
|
|
358
|
+
return reply.code(409).send({ error: `agent already has active assignment: ${active.id}`, active_task: active.task });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const id = randomUUID();
|
|
362
|
+
let pushed = false;
|
|
363
|
+
// Atomic transaction: assignment insert + agent status + event + channel push
|
|
364
|
+
const assignTx = db.transaction(() => {
|
|
365
|
+
db.prepare(`INSERT INTO coord_assignments (id, agent_id, task, description, status, priority, blocked_by, workspace, started_at, context) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, agentId ?? null, task, description ?? null, agentId ? 'assigned' : 'pending', priority, blocked_by ?? null, workspace ?? null, agentId ? new Date().toISOString().replace('T', ' ').slice(0, 19) : null, context ?? null);
|
|
366
|
+
if (agentId) {
|
|
367
|
+
db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`).run(id, agentId);
|
|
368
|
+
}
|
|
369
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_created', ?)`).run(agentId ?? null, `task: ${task}`);
|
|
370
|
+
// Record channel push intent in the DB (stats + event)
|
|
371
|
+
if (agentId) {
|
|
372
|
+
const session = db.prepare(`SELECT agent_id, channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`).get(agentId);
|
|
373
|
+
if (session) {
|
|
374
|
+
// Record channel_push event so agent sees it on next poll/restore
|
|
375
|
+
const pushMsg = `NEW ASSIGNMENT: ${task}${description ? ' — ' + description.slice(0, 200) : ''}`;
|
|
376
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_push', ?)`).run(agentId, pushMsg.slice(0, 500));
|
|
377
|
+
pushed = true;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
assignTx();
|
|
382
|
+
// Actually deliver the push to the worker's channel HTTP endpoint (outside DB transaction)
|
|
383
|
+
let delivered = false;
|
|
384
|
+
if (pushed && agentId) {
|
|
385
|
+
const session = db.prepare(`SELECT channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`).get(agentId);
|
|
386
|
+
if (session) {
|
|
387
|
+
const pushMsg = `NEW ASSIGNMENT: ${task}${description ? ' — ' + description.slice(0, 200) : ''}`;
|
|
388
|
+
const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId);
|
|
389
|
+
const result = await deliverToChannel(agentId, session.channel_id, pushMsg, { source: 'coordinator', agent: agent?.name ?? agentId, assignmentId: id });
|
|
390
|
+
delivered = result.delivered;
|
|
391
|
+
if (delivered) {
|
|
392
|
+
db.prepare(`UPDATE coord_channel_sessions SET last_push_at = datetime('now'), push_count = push_count + 1 WHERE agent_id = ?`).run(agentId);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Bridge context to AWM engrams (outside transaction — engram store has its own DB)
|
|
397
|
+
if (store && context) {
|
|
398
|
+
try {
|
|
399
|
+
const ctx = JSON.parse(context);
|
|
400
|
+
const parts = [];
|
|
401
|
+
if (ctx.files)
|
|
402
|
+
parts.push(`Files: ${JSON.stringify(ctx.files)}`);
|
|
403
|
+
if (ctx.references)
|
|
404
|
+
parts.push(`References: ${JSON.stringify(ctx.references)}`);
|
|
405
|
+
if (ctx.decisions)
|
|
406
|
+
parts.push(`Decisions: ${JSON.stringify(ctx.decisions)}`);
|
|
407
|
+
if (ctx.acceptance_criteria)
|
|
408
|
+
parts.push(`Acceptance criteria: ${JSON.stringify(ctx.acceptance_criteria)}`);
|
|
409
|
+
// Include any remaining keys
|
|
410
|
+
for (const [k, v] of Object.entries(ctx)) {
|
|
411
|
+
if (!['files', 'references', 'decisions', 'acceptance_criteria'].includes(k) && v) {
|
|
412
|
+
parts.push(`${k}: ${JSON.stringify(v)}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (parts.length > 0) {
|
|
416
|
+
store.createEngram({
|
|
417
|
+
agentId: agentId ?? 'coordinator',
|
|
418
|
+
concept: `Task context: ${task.slice(0, 80)}`,
|
|
419
|
+
content: parts.join('\n'),
|
|
420
|
+
tags: ['shared', 'context', `task/${id}`],
|
|
421
|
+
memoryClass: 'canonical',
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// Context is not valid JSON — skip engram bridge silently
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// If push failed or no channel, queue to mailbox so worker gets it on next /next poll
|
|
430
|
+
let queued = false;
|
|
431
|
+
if (agentId && !delivered) {
|
|
432
|
+
const agent = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId);
|
|
433
|
+
if (agent) {
|
|
434
|
+
const mailMsg = `NEW ASSIGNMENT [${id.slice(0, 8)}]: ${task.slice(0, 500)}`;
|
|
435
|
+
db.prepare(`INSERT INTO coord_mailbox (worker_name, workspace, message, source) VALUES (?, ?, ?, 'coordinator')`).run(agent.name, agent.workspace, mailMsg);
|
|
436
|
+
queued = true;
|
|
437
|
+
coordLog(`mailbox/queue → ${agent.name}: assignment ${id.slice(0, 8)} (live push unavailable)`);
|
|
438
|
+
}
|
|
79
439
|
}
|
|
80
|
-
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_created', ?)`).run(agentId ?? null, `task: ${task}`);
|
|
81
440
|
// Log assignment with agent name
|
|
82
441
|
if (agentId) {
|
|
83
442
|
const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId);
|
|
84
|
-
coordLog(`assigned → ${agent?.name ?? 'unknown'}: ${task.slice(0, 80)}`);
|
|
443
|
+
coordLog(`assigned → ${agent?.name ?? 'unknown'}: ${task.slice(0, 80)}${delivered ? ' (pushed+delivered)' : queued ? ' (queued to mailbox)' : ''}`);
|
|
85
444
|
}
|
|
86
445
|
else {
|
|
87
446
|
coordLog(`assignment queued (pending): ${task.slice(0, 80)}`);
|
|
88
447
|
}
|
|
89
|
-
|
|
448
|
+
eventBus?.emit('assignment.created', { assignmentId: id, agentId: agentId ?? '', task, workspace: workspace ?? undefined });
|
|
449
|
+
return reply.code(201).send({ assignmentId: id, status: agentId ? 'assigned' : 'pending', pushed, delivered, queued });
|
|
90
450
|
});
|
|
91
451
|
app.get('/assignment', async (req, reply) => {
|
|
92
|
-
const
|
|
452
|
+
const q = assignmentQuerySchema.parse(req.query);
|
|
453
|
+
let agentId = req.headers['x-agent-id'] ?? q.agentId;
|
|
454
|
+
// Fallback: resolve agentId from name + workspace (with name-only fallback)
|
|
455
|
+
if (!agentId && q.name) {
|
|
456
|
+
let found = q.workspace
|
|
457
|
+
? db.prepare(`SELECT id FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead'`).get(q.name, q.workspace)
|
|
458
|
+
: db.prepare(`SELECT id FROM coord_agents WHERE name = ? AND workspace IS NULL AND status != 'dead'`).get(q.name);
|
|
459
|
+
if (!found) {
|
|
460
|
+
found = db.prepare(`SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`).get(q.name);
|
|
461
|
+
}
|
|
462
|
+
agentId = found?.id;
|
|
463
|
+
}
|
|
93
464
|
if (!agentId) {
|
|
94
465
|
return reply.send({ assignment: null });
|
|
95
466
|
}
|
|
96
467
|
const active = db.prepare(`SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`).get(agentId);
|
|
97
468
|
if (active)
|
|
98
469
|
return reply.send({ assignment: active });
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
470
|
+
// Cross-UUID fallback: if the agent has other UUIDs (e.g., from workspace changes or reconnects
|
|
471
|
+
// that created a new row), check those too. This fixes the case where POST /assign resolved
|
|
472
|
+
// worker_name to a different UUID than the one the worker is currently using.
|
|
473
|
+
const agentRow = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId);
|
|
474
|
+
if (agentRow) {
|
|
475
|
+
const altIds = db.prepare(`SELECT id FROM coord_agents WHERE name = ? AND id != ? AND status != 'dead'`).all(agentRow.name, agentId);
|
|
476
|
+
for (const alt of altIds) {
|
|
477
|
+
const altActive = db.prepare(`SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`).get(alt.id);
|
|
478
|
+
if (altActive) {
|
|
479
|
+
// Reassign to the current agent UUID so future lookups work directly
|
|
480
|
+
db.prepare(`UPDATE coord_assignments SET agent_id = ? WHERE id = ?`).run(agentId, altActive.id);
|
|
481
|
+
db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`).run(altActive.id, agentId);
|
|
482
|
+
altActive.agent_id = agentId;
|
|
483
|
+
coordLog(`assignment ${altActive.id.slice(0, 8)} migrated from alt UUID ${alt.id.slice(0, 8)} to ${agentId.slice(0, 8)} (same agent: ${agentRow.name})`);
|
|
484
|
+
return reply.send({ assignment: altActive });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const agentWorkspace = agentRow?.workspace ?? null;
|
|
489
|
+
const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
|
|
490
|
+
// First, check for tasks reserved specifically for this agent
|
|
491
|
+
const reserved = db.prepare(`SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id = ? ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`).get(agentId);
|
|
492
|
+
// Then fall back to truly unassigned tasks (agent_id IS NULL)
|
|
493
|
+
const pending = reserved ?? (agentWorkspace
|
|
494
|
+
? db.prepare(`SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id IS NULL AND (workspace = ? OR workspace IS NULL) ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`).get(agentWorkspace)
|
|
495
|
+
: db.prepare(`SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id IS NULL ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`).get());
|
|
104
496
|
if (pending) {
|
|
105
497
|
const claimed = db.prepare(`UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`).run(agentId, pending.id);
|
|
106
498
|
if (claimed.changes > 0) {
|
|
@@ -110,7 +502,7 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
110
502
|
return reply.send({ assignment });
|
|
111
503
|
}
|
|
112
504
|
}
|
|
113
|
-
const busyCount = db.prepare(`SELECT COUNT(*) as c FROM coord_agents WHERE status = 'working' AND last_seen > datetime('now', '-
|
|
505
|
+
const busyCount = db.prepare(`SELECT COUNT(*) as c FROM coord_agents WHERE status = 'working' AND last_seen > datetime('now', '-300 seconds')`).get().c;
|
|
114
506
|
const retryAfter = busyCount > 0 ? 30 : 300;
|
|
115
507
|
return reply.send({ assignment: null, retry_after_seconds: retryAfter });
|
|
116
508
|
});
|
|
@@ -120,6 +512,8 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
120
512
|
if (!parsed.success)
|
|
121
513
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
122
514
|
const { agentId } = parsed.data;
|
|
515
|
+
if (!sessionTokenOk(db, agentId, req))
|
|
516
|
+
return reply.code(403).send({ error: 'invalid session token' });
|
|
123
517
|
const result = db.prepare(`UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`).run(agentId, id);
|
|
124
518
|
if (result.changes === 0) {
|
|
125
519
|
return reply.code(409).send({ error: 'assignment not available (already claimed or missing)' });
|
|
@@ -128,32 +522,171 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
128
522
|
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_claimed', ?)`).run(agentId, `claimed assignment ${id}`);
|
|
129
523
|
return reply.send({ ok: true, assignmentId: id });
|
|
130
524
|
});
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
525
|
+
const VALID_TRANSITIONS = {
|
|
526
|
+
assigned: ['in_progress', 'failed'],
|
|
527
|
+
in_progress: ['completed', 'failed', 'blocked'],
|
|
528
|
+
blocked: ['in_progress', 'failed'],
|
|
529
|
+
};
|
|
530
|
+
function handleAssignmentUpdate(id, status, result, commitSha) {
|
|
531
|
+
// Status transition validation
|
|
532
|
+
const current = db.prepare(`SELECT status FROM coord_assignments WHERE id = ?`).get(id);
|
|
533
|
+
if (!current)
|
|
534
|
+
return { error: 'assignment not found' };
|
|
535
|
+
const allowed = VALID_TRANSITIONS[current.status];
|
|
536
|
+
if (allowed && !allowed.includes(status)) {
|
|
537
|
+
return { error: `invalid transition: ${current.status} → ${status}. Valid: ${allowed.join(', ')}` };
|
|
134
538
|
}
|
|
135
|
-
|
|
136
|
-
|
|
539
|
+
if (!allowed && ['completed', 'failed'].includes(current.status)) {
|
|
540
|
+
return { error: `cannot update ${current.status} assignment` };
|
|
137
541
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
|
|
542
|
+
// Verification gate: completed status requires structured proof of work
|
|
543
|
+
if (status === 'completed') {
|
|
544
|
+
if (!result || result.trim().length < 20) {
|
|
545
|
+
return { error: 'completion requires a result summary — minimum 20 characters describing what was done' };
|
|
546
|
+
}
|
|
547
|
+
// Must mention at least one of: commit/SHA, build, audit, test, verified, fix, created, updated, implemented
|
|
548
|
+
const actionWords = /\b(committed?|sha|[0-9a-f]{7,40}|builds?|audite?d?|teste?d?|verified|fixe?d?|created?|updated?|implemented?|added|refactored?|documented?|resolved|merged|deployed|removed|migrated|wrote|reviewed)\b/i;
|
|
549
|
+
if (!actionWords.test(result)) {
|
|
550
|
+
return { error: 'completion result must describe the work done — include what was committed, built, tested, or verified' };
|
|
142
551
|
}
|
|
143
552
|
}
|
|
144
|
-
|
|
145
|
-
|
|
553
|
+
// Atomic transaction: assignment update + agent status + event
|
|
554
|
+
const updateTx = db.transaction(() => {
|
|
555
|
+
if (['completed', 'failed'].includes(status)) {
|
|
556
|
+
db.prepare(`UPDATE coord_assignments SET status = ?, result = ?, commit_sha = ?, completed_at = datetime('now') WHERE id = ?`).run(status, result ?? null, commitSha ?? null, id);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
db.prepare(`UPDATE coord_assignments SET status = ?, result = ? WHERE id = ?`).run(status, result ?? null, id);
|
|
560
|
+
}
|
|
561
|
+
if (['completed', 'failed'].includes(status)) {
|
|
562
|
+
const assignment = db.prepare(`SELECT agent_id FROM coord_assignments WHERE id = ?`).get(id);
|
|
563
|
+
if (assignment?.agent_id) {
|
|
564
|
+
db.prepare(`UPDATE coord_agents SET status = 'idle', current_task = NULL WHERE id = ?`).run(assignment.agent_id);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const eventDetail = ['completed', 'failed'].includes(status)
|
|
568
|
+
? `${id} → ${status}${commitSha ? ' [' + commitSha + ']' : ''}: ${(result ?? '').slice(0, 300)}`
|
|
569
|
+
: `${id} → ${status}`;
|
|
570
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES ((SELECT agent_id FROM coord_assignments WHERE id = ?), 'assignment_update', ?)`).run(id, eventDetail);
|
|
571
|
+
});
|
|
572
|
+
updateTx();
|
|
573
|
+
// Log completion/failure with agent name and task (outside tx — read-only)
|
|
574
|
+
const assignInfo = db.prepare(`SELECT a.agent_id, a.task, g.name AS agent_name FROM coord_assignments a LEFT JOIN coord_agents g ON a.agent_id = g.id WHERE a.id = ?`).get(id);
|
|
146
575
|
if (['completed', 'failed'].includes(status)) {
|
|
147
|
-
|
|
148
|
-
|
|
576
|
+
coordLog(`${assignInfo?.agent_name ?? 'unknown'} ${status}: ${assignInfo?.task?.slice(0, 80) ?? id}`);
|
|
577
|
+
}
|
|
578
|
+
// Emit events
|
|
579
|
+
eventBus?.emit('assignment.updated', { assignmentId: id, agentId: assignInfo?.agent_id ?? null, status, result });
|
|
580
|
+
if (status === 'completed') {
|
|
581
|
+
eventBus?.emit('assignment.completed', { assignmentId: id, agentId: assignInfo?.agent_id ?? null, result: result ?? null });
|
|
582
|
+
}
|
|
583
|
+
// Auto-unblock: when an assignment completes, unblock any assignments that depend on it
|
|
584
|
+
if (status === 'completed') {
|
|
585
|
+
const blocked = db.prepare(`SELECT id, agent_id, task FROM coord_assignments WHERE blocked_by = ? AND status = 'blocked'`).all(id);
|
|
586
|
+
if (blocked.length > 0) {
|
|
587
|
+
const unblockTx = db.transaction(() => {
|
|
588
|
+
for (const dep of blocked) {
|
|
589
|
+
db.prepare(`UPDATE coord_assignments SET blocked_by = NULL, status = 'assigned' WHERE id = ?`).run(dep.id);
|
|
590
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_unblocked', ?)`).run(dep.agent_id, `unblocked by completion of ${id}: ${dep.task.slice(0, 80)}`);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
unblockTx();
|
|
594
|
+
for (const dep of blocked) {
|
|
595
|
+
coordLog(`auto-unblocked: ${dep.task.slice(0, 60)} (was blocked by ${id})`);
|
|
596
|
+
eventBus?.emit('assignment.updated', { assignmentId: dep.id, agentId: dep.agent_id, status: 'assigned', result: undefined });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
149
599
|
}
|
|
600
|
+
return {};
|
|
150
601
|
}
|
|
602
|
+
app.get('/assignment/:id', async (req, reply) => {
|
|
603
|
+
const { id } = assignmentIdParamSchema.parse(req.params);
|
|
604
|
+
const assignment = db.prepare(`SELECT a.*, g.name AS agent_name FROM coord_assignments a LEFT JOIN coord_agents g ON a.agent_id = g.id WHERE a.id = ?`).get(id);
|
|
605
|
+
if (!assignment)
|
|
606
|
+
return reply.code(404).send({ error: 'assignment not found' });
|
|
607
|
+
return reply.send({ assignment });
|
|
608
|
+
});
|
|
609
|
+
// List assignments with optional filters and pagination
|
|
610
|
+
app.get('/assignments', async (req, reply) => {
|
|
611
|
+
const q = assignmentsListSchema.parse(req.query);
|
|
612
|
+
const conditions = [];
|
|
613
|
+
const params = [];
|
|
614
|
+
if (q.status) {
|
|
615
|
+
conditions.push('a.status = ?');
|
|
616
|
+
params.push(q.status);
|
|
617
|
+
}
|
|
618
|
+
if (q.workspace) {
|
|
619
|
+
conditions.push('(a.workspace = ? OR a.workspace IS NULL)');
|
|
620
|
+
params.push(q.workspace);
|
|
621
|
+
}
|
|
622
|
+
if (q.agent_id) {
|
|
623
|
+
conditions.push('a.agent_id = ?');
|
|
624
|
+
params.push(q.agent_id);
|
|
625
|
+
}
|
|
626
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
627
|
+
const total = db.prepare(`SELECT COUNT(*) AS count FROM coord_assignments a ${where}`).get(...params).count;
|
|
628
|
+
const assignments = db.prepare(`SELECT a.*, g.name AS agent_name,
|
|
629
|
+
CASE WHEN a.blocked_by IS NOT NULL AND a.blocked_by NOT IN (SELECT id FROM coord_assignments WHERE status = 'completed')
|
|
630
|
+
THEN 1 ELSE 0 END AS is_blocked
|
|
631
|
+
FROM coord_assignments a
|
|
632
|
+
LEFT JOIN coord_agents g ON a.agent_id = g.id
|
|
633
|
+
${where}
|
|
634
|
+
ORDER BY a.priority DESC, a.created_at DESC
|
|
635
|
+
LIMIT ? OFFSET ?`).all(...params, q.limit, q.offset);
|
|
636
|
+
return reply.send({ assignments, total });
|
|
637
|
+
});
|
|
638
|
+
app.post('/reassign', async (req, reply) => {
|
|
639
|
+
const parsed = reassignSchema.safeParse(req.body);
|
|
640
|
+
if (!parsed.success)
|
|
641
|
+
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
642
|
+
const { assignmentId, target_worker_name } = parsed.data;
|
|
643
|
+
let { targetAgentId } = parsed.data;
|
|
644
|
+
// Verify assignment exists and is active
|
|
645
|
+
const assignment = db.prepare(`SELECT id, agent_id, task, status FROM coord_assignments WHERE id = ?`).get(assignmentId);
|
|
646
|
+
if (!assignment)
|
|
647
|
+
return reply.code(404).send({ error: 'assignment not found' });
|
|
648
|
+
if (['completed', 'failed'].includes(assignment.status)) {
|
|
649
|
+
return reply.code(400).send({ error: `cannot reassign ${assignment.status} assignment` });
|
|
650
|
+
}
|
|
651
|
+
// Resolve target_worker_name → targetAgentId
|
|
652
|
+
if (!targetAgentId && target_worker_name) {
|
|
653
|
+
const found = db.prepare(`SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`).get(target_worker_name);
|
|
654
|
+
if (!found)
|
|
655
|
+
return reply.code(404).send({ error: `target worker not found: ${target_worker_name}` });
|
|
656
|
+
targetAgentId = found.id;
|
|
657
|
+
}
|
|
658
|
+
// Verify targetAgentId exists
|
|
659
|
+
if (targetAgentId) {
|
|
660
|
+
const target = db.prepare(`SELECT id FROM coord_agents WHERE id = ?`).get(targetAgentId);
|
|
661
|
+
if (!target)
|
|
662
|
+
return reply.code(404).send({ error: 'target agent not found' });
|
|
663
|
+
}
|
|
664
|
+
// Release old agent: set idle, clear current_task, release locks
|
|
665
|
+
if (assignment.agent_id) {
|
|
666
|
+
db.prepare(`UPDATE coord_agents SET status = 'idle', current_task = NULL WHERE id = ?`).run(assignment.agent_id);
|
|
667
|
+
db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(assignment.agent_id);
|
|
668
|
+
}
|
|
669
|
+
if (targetAgentId) {
|
|
670
|
+
// Reassign to target
|
|
671
|
+
db.prepare(`UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ?`).run(targetAgentId, assignmentId);
|
|
672
|
+
db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`).run(assignmentId, targetAgentId);
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
// No target — return to pending for auto-claim
|
|
676
|
+
db.prepare(`UPDATE coord_assignments SET agent_id = NULL, status = 'pending', started_at = NULL WHERE id = ?`).run(assignmentId);
|
|
677
|
+
}
|
|
678
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'reassignment', ?)`).run(assignment.agent_id ?? null, `${assignmentId} reassigned from ${assignment.agent_id ?? 'unassigned'} to ${targetAgentId ?? 'pending'}`);
|
|
679
|
+
coordLog(`reassign: ${assignment.task.slice(0, 60)} → ${targetAgentId ?? 'pending'}`);
|
|
680
|
+
return reply.send({ ok: true, assignmentId, newAgentId: targetAgentId ?? null, status: targetAgentId ? 'assigned' : 'pending' });
|
|
681
|
+
});
|
|
151
682
|
app.post('/assignment/:id/update', async (req, reply) => {
|
|
152
683
|
const { id } = assignmentIdParamSchema.parse(req.params);
|
|
153
684
|
const parsed = assignmentUpdateSchema.safeParse(req.body);
|
|
154
685
|
if (!parsed.success)
|
|
155
686
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
156
|
-
handleAssignmentUpdate(id, parsed.data.status, parsed.data.result);
|
|
687
|
+
const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
|
|
688
|
+
if (gate.error)
|
|
689
|
+
return reply.code(400).send({ error: gate.error });
|
|
157
690
|
return reply.send({ ok: true });
|
|
158
691
|
});
|
|
159
692
|
app.patch('/assignment/:id', async (req, reply) => {
|
|
@@ -161,7 +694,9 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
161
694
|
const parsed = assignmentUpdateSchema.safeParse(req.body);
|
|
162
695
|
if (!parsed.success)
|
|
163
696
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
164
|
-
handleAssignmentUpdate(id, parsed.data.status, parsed.data.result);
|
|
697
|
+
const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
|
|
698
|
+
if (gate.error)
|
|
699
|
+
return reply.code(400).send({ error: gate.error });
|
|
165
700
|
return reply.send({ ok: true });
|
|
166
701
|
});
|
|
167
702
|
app.put('/assignment/:id', async (req, reply) => {
|
|
@@ -169,7 +704,9 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
169
704
|
const parsed = assignmentUpdateSchema.safeParse(req.body);
|
|
170
705
|
if (!parsed.success)
|
|
171
706
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
172
|
-
handleAssignmentUpdate(id, parsed.data.status, parsed.data.result);
|
|
707
|
+
const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
|
|
708
|
+
if (gate.error)
|
|
709
|
+
return reply.code(400).send({ error: gate.error });
|
|
173
710
|
return reply.send({ ok: true });
|
|
174
711
|
});
|
|
175
712
|
// ─── Locks ──────────────────────────────────────────────────────
|
|
@@ -178,6 +715,8 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
178
715
|
if (!parsed.success)
|
|
179
716
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
180
717
|
const { agentId, filePath, reason } = parsed.data;
|
|
718
|
+
if (!sessionTokenOk(db, agentId, req))
|
|
719
|
+
return reply.code(403).send({ error: 'invalid session token' });
|
|
181
720
|
const inserted = db.prepare(`INSERT OR IGNORE INTO coord_locks (file_path, agent_id, reason) VALUES (?, ?, ?)`).run(filePath, agentId, reason ?? null);
|
|
182
721
|
if (inserted.changes > 0) {
|
|
183
722
|
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'lock_acquired', ?)`).run(agentId, filePath);
|
|
@@ -198,6 +737,8 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
198
737
|
if (!parsed.success)
|
|
199
738
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
200
739
|
const { agentId, filePath } = parsed.data;
|
|
740
|
+
if (!sessionTokenOk(db, agentId, req))
|
|
741
|
+
return reply.code(403).send({ error: 'invalid session token' });
|
|
201
742
|
const result = db.prepare(`DELETE FROM coord_locks WHERE file_path = ? AND agent_id = ?`).run(filePath, agentId);
|
|
202
743
|
if (result.changes === 0) {
|
|
203
744
|
return reply.code(404).send({ error: 'lock not found or not owned by this agent' });
|
|
@@ -208,7 +749,7 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
208
749
|
app.get('/locks', async (_req, reply) => {
|
|
209
750
|
const locks = db.prepare(`SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
|
|
210
751
|
FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id
|
|
211
|
-
ORDER BY l.locked_at DESC`).all();
|
|
752
|
+
ORDER BY l.locked_at DESC LIMIT 200`).all();
|
|
212
753
|
return reply.send({ locks });
|
|
213
754
|
});
|
|
214
755
|
// ─── Commands ───────────────────────────────────────────────────
|
|
@@ -219,7 +760,10 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
219
760
|
const { command, reason, issuedBy, workspace } = parsed.data;
|
|
220
761
|
if (command === 'RESUME') {
|
|
221
762
|
if (workspace) {
|
|
222
|
-
|
|
763
|
+
// Clear commands targeting this workspace AND global commands (workspace IS NULL).
|
|
764
|
+
// Global commands (e.g. SHUTDOWN with no workspace) apply to all workspaces,
|
|
765
|
+
// so RESUME for a workspace must also clear them — otherwise they persist forever.
|
|
766
|
+
db.prepare(`UPDATE coord_commands SET cleared_at = datetime('now') WHERE cleared_at IS NULL AND (workspace = ? OR workspace IS NULL)`).run(workspace);
|
|
223
767
|
}
|
|
224
768
|
else {
|
|
225
769
|
db.prepare(`UPDATE coord_commands SET cleared_at = datetime('now') WHERE cleared_at IS NULL`).run();
|
|
@@ -254,6 +798,17 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
254
798
|
commands: active,
|
|
255
799
|
});
|
|
256
800
|
});
|
|
801
|
+
app.delete('/command/:id', async (req, reply) => {
|
|
802
|
+
const id = Number(req.params.id);
|
|
803
|
+
if (!Number.isInteger(id) || id <= 0)
|
|
804
|
+
return reply.code(400).send({ error: 'invalid command id' });
|
|
805
|
+
const result = db.prepare(`UPDATE coord_commands SET cleared_at = datetime('now') WHERE id = ? AND cleared_at IS NULL`).run(id);
|
|
806
|
+
if (result.changes === 0) {
|
|
807
|
+
return reply.code(404).send({ error: 'command not found or already cleared' });
|
|
808
|
+
}
|
|
809
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (NULL, 'command', ?)`).run(`command ${id} cleared via DELETE`);
|
|
810
|
+
return reply.send({ ok: true });
|
|
811
|
+
});
|
|
257
812
|
app.get('/command/wait', async (req, reply) => {
|
|
258
813
|
const q = commandWaitQuerySchema.safeParse(req.query);
|
|
259
814
|
const { status: targetStatus, workspace } = q.success ? q.data : { status: 'idle', workspace: undefined };
|
|
@@ -264,8 +819,8 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
264
819
|
: db.prepare(`SELECT id, name, role, status, current_task, last_seen
|
|
265
820
|
FROM coord_agents WHERE status NOT IN ('dead')
|
|
266
821
|
ORDER BY name`).all();
|
|
267
|
-
const ready = agents.filter(a => a.status === targetStatus || a.role === 'orchestrator');
|
|
268
|
-
const notReady = agents.filter(a => a.status !== targetStatus && a.role !== 'orchestrator');
|
|
822
|
+
const ready = agents.filter(a => a.status === targetStatus || a.role === 'orchestrator' || a.role === 'coordinator');
|
|
823
|
+
const notReady = agents.filter(a => a.status !== targetStatus && a.role !== 'orchestrator' && a.role !== 'coordinator');
|
|
269
824
|
return reply.send({
|
|
270
825
|
allReady: notReady.length === 0,
|
|
271
826
|
total: agents.length,
|
|
@@ -279,6 +834,8 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
279
834
|
if (!parsed.success)
|
|
280
835
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
281
836
|
const { agentId, category, severity, filePath, lineNumber, description, suggestion } = parsed.data;
|
|
837
|
+
if (!sessionTokenOk(db, agentId, req))
|
|
838
|
+
return reply.code(403).send({ error: 'invalid session token' });
|
|
282
839
|
db.prepare(`INSERT INTO coord_findings (agent_id, category, severity, file_path, line_number, description, suggestion)
|
|
283
840
|
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(agentId, category, severity ?? 'info', filePath ?? null, lineNumber ?? null, description, suggestion ?? null);
|
|
284
841
|
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'finding', ?)`).run(agentId, `[${severity ?? 'info'}] ${category}: ${description.slice(0, 100)}`);
|
|
@@ -321,25 +878,88 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
321
878
|
db.prepare(`UPDATE coord_findings SET status = 'resolved', resolved_at = datetime('now') WHERE id = ?`).run(id);
|
|
322
879
|
return reply.send({ ok: true });
|
|
323
880
|
});
|
|
881
|
+
app.patch('/finding/:id', async (req, reply) => {
|
|
882
|
+
const { id } = findingIdParamSchema.parse(req.params);
|
|
883
|
+
const parsed = findingUpdateSchema.safeParse(req.body);
|
|
884
|
+
if (!parsed.success)
|
|
885
|
+
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
886
|
+
const { status, suggestion } = parsed.data;
|
|
887
|
+
const existing = db.prepare(`SELECT id FROM coord_findings WHERE id = ?`).get(id);
|
|
888
|
+
if (!existing)
|
|
889
|
+
return reply.code(404).send({ error: 'finding not found' });
|
|
890
|
+
const sets = [];
|
|
891
|
+
const params = [];
|
|
892
|
+
if (status) {
|
|
893
|
+
sets.push('status = ?');
|
|
894
|
+
params.push(status);
|
|
895
|
+
if (status === 'resolved') {
|
|
896
|
+
sets.push("resolved_at = datetime('now')");
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (suggestion !== undefined) {
|
|
900
|
+
sets.push('suggestion = ?');
|
|
901
|
+
params.push(suggestion);
|
|
902
|
+
}
|
|
903
|
+
if (sets.length === 0)
|
|
904
|
+
return reply.send({ ok: true, changed: false });
|
|
905
|
+
params.push(id);
|
|
906
|
+
db.prepare(`UPDATE coord_findings SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
|
907
|
+
return reply.send({ ok: true, changed: true });
|
|
908
|
+
});
|
|
324
909
|
app.get('/findings/summary', async (_req, reply) => {
|
|
325
910
|
const bySeverity = db.prepare(`SELECT severity, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY severity`).all();
|
|
326
911
|
const byCategory = db.prepare(`SELECT category, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY category ORDER BY count DESC`).all();
|
|
327
912
|
const total = db.prepare(`SELECT COUNT(*) as total FROM coord_findings WHERE status = 'open'`).get();
|
|
328
913
|
return reply.send({ total: total.total, bySeverity, byCategory });
|
|
329
914
|
});
|
|
915
|
+
// ─── Decisions (cross-agent propagation) ────────────────────────
|
|
916
|
+
app.get('/decisions', async (req, reply) => {
|
|
917
|
+
const q = decisionsQuerySchema.safeParse(req.query);
|
|
918
|
+
const { since_id, assignment_id, workspace, limit } = q.success ? q.data : { since_id: 0, assignment_id: undefined, workspace: undefined, limit: 20 };
|
|
919
|
+
let sql = `
|
|
920
|
+
SELECT d.id, d.author_id, a.name AS author_name, d.assignment_id, d.tags, d.summary, d.created_at
|
|
921
|
+
FROM coord_decisions d JOIN coord_agents a ON d.author_id = a.id
|
|
922
|
+
WHERE d.id > ?
|
|
923
|
+
`;
|
|
924
|
+
const params = [since_id];
|
|
925
|
+
if (assignment_id) {
|
|
926
|
+
sql += ` AND d.assignment_id = ?`;
|
|
927
|
+
params.push(assignment_id);
|
|
928
|
+
}
|
|
929
|
+
if (workspace) {
|
|
930
|
+
sql += ` AND (a.workspace = ? OR a.workspace IS NULL)`;
|
|
931
|
+
params.push(workspace);
|
|
932
|
+
}
|
|
933
|
+
sql += ` ORDER BY d.created_at ASC LIMIT ?`;
|
|
934
|
+
params.push(limit);
|
|
935
|
+
const decisions = db.prepare(sql).all(...params);
|
|
936
|
+
return reply.send({ decisions });
|
|
937
|
+
});
|
|
938
|
+
app.post('/decisions', async (req, reply) => {
|
|
939
|
+
const { agentId, assignment_id, tags, summary } = decisionCreateSchema.parse(req.body);
|
|
940
|
+
// Verify agent exists
|
|
941
|
+
const agent = db.prepare(`SELECT id FROM coord_agents WHERE id = ?`).get(agentId);
|
|
942
|
+
if (!agent)
|
|
943
|
+
return reply.code(404).send({ error: 'agent not found' });
|
|
944
|
+
if (!sessionTokenOk(db, agentId, req))
|
|
945
|
+
return reply.code(403).send({ error: 'invalid session token' });
|
|
946
|
+
db.prepare(`INSERT INTO coord_decisions (author_id, assignment_id, tags, summary) VALUES (?, ?, ?, ?)`).run(agentId, assignment_id ?? null, tags ?? null, summary);
|
|
947
|
+
const row = db.prepare(`SELECT last_insert_rowid() AS id`).get();
|
|
948
|
+
return reply.code(201).send({ ok: true, id: row.id });
|
|
949
|
+
});
|
|
330
950
|
// ─── Status ─────────────────────────────────────────────────────
|
|
331
951
|
app.get('/status', async (_req, reply) => {
|
|
332
952
|
const agents = db.prepare(`SELECT id, name, role, status, current_task, last_seen,
|
|
333
953
|
ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
|
|
334
954
|
FROM coord_agents WHERE status != 'dead'
|
|
335
|
-
ORDER BY role, name`).all();
|
|
955
|
+
ORDER BY role, name LIMIT 200`).all();
|
|
336
956
|
const assignments = db.prepare(`SELECT a.id, a.task, a.description, a.status, a.agent_id, ag.name AS agent_name,
|
|
337
957
|
a.created_at, a.started_at, a.completed_at
|
|
338
958
|
FROM coord_assignments a LEFT JOIN coord_agents ag ON a.agent_id = ag.id
|
|
339
959
|
WHERE a.status NOT IN ('completed', 'failed')
|
|
340
|
-
ORDER BY a.created_at`).all();
|
|
960
|
+
ORDER BY a.created_at LIMIT 200`).all();
|
|
341
961
|
const locks = db.prepare(`SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
|
|
342
|
-
FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id`).all();
|
|
962
|
+
FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id LIMIT 200`).all();
|
|
343
963
|
const stats = db.prepare(`SELECT
|
|
344
964
|
(SELECT COUNT(*) FROM coord_agents WHERE status != 'dead') AS alive_agents,
|
|
345
965
|
(SELECT COUNT(*) FROM coord_agents WHERE status = 'working') AS busy_agents,
|
|
@@ -363,13 +983,13 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
363
983
|
? db.prepare(`SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
|
|
364
984
|
ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
|
|
365
985
|
FROM coord_agents
|
|
366
|
-
WHERE status != 'dead' AND role
|
|
367
|
-
ORDER BY name`).all(workspace)
|
|
986
|
+
WHERE status != 'dead' AND role NOT IN ('orchestrator', 'coordinator') AND workspace = ?
|
|
987
|
+
ORDER BY name LIMIT 200`).all(workspace)
|
|
368
988
|
: db.prepare(`SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
|
|
369
989
|
ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
|
|
370
990
|
FROM coord_agents
|
|
371
|
-
WHERE status != 'dead' AND role
|
|
372
|
-
ORDER BY name`).all();
|
|
991
|
+
WHERE status != 'dead' AND role NOT IN ('orchestrator', 'coordinator')
|
|
992
|
+
ORDER BY name LIMIT 200`).all();
|
|
373
993
|
if (capability) {
|
|
374
994
|
workers = workers.filter(w => {
|
|
375
995
|
if (!w.capabilities)
|
|
@@ -396,7 +1016,7 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
396
1016
|
workspace: w.workspace,
|
|
397
1017
|
lastSeen: w.last_seen,
|
|
398
1018
|
secondsSinceSeen: w.seconds_since_seen,
|
|
399
|
-
alive: w.seconds_since_seen <
|
|
1019
|
+
alive: w.seconds_since_seen < 300,
|
|
400
1020
|
}));
|
|
401
1021
|
return reply.send({
|
|
402
1022
|
count: result.length,
|
|
@@ -407,15 +1027,35 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
407
1027
|
});
|
|
408
1028
|
app.get('/events', async (req, reply) => {
|
|
409
1029
|
const q = eventsQuerySchema.safeParse(req.query);
|
|
410
|
-
|
|
1030
|
+
if (!q.success)
|
|
1031
|
+
return reply.code(400).send({ error: q.error.issues[0].message });
|
|
1032
|
+
const { since_id, agent_id, event_type, limit } = q.data;
|
|
1033
|
+
const conditions = [];
|
|
1034
|
+
const params = [];
|
|
1035
|
+
if (since_id > 0) {
|
|
1036
|
+
conditions.push('e.id > ?');
|
|
1037
|
+
params.push(since_id);
|
|
1038
|
+
}
|
|
1039
|
+
if (agent_id) {
|
|
1040
|
+
conditions.push('e.agent_id = ?');
|
|
1041
|
+
params.push(agent_id);
|
|
1042
|
+
}
|
|
1043
|
+
if (event_type) {
|
|
1044
|
+
conditions.push('e.event_type = ?');
|
|
1045
|
+
params.push(event_type);
|
|
1046
|
+
}
|
|
1047
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
1048
|
+
params.push(limit);
|
|
411
1049
|
const events = db.prepare(`SELECT e.id, e.agent_id, a.name AS agent_name, e.event_type, e.detail, e.created_at
|
|
412
1050
|
FROM coord_events e LEFT JOIN coord_agents a ON e.agent_id = a.id
|
|
413
|
-
|
|
414
|
-
|
|
1051
|
+
${where}
|
|
1052
|
+
ORDER BY e.id ASC LIMIT ?`).all(...params);
|
|
1053
|
+
const last_id = events.length > 0 ? events[events.length - 1].id : since_id;
|
|
1054
|
+
return reply.send({ events, last_id });
|
|
415
1055
|
});
|
|
416
1056
|
app.get('/stale', async (req, reply) => {
|
|
417
1057
|
const q = staleQuerySchema.safeParse(req.query);
|
|
418
|
-
const threshold = q.success ? q.data.seconds :
|
|
1058
|
+
const threshold = q.success ? q.data.seconds : 300;
|
|
419
1059
|
const cleanup = q.success ? q.data.cleanup : undefined;
|
|
420
1060
|
const stale = detectStale(db, threshold);
|
|
421
1061
|
if (cleanup === '1' || cleanup === 'true') {
|
|
@@ -426,9 +1066,331 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
426
1066
|
});
|
|
427
1067
|
app.post('/stale/cleanup', async (req, reply) => {
|
|
428
1068
|
const q = staleQuerySchema.safeParse(req.query);
|
|
429
|
-
const threshold = q.success ? q.data.seconds :
|
|
1069
|
+
const threshold = q.success ? q.data.seconds : 300;
|
|
430
1070
|
const { stale, cleaned } = cleanupStale(db, threshold);
|
|
431
1071
|
return reply.send({ stale, threshold_seconds: threshold, cleaned });
|
|
432
1072
|
});
|
|
1073
|
+
// ─── Agent Management ───────────────────────────────────────────
|
|
1074
|
+
app.get('/agent/:id', async (req, reply) => {
|
|
1075
|
+
const params = agentIdParamSchema.safeParse(req.params);
|
|
1076
|
+
if (!params.success)
|
|
1077
|
+
return reply.code(400).send({ error: params.error.issues[0].message });
|
|
1078
|
+
const { id } = params.data;
|
|
1079
|
+
const agent = db.prepare(`SELECT id, name, role, status, current_task, pid, capabilities, workspace, metadata, last_seen, started_at,
|
|
1080
|
+
ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
|
|
1081
|
+
FROM coord_agents WHERE id = ?`).get(id);
|
|
1082
|
+
if (!agent)
|
|
1083
|
+
return reply.code(404).send({ error: 'agent not found' });
|
|
1084
|
+
// Include active assignment and locks
|
|
1085
|
+
const assignment = db.prepare(`SELECT id, task, status, priority, created_at FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`).get(id);
|
|
1086
|
+
const locks = db.prepare(`SELECT file_path, locked_at, reason FROM coord_locks WHERE agent_id = ?`).all(id);
|
|
1087
|
+
return reply.send({ agent, assignment: assignment ?? null, locks });
|
|
1088
|
+
});
|
|
1089
|
+
app.delete('/agent/:id', async (req, reply) => {
|
|
1090
|
+
const params = agentIdParamSchema.safeParse(req.params);
|
|
1091
|
+
if (!params.success)
|
|
1092
|
+
return reply.code(400).send({ error: params.error.issues[0].message });
|
|
1093
|
+
const { id } = params.data;
|
|
1094
|
+
const agent = db.prepare(`SELECT id, name, status FROM coord_agents WHERE id = ?`).get(id);
|
|
1095
|
+
if (!agent)
|
|
1096
|
+
return reply.code(404).send({ error: 'agent not found' });
|
|
1097
|
+
if (agent.status === 'dead')
|
|
1098
|
+
return reply.send({ ok: true, action: 'already_dead', agent_name: agent.name });
|
|
1099
|
+
// Fail active assignments
|
|
1100
|
+
const failedAssignments = db.prepare(`UPDATE coord_assignments SET status = 'failed', result = 'agent killed by coordinator', completed_at = datetime('now')
|
|
1101
|
+
WHERE agent_id = ? AND status IN ('assigned', 'in_progress')`).run(id);
|
|
1102
|
+
if (failedAssignments.changes > 0) {
|
|
1103
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_failed', ?)`).run(id, `killed: failed ${failedAssignments.changes} active assignment(s)`);
|
|
1104
|
+
}
|
|
1105
|
+
// Release locks
|
|
1106
|
+
const releasedLocks = db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(id);
|
|
1107
|
+
// Mark dead
|
|
1108
|
+
db.prepare(`UPDATE coord_agents SET status = 'dead', current_task = NULL WHERE id = ?`).run(id);
|
|
1109
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'agent_killed', ?)`).run(id, `${agent.name} killed: failed ${failedAssignments.changes} assignment(s), released ${releasedLocks.changes} lock(s)`);
|
|
1110
|
+
coordLog(`${agent.name} killed — failed ${failedAssignments.changes} assignment(s), released ${releasedLocks.changes} lock(s)`);
|
|
1111
|
+
return reply.send({
|
|
1112
|
+
ok: true,
|
|
1113
|
+
action: 'killed',
|
|
1114
|
+
agent_name: agent.name,
|
|
1115
|
+
failed_assignments: failedAssignments.changes,
|
|
1116
|
+
released_locks: releasedLocks.changes,
|
|
1117
|
+
});
|
|
1118
|
+
});
|
|
1119
|
+
// ─── Timeline ─────────────────────────────────────────────────────
|
|
1120
|
+
app.get('/timeline', async (req, reply) => {
|
|
1121
|
+
const q = timelineQuerySchema.safeParse(req.query);
|
|
1122
|
+
if (!q.success)
|
|
1123
|
+
return reply.code(400).send({ error: q.error.issues[0].message });
|
|
1124
|
+
const { limit, since } = q.data;
|
|
1125
|
+
const conditions = [];
|
|
1126
|
+
const params = [];
|
|
1127
|
+
if (since) {
|
|
1128
|
+
conditions.push('e.created_at >= ?');
|
|
1129
|
+
params.push(since);
|
|
1130
|
+
}
|
|
1131
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
1132
|
+
params.push(limit);
|
|
1133
|
+
const timeline = db.prepare(`SELECT e.created_at AS timestamp, a.name AS agent_name, e.event_type, e.detail,
|
|
1134
|
+
t.task AS assignment_task
|
|
1135
|
+
FROM coord_events e
|
|
1136
|
+
LEFT JOIN coord_agents a ON e.agent_id = a.id
|
|
1137
|
+
LEFT JOIN coord_assignments t ON a.current_task = t.id
|
|
1138
|
+
${where}
|
|
1139
|
+
ORDER BY e.created_at DESC, e.id DESC
|
|
1140
|
+
LIMIT ?`).all(...params);
|
|
1141
|
+
return reply.send({ timeline });
|
|
1142
|
+
});
|
|
1143
|
+
// ─── Stats ──────────────────────────────────────────────────────
|
|
1144
|
+
app.get('/stats', async (_req, reply) => {
|
|
1145
|
+
const workers = db.prepare(`
|
|
1146
|
+
SELECT
|
|
1147
|
+
COUNT(*) AS total,
|
|
1148
|
+
SUM(CASE WHEN status != 'dead' THEN 1 ELSE 0 END) AS alive,
|
|
1149
|
+
SUM(CASE WHEN status = 'idle' THEN 1 ELSE 0 END) AS idle,
|
|
1150
|
+
SUM(CASE WHEN status = 'working' THEN 1 ELSE 0 END) AS working
|
|
1151
|
+
FROM coord_agents
|
|
1152
|
+
`).get();
|
|
1153
|
+
const tasks = db.prepare(`
|
|
1154
|
+
SELECT
|
|
1155
|
+
COUNT(*) AS total_assigned,
|
|
1156
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
|
|
1157
|
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
|
|
1158
|
+
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
|
|
1159
|
+
AVG(CASE
|
|
1160
|
+
WHEN status = 'completed' AND started_at IS NOT NULL AND completed_at IS NOT NULL
|
|
1161
|
+
THEN ROUND((julianday(completed_at) - julianday(started_at)) * 86400)
|
|
1162
|
+
ELSE NULL
|
|
1163
|
+
END) AS avg_completion_seconds
|
|
1164
|
+
FROM coord_assignments
|
|
1165
|
+
`).get();
|
|
1166
|
+
const decisions = db.prepare(`
|
|
1167
|
+
SELECT
|
|
1168
|
+
COALESCE(COUNT(*), 0) AS total,
|
|
1169
|
+
COALESCE(SUM(CASE WHEN created_at >= datetime('now', '-1 hour') THEN 1 ELSE 0 END), 0) AS last_hour
|
|
1170
|
+
FROM coord_decisions
|
|
1171
|
+
`).get();
|
|
1172
|
+
// Uptime = seconds since the earliest non-dead agent started
|
|
1173
|
+
const uptime = db.prepare(`
|
|
1174
|
+
SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS uptime_seconds
|
|
1175
|
+
FROM coord_agents WHERE status != 'dead'
|
|
1176
|
+
`).get();
|
|
1177
|
+
return reply.send({
|
|
1178
|
+
workers,
|
|
1179
|
+
tasks: {
|
|
1180
|
+
...tasks,
|
|
1181
|
+
avg_completion_seconds: tasks.avg_completion_seconds != null
|
|
1182
|
+
? Math.round(tasks.avg_completion_seconds)
|
|
1183
|
+
: null,
|
|
1184
|
+
},
|
|
1185
|
+
decisions,
|
|
1186
|
+
uptime_seconds: uptime.uptime_seconds ?? 0,
|
|
1187
|
+
});
|
|
1188
|
+
});
|
|
1189
|
+
// ─── Prometheus Metrics ────────────────────────────────────────
|
|
1190
|
+
app.get('/metrics', async (_req, reply) => {
|
|
1191
|
+
const agentsByStatus = db.prepare(`SELECT status, COUNT(*) AS count FROM coord_agents GROUP BY status`).all();
|
|
1192
|
+
const assignmentsByStatus = db.prepare(`SELECT status, COUNT(*) AS count FROM coord_assignments GROUP BY status`).all();
|
|
1193
|
+
const locksActive = db.prepare(`SELECT COUNT(*) AS count FROM coord_locks`).get().count;
|
|
1194
|
+
const findingsBySeverity = db.prepare(`SELECT severity, COUNT(*) AS count FROM coord_findings WHERE status = 'open' GROUP BY severity`).all();
|
|
1195
|
+
const eventsTotal = db.prepare(`SELECT COUNT(*) AS count FROM coord_events`).get().count;
|
|
1196
|
+
const uptime = db.prepare(`SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS seconds FROM coord_agents WHERE status != 'dead'`).get().seconds ?? 0;
|
|
1197
|
+
const lines = [
|
|
1198
|
+
'# HELP coord_agents_total Number of agents by status',
|
|
1199
|
+
'# TYPE coord_agents_total gauge',
|
|
1200
|
+
];
|
|
1201
|
+
for (const row of agentsByStatus) {
|
|
1202
|
+
lines.push(`coord_agents_total{status="${row.status}"} ${row.count}`);
|
|
1203
|
+
}
|
|
1204
|
+
lines.push('# HELP coord_assignments_total Number of assignments by status');
|
|
1205
|
+
lines.push('# TYPE coord_assignments_total gauge');
|
|
1206
|
+
for (const row of assignmentsByStatus) {
|
|
1207
|
+
lines.push(`coord_assignments_total{status="${row.status}"} ${row.count}`);
|
|
1208
|
+
}
|
|
1209
|
+
lines.push('# HELP coord_locks_active Number of active file locks');
|
|
1210
|
+
lines.push('# TYPE coord_locks_active gauge');
|
|
1211
|
+
lines.push(`coord_locks_active ${locksActive}`);
|
|
1212
|
+
lines.push('# HELP coord_findings_total Open findings by severity');
|
|
1213
|
+
lines.push('# TYPE coord_findings_total gauge');
|
|
1214
|
+
for (const row of findingsBySeverity) {
|
|
1215
|
+
lines.push(`coord_findings_total{severity="${row.severity}"} ${row.count}`);
|
|
1216
|
+
}
|
|
1217
|
+
lines.push('# HELP coord_events_total Total coordination events');
|
|
1218
|
+
lines.push('# TYPE coord_events_total counter');
|
|
1219
|
+
lines.push(`coord_events_total ${eventsTotal}`);
|
|
1220
|
+
lines.push('# HELP coord_uptime_seconds Seconds since first agent registered');
|
|
1221
|
+
lines.push('# TYPE coord_uptime_seconds gauge');
|
|
1222
|
+
lines.push(`coord_uptime_seconds ${uptime}`);
|
|
1223
|
+
return reply.type('text/plain; version=0.0.4; charset=utf-8').send(lines.join('\n') + '\n');
|
|
1224
|
+
});
|
|
1225
|
+
// ─── Deep Health ───────────────────────────────────────────────
|
|
1226
|
+
app.get('/health/deep', async (_req, reply) => {
|
|
1227
|
+
const dbHealthy = store ? store.integrityCheck().ok : true;
|
|
1228
|
+
const agents = db.prepare(`SELECT COUNT(*) AS alive FROM coord_agents WHERE status != 'dead'`).get();
|
|
1229
|
+
const staleThreshold = 300;
|
|
1230
|
+
const staleCount = db.prepare(`SELECT COUNT(*) AS c FROM coord_agents
|
|
1231
|
+
WHERE status != 'dead'
|
|
1232
|
+
AND (julianday('now') - julianday(last_seen)) * 86400 > ?`).get(staleThreshold).c;
|
|
1233
|
+
const pending = db.prepare(`SELECT COUNT(*) AS c FROM coord_assignments WHERE status IN ('pending', 'assigned', 'in_progress')`).get().c;
|
|
1234
|
+
const uptimeRow = db.prepare(`SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS s
|
|
1235
|
+
FROM coord_agents WHERE status != 'dead'`).get();
|
|
1236
|
+
// WAL file size and autocheckpoint setting
|
|
1237
|
+
let walSizeBytes = null;
|
|
1238
|
+
let walAutocheckpoint = null;
|
|
1239
|
+
try {
|
|
1240
|
+
const fs = require('fs');
|
|
1241
|
+
const walPath = db.name + '-wal';
|
|
1242
|
+
const stat = fs.statSync(walPath);
|
|
1243
|
+
walSizeBytes = stat.size;
|
|
1244
|
+
}
|
|
1245
|
+
catch { /* WAL file may not exist */ }
|
|
1246
|
+
try {
|
|
1247
|
+
const acRow = db.pragma('wal_autocheckpoint');
|
|
1248
|
+
walAutocheckpoint = acRow[0]?.wal_autocheckpoint ?? null;
|
|
1249
|
+
}
|
|
1250
|
+
catch { /* pragma read failed */ }
|
|
1251
|
+
const status = (!dbHealthy || staleCount > 2) ? 'degraded' : 'ok';
|
|
1252
|
+
return reply.send({
|
|
1253
|
+
status,
|
|
1254
|
+
db_healthy: dbHealthy,
|
|
1255
|
+
agents_alive: agents.alive,
|
|
1256
|
+
stale_agents: staleCount,
|
|
1257
|
+
pending_tasks: pending,
|
|
1258
|
+
uptime_seconds: uptimeRow.s ?? 0,
|
|
1259
|
+
wal_size_bytes: walSizeBytes,
|
|
1260
|
+
wal_autocheckpoint: walAutocheckpoint,
|
|
1261
|
+
});
|
|
1262
|
+
});
|
|
1263
|
+
// ─── Channel Sessions ───────────────────────────────────────────
|
|
1264
|
+
/** POST /channel/register — Register or update a channel session for an agent. */
|
|
1265
|
+
app.post('/channel/register', async (request, reply) => {
|
|
1266
|
+
const parsed = channelRegisterSchema.safeParse(request.body);
|
|
1267
|
+
if (!parsed.success)
|
|
1268
|
+
return reply.status(400).send({ error: parsed.error.flatten() });
|
|
1269
|
+
const { agentId, channelId } = parsed.data;
|
|
1270
|
+
const agent = db.prepare('SELECT id FROM coord_agents WHERE id = ?').get(agentId);
|
|
1271
|
+
if (!agent)
|
|
1272
|
+
return reply.status(404).send({ error: 'Agent not found' });
|
|
1273
|
+
db.prepare(`
|
|
1274
|
+
INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
|
|
1275
|
+
VALUES (?, ?, datetime('now'), 'connected')
|
|
1276
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
1277
|
+
channel_id = excluded.channel_id,
|
|
1278
|
+
connected_at = datetime('now'),
|
|
1279
|
+
status = 'connected',
|
|
1280
|
+
push_count = 0,
|
|
1281
|
+
last_push_at = NULL
|
|
1282
|
+
`).run(agentId, channelId);
|
|
1283
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_register', ?)`).run(agentId, JSON.stringify({ channelId }));
|
|
1284
|
+
coordLog(`channel/register: ${agentId} → ${channelId}`);
|
|
1285
|
+
eventBus?.emit('session.started', { agentId, channelId });
|
|
1286
|
+
return reply.send({ ok: true });
|
|
1287
|
+
});
|
|
1288
|
+
/** DELETE /channel/register — Deregister a channel session for an agent. */
|
|
1289
|
+
app.delete('/channel/register', async (request, reply) => {
|
|
1290
|
+
const parsed = channelDeregisterSchema.safeParse(request.body);
|
|
1291
|
+
if (!parsed.success)
|
|
1292
|
+
return reply.status(400).send({ error: parsed.error.flatten() });
|
|
1293
|
+
const { agentId } = parsed.data;
|
|
1294
|
+
const result = db.prepare('DELETE FROM coord_channel_sessions WHERE agent_id = ?').run(agentId);
|
|
1295
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_deregister', NULL)`).run(agentId);
|
|
1296
|
+
coordLog(`channel/deregister: ${agentId} (rows: ${result.changes})`);
|
|
1297
|
+
eventBus?.emit('session.closed', { agentId, channelId: '' });
|
|
1298
|
+
return reply.send({ ok: true });
|
|
1299
|
+
});
|
|
1300
|
+
/**
|
|
1301
|
+
* Deliver a message to a worker's channel HTTP endpoint.
|
|
1302
|
+
* Returns { delivered, error? }. On connection failure, marks session dead.
|
|
1303
|
+
*/
|
|
1304
|
+
async function deliverToChannel(agentId, channelUrl, content, meta) {
|
|
1305
|
+
try {
|
|
1306
|
+
const res = await fetch(`${channelUrl}/push`, {
|
|
1307
|
+
method: 'POST',
|
|
1308
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1309
|
+
body: JSON.stringify({ content, meta: meta ?? {} }),
|
|
1310
|
+
signal: AbortSignal.timeout(5000),
|
|
1311
|
+
});
|
|
1312
|
+
if (!res.ok) {
|
|
1313
|
+
return { delivered: false, error: `channel returned ${res.status}` };
|
|
1314
|
+
}
|
|
1315
|
+
return { delivered: true };
|
|
1316
|
+
}
|
|
1317
|
+
catch (err) {
|
|
1318
|
+
// Connection refused / timeout → worker process is dead, mark session disconnected
|
|
1319
|
+
db.prepare(`UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`).run(agentId);
|
|
1320
|
+
const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId);
|
|
1321
|
+
coordLog(`channel/deliver FAILED → ${agent?.name ?? agentId}: ${err instanceof Error ? err.message : err} — session marked disconnected`);
|
|
1322
|
+
return { delivered: false, error: `worker unreachable: ${err instanceof Error ? err.message : err}` };
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
/** POST /channel/push — Push a message to an agent. Tries live delivery first, falls back to mailbox queue. */
|
|
1326
|
+
app.post('/channel/push', async (request, reply) => {
|
|
1327
|
+
const parsed = channelPushSchema.safeParse(request.body);
|
|
1328
|
+
if (!parsed.success)
|
|
1329
|
+
return reply.status(400).send({ error: parsed.error.flatten() });
|
|
1330
|
+
const { agentId, message } = parsed.data;
|
|
1331
|
+
const agent = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId);
|
|
1332
|
+
if (!agent)
|
|
1333
|
+
return reply.status(404).send({ error: 'Agent not found' });
|
|
1334
|
+
// Try live channel delivery first
|
|
1335
|
+
const session = db.prepare(`SELECT agent_id, channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`).get(agentId);
|
|
1336
|
+
if (session) {
|
|
1337
|
+
const { delivered } = await deliverToChannel(agentId, session.channel_id, message, { source: 'coordinator', agent: agent.name });
|
|
1338
|
+
if (delivered) {
|
|
1339
|
+
db.prepare(`UPDATE coord_channel_sessions SET last_push_at = datetime('now'), push_count = push_count + 1 WHERE agent_id = ?`).run(agentId);
|
|
1340
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_push', ?)`).run(agentId, message.slice(0, 500));
|
|
1341
|
+
coordLog(`channel/push → ${agent.name}: ${message.slice(0, 80)}`);
|
|
1342
|
+
return reply.send({ ok: true, delivered: true, channelId: session.channel_id });
|
|
1343
|
+
}
|
|
1344
|
+
// Live delivery failed — fall through to mailbox
|
|
1345
|
+
}
|
|
1346
|
+
// Queue to mailbox (delivered on next /next poll)
|
|
1347
|
+
db.prepare(`INSERT INTO coord_mailbox (worker_name, workspace, message, source) VALUES (?, ?, ?, 'coordinator')`).run(agent.name, agent.workspace, message);
|
|
1348
|
+
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'mailbox_queued', ?)`).run(agentId, `queued for ${agent.name}: ${message.slice(0, 200)}`);
|
|
1349
|
+
coordLog(`mailbox/queue → ${agent.name}: ${message.slice(0, 80)} (live delivery unavailable)`);
|
|
1350
|
+
return reply.send({ ok: true, delivered: false, queued: true, hint: 'Message queued in mailbox — will be delivered on next /next poll' });
|
|
1351
|
+
});
|
|
1352
|
+
/** GET /channel/sessions — List all active channel sessions with agent names. */
|
|
1353
|
+
app.get('/channel/sessions', async (_request, reply) => {
|
|
1354
|
+
const sessions = db.prepare(`
|
|
1355
|
+
SELECT cs.agent_id, a.name AS agent_name, cs.channel_id,
|
|
1356
|
+
cs.connected_at, cs.last_push_at, cs.push_count, cs.status
|
|
1357
|
+
FROM coord_channel_sessions cs
|
|
1358
|
+
JOIN coord_agents a ON a.id = cs.agent_id
|
|
1359
|
+
WHERE cs.status = 'connected'
|
|
1360
|
+
ORDER BY cs.connected_at DESC
|
|
1361
|
+
`).all();
|
|
1362
|
+
return reply.send({ sessions });
|
|
1363
|
+
});
|
|
1364
|
+
/** POST /channel/probe — Probe all connected channel sessions, mark dead ones as disconnected. */
|
|
1365
|
+
app.post('/channel/probe', async (_request, reply) => {
|
|
1366
|
+
const sessions = db.prepare(`SELECT cs.agent_id, a.name AS agent_name, cs.channel_id
|
|
1367
|
+
FROM coord_channel_sessions cs
|
|
1368
|
+
JOIN coord_agents a ON a.id = cs.agent_id
|
|
1369
|
+
WHERE cs.status = 'connected'`).all();
|
|
1370
|
+
const results = [];
|
|
1371
|
+
for (const session of sessions) {
|
|
1372
|
+
try {
|
|
1373
|
+
const res = await fetch(`${session.channel_id}/health`, {
|
|
1374
|
+
signal: AbortSignal.timeout(3000),
|
|
1375
|
+
});
|
|
1376
|
+
if (res.ok) {
|
|
1377
|
+
results.push({ agent: session.agent_name, alive: true });
|
|
1378
|
+
}
|
|
1379
|
+
else {
|
|
1380
|
+
db.prepare(`UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`).run(session.agent_id);
|
|
1381
|
+
results.push({ agent: session.agent_name, alive: false, error: `health returned ${res.status}` });
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
catch (err) {
|
|
1385
|
+
db.prepare(`UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`).run(session.agent_id);
|
|
1386
|
+
results.push({ agent: session.agent_name, alive: false, error: err instanceof Error ? err.message : String(err) });
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
const alive = results.filter(r => r.alive).length;
|
|
1390
|
+
const dead = results.filter(r => !r.alive).length;
|
|
1391
|
+
if (dead > 0)
|
|
1392
|
+
coordLog(`channel/probe: ${alive} alive, ${dead} dead — dead sessions marked disconnected`);
|
|
1393
|
+
return reply.send({ probed: results.length, alive, dead, results });
|
|
1394
|
+
});
|
|
433
1395
|
}
|
|
434
1396
|
//# sourceMappingURL=routes.js.map
|