agent-working-memory 0.6.0 → 0.6.1

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