agent-working-memory 0.6.0 → 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 +15 -9
- 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/cli.js +104 -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/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 +899 -76
- package/dist/coordination/routes.js.map +1 -1
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +72 -14
- package/dist/coordination/schema.js.map +1 -1
- package/dist/coordination/schemas.d.ts +84 -3
- package/dist/coordination/schemas.d.ts.map +1 -1
- package/dist/coordination/schemas.js +71 -1
- package/dist/coordination/schemas.js.map +1 -1
- package/dist/coordination/stale.d.ts.map +1 -1
- package/dist/coordination/stale.js +2 -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 +16 -3
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +15 -6
- 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 +6 -18
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +52 -3
- package/dist/mcp.js.map +1 -1
- package/dist/storage/sqlite.d.ts +6 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +39 -3
- package/dist/storage/sqlite.js.map +1 -1
- package/package.json +1 -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/cli.ts +107 -238
- package/src/coordination/events.ts +90 -0
- package/src/coordination/index.ts +102 -3
- 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 +1176 -105
- package/src/coordination/schema.ts +67 -14
- package/src/coordination/schemas.ts +85 -1
- package/src/coordination/stale.ts +3 -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 +13 -3
- package/src/engine/consolidation.ts +15 -6
- package/src/engine/retraction.ts +22 -6
- package/src/index.ts +6 -15
- package/src/mcp.ts +73 -9
- package/src/storage/sqlite.ts +39 -3
|
@@ -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, nextSchema, assignCreateSchema, assignmentQuerySchema, assignmentClaimSchema, assignmentUpdateSchema, assignmentIdParamSchema, lockAcquireSchema, lockReleaseSchema, commandCreateSchema, commandWaitQuerySchema, findingCreateSchema, findingsQuerySchema, findingIdParamSchema, decisionsQuerySchema, 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,41 +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
91
|
// Look up ANY existing agent with same name+workspace — including dead ones (upsert)
|
|
34
|
-
|
|
92
|
+
// Falls back to name-only to handle workspace changes between sessions
|
|
93
|
+
let existing = workspace
|
|
35
94
|
? db.prepare(`SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`).get(name, workspace)
|
|
36
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
|
+
}
|
|
37
99
|
if (existing) {
|
|
38
100
|
const wasDead = existing.status === 'dead';
|
|
39
|
-
|
|
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);
|
|
40
104
|
const eventType = wasDead ? 'reconnected' : 'heartbeat';
|
|
41
105
|
const detail = wasDead ? `${name} reconnected (was dead)` : `heartbeat from ${name}`;
|
|
42
106
|
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`).run(existing.id, eventType, detail);
|
|
43
107
|
if (wasDead)
|
|
44
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
|
+
}
|
|
45
123
|
const action = wasDead ? 'reconnected' : 'heartbeat';
|
|
46
124
|
const status = wasDead ? 'idle' : existing.status;
|
|
47
|
-
return reply.send({ agentId: existing.id, action, status, workspace });
|
|
125
|
+
return reply.send({ agentId: existing.id, sessionToken, action, status, workspace });
|
|
48
126
|
}
|
|
49
127
|
const id = randomUUID();
|
|
50
|
-
|
|
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);
|
|
51
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
|
+
}
|
|
52
145
|
coordLog(`${name} registered (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
|
|
53
|
-
|
|
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 });
|
|
54
168
|
});
|
|
55
169
|
app.post('/checkout', async (req, reply) => {
|
|
56
170
|
const parsed = checkoutSchema.safeParse(req.body);
|
|
57
171
|
if (!parsed.success)
|
|
58
172
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
59
173
|
const { agentId } = parsed.data;
|
|
60
|
-
db
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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)
|
|
64
185
|
const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId);
|
|
65
186
|
coordLog(`${agent?.name ?? agentId} checked out`);
|
|
187
|
+
eventBus?.emit('agent.checkout', { agentId, name: agent?.name ?? agentId });
|
|
66
188
|
return reply.send({ ok: true });
|
|
67
189
|
});
|
|
68
190
|
// ─── Pulse (lightweight heartbeat — no event row) ──────────────
|
|
@@ -71,6 +193,15 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
71
193
|
if (!parsed.success)
|
|
72
194
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
73
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);
|
|
74
205
|
db.prepare(`UPDATE coord_agents SET last_seen = datetime('now') WHERE id = ?`).run(agentId);
|
|
75
206
|
return reply.send({ ok: true });
|
|
76
207
|
});
|
|
@@ -79,17 +210,27 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
79
210
|
const parsed = nextSchema.safeParse(req.body);
|
|
80
211
|
if (!parsed.success)
|
|
81
212
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
82
|
-
const { name, workspace, role, capabilities } = parsed.data;
|
|
213
|
+
const { name, workspace, role, capabilities, channelUrl } = parsed.data;
|
|
83
214
|
const capsJson = capabilities ? JSON.stringify(capabilities) : null;
|
|
84
215
|
// Step 1: Upsert agent (checkin / heartbeat) — including dead agents (reuse UUID)
|
|
85
|
-
|
|
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
|
|
86
219
|
? db.prepare(`SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`).get(name, workspace)
|
|
87
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
|
+
}
|
|
88
225
|
let agentId;
|
|
226
|
+
let sessionToken;
|
|
89
227
|
if (existing) {
|
|
90
228
|
agentId = existing.id;
|
|
91
229
|
const wasDead = existing.status === 'dead';
|
|
92
|
-
|
|
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);
|
|
93
234
|
const eventType = wasDead ? 'reconnected' : 'heartbeat';
|
|
94
235
|
const detail = wasDead ? `${name} reconnected via /next` : `heartbeat from ${name}`;
|
|
95
236
|
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`).run(agentId, eventType, detail);
|
|
@@ -98,10 +239,25 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
98
239
|
}
|
|
99
240
|
else {
|
|
100
241
|
agentId = randomUUID();
|
|
101
|
-
|
|
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);
|
|
102
244
|
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`).run(agentId, `${name} joined as ${role ?? 'worker'} via /next`);
|
|
103
245
|
coordLog(`${name} registered via /next (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
|
|
104
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
|
+
}
|
|
105
261
|
// Step 2: Get active commands
|
|
106
262
|
const activeCommands = workspace
|
|
107
263
|
? db.prepare(`SELECT id, command, reason, issued_by, issued_at, workspace
|
|
@@ -112,14 +268,34 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
112
268
|
ORDER BY issued_at DESC`).all();
|
|
113
269
|
// Step 3: Get or auto-claim assignment
|
|
114
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
|
+
}
|
|
115
288
|
if (!assignment) {
|
|
116
289
|
const agentWorkspace = workspace ?? null;
|
|
117
290
|
// Priority-ordered dispatch: higher priority first, then FIFO.
|
|
118
291
|
// Skip assignments blocked by incomplete dependencies.
|
|
119
292
|
const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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());
|
|
123
299
|
if (pending) {
|
|
124
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);
|
|
125
301
|
if (claimed.changes > 0) {
|
|
@@ -129,13 +305,29 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
129
305
|
}
|
|
130
306
|
}
|
|
131
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
|
+
}
|
|
132
312
|
// Read current agent status after all mutations
|
|
133
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
|
+
}
|
|
134
324
|
return reply.send({
|
|
135
325
|
agentId,
|
|
326
|
+
sessionToken,
|
|
136
327
|
status: agentRow.status,
|
|
137
328
|
assignment: assignment ?? null,
|
|
138
329
|
commands: activeCommands,
|
|
330
|
+
mailbox: mailbox.length > 0 ? mailbox.map(m => ({ message: m.message, source: m.source, queued_at: m.created_at })) : undefined,
|
|
139
331
|
});
|
|
140
332
|
});
|
|
141
333
|
// ─── Assignments ────────────────────────────────────────────────
|
|
@@ -143,31 +335,130 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
143
335
|
const parsed = assignCreateSchema.safeParse(req.body);
|
|
144
336
|
if (!parsed.success)
|
|
145
337
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
146
|
-
const {
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
149
355
|
if (agentId) {
|
|
150
|
-
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
|
+
}
|
|
151
439
|
}
|
|
152
|
-
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_created', ?)`).run(agentId ?? null, `task: ${task}`);
|
|
153
440
|
// Log assignment with agent name
|
|
154
441
|
if (agentId) {
|
|
155
442
|
const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId);
|
|
156
|
-
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)' : ''}`);
|
|
157
444
|
}
|
|
158
445
|
else {
|
|
159
446
|
coordLog(`assignment queued (pending): ${task.slice(0, 80)}`);
|
|
160
447
|
}
|
|
161
|
-
|
|
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 });
|
|
162
450
|
});
|
|
163
451
|
app.get('/assignment', async (req, reply) => {
|
|
164
452
|
const q = assignmentQuerySchema.parse(req.query);
|
|
165
453
|
let agentId = req.headers['x-agent-id'] ?? q.agentId;
|
|
166
|
-
// Fallback: resolve agentId from name + workspace
|
|
454
|
+
// Fallback: resolve agentId from name + workspace (with name-only fallback)
|
|
167
455
|
if (!agentId && q.name) {
|
|
168
|
-
|
|
456
|
+
let found = q.workspace
|
|
169
457
|
? db.prepare(`SELECT id FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead'`).get(q.name, q.workspace)
|
|
170
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
|
+
}
|
|
171
462
|
agentId = found?.id;
|
|
172
463
|
}
|
|
173
464
|
if (!agentId) {
|
|
@@ -176,12 +467,32 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
176
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);
|
|
177
468
|
if (active)
|
|
178
469
|
return reply.send({ assignment: active });
|
|
179
|
-
|
|
180
|
-
|
|
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;
|
|
181
489
|
const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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());
|
|
185
496
|
if (pending) {
|
|
186
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);
|
|
187
498
|
if (claimed.changes > 0) {
|
|
@@ -191,7 +502,7 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
191
502
|
return reply.send({ assignment });
|
|
192
503
|
}
|
|
193
504
|
}
|
|
194
|
-
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;
|
|
195
506
|
const retryAfter = busyCount > 0 ? 30 : 300;
|
|
196
507
|
return reply.send({ assignment: null, retry_after_seconds: retryAfter });
|
|
197
508
|
});
|
|
@@ -201,6 +512,8 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
201
512
|
if (!parsed.success)
|
|
202
513
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
203
514
|
const { agentId } = parsed.data;
|
|
515
|
+
if (!sessionTokenOk(db, agentId, req))
|
|
516
|
+
return reply.code(403).send({ error: 'invalid session token' });
|
|
204
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);
|
|
205
518
|
if (result.changes === 0) {
|
|
206
519
|
return reply.code(409).send({ error: 'assignment not available (already claimed or missing)' });
|
|
@@ -209,39 +522,81 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
209
522
|
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_claimed', ?)`).run(agentId, `claimed assignment ${id}`);
|
|
210
523
|
return reply.send({ ok: true, assignmentId: id });
|
|
211
524
|
});
|
|
525
|
+
const VALID_TRANSITIONS = {
|
|
526
|
+
assigned: ['in_progress', 'failed'],
|
|
527
|
+
in_progress: ['completed', 'failed', 'blocked'],
|
|
528
|
+
blocked: ['in_progress', 'failed'],
|
|
529
|
+
};
|
|
212
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(', ')}` };
|
|
538
|
+
}
|
|
539
|
+
if (!allowed && ['completed', 'failed'].includes(current.status)) {
|
|
540
|
+
return { error: `cannot update ${current.status} assignment` };
|
|
541
|
+
}
|
|
213
542
|
// Verification gate: completed status requires structured proof of work
|
|
214
543
|
if (status === 'completed') {
|
|
215
544
|
if (!result || result.trim().length < 20) {
|
|
216
545
|
return { error: 'completion requires a result summary — minimum 20 characters describing what was done' };
|
|
217
546
|
}
|
|
218
547
|
// Must mention at least one of: commit/SHA, build, audit, test, verified, fix, created, updated, implemented
|
|
219
|
-
const actionWords = /\b(
|
|
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;
|
|
220
549
|
if (!actionWords.test(result)) {
|
|
221
550
|
return { error: 'completion result must describe the work done — include what was committed, built, tested, or verified' };
|
|
222
551
|
}
|
|
223
552
|
}
|
|
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);
|
|
224
575
|
if (['completed', 'failed'].includes(status)) {
|
|
225
|
-
|
|
576
|
+
coordLog(`${assignInfo?.agent_name ?? 'unknown'} ${status}: ${assignInfo?.task?.slice(0, 80) ?? id}`);
|
|
226
577
|
}
|
|
227
|
-
|
|
228
|
-
|
|
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 });
|
|
229
582
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
+
}
|
|
234
598
|
}
|
|
235
599
|
}
|
|
236
|
-
const eventDetail = ['completed', 'failed'].includes(status)
|
|
237
|
-
? `${id} → ${status}${commitSha ? ' [' + commitSha + ']' : ''}: ${(result ?? '').slice(0, 300)}`
|
|
238
|
-
: `${id} → ${status}`;
|
|
239
|
-
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);
|
|
240
|
-
// Log completion/failure with agent name and task
|
|
241
|
-
if (['completed', 'failed'].includes(status)) {
|
|
242
|
-
const info = db.prepare(`SELECT 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);
|
|
243
|
-
coordLog(`${info?.agent_name ?? 'unknown'} ${status}: ${info?.task?.slice(0, 80) ?? id}`);
|
|
244
|
-
}
|
|
245
600
|
return {};
|
|
246
601
|
}
|
|
247
602
|
app.get('/assignment/:id', async (req, reply) => {
|
|
@@ -251,16 +606,78 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
251
606
|
return reply.code(404).send({ error: 'assignment not found' });
|
|
252
607
|
return reply.send({ assignment });
|
|
253
608
|
});
|
|
254
|
-
// List
|
|
609
|
+
// List assignments with optional filters and pagination
|
|
255
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;
|
|
256
628
|
const assignments = db.prepare(`SELECT a.*, g.name AS agent_name,
|
|
257
629
|
CASE WHEN a.blocked_by IS NOT NULL AND a.blocked_by NOT IN (SELECT id FROM coord_assignments WHERE status = 'completed')
|
|
258
630
|
THEN 1 ELSE 0 END AS is_blocked
|
|
259
631
|
FROM coord_assignments a
|
|
260
632
|
LEFT JOIN coord_agents g ON a.agent_id = g.id
|
|
261
|
-
|
|
262
|
-
ORDER BY a.priority DESC, a.created_at
|
|
263
|
-
|
|
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' });
|
|
264
681
|
});
|
|
265
682
|
app.post('/assignment/:id/update', async (req, reply) => {
|
|
266
683
|
const { id } = assignmentIdParamSchema.parse(req.params);
|
|
@@ -298,6 +715,8 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
298
715
|
if (!parsed.success)
|
|
299
716
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
300
717
|
const { agentId, filePath, reason } = parsed.data;
|
|
718
|
+
if (!sessionTokenOk(db, agentId, req))
|
|
719
|
+
return reply.code(403).send({ error: 'invalid session token' });
|
|
301
720
|
const inserted = db.prepare(`INSERT OR IGNORE INTO coord_locks (file_path, agent_id, reason) VALUES (?, ?, ?)`).run(filePath, agentId, reason ?? null);
|
|
302
721
|
if (inserted.changes > 0) {
|
|
303
722
|
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'lock_acquired', ?)`).run(agentId, filePath);
|
|
@@ -318,6 +737,8 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
318
737
|
if (!parsed.success)
|
|
319
738
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
320
739
|
const { agentId, filePath } = parsed.data;
|
|
740
|
+
if (!sessionTokenOk(db, agentId, req))
|
|
741
|
+
return reply.code(403).send({ error: 'invalid session token' });
|
|
321
742
|
const result = db.prepare(`DELETE FROM coord_locks WHERE file_path = ? AND agent_id = ?`).run(filePath, agentId);
|
|
322
743
|
if (result.changes === 0) {
|
|
323
744
|
return reply.code(404).send({ error: 'lock not found or not owned by this agent' });
|
|
@@ -328,7 +749,7 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
328
749
|
app.get('/locks', async (_req, reply) => {
|
|
329
750
|
const locks = db.prepare(`SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
|
|
330
751
|
FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id
|
|
331
|
-
ORDER BY l.locked_at DESC`).all();
|
|
752
|
+
ORDER BY l.locked_at DESC LIMIT 200`).all();
|
|
332
753
|
return reply.send({ locks });
|
|
333
754
|
});
|
|
334
755
|
// ─── Commands ───────────────────────────────────────────────────
|
|
@@ -339,7 +760,10 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
339
760
|
const { command, reason, issuedBy, workspace } = parsed.data;
|
|
340
761
|
if (command === 'RESUME') {
|
|
341
762
|
if (workspace) {
|
|
342
|
-
|
|
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);
|
|
343
767
|
}
|
|
344
768
|
else {
|
|
345
769
|
db.prepare(`UPDATE coord_commands SET cleared_at = datetime('now') WHERE cleared_at IS NULL`).run();
|
|
@@ -374,6 +798,17 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
374
798
|
commands: active,
|
|
375
799
|
});
|
|
376
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
|
+
});
|
|
377
812
|
app.get('/command/wait', async (req, reply) => {
|
|
378
813
|
const q = commandWaitQuerySchema.safeParse(req.query);
|
|
379
814
|
const { status: targetStatus, workspace } = q.success ? q.data : { status: 'idle', workspace: undefined };
|
|
@@ -384,8 +819,8 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
384
819
|
: db.prepare(`SELECT id, name, role, status, current_task, last_seen
|
|
385
820
|
FROM coord_agents WHERE status NOT IN ('dead')
|
|
386
821
|
ORDER BY name`).all();
|
|
387
|
-
const ready = agents.filter(a => a.status === targetStatus || a.role === 'orchestrator');
|
|
388
|
-
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');
|
|
389
824
|
return reply.send({
|
|
390
825
|
allReady: notReady.length === 0,
|
|
391
826
|
total: agents.length,
|
|
@@ -399,6 +834,8 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
399
834
|
if (!parsed.success)
|
|
400
835
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
401
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' });
|
|
402
839
|
db.prepare(`INSERT INTO coord_findings (agent_id, category, severity, file_path, line_number, description, suggestion)
|
|
403
840
|
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(agentId, category, severity ?? 'info', filePath ?? null, lineNumber ?? null, description, suggestion ?? null);
|
|
404
841
|
db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'finding', ?)`).run(agentId, `[${severity ?? 'info'}] ${category}: ${description.slice(0, 100)}`);
|
|
@@ -441,6 +878,34 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
441
878
|
db.prepare(`UPDATE coord_findings SET status = 'resolved', resolved_at = datetime('now') WHERE id = ?`).run(id);
|
|
442
879
|
return reply.send({ ok: true });
|
|
443
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
|
+
});
|
|
444
909
|
app.get('/findings/summary', async (_req, reply) => {
|
|
445
910
|
const bySeverity = db.prepare(`SELECT severity, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY severity`).all();
|
|
446
911
|
const byCategory = db.prepare(`SELECT category, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY category ORDER BY count DESC`).all();
|
|
@@ -450,7 +915,7 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
450
915
|
// ─── Decisions (cross-agent propagation) ────────────────────────
|
|
451
916
|
app.get('/decisions', async (req, reply) => {
|
|
452
917
|
const q = decisionsQuerySchema.safeParse(req.query);
|
|
453
|
-
const { since_id, assignment_id, limit } = q.success ? q.data : { since_id: 0, assignment_id: undefined, limit: 20 };
|
|
918
|
+
const { since_id, assignment_id, workspace, limit } = q.success ? q.data : { since_id: 0, assignment_id: undefined, workspace: undefined, limit: 20 };
|
|
454
919
|
let sql = `
|
|
455
920
|
SELECT d.id, d.author_id, a.name AS author_name, d.assignment_id, d.tags, d.summary, d.created_at
|
|
456
921
|
FROM coord_decisions d JOIN coord_agents a ON d.author_id = a.id
|
|
@@ -461,24 +926,40 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
461
926
|
sql += ` AND d.assignment_id = ?`;
|
|
462
927
|
params.push(assignment_id);
|
|
463
928
|
}
|
|
929
|
+
if (workspace) {
|
|
930
|
+
sql += ` AND (a.workspace = ? OR a.workspace IS NULL)`;
|
|
931
|
+
params.push(workspace);
|
|
932
|
+
}
|
|
464
933
|
sql += ` ORDER BY d.created_at ASC LIMIT ?`;
|
|
465
934
|
params.push(limit);
|
|
466
935
|
const decisions = db.prepare(sql).all(...params);
|
|
467
936
|
return reply.send({ decisions });
|
|
468
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
|
+
});
|
|
469
950
|
// ─── Status ─────────────────────────────────────────────────────
|
|
470
951
|
app.get('/status', async (_req, reply) => {
|
|
471
952
|
const agents = db.prepare(`SELECT id, name, role, status, current_task, last_seen,
|
|
472
953
|
ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
|
|
473
954
|
FROM coord_agents WHERE status != 'dead'
|
|
474
|
-
ORDER BY role, name`).all();
|
|
955
|
+
ORDER BY role, name LIMIT 200`).all();
|
|
475
956
|
const assignments = db.prepare(`SELECT a.id, a.task, a.description, a.status, a.agent_id, ag.name AS agent_name,
|
|
476
957
|
a.created_at, a.started_at, a.completed_at
|
|
477
958
|
FROM coord_assignments a LEFT JOIN coord_agents ag ON a.agent_id = ag.id
|
|
478
959
|
WHERE a.status NOT IN ('completed', 'failed')
|
|
479
|
-
ORDER BY a.created_at`).all();
|
|
960
|
+
ORDER BY a.created_at LIMIT 200`).all();
|
|
480
961
|
const locks = db.prepare(`SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
|
|
481
|
-
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();
|
|
482
963
|
const stats = db.prepare(`SELECT
|
|
483
964
|
(SELECT COUNT(*) FROM coord_agents WHERE status != 'dead') AS alive_agents,
|
|
484
965
|
(SELECT COUNT(*) FROM coord_agents WHERE status = 'working') AS busy_agents,
|
|
@@ -502,13 +983,13 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
502
983
|
? db.prepare(`SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
|
|
503
984
|
ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
|
|
504
985
|
FROM coord_agents
|
|
505
|
-
WHERE status != 'dead' AND role
|
|
506
|
-
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)
|
|
507
988
|
: db.prepare(`SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
|
|
508
989
|
ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
|
|
509
990
|
FROM coord_agents
|
|
510
|
-
WHERE status != 'dead' AND role
|
|
511
|
-
ORDER BY name`).all();
|
|
991
|
+
WHERE status != 'dead' AND role NOT IN ('orchestrator', 'coordinator')
|
|
992
|
+
ORDER BY name LIMIT 200`).all();
|
|
512
993
|
if (capability) {
|
|
513
994
|
workers = workers.filter(w => {
|
|
514
995
|
if (!w.capabilities)
|
|
@@ -535,7 +1016,7 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
535
1016
|
workspace: w.workspace,
|
|
536
1017
|
lastSeen: w.last_seen,
|
|
537
1018
|
secondsSinceSeen: w.seconds_since_seen,
|
|
538
|
-
alive: w.seconds_since_seen <
|
|
1019
|
+
alive: w.seconds_since_seen < 300,
|
|
539
1020
|
}));
|
|
540
1021
|
return reply.send({
|
|
541
1022
|
count: result.length,
|
|
@@ -546,15 +1027,35 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
546
1027
|
});
|
|
547
1028
|
app.get('/events', async (req, reply) => {
|
|
548
1029
|
const q = eventsQuerySchema.safeParse(req.query);
|
|
549
|
-
|
|
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);
|
|
550
1049
|
const events = db.prepare(`SELECT e.id, e.agent_id, a.name AS agent_name, e.event_type, e.detail, e.created_at
|
|
551
1050
|
FROM coord_events e LEFT JOIN coord_agents a ON e.agent_id = a.id
|
|
552
|
-
|
|
553
|
-
|
|
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 });
|
|
554
1055
|
});
|
|
555
1056
|
app.get('/stale', async (req, reply) => {
|
|
556
1057
|
const q = staleQuerySchema.safeParse(req.query);
|
|
557
|
-
const threshold = q.success ? q.data.seconds :
|
|
1058
|
+
const threshold = q.success ? q.data.seconds : 300;
|
|
558
1059
|
const cleanup = q.success ? q.data.cleanup : undefined;
|
|
559
1060
|
const stale = detectStale(db, threshold);
|
|
560
1061
|
if (cleanup === '1' || cleanup === 'true') {
|
|
@@ -565,9 +1066,331 @@ export function registerCoordinationRoutes(app, db) {
|
|
|
565
1066
|
});
|
|
566
1067
|
app.post('/stale/cleanup', async (req, reply) => {
|
|
567
1068
|
const q = staleQuerySchema.safeParse(req.query);
|
|
568
|
-
const threshold = q.success ? q.data.seconds :
|
|
1069
|
+
const threshold = q.success ? q.data.seconds : 300;
|
|
569
1070
|
const { stale, cleaned } = cleanupStale(db, threshold);
|
|
570
1071
|
return reply.send({ stale, threshold_seconds: threshold, cleaned });
|
|
571
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
|
+
});
|
|
572
1395
|
}
|
|
573
1396
|
//# sourceMappingURL=routes.js.map
|