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.
|
|
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
package/src/db/delivery.ts
CHANGED
|
@@ -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;
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -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)
|
|
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):
|
|
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
|
}
|