agent-working-memory 0.5.6 → 0.6.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 +73 -44
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +40 -1
- package/dist/api/routes.js.map +1 -1
- package/dist/cli.js +401 -1
- package/dist/cli.js.map +1 -1
- package/dist/coordination/mcp-tools.d.ts.map +1 -1
- package/dist/coordination/mcp-tools.js +10 -5
- package/dist/coordination/mcp-tools.js.map +1 -1
- package/dist/coordination/routes.d.ts.map +1 -1
- package/dist/coordination/routes.js +155 -16
- package/dist/coordination/routes.js.map +1 -1
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +35 -1
- package/dist/coordination/schema.js.map +1 -1
- package/dist/coordination/schemas.d.ts +21 -2
- package/dist/coordination/schemas.d.ts.map +1 -1
- package/dist/coordination/schemas.js +16 -0
- package/dist/coordination/schemas.js.map +1 -1
- package/dist/coordination/stale.d.ts +2 -0
- package/dist/coordination/stale.d.ts.map +1 -1
- package/dist/coordination/stale.js +5 -0
- package/dist/coordination/stale.js.map +1 -1
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +119 -23
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +27 -6
- package/dist/engine/consolidation.js.map +1 -1
- package/dist/index.js +81 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +61 -3
- package/dist/mcp.js.map +1 -1
- package/dist/storage/sqlite.d.ts +18 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +50 -5
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types/engram.d.ts +24 -0
- package/dist/types/engram.d.ts.map +1 -1
- package/dist/types/engram.js.map +1 -1
- package/package.json +3 -1
- package/src/api/routes.ts +50 -1
- package/src/cli.ts +454 -1
- package/src/coordination/mcp-tools.ts +10 -5
- package/src/coordination/routes.ts +209 -19
- package/src/coordination/schema.ts +27 -1
- package/src/coordination/schemas.ts +19 -0
- package/src/coordination/stale.ts +8 -0
- package/src/engine/activation.ts +125 -23
- package/src/engine/consolidation.ts +29 -6
- package/src/index.ts +74 -3
- package/src/mcp.ts +72 -3
- package/src/storage/sqlite.ts +54 -5
- package/src/types/engram.ts +28 -0
|
@@ -10,11 +10,12 @@ import type { FastifyInstance } from 'fastify';
|
|
|
10
10
|
import type Database from 'better-sqlite3';
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
12
|
import {
|
|
13
|
-
checkinSchema, checkoutSchema, pulseSchema,
|
|
13
|
+
checkinSchema, checkoutSchema, pulseSchema, nextSchema,
|
|
14
14
|
assignCreateSchema, assignmentQuerySchema, assignmentClaimSchema, assignmentUpdateSchema, assignmentIdParamSchema,
|
|
15
15
|
lockAcquireSchema, lockReleaseSchema,
|
|
16
16
|
commandCreateSchema, commandWaitQuerySchema,
|
|
17
17
|
findingCreateSchema, findingsQuerySchema, findingIdParamSchema,
|
|
18
|
+
decisionsQuerySchema,
|
|
18
19
|
eventsQuerySchema, staleQuerySchema, workersQuerySchema,
|
|
19
20
|
} from './schemas.js';
|
|
20
21
|
import { detectStale, cleanupStale } from './stale.js';
|
|
@@ -46,24 +47,31 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
|
|
|
46
47
|
const { name, role, pid, metadata, capabilities, workspace } = parsed.data;
|
|
47
48
|
const capsJson = capabilities ? JSON.stringify(capabilities) : null;
|
|
48
49
|
|
|
50
|
+
// Look up ANY existing agent with same name+workspace — including dead ones (upsert)
|
|
49
51
|
const existing = workspace
|
|
50
52
|
? db.prepare(
|
|
51
|
-
`SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ?
|
|
53
|
+
`SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`
|
|
52
54
|
).get(name, workspace) as { id: string; status: string } | undefined
|
|
53
55
|
: db.prepare(
|
|
54
|
-
`SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL
|
|
56
|
+
`SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`
|
|
55
57
|
).get(name) as { id: string; status: string } | undefined;
|
|
56
58
|
|
|
57
59
|
if (existing) {
|
|
60
|
+
const wasDead = existing.status === 'dead';
|
|
58
61
|
db.prepare(
|
|
59
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 = ?`
|
|
60
63
|
).run(pid ?? null, capsJson, existing.id);
|
|
61
64
|
|
|
65
|
+
const eventType = wasDead ? 'reconnected' : 'heartbeat';
|
|
66
|
+
const detail = wasDead ? `${name} reconnected (was dead)` : `heartbeat from ${name}`;
|
|
62
67
|
db.prepare(
|
|
63
|
-
`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?,
|
|
64
|
-
).run(existing.id,
|
|
68
|
+
`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`
|
|
69
|
+
).run(existing.id, eventType, detail);
|
|
65
70
|
|
|
66
|
-
|
|
71
|
+
if (wasDead) coordLog(`${name} reconnected (reusing UUID ${existing.id.slice(0, 8)})`);
|
|
72
|
+
const action = wasDead ? 'reconnected' : 'heartbeat';
|
|
73
|
+
const status = wasDead ? 'idle' : existing.status;
|
|
74
|
+
return reply.send({ agentId: existing.id, action, status, workspace });
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
const id = randomUUID();
|
|
@@ -109,17 +117,117 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
|
|
|
109
117
|
return reply.send({ ok: true });
|
|
110
118
|
});
|
|
111
119
|
|
|
120
|
+
// ─── Next (combined checkin + commands + assignment poll) ───────
|
|
121
|
+
|
|
122
|
+
app.post('/next', async (req, reply) => {
|
|
123
|
+
const parsed = nextSchema.safeParse(req.body);
|
|
124
|
+
if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
125
|
+
const { name, workspace, role, capabilities } = parsed.data;
|
|
126
|
+
const capsJson = capabilities ? JSON.stringify(capabilities) : null;
|
|
127
|
+
|
|
128
|
+
// Step 1: Upsert agent (checkin / heartbeat) — including dead agents (reuse UUID)
|
|
129
|
+
const existing = workspace
|
|
130
|
+
? db.prepare(
|
|
131
|
+
`SELECT id, status FROM coord_agents WHERE name = ? AND workspace = ? ORDER BY last_seen DESC LIMIT 1`
|
|
132
|
+
).get(name, workspace) as { id: string; status: string } | undefined
|
|
133
|
+
: db.prepare(
|
|
134
|
+
`SELECT id, status FROM coord_agents WHERE name = ? AND workspace IS NULL ORDER BY last_seen DESC LIMIT 1`
|
|
135
|
+
).get(name) as { id: string; status: string } | undefined;
|
|
136
|
+
|
|
137
|
+
let agentId: string;
|
|
138
|
+
if (existing) {
|
|
139
|
+
agentId = existing.id;
|
|
140
|
+
const wasDead = existing.status === 'dead';
|
|
141
|
+
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);
|
|
144
|
+
const eventType = wasDead ? 'reconnected' : 'heartbeat';
|
|
145
|
+
const detail = wasDead ? `${name} reconnected via /next` : `heartbeat from ${name}`;
|
|
146
|
+
db.prepare(
|
|
147
|
+
`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, ?, ?)`
|
|
148
|
+
).run(agentId, eventType, detail);
|
|
149
|
+
if (wasDead) coordLog(`${name} reconnected via /next (reusing UUID ${agentId.slice(0, 8)})`);
|
|
150
|
+
} else {
|
|
151
|
+
agentId = randomUUID();
|
|
152
|
+
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);
|
|
155
|
+
db.prepare(
|
|
156
|
+
`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'registered', ?)`
|
|
157
|
+
).run(agentId, `${name} joined as ${role ?? 'worker'} via /next`);
|
|
158
|
+
coordLog(`${name} registered via /next (${role ?? 'worker'})${capabilities ? ' [' + capabilities.join(', ') + ']' : ''}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Step 2: Get active commands
|
|
162
|
+
const activeCommands = workspace
|
|
163
|
+
? db.prepare(
|
|
164
|
+
`SELECT id, command, reason, issued_by, issued_at, workspace
|
|
165
|
+
FROM coord_commands WHERE cleared_at IS NULL AND (workspace = ? OR workspace IS NULL)
|
|
166
|
+
ORDER BY issued_at DESC`
|
|
167
|
+
).all(workspace) as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>
|
|
168
|
+
: db.prepare(
|
|
169
|
+
`SELECT id, command, reason, issued_by, issued_at, workspace
|
|
170
|
+
FROM coord_commands WHERE cleared_at IS NULL
|
|
171
|
+
ORDER BY issued_at DESC`
|
|
172
|
+
).all() as Array<{ id: number; command: string; reason: string; issued_by: string; issued_at: string; workspace: string | null }>;
|
|
173
|
+
|
|
174
|
+
// Step 3: Get or auto-claim assignment
|
|
175
|
+
let assignment = db.prepare(
|
|
176
|
+
`SELECT * FROM coord_assignments WHERE agent_id = ? AND status IN ('assigned', 'in_progress') ORDER BY created_at DESC LIMIT 1`
|
|
177
|
+
).get(agentId) as Record<string, unknown> | undefined;
|
|
178
|
+
|
|
179
|
+
if (!assignment) {
|
|
180
|
+
const agentWorkspace = workspace ?? null;
|
|
181
|
+
// Priority-ordered dispatch: higher priority first, then FIFO.
|
|
182
|
+
// Skip assignments blocked by incomplete dependencies.
|
|
183
|
+
const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
|
|
184
|
+
const pending = agentWorkspace
|
|
185
|
+
? 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`
|
|
187
|
+
).get(agentWorkspace) as { id: string } | undefined
|
|
188
|
+
: 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;
|
|
191
|
+
|
|
192
|
+
if (pending) {
|
|
193
|
+
const claimed = db.prepare(
|
|
194
|
+
`UPDATE coord_assignments SET agent_id = ?, status = 'assigned', started_at = datetime('now') WHERE id = ? AND status = 'pending'`
|
|
195
|
+
).run(agentId, pending.id);
|
|
196
|
+
|
|
197
|
+
if (claimed.changes > 0) {
|
|
198
|
+
db.prepare(
|
|
199
|
+
`UPDATE coord_agents SET status = 'working', current_task = ? WHERE id = ?`
|
|
200
|
+
).run(pending.id, agentId);
|
|
201
|
+
db.prepare(
|
|
202
|
+
`INSERT INTO coord_events (agent_id, event_type, detail) VALUES (?, 'assignment_claimed', ?)`
|
|
203
|
+
).run(agentId, `auto-claimed assignment ${pending.id} via /next`);
|
|
204
|
+
assignment = db.prepare(`SELECT * FROM coord_assignments WHERE id = ?`).get(pending.id) as Record<string, unknown> | undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Read current agent status after all mutations
|
|
210
|
+
const agentRow = db.prepare(`SELECT status FROM coord_agents WHERE id = ?`).get(agentId) as { status: string };
|
|
211
|
+
|
|
212
|
+
return reply.send({
|
|
213
|
+
agentId,
|
|
214
|
+
status: agentRow.status,
|
|
215
|
+
assignment: assignment ?? null,
|
|
216
|
+
commands: activeCommands,
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
112
220
|
// ─── Assignments ────────────────────────────────────────────────
|
|
113
221
|
|
|
114
222
|
app.post('/assign', async (req, reply) => {
|
|
115
223
|
const parsed = assignCreateSchema.safeParse(req.body);
|
|
116
224
|
if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
117
|
-
const { agentId, task, description, workspace } = parsed.data;
|
|
225
|
+
const { agentId, task, description, workspace, priority, blocked_by } = parsed.data;
|
|
118
226
|
|
|
119
227
|
const id = randomUUID();
|
|
120
228
|
db.prepare(
|
|
121
|
-
`INSERT INTO coord_assignments (id, agent_id, task, description, status, workspace) VALUES (?, ?, ?, ?, ?, ?)`
|
|
122
|
-
).run(id, agentId ?? null, task, description ?? null, agentId ? 'assigned' : 'pending', workspace ?? null);
|
|
229
|
+
`INSERT INTO coord_assignments (id, agent_id, task, description, status, priority, blocked_by, workspace) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
230
|
+
).run(id, agentId ?? null, task, description ?? null, agentId ? 'assigned' : 'pending', priority, blocked_by ?? null, workspace ?? null);
|
|
123
231
|
|
|
124
232
|
if (agentId) {
|
|
125
233
|
db.prepare(
|
|
@@ -142,7 +250,20 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
|
|
|
142
250
|
});
|
|
143
251
|
|
|
144
252
|
app.get('/assignment', async (req, reply) => {
|
|
145
|
-
const
|
|
253
|
+
const q = assignmentQuerySchema.parse(req.query);
|
|
254
|
+
let agentId = (req.headers['x-agent-id'] as string | undefined) ?? q.agentId;
|
|
255
|
+
|
|
256
|
+
// Fallback: resolve agentId from name + workspace
|
|
257
|
+
if (!agentId && q.name) {
|
|
258
|
+
const found = q.workspace
|
|
259
|
+
? db.prepare(
|
|
260
|
+
`SELECT id FROM coord_agents WHERE name = ? AND workspace = ? AND status != 'dead'`
|
|
261
|
+
).get(q.name, q.workspace) as { id: string } | undefined
|
|
262
|
+
: db.prepare(
|
|
263
|
+
`SELECT id FROM coord_agents WHERE name = ? AND workspace IS NULL AND status != 'dead'`
|
|
264
|
+
).get(q.name) as { id: string } | undefined;
|
|
265
|
+
agentId = found?.id;
|
|
266
|
+
}
|
|
146
267
|
|
|
147
268
|
if (!agentId) {
|
|
148
269
|
return reply.send({ assignment: null });
|
|
@@ -157,12 +278,13 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
|
|
|
157
278
|
const agentRow = db.prepare(`SELECT workspace FROM coord_agents WHERE id = ?`).get(agentId) as { workspace: string | null } | undefined;
|
|
158
279
|
const agentWorkspace = agentRow?.workspace;
|
|
159
280
|
|
|
281
|
+
const blockedFilter = `AND (blocked_by IS NULL OR blocked_by IN (SELECT id FROM coord_assignments WHERE status = 'completed'))`;
|
|
160
282
|
const pending = agentWorkspace
|
|
161
283
|
? db.prepare(
|
|
162
|
-
`SELECT * FROM coord_assignments WHERE status = 'pending' AND (workspace = ? OR workspace IS NULL) ORDER BY created_at ASC LIMIT 1`
|
|
284
|
+
`SELECT * FROM coord_assignments WHERE status = 'pending' AND (workspace = ? OR workspace IS NULL) ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
|
|
163
285
|
).get(agentWorkspace) as { id: string } | undefined
|
|
164
286
|
: db.prepare(
|
|
165
|
-
`SELECT * FROM coord_assignments WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1`
|
|
287
|
+
`SELECT * FROM coord_assignments WHERE status = 'pending' ${blockedFilter} ORDER BY priority DESC, created_at ASC LIMIT 1`
|
|
166
288
|
).get() as { id: string } | undefined;
|
|
167
289
|
|
|
168
290
|
if (pending) {
|
|
@@ -217,11 +339,23 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
|
|
|
217
339
|
return reply.send({ ok: true, assignmentId: id });
|
|
218
340
|
});
|
|
219
341
|
|
|
220
|
-
function handleAssignmentUpdate(id: string, status: string, result: string | undefined) {
|
|
342
|
+
function handleAssignmentUpdate(id: string, status: string, result: string | undefined, commitSha: string | undefined): { error?: string } {
|
|
343
|
+
// Verification gate: completed status requires structured proof of work
|
|
344
|
+
if (status === 'completed') {
|
|
345
|
+
if (!result || result.trim().length < 20) {
|
|
346
|
+
return { error: 'completion requires a result summary — minimum 20 characters describing what was done' };
|
|
347
|
+
}
|
|
348
|
+
// Must mention at least one of: commit/SHA, build, audit, test, verified, fix, created, updated, implemented
|
|
349
|
+
const actionWords = /\b(commit|sha|[0-9a-f]{7,40}|build|audit|test|verified|fix|created|updated|implemented|added|refactored|documented)\b/i;
|
|
350
|
+
if (!actionWords.test(result)) {
|
|
351
|
+
return { error: 'completion result must describe the work done — include what was committed, built, tested, or verified' };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
221
355
|
if (['completed', 'failed'].includes(status)) {
|
|
222
356
|
db.prepare(
|
|
223
|
-
`UPDATE coord_assignments SET status = ?, result = ?, completed_at = datetime('now') WHERE id = ?`
|
|
224
|
-
).run(status, result ?? null, id);
|
|
357
|
+
`UPDATE coord_assignments SET status = ?, result = ?, commit_sha = ?, completed_at = datetime('now') WHERE id = ?`
|
|
358
|
+
).run(status, result ?? null, commitSha ?? null, id);
|
|
225
359
|
} else {
|
|
226
360
|
db.prepare(
|
|
227
361
|
`UPDATE coord_assignments SET status = ?, result = ? WHERE id = ?`
|
|
@@ -237,9 +371,12 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
|
|
|
237
371
|
}
|
|
238
372
|
}
|
|
239
373
|
|
|
374
|
+
const eventDetail = ['completed', 'failed'].includes(status)
|
|
375
|
+
? `${id} → ${status}${commitSha ? ' [' + commitSha + ']' : ''}: ${(result ?? '').slice(0, 300)}`
|
|
376
|
+
: `${id} → ${status}`;
|
|
240
377
|
db.prepare(
|
|
241
378
|
`INSERT INTO coord_events (agent_id, event_type, detail) VALUES ((SELECT agent_id FROM coord_assignments WHERE id = ?), 'assignment_update', ?)`
|
|
242
|
-
).run(id,
|
|
379
|
+
).run(id, eventDetail);
|
|
243
380
|
|
|
244
381
|
// Log completion/failure with agent name and task
|
|
245
382
|
if (['completed', 'failed'].includes(status)) {
|
|
@@ -248,13 +385,39 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
|
|
|
248
385
|
).get(id) as { task: string; agent_name: string | null } | undefined;
|
|
249
386
|
coordLog(`${info?.agent_name ?? 'unknown'} ${status}: ${info?.task?.slice(0, 80) ?? id}`);
|
|
250
387
|
}
|
|
388
|
+
|
|
389
|
+
return {};
|
|
251
390
|
}
|
|
252
391
|
|
|
392
|
+
app.get('/assignment/:id', async (req, reply) => {
|
|
393
|
+
const { id } = assignmentIdParamSchema.parse(req.params);
|
|
394
|
+
const assignment = db.prepare(
|
|
395
|
+
`SELECT a.*, g.name AS agent_name FROM coord_assignments a LEFT JOIN coord_agents g ON a.agent_id = g.id WHERE a.id = ?`
|
|
396
|
+
).get(id);
|
|
397
|
+
if (!assignment) return reply.code(404).send({ error: 'assignment not found' });
|
|
398
|
+
return reply.send({ assignment });
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// List all non-completed assignments with priority and blocked_by status
|
|
402
|
+
app.get('/assignments', async (req, reply) => {
|
|
403
|
+
const assignments = db.prepare(
|
|
404
|
+
`SELECT a.*, g.name AS agent_name,
|
|
405
|
+
CASE WHEN a.blocked_by IS NOT NULL AND a.blocked_by NOT IN (SELECT id FROM coord_assignments WHERE status = 'completed')
|
|
406
|
+
THEN 1 ELSE 0 END AS is_blocked
|
|
407
|
+
FROM coord_assignments a
|
|
408
|
+
LEFT JOIN coord_agents g ON a.agent_id = g.id
|
|
409
|
+
WHERE a.status NOT IN ('completed', 'failed')
|
|
410
|
+
ORDER BY a.priority DESC, a.created_at ASC`
|
|
411
|
+
).all();
|
|
412
|
+
return reply.send({ assignments });
|
|
413
|
+
});
|
|
414
|
+
|
|
253
415
|
app.post('/assignment/:id/update', async (req, reply) => {
|
|
254
416
|
const { id } = assignmentIdParamSchema.parse(req.params);
|
|
255
417
|
const parsed = assignmentUpdateSchema.safeParse(req.body);
|
|
256
418
|
if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
257
|
-
handleAssignmentUpdate(id, parsed.data.status, parsed.data.result);
|
|
419
|
+
const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
|
|
420
|
+
if (gate.error) return reply.code(400).send({ error: gate.error });
|
|
258
421
|
return reply.send({ ok: true });
|
|
259
422
|
});
|
|
260
423
|
|
|
@@ -262,7 +425,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
|
|
|
262
425
|
const { id } = assignmentIdParamSchema.parse(req.params);
|
|
263
426
|
const parsed = assignmentUpdateSchema.safeParse(req.body);
|
|
264
427
|
if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
265
|
-
handleAssignmentUpdate(id, parsed.data.status, parsed.data.result);
|
|
428
|
+
const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
|
|
429
|
+
if (gate.error) return reply.code(400).send({ error: gate.error });
|
|
266
430
|
return reply.send({ ok: true });
|
|
267
431
|
});
|
|
268
432
|
|
|
@@ -270,7 +434,8 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
|
|
|
270
434
|
const { id } = assignmentIdParamSchema.parse(req.params);
|
|
271
435
|
const parsed = assignmentUpdateSchema.safeParse(req.body);
|
|
272
436
|
if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
273
|
-
handleAssignmentUpdate(id, parsed.data.status, parsed.data.result);
|
|
437
|
+
const gate = handleAssignmentUpdate(id, parsed.data.status, parsed.data.result, parsed.data.commit_sha);
|
|
438
|
+
if (gate.error) return reply.code(400).send({ error: gate.error });
|
|
274
439
|
return reply.send({ ok: true });
|
|
275
440
|
});
|
|
276
441
|
|
|
@@ -507,6 +672,31 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
|
|
|
507
672
|
return reply.send({ total: total.total, bySeverity, byCategory });
|
|
508
673
|
});
|
|
509
674
|
|
|
675
|
+
// ─── Decisions (cross-agent propagation) ────────────────────────
|
|
676
|
+
|
|
677
|
+
app.get('/decisions', async (req, reply) => {
|
|
678
|
+
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 };
|
|
680
|
+
|
|
681
|
+
let sql = `
|
|
682
|
+
SELECT d.id, d.author_id, a.name AS author_name, d.assignment_id, d.tags, d.summary, d.created_at
|
|
683
|
+
FROM coord_decisions d JOIN coord_agents a ON d.author_id = a.id
|
|
684
|
+
WHERE d.id > ?
|
|
685
|
+
`;
|
|
686
|
+
const params: unknown[] = [since_id];
|
|
687
|
+
|
|
688
|
+
if (assignment_id) {
|
|
689
|
+
sql += ` AND d.assignment_id = ?`;
|
|
690
|
+
params.push(assignment_id);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
sql += ` ORDER BY d.created_at ASC LIMIT ?`;
|
|
694
|
+
params.push(limit);
|
|
695
|
+
|
|
696
|
+
const decisions = db.prepare(sql).all(...params);
|
|
697
|
+
return reply.send({ decisions });
|
|
698
|
+
});
|
|
699
|
+
|
|
510
700
|
// ─── Status ─────────────────────────────────────────────────────
|
|
511
701
|
|
|
512
702
|
app.get('/status', async (_req, reply) => {
|
|
@@ -24,6 +24,10 @@ CREATE TABLE IF NOT EXISTS coord_agents (
|
|
|
24
24
|
workspace TEXT
|
|
25
25
|
);
|
|
26
26
|
|
|
27
|
+
-- Prevent duplicate agent registrations for the same name+workspace
|
|
28
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_coord_agents_name_workspace
|
|
29
|
+
ON coord_agents (name, COALESCE(workspace, ''));
|
|
30
|
+
|
|
27
31
|
-- Coordination: assignments
|
|
28
32
|
CREATE TABLE IF NOT EXISTS coord_assignments (
|
|
29
33
|
id TEXT PRIMARY KEY,
|
|
@@ -31,12 +35,16 @@ CREATE TABLE IF NOT EXISTS coord_assignments (
|
|
|
31
35
|
task TEXT NOT NULL,
|
|
32
36
|
description TEXT,
|
|
33
37
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
38
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
blocked_by TEXT,
|
|
34
40
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
35
41
|
started_at TEXT,
|
|
36
42
|
completed_at TEXT,
|
|
37
43
|
result TEXT,
|
|
44
|
+
commit_sha TEXT,
|
|
38
45
|
workspace TEXT,
|
|
39
|
-
FOREIGN KEY (agent_id) REFERENCES coord_agents(id)
|
|
46
|
+
FOREIGN KEY (agent_id) REFERENCES coord_agents(id),
|
|
47
|
+
FOREIGN KEY (blocked_by) REFERENCES coord_assignments(id)
|
|
40
48
|
);
|
|
41
49
|
|
|
42
50
|
-- Coordination: file locks
|
|
@@ -75,6 +83,19 @@ CREATE TABLE IF NOT EXISTS coord_findings (
|
|
|
75
83
|
FOREIGN KEY (agent_id) REFERENCES coord_agents(id)
|
|
76
84
|
);
|
|
77
85
|
|
|
86
|
+
-- Coordination: cross-agent decision propagation
|
|
87
|
+
CREATE TABLE IF NOT EXISTS coord_decisions (
|
|
88
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
89
|
+
author_id TEXT NOT NULL,
|
|
90
|
+
assignment_id TEXT,
|
|
91
|
+
tags TEXT,
|
|
92
|
+
summary TEXT NOT NULL,
|
|
93
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
94
|
+
FOREIGN KEY (author_id) REFERENCES coord_agents(id)
|
|
95
|
+
);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_coord_decisions_assignment
|
|
97
|
+
ON coord_decisions (assignment_id, created_at);
|
|
98
|
+
|
|
78
99
|
-- Coordination: event audit trail
|
|
79
100
|
CREATE TABLE IF NOT EXISTS coord_events (
|
|
80
101
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -91,4 +112,9 @@ CREATE TABLE IF NOT EXISTS coord_events (
|
|
|
91
112
|
*/
|
|
92
113
|
export function initCoordinationTables(db: Database.Database): void {
|
|
93
114
|
db.exec(COORDINATION_TABLES);
|
|
115
|
+
|
|
116
|
+
// Migrations: add columns to existing coord_assignments tables
|
|
117
|
+
try { db.exec(`ALTER TABLE coord_assignments ADD COLUMN commit_sha TEXT`); } catch { /* exists */ }
|
|
118
|
+
try { db.exec(`ALTER TABLE coord_assignments ADD COLUMN priority INTEGER NOT NULL DEFAULT 0`); } catch { /* exists */ }
|
|
119
|
+
try { db.exec(`ALTER TABLE coord_assignments ADD COLUMN blocked_by TEXT`); } catch { /* exists */ }
|
|
94
120
|
}
|
|
@@ -43,13 +43,23 @@ export const assignCreateSchema = z.object({
|
|
|
43
43
|
task: z.string().min(1).max(1000),
|
|
44
44
|
description: z.string().max(5000).optional(),
|
|
45
45
|
workspace: z.string().max(50).optional(),
|
|
46
|
+
priority: z.number().int().min(0).max(10).default(0),
|
|
47
|
+
blocked_by: z.string().uuid().optional(),
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
export const assignmentQuerySchema = z.object({
|
|
49
51
|
agentId: z.string().uuid().optional(),
|
|
52
|
+
name: z.string().min(1).max(50).optional(),
|
|
50
53
|
workspace: z.string().max(50).optional(),
|
|
51
54
|
});
|
|
52
55
|
|
|
56
|
+
export const nextSchema = z.object({
|
|
57
|
+
name: z.string().min(1).max(50),
|
|
58
|
+
workspace: z.string().max(50).optional(),
|
|
59
|
+
role: agentRoleEnum.default('worker'),
|
|
60
|
+
capabilities: z.array(z.string().max(50)).max(20).optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
53
63
|
export const assignmentClaimSchema = z.object({
|
|
54
64
|
agentId: z.string().uuid(),
|
|
55
65
|
});
|
|
@@ -57,6 +67,7 @@ export const assignmentClaimSchema = z.object({
|
|
|
57
67
|
export const assignmentUpdateSchema = z.object({
|
|
58
68
|
status: assignmentStatusEnum,
|
|
59
69
|
result: z.string().max(10000).optional(),
|
|
70
|
+
commit_sha: z.string().max(100).optional(),
|
|
60
71
|
});
|
|
61
72
|
|
|
62
73
|
// ─── Locks ──────────────────────────────────────────────────────
|
|
@@ -118,6 +129,14 @@ export const pulseSchema = z.object({
|
|
|
118
129
|
agentId: z.string().uuid(),
|
|
119
130
|
});
|
|
120
131
|
|
|
132
|
+
// ─── Decisions ─────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export const decisionsQuerySchema = z.object({
|
|
135
|
+
since_id: z.coerce.number().int().min(0).default(0),
|
|
136
|
+
assignment_id: z.string().max(100).optional(),
|
|
137
|
+
limit: z.coerce.number().int().min(1).max(200).default(20),
|
|
138
|
+
});
|
|
139
|
+
|
|
121
140
|
// ─── Status / Events ────────────────────────────────────────────
|
|
122
141
|
|
|
123
142
|
export const eventsQuerySchema = z.object({
|
|
@@ -70,6 +70,14 @@ export function pruneOldHeartbeats(db: Database.Database): number {
|
|
|
70
70
|
return result.changes;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** Purge dead agents older than 24 hours to prevent table bloat. */
|
|
74
|
+
export function purgeDeadAgents(db: Database.Database, maxAgeHours = 24): number {
|
|
75
|
+
const result = db.prepare(
|
|
76
|
+
`DELETE FROM coord_agents WHERE status = 'dead' AND last_seen < datetime('now', '-' || ? || ' hours')`
|
|
77
|
+
).run(maxAgeHours);
|
|
78
|
+
return result.changes;
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
/** Clean slate on startup: mark all live agents dead, release locks, clear commands. */
|
|
74
82
|
export function cleanSlate(db: Database.Database): void {
|
|
75
83
|
const alive = db.prepare(
|