agent-relay-server 0.118.2 → 0.118.4

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.2",
5
+ "version": "0.118.4",
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.2",
3
+ "version": "0.118.4",
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.2",
39
+ "agent-relay-channels-host": "0.118.4",
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.2",
4
+ "version": "0.118.4",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -35,10 +35,10 @@ import { renderTemplate } from "./prompt-resolver";
35
35
  import { isAwaitingReviewerApproval } from "./reviewer-pipeline";
36
36
  import { emitWorkspaceTaskSemanticNotifications } from "./services/task-lifecycle";
37
37
  import { ensureRepoSteward } from "./steward";
38
- import { requestWorkspaceMerge } from "./workspace-merge";
38
+ import { requestWorkspaceMerge, workspaceMergeOrchestrators } from "./workspace-merge";
39
39
  import { workspaceActiveClaim } from "./workspace-claim";
40
40
  import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES } from "./workspace-phase";
41
- import { fetchHostMergePreview, workspacePathWithinBase } from "./workspace-probe";
41
+ import { fetchHostMergePreview } from "./workspace-probe";
42
42
  import type { WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMergePreview, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
43
43
  import { STEWARD_WAKE_COOLDOWN_MS, stewardFallbackTarget, workspaceAutoMergeEnabled } from "./config";
44
44
 
@@ -252,13 +252,29 @@ interface LandSweepResult {
252
252
  heldByClaim: string[];
253
253
  heldByPause: string[];
254
254
  heldAwaitingApproval: string[];
255
+ previewUnavailable: string[];
255
256
  backedOff: string[];
256
257
  leftForSteward: string[];
257
258
  wokeStewards: string[];
258
259
  }
259
260
 
260
261
  function emptyLandSweep(): LandSweepResult {
261
- return { scanned: 0, merged: [], heldByLease: [], heldByClaim: [], heldByPause: [], heldAwaitingApproval: [], backedOff: [], leftForSteward: [], wokeStewards: [] };
262
+ return { scanned: 0, merged: [], heldByLease: [], heldByClaim: [], heldByPause: [], heldAwaitingApproval: [], previewUnavailable: [], backedOff: [], leftForSteward: [], wokeStewards: [] };
263
+ }
264
+
265
+ async function fetchFirstUsableMergePreview(
266
+ ws: WorkspaceRecord,
267
+ orchestrators: ReturnType<typeof listOrchestrators>,
268
+ ): Promise<WorkspaceMergePreview | { available: false } | null> {
269
+ for (const orch of workspaceMergeOrchestrators(ws, orchestrators)) {
270
+ if (!orch.apiUrl) continue;
271
+ const preview = await fetchHostMergePreview(orch.apiUrl, ws);
272
+ if (!preview || (preview as { available?: false }).available === false) continue;
273
+ const p = preview as WorkspaceMergePreview;
274
+ if (p.error || p.missing) continue;
275
+ return p;
276
+ }
277
+ return null;
262
278
  }
263
279
 
264
280
  // Deterministic auto-land (Layer 0, #167/#207/#242): land conflict-free ready work
@@ -269,7 +285,7 @@ async function landCandidates(
269
285
  orchestrators: ReturnType<typeof listOrchestrators>,
270
286
  ): Promise<LandSweepResult> {
271
287
  const stewardEnabled = getStewardConfig().enabled;
272
- const merged: string[] = [];
288
+ const merged: string[] = [], previewUnavailable: string[] = [];
273
289
  const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], backedOff: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
274
290
 
275
291
  for (const ws of candidates) {
@@ -286,12 +302,12 @@ async function landCandidates(
286
302
  escalateNoProgressMerge(ws);
287
303
  continue;
288
304
  }
289
- const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
290
- if (!orch?.apiUrl) continue;
291
- const preview = await fetchHostMergePreview(orch.apiUrl, ws);
292
- if (!preview || (preview as { available?: false }).available === false) continue;
305
+ const preview = await fetchFirstUsableMergePreview(ws, orchestrators);
306
+ if (!preview) {
307
+ previewUnavailable.push(ws.id);
308
+ continue;
309
+ }
293
310
  const p = preview as WorkspaceMergePreview;
294
- if (p.error || p.missing) continue;
295
311
  if (p.reason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
296
312
  patchWorkspaceMetadata(ws.id, { lastLandSkipReason: p.reason, lastLandSkipAt: Date.now() });
297
313
  } else if (ws.metadata.lastLandSkipReason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
@@ -354,7 +370,7 @@ async function landCandidates(
354
370
  });
355
371
  }
356
372
 
357
- return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, backedOff, leftForSteward, wokeStewards };
373
+ return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, previewUnavailable, backedOff, leftForSteward, wokeStewards };
358
374
  }
359
375
 
360
376
  // Coalesced, fire-and-forget per-repo land trigger (see module header). A run already
@@ -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" };