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 +1 -1
- package/package.json +2 -2
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/bus.ts +17 -5
- package/src/db/agents.ts +6 -2
- package/src/db/messages.ts +37 -1
- 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.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.
|
|
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.
|
|
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"
|
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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/db/messages.ts
CHANGED
|
@@ -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
|
-
|
|
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"
|
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;
|