agent-relay-server 0.95.0 → 0.95.1
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.95.
|
|
5
|
+
"version": "0.95.1",
|
|
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/agent-busy-health.ts
CHANGED
|
@@ -3,8 +3,9 @@ import {
|
|
|
3
3
|
STUCK_BUSY_RECONNECT_MS,
|
|
4
4
|
STUCK_BUSY_SHUTDOWN_MS,
|
|
5
5
|
STUCK_BUSY_STARTUP_MS,
|
|
6
|
+
STUCK_BUSY_TRANSPORT_STALE_MS,
|
|
6
7
|
} from "./config";
|
|
7
|
-
import { stringValue } from "agent-relay-sdk";
|
|
8
|
+
import { isRecord, stringValue } from "agent-relay-sdk";
|
|
8
9
|
import type { AgentCard } from "./types";
|
|
9
10
|
|
|
10
11
|
type BusyHealthKind = "provider-turn" | "startup" | "reconnect" | "shutdown";
|
|
@@ -27,6 +28,12 @@ export interface AgentBusyHealth {
|
|
|
27
28
|
activeWorkAgeMs: number;
|
|
28
29
|
lastProviderEventAt?: number;
|
|
29
30
|
lastProviderEventAgeMs?: number;
|
|
31
|
+
/** Most recent of lastProviderEventAt and any fresh lifecycle/timeline transition (e.g. `compacting`). */
|
|
32
|
+
lastProgressAt: number;
|
|
33
|
+
/** Most recent transport liveness signal (server frame / heartbeat ack); undefined when the runner reports no transport. */
|
|
34
|
+
transportLiveAt?: number;
|
|
35
|
+
/** Age of the transport liveness signal; +Infinity when transport is unknown (treated as dead). */
|
|
36
|
+
transportStaleForMs: number;
|
|
30
37
|
staleForMs: number;
|
|
31
38
|
}
|
|
32
39
|
|
|
@@ -45,6 +52,29 @@ function hasLiveToolOrSubprocessWork(works: ActiveWorkLike[]): boolean {
|
|
|
45
52
|
});
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
// #713 — most recent transport-liveness timestamp the runner reported on meta.transport
|
|
56
|
+
// (`lastServerFrameAt` / `lastHeartbeatAckAt`). A current value proves the runner→server link is
|
|
57
|
+
// alive, which means the agent is NOT wedged regardless of provider-token silence. Returns undefined
|
|
58
|
+
// when no transport block is present (older runners / pre-transport meta) — the caller treats that as
|
|
59
|
+
// "can't prove alive" so a genuine wedge with no transport still fires.
|
|
60
|
+
function transportLiveAt(agent: AgentCard): number | undefined {
|
|
61
|
+
const transport = isRecord(agent.meta?.transport) ? agent.meta.transport : undefined;
|
|
62
|
+
if (!transport) return undefined;
|
|
63
|
+
const frame = numberValue(transport.lastServerFrameAt);
|
|
64
|
+
const ack = numberValue(transport.lastHeartbeatAckAt);
|
|
65
|
+
if (frame === undefined && ack === undefined) return undefined;
|
|
66
|
+
return Math.max(frame ?? 0, ack ?? 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// #713 — timestamp of the latest lifecycle/timeline transition (e.g. Claude `compacting`/`compacted`)
|
|
70
|
+
// the runner published on meta.timelineEvent. These mark active progress through a lifecycle even when
|
|
71
|
+
// no provider tokens flow, so they count as progress against the no-progress threshold.
|
|
72
|
+
function timelineProgressAt(agent: AgentCard): number | undefined {
|
|
73
|
+
const event = isRecord(agent.meta?.timelineEvent) ? agent.meta.timelineEvent : undefined;
|
|
74
|
+
if (!event) return undefined;
|
|
75
|
+
return numberValue(event.timestamp);
|
|
76
|
+
}
|
|
77
|
+
|
|
48
78
|
function thresholdFor(kind: BusyHealthKind): number {
|
|
49
79
|
if (kind === "startup") return STUCK_BUSY_STARTUP_MS;
|
|
50
80
|
if (kind === "reconnect") return STUCK_BUSY_RECONNECT_MS;
|
|
@@ -82,12 +112,21 @@ export function deriveAgentBusyHealth(agent: AgentCard, now: number = Date.now()
|
|
|
82
112
|
const activeWorkStartedAt = Math.max(0, classified.startedAt);
|
|
83
113
|
const activeWorkAgeMs = Math.max(0, now - activeWorkStartedAt);
|
|
84
114
|
const lastProviderEventAt = numberValue(agent.meta?.lastProviderEventAt);
|
|
85
|
-
|
|
115
|
+
// Progress = provider tokens OR any lifecycle/timeline transition (e.g. native compaction). #713: a
|
|
116
|
+
// fresh `compacting` event means the worker is actively progressing even with zero provider tokens.
|
|
117
|
+
const lastProgressAt = Math.max(lastProviderEventAt ?? activeWorkStartedAt, timelineProgressAt(agent) ?? 0);
|
|
86
118
|
const staleForMs = Math.max(0, now - lastProgressAt);
|
|
119
|
+
// #713: transport heartbeat liveness. A current server-frame/heartbeat-ack proves the runner→server
|
|
120
|
+
// link is alive ⇒ the agent is NOT wedged, no matter how long provider tokens have been silent
|
|
121
|
+
// (long turns, extended thinking, native compaction). Unknown transport ⇒ +Infinity (can't prove
|
|
122
|
+
// alive), so a genuine wedge whose runner has stopped reporting still trips.
|
|
123
|
+
const liveAt = transportLiveAt(agent);
|
|
124
|
+
const transportStaleForMs = liveAt === undefined ? Number.POSITIVE_INFINITY : Math.max(0, now - liveAt);
|
|
87
125
|
const stuck =
|
|
88
126
|
!hasLiveToolOrSubprocessWork(works) &&
|
|
89
127
|
activeWorkAgeMs >= thresholdMs &&
|
|
90
|
-
staleForMs >= thresholdMs
|
|
128
|
+
staleForMs >= thresholdMs &&
|
|
129
|
+
transportStaleForMs >= STUCK_BUSY_TRANSPORT_STALE_MS;
|
|
91
130
|
const work = classified.work;
|
|
92
131
|
return {
|
|
93
132
|
status: stuck ? "stuck-busy" : "ok",
|
|
@@ -99,6 +138,9 @@ export function deriveAgentBusyHealth(agent: AgentCard, now: number = Date.now()
|
|
|
99
138
|
activeWorkStartedAt,
|
|
100
139
|
activeWorkAgeMs,
|
|
101
140
|
...(lastProviderEventAt !== undefined ? { lastProviderEventAt, lastProviderEventAgeMs: Math.max(0, now - lastProviderEventAt) } : {}),
|
|
141
|
+
lastProgressAt,
|
|
142
|
+
...(liveAt !== undefined ? { transportLiveAt: liveAt } : {}),
|
|
143
|
+
transportStaleForMs,
|
|
102
144
|
staleForMs,
|
|
103
145
|
};
|
|
104
146
|
}
|
package/src/config.ts
CHANGED
|
@@ -64,6 +64,13 @@ export const STUCK_BUSY_PROVIDER_TURN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUS
|
|
|
64
64
|
export const STUCK_BUSY_STARTUP_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_STARTUP_MS", STUCK_BUSY_THRESHOLD_MS);
|
|
65
65
|
export const STUCK_BUSY_RECONNECT_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_RECONNECT_MS", STUCK_BUSY_THRESHOLD_MS);
|
|
66
66
|
export const STUCK_BUSY_SHUTDOWN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_SHUTDOWN_MS", STUCK_BUSY_THRESHOLD_MS);
|
|
67
|
+
// #713 — provider-token silence ALONE is not a wedge signal for Claude (long turns, extended
|
|
68
|
+
// thinking, and native compaction all go token-silent while healthy). Before declaring stuck we
|
|
69
|
+
// ALSO require the transport heartbeat to be genuinely dead: no server frame / heartbeat-ack within
|
|
70
|
+
// this window. The runner heartbeats every 30s, so 2min (4 missed beats) is a clear "transport gone"
|
|
71
|
+
// signal, well above jitter and far below the 15min provider threshold — a real wedge (transport dead
|
|
72
|
+
// AND provider silent AND no lifecycle progress) still fires. A live heartbeat ⇒ not wedged.
|
|
73
|
+
export const STUCK_BUSY_TRANSPORT_STALE_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_TRANSPORT_STALE_MS", 2 * 60_000);
|
|
67
74
|
export const CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_CLAIM_LEASE_MS", 1_800_000); // 30min claim lease
|
|
68
75
|
export const POOL_CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_POOL_CLAIM_LEASE_MS", STALE_TTL_MS * 3); // pool binding lease
|
|
69
76
|
// Per-repo merge serialization lease — only one base merge may run at a time per
|
|
@@ -370,10 +370,25 @@ function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: Quo
|
|
|
370
370
|
if (!incomingWindow) {
|
|
371
371
|
mergedTimes.set(key, existingUpdatedAt); mergedSources.set(key, existingSource); return window;
|
|
372
372
|
}
|
|
373
|
-
// Per-window source precedence
|
|
374
|
-
|
|
373
|
+
// Per-window source precedence is symmetric in BOTH directions (#712, #716):
|
|
374
|
+
// - A higher-priority existing value is kept even against a newer lower-priority reading.
|
|
375
|
+
// - A higher-priority incoming value (e.g. authoritative WHAM `ingest`) overrides the stored
|
|
376
|
+
// value outright, bypassing the timestamp + regression guards below. Those guards exist to
|
|
377
|
+
// reject a noisy lower-or-equal source's improbable swing (a wedged probe self-report) — they
|
|
378
|
+
// must NOT block authoritative data correcting a stale probe that pinned the window high.
|
|
379
|
+
const existingPriority = quotaSourcePriority(existingSource);
|
|
380
|
+
const incomingPriority = quotaSourcePriority(incoming.source);
|
|
381
|
+
if (existingPriority > incomingPriority) {
|
|
375
382
|
mergedTimes.set(key, existingUpdatedAt); mergedSources.set(key, existingSource); return window;
|
|
376
383
|
}
|
|
384
|
+
if (incomingPriority > existingPriority) {
|
|
385
|
+
acceptedIncomingWindow = true;
|
|
386
|
+
updatedAt = Math.max(updatedAt, incoming.updatedAt);
|
|
387
|
+
source = incoming.source;
|
|
388
|
+
mergedTimes.set(key, incoming.updatedAt); mergedSources.set(key, incoming.source);
|
|
389
|
+
return incomingWindow;
|
|
390
|
+
}
|
|
391
|
+
// Same-priority sources: prefer the newer reading and guard against an improbable regression.
|
|
377
392
|
if (incoming.updatedAt < existingUpdatedAt) {
|
|
378
393
|
mergedTimes.set(key, existingUpdatedAt); mergedSources.set(key, existingSource); return window;
|
|
379
394
|
}
|