agent-relay-server 0.121.4 → 0.121.5

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.4",
5
+ "version": "0.121.5",
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.4",
3
+ "version": "0.121.5",
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.4",
40
+ "agent-relay-channels-host": "0.121.5",
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.4",
4
+ "version": "0.121.5",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/bus.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
- import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus, validateAgentSession } from "./db";
2
+ import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, setStatus, validateAgentSession } from "./db";
3
3
  import { getOutboxCursor, hasReplayGap, replayEventsSlice, type BusEvent } from "./bus-outbox";
4
4
  import { projectAgentStatusData } from "./agent-card-projection";
5
5
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
@@ -127,9 +127,11 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs(), lagGraceMs = 0
127
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 }>;
128
128
  const orphanedTasks: Task[] = [];
129
129
  for (const row of rows) {
130
- // Bus-grace staleness across a relay restart/reconnect the process may be alive.
131
- // Tag "stale" so a live runner self-heals via orchestrator re-mint (#484).
132
- revokeRuntimeTokensForAgent(row.id, Date.now(), "stale");
130
+ // #1051 — bus-grace staleness is a liveness observation, not a credential fact: the
131
+ // process may still be alive (relay stall / network blip). Mark offline for routing,
132
+ // but do NOT revoke the token — a reconnect accepts the EXISTING token and re-marks
133
+ // live. The #484 restart-strand revoke now fires on instance takeover in registerAgent
134
+ // (a fresh instanceId → epoch bump), never on a heartbeat lapse.
133
135
  setStatus(row.id, "offline");
134
136
  emitAgentStatusEvent(row.id);
135
137
  // #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
package/src/db/agents.ts CHANGED
@@ -505,10 +505,13 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS, lagGraceMs = 0): s
505
505
  )
506
506
  .all(cutoff) as any[];
507
507
  for (const row of rows) {
508
- // Staleness == last_seen lapsed, which is exactly the relay-restart strand: the
509
- // process may still be alive. Tag "stale" so a live runner can self-heal via an
510
- // orchestrator re-mint (#484); a genuinely-dead one never re-mints and is pruned.
511
- revokeRuntimeTokensForAgent(row.id, now, "stale");
508
+ // #1051 liveness credential validity. A lapsed heartbeat is a transient,
509
+ // recoverable observation (the relay may have stalled, or the network blipped);
510
+ // it marks status only and must NOT void a still-unexpired token. On reconnect the
511
+ // relay accepts the agent's EXISTING token and re-marks it live. The #484 restart-
512
+ // strand invariant (an orphaned prior-instance token must not stay usable) is now
513
+ // enforced on instance takeover instead — see revokeSupersededRuntimeTokens, fired
514
+ // from registerAgent when a fresh process re-registers under a new instanceId.
512
515
  closeOpenPairsForAgent(row.id, now);
513
516
  }
514
517
  return rows.map((r: any) => r.id);
@@ -589,6 +592,28 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
589
592
  export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now(), reason: TokenRevokeReason = "agent-removed"): string[] {
590
593
  const row = getDb().query("SELECT meta FROM agents WHERE id = ?").get(agentId) as { meta?: string } | undefined;
591
594
  const jtis = runtimeTokenJtisFromMeta(parseJson(row?.meta ?? "{}", {}));
595
+ return revokeProviderRuntimeTokensByJti(jtis, reason, now);
596
+ }
597
+
598
+ // #1051 — the #484 restart-strand invariant, re-homed from heartbeat-lapse to instance
599
+ // takeover. Staleness (a missed heartbeat) no longer revokes anything; instead, when a
600
+ // FRESH runner process re-registers under an existing agent id — a new instanceId, which
601
+ // bumps the agent epoch (see upsertAgent) — the PRIOR instance's runtime tokens are
602
+ // revoked "stale" here. "stale" (not a hard kill) keeps them re-mintable via the
603
+ // orchestrator (#484), so a live process that merely rotated instances still self-heals,
604
+ // while an orphaned prior-instance token can no longer authenticate a request directly.
605
+ // The incoming instance's own token (already stamped into the NEW meta) is preserved: a
606
+ // same-instance reconnect (relay-restart / lag recovery) keeps its instanceId, does not
607
+ // bump the epoch, and so never reaches this path — its token stays valid untouched.
608
+ export function revokeSupersededRuntimeTokens(prior: AgentCard, current: AgentCard, now = Date.now()): string[] {
609
+ const priorJtis = runtimeTokenJtisFromMeta((prior.meta ?? {}) as Record<string, unknown>);
610
+ if (priorJtis.length === 0) return [];
611
+ const keep = new Set(runtimeTokenJtisFromMeta((current.meta ?? {}) as Record<string, unknown>));
612
+ const superseded = priorJtis.filter((jti) => !keep.has(jti));
613
+ return revokeProviderRuntimeTokensByJti(superseded, "stale", now);
614
+ }
615
+
616
+ function revokeProviderRuntimeTokensByJti(jtis: string[], reason: TokenRevokeReason, now: number): string[] {
592
617
  if (jtis.length === 0) return [];
593
618
  const revokedAt = Math.floor(now / 1000);
594
619
  const placeholders = jtis.map(() => "?").join(",");
@@ -1,6 +1,8 @@
1
1
  import { getManifest } from "agent-relay-providers";
2
+ import { DEFAULT_USER_ID } from "agent-relay-sdk";
2
3
  import { getAgentProfile } from "./config-store";
3
- import { withResolvedProvisioning } from "./db";
4
+ import { resolveProjectForCwd, resolveScopedAnchor, withResolvedProvisioning } from "./db";
5
+ import { composeSpawnInstructionsWithEvents } from "./project-instructions";
4
6
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
5
7
  import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
6
8
  import { applyDefaultSharedMcp } from "./shared-mcp";
@@ -10,11 +12,19 @@ function managedPolicyPromptMode(policy: SpawnPolicy): "prompt" | "append-system
10
12
  return getManifest(policy.provider)?.launch?.managedPolicyPrompt?.alwaysOn ?? "prompt";
11
13
  }
12
14
 
13
- export function managedPolicyProviderArgs(policy: SpawnPolicy): string[] {
15
+ /**
16
+ * The always-on keepalive prompt an `append-system-prompt`-mode provider (Claude) carries as a
17
+ * system-prompt append (Codex uses the launch `prompt` channel instead — see
18
+ * `managedPolicyLaunchPrompt`). Returns undefined when it does not apply. #1052 folds this into
19
+ * `composeSpawnInstructionsWithEvents` as the caller append so there is a SINGLE ordered
20
+ * `--append-system-prompt` block (profile role charter + project knowledge + this keepalive),
21
+ * instead of a second stray `--append-system-prompt` arg that could stack on the composed one.
22
+ */
23
+ export function managedPolicyAppendSystemPrompt(policy: SpawnPolicy): string | undefined {
14
24
  if (policy.mode === "always-on" && policy.prompt?.trim() && managedPolicyPromptMode(policy) === "append-system-prompt") {
15
- return [...policy.providerArgs, "--append-system-prompt", policy.prompt.trim()];
25
+ return policy.prompt.trim();
16
26
  }
17
- return policy.providerArgs;
27
+ return undefined;
18
28
  }
19
29
 
20
30
  export function managedPolicyLaunchPrompt(policy: SpawnPolicy): string | undefined {
@@ -33,10 +43,35 @@ interface ManagedSpawnContext {
33
43
  }
34
44
 
35
45
  export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string, ctx: ManagedSpawnContext): Record<string, unknown> {
36
- const providerArgs = managedPolicyProviderArgs(policy);
37
46
  const prompt = managedPolicyLaunchPrompt(policy);
38
47
  const resolvedProfile = withResolvedProvisioning(policy.profile ? getAgentProfile(policy.profile)?.value : undefined, policy.provider);
39
48
  const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
49
+
50
+ // #1052 — run the SAME instruction projection a fresh interactive spawn gets
51
+ // (composeSpawnInstructionsWithEvents, the single home in spawn-agent.ts), so a MANAGED agent
52
+ // (e.g. the always-on coordinator) is onboarded IDENTICALLY on its first launch AND on every
53
+ // restart / deploy-bounce relaunch — this builder feeds both `spawnAgent` and `restartAgent`.
54
+ // Previously the managed path forwarded only `agentProfile` (for provisioning) + providerArgs
55
+ // and silently dropped the profile ROLE CHARTER (`instructions.system`/`append`), project-
56
+ // knowledge preamble, and scoped {profile|user} memory, so a restarted coordinator reverted to
57
+ // provider-default IC behavior until a human re-injected the role file (#1052). The always-on
58
+ // keepalive prompt folds in as the caller append → one ordered `--append-system-prompt` block,
59
+ // no double-inject. The projection is deterministic (sorted + deduped), so re-running it on a
60
+ // native-resume relaunch reproduces a byte-identical block rather than stacking a second copy.
61
+ const callerProject = resolveProjectForCwd(policy.cwd) ?? undefined;
62
+ const projectInstructions = agentProfile?.projectInstructions;
63
+ const profileAnchor = policy.profile ? resolveScopedAnchor("profile", policy.profile) ?? undefined : undefined;
64
+ const userAnchor = resolveScopedAnchor("user", DEFAULT_USER_ID) ?? undefined;
65
+ const alwaysOnAppend = managedPolicyAppendSystemPrompt(policy);
66
+ const composed = composeSpawnInstructionsWithEvents({
67
+ ...(callerProject ? { callerProject } : {}),
68
+ ...(projectInstructions ? { projectInstructions } : {}),
69
+ ...(profileAnchor ? { profileAnchor } : {}),
70
+ ...(userAnchor ? { userAnchor } : {}),
71
+ ...(agentProfile ? { profile: agentProfile } : {}),
72
+ ...(alwaysOnAppend ? { callerSystemPromptAppend: alwaysOnAppend } : {}),
73
+ });
74
+
40
75
  return buildSpawnCommand({
41
76
  provider: policy.provider,
42
77
  orchestratorId: policy.orchestratorId,
@@ -51,8 +86,10 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
51
86
  capabilities: policy.capabilities,
52
87
  approvalMode: policy.permissionMode,
53
88
  permissionMode: policy.permissionMode,
54
- providerArgs,
89
+ providerArgs: policy.providerArgs,
55
90
  prompt: prompt || undefined,
91
+ ...(composed.systemPromptAppend ? { systemPromptAppend: composed.systemPromptAppend } : {}),
92
+ ...(composed.relayInjectionEvents.length ? { relayInjectionEvents: composed.relayInjectionEvents } : {}),
56
93
  headless: true,
57
94
  policyName: policy.name,
58
95
  spawnRequestId: requestId,
@@ -10,7 +10,7 @@
10
10
  // status broadcast, the single timeline note, the spawned-child parent-wake, and
11
11
  // the first-registration audit row.
12
12
  import { errMessage, isReservedAgentId } from "agent-relay-sdk";
13
- import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, mergeAgentMeta, readSpawnReportVerbosity, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
13
+ import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, mergeAgentMeta, readSpawnReportVerbosity, revokeSupersededRuntimeTokens, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
14
14
  import { emitAgentStatus, emitNewMessage } from "../sse";
15
15
  import { noteAgentTimelineEvent } from "../compaction-watch";
16
16
  import { agentProviderTimelineEvent } from "../provider-timeline-event";
@@ -93,6 +93,17 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
93
93
  if (existing) delete registrationInput.label;
94
94
  const agent = upsertAgent(registrationInput);
95
95
 
96
+ // #1051/#484 — instance takeover is where the restart-strand invariant fires now that a
97
+ // heartbeat lapse no longer revokes anything. When a FRESH runner process re-registers
98
+ // under this agent id (new instanceId → epoch bump), revoke the PRIOR instance's runtime
99
+ // tokens "stale": they can no longer authenticate a request directly, yet stay re-mintable
100
+ // via the orchestrator (#484). A same-instance reconnect keeps its instanceId, so the epoch
101
+ // is unchanged and the live token is left valid — the whole point of #1051 (in-band recovery
102
+ // after lag/relay-restart without a re-mint).
103
+ if (existing?.instanceId && agent.instanceId && existing.instanceId !== agent.instanceId) {
104
+ revokeSupersededRuntimeTokens(existing, agent);
105
+ }
106
+
96
107
  // Managed came-up-running reconcile — no-op for non-managed agents (the guard lives
97
108
  // in markManagedAgentRunning). Clears backoff, flushes the policy queue, transitions
98
109
  // managed state to running.