agent-relay-server 0.4.16 → 0.4.17

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/src/db.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { VERSION } from "./config.ts";
3
4
  import type {
4
5
  AgentCard,
6
+ AgentSessionGuard,
7
+ CreatePairInput,
8
+ HealthCheck,
9
+ HealthReport,
5
10
  Message,
6
11
  MessageType,
12
+ PairActionInput,
13
+ PairMessageInput,
14
+ PairSession,
15
+ PairStatus,
7
16
  RegisterAgentInput,
8
17
  SendMessageInput,
9
18
  PollQuery,
@@ -14,7 +23,7 @@ import type {
14
23
  IntegrationEventInput,
15
24
  TaskStatusInput,
16
25
  } from "./types";
17
- import { STALE_TTL_MS, DAY_MS } from "./config";
26
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS } from "./config";
18
27
 
19
28
  let db: Database;
20
29
 
@@ -32,6 +41,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
32
41
  rig TEXT,
33
42
  capabilities TEXT NOT NULL DEFAULT '[]',
34
43
  status TEXT NOT NULL DEFAULT 'idle',
44
+ instance_id TEXT,
45
+ epoch INTEGER NOT NULL DEFAULT 0,
35
46
  meta TEXT NOT NULL DEFAULT '{}',
36
47
  last_seen INTEGER NOT NULL,
37
48
  created_at INTEGER NOT NULL
@@ -50,6 +61,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
50
61
  claimable INTEGER NOT NULL DEFAULT 0,
51
62
  claimed_by TEXT,
52
63
  claimed_at INTEGER,
64
+ claim_expires_at INTEGER,
65
+ idempotency_key TEXT,
53
66
  meta TEXT NOT NULL DEFAULT '{}',
54
67
  read_by TEXT NOT NULL DEFAULT '[]',
55
68
  created_at INTEGER NOT NULL
@@ -73,6 +86,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
73
86
  occurrence_count INTEGER NOT NULL DEFAULT 1,
74
87
  claimed_by TEXT,
75
88
  claimed_at INTEGER,
89
+ claim_expires_at INTEGER,
76
90
  message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
77
91
  result TEXT,
78
92
  metadata TEXT NOT NULL DEFAULT '{}',
@@ -110,6 +124,26 @@ export function initDb(path: string = "agent-relay.db"): Database {
110
124
  created_at INTEGER NOT NULL,
111
125
  updated_at INTEGER NOT NULL
112
126
  );
127
+
128
+ CREATE TABLE IF NOT EXISTS pairs (
129
+ id TEXT PRIMARY KEY,
130
+ requester_id TEXT NOT NULL,
131
+ target_id TEXT NOT NULL,
132
+ status TEXT NOT NULL,
133
+ objective TEXT,
134
+ meta TEXT NOT NULL DEFAULT '{}',
135
+ created_at INTEGER NOT NULL,
136
+ updated_at INTEGER NOT NULL,
137
+ expires_at INTEGER NOT NULL,
138
+ accepted_at INTEGER,
139
+ ended_at INTEGER,
140
+ ended_by TEXT,
141
+ last_message_at INTEGER
142
+ );
143
+
144
+ CREATE INDEX IF NOT EXISTS idx_pairs_requester ON pairs(requester_id);
145
+ CREATE INDEX IF NOT EXISTS idx_pairs_target ON pairs(target_id);
146
+ CREATE INDEX IF NOT EXISTS idx_pairs_status ON pairs(status);
113
147
  `);
114
148
 
115
149
  // Migrations
@@ -124,7 +158,17 @@ export function initDb(path: string = "agent-relay.db"): Database {
124
158
  db.run("ALTER TABLE messages ADD COLUMN claimed_by TEXT");
125
159
  db.run("ALTER TABLE messages ADD COLUMN claimed_at INTEGER");
126
160
  }
161
+ if (!colNames.includes("claim_expires_at")) {
162
+ db.run("ALTER TABLE messages ADD COLUMN claim_expires_at INTEGER");
163
+ }
164
+ if (!colNames.includes("idempotency_key")) {
165
+ db.run("ALTER TABLE messages ADD COLUMN idempotency_key TEXT");
166
+ }
167
+ db.prepare(
168
+ "UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
169
+ ).run(Date.now(), CLAIM_LEASE_MS);
127
170
  db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
171
+ db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
128
172
 
129
173
  if (!colNames.includes("type")) {
130
174
  db.run("ALTER TABLE messages ADD COLUMN type TEXT NOT NULL DEFAULT 'message'");
@@ -133,6 +177,15 @@ export function initDb(path: string = "agent-relay.db"): Database {
133
177
  // Backfill thread_id for pre-migration rows (self-threaded).
134
178
  db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
135
179
 
180
+ const taskCols = db.prepare("PRAGMA table_info(tasks)").all() as any[];
181
+ const taskColNames = taskCols.map((c: any) => c.name);
182
+ if (!taskColNames.includes("claim_expires_at")) {
183
+ db.run("ALTER TABLE tasks ADD COLUMN claim_expires_at INTEGER");
184
+ }
185
+ db.prepare(
186
+ "UPDATE tasks SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
187
+ ).run(Date.now(), CLAIM_LEASE_MS);
188
+
136
189
  // message_reads: relational replacement for the read_by JSON array.
137
190
  db.run(`
138
191
  CREATE TABLE IF NOT EXISTS message_reads (
@@ -153,6 +206,12 @@ export function initDb(path: string = "agent-relay.db"): Database {
153
206
  if (!agentColNames.includes("ready")) {
154
207
  db.run("ALTER TABLE agents ADD COLUMN ready INTEGER NOT NULL DEFAULT 0");
155
208
  }
209
+ if (!agentColNames.includes("instance_id")) {
210
+ db.run("ALTER TABLE agents ADD COLUMN instance_id TEXT");
211
+ }
212
+ if (!agentColNames.includes("epoch")) {
213
+ db.run("ALTER TABLE agents ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0");
214
+ }
156
215
  db.run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
157
216
 
158
217
  // Built-in agents — registered unconditionally so sends from these ids
@@ -209,6 +268,8 @@ function rowToAgent(row: any): AgentCard {
209
268
  capabilities: parseStringArray(row.capabilities),
210
269
  ready: row.ready === 1,
211
270
  status: row.status,
271
+ instanceId: row.instance_id ?? undefined,
272
+ epoch: typeof row.epoch === "number" ? row.epoch : 0,
212
273
  meta: parseJson(row.meta, {}),
213
274
  lastSeen: row.last_seen,
214
275
  createdAt: row.created_at,
@@ -229,6 +290,8 @@ function rowToMessage(row: any): Message {
229
290
  claimable: row.claimable === 1 ? true : undefined,
230
291
  claimedBy: row.claimed_by ?? undefined,
231
292
  claimedAt: row.claimed_at ?? undefined,
293
+ claimExpiresAt: row.claim_expires_at ?? undefined,
294
+ idempotencyKey: row.idempotency_key ?? undefined,
232
295
  meta: parseJson(row.meta, {}),
233
296
  readBy: parseJson(row.read_by_agents ?? "[]", []),
234
297
  createdAt: row.created_at,
@@ -250,6 +313,7 @@ function rowToTask(row: any): Task {
250
313
  occurrenceCount: row.occurrence_count,
251
314
  claimedBy: row.claimed_by ?? undefined,
252
315
  claimedAt: row.claimed_at ?? undefined,
316
+ claimExpiresAt: row.claim_expires_at ?? undefined,
253
317
  messageId: row.message_id ?? undefined,
254
318
  result: row.result ?? undefined,
255
319
  metadata: parseJson(row.metadata, {}),
@@ -273,6 +337,24 @@ function rowToTaskEvent(row: any): TaskEvent {
273
337
  };
274
338
  }
275
339
 
340
+ function rowToPair(row: any): PairSession {
341
+ return {
342
+ id: row.id,
343
+ requesterId: row.requester_id,
344
+ targetId: row.target_id,
345
+ status: row.status as PairStatus,
346
+ objective: row.objective ?? undefined,
347
+ createdAt: row.created_at,
348
+ updatedAt: row.updated_at,
349
+ expiresAt: row.expires_at,
350
+ acceptedAt: row.accepted_at ?? undefined,
351
+ endedAt: row.ended_at ?? undefined,
352
+ endedBy: row.ended_by ?? undefined,
353
+ lastMessageAt: row.last_message_at ?? undefined,
354
+ meta: parseJson(row.meta, {}),
355
+ };
356
+ }
357
+
276
358
  const MSG_SELECT = `SELECT m.*, (
277
359
  SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
278
360
  ) AS read_by_agents FROM messages m`;
@@ -285,9 +367,10 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
285
367
  // explicitly sends one (including null to clear).
286
368
  const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
287
369
  const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
370
+ const instanceProvided = Boolean(input.instanceId);
288
371
  const stmt = db.prepare(`
289
- INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
290
- VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $meta, $now, $now)
372
+ INSERT INTO agents (id, name, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, meta, last_seen, created_at)
373
+ VALUES ($id, $name, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $meta, $now, $now)
291
374
  ON CONFLICT(id) DO UPDATE SET
292
375
  name = $name,
293
376
  label = CASE WHEN $labelProvided = 1 THEN $label ELSE agents.label END,
@@ -297,6 +380,11 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
297
380
  capabilities = $capabilities,
298
381
  ready = CASE WHEN $readyProvided = 1 THEN $ready ELSE agents.ready END,
299
382
  status = $status,
383
+ instance_id = CASE WHEN $instanceProvided = 1 THEN $instanceId ELSE agents.instance_id END,
384
+ epoch = CASE
385
+ WHEN $instanceProvided = 1 AND (agents.instance_id IS NULL OR agents.instance_id <> $instanceId) THEN agents.epoch + 1
386
+ ELSE agents.epoch
387
+ END,
300
388
  meta = $meta,
301
389
  last_seen = $now
302
390
  `);
@@ -313,6 +401,9 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
313
401
  $ready: input.ready ? 1 : 0,
314
402
  $readyProvided: readyProvided ? 1 : 0,
315
403
  $status: input.status ?? "idle",
404
+ $instanceId: input.instanceId ?? null,
405
+ $instanceProvided: instanceProvided ? 1 : 0,
406
+ $initialEpoch: instanceProvided ? 1 : 0,
316
407
  $meta: JSON.stringify(input.meta ?? {}),
317
408
  $now: now,
318
409
  });
@@ -320,6 +411,19 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
320
411
  return getAgent(input.id)!;
321
412
  }
322
413
 
414
+ export function validateAgentSession(id: string, guard?: AgentSessionGuard): { ok: boolean; error?: string } {
415
+ if (!guard || (!guard.instanceId && guard.epoch === undefined)) return { ok: true };
416
+ const agent = getAgent(id);
417
+ if (!agent) return { ok: false, error: "agent not found" };
418
+ if (guard.instanceId && agent.instanceId !== guard.instanceId) {
419
+ return { ok: false, error: "stale agent instance" };
420
+ }
421
+ if (guard.epoch !== undefined && agent.epoch !== guard.epoch) {
422
+ return { ok: false, error: "stale agent instance" };
423
+ }
424
+ return { ok: true };
425
+ }
426
+
323
427
  export function setLabel(id: string, label: string | null): boolean {
324
428
  const normalized = label && label.trim() ? label.trim() : null;
325
429
  return (
@@ -327,6 +431,12 @@ export function setLabel(id: string, label: string | null): boolean {
327
431
  );
328
432
  }
329
433
 
434
+ export function setTags(id: string, tags: string[]): AgentCard | null {
435
+ const normalized = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
436
+ const result = db.prepare("UPDATE agents SET tags = ?, last_seen = ? WHERE id = ?").run(JSON.stringify(normalized), Date.now(), id);
437
+ return result.changes > 0 ? getAgent(id) : null;
438
+ }
439
+
330
440
  export function getAgent(id: string): AgentCard | null {
331
441
  const row = db.prepare("SELECT * FROM agents WHERE id = ?").get(id) as any;
332
442
  return row ? rowToAgent(row) : null;
@@ -357,16 +467,20 @@ export function listAgents(filter?: {
357
467
  return (db.prepare(sql).all(...params) as any[]).map(rowToAgent);
358
468
  }
359
469
 
360
- export function setStatus(id: string, status: AgentCard["status"]): boolean {
470
+ export function setStatus(id: string, status: AgentCard["status"], guard?: AgentSessionGuard): boolean {
471
+ if (!validateAgentSession(id, guard).ok) return false;
361
472
  const now = Date.now();
362
473
  const ready = status === "offline" ? 0 : undefined;
363
474
  const sql = ready === 0
364
475
  ? "UPDATE agents SET status = ?, ready = 0, last_seen = ? WHERE id = ?"
365
476
  : "UPDATE agents SET status = ?, last_seen = ? WHERE id = ?";
366
- return db.prepare(sql).run(status, now, id).changes > 0;
477
+ const changed = db.prepare(sql).run(status, now, id).changes > 0;
478
+ if (changed && status === "offline") closeOpenPairsForAgent(id, now);
479
+ return changed;
367
480
  }
368
481
 
369
- export function markReady(id: string, ready: boolean): boolean {
482
+ export function markReady(id: string, ready: boolean, guard?: AgentSessionGuard): boolean {
483
+ if (!validateAgentSession(id, guard).ok) return false;
370
484
  const now = Date.now();
371
485
  return (
372
486
  db
@@ -375,7 +489,8 @@ export function markReady(id: string, ready: boolean): boolean {
375
489
  );
376
490
  }
377
491
 
378
- export function heartbeat(id: string): boolean {
492
+ export function heartbeat(id: string, guard?: AgentSessionGuard): boolean {
493
+ if (!validateAgentSession(id, guard).ok) return false;
379
494
  const result = db
380
495
  .prepare("UPDATE agents SET last_seen = ? WHERE id = ?")
381
496
  .run(Date.now(), id);
@@ -383,12 +498,14 @@ export function heartbeat(id: string): boolean {
383
498
  }
384
499
 
385
500
  export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
386
- const cutoff = Date.now() - ttlMs;
501
+ const now = Date.now();
502
+ const cutoff = now - ttlMs;
387
503
  const rows = db
388
504
  .prepare(
389
505
  "UPDATE agents SET status = 'offline', ready = 0 WHERE status != 'offline' AND last_seen < ? AND id NOT IN ('user', 'system') RETURNING id"
390
506
  )
391
507
  .all(cutoff) as any[];
508
+ for (const row of rows) closeOpenPairsForAgent(row.id, now);
392
509
  return rows.map((r: any) => r.id);
393
510
  }
394
511
 
@@ -401,19 +518,22 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
401
518
  )
402
519
  .all(cutoff) as any[];
403
520
  if (rows.length === 0) return [];
521
+ const now = Date.now();
404
522
 
405
523
  // Release claims held by pruned agents so work becomes claimable again.
406
524
  db
407
525
  .prepare(
408
- "UPDATE messages SET claimed_by = NULL, claimed_at = NULL WHERE claimed_by IN (SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system'))"
526
+ "UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by IN (SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system'))"
409
527
  )
410
528
  .run(cutoff);
411
529
 
412
530
  db
413
531
  .prepare(
414
- "UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, updated_at = ? WHERE claimed_by IN (SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')) AND status IN ('claimed', 'in_progress', 'blocked')"
532
+ "UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by IN (SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')) AND status IN ('claimed', 'in_progress', 'blocked')"
415
533
  )
416
- .run(Date.now(), cutoff);
534
+ .run(now, cutoff);
535
+
536
+ for (const row of rows) closeOpenPairsForAgent(row.id, now);
417
537
 
418
538
  db
419
539
  .prepare(
@@ -443,8 +563,10 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
443
563
  const deleted = db.transaction(() => {
444
564
  // Release any claims held by this agent so the tasks become claimable again.
445
565
  // from_agent is left intact as historical record.
446
- db.prepare("UPDATE messages SET claimed_by = NULL, claimed_at = NULL WHERE claimed_by = ?").run(id);
447
- db.prepare("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, updated_at = ? WHERE claimed_by = ? AND status IN ('claimed', 'in_progress', 'blocked')").run(Date.now(), id);
566
+ const now = Date.now();
567
+ db.prepare("UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by = ?").run(id);
568
+ db.prepare("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by = ? AND status IN ('claimed', 'in_progress', 'blocked')").run(now, id);
569
+ closeOpenPairsForAgent(id, now);
448
570
  return db.prepare("DELETE FROM agents WHERE id = ?").run(id).changes > 0;
449
571
  })();
450
572
  return deleted ? { ok: true } : { ok: false, error: "agent not found" };
@@ -610,7 +732,50 @@ export function listTaskEvents(taskId: number): TaskEvent[] {
610
732
  return (db.prepare("SELECT * FROM task_events WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[]).map(rowToTaskEvent);
611
733
  }
612
734
 
613
- export function claimTask(taskId: number, agentId: string): { ok: boolean; error?: string; task?: Task } {
735
+ export function releaseExpiredClaims(now: number = Date.now()): { messageIds: number[]; tasks: Task[] } {
736
+ return db.transaction(() => {
737
+ const releasableMessageClaim = "claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = messages.id AND t.status IN ('done', 'failed', 'canceled'))";
738
+ const messageRows = db
739
+ .prepare(`SELECT id FROM messages WHERE ${releasableMessageClaim}`)
740
+ .all(now) as any[];
741
+ const taskRows = db
742
+ .prepare(`${TASK_SELECT} WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')`)
743
+ .all(now) as any[];
744
+
745
+ if (messageRows.length > 0) {
746
+ db
747
+ .prepare(`UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE ${releasableMessageClaim}`)
748
+ .run(now);
749
+ }
750
+
751
+ if (taskRows.length > 0) {
752
+ db
753
+ .prepare("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')")
754
+ .run(now, now);
755
+
756
+ for (const row of taskRows) {
757
+ insertTaskEvent(row.id, {
758
+ source: "agent-relay",
759
+ type: "claim.expired",
760
+ severity: row.severity,
761
+ title: "Task claim expired",
762
+ body: `Claim by ${row.claimed_by} expired and was released`,
763
+ metadata: { agentId: row.claimed_by },
764
+ }, now);
765
+ }
766
+ }
767
+
768
+ return {
769
+ messageIds: messageRows.map((row: any) => row.id),
770
+ tasks: taskRows.map((row: any) => getTask(row.id)!),
771
+ };
772
+ })();
773
+ }
774
+
775
+ export function claimTask(taskId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
776
+ releaseExpiredClaims();
777
+ const session = validateAgentSession(agentId, guard);
778
+ if (!session.ok) return { ok: false, error: session.error };
614
779
  const agent = getAgent(agentId);
615
780
  if (!agent) return { ok: false, error: "claiming agent not found" };
616
781
  if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
@@ -621,10 +786,11 @@ export function claimTask(taskId: number, agentId: string): { ok: boolean; error
621
786
  try {
622
787
  return db.transaction(() => {
623
788
  const now = Date.now();
789
+ const expiresAt = now + CLAIM_LEASE_MS;
624
790
  const result = db.prepare(`
625
- UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, updated_at = ?
791
+ UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, claim_expires_at = ?, updated_at = ?
626
792
  WHERE id = ? AND status IN ('open', 'blocked')
627
- `).run(agentId, now, now, taskId);
793
+ `).run(agentId, now, expiresAt, now, taskId);
628
794
  if (result.changes === 0) return { ok: false, error: "claim race — already claimed" };
629
795
  if (task.messageId) {
630
796
  const messageClaim = claimMessageRow(task.messageId, agentId, now);
@@ -646,11 +812,36 @@ export function claimTask(taskId: number, agentId: string): { ok: boolean; error
646
812
  }
647
813
  }
648
814
 
815
+ export function renewTaskClaim(taskId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
816
+ releaseExpiredClaims();
817
+ const session = validateAgentSession(agentId, guard);
818
+ if (!session.ok) return { ok: false, error: session.error };
819
+ const agent = getAgent(agentId);
820
+ if (!agent) return { ok: false, error: "claiming agent not found" };
821
+ if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
822
+ const task = getTask(taskId);
823
+ if (!task) return { ok: false, error: "task not found" };
824
+ if (task.claimedBy !== agentId) return { ok: false, error: task.claimedBy ? `claimed by ${task.claimedBy}` : "task is not claimed" };
825
+ if (!["claimed", "in_progress", "blocked"].includes(task.status)) return { ok: false, error: `task is ${task.status}` };
826
+
827
+ const now = Date.now();
828
+ const expiresAt = now + CLAIM_LEASE_MS;
829
+ db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
830
+ if (task.messageId) {
831
+ db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
832
+ }
833
+ return { ok: true, task: getTask(taskId)! };
834
+ }
835
+
649
836
  export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok: boolean; error?: string; task?: Task; event?: TaskEvent } {
650
837
  const task = getTask(taskId);
651
838
  if (!task) return { ok: false, error: "task not found" };
652
839
  const now = Date.now();
653
840
  const agentId = input.agentId ?? task.claimedBy ?? null;
841
+ if (agentId) {
842
+ const session = validateAgentSession(agentId, input);
843
+ if (!session.ok) return { ok: false, error: session.error };
844
+ }
654
845
  const result = db.prepare(`
655
846
  UPDATE tasks
656
847
  SET status = ?, result = COALESCE(?, result), claimed_by = COALESCE(?, claimed_by),
@@ -659,6 +850,13 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
659
850
  WHERE id = ?
660
851
  `).run(input.status, input.result ?? null, agentId, agentId, now, now, now, taskId);
661
852
  if (result.changes === 0) return { ok: false, error: "task not found" };
853
+ if (agentId && ["claimed", "in_progress", "blocked"].includes(input.status)) {
854
+ const expiresAt = now + CLAIM_LEASE_MS;
855
+ db.prepare("UPDATE tasks SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, taskId, agentId);
856
+ if (task.messageId) {
857
+ db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
858
+ }
859
+ }
662
860
  const event = insertTaskEvent(taskId, {
663
861
  source: input.agentId ?? "agent-relay",
664
862
  type: `status:${input.status}`,
@@ -708,15 +906,284 @@ export function listCallbackDeliveries(taskId: number): Array<{
708
906
  }));
709
907
  }
710
908
 
909
+ // --- Pair sessions ---
910
+
911
+ const OPEN_PAIR_STATUSES = ["pending", "active"] as const;
912
+ const DEFAULT_PAIR_TTL_MS = 5 * 60_000;
913
+ const MAX_PAIR_TTL_MS = DAY_MS;
914
+
915
+ function expirePendingPairs(now: number = Date.now()): void {
916
+ db.prepare("UPDATE pairs SET status = 'expired', updated_at = ?, ended_at = ? WHERE status = 'pending' AND expires_at <= ?")
917
+ .run(now, now, now);
918
+ }
919
+
920
+ function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void {
921
+ db.prepare(`
922
+ UPDATE pairs
923
+ SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ?
924
+ WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
925
+ `).run(now, agentId, now, agentId, agentId);
926
+ }
927
+
928
+ function getOpenPairForAgent(agentId: string): PairSession | null {
929
+ expirePendingPairs();
930
+ const row = db.prepare(`
931
+ SELECT * FROM pairs
932
+ WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
933
+ ORDER BY updated_at DESC
934
+ LIMIT 1
935
+ `).get(agentId, agentId) as any;
936
+ return row ? rowToPair(row) : null;
937
+ }
938
+
939
+ function pairParticipant(pair: PairSession, agentId: string): boolean {
940
+ return pair.requesterId === agentId || pair.targetId === agentId;
941
+ }
942
+
943
+ function pairPeer(pair: PairSession, agentId: string): string {
944
+ return pair.requesterId === agentId ? pair.targetId : pair.requesterId;
945
+ }
946
+
947
+ function agentMatchesPairTarget(agent: AgentCard, target: string): boolean {
948
+ if (target.startsWith("id:")) return agent.id === target.slice(3);
949
+ if (target.startsWith("label:")) return agent.label === target.slice(6);
950
+ if (target.startsWith("tag:")) return agent.tags.includes(target.slice(4));
951
+ if (target.startsWith("cap:")) return agent.capabilities.includes(target.slice(4));
952
+ if (target.startsWith("rig:")) return agent.rig === target.slice(4);
953
+ if (target.startsWith("machine:")) return agent.machine === target.slice(8);
954
+ return agent.id === target ||
955
+ agent.label === target ||
956
+ agent.tags.includes(target) ||
957
+ agent.capabilities.includes(target) ||
958
+ agent.rig === target ||
959
+ agent.name === target;
960
+ }
961
+
962
+ function resolvePairTarget(target: string, requesterId: string): {
963
+ ok: true;
964
+ agent: AgentCard;
965
+ } | {
966
+ ok: false;
967
+ error: string;
968
+ code: "not_found" | "ambiguous" | "busy" | "offline";
969
+ matches?: AgentCard[];
970
+ busy?: Array<{ agent: AgentCard; pair: PairSession }>;
971
+ } {
972
+ const matches = listAgents().filter((agent) => agent.id !== requesterId && agentMatchesPairTarget(agent, target));
973
+ if (matches.length === 0) return { ok: false, code: "not_found", error: `no agent matches ${target}` };
974
+
975
+ const live = matches.filter((agent) => agent.status !== "offline" && agent.ready);
976
+ if (live.length === 0) return { ok: false, code: "offline", error: `no matching agent is online and ready`, matches };
977
+
978
+ const busy: Array<{ agent: AgentCard; pair: PairSession }> = [];
979
+ const available: AgentCard[] = [];
980
+ for (const agent of live) {
981
+ const openPair = getOpenPairForAgent(agent.id);
982
+ if (openPair) busy.push({ agent, pair: openPair });
983
+ else available.push(agent);
984
+ }
985
+
986
+ if (available.length === 0) return { ok: false, code: "busy", error: `matching agent is already paired`, busy };
987
+ if (available.length > 1) {
988
+ return {
989
+ ok: false,
990
+ code: "ambiguous",
991
+ error: `target ${target} matches ${available.length} available agents`,
992
+ matches: available,
993
+ };
994
+ }
995
+ return { ok: true, agent: available[0]! };
996
+ }
997
+
998
+ function pairSystemMessage(pair: PairSession, to: string, event: string, subject: string, body: string): Message {
999
+ return sendMessage({
1000
+ from: "system",
1001
+ to,
1002
+ type: "system",
1003
+ subject,
1004
+ body,
1005
+ meta: {
1006
+ pairId: pair.id,
1007
+ pairEvent: event,
1008
+ requesterId: pair.requesterId,
1009
+ targetId: pair.targetId,
1010
+ pairStatus: pair.status,
1011
+ },
1012
+ });
1013
+ }
1014
+
1015
+ export function getPair(id: string): PairSession | null {
1016
+ expirePendingPairs();
1017
+ const row = db.prepare("SELECT * FROM pairs WHERE id = ?").get(id) as any;
1018
+ return row ? rowToPair(row) : null;
1019
+ }
1020
+
1021
+ export function listPairs(filter?: { agentId?: string; status?: PairStatus }): PairSession[] {
1022
+ expirePendingPairs();
1023
+ const conditions: string[] = [];
1024
+ const params: any[] = [];
1025
+ if (filter?.agentId) {
1026
+ conditions.push("(requester_id = ? OR target_id = ?)");
1027
+ params.push(filter.agentId, filter.agentId);
1028
+ }
1029
+ if (filter?.status) {
1030
+ conditions.push("status = ?");
1031
+ params.push(filter.status);
1032
+ }
1033
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
1034
+ return (db.prepare(`SELECT * FROM pairs ${where} ORDER BY updated_at DESC LIMIT 100`).all(...params) as any[]).map(rowToPair);
1035
+ }
1036
+
1037
+ export function createPair(input: CreatePairInput): {
1038
+ ok: true;
1039
+ pair: PairSession;
1040
+ invite: Message;
1041
+ } | {
1042
+ ok: false;
1043
+ error: string;
1044
+ code: "not_found" | "ambiguous" | "busy" | "offline" | "invalid";
1045
+ matches?: AgentCard[];
1046
+ busy?: Array<{ agent: AgentCard; pair: PairSession }>;
1047
+ } {
1048
+ expirePendingPairs();
1049
+ const requester = getAgent(input.from);
1050
+ if (!requester) return { ok: false, code: "invalid", error: `requester agent ${input.from} not registered` };
1051
+ if (requester.status === "offline" || !requester.ready) return { ok: false, code: "offline", error: `requester agent ${input.from} is not online and ready` };
1052
+ const requesterPair = getOpenPairForAgent(input.from);
1053
+ if (requesterPair) return { ok: false, code: "busy", error: `requester is already paired`, busy: [{ agent: requester, pair: requesterPair }] };
1054
+
1055
+ const resolved = resolvePairTarget(input.target, input.from);
1056
+ if (!resolved.ok) return resolved;
1057
+
1058
+ const now = Date.now();
1059
+ const ttlMs = Math.min(Math.max(input.ttlMs ?? DEFAULT_PAIR_TTL_MS, 10_000), MAX_PAIR_TTL_MS);
1060
+ const id = randomUUID();
1061
+ db.prepare(`
1062
+ INSERT INTO pairs (id, requester_id, target_id, status, objective, meta, created_at, updated_at, expires_at)
1063
+ VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?)
1064
+ `).run(id, input.from, resolved.agent.id, input.objective ?? null, JSON.stringify(input.meta ?? {}), now, now, now + ttlMs);
1065
+ const pair = getPair(id)!;
1066
+ const objective = pair.objective ? `\n\nObjective:\n${pair.objective}` : "";
1067
+ const invite = pairSystemMessage(
1068
+ pair,
1069
+ pair.targetId,
1070
+ "invite",
1071
+ `Pair invite from ${pair.requesterId}`,
1072
+ [
1073
+ `${pair.requesterId} wants to start a two-party live pair session with you.${objective}`,
1074
+ "",
1075
+ `Pair ID: ${pair.id}`,
1076
+ "Accept with POST /api/pairs/{id}/accept using your agentId.",
1077
+ "Reject with POST /api/pairs/{id}/reject using your agentId.",
1078
+ "Pairing is exclusive: each agent can be in at most one pending or active pair.",
1079
+ ].join("\n"),
1080
+ );
1081
+ return { ok: true, pair, invite };
1082
+ }
1083
+
1084
+ export function acceptPair(id: string, input: PairActionInput): { ok: true; pair: PairSession; notices: Message[] } | { ok: false; error: string; code: "not_found" | "forbidden" | "busy" | "invalid" } {
1085
+ expirePendingPairs();
1086
+ const pair = getPair(id);
1087
+ if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
1088
+ if (pair.status !== "pending") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
1089
+ if (pair.targetId !== input.agentId) return { ok: false, code: "forbidden", error: "only the target agent can accept this pair" };
1090
+ const target = getAgent(input.agentId);
1091
+ if (!target || target.status === "offline" || !target.ready) return { ok: false, code: "invalid", error: "accepting agent is not online and ready" };
1092
+ const requester = getAgent(pair.requesterId);
1093
+ if (!requester || requester.status === "offline" || !requester.ready) return { ok: false, code: "invalid", error: "requester agent is no longer online and ready" };
1094
+
1095
+ for (const agentId of [pair.requesterId, pair.targetId]) {
1096
+ const open = getOpenPairForAgent(agentId);
1097
+ if (open && open.id !== pair.id) return { ok: false, code: "busy", error: `${agentId} is already paired` };
1098
+ }
1099
+
1100
+ const now = Date.now();
1101
+ db.prepare("UPDATE pairs SET status = 'active', accepted_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
1102
+ .run(now, now, id);
1103
+ const active = getPair(id)!;
1104
+ const notices = [
1105
+ pairSystemMessage(active, active.requesterId, "accepted", `Pair accepted by ${active.targetId}`, `Pair ${active.id} is active. Send pair messages with POST /api/pairs/${active.id}/messages.`),
1106
+ pairSystemMessage(active, active.targetId, "accepted", `Pair active with ${active.requesterId}`, `Pair ${active.id} is active. Send pair messages with POST /api/pairs/${active.id}/messages.`),
1107
+ ];
1108
+ return { ok: true, pair: active, notices };
1109
+ }
1110
+
1111
+ export function rejectPair(id: string, input: PairActionInput): { ok: true; pair: PairSession; notice: Message } | { ok: false; error: string; code: "not_found" | "forbidden" | "invalid" } {
1112
+ expirePendingPairs();
1113
+ const pair = getPair(id);
1114
+ if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
1115
+ if (pair.status !== "pending") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
1116
+ if (pair.targetId !== input.agentId) return { ok: false, code: "forbidden", error: "only the target agent can reject this pair" };
1117
+ const now = Date.now();
1118
+ db.prepare("UPDATE pairs SET status = 'rejected', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
1119
+ .run(now, input.agentId, now, id);
1120
+ const rejected = getPair(id)!;
1121
+ const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
1122
+ const notice = pairSystemMessage(rejected, rejected.requesterId, "rejected", `Pair rejected by ${input.agentId}`, `Pair ${rejected.id} was rejected.${reason}`);
1123
+ return { ok: true, pair: rejected, notice };
1124
+ }
1125
+
1126
+ export function endPair(id: string, input: PairActionInput): { ok: true; pair: PairSession; notice?: Message } | { ok: false; error: string; code: "not_found" | "forbidden" | "invalid" } {
1127
+ expirePendingPairs();
1128
+ const pair = getPair(id);
1129
+ if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
1130
+ if (!pairParticipant(pair, input.agentId)) return { ok: false, code: "forbidden", error: "only pair participants can hang up" };
1131
+ if (!OPEN_PAIR_STATUSES.includes(pair.status as any)) return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
1132
+ const now = Date.now();
1133
+ db.prepare("UPDATE pairs SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
1134
+ .run(now, input.agentId, now, id);
1135
+ const ended = getPair(id)!;
1136
+ const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
1137
+ const peer = pairPeer(ended, input.agentId);
1138
+ const notice = pairSystemMessage(ended, peer, "ended", `Pair ended by ${input.agentId}`, `Pair ${ended.id} ended.${reason}`);
1139
+ return { ok: true, pair: ended, notice };
1140
+ }
1141
+
1142
+ export function sendPairMessage(id: string, input: PairMessageInput): { ok: true; pair: PairSession; message: Message } | { ok: false; error: string; code: "not_found" | "forbidden" | "invalid" } {
1143
+ expirePendingPairs();
1144
+ const pair = getPair(id);
1145
+ if (!pair) return { ok: false, code: "not_found", error: "pair not found" };
1146
+ if (pair.status !== "active") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
1147
+ if (!pairParticipant(pair, input.from)) return { ok: false, code: "forbidden", error: "only pair participants can send pair messages" };
1148
+ const to = pairPeer(pair, input.from);
1149
+ const now = Date.now();
1150
+ const message = sendMessage({
1151
+ from: input.from,
1152
+ to,
1153
+ subject: input.subject ?? `Pair ${pair.id}`,
1154
+ body: input.body,
1155
+ meta: {
1156
+ pairId: pair.id,
1157
+ pairEvent: "message",
1158
+ requesterId: pair.requesterId,
1159
+ targetId: pair.targetId,
1160
+ },
1161
+ });
1162
+ db.prepare("UPDATE pairs SET last_message_at = ?, updated_at = ? WHERE id = ?").run(now, now, id);
1163
+ return { ok: true, pair: getPair(id)!, message };
1164
+ }
1165
+
711
1166
  // --- Messages ---
712
1167
 
713
- export function sendMessage(input: SendMessageInput): Message {
1168
+ function findMessageByIdempotencyKey(from: string, key: string): Message | null {
1169
+ const row = db
1170
+ .prepare(`${MSG_SELECT} WHERE m.from_agent = ? AND m.idempotency_key = ? LIMIT 1`)
1171
+ .get(from, key) as any;
1172
+ return row ? rowToMessage(row) : null;
1173
+ }
1174
+
1175
+ export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
714
1176
  const now = Date.now();
715
1177
 
716
1178
  if (!getAgent(input.from)) {
717
1179
  throw new ValidationError(`sender agent ${input.from} not registered`);
718
1180
  }
719
1181
 
1182
+ if (input.idempotencyKey) {
1183
+ const existing = findMessageByIdempotencyKey(input.from, input.idempotencyKey);
1184
+ if (existing) return { message: existing, created: false };
1185
+ }
1186
+
720
1187
  // Resolve thread: if replying, inherit from parent; reject unknown replyTo
721
1188
  // rather than silently orphaning the message (leaves thread_id NULL).
722
1189
  let threadId: number | null = null;
@@ -727,8 +1194,8 @@ export function sendMessage(input: SendMessageInput): Message {
727
1194
  }
728
1195
 
729
1196
  const insert = db.prepare(`
730
- INSERT INTO messages (from_agent, to_target, type, channel, subject, body, thread_id, reply_to, claimable, meta, created_at)
731
- VALUES ($from, $to, $type, $channel, $subject, $body, $threadId, $replyTo, $claimable, $meta, $now)
1197
+ INSERT INTO messages (from_agent, to_target, type, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, meta, created_at)
1198
+ VALUES ($from, $to, $type, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $meta, $now)
732
1199
  `);
733
1200
  const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
734
1201
 
@@ -743,6 +1210,7 @@ export function sendMessage(input: SendMessageInput): Message {
743
1210
  $threadId: threadId,
744
1211
  $replyTo: input.replyTo ?? null,
745
1212
  $claimable: input.claimable ? 1 : 0,
1213
+ $idempotencyKey: input.idempotencyKey ?? null,
746
1214
  $meta: JSON.stringify(input.meta ?? {}),
747
1215
  $now: now,
748
1216
  });
@@ -751,7 +1219,11 @@ export function sendMessage(input: SendMessageInput): Message {
751
1219
  return newId;
752
1220
  })();
753
1221
 
754
- return getMessage(id)!;
1222
+ return { message: getMessage(id)!, created: true };
1223
+ }
1224
+
1225
+ export function sendMessage(input: SendMessageInput): Message {
1226
+ return sendMessageWithResult(input).message;
755
1227
  }
756
1228
 
757
1229
  export function getThread(messageId: number): Message[] {
@@ -766,16 +1238,20 @@ export function getThread(messageId: number): Message[] {
766
1238
  }
767
1239
 
768
1240
  function claimMessageRow(messageId: number, agentId: string, now: number): { ok: false; error: string } | { ok: true } {
1241
+ const expiresAt = now + CLAIM_LEASE_MS;
769
1242
  const result = db.prepare(
770
- "UPDATE messages SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL"
771
- ).run(agentId, now, messageId);
1243
+ "UPDATE messages SET claimed_by = ?, claimed_at = ?, claim_expires_at = ? WHERE id = ? AND claimed_by IS NULL"
1244
+ ).run(agentId, now, expiresAt, messageId);
772
1245
 
773
1246
  // Atomic: if changes === 0, someone else claimed it between our read and write
774
1247
  if (result.changes === 0) return { ok: false, error: "claim race — already claimed" };
775
1248
  return { ok: true };
776
1249
  }
777
1250
 
778
- export function claimMessage(messageId: number, agentId: string): { ok: boolean; error?: string; task?: Task } {
1251
+ export function claimMessage(messageId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
1252
+ releaseExpiredClaims();
1253
+ const session = validateAgentSession(agentId, guard);
1254
+ if (!session.ok) return { ok: false, error: session.error };
779
1255
  const agent = getAgent(agentId);
780
1256
  if (!agent) return { ok: false, error: "claiming agent not found" };
781
1257
  if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
@@ -788,6 +1264,7 @@ export function claimMessage(messageId: number, agentId: string): { ok: boolean;
788
1264
  try {
789
1265
  return db.transaction(() => {
790
1266
  const now = Date.now();
1267
+ const expiresAt = now + CLAIM_LEASE_MS;
791
1268
  const messageClaim = claimMessageRow(messageId, agentId, now);
792
1269
  if (!messageClaim.ok) return messageClaim;
793
1270
 
@@ -803,9 +1280,9 @@ export function claimMessage(messageId: number, agentId: string): { ok: boolean;
803
1280
  }
804
1281
 
805
1282
  const taskClaim = db.prepare(`
806
- UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, updated_at = ?
1283
+ UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, claim_expires_at = ?, updated_at = ?
807
1284
  WHERE id = ? AND message_id = ? AND status IN ('open', 'blocked')
808
- `).run(agentId, now, now, taskId, messageId);
1285
+ `).run(agentId, now, expiresAt, now, taskId, messageId);
809
1286
  if (taskClaim.changes === 0) throw new ClaimError("linked task claim race — already claimed");
810
1287
 
811
1288
  insertTaskEvent(taskId, {
@@ -825,6 +1302,34 @@ export function claimMessage(messageId: number, agentId: string): { ok: boolean;
825
1302
  }
826
1303
  }
827
1304
 
1305
+ export function renewMessageClaim(messageId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
1306
+ releaseExpiredClaims();
1307
+ const session = validateAgentSession(agentId, guard);
1308
+ if (!session.ok) return { ok: false, error: session.error };
1309
+ const agent = getAgent(agentId);
1310
+ if (!agent) return { ok: false, error: "claiming agent not found" };
1311
+ if (agent.status === "offline") return { ok: false, error: "claiming agent is offline" };
1312
+ const msg = getMessage(messageId);
1313
+ if (!msg) return { ok: false, error: "message not found" };
1314
+ if (!msg.claimable) return { ok: false, error: "message is not claimable" };
1315
+ if (msg.claimedBy !== agentId) return { ok: false, error: msg.claimedBy ? `claimed by ${msg.claimedBy}` : "message is not claimed" };
1316
+
1317
+ const now = Date.now();
1318
+ const expiresAt = now + CLAIM_LEASE_MS;
1319
+ let task: Task | undefined;
1320
+ db.transaction(() => {
1321
+ db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
1322
+ const taskId = typeof msg.meta?.taskId === "number" && Number.isSafeInteger(msg.meta.taskId)
1323
+ ? msg.meta.taskId
1324
+ : null;
1325
+ if (taskId) {
1326
+ db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
1327
+ task = getTask(taskId) ?? undefined;
1328
+ }
1329
+ })();
1330
+ return { ok: true, task };
1331
+ }
1332
+
828
1333
  export function getMessage(id: number): Message | null {
829
1334
  const row = db.prepare(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
830
1335
  return row ? rowToMessage(row) : null;
@@ -877,9 +1382,10 @@ export function pollMessages(query: PollQuery): Message[] {
877
1382
  }
878
1383
  conditions.push(`(${targetClauses.join(" OR ")})`);
879
1384
 
880
- // Hide claimable messages that were claimed by someone else
881
- conditions.push(`(claimable = 0 OR claimed_by IS NULL OR claimed_by = ?)`);
882
- params.push(query.for);
1385
+ // Hide active claims held by someone else, but let expired claims surface so
1386
+ // another matching agent can recover stuck work.
1387
+ conditions.push(`(claimable = 0 OR claimed_by IS NULL OR claimed_by = ? OR ((claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = m.id AND t.status IN ('done', 'failed', 'canceled'))))`);
1388
+ params.push(query.for, Date.now());
883
1389
 
884
1390
  if (query.sinceId !== undefined) {
885
1391
  conditions.push("id > ?");
@@ -979,3 +1485,66 @@ export function getStats(): {
979
1485
 
980
1486
  return { version: VERSION, agents, online, messages, messagesLast24h, tasks, openTasks };
981
1487
  }
1488
+
1489
+ export function getHealth(now: number = Date.now()): HealthReport {
1490
+ const checks: HealthCheck[] = [];
1491
+
1492
+ try {
1493
+ db.prepare("SELECT 1").get();
1494
+ checks.push({ name: "database", status: "ok" });
1495
+ } catch (e) {
1496
+ checks.push({ name: "database", status: "error", detail: e instanceof Error ? e.message : "database check failed" });
1497
+ }
1498
+
1499
+ const staleLiveAgents = (db
1500
+ .prepare("SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen <= ? AND id NOT IN ('user', 'system')")
1501
+ .get(now - STALE_TTL_MS) as any).c as number;
1502
+ checks.push({
1503
+ name: "stale-live-agents",
1504
+ status: staleLiveAgents > 0 ? "warn" : "ok",
1505
+ count: staleLiveAgents,
1506
+ detail: staleLiveAgents > 0 ? `${staleLiveAgents} non-offline agent(s) missed heartbeat TTL` : undefined,
1507
+ });
1508
+
1509
+ const expiredMessageClaims = (db
1510
+ .prepare("SELECT COUNT(*) as c FROM messages WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = messages.id AND t.status IN ('done', 'failed', 'canceled'))")
1511
+ .get(now) as any).c as number;
1512
+ checks.push({
1513
+ name: "expired-message-claims",
1514
+ status: expiredMessageClaims > 0 ? "warn" : "ok",
1515
+ count: expiredMessageClaims,
1516
+ detail: expiredMessageClaims > 0 ? `${expiredMessageClaims} message claim(s) are expired or missing a lease` : undefined,
1517
+ });
1518
+
1519
+ const expiredTaskClaims = (db
1520
+ .prepare("SELECT COUNT(*) as c FROM tasks WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')")
1521
+ .get(now) as any).c as number;
1522
+ checks.push({
1523
+ name: "expired-task-claims",
1524
+ status: expiredTaskClaims > 0 ? "warn" : "ok",
1525
+ count: expiredTaskClaims,
1526
+ detail: expiredTaskClaims > 0 ? `${expiredTaskClaims} active task claim(s) are expired or missing a lease` : undefined,
1527
+ });
1528
+
1529
+ const offlineClaimedTasks = (db
1530
+ .prepare(`
1531
+ SELECT COUNT(*) as c
1532
+ FROM tasks t
1533
+ JOIN agents a ON a.id = t.claimed_by
1534
+ WHERE t.status IN ('claimed', 'in_progress', 'blocked') AND a.status = 'offline'
1535
+ `)
1536
+ .get() as any).c as number;
1537
+ checks.push({
1538
+ name: "offline-claimed-tasks",
1539
+ status: offlineClaimedTasks > 0 ? "warn" : "ok",
1540
+ count: offlineClaimedTasks,
1541
+ detail: offlineClaimedTasks > 0 ? `${offlineClaimedTasks} active task(s) are claimed by offline agents` : undefined,
1542
+ });
1543
+
1544
+ const status = checks.some((check) => check.status === "error")
1545
+ ? "error"
1546
+ : checks.some((check) => check.status === "warn")
1547
+ ? "degraded"
1548
+ : "ok";
1549
+ return { status, version: VERSION, generatedAt: now, checks };
1550
+ }