agent-relay-server 0.83.1 → 0.85.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 (91) hide show
  1. package/docs/openapi.json +151 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-BTBQr8uG.js → MessageBubble-DqFyach0.js} +2 -2
  4. package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-DqFyach0.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-CCvKbYWe.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-CCvKbYWe.js.map} +1 -1
  7. package/public/assets/{activity-XqRFu_9T.js → activity-C6RvknQQ.js} +2 -2
  8. package/public/assets/{activity-XqRFu_9T.js.map → activity-C6RvknQQ.js.map} +1 -1
  9. package/public/assets/{agents-BWmmWj4Q.js → agents-D7HZJ5eG.js} +2 -2
  10. package/public/assets/{agents-BWmmWj4Q.js.map → agents-D7HZJ5eG.js.map} +1 -1
  11. package/public/assets/{automation-DRtOENs0.js → automation-BUazA3lr.js} +2 -2
  12. package/public/assets/{automation-DRtOENs0.js.map → automation-BUazA3lr.js.map} +1 -1
  13. package/public/assets/{chat-gfUxbTus.js → chat-DBTEaf-H.js} +2 -2
  14. package/public/assets/{chat-gfUxbTus.js.map → chat-DBTEaf-H.js.map} +1 -1
  15. package/public/assets/display-CWMHll1J.js.map +1 -1
  16. package/public/assets/{formatted-body-impl-DpNHGXXQ.js → formatted-body-impl-KG0iExZ-.js} +2 -2
  17. package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-KG0iExZ-.js.map} +1 -1
  18. package/public/assets/index-LUIvWcx6.css +2 -0
  19. package/public/assets/index-TZskQfQL.js +23 -0
  20. package/public/assets/index-TZskQfQL.js.map +1 -0
  21. package/public/assets/{insights-DPaJ5Hme.js → insights-JL1s7Uul.js} +2 -2
  22. package/public/assets/{insights-DPaJ5Hme.js.map → insights-JL1s7Uul.js.map} +1 -1
  23. package/public/assets/{maintenance-DsszLb92.js → maintenance-CTMfhXFM.js} +2 -2
  24. package/public/assets/{maintenance-DsszLb92.js.map → maintenance-CTMfhXFM.js.map} +1 -1
  25. package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-C4cb3xQ4.js} +2 -2
  26. package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-C4cb3xQ4.js.map} +1 -1
  27. package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DQIi0O7r.js} +2 -2
  28. package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DQIi0O7r.js.map} +1 -1
  29. package/public/assets/{memory-C2s9bJ38.js → memory-DJPXFdgw.js} +2 -2
  30. package/public/assets/{memory-C2s9bJ38.js.map → memory-DJPXFdgw.js.map} +1 -1
  31. package/public/assets/{messages-DHRJNAvy.js → messages-RSD2g4Wx.js} +2 -2
  32. package/public/assets/{messages-DHRJNAvy.js.map → messages-RSD2g4Wx.js.map} +1 -1
  33. package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-BGIkULBF.js} +2 -2
  34. package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-BGIkULBF.js.map} +1 -1
  35. package/public/assets/{overview-C1vZfyhW.js → overview-ByE9V8pe.js} +2 -2
  36. package/public/assets/{overview-C1vZfyhW.js.map → overview-ByE9V8pe.js.map} +1 -1
  37. package/public/assets/{pairs-C1uzo8x0.js → pairs-C284ntvD.js} +2 -2
  38. package/public/assets/{pairs-C1uzo8x0.js.map → pairs-C284ntvD.js.map} +1 -1
  39. package/public/assets/{security-XhAAP6q3.js → security-vDglScsg.js} +2 -2
  40. package/public/assets/{security-XhAAP6q3.js.map → security-vDglScsg.js.map} +1 -1
  41. package/public/assets/{select-DEGUFEjx.js → select-DaOiWDBR.js} +2 -2
  42. package/public/assets/{select-DEGUFEjx.js.map → select-DaOiWDBR.js.map} +1 -1
  43. package/public/assets/{settings-DTbVUdlZ.js → settings-DiLcbSqx.js} +2 -2
  44. package/public/assets/{settings-DTbVUdlZ.js.map → settings-DiLcbSqx.js.map} +1 -1
  45. package/public/assets/{tasks-zwGo7hon.js → tasks-DWJdJra3.js} +2 -2
  46. package/public/assets/{tasks-zwGo7hon.js.map → tasks-DWJdJra3.js.map} +1 -1
  47. package/public/assets/{teams-Brc5D8ox.js → teams-FejXkix4.js} +2 -2
  48. package/public/assets/{teams-Brc5D8ox.js.map → teams-FejXkix4.js.map} +1 -1
  49. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-Bkt1w42o.js} +2 -2
  50. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-Bkt1w42o.js.map} +1 -1
  51. package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-ClE2dMD8.js} +2 -2
  52. package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-ClE2dMD8.js.map} +1 -1
  53. package/public/assets/{work-queue-ge4R7keo.js → work-queue-DZdv9kOb.js} +2 -2
  54. package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-DZdv9kOb.js.map} +1 -1
  55. package/public/index.html +2 -2
  56. package/src/agent-death-diagnosis.ts +255 -0
  57. package/src/agent-lifecycle-events.ts +42 -19
  58. package/src/auth/index.ts +23 -0
  59. package/src/auth/password.ts +122 -0
  60. package/src/auth/provider.ts +60 -0
  61. package/src/auth/session.ts +92 -0
  62. package/src/bus.ts +18 -4
  63. package/src/db/agents.ts +35 -2
  64. package/src/db/auth-identities.ts +119 -0
  65. package/src/db/crash-reports.ts +115 -0
  66. package/src/db/index.ts +2 -0
  67. package/src/db/migrations.ts +11 -0
  68. package/src/index.ts +6 -1
  69. package/src/maintenance/jobs.ts +6 -1
  70. package/src/maintenance/teams.ts +68 -1
  71. package/src/mcp/index.ts +2 -1
  72. package/src/mcp/tools-messaging.ts +38 -0
  73. package/src/notification-types.ts +2 -0
  74. package/src/routes/_shared.ts +40 -2
  75. package/src/routes/agents.ts +11 -1
  76. package/src/routes/auth.ts +107 -0
  77. package/src/routes/commands.ts +21 -3
  78. package/src/routes/index.ts +10 -1
  79. package/src/routes/orchestrator.ts +14 -1
  80. package/src/routes/tokens.ts +8 -2
  81. package/src/security.ts +1 -1
  82. package/src/services/terminal-provider-exit.ts +33 -0
  83. package/src/spawn-targets.ts +19 -8
  84. package/src/team-idle-tracker.ts +15 -0
  85. package/src/validation.ts +2 -0
  86. package/src/workspace-actions.ts +5 -1
  87. package/src/workspace-auto-merge.ts +87 -4
  88. package/src/workspace-merge.ts +10 -0
  89. package/public/assets/index-D8TVUdlC.js +0 -22
  90. package/public/assets/index-D8TVUdlC.js.map +0 -1
  91. package/public/assets/index-DgZLDL0K.css +0 -2
@@ -48,28 +48,39 @@ export function buildSpawnTargets(input: { callerId?: string; quota: number }):
48
48
  interface SpawnCatalogish {
49
49
  provider: SpawnProvider;
50
50
  defaultModel?: string;
51
- models: Array<{ alias: string; providerModel: string; efforts: readonly string[]; defaultEffort?: string }>;
51
+ models: Array<{ alias: string; providerModel: string; efforts: readonly string[]; defaultEffort?: string; disabled?: boolean }>;
52
52
  }
53
53
 
54
54
  function providerMatrixFor(o: Orchestrator): Array<Record<string, unknown>> {
55
55
  const reported: ProviderCatalogSummary[] = o.providerCatalog ?? [];
56
56
  const global = effectiveProviderCatalogList();
57
+ // #643 — drop disabled models so relay_spawn_targets advertises only spawnable models (parity
58
+ // with the #485 dashboard pickers' `!model.disabled` filter). The disabled flag is a relay-side
59
+ // override (provider_model_overrides) that the effective catalog carries but the orchestrator-
60
+ // REPORTED catalog does not, so cross-reference the effective catalog by alias regardless of
61
+ // which source supplies the matrix entry — otherwise the reported path keeps advertising a model
62
+ // the server then rejects with "model is disabled".
63
+ const disabledAliasesFor = (provider: SpawnProvider): Set<string> =>
64
+ new Set(global.find((e) => e.provider === provider)?.models.filter((m) => m.disabled).map((m) => m.alias) ?? []);
57
65
  return o.providers.map((provider) => {
58
66
  const cat: SpawnCatalogish | undefined =
59
67
  reported.find((s) => s.provider === provider) ?? global.find((e) => e.provider === provider);
68
+ const disabled = disabledAliasesFor(provider);
60
69
  return {
61
70
  provider,
62
71
  available: true,
63
72
  ...(cat
64
73
  ? {
65
74
  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
- })),
75
+ models: cat.models
76
+ .filter((m) => !m.disabled && !disabled.has(m.alias))
77
+ .map((m) => ({
78
+ id: m.alias,
79
+ providerModel: m.providerModel,
80
+ default: cat.defaultModel === m.alias,
81
+ effortLevels: m.efforts,
82
+ ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}),
83
+ })),
73
84
  }
74
85
  : { models: [] }),
75
86
  };
@@ -11,3 +11,18 @@ export const teamIdleTracker = new Map<string, TeamIdleEntry>();
11
11
  export function getTeamIdleEntry(teamId: string): TeamIdleEntry | undefined {
12
12
  return teamIdleTracker.get(teamId);
13
13
  }
14
+
15
+ // #634 — per-member heartbeat-gap watch. Kept in-memory (not derived from the live roster) ON
16
+ // PURPOSE: a member that was ready/busy and then vanished from the roster entirely escapes any
17
+ // roster-only enumeration, so the watch must remember it independently to detect the silence gap.
18
+ export interface TeamMemberWatch {
19
+ /** Most recent time the member was seen alive (non-offline heartbeat). */
20
+ lastActiveAt: number;
21
+ /** Whether the member's CURRENT silence has already pinged the coordinator (re-armed on recovery). */
22
+ pinged: boolean;
23
+ /** Member label for a readable ping. */
24
+ label?: string;
25
+ }
26
+
27
+ // teamId → (memberId → watch)
28
+ export const teamMemberWatchTracker = new Map<string, Map<string, TeamMemberWatch>>();
package/src/validation.ts CHANGED
@@ -124,6 +124,8 @@ export function cleanTokenConstraints(value: unknown): TokenConstraints | undefi
124
124
  if (cwd) constraints.cwd = cwd;
125
125
  const teamId = cleanString(value.teamId, "constraints.teamId", { max: 120 });
126
126
  if (teamId) constraints.teamId = teamId;
127
+ const tenantId = cleanString(value.tenantId, "constraints.tenantId", { max: 120 });
128
+ if (tenantId) constraints.tenantId = tenantId;
127
129
  for (const key of ["terminalAttach", "logsRead", "canDelegate"] as const) {
128
130
  if (value[key] !== undefined) {
129
131
  if (typeof value[key] !== "boolean") throw new ValidationError(`constraints.${key} must be a boolean`);
@@ -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 { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
28
- import { scheduleRepoMerge } from "./workspace-auto-merge";
28
+ import { CLEAR_MERGE_NO_PROGRESS, scheduleRepoMerge } from "./workspace-auto-merge";
29
29
  import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
30
30
 
31
31
  // Single source of truth for the action verb set. The route's `optionalEnum` and
@@ -235,6 +235,10 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
235
235
  ...metadata,
236
236
  ...(detail ? { detail } : {}),
237
237
  ...(agentId ? { updatedByAgentId: agentId } : {}),
238
+ // #638 — a fresh ready/request-review hand-off means the owner did more work, so a
239
+ // prior auto-merge no-progress back-off should clear: the new commits may resolve
240
+ // whatever blocked the land (e.g. a base that's since been synced). Re-arm the lane.
241
+ ...(READY_TO_LAND_STATUSES.has(nextStatus) ? CLEAR_MERGE_NO_PROGRESS : {}),
238
242
  lastWorkspaceAction: action,
239
243
  lastWorkspaceActionAt: Date.now(),
240
244
  });
@@ -36,7 +36,7 @@ import { workspaceActiveClaim } from "./workspace-claim";
36
36
  import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES } from "./workspace-phase";
37
37
  import { fetchHostMergePreview, workspacePathWithinBase } from "./workspace-probe";
38
38
  import type { WorkspaceMergePreview, WorkspaceRecord } from "./types";
39
- import { STEWARD_WAKE_COOLDOWN_MS, workspaceAutoMergeEnabled } from "./config";
39
+ import { STEWARD_WAKE_COOLDOWN_MS, stewardFallbackTarget, workspaceAutoMergeEnabled } from "./config";
40
40
 
41
41
  // Don't re-wake the managed steward for the same workspace more than once per this
42
42
  // window — a persistent conflict/behind row would otherwise re-ping every sweep.
@@ -71,6 +71,79 @@ export function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | n
71
71
  }
72
72
  }
73
73
 
74
+ // #638 — auto-merge loop breaker. A `workspace.merge` the orchestrator settles `failed`
75
+ // (no progress: origin moved ahead of local base, a replay conflict the clean-preview
76
+ // didn't predict, a push race) returns the workspace to a landable status
77
+ // (`review_requested`). The on-settle re-trigger (routes/commands.ts → scheduleRepoMerge)
78
+ // then re-dispatches it within a few seconds — forever. That's a silent merge-command
79
+ // storm that churns the per-repo lease and 409s every manual land (the live incident).
80
+ // Cap consecutive no-progress attempts per workspace; past the cap, stop dispatching and
81
+ // escalate. The count is incremented by the merge-result handler (server owns the
82
+ // transition) and cleared on genuine progress or a fresh ready/request-review hand-off.
83
+ export const MAX_NO_PROGRESS_MERGE_ATTEMPTS = 3;
84
+
85
+ // Don't re-escalate a backed-off lane more than once per this window.
86
+ const NO_PROGRESS_ESCALATION_COOLDOWN_MS = 30 * 60 * 1000;
87
+
88
+ /** The recorded count of consecutive no-progress merge attempts for a workspace (#638). */
89
+ export function mergeNoProgressCount(metadata: Record<string, unknown>): number {
90
+ const n = metadata.autoMergeNoProgressCount;
91
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
92
+ }
93
+
94
+ /** Metadata patch recording one more no-progress merge attempt (#638). Applied by the
95
+ * merge-result handler when a dispatched merge settles `failed` without landing. */
96
+ export function noteMergeNoProgress(metadata: Record<string, unknown>): Record<string, unknown> {
97
+ return { autoMergeNoProgressCount: mergeNoProgressCount(metadata) + 1, autoMergeNoProgressAt: Date.now() };
98
+ }
99
+
100
+ /** Metadata patch that clears the no-progress back-off — genuine progress (a land/recycle)
101
+ * or a fresh hand-off means the lane is worth attempting again (#638). */
102
+ export const CLEAR_MERGE_NO_PROGRESS: Record<string, unknown> = {
103
+ autoMergeNoProgressCount: 0,
104
+ autoMergeNoProgressAt: undefined,
105
+ autoMergeBackedOffAt: undefined,
106
+ };
107
+
108
+ // Surface a lane auto-merge can't advance (#638): wake the repo steward (if enabled) and
109
+ // route a stranded-escalation notice to the configured fallback / workspace owner, so a
110
+ // no-progress lane raises attention instead of churning silently. Cooldown-guarded so a
111
+ // persistently-stuck row isn't re-escalated every sweep.
112
+ function escalateNoProgressMerge(ws: WorkspaceRecord): void {
113
+ const meta = ws.metadata as Record<string, unknown>;
114
+ const lastEscalated = typeof meta.autoMergeBackedOffAt === "number" ? meta.autoMergeBackedOffAt : 0;
115
+ if (lastEscalated && Date.now() - lastEscalated < NO_PROGRESS_ESCALATION_COOLDOWN_MS) return;
116
+ const mergeError = typeof meta.mergeError === "string" && meta.mergeError ? meta.mergeError : "merge made no progress";
117
+ if (getStewardConfig().enabled) {
118
+ wakeRepoSteward(ws, `auto-merge backed off after ${MAX_NO_PROGRESS_MERGE_ATTEMPTS} no-progress attempts (${mergeError})`);
119
+ }
120
+ const fallback = stewardFallbackTarget();
121
+ const recipients = [...new Set([fallback, ws.ownerAgentId].filter((r): r is string => Boolean(r)))];
122
+ if (recipients.length) {
123
+ routeNotification({
124
+ type: "workspace.stranded_escalation",
125
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ws.ownerAgentId },
126
+ recipients,
127
+ message: {
128
+ subject: "Workspace can't auto-land — needs attention",
129
+ body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} failed to land ${MAX_NO_PROGRESS_MERGE_ATTEMPTS}× without progress (${mergeError}). Auto-merge has backed off to stop a merge-command loop. Sync \`${ws.baseRef ?? "base"}\` and land manually, or release the workspace.`,
130
+ payload: { kind: "workspace.auto-merge-stuck", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: ws.baseRef, attempts: MAX_NO_PROGRESS_MERGE_ATTEMPTS, reason: mergeError },
131
+ },
132
+ });
133
+ }
134
+ patchWorkspaceMetadata(ws.id, { autoMergeBackedOffAt: Date.now() });
135
+ createActivityEvent({
136
+ clientId: `workspace-auto-merge-backoff-${ws.id}-${Date.now()}`,
137
+ kind: "state",
138
+ title: "Auto-merge backed off (no progress)",
139
+ body: `${ws.branch ?? ws.id} → ${ws.baseRef ?? "base"} failed to land ${MAX_NO_PROGRESS_MERGE_ATTEMPTS}× without progress — stopping the merge loop (${mergeError})`,
140
+ meta: ws.branch ?? ws.id,
141
+ icon: "ti-alert-octagon",
142
+ view: "orchestrators",
143
+ metadata: { source: "server", maintenanceJobId: "workspace-auto-merge", workspaceId: ws.id, attempts: MAX_NO_PROGRESS_MERGE_ATTEMPTS },
144
+ });
145
+ }
146
+
74
147
  interface LandSweepResult {
75
148
  scanned: number;
76
149
  merged: string[];
@@ -78,12 +151,13 @@ interface LandSweepResult {
78
151
  heldByClaim: string[];
79
152
  heldByPause: string[];
80
153
  heldAwaitingApproval: string[];
154
+ backedOff: string[];
81
155
  leftForSteward: string[];
82
156
  wokeStewards: string[];
83
157
  }
84
158
 
85
159
  function emptyLandSweep(): LandSweepResult {
86
- return { scanned: 0, merged: [], heldByLease: [], heldByClaim: [], heldByPause: [], heldAwaitingApproval: [], leftForSteward: [], wokeStewards: [] };
160
+ return { scanned: 0, merged: [], heldByLease: [], heldByClaim: [], heldByPause: [], heldAwaitingApproval: [], backedOff: [], leftForSteward: [], wokeStewards: [] };
87
161
  }
88
162
 
89
163
  // Deterministic auto-land (Layer 0, #167/#207/#242): land conflict-free ready work
@@ -95,13 +169,22 @@ async function landCandidates(
95
169
  ): Promise<LandSweepResult> {
96
170
  const stewardEnabled = getStewardConfig().enabled;
97
171
  const merged: string[] = [];
98
- const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
172
+ const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], backedOff: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
99
173
 
100
174
  for (const ws of candidates) {
101
175
  if (isAwaitingReviewerApproval(ws)) { heldAwaitingApproval.push(ws.id); continue; }
102
176
  // A claimed workspace is being validated by a steward — don't race it (#208).
103
177
  if (workspaceActiveClaim(ws)) { heldByClaim.push(ws.id); continue; }
104
178
  if (getActiveMergePause(ws.repoRoot, ws.baseRef ?? "main")) { heldByPause.push(ws.id); continue; }
179
+ // #638 loop breaker: a lane whose last N merges settled `failed` without landing
180
+ // can't be advanced by re-dispatching — stop the storm, escalate once, and leave it
181
+ // for a steward/human. Cleared on genuine progress or a fresh hand-off (the agent did
182
+ // more work), so this is a back-off, not a permanent ban.
183
+ if (mergeNoProgressCount(ws.metadata) >= MAX_NO_PROGRESS_MERGE_ATTEMPTS) {
184
+ backedOff.push(ws.id);
185
+ escalateNoProgressMerge(ws);
186
+ continue;
187
+ }
105
188
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
106
189
  if (!orch?.apiUrl) continue;
107
190
  const preview = await fetchHostMergePreview(orch.apiUrl, ws);
@@ -164,7 +247,7 @@ async function landCandidates(
164
247
  });
165
248
  }
166
249
 
167
- return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, leftForSteward, wokeStewards };
250
+ return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, backedOff, leftForSteward, wokeStewards };
168
251
  }
169
252
 
170
253
  // Coalesced, fire-and-forget per-repo land trigger (see module header). A run already
@@ -3,6 +3,7 @@ import { isRecord, stringValue } from "agent-relay-sdk";
3
3
  import { createCommand } from "./commands-db";
4
4
  import {
5
5
  acquireMergeLease,
6
+ getActiveMergePause,
6
7
  getAgent,
7
8
  listAgents,
8
9
  listOrchestrators,
@@ -186,6 +187,15 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
186
187
  if (workspace.mode !== "isolated" || !workspace.worktreePath) {
187
188
  return { ok: false, status: 422, error: `workspace ${workspace.id} has no worktree to merge` };
188
189
  }
190
+ // #638 — an active merge-pause is the operator's tourniquet for a merge loop on this
191
+ // repo. The auto-merge job already honors it (workspace-auto-merge.ts), but gate the
192
+ // shared dispatcher too as defense-in-depth: never let an auto-merge-originated request
193
+ // acquire the lease + storm while paused, regardless of which caller dispatched it. A
194
+ // manual/steward land (any other `requestedBy`) is the intended RECOVERY path during a
195
+ // pause, so it's allowed through.
196
+ if (opts.requestedBy === "auto-merge" && getActiveMergePause(workspace.repoRoot, workspace.baseRef ?? "main")) {
197
+ return { ok: false, status: 409, error: `auto-merge paused for ${workspace.repoRoot}` };
198
+ }
189
199
  const lease = acquireMergeLease(workspace.repoRoot, workspace.id, opts.requestedBy);
190
200
  if (!lease.ok) {
191
201
  return {