agent-relay-server 0.60.0 → 0.60.1

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 (87) hide show
  1. package/docs/openapi.json +156 -1
  2. package/package.json +1 -1
  3. package/public/assets/MessageBubble-vzupFQD8.js +2 -0
  4. package/public/assets/MessageBubble-vzupFQD8.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-CXix1C-H.js → PlanGraphPanel-DQvycBMW.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CXix1C-H.js.map → PlanGraphPanel-DQvycBMW.js.map} +1 -1
  7. package/public/assets/{activity-CRafQ1Je.js → activity-gmfkOBhQ.js} +2 -2
  8. package/public/assets/{activity-CRafQ1Je.js.map → activity-gmfkOBhQ.js.map} +1 -1
  9. package/public/assets/{agent-profiles-DW4o5-ND.js → agent-profiles-7X8gFuwj.js} +2 -2
  10. package/public/assets/{agent-profiles-DW4o5-ND.js.map → agent-profiles-7X8gFuwj.js.map} +1 -1
  11. package/public/assets/{agents-Cbegv0Hm.js → agents-D201CuTR.js} +2 -2
  12. package/public/assets/{agents-Cbegv0Hm.js.map → agents-D201CuTR.js.map} +1 -1
  13. package/public/assets/{analytics-DX6T7JX3.js → analytics-CkOTrr5t.js} +2 -2
  14. package/public/assets/{analytics-DX6T7JX3.js.map → analytics-CkOTrr5t.js.map} +1 -1
  15. package/public/assets/{automation-CzSBv4gg.js → automation-_5u-nVs8.js} +2 -2
  16. package/public/assets/{automation-CzSBv4gg.js.map → automation-_5u-nVs8.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CRA50r81.js → branch-state-badge-BPXXLpQG.js} +2 -2
  18. package/public/assets/{branch-state-badge-CRA50r81.js.map → branch-state-badge-BPXXLpQG.js.map} +1 -1
  19. package/public/assets/{channels-B1MQCPCT.js → channels-BsJu5TXR.js} +2 -2
  20. package/public/assets/{channels-B1MQCPCT.js.map → channels-BsJu5TXR.js.map} +1 -1
  21. package/public/assets/{chat-BqIaFYGJ.js → chat-CHgPGHQD.js} +2 -2
  22. package/public/assets/{chat-BqIaFYGJ.js.map → chat-CHgPGHQD.js.map} +1 -1
  23. package/public/assets/{connectors-C9aFJIrh.js → connectors-arjfBQvc.js} +2 -2
  24. package/public/assets/{connectors-C9aFJIrh.js.map → connectors-arjfBQvc.js.map} +1 -1
  25. package/public/assets/{formatted-body-impl-DOXH7oDF.js → formatted-body-impl-DBt5tnS0.js} +2 -2
  26. package/public/assets/{formatted-body-impl-DOXH7oDF.js.map → formatted-body-impl-DBt5tnS0.js.map} +1 -1
  27. package/public/assets/{index-br_Y6Cvt.js → index-D7js3XlH.js} +6 -6
  28. package/public/assets/{index-br_Y6Cvt.js.map → index-D7js3XlH.js.map} +1 -1
  29. package/public/assets/{insights-B1edq5lk.js → insights-BvjkOXk9.js} +2 -2
  30. package/public/assets/{insights-B1edq5lk.js.map → insights-BvjkOXk9.js.map} +1 -1
  31. package/public/assets/{integrations-Cm4fjp7_.js → integrations-7L1f3eIE.js} +2 -2
  32. package/public/assets/{integrations-Cm4fjp7_.js.map → integrations-7L1f3eIE.js.map} +1 -1
  33. package/public/assets/{maintenance-D6zvw0fU.js → maintenance-BBoL--vs.js} +2 -2
  34. package/public/assets/{maintenance-D6zvw0fU.js.map → maintenance-BBoL--vs.js.map} +1 -1
  35. package/public/assets/{managed-agents-CzfEznM1.js → managed-agents-BjYBzVZt.js} +2 -2
  36. package/public/assets/{managed-agents-CzfEznM1.js.map → managed-agents-BjYBzVZt.js.map} +1 -1
  37. package/public/assets/{markdown-preview-impl-DZlCaTHQ.js → markdown-preview-impl-BnwjZsmO.js} +2 -2
  38. package/public/assets/{markdown-preview-impl-DZlCaTHQ.js.map → markdown-preview-impl-BnwjZsmO.js.map} +1 -1
  39. package/public/assets/{memory-CUUAzJDc.js → memory-SLycz2gC.js} +2 -2
  40. package/public/assets/{memory-CUUAzJDc.js.map → memory-SLycz2gC.js.map} +1 -1
  41. package/public/assets/{messages-6QZH46LG.js → messages-DfSCTe6N.js} +2 -2
  42. package/public/assets/{messages-6QZH46LG.js.map → messages-DfSCTe6N.js.map} +1 -1
  43. package/public/assets/{orchestrators-CrcqkPuc.js → orchestrators-BwxdTQm_.js} +2 -2
  44. package/public/assets/{orchestrators-CrcqkPuc.js.map → orchestrators-BwxdTQm_.js.map} +1 -1
  45. package/public/assets/{overview-418xXZO_.js → overview-Djy3qkY5.js} +2 -2
  46. package/public/assets/{overview-418xXZO_.js.map → overview-Djy3qkY5.js.map} +1 -1
  47. package/public/assets/{pairs-CnedATgT.js → pairs-9KNduLnC.js} +2 -2
  48. package/public/assets/{pairs-CnedATgT.js.map → pairs-9KNduLnC.js.map} +1 -1
  49. package/public/assets/{security-DS13pbag.js → security-tG3q7fFW.js} +2 -2
  50. package/public/assets/{security-DS13pbag.js.map → security-tG3q7fFW.js.map} +1 -1
  51. package/public/assets/{select-BOprGZDS.js → select-CGUdMNOG.js} +2 -2
  52. package/public/assets/{select-BOprGZDS.js.map → select-CGUdMNOG.js.map} +1 -1
  53. package/public/assets/{settings-BgInn_f3.js → settings-CGcqyJ5L.js} +2 -2
  54. package/public/assets/{settings-BgInn_f3.js.map → settings-CGcqyJ5L.js.map} +1 -1
  55. package/public/assets/{store-DHO_a8BD.js → store-DXdfhiVf.js} +2 -2
  56. package/public/assets/store-DXdfhiVf.js.map +1 -0
  57. package/public/assets/{tasks-B-4oCPuv.js → tasks-DWlKg218.js} +2 -2
  58. package/public/assets/{tasks-B-4oCPuv.js.map → tasks-DWlKg218.js.map} +1 -1
  59. package/public/assets/{teams-sCQAs_QA.js → teams-DRchUP_p.js} +2 -2
  60. package/public/assets/{teams-sCQAs_QA.js.map → teams-DRchUP_p.js.map} +1 -1
  61. package/public/assets/{terminal-viewer-impl-BhqS2XpS.js → terminal-viewer-impl-D_Sqe-vo.js} +2 -2
  62. package/public/assets/{terminal-viewer-impl-BhqS2XpS.js.map → terminal-viewer-impl-D_Sqe-vo.js.map} +1 -1
  63. package/public/assets/{work-queue-BDhjlwUH.js → work-queue-HNcyjON9.js} +2 -2
  64. package/public/assets/{work-queue-BDhjlwUH.js.map → work-queue-HNcyjON9.js.map} +1 -1
  65. package/public/assets/{workspaces-6I-C69nf.js → workspaces-CicYE2Ax.js} +2 -2
  66. package/public/assets/{workspaces-6I-C69nf.js.map → workspaces-CicYE2Ax.js.map} +1 -1
  67. package/public/index.html +2 -2
  68. package/src/cli/index.ts +11 -0
  69. package/src/cli/merges.ts +89 -0
  70. package/src/config.ts +4 -0
  71. package/src/db/merge-lease.ts +95 -1
  72. package/src/db/migrations.ts +15 -0
  73. package/src/maintenance.ts +8 -8
  74. package/src/mcp-merge-tools.ts +66 -0
  75. package/src/mcp.ts +3 -4
  76. package/src/merge-pause-maintenance.ts +13 -0
  77. package/src/routes/agent-sessions.ts +4 -2
  78. package/src/routes/commands.ts +20 -0
  79. package/src/routes/index.ts +7 -0
  80. package/src/routes/merges.ts +95 -0
  81. package/src/security.ts +2 -0
  82. package/src/services/merge-pause.ts +37 -0
  83. package/src/services/spawn-agent.ts +10 -8
  84. package/src/workspace-pr-completion.ts +70 -0
  85. package/public/assets/MessageBubble-BrEKymoV.js +0 -2
  86. package/public/assets/MessageBubble-BrEKymoV.js.map +0 -1
  87. package/public/assets/store-DHO_a8BD.js.map +0 -1
@@ -12,7 +12,7 @@ import {
12
12
  parseRuntimePackage,
13
13
  type RuntimeContracts,
14
14
  } from "../contracts";
15
- import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
15
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS, MAIN_MERGE_PAUSE_TTL_MS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { getDb } from "./connection.ts";
18
18
  import type {
@@ -158,3 +158,97 @@ export function releaseMergeLease(opts: { repoRoot?: string; commandId?: string;
158
158
  return getDb().query(`DELETE FROM workspace_merge_leases WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
159
159
  }
160
160
 
161
+ // --- Per-target merge pause lease (issue #448) -----------------------------
162
+
163
+ export interface MergePauseRecord {
164
+ id: string;
165
+ repoRoot: string;
166
+ baseRef: string;
167
+ holder: string;
168
+ reason?: string;
169
+ createdAt: number;
170
+ updatedAt: number;
171
+ expiresAt: number;
172
+ }
173
+
174
+ export function rowToMergePause(row: any): MergePauseRecord {
175
+ return {
176
+ id: row.id,
177
+ repoRoot: row.repo_root,
178
+ baseRef: row.base_ref,
179
+ holder: row.holder,
180
+ reason: row.reason ?? undefined,
181
+ createdAt: row.created_at,
182
+ updatedAt: row.updated_at,
183
+ expiresAt: row.expires_at,
184
+ };
185
+ }
186
+
187
+ export function getMergePause(id: string): MergePauseRecord | null {
188
+ const row = getDb().query("SELECT * FROM workspace_merge_pauses WHERE id = ?").get(id) as any;
189
+ return row ? rowToMergePause(row) : null;
190
+ }
191
+
192
+ export function getActiveMergePause(repoRoot: string, baseRef = "main", now: number = Date.now()): MergePauseRecord | null {
193
+ const row = getDb().query("SELECT * FROM workspace_merge_pauses WHERE repo_root = ? AND base_ref = ? AND expires_at > ?")
194
+ .get(repoRoot, baseRef, now) as any;
195
+ return row ? rowToMergePause(row) : null;
196
+ }
197
+
198
+ export function listMergePauses(): MergePauseRecord[] {
199
+ return (getDb().query("SELECT * FROM workspace_merge_pauses ORDER BY updated_at DESC").all() as any[]).map(rowToMergePause);
200
+ }
201
+
202
+ export function acquireMergePause(input: {
203
+ repoRoot: string;
204
+ baseRef?: string;
205
+ holder: string;
206
+ reason?: string;
207
+ ttlMs?: number;
208
+ now?: number;
209
+ }): { ok: true; pause: MergePauseRecord } | { ok: false; pause: MergePauseRecord } {
210
+ return getDb().transaction(() => {
211
+ const now = input.now ?? Date.now();
212
+ const baseRef = input.baseRef || "main";
213
+ const existing = getActiveMergePause(input.repoRoot, baseRef, now);
214
+ if (existing) return { ok: false as const, pause: existing };
215
+ const id = randomUUID();
216
+ const expiresAt = now + (input.ttlMs ?? MAIN_MERGE_PAUSE_TTL_MS);
217
+ getDb().query(`
218
+ INSERT INTO workspace_merge_pauses (id, repo_root, base_ref, holder, reason, created_at, updated_at, expires_at)
219
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
220
+ ON CONFLICT(repo_root, base_ref) DO UPDATE SET
221
+ id = excluded.id,
222
+ holder = excluded.holder,
223
+ reason = excluded.reason,
224
+ created_at = excluded.created_at,
225
+ updated_at = excluded.updated_at,
226
+ expires_at = excluded.expires_at
227
+ `).run(id, input.repoRoot, baseRef, input.holder, input.reason ?? null, now, now, expiresAt);
228
+ return { ok: true as const, pause: getMergePause(id)! };
229
+ })();
230
+ }
231
+
232
+ export function renewMergePause(id: string, ttlMs: number = MAIN_MERGE_PAUSE_TTL_MS, now: number = Date.now()): MergePauseRecord | null {
233
+ const current = getMergePause(id);
234
+ if (!current || current.expiresAt <= now) return null;
235
+ getDb().query("UPDATE workspace_merge_pauses SET updated_at = ?, expires_at = ? WHERE id = ?").run(now, now + ttlMs, id);
236
+ return getMergePause(id);
237
+ }
238
+
239
+ export function releaseMergePause(opts: { id?: string; repoRoot?: string; baseRef?: string }): boolean {
240
+ const where: string[] = [];
241
+ const params: string[] = [];
242
+ if (opts.id) { where.push("id = ?"); params.push(opts.id); }
243
+ if (opts.repoRoot) { where.push("repo_root = ?"); params.push(opts.repoRoot); }
244
+ if (opts.baseRef) { where.push("base_ref = ?"); params.push(opts.baseRef); }
245
+ if (!where.length) return false;
246
+ return getDb().query(`DELETE FROM workspace_merge_pauses WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
247
+ }
248
+
249
+ export function releaseExpiredMergePauses(now: number = Date.now()): string[] {
250
+ const expired = getDb().query("SELECT id FROM workspace_merge_pauses WHERE expires_at <= ?").all(now) as Array<{ id: string }>;
251
+ if (!expired.length) return [];
252
+ getDb().query("DELETE FROM workspace_merge_pauses WHERE expires_at <= ?").run(now);
253
+ return expired.map((r) => r.id);
254
+ }
@@ -117,6 +117,21 @@ export function applyMigrations(): void {
117
117
  normalizeExistingMessageReactions();
118
118
 
119
119
  // Migrations
120
+ getDb().run(`
121
+ CREATE TABLE IF NOT EXISTS workspace_merge_pauses (
122
+ id TEXT PRIMARY KEY,
123
+ repo_root TEXT NOT NULL,
124
+ base_ref TEXT NOT NULL,
125
+ holder TEXT NOT NULL,
126
+ reason TEXT,
127
+ created_at INTEGER NOT NULL,
128
+ updated_at INTEGER NOT NULL,
129
+ expires_at INTEGER NOT NULL,
130
+ UNIQUE(repo_root, base_ref)
131
+ )
132
+ `);
133
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_workspace_merge_pauses_expires ON workspace_merge_pauses(expires_at)");
134
+
120
135
  const cols = getDb().query("PRAGMA table_info(messages)").all() as any[];
121
136
  const colNames = cols.map((c: any) => c.name);
122
137
  if (!colNames.includes("thread_id")) {
@@ -13,6 +13,7 @@ import {
13
13
  expireQueuedMessages,
14
14
  getAgent,
15
15
  getDb,
16
+ getActiveMergePause,
16
17
  getRepoSteward,
17
18
  getWorkspace,
18
19
  listActiveTeams,
@@ -64,6 +65,7 @@ import { pruneExpiredTokenRecords } from "./token-db";
64
65
  import { dissolveTeamById } from "./services/team";
65
66
  import { reapTransientAgents, resetTransientTrackerForTests } from "./services/transient-agent-reaper";
66
67
  import { resumeRateLimitedAgents, resetRateLimitResumeTrackerForTests } from "./services/rate-limit-resume";
68
+ import { MERGE_PAUSE_MAINTENANCE_JOB } from "./merge-pause-maintenance";
67
69
  import type { Command, MaintenanceJob, MaintenanceJobRun } from "./types";
68
70
 
69
71
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -197,6 +199,7 @@ const definitions: MaintenanceJobDefinition[] = [
197
199
  };
198
200
  },
199
201
  },
202
+ MERGE_PAUSE_MAINTENANCE_JOB,
200
203
  {
201
204
  id: "task-orphan-release",
202
205
  title: "Orphaned task release",
@@ -659,9 +662,8 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
659
662
  const candidates = listWorkspaces().filter(
660
663
  (ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && CONFLICT_SCAN_STATUSES.has(ws.status),
661
664
  );
662
- const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [];
665
+ const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [], prRecycled: string[] = [];
663
666
  const reviewerArmed: string[] = [], reviewerChangesRequested: string[] = [], relayPrMerged: string[] = [], relayPrWaiting: string[] = [];
664
- const prRecycled: string[] = [];
665
667
 
666
668
  for (const ws of candidates) {
667
669
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
@@ -708,6 +710,7 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
708
710
  const prAction = preparePrCompletionScan(ws, p, orch.agentId);
709
711
  if (prAction.armCommand) { emitCommand(prAction.armCommand); reviewerArmed.push(ws.id); continue; }
710
712
  if (prAction.notifiedOwner) { reviewerChangesRequested.push(ws.id); continue; }
713
+ if (prAction.refreshCommand) { emitCommand(prAction.refreshCommand); continue; }
711
714
  if (prAction.mergeCommand) { emitCommand(prAction.mergeCommand); relayPrMerged.push(ws.id); continue; }
712
715
  if (prAction.waitReason) relayPrWaiting.push(`${ws.id}:${prAction.waitReason}`);
713
716
  if (prAction.stop) continue;
@@ -786,16 +789,13 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
786
789
  );
787
790
  const stewardEnabled = getStewardConfig().enabled;
788
791
  const merged: string[] = [];
789
- const heldByLease: string[] = [];
790
- const heldByClaim: string[] = [];
791
- const heldAwaitingApproval: string[] = [];
792
- const leftForSteward: string[] = [];
793
- const wokeStewards: string[] = [];
792
+ const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
794
793
 
795
794
  for (const ws of candidates) {
796
795
  if (isAwaitingReviewerApproval(ws)) { heldAwaitingApproval.push(ws.id); continue; }
797
796
  // A claimed workspace is being validated by a steward — don't race it (#208).
798
797
  if (workspaceActiveClaim(ws)) { heldByClaim.push(ws.id); continue; }
798
+ if (getActiveMergePause(ws.repoRoot, ws.baseRef ?? "main")) { heldByPause.push(ws.id); continue; }
799
799
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
800
800
  if (!orch?.apiUrl) continue;
801
801
  const preview = await fetchHostMergePreview(orch.apiUrl, ws);
@@ -857,7 +857,7 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
857
857
  });
858
858
  }
859
859
 
860
- return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldAwaitingApproval, leftForSteward, wokeStewards };
860
+ return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, leftForSteward, wokeStewards };
861
861
  }
862
862
 
863
863
  // Route a GC notification, swallowing failures (a stale/missing/misconfigured target must never
@@ -0,0 +1,66 @@
1
+ import { ValidationError } from "./db";
2
+ import { McpNotFoundError } from "./mcp-errors";
3
+ import { authContextFromMcp } from "./services/auth-context";
4
+ import { listAutoMergePauses, pauseAutoMerge, renewAutoMergePause, resumeAutoMerge } from "./services/merge-pause";
5
+ import { cleanString } from "./validation";
6
+
7
+ export const MERGE_TOOLS = [
8
+ { name: "relay_merge_pauses", description: "List active auto-merge pauses. These are repo/base-level holds that make the workspace auto-merge job yield during release or build critical sections.", requiredScopes: ["command:read"], inputSchema: { type: "object", properties: {}, additionalProperties: false } },
9
+ {
10
+ name: "relay_merge_pause",
11
+ description: "Pause workspace auto-merge for one repo/base pair with a TTL. Existing active pause for the same target is returned as a conflict instead of replacing its holder.",
12
+ requiredScopes: ["command:write"],
13
+ inputSchema: {
14
+ type: "object",
15
+ properties: {
16
+ repoRoot: { type: "string", description: "Repository root whose auto-merge should pause." },
17
+ baseRef: { type: "string", description: "Base branch/ref to pause (default main)." },
18
+ ttlMs: { type: "integer", minimum: 1, description: "Pause TTL in milliseconds. Defaults to the server's merge-pause TTL." },
19
+ reason: { type: "string" },
20
+ holder: { type: "string", description: "Optional human-readable holder. Defaults to the authenticated caller." },
21
+ },
22
+ required: ["repoRoot"],
23
+ additionalProperties: false,
24
+ },
25
+ },
26
+ { name: "relay_merge_renew", description: "Renew an active auto-merge pause by id, extending its TTL from now.", requiredScopes: ["command:write"], inputSchema: { type: "object", properties: { id: { type: "string" }, ttlMs: { type: "integer", minimum: 1 } }, required: ["id"], additionalProperties: false } },
27
+ { name: "relay_merge_resume", description: "Release an auto-merge pause by id, or by repoRoot/baseRef.", requiredScopes: ["command:write"], inputSchema: { type: "object", properties: { id: { type: "string" }, repoRoot: { type: "string" }, baseRef: { type: "string", description: "Base branch/ref (default main when repoRoot is used)." } }, additionalProperties: false } },
28
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
29
+
30
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
31
+
32
+ export function relayMergeTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
33
+ const ctx = authContextFromMcp(auth);
34
+ if (name === "relay_merge_pauses") {
35
+ const pauses = listAutoMergePauses();
36
+ return { pauses, count: pauses.length };
37
+ }
38
+ if (name === "relay_merge_pause") {
39
+ const result = pauseAutoMerge({
40
+ repoRoot: cleanString(args.repoRoot, "repoRoot", { required: true, max: 1024 })!,
41
+ baseRef: cleanString(args.baseRef, "baseRef", { max: 240 }),
42
+ holder: cleanString(args.holder, "holder", { max: 240 }) ?? ctx.callerAgentId ?? ctx.actor.id,
43
+ reason: cleanString(args.reason, "reason", { max: 1000 }),
44
+ ttlMs: optionalPositiveInt(args.ttlMs, "ttlMs"),
45
+ });
46
+ return result.ok ? { ok: true, pause: result.pause } : { ok: false, error: "merge target already paused", pause: result.pause };
47
+ }
48
+ if (name === "relay_merge_renew") {
49
+ const pause = renewAutoMergePause(cleanString(args.id, "id", { required: true, max: 120 })!, optionalPositiveInt(args.ttlMs, "ttlMs"));
50
+ if (!pause) throw new McpNotFoundError("merge pause not found or expired");
51
+ return { pause };
52
+ }
53
+ if (name === "relay_merge_resume") {
54
+ const id = cleanString(args.id, "id", { max: 120 });
55
+ const repoRoot = cleanString(args.repoRoot, "repoRoot", { max: 1024 });
56
+ if (!id && !repoRoot) throw new ValidationError("id or repoRoot required");
57
+ return { released: resumeAutoMerge({ id, repoRoot, baseRef: cleanString(args.baseRef, "baseRef", { max: 240 }) }) };
58
+ }
59
+ throw new ValidationError(`unknown tool: ${name}`);
60
+ }
61
+
62
+ function optionalPositiveInt(value: unknown, field: string): number | undefined {
63
+ if (value === undefined || value === null) return undefined;
64
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
65
+ return value;
66
+ }
package/src/mcp.ts CHANGED
@@ -50,7 +50,7 @@ import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
50
50
  import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
51
51
  import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
52
52
  import { PROJECT_TOOLS, relayProjectTool } from "./mcp-project-tools";
53
- import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "./mcp-notification-subscription-tools";
53
+ import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "./mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "./mcp-merge-tools";
54
54
  import { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
55
55
  import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
56
56
  import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
@@ -460,7 +460,7 @@ const TOOLS: ToolDefinition[] = [
460
460
  additionalProperties: false,
461
461
  },
462
462
  },
463
- ...TEAM_TOOLS,
463
+ ...MERGE_TOOLS, ...TEAM_TOOLS,
464
464
  ...PLAN_TOOLS,
465
465
  ...BLACKBOARD_TOOLS,
466
466
  ...PROJECT_TOOLS,
@@ -577,7 +577,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
577
577
  else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
578
578
  else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
579
579
  else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
580
- else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
580
+ else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
581
581
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
582
582
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
583
583
  else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args);
@@ -1043,7 +1043,6 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
1043
1043
  return payload;
1044
1044
  }
1045
1045
 
1046
-
1047
1046
  function payloadWithAttachments(
1048
1047
  payload: Record<string, unknown> | undefined,
1049
1048
  attachments: AttachmentRef[] | undefined,
@@ -0,0 +1,13 @@
1
+ import { REAP_INTERVAL_MS } from "./config";
2
+ import { releaseExpiredMergePauses } from "./db";
3
+
4
+ export const MERGE_PAUSE_MAINTENANCE_JOB = {
5
+ id: "merge-pause-expiration",
6
+ title: "Merge pause expiration",
7
+ description: "Release expired repo/base auto-merge pauses.",
8
+ intervalMs: REAP_INTERVAL_MS,
9
+ runOnStart: true,
10
+ handler() {
11
+ return { releasedMergePauseIds: releaseExpiredMergePauses() };
12
+ },
13
+ };
@@ -1,6 +1,6 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agent-sessions.
2
2
  import { RELAY_TOKEN_HEADER, isRecord } from "agent-relay-sdk";
3
- import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeRoute, dashboardAttribution, emitCommand, error, json, metaString, parseBody, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
3
+ import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
4
4
  import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage } from "../db";
5
5
  import { buildManagedSpawnParams } from "../managed-policy";
6
6
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
@@ -306,6 +306,7 @@ export const postAgentPrompt: Handler = async (req, params) => {
306
306
  try {
307
307
  if (!isRecord(parsed.body)) return error("body required");
308
308
  const body = cleanString(parsed.body.body, "body", { required: true, max: 100_000 })!;
309
+ const attachments = cleanAttachmentRefs(parsed.body.attachments ?? parsed.body.artifacts, "attachments");
309
310
  const agent = getAgent(params.id!);
310
311
  if (!agent) return error("agent not found", 404);
311
312
  if (agent.status === "offline") return error("agent is offline", 422);
@@ -319,12 +320,13 @@ export const postAgentPrompt: Handler = async (req, params) => {
319
320
  to: agent.id,
320
321
  kind: "session",
321
322
  body,
323
+ ...(attachments ? { payload: { attachments } } : {}),
322
324
  });
323
325
  const command = createCommand({
324
326
  type: "prompt.inject",
325
327
  source: "dashboard",
326
328
  target: agent.id,
327
- params: { body, messageId: message.id },
329
+ params: { body, messageId: message.id, ...(attachments ? { attachments } : {}) },
328
330
  });
329
331
  emitCommand(command);
330
332
  emitNewMessage(message);
@@ -315,6 +315,26 @@ export const patchCommand: Handler = async (req, params) => {
315
315
  });
316
316
  }
317
317
  }
318
+ if (command.type === "workspace.pr-refresh") {
319
+ const resultWorkspaceId = isRecord(command.result) ? cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 }) : undefined;
320
+ const workspaceId = resultWorkspaceId ?? command.correlationId;
321
+ if (workspaceId && command.status === "succeeded" && isRecord(command.result) && command.result.prRefreshed === true) {
322
+ patchWorkspaceMetadata(workspaceId, {
323
+ prRefreshCommandId: command.id,
324
+ prRefreshedAt: Date.now(),
325
+ prRefreshResult: command.result,
326
+ prRefreshError: undefined,
327
+ prRefreshFailedAt: undefined,
328
+ });
329
+ } else if (workspaceId && (command.status === "failed" || command.status === "timed_out" || command.status === "rejected" || command.status === "canceled")) {
330
+ patchWorkspaceMetadata(workspaceId, {
331
+ prRefreshCommandId: command.id,
332
+ prRefreshError: command.error ?? "PR branch refresh failed",
333
+ prRefreshFailedAt: Date.now(),
334
+ prRefreshResult: command.result,
335
+ });
336
+ }
337
+ }
318
338
  if (command.type === "workspace.deps-refresh" && command.status === "succeeded" && isRecord(command.result)) {
319
339
  // Record the outcome on the row without touching status (#51) — observability
320
340
  // for the dashboard; the CLI reads the result straight off the command.
@@ -35,6 +35,7 @@ import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, p
35
35
  import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
36
36
  import { postTeamDissolveByIdRoute } from "./teams";
37
37
  import { deleteTeamTemplateRoute, getTeamTemplateRoute, getTeamTemplatesRoute, postTeamTemplateRoute, putTeamTemplateRoute } from "./team-templates";
38
+ import { getMergePauseById, getMergePauses, postMergePause, postMergePauseRenew, postMergePauseResume } from "./merges";
38
39
  import { postMcp } from "../mcp";
39
40
  import { postOrchestratorBootstrap, postOrchestratorBootstrapExchange } from "./orchestrator-bootstrap";
40
41
 
@@ -139,6 +140,12 @@ const routes: Route[] = [
139
140
  route("POST", "/api/workspaces/:id/actions", postWorkspaceAction),
140
141
  route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
141
142
 
143
+ route("GET", "/api/merges/pauses", getMergePauses),
144
+ route("POST", "/api/merges/pause", postMergePause),
145
+ route("POST", "/api/merges/resume", postMergePauseResume),
146
+ route("POST", "/api/merges/renew", postMergePauseRenew),
147
+ route("GET", "/api/merges/pauses/:id", getMergePauseById),
148
+
142
149
  route("POST", "/api/system/broadcast", postSystemBroadcast),
143
150
  route("GET", "/api/teams", getTeamsRoute),
144
151
  route("GET", "/api/teams/outcomes", getTeamOutcomesRoute),
@@ -0,0 +1,95 @@
1
+ // Auto-merge coordination routes. Generic pause/resume primitive for callers
2
+ // that need Relay's auto-merge sweep to defer a target briefly (#448).
3
+ import { isRecord } from "agent-relay-sdk";
4
+ import { ValidationError } from "../db";
5
+ import { getAutoMergePause, listAutoMergePauses, pauseAutoMerge, renewAutoMergePause, resumeAutoMerge } from "../services/merge-pause";
6
+ import { cleanString } from "../validation";
7
+ import { authAuditMetadata, authorizeRoute, error, json, parseBody, type Handler } from "./_shared";
8
+
9
+ function cleanTtlMs(value: unknown): number | undefined {
10
+ if (value === undefined || value === null || value === "") return undefined;
11
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
12
+ throw new ValidationError("ttlMs must be a positive integer");
13
+ }
14
+ return value;
15
+ }
16
+
17
+ function actorFromRequest(req: Request): string {
18
+ const audit = authAuditMetadata(req);
19
+ const auth = isRecord(audit.auth) ? audit.auth : undefined;
20
+ return typeof auth?.sub === "string" ? auth.sub
21
+ : typeof auth?.name === "string" ? auth.name
22
+ : "root";
23
+ }
24
+
25
+ export const getMergePauses: Handler = (req) => {
26
+ const denied = authorizeRoute(req, { scope: "command:read" });
27
+ if (denied) return denied;
28
+ return json({ pauses: listAutoMergePauses() });
29
+ };
30
+
31
+ export const postMergePause: Handler = async (req) => {
32
+ const denied = authorizeRoute(req, { scope: "command:write" });
33
+ if (denied) return denied;
34
+ const parsed = await parseBody<unknown>(req);
35
+ if (!parsed.ok) return error(parsed.error, parsed.status);
36
+ try {
37
+ if (!isRecord(parsed.body)) return error("body required");
38
+ const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { required: true, max: 1000 })!;
39
+ const baseRef = cleanString(parsed.body.baseRef, "baseRef", { max: 240 }) ?? "main";
40
+ const holder = cleanString(parsed.body.holder, "holder", { max: 240 }) ?? actorFromRequest(req);
41
+ const reason = cleanString(parsed.body.reason, "reason", { max: 500 });
42
+ const ttlMs = cleanTtlMs(parsed.body.ttlMs);
43
+ const result = pauseAutoMerge({ repoRoot, baseRef, holder, reason, ttlMs });
44
+ if (!result.ok) {
45
+ return json({ error: "merge target already paused", pause: result.pause }, 409);
46
+ }
47
+ return json({ pause: result.pause }, 201);
48
+ } catch (e) {
49
+ if (e instanceof ValidationError) return error(e.message, 400);
50
+ throw e;
51
+ }
52
+ };
53
+
54
+ export const postMergePauseRenew: Handler = async (req) => {
55
+ const denied = authorizeRoute(req, { scope: "command:write" });
56
+ if (denied) return denied;
57
+ const parsed = await parseBody<unknown>(req);
58
+ if (!parsed.ok) return error(parsed.error, parsed.status);
59
+ try {
60
+ if (!isRecord(parsed.body)) return error("body required");
61
+ const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
62
+ const ttlMs = cleanTtlMs(parsed.body.ttlMs);
63
+ const pause = renewAutoMergePause(id, ttlMs);
64
+ return pause ? json({ pause }) : error("merge pause not found or expired", 404);
65
+ } catch (e) {
66
+ if (e instanceof ValidationError) return error(e.message, 400);
67
+ throw e;
68
+ }
69
+ };
70
+
71
+ export const postMergePauseResume: Handler = async (req) => {
72
+ const denied = authorizeRoute(req, { scope: "command:write" });
73
+ if (denied) return denied;
74
+ const parsed = await parseBody<unknown>(req);
75
+ if (!parsed.ok) return error(parsed.error, parsed.status);
76
+ try {
77
+ if (!isRecord(parsed.body)) return error("body required");
78
+ const id = cleanString(parsed.body.id, "id", { max: 120 });
79
+ const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { max: 1000 });
80
+ const baseRef = cleanString(parsed.body.baseRef, "baseRef", { max: 240 });
81
+ if (!id && !repoRoot) return error("id or repoRoot required", 400);
82
+ const released = resumeAutoMerge(id ? { id } : { repoRoot: repoRoot!, baseRef: baseRef ?? "main" });
83
+ return json({ released });
84
+ } catch (e) {
85
+ if (e instanceof ValidationError) return error(e.message, 400);
86
+ throw e;
87
+ }
88
+ };
89
+
90
+ export const getMergePauseById: Handler = (req, params) => {
91
+ const denied = authorizeRoute(req, { scope: "command:read" });
92
+ if (denied) return denied;
93
+ const pause = getAutoMergePause(params.id!);
94
+ return pause ? json(pause) : error("merge pause not found", 404);
95
+ };
package/src/security.ts CHANGED
@@ -195,6 +195,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
195
195
  if (pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
196
196
  if (pathname.startsWith("/api/messages")) return method === "GET" ? "message:read" : "message:send";
197
197
  if (pathname.startsWith("/api/commands")) return method === "GET" ? "command:read" : "command:write";
198
+ if (pathname.startsWith("/api/merges")) return method === "GET" ? "command:read" : "command:write";
198
199
  if (pathname.startsWith("/api/workspaces")) return method === "GET" ? "agent:read" : "agent:write";
199
200
  if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
200
201
  if (pathname.startsWith("/api/recipes")) return method === "GET" ? "recipes:read" : "recipes:write";
@@ -254,6 +255,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
254
255
  if (method === "GET") return "command:read";
255
256
  return "command:write";
256
257
  }
258
+ if (pathname.startsWith("/api/merges")) return method === "GET" ? "command:read" : "command:write";
257
259
  if (pathname.startsWith("/api/workspaces")) return method === "GET" ? "agent:read" : "agent:write";
258
260
  if (pathname.startsWith("/api/tokens") || pathname.startsWith("/api/token-profiles")) return method === "GET" ? "token:read" : "token:write";
259
261
  if (pathname.startsWith("/api/maintenance")) return "system:admin";
@@ -0,0 +1,37 @@
1
+ import {
2
+ acquireMergePause as acquireMergePauseRow,
3
+ getMergePause,
4
+ listMergePauses,
5
+ releaseMergePause as releaseMergePauseRow,
6
+ renewMergePause as renewMergePauseRow,
7
+ type MergePauseRecord,
8
+ } from "../db";
9
+
10
+ export type { MergePauseRecord };
11
+
12
+ export function listAutoMergePauses(): MergePauseRecord[] {
13
+ return listMergePauses();
14
+ }
15
+
16
+ export function getAutoMergePause(id: string): MergePauseRecord | null {
17
+ return getMergePause(id);
18
+ }
19
+
20
+ export function pauseAutoMerge(input: {
21
+ repoRoot: string;
22
+ baseRef?: string;
23
+ holder: string;
24
+ reason?: string;
25
+ ttlMs?: number;
26
+ now?: number;
27
+ }): { ok: true; pause: MergePauseRecord } | { ok: false; pause: MergePauseRecord } {
28
+ return acquireMergePauseRow(input);
29
+ }
30
+
31
+ export function renewAutoMergePause(id: string, ttlMs?: number): MergePauseRecord | null {
32
+ return renewMergePauseRow(id, ttlMs);
33
+ }
34
+
35
+ export function resumeAutoMerge(input: { id?: string; repoRoot?: string; baseRef?: string }): boolean {
36
+ return releaseMergePauseRow(input);
37
+ }
@@ -208,19 +208,21 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
208
208
  // resolves it. Absent (graceful) when the cwd is inside no registered root.
209
209
  const callerProject = resolveProjectForCwd(resolvedCwd);
210
210
 
211
- // #409 instruction projection (default OFF). Only when the caller opts in AND the
212
- // spawn cwd resolves to a project do we compose a knowledge preamble and PREPEND it to
213
- // systemPromptAppend. Absent flag / no project / empty block ⇒ systemPromptAppend is
214
- // left exactly as the caller passed it, so the spawn command stays byte-identical.
215
- let systemPromptAppend = input.systemPromptAppend;
211
+ // #409 + #463 one composition home for the prompt material handed to providers.
212
+ // Precedence is: project preamble, profile system, profile append entries, then
213
+ // transient caller/fork context. Spawns without profile/project material stay
214
+ // byte-identical to the caller-provided systemPromptAppend.
215
+ const appendBlocks: string[] = [];
216
216
  if (input.projectInstructions && callerProject) {
217
217
  const block = composeProjectInstructions(callerProject, {
218
218
  ...(input.projectInstructions.policy ? { policy: input.projectInstructions.policy } : {}),
219
219
  });
220
- if (block) {
221
- systemPromptAppend = systemPromptAppend ? `${block}\n\n${systemPromptAppend}` : block;
222
- }
220
+ if (block.trim()) appendBlocks.push(block);
223
221
  }
222
+ if (agentProfile?.instructions.system?.trim()) appendBlocks.push(agentProfile.instructions.system);
223
+ appendBlocks.push(...(agentProfile?.instructions.append.filter((entry) => entry.trim()) ?? []));
224
+ if (input.systemPromptAppend?.trim()) appendBlocks.push(input.systemPromptAppend);
225
+ const systemPromptAppend = appendBlocks.length ? appendBlocks.join("\n\n") : input.systemPromptAppend;
224
226
 
225
227
  // Child runner token. `agentInitiated` (set whenever an agent is the caller) forces a
226
228
  // non-spawn-capable child (no grandchildren); `spawnedBy` stamps authoritative lineage the