agent-working-memory 0.5.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +78 -43
  2. package/dist/adapters/claude-code.d.ts +4 -0
  3. package/dist/adapters/claude-code.d.ts.map +1 -0
  4. package/dist/adapters/claude-code.js +218 -0
  5. package/dist/adapters/claude-code.js.map +1 -0
  6. package/dist/adapters/codex.d.ts +4 -0
  7. package/dist/adapters/codex.d.ts.map +1 -0
  8. package/dist/adapters/codex.js +226 -0
  9. package/dist/adapters/codex.js.map +1 -0
  10. package/dist/adapters/common.d.ts +34 -0
  11. package/dist/adapters/common.d.ts.map +1 -0
  12. package/dist/adapters/common.js +145 -0
  13. package/dist/adapters/common.js.map +1 -0
  14. package/dist/adapters/cursor.d.ts +4 -0
  15. package/dist/adapters/cursor.d.ts.map +1 -0
  16. package/dist/adapters/cursor.js +138 -0
  17. package/dist/adapters/cursor.js.map +1 -0
  18. package/dist/adapters/http.d.ts +4 -0
  19. package/dist/adapters/http.d.ts.map +1 -0
  20. package/dist/adapters/http.js +88 -0
  21. package/dist/adapters/http.js.map +1 -0
  22. package/dist/adapters/index.d.ts +7 -0
  23. package/dist/adapters/index.d.ts.map +1 -0
  24. package/dist/adapters/index.js +21 -0
  25. package/dist/adapters/index.js.map +1 -0
  26. package/dist/adapters/types.d.ts +65 -0
  27. package/dist/adapters/types.d.ts.map +1 -0
  28. package/dist/adapters/types.js +4 -0
  29. package/dist/adapters/types.js.map +1 -0
  30. package/dist/api/routes.d.ts.map +1 -1
  31. package/dist/api/routes.js +40 -1
  32. package/dist/api/routes.js.map +1 -1
  33. package/dist/cli.js +504 -230
  34. package/dist/cli.js.map +1 -1
  35. package/dist/coordination/events.d.ts +59 -0
  36. package/dist/coordination/events.d.ts.map +1 -0
  37. package/dist/coordination/events.js +28 -0
  38. package/dist/coordination/events.js.map +1 -0
  39. package/dist/coordination/index.d.ts +10 -1
  40. package/dist/coordination/index.d.ts.map +1 -1
  41. package/dist/coordination/index.js +87 -3
  42. package/dist/coordination/index.js.map +1 -1
  43. package/dist/coordination/mcp-tools.d.ts.map +1 -1
  44. package/dist/coordination/mcp-tools.js +10 -5
  45. package/dist/coordination/mcp-tools.js.map +1 -1
  46. package/dist/coordination/peer-decisions.d.ts +40 -0
  47. package/dist/coordination/peer-decisions.d.ts.map +1 -0
  48. package/dist/coordination/peer-decisions.js +82 -0
  49. package/dist/coordination/peer-decisions.js.map +1 -0
  50. package/dist/coordination/plugin-loader.d.ts +18 -0
  51. package/dist/coordination/plugin-loader.d.ts.map +1 -0
  52. package/dist/coordination/plugin-loader.js +55 -0
  53. package/dist/coordination/plugin-loader.js.map +1 -0
  54. package/dist/coordination/plugin.d.ts +40 -0
  55. package/dist/coordination/plugin.d.ts.map +1 -0
  56. package/dist/coordination/plugin.js +22 -0
  57. package/dist/coordination/plugin.js.map +1 -0
  58. package/dist/coordination/routes.d.ts +2 -1
  59. package/dist/coordination/routes.d.ts.map +1 -1
  60. package/dist/coordination/routes.js +1027 -65
  61. package/dist/coordination/routes.js.map +1 -1
  62. package/dist/coordination/schema.d.ts.map +1 -1
  63. package/dist/coordination/schema.js +104 -12
  64. package/dist/coordination/schema.js.map +1 -1
  65. package/dist/coordination/schemas.d.ts +105 -5
  66. package/dist/coordination/schemas.d.ts.map +1 -1
  67. package/dist/coordination/schemas.js +87 -1
  68. package/dist/coordination/schemas.js.map +1 -1
  69. package/dist/coordination/stale.d.ts +2 -0
  70. package/dist/coordination/stale.d.ts.map +1 -1
  71. package/dist/coordination/stale.js +7 -1
  72. package/dist/coordination/stale.js.map +1 -1
  73. package/dist/coordination/types.d.ts +252 -0
  74. package/dist/coordination/types.d.ts.map +1 -0
  75. package/dist/coordination/types.js +8 -0
  76. package/dist/coordination/types.js.map +1 -0
  77. package/dist/coordination/write-mutex.d.ts +26 -0
  78. package/dist/coordination/write-mutex.d.ts.map +1 -0
  79. package/dist/coordination/write-mutex.js +63 -0
  80. package/dist/coordination/write-mutex.js.map +1 -0
  81. package/dist/core/embeddings.d.ts +2 -0
  82. package/dist/core/embeddings.d.ts.map +1 -1
  83. package/dist/core/embeddings.js +4 -0
  84. package/dist/core/embeddings.js.map +1 -1
  85. package/dist/engine/activation.d.ts.map +1 -1
  86. package/dist/engine/activation.js +135 -26
  87. package/dist/engine/activation.js.map +1 -1
  88. package/dist/engine/consolidation.d.ts.map +1 -1
  89. package/dist/engine/consolidation.js +42 -12
  90. package/dist/engine/consolidation.js.map +1 -1
  91. package/dist/engine/retraction.d.ts +3 -1
  92. package/dist/engine/retraction.d.ts.map +1 -1
  93. package/dist/engine/retraction.js +19 -6
  94. package/dist/engine/retraction.js.map +1 -1
  95. package/dist/index.js +82 -16
  96. package/dist/index.js.map +1 -1
  97. package/dist/mcp.js +113 -6
  98. package/dist/mcp.js.map +1 -1
  99. package/dist/storage/sqlite.d.ts +24 -1
  100. package/dist/storage/sqlite.d.ts.map +1 -1
  101. package/dist/storage/sqlite.js +88 -7
  102. package/dist/storage/sqlite.js.map +1 -1
  103. package/dist/types/engram.d.ts +24 -0
  104. package/dist/types/engram.d.ts.map +1 -1
  105. package/dist/types/engram.js.map +1 -1
  106. package/package.json +3 -1
  107. package/src/adapters/claude-code.ts +234 -0
  108. package/src/adapters/codex.ts +262 -0
  109. package/src/adapters/common.ts +172 -0
  110. package/src/adapters/cursor.ts +150 -0
  111. package/src/adapters/http.ts +100 -0
  112. package/src/adapters/index.ts +31 -0
  113. package/src/adapters/types.ts +75 -0
  114. package/src/api/routes.ts +50 -1
  115. package/src/cli.ts +561 -239
  116. package/src/coordination/events.ts +90 -0
  117. package/src/coordination/index.ts +102 -3
  118. package/src/coordination/mcp-tools.ts +10 -5
  119. package/src/coordination/peer-decisions.ts +105 -0
  120. package/src/coordination/plugin-loader.ts +60 -0
  121. package/src/coordination/plugin.ts +44 -0
  122. package/src/coordination/routes.ts +1353 -92
  123. package/src/coordination/schema.ts +91 -12
  124. package/src/coordination/schemas.ts +104 -1
  125. package/src/coordination/stale.ts +11 -2
  126. package/src/coordination/types.ts +311 -0
  127. package/src/coordination/write-mutex.ts +69 -0
  128. package/src/core/embeddings.ts +5 -0
  129. package/src/engine/activation.ts +138 -26
  130. package/src/engine/consolidation.ts +44 -12
  131. package/src/engine/retraction.ts +22 -6
  132. package/src/index.ts +76 -14
  133. package/src/mcp.ts +142 -9
  134. package/src/storage/sqlite.ts +92 -7
  135. package/src/types/engram.ts +28 -0
@@ -8,14 +8,18 @@
8
8
 
9
9
  import type { FastifyInstance } from 'fastify';
10
10
  import type Database from 'better-sqlite3';
11
+ import type { EngramStore } from '../storage/sqlite.js';
11
12
  import { randomUUID } from 'node:crypto';
12
13
  import {
13
- checkinSchema, checkoutSchema, pulseSchema,
14
- assignCreateSchema, assignmentQuerySchema, assignmentClaimSchema, assignmentUpdateSchema, assignmentIdParamSchema,
14
+ checkinSchema, checkoutSchema, pulseSchema, nextSchema,
15
+ assignCreateSchema, assignmentQuerySchema, assignmentClaimSchema, assignmentUpdateSchema, assignmentIdParamSchema, assignmentsListSchema, reassignSchema,
15
16
  lockAcquireSchema, lockReleaseSchema,
16
17
  commandCreateSchema, commandWaitQuerySchema,
17
- findingCreateSchema, findingsQuerySchema, findingIdParamSchema,
18
+ findingCreateSchema, findingsQuerySchema, findingIdParamSchema, findingUpdateSchema,
19
+ decisionsQuerySchema, decisionCreateSchema,
18
20
  eventsQuerySchema, staleQuerySchema, workersQuerySchema,
21
+ agentIdParamSchema, timelineQuerySchema,
22
+ channelRegisterSchema, channelDeregisterSchema, channelPushSchema,
19
23
  } from './schemas.js';
20
24
  import { detectStale, cleanupStale } from './stale.js';
21
25
 
@@ -29,12 +33,72 @@ function coordLog(msg: string): void {
29
33
  console.log(`${ts()} [coord] ${msg}`);
30
34
  }
31
35
 
32
- export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Database): void {
36
+ /**
37
+ * Optional session-token check.
38
+ * If X-Session-Token header is present and doesn't match the stored token → returns false (caller should 403).
39
+ * If header is absent, or no token stored (old agent row) → returns true (pass through).
40
+ */
41
+ function sessionTokenOk(db: Database.Database, agentId: string, req: import('fastify').FastifyRequest): boolean {
42
+ const provided = req.headers['x-session-token'];
43
+ if (!provided) return true;
44
+ const row = db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(agentId) as { session_token: string | null } | undefined;
45
+ if (!row || !row.session_token) return true; // not found or no token stored — backward compat
46
+ return row.session_token === provided;
47
+ }
33
48
 
34
- // Log errors and non-200 responses
49
+ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Database, store?: EngramStore, eventBus?: import('./events.js').CoordinationEventBus): void {
50
+
51
+ // Request logging — one line per request with method, url, status, response time
52
+ app.addHook('onRequest', async (request) => {
53
+ (request as any)._startTime = Date.now();
54
+ });
35
55
  app.addHook('onResponse', async (request, reply) => {
36
- if (reply.statusCode >= 400) {
37
- coordLog(`${request.method} ${request.url} ${reply.statusCode}`);
56
+ const ms = Date.now() - ((request as any)._startTime ?? Date.now());
57
+ // Skip noisy polling endpoints at 2xx to reduce log spam
58
+ const isPolling = (request.url === '/next' || request.url === '/pulse' || request.url === '/health') && reply.statusCode < 300;
59
+ if (!isPolling) {
60
+ coordLog(`${request.method} ${request.url} ${reply.statusCode} ${ms}ms`);
61
+ }
62
+ });
63
+
64
+ // Pulse coalescing — skip DB write if last pulse was <10s ago
65
+ const PULSE_COALESCE_MS = 10_000;
66
+ const lastPulseTime = new Map<string, number>();
67
+
68
+ // Rate limiting — 300 requests/minute per agent (sliding window)
69
+ // Hive agents poll frequently + synapse-push polls /events every 2s
70
+ const RATE_LIMIT = 300;
71
+ const RATE_WINDOW_MS = 60_000;
72
+ const rateBuckets = new Map<string, number[]>();
73
+
74
+ // Cleanup stale buckets every 5 minutes
75
+ setInterval(() => {
76
+ const cutoff = Date.now() - RATE_WINDOW_MS;
77
+ for (const [key, timestamps] of rateBuckets) {
78
+ const fresh = timestamps.filter(t => t > cutoff);
79
+ if (fresh.length === 0) rateBuckets.delete(key);
80
+ else rateBuckets.set(key, fresh);
81
+ }
82
+ }, 300_000).unref();
83
+
84
+ app.addHook('preHandler', async (request, reply) => {
85
+ if (request.url === '/health') return; // exempt
86
+
87
+ // Identify agent by name from body or query, or agentId
88
+ const body = request.body as Record<string, unknown> | undefined;
89
+ const query = request.query as Record<string, unknown> | undefined;
90
+ const key = (body?.name ?? body?.agentId ?? query?.agentId ?? query?.name ?? request.ip) as string;
91
+ if (!key) return;
92
+
93
+ const now = Date.now();
94
+ const cutoff = now - RATE_WINDOW_MS;
95
+ const timestamps = rateBuckets.get(key) ?? [];
96
+ const recent = timestamps.filter(t => t > cutoff);
97
+ recent.push(now);
98
+ rateBuckets.set(key, recent);
99
+
100
+ if (recent.length > RATE_LIMIT) {
101
+ return reply.code(429).send({ error: `rate limit exceeded — max ${RATE_LIMIT} requests/minute` });
38
102
  }
39
103
  });
40
104
 
@@ -43,40 +107,115 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
43
107
  app.post('/checkin', async (req, reply) => {
44
108
  const parsed = checkinSchema.safeParse(req.body);
45
109
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
46
- const { name, role, pid, metadata, capabilities, workspace } = parsed.data;
110
+ const { name, role, pid, metadata, capabilities, workspace, channelUrl } = parsed.data;
47
111
  const capsJson = capabilities ? JSON.stringify(capabilities) : null;
48
112
 
49
- const existing = workspace
113
+ // Look up ANY existing agent with same name+workspace — including dead ones (upsert)
114
+ // Falls back to name-only to handle workspace changes between sessions
115
+ let existing = workspace
50
116
  ? db.prepare(
51
- `SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead'`
117
+ `SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`
52
118
  ).get(name, workspace) as { id: string; status: string } | undefined
53
119
  : db.prepare(
54
- `SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL AND status != 'dead'`
120
+ `SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`
55
121
  ).get(name) as { id: string; status: string } | undefined;
56
122
 
123
+ if (!existing) {
124
+ existing = db.prepare(
125
+ `SELECT id, status FROM coord_agents WHERE name = ? ORDER BY last_seen DESC LIMIT 1`
126
+ ).get(name) as { id: string; status: string } | undefined;
127
+ }
128
+
57
129
  if (existing) {
130
+ const wasDead = existing.status === 'dead';
131
+ // Issue a fresh token on reconnect; reuse existing token for live heartbeats
132
+ const sessionToken = wasDead ? randomUUID() : (
133
+ (db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(existing.id) as { session_token: string | null }).session_token ?? randomUUID()
134
+ );
58
135
  db.prepare(
59
- `UPDATE coord_agents SET last_seen = datetime('now'), status = CASE WHEN status = 'dead' THEN 'idle' ELSE status END, pid = COALESCE(?, pid), capabilities = COALESCE(?, capabilities) WHERE id = ?`
60
- ).run(pid ?? null, capsJson, existing.id);
136
+ `UPDATE coord_agents SET last_seen = datetime('now'), status = CASE WHEN status = 'dead' THEN 'idle' ELSE status END, pid = COALESCE(?, pid), capabilities = COALESCE(?, capabilities), workspace = COALESCE(?, workspace), session_token = ? WHERE id = ?`
137
+ ).run(pid ?? null, capsJson, workspace ?? null, sessionToken, existing.id);
61
138
 
139
+ const eventType = wasDead ? 'reconnected' : 'heartbeat';
140
+ const detail = wasDead ? `${name} reconnected (was dead)` : `heartbeat from ${name}`;
62
141
  db.prepare(
63
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'heartbeat', ?)`
64
- ).run(existing.id, `heartbeat from ${name}`);
65
-
66
- return reply.send({ agentId: existing.id, action: 'heartbeat', status: existing.status, workspace });
142
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`
143
+ ).run(existing.id, eventType, detail);
144
+
145
+ if (wasDead) coordLog(`${name} reconnected (reusing UUID ${existing.id.slice(0, 8)})`);
146
+ // Auto-register channel session if channelUrl provided
147
+ if (channelUrl) {
148
+ db.prepare(`
149
+ INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
150
+ VALUES (?, ?, datetime('now'), 'connected')
151
+ ON CONFLICT(agent_id) DO UPDATE SET
152
+ channel_id = excluded.channel_id,
153
+ connected_at = datetime('now'),
154
+ status = 'connected',
155
+ push_count = 0,
156
+ last_push_at = NULL
157
+ `).run(existing.id, channelUrl);
158
+ coordLog(`channel auto-registered: ${name} (${existing.id.slice(0, 8)}) → ${channelUrl}`);
159
+ }
160
+ const action = wasDead ? 'reconnected' : 'heartbeat';
161
+ const status = wasDead ? 'idle' : existing.status;
162
+ return reply.send({ agentId: existing.id, sessionToken, action, status, workspace });
67
163
  }
68
164
 
69
165
  const id = randomUUID();
166
+ const sessionToken = randomUUID();
70
167
  db.prepare(
71
- `INSERT INTO coord_agents (id, name, role, pid, status, metadata, capabilities, workspace) VALUES (?, ?, ?, ?, 'idle', ?, ?, ?)`
72
- ).run(id, name, role ?? 'worker', pid ?? null, metadata ? JSON.stringify(metadata) : null, capsJson, workspace ?? null);
168
+ `INSERT INTO coord_agents (id, name, role, pid, status, metadata, capabilities, workspace, session_token) VALUES (?, ?, ?, ?, 'idle', ?, ?, ?, ?)`
169
+ ).run(id, name, role ?? 'worker', pid ?? null, metadata ? JSON.stringify(metadata) : null, capsJson, workspace ?? null, sessionToken);
73
170
 
74
171
  db.prepare(
75
172
  `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`
76
173
  ).run(id, `${name} joined as ${role ?? 'worker'}${workspace ? ' [' + workspace + ']' : ''}${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
77
174
 
175
+ // Auto-register channel session if channelUrl provided
176
+ if (channelUrl) {
177
+ db.prepare(`
178
+ INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
179
+ VALUES (?, ?, datetime('now'), 'connected')
180
+ ON CONFLICT(agent_id) DO UPDATE SET
181
+ channel_id = excluded.channel_id,
182
+ connected_at = datetime('now'),
183
+ status = 'connected',
184
+ push_count = 0,
185
+ last_push_at = NULL
186
+ `).run(id, channelUrl);
187
+ coordLog(`channel auto-registered: ${name} (${id.slice(0, 8)}) → ${channelUrl}`);
188
+ }
189
+
78
190
  coordLog(`${name} registered (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
79
- return reply.code(201).send({ agentId: id, action: 'registered', status: 'idle', workspace });
191
+ eventBus?.emit('agent.checkin', { agentId: id, name, role: role ?? 'worker', workspace: workspace ?? undefined });
192
+ return reply.code(201).send({ agentId: id, sessionToken, action: 'registered', status: 'idle', workspace });
193
+ });
194
+
195
+ // ─── Shutdown (graceful coordination teardown) ─────────────────
196
+
197
+ app.post('/shutdown', async (_req, reply) => {
198
+ // Mark all live agents as dead
199
+ const alive = db.prepare(
200
+ `SELECT id, name FROM coord_agents WHERE status != 'dead'`
201
+ ).all() as Array<{ id: string; name: string }>;
202
+
203
+ const shutdownTx = db.transaction(() => {
204
+ for (const agent of alive) {
205
+ db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(agent.id);
206
+ db.prepare(`UPDATE coord_agents SET status = 'dead', current_task = NULL WHERE id = ?`).run(agent.id);
207
+ db.prepare(
208
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'shutdown', 'graceful shutdown')`
209
+ ).run(agent.id);
210
+ }
211
+ });
212
+ shutdownTx();
213
+
214
+ // Flush WAL before caller terminates the process
215
+ try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch { /* non-fatal if DB is closing */ }
216
+
217
+ coordLog(`Graceful shutdown: ${alive.length} agent(s) marked offline`);
218
+ return reply.send({ ok: true, agents_marked_offline: alive.length });
80
219
  });
81
220
 
82
221
  app.post('/checkout', async (req, reply) => {
@@ -84,17 +223,25 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
84
223
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
85
224
  const { agentId } = parsed.data;
86
225
 
87
- db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(agentId);
88
- db.prepare(
89
- `UPDATE coord_agents SET status = 'dead', last_seen = datetime('now') WHERE id = ?`
90
- ).run(agentId);
91
- db.prepare(
92
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'checkout', 'agent signed off')`
93
- ).run(agentId);
226
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
94
227
 
95
- // Look up agent name for logging
228
+ // Atomic transaction: delete locks + channel session + update agent + event
229
+ const checkoutTx = db.transaction(() => {
230
+ db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(agentId);
231
+ db.prepare(`DELETE FROM coord_channel_sessions WHERE agent_id = ?`).run(agentId);
232
+ db.prepare(
233
+ `UPDATE coord_agents SET status = 'dead', last_seen = datetime('now') WHERE id = ?`
234
+ ).run(agentId);
235
+ db.prepare(
236
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'checkout', 'agent signed off')`
237
+ ).run(agentId);
238
+ });
239
+ checkoutTx();
240
+
241
+ // Look up agent name for logging (outside tx — read-only)
96
242
  const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
97
243
  coordLog(`${agent?.name ?? agentId} checked out`);
244
+ eventBus?.emit('agent.checkout', { agentId, name: agent?.name ?? agentId });
98
245
  return reply.send({ ok: true });
99
246
  });
100
247
 
@@ -105,44 +252,375 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
105
252
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
106
253
  const { agentId } = parsed.data;
107
254
 
255
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
256
+
257
+ // Coalesce: skip DB write if last pulse was <10s ago
258
+ const now = Date.now();
259
+ const lastTime = lastPulseTime.get(agentId) ?? 0;
260
+ if (now - lastTime < PULSE_COALESCE_MS) {
261
+ return reply.send({ ok: true, coalesced: true });
262
+ }
263
+
264
+ lastPulseTime.set(agentId, now);
108
265
  db.prepare(`UPDATE coord_agents SET last_seen = datetime('now') WHERE id = ?`).run(agentId);
109
266
  return reply.send({ ok: true });
110
267
  });
111
268
 
269
+ // ─── Next (combined checkin + commands + assignment poll) ───────
270
+
271
+ app.post('/next', async (req, reply) => {
272
+ const parsed = nextSchema.safeParse(req.body);
273
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
274
+ const { name, workspace, role, capabilities, channelUrl } = parsed.data;
275
+ const capsJson = capabilities ? JSON.stringify(capabilities) : null;
276
+
277
+ // Step 1: Upsert agent (checkin / heartbeat) — including dead agents (reuse UUID)
278
+ // Try exact name+workspace match first, then fall back to name-only to handle
279
+ // workspace changes between sessions (prevents orphaned assignments on old UUID)
280
+ let existing = workspace
281
+ ? db.prepare(
282
+ `SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`
283
+ ).get(name, workspace) as { id: string; status: string } | undefined
284
+ : db.prepare(
285
+ `SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`
286
+ ).get(name) as { id: string; status: string } | undefined;
287
+
288
+ // Fallback: name-only lookup if exact match failed (handles workspace change, e.g. NULL→PERSONAL)
289
+ if (!existing) {
290
+ existing = db.prepare(
291
+ `SELECT id, status FROM coord_agents WHERE name = ? ORDER BY last_seen DESC LIMIT 1`
292
+ ).get(name) as { id: string; status: string } | undefined;
293
+ }
294
+
295
+ let agentId: string;
296
+ let sessionToken: string;
297
+ if (existing) {
298
+ agentId = existing.id;
299
+ const wasDead = existing.status === 'dead';
300
+ // Fresh token on reconnect; reuse existing on heartbeat
301
+ const existingToken = (db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(agentId) as { session_token: string | null }).session_token;
302
+ sessionToken = wasDead ? randomUUID() : (existingToken ?? randomUUID());
303
+ db.prepare(
304
+ `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 = ?`
305
+ ).run(capsJson, workspace ?? null, sessionToken, agentId);
306
+ const eventType = wasDead ? 'reconnected' : 'heartbeat';
307
+ const detail = wasDead ? `${name} reconnected via /next` : `heartbeat from ${name}`;
308
+ db.prepare(
309
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`
310
+ ).run(agentId, eventType, detail);
311
+ if (wasDead) coordLog(`${name} reconnected via /next (reusing UUID ${agentId.slice(0, 8)})`);
312
+ } else {
313
+ agentId = randomUUID();
314
+ sessionToken = randomUUID();
315
+ db.prepare(
316
+ `INSERT INTO coord_agents (id, name, role, pid, status, metadata, capabilities, workspace, session_token) VALUES (?, ?, ?, NULL, 'idle', NULL, ?, ?, ?)`
317
+ ).run(agentId, name, role ?? 'worker', capsJson, workspace ?? null, sessionToken);
318
+ db.prepare(
319
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`
320
+ ).run(agentId, `${name} joined as ${role ?? 'worker'} via /next`);
321
+ coordLog(`${name} registered via /next (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
322
+ }
323
+
324
+ // Auto-register channel session if channelUrl provided
325
+ if (channelUrl) {
326
+ db.prepare(`
327
+ INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
328
+ VALUES (?, ?, datetime('now'), 'connected')
329
+ ON CONFLICT(agent_id) DO UPDATE SET
330
+ channel_id = excluded.channel_id,
331
+ connected_at = datetime('now'),
332
+ status = 'connected',
333
+ push_count = 0,
334
+ last_push_at = NULL
335
+ `).run(agentId, channelUrl);
336
+ coordLog(`channel auto-registered via /next: ${name} (${agentId.slice(0, 8)}) → ${channelUrl}`);
337
+ }
338
+
339
+ // Step 2: Get active commands
340
+ const activeCommands = workspace
341
+ ? db.prepare(
342
+ `SELECT id, command, reason, issued_by, issued_at, workspace
343
+ FROM coord_commands WHERE cleared_at IS NULL AND (workspace = ? OR workspace IS NULL)
344
+ ORDER BY issued_at DESC`
345
+ ).all(workspace) as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>
346
+ : db.prepare(
347
+ `SELECT id, command, reason, issued_by, issued_at, workspace
348
+ FROM coord_commands WHERE cleared_at IS NULL
349
+ ORDER BY issued_at DESC`
350
+ ).all() as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>;
351
+
352
+ // Step 3: Get or auto-claim assignment
353
+ let assignment = db.prepare(
354
+ `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
355
+ ).get(agentId) as Record<string, unknown> | undefined;
356
+
357
+ // Cross-UUID fallback: check if this agent name has assignments under a different UUID
358
+ // (happens when POST /assign resolved worker_name to a stale/alternate UUID)
359
+ if (!assignment) {
360
+ const altIds = db.prepare(
361
+ `SELECT id FROM coord_agents WHERE name = ? AND id != ? AND status != 'dead'`
362
+ ).all(name, agentId) as Array<{ id: string }>;
363
+
364
+ for (const alt of altIds) {
365
+ const altActive = db.prepare(
366
+ `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
367
+ ).get(alt.id) as Record<string, unknown> | undefined;
368
+ if (altActive) {
369
+ // Migrate assignment to the current agent UUID
370
+ db.prepare(`UPDATE coord_assignments SET agent_id = ? WHERE id = ?`).run(agentId, altActive.id as string);
371
+ db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`).run(altActive.id as string, agentId);
372
+ altActive.agent_id = agentId;
373
+ 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})`);
374
+ assignment = altActive;
375
+ break;
376
+ }
377
+ }
378
+ }
379
+
380
+ if (!assignment) {
381
+ const agentWorkspace = workspace ?? null;
382
+ // Priority-ordered dispatch: higher priority first, then FIFO.
383
+ // Skip assignments blocked by incomplete dependencies.
384
+ const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
385
+
386
+ // First, check for tasks reserved specifically for this agent
387
+ const reserved = db.prepare(
388
+ `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id = ? ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
389
+ ).get(agentId) as { id: string } | undefined;
390
+
391
+ // Then fall back to truly unassigned tasks (agent_id IS NULL)
392
+ const pending = reserved ?? (agentWorkspace
393
+ ? db.prepare(
394
+ `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`
395
+ ).get(agentWorkspace) as { id: string } | undefined
396
+ : db.prepare(
397
+ `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id IS NULL ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
398
+ ).get() as { id: string } | undefined);
399
+
400
+ if (pending) {
401
+ const claimed = db.prepare(
402
+ `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`
403
+ ).run(agentId, pending.id);
404
+
405
+ if (claimed.changes > 0) {
406
+ db.prepare(
407
+ `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
408
+ ).run(pending.id, agentId);
409
+ db.prepare(
410
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_claimed', ?)`
411
+ ).run(agentId, `auto-claimed assignment ${pending.id} via /next`);
412
+ assignment = db.prepare(`SELECT * FROM coord_assignments WHERE id = ?`).get(pending.id) as Record<string, unknown> | undefined;
413
+ }
414
+ }
415
+ }
416
+
417
+ // If agent has an active assignment, ensure status is 'working'
418
+ if (assignment) {
419
+ db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ? AND status != 'working'`).run(assignment.id as string, agentId);
420
+ }
421
+
422
+ // Read current agent status after all mutations
423
+ const agentRow = db.prepare(`SELECT status FROM coord_agents WHERE id = ?`).get(agentId) as { status: string };
424
+
425
+ // Deliver queued mailbox messages (persistent messages that survived disconnects/restarts)
426
+ const mailbox = db.prepare(
427
+ `SELECT id, message, source, created_at FROM coord_mailbox
428
+ WHERE worker_name = ? AND delivered_at IS NULL
429
+ AND (workspace = ? OR workspace IS NULL)
430
+ ORDER BY created_at ASC LIMIT 10`
431
+ ).all(name, workspace ?? null) as Array<{ id: number; message: string; source: string; created_at: string }>;
432
+
433
+ if (mailbox.length > 0) {
434
+ const ids = mailbox.map(m => m.id);
435
+ db.prepare(
436
+ `UPDATE coord_mailbox SET delivered_at = datetime('now') WHERE id IN (${ids.map(() => '?').join(',')})`
437
+ ).run(...ids);
438
+ coordLog(`mailbox: delivered ${mailbox.length} queued message(s) to ${name}`);
439
+ }
440
+
441
+ return reply.send({
442
+ agentId,
443
+ sessionToken,
444
+ status: agentRow.status,
445
+ assignment: assignment ?? null,
446
+ commands: activeCommands,
447
+ mailbox: mailbox.length > 0 ? mailbox.map(m => ({ message: m.message, source: m.source, queued_at: m.created_at })) : undefined,
448
+ });
449
+ });
450
+
112
451
  // ─── Assignments ────────────────────────────────────────────────
113
452
 
114
453
  app.post('/assign', async (req, reply) => {
115
454
  const parsed = assignCreateSchema.safeParse(req.body);
116
455
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
117
- const { agentId, task, description, workspace } = parsed.data;
456
+ const { task, description, workspace, priority, blocked_by, worker_name, context } = parsed.data;
457
+ let { agentId } = parsed.data;
458
+
459
+ // Resolve worker_name → agentId if agentId not provided
460
+ if (!agentId && worker_name) {
461
+ let found = workspace
462
+ ? db.prepare(
463
+ `SELECT id FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
464
+ ).get(worker_name, workspace) as { id: string } | undefined
465
+ : db.prepare(
466
+ `SELECT id FROM coord_agents WHERE name = ? AND workspace IS NULL AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
467
+ ).get(worker_name) as { id: string } | undefined;
468
+
469
+ // Fallback: name-only lookup (handles workspace changes)
470
+ if (!found) {
471
+ found = db.prepare(
472
+ `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
473
+ ).get(worker_name) as { id: string } | undefined;
474
+ }
118
475
 
119
- const id = randomUUID();
120
- db.prepare(
121
- `INSERT INTO coord_assignments (id, agent_id, task, description, status, workspace) VALUES (?, ?, ?, ?, ?, ?)`
122
- ).run(id, agentId ?? null, task, description ?? null, agentId ? 'assigned' : 'pending', workspace ?? null);
476
+ if (!found) {
477
+ return reply.code(404).send({ error: `worker not found: ${worker_name}` });
478
+ }
479
+ agentId = found.id;
480
+ }
123
481
 
482
+ // Reject if agent already has an active assignment
124
483
  if (agentId) {
484
+ const active = db.prepare(
485
+ `SELECT id, task FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') LIMIT 1`
486
+ ).get(agentId) as { id: string; task: string } | undefined;
487
+ if (active) {
488
+ return reply.code(409).send({ error: `agent already has active assignment: ${active.id}`, active_task: active.task });
489
+ }
490
+ }
491
+
492
+ const id = randomUUID();
493
+ let pushed = false;
494
+
495
+ // Atomic transaction: assignment insert + agent status + event + channel push
496
+ const assignTx = db.transaction(() => {
125
497
  db.prepare(
126
- `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
127
- ).run(id, agentId);
498
+ `INSERT INTO coord_assignments (id, agent_id, task, description, status, priority, blocked_by, workspace, started_at, context) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
499
+ ).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);
500
+
501
+ if (agentId) {
502
+ db.prepare(
503
+ `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
504
+ ).run(id, agentId);
505
+ }
506
+
507
+ db.prepare(
508
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_created', ?)`
509
+ ).run(agentId ?? null, `task: ${task}`);
510
+
511
+ // Record channel push intent in the DB (stats + event)
512
+ if (agentId) {
513
+ const session = db.prepare(
514
+ `SELECT agent_id, channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`
515
+ ).get(agentId) as { agent_id: string; channel_id: string } | undefined;
516
+ if (session) {
517
+ // Record channel_push event so agent sees it on next poll/restore
518
+ const pushMsg = `NEW ASSIGNMENT: ${task}${description ? ' — ' + description.slice(0, 200) : ''}`;
519
+ db.prepare(
520
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_push', ?)`
521
+ ).run(agentId, pushMsg.slice(0, 500));
522
+ pushed = true;
523
+ }
524
+ }
525
+ });
526
+ assignTx();
527
+
528
+ // Actually deliver the push to the worker's channel HTTP endpoint (outside DB transaction)
529
+ let delivered = false;
530
+ if (pushed && agentId) {
531
+ const session = db.prepare(
532
+ `SELECT channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`
533
+ ).get(agentId) as { channel_id: string } | undefined;
534
+ if (session) {
535
+ const pushMsg = `NEW ASSIGNMENT: ${task}${description ? ' — ' + description.slice(0, 200) : ''}`;
536
+ const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
537
+ const result = await deliverToChannel(
538
+ agentId, session.channel_id, pushMsg,
539
+ { source: 'coordinator', agent: agent?.name ?? agentId, assignmentId: id }
540
+ );
541
+ delivered = result.delivered;
542
+ if (delivered) {
543
+ db.prepare(
544
+ `UPDATE coord_channel_sessions SET last_push_at = datetime('now'), push_count = push_count + 1 WHERE agent_id = ?`
545
+ ).run(agentId);
546
+ }
547
+ }
128
548
  }
129
549
 
130
- db.prepare(
131
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_created', ?)`
132
- ).run(agentId ?? null, `task: ${task}`);
550
+ // Bridge context to AWM engrams (outside transaction — engram store has its own DB)
551
+ if (store && context) {
552
+ try {
553
+ const ctx = JSON.parse(context) as Record<string, unknown>;
554
+ const parts: string[] = [];
555
+ if (ctx.files) parts.push(`Files: ${JSON.stringify(ctx.files)}`);
556
+ if (ctx.references) parts.push(`References: ${JSON.stringify(ctx.references)}`);
557
+ if (ctx.decisions) parts.push(`Decisions: ${JSON.stringify(ctx.decisions)}`);
558
+ if (ctx.acceptance_criteria) parts.push(`Acceptance criteria: ${JSON.stringify(ctx.acceptance_criteria)}`);
559
+ // Include any remaining keys
560
+ for (const [k, v] of Object.entries(ctx)) {
561
+ if (!['files', 'references', 'decisions', 'acceptance_criteria'].includes(k) && v) {
562
+ parts.push(`${k}: ${JSON.stringify(v)}`);
563
+ }
564
+ }
565
+ if (parts.length > 0) {
566
+ store.createEngram({
567
+ agentId: agentId ?? 'coordinator',
568
+ concept: `Task context: ${task.slice(0, 80)}`,
569
+ content: parts.join('\n'),
570
+ tags: ['shared', 'context', `task/${id}`],
571
+ memoryClass: 'canonical',
572
+ });
573
+ }
574
+ } catch {
575
+ // Context is not valid JSON — skip engram bridge silently
576
+ }
577
+ }
578
+
579
+ // If push failed or no channel, queue to mailbox so worker gets it on next /next poll
580
+ let queued = false;
581
+ if (agentId && !delivered) {
582
+ const agent = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId) as { name: string; workspace: string | null } | undefined;
583
+ if (agent) {
584
+ const mailMsg = `NEW ASSIGNMENT [${id.slice(0, 8)}]: ${task.slice(0, 500)}`;
585
+ db.prepare(
586
+ `INSERT INTO coord_mailbox (worker_name, workspace, message, source) VALUES (?, ?, ?, 'coordinator')`
587
+ ).run(agent.name, agent.workspace, mailMsg);
588
+ queued = true;
589
+ coordLog(`mailbox/queue → ${agent.name}: assignment ${id.slice(0, 8)} (live push unavailable)`);
590
+ }
591
+ }
133
592
 
134
593
  // Log assignment with agent name
135
594
  if (agentId) {
136
595
  const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
137
- coordLog(`assigned → ${agent?.name ?? 'unknown'}: ${task.slice(0, 80)}`);
596
+ coordLog(`assigned → ${agent?.name ?? 'unknown'}: ${task.slice(0, 80)}${delivered ? ' (pushed+delivered)' : queued ? ' (queued to mailbox)' : ''}`);
138
597
  } else {
139
598
  coordLog(`assignment queued (pending): ${task.slice(0, 80)}`);
140
599
  }
141
- return reply.code(201).send({ assignmentId: id, status: agentId ? 'assigned' : 'pending' });
600
+ eventBus?.emit('assignment.created', { assignmentId: id, agentId: agentId ?? '', task, workspace: workspace ?? undefined });
601
+ return reply.code(201).send({ assignmentId: id, status: agentId ? 'assigned' : 'pending', pushed, delivered, queued });
142
602
  });
143
603
 
144
604
  app.get('/assignment', async (req, reply) => {
145
- const agentId = (req.headers['x-agent-id'] as string | undefined) ?? assignmentQuerySchema.parse(req.query).agentId;
605
+ const q = assignmentQuerySchema.parse(req.query);
606
+ let agentId = (req.headers['x-agent-id'] as string | undefined) ?? q.agentId;
607
+
608
+ // Fallback: resolve agentId from name + workspace (with name-only fallback)
609
+ if (!agentId && q.name) {
610
+ let found = q.workspace
611
+ ? db.prepare(
612
+ `SELECT id FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead'`
613
+ ).get(q.name, q.workspace) as { id: string } | undefined
614
+ : db.prepare(
615
+ `SELECT id FROM coord_agents WHERE name = ? AND workspace IS NULL AND status != 'dead'`
616
+ ).get(q.name) as { id: string } | undefined;
617
+ if (!found) {
618
+ found = db.prepare(
619
+ `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
620
+ ).get(q.name) as { id: string } | undefined;
621
+ }
622
+ agentId = found?.id;
623
+ }
146
624
 
147
625
  if (!agentId) {
148
626
  return reply.send({ assignment: null });
@@ -154,16 +632,47 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
154
632
 
155
633
  if (active) return reply.send({ assignment: active });
156
634
 
157
- const agentRow = db.prepare(`SELECT workspace FROM coord_agents WHERE id = ?`).get(agentId) as { workspace: string | null } | undefined;
158
- const agentWorkspace = agentRow?.workspace;
635
+ // Cross-UUID fallback: if the agent has other UUIDs (e.g., from workspace changes or reconnects
636
+ // that created a new row), check those too. This fixes the case where POST /assign resolved
637
+ // worker_name to a different UUID than the one the worker is currently using.
638
+ const agentRow = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId) as { name: string; workspace: string | null } | undefined;
639
+ if (agentRow) {
640
+ const altIds = db.prepare(
641
+ `SELECT id FROM coord_agents WHERE name = ? AND id != ? AND status != 'dead'`
642
+ ).all(agentRow.name, agentId) as Array<{ id: string }>;
643
+
644
+ for (const alt of altIds) {
645
+ const altActive = db.prepare(
646
+ `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
647
+ ).get(alt.id) as Record<string, unknown> | undefined;
648
+ if (altActive) {
649
+ // Reassign to the current agent UUID so future lookups work directly
650
+ db.prepare(`UPDATE coord_assignments SET agent_id = ? WHERE id = ?`).run(agentId, altActive.id as string);
651
+ db.prepare(`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`).run(altActive.id as string, agentId);
652
+ altActive.agent_id = agentId;
653
+ 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})`);
654
+ return reply.send({ assignment: altActive });
655
+ }
656
+ }
657
+ }
658
+
659
+ const agentWorkspace = agentRow?.workspace ?? null;
660
+
661
+ const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
159
662
 
160
- const pending = agentWorkspace
663
+ // First, check for tasks reserved specifically for this agent
664
+ const reserved = db.prepare(
665
+ `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id = ? ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
666
+ ).get(agentId) as { id: string } | undefined;
667
+
668
+ // Then fall back to truly unassigned tasks (agent_id IS NULL)
669
+ const pending = reserved ?? (agentWorkspace
161
670
  ? db.prepare(
162
- `SELECT * FROM coord_assignments WHERE status = 'pending' AND (workspace = ? OR workspace IS NULL) ORDER BY created_at ASC LIMIT 1`
671
+ `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`
163
672
  ).get(agentWorkspace) as { id: string } | undefined
164
673
  : db.prepare(
165
- `SELECT * FROM coord_assignments WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1`
166
- ).get() as { id: string } | undefined;
674
+ `SELECT * FROM coord_assignments WHERE status = 'pending' AND agent_id IS NULL ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
675
+ ).get() as { id: string } | undefined);
167
676
 
168
677
  if (pending) {
169
678
  const claimed = db.prepare(
@@ -185,7 +694,7 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
185
694
  }
186
695
 
187
696
  const busyCount = (db.prepare(
188
- `SELECT COUNT(*) as c FROM coord_agents WHERE status = 'working' AND last_seen > datetime('now', '-120 seconds')`
697
+ `SELECT COUNT(*) as c FROM coord_agents WHERE status = 'working' AND last_seen > datetime('now', '-300 seconds')`
189
698
  ).get() as { c: number }).c;
190
699
 
191
700
  const retryAfter = busyCount > 0 ? 30 : 300;
@@ -198,6 +707,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
198
707
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
199
708
  const { agentId } = parsed.data;
200
709
 
710
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
711
+
201
712
  const result = db.prepare(
202
713
  `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`
203
714
  ).run(agentId, id);
@@ -217,44 +728,227 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
217
728
  return reply.send({ ok: true, assignmentId: id });
218
729
  });
219
730
 
220
- function handleAssignmentUpdate(id: string, status: string, result: string | undefined) {
221
- if (['completed', 'failed'].includes(status)) {
222
- db.prepare(
223
- `UPDATE coord_assignments SET status = ?, result = ?, completed_at = datetime('now') WHERE id = ?`
224
- ).run(status, result ?? null, id);
225
- } else {
226
- db.prepare(
227
- `UPDATE coord_assignments SET status = ?, result = ? WHERE id = ?`
228
- ).run(status, result ?? null, id);
731
+ const VALID_TRANSITIONS: Record<string, string[]> = {
732
+ assigned: ['in_progress', 'failed'],
733
+ in_progress: ['completed', 'failed', 'blocked'],
734
+ blocked: ['in_progress', 'failed'],
735
+ };
736
+
737
+ function handleAssignmentUpdate(id: string, status: string, result: string | undefined, commitSha: string | undefined): { error?: string } {
738
+ // Status transition validation
739
+ const current = db.prepare(`SELECT status FROM coord_assignments WHERE id = ?`).get(id) as { status: string } | undefined;
740
+ if (!current) return { error: 'assignment not found' };
741
+
742
+ const allowed = VALID_TRANSITIONS[current.status];
743
+ if (allowed && !allowed.includes(status)) {
744
+ return { error: `invalid transition: ${current.status} → ${status}. Valid: ${allowed.join(', ')}` };
745
+ }
746
+ if (!allowed && ['completed', 'failed'].includes(current.status)) {
747
+ return { error: `cannot update ${current.status} assignment` };
229
748
  }
230
749
 
231
- if (['completed', 'failed'].includes(status)) {
232
- const assignment = db.prepare(`SELECT agent_id FROM coord_assignments WHERE id = ?`).get(id) as { agent_id: string } | undefined;
233
- if (assignment?.agent_id) {
234
- db.prepare(
235
- `UPDATE coord_agents SET status = 'idle', current_task = NULL WHERE id = ?`
236
- ).run(assignment.agent_id);
750
+ // Verification gate: completed status requires structured proof of work
751
+ if (status === 'completed') {
752
+ if (!result || result.trim().length < 20) {
753
+ return { error: 'completion requires a result summary — minimum 20 characters describing what was done' };
754
+ }
755
+ // Must mention at least one of: commit/SHA, build, audit, test, verified, fix, created, updated, implemented
756
+ 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;
757
+ if (!actionWords.test(result)) {
758
+ return { error: 'completion result must describe the work done — include what was committed, built, tested, or verified' };
237
759
  }
238
760
  }
239
761
 
240
- db.prepare(
241
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES ((SELECT agent_id FROM coord_assignments WHERE id = ?), 'assignment_update', ?)`
242
- ).run(id, `${id} → ${status}`);
762
+ // Atomic transaction: assignment update + agent status + event
763
+ const updateTx = db.transaction(() => {
764
+ if (['completed', 'failed'].includes(status)) {
765
+ db.prepare(
766
+ `UPDATE coord_assignments SET status = ?, result = ?, commit_sha = ?, completed_at = datetime('now') WHERE id = ?`
767
+ ).run(status, result ?? null, commitSha ?? null, id);
768
+ } else {
769
+ db.prepare(
770
+ `UPDATE coord_assignments SET status = ?, result = ? WHERE id = ?`
771
+ ).run(status, result ?? null, id);
772
+ }
773
+
774
+ if (['completed', 'failed'].includes(status)) {
775
+ const assignment = db.prepare(`SELECT agent_id FROM coord_assignments WHERE id = ?`).get(id) as { agent_id: string } | undefined;
776
+ if (assignment?.agent_id) {
777
+ db.prepare(
778
+ `UPDATE coord_agents SET status = 'idle', current_task = NULL WHERE id = ?`
779
+ ).run(assignment.agent_id);
780
+ }
781
+ }
782
+
783
+ const eventDetail = ['completed', 'failed'].includes(status)
784
+ ? `${id} → ${status}${commitSha ? ' [' + commitSha + ']' : ''}: ${(result ?? '').slice(0, 300)}`
785
+ : `${id} → ${status}`;
786
+ db.prepare(
787
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES ((SELECT agent_id FROM coord_assignments WHERE id = ?), 'assignment_update', ?)`
788
+ ).run(id, eventDetail);
789
+ });
790
+ updateTx();
243
791
 
244
- // Log completion/failure with agent name and task
792
+ // Log completion/failure with agent name and task (outside tx — read-only)
793
+ const assignInfo = db.prepare(
794
+ `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 = ?`
795
+ ).get(id) as { agent_id: string | null; task: string; agent_name: string | null } | undefined;
245
796
  if (['completed', 'failed'].includes(status)) {
246
- const info = db.prepare(
247
- `SELECT a.task, g.name AS agent_name FROM coord_assignments a LEFT JOIN coord_agents g ON a.agent_id = g.id WHERE a.id = ?`
248
- ).get(id) as { task: string; agent_name: string | null } | undefined;
249
- coordLog(`${info?.agent_name ?? 'unknown'} ${status}: ${info?.task?.slice(0, 80) ?? id}`);
797
+ coordLog(`${assignInfo?.agent_name ?? 'unknown'} ${status}: ${assignInfo?.task?.slice(0, 80) ?? id}`);
798
+ }
799
+
800
+ // Emit events
801
+ eventBus?.emit('assignment.updated', { assignmentId: id, agentId: assignInfo?.agent_id ?? null, status, result });
802
+ if (status === 'completed') {
803
+ eventBus?.emit('assignment.completed', { assignmentId: id, agentId: assignInfo?.agent_id ?? null, result: result ?? null });
250
804
  }
805
+
806
+ // Auto-unblock: when an assignment completes, unblock any assignments that depend on it
807
+ if (status === 'completed') {
808
+ const blocked = db.prepare(
809
+ `SELECT id, agent_id, task FROM coord_assignments WHERE blocked_by = ? AND status = 'blocked'`
810
+ ).all(id) as Array<{ id: string; agent_id: string | null; task: string }>;
811
+
812
+ if (blocked.length > 0) {
813
+ const unblockTx = db.transaction(() => {
814
+ for (const dep of blocked) {
815
+ db.prepare(
816
+ `UPDATE coord_assignments SET blocked_by = NULL, status = 'assigned' WHERE id = ?`
817
+ ).run(dep.id);
818
+ db.prepare(
819
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_unblocked', ?)`
820
+ ).run(dep.agent_id, `unblocked by completion of ${id}: ${dep.task.slice(0, 80)}`);
821
+ }
822
+ });
823
+ unblockTx();
824
+
825
+ for (const dep of blocked) {
826
+ coordLog(`auto-unblocked: ${dep.task.slice(0, 60)} (was blocked by ${id})`);
827
+ eventBus?.emit('assignment.updated', { assignmentId: dep.id, agentId: dep.agent_id, status: 'assigned', result: undefined });
828
+ }
829
+ }
830
+ }
831
+
832
+ return {};
251
833
  }
252
834
 
835
+ app.get('/assignment/:id', async (req, reply) => {
836
+ const { id } = assignmentIdParamSchema.parse(req.params);
837
+ const assignment = db.prepare(
838
+ `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 = ?`
839
+ ).get(id);
840
+ if (!assignment) return reply.code(404).send({ error: 'assignment not found' });
841
+ return reply.send({ assignment });
842
+ });
843
+
844
+ // List assignments with optional filters and pagination
845
+ app.get('/assignments', async (req, reply) => {
846
+ const q = assignmentsListSchema.parse(req.query);
847
+ const conditions: string[] = [];
848
+ const params: unknown[] = [];
849
+
850
+ if (q.status) {
851
+ conditions.push('a.status = ?');
852
+ params.push(q.status);
853
+ }
854
+ if (q.workspace) {
855
+ conditions.push('(a.workspace = ? OR a.workspace IS NULL)');
856
+ params.push(q.workspace);
857
+ }
858
+ if (q.agent_id) {
859
+ conditions.push('a.agent_id = ?');
860
+ params.push(q.agent_id);
861
+ }
862
+
863
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
864
+
865
+ const total = (db.prepare(
866
+ `SELECT COUNT(*) AS count FROM coord_assignments a ${where}`
867
+ ).get(...params) as { count: number }).count;
868
+
869
+ const assignments = db.prepare(
870
+ `SELECT a.*, g.name AS agent_name,
871
+ CASE WHEN a.blocked_by IS NOT NULL AND a.blocked_by NOT IN (SELECT id FROM coord_assignments WHERE status = 'completed')
872
+ THEN 1 ELSE 0 END AS is_blocked
873
+ FROM coord_assignments a
874
+ LEFT JOIN coord_agents g ON a.agent_id = g.id
875
+ ${where}
876
+ ORDER BY a.priority DESC, a.created_at DESC
877
+ LIMIT ? OFFSET ?`
878
+ ).all(...params, q.limit, q.offset);
879
+
880
+ return reply.send({ assignments, total });
881
+ });
882
+
883
+ app.post('/reassign', async (req, reply) => {
884
+ const parsed = reassignSchema.safeParse(req.body);
885
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
886
+ const { assignmentId, target_worker_name } = parsed.data;
887
+ let { targetAgentId } = parsed.data;
888
+
889
+ // Verify assignment exists and is active
890
+ const assignment = db.prepare(
891
+ `SELECT id, agent_id, task, status FROM coord_assignments WHERE id = ?`
892
+ ).get(assignmentId) as { id: string; agent_id: string | null; task: string; status: string } | undefined;
893
+ if (!assignment) return reply.code(404).send({ error: 'assignment not found' });
894
+ if (['completed', 'failed'].includes(assignment.status)) {
895
+ return reply.code(400).send({ error: `cannot reassign ${assignment.status} assignment` });
896
+ }
897
+
898
+ // Resolve target_worker_name → targetAgentId
899
+ if (!targetAgentId && target_worker_name) {
900
+ const found = db.prepare(
901
+ `SELECT id FROM coord_agents WHERE name = ? AND status != 'dead' ORDER BY last_seen DESC LIMIT 1`
902
+ ).get(target_worker_name) as { id: string } | undefined;
903
+ if (!found) return reply.code(404).send({ error: `target worker not found: ${target_worker_name}` });
904
+ targetAgentId = found.id;
905
+ }
906
+
907
+ // Verify targetAgentId exists
908
+ if (targetAgentId) {
909
+ const target = db.prepare(`SELECT id FROM coord_agents WHERE id = ?`).get(targetAgentId) as { id: string } | undefined;
910
+ if (!target) return reply.code(404).send({ error: 'target agent not found' });
911
+ }
912
+
913
+ // Release old agent: set idle, clear current_task, release locks
914
+ if (assignment.agent_id) {
915
+ db.prepare(
916
+ `UPDATE coord_agents SET status = 'idle', current_task = NULL WHERE id = ?`
917
+ ).run(assignment.agent_id);
918
+ db.prepare(
919
+ `DELETE FROM coord_locks WHERE agent_id = ?`
920
+ ).run(assignment.agent_id);
921
+ }
922
+
923
+ if (targetAgentId) {
924
+ // Reassign to target
925
+ db.prepare(
926
+ `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ?`
927
+ ).run(targetAgentId, assignmentId);
928
+ db.prepare(
929
+ `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
930
+ ).run(assignmentId, targetAgentId);
931
+ } else {
932
+ // No target — return to pending for auto-claim
933
+ db.prepare(
934
+ `UPDATE coord_assignments SET agent_id = NULL, status = 'pending', started_at = NULL WHERE id = ?`
935
+ ).run(assignmentId);
936
+ }
937
+
938
+ db.prepare(
939
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'reassignment', ?)`
940
+ ).run(assignment.agent_id ?? null, `${assignmentId} reassigned from ${assignment.agent_id ?? 'unassigned'} to ${targetAgentId ?? 'pending'}`);
941
+
942
+ coordLog(`reassign: ${assignment.task.slice(0, 60)} → ${targetAgentId ?? 'pending'}`);
943
+ return reply.send({ ok: true, assignmentId, newAgentId: targetAgentId ?? null, status: targetAgentId ? 'assigned' : 'pending' });
944
+ });
945
+
253
946
  app.post('/assignment/:id/update', async (req, reply) => {
254
947
  const { id } = assignmentIdParamSchema.parse(req.params);
255
948
  const parsed = assignmentUpdateSchema.safeParse(req.body);
256
949
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
257
- handleAssignmentUpdate(id, parsed.data.status, parsed.data.result);
950
+ const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
951
+ if (gate.error) return reply.code(400).send({ error: gate.error });
258
952
  return reply.send({ ok: true });
259
953
  });
260
954
 
@@ -262,7 +956,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
262
956
  const { id } = assignmentIdParamSchema.parse(req.params);
263
957
  const parsed = assignmentUpdateSchema.safeParse(req.body);
264
958
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
265
- handleAssignmentUpdate(id, parsed.data.status, parsed.data.result);
959
+ const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
960
+ if (gate.error) return reply.code(400).send({ error: gate.error });
266
961
  return reply.send({ ok: true });
267
962
  });
268
963
 
@@ -270,7 +965,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
270
965
  const { id } = assignmentIdParamSchema.parse(req.params);
271
966
  const parsed = assignmentUpdateSchema.safeParse(req.body);
272
967
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
273
- handleAssignmentUpdate(id, parsed.data.status, parsed.data.result);
968
+ const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
969
+ if (gate.error) return reply.code(400).send({ error: gate.error });
274
970
  return reply.send({ ok: true });
275
971
  });
276
972
 
@@ -281,6 +977,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
281
977
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
282
978
  const { agentId, filePath, reason } = parsed.data;
283
979
 
980
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
981
+
284
982
  const inserted = db.prepare(
285
983
  `INSERT OR IGNORE INTO coord_locks (file_path, agent_id, reason) VALUES (?, ?, ?)`
286
984
  ).run(filePath, agentId, reason ?? null);
@@ -312,6 +1010,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
312
1010
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
313
1011
  const { agentId, filePath } = parsed.data;
314
1012
 
1013
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1014
+
315
1015
  const result = db.prepare(
316
1016
  `DELETE FROM coord_locks WHERE file_path = ? AND agent_id = ?`
317
1017
  ).run(filePath, agentId);
@@ -331,7 +1031,7 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
331
1031
  const locks = db.prepare(
332
1032
  `SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
333
1033
  FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id
334
- ORDER BY l.locked_at DESC`
1034
+ ORDER BY l.locked_at DESC LIMIT 200`
335
1035
  ).all();
336
1036
 
337
1037
  return reply.send({ locks });
@@ -346,8 +1046,11 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
346
1046
 
347
1047
  if (command === 'RESUME') {
348
1048
  if (workspace) {
1049
+ // Clear commands targeting this workspace AND global commands (workspace IS NULL).
1050
+ // Global commands (e.g. SHUTDOWN with no workspace) apply to all workspaces,
1051
+ // so RESUME for a workspace must also clear them — otherwise they persist forever.
349
1052
  db.prepare(
350
- `UPDATE coord_commands SET cleared_at = datetime('now') WHERE cleared_at IS NULL AND workspace = ?`
1053
+ `UPDATE coord_commands SET cleared_at = datetime('now') WHERE cleared_at IS NULL AND (workspace = ? OR workspace IS NULL)`
351
1054
  ).run(workspace);
352
1055
  } else {
353
1056
  db.prepare(
@@ -405,6 +1108,25 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
405
1108
  });
406
1109
  });
407
1110
 
1111
+ app.delete('/command/:id', async (req, reply) => {
1112
+ const id = Number((req.params as Record<string, string>).id);
1113
+ if (!Number.isInteger(id) || id <= 0) return reply.code(400).send({ error: 'invalid command id' });
1114
+
1115
+ const result = db.prepare(
1116
+ `UPDATE coord_commands SET cleared_at = datetime('now') WHERE id = ? AND cleared_at IS NULL`
1117
+ ).run(id);
1118
+
1119
+ if (result.changes === 0) {
1120
+ return reply.code(404).send({ error: 'command not found or already cleared' });
1121
+ }
1122
+
1123
+ db.prepare(
1124
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (NULL, 'command', ?)`
1125
+ ).run(`command ${id} cleared via DELETE`);
1126
+
1127
+ return reply.send({ ok: true });
1128
+ });
1129
+
408
1130
  app.get('/command/wait', async (req, reply) => {
409
1131
  const q = commandWaitQuerySchema.safeParse(req.query);
410
1132
  const { status: targetStatus, workspace } = q.success ? q.data : { status: 'idle', workspace: undefined };
@@ -421,8 +1143,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
421
1143
  ORDER BY name`
422
1144
  ).all() as Array<{ id: string; name: string; role: string; status: string; current_task: string | null; last_seen: string }>;
423
1145
 
424
- const ready = agents.filter(a => a.status === targetStatus || a.role === 'orchestrator');
425
- const notReady = agents.filter(a => a.status !== targetStatus && a.role !== 'orchestrator');
1146
+ const ready = agents.filter(a => a.status === targetStatus || a.role === 'orchestrator' || a.role === 'coordinator');
1147
+ const notReady = agents.filter(a => a.status !== targetStatus && a.role !== 'orchestrator' && a.role !== 'coordinator');
426
1148
 
427
1149
  return reply.send({
428
1150
  allReady: notReady.length === 0,
@@ -439,6 +1161,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
439
1161
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
440
1162
  const { agentId, category, severity, filePath, lineNumber, description, suggestion } = parsed.data;
441
1163
 
1164
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1165
+
442
1166
  db.prepare(
443
1167
  `INSERT INTO coord_findings (agent_id, category, severity, file_path, line_number, description, suggestion)
444
1168
  VALUES (?, ?, ?, ?, ?, ?, ?)`
@@ -491,6 +1215,37 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
491
1215
  return reply.send({ ok: true });
492
1216
  });
493
1217
 
1218
+ app.patch('/finding/:id', async (req, reply) => {
1219
+ const { id } = findingIdParamSchema.parse(req.params);
1220
+ const parsed = findingUpdateSchema.safeParse(req.body);
1221
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
1222
+ const { status, suggestion } = parsed.data;
1223
+
1224
+ const existing = db.prepare(`SELECT id FROM coord_findings WHERE id = ?`).get(id);
1225
+ if (!existing) return reply.code(404).send({ error: 'finding not found' });
1226
+
1227
+ const sets: string[] = [];
1228
+ const params: unknown[] = [];
1229
+
1230
+ if (status) {
1231
+ sets.push('status = ?');
1232
+ params.push(status);
1233
+ if (status === 'resolved') {
1234
+ sets.push("resolved_at = datetime('now')");
1235
+ }
1236
+ }
1237
+ if (suggestion !== undefined) {
1238
+ sets.push('suggestion = ?');
1239
+ params.push(suggestion);
1240
+ }
1241
+
1242
+ if (sets.length === 0) return reply.send({ ok: true, changed: false });
1243
+
1244
+ params.push(id);
1245
+ db.prepare(`UPDATE coord_findings SET ${sets.join(', ')} WHERE id = ?`).run(...params);
1246
+ return reply.send({ ok: true, changed: true });
1247
+ });
1248
+
494
1249
  app.get('/findings/summary', async (_req, reply) => {
495
1250
  const bySeverity = db.prepare(
496
1251
  `SELECT severity, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY severity`
@@ -507,6 +1262,53 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
507
1262
  return reply.send({ total: total.total, bySeverity, byCategory });
508
1263
  });
509
1264
 
1265
+ // ─── Decisions (cross-agent propagation) ────────────────────────
1266
+
1267
+ app.get('/decisions', async (req, reply) => {
1268
+ const q = decisionsQuerySchema.safeParse(req.query);
1269
+ const { since_id, assignment_id, workspace, limit } = q.success ? q.data : { since_id: 0, assignment_id: undefined, workspace: undefined, limit: 20 };
1270
+
1271
+ let sql = `
1272
+ SELECT d.id, d.author_id, a.name AS author_name, d.assignment_id, d.tags, d.summary, d.created_at
1273
+ FROM coord_decisions d JOIN coord_agents a ON d.author_id = a.id
1274
+ WHERE d.id > ?
1275
+ `;
1276
+ const params: unknown[] = [since_id];
1277
+
1278
+ if (assignment_id) {
1279
+ sql += ` AND d.assignment_id = ?`;
1280
+ params.push(assignment_id);
1281
+ }
1282
+
1283
+ if (workspace) {
1284
+ sql += ` AND (a.workspace = ? OR a.workspace IS NULL)`;
1285
+ params.push(workspace);
1286
+ }
1287
+
1288
+ sql += ` ORDER BY d.created_at ASC LIMIT ?`;
1289
+ params.push(limit);
1290
+
1291
+ const decisions = db.prepare(sql).all(...params);
1292
+ return reply.send({ decisions });
1293
+ });
1294
+
1295
+ app.post('/decisions', async (req, reply) => {
1296
+ const { agentId, assignment_id, tags, summary } = decisionCreateSchema.parse(req.body);
1297
+
1298
+ // Verify agent exists
1299
+ const agent = db.prepare(`SELECT id FROM coord_agents WHERE id = ?`).get(agentId) as { id: string } | undefined;
1300
+ if (!agent) return reply.code(404).send({ error: 'agent not found' });
1301
+
1302
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1303
+
1304
+ db.prepare(
1305
+ `INSERT INTO coord_decisions (author_id, assignment_id, tags, summary) VALUES (?, ?, ?, ?)`
1306
+ ).run(agentId, assignment_id ?? null, tags ?? null, summary);
1307
+
1308
+ const row = db.prepare(`SELECT last_insert_rowid() AS id`).get() as { id: number };
1309
+ return reply.code(201).send({ ok: true, id: row.id });
1310
+ });
1311
+
510
1312
  // ─── Status ─────────────────────────────────────────────────────
511
1313
 
512
1314
  app.get('/status', async (_req, reply) => {
@@ -514,7 +1316,7 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
514
1316
  `SELECT id, name, role, status, current_task, last_seen,
515
1317
  ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
516
1318
  FROM coord_agents WHERE status != 'dead'
517
- ORDER BY role, name`
1319
+ ORDER BY role, name LIMIT 200`
518
1320
  ).all();
519
1321
 
520
1322
  const assignments = db.prepare(
@@ -522,12 +1324,12 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
522
1324
  a.created_at, a.started_at, a.completed_at
523
1325
  FROM coord_assignments a LEFT JOIN coord_agents ag ON a.agent_id = ag.id
524
1326
  WHERE a.status NOT IN ('completed', 'failed')
525
- ORDER BY a.created_at`
1327
+ ORDER BY a.created_at LIMIT 200`
526
1328
  ).all();
527
1329
 
528
1330
  const locks = db.prepare(
529
1331
  `SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
530
- FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id`
1332
+ FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id LIMIT 200`
531
1333
  ).all();
532
1334
 
533
1335
  const stats = db.prepare(
@@ -562,8 +1364,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
562
1364
  `SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
563
1365
  ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
564
1366
  FROM coord_agents
565
- WHERE status != 'dead' AND role != 'orchestrator' AND workspace = ?
566
- ORDER BY name`
1367
+ WHERE status != 'dead' AND role NOT IN ('orchestrator', 'coordinator') AND workspace = ?
1368
+ ORDER BY name LIMIT 200`
567
1369
  ).all(workspace) as Array<{
568
1370
  id: string; name: string; role: string; status: string;
569
1371
  current_task: string | null; capabilities: string | null;
@@ -573,8 +1375,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
573
1375
  `SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
574
1376
  ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
575
1377
  FROM coord_agents
576
- WHERE status != 'dead' AND role != 'orchestrator'
577
- ORDER BY name`
1378
+ WHERE status != 'dead' AND role NOT IN ('orchestrator', 'coordinator')
1379
+ ORDER BY name LIMIT 200`
578
1380
  ).all() as Array<{
579
1381
  id: string; name: string; role: string; status: string;
580
1382
  current_task: string | null; capabilities: string | null;
@@ -607,7 +1409,7 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
607
1409
  workspace: w.workspace,
608
1410
  lastSeen: w.last_seen,
609
1411
  secondsSinceSeen: w.seconds_since_seen,
610
- alive: w.seconds_since_seen < 120,
1412
+ alive: w.seconds_since_seen < 300,
611
1413
  }));
612
1414
 
613
1415
  return reply.send({
@@ -620,20 +1422,43 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
620
1422
 
621
1423
  app.get('/events', async (req, reply) => {
622
1424
  const q = eventsQuerySchema.safeParse(req.query);
623
- const limit = q.success ? q.data.limit : 50;
1425
+ if (!q.success) return reply.code(400).send({ error: q.error.issues[0].message });
1426
+ const { since_id, agent_id, event_type, limit } = q.data;
1427
+
1428
+ const conditions: string[] = [];
1429
+ const params: unknown[] = [];
1430
+
1431
+ if (since_id > 0) {
1432
+ conditions.push('e.id > ?');
1433
+ params.push(since_id);
1434
+ }
1435
+ if (agent_id) {
1436
+ conditions.push('e.agent_id = ?');
1437
+ params.push(agent_id);
1438
+ }
1439
+ if (event_type) {
1440
+ conditions.push('e.event_type = ?');
1441
+ params.push(event_type);
1442
+ }
1443
+
1444
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
1445
+ params.push(limit);
624
1446
 
625
1447
  const events = db.prepare(
626
1448
  `SELECT e.id, e.agent_id, a.name AS agent_name, e.event_type, e.detail, e.created_at
627
1449
  FROM coord_events e LEFT JOIN coord_agents a ON e.agent_id = a.id
628
- ORDER BY e.created_at DESC LIMIT ?`
629
- ).all(limit);
1450
+ ${where}
1451
+ ORDER BY e.id ASC LIMIT ?`
1452
+ ).all(...params);
1453
+
1454
+ const last_id = events.length > 0 ? (events[events.length - 1] as { id: number }).id : since_id;
630
1455
 
631
- return reply.send({ events });
1456
+ return reply.send({ events, last_id });
632
1457
  });
633
1458
 
634
1459
  app.get('/stale', async (req, reply) => {
635
1460
  const q = staleQuerySchema.safeParse(req.query);
636
- const threshold = q.success ? q.data.seconds : 120;
1461
+ const threshold = q.success ? q.data.seconds : 300;
637
1462
  const cleanup = q.success ? q.data.cleanup : undefined;
638
1463
 
639
1464
  const stale = detectStale(db, threshold);
@@ -648,9 +1473,445 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
648
1473
 
649
1474
  app.post('/stale/cleanup', async (req, reply) => {
650
1475
  const q = staleQuerySchema.safeParse(req.query);
651
- const threshold = q.success ? q.data.seconds : 120;
1476
+ const threshold = q.success ? q.data.seconds : 300;
652
1477
 
653
1478
  const { stale, cleaned } = cleanupStale(db, threshold);
654
1479
  return reply.send({ stale, threshold_seconds: threshold, cleaned });
655
1480
  });
1481
+
1482
+ // ─── Agent Management ───────────────────────────────────────────
1483
+
1484
+ app.get('/agent/:id', async (req, reply) => {
1485
+ const params = agentIdParamSchema.safeParse(req.params);
1486
+ if (!params.success) return reply.code(400).send({ error: params.error.issues[0].message });
1487
+ const { id } = params.data;
1488
+
1489
+ const agent = db.prepare(
1490
+ `SELECT id, name, role, status, current_task, pid, capabilities, workspace, metadata, last_seen, started_at,
1491
+ ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
1492
+ FROM coord_agents WHERE id = ?`
1493
+ ).get(id) as Record<string, unknown> | undefined;
1494
+
1495
+ if (!agent) return reply.code(404).send({ error: 'agent not found' });
1496
+
1497
+ // Include active assignment and locks
1498
+ const assignment = db.prepare(
1499
+ `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`
1500
+ ).get(id) as Record<string, unknown> | undefined;
1501
+
1502
+ const locks = db.prepare(
1503
+ `SELECT file_path, locked_at, reason FROM coord_locks WHERE agent_id = ?`
1504
+ ).all(id);
1505
+
1506
+ return reply.send({ agent, assignment: assignment ?? null, locks });
1507
+ });
1508
+
1509
+ app.delete('/agent/:id', async (req, reply) => {
1510
+ const params = agentIdParamSchema.safeParse(req.params);
1511
+ if (!params.success) return reply.code(400).send({ error: params.error.issues[0].message });
1512
+ const { id } = params.data;
1513
+
1514
+ const agent = db.prepare(`SELECT id, name, status FROM coord_agents WHERE id = ?`).get(id) as { id: string; name: string; status: string } | undefined;
1515
+ if (!agent) return reply.code(404).send({ error: 'agent not found' });
1516
+ if (agent.status === 'dead') return reply.send({ ok: true, action: 'already_dead', agent_name: agent.name });
1517
+
1518
+ // Fail active assignments
1519
+ const failedAssignments = db.prepare(
1520
+ `UPDATE coord_assignments SET status = 'failed', result = 'agent killed by coordinator', completed_at = datetime('now')
1521
+ WHERE agent_id = ? AND status IN ('assigned', 'in_progress')`
1522
+ ).run(id);
1523
+
1524
+ if (failedAssignments.changes > 0) {
1525
+ db.prepare(
1526
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_failed', ?)`
1527
+ ).run(id, `killed: failed ${failedAssignments.changes} active assignment(s)`);
1528
+ }
1529
+
1530
+ // Release locks
1531
+ const releasedLocks = db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(id);
1532
+
1533
+ // Mark dead
1534
+ db.prepare(`UPDATE coord_agents SET status = 'dead', current_task = NULL WHERE id = ?`).run(id);
1535
+
1536
+ db.prepare(
1537
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'agent_killed', ?)`
1538
+ ).run(id, `${agent.name} killed: failed ${failedAssignments.changes} assignment(s), released ${releasedLocks.changes} lock(s)`);
1539
+
1540
+ coordLog(`${agent.name} killed — failed ${failedAssignments.changes} assignment(s), released ${releasedLocks.changes} lock(s)`);
1541
+
1542
+ return reply.send({
1543
+ ok: true,
1544
+ action: 'killed',
1545
+ agent_name: agent.name,
1546
+ failed_assignments: failedAssignments.changes,
1547
+ released_locks: releasedLocks.changes,
1548
+ });
1549
+ });
1550
+
1551
+ // ─── Timeline ─────────────────────────────────────────────────────
1552
+
1553
+ app.get('/timeline', async (req, reply) => {
1554
+ const q = timelineQuerySchema.safeParse(req.query);
1555
+ if (!q.success) return reply.code(400).send({ error: q.error.issues[0].message });
1556
+ const { limit, since } = q.data;
1557
+
1558
+ const conditions: string[] = [];
1559
+ const params: unknown[] = [];
1560
+
1561
+ if (since) {
1562
+ conditions.push('e.created_at >= ?');
1563
+ params.push(since);
1564
+ }
1565
+
1566
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
1567
+ params.push(limit);
1568
+
1569
+ const timeline = db.prepare(
1570
+ `SELECT e.created_at AS timestamp, a.name AS agent_name, e.event_type, e.detail,
1571
+ t.task AS assignment_task
1572
+ FROM coord_events e
1573
+ LEFT JOIN coord_agents a ON e.agent_id = a.id
1574
+ LEFT JOIN coord_assignments t ON a.current_task = t.id
1575
+ ${where}
1576
+ ORDER BY e.created_at DESC, e.id DESC
1577
+ LIMIT ?`
1578
+ ).all(...params);
1579
+
1580
+ return reply.send({ timeline });
1581
+ });
1582
+
1583
+ // ─── Stats ──────────────────────────────────────────────────────
1584
+
1585
+ app.get('/stats', async (_req, reply) => {
1586
+ const workers = db.prepare(`
1587
+ SELECT
1588
+ COUNT(*) AS total,
1589
+ SUM(CASE WHEN status != 'dead' THEN 1 ELSE 0 END) AS alive,
1590
+ SUM(CASE WHEN status = 'idle' THEN 1 ELSE 0 END) AS idle,
1591
+ SUM(CASE WHEN status = 'working' THEN 1 ELSE 0 END) AS working
1592
+ FROM coord_agents
1593
+ `).get() as { total: number; alive: number; idle: number; working: number };
1594
+
1595
+ const tasks = db.prepare(`
1596
+ SELECT
1597
+ COUNT(*) AS total_assigned,
1598
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
1599
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
1600
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
1601
+ AVG(CASE
1602
+ WHEN status = 'completed' AND started_at IS NOT NULL AND completed_at IS NOT NULL
1603
+ THEN ROUND((julianday(completed_at) - julianday(started_at)) * 86400)
1604
+ ELSE NULL
1605
+ END) AS avg_completion_seconds
1606
+ FROM coord_assignments
1607
+ `).get() as { total_assigned: number; completed: number; failed: number; pending: number; avg_completion_seconds: number | null };
1608
+
1609
+ const decisions = db.prepare(`
1610
+ SELECT
1611
+ COALESCE(COUNT(*), 0) AS total,
1612
+ COALESCE(SUM(CASE WHEN created_at >= datetime('now', '-1 hour') THEN 1 ELSE 0 END), 0) AS last_hour
1613
+ FROM coord_decisions
1614
+ `).get() as { total: number; last_hour: number };
1615
+
1616
+ // Uptime = seconds since the earliest non-dead agent started
1617
+ const uptime = db.prepare(`
1618
+ SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS uptime_seconds
1619
+ FROM coord_agents WHERE status != 'dead'
1620
+ `).get() as { uptime_seconds: number | null };
1621
+
1622
+ return reply.send({
1623
+ workers,
1624
+ tasks: {
1625
+ ...tasks,
1626
+ avg_completion_seconds: tasks.avg_completion_seconds != null
1627
+ ? Math.round(tasks.avg_completion_seconds)
1628
+ : null,
1629
+ },
1630
+ decisions,
1631
+ uptime_seconds: uptime.uptime_seconds ?? 0,
1632
+ });
1633
+ });
1634
+
1635
+ // ─── Prometheus Metrics ────────────────────────────────────────
1636
+
1637
+ app.get('/metrics', async (_req, reply) => {
1638
+ const agentsByStatus = db.prepare(
1639
+ `SELECT status, COUNT(*) AS count FROM coord_agents GROUP BY status`
1640
+ ).all() as Array<{ status: string; count: number }>;
1641
+
1642
+ const assignmentsByStatus = db.prepare(
1643
+ `SELECT status, COUNT(*) AS count FROM coord_assignments GROUP BY status`
1644
+ ).all() as Array<{ status: string; count: number }>;
1645
+
1646
+ const locksActive = (db.prepare(
1647
+ `SELECT COUNT(*) AS count FROM coord_locks`
1648
+ ).get() as { count: number }).count;
1649
+
1650
+ const findingsBySeverity = db.prepare(
1651
+ `SELECT severity, COUNT(*) AS count FROM coord_findings WHERE status = 'open' GROUP BY severity`
1652
+ ).all() as Array<{ severity: string; count: number }>;
1653
+
1654
+ const eventsTotal = (db.prepare(
1655
+ `SELECT COUNT(*) AS count FROM coord_events`
1656
+ ).get() as { count: number }).count;
1657
+
1658
+ const uptime = (db.prepare(
1659
+ `SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS seconds FROM coord_agents WHERE status != 'dead'`
1660
+ ).get() as { seconds: number | null }).seconds ?? 0;
1661
+
1662
+ const lines: string[] = [
1663
+ '# HELP coord_agents_total Number of agents by status',
1664
+ '# TYPE coord_agents_total gauge',
1665
+ ];
1666
+ for (const row of agentsByStatus) {
1667
+ lines.push(`coord_agents_total{status="${row.status}"} ${row.count}`);
1668
+ }
1669
+
1670
+ lines.push('# HELP coord_assignments_total Number of assignments by status');
1671
+ lines.push('# TYPE coord_assignments_total gauge');
1672
+ for (const row of assignmentsByStatus) {
1673
+ lines.push(`coord_assignments_total{status="${row.status}"} ${row.count}`);
1674
+ }
1675
+
1676
+ lines.push('# HELP coord_locks_active Number of active file locks');
1677
+ lines.push('# TYPE coord_locks_active gauge');
1678
+ lines.push(`coord_locks_active ${locksActive}`);
1679
+
1680
+ lines.push('# HELP coord_findings_total Open findings by severity');
1681
+ lines.push('# TYPE coord_findings_total gauge');
1682
+ for (const row of findingsBySeverity) {
1683
+ lines.push(`coord_findings_total{severity="${row.severity}"} ${row.count}`);
1684
+ }
1685
+
1686
+ lines.push('# HELP coord_events_total Total coordination events');
1687
+ lines.push('# TYPE coord_events_total counter');
1688
+ lines.push(`coord_events_total ${eventsTotal}`);
1689
+
1690
+ lines.push('# HELP coord_uptime_seconds Seconds since first agent registered');
1691
+ lines.push('# TYPE coord_uptime_seconds gauge');
1692
+ lines.push(`coord_uptime_seconds ${uptime}`);
1693
+
1694
+ return reply.type('text/plain; version=0.0.4; charset=utf-8').send(lines.join('\n') + '\n');
1695
+ });
1696
+
1697
+ // ─── Deep Health ───────────────────────────────────────────────
1698
+
1699
+ app.get('/health/deep', async (_req, reply) => {
1700
+ const dbHealthy = store ? store.integrityCheck().ok : true;
1701
+
1702
+ const agents = db.prepare(
1703
+ `SELECT COUNT(*) AS alive FROM coord_agents WHERE status != 'dead'`
1704
+ ).get() as { alive: number };
1705
+
1706
+ const staleThreshold = 300;
1707
+ const staleCount = (db.prepare(
1708
+ `SELECT COUNT(*) AS c FROM coord_agents
1709
+ WHERE status != 'dead'
1710
+ AND (julianday('now') - julianday(last_seen)) * 86400 > ?`
1711
+ ).get(staleThreshold) as { c: number }).c;
1712
+
1713
+ const pending = (db.prepare(
1714
+ `SELECT COUNT(*) AS c FROM coord_assignments WHERE status IN ('pending', 'assigned', 'in_progress')`
1715
+ ).get() as { c: number }).c;
1716
+
1717
+ const uptimeRow = db.prepare(
1718
+ `SELECT ROUND((julianday('now') - julianday(MIN(started_at))) * 86400) AS s
1719
+ FROM coord_agents WHERE status != 'dead'`
1720
+ ).get() as { s: number | null };
1721
+
1722
+ // WAL file size and autocheckpoint setting
1723
+ let walSizeBytes: number | null = null;
1724
+ let walAutocheckpoint: number | null = null;
1725
+ try {
1726
+ const fs = require('fs');
1727
+ const walPath = db.name + '-wal';
1728
+ const stat = fs.statSync(walPath);
1729
+ walSizeBytes = stat.size;
1730
+ } catch { /* WAL file may not exist */ }
1731
+ try {
1732
+ const acRow = db.pragma('wal_autocheckpoint') as Array<{ wal_autocheckpoint: number }>;
1733
+ walAutocheckpoint = acRow[0]?.wal_autocheckpoint ?? null;
1734
+ } catch { /* pragma read failed */ }
1735
+
1736
+ const status = (!dbHealthy || staleCount > 2) ? 'degraded' : 'ok';
1737
+
1738
+ return reply.send({
1739
+ status,
1740
+ db_healthy: dbHealthy,
1741
+ agents_alive: agents.alive,
1742
+ stale_agents: staleCount,
1743
+ pending_tasks: pending,
1744
+ uptime_seconds: uptimeRow.s ?? 0,
1745
+ wal_size_bytes: walSizeBytes,
1746
+ wal_autocheckpoint: walAutocheckpoint,
1747
+ });
1748
+ });
1749
+
1750
+ // ─── Channel Sessions ───────────────────────────────────────────
1751
+
1752
+ /** POST /channel/register — Register or update a channel session for an agent. */
1753
+ app.post('/channel/register', async (request, reply) => {
1754
+ const parsed = channelRegisterSchema.safeParse(request.body);
1755
+ if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
1756
+ const { agentId, channelId } = parsed.data;
1757
+
1758
+ const agent = db.prepare('SELECT id FROM coord_agents WHERE id = ?').get(agentId) as { id: string } | undefined;
1759
+ if (!agent) return reply.status(404).send({ error: 'Agent not found' });
1760
+
1761
+ db.prepare(`
1762
+ INSERT INTO coord_channel_sessions (agent_id, channel_id, connected_at, status)
1763
+ VALUES (?, ?, datetime('now'), 'connected')
1764
+ ON CONFLICT(agent_id) DO UPDATE SET
1765
+ channel_id = excluded.channel_id,
1766
+ connected_at = datetime('now'),
1767
+ status = 'connected',
1768
+ push_count = 0,
1769
+ last_push_at = NULL
1770
+ `).run(agentId, channelId);
1771
+
1772
+ db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_register', ?)`).run(
1773
+ agentId, JSON.stringify({ channelId })
1774
+ );
1775
+
1776
+ coordLog(`channel/register: ${agentId} → ${channelId}`);
1777
+ eventBus?.emit('session.started', { agentId, channelId });
1778
+ return reply.send({ ok: true });
1779
+ });
1780
+
1781
+ /** DELETE /channel/register — Deregister a channel session for an agent. */
1782
+ app.delete('/channel/register', async (request, reply) => {
1783
+ const parsed = channelDeregisterSchema.safeParse(request.body);
1784
+ if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
1785
+ const { agentId } = parsed.data;
1786
+
1787
+ const result = db.prepare('DELETE FROM coord_channel_sessions WHERE agent_id = ?').run(agentId);
1788
+
1789
+ db.prepare(`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_deregister', NULL)`).run(agentId);
1790
+
1791
+ coordLog(`channel/deregister: ${agentId} (rows: ${result.changes})`);
1792
+ eventBus?.emit('session.closed', { agentId, channelId: '' });
1793
+ return reply.send({ ok: true });
1794
+ });
1795
+
1796
+ /**
1797
+ * Deliver a message to a worker's channel HTTP endpoint.
1798
+ * Returns { delivered, error? }. On connection failure, marks session dead.
1799
+ */
1800
+ async function deliverToChannel(
1801
+ agentId: string, channelUrl: string, content: string, meta?: Record<string, string>
1802
+ ): Promise<{ delivered: boolean; error?: string }> {
1803
+ try {
1804
+ const res = await fetch(`${channelUrl}/push`, {
1805
+ method: 'POST',
1806
+ headers: { 'Content-Type': 'application/json' },
1807
+ body: JSON.stringify({ content, meta: meta ?? {} }),
1808
+ signal: AbortSignal.timeout(5000),
1809
+ });
1810
+ if (!res.ok) {
1811
+ return { delivered: false, error: `channel returned ${res.status}` };
1812
+ }
1813
+ return { delivered: true };
1814
+ } catch (err) {
1815
+ // Connection refused / timeout → worker process is dead, mark session disconnected
1816
+ db.prepare(
1817
+ `UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`
1818
+ ).run(agentId);
1819
+ const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
1820
+ coordLog(`channel/deliver FAILED → ${agent?.name ?? agentId}: ${err instanceof Error ? err.message : err} — session marked disconnected`);
1821
+ return { delivered: false, error: `worker unreachable: ${err instanceof Error ? err.message : err}` };
1822
+ }
1823
+ }
1824
+
1825
+ /** POST /channel/push — Push a message to an agent. Tries live delivery first, falls back to mailbox queue. */
1826
+ app.post('/channel/push', async (request, reply) => {
1827
+ const parsed = channelPushSchema.safeParse(request.body);
1828
+ if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
1829
+ const { agentId, message } = parsed.data;
1830
+
1831
+ const agent = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId) as { name: string; workspace: string | null } | undefined;
1832
+ if (!agent) return reply.status(404).send({ error: 'Agent not found' });
1833
+
1834
+ // Try live channel delivery first
1835
+ const session = db.prepare(
1836
+ `SELECT agent_id, channel_id FROM coord_channel_sessions WHERE agent_id = ? AND status = 'connected'`
1837
+ ).get(agentId) as { agent_id: string; channel_id: string } | undefined;
1838
+
1839
+ if (session) {
1840
+ const { delivered } = await deliverToChannel(
1841
+ agentId, session.channel_id, message,
1842
+ { source: 'coordinator', agent: agent.name }
1843
+ );
1844
+
1845
+ if (delivered) {
1846
+ db.prepare(
1847
+ `UPDATE coord_channel_sessions SET last_push_at = datetime('now'), push_count = push_count + 1 WHERE agent_id = ?`
1848
+ ).run(agentId);
1849
+ db.prepare(
1850
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'channel_push', ?)`
1851
+ ).run(agentId, message.slice(0, 500));
1852
+ coordLog(`channel/push → ${agent.name}: ${message.slice(0, 80)}`);
1853
+ return reply.send({ ok: true, delivered: true, channelId: session.channel_id });
1854
+ }
1855
+ // Live delivery failed — fall through to mailbox
1856
+ }
1857
+
1858
+ // Queue to mailbox (delivered on next /next poll)
1859
+ db.prepare(
1860
+ `INSERT INTO coord_mailbox (worker_name, workspace, message, source) VALUES (?, ?, ?, 'coordinator')`
1861
+ ).run(agent.name, agent.workspace, message);
1862
+ db.prepare(
1863
+ `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'mailbox_queued', ?)`
1864
+ ).run(agentId, `queued for ${agent.name}: ${message.slice(0, 200)}`);
1865
+ coordLog(`mailbox/queue → ${agent.name}: ${message.slice(0, 80)} (live delivery unavailable)`);
1866
+ return reply.send({ ok: true, delivered: false, queued: true, hint: 'Message queued in mailbox — will be delivered on next /next poll' });
1867
+ });
1868
+
1869
+ /** GET /channel/sessions — List all active channel sessions with agent names. */
1870
+ app.get('/channel/sessions', async (_request, reply) => {
1871
+ const sessions = db.prepare(`
1872
+ SELECT cs.agent_id, a.name AS agent_name, cs.channel_id,
1873
+ cs.connected_at, cs.last_push_at, cs.push_count, cs.status
1874
+ FROM coord_channel_sessions cs
1875
+ JOIN coord_agents a ON a.id = cs.agent_id
1876
+ WHERE cs.status = 'connected'
1877
+ ORDER BY cs.connected_at DESC
1878
+ `).all();
1879
+
1880
+ return reply.send({ sessions });
1881
+ });
1882
+
1883
+ /** POST /channel/probe — Probe all connected channel sessions, mark dead ones as disconnected. */
1884
+ app.post('/channel/probe', async (_request, reply) => {
1885
+ const sessions = db.prepare(
1886
+ `SELECT cs.agent_id, a.name AS agent_name, cs.channel_id
1887
+ FROM coord_channel_sessions cs
1888
+ JOIN coord_agents a ON a.id = cs.agent_id
1889
+ WHERE cs.status = 'connected'`
1890
+ ).all() as Array<{ agent_id: string; agent_name: string; channel_id: string }>;
1891
+
1892
+ const results: Array<{ agent: string; alive: boolean; error?: string }> = [];
1893
+
1894
+ for (const session of sessions) {
1895
+ try {
1896
+ const res = await fetch(`${session.channel_id}/health`, {
1897
+ signal: AbortSignal.timeout(3000),
1898
+ });
1899
+ if (res.ok) {
1900
+ results.push({ agent: session.agent_name, alive: true });
1901
+ } else {
1902
+ db.prepare(`UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`).run(session.agent_id);
1903
+ results.push({ agent: session.agent_name, alive: false, error: `health returned ${res.status}` });
1904
+ }
1905
+ } catch (err) {
1906
+ db.prepare(`UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`).run(session.agent_id);
1907
+ results.push({ agent: session.agent_name, alive: false, error: err instanceof Error ? err.message : String(err) });
1908
+ }
1909
+ }
1910
+
1911
+ const alive = results.filter(r => r.alive).length;
1912
+ const dead = results.filter(r => !r.alive).length;
1913
+ if (dead > 0) coordLog(`channel/probe: ${alive} alive, ${dead} dead — dead sessions marked disconnected`);
1914
+
1915
+ return reply.send({ probed: results.length, alive, dead, results });
1916
+ });
656
1917
  }