agent-relay-server 0.94.4 → 0.94.6

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.6",
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.6",
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.6",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -38,6 +38,13 @@ function activeWork(agent: AgentCard): ActiveWorkLike[] {
38
38
  return Array.isArray(agent.meta?.activeWork) ? agent.meta.activeWork as ActiveWorkLike[] : [];
39
39
  }
40
40
 
41
+ function hasLiveToolOrSubprocessWork(works: ActiveWorkLike[]): boolean {
42
+ return works.some((work) => {
43
+ const kind = stringValue(work.kind);
44
+ return Boolean(kind && kind !== "provider-turn");
45
+ });
46
+ }
47
+
41
48
  function thresholdFor(kind: BusyHealthKind): number {
42
49
  if (kind === "startup") return STUCK_BUSY_STARTUP_MS;
43
50
  if (kind === "reconnect") return STUCK_BUSY_RECONNECT_MS;
@@ -70,13 +77,17 @@ function classifyBusy(agent: AgentCard, now: number): { kind: BusyHealthKind; wo
70
77
  export function deriveAgentBusyHealth(agent: AgentCard, now: number = Date.now()): AgentBusyHealth | undefined {
71
78
  const classified = classifyBusy(agent, now);
72
79
  if (!classified) return undefined;
80
+ const works = activeWork(agent);
73
81
  const thresholdMs = thresholdFor(classified.kind);
74
82
  const activeWorkStartedAt = Math.max(0, classified.startedAt);
75
83
  const activeWorkAgeMs = Math.max(0, now - activeWorkStartedAt);
76
84
  const lastProviderEventAt = numberValue(agent.meta?.lastProviderEventAt);
77
85
  const lastProgressAt = lastProviderEventAt ?? activeWorkStartedAt;
78
86
  const staleForMs = Math.max(0, now - lastProgressAt);
79
- const stuck = activeWorkAgeMs >= thresholdMs && staleForMs >= thresholdMs;
87
+ const stuck =
88
+ !hasLiveToolOrSubprocessWork(works) &&
89
+ activeWorkAgeMs >= thresholdMs &&
90
+ staleForMs >= thresholdMs;
80
91
  const work = classified.work;
81
92
  return {
82
93
  status: stuck ? "stuck-busy" : "ok",
@@ -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";
@@ -117,7 +120,6 @@ export function runStuckBusyWatchdog(input: { now?: number } = {}): Record<strin
117
120
  const messages = routeNotification({
118
121
  type: "agent.stuck_busy",
119
122
  scope: { agentId: agent.id, parent: agent.spawnedBy, teamId: agent.teamId },
120
- recipients: ["user"],
121
123
  message: {
122
124
  subject: "Agent stuck busy",
123
125
  body: stuckBusyBody(agent.id, health),
@@ -193,10 +195,35 @@ export const definitions: MaintenanceJobDefinition[] = [
193
195
  runOnStart: true,
194
196
  handler() {
195
197
  const messages = expireQueuedMessages();
196
- for (const message of messages) emitMessageExpired(message);
198
+ for (const message of messages) {
199
+ // Silent data loss is the worst failure mode here (#706): a queued
200
+ // message died without ever reaching its recipient. Surface it loudly.
201
+ console.warn(`[delivery] message ${message.id} to ${message.to} (kind=${message.kind}) expired undelivered after max_age — silent data loss`);
202
+ emitMessageExpired(message);
203
+ }
197
204
  return { expiredQueuedMessageIds: messages.map((message) => message.id) };
198
205
  },
199
206
  },
207
+ {
208
+ id: "queued-message-resweep",
209
+ title: "Queued message re-sweep",
210
+ description: "Re-resolve long-queued direct messages whose target agent is available again — safety net for a missed flush-on-recovery event (#706).",
211
+ intervalMs: REAP_INTERVAL_MS,
212
+ runOnStart: true,
213
+ handler() {
214
+ const reswept: Record<string, number> = {};
215
+ for (const target of listStaleQueuedDirectTargets()) {
216
+ const agent = getAgent(target);
217
+ // Only flush to a target that is actually available now; otherwise the
218
+ // message belongs in the queue (this is hooked to agent availability,
219
+ // not pool-slot reclaim).
220
+ if (!agent || isAgentUnavailable(agent)) continue;
221
+ const flushed = flushQueuedDirectMessages(target);
222
+ if (flushed.length > 0) reswept[target] = flushed.length;
223
+ }
224
+ return { resweptTargets: reswept };
225
+ },
226
+ },
200
227
  {
201
228
  id: "command-expiration",
202
229
  title: "Command expiration",
@@ -203,6 +203,10 @@ function routingReason(
203
203
  ): string {
204
204
  if (!settingsEnabled) return "notifications are disabled for this recipient by settings";
205
205
  if (matchedRule === "author") return "you are the author";
206
+ if (matchedRule === "targeted-at-user") return "you are directly targeted";
207
+ if (matchedRule === "spawned-by-user") return "you directly spawned this agent";
208
+ if (matchedRule === "spawn-parent-of") return "you are the subject agent's spawn parent";
209
+ if (matchedRule === "thread-participant") return "you are a participant in this thread";
206
210
  if (matchedRule === "spawn-parent") return "you are the spawn parent";
207
211
  if (matchedRule === "subject-agent") return "you are the notification subject";
208
212
  if (matchedRule === "same-team") return scope.teamId ? `same team ${scope.teamId}` : "same team";
@@ -1,5 +1,6 @@
1
1
  import type { AgentCard } from "./types";
2
- import { listAgents } from "./db";
2
+ import { canonicalHumanAgentId, isHumanAgentId } from "agent-relay-sdk";
3
+ import { getAgent, getThread, listAgents } from "./db";
3
4
  import { isAgentOnline } from "./agent-ref";
4
5
  import { notificationEnabledFor } from "./notification-settings-store";
5
6
  import type { NotificationType, NotificationScope } from "./notification-types";
@@ -40,6 +41,31 @@ interface AudienceMember {
40
41
  matchedRule: string;
41
42
  }
42
43
 
44
+ function sameAddress(a: string | undefined, b: string | undefined): boolean {
45
+ if (!a || !b) return false;
46
+ if (isHumanAgentId(a) || isHumanAgentId(b)) return canonicalHumanAgentId(a) === canonicalHumanAgentId(b);
47
+ return a === b;
48
+ }
49
+
50
+ function subjectParent(scope: NotificationScope): string | undefined {
51
+ if (scope.parent) return scope.parent;
52
+ return scope.agentId ? getAgent(scope.agentId)?.spawnedBy : undefined;
53
+ }
54
+
55
+ function threadParticipantIds(scope: NotificationScope): Set<string> {
56
+ const ids = new Set<string>();
57
+ for (const id of scope.participants ?? []) {
58
+ if (id.trim()) ids.add(id);
59
+ }
60
+ if (typeof scope.threadId === "number" && Number.isSafeInteger(scope.threadId) && scope.threadId > 0) {
61
+ for (const message of getThread(scope.threadId)) {
62
+ ids.add(message.from);
63
+ ids.add(message.to);
64
+ }
65
+ }
66
+ return ids;
67
+ }
68
+
43
69
  /**
44
70
  * Predicate library — the reusable building blocks each type composes its default audience from.
45
71
  * Each factory returns a single-concern rule. `deliver` includes the agent; `abstain` leaves the
@@ -47,6 +73,38 @@ interface AudienceMember {
47
73
  * shape #443 builds on). Compose them ordered: later rules override earlier (see {@link evaluateAudience}).
48
74
  */
49
75
  export const audiencePredicates = {
76
+ /** A human directly targeted by the message/chat event. */
77
+ targetedAtUser: (): NotificationRule => ({
78
+ name: "targeted-at-user",
79
+ evaluate: (a, { scope }) => {
80
+ if (!isHumanAgentId(a.id)) return "abstain";
81
+ const target = scope.to ?? scope.target;
82
+ return sameAddress(a.id, target) ? "deliver" : "abstain";
83
+ },
84
+ }),
85
+ /** A human directly spawned the subject agent. Depth-1 only; grandchildren abstain. */
86
+ spawnedByUser: (): NotificationRule => ({
87
+ name: "spawned-by-user",
88
+ evaluate: (a, { scope }) => {
89
+ if (!isHumanAgentId(a.id)) return "abstain";
90
+ return sameAddress(a.id, subjectParent(scope)) ? "deliver" : "abstain";
91
+ },
92
+ }),
93
+ /** The subject agent's spawn-parent, deriving it from `scope.agentId` when needed. */
94
+ spawnParentOf: (): NotificationRule => ({
95
+ name: "spawn-parent-of",
96
+ evaluate: (a, { scope }) => (sameAddress(a.id, subjectParent(scope)) ? "deliver" : "abstain"),
97
+ }),
98
+ /** Any participant in the message/chat thread. */
99
+ threadParticipant: (): NotificationRule => ({
100
+ name: "thread-participant",
101
+ evaluate: (a, { scope }) => {
102
+ for (const participant of threadParticipantIds(scope)) {
103
+ if (sameAddress(a.id, participant)) return "deliver";
104
+ }
105
+ return "abstain";
106
+ },
107
+ }),
50
108
  /** The agent whose work the notification is about — delivered even when offline (store-ahead #234). */
51
109
  author: (): NotificationRule => ({
52
110
  name: "author",
@@ -96,14 +154,15 @@ export const audiencePredicates = {
96
154
  } as const;
97
155
 
98
156
  /**
99
- * The candidate pool for rule evaluation: every registered agent EXCEPT the pseudo-identities
100
- * (`system`/`user`) and channel agents, which are never wake-notification recipients. Offline agents
101
- * stay IN the pool deliberately a store-ahead rule (author/spawn-parent) must be able to select an
102
- * offline target; per-rule `onlineOnly` gates the cases that need liveness (e.g. on-repo-root fan-out).
157
+ * The candidate pool for rule evaluation: every registered agent EXCEPT `system` and channel agents,
158
+ * which are never wake-notification recipients. Human sinks stay in the pool so human-surface rules
159
+ * can decide when `user` belongs in an audience. Offline agents stay IN deliberately a store-ahead
160
+ * rule (author/spawn-parent) must be able to select an offline target; per-rule `onlineOnly` gates
161
+ * the cases that need liveness (e.g. on-repo-root fan-out).
103
162
  */
104
163
  export function audienceCandidates(): AgentCard[] {
105
164
  return listAgents().filter(
106
- (a) => a.id !== "system" && a.id !== "user" && a.kind !== "channel" && a.meta?.kind !== "channel",
165
+ (a) => a.id !== "system" && a.kind !== "system" && a.kind !== "channel" && a.meta?.kind !== "channel",
107
166
  );
108
167
  }
109
168
 
@@ -190,3 +249,25 @@ export function __resetAudienceRules(): void {
190
249
  for (const [type, rules] of defaultAudienceRegistry) audienceRegistry.set(type, rules);
191
250
  subscriptionRules = [];
192
251
  }
252
+
253
+ const SPAWN_LIFECYCLE_AUDIENCE_TYPES = [
254
+ "agent.ready",
255
+ "agent.exited",
256
+ "agent.spawn_failed",
257
+ "agent.spawn_recovered",
258
+ ] as const satisfies readonly NotificationType[];
259
+
260
+ for (const type of SPAWN_LIFECYCLE_AUDIENCE_TYPES) {
261
+ registerDefaultAudienceRules(type, [audiencePredicates.spawnParentOf(), audiencePredicates.spawnedByUser()]);
262
+ }
263
+
264
+ registerDefaultAudienceRules("agent.stuck_busy", [
265
+ audiencePredicates.spawnParentOf(),
266
+ audiencePredicates.spawnedByUser(),
267
+ ]);
268
+
269
+ registerDefaultAudienceRules("message.new", [
270
+ audiencePredicates.targetedAtUser(),
271
+ audiencePredicates.spawnedByUser(),
272
+ audiencePredicates.threadParticipant(),
273
+ ]);
@@ -1,4 +1,5 @@
1
1
  export type NotificationType =
2
+ | "message.new"
2
3
  | "landed-failure"
3
4
  | "branch.landed"
4
5
  | "agent.ready"
@@ -31,6 +32,16 @@ export type NotificationType =
31
32
  * All optional because each emitter fills the fields it has in scope at the call site.
32
33
  */
33
34
  export interface NotificationScope {
35
+ /** Message sender for message/chat audience evaluation. */
36
+ from?: string;
37
+ /** Message target for message/chat audience evaluation. */
38
+ to?: string;
39
+ /** Alias for `to` used by audience preview clients that describe the primary target. */
40
+ target?: string;
41
+ /** Message thread id for thread participant audience evaluation. */
42
+ threadId?: number;
43
+ /** Pre-resolved thread participants when the caller already has the thread membership. */
44
+ participants?: string[];
34
45
  /** The agent whose work the notification concerns (branch author, workspace owner). */
35
46
  author?: string;
36
47
  /** The agent the notification is primarily about (lifecycle subject, advisory target). */
@@ -56,6 +67,7 @@ const MINUTES = 60 * 1000;
56
67
  * obligation is `null` (never auto-expires). Only genuinely now-or-never pings get a short default.
57
68
  */
58
69
  export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
70
+ "message.new": null,
59
71
  "landed-failure": null,
60
72
  "branch.landed": null,
61
73
  "agent.ready": null,
@@ -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
  }