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