agent-relay-server 0.118.3 → 0.118.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.118.3",
5
+ "version": "0.118.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.118.3",
3
+ "version": "0.118.5",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "CONTRIBUTING.md"
37
37
  ],
38
38
  "dependencies": {
39
- "agent-relay-channels-host": "0.118.3",
39
+ "agent-relay-channels-host": "0.118.5",
40
40
  "agent-relay-providers": "0.104.1",
41
41
  "agent-relay-sdk": "0.2.101",
42
42
  "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.118.3",
4
+ "version": "0.118.5",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -255,6 +255,7 @@ const SPAWN_LIFECYCLE_AUDIENCE_TYPES = [
255
255
  "agent.exited",
256
256
  "agent.spawn_failed",
257
257
  "agent.spawn_recovered",
258
+ "agent.transient_empty_reap",
258
259
  ] as const satisfies readonly NotificationType[];
259
260
 
260
261
  for (const type of SPAWN_LIFECYCLE_AUDIENCE_TYPES) {
@@ -129,7 +129,7 @@ export function notificationTypeEnabled(config: NotificationsConfig, type: Notif
129
129
  function notificationToggleForType(type: NotificationType): NotificationToggleKey | null {
130
130
  if (type === "branch.landed" || type === "landed-failure") return "branchLanded";
131
131
  if (type === "agent.ready") return "agentReady";
132
- if (type === "agent.exited") return "agentExited";
132
+ if (type === "agent.exited" || type === "agent.transient_empty_reap") return "agentExited";
133
133
  if (type === "agent.spawn_failed") return "agentSpawnFailed";
134
134
  return null;
135
135
  }
@@ -22,6 +22,7 @@ export type NotificationType =
22
22
  | "agent.exited"
23
23
  | "agent.spawn_failed"
24
24
  | "agent.spawn_recovered"
25
+ | "agent.transient_empty_reap"
25
26
  | "agent.context_threshold"
26
27
  | "agent.stuck_busy"
27
28
  | "agent.quota_threshold"
@@ -120,6 +121,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
120
121
  "agent.exited": null,
121
122
  "agent.spawn_failed": null,
122
123
  "agent.spawn_recovered": null,
124
+ "agent.transient_empty_reap": null,
123
125
  "agent.context_threshold": 15 * MINUTES,
124
126
  "agent.stuck_busy": null,
125
127
  "agent.quota_threshold": 15 * MINUTES,
@@ -9,6 +9,7 @@ import {
9
9
  createDeliverableTask,
10
10
  deliverableTaskIdsByOwnerAgent,
11
11
  deliverableTaskIdsBySpawnRequestId,
12
+ getAgent,
12
13
  getTaskDetail,
13
14
  listDeliverableTaskSummaries,
14
15
  listDeliverableTasks,
@@ -34,6 +35,8 @@ import {
34
35
  import { emitTaskChanged } from "../sse";
35
36
  import type { Task, TaskDetail, TaskEvent, TaskGateState, TaskListItem } from "../types";
36
37
  import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
38
+ import { emitRelayEvent } from "../events";
39
+ import { routeNotification } from "../notification-router";
37
40
  import { isAgentExitDoneWhen, stageSatisfiesDoneWhen } from "./task-done-when";
38
41
  import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
39
42
 
@@ -187,11 +190,11 @@ export function taskEntityHistory(id: number): TaskEvent[] {
187
190
  return listTaskEvents(id);
188
191
  }
189
192
 
190
- // #889 — finalize bound tasks for an reaped agent whose doneWhen is "agent-exit" / "reaped".
193
+ // #889 — finalize bound tasks for a reaped agent whose doneWhen is "agent-exit" / "reaped".
191
194
  // Called by the transient reaper after requesting shutdown so ephemeral run-and-exit workers
192
195
  // don't leave their auto-scaffolded Task stuck in_progress forever.
193
196
  // Never overrides an already-terminal status. Idempotent (reapedAt guard inside markTaskAgentReaped).
194
- export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnRequestId?: string; reason?: string } = {}): number[] {
197
+ export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnRequestId?: string; reason?: string; producedWork?: boolean; evidence?: string } = {}): number[] {
195
198
  const ids = new Set<number>();
196
199
  for (const id of deliverableTaskIdsByOwnerAgent(agentId)) ids.add(id);
197
200
  if (opts.spawnRequestId) {
@@ -203,6 +206,15 @@ export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnReque
203
206
  if (!task) continue;
204
207
  if (task.status === "done" || task.status === "failed" || task.status === "canceled") continue;
205
208
  if (!isAgentExitDoneWhen(task.doneWhen)) continue;
209
+ if (opts.producedWork === false) {
210
+ try {
211
+ setTaskEntityStatus(taskId, { status: "open", note: "worker reaped without producing work; task reopened for retry", actor: "task-lifecycle" });
212
+ notifyEmptyTransientReap(agentId, task, opts);
213
+ } catch {
214
+ // best-effort; failures must not surface into the reaper scan
215
+ }
216
+ continue;
217
+ }
206
218
  try {
207
219
  if (!task.reapedAt) markTaskAgentReaped(taskId, { agentId, reason: opts.reason ?? "agent-exit" });
208
220
  setTaskEntityStatus(taskId, { status: "done", note: "doneWhen=agent-exit satisfied by worker reap", actor: "task-lifecycle" });
@@ -213,3 +225,32 @@ export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnReque
213
225
  }
214
226
  return finalized;
215
227
  }
228
+
229
+ function notifyEmptyTransientReap(agentId: string, task: TaskDetail, opts: { spawnRequestId?: string; reason?: string; evidence?: string }): void {
230
+ const agent = getAgent(agentId);
231
+ const parent = agent?.spawnedBy;
232
+ const recipients = [...new Set([parent, task.createdBy].filter((value): value is string => Boolean(value && value.trim())))];
233
+ const payload = {
234
+ kind: "agent.transient_empty_reap",
235
+ agentId,
236
+ parent,
237
+ taskId: task.id,
238
+ taskTitle: task.title,
239
+ spawnRequestId: opts.spawnRequestId,
240
+ reason: opts.reason ?? "agent-exit",
241
+ evidence: opts.evidence ?? "no-produced-work",
242
+ };
243
+ emitRelayEvent({ type: "agent.transient_empty_reap", source: "task-lifecycle", subject: agentId, data: payload });
244
+ routeNotification({
245
+ type: "agent.transient_empty_reap",
246
+ scope: { agentId, parent, taskId: String(task.id) },
247
+ recipients,
248
+ message: {
249
+ subject: "Worker reaped without producing work",
250
+ body: `Worker \`${agentId}\` was reaped without producing work for Task #${task.id} (${task.title}). The task was reopened for retry instead of being marked done.`,
251
+ payload,
252
+ idempotencyKey: `task:${task.id}:empty-reap:${agentId}:${opts.reason ?? "agent-exit"}`,
253
+ replyExpected: false,
254
+ },
255
+ });
256
+ }
@@ -105,24 +105,39 @@ export function resetCommitsAheadForTests(): void {
105
105
  _commitsAheadFn = defaultCommitsAhead;
106
106
  }
107
107
 
108
- function transientLandComplete(agentId: string): { complete: boolean; workspaceId?: string; status?: string; reason?: string } {
108
+ function completedProviderTurns(agent: AgentCard): number {
109
+ const value = agent.meta?.completedProviderTurns;
110
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
111
+ }
112
+
113
+ function workspaceProducedWork(ws: WorkspaceRecord | undefined): boolean {
114
+ if (!ws) return false;
115
+ if (ws.status === "merged") return true;
116
+ return typeof ws.metadata.mergedAt === "number";
117
+ }
118
+
119
+ function transientLandComplete(agent: AgentCard): { complete: boolean; producedWork: boolean; workspaceId?: string; status?: string; reason?: string } {
120
+ const agentId = agent.id;
109
121
  const isolated = listWorkspaces({ ownerAgentId: agentId }).filter((ws) => ws.mode === "isolated");
110
- if (!isolated.length) return { complete: true, reason: "no-isolated-workspace" };
122
+ if (!isolated.length) return { complete: true, producedWork: completedProviderTurns(agent) > 0, reason: "no-isolated-workspace" };
111
123
  const live = isolated.find((ws) => !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
112
- if (!live) return { complete: true, workspaceId: isolated[0]?.id, status: isolated[0]?.status, reason: "terminal-workspace" };
124
+ if (!live) {
125
+ const latest = isolated[0];
126
+ return { complete: true, producedWork: workspaceProducedWork(latest), workspaceId: latest?.id, status: latest?.status, reason: "terminal-workspace" };
127
+ }
113
128
  const mergedAt = typeof live.metadata.mergedAt === "number" ? live.metadata.mergedAt : undefined;
114
129
  const landReportedAt = typeof live.metadata.landReportedAt === "number" ? live.metadata.landReportedAt : undefined;
115
130
  if (live.status === "active" && mergedAt !== undefined && mergedAt > (landReportedAt ?? 0)) {
116
- return { complete: true, workspaceId: live.id, status: live.status, reason: "recycled-after-land" };
131
+ return { complete: true, producedWork: true, workspaceId: live.id, status: live.status, reason: "recycled-after-land" };
117
132
  }
118
133
  // Fast-path (#629): a zero-commit branch has nothing to preserve — reap immediately
119
134
  // instead of holding it pending a land that will never happen. Only fires when the
120
135
  // branch is provably empty (count === 0); null (unknown/git error) falls through to
121
136
  // the safe #614 hold-pending bias below.
122
137
  if (live.status === "active" && _commitsAheadFn(live) === 0) {
123
- return { complete: true, workspaceId: live.id, status: live.status, reason: "empty-branch" };
138
+ return { complete: true, producedWork: false, workspaceId: live.id, status: live.status, reason: "empty-branch" };
124
139
  }
125
- return { complete: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
140
+ return { complete: false, producedWork: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
126
141
  }
127
142
 
128
143
  function notifyTransientHeld(agentId: string, workspaceId: string | undefined, status: string | undefined, waitMs: number): void {
@@ -171,11 +186,14 @@ export function reapTransientAgents(): Record<string, unknown> {
171
186
  // #800 — `reapProtected` is the single home for that decision: it honors only
172
187
  // evidence-based block states, never a high quota-utilization number.
173
188
  if (reapProtected(agent)) { transientTracker.delete(agent.id); continue; }
189
+ // A just-registered transient worker can briefly report idle/ready before its first provider
190
+ // turn starts. Do not start the idle-reap clock until the runner has observed a completed turn.
191
+ if (completedProviderTurns(agent) === 0) { transientTracker.delete(agent.id); waiting.push(agent.id); continue; }
174
192
  const entry = transientTracker.get(agent.id) ?? { firstIdleAt: now };
175
193
  transientTracker.set(agent.id, entry);
176
194
  const idleForMs = now - entry.firstIdleAt;
177
195
  if (idleForMs < grace) { waiting.push(agent.id); continue; }
178
- const land = transientLandComplete(agent.id);
196
+ const land = transientLandComplete(agent);
179
197
  if (!land.complete) {
180
198
  if (idleForMs >= grace + maxLandWait) {
181
199
  held.push(agent.id);
@@ -201,7 +219,7 @@ export function reapTransientAgents(): Record<string, unknown> {
201
219
  // #889 — finalize any bound task whose doneWhen is "agent-exit" (auto-scaffolded ephemeral
202
220
  // tasks that produce no deliverable and would otherwise hang in_progress forever).
203
221
  const spawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
204
- finalizeEphemeralTasksOnReap(agent.id, { spawnRequestId, reason: "transient-complete" });
222
+ finalizeEphemeralTasksOnReap(agent.id, { spawnRequestId, reason: "transient-complete", producedWork: land.producedWork, evidence: land.reason });
205
223
  reaped.push(agent.id);
206
224
  }
207
225
  for (const key of transientTracker.keys()) if (!seen.has(key)) transientTracker.delete(key);
@@ -21,7 +21,6 @@
21
21
  import {
22
22
  createActivityEvent,
23
23
  getActiveMergePause,
24
- getAgent,
25
24
  listOrchestrators,
26
25
  listWorkspaces,
27
26
  patchWorkspaceMetadata,
@@ -36,10 +35,10 @@ import { renderTemplate } from "./prompt-resolver";
36
35
  import { isAwaitingReviewerApproval } from "./reviewer-pipeline";
37
36
  import { emitWorkspaceTaskSemanticNotifications } from "./services/task-lifecycle";
38
37
  import { ensureRepoSteward } from "./steward";
39
- import { requestWorkspaceMerge } from "./workspace-merge";
38
+ import { requestWorkspaceMerge, workspaceMergeOrchestrators } from "./workspace-merge";
40
39
  import { workspaceActiveClaim } from "./workspace-claim";
41
40
  import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES } from "./workspace-phase";
42
- import { fetchHostMergePreview, workspacePathWithinBase } from "./workspace-probe";
41
+ import { fetchHostMergePreview } from "./workspace-probe";
43
42
  import type { WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMergePreview, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
44
43
  import { STEWARD_WAKE_COOLDOWN_MS, stewardFallbackTarget, workspaceAutoMergeEnabled } from "./config";
45
44
 
@@ -263,25 +262,11 @@ function emptyLandSweep(): LandSweepResult {
263
262
  return { scanned: 0, merged: [], heldByLease: [], heldByClaim: [], heldByPause: [], heldAwaitingApproval: [], previewUnavailable: [], backedOff: [], leftForSteward: [], wokeStewards: [] };
264
263
  }
265
264
 
266
- function workspaceOrchestrators(
267
- ws: WorkspaceRecord,
268
- orchestrators: ReturnType<typeof listOrchestrators>,
269
- ): ReturnType<typeof listOrchestrators> {
270
- const candidates = orchestrators.filter((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
271
- const ownerMachine = ws.ownerAgentId ? getAgent(ws.ownerAgentId)?.machine : undefined;
272
- if (!ownerMachine) return candidates;
273
- return [...candidates].sort((a, b) => {
274
- const aOwner = a.hostname === ownerMachine || a.id === ownerMachine ? 0 : 1;
275
- const bOwner = b.hostname === ownerMachine || b.id === ownerMachine ? 0 : 1;
276
- return aOwner - bOwner;
277
- });
278
- }
279
-
280
265
  async function fetchFirstUsableMergePreview(
281
266
  ws: WorkspaceRecord,
282
267
  orchestrators: ReturnType<typeof listOrchestrators>,
283
268
  ): Promise<WorkspaceMergePreview | { available: false } | null> {
284
- for (const orch of workspaceOrchestrators(ws, orchestrators)) {
269
+ for (const orch of workspaceMergeOrchestrators(ws, orchestrators)) {
285
270
  if (!orch.apiUrl) continue;
286
271
  const preview = await fetchHostMergePreview(orch.apiUrl, ws);
287
272
  if (!preview || (preview as { available?: false }).available === false) continue;
@@ -14,6 +14,7 @@ import {
14
14
  import { getLandingConfig } from "./config-store";
15
15
  import type {
16
16
  Command,
17
+ Orchestrator,
17
18
  WorkspaceAutoMergePolicy,
18
19
  WorkspaceLandingPolicy,
19
20
  WorkspaceMergeStrategy,
@@ -142,6 +143,39 @@ export function withOwnerOnline<T extends { ownerAgentId?: string }>(workspace:
142
143
  return { ...workspace, ownerOnline: isOwnerAlive(workspace.ownerAgentId) };
143
144
  }
144
145
 
146
+ function workspaceOwnerHostKeys(workspace: WorkspaceRecord): Set<string> {
147
+ const keys = new Set<string>();
148
+ const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : undefined;
149
+ if (!owner) return keys;
150
+ if (owner.machine) keys.add(owner.machine);
151
+ const meta = isRecord(owner.meta) ? owner.meta : undefined;
152
+ const orchestratorId = meta ? stringValue(meta.orchestratorId) : undefined;
153
+ if (orchestratorId) keys.add(orchestratorId);
154
+ if (orchestratorId) keys.add(`orchestrator-${orchestratorId}`);
155
+ return keys;
156
+ }
157
+
158
+ /**
159
+ * Online hosts that can operate on a workspace, ordered by ownership first.
160
+ * The fallback to any matching host is intentional for manual recovery, but the
161
+ * owner host must win when known: isolated worktree paths are host-local.
162
+ */
163
+ export function workspaceMergeOrchestrators(
164
+ workspace: WorkspaceRecord,
165
+ orchestrators: Orchestrator[] = listOrchestrators(),
166
+ ): Orchestrator[] {
167
+ const candidates = orchestrators.filter((candidate) =>
168
+ candidate.status === "online" && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
169
+ );
170
+ const ownerKeys = workspaceOwnerHostKeys(workspace);
171
+ if (ownerKeys.size === 0) return candidates;
172
+ return [...candidates].sort((a, b) => {
173
+ const aOwner = ownerKeys.has(a.id) || ownerKeys.has(a.hostname) || ownerKeys.has(a.agentId) ? 0 : 1;
174
+ const bOwner = ownerKeys.has(b.id) || ownerKeys.has(b.hostname) || ownerKeys.has(b.agentId) ? 0 : 1;
175
+ return aOwner - bOwner;
176
+ });
177
+ }
178
+
145
179
  /**
146
180
  * Resolved cwds of every live (non-offline) agent, including the workspace sourceCwd
147
181
  * they were spawned in. The third "nobody is using it" signal the reaper needs: a
@@ -206,9 +240,7 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
206
240
  }
207
241
  try {
208
242
  // Merge needs a live host: rebasing against a stale base later is unsafe.
209
- const onlineOwner = listOrchestrators().find(
210
- (candidate) => candidate.status === "online" && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
211
- );
243
+ const onlineOwner = workspaceMergeOrchestrators(workspace)[0];
212
244
  if (!onlineOwner) {
213
245
  releaseMergeLease({ repoRoot: workspace.repoRoot, workspaceId: workspace.id });
214
246
  return { ok: false, status: 409, error: "no online orchestrator available for workspace merge" };