agent-relay-server 0.120.5 → 0.121.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 +1 -1
- package/package.json +3 -3
- package/public/assets/{MessageBubble-BVff6PSi.js → MessageBubble-BaPGFJxq.js} +2 -2
- package/public/assets/{MessageBubble-BVff6PSi.js.map → MessageBubble-BaPGFJxq.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BjrVU5Tg.js → PlanGraphPanel-BXCVfmln.js} +2 -2
- package/public/assets/{PlanGraphPanel-BjrVU5Tg.js.map → PlanGraphPanel-BXCVfmln.js.map} +1 -1
- package/public/assets/{activity-Bk9LEUr4.js → activity-D_shwAZt.js} +2 -2
- package/public/assets/{activity-Bk9LEUr4.js.map → activity-D_shwAZt.js.map} +1 -1
- package/public/assets/{agent-profiles-DSVDoQ_N.js → agent-profiles-BrfVz5IV.js} +2 -2
- package/public/assets/{agent-profiles-DSVDoQ_N.js.map → agent-profiles-BrfVz5IV.js.map} +1 -1
- package/public/assets/agents-B5w-Z9Ic.js +2 -0
- package/public/assets/{agents-8rQuJqii.js.map → agents-B5w-Z9Ic.js.map} +1 -1
- package/public/assets/{analytics-DifGnk7q.js → analytics-Bm3D12UN.js} +2 -2
- package/public/assets/{analytics-DifGnk7q.js.map → analytics-Bm3D12UN.js.map} +1 -1
- package/public/assets/{automation-CMxQAf3l.js → automation-DOTL8BFs.js} +2 -2
- package/public/assets/{automation-CMxQAf3l.js.map → automation-DOTL8BFs.js.map} +1 -1
- package/public/assets/{branch-state-badge-CyC5Qrhc.js → branch-state-badge-BlqeJZ5-.js} +2 -2
- package/public/assets/{branch-state-badge-CyC5Qrhc.js.map → branch-state-badge-BlqeJZ5-.js.map} +1 -1
- package/public/assets/{channels-BLkdt2XR.js → channels-BoiKfK6Q.js} +2 -2
- package/public/assets/{channels-BLkdt2XR.js.map → channels-BoiKfK6Q.js.map} +1 -1
- package/public/assets/chat-PeU3iOaa.js +2 -0
- package/public/assets/{chat-jlXywb9G.js.map → chat-PeU3iOaa.js.map} +1 -1
- package/public/assets/{connectors-C0n12Je8.js → connectors-BvV8Hee2.js} +2 -2
- package/public/assets/{connectors-C0n12Je8.js.map → connectors-BvV8Hee2.js.map} +1 -1
- package/public/assets/{formatted-body-CzehtPtx.js → formatted-body-C1FgzlT1.js} +3 -3
- package/public/assets/{formatted-body-CzehtPtx.js.map → formatted-body-C1FgzlT1.js.map} +1 -1
- package/public/assets/{formatted-body-impl-D3Bnt0Fj.js → formatted-body-impl-BTpU1xH4.js} +2 -2
- package/public/assets/{formatted-body-impl-D3Bnt0Fj.js.map → formatted-body-impl-BTpU1xH4.js.map} +1 -1
- package/public/assets/index-DZEahmyc.js +25 -0
- package/public/assets/index-DZEahmyc.js.map +1 -0
- package/public/assets/{insights-3H6KttBi.js → insights-BqmgSpQm.js} +2 -2
- package/public/assets/{insights-3H6KttBi.js.map → insights-BqmgSpQm.js.map} +1 -1
- package/public/assets/{integrations-v14ehskT.js → integrations-CuT92zQi.js} +2 -2
- package/public/assets/{integrations-v14ehskT.js.map → integrations-CuT92zQi.js.map} +1 -1
- package/public/assets/{maintenance-BANYcBcn.js → maintenance-lM7FeXVC.js} +2 -2
- package/public/assets/{maintenance-BANYcBcn.js.map → maintenance-lM7FeXVC.js.map} +1 -1
- package/public/assets/{managed-agents-DCFMWKqt.js → managed-agents-DaPNv3yY.js} +3 -3
- package/public/assets/{managed-agents-DCFMWKqt.js.map → managed-agents-DaPNv3yY.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CfaR-GRX.js → markdown-preview-impl-Wia3Ho1s.js} +2 -2
- package/public/assets/{markdown-preview-impl-CfaR-GRX.js.map → markdown-preview-impl-Wia3Ho1s.js.map} +1 -1
- package/public/assets/{memory-BmkUEyd5.js → memory-B1v_zD-M.js} +2 -2
- package/public/assets/{memory-BmkUEyd5.js.map → memory-B1v_zD-M.js.map} +1 -1
- package/public/assets/{messages-BQ_fVnSA.js → messages-BNOx3yK6.js} +2 -2
- package/public/assets/{messages-BQ_fVnSA.js.map → messages-BNOx3yK6.js.map} +1 -1
- package/public/assets/{orchestrators-DC7y9vQD.js → orchestrators-DWFy4asl.js} +2 -2
- package/public/assets/{orchestrators-DC7y9vQD.js.map → orchestrators-DWFy4asl.js.map} +1 -1
- package/public/assets/{overview-6lzywFHq.js → overview-C_12cFPM.js} +2 -2
- package/public/assets/{overview-6lzywFHq.js.map → overview-C_12cFPM.js.map} +1 -1
- package/public/assets/{pairs-CRrRIaHI.js → pairs-DIc7gX83.js} +2 -2
- package/public/assets/{pairs-CRrRIaHI.js.map → pairs-DIc7gX83.js.map} +1 -1
- package/public/assets/{projects-CZJW35C7.js → projects-DPjvJUzT.js} +2 -2
- package/public/assets/{projects-CZJW35C7.js.map → projects-DPjvJUzT.js.map} +1 -1
- package/public/assets/{prompt-settings-DvlnMq8H.js → prompt-settings-CtumNgG8.js} +2 -2
- package/public/assets/{prompt-settings-DvlnMq8H.js.map → prompt-settings-CtumNgG8.js.map} +1 -1
- package/public/assets/{registry-3TWg4zbE.js → registry-DzUhgMq9.js} +2 -2
- package/public/assets/{registry-3TWg4zbE.js.map → registry-DzUhgMq9.js.map} +1 -1
- package/public/assets/{security-A2VYKHeI.js → security-BJ3_u0uY.js} +2 -2
- package/public/assets/{security-A2VYKHeI.js.map → security-BJ3_u0uY.js.map} +1 -1
- package/public/assets/{select-_gK5Ojjc.js → select-AWvlctyQ.js} +2 -2
- package/public/assets/{select-_gK5Ojjc.js.map → select-AWvlctyQ.js.map} +1 -1
- package/public/assets/{settings-BZ26ewcu.js → settings-5ofqi-bN.js} +4 -4
- package/public/assets/settings-5ofqi-bN.js.map +1 -0
- package/public/assets/store-D5et8685.js +9 -0
- package/public/assets/store-D5et8685.js.map +1 -0
- package/public/assets/{tasks-DlokSl_P.js → tasks-BEE9bBf7.js} +2 -2
- package/public/assets/{tasks-DlokSl_P.js.map → tasks-BEE9bBf7.js.map} +1 -1
- package/public/assets/{teams-t7M1HMgS.js → teams-C27H_Gwo.js} +2 -2
- package/public/assets/{teams-t7M1HMgS.js.map → teams-C27H_Gwo.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DxgrO-jH.js → terminal-viewer-impl-WD2TboA7.js} +22 -22
- package/public/assets/{terminal-viewer-impl-DxgrO-jH.js.map → terminal-viewer-impl-WD2TboA7.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-V8LR-3Gi.js → work-queue-CtmPK2hK.js} +2 -2
- package/public/assets/{work-queue-V8LR-3Gi.js.map → work-queue-CtmPK2hK.js.map} +1 -1
- package/public/assets/{workspaces-DI317fMk.js → workspaces-B26EKv_H.js} +2 -2
- package/public/assets/{workspaces-DI317fMk.js.map → workspaces-B26EKv_H.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/agent-card-projection.ts +39 -9
- package/src/bus-outbox.ts +30 -0
- package/src/bus.ts +57 -1
- package/src/commands-db.ts +14 -0
- package/src/config-store.ts +2 -0
- package/src/config.ts +4 -0
- package/src/db/connection.ts +3 -1
- package/src/db/delivery.ts +85 -3
- package/src/db/index.ts +1 -0
- package/src/db/maintenance-worker.ts +91 -0
- package/src/db/messages.ts +19 -4
- package/src/db/migrations.ts +7 -0
- package/src/db/retention.ts +130 -0
- package/src/db/schema.ts +5 -0
- package/src/db/stats.ts +32 -8
- package/src/db/task-entity.ts +7 -8
- package/src/event-loop-lag.ts +90 -0
- package/src/lifecycle-manager.ts +11 -12
- package/src/maintenance/jobs.ts +23 -7
- package/src/maintenance/workspaces.ts +28 -47
- package/src/managed-policy-escalation.ts +47 -0
- package/src/mcp-task-tools.ts +4 -5
- package/src/retention-config-store.ts +32 -0
- package/src/retention-config.ts +58 -0
- package/src/routes/commands.ts +8 -245
- package/src/routes/messages.ts +13 -1
- package/src/routes/tasks.ts +3 -5
- package/src/routing-policy-store.ts +11 -1
- package/src/server.ts +2 -0
- package/src/services/issue-lifecycle.ts +23 -8
- package/src/services/send-message.ts +63 -21
- package/src/services/spawn-registration-watchdog.ts +74 -9
- package/src/services/task-entity.ts +54 -2
- package/src/settings/registry.ts +11 -0
- package/src/sse.ts +54 -9
- package/src/workspace-auto-merge.ts +94 -34
- package/src/workspace-command-settle.ts +227 -0
- package/src/workspace-escalation.ts +45 -0
- package/src/workspace-merge-progress.ts +16 -0
- package/src/workspace-orphans.ts +91 -3
- package/public/assets/agents-8rQuJqii.js +0 -2
- package/public/assets/chat-jlXywb9G.js +0 -2
- package/public/assets/index-4g3PbGVN.js +0 -25
- package/public/assets/index-4g3PbGVN.js.map +0 -1
- package/public/assets/settings-BZ26ewcu.js.map +0 -1
- package/public/assets/store-nXGxgz2Q.js +0 -9
- package/public/assets/store-nXGxgz2Q.js.map +0 -1
|
@@ -28,11 +28,13 @@ import {
|
|
|
28
28
|
getMessage,
|
|
29
29
|
listAgents,
|
|
30
30
|
promoteLineageCapturedResponse,
|
|
31
|
+
sendMessagesBatchWithResult,
|
|
31
32
|
sendMessageWithResult,
|
|
32
33
|
channelObligationAnsweredBy,
|
|
34
|
+
type SendMessageResult as DbSendMessageResult,
|
|
33
35
|
} from "../db";
|
|
34
36
|
import { planSend, type DeliveryReceipt } from "../agent-ref";
|
|
35
|
-
import { emitMessageQueued,
|
|
37
|
+
import { emitMessageQueued, emitNewMessages } from "../sse";
|
|
36
38
|
import { getLifecycleManager } from "../lifecycle-manager";
|
|
37
39
|
import { isComponentAuthorizedFor, isIntegrationAllowed } from "../security";
|
|
38
40
|
import { AmbiguousTargetError, ServiceAuthError } from "./errors";
|
|
@@ -175,6 +177,42 @@ export function sendMessageService(
|
|
|
175
177
|
ctx: AuthContext,
|
|
176
178
|
opts: SendMessageOptions = {},
|
|
177
179
|
): SendMessageResult {
|
|
180
|
+
const { receipt, suppressedParent } = prepareSendMessage(input, ctx, opts);
|
|
181
|
+
if (suppressedParent) return { message: suppressedParent, created: false, receipt };
|
|
182
|
+
const result = sendMessageWithResult(input);
|
|
183
|
+
emitSendResults([result]);
|
|
184
|
+
return { message: result.message, created: result.created, receipt };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function sendMessagesBatchService(
|
|
188
|
+
inputs: SendMessageInput[],
|
|
189
|
+
ctx: AuthContext,
|
|
190
|
+
opts: SendMessageOptions = {},
|
|
191
|
+
): SendMessageResult[] {
|
|
192
|
+
const prepared = inputs.map((input) => ({ input, ...prepareSendMessage(input, ctx, opts) }));
|
|
193
|
+
const results: SendMessageResult[] = [];
|
|
194
|
+
const storeInputs: SendMessageInput[] = [];
|
|
195
|
+
const storeIndexes: number[] = [];
|
|
196
|
+
for (const item of prepared) {
|
|
197
|
+
if (item.suppressedParent) {
|
|
198
|
+
results.push({ message: item.suppressedParent, created: false, receipt: item.receipt });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
results.push(null as unknown as SendMessageResult);
|
|
202
|
+
storeIndexes.push(results.length - 1);
|
|
203
|
+
storeInputs.push(item.input);
|
|
204
|
+
}
|
|
205
|
+
const stored = sendMessagesBatchWithResult(storeInputs);
|
|
206
|
+
for (let i = 0; i < stored.length; i++) {
|
|
207
|
+
const result = stored[i]!;
|
|
208
|
+
const outputIndex = storeIndexes[i]!;
|
|
209
|
+
results[outputIndex] = { message: result.message, created: result.created, receipt: prepared[outputIndex]!.receipt };
|
|
210
|
+
}
|
|
211
|
+
emitSendResults(stored);
|
|
212
|
+
return results;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function prepareSendMessage(input: SendMessageInput, ctx: AuthContext, opts: SendMessageOptions): { receipt: DeliveryReceipt; suppressedParent: Message | null } {
|
|
178
216
|
const receipt = validateSendMessageAuthorization(input, ctx, opts);
|
|
179
217
|
// Reply-obligation linking: some real answers are ordinary sends without replyTo/thread linkage.
|
|
180
218
|
// Channel bridge replies have a single unambiguous open obligation case;
|
|
@@ -184,29 +222,33 @@ export function sendMessageService(
|
|
|
184
222
|
const answered = channelObligationAnsweredBy(input.from, input.to);
|
|
185
223
|
if (answered !== null) input.replyTo = answered;
|
|
186
224
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (result.message.
|
|
195
|
-
|
|
225
|
+
return { receipt, suppressedParent: suppressResultsOnlyAck(input) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function emitSendResults(results: DbSendMessageResult[]): void {
|
|
229
|
+
const liveMessages: Message[] = [];
|
|
230
|
+
for (const result of results) {
|
|
231
|
+
if (result.created) {
|
|
232
|
+
if (result.message.deliveryStatus === "queued") {
|
|
233
|
+
emitMessageQueued(result.message);
|
|
234
|
+
// Wake an on-demand managed policy that has no live agent attached yet.
|
|
235
|
+
if (result.message.to?.startsWith("policy:")) {
|
|
236
|
+
getLifecycleManager().onMessageForPolicy(result.message.to.slice("policy:".length));
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
liveMessages.push(result.message);
|
|
196
240
|
}
|
|
197
|
-
|
|
198
|
-
|
|
241
|
+
const promoted = promoteLineageCapturedResponse(result.message);
|
|
242
|
+
if (promoted?.created) liveMessages.push(promoted.message);
|
|
243
|
+
} else if (result.updated) {
|
|
244
|
+
// Session-step upsert (#parity): the row changed in place (same DB id). Re-emit so the
|
|
245
|
+
// dashboard's id-keyed merge replaces the existing step live — no duplicate row.
|
|
246
|
+
liveMessages.push(result.message);
|
|
247
|
+
const promoted = promoteLineageCapturedResponse(result.message);
|
|
248
|
+
if (promoted?.created) liveMessages.push(promoted.message);
|
|
199
249
|
}
|
|
200
|
-
const promoted = promoteLineageCapturedResponse(result.message);
|
|
201
|
-
if (promoted?.created) emitNewMessage(promoted.message);
|
|
202
|
-
} else if (result.updated) {
|
|
203
|
-
// Session-step upsert (#parity): the row changed in place (same DB id). Re-emit so the
|
|
204
|
-
// dashboard's id-keyed merge replaces the existing step live — no duplicate row.
|
|
205
|
-
emitNewMessage(result.message);
|
|
206
|
-
const promoted = promoteLineageCapturedResponse(result.message);
|
|
207
|
-
if (promoted?.created) emitNewMessage(promoted.message);
|
|
208
250
|
}
|
|
209
|
-
|
|
251
|
+
emitNewMessages(liveMessages);
|
|
210
252
|
}
|
|
211
253
|
|
|
212
254
|
function suppressResultsOnlyAck(input: SendMessageInput): Message | null {
|
|
@@ -20,12 +20,21 @@ interface SpawnCommandGroup {
|
|
|
20
20
|
rows: SpawnCommandRow[];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
interface SpawnCandidateRow {
|
|
24
|
+
spawn_request_id: string;
|
|
25
|
+
latest_updated_at: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
interface SpawnRegistrationWatchdogResult extends Record<string, unknown> {
|
|
24
29
|
failedSpawnCommandIds: string[];
|
|
25
30
|
notifiedSpawnCommandIds: string[];
|
|
26
31
|
silencedSpawnCommandIds: string[];
|
|
32
|
+
confirmedSpawnCommandIds: string[];
|
|
33
|
+
checkedSpawnGroups: number;
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
const MAX_SPAWN_GROUPS_PER_RUN = 25;
|
|
37
|
+
|
|
29
38
|
export function reapUnregisteredSpawnCommands(
|
|
30
39
|
now = Date.now(),
|
|
31
40
|
deadlineMs = SPAWN_REGISTRATION_DEADLINE_MS,
|
|
@@ -33,20 +42,40 @@ export function reapUnregisteredSpawnCommands(
|
|
|
33
42
|
): SpawnRegistrationWatchdogResult {
|
|
34
43
|
const cutoff = now - deadlineMs;
|
|
35
44
|
const notifyCutoff = now - notifyMaxAgeMs;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
const candidateRows = getDb().query(`
|
|
46
|
+
WITH spawn_groups AS (
|
|
47
|
+
SELECT
|
|
48
|
+
coalesce(nullif(json_extract(params, '$.spawnRequestId'), ''), correlation_id) AS spawn_request_id,
|
|
49
|
+
max(updated_at) AS latest_updated_at
|
|
50
|
+
FROM commands
|
|
51
|
+
WHERE type = 'agent.spawn'
|
|
52
|
+
AND status = 'succeeded'
|
|
53
|
+
AND json_valid(params)
|
|
54
|
+
AND coalesce(json_extract(params, '$.registrationConfirmed'), 0) != 1
|
|
55
|
+
AND coalesce(nullif(json_extract(params, '$.spawnedBy'), ''), '') != ''
|
|
56
|
+
GROUP BY coalesce(nullif(json_extract(params, '$.spawnRequestId'), ''), correlation_id)
|
|
57
|
+
)
|
|
58
|
+
SELECT spawn_request_id, latest_updated_at
|
|
59
|
+
FROM spawn_groups
|
|
60
|
+
WHERE spawn_request_id IS NOT NULL
|
|
61
|
+
AND latest_updated_at <= ?
|
|
62
|
+
ORDER BY latest_updated_at DESC
|
|
63
|
+
LIMIT ?
|
|
64
|
+
`).all(cutoff, MAX_SPAWN_GROUPS_PER_RUN) as SpawnCandidateRow[];
|
|
65
|
+
const rows = loadSpawnCommandRows(candidateRows.map((row) => row.spawn_request_id));
|
|
43
66
|
|
|
44
67
|
const failedSpawnCommandIds: string[] = [];
|
|
45
68
|
const notifiedSpawnCommandIds: string[] = [];
|
|
46
69
|
const silencedSpawnCommandIds: string[] = [];
|
|
70
|
+
const confirmedSpawnCommandIds: string[] = [];
|
|
71
|
+
let checkedSpawnGroups = 0;
|
|
47
72
|
for (const group of groupSpawnCommands(rows)) {
|
|
48
73
|
if (group.latestUpdatedAt > cutoff) continue;
|
|
49
|
-
|
|
74
|
+
checkedSpawnGroups++;
|
|
75
|
+
if (hasEverRegisteredAgent(group.spawnRequestId)) {
|
|
76
|
+
confirmedSpawnCommandIds.push(...markSpawnGroupRegistrationConfirmed(group, now));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
50
79
|
|
|
51
80
|
const reason = `spawned agent did not register within ${deadlineMs}ms`;
|
|
52
81
|
const failedCommands = group.rows
|
|
@@ -70,7 +99,25 @@ export function reapUnregisteredSpawnCommands(
|
|
|
70
99
|
}
|
|
71
100
|
}
|
|
72
101
|
|
|
73
|
-
return { failedSpawnCommandIds, notifiedSpawnCommandIds, silencedSpawnCommandIds };
|
|
102
|
+
return { failedSpawnCommandIds, notifiedSpawnCommandIds, silencedSpawnCommandIds, confirmedSpawnCommandIds, checkedSpawnGroups };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loadSpawnCommandRows(spawnRequestIds: string[]): SpawnCommandRow[] {
|
|
106
|
+
if (spawnRequestIds.length === 0) return [];
|
|
107
|
+
const placeholders = spawnRequestIds.map(() => "?").join(",");
|
|
108
|
+
return getDb().query(`
|
|
109
|
+
SELECT id, params, correlation_id, updated_at
|
|
110
|
+
FROM commands
|
|
111
|
+
WHERE type = 'agent.spawn'
|
|
112
|
+
AND status = 'succeeded'
|
|
113
|
+
AND json_valid(params)
|
|
114
|
+
AND coalesce(json_extract(params, '$.registrationConfirmed'), 0) != 1
|
|
115
|
+
AND (
|
|
116
|
+
correlation_id IN (${placeholders})
|
|
117
|
+
OR json_extract(params, '$.spawnRequestId') IN (${placeholders})
|
|
118
|
+
)
|
|
119
|
+
ORDER BY updated_at ASC
|
|
120
|
+
`).all(...spawnRequestIds, ...spawnRequestIds) as SpawnCommandRow[];
|
|
74
121
|
}
|
|
75
122
|
|
|
76
123
|
function groupSpawnCommands(rows: SpawnCommandRow[]): SpawnCommandGroup[] {
|
|
@@ -121,6 +168,24 @@ function hasEverRegisteredAgent(spawnRequestId: string): boolean {
|
|
|
121
168
|
return Boolean(registration);
|
|
122
169
|
}
|
|
123
170
|
|
|
171
|
+
function markSpawnGroupRegistrationConfirmed(group: SpawnCommandGroup, now: number): string[] {
|
|
172
|
+
const confirmed: string[] = [];
|
|
173
|
+
const stmt = getDb().query(`
|
|
174
|
+
UPDATE commands
|
|
175
|
+
SET params = ?
|
|
176
|
+
WHERE id = ?
|
|
177
|
+
AND status = 'succeeded'
|
|
178
|
+
`);
|
|
179
|
+
for (const row of group.rows) {
|
|
180
|
+
const params = parseParams(row.params);
|
|
181
|
+
if (params.registrationConfirmed === true) continue;
|
|
182
|
+
params.registrationConfirmed = true;
|
|
183
|
+
params.registrationConfirmedAt = now;
|
|
184
|
+
if (stmt.run(JSON.stringify(params), row.id).changes > 0) confirmed.push(row.id);
|
|
185
|
+
}
|
|
186
|
+
return confirmed;
|
|
187
|
+
}
|
|
188
|
+
|
|
124
189
|
function parseParams(raw: string): Record<string, unknown> {
|
|
125
190
|
try {
|
|
126
191
|
const parsed = JSON.parse(raw);
|
|
@@ -34,7 +34,9 @@ import {
|
|
|
34
34
|
type SetTaskStatusInput,
|
|
35
35
|
type UpdateDeliverableTaskInput,
|
|
36
36
|
} from "../db";
|
|
37
|
+
import { errMessage } from "agent-relay-sdk";
|
|
37
38
|
import { findSpawnCommandByRequestId } from "../commands-db";
|
|
39
|
+
import { defaultIssueRepo } from "../config";
|
|
38
40
|
import { emitTaskChanged } from "../sse";
|
|
39
41
|
import type { CommandStatus, Task, TaskDetail, TaskEvent, TaskGateState, TaskListItem, TaskStatus, WorkspaceRecord } from "../types";
|
|
40
42
|
import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
|
|
@@ -43,8 +45,58 @@ import { routeNotification } from "../notification-router";
|
|
|
43
45
|
import { isAgentExitDoneWhen, stageSatisfiesDoneWhen } from "./task-done-when";
|
|
44
46
|
import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
type CreateTaskEntityInput = CreateDeliverableTaskInput & {
|
|
49
|
+
issueRefs?: string[];
|
|
50
|
+
defaultRepo?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const EXPLICIT_ISSUE_REF_RE = /(https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/[0-9]+(?:[/?#][^\s,)]*)?|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)/;
|
|
54
|
+
const ISSUE_SLUG_RE = /(?:^|[\/_\-\s])(?:fix|feat|bug|issue|debug|chore|reverify|reenable)-([1-9][0-9]{0,6})(?=$|[\/_\-\s])/i;
|
|
55
|
+
|
|
56
|
+
function explicitIssueRefIn(text: string | undefined): string | undefined {
|
|
57
|
+
return text?.match(EXPLICIT_ISSUE_REF_RE)?.[1];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function slugIssueRefIn(text: string | undefined): string | undefined {
|
|
61
|
+
const match = text?.match(ISSUE_SLUG_RE)?.[1];
|
|
62
|
+
return match ? `#${match}` : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function explicitIssueRefs(input: CreateTaskEntityInput): string[] {
|
|
66
|
+
return (input.issueRefs ?? []).map((ref) => ref.trim()).filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function inferIssueRef(input: CreateTaskEntityInput): string | undefined {
|
|
70
|
+
for (const text of [input.title, input.branch, input.intent, input.context, input.latestSteer]) {
|
|
71
|
+
const explicit = explicitIssueRefIn(text);
|
|
72
|
+
if (explicit) return explicit;
|
|
73
|
+
}
|
|
74
|
+
for (const text of [input.title, input.branch]) {
|
|
75
|
+
const slug = slugIssueRefIn(text);
|
|
76
|
+
if (slug) return slug;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function addInitialIssueRefs(taskId: number, input: CreateTaskEntityInput): TaskDetail {
|
|
82
|
+
const refs = explicitIssueRefs(input);
|
|
83
|
+
const inferred = refs.length > 0 ? undefined : inferIssueRef(input);
|
|
84
|
+
const issueRefs = refs.length > 0 ? refs : inferred ? [inferred] : [];
|
|
85
|
+
const defaultRepo = input.defaultRepo?.trim() || input.repo?.trim() || defaultIssueRepo();
|
|
86
|
+
let detail = getTaskDetail(taskId)!;
|
|
87
|
+
for (const issue of issueRefs) {
|
|
88
|
+
try {
|
|
89
|
+
detail = addTaskRef(taskId, { issue, defaultRepo, actor: input.createdBy });
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.warn(`task ${taskId} issue ref '${issue}' could not be linked on create: ${errMessage(error)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return detail;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createTaskEntity(input: CreateTaskEntityInput): TaskDetail {
|
|
98
|
+
const created = createDeliverableTask(input);
|
|
99
|
+
const detail = addInitialIssueRefs(created.id, input);
|
|
48
100
|
autoSubscribeTaskCoordinator(input.createdBy, detail.id);
|
|
49
101
|
emitTaskChanged(detail, "task.created");
|
|
50
102
|
emitTaskSemanticNotification({
|
package/src/settings/registry.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
NOTIFICATIONS_SETTINGS_SCHEMA,
|
|
36
36
|
parseNotificationSettingsKey,
|
|
37
37
|
} from "../notification-settings";
|
|
38
|
+
import { RETENTION_CONFIG_DEFAULTS, RETENTION_CONFIG_SCHEMA, RETENTION_KEY, RETENTION_NAMESPACE } from "../retention-config";
|
|
38
39
|
|
|
39
40
|
/** Fail-closed default scope — a descriptor that omits scope requires admin to
|
|
40
41
|
* read and write (#385 owns the scope grammar; this layer only declares it). */
|
|
@@ -400,6 +401,16 @@ function seedBuiltInSettings(): void {
|
|
|
400
401
|
display: { group: "Teams", label: "Team idle watchdog", description: "Coordinator nudge when a team has no busy members.", order: 45 },
|
|
401
402
|
});
|
|
402
403
|
|
|
404
|
+
registerSetting({
|
|
405
|
+
namespace: RETENTION_NAMESPACE,
|
|
406
|
+
key: RETENTION_KEY,
|
|
407
|
+
schema: RETENTION_CONFIG_SCHEMA,
|
|
408
|
+
defaults: RETENTION_CONFIG_DEFAULTS,
|
|
409
|
+
scope: { read: "settings:read:system", write: "settings:write:system" },
|
|
410
|
+
tier: "server-runtime",
|
|
411
|
+
display: { group: "System", label: "Data retention", description: "Retention windows and batch size for high-churn Relay tables.", order: 46 },
|
|
412
|
+
});
|
|
413
|
+
|
|
403
414
|
registerSetting({
|
|
404
415
|
namespace: "provider",
|
|
405
416
|
key: "chatCaptureMode",
|
package/src/sse.ts
CHANGED
|
@@ -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;
|
|
@@ -328,6 +366,13 @@ function replayEventsToConnection(conn: Connection | undefined, plan: ReplayPlan
|
|
|
328
366
|
}
|
|
329
367
|
|
|
330
368
|
function sendReplayEvent(conn: Connection, event: RelayEvent, agentCache: Map<string, AgentCard | null>): void {
|
|
369
|
+
if (event.type === "message.batch") {
|
|
370
|
+
for (const [index, msg] of batchMessages(event.data).entries()) {
|
|
371
|
+
if (conn.agentId && !messageMatchesAgent(msg, conn.agentId, fanoutAgent(conn.agentId, agentCache))) continue;
|
|
372
|
+
send(conn, "message.new", msg, event.seq + index);
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
331
376
|
if (event.type === "message.new" && conn.agentId) {
|
|
332
377
|
const msg = event.data as unknown as Message;
|
|
333
378
|
if (!messageMatchesAgent(msg, conn.agentId, fanoutAgent(conn.agentId, agentCache))) return;
|
|
@@ -21,10 +21,13 @@
|
|
|
21
21
|
import {
|
|
22
22
|
createActivityEvent,
|
|
23
23
|
getActiveMergePause,
|
|
24
|
+
getWorkspace,
|
|
24
25
|
listOrchestrators,
|
|
25
26
|
listWorkspaces,
|
|
26
27
|
patchWorkspaceMetadata,
|
|
28
|
+
updateWorkspaceStatus,
|
|
27
29
|
} from "./db";
|
|
30
|
+
import { findLatestCommandByCorrelation } from "./commands-db";
|
|
28
31
|
import { notifyBranchLandFailed, notifyStewardHandoff } from "./branch-landed";
|
|
29
32
|
import { resolveStewardIdentity } from "./steward-identity";
|
|
30
33
|
import { emitCommandEvent } from "./command-events";
|
|
@@ -36,17 +39,20 @@ import { isAwaitingReviewerApproval } from "./reviewer-pipeline";
|
|
|
36
39
|
import { emitWorkspaceTaskSemanticNotifications } from "./services/task-lifecycle";
|
|
37
40
|
import { ensureRepoSteward } from "./steward";
|
|
38
41
|
import { requestWorkspaceMerge, workspaceMergeOrchestrators } from "./workspace-merge";
|
|
42
|
+
import { settleWorkspaceCommand } from "./workspace-command-settle";
|
|
43
|
+
import { CLEAR_MERGE_NO_PROGRESS, MAX_NO_PROGRESS_MERGE_ATTEMPTS, mergeNoProgressCount, noteMergeNoProgress } from "./workspace-merge-progress";
|
|
39
44
|
import { workspaceOwnerOrchestrator } from "./workspace-host";
|
|
40
45
|
import { workspaceActiveClaim } from "./workspace-claim";
|
|
41
46
|
import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES } from "./workspace-phase";
|
|
42
47
|
import { fetchHostMergePreview } from "./workspace-probe";
|
|
43
|
-
import type { WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMergePreview, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
|
|
48
|
+
import type { Command, WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMergePreview, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
|
|
44
49
|
import { STEWARD_WAKE_COOLDOWN_MS, stewardFallbackTarget, workspaceAutoMergeEnabled } from "./config";
|
|
45
50
|
import { isPathWithinBase } from "./utils";
|
|
46
51
|
import type { Orchestrator } from "./types";
|
|
47
52
|
|
|
48
53
|
const WORKSPACE_LAND_STRATEGIES = ["direct", "pr"] as const;
|
|
49
54
|
const WORKSPACE_AUTO_MERGE_POLICIES = ["on-green", "on-approval", "manual"] as const;
|
|
55
|
+
export { CLEAR_MERGE_NO_PROGRESS, MAX_NO_PROGRESS_MERGE_ATTEMPTS, mergeNoProgressCount, noteMergeNoProgress } from "./workspace-merge-progress";
|
|
50
56
|
|
|
51
57
|
type AutoMergeRequestOptions = {
|
|
52
58
|
requestedBy: "auto-merge";
|
|
@@ -174,41 +180,13 @@ export function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | n
|
|
|
174
180
|
}
|
|
175
181
|
}
|
|
176
182
|
|
|
177
|
-
// #638 — auto-merge loop breaker. A `workspace.merge` the orchestrator settles `failed`
|
|
178
|
-
// (no progress: origin moved ahead of local base, a replay conflict the clean-preview
|
|
179
|
-
// didn't predict, a push race) returns the workspace to a landable status
|
|
180
|
-
// (`review_requested`). The on-settle re-trigger (routes/commands.ts → scheduleRepoMerge)
|
|
181
|
-
// then re-dispatches it within a few seconds — forever. That's a silent merge-command
|
|
182
|
-
// storm that churns the per-repo lease and 409s every manual land (the live incident).
|
|
183
|
-
// Cap consecutive no-progress attempts per workspace; past the cap, stop dispatching and
|
|
184
|
-
// escalate. The count is incremented by the merge-result handler (server owns the
|
|
185
|
-
// transition) and cleared on genuine progress or a fresh ready/request-review hand-off.
|
|
186
|
-
export const MAX_NO_PROGRESS_MERGE_ATTEMPTS = 3;
|
|
187
|
-
|
|
188
183
|
// Don't re-escalate a backed-off lane more than once per this window.
|
|
189
184
|
const NO_PROGRESS_ESCALATION_COOLDOWN_MS = 30 * 60 * 1000;
|
|
190
185
|
export const AUTO_MERGE_ERROR_BACKOFF_MS = 3_000;
|
|
191
186
|
export const AUTO_MERGE_WATCHDOG_STALE_MS = 5 * 60 * 1000;
|
|
187
|
+
export const IN_FLIGHT_WORKSPACE_WATCHDOG_AGE_MS = 20 * 60 * 1000;
|
|
192
188
|
|
|
193
|
-
|
|
194
|
-
export function mergeNoProgressCount(metadata: Record<string, unknown>): number {
|
|
195
|
-
const n = metadata.autoMergeNoProgressCount;
|
|
196
|
-
return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** Metadata patch recording one more no-progress merge attempt (#638). Applied by the
|
|
200
|
-
* merge-result handler when a dispatched merge settles `failed` without landing. */
|
|
201
|
-
export function noteMergeNoProgress(metadata: Record<string, unknown>): Record<string, unknown> {
|
|
202
|
-
return { autoMergeNoProgressCount: mergeNoProgressCount(metadata) + 1, autoMergeNoProgressAt: Date.now() };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/** Metadata patch that clears the no-progress back-off — genuine progress (a land/recycle)
|
|
206
|
-
* or a fresh hand-off means the lane is worth attempting again (#638). */
|
|
207
|
-
export const CLEAR_MERGE_NO_PROGRESS: Record<string, unknown> = {
|
|
208
|
-
autoMergeNoProgressCount: 0,
|
|
209
|
-
autoMergeNoProgressAt: undefined,
|
|
210
|
-
autoMergeBackedOffAt: undefined,
|
|
211
|
-
};
|
|
189
|
+
const TERMINAL_COMMAND_STATUSES = new Set(["succeeded", "failed", "timed_out", "rejected", "canceled"]);
|
|
212
190
|
|
|
213
191
|
// Surface a lane auto-merge can't advance (#638): wake the repo steward (if enabled) and
|
|
214
192
|
// route a stranded-escalation notice to the configured fallback / workspace owner, so a
|
|
@@ -558,11 +536,93 @@ export function resetRepoMergeSchedulerForTests(): void {
|
|
|
558
536
|
autoMergeWatchdogStartedAt = Date.now();
|
|
559
537
|
}
|
|
560
538
|
|
|
539
|
+
function commandIsTerminal(command: Command | null): boolean {
|
|
540
|
+
return Boolean(command && TERMINAL_COMMAND_STATUSES.has(command.status));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function latestWorkspaceCommand(ws: WorkspaceRecord): Command | null {
|
|
544
|
+
const type = ws.status === "merge_planned" ? "workspace.merge" : "workspace.cleanup";
|
|
545
|
+
return findLatestCommandByCorrelation(type, ws.id);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function recoverAgedInFlightWorkspaces(now: number): Record<string, string[]> {
|
|
549
|
+
const oldEnough = (ws: WorkspaceRecord) => now - ws.updatedAt >= IN_FLIGHT_WORKSPACE_WATCHDOG_AGE_MS;
|
|
550
|
+
const recoveredMergePlanned: string[] = [];
|
|
551
|
+
const recoveredCleanupRequested: string[] = [];
|
|
552
|
+
const settledTerminal: string[] = [];
|
|
553
|
+
const stillActive: string[] = [];
|
|
554
|
+
|
|
555
|
+
for (const ws of listWorkspaces().filter((row) =>
|
|
556
|
+
row.mode === "isolated"
|
|
557
|
+
&& Boolean(row.worktreePath)
|
|
558
|
+
&& (row.status === "merge_planned" || row.status === "cleanup_requested")
|
|
559
|
+
&& oldEnough(row),
|
|
560
|
+
)) {
|
|
561
|
+
const command = latestWorkspaceCommand(ws);
|
|
562
|
+
if (command && !commandIsTerminal(command)) {
|
|
563
|
+
stillActive.push(ws.id);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (command) {
|
|
567
|
+
settleWorkspaceCommand(command, { onMergeSettled: scheduleRepoMerge });
|
|
568
|
+
settledTerminal.push(ws.id);
|
|
569
|
+
}
|
|
570
|
+
const fresh = getWorkspace(ws.id);
|
|
571
|
+
if (!fresh) continue;
|
|
572
|
+
if (fresh.status === "merge_planned") {
|
|
573
|
+
const reason = command
|
|
574
|
+
? `workspace.merge ${command.status} without settling workspace state`
|
|
575
|
+
: "workspace.merge command missing";
|
|
576
|
+
updateWorkspaceStatus(fresh.id, "review_requested", {
|
|
577
|
+
mergeCommandId: command?.id,
|
|
578
|
+
mergeError: reason,
|
|
579
|
+
inFlightWatchdogRecoveredAt: now,
|
|
580
|
+
...noteMergeNoProgress(fresh.metadata),
|
|
581
|
+
});
|
|
582
|
+
recoveredMergePlanned.push(fresh.id);
|
|
583
|
+
scheduleRepoMerge(fresh.repoRoot);
|
|
584
|
+
createActivityEvent({
|
|
585
|
+
clientId: `workspace-inflight-watchdog-merge-${fresh.id}-${now}`,
|
|
586
|
+
kind: "state",
|
|
587
|
+
title: "Recovered stuck workspace merge",
|
|
588
|
+
body: `${fresh.branch ?? fresh.id} was stuck in merge_planned; returned to review_requested (${reason})`,
|
|
589
|
+
meta: fresh.branch ?? fresh.id,
|
|
590
|
+
icon: "ti-refresh-alert",
|
|
591
|
+
view: "orchestrators",
|
|
592
|
+
metadata: { source: "server", maintenanceJobId: "workspace-auto-merge-watchdog", workspaceId: fresh.id, commandId: command?.id, commandStatus: command?.status },
|
|
593
|
+
});
|
|
594
|
+
} else if (fresh.status === "cleanup_requested") {
|
|
595
|
+
const reason = command
|
|
596
|
+
? `workspace.cleanup ${command.status} without cleaning workspace`
|
|
597
|
+
: "workspace.cleanup command missing";
|
|
598
|
+
updateWorkspaceStatus(fresh.id, "active", {
|
|
599
|
+
cleanupCommandId: command?.id,
|
|
600
|
+
cleanupError: reason,
|
|
601
|
+
inFlightWatchdogRecoveredAt: now,
|
|
602
|
+
});
|
|
603
|
+
recoveredCleanupRequested.push(fresh.id);
|
|
604
|
+
createActivityEvent({
|
|
605
|
+
clientId: `workspace-inflight-watchdog-cleanup-${fresh.id}-${now}`,
|
|
606
|
+
kind: "state",
|
|
607
|
+
title: "Recovered stuck workspace cleanup",
|
|
608
|
+
body: `${fresh.branch ?? fresh.id} was stuck in cleanup_requested; returned to active (${reason})`,
|
|
609
|
+
meta: fresh.branch ?? fresh.id,
|
|
610
|
+
icon: "ti-refresh-alert",
|
|
611
|
+
view: "orchestrators",
|
|
612
|
+
metadata: { source: "server", maintenanceJobId: "workspace-auto-merge-watchdog", workspaceId: fresh.id, commandId: command?.id, commandStatus: command?.status },
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return { recoveredMergePlanned, recoveredCleanupRequested, settledTerminal, stillActive };
|
|
618
|
+
}
|
|
619
|
+
|
|
561
620
|
export function runAutoMergeWatchdog(
|
|
562
621
|
opts: { now?: number; staleMs?: number; log?: (message: string) => void } = {},
|
|
563
622
|
): Record<string, unknown> {
|
|
564
623
|
const now = opts.now ?? Date.now();
|
|
565
624
|
const staleMs = opts.staleMs ?? AUTO_MERGE_WATCHDOG_STALE_MS;
|
|
625
|
+
const inFlightRecovery = recoverAgedInFlightWorkspaces(now);
|
|
566
626
|
const ready = landableWorkspaces();
|
|
567
627
|
const repoRoots = [...new Set(ready.map((ws) => ws.repoRoot).filter(Boolean))];
|
|
568
628
|
const repoStaleForMs = Object.fromEntries(repoRoots.map((repoRoot) => {
|
|
@@ -571,10 +631,10 @@ export function runAutoMergeWatchdog(
|
|
|
571
631
|
return [repoRoot, now - lastProgressAt];
|
|
572
632
|
}));
|
|
573
633
|
const staleForMs = Object.values(repoStaleForMs).reduce((max, value) => Math.max(max, value), 0);
|
|
574
|
-
if (!ready.length) return { readyCount: 0, staleForMs, repoStaleForMs, restartedRepoRoots: [] };
|
|
634
|
+
if (!ready.length) return { readyCount: 0, staleForMs, repoStaleForMs, restartedRepoRoots: [], inFlightRecovery };
|
|
575
635
|
|
|
576
636
|
const restartedRepoRoots = repoRoots.filter((repoRoot) => repoStaleForMs[repoRoot]! >= staleMs);
|
|
577
|
-
if (!restartedRepoRoots.length) return { readyCount: ready.length, staleForMs, repoStaleForMs, restartedRepoRoots: [] };
|
|
637
|
+
if (!restartedRepoRoots.length) return { readyCount: ready.length, staleForMs, repoStaleForMs, restartedRepoRoots: [], inFlightRecovery };
|
|
578
638
|
|
|
579
639
|
const log = opts.log ?? console.warn;
|
|
580
640
|
log(`[workspace-auto-merge] Watchdog: no auto-merge cycle completed for ${staleForMs}ms while ${ready.length} ready workspace(s) exist; restarting ${restartedRepoRoots.length} repo merge loop(s)`);
|
|
@@ -583,7 +643,7 @@ export function runAutoMergeWatchdog(
|
|
|
583
643
|
if (existing) existing.rerun = true;
|
|
584
644
|
else scheduleRepoMerge(repoRoot);
|
|
585
645
|
}
|
|
586
|
-
return { readyCount: ready.length, staleForMs, repoStaleForMs, restartedRepoRoots };
|
|
646
|
+
return { readyCount: ready.length, staleForMs, repoStaleForMs, restartedRepoRoots, inFlightRecovery };
|
|
587
647
|
}
|
|
588
648
|
|
|
589
649
|
// Drain the landable rows for ONE repo (the event-driven fast path). Scoped to the
|