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 +1 -1
- package/package.json +2 -2
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/bus.ts +6 -2
- package/src/db/agents.ts +6 -2
- package/src/event-loop-lag.ts +10 -1
- package/src/maintenance/jobs.ts +8 -2
- package/src/routes/workspaces.ts +25 -7
- package/src/security.ts +15 -0
- package/src/server.ts +6 -0
- package/src/workspace-merge.ts +16 -0
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.
|
|
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.
|
|
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.
|
|
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"
|
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
|
-
|
|
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
|
-
|
|
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(
|
package/src/event-loop-lag.ts
CHANGED
|
@@ -43,9 +43,18 @@ export function recordEventLoopLagSample(lagMs: number, sampledAt: number = Date
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
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 {
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -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
|
-
|
|
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({
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -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)
|
|
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 (
|
|
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 &&
|
|
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);
|
package/src/workspace-merge.ts
CHANGED
|
@@ -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;
|