agent-relay-server 0.37.0 → 0.38.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 (76) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{activity-BgkmA1lh.js → activity-ClpDglG8.js} +2 -2
  4. package/public/assets/{activity-BgkmA1lh.js.map → activity-ClpDglG8.js.map} +1 -1
  5. package/public/assets/{agent-profiles-DS4_jLPT.js → agent-profiles-kb5H23CF.js} +2 -2
  6. package/public/assets/{agent-profiles-DS4_jLPT.js.map → agent-profiles-kb5H23CF.js.map} +1 -1
  7. package/public/assets/{agents-B7HnuAXx.js → agents-CHmEJvqV.js} +2 -2
  8. package/public/assets/{agents-B7HnuAXx.js.map → agents-CHmEJvqV.js.map} +1 -1
  9. package/public/assets/{analytics-0-akxJCJ.js → analytics-2kTjXIj1.js} +3 -3
  10. package/public/assets/{analytics-0-akxJCJ.js.map → analytics-2kTjXIj1.js.map} +1 -1
  11. package/public/assets/{automation-CaE1z_-M.js → automation-B5U_g-1P.js} +2 -2
  12. package/public/assets/{automation-CaE1z_-M.js.map → automation-B5U_g-1P.js.map} +1 -1
  13. package/public/assets/{branch-state-badge-D8-T2c1K.js → branch-state-badge-B1K7aIzF.js} +2 -2
  14. package/public/assets/{branch-state-badge-D8-T2c1K.js.map → branch-state-badge-B1K7aIzF.js.map} +1 -1
  15. package/public/assets/{channels-ppN8k4hu.js → channels-DyPw9JsY.js} +2 -2
  16. package/public/assets/{channels-ppN8k4hu.js.map → channels-DyPw9JsY.js.map} +1 -1
  17. package/public/assets/chat-zPXWB-03.js +2 -0
  18. package/public/assets/chat-zPXWB-03.js.map +1 -0
  19. package/public/assets/{connectors-CL9BALhF.js → connectors-k7JYCrrl.js} +2 -2
  20. package/public/assets/{connectors-CL9BALhF.js.map → connectors-k7JYCrrl.js.map} +1 -1
  21. package/public/assets/display-ConJ9cJB.js.map +1 -1
  22. package/public/assets/{formatted-body-impl-DExNPNsL.js → formatted-body-impl-tmf8IBfr.js} +2 -2
  23. package/public/assets/{formatted-body-impl-DExNPNsL.js.map → formatted-body-impl-tmf8IBfr.js.map} +1 -1
  24. package/public/assets/index-B1QUkb_O.js +21 -0
  25. package/public/assets/index-B1QUkb_O.js.map +1 -0
  26. package/public/assets/index-Bins8N_5.css +2 -0
  27. package/public/assets/{integrations-DX55ARy0.js → integrations-BEkyjBAs.js} +2 -2
  28. package/public/assets/{integrations-DX55ARy0.js.map → integrations-BEkyjBAs.js.map} +1 -1
  29. package/public/assets/{maintenance-DpTdJxQp.js → maintenance-Tn23oWBF.js} +2 -2
  30. package/public/assets/{maintenance-DpTdJxQp.js.map → maintenance-Tn23oWBF.js.map} +1 -1
  31. package/public/assets/{managed-agents-B3df2xfk.js → managed-agents-CasacvJX.js} +2 -2
  32. package/public/assets/{managed-agents-B3df2xfk.js.map → managed-agents-CasacvJX.js.map} +1 -1
  33. package/public/assets/{markdown-preview-impl-D0Zj7c3T.js → markdown-preview-impl-D4UIjB3I.js} +2 -2
  34. package/public/assets/{markdown-preview-impl-D0Zj7c3T.js.map → markdown-preview-impl-D4UIjB3I.js.map} +1 -1
  35. package/public/assets/{memory-TATN2vZf.js → memory-SVCob0fo.js} +2 -2
  36. package/public/assets/{memory-TATN2vZf.js.map → memory-SVCob0fo.js.map} +1 -1
  37. package/public/assets/{messages-3rS1lxIf.js → messages-CHK24Uxx.js} +2 -2
  38. package/public/assets/{messages-3rS1lxIf.js.map → messages-CHK24Uxx.js.map} +1 -1
  39. package/public/assets/{orchestrators-CRIV0g5y.js → orchestrators-CQcJb6VE.js} +2 -2
  40. package/public/assets/{orchestrators-CRIV0g5y.js.map → orchestrators-CQcJb6VE.js.map} +1 -1
  41. package/public/assets/{overview-CmHC_5oM.js → overview-DbyX7k-7.js} +2 -2
  42. package/public/assets/{overview-CmHC_5oM.js.map → overview-DbyX7k-7.js.map} +1 -1
  43. package/public/assets/{pairs-DBPAhXTI.js → pairs-CaL0_ZfW.js} +2 -2
  44. package/public/assets/{pairs-DBPAhXTI.js.map → pairs-CaL0_ZfW.js.map} +1 -1
  45. package/public/assets/{security-572X5MNX.js → security-BogsfkbT.js} +2 -2
  46. package/public/assets/{security-572X5MNX.js.map → security-BogsfkbT.js.map} +1 -1
  47. package/public/assets/{settings-vTBu8w3O.js → settings-BOsnUh5f.js} +2 -2
  48. package/public/assets/{settings-vTBu8w3O.js.map → settings-BOsnUh5f.js.map} +1 -1
  49. package/public/assets/{store-DKVWC6Uh.js → store-Bo72e9My.js} +2 -2
  50. package/public/assets/{store-DKVWC6Uh.js.map → store-Bo72e9My.js.map} +1 -1
  51. package/public/assets/{tasks-C0bPrDgN.js → tasks-CCxQovOv.js} +2 -2
  52. package/public/assets/{tasks-C0bPrDgN.js.map → tasks-CCxQovOv.js.map} +1 -1
  53. package/public/assets/{terminal-viewer-impl-rVPA6Fsx.js → terminal-viewer-impl-BDikdsxs.js} +2 -2
  54. package/public/assets/{terminal-viewer-impl-rVPA6Fsx.js.map → terminal-viewer-impl-BDikdsxs.js.map} +1 -1
  55. package/public/assets/{work-queue-BxkpTt_A.js → work-queue-fM-tu0iP.js} +2 -2
  56. package/public/assets/{work-queue-BxkpTt_A.js.map → work-queue-fM-tu0iP.js.map} +1 -1
  57. package/public/assets/{workspaces-CoC2nflZ.js → workspaces-Df0xJuIo.js} +2 -2
  58. package/public/assets/{workspaces-CoC2nflZ.js.map → workspaces-Df0xJuIo.js.map} +1 -1
  59. package/public/index.html +3 -3
  60. package/src/automations.ts +17 -2
  61. package/src/cli/index.ts +1 -1
  62. package/src/cli/workspace.ts +36 -3
  63. package/src/config-store.ts +46 -0
  64. package/src/maintenance.ts +6 -1
  65. package/src/mcp.ts +7 -2
  66. package/src/routes/workspaces.ts +4 -1
  67. package/src/services/send-message.ts +19 -1
  68. package/src/services/spawn-agent.ts +6 -0
  69. package/src/workspace-actions.ts +5 -1
  70. package/src/workspace-merge.ts +102 -3
  71. package/src/workspace-phase.ts +15 -1
  72. package/public/assets/chat-BANKUW05.js +0 -2
  73. package/public/assets/chat-BANKUW05.js.map +0 -1
  74. package/public/assets/index-3pO43nJo.css +0 -2
  75. package/public/assets/index-DEZdON6c.js +0 -21
  76. package/public/assets/index-DEZdON6c.js.map +0 -1
@@ -8,6 +8,7 @@ import type {
8
8
  ConfigEntry,
9
9
  ConfigHistoryEntry,
10
10
  InsightsConfig,
11
+ LandingConfig,
11
12
  ManagedAgentState,
12
13
  ManagedAgentStatus,
13
14
  NotificationsConfig,
@@ -16,6 +17,7 @@ import type {
16
17
  SpawnProvider,
17
18
  StewardConfig,
18
19
  WorkspaceConfig,
20
+ WorkspaceLandingPolicy,
19
21
  } from "./types";
20
22
 
21
23
  const CONFIG_HISTORY_LIMIT = 50;
@@ -29,6 +31,9 @@ const NOTIFICATIONS_NAMESPACE = "notifications";
29
31
  const NOTIFICATIONS_KEY = "default";
30
32
  const WORKSPACE_NAMESPACE = "workspace";
31
33
  const WORKSPACE_KEY = "default";
34
+ const LANDING_NAMESPACE = "landing";
35
+ const LANDING_KEY = "default";
36
+ const VALID_LANDING_STRATEGIES = ["direct", "pr"] as const;
32
37
  const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
33
38
  const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
34
39
  const VALID_PROFILE_INSTRUCTION_POLICIES = ["allow", "ignore"] as const;
@@ -239,6 +244,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
239
244
  env: input.env ?? {},
240
245
  providerOptions: input.providerOptions ?? {},
241
246
  ...(input.maxSpawnedAgents === undefined ? {} : { maxSpawnedAgents: input.maxSpawnedAgents }),
247
+ ...(input.landingStrategy === undefined ? {} : { landingStrategy: input.landingStrategy }),
242
248
  };
243
249
  }
244
250
 
@@ -317,6 +323,9 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
317
323
  maxSpawnedAgents: value.maxSpawnedAgents === undefined || value.maxSpawnedAgents === null
318
324
  ? undefined
319
325
  : cleanNumber(value.maxSpawnedAgents, "maxSpawnedAgents", { min: 0, max: 100 }),
326
+ landingStrategy: value.landingStrategy === undefined || value.landingStrategy === null
327
+ ? undefined
328
+ : cleanEnum(value.landingStrategy, "landingStrategy", VALID_LANDING_STRATEGIES) as WorkspaceLandingPolicy,
320
329
  });
321
330
  }
322
331
 
@@ -508,6 +517,19 @@ function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
508
517
  return { symlinkPaths };
509
518
  }
510
519
 
520
+ const LANDING_CONFIG_DEFAULTS: LandingConfig = {
521
+ strategy: "direct",
522
+ };
523
+
524
+ function validateLandingConfig(value: unknown): LandingConfig {
525
+ if (!isRecord(value)) throw new ValidationError("landing config value must be an object");
526
+ return {
527
+ strategy: value.strategy === undefined || value.strategy === null
528
+ ? LANDING_CONFIG_DEFAULTS.strategy
529
+ : cleanEnum(value.strategy, "strategy", VALID_LANDING_STRATEGIES) as WorkspaceLandingPolicy,
530
+ };
531
+ }
532
+
511
533
  function normalizeValue(namespace: string, key: string, value: unknown): unknown {
512
534
  if (value === undefined) throw new ValidationError("value required");
513
535
  if (namespace === SPAWN_POLICY_NAMESPACE) return validateSpawnPolicy(key, value);
@@ -516,6 +538,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
516
538
  if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
517
539
  if (namespace === NOTIFICATIONS_NAMESPACE) return validateNotificationsConfig(value);
518
540
  if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
541
+ if (namespace === LANDING_NAMESPACE) return validateLandingConfig(value);
519
542
  if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
520
543
  return value;
521
544
  }
@@ -682,6 +705,29 @@ export function setWorkspaceConfig(value: unknown, updatedBy?: string): ConfigEn
682
705
  return setConfig(WORKSPACE_NAMESPACE, WORKSPACE_KEY, value as WorkspaceConfig, updatedBy);
683
706
  }
684
707
 
708
+ /** Instance-wide landing config, merged over defaults (always returns a usable value). */
709
+ export function getLandingConfig(): LandingConfig {
710
+ const entry = getConfig<Partial<LandingConfig>>(LANDING_NAMESPACE, LANDING_KEY);
711
+ if (!entry) return { ...LANDING_CONFIG_DEFAULTS };
712
+ return validateLandingConfig({ ...LANDING_CONFIG_DEFAULTS, ...entry.value });
713
+ }
714
+
715
+ export function getLandingConfigEntry(): ConfigEntry<LandingConfig> {
716
+ const entry = getConfig<LandingConfig>(LANDING_NAMESPACE, LANDING_KEY);
717
+ return entry ?? {
718
+ namespace: LANDING_NAMESPACE,
719
+ key: LANDING_KEY,
720
+ value: { ...LANDING_CONFIG_DEFAULTS },
721
+ version: 0,
722
+ updatedAt: "default",
723
+ updatedBy: "system",
724
+ };
725
+ }
726
+
727
+ export function setLandingConfig(value: unknown, updatedBy?: string): ConfigEntry<LandingConfig> {
728
+ return setConfig(LANDING_NAMESPACE, LANDING_KEY, value as LandingConfig, updatedBy);
729
+ }
730
+
685
731
  /**
686
732
  * Spawn-param fragment carrying the global workspace symlink list to the orchestrator.
687
733
  * Spread into every spawn command's params next to `agentProfile` so any isolated
@@ -37,7 +37,7 @@ import { reconcileLandedWorkspace } from "./branch-landed";
37
37
  import { notifyAgentOffline } from "./agent-lifecycle-events";
38
38
  import { workspaceActiveClaim } from "./workspace-claim";
39
39
  import { reapOrphanedWorktrees } from "./workspace-orphans";
40
- import { deriveBranchState, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
40
+ import { deriveBranchState, DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
41
41
  import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
42
42
  import { getStewardConfig } from "./config-store";
43
43
  import { ensureRepoSteward } from "./steward";
@@ -716,6 +716,11 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
716
716
  if (!preview || (preview as { available?: false }).available === false) continue;
717
717
  const p = preview as WorkspaceMergePreview;
718
718
  if (p.error || p.missing) continue;
719
+ if (p.reason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
720
+ patchWorkspaceMetadata(ws.id, { lastLandSkipReason: p.reason, lastLandSkipAt: Date.now() });
721
+ } else if (ws.metadata.lastLandSkipReason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
722
+ patchWorkspaceMetadata(ws.id, { lastLandSkipReason: undefined, lastLandSkipAt: undefined });
723
+ }
719
724
 
720
725
  const ahead = p.unmergedAhead ?? p.ahead ?? 0;
721
726
  const behind = p.behind ?? 0;
package/src/mcp.ts CHANGED
@@ -44,8 +44,9 @@ import {
44
44
  } from "./security";
45
45
  import { sendMessageService } from "./services/send-message";
46
46
  import { ServiceAuthError } from "./services/errors";
47
- import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
47
+ import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
48
48
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
49
+ import { AUTO_MERGE_POLICIES } from "./workspace-merge";
49
50
  import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
50
51
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
51
52
  import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
@@ -395,10 +396,12 @@ const TOOLS: ToolDefinition[] = [
395
396
  type: "object",
396
397
  properties: {
397
398
  workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
398
- strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto)." },
399
+ strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto). Falls back to the instance landing.strategy config when unset." },
399
400
  deleteBranch: { type: "boolean", description: "Delete the branch after a successful merge (default true)." },
400
401
  prTitle: { type: "string" },
401
402
  prBody: { type: "string" },
403
+ autoMerge: { type: "string", enum: AUTO_MERGE_POLICIES, description: "Auto-merge policy for PR-strategy lands. Defaults to 'on-green' when strategy resolves to 'pr'. Ignored for rebase-ff/auto strategies." },
404
+ reviewer: { type: "string", description: "GitHub login or team slug to request as a reviewer on the opened PR (pr strategy only)." },
402
405
  detail: { type: "string" },
403
406
  },
404
407
  additionalProperties: false,
@@ -919,6 +922,8 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
919
922
  deleteBranch: action === "merge" ? optionalBoolean(args.deleteBranch, "deleteBranch") : undefined,
920
923
  prTitle: optionalString(args.prTitle, "prTitle", 240),
921
924
  prBody: optionalString(args.prBody, "prBody", 8000),
925
+ autoMerge: action === "merge" ? (optionalEnum(args.autoMerge, "autoMerge", AUTO_MERGE_POLICIES) as WorkspaceAutoMergePolicy | undefined) : undefined,
926
+ reviewer: action === "merge" ? optionalString(args.reviewer, "reviewer", 240) : undefined,
922
927
  purpose: optionalString(args.purpose, "purpose", 120),
923
928
  checkOnly: action === "deps-refresh" ? optionalBoolean(args.checkOnly, "checkOnly") === true : undefined,
924
929
  auditMetadata: { via: "mcp", actor: auth.actor },
@@ -10,7 +10,8 @@ import { createCommand } from "../commands-db";
10
10
  import { LAND_STRATEGIES, isOwnerAlive, withOwnerOnline } from "../workspace-merge";
11
11
  import { isPathWithinBase } from "../utils";
12
12
  import { resolve } from "node:path";
13
- import { type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecord, type WorkspaceStatus } from "../types";
13
+ import { type WorkspaceAutoMergePolicy, type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecord, type WorkspaceStatus } from "../types";
14
+ import { AUTO_MERGE_POLICIES } from "../workspace-merge";
14
15
  import { workspaceActiveClaim } from "../workspace-claim";
15
16
 
16
17
  export const getWorkspaces: Handler = (req) => {
@@ -327,6 +328,8 @@ export const postWorkspaceAction: Handler = async (req, params) => {
327
328
  force: parsed.body.force === true,
328
329
  prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
329
330
  prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
331
+ autoMerge: optionalEnum(parsed.body.autoMerge, "autoMerge", AUTO_MERGE_POLICIES) as WorkspaceAutoMergePolicy | undefined,
332
+ reviewer: cleanString(parsed.body.reviewer, "reviewer", { max: 240 }),
330
333
  purpose: cleanString(parsed.body.purpose, "purpose", { max: 120 }),
331
334
  checkOnly: parsed.body.checkOnly === true,
332
335
  auditMetadata: authAuditMetadata(req),
@@ -53,10 +53,28 @@ export interface SendMessageOptions {
53
53
  * the runner reporting an agent's turn ON ITS BEHALF, so the wire `from` (the reported agent) is
54
54
  * authoritative there and the token's own identity must NOT override it. This is the relay's own
55
55
  * lane (not agent-directed), the agentId authz predicate is dropped for it too, and it preserves
56
- * the pre-convergence behavior — a steward/runner token managing other agents can still mirror. */
56
+ * the pre-convergence behavior — a steward/runner token managing other agents can still mirror.
57
+ *
58
+ * LINEAGE GATE (#362): the reserved-sink exception is NOT blanket lane access — the wire `from`
59
+ * must be an agent the caller is authorised to speak as. The caller qualifies when the wire
60
+ * `from` matches its own resolved identity (`callerAgentId`), OR when `from` is explicitly
61
+ * listed in the token's managed set (`constraints.agents`), OR when the caller carries no
62
+ * agent-level constraints at all (admin/server/integration). Any other combination is an
63
+ * impersonation attempt and is rejected. */
57
64
  function resolveFrom(input: SendMessageInput, ctx: AuthContext, reservedSink: boolean): string {
58
65
  const from = reservedSink ? (input.from || ctx.callerAgentId) : (ctx.callerAgentId ?? input.from);
59
66
  if (!from) throw new ValidationError("from is required");
67
+ if (reservedSink && from !== ctx.callerAgentId) {
68
+ // Constrained component tokens: the wire `from` must be in the token's managed agents list.
69
+ // Unconstrained callers (admin, server, integration — ctx.constraints is undefined) are
70
+ // allowed through unchanged; they hold full reach by design.
71
+ const managed = ctx.constraints?.agents;
72
+ if (managed !== undefined && !managed.includes(from)) {
73
+ throw new ServiceAuthError(
74
+ `reserved-sink wire from "${from}" is not in the caller's managed agents — impersonation rejected (#362)`,
75
+ );
76
+ }
77
+ }
60
78
  return from;
61
79
  }
62
80
 
@@ -190,6 +190,12 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
190
190
  policyName: input.policyName,
191
191
  spawnRequestId,
192
192
  createdBy: requestedBy,
193
+ // Thread the resolved profile into the mint so the spawn grant (command:spawn/shutdown +
194
+ // maxSpawnedAgents quota) is honored — parity with the HTTP path (agent-sessions.ts). #349
195
+ // dropped this when it unified the spawn service, silently un-spawning every default-spawner
196
+ // (P0 regression #364). For an agent caller, agentInitiated still forces a non-spawn-capable
197
+ // child regardless of profile (no grandchildren).
198
+ profile: input.profile || undefined,
193
199
  ...(callerId ? { agentInitiated: true, spawnedBy: callerId } : {}),
194
200
  });
195
201
 
@@ -25,7 +25,7 @@ import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
25
25
  export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
26
26
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
27
27
  import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
28
- import type { Command, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
28
+ import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
29
29
 
30
30
  // Single source of truth for the action verb set. The route's `optionalEnum` and
31
31
  // the MCP tool surface both import this so they can never drift out of sync.
@@ -54,6 +54,8 @@ interface ApplyWorkspaceActionInput {
54
54
  deleteBranch?: boolean;
55
55
  prTitle?: string;
56
56
  prBody?: string;
57
+ autoMerge?: WorkspaceAutoMergePolicy;
58
+ reviewer?: string;
57
59
  // cleanup
58
60
  force?: boolean;
59
61
  // claim / release-claim
@@ -171,6 +173,8 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
171
173
  deleteBranch: input.deleteBranch !== false,
172
174
  prTitle: input.prTitle,
173
175
  prBody: input.prBody,
176
+ autoMerge: input.autoMerge,
177
+ reviewer: input.reviewer,
174
178
  metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
175
179
  });
176
180
  if (!result.ok) return { ok: false, httpStatus: result.status, error: result.error };
@@ -7,9 +7,21 @@ import {
7
7
  setMergeLeaseCommand,
8
8
  updateWorkspaceStatus,
9
9
  } from "./db";
10
- import type { Command, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
10
+ import { getLandingConfig } from "./config-store";
11
+ import type {
12
+ Command,
13
+ WorkspaceAutoMergePolicy,
14
+ WorkspaceLandingPolicy,
15
+ WorkspaceMergeStrategy,
16
+ WorkspaceRecord,
17
+ } from "./types";
18
+ import { AUTO_MERGE_POLICIES } from "./types";
11
19
  import { isPathWithinBase } from "./utils";
12
20
 
21
+ // Re-export so consumers can import the tuple from the strategy home without a
22
+ // second import hop.
23
+ export { AUTO_MERGE_POLICIES } from "./types";
24
+
13
25
  // One home for the land-strategy contract, shared by BOTH land surfaces — the HTTP
14
26
  // route (`POST /api/workspaces/:id/actions`, driven by `agent-relay workspace land`)
15
27
  // and the `relay_workspace_land` MCP tool. They validate against the same tuple and
@@ -21,6 +33,60 @@ import { isPathWithinBase } from "./utils";
21
33
  export const LAND_STRATEGIES = ["pr", "rebase-ff", "auto"] as const;
22
34
  export const DEFAULT_MERGE_STRATEGY: WorkspaceMergeStrategy = "auto";
23
35
 
36
+ /** Normalize a user-facing policy word or a raw WorkspaceMergeStrategy to a WorkspaceMergeStrategy. */
37
+ function normalizeToMergeStrategy(value: string): WorkspaceMergeStrategy {
38
+ if (value === "direct") return "auto";
39
+ if (value === "pr" || value === "rebase-ff" || value === "auto") return value;
40
+ return "auto";
41
+ }
42
+
43
+ /**
44
+ * Resolve the effective merge strategy from the three-level precedence chain.
45
+ *
46
+ * Precedence (highest → lowest):
47
+ * explicit (per-invocation --strategy or relay_workspace_land.strategy)
48
+ * > profileStrategy (per-profile landingStrategy)
49
+ * > instanceStrategy (instance-wide landing.strategy from config-store)
50
+ * > "direct" (hard-coded default, which maps to "auto")
51
+ *
52
+ * `explicit` accepts BOTH raw WorkspaceMergeStrategy literals ("pr", "rebase-ff",
53
+ * "auto") and policy words ("direct", "pr") so callers don't need to pre-normalise.
54
+ */
55
+ export function resolveLandStrategy(opts: {
56
+ explicit?: string;
57
+ profileStrategy?: WorkspaceLandingPolicy;
58
+ instanceStrategy?: WorkspaceLandingPolicy;
59
+ }): WorkspaceMergeStrategy {
60
+ if (opts.explicit !== undefined && opts.explicit !== null) {
61
+ return normalizeToMergeStrategy(opts.explicit);
62
+ }
63
+ if (opts.profileStrategy !== undefined) {
64
+ return normalizeToMergeStrategy(opts.profileStrategy);
65
+ }
66
+ if (opts.instanceStrategy !== undefined) {
67
+ return normalizeToMergeStrategy(opts.instanceStrategy);
68
+ }
69
+ return DEFAULT_MERGE_STRATEGY;
70
+ }
71
+
72
+ /**
73
+ * Derive the issue number from a workspace metadata bag.
74
+ * Checks `metadata.issueNumber` (number or numeric string) and
75
+ * `metadata.taskId` (number or numeric string) — both common shapes.
76
+ * Returns the integer if found, otherwise undefined.
77
+ */
78
+ function resolveIssueNumber(metadata: Record<string, unknown>): number | undefined {
79
+ for (const key of ["issueNumber", "taskId"]) {
80
+ const raw = metadata[key];
81
+ if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0) return raw;
82
+ if (typeof raw === "string") {
83
+ const n = Number.parseInt(raw, 10);
84
+ if (Number.isSafeInteger(n) && n > 0) return n;
85
+ }
86
+ }
87
+ return undefined;
88
+ }
89
+
24
90
  interface RequestWorkspaceMergeOptions {
25
91
  /** Who asked for the merge (lease holder + audit). e.g. an agent id, "dashboard", "auto-merge". */
26
92
  requestedBy: string;
@@ -33,6 +99,15 @@ interface RequestWorkspaceMergeOptions {
33
99
  push?: boolean;
34
100
  prTitle?: string;
35
101
  prBody?: string;
102
+ /**
103
+ * Auto-merge policy for a PR-strategy land. When the effective strategy is "pr" and
104
+ * this is unset, defaults to "on-green" — a pr land MUST always reach a terminal
105
+ * state and should not block on a human hitting the merge button. Ignored for non-pr
106
+ * strategies (omitted from the command params entirely so the host stays clean).
107
+ */
108
+ autoMerge?: WorkspaceAutoMergePolicy;
109
+ /** Optional reviewer handle (GitHub login or team slug) to request on the opened PR. */
110
+ reviewer?: string;
36
111
  /** Extra metadata merged onto the workspace row when moving to merge_planned. */
37
112
  metadata?: Record<string, unknown>;
38
113
  }
@@ -101,6 +176,28 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
101
176
  releaseMergeLease({ repoRoot: workspace.repoRoot, workspaceId: workspace.id });
102
177
  return { ok: false, status: 404, error: "workspace not found" };
103
178
  }
179
+ const effectiveStrategy = opts.strategy ?? DEFAULT_MERGE_STRATEGY;
180
+
181
+ // Slice 6 — issue auto-linkage: append "Closes #N" to the PR body when the
182
+ // workspace metadata carries an issue number, WITHOUT clobbering an explicit body.
183
+ const issueNumber = resolveIssueNumber(workspace.metadata);
184
+ let prBody = opts.prBody;
185
+ if (issueNumber !== undefined) {
186
+ const closesLine = `Closes #${issueNumber}`;
187
+ if (!prBody) {
188
+ prBody = closesLine;
189
+ } else if (!prBody.includes(closesLine)) {
190
+ prBody = `${prBody}\n\n${closesLine}`;
191
+ }
192
+ }
193
+
194
+ // Slice 3 — auto-merge default for PR lands: a pr-strategy land MUST reach a
195
+ // terminal state; default to "on-green" so CI can close the loop automatically.
196
+ const isPrStrategy = effectiveStrategy === "pr";
197
+ const autoMerge: WorkspaceAutoMergePolicy | undefined = isPrStrategy
198
+ ? (opts.autoMerge ?? "on-green")
199
+ : undefined;
200
+
104
201
  const command = createCommand({
105
202
  type: "workspace.merge",
106
203
  source: "system",
@@ -114,11 +211,13 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
114
211
  branch: workspace.branch,
115
212
  baseRef: workspace.baseRef,
116
213
  baseSha: workspace.baseSha,
117
- strategy: opts.strategy ?? DEFAULT_MERGE_STRATEGY,
214
+ strategy: effectiveStrategy,
118
215
  deleteBranch,
119
216
  push: opts.push !== false,
120
217
  prTitle: opts.prTitle,
121
- prBody: opts.prBody,
218
+ prBody,
219
+ ...(autoMerge !== undefined ? { autoMerge } : {}),
220
+ ...(opts.reviewer !== undefined ? { reviewer: opts.reviewer } : {}),
122
221
  requestedBy: opts.requestedBy,
123
222
  requestedAt: Date.now(),
124
223
  },
@@ -80,6 +80,7 @@ export function worktreeReapable(state: WorktreeReapState | null | undefined): b
80
80
  // an unpushed branch, a wedged steward) and the agent/human should be told —
81
81
  // instead of the old behavior where it looked healthy for 90 minutes.
82
82
  export const LAND_PENDING_STALL_MS = 15 * 60 * 1000;
83
+ export const DIRTY_WORKTREE_LAND_SKIP_REASON = "worktree has uncommitted changes";
83
84
 
84
85
  type WorkspacePhase =
85
86
  | "working" // active — your turn: commit, then mark ready
@@ -134,7 +135,7 @@ const READY_ACTION: WorkspaceNextAction = {
134
135
  // marks ready, immune to the heartbeat `updated_at` bump) — not `updatedAt`, which
135
136
  // keeps ticking on every heartbeat and made the stall look fresh forever.
136
137
  export function describeWorkspacePhase(
137
- workspace: Pick<WorkspaceRecord, "status" | "branch" | "stewardAgentId" | "readyAt">,
138
+ workspace: Pick<WorkspaceRecord, "status" | "branch" | "stewardAgentId" | "readyAt"> & { metadata?: Record<string, unknown> },
138
139
  opts: { now?: number; stallMs?: number } = {},
139
140
  ): WorkspacePhaseView {
140
141
  switch (workspace.status) {
@@ -160,6 +161,19 @@ export function describeWorkspacePhase(
160
161
  // the agent (and the dashboard) stop reporting a wedged land as healthy.
161
162
  if (pendingMs !== undefined && pendingMs > stallMs) {
162
163
  const mins = Math.round(pendingMs / 60_000);
164
+ const lastLandSkipReason = typeof workspace.metadata?.lastLandSkipReason === "string"
165
+ ? workspace.metadata.lastLandSkipReason
166
+ : undefined;
167
+ if (lastLandSkipReason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
168
+ return {
169
+ phase: "land-pending",
170
+ headline: `Stalled — handed off ${mins} min ago but auto-merge is blocked by uncommitted worktree changes.`,
171
+ hint: "Commit or stash your uncommitted changes in the worktree, then wait for Relay to retry the auto-merge. Do NOT merge, push, rebase, or touch the main checkout yourself.",
172
+ actionNeeded: true,
173
+ nextActions: [WAIT_ACTION],
174
+ blockers: ["owner worktree has uncommitted changes — commit or stash them in the worktree"],
175
+ };
176
+ }
163
177
  return {
164
178
  phase: "land-pending",
165
179
  headline: `Stalled — handed off ${mins} min ago but still hasn't landed. A clean auto-merge runs every ~2 min, so this is past the healthy window and likely stuck (no online orchestrator, an unpushed branch, or a wedged merge/steward).`,