agent-relay-server 0.91.1 → 0.91.3

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.91.1",
5
+ "version": "0.91.3",
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.91.1",
3
+ "version": "0.91.3",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -99,6 +99,11 @@ export function registrationTimeoutMsFromEnv(): number {
99
99
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 60_000;
100
100
  }
101
101
 
102
+ export function nativeSelfResumeTimeoutMsFromEnv(): number {
103
+ const parsed = Number(process.env.AGENT_RELAY_NATIVE_SELF_RESUME_TIMEOUT_MS);
104
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 120_000;
105
+ }
106
+
102
107
  export function agentProfileNameFromEnv(): string | undefined {
103
108
  return process.env.AGENT_RELAY_AGENT_PROFILE;
104
109
  }
@@ -173,6 +173,10 @@ export function classifyAgentDeath(ev: DeathEvidence): AgentDeathDiagnosis {
173
173
  pushUnique(evidence, "log/error text matched a crash pattern (panic/segfault/traceback)");
174
174
  return verdict("provider-crash", "medium", "Likely provider crash (log pattern)");
175
175
  }
176
+ if (/self-resume-(?:exit-before-resume|relaunch-failed)/i.test(text)) {
177
+ pushUnique(evidence, "native self-resume exited before Relay observed a resumed provider");
178
+ return verdict("provider-crash", "high", "Self-resume failed before relaunch completed");
179
+ }
176
180
 
177
181
  // 5) killed — external SIGKILL/SIGTERM that isn't OOM and isn't a parent-initiated shutdown.
178
182
  if (signal && /KILL|TERM|INT|HUP|QUIT/i.test(signal)) {
package/src/bus.ts CHANGED
@@ -11,6 +11,7 @@ import { reconcileTerminalProviderExit } from "./services/terminal-provider-exit
11
11
  import { registerAgent } from "./services/register-agent";
12
12
  import { authContextFromBus } from "./services/auth-context";
13
13
  import { commandAuthorizationResource, dispatchCommand } from "./services/dispatch-command";
14
+ import { auditProviderStateTransition } from "./services/provider-state-audit";
14
15
  import { ServiceAuthError } from "./services/errors";
15
16
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
16
17
  import { applyCommandToRecipe } from "./recipe-runner";
@@ -27,9 +28,8 @@ import {
27
28
  type RegisterFrame,
28
29
  } from "agent-relay-sdk/protocol";
29
30
  import { errMessage, isRecord, stringValue } from "agent-relay-sdk";
30
- import { agentProviderState } from "./provider-state";
31
31
  import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
32
- import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, QuotaState, RegisterAgentInput, Task } from "./types";
32
+ import type { Command, ComponentToken, ContextState, Message, ProviderCapabilities, QuotaState, RegisterAgentInput, Task } from "./types";
33
33
 
34
34
  interface BusSocketData {
35
35
  kind: "bus";
@@ -562,36 +562,6 @@ function emitAgentStatusEvent(agentId: string): void {
562
562
  });
563
563
  }
564
564
 
565
- function auditProviderStateTransition(agentId: string, before: AgentCard | null | undefined, after: AgentCard | null | undefined): void {
566
- const previous = agentProviderState(before);
567
- const next = agentProviderState(after);
568
- const previousKey = providerStateKey(previous);
569
- const nextKey = providerStateKey(next);
570
- if (previousKey === nextKey) return;
571
- if (next?.state !== "blocked" && previous?.state !== "blocked") return;
572
-
573
- const blocked = next?.state === "blocked";
574
- const label = typeof next?.label === "string" ? next.label : typeof previous?.label === "string" ? previous.label : "provider blocked";
575
- try {
576
- createActivityEvent({
577
- clientId: `server-agent-${agentId}-provider-state-${nextKey ?? "cleared"}-${Date.now()}`,
578
- kind: "state",
579
- title: blocked ? `Agent blocked: ${label}` : "Agent provider unblocked",
580
- meta: agentId,
581
- icon: blocked ? "ti-alert-circle" : "ti-circle-check",
582
- view: "agents",
583
- agentId,
584
- metadata: {
585
- source: "server",
586
- previousProviderState: previous ?? null,
587
- providerState: next ?? null,
588
- },
589
- });
590
- } catch {
591
- // Audit trail writes must never block bus status updates.
592
- }
593
- }
594
-
595
565
  function auditRunnerTimelineEvent(agentId: string, timelineEvent: unknown): void {
596
566
  if (!isRecord(timelineEvent)) return;
597
567
  const metadata = isRecord(timelineEvent.metadata) ? timelineEvent.metadata : {};
@@ -633,11 +603,6 @@ function auditRunnerTimelineEvent(agentId: string, timelineEvent: unknown): void
633
603
  }
634
604
  }
635
605
 
636
- function providerStateKey(state: Record<string, unknown> | null): string | null {
637
- if (!state) return null;
638
- return [state.state, typeof state.reason === "string" ? state.reason : ""].join(":");
639
- }
640
-
641
606
  function sendCommandResult(
642
607
  ws: BusWebSocket,
643
608
  commandId: string,
@@ -12,6 +12,7 @@ export type NotificationType =
12
12
  | "agent.resume_budget_exhausted"
13
13
  | "agent.transient_land_hold"
14
14
  | "agent.rate_limit_resume"
15
+ | "agent.provider_blocked"
15
16
  | "team.idle"
16
17
  | "team.member_silent"
17
18
  | "team.coordinator_gone"
@@ -68,6 +69,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
68
69
  "agent.resume_budget_exhausted": null,
69
70
  "agent.transient_land_hold": null,
70
71
  "agent.rate_limit_resume": 15 * MINUTES,
72
+ "agent.provider_blocked": null,
71
73
  "team.idle": null,
72
74
  "team.member_silent": null,
73
75
  "team.coordinator_gone": null,
@@ -38,6 +38,8 @@ export function isProviderBlocked(agent: HasMeta | null | undefined): boolean {
38
38
 
39
39
  /** The `reason` value a rate-limit hold carries (matches the runner's control-server seam). */
40
40
  export const RATE_LIMIT_BLOCK_REASON = "rate_limit";
41
+ export const TRANSIENT_RATE_LIMIT_KIND = "transient_overload";
42
+ export const TRANSIENT_RATE_LIMIT_BACKOFF_MS = [30_000, 2 * 60_000, 5 * 60_000] as const;
41
43
 
42
44
  /** True when the agent is specifically held on a provider usage/rate limit. */
43
45
  export function isRateLimitHold(agent: HasMeta | null | undefined): boolean {
@@ -45,6 +47,19 @@ export function isRateLimitHold(agent: HasMeta | null | undefined): boolean {
45
47
  return state?.state === "blocked" && state.reason === RATE_LIMIT_BLOCK_REASON;
46
48
  }
47
49
 
50
+ export function isTransientRateLimitHold(state: ProviderStateView | null): boolean {
51
+ return state?.kind === TRANSIENT_RATE_LIMIT_KIND;
52
+ }
53
+
54
+ export function transientRateLimitAttempt(state: ProviderStateView | null): number {
55
+ const raw = state?.resumeAttempt;
56
+ return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
57
+ }
58
+
59
+ export function transientRateLimitExhausted(state: ProviderStateView | null): boolean {
60
+ return isTransientRateLimitHold(state) && transientRateLimitAttempt(state) >= TRANSIENT_RATE_LIMIT_BACKOFF_MS.length;
61
+ }
62
+
48
63
  // Fallback window when the provider gave no reset time: retry after this long
49
64
  // rather than waiting the full (unknown) limit window. Re-entering the hold on a
50
65
  // still-limited retry is cheap, and a subscription limit almost always carries a
@@ -56,6 +71,12 @@ export function isRateLimitHold(agent: HasMeta | null | undefined): boolean {
56
71
  */
57
72
  export function rateLimitResumeAt(state: ProviderStateView | null): number | null {
58
73
  if (!state) return null;
74
+ if (isTransientRateLimitHold(state)) {
75
+ if (typeof state.nextResumeAt === "number" && Number.isFinite(state.nextResumeAt)) return state.nextResumeAt;
76
+ if (transientRateLimitExhausted(state)) return null;
77
+ const base = typeof state.enteredAt === "number" && Number.isFinite(state.enteredAt) ? state.enteredAt : Date.now();
78
+ return base + TRANSIENT_RATE_LIMIT_BACKOFF_MS[transientRateLimitAttempt(state)]!;
79
+ }
59
80
  if (typeof state.resetAt === "number" && Number.isFinite(state.resetAt)) return state.resetAt;
60
81
  if (typeof state.enteredAt === "number" && Number.isFinite(state.enteredAt)) {
61
82
  return state.enteredAt + rateLimitFallbackMs();
@@ -0,0 +1,52 @@
1
+ import { createActivityEvent } from "../db";
2
+ import { routeNotification } from "../notification-router";
3
+ import { agentProviderState } from "../provider-state";
4
+ import type { AgentCard } from "../types";
5
+
6
+ export function auditProviderStateTransition(agentId: string, before: AgentCard | null | undefined, after: AgentCard | null | undefined): void {
7
+ const previous = agentProviderState(before);
8
+ const next = agentProviderState(after);
9
+ const previousKey = providerStateKey(previous);
10
+ const nextKey = providerStateKey(next);
11
+ if (previousKey === nextKey) return;
12
+ if (next?.state !== "blocked" && previous?.state !== "blocked") return;
13
+
14
+ const blocked = next?.state === "blocked";
15
+ const label = typeof next?.label === "string" ? next.label : typeof previous?.label === "string" ? previous.label : "provider blocked";
16
+ try {
17
+ createActivityEvent({
18
+ clientId: `server-agent-${agentId}-provider-state-${nextKey ?? "cleared"}-${Date.now()}`,
19
+ kind: "state",
20
+ title: blocked ? `Agent blocked: ${label}` : "Agent provider unblocked",
21
+ meta: agentId,
22
+ icon: blocked ? "ti-alert-circle" : "ti-circle-check",
23
+ view: "agents",
24
+ agentId,
25
+ metadata: {
26
+ source: "server",
27
+ previousProviderState: previous ?? null,
28
+ providerState: next ?? null,
29
+ },
30
+ });
31
+ if (!blocked || !after?.spawnedBy) return;
32
+ routeNotification({
33
+ type: "agent.provider_blocked",
34
+ scope: { agentId, parent: after.spawnedBy },
35
+ recipients: [after.spawnedBy],
36
+ message: {
37
+ subject: "Child held on provider block",
38
+ body: `Child agent ${after.label ?? after.name ?? agentId} is held: ${label}.`,
39
+ payload: { kind: "agent.provider_blocked", agentId, providerState: next ?? null },
40
+ replyExpected: false,
41
+ idempotencyKey: `provider-blocked:${agentId}:${nextKey ?? "blocked"}`,
42
+ },
43
+ });
44
+ } catch {
45
+ // Audit trail writes must never block bus status updates.
46
+ }
47
+ }
48
+
49
+ function providerStateKey(state: Record<string, unknown> | null): string | null {
50
+ if (!state) return null;
51
+ return [state.state, typeof state.reason === "string" ? state.reason : ""].join(":");
52
+ }
@@ -1,6 +1,14 @@
1
- import { createActivityEvent, listAgents } from "../db";
1
+ import { createActivityEvent, listAgents, mergeAgentMeta } from "../db";
2
2
  import { routeNotification } from "../notification-router";
3
- import { agentProviderState, isRateLimitHold, rateLimitResumeAt } from "../provider-state";
3
+ import {
4
+ TRANSIENT_RATE_LIMIT_BACKOFF_MS,
5
+ agentProviderState,
6
+ isRateLimitHold,
7
+ isTransientRateLimitHold,
8
+ rateLimitResumeAt,
9
+ transientRateLimitAttempt,
10
+ transientRateLimitExhausted,
11
+ } from "../provider-state";
4
12
 
5
13
  // #286 Phase 2: auto-resume agents held on a usage/rate-limit stall once their
6
14
  // window resets. The runner reports the hold as `providerState: blocked` (reason
@@ -22,14 +30,49 @@ const envMsOrDefault = (name: string, fallback: number): number => {
22
30
  // between delivery and the agent flipping to busy (which clears the hold).
23
31
  const resumeCooldownMs = () => envMsOrDefault("AGENT_RELAY_RATE_LIMIT_RESUME_COOLDOWN_MS", 5 * 60 * 1000);
24
32
 
25
- const resumeTracker = new Map<string, number>();
33
+ interface ResumeTrackerEntry {
34
+ lastResumeAt?: number;
35
+ attempts?: number;
36
+ exhaustedNotified?: boolean;
37
+ }
38
+
39
+ const resumeTracker = new Map<string, ResumeTrackerEntry>();
26
40
 
27
41
  export function resetRateLimitResumeTrackerForTests(): void {
28
42
  resumeTracker.clear();
29
43
  }
30
44
 
31
45
  const RESUME_BODY =
32
- "▶️ Your usage/rate limit has reset — resuming. Continue the task you were working on when the limit interrupted you. If you had already finished, no action is needed.";
46
+ "Your provider limit/overload hold is ready to retry — resuming. Continue the task you were working on when the provider interrupted you. If you had already finished, no action is needed.";
47
+
48
+ function nextTransientState(state: Record<string, unknown>, now: number, attempts: number): Record<string, unknown> {
49
+ const nextAttempts = attempts + 1;
50
+ const nextDelay = TRANSIENT_RATE_LIMIT_BACKOFF_MS[nextAttempts];
51
+ return {
52
+ ...state,
53
+ resumeAttempt: nextAttempts,
54
+ lastResumeAt: now,
55
+ ...(nextDelay !== undefined ? { nextResumeAt: now + nextDelay } : {}),
56
+ updatedAt: now,
57
+ };
58
+ }
59
+
60
+ function notifyTransientExhausted(agent: { id: string; label?: string | null; spawnedBy?: string | null }, state: Record<string, unknown>, now: number): void {
61
+ mergeAgentMeta(agent.id, { providerState: { ...state, exhaustedAt: now, updatedAt: now } });
62
+ if (!agent.spawnedBy) return;
63
+ routeNotification({
64
+ type: "agent.provider_blocked",
65
+ scope: { agentId: agent.id, parent: agent.spawnedBy },
66
+ recipients: [agent.spawnedBy],
67
+ message: {
68
+ subject: "Child held on provider overload",
69
+ body: `Child agent ${agent.label ?? agent.id} is still held on transient provider overload after bounded auto-resume attempts. Manual check or restart may be needed.`,
70
+ payload: { kind: "agent.provider_blocked", agentId: agent.id, providerState: state, exhausted: true },
71
+ replyExpected: false,
72
+ idempotencyKey: `provider-overload-exhausted:${agent.id}:${state.enteredAt ?? "unknown"}`,
73
+ },
74
+ });
75
+ }
33
76
 
34
77
  export function resumeRateLimitedAgents(): Record<string, unknown> {
35
78
  const now = Date.now();
@@ -47,9 +90,21 @@ export function resumeRateLimitedAgents(): Record<string, unknown> {
47
90
  const resumeAt = rateLimitResumeAt(state);
48
91
  if (resumeAt !== null && now < resumeAt) { waiting.push(agent.id); continue; }
49
92
 
50
- const lastResume = resumeTracker.get(agent.id);
51
- if (lastResume !== undefined && now - lastResume < cooldown) { cooling.push(agent.id); continue; }
52
- resumeTracker.set(agent.id, now);
93
+ const transient = isTransientRateLimitHold(state);
94
+ const tracked = resumeTracker.get(agent.id) ?? {};
95
+ const attempts = transient ? Math.max(transientRateLimitAttempt(state), tracked.attempts ?? 0) : 0;
96
+ if (transient && (attempts >= TRANSIENT_RATE_LIMIT_BACKOFF_MS.length || transientRateLimitExhausted(state))) {
97
+ if (!tracked.exhaustedNotified) {
98
+ notifyTransientExhausted(agent, state as Record<string, unknown>, now);
99
+ resumeTracker.set(agent.id, { ...tracked, attempts, exhaustedNotified: true });
100
+ }
101
+ waiting.push(agent.id);
102
+ continue;
103
+ }
104
+
105
+ const lastResume = tracked.lastResumeAt;
106
+ if (!transient && lastResume !== undefined && now - lastResume < cooldown) { cooling.push(agent.id); continue; }
107
+ resumeTracker.set(agent.id, { ...tracked, attempts: transient ? attempts + 1 : undefined, lastResumeAt: now });
53
108
 
54
109
  try {
55
110
  // Short default TTL (per-type): a "limit reset, resume" push is pointless once stale — the
@@ -77,6 +132,8 @@ export function resumeRateLimitedAgents(): Record<string, unknown> {
77
132
  continue;
78
133
  }
79
134
 
135
+ if (transient) mergeAgentMeta(agent.id, { providerState: nextTransientState(state as Record<string, unknown>, now, attempts) });
136
+
80
137
  resumed.push(agent.id);
81
138
  createActivityEvent({
82
139
  clientId: `rate-limit-resume-${agent.id}-${now}`,
@@ -97,7 +154,10 @@ export function resumeRateLimitedAgents(): Record<string, unknown> {
97
154
  }
98
155
 
99
156
  // Forget agents no longer held so a future hold resumes immediately.
100
- for (const id of resumeTracker.keys()) if (!seen.has(id)) resumeTracker.delete(id);
157
+ for (const [id, tracked] of resumeTracker.entries()) {
158
+ if (seen.has(id)) continue;
159
+ if (tracked.lastResumeAt === undefined || now - tracked.lastResumeAt > 15 * 60_000) resumeTracker.delete(id);
160
+ }
101
161
 
102
162
  return { held: seen.size, resumed, waiting, cooling, tracked: resumeTracker.size };
103
163
  }
@@ -8,6 +8,7 @@ import { isRecord } from "agent-relay-sdk";
8
8
  import { clearAgentMetaKeys } from "../db";
9
9
  import { notifyAgentOffline } from "../agent-lifecycle-events";
10
10
  import { diagnoseAgentDeath } from "../agent-death-diagnosis";
11
+ import { routeNotification } from "../notification-router";
11
12
  import type { AgentCard } from "../types";
12
13
 
13
14
  /**
@@ -29,5 +30,27 @@ export function reconcileTerminalProviderExit(agentId: string, before: AgentCard
29
30
  if (!isRecord(marker)) return;
30
31
  const reasonText = typeof marker.reason === "string" ? marker.reason : "provider exited";
31
32
  const reason = `provider exited (${reasonText})`;
32
- notifyAgentOffline(agentId, reason, diagnoseAgentDeath({ agent: after, reason }));
33
+ const diagnosis = diagnoseAgentDeath({ agent: after, reason });
34
+ notifyAgentOffline(agentId, reason, diagnosis);
35
+ if (!after.spawnedBy && reasonText.startsWith("self-resume-")) {
36
+ routeNotification({
37
+ type: "agent.exited",
38
+ scope: { agentId },
39
+ recipients: ["user"],
40
+ message: {
41
+ subject: "Self-resume failed",
42
+ body: `Agent \`${agentId}\` exited during self-resume before the resumed session was confirmed. Manual recovery may need the provider resume id from the runner log.`,
43
+ payload: {
44
+ kind: "agent.self_resume_failed",
45
+ agentId,
46
+ reason,
47
+ diagnosis,
48
+ ...(typeof marker.claudeResumeId === "string" ? { claudeResumeId: marker.claudeResumeId } : {}),
49
+ ...(typeof marker.selfResumeCommandId === "string" ? { commandId: marker.selfResumeCommandId } : {}),
50
+ ...(typeof marker.selfResumeGeneration === "number" ? { generation: marker.selfResumeGeneration } : {}),
51
+ },
52
+ replyExpected: false,
53
+ },
54
+ });
55
+ }
33
56
  }