agent-working-memory 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +19 -9
  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 +1 -0
  32. package/dist/api/routes.js.map +1 -1
  33. package/dist/cli.js +104 -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/peer-decisions.d.ts +40 -0
  44. package/dist/coordination/peer-decisions.d.ts.map +1 -0
  45. package/dist/coordination/peer-decisions.js +82 -0
  46. package/dist/coordination/peer-decisions.js.map +1 -0
  47. package/dist/coordination/plugin-loader.d.ts +18 -0
  48. package/dist/coordination/plugin-loader.d.ts.map +1 -0
  49. package/dist/coordination/plugin-loader.js +55 -0
  50. package/dist/coordination/plugin-loader.js.map +1 -0
  51. package/dist/coordination/plugin.d.ts +40 -0
  52. package/dist/coordination/plugin.d.ts.map +1 -0
  53. package/dist/coordination/plugin.js +22 -0
  54. package/dist/coordination/plugin.js.map +1 -0
  55. package/dist/coordination/routes.d.ts +2 -1
  56. package/dist/coordination/routes.d.ts.map +1 -1
  57. package/dist/coordination/routes.js +899 -76
  58. package/dist/coordination/routes.js.map +1 -1
  59. package/dist/coordination/schema.d.ts.map +1 -1
  60. package/dist/coordination/schema.js +72 -14
  61. package/dist/coordination/schema.js.map +1 -1
  62. package/dist/coordination/schemas.d.ts +84 -3
  63. package/dist/coordination/schemas.d.ts.map +1 -1
  64. package/dist/coordination/schemas.js +71 -1
  65. package/dist/coordination/schemas.js.map +1 -1
  66. package/dist/coordination/stale.d.ts.map +1 -1
  67. package/dist/coordination/stale.js +2 -1
  68. package/dist/coordination/stale.js.map +1 -1
  69. package/dist/coordination/types.d.ts +252 -0
  70. package/dist/coordination/types.d.ts.map +1 -0
  71. package/dist/coordination/types.js +8 -0
  72. package/dist/coordination/types.js.map +1 -0
  73. package/dist/coordination/write-mutex.d.ts +26 -0
  74. package/dist/coordination/write-mutex.d.ts.map +1 -0
  75. package/dist/coordination/write-mutex.js +63 -0
  76. package/dist/coordination/write-mutex.js.map +1 -0
  77. package/dist/core/embeddings.d.ts +2 -0
  78. package/dist/core/embeddings.d.ts.map +1 -1
  79. package/dist/core/embeddings.js +4 -0
  80. package/dist/core/embeddings.js.map +1 -1
  81. package/dist/core/hebbian.d.ts +25 -1
  82. package/dist/core/hebbian.d.ts.map +1 -1
  83. package/dist/core/hebbian.js +71 -3
  84. package/dist/core/hebbian.js.map +1 -1
  85. package/dist/engine/activation.d.ts +22 -4
  86. package/dist/engine/activation.d.ts.map +1 -1
  87. package/dist/engine/activation.js +152 -76
  88. package/dist/engine/activation.js.map +1 -1
  89. package/dist/engine/consolidation.d.ts.map +1 -1
  90. package/dist/engine/consolidation.js +15 -6
  91. package/dist/engine/consolidation.js.map +1 -1
  92. package/dist/engine/retraction.d.ts +3 -1
  93. package/dist/engine/retraction.d.ts.map +1 -1
  94. package/dist/engine/retraction.js +19 -6
  95. package/dist/engine/retraction.js.map +1 -1
  96. package/dist/index.js +6 -18
  97. package/dist/index.js.map +1 -1
  98. package/dist/mcp.js +61 -4
  99. package/dist/mcp.js.map +1 -1
  100. package/dist/storage/sqlite.d.ts +23 -1
  101. package/dist/storage/sqlite.d.ts.map +1 -1
  102. package/dist/storage/sqlite.js +112 -3
  103. package/dist/storage/sqlite.js.map +1 -1
  104. package/dist/types/engram.d.ts +1 -0
  105. package/dist/types/engram.d.ts.map +1 -1
  106. package/package.json +1 -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 +2 -0
  115. package/src/cli.ts +107 -238
  116. package/src/coordination/events.ts +90 -0
  117. package/src/coordination/index.ts +102 -3
  118. package/src/coordination/peer-decisions.ts +105 -0
  119. package/src/coordination/plugin-loader.ts +60 -0
  120. package/src/coordination/plugin.ts +44 -0
  121. package/src/coordination/routes.ts +1176 -105
  122. package/src/coordination/schema.ts +67 -14
  123. package/src/coordination/schemas.ts +85 -1
  124. package/src/coordination/stale.ts +3 -2
  125. package/src/coordination/types.ts +311 -0
  126. package/src/coordination/write-mutex.ts +69 -0
  127. package/src/core/embeddings.ts +5 -0
  128. package/src/core/hebbian.ts +84 -3
  129. package/src/engine/activation.ts +149 -73
  130. package/src/engine/consolidation.ts +15 -6
  131. package/src/engine/retraction.ts +22 -6
  132. package/src/index.ts +6 -15
  133. package/src/mcp.ts +77 -4
  134. package/src/storage/sqlite.ts +116 -3
  135. package/src/types/engram.ts +1 -0
@@ -8,15 +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
14
  checkinSchema, checkoutSchema, pulseSchema, nextSchema,
14
- assignCreateSchema, assignmentQuerySchema, assignmentClaimSchema, assignmentUpdateSchema, assignmentIdParamSchema,
15
+ assignCreateSchema, assignmentQuerySchema, assignmentClaimSchema, assignmentUpdateSchema, assignmentIdParamSchema, assignmentsListSchema, reassignSchema,
15
16
  lockAcquireSchema, lockReleaseSchema,
16
17
  commandCreateSchema, commandWaitQuerySchema,
17
- findingCreateSchema, findingsQuerySchema, findingIdParamSchema,
18
- decisionsQuerySchema,
18
+ findingCreateSchema, findingsQuerySchema, findingIdParamSchema, findingUpdateSchema,
19
+ decisionsQuerySchema, decisionCreateSchema,
19
20
  eventsQuerySchema, staleQuerySchema, workersQuerySchema,
21
+ agentIdParamSchema, timelineQuerySchema,
22
+ channelRegisterSchema, channelDeregisterSchema, channelPushSchema,
20
23
  } from './schemas.js';
21
24
  import { detectStale, cleanupStale } from './stale.js';
22
25
 
@@ -30,12 +33,72 @@ function coordLog(msg: string): void {
30
33
  console.log(`${ts()} [coord] ${msg}`);
31
34
  }
32
35
 
33
- 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
+ }
34
48
 
35
- // 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
+ });
36
55
  app.addHook('onResponse', async (request, reply) => {
37
- if (reply.statusCode >= 400) {
38
- 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` });
39
102
  }
40
103
  });
41
104
 
@@ -44,11 +107,12 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
44
107
  app.post('/checkin', async (req, reply) => {
45
108
  const parsed = checkinSchema.safeParse(req.body);
46
109
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
47
- const { name, role, pid, metadata, capabilities, workspace } = parsed.data;
110
+ const { name, role, pid, metadata, capabilities, workspace, channelUrl } = parsed.data;
48
111
  const capsJson = capabilities ? JSON.stringify(capabilities) : null;
49
112
 
50
113
  // Look up ANY existing agent with same name+workspace — including dead ones (upsert)
51
- const existing = workspace
114
+ // Falls back to name-only to handle workspace changes between sessions
115
+ let existing = workspace
52
116
  ? db.prepare(
53
117
  `SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`
54
118
  ).get(name, workspace) as { id: string; status: string } | undefined
@@ -56,11 +120,21 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
56
120
  `SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`
57
121
  ).get(name) as { id: string; status: string } | undefined;
58
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
+
59
129
  if (existing) {
60
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
+ );
61
135
  db.prepare(
62
- `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 = ?`
63
- ).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);
64
138
 
65
139
  const eventType = wasDead ? 'reconnected' : 'heartbeat';
66
140
  const detail = wasDead ? `${name} reconnected (was dead)` : `heartbeat from ${name}`;
@@ -69,22 +143,79 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
69
143
  ).run(existing.id, eventType, detail);
70
144
 
71
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
+ }
72
160
  const action = wasDead ? 'reconnected' : 'heartbeat';
73
161
  const status = wasDead ? 'idle' : existing.status;
74
- return reply.send({ agentId: existing.id, action, status, workspace });
162
+ return reply.send({ agentId: existing.id, sessionToken, action, status, workspace });
75
163
  }
76
164
 
77
165
  const id = randomUUID();
166
+ const sessionToken = randomUUID();
78
167
  db.prepare(
79
- `INSERT INTO coord_agents (id, name, role, pid, status, metadata, capabilities, workspace) VALUES (?, ?, ?, ?, 'idle', ?, ?, ?)`
80
- ).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);
81
170
 
82
171
  db.prepare(
83
172
  `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`
84
173
  ).run(id, `${name} joined as ${role ?? 'worker'}${workspace ? ' [' + workspace + ']' : ''}${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
85
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
+
86
190
  coordLog(`${name} registered (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
87
- 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 });
88
219
  });
89
220
 
90
221
  app.post('/checkout', async (req, reply) => {
@@ -92,17 +223,25 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
92
223
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
93
224
  const { agentId } = parsed.data;
94
225
 
95
- db.prepare(`DELETE FROM coord_locks WHERE agent_id = ?`).run(agentId);
96
- db.prepare(
97
- `UPDATE coord_agents SET status = 'dead', last_seen = datetime('now') WHERE id = ?`
98
- ).run(agentId);
99
- db.prepare(
100
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'checkout', 'agent signed off')`
101
- ).run(agentId);
226
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
102
227
 
103
- // 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)
104
242
  const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
105
243
  coordLog(`${agent?.name ?? agentId} checked out`);
244
+ eventBus?.emit('agent.checkout', { agentId, name: agent?.name ?? agentId });
106
245
  return reply.send({ ok: true });
107
246
  });
108
247
 
@@ -113,6 +252,16 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
113
252
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
114
253
  const { agentId } = parsed.data;
115
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);
116
265
  db.prepare(`UPDATE coord_agents SET last_seen = datetime('now') WHERE id = ?`).run(agentId);
117
266
  return reply.send({ ok: true });
118
267
  });
@@ -122,11 +271,13 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
122
271
  app.post('/next', async (req, reply) => {
123
272
  const parsed = nextSchema.safeParse(req.body);
124
273
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
125
- const { name, workspace, role, capabilities } = parsed.data;
274
+ const { name, workspace, role, capabilities, channelUrl } = parsed.data;
126
275
  const capsJson = capabilities ? JSON.stringify(capabilities) : null;
127
276
 
128
277
  // Step 1: Upsert agent (checkin / heartbeat) — including dead agents (reuse UUID)
129
- const existing = workspace
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
130
281
  ? db.prepare(
131
282
  `SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`
132
283
  ).get(name, workspace) as { id: string; status: string } | undefined
@@ -134,13 +285,24 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
134
285
  `SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`
135
286
  ).get(name) as { id: string; status: string } | undefined;
136
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
+
137
295
  let agentId: string;
296
+ let sessionToken: string;
138
297
  if (existing) {
139
298
  agentId = existing.id;
140
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());
141
303
  db.prepare(
142
- `UPDATE coord_agents SET last_seen = datetime('now'), status = CASE WHEN status = 'dead' THEN 'idle' ELSE status END, capabilities = COALESCE(?, capabilities) WHERE id = ?`
143
- ).run(capsJson, agentId);
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);
144
306
  const eventType = wasDead ? 'reconnected' : 'heartbeat';
145
307
  const detail = wasDead ? `${name} reconnected via /next` : `heartbeat from ${name}`;
146
308
  db.prepare(
@@ -149,15 +311,31 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
149
311
  if (wasDead) coordLog(`${name} reconnected via /next (reusing UUID ${agentId.slice(0, 8)})`);
150
312
  } else {
151
313
  agentId = randomUUID();
314
+ sessionToken = randomUUID();
152
315
  db.prepare(
153
- `INSERT INTO coord_agents (id, name, role, pid, status, metadata, capabilities, workspace) VALUES (?, ?, ?, NULL, 'idle', NULL, ?, ?)`
154
- ).run(agentId, name, role ?? 'worker', capsJson, workspace ?? null);
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);
155
318
  db.prepare(
156
319
  `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`
157
320
  ).run(agentId, `${name} joined as ${role ?? 'worker'} via /next`);
158
321
  coordLog(`${name} registered via /next (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
159
322
  }
160
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
+
161
339
  // Step 2: Get active commands
162
340
  const activeCommands = workspace
163
341
  ? db.prepare(
@@ -176,18 +354,48 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
176
354
  `SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
177
355
  ).get(agentId) as Record<string, unknown> | undefined;
178
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
+
179
380
  if (!assignment) {
180
381
  const agentWorkspace = workspace ?? null;
181
382
  // Priority-ordered dispatch: higher priority first, then FIFO.
182
383
  // Skip assignments blocked by incomplete dependencies.
183
384
  const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
184
- const pending = agentWorkspace
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
185
393
  ? db.prepare(
186
- `SELECT * FROM coord_assignments WHERE status = 'pending' AND (workspace = ? OR workspace IS NULL) ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
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`
187
395
  ).get(agentWorkspace) as { id: string } | undefined
188
396
  : db.prepare(
189
- `SELECT * FROM coord_assignments WHERE status = 'pending' ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
190
- ).get() as { id: string } | undefined;
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);
191
399
 
192
400
  if (pending) {
193
401
  const claimed = db.prepare(
@@ -206,14 +414,37 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
206
414
  }
207
415
  }
208
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
+
209
422
  // Read current agent status after all mutations
210
423
  const agentRow = db.prepare(`SELECT status FROM coord_agents WHERE id = ?`).get(agentId) as { status: string };
211
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
+
212
441
  return reply.send({
213
442
  agentId,
443
+ sessionToken,
214
444
  status: agentRow.status,
215
445
  assignment: assignment ?? null,
216
446
  commands: activeCommands,
447
+ mailbox: mailbox.length > 0 ? mailbox.map(m => ({ message: m.message, source: m.source, queued_at: m.created_at })) : undefined,
217
448
  });
218
449
  });
219
450
 
@@ -222,46 +453,172 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
222
453
  app.post('/assign', async (req, reply) => {
223
454
  const parsed = assignCreateSchema.safeParse(req.body);
224
455
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
225
- const { agentId, task, description, workspace, priority, blocked_by } = parsed.data;
456
+ const { task, description, workspace, priority, blocked_by, worker_name, context } = parsed.data;
457
+ let { agentId } = parsed.data;
226
458
 
227
- const id = randomUUID();
228
- db.prepare(
229
- `INSERT INTO coord_assignments (id, agent_id, task, description, status, priority, blocked_by, workspace) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
230
- ).run(id, agentId ?? null, task, description ?? null, agentId ? 'assigned' : 'pending', priority, blocked_by ?? null, workspace ?? null);
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
+ }
475
+
476
+ if (!found) {
477
+ return reply.code(404).send({ error: `worker not found: ${worker_name}` });
478
+ }
479
+ agentId = found.id;
480
+ }
231
481
 
482
+ // Reject if agent already has an active assignment
232
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(() => {
233
497
  db.prepare(
234
- `UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
235
- ).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
+ }
236
548
  }
237
549
 
238
- db.prepare(
239
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_created', ?)`
240
- ).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
+ }
241
592
 
242
593
  // Log assignment with agent name
243
594
  if (agentId) {
244
595
  const agent = db.prepare(`SELECT name FROM coord_agents WHERE id = ?`).get(agentId) as { name: string } | undefined;
245
- 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)' : ''}`);
246
597
  } else {
247
598
  coordLog(`assignment queued (pending): ${task.slice(0, 80)}`);
248
599
  }
249
- 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 });
250
602
  });
251
603
 
252
604
  app.get('/assignment', async (req, reply) => {
253
605
  const q = assignmentQuerySchema.parse(req.query);
254
606
  let agentId = (req.headers['x-agent-id'] as string | undefined) ?? q.agentId;
255
607
 
256
- // Fallback: resolve agentId from name + workspace
608
+ // Fallback: resolve agentId from name + workspace (with name-only fallback)
257
609
  if (!agentId && q.name) {
258
- const found = q.workspace
610
+ let found = q.workspace
259
611
  ? db.prepare(
260
612
  `SELECT id FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead'`
261
613
  ).get(q.name, q.workspace) as { id: string } | undefined
262
614
  : db.prepare(
263
615
  `SELECT id FROM coord_agents WHERE name = ? AND workspace IS NULL AND status != 'dead'`
264
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
+ }
265
622
  agentId = found?.id;
266
623
  }
267
624
 
@@ -275,17 +632,47 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
275
632
 
276
633
  if (active) return reply.send({ assignment: active });
277
634
 
278
- const agentRow = db.prepare(`SELECT workspace FROM coord_agents WHERE id = ?`).get(agentId) as { workspace: string | null } | undefined;
279
- 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;
280
660
 
281
661
  const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
282
- const pending = agentWorkspace
662
+
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
283
670
  ? db.prepare(
284
- `SELECT * FROM coord_assignments WHERE status = 'pending' AND (workspace = ? OR workspace IS NULL) ${blockedFilter} ORDER BY priority DESC, 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`
285
672
  ).get(agentWorkspace) as { id: string } | undefined
286
673
  : db.prepare(
287
- `SELECT * FROM coord_assignments WHERE status = 'pending' ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
288
- ).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);
289
676
 
290
677
  if (pending) {
291
678
  const claimed = db.prepare(
@@ -307,7 +694,7 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
307
694
  }
308
695
 
309
696
  const busyCount = (db.prepare(
310
- `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')`
311
698
  ).get() as { c: number }).c;
312
699
 
313
700
  const retryAfter = busyCount > 0 ? 30 : 300;
@@ -320,6 +707,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
320
707
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
321
708
  const { agentId } = parsed.data;
322
709
 
710
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
711
+
323
712
  const result = db.prepare(
324
713
  `UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`
325
714
  ).run(agentId, id);
@@ -339,51 +728,105 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
339
728
  return reply.send({ ok: true, assignmentId: id });
340
729
  });
341
730
 
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
+
342
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` };
748
+ }
749
+
343
750
  // Verification gate: completed status requires structured proof of work
344
751
  if (status === 'completed') {
345
752
  if (!result || result.trim().length < 20) {
346
753
  return { error: 'completion requires a result summary — minimum 20 characters describing what was done' };
347
754
  }
348
755
  // Must mention at least one of: commit/SHA, build, audit, test, verified, fix, created, updated, implemented
349
- const actionWords = /\b(commit|sha|[0-9a-f]{7,40}|build|audit|test|verified|fix|created|updated|implemented|added|refactored|documented)\b/i;
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;
350
757
  if (!actionWords.test(result)) {
351
758
  return { error: 'completion result must describe the work done — include what was committed, built, tested, or verified' };
352
759
  }
353
760
  }
354
761
 
355
- if (['completed', 'failed'].includes(status)) {
356
- db.prepare(
357
- `UPDATE coord_assignments SET status = ?, result = ?, commit_sha = ?, completed_at = datetime('now') WHERE id = ?`
358
- ).run(status, result ?? null, commitSha ?? null, id);
359
- } else {
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}`;
360
786
  db.prepare(
361
- `UPDATE coord_assignments SET status = ?, result = ? WHERE id = ?`
362
- ).run(status, result ?? null, id);
363
- }
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();
364
791
 
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;
365
796
  if (['completed', 'failed'].includes(status)) {
366
- const assignment = db.prepare(`SELECT agent_id FROM coord_assignments WHERE id = ?`).get(id) as { agent_id: string } | undefined;
367
- if (assignment?.agent_id) {
368
- db.prepare(
369
- `UPDATE coord_agents SET status = 'idle', current_task = NULL WHERE id = ?`
370
- ).run(assignment.agent_id);
371
- }
797
+ coordLog(`${assignInfo?.agent_name ?? 'unknown'} ${status}: ${assignInfo?.task?.slice(0, 80) ?? id}`);
372
798
  }
373
799
 
374
- const eventDetail = ['completed', 'failed'].includes(status)
375
- ? `${id} → ${status}${commitSha ? ' [' + commitSha + ']' : ''}: ${(result ?? '').slice(0, 300)}`
376
- : `${id} ${status}`;
377
- db.prepare(
378
- `INSERT INTO coord_events (agent_id, event_type, detail) VALUES ((SELECT agent_id FROM coord_assignments WHERE id = ?), 'assignment_update', ?)`
379
- ).run(id, eventDetail);
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 });
804
+ }
380
805
 
381
- // Log completion/failure with agent name and task
382
- if (['completed', 'failed'].includes(status)) {
383
- const info = db.prepare(
384
- `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 = ?`
385
- ).get(id) as { task: string; agent_name: string | null } | undefined;
386
- coordLog(`${info?.agent_name ?? 'unknown'} ${status}: ${info?.task?.slice(0, 80) ?? id}`);
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
+ }
387
830
  }
388
831
 
389
832
  return {};
@@ -398,18 +841,106 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
398
841
  return reply.send({ assignment });
399
842
  });
400
843
 
401
- // List all non-completed assignments with priority and blocked_by status
844
+ // List assignments with optional filters and pagination
402
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
+
403
869
  const assignments = db.prepare(
404
870
  `SELECT a.*, g.name AS agent_name,
405
871
  CASE WHEN a.blocked_by IS NOT NULL AND a.blocked_by NOT IN (SELECT id FROM coord_assignments WHERE status = 'completed')
406
872
  THEN 1 ELSE 0 END AS is_blocked
407
873
  FROM coord_assignments a
408
874
  LEFT JOIN coord_agents g ON a.agent_id = g.id
409
- WHERE a.status NOT IN ('completed', 'failed')
410
- ORDER BY a.priority DESC, a.created_at ASC`
411
- ).all();
412
- return reply.send({ assignments });
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' });
413
944
  });
414
945
 
415
946
  app.post('/assignment/:id/update', async (req, reply) => {
@@ -446,6 +977,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
446
977
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
447
978
  const { agentId, filePath, reason } = parsed.data;
448
979
 
980
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
981
+
449
982
  const inserted = db.prepare(
450
983
  `INSERT OR IGNORE INTO coord_locks (file_path, agent_id, reason) VALUES (?, ?, ?)`
451
984
  ).run(filePath, agentId, reason ?? null);
@@ -477,6 +1010,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
477
1010
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
478
1011
  const { agentId, filePath } = parsed.data;
479
1012
 
1013
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1014
+
480
1015
  const result = db.prepare(
481
1016
  `DELETE FROM coord_locks WHERE file_path = ? AND agent_id = ?`
482
1017
  ).run(filePath, agentId);
@@ -496,7 +1031,7 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
496
1031
  const locks = db.prepare(
497
1032
  `SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
498
1033
  FROM coord_locks l JOIN coord_agents a ON l.agent_id = a.id
499
- ORDER BY l.locked_at DESC`
1034
+ ORDER BY l.locked_at DESC LIMIT 200`
500
1035
  ).all();
501
1036
 
502
1037
  return reply.send({ locks });
@@ -511,8 +1046,11 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
511
1046
 
512
1047
  if (command === 'RESUME') {
513
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.
514
1052
  db.prepare(
515
- `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)`
516
1054
  ).run(workspace);
517
1055
  } else {
518
1056
  db.prepare(
@@ -570,6 +1108,25 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
570
1108
  });
571
1109
  });
572
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
+
573
1130
  app.get('/command/wait', async (req, reply) => {
574
1131
  const q = commandWaitQuerySchema.safeParse(req.query);
575
1132
  const { status: targetStatus, workspace } = q.success ? q.data : { status: 'idle', workspace: undefined };
@@ -586,8 +1143,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
586
1143
  ORDER BY name`
587
1144
  ).all() as Array<{ id: string; name: string; role: string; status: string; current_task: string | null; last_seen: string }>;
588
1145
 
589
- const ready = agents.filter(a => a.status === targetStatus || a.role === 'orchestrator');
590
- 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');
591
1148
 
592
1149
  return reply.send({
593
1150
  allReady: notReady.length === 0,
@@ -604,6 +1161,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
604
1161
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
605
1162
  const { agentId, category, severity, filePath, lineNumber, description, suggestion } = parsed.data;
606
1163
 
1164
+ if (!sessionTokenOk(db, agentId, req)) return reply.code(403).send({ error: 'invalid session token' });
1165
+
607
1166
  db.prepare(
608
1167
  `INSERT INTO coord_findings (agent_id, category, severity, file_path, line_number, description, suggestion)
609
1168
  VALUES (?, ?, ?, ?, ?, ?, ?)`
@@ -656,6 +1215,37 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
656
1215
  return reply.send({ ok: true });
657
1216
  });
658
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
+
659
1249
  app.get('/findings/summary', async (_req, reply) => {
660
1250
  const bySeverity = db.prepare(
661
1251
  `SELECT severity, COUNT(*) as count FROM coord_findings WHERE status = 'open' GROUP BY severity`
@@ -676,7 +1266,7 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
676
1266
 
677
1267
  app.get('/decisions', async (req, reply) => {
678
1268
  const q = decisionsQuerySchema.safeParse(req.query);
679
- const { since_id, assignment_id, limit } = q.success ? q.data : { since_id: 0, assignment_id: undefined, limit: 20 };
1269
+ const { since_id, assignment_id, workspace, limit } = q.success ? q.data : { since_id: 0, assignment_id: undefined, workspace: undefined, limit: 20 };
680
1270
 
681
1271
  let sql = `
682
1272
  SELECT d.id, d.author_id, a.name AS author_name, d.assignment_id, d.tags, d.summary, d.created_at
@@ -690,6 +1280,11 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
690
1280
  params.push(assignment_id);
691
1281
  }
692
1282
 
1283
+ if (workspace) {
1284
+ sql += ` AND (a.workspace = ? OR a.workspace IS NULL)`;
1285
+ params.push(workspace);
1286
+ }
1287
+
693
1288
  sql += ` ORDER BY d.created_at ASC LIMIT ?`;
694
1289
  params.push(limit);
695
1290
 
@@ -697,6 +1292,23 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
697
1292
  return reply.send({ decisions });
698
1293
  });
699
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
+
700
1312
  // ─── Status ─────────────────────────────────────────────────────
701
1313
 
702
1314
  app.get('/status', async (_req, reply) => {
@@ -704,7 +1316,7 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
704
1316
  `SELECT id, name, role, status, current_task, last_seen,
705
1317
  ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
706
1318
  FROM coord_agents WHERE status != 'dead'
707
- ORDER BY role, name`
1319
+ ORDER BY role, name LIMIT 200`
708
1320
  ).all();
709
1321
 
710
1322
  const assignments = db.prepare(
@@ -712,12 +1324,12 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
712
1324
  a.created_at, a.started_at, a.completed_at
713
1325
  FROM coord_assignments a LEFT JOIN coord_agents ag ON a.agent_id = ag.id
714
1326
  WHERE a.status NOT IN ('completed', 'failed')
715
- ORDER BY a.created_at`
1327
+ ORDER BY a.created_at LIMIT 200`
716
1328
  ).all();
717
1329
 
718
1330
  const locks = db.prepare(
719
1331
  `SELECT l.file_path, l.agent_id, a.name AS agent_name, l.locked_at, l.reason
720
- 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`
721
1333
  ).all();
722
1334
 
723
1335
  const stats = db.prepare(
@@ -752,8 +1364,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
752
1364
  `SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
753
1365
  ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
754
1366
  FROM coord_agents
755
- WHERE status != 'dead' AND role != 'orchestrator' AND workspace = ?
756
- ORDER BY name`
1367
+ WHERE status != 'dead' AND role NOT IN ('orchestrator', 'coordinator') AND workspace = ?
1368
+ ORDER BY name LIMIT 200`
757
1369
  ).all(workspace) as Array<{
758
1370
  id: string; name: string; role: string; status: string;
759
1371
  current_task: string | null; capabilities: string | null;
@@ -763,8 +1375,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
763
1375
  `SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
764
1376
  ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
765
1377
  FROM coord_agents
766
- WHERE status != 'dead' AND role != 'orchestrator'
767
- ORDER BY name`
1378
+ WHERE status != 'dead' AND role NOT IN ('orchestrator', 'coordinator')
1379
+ ORDER BY name LIMIT 200`
768
1380
  ).all() as Array<{
769
1381
  id: string; name: string; role: string; status: string;
770
1382
  current_task: string | null; capabilities: string | null;
@@ -797,7 +1409,7 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
797
1409
  workspace: w.workspace,
798
1410
  lastSeen: w.last_seen,
799
1411
  secondsSinceSeen: w.seconds_since_seen,
800
- alive: w.seconds_since_seen < 120,
1412
+ alive: w.seconds_since_seen < 300,
801
1413
  }));
802
1414
 
803
1415
  return reply.send({
@@ -810,20 +1422,43 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
810
1422
 
811
1423
  app.get('/events', async (req, reply) => {
812
1424
  const q = eventsQuerySchema.safeParse(req.query);
813
- 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);
814
1446
 
815
1447
  const events = db.prepare(
816
1448
  `SELECT e.id, e.agent_id, a.name AS agent_name, e.event_type, e.detail, e.created_at
817
1449
  FROM coord_events e LEFT JOIN coord_agents a ON e.agent_id = a.id
818
- ORDER BY e.created_at DESC LIMIT ?`
819
- ).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;
820
1455
 
821
- return reply.send({ events });
1456
+ return reply.send({ events, last_id });
822
1457
  });
823
1458
 
824
1459
  app.get('/stale', async (req, reply) => {
825
1460
  const q = staleQuerySchema.safeParse(req.query);
826
- const threshold = q.success ? q.data.seconds : 120;
1461
+ const threshold = q.success ? q.data.seconds : 300;
827
1462
  const cleanup = q.success ? q.data.cleanup : undefined;
828
1463
 
829
1464
  const stale = detectStale(db, threshold);
@@ -838,9 +1473,445 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
838
1473
 
839
1474
  app.post('/stale/cleanup', async (req, reply) => {
840
1475
  const q = staleQuerySchema.safeParse(req.query);
841
- const threshold = q.success ? q.data.seconds : 120;
1476
+ const threshold = q.success ? q.data.seconds : 300;
842
1477
 
843
1478
  const { stale, cleaned } = cleanupStale(db, threshold);
844
1479
  return reply.send({ stale, threshold_seconds: threshold, cleaned });
845
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
+ });
846
1917
  }