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
@@ -1,19 +1,30 @@
1
- // The ONE projection applied to an AgentCard before it leaves the relay over SSE or REST:
1
+ // The ONE projection source for agent cards before they leave the relay:
2
2
  // branch-state badge fields (#236) + the resolved `issueRepo` for chat #NNN linkify (#533).
3
3
  //
4
4
  // Every `agent.status` emitter MUST route through this — the direct dashboard emitter
5
- // (sse.ts) AND the bus lifecycle emitter (bus.ts). The dashboard's agent.status handler
6
- // FULL-REPLACES its cached agent card with whatever arrives, so a single emitter sending a
7
- // raw (un-enriched) card silently blanks these derived fields until the next REST poll
8
- // which made #NNN links and branch badges flicker on/off every few seconds (#560). Keeping
9
- // one projection here is the structural guarantee that projection == reality on every
10
- // surface; a new emitter can't reintroduce the drift without going around this function.
5
+ // (sse.ts) AND the bus lifecycle emitter (bus.ts). The dashboard merges the slim status
6
+ // projection into its cached card, so a raw (un-enriched) status tick would still blank
7
+ // derived fields on fresh/new cards until the next REST poll (#560).
11
8
 
9
+ import { isRecord } from "agent-relay-sdk";
12
10
  import { attachBranchState } from "./agent-branch-state";
13
11
  import { deriveAgentBusyHealth } from "./agent-busy-health";
14
12
  import { attachIssueRepo } from "./agent-issue-repo";
15
13
  import type { AgentCard } from "./types";
16
14
 
15
+ const OMITTED_STATUS_META_KEYS = new Set([
16
+ "agentProfile",
17
+ "auth",
18
+ "providerCapabilities",
19
+ "lifecycleCapabilities",
20
+ ]);
21
+
22
+ const OMITTED_STATUS_WORKSPACE_KEYS = new Set([
23
+ "deps",
24
+ "probe",
25
+ "symlinks",
26
+ ]);
27
+
17
28
  /** Enrich an AgentCard with the derived fields the dashboard expects on every surface. */
18
29
  export function projectAgentCard(agent: AgentCard): AgentCard {
19
30
  const projected = attachIssueRepo(attachBranchState(agent));
@@ -21,11 +32,30 @@ export function projectAgentCard(agent: AgentCard): AgentCard {
21
32
  return busyHealth ? { ...projected, meta: { ...(projected.meta ?? {}), busyHealth } } : projected;
22
33
  }
23
34
 
35
+ function projectStatusMeta(meta: AgentCard["meta"]): AgentCard["meta"] | undefined {
36
+ if (!meta) return undefined;
37
+ const projected: Record<string, unknown> = {};
38
+ for (const [key, value] of Object.entries(meta)) {
39
+ if (OMITTED_STATUS_META_KEYS.has(key)) continue;
40
+ if (key === "workspace" && isRecord(value)) {
41
+ projected.workspace = Object.fromEntries(Object.entries(value).filter(([workspaceKey]) => !OMITTED_STATUS_WORKSPACE_KEYS.has(workspaceKey)));
42
+ continue;
43
+ }
44
+ projected[key] = value;
45
+ }
46
+ return projected;
47
+ }
48
+
24
49
  /** The `agent.status` SSE payload: the enriched card when the agent is known, else the
25
- * minimal offline stub the dashboard treats as "agent gone". Shared by both emitters. */
50
+ * minimal offline stub the dashboard treats as "agent gone". Shared by both emitters.
51
+ * Runtime status stays live, while bulky static/secret launch projection fields stay
52
+ * behind GET /api/agents/:id and the normal dashboard polling path. */
26
53
  export function projectAgentStatusData(
27
54
  agentId: string,
28
55
  agent: AgentCard | null | undefined,
29
56
  ): AgentCard | { id: string; status: string } {
30
- return agent ? projectAgentCard(agent) : { id: agentId, status: "offline" };
57
+ if (!agent) return { id: agentId, status: "offline" };
58
+ const projected = projectAgentCard(agent);
59
+ const meta = projectStatusMeta(projected.meta);
60
+ return meta ? { ...projected, meta } : { ...projected, meta: undefined };
31
61
  }
package/src/bus-outbox.ts CHANGED
@@ -20,6 +20,23 @@ interface OutboxRow {
20
20
 
21
21
  export function appendEvent(type: string, source: string, data: unknown, subject?: string): number {
22
22
  const timestamp = Date.now();
23
+ if (type === "message.batch") {
24
+ const messages = batchMessages(data);
25
+ if (messages.length > 0) {
26
+ return getDb().transaction(() => {
27
+ let firstSeq = 0;
28
+ const insert = getDb().query(`
29
+ INSERT INTO bus_outbox (event_type, source, subject, data, timestamp)
30
+ VALUES (?, ?, ?, ?, ?)
31
+ `);
32
+ for (const message of messages) {
33
+ const result = insert.run("message.new", message.from, String(message.id), JSON.stringify(message), timestamp);
34
+ if (firstSeq === 0) firstSeq = Number(result.lastInsertRowid);
35
+ }
36
+ return firstSeq;
37
+ })();
38
+ }
39
+ }
23
40
  const result = getDb().query(`
24
41
  INSERT INTO bus_outbox (event_type, source, subject, data, timestamp)
25
42
  VALUES (?, ?, ?, ?, ?)
@@ -73,3 +90,16 @@ function parseData(raw: string): Record<string, unknown> {
73
90
  return {};
74
91
  }
75
92
  }
93
+
94
+ function batchMessages(data: unknown): Array<{ id: number; from: string } & Record<string, unknown>> {
95
+ if (!data || typeof data !== "object" || Array.isArray(data)) return [];
96
+ const messages = (data as { messages?: unknown }).messages;
97
+ if (!Array.isArray(messages)) return [];
98
+ return messages.filter((message): message is { id: number; from: string } & Record<string, unknown> =>
99
+ Boolean(message) &&
100
+ typeof message === "object" &&
101
+ !Array.isArray(message) &&
102
+ typeof (message as { id?: unknown }).id === "number" &&
103
+ typeof (message as { from?: unknown }).from === "string"
104
+ );
105
+ }
package/src/bus.ts CHANGED
@@ -471,7 +471,7 @@ function handleResume(ws: BusWebSocket, frameId: string, since: number): void {
471
471
  sendError(ws, frameId, "REPLAY_GAP", "requested cursor is older than retained bus outbox events");
472
472
  return;
473
473
  }
474
- const events = replayEvents(since).map(outboxToPayload);
474
+ const events = replayEvents(since).flatMap(outboxToPayloads);
475
475
  send(ws, {
476
476
  type: "resumed",
477
477
  payload: {
@@ -503,6 +503,10 @@ function withRegistered(
503
503
  }
504
504
 
505
505
  function broadcastRelayEvent(event: RelayEvent): void {
506
+ if (event.type === "message.batch") {
507
+ broadcastMessageBatch(event);
508
+ return;
509
+ }
506
510
  const frame = JSON.stringify({
507
511
  type: "event",
508
512
  payload: {
@@ -521,6 +525,41 @@ function broadcastRelayEvent(event: RelayEvent): void {
521
525
  }
522
526
  }
523
527
 
528
+ function broadcastMessageBatch(event: RelayEvent): void {
529
+ const messages = batchMessages(event.data);
530
+ const messageEvents = messages.map((message, index) => {
531
+ const messageEvent: RelayEvent = {
532
+ ...event,
533
+ seq: event.seq + index,
534
+ type: "message.new",
535
+ source: message.from,
536
+ subject: String(message.id),
537
+ data: message as unknown as Record<string, unknown>,
538
+ };
539
+ return {
540
+ event: messageEvent,
541
+ frame: JSON.stringify({
542
+ type: "event",
543
+ payload: {
544
+ seq: messageEvent.seq,
545
+ eventType: "message.new",
546
+ source: message.from,
547
+ subject: String(message.id),
548
+ data: message,
549
+ timestamp: event.timestamp,
550
+ },
551
+ }),
552
+ };
553
+ });
554
+ const agentCache = new Map<string, AgentCard | null>();
555
+ for (const conn of busConnections.values()) {
556
+ for (const { event: messageEvent, frame } of messageEvents) {
557
+ if (!connectionWantsEvent(conn, messageEvent, agentCache)) continue;
558
+ conn.ws.send(frame);
559
+ }
560
+ }
561
+ }
562
+
524
563
  function fanoutAgent(agentId: string, cache: Map<string, AgentCard | null>): AgentCard | null {
525
564
  if (!cache.has(agentId)) cache.set(agentId, getAgent(agentId));
526
565
  return cache.get(agentId) ?? null;
@@ -565,6 +604,23 @@ function outboxToPayload(event: BusEvent) {
565
604
  };
566
605
  }
567
606
 
607
+ function outboxToPayloads(event: BusEvent) {
608
+ if (event.eventType !== "message.batch") return [outboxToPayload(event)];
609
+ return batchMessages(event.data).map((message, index) => ({
610
+ seq: event.seq + index,
611
+ eventType: "message.new",
612
+ source: message.from,
613
+ subject: String(message.id),
614
+ data: message as unknown as Record<string, unknown>,
615
+ timestamp: event.timestamp,
616
+ }));
617
+ }
618
+
619
+ function batchMessages(data: Record<string, unknown>): Message[] {
620
+ const messages = (data as { messages?: unknown }).messages;
621
+ return Array.isArray(messages) ? messages as Message[] : [];
622
+ }
623
+
568
624
  function emitAgentStatusEvent(agentId: string): void {
569
625
  const agent = getAgent(agentId);
570
626
  // Same enriched projection as sse.ts's emitAgentStatus — without this the bus lifecycle
@@ -38,11 +38,13 @@ const TERMINAL_STATUSES: CommandStatus[] = ["succeeded", "failed", "canceled", "
38
38
  // committed to doing the work, not from enqueue.
39
39
  const SPAWN_TTL_MS = 180_000;
40
40
  const LIFECYCLE_TTL_MS = 30_000;
41
+ const WORKSPACE_COMMAND_TTL_MS = 20 * 60_000;
41
42
  const DISPATCH_STATUSES: CommandStatus[] = ["accepted", "running"];
42
43
 
43
44
  function commandTtlMs(type: string): number | undefined {
44
45
  if (type === "agent.spawn") return SPAWN_TTL_MS;
45
46
  if (type === "agent.shutdown" || type === "agent.restart" || type === "agent.reconnect" || type === "agent.kill" || type === "agent.compact" || type === "agent.clearContext" || type === "agent.injectContext") return LIFECYCLE_TTL_MS;
47
+ if (type === "workspace.merge" || type === "workspace.cleanup" || type === "workspace.reconcile" || type === "workspace.idle-refresh") return WORKSPACE_COMMAND_TTL_MS;
46
48
  return undefined;
47
49
  }
48
50
 
@@ -148,6 +150,18 @@ export function findSpawnCommandByRequestId(spawnRequestId: string, statuses?: C
148
150
  return rows[0] ? rowToCommand(rows[0]) : null;
149
151
  }
150
152
 
153
+ export function findLatestCommandByCorrelation(type: string, correlationId: string): Command | null {
154
+ const row = getDb()
155
+ .query(`
156
+ SELECT * FROM commands
157
+ WHERE type = ? AND correlation_id = ?
158
+ ORDER BY created_at DESC
159
+ LIMIT 1
160
+ `)
161
+ .get(type, correlationId) as CommandRow | undefined;
162
+ return row ? rowToCommand(row) : null;
163
+ }
164
+
151
165
  export function countInFlightSpawnCommands(parentId: string): number {
152
166
  const row = getDb()
153
167
  .query(
@@ -9,6 +9,7 @@ import {
9
9
  validateNotificationsConfig,
10
10
  } from "./notification-settings";
11
11
  import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
12
+ import { RETENTION_NAMESPACE, validateRetentionConfig } from "./retention-config";
12
13
  import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
13
14
  import { PROJECT_INSTRUCTION_POLICY_NAMES } from "./project-instructions";
14
15
  import { renderTemplate } from "./prompt-resolver";
@@ -674,6 +675,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
674
675
  if (namespace === SELF_RESUME_NAMESPACE) return validateSelfResumeConfig(value);
675
676
  if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
676
677
  if (namespace === LANDING_NAMESPACE) return validateLandingConfig(value);
678
+ if (namespace === RETENTION_NAMESPACE) return validateRetentionConfig(value);
677
679
  if (namespace === TEAM_TEMPLATE_NAMESPACE) return validateTeamTemplate(key, value);
678
680
  if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
679
681
  return value;
package/src/config.ts CHANGED
@@ -64,6 +64,10 @@ export const STUCK_BUSY_PROVIDER_TURN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUS
64
64
  export const STUCK_BUSY_STARTUP_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_STARTUP_MS", STUCK_BUSY_THRESHOLD_MS);
65
65
  export const STUCK_BUSY_RECONNECT_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_RECONNECT_MS", STUCK_BUSY_THRESHOLD_MS);
66
66
  export const STUCK_BUSY_SHUTDOWN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_SHUTDOWN_MS", STUCK_BUSY_THRESHOLD_MS);
67
+ export const EVENT_LOOP_LAG_SAMPLE_MS = envPositiveInt("AGENT_RELAY_EVENT_LOOP_LAG_SAMPLE_MS", 1_000);
68
+ export const EVENT_LOOP_LAG_WINDOW_SAMPLES = envPositiveInt("AGENT_RELAY_EVENT_LOOP_LAG_WINDOW_SAMPLES", 60);
69
+ export const EVENT_LOOP_LAG_WARN_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_WARN_MS", 250);
70
+ export const EVENT_LOOP_LAG_LOG_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_LOG_MS", EVENT_LOOP_LAG_WARN_MS);
67
71
  // #713 — provider-token silence ALONE is not a wedge signal for Claude (long turns, extended
68
72
  // thinking, and native compaction all go token-silent while healthy). Before declaring stuck we
69
73
  // ALSO require the transport heartbeat to be genuinely dead: no server frame / heartbeat-ack within
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { existsSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { DB_SLOW_QUERY_MS } from "../config";
4
5
 
@@ -79,7 +80,8 @@ export async function runDbMaintenanceWorker<T>(operation: string, args: string[
79
80
  throw new Error("database worker requires a file-backed database");
80
81
  }
81
82
  if (signal?.aborted) throw new Error("database worker aborted");
82
- const proc = Bun.spawn([process.execPath, join(import.meta.dir, "maintenance-worker.ts"), operation, dbPath, ...args], {
83
+ const bunPath = existsSync(process.execPath) ? process.execPath : (Bun.which("bun") ?? process.execPath);
84
+ const proc = Bun.spawn([bunPath, join(import.meta.dir, "maintenance-worker.ts"), operation, dbPath, ...args], {
83
85
  stdin: "ignore",
84
86
  stdout: "pipe",
85
87
  stderr: "pipe",
@@ -474,13 +474,95 @@ export function resolveQueuedDirectMessages(agentId: string): Message[] {
474
474
  * message that died without ever reaching its recipient — as a health signal.
475
475
  */
476
476
  export function countExpiredUndeliveredMessages(now: number = Date.now(), windowMs = 86_400_000): number {
477
- return (getDb().query(`
478
- SELECT COUNT(*) as c FROM messages
477
+ return summarizeExpiredUndeliveredMessages(now, windowMs).unexplainedCount;
478
+ }
479
+
480
+ interface ExpiredUndeliveredRow {
481
+ id: number;
482
+ toTarget: string;
483
+ kind: string;
484
+ subject?: string | null;
485
+ policyValue?: string | null;
486
+ policyStatus?: string | null;
487
+ policyAgentId?: string | null;
488
+ policyLastError?: string | null;
489
+ }
490
+
491
+ export interface ExpiredUndeliveredMessagesSummary {
492
+ totalCount: number;
493
+ unexplainedCount: number;
494
+ explainedManagedPolicyCount: number;
495
+ subjects: NonNullable<HealthCheck["subjects"]>;
496
+ }
497
+
498
+ function managedPolicyExpiryExplanation(row: ExpiredUndeliveredRow): string | undefined {
499
+ if (!row.toTarget.startsWith("policy:")) return undefined;
500
+ if (typeof row.policyValue !== "string") return undefined;
501
+ const policy = parseJson<Record<string, unknown>>(row.policyValue, {});
502
+ if (policy.enabled === false) return "managed policy is disabled";
503
+ if (row.policyStatus === "backoff" || row.policyStatus === "failed") {
504
+ return `managed policy is ${row.policyStatus}${row.policyLastError ? `: ${row.policyLastError}` : ""}`;
505
+ }
506
+ return undefined;
507
+ }
508
+
509
+ /**
510
+ * Classify queued-message expiries that happened within the trailing window.
511
+ * Channel/direct/unknown expiries remain a silent-loss health warning. Managed
512
+ * policy expiries with an explicit unavailable policy state are explained by the
513
+ * policy health surface instead, so they stay visible without degrading global
514
+ * health as generic message loss (#998).
515
+ */
516
+ export function summarizeExpiredUndeliveredMessages(now: number = Date.now(), windowMs = 86_400_000): ExpiredUndeliveredMessagesSummary {
517
+ const rows = getDb().query(`
518
+ SELECT
519
+ m.id,
520
+ m.to_target AS toTarget,
521
+ m.kind,
522
+ m.subject,
523
+ c.value AS policyValue,
524
+ s.status AS policyStatus,
525
+ s.agent_id AS policyAgentId,
526
+ s.last_error AS policyLastError
527
+ FROM messages m
528
+ LEFT JOIN config c
529
+ ON c.namespace = 'spawn-policy'
530
+ AND c.key = substr(m.to_target, 8)
531
+ LEFT JOIN managed_agent_state s
532
+ ON s.policy_name = substr(m.to_target, 8)
479
533
  WHERE delivery_status = 'dead'
480
534
  AND delivery_poison_reason = 'queued message expired'
481
535
  AND delivery_updated_at IS NOT NULL
482
536
  AND delivery_updated_at >= ?
483
- `).get(now - windowMs) as { c: number }).c;
537
+ `).all(now - windowMs) as ExpiredUndeliveredRow[];
538
+
539
+ let unexplainedCount = 0;
540
+ let explainedManagedPolicyCount = 0;
541
+ const explainedByTarget = new Map<string, { count: number; detail: string }>();
542
+
543
+ for (const row of rows) {
544
+ const explanation = managedPolicyExpiryExplanation(row);
545
+ if (!explanation) {
546
+ unexplainedCount += 1;
547
+ continue;
548
+ }
549
+ explainedManagedPolicyCount += 1;
550
+ const existing = explainedByTarget.get(row.toTarget);
551
+ if (existing) existing.count += 1;
552
+ else explainedByTarget.set(row.toTarget, { count: 1, detail: explanation });
553
+ }
554
+
555
+ return {
556
+ totalCount: rows.length,
557
+ unexplainedCount,
558
+ explainedManagedPolicyCount,
559
+ subjects: [...explainedByTarget.entries()].slice(0, 10).map(([target, item]) => ({
560
+ id: target,
561
+ label: target,
562
+ status: "explained",
563
+ detail: `${item.count} expired message(s); ${item.detail}`,
564
+ })),
565
+ };
484
566
  }
485
567
 
486
568
  export function expireQueuedMessages(now: number = Date.now()): Message[] {
package/src/db/index.ts CHANGED
@@ -33,6 +33,7 @@ export * from "./continuations.ts";
33
33
  export * from "./continuation-archives.ts";
34
34
  export * from "./crash-reports.ts";
35
35
  export * from "./terminal-events.ts";
36
+ export * from "./retention.ts";
36
37
  export * from "./plans.ts";
37
38
  export * from "./teams.ts";
38
39
  export * from "./blackboard.ts";
@@ -29,6 +29,97 @@ try {
29
29
  return db.query("DELETE FROM messages WHERE created_at < ?").run(cutoff).changes;
30
30
  })();
31
31
  console.log(JSON.stringify({ prunedMessages }));
32
+ } else if (operation === "retention-prune") {
33
+ if (!arg) throw new Error("retention-prune requires options JSON");
34
+ const opts = JSON.parse(arg) as {
35
+ messageCutoff: number;
36
+ activityCutoff: number;
37
+ activityMaxRows: number;
38
+ spawnCommandCutoff: number;
39
+ batchSize: number;
40
+ };
41
+ const batchSize = Math.min(Math.max(Math.trunc(opts.batchSize), 1), 50_000);
42
+ const result = db.transaction(() => {
43
+ db.query(`
44
+ UPDATE messages
45
+ SET reply_to = NULL
46
+ WHERE reply_to IN (
47
+ SELECT id FROM messages
48
+ WHERE created_at < ?
49
+ ORDER BY created_at ASC, id ASC
50
+ LIMIT ?
51
+ )
52
+ `).run(opts.messageCutoff, batchSize);
53
+ const prunedMessages = db.query(`
54
+ DELETE FROM messages
55
+ WHERE id IN (
56
+ SELECT id FROM messages
57
+ WHERE created_at < ?
58
+ ORDER BY created_at ASC, id ASC
59
+ LIMIT ?
60
+ )
61
+ `).run(opts.messageCutoff, batchSize).changes;
62
+ const prunedActivityEvents = db.query(`
63
+ DELETE FROM activity_events
64
+ WHERE id IN (
65
+ SELECT ae.id
66
+ FROM activity_events ae
67
+ WHERE (
68
+ ae.created_at < ?
69
+ OR ae.id NOT IN (
70
+ SELECT id
71
+ FROM activity_events
72
+ ORDER BY created_at DESC, id DESC
73
+ LIMIT ?
74
+ )
75
+ )
76
+ AND NOT (
77
+ ae.kind = 'state'
78
+ AND ae.title = 'Agent registered'
79
+ AND EXISTS (
80
+ SELECT 1
81
+ FROM commands c
82
+ WHERE c.type = 'agent.spawn'
83
+ AND c.status = 'succeeded'
84
+ AND coalesce(json_extract(c.params, '$.registrationConfirmed'), 0) != 1
85
+ AND (
86
+ c.correlation_id = json_extract(ae.metadata, '$.spawnRequestId')
87
+ OR json_extract(c.params, '$.spawnRequestId') = json_extract(ae.metadata, '$.spawnRequestId')
88
+ )
89
+ )
90
+ )
91
+ ORDER BY ae.created_at ASC, ae.id ASC
92
+ LIMIT ?
93
+ )
94
+ `).run(opts.activityCutoff, opts.activityMaxRows, batchSize).changes;
95
+ const prunedSpawnCommands = db.query(`
96
+ DELETE FROM commands
97
+ WHERE id IN (
98
+ SELECT c.id
99
+ FROM commands c
100
+ WHERE c.type = 'agent.spawn'
101
+ AND c.status IN ('succeeded', 'failed', 'canceled', 'timed_out')
102
+ AND c.updated_at < ?
103
+ AND NOT (
104
+ c.status = 'succeeded'
105
+ AND coalesce(json_extract(c.params, '$.registrationConfirmed'), 0) != 1
106
+ )
107
+ AND NOT EXISTS (
108
+ SELECT 1
109
+ FROM managed_agent_state mas
110
+ WHERE mas.spawn_request_id IS NOT NULL
111
+ AND (
112
+ mas.spawn_request_id = c.correlation_id
113
+ OR mas.spawn_request_id = json_extract(c.params, '$.spawnRequestId')
114
+ )
115
+ )
116
+ ORDER BY c.updated_at ASC, c.created_at ASC
117
+ LIMIT ?
118
+ )
119
+ `).run(opts.spawnCommandCutoff, batchSize).changes;
120
+ return { prunedMessages, prunedActivityEvents, prunedSpawnCommands };
121
+ })();
122
+ console.log(JSON.stringify(result));
32
123
  } else {
33
124
  throw new Error(`unknown maintenance operation: ${operation}`);
34
125
  }
@@ -402,7 +402,21 @@ function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: number):
402
402
  return Math.floor(occurredAt);
403
403
  }
404
404
 
405
- export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean; updated?: boolean } {
405
+ export interface SendMessageResult {
406
+ message: Message;
407
+ created: boolean;
408
+ updated?: boolean;
409
+ }
410
+
411
+ export function sendMessageWithResult(input: SendMessageInput): SendMessageResult {
412
+ return sendMessageWithResultInternal(input, true);
413
+ }
414
+
415
+ export function sendMessagesBatchWithResult(inputs: SendMessageInput[]): SendMessageResult[] {
416
+ return getDb().transaction(() => inputs.map((input) => sendMessageWithResultInternal(input, false)))();
417
+ }
418
+
419
+ function sendMessageWithResultInternal(input: SendMessageInput, wrapTransaction: boolean): SendMessageResult {
406
420
  const now = Date.now();
407
421
  const payload = input.payload ?? {};
408
422
  const attachmentRefs = cleanAttachmentRefs(payload);
@@ -495,7 +509,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
495
509
  maxAgeSeconds = DEFAULT_FANOUT_TTL_SECONDS;
496
510
  }
497
511
 
498
- const id = getDb().transaction(() => {
512
+ const insertMessage = () => {
499
513
  const result = insert.run({
500
514
  $from: input.from,
501
515
  $to: input.to,
@@ -528,7 +542,8 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
528
542
  else enforceQueueLimit(input.to);
529
543
  }
530
544
  return newId;
531
- })();
545
+ };
546
+ const id = wrapTransaction ? getDb().transaction(insertMessage)() : insertMessage();
532
547
 
533
548
  return { message: getMessage(id)!, created: true };
534
549
  }
@@ -543,7 +558,7 @@ export function getThread(messageId: number): Message[] {
543
558
  const threadId = msg.threadId ?? msg.id;
544
559
  return (
545
560
  getDb()
546
- .query(`${MSG_SELECT} WHERE m.thread_id = ? ORDER BY m.created_at ASC`)
561
+ .query(`${MSG_SELECT} WHERE m.thread_id = ? ORDER BY m.created_at ASC, m.id ASC`)
547
562
  .all(threadId) as any[]
548
563
  ).map(rowToMessage);
549
564
  }
@@ -706,6 +706,7 @@ export function applyMigrations(): void {
706
706
  getDb().run("ALTER TABLE agents ADD COLUMN team_id TEXT");
707
707
  }
708
708
  getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_team ON agents(team_id)");
709
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_spawn_request ON agents(json_extract(meta, '$.spawnRequestId'))");
709
710
  getDb().run(`
710
711
  CREATE TABLE IF NOT EXISTS notification_subscriptions (
711
712
  id TEXT PRIMARY KEY,
@@ -913,6 +914,12 @@ export function applyMigrations(): void {
913
914
  getDb().run("CREATE INDEX IF NOT EXISTS idx_agent_active_memories_agent ON agent_active_memories(agent_id)");
914
915
  getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
915
916
  getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_kind ON agents(kind)");
917
+ getDb().run(`
918
+ CREATE INDEX IF NOT EXISTS idx_activity_registered_spawn_request
919
+ ON activity_events(json_extract(metadata, '$.spawnRequestId'))
920
+ WHERE kind = 'state' AND title = 'Agent registered'
921
+ `);
922
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_commands_spawn_watchdog ON commands(type, status, updated_at)");
916
923
 
917
924
  getDb().run(`
918
925
  CREATE TABLE IF NOT EXISTS integration_registry (