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