agent-relay-server 0.121.1 → 0.121.2

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.121.1",
5
+ "version": "0.121.2",
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.121.1",
3
+ "version": "0.121.2",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "CONTRIBUTING.md"
38
38
  ],
39
39
  "dependencies": {
40
- "agent-relay-channels-host": "0.121.1",
40
+ "agent-relay-channels-host": "0.121.2",
41
41
  "agent-relay-providers": "0.104.3",
42
42
  "agent-relay-sdk": "0.2.113",
43
43
  "ajv": "^8.20.0"
@@ -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.121.1",
4
+ "version": "0.121.2",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/bus.ts CHANGED
@@ -118,8 +118,12 @@ export function getBusConnectionCount(): number {
118
118
  return busConnections.size;
119
119
  }
120
120
 
121
- export function expireStaleBusAgents(graceMs = busStaleGraceMs()): { agentIds: string[]; orphanedTasks: Task[] } {
122
- const cutoff = Date.now() - graceMs;
121
+ export function expireStaleBusAgents(graceMs = busStaleGraceMs(), lagGraceMs = 0): { agentIds: string[]; orphanedTasks: Task[] } {
122
+ // #1048 defense-in-depth: subtract the relay's own recently-observed event-loop lag
123
+ // from the cutoff so a bus-grace expiry can't fire on a heartbeat gap that was really
124
+ // the relay stalling. Secondary to the stale reaper (this only touches already-`stale`
125
+ // rows), but kept symmetric. lagGraceMs is passed in — bus.ts stays health-agnostic.
126
+ const cutoff = Date.now() - graceMs - Math.max(0, lagGraceMs);
123
127
  const rows = getDb().query("SELECT id FROM agents WHERE status = 'stale' AND last_seen < ? AND id NOT IN ('user', 'system')").all(cutoff) as Array<{ id: string }>;
124
128
  const orphanedTasks: Task[] = [];
125
129
  for (const row of rows) {
package/src/db/agents.ts CHANGED
@@ -491,9 +491,13 @@ function mergeRelayContextMarkers(agentId: string, next: ContextState | undefine
491
491
  }
492
492
 
493
493
 
494
- export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
494
+ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS, lagGraceMs = 0): string[] {
495
495
  const now = Date.now();
496
- const cutoff = now - ttlMs;
496
+ // #1048 — extend the stale cutoff by the relay's own recently-observed event-loop
497
+ // lag. A missed heartbeat while THIS process was stalled means "the relay couldn't
498
+ // read it", not "the agent died"; the caller passes the grace in (this module stays
499
+ // health-agnostic). lagGraceMs of 0 is the pre-#1048 behavior.
500
+ const cutoff = now - ttlMs - Math.max(0, lagGraceMs);
497
501
  getDb().query("UPDATE agents SET last_seen = ? WHERE id IN ('user', 'system')").run(now);
498
502
  const rows = getDb()
499
503
  .query(
@@ -43,9 +43,18 @@ export function recordEventLoopLagSample(lagMs: number, sampledAt: number = Date
43
43
  }
44
44
  }
45
45
 
46
- export function getEventLoopLagSnapshot(): EventLoopLagSnapshot {
46
+ // Max lag over the retained ring buffer. SIDE-EFFECT-FREE: unlike
47
+ // getEventLoopLagSnapshot it does NOT reset maxSinceLastReadMs, so a caller that
48
+ // only needs the recent-lag ceiling (the stale reaper's grace, #1048) can read it
49
+ // without stealing the spike the /health poller owns.
50
+ export function eventLoopLagMaxMs(): number {
47
51
  let maxMs = 0;
48
52
  for (let i = 0; i < sampleCount; i += 1) maxMs = Math.max(maxMs, samples[i] ?? 0);
53
+ return maxMs;
54
+ }
55
+
56
+ export function getEventLoopLagSnapshot(): EventLoopLagSnapshot {
57
+ const maxMs = eventLoopLagMaxMs();
49
58
  const maxSinceLastRead = maxSinceLastReadMs;
50
59
  maxSinceLastReadMs = 0;
51
60
  return {
@@ -1,6 +1,7 @@
1
1
  import { getArtifactStorage } from "../artifact-storage";
2
2
  import { deriveAgentBusyHealth } from "../agent-busy-health";
3
3
  import { expireStaleBusAgents } from "../bus";
4
+ import { eventLoopLagMaxMs } from "../event-loop-lag";
4
5
  import { pruneOutbox } from "../bus-outbox";
5
6
  import { expireCommands } from "../commands-db";
6
7
  import { getRetentionConfig } from "../retention-config-store";
@@ -277,7 +278,7 @@ export const definitions: MaintenanceJobDefinition[] = [
277
278
  intervalMs: REAP_INTERVAL_MS,
278
279
  runOnStart: true,
279
280
  handler() {
280
- const expired = expireStaleBusAgents();
281
+ const expired = expireStaleBusAgents(undefined, eventLoopLagMaxMs());
281
282
  for (const id of expired.agentIds) {
282
283
  getLifecycleManager().onAgentDisappeared(id);
283
284
  // #636/#659 — only after the stale reconnect grace expires do we turn a bare
@@ -300,7 +301,12 @@ export const definitions: MaintenanceJobDefinition[] = [
300
301
  intervalMs: REAP_INTERVAL_MS,
301
302
  runOnStart: true,
302
303
  handler() {
303
- const reapedAgentIds = reapStaleAgents(STALE_TTL_MS);
304
+ // #1048 gate staleness on the relay's OWN event-loop health. eventLoopLagMaxMs()
305
+ // is side-effect-free (does not consume the /health poller's spike max). During a
306
+ // relay self-stall this grace keeps live agents whose heartbeats we simply couldn't
307
+ // process from being reaped as dead.
308
+ const lagGraceMs = eventLoopLagMaxMs();
309
+ const reapedAgentIds = reapStaleAgents(STALE_TTL_MS, lagGraceMs);
304
310
  for (const id of reapedAgentIds) {
305
311
  emitAgentStatus(id);
306
312
  createActivityEvent({
@@ -7,7 +7,7 @@ import { WORKSPACE_ACTIONS, applyWorkspaceAction, buildWorkspaceCleanupCommand,
7
7
  import { auditEvent, authAuditMetadata, authorizeRoute, emitCommand, error, json, parseBody, type Handler } from "./_shared";
8
8
  import { collectWorkspaceOrphans } from "../workspace-orphans";
9
9
  import { createCommand } from "../commands-db";
10
- import { LAND_STRATEGIES, isOwnerAlive, withOwnerOnline } from "../workspace-merge";
10
+ import { LAND_STRATEGIES, isOwnerAlive, isOwnerProcessConfirmedGone, withOwnerOnline } from "../workspace-merge";
11
11
  import { isPathWithinBase } from "../utils";
12
12
  import { resolve } from "node:path";
13
13
  import { type WorkspaceAutoMergePolicy, type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecoveryBranch, type WorkspaceRecord, type WorkspaceStatus } from "../types";
@@ -340,8 +340,14 @@ async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ sta
340
340
  }
341
341
  }
342
342
 
343
- function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnline: boolean; gitState?: WorkspaceGitState; claim: ReturnType<typeof workspaceActiveClaim> }): WorkspaceDiagnostics["recommendation"] {
344
- const { workspace, ownerOnline, gitState, claim } = input;
343
+ function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnline: boolean; ownerProcessGone: boolean; gitState?: WorkspaceGitState; claim: ReturnType<typeof workspaceActiveClaim> }): WorkspaceDiagnostics["recommendation"] {
344
+ const { workspace, ownerOnline, ownerProcessGone, gitState, claim } = input;
345
+ // #1049 — `!ownerOnline` is a heartbeat lapse, which a relay self-stall fakes for LIVE
346
+ // agents (the 2026-07-04 cascade). It must NOT alone drive a destructive recommendation.
347
+ // Treat the owner as genuinely gone only when its process death was actually observed
348
+ // (orchestrator-reported exit); otherwise a maybe-alive owner gets recovery, not a nuke.
349
+ const ownerGone = !ownerOnline && ownerProcessGone;
350
+ const awaitingReview = workspace.status === "review_requested" || workspace.status === "conflict";
345
351
  if (claim) return { action: "wait", confidence: "high", reason: `claimed by ${claim.by ?? "steward"}` };
346
352
  if (TERMINAL_WORKSPACE_STATUSES.has(workspace.status)) return { action: "none", confidence: "high", reason: `workspace is ${workspace.status}` };
347
353
  if (!gitState || gitState.error) return { action: "review", confidence: "low", reason: gitState?.error ? `git state error: ${gitState.error}` : "git state unavailable" };
@@ -350,19 +356,31 @@ function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnli
350
356
  const landed = gitState.landed === true;
351
357
  if ((gitState.dirtyCount ?? 0) > 0) return { action: "review", confidence: "medium", reason: `${gitState.dirtyCount} uncommitted change(s)` };
352
358
  if (ahead === 0 || landed) {
353
- if (!ownerOnline) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner offline" : "no unmerged commits; owner offline" };
359
+ if (!ownerOnline) {
360
+ if (ownerGone) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner process gone" : "no unmerged commits; owner process gone" };
361
+ // Offline but the process wasn't observed dying — likely a false exit (relay stall /
362
+ // transport gap). Don't clean up; surface for review so a live owner can reconnect.
363
+ return { action: "review", confidence: "medium", reason: landed ? "work already landed; owner offline but process not confirmed gone — recover, don't clean up" : "owner offline but process not confirmed gone — recover, don't clean up" };
364
+ }
354
365
  // Owner active but the workspace is awaiting attention with nothing to land (#230):
355
366
  // landing it is a safe no-op that resolves it to terminal `merged`, clearing the
356
367
  // queue (the host keeps a live owner's worktree). Otherwise there's genuinely
357
368
  // nothing to do.
358
- if (workspace.status === "review_requested" || workspace.status === "conflict") {
369
+ if (awaitingReview) {
359
370
  return { action: "merge", confidence: "high", reason: landed ? "work already landed; resolve the no-op" : "nothing to merge; resolve the no-op" };
360
371
  }
361
372
  return { action: "none", confidence: "medium", reason: "nothing to merge; owner active" };
362
373
  }
363
- if (ownerOnline && workspace.status !== "review_requested" && workspace.status !== "conflict") {
374
+ if (ownerOnline && !awaitingReview) {
364
375
  return { action: "wait", confidence: "medium", reason: "owner active and not awaiting review" };
365
376
  }
377
+ // ahead > 0 with the owner offline. A worker that explicitly signaled review/conflict
378
+ // before going offline asked for the land — honor it. But a worker that merely lapsed
379
+ // mid-work (not awaiting review) with its process NOT confirmed gone is a false-exit
380
+ // candidate; recommend recovery, never an auto-land of its unmerged commits (#1049).
381
+ if (!ownerOnline && !awaitingReview && !ownerGone) {
382
+ return { action: "review", confidence: "medium", reason: `${ahead} unmerged commit(s) but owner offline and process not confirmed gone — recover, don't land` };
383
+ }
366
384
  if (workspace.status === "conflict") return { action: "rebase", confidence: "high", reason: "conflict — rebase onto base and resolve" };
367
385
  if ((gitState.behind ?? 0) > 0) return { action: "rebase", confidence: "medium", reason: `${gitState.behind} behind base — rebase then merge` };
368
386
  return { action: "merge", confidence: "high", reason: `${ahead} commit(s) ready to land` };
@@ -397,7 +415,7 @@ export const getWorkspaceDiagnostics: Handler = async (_req, params) => {
397
415
  orchestrator: { id: orch?.id, online: orchOnline },
398
416
  ...(claim ? { claim: { by: claim.by, purpose: claim.purpose, expiresAt: claim.expiresAt } } : {}),
399
417
  ...(gitState ? { gitState } : { gitStateUnavailable: "unavailable" in fetched ? fetched.unavailable : "unknown" }),
400
- recommendation: recommendWorkspaceAction({ workspace, ownerOnline, gitState, claim }),
418
+ recommendation: recommendWorkspaceAction({ workspace, ownerOnline, ownerProcessGone: isOwnerProcessConfirmedGone(workspace.ownerAgentId), gitState, claim }),
401
419
  };
402
420
  return json(diagnostics);
403
421
  };
package/src/security.ts CHANGED
@@ -476,6 +476,21 @@ export function unauthorized(req: Request): Response {
476
476
  return applyCors(req, response);
477
477
  }
478
478
 
479
+ // #1047 — the MCP endpoint speaks JSON-RPC, so a 401 must be a JSON-RPC error
480
+ // envelope, not the bare {"error":"unauthorized"} body (which fails client schema
481
+ // validation and reads as protocol noise). Mirrors the runner proxy's own auth
482
+ // failure (runner/src/relay-mcp-proxy.ts). id is null: the coarse auth gate runs
483
+ // before the JSON-RPC body is parsed, and a revoked-token caller can't be trusted
484
+ // to have supplied a well-formed id anyway.
485
+ export function unauthorizedMcp(req: Request): Response {
486
+ const response = Response.json(
487
+ { jsonrpc: "2.0", id: null, error: { code: -32001, message: "unauthorized: session invalid or expired" } },
488
+ { status: 401 },
489
+ );
490
+ response.headers.set("WWW-Authenticate", "Bearer");
491
+ return applyCors(req, response);
492
+ }
493
+
479
494
  function authToken(): string {
480
495
  return relayToken();
481
496
  }
package/src/server.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  isScopedRequestAuthorized,
40
40
  isOriginAllowed,
41
41
  unauthorized,
42
+ unauthorizedMcp,
42
43
  } from "./security";
43
44
  import { startMaintenanceScheduler } from "./maintenance";
44
45
  import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
@@ -304,6 +305,11 @@ export function createFetchHandler(
304
305
  url.pathname.startsWith("/api/settings/");
305
306
  if (!isAuthorized(req)) {
306
307
  if (!integrationAuth) {
308
+ // #1047 — the MCP endpoint must fail with a JSON-RPC envelope, not the bare
309
+ // 401 body. This gate runs BEFORE postMcp (whose mcpAuthContext falls back to
310
+ // full server scope), so it stays the authoritative reject for a revoked/
311
+ // invalid session token — it just answers in the caller's protocol.
312
+ if (url.pathname === "/api/mcp") return unauthorizedMcp(req);
307
313
  return unauthorized(req);
308
314
  }
309
315
  if (!handlerOwnsScopedAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
@@ -144,6 +144,22 @@ export function withOwnerOnline<T extends { ownerAgentId?: string }>(workspace:
144
144
  return { ...workspace, ownerOnline: isOwnerAlive(workspace.ownerAgentId) };
145
145
  }
146
146
 
147
+ // #1049 — process-level death, distinct from "offline". `offline` is set on a mere
148
+ // heartbeat lapse (exactly what a relay self-stall fakes for a batch of LIVE agents,
149
+ // the 2026-07-04 cascade). A genuine process exit is only known when the owning
150
+ // orchestrator REPORTS it: it stamps `meta.lastExit` (routes/orchestrator.ts). So
151
+ // require both offline AND a reported exit before treating the owner as gone — never
152
+ // nuke/land a worktree on a heartbeat gap alone. Conservative by design: an unmanaged
153
+ // agent with no orchestrator never gets `lastExit`, so it reads as maybe-alive and the
154
+ // orphan reaper's land-safety + grace window handle its eventual cleanup instead.
155
+ export function isOwnerProcessConfirmedGone(ownerAgentId: string | undefined): boolean {
156
+ if (!ownerAgentId) return false;
157
+ const agent = getAgent(ownerAgentId);
158
+ if (!agent || agent.status !== "offline") return false;
159
+ const meta = isRecord(agent.meta) ? agent.meta : undefined;
160
+ return Boolean(meta && meta.lastExit);
161
+ }
162
+
147
163
  function workspaceOwnerHostKeys(workspace: WorkspaceRecord): Set<string> {
148
164
  const keys = new Set<string>();
149
165
  const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : undefined;