agent-relay-server 0.121.1 → 0.121.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.121.1",
5
+ "version": "0.121.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.121.1",
3
+ "version": "0.121.3",
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.3",
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.3",
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) {
@@ -170,7 +174,12 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
170
174
  // frame. Flush the held report-up on THIS edge too, else `before.status` is already
171
175
  // non-busy at the next status frame → the flush never fires → the child deadlocks idle
172
176
  // with its verdict held and the coordinator waits forever.
173
- if (current?.status === "busy" && frame.payload.status !== "busy") flushIdleLineageReport(conn.agentId);
177
+ // #1037 (re-regression) gate on a genuine at-rest `idle`, NOT merely "not busy". A
178
+ // real turn-end always reports `idle`; a transient non-idle status (a stray `online`,
179
+ // a malformed frame) is NOT the end of the child's work and must not flush its held
180
+ // mid-work capture upstream. Terminal exit edges (WS-close/stale sweep) keep their
181
+ // unconditional flush for the #1040 delivery guarantee — this only tightens the LIVE edge.
182
+ if (current?.status === "busy" && frame.payload.status === "idle") flushIdleLineageReport(conn.agentId);
174
183
  }
175
184
  emitAgentStatusEvent(conn.agentId);
176
185
  // #416 — flush queued messages if the agent transitioned from unavailable to available.
@@ -216,8 +225,11 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
216
225
  // #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
217
226
  probeWorkspaceOnTurnEnd(before, after);
218
227
  // #1037 — the same busy→idle turn-end edge flushes a quiet child's deferred turn-final
219
- // report-up to its spawner (held back while the child was mid-work).
220
- if (before?.status === "busy" && after && after.status !== "busy") flushIdleLineageReport(conn.agentId);
228
+ // report-up to its spawner (held back while the child was mid-work). Gate on a genuine
229
+ // at-rest `idle`: a real turn-end reports `idle`, whereas a transient non-idle status
230
+ // (stray `online`, reconnect) is not the child going to rest and must not flush its held
231
+ // mid-work capture. Terminal exit edges keep the #1040 unconditional flush.
232
+ if (before?.status === "busy" && after && after.status === "idle") flushIdleLineageReport(conn.agentId);
221
233
  // #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
222
234
  // marks the offline edge with `terminalProviderExit`; the owning service turns that into a
223
235
  // terminal event + #636 diagnosis (and clears the hold on a genuine resume).
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(
@@ -347,6 +347,40 @@ function childIsBusy(child: AgentCard): boolean {
347
347
  return child.status === "busy";
348
348
  }
349
349
 
350
+ // #1037 (re-regression) — the report-up must reach the spawner as a CONCISE result, not the full
351
+ // turn transcript. In `chatCaptureMode: "full"` (and for rambling multi-block recaps) the captured
352
+ // turn-final body is the ENTIRE turn's narration — every "Let me read X… now Y… now Z…" segment
353
+ // joined by the canonical `\n\n` block boundary (runner extractLastAssistantTurn). Forwarding it
354
+ // verbatim floods the coordinator with a wall of mid-work text (the exact bug #1037 keeps
355
+ // re-shipping). Idle-gating alone can't help: this is a genuine turn-final at a genuine idle, so
356
+ // the payload itself must be distilled. Short bodies pass through untouched (concise finals are
357
+ // unaffected); a long body is trimmed to its trailing result block(s) — a turn's conclusion lives
358
+ // at its end — with an elision marker so the coordinator knows narration was dropped. The actual
359
+ // result is never lost. Pure function of the body → deterministic, so re-promotion (the idle flush)
360
+ // yields the identical distilled body + idempotency key and dedups cleanly.
361
+ const REPORT_UP_DISTILL_THRESHOLD = 800;
362
+ const REPORT_UP_TAIL_MAX = 800;
363
+ const REPORT_UP_ELISION = "[…mid-work narration trimmed by report-up…]";
364
+
365
+ function distillLineageReportBody(body: string): string {
366
+ const trimmed = body.trim();
367
+ if (trimmed.length <= REPORT_UP_DISTILL_THRESHOLD) return body;
368
+ const blocks = trimmed.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
369
+ const kept: string[] = [];
370
+ let size = 0;
371
+ for (let i = blocks.length - 1; i >= 0; i--) {
372
+ const block = blocks[i]!;
373
+ if (kept.length > 0 && size + block.length > REPORT_UP_TAIL_MAX) break;
374
+ kept.unshift(block);
375
+ size += block.length + 2;
376
+ }
377
+ let tail = kept.join("\n\n");
378
+ // A single oversized trailing block (unsegmented narration) — hard-cap to its own tail.
379
+ if (tail.length > REPORT_UP_TAIL_MAX) tail = tail.slice(tail.length - REPORT_UP_TAIL_MAX).replace(/^\S*\s/, "").trimStart();
380
+ if (tail.length >= trimmed.length) return body; // nothing meaningful trimmed
381
+ return `${REPORT_UP_ELISION}\n\n${tail}`;
382
+ }
383
+
350
384
  // #1037 — a quiet child's turn-final response that arrives while the child is still busy is a
351
385
  // mid-task yield, not the end of its work. Instead of shipping it upstream (mid-work narration) or
352
386
  // dropping it (a genuine last turn whose idle status simply hasn't landed yet), we HOLD the latest
@@ -493,7 +527,9 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
493
527
  from: message.from,
494
528
  to: parent,
495
529
  kind: "chat",
496
- body: message.body,
530
+ // #1037 — distill the turn-final report-up to a concise result; verbose child-stream captures
531
+ // (an explicit subscriber that asked for the raw stream) are forwarded untouched.
532
+ body: stream === "responses" ? distillLineageReportBody(message.body) : message.body,
497
533
  replyExpected: false,
498
534
  idempotencyKey,
499
535
  payload: stream === "responses"
@@ -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;