agent-relay-server 0.120.6 → 0.121.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 +1 -1
- package/package.json +5 -4
- package/public/assets/{MessageBubble-So86mU9-.js → MessageBubble-CLqxCG9b.js} +2 -2
- package/public/assets/{MessageBubble-So86mU9-.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-DBZYAsww.js → PlanGraphPanel-BkkToFEb.js} +2 -2
- package/public/assets/{PlanGraphPanel-DBZYAsww.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
- package/public/assets/{activity-CZNwmXg1.js → activity-D-ocGQGs.js} +2 -2
- package/public/assets/{activity-CZNwmXg1.js.map → activity-D-ocGQGs.js.map} +1 -1
- package/public/assets/{agent-profiles-BhadoGwi.js → agent-profiles-CRPgYTyb.js} +2 -2
- package/public/assets/{agent-profiles-BhadoGwi.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
- package/public/assets/{agents-DWn1xx9x.js → agents-Chw5E_2q.js} +2 -2
- package/public/assets/{agents-DWn1xx9x.js.map → agents-Chw5E_2q.js.map} +1 -1
- package/public/assets/{analytics-DifGnk7q.js → analytics-BmNDG0hR.js} +2 -2
- package/public/assets/{analytics-DifGnk7q.js.map → analytics-BmNDG0hR.js.map} +1 -1
- package/public/assets/{automation-BExrtKkb.js → automation-D7g90kTv.js} +2 -2
- package/public/assets/{automation-BExrtKkb.js.map → automation-D7g90kTv.js.map} +1 -1
- package/public/assets/{branch-state-badge-CyC5Qrhc.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
- package/public/assets/{branch-state-badge-CyC5Qrhc.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
- package/public/assets/{channels-BLkdt2XR.js → channels-CLwPeEPi.js} +2 -2
- package/public/assets/{channels-BLkdt2XR.js.map → channels-CLwPeEPi.js.map} +1 -1
- package/public/assets/{chat-C7OhKtr6.js → chat-Cgj7VKzc.js} +2 -2
- package/public/assets/{chat-C7OhKtr6.js.map → chat-Cgj7VKzc.js.map} +1 -1
- package/public/assets/{connectors-C0n12Je8.js → connectors-Br6fiTny.js} +2 -2
- package/public/assets/{connectors-C0n12Je8.js.map → connectors-Br6fiTny.js.map} +1 -1
- package/public/assets/{formatted-body-BCQT2qSb.js → formatted-body-CmkuD23M.js} +3 -3
- package/public/assets/{formatted-body-BCQT2qSb.js.map → formatted-body-CmkuD23M.js.map} +1 -1
- package/public/assets/{formatted-body-impl-Bk7wnESl.js → formatted-body-impl-DbdFmbwW.js} +2 -2
- package/public/assets/{formatted-body-impl-Bk7wnESl.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
- package/public/assets/{index-CRraCeo_.js → index-D9OogaB5.js} +6 -6
- package/public/assets/{index-CRraCeo_.js.map → index-D9OogaB5.js.map} +1 -1
- package/public/assets/{insights-DEgES6jz.js → insights-qvaPqWCV.js} +2 -2
- package/public/assets/{insights-DEgES6jz.js.map → insights-qvaPqWCV.js.map} +1 -1
- package/public/assets/{integrations-v14ehskT.js → integrations-DYEGSqq_.js} +2 -2
- package/public/assets/{integrations-v14ehskT.js.map → integrations-DYEGSqq_.js.map} +1 -1
- package/public/assets/{maintenance-BgVB6_gO.js → maintenance-C0HIEB-J.js} +2 -2
- package/public/assets/{maintenance-BgVB6_gO.js.map → maintenance-C0HIEB-J.js.map} +1 -1
- package/public/assets/{managed-agents-8_NKSkhI.js → managed-agents-DdS7aI38.js} +2 -2
- package/public/assets/{managed-agents-8_NKSkhI.js.map → managed-agents-DdS7aI38.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-yxQkrd2M.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
- package/public/assets/{markdown-preview-impl-yxQkrd2M.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
- package/public/assets/{memory-CJD8eekg.js → memory-CSwx7bz8.js} +2 -2
- package/public/assets/{memory-CJD8eekg.js.map → memory-CSwx7bz8.js.map} +1 -1
- package/public/assets/{messages-DLHMZbHZ.js → messages-Be9CmvDv.js} +2 -2
- package/public/assets/{messages-DLHMZbHZ.js.map → messages-Be9CmvDv.js.map} +1 -1
- package/public/assets/{orchestrators-DEOep7FX.js → orchestrators-BgtvvHvo.js} +2 -2
- package/public/assets/{orchestrators-DEOep7FX.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
- package/public/assets/{overview-BgpfO6mt.js → overview-BvhbPWtF.js} +2 -2
- package/public/assets/{overview-BgpfO6mt.js.map → overview-BvhbPWtF.js.map} +1 -1
- package/public/assets/{pairs-8NVKHh96.js → pairs-CpXSOMx0.js} +2 -2
- package/public/assets/{pairs-8NVKHh96.js.map → pairs-CpXSOMx0.js.map} +1 -1
- package/public/assets/{projects-CZJW35C7.js → projects-DdPNPFJI.js} +2 -2
- package/public/assets/{projects-CZJW35C7.js.map → projects-DdPNPFJI.js.map} +1 -1
- package/public/assets/{prompt-settings-fqyLwUa-.js → prompt-settings-BpZse7-X.js} +2 -2
- package/public/assets/{prompt-settings-fqyLwUa-.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
- package/public/assets/{registry-BKbLL4mO.js → registry-pb1gTZEg.js} +2 -2
- package/public/assets/{registry-BKbLL4mO.js.map → registry-pb1gTZEg.js.map} +1 -1
- package/public/assets/{security-BlTFTbq0.js → security-BgcX1n9D.js} +2 -2
- package/public/assets/{security-BlTFTbq0.js.map → security-BgcX1n9D.js.map} +1 -1
- package/public/assets/{select-Zd_qh0uU.js → select-DZPVpKPv.js} +2 -2
- package/public/assets/{select-Zd_qh0uU.js.map → select-DZPVpKPv.js.map} +1 -1
- package/public/assets/{settings-nHuSVRkL.js → settings-hd5kzdRB.js} +4 -4
- package/public/assets/settings-hd5kzdRB.js.map +1 -0
- package/public/assets/store-DKTLubBT.js +9 -0
- package/public/assets/store-DKTLubBT.js.map +1 -0
- package/public/assets/{tasks-C3JhQ8vB.js → tasks-C5d2LU5E.js} +2 -2
- package/public/assets/{tasks-C3JhQ8vB.js.map → tasks-C5d2LU5E.js.map} +1 -1
- package/public/assets/{teams-C9E9Yt39.js → teams-B8f2pGJe.js} +2 -2
- package/public/assets/{teams-C9E9Yt39.js.map → teams-B8f2pGJe.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-FP0JlsvS.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
- package/public/assets/{terminal-viewer-impl-FP0JlsvS.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-Ema7Hy3s.js → work-queue-9imc7aNK.js} +2 -2
- package/public/assets/{work-queue-Ema7Hy3s.js.map → work-queue-9imc7aNK.js.map} +1 -1
- package/public/assets/{workspaces-DI317fMk.js → workspaces-CLYc3yF3.js} +2 -2
- package/public/assets/{workspaces-DI317fMk.js.map → workspaces-CLYc3yF3.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
- package/src/agent-card-projection.ts +39 -9
- package/src/agent-issue-repo.ts +10 -3
- package/src/agent-lifecycle-events.ts +23 -1
- package/src/agent-ref.ts +18 -3
- package/src/bus-outbox.ts +74 -1
- package/src/bus.ts +84 -5
- package/src/channel-target.ts +4 -0
- package/src/cli/steward.ts +15 -14
- package/src/commands-db.ts +63 -9
- package/src/config-store.ts +2 -0
- package/src/config.ts +54 -0
- package/src/db/claim-sql.ts +16 -0
- package/src/db/connection.ts +3 -1
- package/src/db/continuations.ts +44 -3
- package/src/db/index.ts +1 -0
- package/src/db/maintenance-worker.ts +91 -0
- package/src/db/message-reads.ts +34 -4
- package/src/db/messages.ts +147 -8
- package/src/db/migrations.ts +7 -0
- package/src/db/pairs.ts +1 -0
- package/src/db/retention.ts +130 -0
- package/src/db/schema.ts +11 -0
- package/src/db/stats.ts +22 -0
- package/src/event-loop-lag.ts +90 -0
- package/src/gate-resolver.ts +124 -0
- package/src/lifecycle-manager.ts +41 -10
- package/src/lifecycle-signal-registry.ts +87 -0
- package/src/maintenance/constants.ts +5 -1
- package/src/maintenance/jobs.ts +48 -8
- package/src/maintenance/workspaces.ts +75 -44
- package/src/managed-policy-escalation.ts +68 -3
- package/src/mcp/tools-spawn.ts +16 -6
- package/src/mcp-plan-tools.ts +2 -3
- package/src/notification-settings.ts +11 -0
- package/src/notification-types.ts +4 -0
- package/src/operator-alarm.ts +37 -0
- package/src/retention-config-store.ts +32 -0
- package/src/retention-config.ts +58 -0
- package/src/routes/agents.ts +4 -0
- package/src/routes/messages.ts +13 -1
- package/src/routes/orchestrator-proxy.ts +13 -1
- package/src/routes/orchestrator.ts +4 -0
- package/src/routing-policy-store.ts +11 -1
- package/src/security.ts +20 -0
- package/src/self-resume.ts +185 -21
- package/src/server.ts +4 -0
- package/src/services/plan-graph.ts +42 -12
- package/src/services/register-agent.ts +5 -1
- package/src/services/send-message.ts +99 -21
- package/src/services/spawn-agent.ts +21 -2
- package/src/services/spawn-registration-watchdog.ts +74 -9
- package/src/services/task-semantic-events.ts +17 -1
- package/src/settings/registry.ts +11 -0
- package/src/spawn-command.ts +3 -0
- package/src/spawn-targets.ts +26 -1
- package/src/sse.ts +67 -14
- package/src/workspace-auto-merge.ts +90 -34
- package/src/workspace-escalation.ts +52 -3
- package/src/workspace-human-escalation.ts +68 -0
- package/src/workspace-orphans.ts +11 -6
- package/src/workspace-phase.ts +22 -1
- package/public/assets/settings-nHuSVRkL.js.map +0 -1
- package/public/assets/store-nXGxgz2Q.js +0 -9
- package/public/assets/store-nXGxgz2Q.js.map +0 -1
package/src/sse.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getAgent, getOrchestrator, matchingDeliveryAgents, onWorkspaceChange, sendMessageWithResult, setChannelSubscriberProbe } from "./db";
|
|
2
|
-
import {
|
|
2
|
+
import { getOutboxCursor, hasReplayGap, replayEventsSlice, type BusEvent } from "./bus-outbox";
|
|
3
3
|
import { projectAgentStatusData } from "./agent-card-projection";
|
|
4
4
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
5
5
|
import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
|
|
@@ -161,6 +161,10 @@ function fanout(event: RelayEvent): void {
|
|
|
161
161
|
sendNewMessage(event.data as unknown as Message, event.seq);
|
|
162
162
|
return;
|
|
163
163
|
}
|
|
164
|
+
if (event.type === "message.batch") {
|
|
165
|
+
sendNewMessages(batchMessages(event.data), event.seq);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
164
168
|
if (event.type === "task.updated" || event.type === "task.created" || event.type === "task.claimed" || event.type === "task.status") {
|
|
165
169
|
sendTaskChanged(event.data as unknown as Task, event.type, event.seq);
|
|
166
170
|
return;
|
|
@@ -172,28 +176,62 @@ function fanout(event: RelayEvent): void {
|
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
function sendNewMessage(msg: Message, seq?: number): void {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
sendNewMessages([msg], seq);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function sendNewMessages(messages: Message[], seq?: number): void {
|
|
183
|
+
if (messages.length === 0) return;
|
|
184
|
+
const chunks = messages.map((msg, index) => [msg, sseFrame("message.new", msg, seq === undefined ? undefined : seq + index)] as const);
|
|
185
|
+
const broadcastScopes = new Map<number, Set<string>>();
|
|
186
|
+
for (const msg of messages) {
|
|
187
|
+
if (msg.to === "broadcast") {
|
|
188
|
+
broadcastScopes.set(msg.id, new Set(matchingDeliveryAgents("broadcast", { from: msg.from, channel: msg.channel }).map((agent) => agent.id)));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
179
191
|
const agentCache = new Map<string, AgentCard | null>();
|
|
180
192
|
for (const conn of connections.values()) {
|
|
181
|
-
|
|
193
|
+
for (const [msg, chunk] of chunks) {
|
|
194
|
+
if (!conn.agentId) {
|
|
195
|
+
sendEncoded(conn, chunk);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const scopedBroadcastAgentIds = broadcastScopes.get(msg.id) ?? null;
|
|
199
|
+
if (scopedBroadcastAgentIds && !scopedBroadcastAgentIds.has(conn.agentId)) continue;
|
|
200
|
+
if (!messageMatchesAgent(msg, conn.agentId, fanoutAgent(conn.agentId, agentCache))) continue;
|
|
182
201
|
sendEncoded(conn, chunk);
|
|
183
|
-
continue;
|
|
184
202
|
}
|
|
185
|
-
if (scopedBroadcastAgentIds && !scopedBroadcastAgentIds.has(conn.agentId)) continue;
|
|
186
|
-
if (!messageMatchesAgent(msg, conn.agentId, fanoutAgent(conn.agentId, agentCache))) continue;
|
|
187
|
-
sendEncoded(conn, chunk);
|
|
188
203
|
}
|
|
189
204
|
}
|
|
190
205
|
|
|
206
|
+
function batchMessages(data: Record<string, unknown>): Message[] {
|
|
207
|
+
const messages = (data as { messages?: unknown }).messages;
|
|
208
|
+
return Array.isArray(messages) ? messages as Message[] : [];
|
|
209
|
+
}
|
|
210
|
+
|
|
191
211
|
export function emitNewMessage(msg: Message) {
|
|
192
212
|
emitRelayEvent({ type: "message.new", source: msg.from, subject: String(msg.id), data: msg as unknown as Record<string, unknown> });
|
|
193
213
|
const notification = notificationForMessage(msg);
|
|
194
214
|
if (notification) emitNotificationCreated(notification);
|
|
195
215
|
}
|
|
196
216
|
|
|
217
|
+
export function emitNewMessages(messages: Message[]) {
|
|
218
|
+
if (messages.length === 0) return;
|
|
219
|
+
if (messages.length === 1) {
|
|
220
|
+
emitNewMessage(messages[0]!);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
emitRelayEvent({
|
|
224
|
+
type: "message.batch",
|
|
225
|
+
source: messages[0]!.from,
|
|
226
|
+
subject: `${messages[0]!.id}:${messages.at(-1)!.id}`,
|
|
227
|
+
data: { messages } as unknown as Record<string, unknown>,
|
|
228
|
+
});
|
|
229
|
+
for (const msg of messages) {
|
|
230
|
+
const notification = notificationForMessage(msg);
|
|
231
|
+
if (notification) emitNotificationCreated(notification);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
197
235
|
interface RelayInjectionTimelineInput {
|
|
198
236
|
agentId: string;
|
|
199
237
|
category: RelayInjectionCategory;
|
|
@@ -310,8 +348,7 @@ interface ReplayPlan {
|
|
|
310
348
|
function replayPlan(lastEventId: number): ReplayPlan {
|
|
311
349
|
const since = Number.isSafeInteger(lastEventId) && lastEventId > 0 ? lastEventId : 0;
|
|
312
350
|
if (since === 0) return { requested: false, since: 0, available: false, gap: false };
|
|
313
|
-
const
|
|
314
|
-
const gap = oldest > 0 && oldest > since + 1;
|
|
351
|
+
const gap = hasReplayGap(since);
|
|
315
352
|
return { requested: true, since, available: !gap, gap };
|
|
316
353
|
}
|
|
317
354
|
|
|
@@ -321,13 +358,29 @@ function replayEventsToConnection(conn: Connection | undefined, plan: ReplayPlan
|
|
|
321
358
|
send(conn, "replay.gap", { since: plan.since }, getOutboxCursor());
|
|
322
359
|
return;
|
|
323
360
|
}
|
|
361
|
+
// Drain the whole backlog in pages. A single capped replay dropped everything past the first 500
|
|
362
|
+
// events between the client's cursor and the outbox head (#1022 F1) — the SSE stream then went
|
|
363
|
+
// live from the head with no signal, so the tail was lost. Loop until the page is not truncated.
|
|
324
364
|
const agentCache = new Map<string, AgentCard | null>();
|
|
325
|
-
|
|
326
|
-
|
|
365
|
+
let since = plan.since;
|
|
366
|
+
for (;;) {
|
|
367
|
+
const slice = replayEventsSlice(since);
|
|
368
|
+
for (const event of slice.events) {
|
|
369
|
+
sendReplayEvent(conn, outboxToRelayEvent(event), agentCache);
|
|
370
|
+
}
|
|
371
|
+
if (!slice.truncated) break;
|
|
372
|
+
since = slice.lastSeq;
|
|
327
373
|
}
|
|
328
374
|
}
|
|
329
375
|
|
|
330
376
|
function sendReplayEvent(conn: Connection, event: RelayEvent, agentCache: Map<string, AgentCard | null>): void {
|
|
377
|
+
if (event.type === "message.batch") {
|
|
378
|
+
for (const [index, msg] of batchMessages(event.data).entries()) {
|
|
379
|
+
if (conn.agentId && !messageMatchesAgent(msg, conn.agentId, fanoutAgent(conn.agentId, agentCache))) continue;
|
|
380
|
+
send(conn, "message.new", msg, event.seq + index);
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
331
384
|
if (event.type === "message.new" && conn.agentId) {
|
|
332
385
|
const msg = event.data as unknown as Message;
|
|
333
386
|
if (!messageMatchesAgent(msg, conn.agentId, fanoutAgent(conn.agentId, agentCache))) return;
|
|
@@ -43,6 +43,7 @@ import { settleWorkspaceCommand } from "./workspace-command-settle";
|
|
|
43
43
|
import { CLEAR_MERGE_NO_PROGRESS, MAX_NO_PROGRESS_MERGE_ATTEMPTS, mergeNoProgressCount, noteMergeNoProgress } from "./workspace-merge-progress";
|
|
44
44
|
import { workspaceOwnerOrchestrator } from "./workspace-host";
|
|
45
45
|
import { workspaceActiveClaim } from "./workspace-claim";
|
|
46
|
+
import { notifyWorkspaceHumanEscalation } from "./workspace-human-escalation";
|
|
46
47
|
import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES } from "./workspace-phase";
|
|
47
48
|
import { fetchHostMergePreview } from "./workspace-probe";
|
|
48
49
|
import type { Command, WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMergePreview, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
|
|
@@ -119,20 +120,33 @@ function autoMergeRequestOptions(ws: WorkspaceRecord): AutoMergeRequestOptions {
|
|
|
119
120
|
// auto-provision the policy on the workspace-owning host, then queue a `policy:` wake
|
|
120
121
|
// message (which also spawns the on-demand agent now via onMessageForPolicy). Honors a
|
|
121
122
|
// per-workspace cooldown so a persistent conflict/behind row isn't re-pinged every sweep.
|
|
122
|
-
//
|
|
123
|
-
|
|
123
|
+
// The result of trying to wake the managed steward (#1021 A1). `ok` carries the policy name
|
|
124
|
+
// only when a steward is genuinely alive or was just spawned. On `!ok`, `cooldown` tells a
|
|
125
|
+
// fresh failure (nothing spawned — the caller should advance the loop-breaker) apart from a
|
|
126
|
+
// recent successful wake still inside its cooldown window (NOT a failure — leave it be).
|
|
127
|
+
export type StewardWakeOutcome =
|
|
128
|
+
| { ok: true; policy: string }
|
|
129
|
+
| { ok: false; cooldown: boolean; error?: string };
|
|
130
|
+
|
|
131
|
+
// Wake the managed per-host/repo steward for a workspace it should handle: auto-provision
|
|
132
|
+
// the policy on the workspace-owning host, queue a `policy:` wake message, and spawn the
|
|
133
|
+
// on-demand agent. Only reports `ok` when the spawn actually happened (or a steward was
|
|
134
|
+
// already live) — a queued message alone is NOT a wake (#1021 A1). Honors a per-workspace
|
|
135
|
+
// cooldown so a persistent conflict/behind row isn't re-pinged every sweep.
|
|
136
|
+
export function wakeRepoSteward(ws: WorkspaceRecord, reason: string): StewardWakeOutcome {
|
|
124
137
|
const meta = ws.metadata as Record<string, unknown>;
|
|
125
138
|
const lastWoke = typeof meta.stewardWokenAt === "number" ? meta.stewardWokenAt : 0;
|
|
126
|
-
if (lastWoke && Date.now() - lastWoke < STEWARD_WAKE_COOLDOWN_MS) return
|
|
139
|
+
if (lastWoke && Date.now() - lastWoke < STEWARD_WAKE_COOLDOWN_MS) return { ok: false, cooldown: true };
|
|
127
140
|
const ownerResult = stewardOwnerForWorkspace(ws);
|
|
128
141
|
if ("error" in ownerResult) {
|
|
129
142
|
patchWorkspaceMetadata(ws.id, { stewardWakeError: ownerResult.error, stewardWakeErrorAt: Date.now() });
|
|
130
|
-
return
|
|
143
|
+
return { ok: false, cooldown: false, error: ownerResult.error };
|
|
131
144
|
}
|
|
132
145
|
const policyName = ensureRepoSteward(ws.repoRoot, ownerResult.owner);
|
|
133
146
|
if (!policyName) {
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
const error = "cannot spawn steward: steward policy could not be provisioned";
|
|
148
|
+
patchWorkspaceMetadata(ws.id, { stewardWakeError: error, stewardWakeErrorAt: Date.now() });
|
|
149
|
+
return { ok: false, cooldown: false, error };
|
|
136
150
|
}
|
|
137
151
|
// #640 — resolve the identity triangle so the steward isn't blind to the people-graph:
|
|
138
152
|
// the workspace's branch agent (owner) and that agent's coordinator (owner's `spawnedBy`).
|
|
@@ -163,7 +177,16 @@ export function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | n
|
|
|
163
177
|
payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason, ownerAgentId, coordinatorAgentId },
|
|
164
178
|
},
|
|
165
179
|
});
|
|
166
|
-
|
|
180
|
+
// #1021 A1 — the durable `policy:` obligation is enqueued above (it survives until a
|
|
181
|
+
// steward claims it), but a wake is only real if a steward actually spawned or is
|
|
182
|
+
// already live. If nothing spawned (host offline / provider error), record the error and
|
|
183
|
+
// report failure — do NOT stamp stewardWokenAt (which arms the cooldown) or claim a
|
|
184
|
+
// hand-off that never happened.
|
|
185
|
+
const spawn = getLifecycleManager().onMessageForPolicy(policyName);
|
|
186
|
+
if (!spawn.woke) {
|
|
187
|
+
patchWorkspaceMetadata(ws.id, { stewardWakeError: spawn.reason, stewardWakeErrorAt: Date.now() });
|
|
188
|
+
return { ok: false, cooldown: false, error: spawn.reason };
|
|
189
|
+
}
|
|
167
190
|
patchWorkspaceMetadata(ws.id, {
|
|
168
191
|
stewardWokenAt: Date.now(),
|
|
169
192
|
stewardPolicy: policyName,
|
|
@@ -173,10 +196,11 @@ export function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | n
|
|
|
173
196
|
});
|
|
174
197
|
// #641/#642 — tell the owner which steward took over and proactively loop in its coordinator.
|
|
175
198
|
notifyStewardHandoff({ workspace: ws, stewardId: policyName, coordinatorAgentId, reason });
|
|
176
|
-
return policyName;
|
|
199
|
+
return { ok: true, policy: policyName };
|
|
177
200
|
} catch (e) {
|
|
178
|
-
|
|
179
|
-
|
|
201
|
+
const error = e instanceof Error ? e.message : "failed to wake steward";
|
|
202
|
+
patchWorkspaceMetadata(ws.id, { stewardWakeError: error, stewardWakeErrorAt: Date.now() });
|
|
203
|
+
return { ok: false, cooldown: false, error };
|
|
180
204
|
}
|
|
181
205
|
}
|
|
182
206
|
|
|
@@ -227,25 +251,36 @@ function escalateNoProgressMerge(ws: WorkspaceRecord): void {
|
|
|
227
251
|
const { coordinatorAgentId } = resolveStewardIdentity(ws);
|
|
228
252
|
const fallback = stewardFallbackTarget();
|
|
229
253
|
const recipients = [...new Set([coordinatorAgentId, fallback, ws.ownerAgentId].filter((r): r is string => Boolean(r)))];
|
|
254
|
+
const subject = "Workspace can't auto-land — needs attention";
|
|
255
|
+
const body = renderTemplate("steward.merge-stall", {
|
|
256
|
+
branch: `\`${ws.branch ?? ws.id}\``,
|
|
257
|
+
repoRoot: ws.repoRoot,
|
|
258
|
+
attempts: String(MAX_NO_PROGRESS_MERGE_ATTEMPTS),
|
|
259
|
+
mergeError,
|
|
260
|
+
baseRef: ws.baseRef ?? "base",
|
|
261
|
+
});
|
|
262
|
+
const 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 };
|
|
263
|
+
let escalated = false;
|
|
230
264
|
if (recipients.length) {
|
|
231
265
|
routeNotification({
|
|
232
266
|
type: "workspace.stranded_escalation",
|
|
233
267
|
scope: { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ws.ownerAgentId, parent: coordinatorAgentId ?? undefined },
|
|
234
268
|
recipients,
|
|
235
269
|
message: {
|
|
236
|
-
subject
|
|
237
|
-
body
|
|
238
|
-
|
|
239
|
-
repoRoot: ws.repoRoot,
|
|
240
|
-
attempts: String(MAX_NO_PROGRESS_MERGE_ATTEMPTS),
|
|
241
|
-
mergeError,
|
|
242
|
-
baseRef: ws.baseRef ?? "base",
|
|
243
|
-
}),
|
|
244
|
-
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 },
|
|
270
|
+
subject,
|
|
271
|
+
body,
|
|
272
|
+
payload,
|
|
245
273
|
},
|
|
246
274
|
});
|
|
275
|
+
escalated = true;
|
|
276
|
+
} else {
|
|
277
|
+
const human = notifyWorkspaceHumanEscalation({ subject, body, payload, source: "workspace-auto-merge" });
|
|
278
|
+
escalated = human.accepted;
|
|
279
|
+
if (!human.accepted) {
|
|
280
|
+
console.warn(`[workspace-auto-merge] no-progress escalation for ${ws.id} had zero agent recipients and no human channel: ${human.reason ?? "unknown reason"}`);
|
|
281
|
+
}
|
|
247
282
|
}
|
|
248
|
-
patchWorkspaceMetadata(ws.id, { autoMergeBackedOffAt: Date.now() });
|
|
283
|
+
if (escalated) patchWorkspaceMetadata(ws.id, { autoMergeBackedOffAt: Date.now() });
|
|
249
284
|
createActivityEvent({
|
|
250
285
|
clientId: `workspace-auto-merge-backoff-${ws.id}-${Date.now()}`,
|
|
251
286
|
kind: "state",
|
|
@@ -349,6 +384,22 @@ async function landCandidates(
|
|
|
349
384
|
const preview = await fetchFirstUsableMergePreview(ws, orchestrators);
|
|
350
385
|
if (!preview) {
|
|
351
386
|
previewUnavailable.push(ws.id);
|
|
387
|
+
// #1025 A5 — a lane whose preview is persistently unusable (owner host offline, the
|
|
388
|
+
// worktree path is gone, or every probe returns error/missing) used to exit here with
|
|
389
|
+
// ZERO side effects: no no-progress, no wake, no notify — a ready branch would strand
|
|
390
|
+
// silently forever. Give it the SAME no-progress/strand path as the unknown-merge-state
|
|
391
|
+
// lane so no status is escalation-blind: advance the loop-breaker, and once it crosses
|
|
392
|
+
// the cap, escalateNoProgressMerge fires the owner `landed-failure` wake + steward/
|
|
393
|
+
// coordinator escalation. A transient blip self-clears when a usable preview returns.
|
|
394
|
+
const held = patchWorkspaceMetadata(ws.id, {
|
|
395
|
+
...noteMergeNoProgress(ws.metadata),
|
|
396
|
+
mergeError: "no online host returned a usable merge preview (owner offline, worktree missing, or probe error)",
|
|
397
|
+
lastPreviewUnavailableAt: Date.now(),
|
|
398
|
+
}) ?? ws;
|
|
399
|
+
if (mergeNoProgressCount(held.metadata) >= MAX_NO_PROGRESS_MERGE_ATTEMPTS) {
|
|
400
|
+
backedOff.push(ws.id);
|
|
401
|
+
escalateNoProgressMerge(held);
|
|
402
|
+
}
|
|
352
403
|
continue;
|
|
353
404
|
}
|
|
354
405
|
const p = preview as WorkspaceMergePreview;
|
|
@@ -401,21 +452,26 @@ async function landCandidates(
|
|
|
401
452
|
}
|
|
402
453
|
}
|
|
403
454
|
leftForSteward.push(ws.id);
|
|
404
|
-
let woke: string | null = null;
|
|
405
455
|
if (stewardEnabled) {
|
|
406
|
-
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
456
|
+
const wake = wakeRepoSteward(handoffWorkspace, p.conflict === true ? "conflict" : "merge state unknown");
|
|
457
|
+
if (wake.ok) {
|
|
458
|
+
wokeStewards.push(wake.policy);
|
|
459
|
+
} else if (!wake.cooldown && p.conflict === true) {
|
|
460
|
+
// #1021 A1+A2 — a GENUINE wake failure (nothing spawned: host offline / provider
|
|
461
|
+
// error) now correctly yields !ok, so the conflict lane's loop-breaker advances and
|
|
462
|
+
// escalateNoProgressMerge becomes reachable. A cooldown means a steward WAS woken
|
|
463
|
+
// recently and is presumably working — that is not a failure, so don't count it (and
|
|
464
|
+
// don't mislabel the cooldown as a spawn failure in mergeError). Carry the real error.
|
|
465
|
+
const failedHandoff = patchWorkspaceMetadata(ws.id, {
|
|
466
|
+
...noteMergeNoProgress(ws.metadata),
|
|
467
|
+
mergeError: wake.error ?? "cannot spawn steward on the workspace owning host",
|
|
468
|
+
lastStewardWakeFailedAt: Date.now(),
|
|
469
|
+
}) ?? ws;
|
|
470
|
+
if (mergeNoProgressCount(failedHandoff.metadata) >= MAX_NO_PROGRESS_MERGE_ATTEMPTS) {
|
|
471
|
+
backedOff.push(ws.id);
|
|
472
|
+
escalateNoProgressMerge(failedHandoff);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
419
475
|
}
|
|
420
476
|
}
|
|
421
477
|
if (p.conflict === true) {
|
|
@@ -1,23 +1,43 @@
|
|
|
1
1
|
import { stewardFallbackTarget } from "./config";
|
|
2
2
|
import { routeNotification } from "./notification-router";
|
|
3
|
+
import { raiseOperatorAlarm } from "./operator-alarm";
|
|
3
4
|
import { resolveStewardIdentity } from "./steward-identity";
|
|
5
|
+
import { notifyWorkspaceHumanEscalation } from "./workspace-human-escalation";
|
|
4
6
|
import type { NotificationType } from "./notification-types";
|
|
5
7
|
import type { WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
6
8
|
|
|
7
9
|
export const READY_TO_LAND_ESCALATION_STATUSES = new Set<WorkspaceStatus>(["ready", "review_requested"]);
|
|
8
10
|
|
|
9
|
-
export
|
|
11
|
+
export interface WorkspaceCustodyEscalationInput {
|
|
10
12
|
workspace: Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "status" | "ownerAgentId">;
|
|
11
13
|
type?: NotificationType;
|
|
12
14
|
subject: string;
|
|
13
15
|
body: string;
|
|
14
16
|
payload: Record<string, unknown>;
|
|
15
17
|
idempotencyKey?: string;
|
|
16
|
-
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function routeWorkspaceCustodyEscalation(input: WorkspaceCustodyEscalationInput): string[] {
|
|
17
21
|
const { ownerAgentId, coordinatorAgentId } = resolveStewardIdentity(input.workspace);
|
|
18
22
|
const fallback = stewardFallbackTarget();
|
|
19
23
|
const recipients = [...new Set([ownerAgentId, coordinatorAgentId, fallback].filter((value): value is string => Boolean(value)))];
|
|
20
|
-
if (!recipients.length)
|
|
24
|
+
if (!recipients.length) {
|
|
25
|
+
const payload = {
|
|
26
|
+
...input.payload,
|
|
27
|
+
coordinatorAgentId,
|
|
28
|
+
ownerAgentId,
|
|
29
|
+
fallbackTarget: fallback || undefined,
|
|
30
|
+
};
|
|
31
|
+
const human = notifyWorkspaceHumanEscalation({
|
|
32
|
+
subject: input.subject,
|
|
33
|
+
body: input.body,
|
|
34
|
+
payload,
|
|
35
|
+
source: "workspace-custody",
|
|
36
|
+
});
|
|
37
|
+
if (human.accepted && human.channelId) return [human.channelId];
|
|
38
|
+
console.warn(`[workspace-escalation] zero agent recipients and no human channel for workspace ${input.workspace.id}: ${human.reason ?? "unknown reason"}`);
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
21
41
|
|
|
22
42
|
routeNotification({
|
|
23
43
|
type: input.type ?? "workspace.stranded_escalation",
|
|
@@ -43,3 +63,32 @@ export function routeWorkspaceCustodyEscalation(input: {
|
|
|
43
63
|
});
|
|
44
64
|
return recipients;
|
|
45
65
|
}
|
|
66
|
+
|
|
67
|
+
// Route a custody escalation, and if it resolves to ZERO reachable recipients — owner
|
|
68
|
+
// exited, coordinator idle, no `AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK` configured —
|
|
69
|
+
// raise a louder operator-visible alarm instead of letting the caller silently record it
|
|
70
|
+
// as "escalated" (#1025 C1/C4, same class as #1021 A4). A disk-only orphan (ownerAgentId
|
|
71
|
+
// undefined) and most `review_requested` auto-abandons have no agent to page by default,
|
|
72
|
+
// so without this the notice is swallowed and the strand loops in the dark. Returns the
|
|
73
|
+
// (possibly empty) recipient list so the caller can still decide whether to stamp state.
|
|
74
|
+
export function routeWorkspaceCustodyEscalationOrAlarm(
|
|
75
|
+
input: WorkspaceCustodyEscalationInput & { source: string },
|
|
76
|
+
): string[] {
|
|
77
|
+
const { source, ...escalation } = input;
|
|
78
|
+
const targets = routeWorkspaceCustodyEscalation(escalation);
|
|
79
|
+
if (!targets.length) {
|
|
80
|
+
raiseOperatorAlarm({
|
|
81
|
+
clientId: `${escalation.idempotencyKey ?? `${escalation.workspace.id}:${escalation.subject}`}:no-recipients`,
|
|
82
|
+
source,
|
|
83
|
+
subject: escalation.subject,
|
|
84
|
+
detail: `${escalation.body} — no owner, coordinator, or fallback configured to escalate to. Configure AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK or reclaim the worktree manually.`,
|
|
85
|
+
metadata: {
|
|
86
|
+
workspaceId: escalation.workspace.id,
|
|
87
|
+
repoRoot: escalation.workspace.repoRoot,
|
|
88
|
+
branch: escalation.workspace.branch,
|
|
89
|
+
status: escalation.workspace.status,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return targets;
|
|
94
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { errMessage } from "agent-relay-sdk";
|
|
2
|
+
import { stewardFallbackTarget } from "./config";
|
|
3
|
+
import { getChannel } from "./db";
|
|
4
|
+
import { relayEgressToChannel, relayNotifyNtfy } from "./mcp-egress-tools";
|
|
5
|
+
import { getResolvedNotificationsConfig } from "./notification-settings-store";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_NTFY_CHANNEL_ID = "ntfy:default";
|
|
8
|
+
const WARN = "no human-reachable escalation target - stranded workspaces will page nobody. Set AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK or configure ntfy.";
|
|
9
|
+
|
|
10
|
+
export interface WorkspaceHumanEscalationInput {
|
|
11
|
+
subject: string;
|
|
12
|
+
body: string;
|
|
13
|
+
payload?: Record<string, unknown>;
|
|
14
|
+
priority?: "min" | "low" | "default" | "high" | "urgent";
|
|
15
|
+
tags?: string[];
|
|
16
|
+
source?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WorkspaceHumanEscalationResult {
|
|
20
|
+
accepted: boolean;
|
|
21
|
+
channelId?: string;
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function workspaceHumanEscalationChannelId(): string {
|
|
26
|
+
return getResolvedNotificationsConfig().humanEscalationChannelId.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function hasHumanEscalationChannel(): boolean {
|
|
30
|
+
const channelId = workspaceHumanEscalationChannelId();
|
|
31
|
+
return Boolean(channelId && getChannel(channelId));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hasHumanReachableEscalationTarget(): boolean {
|
|
35
|
+
return Boolean(stewardFallbackTarget() || hasHumanEscalationChannel());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function warnIfNoHumanReachableEscalationTarget(log: (message: string) => void = console.warn): boolean {
|
|
39
|
+
if (hasHumanReachableEscalationTarget()) return false;
|
|
40
|
+
log(WARN);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function notifyWorkspaceHumanEscalation(input: WorkspaceHumanEscalationInput): WorkspaceHumanEscalationResult {
|
|
45
|
+
const channelId = workspaceHumanEscalationChannelId();
|
|
46
|
+
if (!channelId) return { accepted: false, reason: "no human escalation channel configured" };
|
|
47
|
+
if (!getChannel(channelId)) return { accepted: false, reason: `human escalation channel ${channelId} is not registered` };
|
|
48
|
+
|
|
49
|
+
const event: Record<string, unknown> = {
|
|
50
|
+
title: input.subject,
|
|
51
|
+
body: input.body,
|
|
52
|
+
priority: input.priority ?? "urgent",
|
|
53
|
+
tags: input.tags ?? ["agent-relay", "workspace"],
|
|
54
|
+
...(input.payload ? { relay: input.payload } : {}),
|
|
55
|
+
...(input.source ? { source: input.source } : {}),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
if (channelId === DEFAULT_NTFY_CHANNEL_ID) {
|
|
60
|
+
relayNotifyNtfy(event);
|
|
61
|
+
} else {
|
|
62
|
+
relayEgressToChannel({ channelId, event });
|
|
63
|
+
}
|
|
64
|
+
return { accepted: true, channelId };
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { accepted: false, channelId, reason: errMessage(error) };
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/workspace-orphans.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { isOwnerAlive, liveAgentCwds, worktreeInUseByLiveAgent } from "./workspa
|
|
|
30
30
|
import { applyWorkspaceAction } from "./workspace-actions";
|
|
31
31
|
import { workspaceOwnerOrchestrator } from "./workspace-host";
|
|
32
32
|
import { UNLANDED_FLAG_COOLDOWN_MS, orphanGraceMs, orphanWorktreeReapEnabled, relayToken } from "./config";
|
|
33
|
-
import { READY_TO_LAND_ESCALATION_STATUSES,
|
|
33
|
+
import { READY_TO_LAND_ESCALATION_STATUSES, routeWorkspaceCustodyEscalationOrAlarm } from "./workspace-escalation";
|
|
34
34
|
|
|
35
35
|
// Don't re-flag the same un-landed orphan every sweep — surface it once, then
|
|
36
36
|
// stay quiet for this window. In-memory (keyed by worktree path) like the
|
|
@@ -425,7 +425,8 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
|
|
|
425
425
|
? "uncommitted changes in the worktree"
|
|
426
426
|
: `${orphan.unmergedAhead ?? orphan.ahead ?? "?"} un-landed commit(s)`;
|
|
427
427
|
if (orphanHasWorkEvidence(orphan)) {
|
|
428
|
-
|
|
428
|
+
routeWorkspaceCustodyEscalationOrAlarm({
|
|
429
|
+
source: "workspace-orphan-reaper",
|
|
429
430
|
workspace: { id: `orphan:${orphan.worktreePath}`, repoRoot: orphan.repoRoot, branch: orphan.branch, status: "review_requested", ownerAgentId: undefined },
|
|
430
431
|
subject: "Stranded worktree needs attention",
|
|
431
432
|
body: `${orphan.branch ?? orphan.worktreePath} in ${orphan.repoRoot} is orphaned and holds work that has not landed - ${detail}. Recover the commits before removing it.`,
|
|
@@ -456,7 +457,8 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
|
|
|
456
457
|
if (now - last < UNLANDED_FLAG_COOLDOWN_MS) continue;
|
|
457
458
|
flaggedAt.set(key, now);
|
|
458
459
|
if (workspaceNeedsCustodyEscalation(workspace, false)) {
|
|
459
|
-
|
|
460
|
+
routeWorkspaceCustodyEscalationOrAlarm({
|
|
461
|
+
source: "workspace-orphan-reaper",
|
|
460
462
|
workspace,
|
|
461
463
|
subject: "Workspace row has no worktree on disk",
|
|
462
464
|
body: `Workspace \`${workspace.branch ?? workspace.id}\` in ${workspace.repoRoot} is ${workspace.status}, but its worktree ${workspace.worktreePath} is missing on disk. This was in Relay custody; recover or explicitly abandon it.`,
|
|
@@ -506,7 +508,8 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
|
|
|
506
508
|
autoAbandoned.push(workspace.id);
|
|
507
509
|
flaggedAt.delete(key);
|
|
508
510
|
if (workspaceNeedsCustodyEscalation(workspace, previewHasWorkEvidence(probe.kind === "preview" ? probe.preview : null))) {
|
|
509
|
-
|
|
511
|
+
routeWorkspaceCustodyEscalationOrAlarm({
|
|
512
|
+
source: "workspace-orphan-reaper",
|
|
510
513
|
workspace,
|
|
511
514
|
type: "workspace.auto_abandoned",
|
|
512
515
|
subject: "Workspace auto-abandoned",
|
|
@@ -547,7 +550,8 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
|
|
|
547
550
|
? `host could not confirm whether branch ${workspace.branch} has landed`
|
|
548
551
|
: "host could not confirm whether recoverable branch work remains";
|
|
549
552
|
if (workspaceNeedsCustodyEscalation(workspace, hasWorkEvidence)) {
|
|
550
|
-
|
|
553
|
+
routeWorkspaceCustodyEscalationOrAlarm({
|
|
554
|
+
source: "workspace-orphan-reaper",
|
|
551
555
|
workspace,
|
|
552
556
|
subject: "Missing-worktree workspace needs attention",
|
|
553
557
|
body: `${workspace.id} in ${workspace.repoRoot} has no worktree on disk and cannot be auto-abandoned safely - ${detail}. Recover it, then abandon or clean it up explicitly.`,
|
|
@@ -657,7 +661,8 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
|
|
|
657
661
|
: `${preview?.unmergedAhead ?? preview?.ahead ?? "?"} un-landed commit(s)`;
|
|
658
662
|
const hasWorkEvidence = previewHasWorkEvidence(preview);
|
|
659
663
|
if (workspaceNeedsCustodyEscalation(workspace, hasWorkEvidence)) {
|
|
660
|
-
|
|
664
|
+
routeWorkspaceCustodyEscalationOrAlarm({
|
|
665
|
+
source: "workspace-orphan-reaper",
|
|
661
666
|
workspace,
|
|
662
667
|
subject: "Dead-owner worktree needs attention",
|
|
663
668
|
body: `${workspace.branch ?? workspace.id} in ${workspace.repoRoot} is owned by gone agent ${workspace.ownerAgentId ?? "?"} and holds work that has not landed (${detail}). Recover the commits, then clean it up explicitly.`,
|
package/src/workspace-phase.ts
CHANGED
|
@@ -200,7 +200,27 @@ export function describeWorkspacePhase(
|
|
|
200
200
|
blockers: [],
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
|
-
case "merge_planned":
|
|
203
|
+
case "merge_planned": {
|
|
204
|
+
// #1025 A6 — a merge is dispatched and normally settles in seconds; the in-flight
|
|
205
|
+
// watchdog recovers a genuinely-stuck one at ~20 min. But a lost merge command/result
|
|
206
|
+
// (target host gone) could leave the row here indefinitely while this view reported
|
|
207
|
+
// "healthy, wait" forever. Give it the same #242 stall clock as land-pending: past the
|
|
208
|
+
// bound, surface it as needs-attention with a real blocker instead of the anti-panic
|
|
209
|
+
// view. Clocked on `readyAt` (the stable hand-off time, immune to heartbeat bumps).
|
|
210
|
+
const now = opts.now ?? Date.now();
|
|
211
|
+
const stallMs = opts.stallMs ?? LAND_PENDING_STALL_MS;
|
|
212
|
+
const pendingMs = typeof workspace.readyAt === "number" ? now - workspace.readyAt : undefined;
|
|
213
|
+
if (pendingMs !== undefined && pendingMs > stallMs) {
|
|
214
|
+
const mins = Math.round(pendingMs / 60_000);
|
|
215
|
+
return {
|
|
216
|
+
phase: "landing",
|
|
217
|
+
headline: `Stalled — merge was dispatched ${mins} min ago but hasn't settled. A merge normally lands in seconds, so this is past the healthy window and likely stuck (the owning host went offline mid-merge, or the merge result was lost).`,
|
|
218
|
+
hint: "Do NOT merge, push, rebase, or touch the main checkout yourself. Flag this to a human or the repo steward — the merge dispatch isn't settling and needs attention.",
|
|
219
|
+
actionNeeded: true,
|
|
220
|
+
nextActions: [WAIT_ACTION],
|
|
221
|
+
blockers: [`merge dispatched ~${mins} min ago with no result — the land isn't settling`],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
204
224
|
return {
|
|
205
225
|
phase: "landing",
|
|
206
226
|
headline: renderTemplate("workspace.phase.merge-planned.headline"),
|
|
@@ -209,6 +229,7 @@ export function describeWorkspacePhase(
|
|
|
209
229
|
nextActions: [WAIT_ACTION],
|
|
210
230
|
blockers: [],
|
|
211
231
|
};
|
|
232
|
+
}
|
|
212
233
|
case "conflict":
|
|
213
234
|
return {
|
|
214
235
|
phase: "steward",
|