agent-relay-server 0.120.6 → 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 (104) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-So86mU9-.js → MessageBubble-BaPGFJxq.js} +2 -2
  4. package/public/assets/{MessageBubble-So86mU9-.js.map → MessageBubble-BaPGFJxq.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-DBZYAsww.js → PlanGraphPanel-BXCVfmln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DBZYAsww.js.map → PlanGraphPanel-BXCVfmln.js.map} +1 -1
  7. package/public/assets/{activity-CZNwmXg1.js → activity-D_shwAZt.js} +2 -2
  8. package/public/assets/{activity-CZNwmXg1.js.map → activity-D_shwAZt.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BhadoGwi.js → agent-profiles-BrfVz5IV.js} +2 -2
  10. package/public/assets/{agent-profiles-BhadoGwi.js.map → agent-profiles-BrfVz5IV.js.map} +1 -1
  11. package/public/assets/{agents-DWn1xx9x.js → agents-B5w-Z9Ic.js} +2 -2
  12. package/public/assets/{agents-DWn1xx9x.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-BExrtKkb.js → automation-DOTL8BFs.js} +2 -2
  16. package/public/assets/{automation-BExrtKkb.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-C7OhKtr6.js → chat-PeU3iOaa.js} +2 -2
  22. package/public/assets/{chat-C7OhKtr6.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-BCQT2qSb.js → formatted-body-C1FgzlT1.js} +3 -3
  26. package/public/assets/{formatted-body-BCQT2qSb.js.map → formatted-body-C1FgzlT1.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-Bk7wnESl.js → formatted-body-impl-BTpU1xH4.js} +2 -2
  28. package/public/assets/{formatted-body-impl-Bk7wnESl.js.map → formatted-body-impl-BTpU1xH4.js.map} +1 -1
  29. package/public/assets/{index-CRraCeo_.js → index-DZEahmyc.js} +6 -6
  30. package/public/assets/{index-CRraCeo_.js.map → index-DZEahmyc.js.map} +1 -1
  31. package/public/assets/{insights-DEgES6jz.js → insights-BqmgSpQm.js} +2 -2
  32. package/public/assets/{insights-DEgES6jz.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-BgVB6_gO.js → maintenance-lM7FeXVC.js} +2 -2
  36. package/public/assets/{maintenance-BgVB6_gO.js.map → maintenance-lM7FeXVC.js.map} +1 -1
  37. package/public/assets/{managed-agents-8_NKSkhI.js → managed-agents-DaPNv3yY.js} +2 -2
  38. package/public/assets/{managed-agents-8_NKSkhI.js.map → managed-agents-DaPNv3yY.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-yxQkrd2M.js → markdown-preview-impl-Wia3Ho1s.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-yxQkrd2M.js.map → markdown-preview-impl-Wia3Ho1s.js.map} +1 -1
  41. package/public/assets/{memory-CJD8eekg.js → memory-B1v_zD-M.js} +2 -2
  42. package/public/assets/{memory-CJD8eekg.js.map → memory-B1v_zD-M.js.map} +1 -1
  43. package/public/assets/{messages-DLHMZbHZ.js → messages-BNOx3yK6.js} +2 -2
  44. package/public/assets/{messages-DLHMZbHZ.js.map → messages-BNOx3yK6.js.map} +1 -1
  45. package/public/assets/{orchestrators-DEOep7FX.js → orchestrators-DWFy4asl.js} +2 -2
  46. package/public/assets/{orchestrators-DEOep7FX.js.map → orchestrators-DWFy4asl.js.map} +1 -1
  47. package/public/assets/{overview-BgpfO6mt.js → overview-C_12cFPM.js} +2 -2
  48. package/public/assets/{overview-BgpfO6mt.js.map → overview-C_12cFPM.js.map} +1 -1
  49. package/public/assets/{pairs-8NVKHh96.js → pairs-DIc7gX83.js} +2 -2
  50. package/public/assets/{pairs-8NVKHh96.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-fqyLwUa-.js → prompt-settings-CtumNgG8.js} +2 -2
  54. package/public/assets/{prompt-settings-fqyLwUa-.js.map → prompt-settings-CtumNgG8.js.map} +1 -1
  55. package/public/assets/{registry-BKbLL4mO.js → registry-DzUhgMq9.js} +2 -2
  56. package/public/assets/{registry-BKbLL4mO.js.map → registry-DzUhgMq9.js.map} +1 -1
  57. package/public/assets/{security-BlTFTbq0.js → security-BJ3_u0uY.js} +2 -2
  58. package/public/assets/{security-BlTFTbq0.js.map → security-BJ3_u0uY.js.map} +1 -1
  59. package/public/assets/{select-Zd_qh0uU.js → select-AWvlctyQ.js} +2 -2
  60. package/public/assets/{select-Zd_qh0uU.js.map → select-AWvlctyQ.js.map} +1 -1
  61. package/public/assets/{settings-nHuSVRkL.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-C3JhQ8vB.js → tasks-BEE9bBf7.js} +2 -2
  66. package/public/assets/{tasks-C3JhQ8vB.js.map → tasks-BEE9bBf7.js.map} +1 -1
  67. package/public/assets/{teams-C9E9Yt39.js → teams-C27H_Gwo.js} +2 -2
  68. package/public/assets/{teams-C9E9Yt39.js.map → teams-C27H_Gwo.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-FP0JlsvS.js → terminal-viewer-impl-WD2TboA7.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-FP0JlsvS.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-Ema7Hy3s.js → work-queue-CtmPK2hK.js} +2 -2
  73. package/public/assets/{work-queue-Ema7Hy3s.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/config-store.ts +2 -0
  82. package/src/config.ts +4 -0
  83. package/src/db/connection.ts +3 -1
  84. package/src/db/index.ts +1 -0
  85. package/src/db/maintenance-worker.ts +91 -0
  86. package/src/db/messages.ts +19 -4
  87. package/src/db/migrations.ts +7 -0
  88. package/src/db/retention.ts +130 -0
  89. package/src/db/schema.ts +5 -0
  90. package/src/db/stats.ts +22 -0
  91. package/src/event-loop-lag.ts +90 -0
  92. package/src/maintenance/jobs.ts +20 -6
  93. package/src/retention-config-store.ts +32 -0
  94. package/src/retention-config.ts +58 -0
  95. package/src/routes/messages.ts +13 -1
  96. package/src/routing-policy-store.ts +11 -1
  97. package/src/server.ts +2 -0
  98. package/src/services/send-message.ts +63 -21
  99. package/src/services/spawn-registration-watchdog.ts +74 -9
  100. package/src/settings/registry.ts +11 -0
  101. package/src/sse.ts +54 -9
  102. package/public/assets/settings-nHuSVRkL.js.map +0 -1
  103. package/public/assets/store-nXGxgz2Q.js +0 -9
  104. 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
@@ -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",
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 (
@@ -0,0 +1,130 @@
1
+ import { getDb, getDbPath, runDbMaintenanceWorker } from "./connection.ts";
2
+
3
+ interface RetentionPruneInput {
4
+ messageCutoff: number;
5
+ activityCutoff: number;
6
+ activityMaxRows: number;
7
+ spawnCommandCutoff: number;
8
+ batchSize: number;
9
+ }
10
+
11
+ interface RetentionPruneResult {
12
+ prunedMessages: number;
13
+ prunedActivityEvents: number;
14
+ prunedSpawnCommands: number;
15
+ offThread: boolean;
16
+ }
17
+
18
+ function boundedBatchSize(batchSize: number): number {
19
+ return Math.min(Math.max(Math.trunc(batchSize), 1), 50_000);
20
+ }
21
+
22
+ function pruneMessagesBefore(cutoff: number, batchSize: number): number {
23
+ return getDb().transaction(() => {
24
+ getDb().query(`
25
+ UPDATE messages
26
+ SET reply_to = NULL
27
+ WHERE reply_to IN (
28
+ SELECT id FROM messages
29
+ WHERE created_at < ?
30
+ ORDER BY created_at ASC, id ASC
31
+ LIMIT ?
32
+ )
33
+ `).run(cutoff, batchSize);
34
+ return getDb().query(`
35
+ DELETE FROM messages
36
+ WHERE id IN (
37
+ SELECT id FROM messages
38
+ WHERE created_at < ?
39
+ ORDER BY created_at ASC, id ASC
40
+ LIMIT ?
41
+ )
42
+ `).run(cutoff, batchSize).changes;
43
+ })();
44
+ }
45
+
46
+ function pruneActivityEvents(input: RetentionPruneInput): number {
47
+ return getDb().query(`
48
+ DELETE FROM activity_events
49
+ WHERE id IN (
50
+ SELECT ae.id
51
+ FROM activity_events ae
52
+ WHERE (
53
+ ae.created_at < ?
54
+ OR ae.id NOT IN (
55
+ SELECT id
56
+ FROM activity_events
57
+ ORDER BY created_at DESC, id DESC
58
+ LIMIT ?
59
+ )
60
+ )
61
+ AND NOT (
62
+ ae.kind = 'state'
63
+ AND ae.title = 'Agent registered'
64
+ AND EXISTS (
65
+ SELECT 1
66
+ FROM commands c
67
+ WHERE c.type = 'agent.spawn'
68
+ AND c.status = 'succeeded'
69
+ AND coalesce(json_extract(c.params, '$.registrationConfirmed'), 0) != 1
70
+ AND (
71
+ c.correlation_id = json_extract(ae.metadata, '$.spawnRequestId')
72
+ OR json_extract(c.params, '$.spawnRequestId') = json_extract(ae.metadata, '$.spawnRequestId')
73
+ )
74
+ )
75
+ )
76
+ ORDER BY ae.created_at ASC, ae.id ASC
77
+ LIMIT ?
78
+ )
79
+ `).run(input.activityCutoff, input.activityMaxRows, input.batchSize).changes;
80
+ }
81
+
82
+ function pruneTerminalSpawnCommands(cutoff: number, batchSize: number): number {
83
+ return getDb().query(`
84
+ DELETE FROM commands
85
+ WHERE id IN (
86
+ SELECT c.id
87
+ FROM commands c
88
+ WHERE c.type = 'agent.spawn'
89
+ AND c.status IN ('succeeded', 'failed', 'canceled', 'timed_out')
90
+ AND c.updated_at < ?
91
+ AND NOT (
92
+ c.status = 'succeeded'
93
+ AND coalesce(json_extract(c.params, '$.registrationConfirmed'), 0) != 1
94
+ )
95
+ AND NOT EXISTS (
96
+ SELECT 1
97
+ FROM managed_agent_state mas
98
+ WHERE mas.spawn_request_id IS NOT NULL
99
+ AND (
100
+ mas.spawn_request_id = c.correlation_id
101
+ OR mas.spawn_request_id = json_extract(c.params, '$.spawnRequestId')
102
+ )
103
+ )
104
+ ORDER BY c.updated_at ASC, c.created_at ASC
105
+ LIMIT ?
106
+ )
107
+ `).run(cutoff, batchSize).changes;
108
+ }
109
+
110
+ export function pruneRetentionBatch(input: RetentionPruneInput): Omit<RetentionPruneResult, "offThread"> {
111
+ const batchSize = boundedBatchSize(input.batchSize);
112
+ return getDb().transaction(() => ({
113
+ prunedMessages: pruneMessagesBefore(input.messageCutoff, batchSize),
114
+ prunedActivityEvents: pruneActivityEvents({ ...input, batchSize }),
115
+ prunedSpawnCommands: pruneTerminalSpawnCommands(input.spawnCommandCutoff, batchSize),
116
+ }))();
117
+ }
118
+
119
+ export async function pruneRetentionBatchAsync(input: RetentionPruneInput, options: { signal?: AbortSignal } = {}): Promise<RetentionPruneResult> {
120
+ const normalized = { ...input, batchSize: boundedBatchSize(input.batchSize) };
121
+ if (!getDbPath() || getDbPath() === ":memory:") {
122
+ return { ...pruneRetentionBatch(normalized), offThread: false };
123
+ }
124
+ const result = await runDbMaintenanceWorker<Omit<RetentionPruneResult, "offThread">>(
125
+ "retention-prune",
126
+ [JSON.stringify(normalized)],
127
+ options.signal,
128
+ );
129
+ return { ...result, offThread: true };
130
+ }
package/src/db/schema.ts CHANGED
@@ -114,6 +114,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
114
114
  last_seen INTEGER NOT NULL,
115
115
  created_at INTEGER NOT NULL
116
116
  );
117
+ CREATE INDEX IF NOT EXISTS idx_agents_spawn_request ON agents(json_extract(meta, '$.spawnRequestId'));
117
118
  CREATE TABLE IF NOT EXISTS notification_subscriptions (
118
119
  id TEXT PRIMARY KEY,
119
120
  agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
@@ -865,6 +866,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
865
866
  CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
866
867
  CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
867
868
  CREATE INDEX IF NOT EXISTS idx_activity_agent ON activity_events(agent_id, created_at); CREATE INDEX IF NOT EXISTS idx_activity_policy_created ON activity_events(json_extract(metadata, '$.policyName'), created_at DESC, id DESC);
869
+ CREATE INDEX IF NOT EXISTS idx_activity_registered_spawn_request
870
+ ON activity_events(json_extract(metadata, '$.spawnRequestId'))
871
+ WHERE kind = 'state' AND title = 'Agent registered';
868
872
  -- issue_ref_id/issue_action indexes are created in applyMigrations() AFTER the ALTER — never inline here (#483: inline index over a not-yet-migrated column crashes initDb on existing DBs).
869
873
 
870
874
  CREATE TABLE IF NOT EXISTS channels (
@@ -942,6 +946,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
942
946
  CREATE INDEX IF NOT EXISTS idx_commands_status ON commands(status);
943
947
  CREATE INDEX IF NOT EXISTS idx_commands_created ON commands(created_at);
944
948
  CREATE INDEX IF NOT EXISTS idx_commands_correlation ON commands(correlation_id);
949
+ CREATE INDEX IF NOT EXISTS idx_commands_spawn_watchdog ON commands(type, status, updated_at);
945
950
 
946
951
  CREATE TABLE IF NOT EXISTS recipe_instances (
947
952
  id TEXT PRIMARY KEY,
package/src/db/stats.ts CHANGED
@@ -20,6 +20,7 @@ import { summarizeExpiredUndeliveredMessages } from "./delivery.ts";
20
20
  import { messageTaskClosed } from "./claim-sql.ts";
21
21
  import { claimTaskWhere } from "./task-core.ts";
22
22
  import { getDb } from "./connection.ts";
23
+ import { getEventLoopLagSnapshot } from "../event-loop-lag";
23
24
  import type {
24
25
  AgentCard,
25
26
  ActivityEvent,
@@ -408,6 +409,27 @@ export function getHealth(now: number = Date.now()): HealthReport {
408
409
  checks.push({ name: "database", status: "error", detail: e instanceof Error ? e.message : "database check failed" });
409
410
  }
410
411
 
412
+ const eventLoopLag = getEventLoopLagSnapshot();
413
+ const eventLoopLagHealthMaxMs = Math.max(eventLoopLag.maxMs, eventLoopLag.maxSinceLastReadMs);
414
+ const eventLoopLagWarn = eventLoopLag.warnThresholdMs > 0 && eventLoopLag.sampleCount > 0 && eventLoopLagHealthMaxMs >= eventLoopLag.warnThresholdMs;
415
+ checks.push({
416
+ name: "event-loop-lag",
417
+ status: eventLoopLagWarn ? "warn" : "ok",
418
+ detail: eventLoopLagWarn
419
+ ? `event loop lag max ${Math.round(eventLoopLagHealthMaxMs)}ms over ${eventLoopLag.sampleCount}/${eventLoopLag.windowSamples} sample(s)`
420
+ : undefined,
421
+ metrics: {
422
+ currentMs: eventLoopLag.currentMs,
423
+ maxMs: eventLoopLag.maxMs,
424
+ maxSinceLastReadMs: eventLoopLag.maxSinceLastReadMs,
425
+ sampleCount: eventLoopLag.sampleCount,
426
+ windowSamples: eventLoopLag.windowSamples,
427
+ sampleIntervalMs: eventLoopLag.sampleIntervalMs,
428
+ warnThresholdMs: eventLoopLag.warnThresholdMs,
429
+ ...(eventLoopLag.lastSampleAt === undefined ? {} : { lastSampleAt: eventLoopLag.lastSampleAt }),
430
+ },
431
+ });
432
+
411
433
  const staleLiveAgents = (getDb()
412
434
  .query("SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen <= ? AND id NOT IN ('user', 'system')")
413
435
  .get(now - STALE_TTL_MS) as any).c as number;