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.0",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.95.0",
3
+ "version": "0.95.1",
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.95.0",
4
+ "version": "0.95.1",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -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
- const lastProgressAt = lastProviderEventAt ?? activeWorkStartedAt;
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: higher-priority source keeps value even with newer timestamp (#712)
374
- if (quotaSourcePriority(existingSource) > quotaSourcePriority(incoming.source)) {
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
  }