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.
- package/docs/openapi.json +151 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-BTBQr8uG.js → MessageBubble-DqFyach0.js} +2 -2
- package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-DqFyach0.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-CCvKbYWe.js} +2 -2
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-CCvKbYWe.js.map} +1 -1
- package/public/assets/{activity-XqRFu_9T.js → activity-C6RvknQQ.js} +2 -2
- package/public/assets/{activity-XqRFu_9T.js.map → activity-C6RvknQQ.js.map} +1 -1
- package/public/assets/{agents-BWmmWj4Q.js → agents-D7HZJ5eG.js} +2 -2
- package/public/assets/{agents-BWmmWj4Q.js.map → agents-D7HZJ5eG.js.map} +1 -1
- package/public/assets/{automation-DRtOENs0.js → automation-BUazA3lr.js} +2 -2
- package/public/assets/{automation-DRtOENs0.js.map → automation-BUazA3lr.js.map} +1 -1
- package/public/assets/{chat-gfUxbTus.js → chat-DBTEaf-H.js} +2 -2
- package/public/assets/{chat-gfUxbTus.js.map → chat-DBTEaf-H.js.map} +1 -1
- package/public/assets/display-CWMHll1J.js.map +1 -1
- package/public/assets/{formatted-body-impl-DpNHGXXQ.js → formatted-body-impl-KG0iExZ-.js} +2 -2
- package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-KG0iExZ-.js.map} +1 -1
- package/public/assets/index-LUIvWcx6.css +2 -0
- package/public/assets/index-TZskQfQL.js +23 -0
- package/public/assets/index-TZskQfQL.js.map +1 -0
- package/public/assets/{insights-DPaJ5Hme.js → insights-JL1s7Uul.js} +2 -2
- package/public/assets/{insights-DPaJ5Hme.js.map → insights-JL1s7Uul.js.map} +1 -1
- package/public/assets/{maintenance-DsszLb92.js → maintenance-CTMfhXFM.js} +2 -2
- package/public/assets/{maintenance-DsszLb92.js.map → maintenance-CTMfhXFM.js.map} +1 -1
- package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-C4cb3xQ4.js} +2 -2
- package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-C4cb3xQ4.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DQIi0O7r.js} +2 -2
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DQIi0O7r.js.map} +1 -1
- package/public/assets/{memory-C2s9bJ38.js → memory-DJPXFdgw.js} +2 -2
- package/public/assets/{memory-C2s9bJ38.js.map → memory-DJPXFdgw.js.map} +1 -1
- package/public/assets/{messages-DHRJNAvy.js → messages-RSD2g4Wx.js} +2 -2
- package/public/assets/{messages-DHRJNAvy.js.map → messages-RSD2g4Wx.js.map} +1 -1
- package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-BGIkULBF.js} +2 -2
- package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-BGIkULBF.js.map} +1 -1
- package/public/assets/{overview-C1vZfyhW.js → overview-ByE9V8pe.js} +2 -2
- package/public/assets/{overview-C1vZfyhW.js.map → overview-ByE9V8pe.js.map} +1 -1
- package/public/assets/{pairs-C1uzo8x0.js → pairs-C284ntvD.js} +2 -2
- package/public/assets/{pairs-C1uzo8x0.js.map → pairs-C284ntvD.js.map} +1 -1
- package/public/assets/{security-XhAAP6q3.js → security-vDglScsg.js} +2 -2
- package/public/assets/{security-XhAAP6q3.js.map → security-vDglScsg.js.map} +1 -1
- package/public/assets/{select-DEGUFEjx.js → select-DaOiWDBR.js} +2 -2
- package/public/assets/{select-DEGUFEjx.js.map → select-DaOiWDBR.js.map} +1 -1
- package/public/assets/{settings-DTbVUdlZ.js → settings-DiLcbSqx.js} +2 -2
- package/public/assets/{settings-DTbVUdlZ.js.map → settings-DiLcbSqx.js.map} +1 -1
- package/public/assets/{tasks-zwGo7hon.js → tasks-DWJdJra3.js} +2 -2
- package/public/assets/{tasks-zwGo7hon.js.map → tasks-DWJdJra3.js.map} +1 -1
- package/public/assets/{teams-Brc5D8ox.js → teams-FejXkix4.js} +2 -2
- package/public/assets/{teams-Brc5D8ox.js.map → teams-FejXkix4.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-Bkt1w42o.js} +2 -2
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-Bkt1w42o.js.map} +1 -1
- package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-ClE2dMD8.js} +2 -2
- package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-ClE2dMD8.js.map} +1 -1
- package/public/assets/{work-queue-ge4R7keo.js → work-queue-DZdv9kOb.js} +2 -2
- package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-DZdv9kOb.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/agent-death-diagnosis.ts +255 -0
- package/src/agent-lifecycle-events.ts +42 -19
- package/src/auth/index.ts +23 -0
- package/src/auth/password.ts +122 -0
- package/src/auth/provider.ts +60 -0
- package/src/auth/session.ts +92 -0
- package/src/bus.ts +18 -4
- package/src/db/agents.ts +35 -2
- package/src/db/auth-identities.ts +119 -0
- package/src/db/crash-reports.ts +115 -0
- package/src/db/index.ts +2 -0
- package/src/db/migrations.ts +11 -0
- package/src/index.ts +6 -1
- package/src/maintenance/jobs.ts +6 -1
- package/src/maintenance/teams.ts +68 -1
- package/src/mcp/index.ts +2 -1
- package/src/mcp/tools-messaging.ts +38 -0
- package/src/notification-types.ts +2 -0
- package/src/routes/_shared.ts +40 -2
- package/src/routes/agents.ts +11 -1
- package/src/routes/auth.ts +107 -0
- package/src/routes/commands.ts +21 -3
- package/src/routes/index.ts +10 -1
- package/src/routes/orchestrator.ts +14 -1
- package/src/routes/tokens.ts +8 -2
- package/src/security.ts +1 -1
- package/src/services/terminal-provider-exit.ts +33 -0
- package/src/spawn-targets.ts +19 -8
- package/src/team-idle-tracker.ts +15 -0
- package/src/validation.ts +2 -0
- package/src/workspace-actions.ts +5 -1
- package/src/workspace-auto-merge.ts +87 -4
- package/src/workspace-merge.ts +10 -0
- package/public/assets/index-D8TVUdlC.js +0 -22
- package/public/assets/index-D8TVUdlC.js.map +0 -1
- package/public/assets/index-DgZLDL0K.css +0 -2
package/src/spawn-targets.ts
CHANGED
|
@@ -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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
};
|
package/src/team-idle-tracker.ts
CHANGED
|
@@ -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`);
|
package/src/workspace-actions.ts
CHANGED
|
@@ -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
|
package/src/workspace-merge.ts
CHANGED
|
@@ -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 {
|