agent-relay-server 0.94.4 → 0.94.5

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/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.94.4",
5
+ "version": "0.94.5",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.94.4",
3
+ "version": "0.94.5",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.94.4",
4
+ "version": "0.94.5",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -422,6 +422,23 @@ export function resolveQueuedPolicyMessages(policyName: string, agentId: string)
422
422
  * Returns the flushed messages so callers can emit `message.available` / `message.new` /
423
423
  * `message.delivery_updated` events.
424
424
  */
425
+ /**
426
+ * Distinct direct (agent) targets that have messages still parked at
427
+ * `queued` older than `minAgeMs`. Backstop for #706: the primary recovery is
428
+ * flush-on-availability, but if that event is ever missed a queued message is
429
+ * invisible (pollMessages excludes `queued`) until it expires. The periodic
430
+ * resweep uses this to re-resolve such messages once the target is available.
431
+ */
432
+ export function listStaleQueuedDirectTargets(now: number = Date.now(), minAgeMs = 60_000): string[] {
433
+ const rows = getDb().query(`
434
+ SELECT DISTINCT to_target FROM messages
435
+ WHERE delivery_status = 'queued'
436
+ AND to_target IS NOT NULL
437
+ AND COALESCE(queued_at, created_at) <= ?
438
+ `).all(now - minAgeMs) as Array<{ to_target: string }>;
439
+ return rows.map((row) => row.to_target);
440
+ }
441
+
425
442
  export function resolveQueuedDirectMessages(agentId: string): Message[] {
426
443
  const rows = getDb().query(`
427
444
  SELECT m.id
@@ -451,6 +468,21 @@ export function resolveQueuedDirectMessages(agentId: string): Message[] {
451
468
  return ids.map((id) => getMessage(id)).filter((message): message is Message => Boolean(message));
452
469
  }
453
470
 
471
+ /**
472
+ * Count messages that expired undelivered at max_age within the trailing
473
+ * `windowMs` (default 24h). Surfaces silent channel data loss (#706) — a queued
474
+ * message that died without ever reaching its recipient — as a health signal.
475
+ */
476
+ export function countExpiredUndeliveredMessages(now: number = Date.now(), windowMs = 86_400_000): number {
477
+ return (getDb().query(`
478
+ SELECT COUNT(*) as c FROM messages
479
+ WHERE delivery_status = 'dead'
480
+ AND delivery_poison_reason = 'queued message expired'
481
+ AND delivery_updated_at IS NOT NULL
482
+ AND delivery_updated_at >= ?
483
+ `).get(now - windowMs) as { c: number }).c;
484
+ }
485
+
454
486
  export function expireQueuedMessages(now: number = Date.now()): Message[] {
455
487
  const rows = getDb().query(`
456
488
  SELECT id FROM messages
package/src/db/stats.ts CHANGED
@@ -16,6 +16,7 @@ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_ME
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { listAgents } from "./agents.ts";
18
18
  import { listChannels } from "./channels.ts";
19
+ import { countExpiredUndeliveredMessages } from "./delivery.ts";
19
20
  import { messageTaskClosed } from "./claim-sql.ts";
20
21
  import { getDb } from "./connection.ts";
21
22
  import type {
@@ -466,6 +467,37 @@ export function getHealth(now: number = Date.now()): HealthReport {
466
467
  : undefined,
467
468
  });
468
469
 
470
+ // A channel bridge (e.g. Telegram) that is registered but stale or not-ready
471
+ // is silently NOT delivering — channel messages queue against it (#706). The
472
+ // binding-target health above can read "ok" while the bridge itself is wedged,
473
+ // so check the bridge agent's own liveness to avoid green-washing a down bridge.
474
+ const channelBridges = listAgents().filter((agent) =>
475
+ agent.id !== "user" && agent.id !== "system" &&
476
+ (agent.kind === "channel" || agent.meta?.kind === "channel"));
477
+ const staleChannelBridges = channelBridges.filter((agent) =>
478
+ agent.status !== "offline" && agent.lastSeen <= now - STALE_TTL_MS);
479
+ const notReadyChannelBridges = channelBridges.filter((agent) =>
480
+ agent.status !== "offline" && !agent.ready && agent.lastSeen > now - STALE_TTL_MS);
481
+ const unhealthyChannelBridges = [...staleChannelBridges, ...notReadyChannelBridges];
482
+ checks.push({
483
+ name: "channel-bridge-liveness",
484
+ status: staleChannelBridges.length > 0 ? "error" : notReadyChannelBridges.length > 0 ? "warn" : "ok",
485
+ count: unhealthyChannelBridges.length,
486
+ detail: unhealthyChannelBridges.length > 0
487
+ ? `${unhealthyChannelBridges.length} channel bridge(s) not delivering: ${unhealthyChannelBridges.slice(0, 3).map((a) => `${a.id} (status=${a.status}, ready=${a.ready})`).join("; ")}`
488
+ : undefined,
489
+ });
490
+
491
+ const expiredUndelivered = countExpiredUndeliveredMessages(now);
492
+ checks.push({
493
+ name: "expired-undelivered-messages",
494
+ status: expiredUndelivered > 0 ? "warn" : "ok",
495
+ count: expiredUndelivered,
496
+ detail: expiredUndelivered > 0
497
+ ? `${expiredUndelivered} queued message(s) expired undelivered at max_age in the last 24h (silent data loss)`
498
+ : undefined,
499
+ });
500
+
469
501
  const rootTokenRuntimeAgents = listAgents().filter((agent) => {
470
502
  if (agent.status === "offline") return false;
471
503
  if (agent.id === "user" || agent.id === "system") return false;
@@ -10,6 +10,7 @@ import {
10
10
  expireQueuedMessages,
11
11
  getAgent,
12
12
  listAgents,
13
+ listStaleQueuedDirectTargets,
13
14
  pruneOrphanedSharedWorkspaces,
14
15
  pruneOfflineAgents,
15
16
  pruneOldMessages,
@@ -22,6 +23,8 @@ import {
22
23
  runDbMaintenance,
23
24
  sweepArtifacts,
24
25
  } from "../db";
26
+ import { isAgentUnavailable } from "../db/agent-predicates";
27
+ import { flushQueuedDirectMessages } from "../services/managed-running";
25
28
  import { notifyAgentOffline } from "../agent-lifecycle-events";
26
29
  import { diagnoseAgentDeath } from "../agent-death-diagnosis";
27
30
  import { autoMergeCleanFastForwards } from "../workspace-auto-merge";
@@ -193,10 +196,35 @@ export const definitions: MaintenanceJobDefinition[] = [
193
196
  runOnStart: true,
194
197
  handler() {
195
198
  const messages = expireQueuedMessages();
196
- for (const message of messages) emitMessageExpired(message);
199
+ for (const message of messages) {
200
+ // Silent data loss is the worst failure mode here (#706): a queued
201
+ // message died without ever reaching its recipient. Surface it loudly.
202
+ console.warn(`[delivery] message ${message.id} to ${message.to} (kind=${message.kind}) expired undelivered after max_age — silent data loss`);
203
+ emitMessageExpired(message);
204
+ }
197
205
  return { expiredQueuedMessageIds: messages.map((message) => message.id) };
198
206
  },
199
207
  },
208
+ {
209
+ id: "queued-message-resweep",
210
+ title: "Queued message re-sweep",
211
+ description: "Re-resolve long-queued direct messages whose target agent is available again — safety net for a missed flush-on-recovery event (#706).",
212
+ intervalMs: REAP_INTERVAL_MS,
213
+ runOnStart: true,
214
+ handler() {
215
+ const reswept: Record<string, number> = {};
216
+ for (const target of listStaleQueuedDirectTargets()) {
217
+ const agent = getAgent(target);
218
+ // Only flush to a target that is actually available now; otherwise the
219
+ // message belongs in the queue (this is hooked to agent availability,
220
+ // not pool-slot reclaim).
221
+ if (!agent || isAgentUnavailable(agent)) continue;
222
+ const flushed = flushQueuedDirectMessages(target);
223
+ if (flushed.length > 0) reswept[target] = flushed.length;
224
+ }
225
+ return { resweptTargets: reswept };
226
+ },
227
+ },
200
228
  {
201
229
  id: "command-expiration",
202
230
  title: "Command expiration",
@@ -17,7 +17,7 @@ import { getManagedAgentState, updateManagedAgentState } from "../config-store";
17
17
  import { emitRelayEvent } from "../events";
18
18
  import { emitMessageAvailable, emitMessageDeliveryUpdated, emitNewMessage } from "../sse";
19
19
  import type { AuthContext } from "./auth-context";
20
- import type { ManagedAgentState } from "../types";
20
+ import type { ManagedAgentState, Message } from "../types";
21
21
 
22
22
  interface ManagedRunningInput {
23
23
  policyName?: string;
@@ -135,14 +135,16 @@ function recordManagedTransition(
135
135
  * transitions back to available: heartbeat stale→idle, re-registration after restart/compact, or
136
136
  * rate-limit resume. Emits the same `message.available` / `message.new` / `message.delivery_updated`
137
137
  * events that the policy flush emits so SSE subscribers and the dashboard react identically.
138
- * No-op when there are no queued direct messages.
138
+ * No-op when there are no queued direct messages. Returns the flushed messages so
139
+ * callers (e.g. the resweep safety net, #706) can report how many were recovered.
139
140
  */
140
- export function flushQueuedDirectMessages(agentId: string): void {
141
+ export function flushQueuedDirectMessages(agentId: string): Message[] {
141
142
  const available = resolveQueuedDirectMessages(agentId);
142
- if (!available.length) return;
143
+ if (!available.length) return [];
143
144
  emitMessageAvailable("direct", agentId, available);
144
145
  for (const message of available) {
145
146
  emitNewMessage(message);
146
147
  emitMessageDeliveryUpdated(message);
147
148
  }
149
+ return available;
148
150
  }