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