agent-relay-server 0.32.3 → 0.33.0

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.
Files changed (105) hide show
  1. package/package.json +2 -2
  2. package/public/assets/{activity-DT1JGHnp.js → activity-B0_uE6Yh.js} +2 -2
  3. package/public/assets/{activity-DT1JGHnp.js.map → activity-B0_uE6Yh.js.map} +1 -1
  4. package/public/assets/{agent-profiles-CrMemMkZ.js → agent-profiles-Rwxrcf9F.js} +2 -2
  5. package/public/assets/{agent-profiles-CrMemMkZ.js.map → agent-profiles-Rwxrcf9F.js.map} +1 -1
  6. package/public/assets/{agents-Bl-rrgOy.js → agents-Dp1EXJc8.js} +2 -2
  7. package/public/assets/{agents-Bl-rrgOy.js.map → agents-Dp1EXJc8.js.map} +1 -1
  8. package/public/assets/{analytics-a663ak56.js → analytics-D5OT5ajj.js} +2 -2
  9. package/public/assets/{analytics-a663ak56.js.map → analytics-D5OT5ajj.js.map} +1 -1
  10. package/public/assets/automation-Dm6rXNxK.js +2 -0
  11. package/public/assets/{automation-CiaLThdO.js.map → automation-Dm6rXNxK.js.map} +1 -1
  12. package/public/assets/{branch-state-badge-D4ur3m3_.js → branch-state-badge-FX5Yww2s.js} +2 -2
  13. package/public/assets/{branch-state-badge-D4ur3m3_.js.map → branch-state-badge-FX5Yww2s.js.map} +1 -1
  14. package/public/assets/{channels-o9KLTHoK.js → channels--rdAiX17.js} +2 -2
  15. package/public/assets/{channels-o9KLTHoK.js.map → channels--rdAiX17.js.map} +1 -1
  16. package/public/assets/chat-JZAEDGfX.js +2 -0
  17. package/public/assets/chat-JZAEDGfX.js.map +1 -0
  18. package/public/assets/{connectors-CdC806mA.js → connectors-Bx4gzvNf.js} +2 -2
  19. package/public/assets/{connectors-CdC806mA.js.map → connectors-Bx4gzvNf.js.map} +1 -1
  20. package/public/assets/display-Bebqs1qu.js +3 -0
  21. package/public/assets/display-Bebqs1qu.js.map +1 -0
  22. package/public/assets/{formatted-body-impl-Ca74OAEH.js → formatted-body-impl-CVq4qHix.js} +2 -2
  23. package/public/assets/{formatted-body-impl-Ca74OAEH.js.map → formatted-body-impl-CVq4qHix.js.map} +1 -1
  24. package/public/assets/{index-C_33ymaw.js → index-BHRtR4q7.js} +8 -8
  25. package/public/assets/{index-C_33ymaw.js.map → index-BHRtR4q7.js.map} +1 -1
  26. package/public/assets/{insights-ClI68s39.js → insights-yJFgCa3o.js} +2 -2
  27. package/public/assets/{insights-ClI68s39.js.map → insights-yJFgCa3o.js.map} +1 -1
  28. package/public/assets/{integrations-1nxMizDY.js → integrations-k1HIONjo.js} +2 -2
  29. package/public/assets/{integrations-1nxMizDY.js.map → integrations-k1HIONjo.js.map} +1 -1
  30. package/public/assets/maintenance-CsoOFBXx.js +2 -0
  31. package/public/assets/{maintenance-DiFNzNPN.js.map → maintenance-CsoOFBXx.js.map} +1 -1
  32. package/public/assets/{managed-agents-Do3dKvfj.js → managed-agents-Q3HuVjGg.js} +2 -2
  33. package/public/assets/{managed-agents-Do3dKvfj.js.map → managed-agents-Q3HuVjGg.js.map} +1 -1
  34. package/public/assets/{markdown-preview-impl-CLA0J255.js → markdown-preview-impl-CnsMjrnu.js} +2 -2
  35. package/public/assets/{markdown-preview-impl-CLA0J255.js.map → markdown-preview-impl-CnsMjrnu.js.map} +1 -1
  36. package/public/assets/{memory-IjwqFzBd.js → memory-D3-K5eJS.js} +2 -2
  37. package/public/assets/{memory-IjwqFzBd.js.map → memory-D3-K5eJS.js.map} +1 -1
  38. package/public/assets/{messages-DjvWqHyn.js → messages-B4lCP5rS.js} +2 -2
  39. package/public/assets/{messages-DjvWqHyn.js.map → messages-B4lCP5rS.js.map} +1 -1
  40. package/public/assets/{orchestrators-D2IqDxDT.js → orchestrators-CRoZtLeQ.js} +2 -2
  41. package/public/assets/{orchestrators-D2IqDxDT.js.map → orchestrators-CRoZtLeQ.js.map} +1 -1
  42. package/public/assets/{overview-DKC3TbAh.js → overview-CxCU2fOF.js} +2 -2
  43. package/public/assets/{overview-DKC3TbAh.js.map → overview-CxCU2fOF.js.map} +1 -1
  44. package/public/assets/pairs-unqjPlmq.js +2 -0
  45. package/public/assets/{pairs-WpKCPE1n.js.map → pairs-unqjPlmq.js.map} +1 -1
  46. package/public/assets/{security-BF7ZtPQe.js → security-B7HhSYNy.js} +2 -2
  47. package/public/assets/{security-BF7ZtPQe.js.map → security-B7HhSYNy.js.map} +1 -1
  48. package/public/assets/{settings-CQnjrTa-.js → settings-B9NDhsAb.js} +2 -2
  49. package/public/assets/{settings-CQnjrTa-.js.map → settings-B9NDhsAb.js.map} +1 -1
  50. package/public/assets/store-DiSzYHj9.js +9 -0
  51. package/public/assets/{store-C9VcSo05.js.map → store-DiSzYHj9.js.map} +1 -1
  52. package/public/assets/{tasks-CbN_GSSb.js → tasks-CIQolvNm.js} +2 -2
  53. package/public/assets/{tasks-CbN_GSSb.js.map → tasks-CIQolvNm.js.map} +1 -1
  54. package/public/assets/{terminal-viewer-impl-BJRohThT.js → terminal-viewer-impl-DCifVqFR.js} +2 -2
  55. package/public/assets/{terminal-viewer-impl-BJRohThT.js.map → terminal-viewer-impl-DCifVqFR.js.map} +1 -1
  56. package/public/assets/{work-queue-C5xLBLmm.js → work-queue-Dr3c1V6O.js} +2 -2
  57. package/public/assets/{work-queue-C5xLBLmm.js.map → work-queue-Dr3c1V6O.js.map} +1 -1
  58. package/public/assets/{workspaces-D91H3wDX.js → workspaces-B1Jxop7h.js} +3 -3
  59. package/public/assets/{workspaces-D91H3wDX.js.map → workspaces-B1Jxop7h.js.map} +1 -1
  60. package/public/index.html +3 -3
  61. package/runner/src/adapter.ts +1 -1
  62. package/src/agent-lifecycle-events.ts +137 -0
  63. package/src/artifact-storage.ts +3 -5
  64. package/src/branch-landed.ts +38 -2
  65. package/src/cli/_shared.ts +80 -0
  66. package/src/cli/agent-detect.ts +188 -0
  67. package/src/cli/agent-meta.ts +95 -0
  68. package/src/cli/context-probe.ts +88 -0
  69. package/src/cli/daemon.ts +111 -0
  70. package/src/cli/dev.ts +173 -0
  71. package/src/cli/index.ts +361 -0
  72. package/src/cli/introspect.ts +73 -0
  73. package/src/cli/memory.ts +37 -0
  74. package/src/cli/message.ts +201 -0
  75. package/src/cli/orchestrator.ts +227 -0
  76. package/src/cli/pair.ts +125 -0
  77. package/src/cli/provider.ts +209 -0
  78. package/src/cli/recipe.ts +110 -0
  79. package/src/cli/reply.ts +141 -0
  80. package/src/cli/setup.ts +57 -0
  81. package/src/cli/steward.ts +59 -0
  82. package/src/cli/token.ts +81 -0
  83. package/src/cli/upgrade.ts +193 -0
  84. package/src/cli/workspace.ts +215 -0
  85. package/src/cli.ts +4 -2718
  86. package/src/config-store.ts +10 -6
  87. package/src/maintenance.ts +25 -21
  88. package/src/mcp-errors.ts +7 -0
  89. package/src/mcp.ts +34 -36
  90. package/src/routes/agents-spawn.ts +9 -1
  91. package/src/routes/agents.ts +5 -0
  92. package/src/routes/commands.ts +15 -0
  93. package/src/routes/workspaces.ts +13 -4
  94. package/src/spawn-targets.ts +159 -0
  95. package/src/utils.ts +16 -1
  96. package/src/workspace-actions.ts +7 -1
  97. package/src/workspace-merge.ts +12 -1
  98. package/public/assets/automation-CiaLThdO.js +0 -2
  99. package/public/assets/chat-5hvHZcAe.js +0 -2
  100. package/public/assets/chat-5hvHZcAe.js.map +0 -1
  101. package/public/assets/display-JI19Vc7L.js +0 -3
  102. package/public/assets/display-JI19Vc7L.js.map +0 -1
  103. package/public/assets/maintenance-DiFNzNPN.js +0 -2
  104. package/public/assets/pairs-WpKCPE1n.js +0 -2
  105. package/public/assets/store-C9VcSo05.js +0 -9
@@ -469,17 +469,21 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
469
469
  const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = {
470
470
  enabled: true,
471
471
  branchLanded: true,
472
+ agentReady: true,
473
+ agentExited: true,
474
+ agentSpawnFailed: true,
472
475
  };
473
476
 
474
477
  function validateNotificationsConfig(value: unknown): NotificationsConfig {
475
478
  if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
479
+ const bool = (key: keyof NotificationsConfig): boolean =>
480
+ value[key] === undefined ? NOTIFICATIONS_CONFIG_DEFAULTS[key] : cleanBoolean(value[key], key);
476
481
  return {
477
- enabled: value.enabled === undefined
478
- ? NOTIFICATIONS_CONFIG_DEFAULTS.enabled
479
- : cleanBoolean(value.enabled, "enabled"),
480
- branchLanded: value.branchLanded === undefined
481
- ? NOTIFICATIONS_CONFIG_DEFAULTS.branchLanded
482
- : cleanBoolean(value.branchLanded, "branchLanded"),
482
+ enabled: bool("enabled"),
483
+ branchLanded: bool("branchLanded"),
484
+ agentReady: bool("agentReady"),
485
+ agentExited: bool("agentExited"),
486
+ agentSpawnFailed: bool("agentSpawnFailed"),
483
487
  };
484
488
  }
485
489
 
@@ -33,6 +33,8 @@ import {
33
33
  } from "./db";
34
34
  import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
35
35
  import { requestWorkspaceMerge } from "./workspace-merge";
36
+ import { reconcileLandedWorkspace } from "./branch-landed";
37
+ import { notifyAgentOffline } from "./agent-lifecycle-events";
36
38
  import { workspaceActiveClaim } from "./workspace-claim";
37
39
  import { reapOrphanedWorktrees } from "./workspace-orphans";
38
40
  import { deriveBranchState, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
@@ -251,6 +253,9 @@ const definitions: MaintenanceJobDefinition[] = [
251
253
  for (const id of reapedAgentIds) {
252
254
  emitAgentStatus(id);
253
255
  getLifecycleManager().onAgentDisappeared(id);
256
+ // #308 — if a reaped agent was a spawned child, wake its parent: exited (it had become
257
+ // ready) vs spawn_failed (it never did — crash-loop / onboarding gate). No-op otherwise.
258
+ notifyAgentOffline(id, "heartbeat lost");
254
259
  createActivityEvent({
255
260
  clientId: "server-agent-" + id + "-heartbeat-lost-" + Date.now(),
256
261
  kind: "state",
@@ -434,8 +439,11 @@ function workspacePathWithinBase(path: string | undefined, baseDir: string | und
434
439
  return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
435
440
  }
436
441
 
437
- async function fetchHostMergePreview(apiUrl: string, workspace: WorkspaceRecord): Promise<WorkspaceMergePreview | { available: false } | null> {
438
- const query = new URLSearchParams({ path: workspace.worktreePath, checkPr: "1" });
442
+ async function fetchHostMergePreview(apiUrl: string, workspace: WorkspaceRecord, opts: { checkPr?: boolean } = {}): Promise<WorkspaceMergePreview | { available: false } | null> {
443
+ // `checkPr` costs a `gh` round-trip, so the caller opts in only where PR-merge ground
444
+ // truth is actionable (reconcile/land candidates), not for every badge-probe (#304).
445
+ const checkPr = opts.checkPr !== false;
446
+ const query = new URLSearchParams({ path: workspace.worktreePath, ...(checkPr ? { checkPr: "1" } : {}) });
439
447
  if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
440
448
  if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
441
449
  const headers: Record<string, string> = {};
@@ -577,10 +585,12 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
577
585
  for (const ws of candidates) {
578
586
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
579
587
  if (!orch?.apiUrl) continue;
580
- const preview = await fetchHostMergePreview(orch.apiUrl, ws);
588
+ // Spend the `gh` PR-state round-trip only on reconcile-eligible rows; active/ready
589
+ // are excluded from reconcile, so their scan stays git-only (#304).
590
+ const preview = await fetchHostMergePreview(orch.apiUrl, ws, { checkPr: LANDED_RECONCILE_STATUSES.has(ws.status) });
581
591
  if (!preview || (preview as { available?: false }).available === false) continue;
582
592
  const p = preview as WorkspaceMergePreview;
583
- if (p.error || p.missing || p.conflict === undefined) continue;
593
+ if (p.error || p.missing) continue;
584
594
 
585
595
  const meta = ws.metadata as Record<string, unknown>;
586
596
 
@@ -605,29 +615,23 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
605
615
  // merge_planned forever otherwise, and the conflict scan can even pin a
606
616
  // landed branch to `conflict`). Reconcile to the terminal `merged` status so
607
617
  // the dashboard stops showing it as unmerged and GC prunes it on schedule.
618
+ // This runs BEFORE the conflict-undefined skip below: a PR merged via a regular
619
+ // merge commit makes the branch an ancestor (ahead=0 → no-op → conflict comes
620
+ // back undefined), which the skip would otherwise drop, stranding the row at
621
+ // merge_planned — the exact #304 stall.
622
+ // Reconcile + finalize (record SHA, fire branch.landed) in branch-landed.ts so the
623
+ // giant doesn't grow (#291) and land-notify stays single-homed (#304).
608
624
  const landed = p.landed === true || p.prMerged === true;
609
625
  if (landed && LANDED_RECONCILE_STATUSES.has(ws.status)) {
610
- updateWorkspaceStatus(ws.id, "merged", {
611
- autoMerged: true,
612
- mergedFromStatus: ws.status,
613
- landedDetectedAt: Date.now(),
614
- landedVia: p.prMerged === true ? "pr" : "git",
615
- autoConflict: false,
616
- });
626
+ reconcileLandedWorkspace(ws, p);
617
627
  merged.push(ws.id);
618
- createActivityEvent({
619
- clientId: "server-workspace-" + ws.id + "-merged-" + Date.now(),
620
- kind: "state",
621
- title: "Workspace work landed in base",
622
- body: `${ws.branch ?? ws.id} is ${p.prMerged === true ? "merged on the remote (PR)" : "already merged into base"} ${p.baseRef ? `(${p.baseRef})` : ""} — marking merged`,
623
- meta: ws.branch ?? ws.id,
624
- icon: "ti-git-merge",
625
- view: "orchestrators",
626
- metadata: { source: "server", maintenanceJobId: "workspace-conflict-scan", workspaceId: ws.id, fromStatus: ws.status },
627
- });
628
628
  continue;
629
629
  }
630
630
 
631
+ // Past here we act on the conflict signal — skip when the host couldn't assess it
632
+ // (undefined): never flag/clear a conflict on incomplete data.
633
+ if (p.conflict === undefined) continue;
634
+
631
635
  if (p.conflict === true && ws.status !== "conflict") {
632
636
  updateWorkspaceStatus(ws.id, "conflict", {
633
637
  autoConflict: true,
@@ -0,0 +1,7 @@
1
+ // Typed MCP errors shared between the MCP endpoint (src/mcp.ts) and the spawn-target
2
+ // selection it delegates (src/spawn-targets.ts). callTool maps these to JSON-RPC error
3
+ // codes + HTTP statuses (auth → 403/-32001, not-found → 404/-32004, else 400/-32602).
4
+ // Kept in their own module so spawn-targets.ts can throw them without importing mcp.ts
5
+ // (which would be a cycle: mcp.ts → spawn-targets.ts → mcp.ts).
6
+ export class McpAuthError extends Error {}
7
+ export class McpNotFoundError extends Error {}
package/src/mcp.ts CHANGED
@@ -8,6 +8,8 @@ import { listManagedOrchestratorsForAgent } from "./orchestrator-lookup";
8
8
  import { bytesToStream, readBodyBytes } from "./http-body";
9
9
  import { MAX_BODY_BYTES, VERSION } from "./config";
10
10
  import { getManagedAgentState, getSpawnPolicy, listSpawnPolicies } from "./config-store";
11
+ import { buildSpawnTargets, selectSpawnOrchestrator, spawnCapablePrimer } from "./spawn-targets";
12
+ import { McpAuthError, McpNotFoundError } from "./mcp-errors";
11
13
  import {
12
14
  countLiveSpawnedAgents,
13
15
  createArtifact,
@@ -45,7 +47,7 @@ import {
45
47
  isIntegrationAllowed,
46
48
  } from "./security";
47
49
  import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
48
- import { applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
50
+ import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
49
51
  import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
50
52
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
51
53
  import { errMessage, isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
@@ -274,6 +276,16 @@ const TOOLS: ToolDefinition[] = [
274
276
  additionalProperties: false,
275
277
  },
276
278
  },
279
+ {
280
+ name: "relay_spawn_targets",
281
+ description: "Return the LIVE spawn capability matrix before you spawn: which orchestrators/hosts are online, which providers actually run on each (orchestrator-probed, not a static guess), each provider's models with per-model effort levels + defaults, your own host (isSelf) and remaining spawn quota, and named profiles you can spawn by role. Call this first whenever you decide to spawn — it removes the guesswork that otherwise only surfaces as a runtime rejection. Requires the command:spawn scope.",
282
+ requiredScopes: ["command:spawn"],
283
+ inputSchema: {
284
+ type: "object",
285
+ properties: {},
286
+ additionalProperties: false,
287
+ },
288
+ },
277
289
  {
278
290
  name: "relay_shutdown_agent",
279
291
  description: "Shut down an agent through Relay's orchestrator. Gated: requires the command:shutdown scope; an agent caller may only target its OWN live spawned children. Admin tokens keep full reach.",
@@ -413,12 +425,18 @@ export async function postMcp(req: Request): Promise<Response> {
413
425
  if (method === "initialize") {
414
426
  // Mode-tailored primer (#214 §3): surface the worktree merge-back map only
415
427
  // to callers that own an isolated worktree; shared-workspace agents omit it.
428
+ // #308: append the spawn primer only when the token actually grants command:spawn —
429
+ // authoritative scope-gating, eager + thin, so non-spawn agents get nothing.
416
430
  const worktree = callerIsolatedWorkspace(auth);
431
+ const instructions = [
432
+ worktree ? worktreeMcpInstructions(worktree) : "",
433
+ spawnCapablePrimer({ canSpawn: hasAnyScope(auth, ["command:spawn"]), quota: auth.component?.constraints?.maxSpawnedAgents }),
434
+ ].filter(Boolean).join("\n\n");
417
435
  return Response.json(jsonRpcResult(id, {
418
436
  protocolVersion: MCP_PROTOCOL_VERSION,
419
437
  capabilities: { tools: {} },
420
438
  serverInfo: { name: "agent-relay", title: "Agent Relay", version: VERSION },
421
- ...(worktree ? { instructions: worktreeMcpInstructions(worktree) } : {}),
439
+ ...(instructions ? { instructions } : {}),
422
440
  }));
423
441
  }
424
442
  if (method === "notifications/initialized") {
@@ -488,6 +506,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
488
506
  else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
489
507
  else if (name === "relay_whoami") result = relayWhoami(auth);
490
508
  else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
509
+ else if (name === "relay_spawn_targets") result = relaySpawnTargets(auth);
491
510
  else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
492
511
  else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
493
512
  else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
@@ -774,8 +793,10 @@ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknow
774
793
  const preferHost = callerId ? getAgent(callerId)?.machine : undefined;
775
794
  const orchestrator = selectSpawnOrchestrator(provider, optionalString(args.orchestratorId, "orchestratorId", 200), cwd, preferHost);
776
795
  const resolvedCwd = cwd || orchestrator.baseDir;
796
+ // #308 §3 — cwd must resolve within the TARGET host's base dir. A path valid on your own host
797
+ // may not exist on a different orchestrator, so validate against the chosen host and say which.
777
798
  if (cwd && !isPathWithinBase(cwd, orchestrator.baseDir)) {
778
- throw new ValidationError(`cwd must be within orchestrator base directory: ${orchestrator.baseDir}`);
799
+ throw new ValidationError(`cwd '${cwd}' is not within ${orchestrator.id} (host ${orchestrator.hostname})'s base dir '${orchestrator.baseDir}' — a path valid on your host may not exist on the target. Pass a cwd under that base dir, or omit cwd to default to it.`);
779
800
  }
780
801
  const selection = providerSelection(provider, args);
781
802
  const approvalMode = optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined ?? "guarded";
@@ -843,6 +864,9 @@ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknow
843
864
  requestedVia: "mcp",
844
865
  requestedAt: Date.now(),
845
866
  orchestratorId: orchestrator.id,
867
+ // #308 — stamp the spawning parent so a failed agent.spawn command can be routed back to
868
+ // it (the child never registers, so there's no agent record to resolve `spawnedBy` from).
869
+ ...(callerId ? { extra: { spawnedBy: callerId } } : {}),
846
870
  }),
847
871
  });
848
872
  emitCommand(command);
@@ -1011,7 +1035,7 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
1011
1035
  action,
1012
1036
  agentId: callerAgentId(auth) ?? auth.actor,
1013
1037
  detail: optionalString(args.detail, "detail", 4000),
1014
- strategy: action === "merge" ? (optionalEnum(args.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const) as WorkspaceMergeStrategy | undefined) : undefined,
1038
+ strategy: action === "merge" ? (optionalEnum(args.strategy, "strategy", LAND_STRATEGIES) as WorkspaceMergeStrategy | undefined) : undefined,
1015
1039
  deleteBranch: action === "merge" ? optionalBoolean(args.deleteBranch, "deleteBranch") : undefined,
1016
1040
  prTitle: optionalString(args.prTitle, "prTitle", 240),
1017
1041
  prBody: optionalString(args.prBody, "prBody", 8000),
@@ -1087,35 +1111,12 @@ function policyStatusPayload(policy: NonNullable<ReturnType<typeof getSpawnPolic
1087
1111
  };
1088
1112
  }
1089
1113
 
1090
- export function selectSpawnOrchestrator(
1091
- provider: SpawnProvider,
1092
- orchestratorId?: string,
1093
- cwd?: string,
1094
- preferHost?: string,
1095
- ): NonNullable<ReturnType<typeof getOrchestrator>> {
1096
- if (orchestratorId) {
1097
- const orchestrator = getOrchestrator(orchestratorId);
1098
- if (!orchestrator) throw new McpNotFoundError(`orchestrator ${orchestratorId} not found`);
1099
- if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
1100
- if (!orchestrator.providers.includes(provider)) throw new ValidationError(`orchestrator does not have provider available: ${provider}`);
1101
- return orchestrator;
1102
- }
1103
- const candidates = listOrchestrators().filter((item) => item.status === "online" && item.providers.includes(provider));
1104
- if (cwd) {
1105
- const match = candidates.find((item) => isPathWithinBase(cwd, item.baseDir));
1106
- if (match) return match;
1107
- }
1108
- // #255: with neither an explicit id nor a cwd to pin the host, default to the CALLER's own
1109
- // host instead of silently grabbing candidates[0] (a foreign host whose baseDir would then
1110
- // reject the caller's cwd — the footgun the spawn recipe warned about). An agent's `machine`
1111
- // is its OS hostname; match it against the orchestrator hostname (or id, defensively).
1112
- if (preferHost) {
1113
- const own = candidates.find((item) => item.hostname === preferHost || item.id === preferHost);
1114
- if (own) return own;
1115
- }
1116
- const orchestrator = candidates[0];
1117
- if (!orchestrator) throw new McpNotFoundError(`no orchestrator available for provider: ${provider}`);
1118
- return orchestrator;
1114
+ // #308 — thin auth wrapper over the spawn capability matrix (src/spawn-targets.ts owns the build).
1115
+ function relaySpawnTargets(auth: McpAuthContext): Record<string, unknown> {
1116
+ return buildSpawnTargets({
1117
+ callerId: callerAgentId(auth),
1118
+ quota: auth.component?.constraints?.maxSpawnedAgents ?? 0,
1119
+ });
1119
1120
  }
1120
1121
 
1121
1122
  function selectControlOrchestrator(input: {
@@ -1396,6 +1397,3 @@ function optionalAttachments(value: unknown): AttachmentRef[] | undefined {
1396
1397
  function encodedLength(value: string): number {
1397
1398
  return new TextEncoder().encode(value).byteLength;
1398
1399
  }
1399
-
1400
- class McpAuthError extends Error {}
1401
- export class McpNotFoundError extends Error {}
@@ -1,6 +1,7 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents-spawn.
2
2
  import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
- import { McpNotFoundError, selectSpawnOrchestrator } from "../mcp";
3
+ import { McpNotFoundError } from "../mcp-errors";
4
+ import { selectSpawnOrchestrator } from "../spawn-targets";
4
5
  import { VALID_AGENT_STATUSES, auditEvent, authAuditMetadata, authorizeRoute, emitCommand, error, json, metaString, parseBody, spawnRequestId, type Handler } from "./_shared";
5
6
  import { ValidationError, getAgent, getOrchestrator, listAgents, resolveQueuedPolicyMessages, upsertAgent } from "../db";
6
7
  import { buildSpawnCommand, resolveSpawnModelParams, type SpawnModelParams } from "../spawn-command";
@@ -8,6 +9,7 @@ import { cleanMeta, cleanNullableString, cleanString, cleanStringArray, optional
8
9
  import { createCommand } from "../commands-db";
9
10
  import { emitAgentStatus, emitManagedAgentStateChanged, emitMessageAvailable, emitMessageDeliveryUpdated, emitNewMessage } from "../sse";
10
11
  import { getAgentProfile, getManagedAgentState, updateManagedAgentState } from "../config-store";
12
+ import { notifyAgentReady } from "../agent-lifecycle-events";
11
13
  import { getCompactionWatch } from "../compaction-watch";
12
14
  import { getComponentAuth } from "../security";
13
15
  import { isPathWithinBase } from "../utils";
@@ -98,6 +100,12 @@ export const postAgent: Handler = async (req) => {
98
100
  }
99
101
  }
100
102
  emitAgentStatus(agent.id);
103
+ // #308 — a spawned child registering already-ready (the common isolated-worktree case: it
104
+ // comes up ready+idle) is genuinely ready; wake its parent so it can stop block-polling.
105
+ // Fire on the first-ever ready (no prior record, or prior record wasn't ready yet) — the
106
+ // ready/false→true flip path is handled in patchAgentReady. notifyAgentReady no-ops for
107
+ // non-spawned agents and dedups repeats.
108
+ if (agent.ready && !existing?.ready) notifyAgentReady(agent.id);
101
109
  // A real PreCompact / SessionStart(clear) hook reports progress via the
102
110
  // agent's timelineEvent — clears any pending stall watch for this agent.
103
111
  // timelineEvent is latched, so pass its timestamp: only a fresh event
@@ -5,6 +5,7 @@ import { attachBranchState, attachBranchStates } from "../agent-branch-state";
5
5
  import { cleanStringArray } from "../validation";
6
6
  import { clearActiveMemories, memoryBroker } from "../memory-service";
7
7
  import { emitAgentRemoved, emitAgentStatus } from "../sse";
8
+ import { notifyAgentReady } from "../agent-lifecycle-events";
8
9
  import { getCompactionWatch } from "../compaction-watch";
9
10
  import { isRecord } from "agent-relay-sdk";
10
11
  import { type AgentCard } from "../types";
@@ -231,6 +232,10 @@ export const patchAgentReady: Handler = async (req, params) => {
231
232
  const before = getAgent(params.id!);
232
233
  if (!markReady(params.id!, body.ready, guard)) return error("agent not found", 404);
233
234
  auditAgentStateTransition(params.id!, before, getAgent(params.id!));
235
+ // #308 — a spawned child flipping false→true ready is "genuinely ready" (past onboarding):
236
+ // wake its parent so it can stop block-polling. notifyAgentReady no-ops for non-spawned
237
+ // agents and dedups repeat readies.
238
+ if (body.ready && !before?.ready) notifyAgentReady(params.id!);
234
239
  } catch (e) {
235
240
  if (e instanceof ValidationError) return error(e.message, 400);
236
241
  throw e;
@@ -11,6 +11,7 @@ import { getCompactionWatch } from "../compaction-watch";
11
11
  import { isRecord } from "agent-relay-sdk";
12
12
  import { isRequestAuthorizedFor } from "../security";
13
13
  import { notifyBranchLanded } from "../branch-landed";
14
+ import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
14
15
  import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
15
16
 
16
17
  const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
@@ -298,6 +299,20 @@ export const patchCommand: Handler = async (req, params) => {
298
299
  patchWorkspaceMetadata(workspaceId, { lastDepsRefresh: command.result, lastDepsRefreshCommandId: command.id, lastDepsRefreshAt: Date.now() });
299
300
  }
300
301
  }
302
+ // #308 — a failed agent.spawn command never produces a child agent, so route the failure
303
+ // straight to the spawning parent stamped on the command (provider unavailable, bad cwd,
304
+ // launch error). The never-became-ready crash-loop case is covered separately by the reaper.
305
+ if (command.type === "agent.spawn" && command.status === "failed") {
306
+ const parent = isRecord(command.params) ? cleanString(command.params.spawnedBy, "params.spawnedBy", { max: 240 }) : undefined;
307
+ if (parent) {
308
+ notifyAgentSpawnFailed({
309
+ parent,
310
+ spawnRequestId: isRecord(command.params) ? cleanString(command.params.spawnRequestId, "params.spawnRequestId", { max: 160 }) : undefined,
311
+ provider: isRecord(command.params) ? cleanString(command.params.provider, "params.provider", { max: 40 }) : undefined,
312
+ reason: command.error ?? "spawn command failed",
313
+ });
314
+ }
315
+ }
301
316
  settleFailedOrchestratorUpgrade(command);
302
317
  emitCommand(command);
303
318
  auditCommandOutcome(command);
@@ -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 { isOwnerAlive, withOwnerOnline } from "../workspace-merge";
10
+ import { LAND_STRATEGIES, isOwnerAlive, withOwnerOnline } from "../workspace-merge";
11
11
  import { isPathWithinBase } from "../utils";
12
12
  import { resolve } from "node:path";
13
13
  import { type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecord, type WorkspaceStatus } from "../types";
@@ -220,12 +220,18 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
220
220
  if (!parsed.ok) return error(parsed.error, parsed.status);
221
221
  const body = isRecord(parsed.body) ? parsed.body : {};
222
222
  const repoRoot = cleanString(body.repoRoot, "repoRoot", { max: 1000 });
223
+ // Scope the sweep to a single workspace when the caller named one (#307). Without
224
+ // this, `--id` was silently ignored and a sweep scoped to one id went repo-wide —
225
+ // a destructive-action footgun. Now an explicit id narrows the candidate set.
226
+ const workspaceId = cleanString(body.workspaceId, "workspaceId", { max: 160 });
223
227
  const dryRun = body.dryRun !== false; // safe by default
224
228
  const landedOnly = body.landedOnly !== false;
225
229
  const offlineOwnerOnly = body.offlineOwnerOnly !== false;
226
230
 
227
231
  const candidates = listWorkspaces().filter((ws) =>
228
- ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status) && (!repoRoot || ws.repoRoot === repoRoot),
232
+ ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status)
233
+ && (!repoRoot || ws.repoRoot === repoRoot)
234
+ && (!workspaceId || ws.id === workspaceId),
229
235
  );
230
236
 
231
237
  const rows: Array<Record<string, unknown>> = [];
@@ -259,7 +265,7 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
259
265
  }
260
266
  rows.push(row);
261
267
  }
262
- return json({ dryRun, landedOnly, offlineOwnerOnly, repoRoot, scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
268
+ return json({ dryRun, landedOnly, offlineOwnerOnly, repoRoot, ...(workspaceId ? { workspaceId } : {}), scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
263
269
  };
264
270
 
265
271
  export const postWorkspaceAction: Handler = async (req, params) => {
@@ -313,7 +319,10 @@ export const postWorkspaceAction: Handler = async (req, params) => {
313
319
  agentId,
314
320
  detail: cleanString(parsed.body.detail, "detail", { max: 4000 }),
315
321
  metadata: cleanMeta(parsed.body.metadata) ?? {},
316
- strategy: optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy,
322
+ // No inline fallback: an absent strategy stays undefined and the shared core
323
+ // (requestWorkspaceMerge) applies DEFAULT_MERGE_STRATEGY, identically to the
324
+ // MCP land tool — one resolution point, no per-surface drift (#304).
325
+ strategy: optionalEnum(parsed.body.strategy, "strategy", LAND_STRATEGIES) as WorkspaceMergeStrategy | undefined,
317
326
  deleteBranch: typeof parsed.body.deleteBranch === "boolean" ? parsed.body.deleteBranch : undefined,
318
327
  force: parsed.body.force === true,
319
328
  prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
@@ -0,0 +1,159 @@
1
+ import { countLiveSpawnedAgents, getAgent, getOrchestrator, listOrchestrators, ValidationError } from "./db";
2
+ import { listAgentProfiles } from "./config-store";
3
+ import { effectiveProviderCatalogList } from "./provider-catalog-store";
4
+ import { isPathWithinBase } from "./utils";
5
+ import { McpNotFoundError } from "./mcp-errors";
6
+ import type { Orchestrator, ProviderCatalogSummary, SpawnProvider } from "./types";
7
+
8
+ // #308 — spawn discovery + target selection. Home for the live capability matrix that
9
+ // relay_spawn_targets returns, the scope-gated startup primer, and the orchestrator-selection
10
+ // (with structured "name the nearest valid alternative" errors). Lives in its own module so the
11
+ // already-large src/mcp.ts only carries the thin auth wrappers that call in here.
12
+
13
+ // === relay_spawn_targets: the live spawn capability matrix ===
14
+
15
+ // Reuses the per-host catalog each orchestrator already reports on heartbeat (orchestrator-probed
16
+ // availability, not a static config guess), so the agent learns what it can spawn (and where)
17
+ // upfront instead of from a runtime rejection. Caller identity + quota come from the MCP auth.
18
+ export function buildSpawnTargets(input: { callerId?: string; quota: number }): Record<string, unknown> {
19
+ const me = input.callerId ? getAgent(input.callerId) : undefined;
20
+ const preferHost = me?.machine;
21
+ const orchestrators = listOrchestrators();
22
+ const self = preferHost ? orchestrators.find((o) => o.hostname === preferHost || o.id === preferHost) : undefined;
23
+ const liveChildren = input.callerId ? countLiveSpawnedAgents(input.callerId) : 0;
24
+
25
+ return {
26
+ self: {
27
+ orchestratorId: self?.id,
28
+ host: self?.hostname ?? preferHost,
29
+ spawnQuota: { max: input.quota, liveChildren, remaining: Math.max(0, input.quota - liveChildren) },
30
+ },
31
+ orchestrators: orchestrators
32
+ .filter((o) => o.status === "online")
33
+ .map((o) => ({
34
+ orchestratorId: o.id,
35
+ host: o.hostname,
36
+ online: true,
37
+ isSelf: self ? o.id === self.id : false,
38
+ providers: providerMatrixFor(o),
39
+ })),
40
+ profiles: spawnProfilesSummary(),
41
+ guidance:
42
+ "Default to your own host (isSelf:true); only set orchestratorId to spawn onto another host that runs a provider/model yours doesn't. Omitting model/effort uses the provider default (marked below). Prefer a named profile over assembling raw knobs; set spawnRequestId for idempotency. After spawning you'll be pushed agent.ready / agent.exited / agent.spawn_failed — don't block-poll.",
43
+ };
44
+ }
45
+
46
+ // The subset of the (orchestrator-reported OR global) catalog the matrix projects. Both
47
+ // ProviderCatalogSummary and ProviderCatalogEntry are structurally compatible with this shape.
48
+ interface SpawnCatalogish {
49
+ provider: SpawnProvider;
50
+ defaultModel?: string;
51
+ models: Array<{ alias: string; providerModel: string; efforts: readonly string[]; defaultEffort?: string }>;
52
+ }
53
+
54
+ function providerMatrixFor(o: Orchestrator): Array<Record<string, unknown>> {
55
+ const reported: ProviderCatalogSummary[] = o.providerCatalog ?? [];
56
+ const global = effectiveProviderCatalogList();
57
+ return o.providers.map((provider) => {
58
+ const cat: SpawnCatalogish | undefined =
59
+ reported.find((s) => s.provider === provider) ?? global.find((e) => e.provider === provider);
60
+ return {
61
+ provider,
62
+ available: true,
63
+ ...(cat
64
+ ? {
65
+ defaultModel: cat.defaultModel,
66
+ models: cat.models.map((m) => ({
67
+ id: m.alias,
68
+ providerModel: m.providerModel,
69
+ default: cat.defaultModel === m.alias,
70
+ effortLevels: m.efforts,
71
+ ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}),
72
+ })),
73
+ }
74
+ : { models: [] }),
75
+ };
76
+ });
77
+ }
78
+
79
+ function spawnProfilesSummary(): Array<Record<string, unknown>> {
80
+ return listAgentProfiles().map(({ key, value }) => ({
81
+ name: value.name ?? key,
82
+ description: value.description,
83
+ provider: value.provider,
84
+ approvalMode: value.permissions?.mode,
85
+ ...(value.maxSpawnedAgents !== undefined ? { maxSpawnedAgents: value.maxSpawnedAgents } : {}),
86
+ builtIn: value.builtIn ?? false,
87
+ }));
88
+ }
89
+
90
+ // A human-readable list of (provider@host) an agent can spawn onto right now, so a structured
91
+ // rejection names the nearest valid alternative instead of a bare "offline".
92
+ function spawnAlternatives(provider?: SpawnProvider): string {
93
+ const live = listOrchestrators()
94
+ .filter((o) => o.status === "online")
95
+ .flatMap((o) => o.providers.filter((p) => !provider || p === provider).map((p) => `${p}@${o.hostname}`));
96
+ return live.length ? live.join(", ") : "none currently online";
97
+ }
98
+
99
+ // === Startup primer (eager, scope-gated) ===
100
+
101
+ // Thin, eager startup primer for spawn-capable agents only (canSpawn = token carries
102
+ // command:spawn). Names the exact discovery tool (relay_spawn_targets) so there's zero discovery
103
+ // cost under lazy tool-loading, plus the same-host-default + fire-and-forget rules. The full matrix
104
+ // stays lazy — fetched only when the agent actually decides to spawn. Returns "" for non-spawn agents.
105
+ export function spawnCapablePrimer(input: { canSpawn: boolean; quota?: number }): string {
106
+ if (!input.canSpawn) return "";
107
+ const quotaLabel = typeof input.quota === "number" && input.quota > 0 ? ` (live-children quota: ${input.quota})` : "";
108
+ return [
109
+ `You can spawn long-living child agents${quotaLabel}. Shut your own down with relay_shutdown_agent.`,
110
+ "- BEFORE spawning, call relay_spawn_targets: it returns the LIVE matrix of hosts × providers × models × per-model effort, your remaining quota, and named profiles. Don't guess — that tool is the discovery path.",
111
+ "- Default to your OWN host. Only set orchestratorId to spawn onto another host when it runs a provider/model yours doesn't, or the work must run there.",
112
+ "- Prefer a named profile (profile: \"<name>\") over hand-assembling provider+model+effort+approvalMode. Omitting model/effort uses the provider default. Lean to the cheapest provider/model/effort that fits; escalate only for complex work.",
113
+ "- Set spawnRequestId for idempotency so a retried spawn doesn't double-create.",
114
+ "- Spawn is fire-and-forget: pass waitForRegistrationMs:0 and keep working — you'll be PUSHED agent.ready when the child is genuinely ready (past onboarding), or agent.spawn_failed / agent.exited otherwise. Don't block-poll.",
115
+ "- Spawned children cannot themselves spawn (no grandchildren).",
116
+ ].join("\n");
117
+ }
118
+
119
+ // === Orchestrator selection ===
120
+
121
+ export function selectSpawnOrchestrator(
122
+ provider: SpawnProvider,
123
+ orchestratorId?: string,
124
+ cwd?: string,
125
+ preferHost?: string,
126
+ ): Orchestrator {
127
+ if (orchestratorId) {
128
+ const orchestrator = getOrchestrator(orchestratorId);
129
+ if (!orchestrator) throw new McpNotFoundError(`orchestrator ${orchestratorId} not found`);
130
+ // #308 — name the nearest valid alternative on a miss instead of a bare "offline"/"unavailable",
131
+ // so the agent can re-target without a second relay_spawn_targets round trip.
132
+ if (orchestrator.status !== "online") {
133
+ throw new ValidationError(`orchestrator ${orchestratorId} is offline. Available: ${spawnAlternatives(provider)} (call relay_spawn_targets for the live matrix).`);
134
+ }
135
+ if (!orchestrator.providers.includes(provider)) {
136
+ const has = orchestrator.providers.length ? orchestrator.providers.join(", ") : "no providers";
137
+ throw new ValidationError(`orchestrator ${orchestratorId} does not run provider '${provider}' (it has: ${has}). Available for '${provider}': ${spawnAlternatives(provider)}.`);
138
+ }
139
+ return orchestrator;
140
+ }
141
+ const candidates = listOrchestrators().filter((item) => item.status === "online" && item.providers.includes(provider));
142
+ if (cwd) {
143
+ const match = candidates.find((item) => isPathWithinBase(cwd, item.baseDir));
144
+ if (match) return match;
145
+ }
146
+ // #255: with neither an explicit id nor a cwd to pin the host, default to the CALLER's own
147
+ // host instead of silently grabbing candidates[0] (a foreign host whose baseDir would then
148
+ // reject the caller's cwd — the footgun the spawn recipe warned about). An agent's `machine`
149
+ // is its OS hostname; match it against the orchestrator hostname (or id, defensively).
150
+ if (preferHost) {
151
+ const own = candidates.find((item) => item.hostname === preferHost || item.id === preferHost);
152
+ if (own) return own;
153
+ }
154
+ const orchestrator = candidates[0];
155
+ if (!orchestrator) {
156
+ throw new McpNotFoundError(`no online orchestrator runs provider '${provider}'. Available: ${spawnAlternatives()} (call relay_spawn_targets for the live matrix).`);
157
+ }
158
+ return orchestrator;
159
+ }
package/src/utils.ts CHANGED
@@ -1,4 +1,19 @@
1
- import { isAbsolute, relative, resolve } from "node:path";
1
+ import { isAbsolute, join, relative, resolve } from "node:path";
2
+ import { homedir } from "node:os";
3
+
4
+ /**
5
+ * Expand a leading `~` / `~/` to the current user's home directory. Leaves any
6
+ * other value (absolute paths, `./rel`, embedded `~` mid-string) untouched.
7
+ *
8
+ * Folds the identical hand-rolled copies in `cli.ts` (`expandHomePath`, used for
9
+ * orchestrator `--base-dir`/`--runtime-prefix`/`--path-prefix`) and
10
+ * `artifact-storage.ts` (artifact root). Import this; never re-declare it.
11
+ */
12
+ export function expandTilde(value: string): string {
13
+ if (value === "~") return homedir();
14
+ if (value.startsWith("~/")) return join(homedir(), value.slice(2));
15
+ return value;
16
+ }
2
17
 
3
18
  /**
4
19
  * Path containment check — is `path` inside (or equal to) `baseDir`?
@@ -20,6 +20,9 @@ import {
20
20
  } from "./db";
21
21
  import { emitActivityEvent } from "./sse";
22
22
  import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
23
+ // Re-export the land-strategy contract so the MCP land tool validates against the same
24
+ // tuple as the HTTP route without a second import hop (one source: workspace-merge, #304).
25
+ export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
23
26
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
24
27
  import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
25
28
  import type { Command, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
@@ -161,7 +164,10 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
161
164
  if (action === "merge") {
162
165
  const result = requestWorkspaceMerge(workspace, {
163
166
  requestedBy: agentId ?? "dashboard",
164
- strategy: input.strategy ?? "auto",
167
+ // Pass the strategy through verbatim (undefined when unset) — the single
168
+ // default lives in requestWorkspaceMerge, so both land surfaces resolve it
169
+ // identically instead of each applying its own fallback (#304).
170
+ strategy: input.strategy,
165
171
  deleteBranch: input.deleteBranch !== false,
166
172
  prTitle: input.prTitle,
167
173
  prBody: input.prBody,
@@ -10,6 +10,17 @@ import {
10
10
  import type { Command, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
11
11
  import { isPathWithinBase } from "./utils";
12
12
 
13
+ // One home for the land-strategy contract, shared by BOTH land surfaces — the HTTP
14
+ // route (`POST /api/workspaces/:id/actions`, driven by `agent-relay workspace land`)
15
+ // and the `relay_workspace_land` MCP tool. They validate against the same tuple and
16
+ // fall back to the same default, so the effective strategy can't drift by surface
17
+ // (#304: the CLI used to omit the strategy and let the server default while MCP
18
+ // passed its own — two encodings of one rule). The default is applied ONCE, in
19
+ // `requestWorkspaceMerge` below; callers pass `strategy` through verbatim (undefined
20
+ // when unset) so there is a single resolution point, not one per entrypoint.
21
+ export const LAND_STRATEGIES = ["pr", "rebase-ff", "auto"] as const;
22
+ export const DEFAULT_MERGE_STRATEGY: WorkspaceMergeStrategy = "auto";
23
+
13
24
  interface RequestWorkspaceMergeOptions {
14
25
  /** Who asked for the merge (lease holder + audit). e.g. an agent id, "dashboard", "auto-merge". */
15
26
  requestedBy: string;
@@ -103,7 +114,7 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
103
114
  branch: workspace.branch,
104
115
  baseRef: workspace.baseRef,
105
116
  baseSha: workspace.baseSha,
106
- strategy: opts.strategy ?? "auto",
117
+ strategy: opts.strategy ?? DEFAULT_MERGE_STRATEGY,
107
118
  deleteBranch,
108
119
  push: opts.push !== false,
109
120
  prTitle: opts.prTitle,