agent-relay-server 0.10.7 → 0.10.8

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
@@ -83,6 +83,11 @@ export function initDb(path: string = "agent-relay.db"): Database {
83
83
  claimed_at INTEGER,
84
84
  claim_expires_at INTEGER,
85
85
  idempotency_key TEXT,
86
+ delivery_status TEXT NOT NULL DEFAULT 'pending',
87
+ delivery_attempts INTEGER NOT NULL DEFAULT 0,
88
+ queued_at INTEGER,
89
+ max_age_seconds INTEGER,
90
+ resolved_to_agent TEXT,
86
91
  payload TEXT NOT NULL DEFAULT '{}',
87
92
  meta TEXT NOT NULL DEFAULT '{}',
88
93
  read_by TEXT NOT NULL DEFAULT '[]',
@@ -93,6 +98,48 @@ export function initDb(path: string = "agent-relay.db"): Database {
93
98
  CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(created_at);
94
99
  CREATE INDEX IF NOT EXISTS idx_msg_channel ON messages(channel);
95
100
 
101
+ CREATE TABLE IF NOT EXISTS config (
102
+ namespace TEXT NOT NULL,
103
+ key TEXT NOT NULL,
104
+ value TEXT NOT NULL,
105
+ version INTEGER NOT NULL DEFAULT 1,
106
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
107
+ updated_by TEXT,
108
+ PRIMARY KEY (namespace, key)
109
+ );
110
+
111
+ CREATE TABLE IF NOT EXISTS config_history (
112
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
113
+ namespace TEXT NOT NULL,
114
+ key TEXT NOT NULL,
115
+ value TEXT NOT NULL,
116
+ version INTEGER NOT NULL,
117
+ changed_at TEXT NOT NULL DEFAULT (datetime('now')),
118
+ changed_by TEXT
119
+ );
120
+ CREATE INDEX IF NOT EXISTS idx_config_history_key ON config_history(namespace, key, version);
121
+
122
+ CREATE TABLE IF NOT EXISTS managed_agent_state (
123
+ policy_name TEXT PRIMARY KEY,
124
+ status TEXT NOT NULL,
125
+ agent_id TEXT,
126
+ orchestrator_id TEXT NOT NULL,
127
+ provider TEXT NOT NULL,
128
+ tmux_session TEXT,
129
+ spawn_request_id TEXT,
130
+ last_spawn_at INTEGER,
131
+ last_stop_at INTEGER,
132
+ healthy_since INTEGER,
133
+ restart_count INTEGER NOT NULL DEFAULT 0,
134
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
135
+ backoff_until INTEGER,
136
+ last_error TEXT,
137
+ updated_at INTEGER NOT NULL
138
+ );
139
+ CREATE INDEX IF NOT EXISTS idx_managed_agent_state_status ON managed_agent_state(status);
140
+ CREATE INDEX IF NOT EXISTS idx_managed_agent_state_agent ON managed_agent_state(agent_id);
141
+ CREATE INDEX IF NOT EXISTS idx_managed_agent_state_spawn_request ON managed_agent_state(policy_name, spawn_request_id);
142
+
96
143
  CREATE TABLE IF NOT EXISTS tasks (
97
144
  id INTEGER PRIMARY KEY AUTOINCREMENT,
98
145
  source TEXT NOT NULL,
@@ -380,11 +427,28 @@ export function initDb(path: string = "agent-relay.db"): Database {
380
427
  if (!colNames.includes("idempotency_key")) {
381
428
  db.run("ALTER TABLE messages ADD COLUMN idempotency_key TEXT");
382
429
  }
430
+ if (!colNames.includes("delivery_status")) {
431
+ db.run("ALTER TABLE messages ADD COLUMN delivery_status TEXT NOT NULL DEFAULT 'pending'");
432
+ }
433
+ if (!colNames.includes("delivery_attempts")) {
434
+ db.run("ALTER TABLE messages ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0");
435
+ }
436
+ if (!colNames.includes("queued_at")) {
437
+ db.run("ALTER TABLE messages ADD COLUMN queued_at INTEGER");
438
+ }
439
+ if (!colNames.includes("max_age_seconds")) {
440
+ db.run("ALTER TABLE messages ADD COLUMN max_age_seconds INTEGER");
441
+ }
442
+ if (!colNames.includes("resolved_to_agent")) {
443
+ db.run("ALTER TABLE messages ADD COLUMN resolved_to_agent TEXT");
444
+ }
383
445
  db.prepare(
384
446
  "UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
385
447
  ).run(Date.now(), CLAIM_LEASE_MS);
386
448
  db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
387
449
  db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
450
+ db.run("CREATE INDEX IF NOT EXISTS idx_msg_delivery_status ON messages(delivery_status)");
451
+ db.run("CREATE INDEX IF NOT EXISTS idx_msg_resolved_to_agent ON messages(resolved_to_agent)");
388
452
 
389
453
  const channelBindingsSql = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
390
454
  if (channelBindingsSql?.sql?.includes("UNIQUE(channel_id, conversation_key)")) {
@@ -626,6 +690,11 @@ function rowToMessage(row: any): Message {
626
690
  claimedAt: row.claimed_at ?? undefined,
627
691
  claimExpiresAt: row.claim_expires_at ?? undefined,
628
692
  idempotencyKey: row.idempotency_key ?? undefined,
693
+ deliveryStatus: row.delivery_status ?? "pending",
694
+ deliveryAttempts: row.delivery_attempts ?? 0,
695
+ queuedAt: row.queued_at ?? undefined,
696
+ maxAgeSeconds: row.max_age_seconds ?? undefined,
697
+ resolvedToAgent: row.resolved_to_agent ?? undefined,
629
698
  payload: parseJson(row.payload ?? "{}", {}),
630
699
  meta: parseJson(row.meta, {}),
631
700
  readBy: parseJson(row.read_by_agents ?? "[]", []),
@@ -763,6 +832,7 @@ function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
763
832
  if (target.type === "broadcast") return "broadcast";
764
833
  if (target.type === "orchestrator") return `orchestrator:${target.id}`;
765
834
  if (target.type === "pool") return `pool:${target.id}`;
835
+ if (target.type === "policy") return `policy:${target.id}`;
766
836
  return "";
767
837
  }
768
838
 
@@ -801,6 +871,11 @@ function channelTargetMatches(target: ChannelRouteTarget, channelId?: string): A
801
871
  const agent = getAgent(target.id);
802
872
  return agent ? [agent] : [];
803
873
  }
874
+ if (target.type === "policy") {
875
+ const agentId = runningAgentForPolicy(target.id);
876
+ const agent = agentId ? getAgent(agentId) : null;
877
+ return agent ? [agent] : [];
878
+ }
804
879
  const channelCandidates = candidates.filter((agent) => agentCanServeChannel(agent, channelId));
805
880
  if (target.type === "pool") return poolSelectorMatches(target.id, channelCandidates);
806
881
  if (target.type === "label") return channelCandidates.filter((agent) => agent.label === target.id);
@@ -867,7 +942,7 @@ function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()):
867
942
  return { status: "ok", detail: `Pool ${targetLabel}: claimed by ${binding.poolAgentId}`, target, matches: snapshots };
868
943
  }
869
944
 
870
- if (target.type === "agent" || target.type === "orchestrator") {
945
+ if (target.type === "agent" || target.type === "orchestrator" || target.type === "policy") {
871
946
  const agent = matches[0];
872
947
  if (!agent) {
873
948
  return { status: "error", detail: `Target ${targetLabel} is not registered`, target, matches: [] };
@@ -2137,6 +2212,56 @@ function findMessageByIdempotencyKey(from: string, key: string): Message | null
2137
2212
  return row ? rowToMessage(row) : null;
2138
2213
  }
2139
2214
 
2215
+ function policyNameFromTarget(target: string): string | null {
2216
+ if (!target.startsWith("policy:")) return null;
2217
+ const name = target.slice("policy:".length).trim();
2218
+ return name || null;
2219
+ }
2220
+
2221
+ function spawnPolicyExists(policyName: string): boolean {
2222
+ const row = db.prepare("SELECT 1 FROM config WHERE namespace = 'spawn-policy' AND key = ?").get(policyName);
2223
+ return Boolean(row);
2224
+ }
2225
+
2226
+ function runningAgentForPolicy(policyName: string): string | null {
2227
+ const row = db.prepare(`
2228
+ SELECT agent_id
2229
+ FROM managed_agent_state
2230
+ WHERE policy_name = ? AND status = 'running' AND agent_id IS NOT NULL
2231
+ `).get(policyName) as { agent_id?: string } | undefined;
2232
+ if (!row?.agent_id) return null;
2233
+ const agent = getAgent(row.agent_id);
2234
+ if (!agent || agent.status === "offline") return null;
2235
+ return row.agent_id;
2236
+ }
2237
+
2238
+ function queueDepthLimit(target: string): number {
2239
+ const row = db.prepare("SELECT value FROM config WHERE namespace = 'system' AND key = 'message-queue'").get() as { value?: string } | undefined;
2240
+ const parsed = row?.value ? parseJson<Record<string, unknown>>(row.value, {}) : {};
2241
+ const perTarget = parsed?.maxDepthPerTarget;
2242
+ if (typeof perTarget === "number" && Number.isSafeInteger(perTarget) && perTarget > 0) return perTarget;
2243
+ const targetLimits = parsed?.targetLimits;
2244
+ if (targetLimits && typeof targetLimits === "object" && !Array.isArray(targetLimits)) {
2245
+ const value = (targetLimits as Record<string, unknown>)[target];
2246
+ if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return value;
2247
+ }
2248
+ return 100;
2249
+ }
2250
+
2251
+ function enforceQueueLimit(target: string): void {
2252
+ const limit = queueDepthLimit(target);
2253
+ db.prepare(`
2254
+ UPDATE messages
2255
+ SET delivery_status = 'failed'
2256
+ WHERE id IN (
2257
+ SELECT id FROM messages
2258
+ WHERE to_target = ? AND delivery_status = 'queued'
2259
+ ORDER BY queued_at DESC, id DESC
2260
+ LIMIT -1 OFFSET ?
2261
+ )
2262
+ `).run(target, limit);
2263
+ }
2264
+
2140
2265
  function isDeliveryAgent(agent: AgentCard): boolean {
2141
2266
  return agent.status !== "offline" &&
2142
2267
  agent.id !== "user" &&
@@ -2231,12 +2356,35 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
2231
2356
  }
2232
2357
 
2233
2358
  const insert = db.prepare(`
2234
- INSERT INTO messages (from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable, idempotency_key, payload, meta, created_at)
2235
- VALUES ($from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $payload, $meta, $now)
2359
+ INSERT INTO messages (
2360
+ from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable,
2361
+ idempotency_key, delivery_status, queued_at, max_age_seconds, resolved_to_agent,
2362
+ payload, meta, created_at
2363
+ )
2364
+ VALUES (
2365
+ $from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable,
2366
+ $idempotencyKey, $deliveryStatus, $queuedAt, $maxAgeSeconds, $resolvedToAgent,
2367
+ $payload, $meta, $now
2368
+ )
2236
2369
  `);
2237
2370
  const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
2238
2371
  const claimable = shouldStoreClaimable(input);
2239
2372
  const kind = inferMessageKind(input);
2373
+ const policyName = policyNameFromTarget(input.to);
2374
+ let deliveryStatus: Message["deliveryStatus"] = "pending";
2375
+ let queuedAt: number | null = null;
2376
+ let maxAgeSeconds = input.maxAgeSeconds ?? null;
2377
+ let resolvedToAgent: string | null = null;
2378
+
2379
+ if (policyName) {
2380
+ if (!spawnPolicyExists(policyName)) throw new ValidationError(`spawn policy ${policyName} not found`);
2381
+ resolvedToAgent = runningAgentForPolicy(policyName);
2382
+ if (!resolvedToAgent) {
2383
+ deliveryStatus = "queued";
2384
+ queuedAt = now;
2385
+ maxAgeSeconds = maxAgeSeconds ?? 86_400;
2386
+ }
2387
+ }
2240
2388
 
2241
2389
  const id = db.transaction(() => {
2242
2390
  const result = insert.run({
@@ -2250,12 +2398,17 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
2250
2398
  $replyTo: input.replyTo ?? null,
2251
2399
  $claimable: claimable ? 1 : 0,
2252
2400
  $idempotencyKey: input.idempotencyKey ?? null,
2401
+ $deliveryStatus: deliveryStatus,
2402
+ $queuedAt: queuedAt,
2403
+ $maxAgeSeconds: maxAgeSeconds,
2404
+ $resolvedToAgent: resolvedToAgent,
2253
2405
  $payload: JSON.stringify(input.payload ?? {}),
2254
2406
  $meta: JSON.stringify(input.meta ?? {}),
2255
2407
  $now: now,
2256
2408
  });
2257
2409
  const newId = Number(result.lastInsertRowid);
2258
2410
  if (threadId === null) setSelfThread.run(newId, newId);
2411
+ if (policyName && deliveryStatus === "queued") enforceQueueLimit(`policy:${policyName}`);
2259
2412
  return newId;
2260
2413
  })();
2261
2414
 
@@ -2375,6 +2528,65 @@ export function getMessage(id: number): Message | null {
2375
2528
  return row ? rowToMessage(row) : null;
2376
2529
  }
2377
2530
 
2531
+ export function getMessageDeliveryStatus(id: number): Pick<Message, "id" | "to" | "deliveryStatus" | "deliveryAttempts" | "queuedAt" | "maxAgeSeconds" | "resolvedToAgent"> | null {
2532
+ const message = getMessage(id);
2533
+ if (!message) return null;
2534
+ return {
2535
+ id: message.id,
2536
+ to: message.to,
2537
+ deliveryStatus: message.deliveryStatus,
2538
+ deliveryAttempts: message.deliveryAttempts,
2539
+ queuedAt: message.queuedAt,
2540
+ maxAgeSeconds: message.maxAgeSeconds,
2541
+ resolvedToAgent: message.resolvedToAgent,
2542
+ };
2543
+ }
2544
+
2545
+ export function listQueuedMessages(target: string, limit = 100): Message[] {
2546
+ const safeLimit = Math.min(Math.max(limit, 1), 500);
2547
+ return (db.prepare(`
2548
+ ${MSG_SELECT}
2549
+ WHERE m.to_target = ? AND m.delivery_status = 'queued'
2550
+ ORDER BY m.queued_at ASC, m.id ASC
2551
+ LIMIT ?
2552
+ `).all(target, safeLimit) as any[]).map(rowToMessage);
2553
+ }
2554
+
2555
+ export function resolveQueuedPolicyMessages(policyName: string, agentId: string): Message[] {
2556
+ const target = `policy:${policyName}`;
2557
+ const rows = db.prepare(`
2558
+ SELECT id FROM messages
2559
+ WHERE to_target = ? AND delivery_status = 'queued'
2560
+ ORDER BY queued_at ASC, id ASC
2561
+ `).all(target) as Array<{ id: number }>;
2562
+ if (rows.length === 0) return [];
2563
+ const ids = rows.map((row) => row.id);
2564
+ const placeholders = ids.map(() => "?").join(",");
2565
+ db.prepare(`
2566
+ UPDATE messages
2567
+ SET delivery_status = 'pending',
2568
+ resolved_to_agent = ?,
2569
+ delivery_attempts = delivery_attempts + 1
2570
+ WHERE id IN (${placeholders})
2571
+ `).run(agentId, ...ids);
2572
+ return ids.map((id) => getMessage(id)).filter((message): message is Message => Boolean(message));
2573
+ }
2574
+
2575
+ export function expireQueuedMessages(now: number = Date.now()): Message[] {
2576
+ const rows = db.prepare(`
2577
+ SELECT id FROM messages
2578
+ WHERE delivery_status = 'queued'
2579
+ AND queued_at IS NOT NULL
2580
+ AND coalesce(max_age_seconds, 86400) >= 0
2581
+ AND queued_at + (coalesce(max_age_seconds, 86400) * 1000) <= ?
2582
+ `).all(now) as Array<{ id: number }>;
2583
+ if (rows.length === 0) return [];
2584
+ const ids = rows.map((row) => row.id);
2585
+ const placeholders = ids.map(() => "?").join(",");
2586
+ db.prepare(`UPDATE messages SET delivery_status = 'failed' WHERE id IN (${placeholders})`).run(...ids);
2587
+ return ids.map((id) => getMessage(id)).filter((message): message is Message => Boolean(message));
2588
+ }
2589
+
2378
2590
  export function listRecentMessages(limit: number = 100, since?: number, channel?: string): Message[] {
2379
2591
  const conditions: string[] = [];
2380
2592
  const params: any[] = [];
@@ -2409,8 +2621,8 @@ export function pollMessages(query: PollQuery): Message[] {
2409
2621
  // Channel agents also accept legacy bare provider targets (for example
2410
2622
  // "telegram") so older clients keep working after canonical IDs became
2411
2623
  // provider:account ("telegram:default").
2412
- const targetClauses = ["to_target = ?", "to_target = 'broadcast'"];
2413
- params.push(query.for);
2624
+ const targetClauses = ["to_target = ?", "resolved_to_agent = ?", "to_target = 'broadcast'"];
2625
+ params.push(query.for, query.for);
2414
2626
 
2415
2627
  for (const tag of agentTags) {
2416
2628
  targetClauses.push("to_target = ?");
@@ -2429,6 +2641,7 @@ export function pollMessages(query: PollQuery): Message[] {
2429
2641
  params.push(target);
2430
2642
  }
2431
2643
  conditions.push(`(${targetClauses.join(" OR ")})`);
2644
+ conditions.push("delivery_status != 'queued' AND delivery_status != 'failed'");
2432
2645
 
2433
2646
  // Hide active claims held by someone else, but let expired claims surface so
2434
2647
  // another matching agent can recover stuck work.
@@ -2468,6 +2681,11 @@ export function markRead(messageId: number, agentId: string): boolean {
2468
2681
  db.prepare(
2469
2682
  "INSERT OR IGNORE INTO message_reads (message_id, agent_id, read_at) VALUES (?, ?, ?)"
2470
2683
  ).run(messageId, agentId, Date.now());
2684
+ db.prepare(`
2685
+ UPDATE messages
2686
+ SET delivery_status = 'delivered'
2687
+ WHERE id = ? AND (to_target = ? OR resolved_to_agent = ? OR to_target = 'broadcast' OR to_target LIKE 'tag:%' OR to_target LIKE 'cap:%' OR to_target LIKE 'label:%')
2688
+ `).run(messageId, agentId, agentId);
2471
2689
  return true;
2472
2690
  }
2473
2691
 
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { expireCommands } from "./commands-db";
8
8
  import { applyCommandToRecipe } from "./recipe-runner";
9
9
  import { emitRelayEvent } from "./events";
10
10
  import { reconcileAutomationRuns, runDueAutomations, type AutomationDispatchResult, type AutomationReconcileResult } from "./automations";
11
+ import { getLifecycleManager } from "./lifecycle-manager";
11
12
  import { resolve, sep } from "path";
12
13
  import {
13
14
  REAP_INTERVAL_MS,
@@ -48,6 +49,7 @@ function startServer(): void {
48
49
 
49
50
  assertSafeNetworkConfig(HOST);
50
51
  initDb(DB_PATH);
52
+ getLifecycleManager().start();
51
53
 
52
54
  setInterval(() => {
53
55
  const released = releaseExpiredClaims();