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.
- package/docs/openapi.json +156 -1
- package/package.json +1 -1
- package/public/assets/MessageBubble-vzupFQD8.js +2 -0
- package/public/assets/MessageBubble-vzupFQD8.js.map +1 -0
- package/public/assets/{PlanGraphPanel-CXix1C-H.js → PlanGraphPanel-DQvycBMW.js} +2 -2
- package/public/assets/{PlanGraphPanel-CXix1C-H.js.map → PlanGraphPanel-DQvycBMW.js.map} +1 -1
- package/public/assets/{activity-CRafQ1Je.js → activity-gmfkOBhQ.js} +2 -2
- package/public/assets/{activity-CRafQ1Je.js.map → activity-gmfkOBhQ.js.map} +1 -1
- package/public/assets/{agent-profiles-DW4o5-ND.js → agent-profiles-7X8gFuwj.js} +2 -2
- package/public/assets/{agent-profiles-DW4o5-ND.js.map → agent-profiles-7X8gFuwj.js.map} +1 -1
- package/public/assets/{agents-Cbegv0Hm.js → agents-D201CuTR.js} +2 -2
- package/public/assets/{agents-Cbegv0Hm.js.map → agents-D201CuTR.js.map} +1 -1
- package/public/assets/{analytics-DX6T7JX3.js → analytics-CkOTrr5t.js} +2 -2
- package/public/assets/{analytics-DX6T7JX3.js.map → analytics-CkOTrr5t.js.map} +1 -1
- package/public/assets/{automation-CzSBv4gg.js → automation-_5u-nVs8.js} +2 -2
- package/public/assets/{automation-CzSBv4gg.js.map → automation-_5u-nVs8.js.map} +1 -1
- package/public/assets/{branch-state-badge-CRA50r81.js → branch-state-badge-BPXXLpQG.js} +2 -2
- package/public/assets/{branch-state-badge-CRA50r81.js.map → branch-state-badge-BPXXLpQG.js.map} +1 -1
- package/public/assets/{channels-B1MQCPCT.js → channels-BsJu5TXR.js} +2 -2
- package/public/assets/{channels-B1MQCPCT.js.map → channels-BsJu5TXR.js.map} +1 -1
- package/public/assets/{chat-BqIaFYGJ.js → chat-CHgPGHQD.js} +2 -2
- package/public/assets/{chat-BqIaFYGJ.js.map → chat-CHgPGHQD.js.map} +1 -1
- package/public/assets/{connectors-C9aFJIrh.js → connectors-arjfBQvc.js} +2 -2
- package/public/assets/{connectors-C9aFJIrh.js.map → connectors-arjfBQvc.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DOXH7oDF.js → formatted-body-impl-DBt5tnS0.js} +2 -2
- package/public/assets/{formatted-body-impl-DOXH7oDF.js.map → formatted-body-impl-DBt5tnS0.js.map} +1 -1
- package/public/assets/{index-br_Y6Cvt.js → index-D7js3XlH.js} +6 -6
- package/public/assets/{index-br_Y6Cvt.js.map → index-D7js3XlH.js.map} +1 -1
- package/public/assets/{insights-B1edq5lk.js → insights-BvjkOXk9.js} +2 -2
- package/public/assets/{insights-B1edq5lk.js.map → insights-BvjkOXk9.js.map} +1 -1
- package/public/assets/{integrations-Cm4fjp7_.js → integrations-7L1f3eIE.js} +2 -2
- package/public/assets/{integrations-Cm4fjp7_.js.map → integrations-7L1f3eIE.js.map} +1 -1
- package/public/assets/{maintenance-D6zvw0fU.js → maintenance-BBoL--vs.js} +2 -2
- package/public/assets/{maintenance-D6zvw0fU.js.map → maintenance-BBoL--vs.js.map} +1 -1
- package/public/assets/{managed-agents-CzfEznM1.js → managed-agents-BjYBzVZt.js} +2 -2
- package/public/assets/{managed-agents-CzfEznM1.js.map → managed-agents-BjYBzVZt.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DZlCaTHQ.js → markdown-preview-impl-BnwjZsmO.js} +2 -2
- package/public/assets/{markdown-preview-impl-DZlCaTHQ.js.map → markdown-preview-impl-BnwjZsmO.js.map} +1 -1
- package/public/assets/{memory-CUUAzJDc.js → memory-SLycz2gC.js} +2 -2
- package/public/assets/{memory-CUUAzJDc.js.map → memory-SLycz2gC.js.map} +1 -1
- package/public/assets/{messages-6QZH46LG.js → messages-DfSCTe6N.js} +2 -2
- package/public/assets/{messages-6QZH46LG.js.map → messages-DfSCTe6N.js.map} +1 -1
- package/public/assets/{orchestrators-CrcqkPuc.js → orchestrators-BwxdTQm_.js} +2 -2
- package/public/assets/{orchestrators-CrcqkPuc.js.map → orchestrators-BwxdTQm_.js.map} +1 -1
- package/public/assets/{overview-418xXZO_.js → overview-Djy3qkY5.js} +2 -2
- package/public/assets/{overview-418xXZO_.js.map → overview-Djy3qkY5.js.map} +1 -1
- package/public/assets/{pairs-CnedATgT.js → pairs-9KNduLnC.js} +2 -2
- package/public/assets/{pairs-CnedATgT.js.map → pairs-9KNduLnC.js.map} +1 -1
- package/public/assets/{security-DS13pbag.js → security-tG3q7fFW.js} +2 -2
- package/public/assets/{security-DS13pbag.js.map → security-tG3q7fFW.js.map} +1 -1
- package/public/assets/{select-BOprGZDS.js → select-CGUdMNOG.js} +2 -2
- package/public/assets/{select-BOprGZDS.js.map → select-CGUdMNOG.js.map} +1 -1
- package/public/assets/{settings-BgInn_f3.js → settings-CGcqyJ5L.js} +2 -2
- package/public/assets/{settings-BgInn_f3.js.map → settings-CGcqyJ5L.js.map} +1 -1
- package/public/assets/{store-DHO_a8BD.js → store-DXdfhiVf.js} +2 -2
- package/public/assets/store-DXdfhiVf.js.map +1 -0
- package/public/assets/{tasks-B-4oCPuv.js → tasks-DWlKg218.js} +2 -2
- package/public/assets/{tasks-B-4oCPuv.js.map → tasks-DWlKg218.js.map} +1 -1
- package/public/assets/{teams-sCQAs_QA.js → teams-DRchUP_p.js} +2 -2
- package/public/assets/{teams-sCQAs_QA.js.map → teams-DRchUP_p.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-BhqS2XpS.js → terminal-viewer-impl-D_Sqe-vo.js} +2 -2
- package/public/assets/{terminal-viewer-impl-BhqS2XpS.js.map → terminal-viewer-impl-D_Sqe-vo.js.map} +1 -1
- package/public/assets/{work-queue-BDhjlwUH.js → work-queue-HNcyjON9.js} +2 -2
- package/public/assets/{work-queue-BDhjlwUH.js.map → work-queue-HNcyjON9.js.map} +1 -1
- package/public/assets/{workspaces-6I-C69nf.js → workspaces-CicYE2Ax.js} +2 -2
- package/public/assets/{workspaces-6I-C69nf.js.map → workspaces-CicYE2Ax.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/cli/index.ts +11 -0
- package/src/cli/merges.ts +89 -0
- package/src/config.ts +4 -0
- package/src/db/merge-lease.ts +95 -1
- package/src/db/migrations.ts +15 -0
- package/src/maintenance.ts +8 -8
- package/src/mcp-merge-tools.ts +66 -0
- package/src/mcp.ts +3 -4
- package/src/merge-pause-maintenance.ts +13 -0
- package/src/routes/agent-sessions.ts +4 -2
- package/src/routes/commands.ts +20 -0
- package/src/routes/index.ts +7 -0
- package/src/routes/merges.ts +95 -0
- package/src/security.ts +2 -0
- package/src/services/merge-pause.ts +37 -0
- package/src/services/spawn-agent.ts +10 -8
- package/src/workspace-pr-completion.ts +70 -0
- package/public/assets/MessageBubble-BrEKymoV.js +0 -2
- package/public/assets/MessageBubble-BrEKymoV.js.map +0 -1
- package/public/assets/store-DHO_a8BD.js.map +0 -1
package/src/db/merge-lease.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/db/migrations.ts
CHANGED
|
@@ -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")) {
|
package/src/maintenance.ts
CHANGED
|
@@ -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);
|
package/src/routes/commands.ts
CHANGED
|
@@ -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.
|
package/src/routes/index.ts
CHANGED
|
@@ -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
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
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
|