agent-working-memory 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +29 -0
  2. package/dist/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/claude-code.js +63 -3
  4. package/dist/adapters/claude-code.js.map +1 -1
  5. package/dist/adapters/common.d.ts.map +1 -1
  6. package/dist/adapters/common.js +329 -306
  7. package/dist/adapters/common.js.map +1 -1
  8. package/dist/api/routes.d.ts.map +1 -1
  9. package/dist/api/routes.js +29 -7
  10. package/dist/api/routes.js.map +1 -1
  11. package/dist/coordination/routes.d.ts.map +1 -1
  12. package/dist/coordination/routes.js +174 -170
  13. package/dist/coordination/routes.js.map +1 -1
  14. package/dist/core/embeddings.d.ts.map +1 -1
  15. package/dist/core/embeddings.js +3 -0
  16. package/dist/core/embeddings.js.map +1 -1
  17. package/dist/core/entity-extract.d.ts +3 -0
  18. package/dist/core/entity-extract.d.ts.map +1 -0
  19. package/dist/core/entity-extract.js +47 -0
  20. package/dist/core/entity-extract.js.map +1 -0
  21. package/dist/core/salience.d.ts.map +1 -1
  22. package/dist/core/salience.js +14 -2
  23. package/dist/core/salience.js.map +1 -1
  24. package/dist/core/whoami.d.ts +24 -0
  25. package/dist/core/whoami.d.ts.map +1 -0
  26. package/dist/core/whoami.js +66 -0
  27. package/dist/core/whoami.js.map +1 -0
  28. package/dist/core/write-pipeline.d.ts +9 -0
  29. package/dist/core/write-pipeline.d.ts.map +1 -1
  30. package/dist/core/write-pipeline.js +109 -68
  31. package/dist/core/write-pipeline.js.map +1 -1
  32. package/dist/core/write-telemetry.d.ts +33 -0
  33. package/dist/core/write-telemetry.d.ts.map +1 -0
  34. package/dist/core/write-telemetry.js +110 -0
  35. package/dist/core/write-telemetry.js.map +1 -0
  36. package/dist/engine/activation.d.ts +22 -12
  37. package/dist/engine/activation.d.ts.map +1 -1
  38. package/dist/engine/activation.js +133 -17
  39. package/dist/engine/activation.js.map +1 -1
  40. package/dist/engine/consolidation-scheduler.d.ts +1 -1
  41. package/dist/engine/consolidation-scheduler.js +1 -1
  42. package/dist/engine/consolidation.d.ts +1 -0
  43. package/dist/engine/consolidation.d.ts.map +1 -1
  44. package/dist/engine/consolidation.js +18 -0
  45. package/dist/engine/consolidation.js.map +1 -1
  46. package/dist/engine/eval.d.ts.map +1 -1
  47. package/dist/engine/eval.js +5 -1
  48. package/dist/engine/eval.js.map +1 -1
  49. package/dist/index.js +20 -2
  50. package/dist/index.js.map +1 -1
  51. package/dist/mcp.d.ts +2 -1
  52. package/dist/mcp.d.ts.map +1 -1
  53. package/dist/mcp.js +168 -100
  54. package/dist/mcp.js.map +1 -1
  55. package/dist/recipes/index.d.ts +57 -0
  56. package/dist/recipes/index.d.ts.map +1 -0
  57. package/dist/recipes/index.js +81 -0
  58. package/dist/recipes/index.js.map +1 -0
  59. package/dist/storage/pglite-schema.d.ts.map +1 -1
  60. package/dist/storage/pglite-schema.js +27 -0
  61. package/dist/storage/pglite-schema.js.map +1 -1
  62. package/dist/storage/pglite.d.ts +5 -0
  63. package/dist/storage/pglite.d.ts.map +1 -1
  64. package/dist/storage/pglite.js +180 -138
  65. package/dist/storage/pglite.js.map +1 -1
  66. package/dist/storage/postgres.d.ts +5 -0
  67. package/dist/storage/postgres.d.ts.map +1 -1
  68. package/dist/storage/postgres.js +180 -138
  69. package/dist/storage/postgres.js.map +1 -1
  70. package/dist/storage/sqlite.d.ts +9 -0
  71. package/dist/storage/sqlite.d.ts.map +1 -1
  72. package/dist/storage/sqlite.js +394 -326
  73. package/dist/storage/sqlite.js.map +1 -1
  74. package/dist/types/engram.d.ts +14 -0
  75. package/dist/types/engram.d.ts.map +1 -1
  76. package/dist/types/engram.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/adapters/claude-code.ts +66 -3
  79. package/src/adapters/common.ts +538 -515
  80. package/src/api/routes.ts +999 -971
  81. package/src/coordination/routes.ts +2155 -2150
  82. package/src/core/embeddings.ts +3 -0
  83. package/src/core/entity-extract.ts +47 -0
  84. package/src/core/salience.ts +529 -514
  85. package/src/core/whoami.ts +92 -0
  86. package/src/core/write-pipeline.ts +60 -8
  87. package/src/core/write-telemetry.ts +131 -0
  88. package/src/engine/activation.ts +1468 -1369
  89. package/src/engine/consolidation-scheduler.ts +1 -1
  90. package/src/engine/consolidation.ts +887 -869
  91. package/src/engine/eval.ts +6 -1
  92. package/src/index.ts +248 -227
  93. package/src/mcp.ts +1341 -1270
  94. package/src/recipes/index.ts +125 -0
  95. package/src/storage/pglite-schema.ts +27 -0
  96. package/src/storage/pglite.ts +1420 -1372
  97. package/src/storage/postgres.ts +1523 -1475
  98. package/src/storage/sqlite.ts +1936 -1861
  99. package/src/types/engram.ts +22 -0
@@ -1,2150 +1,2155 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * HTTP routes for the coordination module.
5
- * Ported from AgentSynapse packages/coordinator/src/routes/*.ts into a single file.
6
- * All tables use coord_ prefix to avoid collision with AWM core tables.
7
- */
8
-
9
- import type { FastifyInstance } from 'fastify';
10
- import type Database from 'better-sqlite3';
11
- import type { EngramStore } from '../storage/sqlite.js';
12
- import { randomUUID } from 'node:crypto';
13
- import {
14
- checkinSchema, checkoutSchema, pulseSchema, nextSchema,
15
- assignCreateSchema, assignmentQuerySchema, assignmentClaimSchema, assignmentUpdateSchema, assignmentIdParamSchema, assignmentsListSchema, reassignSchema,
16
- lockAcquireSchema, lockReleaseSchema,
17
- commandCreateSchema, commandWaitQuerySchema,
18
- findingCreateSchema, findingsQuerySchema, findingIdParamSchema, findingUpdateSchema,
19
- decisionsQuerySchema, decisionCreateSchema,
20
- eventsQuerySchema, staleQuerySchema, workersQuerySchema,
21
- agentIdParamSchema, timelineQuerySchema,
22
- channelRegisterSchema, channelDeregisterSchema, channelPushSchema,
23
- } from './schemas.js';
24
- import { detectStale, cleanupStale, retryOrFailAssignment } from './stale.js';
25
- import { classifyFailure, FailureMode } from './failure-modes.js';
26
- import { recordSuccess, recordFailure as circuitRecordFailure, isAvailable } from './circuit-breaker.js';
27
-
28
- /** Pretty timestamp for coordination logs. */
29
- function ts(): string {
30
- return new Date().toLocaleTimeString('en-GB', { hour12: false });
31
- }
32
-
33
- /** Log a coordination event in human-readable format. */
34
- function coordLog(msg: string): void {
35
- console.log(`${ts()} [coord] ${msg}`);
36
- }
37
-
38
- /**
39
- * In-process counters for channel push telemetry.
40
- * Reset on coordinator restart — intended for short-window observability
41
- * ("ship it, watch numbers for a day"). Persistent counters would need a
42
- * coord_metrics table; deferred until we know what's worth keeping.
43
- *
44
- * Fields:
45
- * attempts — every call to deliverToChannel (HTTP push to worker)
46
- * delivered — fetch returned 2xx
47
- * failed_http — fetch returned non-2xx (worker reachable but rejected)
48
- * failed_unreachable — fetch threw (timeout, ECONNREFUSED, etc.)
49
- * no_session — push intent existed but no connected session
50
- * fallback_mailbox — push failed, message queued to mailbox instead
51
- * session_disconnects — session marked 'disconnected' after delivery failure
52
- */
53
- interface ChannelMetrics {
54
- attempts: number;
55
- delivered: number;
56
- failed_http: number;
57
- failed_unreachable: number;
58
- no_session: number;
59
- fallback_mailbox: number;
60
- session_disconnects: number;
61
- started_at: number;
62
- }
63
-
64
- function createChannelMetrics(): ChannelMetrics {
65
- return {
66
- attempts: 0,
67
- delivered: 0,
68
- failed_http: 0,
69
- failed_unreachable: 0,
70
- no_session: 0,
71
- fallback_mailbox: 0,
72
- session_disconnects: 0,
73
- started_at: Date.now(),
74
- };
75
- }
76
-
77
- /**
78
- * Optional session-token check.
79
- * If X-Session-Token header is present and doesn't match the stored token → returns false (caller should 403).
80
- * If header is absent, or no token stored (old agent row) → returns true (pass through).
81
- */
82
- function sessionTokenOk(db: Database.Database, agentId: string, req: import('fastify').FastifyRequest): boolean {
83
- const provided = req.headers['x-session-token'];
84
- if (!provided) return true;
85
- const row = db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(agentId) as { session_token: string | null } | undefined;
86
- if (!row || !row.session_token) return true; // not found or no token stored — backward compat
87
- return row.session_token === provided;
88
- }
89
-
90
- export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Database, store?: EngramStore, eventBus?: import('./events.js').CoordinationEventBus): void {
91
- // Channel push telemetry process-scoped counters. See ChannelMetrics docs above.
92
- const channelMetrics = createChannelMetrics();
93
-
94
-
95
- // Request logging one line per request with method, url, status, response time
96
- app.addHook('onRequest', async (request) => {
97
- (request as any)._startTime = Date.now();
98
- });
99
- app.addHook('onResponse', async (request, reply) => {
100
- const ms = Date.now() - ((request as any)._startTime ?? Date.now());
101
- // Skip noisy polling endpoints at 2xx to reduce log spam
102
- const isPolling = (request.url === '/next' || request.url === '/pulse' || request.url === '/health') && reply.statusCode < 300;
103
- if (!isPolling) {
104
- coordLog(`${request.method} ${request.url} ${reply.statusCode} ${ms}ms`);
105
- }
106
- });
107
-
108
- // Pulse coalescing — skip DB write if last pulse was <10s ago
109
- const PULSE_COALESCE_MS = 10_000;
110
- const lastPulseTime = new Map<string, number>();
111
-
112
- // Rate limiting — 300 requests/minute per agent (sliding window)
113
- // Hive agents poll frequently + synapse-push polls /events every 2s
114
- const RATE_LIMIT = 300;
115
- const RATE_WINDOW_MS = 60_000;
116
- const rateBuckets = new Map<string, number[]>();
117
-
118
- // Cleanup stale buckets every 5 minutes
119
- setInterval(() => {
120
- const cutoff = Date.now() - RATE_WINDOW_MS;
121
- for (const [key, timestamps] of rateBuckets) {
122
- const fresh = timestamps.filter(t => t > cutoff);
123
- if (fresh.length === 0) rateBuckets.delete(key);
124
- else rateBuckets.set(key, fresh);
125
- }
126
- }, 300_000).unref();
127
-
128
- app.addHook('preHandler', async (request, reply) => {
129
- if (request.url === '/health') return; // exempt
130
-
131
- // Identify agent by name from body or query, or agentId
132
- const body = request.body as Record<string, unknown> | undefined;
133
- const query = request.query as Record<string, unknown> | undefined;
134
- const key = (body?.name ?? body?.agentId ?? query?.agentId ?? query?.name ?? request.ip) as string;
135
- if (!key) return;
136
-
137
- const now = Date.now();
138
- const cutoff = now - RATE_WINDOW_MS;
139
- const timestamps = rateBuckets.get(key) ?? [];
140
- const recent = timestamps.filter(t => t > cutoff);
141
- recent.push(now);
142
- rateBuckets.set(key, recent);
143
-
144
- if (recent.length > RATE_LIMIT) {
145
- return reply.code(429).send({ error: `rate limit exceeded — max ${RATE_LIMIT} requests/minute` });
146
- }
147
- });
148
-
149
- // ─── Checkin ────────────────────────────────────────────────────
150
-
151
- app.post('/checkin', async (req, reply) => {
152
- const parsed = checkinSchema.safeParse(req.body);
153
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
154
- const { name, role, pid, metadata, capabilities, workspace, channelUrl } = parsed.data;
155
- const capsJson = capabilities ? JSON.stringify(capabilities) : null;
156
-
157
- // Look up ANY existing agent with same name+workspace — including dead ones (upsert)
158
- // Falls back to name-only to handle workspace changes between sessions
159
- let existing = workspace
160
- ? db.prepare(
161
- `SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`
162
- ).get(name, workspace) as { id: string; status: string } | undefined
163
- : db.prepare(
164
- `SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`
165
- ).get(name) as { id: string; status: string } | undefined;
166
-
167
- if (!existing) {
168
- existing = db.prepare(
169
- `SELECT id, status FROM coord_agents WHERE name = ? ORDER BY last_seen DESC LIMIT 1`
170
- ).get(name) as { id: string; status: string } | undefined;
171
- }
172
-
173
- if (existing) {
174
- const wasDead = existing.status === 'dead';
175
- // Issue a fresh token on reconnect; reuse existing token for live heartbeats
176
- const sessionToken = wasDead ? randomUUID() : (
177
- (db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(existing.id) as { session_token: string | null }).session_token ?? randomUUID()
178
- );
179
- // role IS updated on every checkin — agents know their own role and
180
- // re-registrations may correct stale role values (e.g., when an old
181
- // coord_agents row was inserted with role='orchestrator' before the
182
- // 'coordinator' role was canonical, or when the channel-server's
183
- // hardcoded role='worker' overwrote a real role).
184
- db.prepare(
185
- `UPDATE coord_agents SET last_seen = datetime('now'), status = CASE WHEN status = 'dead' THEN 'idle' ELSE status END, role = ?, pid = COALESCE(?, pid), capabilities = COALESCE(?, capabilities), workspace = COALESCE(?, workspace), session_token = ? WHERE id = ?`
186
- ).run(role, pid ?? null, capsJson, workspace ?? null, sessionToken, existing.id);
187
-
188
- const eventType = wasDead ? 'reconnected' : 'heartbeat';
189
- const detail = wasDead ? `${name} reconnected (was dead)` : `heartbeat from ${name}`;
190
- db.prepare(
191
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`
192
- ).run(existing.id, eventType, detail);
193
-
194
- if (wasDead) coordLog(`${name} reconnected (reusing UUID ${existing.id.slice(0, 8)})`);
195
- // Auto-register channel session if channelUrl provided
196
- if (channelUrl) {
197
- db.prepare(`
198
- INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
199
- VALUES (?, ?, datetime('now'), 'connected')
200
- ON CONFLICT(agent_id) DO UPDATE SET
201
- channel_id = excluded.channel_id,
202
- connected_at = datetime('now'),
203
- status = 'connected',
204
- push_count = 0,
205
- last_push_at = NULL
206
- `).run(existing.id, channelUrl);
207
- coordLog(`channel auto-registered: ${name} (${existing.id.slice(0, 8)}) → ${channelUrl}`);
208
- }
209
- const action = wasDead ? 'reconnected' : 'heartbeat';
210
- const status = wasDead ? 'idle' : existing.status;
211
- return reply.send({ agentId: existing.id, sessionToken, action, status, workspace });
212
- }
213
-
214
- const id = randomUUID();
215
- const sessionToken = randomUUID();
216
- db.prepare(
217
- `INSERT INTO coord_agents (id, name, role, pid, status, metadata, capabilities, workspace, session_token) VALUES (?, ?, ?, ?, 'idle', ?, ?, ?, ?)`
218
- ).run(id, name, role ?? 'worker', pid ?? null, metadata ? JSON.stringify(metadata) : null, capsJson, workspace ?? null, sessionToken);
219
-
220
- db.prepare(
221
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`
222
- ).run(id, `${name} joined as ${role ?? 'worker'}${workspace ? ' [' + workspace + ']' : ''}${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
223
-
224
- // Auto-register channel session if channelUrl provided
225
- if (channelUrl) {
226
- db.prepare(`
227
- INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
228
- VALUES (?, ?, datetime('now'), 'connected')
229
- ON CONFLICT(agent_id) DO UPDATE SET
230
- channel_id = excluded.channel_id,
231
- connected_at = datetime('now'),
232
- status = 'connected',
233
- push_count = 0,
234
- last_push_at = NULL
235
- `).run(id, channelUrl);
236
- coordLog(`channel auto-registered: ${name} (${id.slice(0, 8)}) → ${channelUrl}`);
237
- }
238
-
239
- coordLog(`${name} registered (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
240
- eventBus?.emit('agent.checkin', { agentId: id, name, role: role ?? 'worker', workspace: workspace ?? undefined });
241
- return reply.code(201).send({ agentId: id, sessionToken, action: 'registered', status: 'idle', workspace });
242
- });
243
-
244
- // ─── Shutdown (graceful coordination teardown) ─────────────────
245
-
246
- app.post('/shutdown', async (_req, reply) => {
247
- // Mark all live agents as dead
248
- const alive = db.prepare(
249
- `SELECT id, name FROM coord_agents WHERE status != 'dead'`
250
- ).all() as Array<{ id: string; name: string }>;
251
-
252
- const shutdownTx = db.transaction(() => {
253
- for (const agent of alive) {
254
- db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(agent.id);
255
- db.prepare(`UPDATE coord_agents SET status = 'dead', current_task = NULL WHERE id = ?`).run(agent.id);
256
- db.prepare(
257
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'shutdown', 'graceful shutdown')`
258
- ).run(agent.id);
259
- }
260
- });
261
- shutdownTx();
262
-
263
- // Flush WAL before caller terminates the process
264
- try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch { /* non-fatal if DB is closing */ }
265
-
266
- coordLog(`Graceful shutdown: ${alive.length} agent(s) marked offline`);
267
- return reply.send({ ok: true, agents_marked_offline: alive.length });
268
- });
269
-
270
- app.post('/checkout', async (req, reply) => {
271
- const parsed = checkoutSchema.safeParse(req.body);
272
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
273
- const { agentId } = parsed.data;
274
-
275
- if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
276
-
277
- // Atomic transaction: delete locks + channel session + update agent + event
278
- const checkoutTx = db.transaction(() => {
279
- db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(agentId);
280
- db.prepare(`DELETE FROM coord_channel_sessions WHERE agent_id = ?`).run(agentId);
281
- db.prepare(
282
- `UPDATE coord_agents SET status = 'dead', last_seen = datetime('now') WHERE id = ?`
283
- ).run(agentId);
284
- db.prepare(
285
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'checkout', 'agent signed off')`
286
- ).run(agentId);
287
- });
288
- checkoutTx();
289
-
290
- // Look up agent name for logging (outside tx read-only)
291
- const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
292
- coordLog(`${agent?.name ?? agentId} checked out`);
293
- eventBus?.emit('agent.checkout', { agentId, name: agent?.name ?? agentId });
294
- return reply.send({ ok: true });
295
- });
296
-
297
- // ─── Pulse (lightweight heartbeat no event row) ──────────────
298
-
299
- app.patch('/pulse', async (req, reply) => {
300
- const parsed = pulseSchema.safeParse(req.body);
301
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
302
- const { agentId } = parsed.data;
303
-
304
- if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
305
-
306
- // Coalesce: skip DB write if last pulse was <10s ago
307
- const now = Date.now();
308
- const lastTime = lastPulseTime.get(agentId) ?? 0;
309
- if (now - lastTime < PULSE_COALESCE_MS) {
310
- return reply.send({ ok: true, coalesced: true });
311
- }
312
-
313
- lastPulseTime.set(agentId, now);
314
- db.prepare(`UPDATE coord_agents SET last_seen = datetime('now') WHERE id = ?`).run(agentId);
315
- return reply.send({ ok: true });
316
- });
317
-
318
- // ─── Next (combined checkin + commands + assignment poll) ───────
319
-
320
- app.post('/next', async (req, reply) => {
321
- const parsed = nextSchema.safeParse(req.body);
322
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
323
- const { name, workspace, role, capabilities, channelUrl } = parsed.data;
324
- const capsJson = capabilities ? JSON.stringify(capabilities) : null;
325
-
326
- // Step 1: Upsert agent (checkin / heartbeat) — including dead agents (reuse UUID)
327
- // Try exact name+workspace match first, then fall back to name-only to handle
328
- // workspace changes between sessions (prevents orphaned assignments on old UUID)
329
- let existing = workspace
330
- ? db.prepare(
331
- `SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`
332
- ).get(name, workspace) as { id: string; status: string } | undefined
333
- : db.prepare(
334
- `SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`
335
- ).get(name) as { id: string; status: string } | undefined;
336
-
337
- // Fallback: name-only lookup if exact match failed (handles workspace change, e.g. NULL→PERSONAL)
338
- if (!existing) {
339
- existing = db.prepare(
340
- `SELECT id, status FROM coord_agents WHERE name = ? ORDER BY last_seen DESC LIMIT 1`
341
- ).get(name) as { id: string; status: string } | undefined;
342
- }
343
-
344
- let agentId: string;
345
- let sessionToken: string;
346
- if (existing) {
347
- agentId = existing.id;
348
- const wasDead = existing.status === 'dead';
349
- // Fresh token on reconnect; reuse existing on heartbeat
350
- const existingToken = (db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(agentId) as { session_token: string | null }).session_token;
351
- sessionToken = wasDead ? randomUUID() : (existingToken ?? randomUUID());
352
- db.prepare(
353
- `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 = ?`
354
- ).run(capsJson, workspace ?? null, sessionToken, agentId);
355
- const eventType = wasDead ? 'reconnected' : 'heartbeat';
356
- const detail = wasDead ? `${name} reconnected via /next` : `heartbeat from ${name}`;
357
- db.prepare(
358
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`
359
- ).run(agentId, eventType, detail);
360
- if (wasDead) coordLog(`${name} reconnected via /next (reusing UUID ${agentId.slice(0, 8)})`);
361
- } else {
362
- agentId = randomUUID();
363
- sessionToken = randomUUID();
364
- db.prepare(
365
- `INSERT INTO coord_agents (id, name, role, pid, status, metadata, capabilities, workspace, session_token) VALUES (?, ?, ?, NULL, 'idle', NULL, ?, ?, ?)`
366
- ).run(agentId, name, role ?? 'worker', capsJson, workspace ?? null, sessionToken);
367
- db.prepare(
368
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`
369
- ).run(agentId, `${name} joined as ${role ?? 'worker'} via /next`);
370
- coordLog(`${name} registered via /next (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
371
- }
372
-
373
- // Auto-register channel session if channelUrl provided
374
- if (channelUrl) {
375
- db.prepare(`
376
- INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
377
- VALUES (?, ?, datetime('now'), 'connected')
378
- ON CONFLICT(agent_id) DO UPDATE SET
379
- channel_id = excluded.channel_id,
380
- connected_at = datetime('now'),
381
- status = 'connected',
382
- push_count = 0,
383
- last_push_at = NULL
384
- `).run(agentId, channelUrl);
385
- coordLog(`channel auto-registered via /next: ${name} (${agentId.slice(0, 8)}) → ${channelUrl}`);
386
- }
387
-
388
- // Step 2: Get active commands
389
- const activeCommands = workspace
390
- ? db.prepare(
391
- `SELECT id, command, reason, issued_by, issued_at, workspace
392
- FROM coord_commands WHERE cleared_at IS NULL AND (workspace = ? OR workspace IS NULL)
393
- ORDER BY issued_at DESC`
394
- ).all(workspace) as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>
395
- : db.prepare(
396
- `SELECT id, command, reason, issued_by, issued_at, workspace
397
- FROM coord_commands WHERE cleared_at IS NULL
398
- ORDER BY issued_at DESC`
399
- ).all() as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>;
400
-
401
- // Step 3: Get or auto-claim assignment
402
- let assignment = db.prepare(
403
- `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
404
- ).get(agentId) as Record<string, unknown> | undefined;
405
-
406
- // Cross-UUID fallback: check if this agent name has assignments under a different UUID
407
- // (happens when POST /assign resolved worker_name to a stale/alternate UUID)
408
- if (!assignment) {
409
- const altIds = db.prepare(
410
- `SELECT id FROM coord_agents WHERE name = ? AND id != ? AND status != 'dead'`
411
- ).all(name, agentId) as Array<{ id: string }>;
412
-
413
- for (const alt of altIds) {
414
- const altActive = db.prepare(
415
- `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
416
- ).get(alt.id) as Record<string, unknown> | undefined;
417
- if (altActive) {
418
- // Migrate assignment to the current agent UUID
419
- db.prepare(`UPDATE coord_assignments SET agent_id = ? WHERE id = ?`).run(agentId, altActive.id as string);
420
- db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`).run(altActive.id as string, agentId);
421
- altActive.agent_id = agentId;
422
- coordLog(`assignment ${(altActive.id as string).slice(0, 8)} migrated from alt UUID ${alt.id.slice(0, 8)} to ${agentId.slice(0, 8)} (same agent: ${name})`);
423
- assignment = altActive;
424
- break;
425
- }
426
- }
427
- }
428
-
429
- if (!assignment) {
430
- const agentWorkspace = workspace ?? null;
431
- // Priority-ordered dispatch: higher priority first, then FIFO.
432
- // Skip assignments blocked by incomplete dependencies.
433
- const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
434
-
435
- // First, check for tasks reserved specifically for this agent
436
- const reserved = db.prepare(
437
- `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id = ? ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
438
- ).get(agentId) as { id: string } | undefined;
439
-
440
- // Then fall back to truly unassigned tasks (agent_id IS NULL)
441
- const pending = reserved ?? (agentWorkspace
442
- ? db.prepare(
443
- `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`
444
- ).get(agentWorkspace) as { id: string } | undefined
445
- : db.prepare(
446
- `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id IS NULL ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
447
- ).get() as { id: string } | undefined);
448
-
449
- // Circuit breaker: refuse assignment if worker is in open state
450
- if (pending && !isAvailable(db, agentId)) {
451
- return reply.code(423).send({ status: 'idle', assignment: null, circuit_open: true, reason: 'circuit_open' });
452
- }
453
-
454
- if (pending) {
455
- const claimed = db.prepare(
456
- `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`
457
- ).run(agentId, pending.id);
458
-
459
- if (claimed.changes > 0) {
460
- db.prepare(
461
- `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
462
- ).run(pending.id, agentId);
463
- db.prepare(
464
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_claimed', ?)`
465
- ).run(agentId, `auto-claimed assignment ${pending.id} via /next`);
466
- assignment = db.prepare(`SELECT * FROM coord_assignments WHERE id = ?`).get(pending.id) as Record<string, unknown> | undefined;
467
- }
468
- }
469
- }
470
-
471
- // If agent has an active assignment, ensure status is 'working'
472
- if (assignment) {
473
- db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ? AND status != 'working'`).run(assignment.id as string, agentId);
474
- }
475
-
476
- // Read current agent status after all mutations
477
- const agentRow = db.prepare(`SELECT status FROM coord_agents WHERE id = ?`).get(agentId) as { status: string };
478
-
479
- // Deliver queued mailbox messages (persistent messages that survived disconnects/restarts)
480
- const mailbox = db.prepare(
481
- `SELECT id, message, source, created_at FROM coord_mailbox
482
- WHERE worker_name = ? AND delivered_at IS NULL
483
- AND (workspace = ? OR workspace IS NULL)
484
- ORDER BY created_at ASC LIMIT 10`
485
- ).all(name, workspace ?? null) as Array<{ id: number; message: string; source: string; created_at: string }>;
486
-
487
- if (mailbox.length > 0) {
488
- const ids = mailbox.map(m => m.id);
489
- db.prepare(
490
- `UPDATE coord_mailbox SET delivered_at = datetime('now') WHERE id IN (${ids.map(() => '?').join(',')})`
491
- ).run(...ids);
492
- coordLog(`mailbox: delivered ${mailbox.length} queued message(s) to ${name}`);
493
- }
494
-
495
- return reply.send({
496
- agentId,
497
- sessionToken,
498
- status: agentRow.status,
499
- assignment: assignment ?? null,
500
- commands: activeCommands,
501
- mailbox: mailbox.length > 0 ? mailbox.map(m => ({ message: m.message, source: m.source, queued_at: m.created_at })) : undefined,
502
- });
503
- });
504
-
505
- // ─── Assignments ────────────────────────────────────────────────
506
-
507
- app.post('/assign', async (req, reply) => {
508
- const parsed = assignCreateSchema.safeParse(req.body);
509
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
510
- const { task, description, workspace, priority, blocked_by, worker_name, context } = parsed.data;
511
- let { agentId } = parsed.data;
512
-
513
- // Resolve worker_name → agentId if agentId not provided
514
- if (!agentId && worker_name) {
515
- let found = workspace
516
- ? db.prepare(
517
- `SELECT id FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
518
- ).get(worker_name, workspace) as { id: string } | undefined
519
- : db.prepare(
520
- `SELECT id FROM coord_agents WHERE name = ? AND workspace IS NULL AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
521
- ).get(worker_name) as { id: string } | undefined;
522
-
523
- // Fallback: name-only lookup (handles workspace changes)
524
- if (!found) {
525
- found = db.prepare(
526
- `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
527
- ).get(worker_name) as { id: string } | undefined;
528
- }
529
-
530
- if (!found) {
531
- return reply.code(404).send({ error: `worker not found: ${worker_name}` });
532
- }
533
- agentId = found.id;
534
- }
535
-
536
- // Reject if agent already has an active assignment
537
- if (agentId) {
538
- const active = db.prepare(
539
- `SELECT id, task FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') LIMIT 1`
540
- ).get(agentId) as { id: string; task: string } | undefined;
541
- if (active) {
542
- return reply.code(409).send({ error: `agent already has active assignment: ${active.id}`, active_task: active.task });
543
- }
544
- }
545
-
546
- const id = randomUUID();
547
- let pushed = false;
548
-
549
- // Atomic transaction: assignment insert + agent status + event + channel push
550
- const assignTx = db.transaction(() => {
551
- db.prepare(
552
- `INSERT INTO coord_assignments (id, agent_id, task, description, status, priority, blocked_by, workspace, started_at, context) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
553
- ).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);
554
-
555
- if (agentId) {
556
- db.prepare(
557
- `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
558
- ).run(id, agentId);
559
- }
560
-
561
- db.prepare(
562
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_created', ?)`
563
- ).run(agentId ?? null, `task: ${task}`);
564
-
565
- // Record channel push intent in the DB (stats + event)
566
- if (agentId) {
567
- const session = db.prepare(
568
- `SELECT agent_id, channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`
569
- ).get(agentId) as { agent_id: string; channel_id: string } | undefined;
570
- if (session) {
571
- // Record channel_push event so agent sees it on next poll/restore
572
- const pushMsg = `NEW ASSIGNMENT: ${task}${description ? ' — ' + description.slice(0, 200) : ''}`;
573
- db.prepare(
574
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_push', ?)`
575
- ).run(agentId, pushMsg.slice(0, 500));
576
- pushed = true;
577
- }
578
- }
579
- });
580
- assignTx();
581
-
582
- // Actually deliver the push to the worker's channel HTTP endpoint (outside DB transaction)
583
- let delivered = false;
584
- if (pushed && agentId) {
585
- const session = db.prepare(
586
- `SELECT channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`
587
- ).get(agentId) as { channel_id: string } | undefined;
588
- if (session) {
589
- const pushMsg = `NEW ASSIGNMENT: ${task}${description ? ' — ' + description.slice(0, 200) : ''}`;
590
- const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
591
- const result = await deliverToChannel(
592
- agentId, session.channel_id, pushMsg,
593
- { source: 'coordinator', agent: agent?.name ?? agentId, assignmentId: id }
594
- );
595
- delivered = result.delivered;
596
- if (delivered) {
597
- db.prepare(
598
- `UPDATE coord_channel_sessions SET last_push_at = datetime('now'), push_count = push_count + 1 WHERE agent_id = ?`
599
- ).run(agentId);
600
- }
601
- } else {
602
- // Session disappeared between intent record and delivery — race or rapid disconnect
603
- channelMetrics.no_session++;
604
- }
605
- }
606
-
607
- // Bridge context to AWM engrams (outside transactionengram store has its own DB)
608
- if (store && context) {
609
- try {
610
- const ctx = JSON.parse(context) as Record<string, unknown>;
611
- const parts: string[] = [];
612
- if (ctx.files) parts.push(`Files: ${JSON.stringify(ctx.files)}`);
613
- if (ctx.references) parts.push(`References: ${JSON.stringify(ctx.references)}`);
614
- if (ctx.decisions) parts.push(`Decisions: ${JSON.stringify(ctx.decisions)}`);
615
- if (ctx.acceptance_criteria) parts.push(`Acceptance criteria: ${JSON.stringify(ctx.acceptance_criteria)}`);
616
- // Include any remaining keys
617
- for (const [k, v] of Object.entries(ctx)) {
618
- if (!['files', 'references', 'decisions', 'acceptance_criteria'].includes(k) && v) {
619
- parts.push(`${k}: ${JSON.stringify(v)}`);
620
- }
621
- }
622
- if (parts.length > 0) {
623
- store.createEngram({
624
- agentId: agentId ?? 'coordinator',
625
- concept: `Task context: ${task.slice(0, 80)}`,
626
- content: parts.join('\n'),
627
- tags: ['shared', 'context', `task/${id}`],
628
- memoryClass: 'canonical',
629
- });
630
- }
631
- } catch {
632
- // Context is not valid JSON — skip engram bridge silently
633
- }
634
- }
635
-
636
- // If push failed or no channel, queue to mailbox so worker gets it on next /next poll
637
- let queued = false;
638
- if (agentId && !delivered) {
639
- const agent = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId) as { name: string; workspace: string | null } | undefined;
640
- if (agent) {
641
- const mailMsg = `NEW ASSIGNMENT [${id.slice(0, 8)}]: ${task.slice(0, 500)}`;
642
- db.prepare(
643
- `INSERT INTO coord_mailbox (worker_name, workspace, message, source) VALUES (?, ?, ?, 'coordinator')`
644
- ).run(agent.name, agent.workspace, mailMsg);
645
- queued = true;
646
- coordLog(`mailbox/queue ${agent.name}: assignment ${id.slice(0, 8)} (live push unavailable)`);
647
- }
648
- }
649
-
650
- // Log assignment with agent name
651
- if (agentId) {
652
- const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
653
- coordLog(`assigned → ${agent?.name ?? 'unknown'}: ${task.slice(0, 80)}${delivered ? ' (pushed+delivered)' : queued ? ' (queued to mailbox)' : ''}`);
654
- } else {
655
- coordLog(`assignment queued (pending): ${task.slice(0, 80)}`);
656
- }
657
- eventBus?.emit('assignment.created', { assignmentId: id, agentId: agentId ?? '', task, workspace: workspace ?? undefined });
658
- return reply.code(201).send({ assignmentId: id, status: agentId ? 'assigned' : 'pending', pushed, delivered, queued });
659
- });
660
-
661
- app.get('/assignment', async (req, reply) => {
662
- const q = assignmentQuerySchema.parse(req.query);
663
- let agentId = (req.headers['x-agent-id'] as string | undefined) ?? q.agentId;
664
-
665
- // Fallback: resolve agentId from name + workspace (with name-only fallback)
666
- if (!agentId && q.name) {
667
- let found = q.workspace
668
- ? db.prepare(
669
- `SELECT id FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead'`
670
- ).get(q.name, q.workspace) as { id: string } | undefined
671
- : db.prepare(
672
- `SELECT id FROM coord_agents WHERE name = ? AND workspace IS NULL AND status != 'dead'`
673
- ).get(q.name) as { id: string } | undefined;
674
- if (!found) {
675
- found = db.prepare(
676
- `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
677
- ).get(q.name) as { id: string } | undefined;
678
- }
679
- agentId = found?.id;
680
- }
681
-
682
- if (!agentId) {
683
- return reply.send({ assignment: null });
684
- }
685
-
686
- const active = db.prepare(
687
- `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
688
- ).get(agentId);
689
-
690
- if (active) return reply.send({ assignment: active });
691
-
692
- // Cross-UUID fallback: if the agent has other UUIDs (e.g., from workspace changes or reconnects
693
- // that created a new row), check those too. This fixes the case where POST /assign resolved
694
- // worker_name to a different UUID than the one the worker is currently using.
695
- const agentRow = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId) as { name: string; workspace: string | null } | undefined;
696
- if (agentRow) {
697
- const altIds = db.prepare(
698
- `SELECT id FROM coord_agents WHERE name = ? AND id != ? AND status != 'dead'`
699
- ).all(agentRow.name, agentId) as Array<{ id: string }>;
700
-
701
- for (const alt of altIds) {
702
- const altActive = db.prepare(
703
- `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
704
- ).get(alt.id) as Record<string, unknown> | undefined;
705
- if (altActive) {
706
- // Reassign to the current agent UUID so future lookups work directly
707
- db.prepare(`UPDATE coord_assignments SET agent_id = ? WHERE id = ?`).run(agentId, altActive.id as string);
708
- db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`).run(altActive.id as string, agentId);
709
- altActive.agent_id = agentId;
710
- coordLog(`assignment ${(altActive.id as string).slice(0, 8)} migrated from alt UUID ${alt.id.slice(0, 8)} to ${agentId.slice(0, 8)} (same agent: ${agentRow.name})`);
711
- return reply.send({ assignment: altActive });
712
- }
713
- }
714
- }
715
-
716
- const agentWorkspace = agentRow?.workspace ?? null;
717
-
718
- const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
719
-
720
- // First, check for tasks reserved specifically for this agent
721
- const reserved = db.prepare(
722
- `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id = ? ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
723
- ).get(agentId) as { id: string } | undefined;
724
-
725
- // Then fall back to truly unassigned tasks (agent_id IS NULL)
726
- const pending = reserved ?? (agentWorkspace
727
- ? db.prepare(
728
- `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`
729
- ).get(agentWorkspace) as { id: string } | undefined
730
- : db.prepare(
731
- `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id IS NULL ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
732
- ).get() as { id: string } | undefined);
733
-
734
- // Circuit breaker: refuse assignment if worker is in open state
735
- if (pending && !isAvailable(db, agentId)) {
736
- return reply.code(423).send({ assignment: null, circuit_open: true, reason: 'circuit_open' });
737
- }
738
-
739
- if (pending) {
740
- const claimed = db.prepare(
741
- `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`
742
- ).run(agentId, pending.id);
743
-
744
- if (claimed.changes > 0) {
745
- db.prepare(
746
- `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
747
- ).run(pending.id, agentId);
748
-
749
- db.prepare(
750
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_claimed', ?)`
751
- ).run(agentId, `auto-claimed assignment ${pending.id}`);
752
-
753
- const assignment = db.prepare(`SELECT * FROM coord_assignments WHERE id = ?`).get(pending.id);
754
- return reply.send({ assignment });
755
- }
756
- }
757
-
758
- const busyCount = (db.prepare(
759
- `SELECT COUNT(*) as c FROM coord_agents WHERE status = 'working' AND last_seen > datetime('now', '-300 seconds')`
760
- ).get() as { c: number }).c;
761
-
762
- const retryAfter = busyCount > 0 ? 30 : 300;
763
- return reply.send({ assignment: null, retry_after_seconds: retryAfter });
764
- });
765
-
766
- app.post('/assignment/:id/claim', async (req, reply) => {
767
- const { id } = assignmentIdParamSchema.parse(req.params);
768
- const parsed = assignmentClaimSchema.safeParse(req.body);
769
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
770
- const { agentId } = parsed.data;
771
-
772
- if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
773
-
774
- const result = db.prepare(
775
- `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`
776
- ).run(agentId, id);
777
-
778
- if (result.changes === 0) {
779
- return reply.code(409).send({ error: 'assignment not available (already claimed or missing)' });
780
- }
781
-
782
- db.prepare(
783
- `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
784
- ).run(id, agentId);
785
-
786
- db.prepare(
787
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_claimed', ?)`
788
- ).run(agentId, `claimed assignment ${id}`);
789
-
790
- return reply.send({ ok: true, assignmentId: id });
791
- });
792
-
793
- const VALID_TRANSITIONS: Record<string, string[]> = {
794
- assigned: ['in_progress', 'failed'],
795
- in_progress: ['completed', 'failed', 'blocked'],
796
- blocked: ['in_progress', 'failed'],
797
- };
798
-
799
- function handleAssignmentUpdate(id: string, status: string, result: string | undefined, commitSha: string | undefined): { error?: string } {
800
- // Status transition validation
801
- const current = db.prepare(`SELECT status FROM coord_assignments WHERE id = ?`).get(id) as { status: string } | undefined;
802
- if (!current) return { error: 'assignment not found' };
803
-
804
- const allowed = VALID_TRANSITIONS[current.status];
805
- if (allowed && !allowed.includes(status)) {
806
- return { error: `invalid transition: ${current.status} ${status}. Valid: ${allowed.join(', ')}` };
807
- }
808
- if (!allowed && ['completed', 'failed'].includes(current.status)) {
809
- return { error: `cannot update ${current.status} assignment` };
810
- }
811
-
812
- // Verification gate: completed status requires structured proof of work
813
- if (status === 'completed') {
814
- if (!result || result.trim().length < 20) {
815
- return { error: 'completion requires a result summary — minimum 20 characters describing what was done' };
816
- }
817
- // Must mention at least one of: commit/SHA, build, audit, test, verified, fix, created, updated, implemented
818
- 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;
819
- if (!actionWords.test(result)) {
820
- return { error: 'completion result must describe the work done include what was committed, built, tested, or verified' };
821
- }
822
- }
823
-
824
- // Atomic transaction: assignment update + agent status + event
825
- const updateTx = db.transaction(() => {
826
- if (['completed', 'failed'].includes(status)) {
827
- db.prepare(
828
- `UPDATE coord_assignments SET status = ?, result = ?, commit_sha = ?, completed_at = datetime('now') WHERE id = ?`
829
- ).run(status, result ?? null, commitSha ?? null, id);
830
- } else {
831
- db.prepare(
832
- `UPDATE coord_assignments SET status = ?, result = ? WHERE id = ?`
833
- ).run(status, result ?? null, id);
834
- }
835
-
836
- if (['completed', 'failed'].includes(status)) {
837
- const assignment = db.prepare(`SELECT agent_id FROM coord_assignments WHERE id = ?`).get(id) as { agent_id: string } | undefined;
838
- if (assignment?.agent_id) {
839
- db.prepare(
840
- `UPDATE coord_agents SET status = 'idle', current_task = NULL WHERE id = ?`
841
- ).run(assignment.agent_id);
842
- }
843
- }
844
-
845
- const eventDetail = ['completed', 'failed'].includes(status)
846
- ? `${id} → ${status}${commitSha ? ' [' + commitSha + ']' : ''}: ${(result ?? '').slice(0, 300)}`
847
- : `${id} → ${status}`;
848
- db.prepare(
849
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES ((SELECT agent_id FROM coord_assignments WHERE id = ?), 'assignment_update', ?)`
850
- ).run(id, eventDetail);
851
- });
852
- updateTx();
853
-
854
- // Log completion/failure with agent name and task (outside tx read-only)
855
- const assignInfo = db.prepare(
856
- `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 = ?`
857
- ).get(id) as { agent_id: string | null; task: string; agent_name: string | null } | undefined;
858
- if (['completed', 'failed'].includes(status)) {
859
- coordLog(`${assignInfo?.agent_name ?? 'unknown'} ${status}: ${assignInfo?.task?.slice(0, 80) ?? id}`);
860
- }
861
-
862
- // Circuit breaker: track success/failure per worker
863
- if (assignInfo?.agent_id) {
864
- if (status === 'completed') recordSuccess(db, assignInfo.agent_id);
865
- else if (status === 'failed') circuitRecordFailure(db, assignInfo.agent_id);
866
- }
867
-
868
- // Emit events
869
- eventBus?.emit('assignment.updated', { assignmentId: id, agentId: assignInfo?.agent_id ?? null, status, result });
870
- if (status === 'completed') {
871
- eventBus?.emit('assignment.completed', { assignmentId: id, agentId: assignInfo?.agent_id ?? null, result: result ?? null });
872
- }
873
-
874
- // Auto-unblock: when an assignment completes, unblock any assignments that depend on it
875
- if (status === 'completed') {
876
- const blocked = db.prepare(
877
- `SELECT id, agent_id, task FROM coord_assignments WHERE blocked_by = ? AND status = 'blocked'`
878
- ).all(id) as Array<{ id: string; agent_id: string | null; task: string }>;
879
-
880
- if (blocked.length > 0) {
881
- const unblockTx = db.transaction(() => {
882
- for (const dep of blocked) {
883
- db.prepare(
884
- `UPDATE coord_assignments SET blocked_by = NULL, status = 'assigned' WHERE id = ?`
885
- ).run(dep.id);
886
- db.prepare(
887
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_unblocked', ?)`
888
- ).run(dep.agent_id, `unblocked by completion of ${id}: ${dep.task.slice(0, 80)}`);
889
- }
890
- });
891
- unblockTx();
892
-
893
- for (const dep of blocked) {
894
- coordLog(`auto-unblocked: ${dep.task.slice(0, 60)} (was blocked by ${id})`);
895
- eventBus?.emit('assignment.updated', { assignmentId: dep.id, agentId: dep.agent_id, status: 'assigned', result: undefined });
896
- }
897
- }
898
- }
899
-
900
- return {};
901
- }
902
-
903
- app.get('/assignment/:id', async (req, reply) => {
904
- const { id } = assignmentIdParamSchema.parse(req.params);
905
- const assignment = db.prepare(
906
- `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 = ?`
907
- ).get(id);
908
- if (!assignment) return reply.code(404).send({ error: 'assignment not found' });
909
- return reply.send({ assignment });
910
- });
911
-
912
- // List assignments with optional filters and pagination
913
- app.get('/assignments', async (req, reply) => {
914
- const q = assignmentsListSchema.parse(req.query);
915
- const conditions: string[] = [];
916
- const params: unknown[] = [];
917
-
918
- if (q.status) {
919
- conditions.push('a.status = ?');
920
- params.push(q.status);
921
- }
922
- if (q.workspace) {
923
- conditions.push('(a.workspace = ? OR a.workspace IS NULL)');
924
- params.push(q.workspace);
925
- }
926
- if (q.agent_id) {
927
- conditions.push('a.agent_id = ?');
928
- params.push(q.agent_id);
929
- }
930
-
931
- const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
932
-
933
- const total = (db.prepare(
934
- `SELECT COUNT(*) AS count FROM coord_assignments a ${where}`
935
- ).get(...params) as { count: number }).count;
936
-
937
- const assignments = db.prepare(
938
- `SELECT a.*, g.name AS agent_name,
939
- CASE WHEN a.blocked_by IS NOT NULL AND a.blocked_by NOT IN (SELECT id FROM coord_assignments WHERE status = 'completed')
940
- THEN 1 ELSE 0 END AS is_blocked
941
- FROM coord_assignments a
942
- LEFT JOIN coord_agents g ON a.agent_id = g.id
943
- ${where}
944
- ORDER BY a.priority DESC, a.created_at DESC
945
- LIMIT ? OFFSET ?`
946
- ).all(...params, q.limit, q.offset);
947
-
948
- return reply.send({ assignments, total });
949
- });
950
-
951
- app.post('/reassign', async (req, reply) => {
952
- const parsed = reassignSchema.safeParse(req.body);
953
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
954
- const { assignmentId, target_worker_name } = parsed.data;
955
- let { targetAgentId } = parsed.data;
956
-
957
- // Verify assignment exists and is active
958
- const assignment = db.prepare(
959
- `SELECT id, agent_id, task, status FROM coord_assignments WHERE id = ?`
960
- ).get(assignmentId) as { id: string; agent_id: string | null; task: string; status: string } | undefined;
961
- if (!assignment) return reply.code(404).send({ error: 'assignment not found' });
962
- if (['completed', 'failed'].includes(assignment.status)) {
963
- return reply.code(400).send({ error: `cannot reassign ${assignment.status} assignment` });
964
- }
965
-
966
- // Resolve target_worker_name targetAgentId
967
- if (!targetAgentId && target_worker_name) {
968
- const found = db.prepare(
969
- `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
970
- ).get(target_worker_name) as { id: string } | undefined;
971
- if (!found) return reply.code(404).send({ error: `target worker not found: ${target_worker_name}` });
972
- targetAgentId = found.id;
973
- }
974
-
975
- // Verify targetAgentId exists
976
- if (targetAgentId) {
977
- const target = db.prepare(`SELECT id FROM coord_agents WHERE id = ?`).get(targetAgentId) as { id: string } | undefined;
978
- if (!target) return reply.code(404).send({ error: 'target agent not found' });
979
- }
980
-
981
- // Release old agent: set idle, clear current_task, release locks
982
- if (assignment.agent_id) {
983
- db.prepare(
984
- `UPDATE coord_agents SET status = 'idle', current_task = NULL WHERE id = ?`
985
- ).run(assignment.agent_id);
986
- db.prepare(
987
- `DELETE FROM coord_locks WHERE agent_id = ?`
988
- ).run(assignment.agent_id);
989
- }
990
-
991
- if (targetAgentId) {
992
- // Reassign to target
993
- db.prepare(
994
- `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ?`
995
- ).run(targetAgentId, assignmentId);
996
- db.prepare(
997
- `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
998
- ).run(assignmentId, targetAgentId);
999
- } else {
1000
- // No target — return to pending for auto-claim
1001
- db.prepare(
1002
- `UPDATE coord_assignments SET agent_id = NULL, status = 'pending', started_at = NULL WHERE id = ?`
1003
- ).run(assignmentId);
1004
- }
1005
-
1006
- db.prepare(
1007
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'reassignment', ?)`
1008
- ).run(assignment.agent_id ?? null, `${assignmentId} reassigned from ${assignment.agent_id ?? 'unassigned'} to ${targetAgentId ?? 'pending'}`);
1009
-
1010
- coordLog(`reassign: ${assignment.task.slice(0, 60)} → ${targetAgentId ?? 'pending'}`);
1011
- return reply.send({ ok: true, assignmentId, newAgentId: targetAgentId ?? null, status: targetAgentId ? 'assigned' : 'pending' });
1012
- });
1013
-
1014
- app.post('/assignment/:id/update', async (req, reply) => {
1015
- const { id } = assignmentIdParamSchema.parse(req.params);
1016
- const parsed = assignmentUpdateSchema.safeParse(req.body);
1017
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1018
- const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
1019
- if (gate.error) return reply.code(400).send({ error: gate.error });
1020
- return reply.send({ ok: true });
1021
- });
1022
-
1023
- app.patch('/assignment/:id', async (req, reply) => {
1024
- const { id } = assignmentIdParamSchema.parse(req.params);
1025
- const parsed = assignmentUpdateSchema.safeParse(req.body);
1026
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1027
- const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
1028
- if (gate.error) return reply.code(400).send({ error: gate.error });
1029
- return reply.send({ ok: true });
1030
- });
1031
-
1032
- /** POST /assignment/:id/fail Worker voluntarily fails an assignment with retry logic.
1033
- * Body: { result: string, mode?: FailureMode }
1034
- * Returns: { outcome: 'retried' | 'failed', attempt_count, last_failure_mode }
1035
- */
1036
- app.post('/assignment/:id/fail', async (req, reply) => {
1037
- const { id } = assignmentIdParamSchema.parse(req.params);
1038
- const body = req.body as { result?: string; mode?: string } | undefined;
1039
- const result = body?.result ?? 'worker-initiated failure';
1040
- const mode = body?.mode as FailureMode | undefined;
1041
-
1042
- const row = db.prepare(
1043
- `SELECT id, agent_id, status FROM coord_assignments WHERE id = ?`
1044
- ).get(id) as { id: string; agent_id: string | null; status: string } | undefined;
1045
-
1046
- if (!row) return reply.code(404).send({ error: 'assignment not found' });
1047
- if (['completed', 'failed'].includes(row.status)) {
1048
- return reply.code(400).send({ error: `cannot fail a ${row.status} assignment` });
1049
- }
1050
-
1051
- const agentId = row.agent_id ?? 'unknown';
1052
- const outcome = retryOrFailAssignment(db, id, agentId, result, mode);
1053
-
1054
- // Circuit breaker: record failure on voluntary fail
1055
- if (agentId !== 'unknown') circuitRecordFailure(db, agentId);
1056
-
1057
- const updated = db.prepare(
1058
- `SELECT attempt_count, last_failure_mode FROM coord_assignments WHERE id = ?`
1059
- ).get(id) as { attempt_count: number; last_failure_mode: string | null } | undefined;
1060
-
1061
- return reply.send({ ok: true, outcome, attempt_count: updated?.attempt_count ?? 0, last_failure_mode: updated?.last_failure_mode ?? null });
1062
- });
1063
-
1064
- app.put('/assignment/:id', async (req, reply) => {
1065
- const { id } = assignmentIdParamSchema.parse(req.params);
1066
- const parsed = assignmentUpdateSchema.safeParse(req.body);
1067
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1068
- const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
1069
- if (gate.error) return reply.code(400).send({ error: gate.error });
1070
- return reply.send({ ok: true });
1071
- });
1072
-
1073
- // ─── Locks ──────────────────────────────────────────────────────
1074
-
1075
- app.post('/lock', async (req, reply) => {
1076
- const parsed = lockAcquireSchema.safeParse(req.body);
1077
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1078
- const { agentId, filePath, reason } = parsed.data;
1079
-
1080
- if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1081
-
1082
- const inserted = db.prepare(
1083
- `INSERT OR IGNORE INTO coord_locks (file_path, agent_id, reason) VALUES (?, ?, ?)`
1084
- ).run(filePath, agentId, reason ?? null);
1085
-
1086
- if (inserted.changes > 0) {
1087
- db.prepare(
1088
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'lock_acquired', ?)`
1089
- ).run(agentId, filePath);
1090
- return reply.send({ ok: true, action: 'acquired' });
1091
- }
1092
-
1093
- const existing = db.prepare(
1094
- `SELECT agent_id FROM coord_locks WHERE file_path = ?`
1095
- ).get(filePath) as { agent_id: string } | undefined;
1096
-
1097
- if (existing?.agent_id === agentId) {
1098
- db.prepare(`UPDATE coord_locks SET locked_at = datetime('now') WHERE file_path = ?`).run(filePath);
1099
- return reply.send({ ok: true, action: 'refreshed' });
1100
- }
1101
-
1102
- return reply.code(409).send({
1103
- error: 'file locked by another agent',
1104
- lockedBy: existing?.agent_id,
1105
- });
1106
- });
1107
-
1108
- app.delete('/lock', async (req, reply) => {
1109
- const parsed = lockReleaseSchema.safeParse(req.body);
1110
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1111
- const { agentId, filePath } = parsed.data;
1112
-
1113
- if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1114
-
1115
- const result = db.prepare(
1116
- `DELETE FROM coord_locks WHERE file_path = ? AND agent_id = ?`
1117
- ).run(filePath, agentId);
1118
-
1119
- if (result.changes === 0) {
1120
- return reply.code(404).send({ error: 'lock not found or not owned by this agent' });
1121
- }
1122
-
1123
- db.prepare(
1124
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'lock_released', ?)`
1125
- ).run(agentId, filePath);
1126
-
1127
- return reply.send({ ok: true });
1128
- });
1129
-
1130
- app.get('/locks', async (_req, reply) => {
1131
- const locks = db.prepare(
1132
- `SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
1133
- FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id
1134
- ORDER BY l.locked_at DESC LIMIT 200`
1135
- ).all();
1136
-
1137
- return reply.send({ locks });
1138
- });
1139
-
1140
- // ─── Commands ───────────────────────────────────────────────────
1141
-
1142
- app.post('/command', async (req, reply) => {
1143
- const parsed = commandCreateSchema.safeParse(req.body);
1144
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1145
- const { command, reason, issuedBy, workspace } = parsed.data;
1146
-
1147
- if (command === 'RESUME') {
1148
- if (workspace) {
1149
- // Clear commands targeting this workspace AND global commands (workspace IS NULL).
1150
- // Global commands (e.g. SHUTDOWN with no workspace) apply to all workspaces,
1151
- // so RESUME for a workspace must also clear them — otherwise they persist forever.
1152
- db.prepare(
1153
- `UPDATE coord_commands SET cleared_at = datetime('now') WHERE cleared_at IS NULL AND (workspace = ? OR workspace IS NULL)`
1154
- ).run(workspace);
1155
- } else {
1156
- db.prepare(
1157
- `UPDATE coord_commands SET cleared_at = datetime('now') WHERE cleared_at IS NULL`
1158
- ).run();
1159
- }
1160
-
1161
- db.prepare(
1162
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'command', ?)`
1163
- ).run(issuedBy ?? null, `RESUME${workspace ? ' [' + workspace + ']' : ''} — commands cleared`);
1164
-
1165
- return reply.send({ ok: true, command: 'RESUME', workspace, message: workspace ? `commands cleared for ${workspace}` : 'all active commands cleared' });
1166
- }
1167
-
1168
- db.prepare(
1169
- `INSERT INTO coord_commands (command, reason, issued_by, workspace) VALUES (?, ?, ?, ?)`
1170
- ).run(command, reason ?? null, issuedBy ?? null, workspace ?? null);
1171
-
1172
- db.prepare(
1173
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'command', ?)`
1174
- ).run(issuedBy ?? null, `${command}${workspace ? ' [' + workspace + ']' : ''}: ${reason ?? 'no reason given'}`);
1175
-
1176
- coordLog(`COMMAND: ${command}${reason ? ' — ' + reason : ''}`);
1177
- return reply.code(201).send({ ok: true, command, reason, workspace });
1178
- });
1179
-
1180
- app.get('/command', async (req, reply) => {
1181
- const workspace = (req.query as Record<string, string>).workspace;
1182
-
1183
- const active = workspace
1184
- ? db.prepare(
1185
- `SELECT id, command, reason, issued_by, issued_at, workspace
1186
- FROM coord_commands WHERE cleared_at IS NULL AND (workspace = ? OR workspace IS NULL)
1187
- ORDER BY issued_at DESC`
1188
- ).all(workspace) as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>
1189
- : db.prepare(
1190
- `SELECT id, command, reason, issued_by, issued_at, workspace
1191
- FROM coord_commands WHERE cleared_at IS NULL
1192
- ORDER BY issued_at DESC`
1193
- ).all() as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>;
1194
-
1195
- if (active.length === 0) {
1196
- return reply.send({ active: false, commands: [] });
1197
- }
1198
-
1199
- const priority: Record<string, number> = { SHUTDOWN: 3, BUILD_FREEZE: 2, PAUSE: 1 };
1200
- active.sort((a, b) => (priority[b.command] ?? 0) - (priority[a.command] ?? 0));
1201
-
1202
- return reply.send({
1203
- active: true,
1204
- command: active[0].command,
1205
- reason: active[0].reason,
1206
- issued_at: active[0].issued_at,
1207
- commands: active,
1208
- });
1209
- });
1210
-
1211
- app.delete('/command/:id', async (req, reply) => {
1212
- const id = Number((req.params as Record<string, string>).id);
1213
- if (!Number.isInteger(id) || id <= 0) return reply.code(400).send({ error: 'invalid command id' });
1214
-
1215
- const result = db.prepare(
1216
- `UPDATE coord_commands SET cleared_at = datetime('now') WHERE id = ? AND cleared_at IS NULL`
1217
- ).run(id);
1218
-
1219
- if (result.changes === 0) {
1220
- return reply.code(404).send({ error: 'command not found or already cleared' });
1221
- }
1222
-
1223
- db.prepare(
1224
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (NULL, 'command', ?)`
1225
- ).run(`command ${id} cleared via DELETE`);
1226
-
1227
- return reply.send({ ok: true });
1228
- });
1229
-
1230
- app.get('/command/wait', async (req, reply) => {
1231
- const q = commandWaitQuerySchema.safeParse(req.query);
1232
- const { status: targetStatus, workspace } = q.success ? q.data : { status: 'idle', workspace: undefined };
1233
-
1234
- const agents = workspace
1235
- ? db.prepare(
1236
- `SELECT id, name, role, status, current_task, last_seen
1237
- FROM coord_agents WHERE status NOT IN ('dead') AND workspace = ?
1238
- ORDER BY name`
1239
- ).all(workspace) as Array<{ id: string; name: string; role: string; status: string; current_task: string | null; last_seen: string }>
1240
- : db.prepare(
1241
- `SELECT id, name, role, status, current_task, last_seen
1242
- FROM coord_agents WHERE status NOT IN ('dead')
1243
- ORDER BY name`
1244
- ).all() as Array<{ id: string; name: string; role: string; status: string; current_task: string | null; last_seen: string }>;
1245
-
1246
- const ready = agents.filter(a => a.status === targetStatus || a.role === 'orchestrator' || a.role === 'coordinator');
1247
- const notReady = agents.filter(a => a.status !== targetStatus && a.role !== 'orchestrator' && a.role !== 'coordinator');
1248
-
1249
- return reply.send({
1250
- allReady: notReady.length === 0,
1251
- total: agents.length,
1252
- ready: ready.map(a => ({ name: a.name, status: a.status })),
1253
- waiting: notReady.map(a => ({ name: a.name, status: a.status, task: a.current_task })),
1254
- });
1255
- });
1256
-
1257
- // ─── Findings ───────────────────────────────────────────────────
1258
-
1259
- app.post('/finding', async (req, reply) => {
1260
- const parsed = findingCreateSchema.safeParse(req.body);
1261
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1262
- const { agentId, category, severity, filePath, lineNumber, description, suggestion } = parsed.data;
1263
-
1264
- if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1265
-
1266
- db.prepare(
1267
- `INSERT INTO coord_findings (agent_id, category, severity, file_path, line_number, description, suggestion)
1268
- VALUES (?, ?, ?, ?, ?, ?, ?)`
1269
- ).run(agentId, category, severity ?? 'info', filePath ?? null, lineNumber ?? null, description, suggestion ?? null);
1270
-
1271
- db.prepare(
1272
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'finding', ?)`
1273
- ).run(agentId, `[${severity ?? 'info'}] ${category}: ${description.slice(0, 100)}`);
1274
-
1275
- return reply.code(201).send({ ok: true });
1276
- });
1277
-
1278
- app.get('/findings', async (req, reply) => {
1279
- const q = findingsQuerySchema.safeParse(req.query);
1280
- const { category, severity, status, limit } = q.success ? q.data : { category: undefined, severity: undefined, status: undefined, limit: 50 };
1281
-
1282
- let sql = `
1283
- SELECT f.id, f.category, f.severity, f.file_path, f.line_number,
1284
- f.description, f.suggestion, f.status, f.created_at,
1285
- a.name AS agent_name
1286
- FROM coord_findings f JOIN coord_agents a ON f.agent_id = a.id
1287
- WHERE 1=1
1288
- `;
1289
- const params: unknown[] = [];
1290
-
1291
- if (category) { sql += ` AND f.category = ?`; params.push(category); }
1292
- if (severity) { sql += ` AND f.severity = ?`; params.push(severity); }
1293
- if (status) { sql += ` AND f.status = ?`; params.push(status); }
1294
-
1295
- sql += ` ORDER BY
1296
- CASE f.severity WHEN 'critical' THEN 0 WHEN 'error' THEN 1 WHEN 'warn' THEN 2 ELSE 3 END,
1297
- f.created_at DESC
1298
- LIMIT ?`;
1299
- params.push(limit);
1300
-
1301
- const findings = db.prepare(sql).all(...params);
1302
-
1303
- const stats = db.prepare(
1304
- `SELECT severity, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY severity`
1305
- ).all();
1306
-
1307
- return reply.send({ findings, stats });
1308
- });
1309
-
1310
- app.post('/finding/:id/resolve', async (req, reply) => {
1311
- const { id } = findingIdParamSchema.parse(req.params);
1312
- db.prepare(
1313
- `UPDATE coord_findings SET status = 'resolved', resolved_at = datetime('now') WHERE id = ?`
1314
- ).run(id);
1315
- return reply.send({ ok: true });
1316
- });
1317
-
1318
- app.patch('/finding/:id', async (req, reply) => {
1319
- const { id } = findingIdParamSchema.parse(req.params);
1320
- const parsed = findingUpdateSchema.safeParse(req.body);
1321
- if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1322
- const { status, suggestion } = parsed.data;
1323
-
1324
- const existing = db.prepare(`SELECT id FROM coord_findings WHERE id = ?`).get(id);
1325
- if (!existing) return reply.code(404).send({ error: 'finding not found' });
1326
-
1327
- const sets: string[] = [];
1328
- const params: unknown[] = [];
1329
-
1330
- if (status) {
1331
- sets.push('status = ?');
1332
- params.push(status);
1333
- if (status === 'resolved') {
1334
- sets.push("resolved_at = datetime('now')");
1335
- }
1336
- }
1337
- if (suggestion !== undefined) {
1338
- sets.push('suggestion = ?');
1339
- params.push(suggestion);
1340
- }
1341
-
1342
- if (sets.length === 0) return reply.send({ ok: true, changed: false });
1343
-
1344
- params.push(id);
1345
- db.prepare(`UPDATE coord_findings SET ${sets.join(', ')} WHERE id = ?`).run(...params);
1346
- return reply.send({ ok: true, changed: true });
1347
- });
1348
-
1349
- app.get('/findings/summary', async (_req, reply) => {
1350
- const bySeverity = db.prepare(
1351
- `SELECT severity, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY severity`
1352
- ).all();
1353
-
1354
- const byCategory = db.prepare(
1355
- `SELECT category, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY category ORDER BY count DESC`
1356
- ).all();
1357
-
1358
- const total = db.prepare(
1359
- `SELECT COUNT(*) as total FROM coord_findings WHERE status = 'open'`
1360
- ).get() as { total: number };
1361
-
1362
- return reply.send({ total: total.total, bySeverity, byCategory });
1363
- });
1364
-
1365
- // ─── Decisions (cross-agent propagation) ────────────────────────
1366
-
1367
- app.get('/decisions', async (req, reply) => {
1368
- const q = decisionsQuerySchema.safeParse(req.query);
1369
- const { since_id, assignment_id, workspace, limit } = q.success ? q.data : { since_id: 0, assignment_id: undefined, workspace: undefined, limit: 20 };
1370
-
1371
- let sql = `
1372
- SELECT d.id, d.author_id, a.name AS author_name, d.assignment_id, d.tags, d.summary, d.created_at
1373
- FROM coord_decisions d JOIN coord_agents a ON d.author_id = a.id
1374
- WHERE d.id > ?
1375
- `;
1376
- const params: unknown[] = [since_id];
1377
-
1378
- if (assignment_id) {
1379
- sql += ` AND d.assignment_id = ?`;
1380
- params.push(assignment_id);
1381
- }
1382
-
1383
- if (workspace) {
1384
- sql += ` AND (a.workspace = ? OR a.workspace IS NULL)`;
1385
- params.push(workspace);
1386
- }
1387
-
1388
- sql += ` ORDER BY d.created_at ASC LIMIT ?`;
1389
- params.push(limit);
1390
-
1391
- const decisions = db.prepare(sql).all(...params);
1392
- return reply.send({ decisions });
1393
- });
1394
-
1395
- app.post('/decisions', async (req, reply) => {
1396
- const { agentId, assignment_id, tags, summary } = decisionCreateSchema.parse(req.body);
1397
-
1398
- // Verify agent exists
1399
- const agent = db.prepare(`SELECT id FROM coord_agents WHERE id = ?`).get(agentId) as { id: string } | undefined;
1400
- if (!agent) return reply.code(404).send({ error: 'agent not found' });
1401
-
1402
- if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1403
-
1404
- db.prepare(
1405
- `INSERT INTO coord_decisions (author_id, assignment_id, tags, summary) VALUES (?, ?, ?, ?)`
1406
- ).run(agentId, assignment_id ?? null, tags ?? null, summary);
1407
-
1408
- const row = db.prepare(`SELECT last_insert_rowid() AS id`).get() as { id: number };
1409
- return reply.code(201).send({ ok: true, id: row.id });
1410
- });
1411
-
1412
- // ─── Status ─────────────────────────────────────────────────────
1413
-
1414
- app.get('/status', async (_req, reply) => {
1415
- const agents = db.prepare(
1416
- `SELECT id, name, role, status, current_task, last_seen,
1417
- ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
1418
- FROM coord_agents WHERE status != 'dead'
1419
- ORDER BY role, name LIMIT 200`
1420
- ).all();
1421
-
1422
- const assignments = db.prepare(
1423
- `SELECT a.id, a.task, a.description, a.status, a.agent_id, ag.name AS agent_name,
1424
- a.created_at, a.started_at, a.completed_at
1425
- FROM coord_assignments a LEFT JOIN coord_agents ag ON a.agent_id = ag.id
1426
- WHERE a.status NOT IN ('completed', 'failed')
1427
- ORDER BY a.created_at LIMIT 200`
1428
- ).all();
1429
-
1430
- const locks = db.prepare(
1431
- `SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
1432
- FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id LIMIT 200`
1433
- ).all();
1434
-
1435
- const stats = db.prepare(
1436
- `SELECT
1437
- (SELECT COUNT(*) FROM coord_agents WHERE status != 'dead') AS alive_agents,
1438
- (SELECT COUNT(*) FROM coord_agents WHERE status = 'working') AS busy_agents,
1439
- (SELECT COUNT(*) FROM coord_assignments WHERE status = 'pending') AS pending_tasks,
1440
- (SELECT COUNT(*) FROM coord_assignments WHERE status IN ('assigned', 'in_progress')) AS active_tasks,
1441
- (SELECT COUNT(*) FROM coord_locks) AS active_locks,
1442
- (SELECT COUNT(*) FROM coord_findings WHERE status = 'open') AS open_findings,
1443
- (SELECT COUNT(*) FROM coord_findings WHERE status = 'open' AND severity IN ('critical', 'error')) AS urgent_findings`
1444
- ).get();
1445
-
1446
- const recentFindings = db.prepare(
1447
- `SELECT f.id, f.category, f.severity, f.file_path, f.description, a.name AS agent_name, f.created_at
1448
- FROM coord_findings f JOIN coord_agents a ON f.agent_id = a.id
1449
- WHERE f.status = 'open'
1450
- ORDER BY CASE f.severity WHEN 'critical' THEN 0 WHEN 'error' THEN 1 WHEN 'warn' THEN 2 ELSE 3 END,
1451
- f.created_at DESC
1452
- LIMIT 10`
1453
- ).all();
1454
-
1455
- return reply.send({ agents, assignments, locks, stats, recentFindings });
1456
- });
1457
-
1458
- app.get('/workers', async (req, reply) => {
1459
- const q = workersQuerySchema.safeParse(req.query);
1460
- const { capability, status: filterStatus, workspace } = q.success ? q.data : { capability: undefined, status: undefined, workspace: undefined };
1461
-
1462
- // Join with coord_channel_sessions so the coordinator agent can compute
1463
- // alive=true for workers that have a connected channel session even when
1464
- // their /pulse is stale. Without this, /workers under-reports liveness
1465
- // during long tool-call sequences where the worker is processing but
1466
- // hasn't called /pulse for >5min — leading to false-positive duplicate
1467
- // spawns. Channel sessions get probed every 60s (coordination/index.ts:111),
1468
- // so a stale channel-server.js gets status='disconnected' within 60-120s.
1469
- let workers = workspace
1470
- ? db.prepare(
1471
- `SELECT a.id, a.name, a.role, a.status, a.current_task, a.capabilities, a.workspace, a.last_seen,
1472
- ROUND((julianday('now') - julianday(a.last_seen)) * 86400) AS seconds_since_seen,
1473
- cs.status AS channel_status, cs.last_push_at AS channel_last_push
1474
- FROM coord_agents a
1475
- LEFT JOIN coord_channel_sessions cs ON cs.agent_id = a.id
1476
- WHERE a.status != 'dead' AND a.role NOT IN ('orchestrator', 'coordinator') AND a.workspace = ?
1477
- ORDER BY a.name LIMIT 200`
1478
- ).all(workspace) as Array<{
1479
- id: string; name: string; role: string; status: string;
1480
- current_task: string | null; capabilities: string | null;
1481
- workspace: string | null; last_seen: string; seconds_since_seen: number;
1482
- channel_status: string | null; channel_last_push: string | null;
1483
- }>
1484
- : db.prepare(
1485
- `SELECT a.id, a.name, a.role, a.status, a.current_task, a.capabilities, a.workspace, a.last_seen,
1486
- ROUND((julianday('now') - julianday(a.last_seen)) * 86400) AS seconds_since_seen,
1487
- cs.status AS channel_status, cs.last_push_at AS channel_last_push
1488
- FROM coord_agents a
1489
- LEFT JOIN coord_channel_sessions cs ON cs.agent_id = a.id
1490
- WHERE a.status != 'dead' AND a.role NOT IN ('orchestrator', 'coordinator')
1491
- ORDER BY a.name LIMIT 200`
1492
- ).all() as Array<{
1493
- id: string; name: string; role: string; status: string;
1494
- current_task: string | null; capabilities: string | null;
1495
- workspace: string | null; last_seen: string; seconds_since_seen: number;
1496
- channel_status: string | null; channel_last_push: string | null;
1497
- }>;
1498
-
1499
- if (capability) {
1500
- workers = workers.filter(w => {
1501
- if (!w.capabilities) return false;
1502
- try {
1503
- const caps = JSON.parse(w.capabilities) as string[];
1504
- return caps.includes(capability);
1505
- } catch {
1506
- return false;
1507
- }
1508
- });
1509
- }
1510
-
1511
- if (filterStatus) {
1512
- workers = workers.filter(w => w.status === filterStatus);
1513
- }
1514
-
1515
- const result = workers.map(w => ({
1516
- id: w.id,
1517
- name: w.name,
1518
- role: w.role,
1519
- status: w.status,
1520
- currentTask: w.current_task,
1521
- capabilities: w.capabilities ? JSON.parse(w.capabilities) : [],
1522
- workspace: w.workspace,
1523
- lastSeen: w.last_seen,
1524
- secondsSinceSeen: w.seconds_since_seen,
1525
- // alive = recent /pulse OR connected channel session.
1526
- // Channel sessions get probed every 60s and marked 'disconnected'
1527
- // when unreachable, so a connected session is reliable proof of life
1528
- // even during long tool sequences where the worker hasn't pulsed.
1529
- // Prevents duplicate worker spawns when /pulse is stale but worker is busy.
1530
- alive: w.seconds_since_seen < 300 || w.channel_status === 'connected',
1531
- channelStatus: w.channel_status,
1532
- channelLastPush: w.channel_last_push,
1533
- }));
1534
-
1535
- return reply.send({
1536
- count: result.length,
1537
- idle: result.filter(w => w.status === 'idle').length,
1538
- working: result.filter(w => w.status === 'working').length,
1539
- workers: result,
1540
- });
1541
- });
1542
-
1543
- app.get('/events', async (req, reply) => {
1544
- const q = eventsQuerySchema.safeParse(req.query);
1545
- if (!q.success) return reply.code(400).send({ error: q.error.issues[0].message });
1546
- const { since_id, agent_id, event_type, limit } = q.data;
1547
-
1548
- const conditions: string[] = [];
1549
- const params: unknown[] = [];
1550
-
1551
- if (since_id > 0) {
1552
- conditions.push('e.id > ?');
1553
- params.push(since_id);
1554
- }
1555
- if (agent_id) {
1556
- conditions.push('e.agent_id = ?');
1557
- params.push(agent_id);
1558
- }
1559
- if (event_type) {
1560
- conditions.push('e.event_type = ?');
1561
- params.push(event_type);
1562
- }
1563
-
1564
- const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
1565
- params.push(limit);
1566
-
1567
- const events = db.prepare(
1568
- `SELECT e.id, e.agent_id, a.name AS agent_name, e.event_type, e.detail, e.created_at
1569
- FROM coord_events e LEFT JOIN coord_agents a ON e.agent_id = a.id
1570
- ${where}
1571
- ORDER BY e.id ASC LIMIT ?`
1572
- ).all(...params);
1573
-
1574
- const last_id = events.length > 0 ? (events[events.length - 1] as { id: number }).id : since_id;
1575
-
1576
- return reply.send({ events, last_id });
1577
- });
1578
-
1579
- app.get('/stale', async (req, reply) => {
1580
- const q = staleQuerySchema.safeParse(req.query);
1581
- const threshold = q.success ? q.data.seconds : 300;
1582
- const cleanup = q.success ? q.data.cleanup : undefined;
1583
-
1584
- const stale = detectStale(db, threshold);
1585
-
1586
- if (cleanup === '1' || cleanup === 'true') {
1587
- const { cleaned } = cleanupStale(db, threshold);
1588
- return reply.send({ stale, threshold_seconds: threshold, cleaned });
1589
- }
1590
-
1591
- return reply.send({ stale, threshold_seconds: threshold });
1592
- });
1593
-
1594
- app.post('/stale/cleanup', async (req, reply) => {
1595
- const q = staleQuerySchema.safeParse(req.query);
1596
- const threshold = q.success ? q.data.seconds : 300;
1597
-
1598
- const { stale, cleaned } = cleanupStale(db, threshold);
1599
- return reply.send({ stale, threshold_seconds: threshold, cleaned });
1600
- });
1601
-
1602
- // ─── Agent Management ───────────────────────────────────────────
1603
-
1604
- app.get('/agent/:id', async (req, reply) => {
1605
- const params = agentIdParamSchema.safeParse(req.params);
1606
- if (!params.success) return reply.code(400).send({ error: params.error.issues[0].message });
1607
- const { id } = params.data;
1608
-
1609
- const agent = db.prepare(
1610
- `SELECT id, name, role, status, current_task, pid, capabilities, workspace, metadata, last_seen, started_at,
1611
- ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
1612
- FROM coord_agents WHERE id = ?`
1613
- ).get(id) as Record<string, unknown> | undefined;
1614
-
1615
- if (!agent) return reply.code(404).send({ error: 'agent not found' });
1616
-
1617
- // Include active assignment and locks
1618
- const assignment = db.prepare(
1619
- `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`
1620
- ).get(id) as Record<string, unknown> | undefined;
1621
-
1622
- const locks = db.prepare(
1623
- `SELECT file_path, locked_at, reason FROM coord_locks WHERE agent_id = ?`
1624
- ).all(id);
1625
-
1626
- return reply.send({ agent, assignment: assignment ?? null, locks });
1627
- });
1628
-
1629
- app.delete('/agent/:id', async (req, reply) => {
1630
- const params = agentIdParamSchema.safeParse(req.params);
1631
- if (!params.success) return reply.code(400).send({ error: params.error.issues[0].message });
1632
- const { id } = params.data;
1633
-
1634
- const agent = db.prepare(`SELECT id, name, status FROM coord_agents WHERE id = ?`).get(id) as { id: string; name: string; status: string } | undefined;
1635
- if (!agent) return reply.code(404).send({ error: 'agent not found' });
1636
- if (agent.status === 'dead') return reply.send({ ok: true, action: 'already_dead', agent_name: agent.name });
1637
-
1638
- // Fail active assignments
1639
- const failedAssignments = db.prepare(
1640
- `UPDATE coord_assignments SET status = 'failed', result = 'agent killed by coordinator', completed_at = datetime('now')
1641
- WHERE agent_id = ? AND status IN ('assigned', 'in_progress')`
1642
- ).run(id);
1643
-
1644
- if (failedAssignments.changes > 0) {
1645
- db.prepare(
1646
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_failed', ?)`
1647
- ).run(id, `killed: failed ${failedAssignments.changes} active assignment(s)`);
1648
- }
1649
-
1650
- // Release locks
1651
- const releasedLocks = db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(id);
1652
-
1653
- // Mark dead
1654
- db.prepare(`UPDATE coord_agents SET status = 'dead', current_task = NULL WHERE id = ?`).run(id);
1655
-
1656
- db.prepare(
1657
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'agent_killed', ?)`
1658
- ).run(id, `${agent.name} killed: failed ${failedAssignments.changes} assignment(s), released ${releasedLocks.changes} lock(s)`);
1659
-
1660
- coordLog(`${agent.name} killed — failed ${failedAssignments.changes} assignment(s), released ${releasedLocks.changes} lock(s)`);
1661
-
1662
- return reply.send({
1663
- ok: true,
1664
- action: 'killed',
1665
- agent_name: agent.name,
1666
- failed_assignments: failedAssignments.changes,
1667
- released_locks: releasedLocks.changes,
1668
- });
1669
- });
1670
-
1671
- // ─── Timeline ─────────────────────────────────────────────────────
1672
-
1673
- app.get('/timeline', async (req, reply) => {
1674
- const q = timelineQuerySchema.safeParse(req.query);
1675
- if (!q.success) return reply.code(400).send({ error: q.error.issues[0].message });
1676
- const { limit, since } = q.data;
1677
-
1678
- const conditions: string[] = [];
1679
- const params: unknown[] = [];
1680
-
1681
- if (since) {
1682
- conditions.push('e.created_at >= ?');
1683
- params.push(since);
1684
- }
1685
-
1686
- const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
1687
- params.push(limit);
1688
-
1689
- const timeline = db.prepare(
1690
- `SELECT e.created_at AS timestamp, a.name AS agent_name, e.event_type, e.detail,
1691
- t.task AS assignment_task
1692
- FROM coord_events e
1693
- LEFT JOIN coord_agents a ON e.agent_id = a.id
1694
- LEFT JOIN coord_assignments t ON a.current_task = t.id
1695
- ${where}
1696
- ORDER BY e.created_at DESC, e.id DESC
1697
- LIMIT ?`
1698
- ).all(...params);
1699
-
1700
- return reply.send({ timeline });
1701
- });
1702
-
1703
- // ─── Stats ──────────────────────────────────────────────────────
1704
-
1705
- app.get('/stats', async (_req, reply) => {
1706
- const workers = db.prepare(`
1707
- SELECT
1708
- COUNT(*) AS total,
1709
- SUM(CASE WHEN status != 'dead' THEN 1 ELSE 0 END) AS alive,
1710
- SUM(CASE WHEN status = 'idle' THEN 1 ELSE 0 END) AS idle,
1711
- SUM(CASE WHEN status = 'working' THEN 1 ELSE 0 END) AS working
1712
- FROM coord_agents
1713
- `).get() as { total: number; alive: number; idle: number; working: number };
1714
-
1715
- const tasks = db.prepare(`
1716
- SELECT
1717
- COUNT(*) AS total_assigned,
1718
- SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
1719
- SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
1720
- SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
1721
- AVG(CASE
1722
- WHEN status = 'completed' AND started_at IS NOT NULL AND completed_at IS NOT NULL
1723
- THEN ROUND((julianday(completed_at) - julianday(started_at)) * 86400)
1724
- ELSE NULL
1725
- END) AS avg_completion_seconds
1726
- FROM coord_assignments
1727
- `).get() as { total_assigned: number; completed: number; failed: number; pending: number; avg_completion_seconds: number | null };
1728
-
1729
- const decisions = db.prepare(`
1730
- SELECT
1731
- COALESCE(COUNT(*), 0) AS total,
1732
- COALESCE(SUM(CASE WHEN created_at >= datetime('now', '-1 hour') THEN 1 ELSE 0 END), 0) AS last_hour
1733
- FROM coord_decisions
1734
- `).get() as { total: number; last_hour: number };
1735
-
1736
- // Uptime = seconds since the earliest non-dead agent started
1737
- const uptime = db.prepare(`
1738
- SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS uptime_seconds
1739
- FROM coord_agents WHERE status != 'dead'
1740
- `).get() as { uptime_seconds: number | null };
1741
-
1742
- return reply.send({
1743
- workers,
1744
- tasks: {
1745
- ...tasks,
1746
- avg_completion_seconds: tasks.avg_completion_seconds != null
1747
- ? Math.round(tasks.avg_completion_seconds)
1748
- : null,
1749
- },
1750
- decisions,
1751
- uptime_seconds: uptime.uptime_seconds ?? 0,
1752
- });
1753
- });
1754
-
1755
- // ─── Prometheus Metrics ────────────────────────────────────────
1756
-
1757
- app.get('/metrics', async (_req, reply) => {
1758
- const agentsByStatus = db.prepare(
1759
- `SELECT status, COUNT(*) AS count FROM coord_agents GROUP BY status`
1760
- ).all() as Array<{ status: string; count: number }>;
1761
-
1762
- const assignmentsByStatus = db.prepare(
1763
- `SELECT status, COUNT(*) AS count FROM coord_assignments GROUP BY status`
1764
- ).all() as Array<{ status: string; count: number }>;
1765
-
1766
- const locksActive = (db.prepare(
1767
- `SELECT COUNT(*) AS count FROM coord_locks`
1768
- ).get() as { count: number }).count;
1769
-
1770
- const findingsBySeverity = db.prepare(
1771
- `SELECT severity, COUNT(*) AS count FROM coord_findings WHERE status = 'open' GROUP BY severity`
1772
- ).all() as Array<{ severity: string; count: number }>;
1773
-
1774
- const eventsTotal = (db.prepare(
1775
- `SELECT COUNT(*) AS count FROM coord_events`
1776
- ).get() as { count: number }).count;
1777
-
1778
- const uptime = (db.prepare(
1779
- `SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS seconds FROM coord_agents WHERE status != 'dead'`
1780
- ).get() as { seconds: number | null }).seconds ?? 0;
1781
-
1782
- const lines: string[] = [
1783
- '# HELP coord_agents_total Number of agents by status',
1784
- '# TYPE coord_agents_total gauge',
1785
- ];
1786
- for (const row of agentsByStatus) {
1787
- lines.push(`coord_agents_total{status="${row.status}"} ${row.count}`);
1788
- }
1789
-
1790
- lines.push('# HELP coord_assignments_total Number of assignments by status');
1791
- lines.push('# TYPE coord_assignments_total gauge');
1792
- for (const row of assignmentsByStatus) {
1793
- lines.push(`coord_assignments_total{status="${row.status}"} ${row.count}`);
1794
- }
1795
-
1796
- lines.push('# HELP coord_locks_active Number of active file locks');
1797
- lines.push('# TYPE coord_locks_active gauge');
1798
- lines.push(`coord_locks_active ${locksActive}`);
1799
-
1800
- lines.push('# HELP coord_findings_total Open findings by severity');
1801
- lines.push('# TYPE coord_findings_total gauge');
1802
- for (const row of findingsBySeverity) {
1803
- lines.push(`coord_findings_total{severity="${row.severity}"} ${row.count}`);
1804
- }
1805
-
1806
- lines.push('# HELP coord_events_total Total coordination events');
1807
- lines.push('# TYPE coord_events_total counter');
1808
- lines.push(`coord_events_total ${eventsTotal}`);
1809
-
1810
- lines.push('# HELP coord_uptime_seconds Seconds since first agent registered');
1811
- lines.push('# TYPE coord_uptime_seconds gauge');
1812
- lines.push(`coord_uptime_seconds ${uptime}`);
1813
-
1814
- // ─── Channel push telemetry (process-scoped, reset on restart) ───
1815
- lines.push('# HELP coord_channel_push_attempts_total Total channel push attempts since coordinator startup');
1816
- lines.push('# TYPE coord_channel_push_attempts_total counter');
1817
- lines.push(`coord_channel_push_attempts_total ${channelMetrics.attempts}`);
1818
-
1819
- lines.push('# HELP coord_channel_push_delivered_total Successful channel deliveries');
1820
- lines.push('# TYPE coord_channel_push_delivered_total counter');
1821
- lines.push(`coord_channel_push_delivered_total ${channelMetrics.delivered}`);
1822
-
1823
- lines.push('# HELP coord_channel_push_failed_total Failed channel deliveries by reason');
1824
- lines.push('# TYPE coord_channel_push_failed_total counter');
1825
- lines.push(`coord_channel_push_failed_total{reason="http"} ${channelMetrics.failed_http}`);
1826
- lines.push(`coord_channel_push_failed_total{reason="unreachable"} ${channelMetrics.failed_unreachable}`);
1827
-
1828
- lines.push('# HELP coord_channel_no_session_total Push attempts where agent had no connected session');
1829
- lines.push('# TYPE coord_channel_no_session_total counter');
1830
- lines.push(`coord_channel_no_session_total ${channelMetrics.no_session}`);
1831
-
1832
- lines.push('# HELP coord_channel_fallback_mailbox_total Pushes that fell back to mailbox after delivery failure');
1833
- lines.push('# TYPE coord_channel_fallback_mailbox_total counter');
1834
- lines.push(`coord_channel_fallback_mailbox_total ${channelMetrics.fallback_mailbox}`);
1835
-
1836
- lines.push('# HELP coord_channel_session_disconnects_total Sessions marked disconnected after delivery failure');
1837
- lines.push('# TYPE coord_channel_session_disconnects_total counter');
1838
- lines.push(`coord_channel_session_disconnects_total ${channelMetrics.session_disconnects}`);
1839
-
1840
- return reply.type('text/plain; version=0.0.4; charset=utf-8').send(lines.join('\n') + '\n');
1841
- });
1842
-
1843
- // ─── Deep Health ───────────────────────────────────────────────
1844
-
1845
- app.get('/health/deep', async (_req, reply) => {
1846
- const dbHealthy = store ? store.integrityCheck().ok : true;
1847
-
1848
- const agents = db.prepare(
1849
- `SELECT COUNT(*) AS alive FROM coord_agents WHERE status != 'dead'`
1850
- ).get() as { alive: number };
1851
-
1852
- const staleThreshold = 300;
1853
- const staleCount = (db.prepare(
1854
- `SELECT COUNT(*) AS c FROM coord_agents
1855
- WHERE status != 'dead'
1856
- AND (julianday('now') - julianday(last_seen)) * 86400 > ?`
1857
- ).get(staleThreshold) as { c: number }).c;
1858
-
1859
- const pending = (db.prepare(
1860
- `SELECT COUNT(*) AS c FROM coord_assignments WHERE status IN ('pending', 'assigned', 'in_progress')`
1861
- ).get() as { c: number }).c;
1862
-
1863
- const uptimeRow = db.prepare(
1864
- `SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS s
1865
- FROM coord_agents WHERE status != 'dead'`
1866
- ).get() as { s: number | null };
1867
-
1868
- // WAL file size and autocheckpoint setting
1869
- let walSizeBytes: number | null = null;
1870
- let walAutocheckpoint: number | null = null;
1871
- try {
1872
- const fs = require('fs');
1873
- const walPath = db.name + '-wal';
1874
- const stat = fs.statSync(walPath);
1875
- walSizeBytes = stat.size;
1876
- } catch { /* WAL file may not exist */ }
1877
- try {
1878
- const acRow = db.pragma('wal_autocheckpoint') as Array<{ wal_autocheckpoint: number }>;
1879
- walAutocheckpoint = acRow[0]?.wal_autocheckpoint ?? null;
1880
- } catch { /* pragma read failed */ }
1881
-
1882
- const status = (!dbHealthy || staleCount > 2) ? 'degraded' : 'ok';
1883
-
1884
- return reply.send({
1885
- status,
1886
- db_healthy: dbHealthy,
1887
- agents_alive: agents.alive,
1888
- stale_agents: staleCount,
1889
- pending_tasks: pending,
1890
- uptime_seconds: uptimeRow.s ?? 0,
1891
- wal_size_bytes: walSizeBytes,
1892
- wal_autocheckpoint: walAutocheckpoint,
1893
- });
1894
- });
1895
-
1896
- // ─── Channel Sessions ───────────────────────────────────────────
1897
-
1898
- /** POST /channel/register — Register or update a channel session for an agent. */
1899
- app.post('/channel/register', async (request, reply) => {
1900
- const parsed = channelRegisterSchema.safeParse(request.body);
1901
- if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
1902
- const { agentId, channelId } = parsed.data;
1903
-
1904
- const agent = db.prepare('SELECT id FROM coord_agents WHERE id = ?').get(agentId) as { id: string } | undefined;
1905
- if (!agent) return reply.status(404).send({ error: 'Agent not found' });
1906
-
1907
- db.prepare(`
1908
- INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
1909
- VALUES (?, ?, datetime('now'), 'connected')
1910
- ON CONFLICT(agent_id) DO UPDATE SET
1911
- channel_id = excluded.channel_id,
1912
- connected_at = datetime('now'),
1913
- status = 'connected',
1914
- push_count = 0,
1915
- last_push_at = NULL
1916
- `).run(agentId, channelId);
1917
-
1918
- db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_register', ?)`).run(
1919
- agentId, JSON.stringify({ channelId })
1920
- );
1921
-
1922
- coordLog(`channel/register: ${agentId} → ${channelId}`);
1923
- eventBus?.emit('session.started', { agentId, channelId });
1924
- return reply.send({ ok: true });
1925
- });
1926
-
1927
- /** DELETE /channel/register Deregister a channel session for an agent. */
1928
- app.delete('/channel/register', async (request, reply) => {
1929
- const parsed = channelDeregisterSchema.safeParse(request.body);
1930
- if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
1931
- const { agentId } = parsed.data;
1932
-
1933
- const result = db.prepare('DELETE FROM coord_channel_sessions WHERE agent_id = ?').run(agentId);
1934
-
1935
- db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_deregister', NULL)`).run(agentId);
1936
-
1937
- coordLog(`channel/deregister: ${agentId} (rows: ${result.changes})`);
1938
- eventBus?.emit('session.closed', { agentId, channelId: '' });
1939
- return reply.send({ ok: true });
1940
- });
1941
-
1942
- /**
1943
- * Deliver a message to a worker's channel HTTP endpoint.
1944
- * Returns { delivered, error? }. On connection failure, marks session dead.
1945
- */
1946
- async function deliverToChannel(
1947
- agentId: string, channelUrl: string, content: string, meta?: Record<string, string>
1948
- ): Promise<{ delivered: boolean; error?: string }> {
1949
- channelMetrics.attempts++;
1950
- try {
1951
- const res = await fetch(`${channelUrl}/push`, {
1952
- method: 'POST',
1953
- headers: { 'Content-Type': 'application/json' },
1954
- body: JSON.stringify({ content, meta: meta ?? {} }),
1955
- signal: AbortSignal.timeout(5000),
1956
- });
1957
- if (!res.ok) {
1958
- channelMetrics.failed_http++;
1959
- return { delivered: false, error: `channel returned ${res.status}` };
1960
- }
1961
- channelMetrics.delivered++;
1962
- return { delivered: true };
1963
- } catch (err) {
1964
- // Connection refused / timeout worker process is dead, mark session disconnected
1965
- channelMetrics.failed_unreachable++;
1966
- channelMetrics.session_disconnects++;
1967
- db.prepare(
1968
- `UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`
1969
- ).run(agentId);
1970
- const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
1971
- coordLog(`channel/deliver FAILED → ${agent?.name ?? agentId}: ${err instanceof Error ? err.message : err} — session marked disconnected`);
1972
- return { delivered: false, error: `worker unreachable: ${err instanceof Error ? err.message : err}` };
1973
- }
1974
- }
1975
-
1976
- /** POST /channel/push Push a message to an agent. Tries live delivery first, falls back to mailbox queue.
1977
- *
1978
- * Two addressing modes:
1979
- * - {agentId, message} — direct UUID
1980
- * - {role, workspace, message} — server resolves to most-recently-seen alive agent
1981
- * matching role+workspace. Used by workers to notify
1982
- * coordinator (whose UUID changes across restarts).
1983
- */
1984
- app.post('/channel/push', async (request, reply) => {
1985
- const parsed = channelPushSchema.safeParse(request.body);
1986
- if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
1987
- const { message } = parsed.data;
1988
- let { agentId } = parsed.data;
1989
-
1990
- // Role-based addressing — resolve to a concrete agentId
1991
- if (!agentId && parsed.data.role && parsed.data.workspace) {
1992
- const resolved = db.prepare(
1993
- `SELECT id FROM coord_agents
1994
- WHERE role = ? AND workspace = ? AND status != 'dead'
1995
- ORDER BY last_seen DESC
1996
- LIMIT 1`
1997
- ).get(parsed.data.role, parsed.data.workspace) as { id: string } | undefined;
1998
- if (!resolved) {
1999
- return reply.status(404).send({
2000
- error: `No alive agent found for role='${parsed.data.role}' workspace='${parsed.data.workspace}'`,
2001
- });
2002
- }
2003
- agentId = resolved.id;
2004
- }
2005
-
2006
- // Type narrowing — Zod refine guarantees agentId is set by this point,
2007
- // but TypeScript can't see through the refine. This guard is unreachable
2008
- // in practice (would have 400'd earlier).
2009
- if (!agentId) return reply.status(400).send({ error: 'Internal: agentId resolution failed' });
2010
-
2011
- const agent = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId) as { name: string; workspace: string | null } | undefined;
2012
- if (!agent) return reply.status(404).send({ error: 'Agent not found' });
2013
-
2014
- // Circuit breaker: refuse push to open-circuit workers
2015
- if (!isAvailable(db, agentId)) {
2016
- return reply.status(423).send({ error: 'circuit_open', reason: 'Worker circuit is open too many consecutive failures. Try again after 30s or after a successful assignment.' });
2017
- }
2018
-
2019
- // Try live channel delivery first
2020
- const session = db.prepare(
2021
- `SELECT agent_id, channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`
2022
- ).get(agentId) as { agent_id: string; channel_id: string } | undefined;
2023
-
2024
- if (session) {
2025
- const { delivered } = await deliverToChannel(
2026
- agentId, session.channel_id, message,
2027
- { source: 'coordinator', agent: agent.name }
2028
- );
2029
-
2030
- if (delivered) {
2031
- db.prepare(
2032
- `UPDATE coord_channel_sessions SET last_push_at = datetime('now'), push_count = push_count + 1 WHERE agent_id = ?`
2033
- ).run(agentId);
2034
- db.prepare(
2035
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_push', ?)`
2036
- ).run(agentId, message.slice(0, 500));
2037
- coordLog(`channel/push ${agent.name}: ${message.slice(0, 80)}`);
2038
- return reply.send({ ok: true, delivered: true, channelId: session.channel_id });
2039
- }
2040
- // Live delivery failed fall through to mailbox
2041
- channelMetrics.fallback_mailbox++;
2042
- } else {
2043
- // No connected session push went straight to mailbox
2044
- channelMetrics.no_session++;
2045
- }
2046
-
2047
- // Queue to mailbox (delivered on next /next poll)
2048
- db.prepare(
2049
- `INSERT INTO coord_mailbox (worker_name, workspace, message, source) VALUES (?, ?, ?, 'coordinator')`
2050
- ).run(agent.name, agent.workspace, message);
2051
- db.prepare(
2052
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'mailbox_queued', ?)`
2053
- ).run(agentId, `queued for ${agent.name}: ${message.slice(0, 200)}`);
2054
- coordLog(`mailbox/queue ${agent.name}: ${message.slice(0, 80)} (live delivery unavailable)`);
2055
- return reply.send({ ok: true, delivered: false, queued: true, hint: 'Message queued in mailbox — will be delivered on next /next poll' });
2056
- });
2057
-
2058
- /** GET /channel/sessions — List all active channel sessions with agent names. */
2059
- app.get('/channel/sessions', async (_request, reply) => {
2060
- const sessions = db.prepare(`
2061
- SELECT cs.agent_id, a.name AS agent_name, cs.channel_id,
2062
- cs.connected_at, cs.last_push_at, cs.push_count, cs.status
2063
- FROM coord_channel_sessions cs
2064
- JOIN coord_agents a ON a.id = cs.agent_id
2065
- WHERE cs.status = 'connected'
2066
- ORDER BY cs.connected_at DESC
2067
- `).all();
2068
-
2069
- return reply.send({ sessions });
2070
- });
2071
-
2072
- /** POST /channel/probe — Probe all connected channel sessions, mark dead ones as disconnected. */
2073
- app.post('/channel/probe', async (_request, reply) => {
2074
- const sessions = db.prepare(
2075
- `SELECT cs.agent_id, a.name AS agent_name, cs.channel_id
2076
- FROM coord_channel_sessions cs
2077
- JOIN coord_agents a ON a.id = cs.agent_id
2078
- WHERE cs.status = 'connected'`
2079
- ).all() as Array<{ agent_id: string; agent_name: string; channel_id: string }>;
2080
-
2081
- const results: Array<{ agent: string; alive: boolean; error?: string }> = [];
2082
-
2083
- for (const session of sessions) {
2084
- try {
2085
- const res = await fetch(`${session.channel_id}/health`, {
2086
- signal: AbortSignal.timeout(3000),
2087
- });
2088
- if (res.ok) {
2089
- results.push({ agent: session.agent_name, alive: true });
2090
- } else {
2091
- db.prepare(`UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`).run(session.agent_id);
2092
- results.push({ agent: session.agent_name, alive: false, error: `health returned ${res.status}` });
2093
- }
2094
- } catch (err) {
2095
- db.prepare(`UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`).run(session.agent_id);
2096
- results.push({ agent: session.agent_name, alive: false, error: err instanceof Error ? err.message : String(err) });
2097
- }
2098
- }
2099
-
2100
- const alive = results.filter(r => r.alive).length;
2101
- const dead = results.filter(r => !r.alive).length;
2102
- if (dead > 0) coordLog(`channel/probe: ${alive} alive, ${dead} dead — dead sessions marked disconnected`);
2103
-
2104
- return reply.send({ probed: results.length, alive, dead, results });
2105
- });
2106
-
2107
- /**
2108
- * GET /telemetry/channels — Channel push delivery telemetry.
2109
- *
2110
- * Counters reset on coordinator restart (in-process). Use this to answer:
2111
- * "Are channels reliable enough to depend on, or do we need a polling fallback?"
2112
- *
2113
- * Response shape:
2114
- * {
2115
- * since: ISO timestamp of when counters started,
2116
- * uptime_seconds: number,
2117
- * attempts, delivered, failed_http, failed_unreachable,
2118
- * no_session, fallback_mailbox, session_disconnects: number,
2119
- * delivery_rate: 0..1 (delivered / attempts) or null if zero attempts,
2120
- * per_agent: [{ agent_name, push_count, last_push_at, status }]
2121
- * }
2122
- */
2123
- app.get('/telemetry/channels', async (_request, reply) => {
2124
- const perAgent = db.prepare(`
2125
- SELECT a.name AS agent_name, cs.push_count, cs.last_push_at, cs.status,
2126
- cs.connected_at
2127
- FROM coord_channel_sessions cs
2128
- JOIN coord_agents a ON a.id = cs.agent_id
2129
- ORDER BY cs.push_count DESC, cs.connected_at DESC
2130
- `).all();
2131
-
2132
- const deliveryRate = channelMetrics.attempts > 0
2133
- ? channelMetrics.delivered / channelMetrics.attempts
2134
- : null;
2135
-
2136
- return reply.send({
2137
- since: new Date(channelMetrics.started_at).toISOString(),
2138
- uptime_seconds: Math.round((Date.now() - channelMetrics.started_at) / 1000),
2139
- attempts: channelMetrics.attempts,
2140
- delivered: channelMetrics.delivered,
2141
- failed_http: channelMetrics.failed_http,
2142
- failed_unreachable: channelMetrics.failed_unreachable,
2143
- no_session: channelMetrics.no_session,
2144
- fallback_mailbox: channelMetrics.fallback_mailbox,
2145
- session_disconnects: channelMetrics.session_disconnects,
2146
- delivery_rate: deliveryRate,
2147
- per_agent: perAgent,
2148
- });
2149
- });
2150
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * HTTP routes for the coordination module.
5
+ * Ported from AgentSynapse packages/coordinator/src/routes/*.ts into a single file.
6
+ * All tables use coord_ prefix to avoid collision with AWM core tables.
7
+ */
8
+
9
+ import type { FastifyInstance } from 'fastify';
10
+ import type Database from 'better-sqlite3';
11
+ import type { EngramStore } from '../storage/sqlite.js';
12
+ import { randomUUID } from 'node:crypto';
13
+ import {
14
+ checkinSchema, checkoutSchema, pulseSchema, nextSchema,
15
+ assignCreateSchema, assignmentQuerySchema, assignmentClaimSchema, assignmentUpdateSchema, assignmentIdParamSchema, assignmentsListSchema, reassignSchema,
16
+ lockAcquireSchema, lockReleaseSchema,
17
+ commandCreateSchema, commandWaitQuerySchema,
18
+ findingCreateSchema, findingsQuerySchema, findingIdParamSchema, findingUpdateSchema,
19
+ decisionsQuerySchema, decisionCreateSchema,
20
+ eventsQuerySchema, staleQuerySchema, workersQuerySchema,
21
+ agentIdParamSchema, timelineQuerySchema,
22
+ channelRegisterSchema, channelDeregisterSchema, channelPushSchema,
23
+ } from './schemas.js';
24
+ import { detectStale, cleanupStale, retryOrFailAssignment } from './stale.js';
25
+ import { classifyFailure, FailureMode } from './failure-modes.js';
26
+ import { recordSuccess, recordFailure as circuitRecordFailure, isAvailable } from './circuit-breaker.js';
27
+
28
+ /** Pretty timestamp for coordination logs. */
29
+ function ts(): string {
30
+ return new Date().toLocaleTimeString('en-GB', { hour12: false });
31
+ }
32
+
33
+ /** Log a coordination event in human-readable format. */
34
+ function coordLog(msg: string): void {
35
+ console.log(`${ts()} [coord] ${msg}`);
36
+ }
37
+
38
+ /**
39
+ * In-process counters for channel push telemetry.
40
+ * Reset on coordinator restart — intended for short-window observability
41
+ * ("ship it, watch numbers for a day"). Persistent counters would need a
42
+ * coord_metrics table; deferred until we know what's worth keeping.
43
+ *
44
+ * Fields:
45
+ * attempts — every call to deliverToChannel (HTTP push to worker)
46
+ * delivered — fetch returned 2xx
47
+ * failed_http — fetch returned non-2xx (worker reachable but rejected)
48
+ * failed_unreachable — fetch threw (timeout, ECONNREFUSED, etc.)
49
+ * no_session — push intent existed but no connected session
50
+ * fallback_mailbox — push failed, message queued to mailbox instead
51
+ * session_disconnects — session marked 'disconnected' after delivery failure
52
+ */
53
+ interface ChannelMetrics {
54
+ attempts: number;
55
+ delivered: number;
56
+ failed_http: number;
57
+ failed_unreachable: number;
58
+ no_session: number;
59
+ fallback_mailbox: number;
60
+ session_disconnects: number;
61
+ started_at: number;
62
+ }
63
+
64
+ function createChannelMetrics(): ChannelMetrics {
65
+ return {
66
+ attempts: 0,
67
+ delivered: 0,
68
+ failed_http: 0,
69
+ failed_unreachable: 0,
70
+ no_session: 0,
71
+ fallback_mailbox: 0,
72
+ session_disconnects: 0,
73
+ started_at: Date.now(),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Optional session-token check.
79
+ * If X-Session-Token header is present and doesn't match the stored token → returns false (caller should 403).
80
+ * If header is absent, or no token stored (old agent row) → returns true (pass through).
81
+ */
82
+ function sessionTokenOk(db: Database.Database, agentId: string, req: import('fastify').FastifyRequest): boolean {
83
+ const provided = req.headers['x-session-token'];
84
+ if (!provided) {
85
+ // D2 (2026-07-30): AWM_COORD_REQUIRE_TOKENS=1 closes the omit-the-header
86
+ // bypass. Default stays backward-compatible (registered workers that never
87
+ // send tokens keep working); the network perimeter is the bind+API-key gate.
88
+ return process.env.AWM_COORD_REQUIRE_TOKENS !== '1';
89
+ }
90
+ const row = db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(agentId) as { session_token: string | null } | undefined;
91
+ if (!row || !row.session_token) return true; // not found or no token stored backward compat
92
+ return row.session_token === provided;
93
+ }
94
+
95
+ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Database, store?: EngramStore, eventBus?: import('./events.js').CoordinationEventBus): void {
96
+ // Channel push telemetry — process-scoped counters. See ChannelMetrics docs above.
97
+ const channelMetrics = createChannelMetrics();
98
+
99
+
100
+ // Request logging one line per request with method, url, status, response time
101
+ app.addHook('onRequest', async (request) => {
102
+ (request as any)._startTime = Date.now();
103
+ });
104
+ app.addHook('onResponse', async (request, reply) => {
105
+ const ms = Date.now() - ((request as any)._startTime ?? Date.now());
106
+ // Skip noisy polling endpoints at 2xx to reduce log spam
107
+ const isPolling = (request.url === '/next' || request.url === '/pulse' || request.url === '/health') && reply.statusCode < 300;
108
+ if (!isPolling) {
109
+ coordLog(`${request.method} ${request.url} ${reply.statusCode} ${ms}ms`);
110
+ }
111
+ });
112
+
113
+ // Pulse coalescing skip DB write if last pulse was <10s ago
114
+ const PULSE_COALESCE_MS = 10_000;
115
+ const lastPulseTime = new Map<string, number>();
116
+
117
+ // Rate limiting — 300 requests/minute per agent (sliding window)
118
+ // Hive agents poll frequently + synapse-push polls /events every 2s
119
+ const RATE_LIMIT = 300;
120
+ const RATE_WINDOW_MS = 60_000;
121
+ const rateBuckets = new Map<string, number[]>();
122
+
123
+ // Cleanup stale buckets every 5 minutes
124
+ setInterval(() => {
125
+ const cutoff = Date.now() - RATE_WINDOW_MS;
126
+ for (const [key, timestamps] of rateBuckets) {
127
+ const fresh = timestamps.filter(t => t > cutoff);
128
+ if (fresh.length === 0) rateBuckets.delete(key);
129
+ else rateBuckets.set(key, fresh);
130
+ }
131
+ }, 300_000).unref();
132
+
133
+ app.addHook('preHandler', async (request, reply) => {
134
+ if (request.url === '/health') return; // exempt
135
+
136
+ // Identify agent by name from body or query, or agentId
137
+ const body = request.body as Record<string, unknown> | undefined;
138
+ const query = request.query as Record<string, unknown> | undefined;
139
+ const key = (body?.name ?? body?.agentId ?? query?.agentId ?? query?.name ?? request.ip) as string;
140
+ if (!key) return;
141
+
142
+ const now = Date.now();
143
+ const cutoff = now - RATE_WINDOW_MS;
144
+ const timestamps = rateBuckets.get(key) ?? [];
145
+ const recent = timestamps.filter(t => t > cutoff);
146
+ recent.push(now);
147
+ rateBuckets.set(key, recent);
148
+
149
+ if (recent.length > RATE_LIMIT) {
150
+ return reply.code(429).send({ error: `rate limit exceeded — max ${RATE_LIMIT} requests/minute` });
151
+ }
152
+ });
153
+
154
+ // ─── Checkin ────────────────────────────────────────────────────
155
+
156
+ app.post('/checkin', async (req, reply) => {
157
+ const parsed = checkinSchema.safeParse(req.body);
158
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
159
+ const { name, role, pid, metadata, capabilities, workspace, channelUrl } = parsed.data;
160
+ const capsJson = capabilities ? JSON.stringify(capabilities) : null;
161
+
162
+ // Look up ANY existing agent with same name+workspace including dead ones (upsert)
163
+ // Falls back to name-only to handle workspace changes between sessions
164
+ let existing = workspace
165
+ ? db.prepare(
166
+ `SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`
167
+ ).get(name, workspace) as { id: string; status: string } | undefined
168
+ : db.prepare(
169
+ `SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`
170
+ ).get(name) as { id: string; status: string } | undefined;
171
+
172
+ if (!existing) {
173
+ existing = db.prepare(
174
+ `SELECT id, status FROM coord_agents WHERE name = ? ORDER BY last_seen DESC LIMIT 1`
175
+ ).get(name) as { id: string; status: string } | undefined;
176
+ }
177
+
178
+ if (existing) {
179
+ const wasDead = existing.status === 'dead';
180
+ // Issue a fresh token on reconnect; reuse existing token for live heartbeats
181
+ const sessionToken = wasDead ? randomUUID() : (
182
+ (db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(existing.id) as { session_token: string | null }).session_token ?? randomUUID()
183
+ );
184
+ // role IS updated on every checkin — agents know their own role and
185
+ // re-registrations may correct stale role values (e.g., when an old
186
+ // coord_agents row was inserted with role='orchestrator' before the
187
+ // 'coordinator' role was canonical, or when the channel-server's
188
+ // hardcoded role='worker' overwrote a real role).
189
+ db.prepare(
190
+ `UPDATE coord_agents SET last_seen = datetime('now'), status = CASE WHEN status = 'dead' THEN 'idle' ELSE status END, role = ?, pid = COALESCE(?, pid), capabilities = COALESCE(?, capabilities), workspace = COALESCE(?, workspace), session_token = ? WHERE id = ?`
191
+ ).run(role, pid ?? null, capsJson, workspace ?? null, sessionToken, existing.id);
192
+
193
+ const eventType = wasDead ? 'reconnected' : 'heartbeat';
194
+ const detail = wasDead ? `${name} reconnected (was dead)` : `heartbeat from ${name}`;
195
+ db.prepare(
196
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`
197
+ ).run(existing.id, eventType, detail);
198
+
199
+ if (wasDead) coordLog(`${name} reconnected (reusing UUID ${existing.id.slice(0, 8)})`);
200
+ // Auto-register channel session if channelUrl provided
201
+ if (channelUrl) {
202
+ db.prepare(`
203
+ INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
204
+ VALUES (?, ?, datetime('now'), 'connected')
205
+ ON CONFLICT(agent_id) DO UPDATE SET
206
+ channel_id = excluded.channel_id,
207
+ connected_at = datetime('now'),
208
+ status = 'connected',
209
+ push_count = 0,
210
+ last_push_at = NULL
211
+ `).run(existing.id, channelUrl);
212
+ coordLog(`channel auto-registered: ${name} (${existing.id.slice(0, 8)}) → ${channelUrl}`);
213
+ }
214
+ const action = wasDead ? 'reconnected' : 'heartbeat';
215
+ const status = wasDead ? 'idle' : existing.status;
216
+ return reply.send({ agentId: existing.id, sessionToken, action, status, workspace });
217
+ }
218
+
219
+ const id = randomUUID();
220
+ const sessionToken = randomUUID();
221
+ db.prepare(
222
+ `INSERT INTO coord_agents (id, name, role, pid, status, metadata, capabilities, workspace, session_token) VALUES (?, ?, ?, ?, 'idle', ?, ?, ?, ?)`
223
+ ).run(id, name, role ?? 'worker', pid ?? null, metadata ? JSON.stringify(metadata) : null, capsJson, workspace ?? null, sessionToken);
224
+
225
+ db.prepare(
226
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`
227
+ ).run(id, `${name} joined as ${role ?? 'worker'}${workspace ? ' [' + workspace + ']' : ''}${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
228
+
229
+ // Auto-register channel session if channelUrl provided
230
+ if (channelUrl) {
231
+ db.prepare(`
232
+ INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
233
+ VALUES (?, ?, datetime('now'), 'connected')
234
+ ON CONFLICT(agent_id) DO UPDATE SET
235
+ channel_id = excluded.channel_id,
236
+ connected_at = datetime('now'),
237
+ status = 'connected',
238
+ push_count = 0,
239
+ last_push_at = NULL
240
+ `).run(id, channelUrl);
241
+ coordLog(`channel auto-registered: ${name} (${id.slice(0, 8)}) ${channelUrl}`);
242
+ }
243
+
244
+ coordLog(`${name} registered (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
245
+ eventBus?.emit('agent.checkin', { agentId: id, name, role: role ?? 'worker', workspace: workspace ?? undefined });
246
+ return reply.code(201).send({ agentId: id, sessionToken, action: 'registered', status: 'idle', workspace });
247
+ });
248
+
249
+ // ─── Shutdown (graceful coordination teardown) ─────────────────
250
+
251
+ app.post('/shutdown', async (_req, reply) => {
252
+ // Mark all live agents as dead
253
+ const alive = db.prepare(
254
+ `SELECT id, name FROM coord_agents WHERE status != 'dead'`
255
+ ).all() as Array<{ id: string; name: string }>;
256
+
257
+ const shutdownTx = db.transaction(() => {
258
+ for (const agent of alive) {
259
+ db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(agent.id);
260
+ db.prepare(`UPDATE coord_agents SET status = 'dead', current_task = NULL WHERE id = ?`).run(agent.id);
261
+ db.prepare(
262
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'shutdown', 'graceful shutdown')`
263
+ ).run(agent.id);
264
+ }
265
+ });
266
+ shutdownTx();
267
+
268
+ // Flush WAL before caller terminates the process
269
+ try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch { /* non-fatal if DB is closing */ }
270
+
271
+ coordLog(`Graceful shutdown: ${alive.length} agent(s) marked offline`);
272
+ return reply.send({ ok: true, agents_marked_offline: alive.length });
273
+ });
274
+
275
+ app.post('/checkout', async (req, reply) => {
276
+ const parsed = checkoutSchema.safeParse(req.body);
277
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
278
+ const { agentId } = parsed.data;
279
+
280
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
281
+
282
+ // Atomic transaction: delete locks + channel session + update agent + event
283
+ const checkoutTx = db.transaction(() => {
284
+ db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(agentId);
285
+ db.prepare(`DELETE FROM coord_channel_sessions WHERE agent_id = ?`).run(agentId);
286
+ db.prepare(
287
+ `UPDATE coord_agents SET status = 'dead', last_seen = datetime('now') WHERE id = ?`
288
+ ).run(agentId);
289
+ db.prepare(
290
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'checkout', 'agent signed off')`
291
+ ).run(agentId);
292
+ });
293
+ checkoutTx();
294
+
295
+ // Look up agent name for logging (outside tx — read-only)
296
+ const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
297
+ coordLog(`${agent?.name ?? agentId} checked out`);
298
+ eventBus?.emit('agent.checkout', { agentId, name: agent?.name ?? agentId });
299
+ return reply.send({ ok: true });
300
+ });
301
+
302
+ // ─── Pulse (lightweight heartbeat — no event row) ──────────────
303
+
304
+ app.patch('/pulse', async (req, reply) => {
305
+ const parsed = pulseSchema.safeParse(req.body);
306
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
307
+ const { agentId } = parsed.data;
308
+
309
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
310
+
311
+ // Coalesce: skip DB write if last pulse was <10s ago
312
+ const now = Date.now();
313
+ const lastTime = lastPulseTime.get(agentId) ?? 0;
314
+ if (now - lastTime < PULSE_COALESCE_MS) {
315
+ return reply.send({ ok: true, coalesced: true });
316
+ }
317
+
318
+ lastPulseTime.set(agentId, now);
319
+ db.prepare(`UPDATE coord_agents SET last_seen = datetime('now') WHERE id = ?`).run(agentId);
320
+ return reply.send({ ok: true });
321
+ });
322
+
323
+ // ─── Next (combined checkin + commands + assignment poll) ───────
324
+
325
+ app.post('/next', async (req, reply) => {
326
+ const parsed = nextSchema.safeParse(req.body);
327
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
328
+ const { name, workspace, role, capabilities, channelUrl } = parsed.data;
329
+ const capsJson = capabilities ? JSON.stringify(capabilities) : null;
330
+
331
+ // Step 1: Upsert agent (checkin / heartbeat) including dead agents (reuse UUID)
332
+ // Try exact name+workspace match first, then fall back to name-only to handle
333
+ // workspace changes between sessions (prevents orphaned assignments on old UUID)
334
+ let existing = workspace
335
+ ? db.prepare(
336
+ `SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`
337
+ ).get(name, workspace) as { id: string; status: string } | undefined
338
+ : db.prepare(
339
+ `SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`
340
+ ).get(name) as { id: string; status: string } | undefined;
341
+
342
+ // Fallback: name-only lookup if exact match failed (handles workspace change, e.g. NULL→PERSONAL)
343
+ if (!existing) {
344
+ existing = db.prepare(
345
+ `SELECT id, status FROM coord_agents WHERE name = ? ORDER BY last_seen DESC LIMIT 1`
346
+ ).get(name) as { id: string; status: string } | undefined;
347
+ }
348
+
349
+ let agentId: string;
350
+ let sessionToken: string;
351
+ if (existing) {
352
+ agentId = existing.id;
353
+ const wasDead = existing.status === 'dead';
354
+ // Fresh token on reconnect; reuse existing on heartbeat
355
+ const existingToken = (db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(agentId) as { session_token: string | null }).session_token;
356
+ sessionToken = wasDead ? randomUUID() : (existingToken ?? randomUUID());
357
+ db.prepare(
358
+ `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 = ?`
359
+ ).run(capsJson, workspace ?? null, sessionToken, agentId);
360
+ const eventType = wasDead ? 'reconnected' : 'heartbeat';
361
+ const detail = wasDead ? `${name} reconnected via /next` : `heartbeat from ${name}`;
362
+ db.prepare(
363
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`
364
+ ).run(agentId, eventType, detail);
365
+ if (wasDead) coordLog(`${name} reconnected via /next (reusing UUID ${agentId.slice(0, 8)})`);
366
+ } else {
367
+ agentId = randomUUID();
368
+ sessionToken = randomUUID();
369
+ db.prepare(
370
+ `INSERT INTO coord_agents (id, name, role, pid, status, metadata, capabilities, workspace, session_token) VALUES (?, ?, ?, NULL, 'idle', NULL, ?, ?, ?)`
371
+ ).run(agentId, name, role ?? 'worker', capsJson, workspace ?? null, sessionToken);
372
+ db.prepare(
373
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`
374
+ ).run(agentId, `${name} joined as ${role ?? 'worker'} via /next`);
375
+ coordLog(`${name} registered via /next (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
376
+ }
377
+
378
+ // Auto-register channel session if channelUrl provided
379
+ if (channelUrl) {
380
+ db.prepare(`
381
+ INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
382
+ VALUES (?, ?, datetime('now'), 'connected')
383
+ ON CONFLICT(agent_id) DO UPDATE SET
384
+ channel_id = excluded.channel_id,
385
+ connected_at = datetime('now'),
386
+ status = 'connected',
387
+ push_count = 0,
388
+ last_push_at = NULL
389
+ `).run(agentId, channelUrl);
390
+ coordLog(`channel auto-registered via /next: ${name} (${agentId.slice(0, 8)}) → ${channelUrl}`);
391
+ }
392
+
393
+ // Step 2: Get active commands
394
+ const activeCommands = workspace
395
+ ? db.prepare(
396
+ `SELECT id, command, reason, issued_by, issued_at, workspace
397
+ FROM coord_commands WHERE cleared_at IS NULL AND (workspace = ? OR workspace IS NULL)
398
+ ORDER BY issued_at DESC`
399
+ ).all(workspace) as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>
400
+ : db.prepare(
401
+ `SELECT id, command, reason, issued_by, issued_at, workspace
402
+ FROM coord_commands WHERE cleared_at IS NULL
403
+ ORDER BY issued_at DESC`
404
+ ).all() as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>;
405
+
406
+ // Step 3: Get or auto-claim assignment
407
+ let assignment = db.prepare(
408
+ `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
409
+ ).get(agentId) as Record<string, unknown> | undefined;
410
+
411
+ // Cross-UUID fallback: check if this agent name has assignments under a different UUID
412
+ // (happens when POST /assign resolved worker_name to a stale/alternate UUID)
413
+ if (!assignment) {
414
+ const altIds = db.prepare(
415
+ `SELECT id FROM coord_agents WHERE name = ? AND id != ? AND status != 'dead'`
416
+ ).all(name, agentId) as Array<{ id: string }>;
417
+
418
+ for (const alt of altIds) {
419
+ const altActive = db.prepare(
420
+ `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
421
+ ).get(alt.id) as Record<string, unknown> | undefined;
422
+ if (altActive) {
423
+ // Migrate assignment to the current agent UUID
424
+ db.prepare(`UPDATE coord_assignments SET agent_id = ? WHERE id = ?`).run(agentId, altActive.id as string);
425
+ db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`).run(altActive.id as string, agentId);
426
+ altActive.agent_id = agentId;
427
+ coordLog(`assignment ${(altActive.id as string).slice(0, 8)} migrated from alt UUID ${alt.id.slice(0, 8)} to ${agentId.slice(0, 8)} (same agent: ${name})`);
428
+ assignment = altActive;
429
+ break;
430
+ }
431
+ }
432
+ }
433
+
434
+ if (!assignment) {
435
+ const agentWorkspace = workspace ?? null;
436
+ // Priority-ordered dispatch: higher priority first, then FIFO.
437
+ // Skip assignments blocked by incomplete dependencies.
438
+ const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
439
+
440
+ // First, check for tasks reserved specifically for this agent
441
+ const reserved = db.prepare(
442
+ `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id = ? ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
443
+ ).get(agentId) as { id: string } | undefined;
444
+
445
+ // Then fall back to truly unassigned tasks (agent_id IS NULL)
446
+ const pending = reserved ?? (agentWorkspace
447
+ ? db.prepare(
448
+ `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`
449
+ ).get(agentWorkspace) as { id: string } | undefined
450
+ : db.prepare(
451
+ `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id IS NULL ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
452
+ ).get() as { id: string } | undefined);
453
+
454
+ // Circuit breaker: refuse assignment if worker is in open state
455
+ if (pending && !isAvailable(db, agentId)) {
456
+ return reply.code(423).send({ status: 'idle', assignment: null, circuit_open: true, reason: 'circuit_open' });
457
+ }
458
+
459
+ if (pending) {
460
+ const claimed = db.prepare(
461
+ `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`
462
+ ).run(agentId, pending.id);
463
+
464
+ if (claimed.changes > 0) {
465
+ db.prepare(
466
+ `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
467
+ ).run(pending.id, agentId);
468
+ db.prepare(
469
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_claimed', ?)`
470
+ ).run(agentId, `auto-claimed assignment ${pending.id} via /next`);
471
+ assignment = db.prepare(`SELECT * FROM coord_assignments WHERE id = ?`).get(pending.id) as Record<string, unknown> | undefined;
472
+ }
473
+ }
474
+ }
475
+
476
+ // If agent has an active assignment, ensure status is 'working'
477
+ if (assignment) {
478
+ db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ? AND status != 'working'`).run(assignment.id as string, agentId);
479
+ }
480
+
481
+ // Read current agent status after all mutations
482
+ const agentRow = db.prepare(`SELECT status FROM coord_agents WHERE id = ?`).get(agentId) as { status: string };
483
+
484
+ // Deliver queued mailbox messages (persistent messages that survived disconnects/restarts)
485
+ const mailbox = db.prepare(
486
+ `SELECT id, message, source, created_at FROM coord_mailbox
487
+ WHERE worker_name = ? AND delivered_at IS NULL
488
+ AND (workspace = ? OR workspace IS NULL)
489
+ ORDER BY created_at ASC LIMIT 10`
490
+ ).all(name, workspace ?? null) as Array<{ id: number; message: string; source: string; created_at: string }>;
491
+
492
+ if (mailbox.length > 0) {
493
+ const ids = mailbox.map(m => m.id);
494
+ db.prepare(
495
+ `UPDATE coord_mailbox SET delivered_at = datetime('now') WHERE id IN (${ids.map(() => '?').join(',')})`
496
+ ).run(...ids);
497
+ coordLog(`mailbox: delivered ${mailbox.length} queued message(s) to ${name}`);
498
+ }
499
+
500
+ return reply.send({
501
+ agentId,
502
+ sessionToken,
503
+ status: agentRow.status,
504
+ assignment: assignment ?? null,
505
+ commands: activeCommands,
506
+ mailbox: mailbox.length > 0 ? mailbox.map(m => ({ message: m.message, source: m.source, queued_at: m.created_at })) : undefined,
507
+ });
508
+ });
509
+
510
+ // ─── Assignments ────────────────────────────────────────────────
511
+
512
+ app.post('/assign', async (req, reply) => {
513
+ const parsed = assignCreateSchema.safeParse(req.body);
514
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
515
+ const { task, description, workspace, priority, blocked_by, worker_name, context } = parsed.data;
516
+ let { agentId } = parsed.data;
517
+
518
+ // Resolve worker_name agentId if agentId not provided
519
+ if (!agentId && worker_name) {
520
+ let found = workspace
521
+ ? db.prepare(
522
+ `SELECT id FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
523
+ ).get(worker_name, workspace) as { id: string } | undefined
524
+ : db.prepare(
525
+ `SELECT id FROM coord_agents WHERE name = ? AND workspace IS NULL AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
526
+ ).get(worker_name) as { id: string } | undefined;
527
+
528
+ // Fallback: name-only lookup (handles workspace changes)
529
+ if (!found) {
530
+ found = db.prepare(
531
+ `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
532
+ ).get(worker_name) as { id: string } | undefined;
533
+ }
534
+
535
+ if (!found) {
536
+ return reply.code(404).send({ error: `worker not found: ${worker_name}` });
537
+ }
538
+ agentId = found.id;
539
+ }
540
+
541
+ // Reject if agent already has an active assignment
542
+ if (agentId) {
543
+ const active = db.prepare(
544
+ `SELECT id, task FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') LIMIT 1`
545
+ ).get(agentId) as { id: string; task: string } | undefined;
546
+ if (active) {
547
+ return reply.code(409).send({ error: `agent already has active assignment: ${active.id}`, active_task: active.task });
548
+ }
549
+ }
550
+
551
+ const id = randomUUID();
552
+ let pushed = false;
553
+
554
+ // Atomic transaction: assignment insert + agent status + event + channel push
555
+ const assignTx = db.transaction(() => {
556
+ db.prepare(
557
+ `INSERT INTO coord_assignments (id, agent_id, task, description, status, priority, blocked_by, workspace, started_at, context) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
558
+ ).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);
559
+
560
+ if (agentId) {
561
+ db.prepare(
562
+ `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
563
+ ).run(id, agentId);
564
+ }
565
+
566
+ db.prepare(
567
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_created', ?)`
568
+ ).run(agentId ?? null, `task: ${task}`);
569
+
570
+ // Record channel push intent in the DB (stats + event)
571
+ if (agentId) {
572
+ const session = db.prepare(
573
+ `SELECT agent_id, channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`
574
+ ).get(agentId) as { agent_id: string; channel_id: string } | undefined;
575
+ if (session) {
576
+ // Record channel_push event so agent sees it on next poll/restore
577
+ const pushMsg = `NEW ASSIGNMENT: ${task}${description ? ' — ' + description.slice(0, 200) : ''}`;
578
+ db.prepare(
579
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_push', ?)`
580
+ ).run(agentId, pushMsg.slice(0, 500));
581
+ pushed = true;
582
+ }
583
+ }
584
+ });
585
+ assignTx();
586
+
587
+ // Actually deliver the push to the worker's channel HTTP endpoint (outside DB transaction)
588
+ let delivered = false;
589
+ if (pushed && agentId) {
590
+ const session = db.prepare(
591
+ `SELECT channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`
592
+ ).get(agentId) as { channel_id: string } | undefined;
593
+ if (session) {
594
+ const pushMsg = `NEW ASSIGNMENT: ${task}${description ? ' — ' + description.slice(0, 200) : ''}`;
595
+ const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
596
+ const result = await deliverToChannel(
597
+ agentId, session.channel_id, pushMsg,
598
+ { source: 'coordinator', agent: agent?.name ?? agentId, assignmentId: id }
599
+ );
600
+ delivered = result.delivered;
601
+ if (delivered) {
602
+ db.prepare(
603
+ `UPDATE coord_channel_sessions SET last_push_at = datetime('now'), push_count = push_count + 1 WHERE agent_id = ?`
604
+ ).run(agentId);
605
+ }
606
+ } else {
607
+ // Session disappeared between intent record and deliveryrace or rapid disconnect
608
+ channelMetrics.no_session++;
609
+ }
610
+ }
611
+
612
+ // Bridge context to AWM engrams (outside transaction — engram store has its own DB)
613
+ if (store && context) {
614
+ try {
615
+ const ctx = JSON.parse(context) as Record<string, unknown>;
616
+ const parts: string[] = [];
617
+ if (ctx.files) parts.push(`Files: ${JSON.stringify(ctx.files)}`);
618
+ if (ctx.references) parts.push(`References: ${JSON.stringify(ctx.references)}`);
619
+ if (ctx.decisions) parts.push(`Decisions: ${JSON.stringify(ctx.decisions)}`);
620
+ if (ctx.acceptance_criteria) parts.push(`Acceptance criteria: ${JSON.stringify(ctx.acceptance_criteria)}`);
621
+ // Include any remaining keys
622
+ for (const [k, v] of Object.entries(ctx)) {
623
+ if (!['files', 'references', 'decisions', 'acceptance_criteria'].includes(k) && v) {
624
+ parts.push(`${k}: ${JSON.stringify(v)}`);
625
+ }
626
+ }
627
+ if (parts.length > 0) {
628
+ store.createEngram({
629
+ agentId: agentId ?? 'coordinator',
630
+ concept: `Task context: ${task.slice(0, 80)}`,
631
+ content: parts.join('\n'),
632
+ tags: ['shared', 'context', `task/${id}`],
633
+ memoryClass: 'canonical',
634
+ });
635
+ }
636
+ } catch {
637
+ // Context is not valid JSON — skip engram bridge silently
638
+ }
639
+ }
640
+
641
+ // If push failed or no channel, queue to mailbox so worker gets it on next /next poll
642
+ let queued = false;
643
+ if (agentId && !delivered) {
644
+ const agent = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId) as { name: string; workspace: string | null } | undefined;
645
+ if (agent) {
646
+ const mailMsg = `NEW ASSIGNMENT [${id.slice(0, 8)}]: ${task.slice(0, 500)}`;
647
+ db.prepare(
648
+ `INSERT INTO coord_mailbox (worker_name, workspace, message, source) VALUES (?, ?, ?, 'coordinator')`
649
+ ).run(agent.name, agent.workspace, mailMsg);
650
+ queued = true;
651
+ coordLog(`mailbox/queue → ${agent.name}: assignment ${id.slice(0, 8)} (live push unavailable)`);
652
+ }
653
+ }
654
+
655
+ // Log assignment with agent name
656
+ if (agentId) {
657
+ const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
658
+ coordLog(`assigned → ${agent?.name ?? 'unknown'}: ${task.slice(0, 80)}${delivered ? ' (pushed+delivered)' : queued ? ' (queued to mailbox)' : ''}`);
659
+ } else {
660
+ coordLog(`assignment queued (pending): ${task.slice(0, 80)}`);
661
+ }
662
+ eventBus?.emit('assignment.created', { assignmentId: id, agentId: agentId ?? '', task, workspace: workspace ?? undefined });
663
+ return reply.code(201).send({ assignmentId: id, status: agentId ? 'assigned' : 'pending', pushed, delivered, queued });
664
+ });
665
+
666
+ app.get('/assignment', async (req, reply) => {
667
+ const q = assignmentQuerySchema.parse(req.query);
668
+ let agentId = (req.headers['x-agent-id'] as string | undefined) ?? q.agentId;
669
+
670
+ // Fallback: resolve agentId from name + workspace (with name-only fallback)
671
+ if (!agentId && q.name) {
672
+ let found = q.workspace
673
+ ? db.prepare(
674
+ `SELECT id FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead'`
675
+ ).get(q.name, q.workspace) as { id: string } | undefined
676
+ : db.prepare(
677
+ `SELECT id FROM coord_agents WHERE name = ? AND workspace IS NULL AND status != 'dead'`
678
+ ).get(q.name) as { id: string } | undefined;
679
+ if (!found) {
680
+ found = db.prepare(
681
+ `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
682
+ ).get(q.name) as { id: string } | undefined;
683
+ }
684
+ agentId = found?.id;
685
+ }
686
+
687
+ if (!agentId) {
688
+ return reply.send({ assignment: null });
689
+ }
690
+
691
+ const active = db.prepare(
692
+ `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
693
+ ).get(agentId);
694
+
695
+ if (active) return reply.send({ assignment: active });
696
+
697
+ // Cross-UUID fallback: if the agent has other UUIDs (e.g., from workspace changes or reconnects
698
+ // that created a new row), check those too. This fixes the case where POST /assign resolved
699
+ // worker_name to a different UUID than the one the worker is currently using.
700
+ const agentRow = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId) as { name: string; workspace: string | null } | undefined;
701
+ if (agentRow) {
702
+ const altIds = db.prepare(
703
+ `SELECT id FROM coord_agents WHERE name = ? AND id != ? AND status != 'dead'`
704
+ ).all(agentRow.name, agentId) as Array<{ id: string }>;
705
+
706
+ for (const alt of altIds) {
707
+ const altActive = db.prepare(
708
+ `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
709
+ ).get(alt.id) as Record<string, unknown> | undefined;
710
+ if (altActive) {
711
+ // Reassign to the current agent UUID so future lookups work directly
712
+ db.prepare(`UPDATE coord_assignments SET agent_id = ? WHERE id = ?`).run(agentId, altActive.id as string);
713
+ db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`).run(altActive.id as string, agentId);
714
+ altActive.agent_id = agentId;
715
+ coordLog(`assignment ${(altActive.id as string).slice(0, 8)} migrated from alt UUID ${alt.id.slice(0, 8)} to ${agentId.slice(0, 8)} (same agent: ${agentRow.name})`);
716
+ return reply.send({ assignment: altActive });
717
+ }
718
+ }
719
+ }
720
+
721
+ const agentWorkspace = agentRow?.workspace ?? null;
722
+
723
+ const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
724
+
725
+ // First, check for tasks reserved specifically for this agent
726
+ const reserved = db.prepare(
727
+ `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id = ? ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
728
+ ).get(agentId) as { id: string } | undefined;
729
+
730
+ // Then fall back to truly unassigned tasks (agent_id IS NULL)
731
+ const pending = reserved ?? (agentWorkspace
732
+ ? db.prepare(
733
+ `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`
734
+ ).get(agentWorkspace) as { id: string } | undefined
735
+ : db.prepare(
736
+ `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id IS NULL ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
737
+ ).get() as { id: string } | undefined);
738
+
739
+ // Circuit breaker: refuse assignment if worker is in open state
740
+ if (pending && !isAvailable(db, agentId)) {
741
+ return reply.code(423).send({ assignment: null, circuit_open: true, reason: 'circuit_open' });
742
+ }
743
+
744
+ if (pending) {
745
+ const claimed = db.prepare(
746
+ `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`
747
+ ).run(agentId, pending.id);
748
+
749
+ if (claimed.changes > 0) {
750
+ db.prepare(
751
+ `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
752
+ ).run(pending.id, agentId);
753
+
754
+ db.prepare(
755
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_claimed', ?)`
756
+ ).run(agentId, `auto-claimed assignment ${pending.id}`);
757
+
758
+ const assignment = db.prepare(`SELECT * FROM coord_assignments WHERE id = ?`).get(pending.id);
759
+ return reply.send({ assignment });
760
+ }
761
+ }
762
+
763
+ const busyCount = (db.prepare(
764
+ `SELECT COUNT(*) as c FROM coord_agents WHERE status = 'working' AND last_seen > datetime('now', '-300 seconds')`
765
+ ).get() as { c: number }).c;
766
+
767
+ const retryAfter = busyCount > 0 ? 30 : 300;
768
+ return reply.send({ assignment: null, retry_after_seconds: retryAfter });
769
+ });
770
+
771
+ app.post('/assignment/:id/claim', async (req, reply) => {
772
+ const { id } = assignmentIdParamSchema.parse(req.params);
773
+ const parsed = assignmentClaimSchema.safeParse(req.body);
774
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
775
+ const { agentId } = parsed.data;
776
+
777
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
778
+
779
+ const result = db.prepare(
780
+ `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`
781
+ ).run(agentId, id);
782
+
783
+ if (result.changes === 0) {
784
+ return reply.code(409).send({ error: 'assignment not available (already claimed or missing)' });
785
+ }
786
+
787
+ db.prepare(
788
+ `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
789
+ ).run(id, agentId);
790
+
791
+ db.prepare(
792
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_claimed', ?)`
793
+ ).run(agentId, `claimed assignment ${id}`);
794
+
795
+ return reply.send({ ok: true, assignmentId: id });
796
+ });
797
+
798
+ const VALID_TRANSITIONS: Record<string, string[]> = {
799
+ assigned: ['in_progress', 'failed'],
800
+ in_progress: ['completed', 'failed', 'blocked'],
801
+ blocked: ['in_progress', 'failed'],
802
+ };
803
+
804
+ function handleAssignmentUpdate(id: string, status: string, result: string | undefined, commitSha: string | undefined): { error?: string } {
805
+ // Status transition validation
806
+ const current = db.prepare(`SELECT status FROM coord_assignments WHERE id = ?`).get(id) as { status: string } | undefined;
807
+ if (!current) return { error: 'assignment not found' };
808
+
809
+ const allowed = VALID_TRANSITIONS[current.status];
810
+ if (allowed && !allowed.includes(status)) {
811
+ return { error: `invalid transition: ${current.status} → ${status}. Valid: ${allowed.join(', ')}` };
812
+ }
813
+ if (!allowed && ['completed', 'failed'].includes(current.status)) {
814
+ return { error: `cannot update ${current.status} assignment` };
815
+ }
816
+
817
+ // Verification gate: completed status requires structured proof of work
818
+ if (status === 'completed') {
819
+ if (!result || result.trim().length < 20) {
820
+ return { error: 'completion requires a result summary minimum 20 characters describing what was done' };
821
+ }
822
+ // Must mention at least one of: commit/SHA, build, audit, test, verified, fix, created, updated, implemented
823
+ 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;
824
+ if (!actionWords.test(result)) {
825
+ return { error: 'completion result must describe the work done — include what was committed, built, tested, or verified' };
826
+ }
827
+ }
828
+
829
+ // Atomic transaction: assignment update + agent status + event
830
+ const updateTx = db.transaction(() => {
831
+ if (['completed', 'failed'].includes(status)) {
832
+ db.prepare(
833
+ `UPDATE coord_assignments SET status = ?, result = ?, commit_sha = ?, completed_at = datetime('now') WHERE id = ?`
834
+ ).run(status, result ?? null, commitSha ?? null, id);
835
+ } else {
836
+ db.prepare(
837
+ `UPDATE coord_assignments SET status = ?, result = ? WHERE id = ?`
838
+ ).run(status, result ?? null, id);
839
+ }
840
+
841
+ if (['completed', 'failed'].includes(status)) {
842
+ const assignment = db.prepare(`SELECT agent_id FROM coord_assignments WHERE id = ?`).get(id) as { agent_id: string } | undefined;
843
+ if (assignment?.agent_id) {
844
+ db.prepare(
845
+ `UPDATE coord_agents SET status = 'idle', current_task = NULL WHERE id = ?`
846
+ ).run(assignment.agent_id);
847
+ }
848
+ }
849
+
850
+ const eventDetail = ['completed', 'failed'].includes(status)
851
+ ? `${id} → ${status}${commitSha ? ' [' + commitSha + ']' : ''}: ${(result ?? '').slice(0, 300)}`
852
+ : `${id} → ${status}`;
853
+ db.prepare(
854
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES ((SELECT agent_id FROM coord_assignments WHERE id = ?), 'assignment_update', ?)`
855
+ ).run(id, eventDetail);
856
+ });
857
+ updateTx();
858
+
859
+ // Log completion/failure with agent name and task (outside tx read-only)
860
+ const assignInfo = db.prepare(
861
+ `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 = ?`
862
+ ).get(id) as { agent_id: string | null; task: string; agent_name: string | null } | undefined;
863
+ if (['completed', 'failed'].includes(status)) {
864
+ coordLog(`${assignInfo?.agent_name ?? 'unknown'} ${status}: ${assignInfo?.task?.slice(0, 80) ?? id}`);
865
+ }
866
+
867
+ // Circuit breaker: track success/failure per worker
868
+ if (assignInfo?.agent_id) {
869
+ if (status === 'completed') recordSuccess(db, assignInfo.agent_id);
870
+ else if (status === 'failed') circuitRecordFailure(db, assignInfo.agent_id);
871
+ }
872
+
873
+ // Emit events
874
+ eventBus?.emit('assignment.updated', { assignmentId: id, agentId: assignInfo?.agent_id ?? null, status, result });
875
+ if (status === 'completed') {
876
+ eventBus?.emit('assignment.completed', { assignmentId: id, agentId: assignInfo?.agent_id ?? null, result: result ?? null });
877
+ }
878
+
879
+ // Auto-unblock: when an assignment completes, unblock any assignments that depend on it
880
+ if (status === 'completed') {
881
+ const blocked = db.prepare(
882
+ `SELECT id, agent_id, task FROM coord_assignments WHERE blocked_by = ? AND status = 'blocked'`
883
+ ).all(id) as Array<{ id: string; agent_id: string | null; task: string }>;
884
+
885
+ if (blocked.length > 0) {
886
+ const unblockTx = db.transaction(() => {
887
+ for (const dep of blocked) {
888
+ db.prepare(
889
+ `UPDATE coord_assignments SET blocked_by = NULL, status = 'assigned' WHERE id = ?`
890
+ ).run(dep.id);
891
+ db.prepare(
892
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_unblocked', ?)`
893
+ ).run(dep.agent_id, `unblocked by completion of ${id}: ${dep.task.slice(0, 80)}`);
894
+ }
895
+ });
896
+ unblockTx();
897
+
898
+ for (const dep of blocked) {
899
+ coordLog(`auto-unblocked: ${dep.task.slice(0, 60)} (was blocked by ${id})`);
900
+ eventBus?.emit('assignment.updated', { assignmentId: dep.id, agentId: dep.agent_id, status: 'assigned', result: undefined });
901
+ }
902
+ }
903
+ }
904
+
905
+ return {};
906
+ }
907
+
908
+ app.get('/assignment/:id', async (req, reply) => {
909
+ const { id } = assignmentIdParamSchema.parse(req.params);
910
+ const assignment = db.prepare(
911
+ `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 = ?`
912
+ ).get(id);
913
+ if (!assignment) return reply.code(404).send({ error: 'assignment not found' });
914
+ return reply.send({ assignment });
915
+ });
916
+
917
+ // List assignments with optional filters and pagination
918
+ app.get('/assignments', async (req, reply) => {
919
+ const q = assignmentsListSchema.parse(req.query);
920
+ const conditions: string[] = [];
921
+ const params: unknown[] = [];
922
+
923
+ if (q.status) {
924
+ conditions.push('a.status = ?');
925
+ params.push(q.status);
926
+ }
927
+ if (q.workspace) {
928
+ conditions.push('(a.workspace = ? OR a.workspace IS NULL)');
929
+ params.push(q.workspace);
930
+ }
931
+ if (q.agent_id) {
932
+ conditions.push('a.agent_id = ?');
933
+ params.push(q.agent_id);
934
+ }
935
+
936
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
937
+
938
+ const total = (db.prepare(
939
+ `SELECT COUNT(*) AS count FROM coord_assignments a ${where}`
940
+ ).get(...params) as { count: number }).count;
941
+
942
+ const assignments = db.prepare(
943
+ `SELECT a.*, g.name AS agent_name,
944
+ CASE WHEN a.blocked_by IS NOT NULL AND a.blocked_by NOT IN (SELECT id FROM coord_assignments WHERE status = 'completed')
945
+ THEN 1 ELSE 0 END AS is_blocked
946
+ FROM coord_assignments a
947
+ LEFT JOIN coord_agents g ON a.agent_id = g.id
948
+ ${where}
949
+ ORDER BY a.priority DESC, a.created_at DESC
950
+ LIMIT ? OFFSET ?`
951
+ ).all(...params, q.limit, q.offset);
952
+
953
+ return reply.send({ assignments, total });
954
+ });
955
+
956
+ app.post('/reassign', async (req, reply) => {
957
+ const parsed = reassignSchema.safeParse(req.body);
958
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
959
+ const { assignmentId, target_worker_name } = parsed.data;
960
+ let { targetAgentId } = parsed.data;
961
+
962
+ // Verify assignment exists and is active
963
+ const assignment = db.prepare(
964
+ `SELECT id, agent_id, task, status FROM coord_assignments WHERE id = ?`
965
+ ).get(assignmentId) as { id: string; agent_id: string | null; task: string; status: string } | undefined;
966
+ if (!assignment) return reply.code(404).send({ error: 'assignment not found' });
967
+ if (['completed', 'failed'].includes(assignment.status)) {
968
+ return reply.code(400).send({ error: `cannot reassign ${assignment.status} assignment` });
969
+ }
970
+
971
+ // Resolve target_worker_name targetAgentId
972
+ if (!targetAgentId && target_worker_name) {
973
+ const found = db.prepare(
974
+ `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
975
+ ).get(target_worker_name) as { id: string } | undefined;
976
+ if (!found) return reply.code(404).send({ error: `target worker not found: ${target_worker_name}` });
977
+ targetAgentId = found.id;
978
+ }
979
+
980
+ // Verify targetAgentId exists
981
+ if (targetAgentId) {
982
+ const target = db.prepare(`SELECT id FROM coord_agents WHERE id = ?`).get(targetAgentId) as { id: string } | undefined;
983
+ if (!target) return reply.code(404).send({ error: 'target agent not found' });
984
+ }
985
+
986
+ // Release old agent: set idle, clear current_task, release locks
987
+ if (assignment.agent_id) {
988
+ db.prepare(
989
+ `UPDATE coord_agents SET status = 'idle', current_task = NULL WHERE id = ?`
990
+ ).run(assignment.agent_id);
991
+ db.prepare(
992
+ `DELETE FROM coord_locks WHERE agent_id = ?`
993
+ ).run(assignment.agent_id);
994
+ }
995
+
996
+ if (targetAgentId) {
997
+ // Reassign to target
998
+ db.prepare(
999
+ `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ?`
1000
+ ).run(targetAgentId, assignmentId);
1001
+ db.prepare(
1002
+ `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
1003
+ ).run(assignmentId, targetAgentId);
1004
+ } else {
1005
+ // No target — return to pending for auto-claim
1006
+ db.prepare(
1007
+ `UPDATE coord_assignments SET agent_id = NULL, status = 'pending', started_at = NULL WHERE id = ?`
1008
+ ).run(assignmentId);
1009
+ }
1010
+
1011
+ db.prepare(
1012
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'reassignment', ?)`
1013
+ ).run(assignment.agent_id ?? null, `${assignmentId} reassigned from ${assignment.agent_id ?? 'unassigned'} to ${targetAgentId ?? 'pending'}`);
1014
+
1015
+ coordLog(`reassign: ${assignment.task.slice(0, 60)} ${targetAgentId ?? 'pending'}`);
1016
+ return reply.send({ ok: true, assignmentId, newAgentId: targetAgentId ?? null, status: targetAgentId ? 'assigned' : 'pending' });
1017
+ });
1018
+
1019
+ app.post('/assignment/:id/update', async (req, reply) => {
1020
+ const { id } = assignmentIdParamSchema.parse(req.params);
1021
+ const parsed = assignmentUpdateSchema.safeParse(req.body);
1022
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1023
+ const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
1024
+ if (gate.error) return reply.code(400).send({ error: gate.error });
1025
+ return reply.send({ ok: true });
1026
+ });
1027
+
1028
+ app.patch('/assignment/:id', async (req, reply) => {
1029
+ const { id } = assignmentIdParamSchema.parse(req.params);
1030
+ const parsed = assignmentUpdateSchema.safeParse(req.body);
1031
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1032
+ const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
1033
+ if (gate.error) return reply.code(400).send({ error: gate.error });
1034
+ return reply.send({ ok: true });
1035
+ });
1036
+
1037
+ /** POST /assignment/:id/fail Worker voluntarily fails an assignment with retry logic.
1038
+ * Body: { result: string, mode?: FailureMode }
1039
+ * Returns: { outcome: 'retried' | 'failed', attempt_count, last_failure_mode }
1040
+ */
1041
+ app.post('/assignment/:id/fail', async (req, reply) => {
1042
+ const { id } = assignmentIdParamSchema.parse(req.params);
1043
+ const body = req.body as { result?: string; mode?: string } | undefined;
1044
+ const result = body?.result ?? 'worker-initiated failure';
1045
+ const mode = body?.mode as FailureMode | undefined;
1046
+
1047
+ const row = db.prepare(
1048
+ `SELECT id, agent_id, status FROM coord_assignments WHERE id = ?`
1049
+ ).get(id) as { id: string; agent_id: string | null; status: string } | undefined;
1050
+
1051
+ if (!row) return reply.code(404).send({ error: 'assignment not found' });
1052
+ if (['completed', 'failed'].includes(row.status)) {
1053
+ return reply.code(400).send({ error: `cannot fail a ${row.status} assignment` });
1054
+ }
1055
+
1056
+ const agentId = row.agent_id ?? 'unknown';
1057
+ const outcome = retryOrFailAssignment(db, id, agentId, result, mode);
1058
+
1059
+ // Circuit breaker: record failure on voluntary fail
1060
+ if (agentId !== 'unknown') circuitRecordFailure(db, agentId);
1061
+
1062
+ const updated = db.prepare(
1063
+ `SELECT attempt_count, last_failure_mode FROM coord_assignments WHERE id = ?`
1064
+ ).get(id) as { attempt_count: number; last_failure_mode: string | null } | undefined;
1065
+
1066
+ return reply.send({ ok: true, outcome, attempt_count: updated?.attempt_count ?? 0, last_failure_mode: updated?.last_failure_mode ?? null });
1067
+ });
1068
+
1069
+ app.put('/assignment/:id', async (req, reply) => {
1070
+ const { id } = assignmentIdParamSchema.parse(req.params);
1071
+ const parsed = assignmentUpdateSchema.safeParse(req.body);
1072
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1073
+ const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
1074
+ if (gate.error) return reply.code(400).send({ error: gate.error });
1075
+ return reply.send({ ok: true });
1076
+ });
1077
+
1078
+ // ─── Locks ──────────────────────────────────────────────────────
1079
+
1080
+ app.post('/lock', async (req, reply) => {
1081
+ const parsed = lockAcquireSchema.safeParse(req.body);
1082
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1083
+ const { agentId, filePath, reason } = parsed.data;
1084
+
1085
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1086
+
1087
+ const inserted = db.prepare(
1088
+ `INSERT OR IGNORE INTO coord_locks (file_path, agent_id, reason) VALUES (?, ?, ?)`
1089
+ ).run(filePath, agentId, reason ?? null);
1090
+
1091
+ if (inserted.changes > 0) {
1092
+ db.prepare(
1093
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'lock_acquired', ?)`
1094
+ ).run(agentId, filePath);
1095
+ return reply.send({ ok: true, action: 'acquired' });
1096
+ }
1097
+
1098
+ const existing = db.prepare(
1099
+ `SELECT agent_id FROM coord_locks WHERE file_path = ?`
1100
+ ).get(filePath) as { agent_id: string } | undefined;
1101
+
1102
+ if (existing?.agent_id === agentId) {
1103
+ db.prepare(`UPDATE coord_locks SET locked_at = datetime('now') WHERE file_path = ?`).run(filePath);
1104
+ return reply.send({ ok: true, action: 'refreshed' });
1105
+ }
1106
+
1107
+ return reply.code(409).send({
1108
+ error: 'file locked by another agent',
1109
+ lockedBy: existing?.agent_id,
1110
+ });
1111
+ });
1112
+
1113
+ app.delete('/lock', async (req, reply) => {
1114
+ const parsed = lockReleaseSchema.safeParse(req.body);
1115
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1116
+ const { agentId, filePath } = parsed.data;
1117
+
1118
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1119
+
1120
+ const result = db.prepare(
1121
+ `DELETE FROM coord_locks WHERE file_path = ? AND agent_id = ?`
1122
+ ).run(filePath, agentId);
1123
+
1124
+ if (result.changes === 0) {
1125
+ return reply.code(404).send({ error: 'lock not found or not owned by this agent' });
1126
+ }
1127
+
1128
+ db.prepare(
1129
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'lock_released', ?)`
1130
+ ).run(agentId, filePath);
1131
+
1132
+ return reply.send({ ok: true });
1133
+ });
1134
+
1135
+ app.get('/locks', async (_req, reply) => {
1136
+ const locks = db.prepare(
1137
+ `SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
1138
+ FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id
1139
+ ORDER BY l.locked_at DESC LIMIT 200`
1140
+ ).all();
1141
+
1142
+ return reply.send({ locks });
1143
+ });
1144
+
1145
+ // ─── Commands ───────────────────────────────────────────────────
1146
+
1147
+ app.post('/command', async (req, reply) => {
1148
+ const parsed = commandCreateSchema.safeParse(req.body);
1149
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1150
+ const { command, reason, issuedBy, workspace } = parsed.data;
1151
+
1152
+ if (command === 'RESUME') {
1153
+ if (workspace) {
1154
+ // Clear commands targeting this workspace AND global commands (workspace IS NULL).
1155
+ // Global commands (e.g. SHUTDOWN with no workspace) apply to all workspaces,
1156
+ // so RESUME for a workspace must also clear them — otherwise they persist forever.
1157
+ db.prepare(
1158
+ `UPDATE coord_commands SET cleared_at = datetime('now') WHERE cleared_at IS NULL AND (workspace = ? OR workspace IS NULL)`
1159
+ ).run(workspace);
1160
+ } else {
1161
+ db.prepare(
1162
+ `UPDATE coord_commands SET cleared_at = datetime('now') WHERE cleared_at IS NULL`
1163
+ ).run();
1164
+ }
1165
+
1166
+ db.prepare(
1167
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'command', ?)`
1168
+ ).run(issuedBy ?? null, `RESUME${workspace ? ' [' + workspace + ']' : ''} — commands cleared`);
1169
+
1170
+ return reply.send({ ok: true, command: 'RESUME', workspace, message: workspace ? `commands cleared for ${workspace}` : 'all active commands cleared' });
1171
+ }
1172
+
1173
+ db.prepare(
1174
+ `INSERT INTO coord_commands (command, reason, issued_by, workspace) VALUES (?, ?, ?, ?)`
1175
+ ).run(command, reason ?? null, issuedBy ?? null, workspace ?? null);
1176
+
1177
+ db.prepare(
1178
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'command', ?)`
1179
+ ).run(issuedBy ?? null, `${command}${workspace ? ' [' + workspace + ']' : ''}: ${reason ?? 'no reason given'}`);
1180
+
1181
+ coordLog(`COMMAND: ${command}${reason ? ' ' + reason : ''}`);
1182
+ return reply.code(201).send({ ok: true, command, reason, workspace });
1183
+ });
1184
+
1185
+ app.get('/command', async (req, reply) => {
1186
+ const workspace = (req.query as Record<string, string>).workspace;
1187
+
1188
+ const active = workspace
1189
+ ? db.prepare(
1190
+ `SELECT id, command, reason, issued_by, issued_at, workspace
1191
+ FROM coord_commands WHERE cleared_at IS NULL AND (workspace = ? OR workspace IS NULL)
1192
+ ORDER BY issued_at DESC`
1193
+ ).all(workspace) as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>
1194
+ : db.prepare(
1195
+ `SELECT id, command, reason, issued_by, issued_at, workspace
1196
+ FROM coord_commands WHERE cleared_at IS NULL
1197
+ ORDER BY issued_at DESC`
1198
+ ).all() as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>;
1199
+
1200
+ if (active.length === 0) {
1201
+ return reply.send({ active: false, commands: [] });
1202
+ }
1203
+
1204
+ const priority: Record<string, number> = { SHUTDOWN: 3, BUILD_FREEZE: 2, PAUSE: 1 };
1205
+ active.sort((a, b) => (priority[b.command] ?? 0) - (priority[a.command] ?? 0));
1206
+
1207
+ return reply.send({
1208
+ active: true,
1209
+ command: active[0].command,
1210
+ reason: active[0].reason,
1211
+ issued_at: active[0].issued_at,
1212
+ commands: active,
1213
+ });
1214
+ });
1215
+
1216
+ app.delete('/command/:id', async (req, reply) => {
1217
+ const id = Number((req.params as Record<string, string>).id);
1218
+ if (!Number.isInteger(id) || id <= 0) return reply.code(400).send({ error: 'invalid command id' });
1219
+
1220
+ const result = db.prepare(
1221
+ `UPDATE coord_commands SET cleared_at = datetime('now') WHERE id = ? AND cleared_at IS NULL`
1222
+ ).run(id);
1223
+
1224
+ if (result.changes === 0) {
1225
+ return reply.code(404).send({ error: 'command not found or already cleared' });
1226
+ }
1227
+
1228
+ db.prepare(
1229
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (NULL, 'command', ?)`
1230
+ ).run(`command ${id} cleared via DELETE`);
1231
+
1232
+ return reply.send({ ok: true });
1233
+ });
1234
+
1235
+ app.get('/command/wait', async (req, reply) => {
1236
+ const q = commandWaitQuerySchema.safeParse(req.query);
1237
+ const { status: targetStatus, workspace } = q.success ? q.data : { status: 'idle', workspace: undefined };
1238
+
1239
+ const agents = workspace
1240
+ ? db.prepare(
1241
+ `SELECT id, name, role, status, current_task, last_seen
1242
+ FROM coord_agents WHERE status NOT IN ('dead') AND workspace = ?
1243
+ ORDER BY name`
1244
+ ).all(workspace) as Array<{ id: string; name: string; role: string; status: string; current_task: string | null; last_seen: string }>
1245
+ : db.prepare(
1246
+ `SELECT id, name, role, status, current_task, last_seen
1247
+ FROM coord_agents WHERE status NOT IN ('dead')
1248
+ ORDER BY name`
1249
+ ).all() as Array<{ id: string; name: string; role: string; status: string; current_task: string | null; last_seen: string }>;
1250
+
1251
+ const ready = agents.filter(a => a.status === targetStatus || a.role === 'orchestrator' || a.role === 'coordinator');
1252
+ const notReady = agents.filter(a => a.status !== targetStatus && a.role !== 'orchestrator' && a.role !== 'coordinator');
1253
+
1254
+ return reply.send({
1255
+ allReady: notReady.length === 0,
1256
+ total: agents.length,
1257
+ ready: ready.map(a => ({ name: a.name, status: a.status })),
1258
+ waiting: notReady.map(a => ({ name: a.name, status: a.status, task: a.current_task })),
1259
+ });
1260
+ });
1261
+
1262
+ // ─── Findings ───────────────────────────────────────────────────
1263
+
1264
+ app.post('/finding', async (req, reply) => {
1265
+ const parsed = findingCreateSchema.safeParse(req.body);
1266
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1267
+ const { agentId, category, severity, filePath, lineNumber, description, suggestion } = parsed.data;
1268
+
1269
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1270
+
1271
+ db.prepare(
1272
+ `INSERT INTO coord_findings (agent_id, category, severity, file_path, line_number, description, suggestion)
1273
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
1274
+ ).run(agentId, category, severity ?? 'info', filePath ?? null, lineNumber ?? null, description, suggestion ?? null);
1275
+
1276
+ db.prepare(
1277
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'finding', ?)`
1278
+ ).run(agentId, `[${severity ?? 'info'}] ${category}: ${description.slice(0, 100)}`);
1279
+
1280
+ return reply.code(201).send({ ok: true });
1281
+ });
1282
+
1283
+ app.get('/findings', async (req, reply) => {
1284
+ const q = findingsQuerySchema.safeParse(req.query);
1285
+ const { category, severity, status, limit } = q.success ? q.data : { category: undefined, severity: undefined, status: undefined, limit: 50 };
1286
+
1287
+ let sql = `
1288
+ SELECT f.id, f.category, f.severity, f.file_path, f.line_number,
1289
+ f.description, f.suggestion, f.status, f.created_at,
1290
+ a.name AS agent_name
1291
+ FROM coord_findings f JOIN coord_agents a ON f.agent_id = a.id
1292
+ WHERE 1=1
1293
+ `;
1294
+ const params: unknown[] = [];
1295
+
1296
+ if (category) { sql += ` AND f.category = ?`; params.push(category); }
1297
+ if (severity) { sql += ` AND f.severity = ?`; params.push(severity); }
1298
+ if (status) { sql += ` AND f.status = ?`; params.push(status); }
1299
+
1300
+ sql += ` ORDER BY
1301
+ CASE f.severity WHEN 'critical' THEN 0 WHEN 'error' THEN 1 WHEN 'warn' THEN 2 ELSE 3 END,
1302
+ f.created_at DESC
1303
+ LIMIT ?`;
1304
+ params.push(limit);
1305
+
1306
+ const findings = db.prepare(sql).all(...params);
1307
+
1308
+ const stats = db.prepare(
1309
+ `SELECT severity, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY severity`
1310
+ ).all();
1311
+
1312
+ return reply.send({ findings, stats });
1313
+ });
1314
+
1315
+ app.post('/finding/:id/resolve', async (req, reply) => {
1316
+ const { id } = findingIdParamSchema.parse(req.params);
1317
+ db.prepare(
1318
+ `UPDATE coord_findings SET status = 'resolved', resolved_at = datetime('now') WHERE id = ?`
1319
+ ).run(id);
1320
+ return reply.send({ ok: true });
1321
+ });
1322
+
1323
+ app.patch('/finding/:id', async (req, reply) => {
1324
+ const { id } = findingIdParamSchema.parse(req.params);
1325
+ const parsed = findingUpdateSchema.safeParse(req.body);
1326
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1327
+ const { status, suggestion } = parsed.data;
1328
+
1329
+ const existing = db.prepare(`SELECT id FROM coord_findings WHERE id = ?`).get(id);
1330
+ if (!existing) return reply.code(404).send({ error: 'finding not found' });
1331
+
1332
+ const sets: string[] = [];
1333
+ const params: unknown[] = [];
1334
+
1335
+ if (status) {
1336
+ sets.push('status = ?');
1337
+ params.push(status);
1338
+ if (status === 'resolved') {
1339
+ sets.push("resolved_at = datetime('now')");
1340
+ }
1341
+ }
1342
+ if (suggestion !== undefined) {
1343
+ sets.push('suggestion = ?');
1344
+ params.push(suggestion);
1345
+ }
1346
+
1347
+ if (sets.length === 0) return reply.send({ ok: true, changed: false });
1348
+
1349
+ params.push(id);
1350
+ db.prepare(`UPDATE coord_findings SET ${sets.join(', ')} WHERE id = ?`).run(...params);
1351
+ return reply.send({ ok: true, changed: true });
1352
+ });
1353
+
1354
+ app.get('/findings/summary', async (_req, reply) => {
1355
+ const bySeverity = db.prepare(
1356
+ `SELECT severity, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY severity`
1357
+ ).all();
1358
+
1359
+ const byCategory = db.prepare(
1360
+ `SELECT category, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY category ORDER BY count DESC`
1361
+ ).all();
1362
+
1363
+ const total = db.prepare(
1364
+ `SELECT COUNT(*) as total FROM coord_findings WHERE status = 'open'`
1365
+ ).get() as { total: number };
1366
+
1367
+ return reply.send({ total: total.total, bySeverity, byCategory });
1368
+ });
1369
+
1370
+ // ─── Decisions (cross-agent propagation) ────────────────────────
1371
+
1372
+ app.get('/decisions', async (req, reply) => {
1373
+ const q = decisionsQuerySchema.safeParse(req.query);
1374
+ const { since_id, assignment_id, workspace, limit } = q.success ? q.data : { since_id: 0, assignment_id: undefined, workspace: undefined, limit: 20 };
1375
+
1376
+ let sql = `
1377
+ SELECT d.id, d.author_id, a.name AS author_name, d.assignment_id, d.tags, d.summary, d.created_at
1378
+ FROM coord_decisions d JOIN coord_agents a ON d.author_id = a.id
1379
+ WHERE d.id > ?
1380
+ `;
1381
+ const params: unknown[] = [since_id];
1382
+
1383
+ if (assignment_id) {
1384
+ sql += ` AND d.assignment_id = ?`;
1385
+ params.push(assignment_id);
1386
+ }
1387
+
1388
+ if (workspace) {
1389
+ sql += ` AND (a.workspace = ? OR a.workspace IS NULL)`;
1390
+ params.push(workspace);
1391
+ }
1392
+
1393
+ sql += ` ORDER BY d.created_at ASC LIMIT ?`;
1394
+ params.push(limit);
1395
+
1396
+ const decisions = db.prepare(sql).all(...params);
1397
+ return reply.send({ decisions });
1398
+ });
1399
+
1400
+ app.post('/decisions', async (req, reply) => {
1401
+ const { agentId, assignment_id, tags, summary } = decisionCreateSchema.parse(req.body);
1402
+
1403
+ // Verify agent exists
1404
+ const agent = db.prepare(`SELECT id FROM coord_agents WHERE id = ?`).get(agentId) as { id: string } | undefined;
1405
+ if (!agent) return reply.code(404).send({ error: 'agent not found' });
1406
+
1407
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1408
+
1409
+ db.prepare(
1410
+ `INSERT INTO coord_decisions (author_id, assignment_id, tags, summary) VALUES (?, ?, ?, ?)`
1411
+ ).run(agentId, assignment_id ?? null, tags ?? null, summary);
1412
+
1413
+ const row = db.prepare(`SELECT last_insert_rowid() AS id`).get() as { id: number };
1414
+ return reply.code(201).send({ ok: true, id: row.id });
1415
+ });
1416
+
1417
+ // ─── Status ─────────────────────────────────────────────────────
1418
+
1419
+ app.get('/status', async (_req, reply) => {
1420
+ const agents = db.prepare(
1421
+ `SELECT id, name, role, status, current_task, last_seen,
1422
+ ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
1423
+ FROM coord_agents WHERE status != 'dead'
1424
+ ORDER BY role, name LIMIT 200`
1425
+ ).all();
1426
+
1427
+ const assignments = db.prepare(
1428
+ `SELECT a.id, a.task, a.description, a.status, a.agent_id, ag.name AS agent_name,
1429
+ a.created_at, a.started_at, a.completed_at
1430
+ FROM coord_assignments a LEFT JOIN coord_agents ag ON a.agent_id = ag.id
1431
+ WHERE a.status NOT IN ('completed', 'failed')
1432
+ ORDER BY a.created_at LIMIT 200`
1433
+ ).all();
1434
+
1435
+ const locks = db.prepare(
1436
+ `SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
1437
+ FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id LIMIT 200`
1438
+ ).all();
1439
+
1440
+ const stats = db.prepare(
1441
+ `SELECT
1442
+ (SELECT COUNT(*) FROM coord_agents WHERE status != 'dead') AS alive_agents,
1443
+ (SELECT COUNT(*) FROM coord_agents WHERE status = 'working') AS busy_agents,
1444
+ (SELECT COUNT(*) FROM coord_assignments WHERE status = 'pending') AS pending_tasks,
1445
+ (SELECT COUNT(*) FROM coord_assignments WHERE status IN ('assigned', 'in_progress')) AS active_tasks,
1446
+ (SELECT COUNT(*) FROM coord_locks) AS active_locks,
1447
+ (SELECT COUNT(*) FROM coord_findings WHERE status = 'open') AS open_findings,
1448
+ (SELECT COUNT(*) FROM coord_findings WHERE status = 'open' AND severity IN ('critical', 'error')) AS urgent_findings`
1449
+ ).get();
1450
+
1451
+ const recentFindings = db.prepare(
1452
+ `SELECT f.id, f.category, f.severity, f.file_path, f.description, a.name AS agent_name, f.created_at
1453
+ FROM coord_findings f JOIN coord_agents a ON f.agent_id = a.id
1454
+ WHERE f.status = 'open'
1455
+ ORDER BY CASE f.severity WHEN 'critical' THEN 0 WHEN 'error' THEN 1 WHEN 'warn' THEN 2 ELSE 3 END,
1456
+ f.created_at DESC
1457
+ LIMIT 10`
1458
+ ).all();
1459
+
1460
+ return reply.send({ agents, assignments, locks, stats, recentFindings });
1461
+ });
1462
+
1463
+ app.get('/workers', async (req, reply) => {
1464
+ const q = workersQuerySchema.safeParse(req.query);
1465
+ const { capability, status: filterStatus, workspace } = q.success ? q.data : { capability: undefined, status: undefined, workspace: undefined };
1466
+
1467
+ // Join with coord_channel_sessions so the coordinator agent can compute
1468
+ // alive=true for workers that have a connected channel session even when
1469
+ // their /pulse is stale. Without this, /workers under-reports liveness
1470
+ // during long tool-call sequences where the worker is processing but
1471
+ // hasn't called /pulse for >5min leading to false-positive duplicate
1472
+ // spawns. Channel sessions get probed every 60s (coordination/index.ts:111),
1473
+ // so a stale channel-server.js gets status='disconnected' within 60-120s.
1474
+ let workers = workspace
1475
+ ? db.prepare(
1476
+ `SELECT a.id, a.name, a.role, a.status, a.current_task, a.capabilities, a.workspace, a.last_seen,
1477
+ ROUND((julianday('now') - julianday(a.last_seen)) * 86400) AS seconds_since_seen,
1478
+ cs.status AS channel_status, cs.last_push_at AS channel_last_push
1479
+ FROM coord_agents a
1480
+ LEFT JOIN coord_channel_sessions cs ON cs.agent_id = a.id
1481
+ WHERE a.status != 'dead' AND a.role NOT IN ('orchestrator', 'coordinator') AND a.workspace = ?
1482
+ ORDER BY a.name LIMIT 200`
1483
+ ).all(workspace) as Array<{
1484
+ id: string; name: string; role: string; status: string;
1485
+ current_task: string | null; capabilities: string | null;
1486
+ workspace: string | null; last_seen: string; seconds_since_seen: number;
1487
+ channel_status: string | null; channel_last_push: string | null;
1488
+ }>
1489
+ : db.prepare(
1490
+ `SELECT a.id, a.name, a.role, a.status, a.current_task, a.capabilities, a.workspace, a.last_seen,
1491
+ ROUND((julianday('now') - julianday(a.last_seen)) * 86400) AS seconds_since_seen,
1492
+ cs.status AS channel_status, cs.last_push_at AS channel_last_push
1493
+ FROM coord_agents a
1494
+ LEFT JOIN coord_channel_sessions cs ON cs.agent_id = a.id
1495
+ WHERE a.status != 'dead' AND a.role NOT IN ('orchestrator', 'coordinator')
1496
+ ORDER BY a.name LIMIT 200`
1497
+ ).all() as Array<{
1498
+ id: string; name: string; role: string; status: string;
1499
+ current_task: string | null; capabilities: string | null;
1500
+ workspace: string | null; last_seen: string; seconds_since_seen: number;
1501
+ channel_status: string | null; channel_last_push: string | null;
1502
+ }>;
1503
+
1504
+ if (capability) {
1505
+ workers = workers.filter(w => {
1506
+ if (!w.capabilities) return false;
1507
+ try {
1508
+ const caps = JSON.parse(w.capabilities) as string[];
1509
+ return caps.includes(capability);
1510
+ } catch {
1511
+ return false;
1512
+ }
1513
+ });
1514
+ }
1515
+
1516
+ if (filterStatus) {
1517
+ workers = workers.filter(w => w.status === filterStatus);
1518
+ }
1519
+
1520
+ const result = workers.map(w => ({
1521
+ id: w.id,
1522
+ name: w.name,
1523
+ role: w.role,
1524
+ status: w.status,
1525
+ currentTask: w.current_task,
1526
+ capabilities: w.capabilities ? JSON.parse(w.capabilities) : [],
1527
+ workspace: w.workspace,
1528
+ lastSeen: w.last_seen,
1529
+ secondsSinceSeen: w.seconds_since_seen,
1530
+ // alive = recent /pulse OR connected channel session.
1531
+ // Channel sessions get probed every 60s and marked 'disconnected'
1532
+ // when unreachable, so a connected session is reliable proof of life
1533
+ // even during long tool sequences where the worker hasn't pulsed.
1534
+ // Prevents duplicate worker spawns when /pulse is stale but worker is busy.
1535
+ alive: w.seconds_since_seen < 300 || w.channel_status === 'connected',
1536
+ channelStatus: w.channel_status,
1537
+ channelLastPush: w.channel_last_push,
1538
+ }));
1539
+
1540
+ return reply.send({
1541
+ count: result.length,
1542
+ idle: result.filter(w => w.status === 'idle').length,
1543
+ working: result.filter(w => w.status === 'working').length,
1544
+ workers: result,
1545
+ });
1546
+ });
1547
+
1548
+ app.get('/events', async (req, reply) => {
1549
+ const q = eventsQuerySchema.safeParse(req.query);
1550
+ if (!q.success) return reply.code(400).send({ error: q.error.issues[0].message });
1551
+ const { since_id, agent_id, event_type, limit } = q.data;
1552
+
1553
+ const conditions: string[] = [];
1554
+ const params: unknown[] = [];
1555
+
1556
+ if (since_id > 0) {
1557
+ conditions.push('e.id > ?');
1558
+ params.push(since_id);
1559
+ }
1560
+ if (agent_id) {
1561
+ conditions.push('e.agent_id = ?');
1562
+ params.push(agent_id);
1563
+ }
1564
+ if (event_type) {
1565
+ conditions.push('e.event_type = ?');
1566
+ params.push(event_type);
1567
+ }
1568
+
1569
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
1570
+ params.push(limit);
1571
+
1572
+ const events = db.prepare(
1573
+ `SELECT e.id, e.agent_id, a.name AS agent_name, e.event_type, e.detail, e.created_at
1574
+ FROM coord_events e LEFT JOIN coord_agents a ON e.agent_id = a.id
1575
+ ${where}
1576
+ ORDER BY e.id ASC LIMIT ?`
1577
+ ).all(...params);
1578
+
1579
+ const last_id = events.length > 0 ? (events[events.length - 1] as { id: number }).id : since_id;
1580
+
1581
+ return reply.send({ events, last_id });
1582
+ });
1583
+
1584
+ app.get('/stale', async (req, reply) => {
1585
+ const q = staleQuerySchema.safeParse(req.query);
1586
+ const threshold = q.success ? q.data.seconds : 300;
1587
+ const cleanup = q.success ? q.data.cleanup : undefined;
1588
+
1589
+ const stale = detectStale(db, threshold);
1590
+
1591
+ if (cleanup === '1' || cleanup === 'true') {
1592
+ const { cleaned } = cleanupStale(db, threshold);
1593
+ return reply.send({ stale, threshold_seconds: threshold, cleaned });
1594
+ }
1595
+
1596
+ return reply.send({ stale, threshold_seconds: threshold });
1597
+ });
1598
+
1599
+ app.post('/stale/cleanup', async (req, reply) => {
1600
+ const q = staleQuerySchema.safeParse(req.query);
1601
+ const threshold = q.success ? q.data.seconds : 300;
1602
+
1603
+ const { stale, cleaned } = cleanupStale(db, threshold);
1604
+ return reply.send({ stale, threshold_seconds: threshold, cleaned });
1605
+ });
1606
+
1607
+ // ─── Agent Management ───────────────────────────────────────────
1608
+
1609
+ app.get('/agent/:id', async (req, reply) => {
1610
+ const params = agentIdParamSchema.safeParse(req.params);
1611
+ if (!params.success) return reply.code(400).send({ error: params.error.issues[0].message });
1612
+ const { id } = params.data;
1613
+
1614
+ const agent = db.prepare(
1615
+ `SELECT id, name, role, status, current_task, pid, capabilities, workspace, metadata, last_seen, started_at,
1616
+ ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
1617
+ FROM coord_agents WHERE id = ?`
1618
+ ).get(id) as Record<string, unknown> | undefined;
1619
+
1620
+ if (!agent) return reply.code(404).send({ error: 'agent not found' });
1621
+
1622
+ // Include active assignment and locks
1623
+ const assignment = db.prepare(
1624
+ `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`
1625
+ ).get(id) as Record<string, unknown> | undefined;
1626
+
1627
+ const locks = db.prepare(
1628
+ `SELECT file_path, locked_at, reason FROM coord_locks WHERE agent_id = ?`
1629
+ ).all(id);
1630
+
1631
+ return reply.send({ agent, assignment: assignment ?? null, locks });
1632
+ });
1633
+
1634
+ app.delete('/agent/:id', async (req, reply) => {
1635
+ const params = agentIdParamSchema.safeParse(req.params);
1636
+ if (!params.success) return reply.code(400).send({ error: params.error.issues[0].message });
1637
+ const { id } = params.data;
1638
+
1639
+ const agent = db.prepare(`SELECT id, name, status FROM coord_agents WHERE id = ?`).get(id) as { id: string; name: string; status: string } | undefined;
1640
+ if (!agent) return reply.code(404).send({ error: 'agent not found' });
1641
+ if (agent.status === 'dead') return reply.send({ ok: true, action: 'already_dead', agent_name: agent.name });
1642
+
1643
+ // Fail active assignments
1644
+ const failedAssignments = db.prepare(
1645
+ `UPDATE coord_assignments SET status = 'failed', result = 'agent killed by coordinator', completed_at = datetime('now')
1646
+ WHERE agent_id = ? AND status IN ('assigned', 'in_progress')`
1647
+ ).run(id);
1648
+
1649
+ if (failedAssignments.changes > 0) {
1650
+ db.prepare(
1651
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_failed', ?)`
1652
+ ).run(id, `killed: failed ${failedAssignments.changes} active assignment(s)`);
1653
+ }
1654
+
1655
+ // Release locks
1656
+ const releasedLocks = db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(id);
1657
+
1658
+ // Mark dead
1659
+ db.prepare(`UPDATE coord_agents SET status = 'dead', current_task = NULL WHERE id = ?`).run(id);
1660
+
1661
+ db.prepare(
1662
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'agent_killed', ?)`
1663
+ ).run(id, `${agent.name} killed: failed ${failedAssignments.changes} assignment(s), released ${releasedLocks.changes} lock(s)`);
1664
+
1665
+ coordLog(`${agent.name} killed — failed ${failedAssignments.changes} assignment(s), released ${releasedLocks.changes} lock(s)`);
1666
+
1667
+ return reply.send({
1668
+ ok: true,
1669
+ action: 'killed',
1670
+ agent_name: agent.name,
1671
+ failed_assignments: failedAssignments.changes,
1672
+ released_locks: releasedLocks.changes,
1673
+ });
1674
+ });
1675
+
1676
+ // ─── Timeline ─────────────────────────────────────────────────────
1677
+
1678
+ app.get('/timeline', async (req, reply) => {
1679
+ const q = timelineQuerySchema.safeParse(req.query);
1680
+ if (!q.success) return reply.code(400).send({ error: q.error.issues[0].message });
1681
+ const { limit, since } = q.data;
1682
+
1683
+ const conditions: string[] = [];
1684
+ const params: unknown[] = [];
1685
+
1686
+ if (since) {
1687
+ conditions.push('e.created_at >= ?');
1688
+ params.push(since);
1689
+ }
1690
+
1691
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
1692
+ params.push(limit);
1693
+
1694
+ const timeline = db.prepare(
1695
+ `SELECT e.created_at AS timestamp, a.name AS agent_name, e.event_type, e.detail,
1696
+ t.task AS assignment_task
1697
+ FROM coord_events e
1698
+ LEFT JOIN coord_agents a ON e.agent_id = a.id
1699
+ LEFT JOIN coord_assignments t ON a.current_task = t.id
1700
+ ${where}
1701
+ ORDER BY e.created_at DESC, e.id DESC
1702
+ LIMIT ?`
1703
+ ).all(...params);
1704
+
1705
+ return reply.send({ timeline });
1706
+ });
1707
+
1708
+ // ─── Stats ──────────────────────────────────────────────────────
1709
+
1710
+ app.get('/stats', async (_req, reply) => {
1711
+ const workers = db.prepare(`
1712
+ SELECT
1713
+ COUNT(*) AS total,
1714
+ SUM(CASE WHEN status != 'dead' THEN 1 ELSE 0 END) AS alive,
1715
+ SUM(CASE WHEN status = 'idle' THEN 1 ELSE 0 END) AS idle,
1716
+ SUM(CASE WHEN status = 'working' THEN 1 ELSE 0 END) AS working
1717
+ FROM coord_agents
1718
+ `).get() as { total: number; alive: number; idle: number; working: number };
1719
+
1720
+ const tasks = db.prepare(`
1721
+ SELECT
1722
+ COUNT(*) AS total_assigned,
1723
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
1724
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
1725
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
1726
+ AVG(CASE
1727
+ WHEN status = 'completed' AND started_at IS NOT NULL AND completed_at IS NOT NULL
1728
+ THEN ROUND((julianday(completed_at) - julianday(started_at)) * 86400)
1729
+ ELSE NULL
1730
+ END) AS avg_completion_seconds
1731
+ FROM coord_assignments
1732
+ `).get() as { total_assigned: number; completed: number; failed: number; pending: number; avg_completion_seconds: number | null };
1733
+
1734
+ const decisions = db.prepare(`
1735
+ SELECT
1736
+ COALESCE(COUNT(*), 0) AS total,
1737
+ COALESCE(SUM(CASE WHEN created_at >= datetime('now', '-1 hour') THEN 1 ELSE 0 END), 0) AS last_hour
1738
+ FROM coord_decisions
1739
+ `).get() as { total: number; last_hour: number };
1740
+
1741
+ // Uptime = seconds since the earliest non-dead agent started
1742
+ const uptime = db.prepare(`
1743
+ SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS uptime_seconds
1744
+ FROM coord_agents WHERE status != 'dead'
1745
+ `).get() as { uptime_seconds: number | null };
1746
+
1747
+ return reply.send({
1748
+ workers,
1749
+ tasks: {
1750
+ ...tasks,
1751
+ avg_completion_seconds: tasks.avg_completion_seconds != null
1752
+ ? Math.round(tasks.avg_completion_seconds)
1753
+ : null,
1754
+ },
1755
+ decisions,
1756
+ uptime_seconds: uptime.uptime_seconds ?? 0,
1757
+ });
1758
+ });
1759
+
1760
+ // ─── Prometheus Metrics ────────────────────────────────────────
1761
+
1762
+ app.get('/metrics', async (_req, reply) => {
1763
+ const agentsByStatus = db.prepare(
1764
+ `SELECT status, COUNT(*) AS count FROM coord_agents GROUP BY status`
1765
+ ).all() as Array<{ status: string; count: number }>;
1766
+
1767
+ const assignmentsByStatus = db.prepare(
1768
+ `SELECT status, COUNT(*) AS count FROM coord_assignments GROUP BY status`
1769
+ ).all() as Array<{ status: string; count: number }>;
1770
+
1771
+ const locksActive = (db.prepare(
1772
+ `SELECT COUNT(*) AS count FROM coord_locks`
1773
+ ).get() as { count: number }).count;
1774
+
1775
+ const findingsBySeverity = db.prepare(
1776
+ `SELECT severity, COUNT(*) AS count FROM coord_findings WHERE status = 'open' GROUP BY severity`
1777
+ ).all() as Array<{ severity: string; count: number }>;
1778
+
1779
+ const eventsTotal = (db.prepare(
1780
+ `SELECT COUNT(*) AS count FROM coord_events`
1781
+ ).get() as { count: number }).count;
1782
+
1783
+ const uptime = (db.prepare(
1784
+ `SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS seconds FROM coord_agents WHERE status != 'dead'`
1785
+ ).get() as { seconds: number | null }).seconds ?? 0;
1786
+
1787
+ const lines: string[] = [
1788
+ '# HELP coord_agents_total Number of agents by status',
1789
+ '# TYPE coord_agents_total gauge',
1790
+ ];
1791
+ for (const row of agentsByStatus) {
1792
+ lines.push(`coord_agents_total{status="${row.status}"} ${row.count}`);
1793
+ }
1794
+
1795
+ lines.push('# HELP coord_assignments_total Number of assignments by status');
1796
+ lines.push('# TYPE coord_assignments_total gauge');
1797
+ for (const row of assignmentsByStatus) {
1798
+ lines.push(`coord_assignments_total{status="${row.status}"} ${row.count}`);
1799
+ }
1800
+
1801
+ lines.push('# HELP coord_locks_active Number of active file locks');
1802
+ lines.push('# TYPE coord_locks_active gauge');
1803
+ lines.push(`coord_locks_active ${locksActive}`);
1804
+
1805
+ lines.push('# HELP coord_findings_total Open findings by severity');
1806
+ lines.push('# TYPE coord_findings_total gauge');
1807
+ for (const row of findingsBySeverity) {
1808
+ lines.push(`coord_findings_total{severity="${row.severity}"} ${row.count}`);
1809
+ }
1810
+
1811
+ lines.push('# HELP coord_events_total Total coordination events');
1812
+ lines.push('# TYPE coord_events_total counter');
1813
+ lines.push(`coord_events_total ${eventsTotal}`);
1814
+
1815
+ lines.push('# HELP coord_uptime_seconds Seconds since first agent registered');
1816
+ lines.push('# TYPE coord_uptime_seconds gauge');
1817
+ lines.push(`coord_uptime_seconds ${uptime}`);
1818
+
1819
+ // ─── Channel push telemetry (process-scoped, reset on restart) ───
1820
+ lines.push('# HELP coord_channel_push_attempts_total Total channel push attempts since coordinator startup');
1821
+ lines.push('# TYPE coord_channel_push_attempts_total counter');
1822
+ lines.push(`coord_channel_push_attempts_total ${channelMetrics.attempts}`);
1823
+
1824
+ lines.push('# HELP coord_channel_push_delivered_total Successful channel deliveries');
1825
+ lines.push('# TYPE coord_channel_push_delivered_total counter');
1826
+ lines.push(`coord_channel_push_delivered_total ${channelMetrics.delivered}`);
1827
+
1828
+ lines.push('# HELP coord_channel_push_failed_total Failed channel deliveries by reason');
1829
+ lines.push('# TYPE coord_channel_push_failed_total counter');
1830
+ lines.push(`coord_channel_push_failed_total{reason="http"} ${channelMetrics.failed_http}`);
1831
+ lines.push(`coord_channel_push_failed_total{reason="unreachable"} ${channelMetrics.failed_unreachable}`);
1832
+
1833
+ lines.push('# HELP coord_channel_no_session_total Push attempts where agent had no connected session');
1834
+ lines.push('# TYPE coord_channel_no_session_total counter');
1835
+ lines.push(`coord_channel_no_session_total ${channelMetrics.no_session}`);
1836
+
1837
+ lines.push('# HELP coord_channel_fallback_mailbox_total Pushes that fell back to mailbox after delivery failure');
1838
+ lines.push('# TYPE coord_channel_fallback_mailbox_total counter');
1839
+ lines.push(`coord_channel_fallback_mailbox_total ${channelMetrics.fallback_mailbox}`);
1840
+
1841
+ lines.push('# HELP coord_channel_session_disconnects_total Sessions marked disconnected after delivery failure');
1842
+ lines.push('# TYPE coord_channel_session_disconnects_total counter');
1843
+ lines.push(`coord_channel_session_disconnects_total ${channelMetrics.session_disconnects}`);
1844
+
1845
+ return reply.type('text/plain; version=0.0.4; charset=utf-8').send(lines.join('\n') + '\n');
1846
+ });
1847
+
1848
+ // ─── Deep Health ───────────────────────────────────────────────
1849
+
1850
+ app.get('/health/deep', async (_req, reply) => {
1851
+ const dbHealthy = store ? store.integrityCheck().ok : true;
1852
+
1853
+ const agents = db.prepare(
1854
+ `SELECT COUNT(*) AS alive FROM coord_agents WHERE status != 'dead'`
1855
+ ).get() as { alive: number };
1856
+
1857
+ const staleThreshold = 300;
1858
+ const staleCount = (db.prepare(
1859
+ `SELECT COUNT(*) AS c FROM coord_agents
1860
+ WHERE status != 'dead'
1861
+ AND (julianday('now') - julianday(last_seen)) * 86400 > ?`
1862
+ ).get(staleThreshold) as { c: number }).c;
1863
+
1864
+ const pending = (db.prepare(
1865
+ `SELECT COUNT(*) AS c FROM coord_assignments WHERE status IN ('pending', 'assigned', 'in_progress')`
1866
+ ).get() as { c: number }).c;
1867
+
1868
+ const uptimeRow = db.prepare(
1869
+ `SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS s
1870
+ FROM coord_agents WHERE status != 'dead'`
1871
+ ).get() as { s: number | null };
1872
+
1873
+ // WAL file size and autocheckpoint setting
1874
+ let walSizeBytes: number | null = null;
1875
+ let walAutocheckpoint: number | null = null;
1876
+ try {
1877
+ const fs = require('fs');
1878
+ const walPath = db.name + '-wal';
1879
+ const stat = fs.statSync(walPath);
1880
+ walSizeBytes = stat.size;
1881
+ } catch { /* WAL file may not exist */ }
1882
+ try {
1883
+ const acRow = db.pragma('wal_autocheckpoint') as Array<{ wal_autocheckpoint: number }>;
1884
+ walAutocheckpoint = acRow[0]?.wal_autocheckpoint ?? null;
1885
+ } catch { /* pragma read failed */ }
1886
+
1887
+ const status = (!dbHealthy || staleCount > 2) ? 'degraded' : 'ok';
1888
+
1889
+ return reply.send({
1890
+ status,
1891
+ db_healthy: dbHealthy,
1892
+ agents_alive: agents.alive,
1893
+ stale_agents: staleCount,
1894
+ pending_tasks: pending,
1895
+ uptime_seconds: uptimeRow.s ?? 0,
1896
+ wal_size_bytes: walSizeBytes,
1897
+ wal_autocheckpoint: walAutocheckpoint,
1898
+ });
1899
+ });
1900
+
1901
+ // ─── Channel Sessions ───────────────────────────────────────────
1902
+
1903
+ /** POST /channel/register — Register or update a channel session for an agent. */
1904
+ app.post('/channel/register', async (request, reply) => {
1905
+ const parsed = channelRegisterSchema.safeParse(request.body);
1906
+ if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
1907
+ const { agentId, channelId } = parsed.data;
1908
+
1909
+ const agent = db.prepare('SELECT id FROM coord_agents WHERE id = ?').get(agentId) as { id: string } | undefined;
1910
+ if (!agent) return reply.status(404).send({ error: 'Agent not found' });
1911
+
1912
+ db.prepare(`
1913
+ INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
1914
+ VALUES (?, ?, datetime('now'), 'connected')
1915
+ ON CONFLICT(agent_id) DO UPDATE SET
1916
+ channel_id = excluded.channel_id,
1917
+ connected_at = datetime('now'),
1918
+ status = 'connected',
1919
+ push_count = 0,
1920
+ last_push_at = NULL
1921
+ `).run(agentId, channelId);
1922
+
1923
+ db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_register', ?)`).run(
1924
+ agentId, JSON.stringify({ channelId })
1925
+ );
1926
+
1927
+ coordLog(`channel/register: ${agentId} ${channelId}`);
1928
+ eventBus?.emit('session.started', { agentId, channelId });
1929
+ return reply.send({ ok: true });
1930
+ });
1931
+
1932
+ /** DELETE /channel/register — Deregister a channel session for an agent. */
1933
+ app.delete('/channel/register', async (request, reply) => {
1934
+ const parsed = channelDeregisterSchema.safeParse(request.body);
1935
+ if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
1936
+ const { agentId } = parsed.data;
1937
+
1938
+ const result = db.prepare('DELETE FROM coord_channel_sessions WHERE agent_id = ?').run(agentId);
1939
+
1940
+ db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_deregister', NULL)`).run(agentId);
1941
+
1942
+ coordLog(`channel/deregister: ${agentId} (rows: ${result.changes})`);
1943
+ eventBus?.emit('session.closed', { agentId, channelId: '' });
1944
+ return reply.send({ ok: true });
1945
+ });
1946
+
1947
+ /**
1948
+ * Deliver a message to a worker's channel HTTP endpoint.
1949
+ * Returns { delivered, error? }. On connection failure, marks session dead.
1950
+ */
1951
+ async function deliverToChannel(
1952
+ agentId: string, channelUrl: string, content: string, meta?: Record<string, string>
1953
+ ): Promise<{ delivered: boolean; error?: string }> {
1954
+ channelMetrics.attempts++;
1955
+ try {
1956
+ const res = await fetch(`${channelUrl}/push`, {
1957
+ method: 'POST',
1958
+ headers: { 'Content-Type': 'application/json' },
1959
+ body: JSON.stringify({ content, meta: meta ?? {} }),
1960
+ signal: AbortSignal.timeout(5000),
1961
+ });
1962
+ if (!res.ok) {
1963
+ channelMetrics.failed_http++;
1964
+ return { delivered: false, error: `channel returned ${res.status}` };
1965
+ }
1966
+ channelMetrics.delivered++;
1967
+ return { delivered: true };
1968
+ } catch (err) {
1969
+ // Connection refused / timeout → worker process is dead, mark session disconnected
1970
+ channelMetrics.failed_unreachable++;
1971
+ channelMetrics.session_disconnects++;
1972
+ db.prepare(
1973
+ `UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`
1974
+ ).run(agentId);
1975
+ const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
1976
+ coordLog(`channel/deliver FAILED ${agent?.name ?? agentId}: ${err instanceof Error ? err.message : err} session marked disconnected`);
1977
+ return { delivered: false, error: `worker unreachable: ${err instanceof Error ? err.message : err}` };
1978
+ }
1979
+ }
1980
+
1981
+ /** POST /channel/push — Push a message to an agent. Tries live delivery first, falls back to mailbox queue.
1982
+ *
1983
+ * Two addressing modes:
1984
+ * - {agentId, message} — direct UUID
1985
+ * - {role, workspace, message} — server resolves to most-recently-seen alive agent
1986
+ * matching role+workspace. Used by workers to notify
1987
+ * coordinator (whose UUID changes across restarts).
1988
+ */
1989
+ app.post('/channel/push', async (request, reply) => {
1990
+ const parsed = channelPushSchema.safeParse(request.body);
1991
+ if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
1992
+ const { message } = parsed.data;
1993
+ let { agentId } = parsed.data;
1994
+
1995
+ // Role-based addressing — resolve to a concrete agentId
1996
+ if (!agentId && parsed.data.role && parsed.data.workspace) {
1997
+ const resolved = db.prepare(
1998
+ `SELECT id FROM coord_agents
1999
+ WHERE role = ? AND workspace = ? AND status != 'dead'
2000
+ ORDER BY last_seen DESC
2001
+ LIMIT 1`
2002
+ ).get(parsed.data.role, parsed.data.workspace) as { id: string } | undefined;
2003
+ if (!resolved) {
2004
+ return reply.status(404).send({
2005
+ error: `No alive agent found for role='${parsed.data.role}' workspace='${parsed.data.workspace}'`,
2006
+ });
2007
+ }
2008
+ agentId = resolved.id;
2009
+ }
2010
+
2011
+ // Type narrowing Zod refine guarantees agentId is set by this point,
2012
+ // but TypeScript can't see through the refine. This guard is unreachable
2013
+ // in practice (would have 400'd earlier).
2014
+ if (!agentId) return reply.status(400).send({ error: 'Internal: agentId resolution failed' });
2015
+
2016
+ const agent = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId) as { name: string; workspace: string | null } | undefined;
2017
+ if (!agent) return reply.status(404).send({ error: 'Agent not found' });
2018
+
2019
+ // Circuit breaker: refuse push to open-circuit workers
2020
+ if (!isAvailable(db, agentId)) {
2021
+ return reply.status(423).send({ error: 'circuit_open', reason: 'Worker circuit is open too many consecutive failures. Try again after 30s or after a successful assignment.' });
2022
+ }
2023
+
2024
+ // Try live channel delivery first
2025
+ const session = db.prepare(
2026
+ `SELECT agent_id, channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`
2027
+ ).get(agentId) as { agent_id: string; channel_id: string } | undefined;
2028
+
2029
+ if (session) {
2030
+ const { delivered } = await deliverToChannel(
2031
+ agentId, session.channel_id, message,
2032
+ { source: 'coordinator', agent: agent.name }
2033
+ );
2034
+
2035
+ if (delivered) {
2036
+ db.prepare(
2037
+ `UPDATE coord_channel_sessions SET last_push_at = datetime('now'), push_count = push_count + 1 WHERE agent_id = ?`
2038
+ ).run(agentId);
2039
+ db.prepare(
2040
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_push', ?)`
2041
+ ).run(agentId, message.slice(0, 500));
2042
+ coordLog(`channel/push → ${agent.name}: ${message.slice(0, 80)}`);
2043
+ return reply.send({ ok: true, delivered: true, channelId: session.channel_id });
2044
+ }
2045
+ // Live delivery failed — fall through to mailbox
2046
+ channelMetrics.fallback_mailbox++;
2047
+ } else {
2048
+ // No connected session — push went straight to mailbox
2049
+ channelMetrics.no_session++;
2050
+ }
2051
+
2052
+ // Queue to mailbox (delivered on next /next poll)
2053
+ db.prepare(
2054
+ `INSERT INTO coord_mailbox (worker_name, workspace, message, source) VALUES (?, ?, ?, 'coordinator')`
2055
+ ).run(agent.name, agent.workspace, message);
2056
+ db.prepare(
2057
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'mailbox_queued', ?)`
2058
+ ).run(agentId, `queued for ${agent.name}: ${message.slice(0, 200)}`);
2059
+ coordLog(`mailbox/queue ${agent.name}: ${message.slice(0, 80)} (live delivery unavailable)`);
2060
+ return reply.send({ ok: true, delivered: false, queued: true, hint: 'Message queued in mailbox — will be delivered on next /next poll' });
2061
+ });
2062
+
2063
+ /** GET /channel/sessions — List all active channel sessions with agent names. */
2064
+ app.get('/channel/sessions', async (_request, reply) => {
2065
+ const sessions = db.prepare(`
2066
+ SELECT cs.agent_id, a.name AS agent_name, cs.channel_id,
2067
+ cs.connected_at, cs.last_push_at, cs.push_count, cs.status
2068
+ FROM coord_channel_sessions cs
2069
+ JOIN coord_agents a ON a.id = cs.agent_id
2070
+ WHERE cs.status = 'connected'
2071
+ ORDER BY cs.connected_at DESC
2072
+ `).all();
2073
+
2074
+ return reply.send({ sessions });
2075
+ });
2076
+
2077
+ /** POST /channel/probe Probe all connected channel sessions, mark dead ones as disconnected. */
2078
+ app.post('/channel/probe', async (_request, reply) => {
2079
+ const sessions = db.prepare(
2080
+ `SELECT cs.agent_id, a.name AS agent_name, cs.channel_id
2081
+ FROM coord_channel_sessions cs
2082
+ JOIN coord_agents a ON a.id = cs.agent_id
2083
+ WHERE cs.status = 'connected'`
2084
+ ).all() as Array<{ agent_id: string; agent_name: string; channel_id: string }>;
2085
+
2086
+ const results: Array<{ agent: string; alive: boolean; error?: string }> = [];
2087
+
2088
+ for (const session of sessions) {
2089
+ try {
2090
+ const res = await fetch(`${session.channel_id}/health`, {
2091
+ signal: AbortSignal.timeout(3000),
2092
+ });
2093
+ if (res.ok) {
2094
+ results.push({ agent: session.agent_name, alive: true });
2095
+ } else {
2096
+ db.prepare(`UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`).run(session.agent_id);
2097
+ results.push({ agent: session.agent_name, alive: false, error: `health returned ${res.status}` });
2098
+ }
2099
+ } catch (err) {
2100
+ db.prepare(`UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`).run(session.agent_id);
2101
+ results.push({ agent: session.agent_name, alive: false, error: err instanceof Error ? err.message : String(err) });
2102
+ }
2103
+ }
2104
+
2105
+ const alive = results.filter(r => r.alive).length;
2106
+ const dead = results.filter(r => !r.alive).length;
2107
+ if (dead > 0) coordLog(`channel/probe: ${alive} alive, ${dead} dead — dead sessions marked disconnected`);
2108
+
2109
+ return reply.send({ probed: results.length, alive, dead, results });
2110
+ });
2111
+
2112
+ /**
2113
+ * GET /telemetry/channels — Channel push delivery telemetry.
2114
+ *
2115
+ * Counters reset on coordinator restart (in-process). Use this to answer:
2116
+ * "Are channels reliable enough to depend on, or do we need a polling fallback?"
2117
+ *
2118
+ * Response shape:
2119
+ * {
2120
+ * since: ISO timestamp of when counters started,
2121
+ * uptime_seconds: number,
2122
+ * attempts, delivered, failed_http, failed_unreachable,
2123
+ * no_session, fallback_mailbox, session_disconnects: number,
2124
+ * delivery_rate: 0..1 (delivered / attempts) or null if zero attempts,
2125
+ * per_agent: [{ agent_name, push_count, last_push_at, status }]
2126
+ * }
2127
+ */
2128
+ app.get('/telemetry/channels', async (_request, reply) => {
2129
+ const perAgent = db.prepare(`
2130
+ SELECT a.name AS agent_name, cs.push_count, cs.last_push_at, cs.status,
2131
+ cs.connected_at
2132
+ FROM coord_channel_sessions cs
2133
+ JOIN coord_agents a ON a.id = cs.agent_id
2134
+ ORDER BY cs.push_count DESC, cs.connected_at DESC
2135
+ `).all();
2136
+
2137
+ const deliveryRate = channelMetrics.attempts > 0
2138
+ ? channelMetrics.delivered / channelMetrics.attempts
2139
+ : null;
2140
+
2141
+ return reply.send({
2142
+ since: new Date(channelMetrics.started_at).toISOString(),
2143
+ uptime_seconds: Math.round((Date.now() - channelMetrics.started_at) / 1000),
2144
+ attempts: channelMetrics.attempts,
2145
+ delivered: channelMetrics.delivered,
2146
+ failed_http: channelMetrics.failed_http,
2147
+ failed_unreachable: channelMetrics.failed_unreachable,
2148
+ no_session: channelMetrics.no_session,
2149
+ fallback_mailbox: channelMetrics.fallback_mailbox,
2150
+ session_disconnects: channelMetrics.session_disconnects,
2151
+ delivery_rate: deliveryRate,
2152
+ per_agent: perAgent,
2153
+ });
2154
+ });
2155
+ }