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.
Files changed (124) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-BVff6PSi.js → MessageBubble-BaPGFJxq.js} +2 -2
  4. package/public/assets/{MessageBubble-BVff6PSi.js.map → MessageBubble-BaPGFJxq.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BjrVU5Tg.js → PlanGraphPanel-BXCVfmln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BjrVU5Tg.js.map → PlanGraphPanel-BXCVfmln.js.map} +1 -1
  7. package/public/assets/{activity-Bk9LEUr4.js → activity-D_shwAZt.js} +2 -2
  8. package/public/assets/{activity-Bk9LEUr4.js.map → activity-D_shwAZt.js.map} +1 -1
  9. package/public/assets/{agent-profiles-DSVDoQ_N.js → agent-profiles-BrfVz5IV.js} +2 -2
  10. package/public/assets/{agent-profiles-DSVDoQ_N.js.map → agent-profiles-BrfVz5IV.js.map} +1 -1
  11. package/public/assets/agents-B5w-Z9Ic.js +2 -0
  12. package/public/assets/{agents-8rQuJqii.js.map → agents-B5w-Z9Ic.js.map} +1 -1
  13. package/public/assets/{analytics-DifGnk7q.js → analytics-Bm3D12UN.js} +2 -2
  14. package/public/assets/{analytics-DifGnk7q.js.map → analytics-Bm3D12UN.js.map} +1 -1
  15. package/public/assets/{automation-CMxQAf3l.js → automation-DOTL8BFs.js} +2 -2
  16. package/public/assets/{automation-CMxQAf3l.js.map → automation-DOTL8BFs.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CyC5Qrhc.js → branch-state-badge-BlqeJZ5-.js} +2 -2
  18. package/public/assets/{branch-state-badge-CyC5Qrhc.js.map → branch-state-badge-BlqeJZ5-.js.map} +1 -1
  19. package/public/assets/{channels-BLkdt2XR.js → channels-BoiKfK6Q.js} +2 -2
  20. package/public/assets/{channels-BLkdt2XR.js.map → channels-BoiKfK6Q.js.map} +1 -1
  21. package/public/assets/chat-PeU3iOaa.js +2 -0
  22. package/public/assets/{chat-jlXywb9G.js.map → chat-PeU3iOaa.js.map} +1 -1
  23. package/public/assets/{connectors-C0n12Je8.js → connectors-BvV8Hee2.js} +2 -2
  24. package/public/assets/{connectors-C0n12Je8.js.map → connectors-BvV8Hee2.js.map} +1 -1
  25. package/public/assets/{formatted-body-CzehtPtx.js → formatted-body-C1FgzlT1.js} +3 -3
  26. package/public/assets/{formatted-body-CzehtPtx.js.map → formatted-body-C1FgzlT1.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-D3Bnt0Fj.js → formatted-body-impl-BTpU1xH4.js} +2 -2
  28. package/public/assets/{formatted-body-impl-D3Bnt0Fj.js.map → formatted-body-impl-BTpU1xH4.js.map} +1 -1
  29. package/public/assets/index-DZEahmyc.js +25 -0
  30. package/public/assets/index-DZEahmyc.js.map +1 -0
  31. package/public/assets/{insights-3H6KttBi.js → insights-BqmgSpQm.js} +2 -2
  32. package/public/assets/{insights-3H6KttBi.js.map → insights-BqmgSpQm.js.map} +1 -1
  33. package/public/assets/{integrations-v14ehskT.js → integrations-CuT92zQi.js} +2 -2
  34. package/public/assets/{integrations-v14ehskT.js.map → integrations-CuT92zQi.js.map} +1 -1
  35. package/public/assets/{maintenance-BANYcBcn.js → maintenance-lM7FeXVC.js} +2 -2
  36. package/public/assets/{maintenance-BANYcBcn.js.map → maintenance-lM7FeXVC.js.map} +1 -1
  37. package/public/assets/{managed-agents-DCFMWKqt.js → managed-agents-DaPNv3yY.js} +3 -3
  38. package/public/assets/{managed-agents-DCFMWKqt.js.map → managed-agents-DaPNv3yY.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-CfaR-GRX.js → markdown-preview-impl-Wia3Ho1s.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-CfaR-GRX.js.map → markdown-preview-impl-Wia3Ho1s.js.map} +1 -1
  41. package/public/assets/{memory-BmkUEyd5.js → memory-B1v_zD-M.js} +2 -2
  42. package/public/assets/{memory-BmkUEyd5.js.map → memory-B1v_zD-M.js.map} +1 -1
  43. package/public/assets/{messages-BQ_fVnSA.js → messages-BNOx3yK6.js} +2 -2
  44. package/public/assets/{messages-BQ_fVnSA.js.map → messages-BNOx3yK6.js.map} +1 -1
  45. package/public/assets/{orchestrators-DC7y9vQD.js → orchestrators-DWFy4asl.js} +2 -2
  46. package/public/assets/{orchestrators-DC7y9vQD.js.map → orchestrators-DWFy4asl.js.map} +1 -1
  47. package/public/assets/{overview-6lzywFHq.js → overview-C_12cFPM.js} +2 -2
  48. package/public/assets/{overview-6lzywFHq.js.map → overview-C_12cFPM.js.map} +1 -1
  49. package/public/assets/{pairs-CRrRIaHI.js → pairs-DIc7gX83.js} +2 -2
  50. package/public/assets/{pairs-CRrRIaHI.js.map → pairs-DIc7gX83.js.map} +1 -1
  51. package/public/assets/{projects-CZJW35C7.js → projects-DPjvJUzT.js} +2 -2
  52. package/public/assets/{projects-CZJW35C7.js.map → projects-DPjvJUzT.js.map} +1 -1
  53. package/public/assets/{prompt-settings-DvlnMq8H.js → prompt-settings-CtumNgG8.js} +2 -2
  54. package/public/assets/{prompt-settings-DvlnMq8H.js.map → prompt-settings-CtumNgG8.js.map} +1 -1
  55. package/public/assets/{registry-3TWg4zbE.js → registry-DzUhgMq9.js} +2 -2
  56. package/public/assets/{registry-3TWg4zbE.js.map → registry-DzUhgMq9.js.map} +1 -1
  57. package/public/assets/{security-A2VYKHeI.js → security-BJ3_u0uY.js} +2 -2
  58. package/public/assets/{security-A2VYKHeI.js.map → security-BJ3_u0uY.js.map} +1 -1
  59. package/public/assets/{select-_gK5Ojjc.js → select-AWvlctyQ.js} +2 -2
  60. package/public/assets/{select-_gK5Ojjc.js.map → select-AWvlctyQ.js.map} +1 -1
  61. package/public/assets/{settings-BZ26ewcu.js → settings-5ofqi-bN.js} +4 -4
  62. package/public/assets/settings-5ofqi-bN.js.map +1 -0
  63. package/public/assets/store-D5et8685.js +9 -0
  64. package/public/assets/store-D5et8685.js.map +1 -0
  65. package/public/assets/{tasks-DlokSl_P.js → tasks-BEE9bBf7.js} +2 -2
  66. package/public/assets/{tasks-DlokSl_P.js.map → tasks-BEE9bBf7.js.map} +1 -1
  67. package/public/assets/{teams-t7M1HMgS.js → teams-C27H_Gwo.js} +2 -2
  68. package/public/assets/{teams-t7M1HMgS.js.map → teams-C27H_Gwo.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-DxgrO-jH.js → terminal-viewer-impl-WD2TboA7.js} +22 -22
  70. package/public/assets/{terminal-viewer-impl-DxgrO-jH.js.map → terminal-viewer-impl-WD2TboA7.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-V8LR-3Gi.js → work-queue-CtmPK2hK.js} +2 -2
  73. package/public/assets/{work-queue-V8LR-3Gi.js.map → work-queue-CtmPK2hK.js.map} +1 -1
  74. package/public/assets/{workspaces-DI317fMk.js → workspaces-B26EKv_H.js} +2 -2
  75. package/public/assets/{workspaces-DI317fMk.js.map → workspaces-B26EKv_H.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/src/agent-card-projection.ts +39 -9
  79. package/src/bus-outbox.ts +30 -0
  80. package/src/bus.ts +57 -1
  81. package/src/commands-db.ts +14 -0
  82. package/src/config-store.ts +2 -0
  83. package/src/config.ts +4 -0
  84. package/src/db/connection.ts +3 -1
  85. package/src/db/delivery.ts +85 -3
  86. package/src/db/index.ts +1 -0
  87. package/src/db/maintenance-worker.ts +91 -0
  88. package/src/db/messages.ts +19 -4
  89. package/src/db/migrations.ts +7 -0
  90. package/src/db/retention.ts +130 -0
  91. package/src/db/schema.ts +5 -0
  92. package/src/db/stats.ts +32 -8
  93. package/src/db/task-entity.ts +7 -8
  94. package/src/event-loop-lag.ts +90 -0
  95. package/src/lifecycle-manager.ts +11 -12
  96. package/src/maintenance/jobs.ts +23 -7
  97. package/src/maintenance/workspaces.ts +28 -47
  98. package/src/managed-policy-escalation.ts +47 -0
  99. package/src/mcp-task-tools.ts +4 -5
  100. package/src/retention-config-store.ts +32 -0
  101. package/src/retention-config.ts +58 -0
  102. package/src/routes/commands.ts +8 -245
  103. package/src/routes/messages.ts +13 -1
  104. package/src/routes/tasks.ts +3 -5
  105. package/src/routing-policy-store.ts +11 -1
  106. package/src/server.ts +2 -0
  107. package/src/services/issue-lifecycle.ts +23 -8
  108. package/src/services/send-message.ts +63 -21
  109. package/src/services/spawn-registration-watchdog.ts +74 -9
  110. package/src/services/task-entity.ts +54 -2
  111. package/src/settings/registry.ts +11 -0
  112. package/src/sse.ts +54 -9
  113. package/src/workspace-auto-merge.ts +94 -34
  114. package/src/workspace-command-settle.ts +227 -0
  115. package/src/workspace-escalation.ts +45 -0
  116. package/src/workspace-merge-progress.ts +16 -0
  117. package/src/workspace-orphans.ts +91 -3
  118. package/public/assets/agents-8rQuJqii.js +0 -2
  119. package/public/assets/chat-jlXywb9G.js +0 -2
  120. package/public/assets/index-4g3PbGVN.js +0 -25
  121. package/public/assets/index-4g3PbGVN.js.map +0 -1
  122. package/public/assets/settings-BZ26ewcu.js.map +0 -1
  123. package/public/assets/store-nXGxgz2Q.js +0 -9
  124. 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, emitNewMessage } from "../sse";
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
- const suppressedParent = suppressResultsOnlyAck(input);
188
- if (suppressedParent) return { message: suppressedParent, created: false, receipt };
189
- const result = sendMessageWithResult(input);
190
- if (result.created) {
191
- if (result.message.deliveryStatus === "queued") {
192
- emitMessageQueued(result.message);
193
- // Wake an on-demand managed policy that has no live agent attached yet.
194
- if (result.message.to?.startsWith("policy:")) {
195
- getLifecycleManager().onMessageForPolicy(result.message.to.slice("policy:".length));
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
- } else {
198
- emitNewMessage(result.message);
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
- return { message: result.message, created: result.created, receipt };
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 rows = getDb().query(`
37
- SELECT id, params, correlation_id, updated_at
38
- FROM commands
39
- WHERE type = 'agent.spawn'
40
- AND status = 'succeeded'
41
- ORDER BY updated_at ASC
42
- `).all() as SpawnCommandRow[];
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
- if (hasEverRegisteredAgent(group.spawnRequestId)) continue;
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
- export function createTaskEntity(input: CreateDeliverableTaskInput): TaskDetail {
47
- const detail = createDeliverableTask(input);
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({
@@ -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
- const scopedBroadcastAgentIds = msg.to === "broadcast"
176
- ? new Set(matchingDeliveryAgents("broadcast", { from: msg.from, channel: msg.channel }).map((agent) => agent.id))
177
- : null;
178
- const chunk = sseFrame("message.new", msg, seq);
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
- if (!conn.agentId) {
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
- /** The recorded count of consecutive no-progress merge attempts for a workspace (#638). */
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