agent-relay-server 0.120.6 → 0.121.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -4
  3. package/public/assets/{MessageBubble-So86mU9-.js → MessageBubble-CLqxCG9b.js} +2 -2
  4. package/public/assets/{MessageBubble-So86mU9-.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-DBZYAsww.js → PlanGraphPanel-BkkToFEb.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DBZYAsww.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
  7. package/public/assets/{activity-CZNwmXg1.js → activity-D-ocGQGs.js} +2 -2
  8. package/public/assets/{activity-CZNwmXg1.js.map → activity-D-ocGQGs.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BhadoGwi.js → agent-profiles-CRPgYTyb.js} +2 -2
  10. package/public/assets/{agent-profiles-BhadoGwi.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
  11. package/public/assets/{agents-DWn1xx9x.js → agents-Chw5E_2q.js} +2 -2
  12. package/public/assets/{agents-DWn1xx9x.js.map → agents-Chw5E_2q.js.map} +1 -1
  13. package/public/assets/{analytics-DifGnk7q.js → analytics-BmNDG0hR.js} +2 -2
  14. package/public/assets/{analytics-DifGnk7q.js.map → analytics-BmNDG0hR.js.map} +1 -1
  15. package/public/assets/{automation-BExrtKkb.js → automation-D7g90kTv.js} +2 -2
  16. package/public/assets/{automation-BExrtKkb.js.map → automation-D7g90kTv.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CyC5Qrhc.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
  18. package/public/assets/{branch-state-badge-CyC5Qrhc.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
  19. package/public/assets/{channels-BLkdt2XR.js → channels-CLwPeEPi.js} +2 -2
  20. package/public/assets/{channels-BLkdt2XR.js.map → channels-CLwPeEPi.js.map} +1 -1
  21. package/public/assets/{chat-C7OhKtr6.js → chat-Cgj7VKzc.js} +2 -2
  22. package/public/assets/{chat-C7OhKtr6.js.map → chat-Cgj7VKzc.js.map} +1 -1
  23. package/public/assets/{connectors-C0n12Je8.js → connectors-Br6fiTny.js} +2 -2
  24. package/public/assets/{connectors-C0n12Je8.js.map → connectors-Br6fiTny.js.map} +1 -1
  25. package/public/assets/{formatted-body-BCQT2qSb.js → formatted-body-CmkuD23M.js} +3 -3
  26. package/public/assets/{formatted-body-BCQT2qSb.js.map → formatted-body-CmkuD23M.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-Bk7wnESl.js → formatted-body-impl-DbdFmbwW.js} +2 -2
  28. package/public/assets/{formatted-body-impl-Bk7wnESl.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
  29. package/public/assets/{index-CRraCeo_.js → index-D9OogaB5.js} +6 -6
  30. package/public/assets/{index-CRraCeo_.js.map → index-D9OogaB5.js.map} +1 -1
  31. package/public/assets/{insights-DEgES6jz.js → insights-qvaPqWCV.js} +2 -2
  32. package/public/assets/{insights-DEgES6jz.js.map → insights-qvaPqWCV.js.map} +1 -1
  33. package/public/assets/{integrations-v14ehskT.js → integrations-DYEGSqq_.js} +2 -2
  34. package/public/assets/{integrations-v14ehskT.js.map → integrations-DYEGSqq_.js.map} +1 -1
  35. package/public/assets/{maintenance-BgVB6_gO.js → maintenance-C0HIEB-J.js} +2 -2
  36. package/public/assets/{maintenance-BgVB6_gO.js.map → maintenance-C0HIEB-J.js.map} +1 -1
  37. package/public/assets/{managed-agents-8_NKSkhI.js → managed-agents-DdS7aI38.js} +2 -2
  38. package/public/assets/{managed-agents-8_NKSkhI.js.map → managed-agents-DdS7aI38.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-yxQkrd2M.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-yxQkrd2M.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
  41. package/public/assets/{memory-CJD8eekg.js → memory-CSwx7bz8.js} +2 -2
  42. package/public/assets/{memory-CJD8eekg.js.map → memory-CSwx7bz8.js.map} +1 -1
  43. package/public/assets/{messages-DLHMZbHZ.js → messages-Be9CmvDv.js} +2 -2
  44. package/public/assets/{messages-DLHMZbHZ.js.map → messages-Be9CmvDv.js.map} +1 -1
  45. package/public/assets/{orchestrators-DEOep7FX.js → orchestrators-BgtvvHvo.js} +2 -2
  46. package/public/assets/{orchestrators-DEOep7FX.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
  47. package/public/assets/{overview-BgpfO6mt.js → overview-BvhbPWtF.js} +2 -2
  48. package/public/assets/{overview-BgpfO6mt.js.map → overview-BvhbPWtF.js.map} +1 -1
  49. package/public/assets/{pairs-8NVKHh96.js → pairs-CpXSOMx0.js} +2 -2
  50. package/public/assets/{pairs-8NVKHh96.js.map → pairs-CpXSOMx0.js.map} +1 -1
  51. package/public/assets/{projects-CZJW35C7.js → projects-DdPNPFJI.js} +2 -2
  52. package/public/assets/{projects-CZJW35C7.js.map → projects-DdPNPFJI.js.map} +1 -1
  53. package/public/assets/{prompt-settings-fqyLwUa-.js → prompt-settings-BpZse7-X.js} +2 -2
  54. package/public/assets/{prompt-settings-fqyLwUa-.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
  55. package/public/assets/{registry-BKbLL4mO.js → registry-pb1gTZEg.js} +2 -2
  56. package/public/assets/{registry-BKbLL4mO.js.map → registry-pb1gTZEg.js.map} +1 -1
  57. package/public/assets/{security-BlTFTbq0.js → security-BgcX1n9D.js} +2 -2
  58. package/public/assets/{security-BlTFTbq0.js.map → security-BgcX1n9D.js.map} +1 -1
  59. package/public/assets/{select-Zd_qh0uU.js → select-DZPVpKPv.js} +2 -2
  60. package/public/assets/{select-Zd_qh0uU.js.map → select-DZPVpKPv.js.map} +1 -1
  61. package/public/assets/{settings-nHuSVRkL.js → settings-hd5kzdRB.js} +4 -4
  62. package/public/assets/settings-hd5kzdRB.js.map +1 -0
  63. package/public/assets/store-DKTLubBT.js +9 -0
  64. package/public/assets/store-DKTLubBT.js.map +1 -0
  65. package/public/assets/{tasks-C3JhQ8vB.js → tasks-C5d2LU5E.js} +2 -2
  66. package/public/assets/{tasks-C3JhQ8vB.js.map → tasks-C5d2LU5E.js.map} +1 -1
  67. package/public/assets/{teams-C9E9Yt39.js → teams-B8f2pGJe.js} +2 -2
  68. package/public/assets/{teams-C9E9Yt39.js.map → teams-B8f2pGJe.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-FP0JlsvS.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-FP0JlsvS.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-Ema7Hy3s.js → work-queue-9imc7aNK.js} +2 -2
  73. package/public/assets/{work-queue-Ema7Hy3s.js.map → work-queue-9imc7aNK.js.map} +1 -1
  74. package/public/assets/{workspaces-DI317fMk.js → workspaces-CLYc3yF3.js} +2 -2
  75. package/public/assets/{workspaces-DI317fMk.js.map → workspaces-CLYc3yF3.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
  79. package/src/agent-card-projection.ts +39 -9
  80. package/src/agent-issue-repo.ts +10 -3
  81. package/src/agent-lifecycle-events.ts +23 -1
  82. package/src/agent-ref.ts +18 -3
  83. package/src/bus-outbox.ts +74 -1
  84. package/src/bus.ts +84 -5
  85. package/src/channel-target.ts +4 -0
  86. package/src/cli/steward.ts +15 -14
  87. package/src/commands-db.ts +63 -9
  88. package/src/config-store.ts +2 -0
  89. package/src/config.ts +54 -0
  90. package/src/db/claim-sql.ts +16 -0
  91. package/src/db/connection.ts +3 -1
  92. package/src/db/continuations.ts +44 -3
  93. package/src/db/index.ts +1 -0
  94. package/src/db/maintenance-worker.ts +91 -0
  95. package/src/db/message-reads.ts +34 -4
  96. package/src/db/messages.ts +147 -8
  97. package/src/db/migrations.ts +7 -0
  98. package/src/db/pairs.ts +1 -0
  99. package/src/db/retention.ts +130 -0
  100. package/src/db/schema.ts +11 -0
  101. package/src/db/stats.ts +22 -0
  102. package/src/event-loop-lag.ts +90 -0
  103. package/src/gate-resolver.ts +124 -0
  104. package/src/lifecycle-manager.ts +41 -10
  105. package/src/lifecycle-signal-registry.ts +87 -0
  106. package/src/maintenance/constants.ts +5 -1
  107. package/src/maintenance/jobs.ts +48 -8
  108. package/src/maintenance/workspaces.ts +75 -44
  109. package/src/managed-policy-escalation.ts +68 -3
  110. package/src/mcp/tools-spawn.ts +16 -6
  111. package/src/mcp-plan-tools.ts +2 -3
  112. package/src/notification-settings.ts +11 -0
  113. package/src/notification-types.ts +4 -0
  114. package/src/operator-alarm.ts +37 -0
  115. package/src/retention-config-store.ts +32 -0
  116. package/src/retention-config.ts +58 -0
  117. package/src/routes/agents.ts +4 -0
  118. package/src/routes/messages.ts +13 -1
  119. package/src/routes/orchestrator-proxy.ts +13 -1
  120. package/src/routes/orchestrator.ts +4 -0
  121. package/src/routing-policy-store.ts +11 -1
  122. package/src/security.ts +20 -0
  123. package/src/self-resume.ts +185 -21
  124. package/src/server.ts +4 -0
  125. package/src/services/plan-graph.ts +42 -12
  126. package/src/services/register-agent.ts +5 -1
  127. package/src/services/send-message.ts +99 -21
  128. package/src/services/spawn-agent.ts +21 -2
  129. package/src/services/spawn-registration-watchdog.ts +74 -9
  130. package/src/services/task-semantic-events.ts +17 -1
  131. package/src/settings/registry.ts +11 -0
  132. package/src/spawn-command.ts +3 -0
  133. package/src/spawn-targets.ts +26 -1
  134. package/src/sse.ts +67 -14
  135. package/src/workspace-auto-merge.ts +90 -34
  136. package/src/workspace-escalation.ts +52 -3
  137. package/src/workspace-human-escalation.ts +68 -0
  138. package/src/workspace-orphans.ts +11 -6
  139. package/src/workspace-phase.ts +22 -1
  140. package/public/assets/settings-nHuSVRkL.js.map +0 -1
  141. package/public/assets/store-nXGxgz2Q.js +0 -9
  142. 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
  }
@@ -13,11 +13,18 @@ function agentCwd(agent: AgentCard): string | undefined {
13
13
  return typeof cwd === "string" && cwd ? cwd : undefined;
14
14
  }
15
15
 
16
+ /** Resolve an agent's project issueRepo via its cwd, or undefined if it has no cwd or no
17
+ * matching project/issueRepo. Exported so other callers needing a caller-scoped default repo
18
+ * (e.g. plan-graph's bare `#NNN` issue ref resolution) reuse this instead of re-deriving cwd
19
+ * → project → issueRepo themselves. */
20
+ export function issueRepoForAgent(agent: AgentCard): string | undefined {
21
+ const cwd = agentCwd(agent);
22
+ return cwd ? resolveProjectForCwd(cwd)?.issueRepo : undefined;
23
+ }
24
+
16
25
  /** Enrich a single agent with its resolved project issueRepo (one project_roots scan). */
17
26
  export function attachIssueRepo(agent: AgentCard): AgentCard {
18
- const cwd = agentCwd(agent);
19
- if (!cwd) return agent;
20
- const repo = resolveProjectForCwd(cwd)?.issueRepo;
27
+ const repo = issueRepoForAgent(agent);
21
28
  return repo ? { ...agent, issueRepo: repo } : agent;
22
29
  }
23
30
 
@@ -1,6 +1,7 @@
1
1
  import { emitRelayEvent } from "./events";
2
2
  import { routeNotification } from "./notification-router";
3
- import { getAgent, markAgentReadyNotified, recordCrashReport, recordTerminalEvent } from "./db";
3
+ import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSignalKey } from "./lifecycle-signal-registry";
4
+ import { deliverableTaskIdsBySpawnRequestId, getAgent, markAgentReadyNotified, recordCrashReport, recordTerminalEvent } from "./db";
4
5
  import { crashReportFor } from "./agent-death-diagnosis";
5
6
  import { renderTemplate } from "./prompt-resolver";
6
7
  import type { AgentCard, AgentDeathDiagnosis } from "./types";
@@ -82,6 +83,26 @@ function spawnRequestIdOf(child: AgentCard): string | undefined {
82
83
  return typeof s === "string" ? s : undefined;
83
84
  }
84
85
 
86
+ // #1032b — record that a first-class lifecycle event (ready/exit) told the parent about this child,
87
+ // so a redundant "Task status pulse" for the same child/task is suppressed instead of double-waking.
88
+ // #1038 — agent.ready means the child is coming up to work its task, so it is state-keyed to
89
+ // `working` (same as a report-up): it supersedes a redundant `working` pulse only, never a later
90
+ // distinct-state transition for the same task within the TTL.
91
+ function stampChildLifecycleSignal(parent: string, child: AgentCard): void {
92
+ stampLifecycleSignal(parent, childSignalKey(child.id));
93
+ const direct = child.meta?.taskId;
94
+ const directNum = typeof direct === "string" ? Number(direct) : direct;
95
+ if (typeof directNum === "number" && Number.isSafeInteger(directNum) && directNum > 0) {
96
+ stampLifecycleSignal(parent, taskSignalKey(directNum, LIFECYCLE_STATE_WORKING));
97
+ }
98
+ const spawnRequestId = spawnRequestIdOf(child);
99
+ if (spawnRequestId) {
100
+ for (const taskId of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) {
101
+ stampLifecycleSignal(parent, taskSignalKey(taskId, LIFECYCLE_STATE_WORKING));
102
+ }
103
+ }
104
+ }
105
+
85
106
  /**
86
107
  * A spawned child became genuinely ready (registered AND past onboarding — ready+idle, not merely
87
108
  * process-started). Emits the durable `agent.ready` event and, unless suppressed, pushes the
@@ -152,6 +173,7 @@ export function notifyAgentReady(childId: string): void {
152
173
  replyExpected: false,
153
174
  },
154
175
  });
176
+ stampChildLifecycleSignal(parent, child);
155
177
  }
156
178
 
157
179
  /**
package/src/agent-ref.ts CHANGED
@@ -99,9 +99,19 @@ function idMatchesSegment(id: string, ref: string): boolean {
99
99
  export function matchAgents(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): AgentCard[] {
100
100
  const trimmed = ref.trim();
101
101
  if (!trimmed) return [];
102
- const pool = opts.excludeId ? agents.filter((a) => a.id !== opts.excludeId) : agents;
103
102
 
104
103
  const selector = parseSelector(trimmed);
104
+ // An exact id reference — a bare full id, or an `id:` selector — names ONE agent unambiguously,
105
+ // so it is honored even when it targets the excluded (self) agent: addressing your own full id
106
+ // is a deliberate self/peer send, NOT the #290 bare-ref self-loop that `excludeId` guards
107
+ // against. Without this exemption an exact self-id is refused by the matcher yet still offered by
108
+ // the "did you mean" hint — the resolver suggesting the very id it just rejected (#1035).
109
+ const exactId = selector ? (selector.field === "id" ? selector.value : null) : trimmed;
110
+ const selfExact = opts.excludeId && exactId
111
+ ? agents.find((a) => a.id === opts.excludeId && a.id === exactId)
112
+ : undefined;
113
+ const pool = opts.excludeId && !selfExact ? agents.filter((a) => a.id !== opts.excludeId) : agents;
114
+
105
115
  if (selector) return selector.value ? pool.filter((a) => matchesSelector(a, selector)) : [];
106
116
 
107
117
  const exact = pool.filter((a) => matchesExact(a, trimmed));
@@ -207,9 +217,14 @@ function ambiguousMessage(ref: string, candidates: AgentCard[]): string {
207
217
  return `agent reference "${ref}" is ambiguous — matches ${candidates.length}: ${list}${more}. Use a full id, label, or a fan-out selector (tag:/cap:/label:/team:).`;
208
218
  }
209
219
 
210
- function notFoundMessage(ref: string, agents: AgentCard[]): string {
220
+ function notFoundMessage(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): string {
211
221
  const needle = ref.toLowerCase();
212
222
  const near = agents
223
+ // Canonicalize the suggestion pool to the SAME identity the matcher used: never offer the
224
+ // excluded (self) agent, and never suggest an id/label that EQUALS the ref — if it were an
225
+ // exact match it would have resolved, so surfacing it means offering the id we just rejected
226
+ // (#1035, the "Did you mean: <the id you typed>?" self-suggestion).
227
+ .filter((a) => a.id !== opts.excludeId && a.id !== ref && a.label !== ref)
213
228
  .filter((a) => isPseudoAgent(a) && (a.id.toLowerCase().includes(needle) || a.label?.toLowerCase().includes(needle)))
214
229
  .slice(0, 3)
215
230
  .map(describeAgent);
@@ -261,7 +276,7 @@ export function planSend(to: string, agents: AgentCard[], opts: ResolveOptions =
261
276
  if (offline.length > 1) {
262
277
  return { kind: "ambiguous", message: ambiguousMessage(target, offline), candidates: offline };
263
278
  }
264
- return { kind: "not_found", message: notFoundMessage(target, agents) };
279
+ return { kind: "not_found", message: notFoundMessage(target, agents, opts) };
265
280
  }
266
281
 
267
282
  /**
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 (?, ?, ?, ?, ?)
@@ -28,6 +45,24 @@ export function appendEvent(type: string, source: string, data: unknown, subject
28
45
  }
29
46
 
30
47
  export function replayEvents(since: number, limit = 500): BusEvent[] {
48
+ return replayEventsSlice(since, limit).events;
49
+ }
50
+
51
+ export interface ReplaySlice {
52
+ events: BusEvent[];
53
+ /** The highest real outbox row seq in this slice (or `since` when empty). Resume from here to
54
+ * continue past the slice — driven by the raw row seq, not the (possibly batch-expanded) payload
55
+ * seq, so a legacy `message.batch` row can't be half-skipped. */
56
+ lastSeq: number;
57
+ /** More retained events exist beyond `lastSeq` — the caller MUST fetch the next slice, or the
58
+ * backlog tail between this slice and the outbox head is silently dropped (#1022 F1). */
59
+ truncated: boolean;
60
+ }
61
+
62
+ /** One page of the resume backlog plus whether more remains. `replayEvents`' 500-row cap used to
63
+ * be a silent ceiling: a resume returning exactly `limit` rows dropped everything between the last
64
+ * replayed event and the outbox head. Callers loop on `truncated`, resuming from `lastSeq`. */
65
+ export function replayEventsSlice(since: number, limit = 500): ReplaySlice {
31
66
  const rows = getDb().query(`
32
67
  SELECT seq, event_type, source, subject, data, timestamp
33
68
  FROM bus_outbox
@@ -35,7 +70,10 @@ export function replayEvents(since: number, limit = 500): BusEvent[] {
35
70
  ORDER BY seq ASC
36
71
  LIMIT ?
37
72
  `).all(since, limit) as OutboxRow[];
38
- return rows.map(rowToEvent);
73
+ const events = rows.map(rowToEvent);
74
+ const lastSeq = rows.length > 0 ? rows[rows.length - 1]!.seq : since;
75
+ const truncated = rows.length === limit && getOutboxCursor() > lastSeq;
76
+ return { events, lastSeq, truncated };
39
77
  }
40
78
 
41
79
  export function pruneOutbox(retentionMs = 60 * 60 * 1000): number {
@@ -54,6 +92,28 @@ export function getOldestOutboxCursor(): number {
54
92
  return row.cursor ?? 0;
55
93
  }
56
94
 
95
+ /** The highest seq ever assigned to the outbox — read from `sqlite_sequence`, so it survives the
96
+ * retention prune that empties the table. Needed for gap detection: once the outbox is fully
97
+ * pruned `getOldestOutboxCursor()` returns 0, which read as "no gap" and let a reconnecting client
98
+ * believe it was caught up while its post-cursor events had aged out (#1022 F2). */
99
+ export function getOutboxHighWater(): number {
100
+ const row = getDb().query("SELECT seq FROM sqlite_sequence WHERE name = 'bus_outbox'").get() as { seq: number } | undefined;
101
+ return row?.seq ?? 0;
102
+ }
103
+
104
+ /** Whether a resume from `since` has lost events to the retention prune. Two cases:
105
+ * - outbox non-empty: the oldest retained event is newer than `since + 1` → the events between
106
+ * were pruned.
107
+ * - outbox fully pruned (oldest = 0): a gap exists iff events after `since` were ever assigned
108
+ * (`since` is behind the high-water mark) and have since aged out.
109
+ * A client that hits a gap must reconcile from scratch rather than resume from a stale cursor. */
110
+ export function hasReplayGap(since: number): boolean {
111
+ if (since <= 0) return false;
112
+ const oldest = getOldestOutboxCursor();
113
+ if (oldest > 0) return oldest > since + 1;
114
+ return since < getOutboxHighWater();
115
+ }
116
+
57
117
  function rowToEvent(row: OutboxRow): BusEvent {
58
118
  return {
59
119
  seq: row.seq,
@@ -73,3 +133,16 @@ function parseData(raw: string): Record<string, unknown> {
73
133
  return {};
74
134
  }
75
135
  }
136
+
137
+ function batchMessages(data: unknown): Array<{ id: number; from: string } & Record<string, unknown>> {
138
+ if (!data || typeof data !== "object" || Array.isArray(data)) return [];
139
+ const messages = (data as { messages?: unknown }).messages;
140
+ if (!Array.isArray(messages)) return [];
141
+ return messages.filter((message): message is { id: number; from: string } & Record<string, unknown> =>
142
+ Boolean(message) &&
143
+ typeof message === "object" &&
144
+ !Array.isArray(message) &&
145
+ typeof (message as { id?: unknown }).id === "number" &&
146
+ typeof (message as { from?: unknown }).from === "string"
147
+ );
148
+ }
package/src/bus.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
2
  import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus, validateAgentSession } from "./db";
3
- import { getOldestOutboxCursor, getOutboxCursor, replayEvents, type BusEvent } from "./bus-outbox";
3
+ import { getOutboxCursor, hasReplayGap, replayEventsSlice, type BusEvent } from "./bus-outbox";
4
4
  import { projectAgentStatusData } from "./agent-card-projection";
5
5
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
6
6
  import { getCommand, updateCommand } from "./commands-db";
@@ -15,6 +15,7 @@ import { agentProviderTimelineEvent, auditRunnerTimelineEvent } from "./provider
15
15
  import { ServiceAuthError } from "./services/errors";
16
16
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
17
17
  import { flushQueuedDirectMessages } from "./services/managed-running";
18
+ import { flushIdleLineageReport } from "./services/send-message";
18
19
  import { isAgentUnavailable } from "./db/agent-predicates";
19
20
  import { applyCommandToRecipe } from "./recipe-runner";
20
21
  import { enqueueContinuationInjectionAfterSelfResume } from "./self-resume";
@@ -105,6 +106,10 @@ export function busHandleClose(ws: BusWebSocket): void {
105
106
  if (!conn.transportReconnect) {
106
107
  setStatus(conn.agentId, "stale", { instanceId: conn.instanceId, epoch: conn.epoch });
107
108
  emitAgentStatusEvent(conn.agentId);
109
+ // #1040 — a WS close is a busy→exit edge (the runner crashed/dropped before its stop-hook
110
+ // status frame). Flush any held turn-final report-up so it still reaches the spawner instead
111
+ // of dying with the socket. No-op when nothing was deferred.
112
+ flushIdleLineageReport(conn.agentId);
108
113
  }
109
114
  }
110
115
  }
@@ -123,6 +128,9 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs()): { agentIds: s
123
128
  revokeRuntimeTokensForAgent(row.id, Date.now(), "stale");
124
129
  setStatus(row.id, "offline");
125
130
  emitAgentStatusEvent(row.id);
131
+ // #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
132
+ // clean idle transition. Flush any held turn-final report-up before the agent ages out.
133
+ flushIdleLineageReport(row.id);
126
134
  const tasks = orphanTasksForAgent(row.id);
127
135
  for (const task of tasks) {
128
136
  orphanedTasks.push(task);
@@ -156,7 +164,14 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
156
164
  const wasUnavailable = current ? isAgentUnavailable(current) : false;
157
165
  const runtime = runtimeFromMeta(frame.payload.meta);
158
166
  heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
159
- if (frame.payload.status) setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
167
+ if (frame.payload.status) {
168
+ setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
169
+ // #1040 — a heartbeat frame can carry the busy→idle transition instead of a `status`
170
+ // frame. Flush the held report-up on THIS edge too, else `before.status` is already
171
+ // non-busy at the next status frame → the flush never fires → the child deadlocks idle
172
+ // with its verdict held and the coordinator waits forever.
173
+ if (current?.status === "busy" && frame.payload.status !== "busy") flushIdleLineageReport(conn.agentId);
174
+ }
160
175
  emitAgentStatusEvent(conn.agentId);
161
176
  // #416 — flush queued messages if the agent transitioned from unavailable to available.
162
177
  if (wasUnavailable) {
@@ -200,6 +215,9 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
200
215
  emitAgentStatusEvent(conn.agentId);
201
216
  // #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
202
217
  probeWorkspaceOnTurnEnd(before, after);
218
+ // #1037 — the same busy→idle turn-end edge flushes a quiet child's deferred turn-final
219
+ // report-up to its spawner (held back while the child was mid-work).
220
+ if (before?.status === "busy" && after && after.status !== "busy") flushIdleLineageReport(conn.agentId);
203
221
  // #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
204
222
  // marks the offline edge with `terminalProviderExit`; the owning service turns that into a
205
223
  // terminal event + #636 diagnosis (and clears the hold on a genuine resume).
@@ -466,18 +484,23 @@ function hasRunnerLifecycleCapabilities(capabilities: string[], meta: Record<str
466
484
  }
467
485
 
468
486
  function handleResume(ws: BusWebSocket, frameId: string, since: number): void {
469
- const oldest = getOldestOutboxCursor();
470
- if (since > 0 && oldest > 0 && oldest > since + 1) {
487
+ if (hasReplayGap(since)) {
471
488
  sendError(ws, frameId, "REPLAY_GAP", "requested cursor is older than retained bus outbox events");
472
489
  return;
473
490
  }
474
- const events = replayEvents(since).map(outboxToPayload);
491
+ // One page only. The replay cap silently dropped the backlog tail between item 500 and the head
492
+ // (#1022 F1); we now report `truncated` + the raw `nextSince` row seq so the client loops resume
493
+ // until it has drained the whole backlog rather than lurching to the live head on the next event.
494
+ const slice = replayEventsSlice(since);
495
+ const events = slice.events.flatMap(outboxToPayloads);
475
496
  send(ws, {
476
497
  type: "resumed",
477
498
  payload: {
478
499
  events,
479
500
  fromSeq: events[0]?.seq ?? since,
480
501
  toSeq: events.at(-1)?.seq ?? since,
502
+ nextSince: slice.lastSeq,
503
+ truncated: slice.truncated,
481
504
  },
482
505
  });
483
506
  }
@@ -503,6 +526,10 @@ function withRegistered(
503
526
  }
504
527
 
505
528
  function broadcastRelayEvent(event: RelayEvent): void {
529
+ if (event.type === "message.batch") {
530
+ broadcastMessageBatch(event);
531
+ return;
532
+ }
506
533
  const frame = JSON.stringify({
507
534
  type: "event",
508
535
  payload: {
@@ -521,6 +548,41 @@ function broadcastRelayEvent(event: RelayEvent): void {
521
548
  }
522
549
  }
523
550
 
551
+ function broadcastMessageBatch(event: RelayEvent): void {
552
+ const messages = batchMessages(event.data);
553
+ const messageEvents = messages.map((message, index) => {
554
+ const messageEvent: RelayEvent = {
555
+ ...event,
556
+ seq: event.seq + index,
557
+ type: "message.new",
558
+ source: message.from,
559
+ subject: String(message.id),
560
+ data: message as unknown as Record<string, unknown>,
561
+ };
562
+ return {
563
+ event: messageEvent,
564
+ frame: JSON.stringify({
565
+ type: "event",
566
+ payload: {
567
+ seq: messageEvent.seq,
568
+ eventType: "message.new",
569
+ source: message.from,
570
+ subject: String(message.id),
571
+ data: message,
572
+ timestamp: event.timestamp,
573
+ },
574
+ }),
575
+ };
576
+ });
577
+ const agentCache = new Map<string, AgentCard | null>();
578
+ for (const conn of busConnections.values()) {
579
+ for (const { event: messageEvent, frame } of messageEvents) {
580
+ if (!connectionWantsEvent(conn, messageEvent, agentCache)) continue;
581
+ conn.ws.send(frame);
582
+ }
583
+ }
584
+ }
585
+
524
586
  function fanoutAgent(agentId: string, cache: Map<string, AgentCard | null>): AgentCard | null {
525
587
  if (!cache.has(agentId)) cache.set(agentId, getAgent(agentId));
526
588
  return cache.get(agentId) ?? null;
@@ -565,6 +627,23 @@ function outboxToPayload(event: BusEvent) {
565
627
  };
566
628
  }
567
629
 
630
+ function outboxToPayloads(event: BusEvent) {
631
+ if (event.eventType !== "message.batch") return [outboxToPayload(event)];
632
+ return batchMessages(event.data).map((message, index) => ({
633
+ seq: event.seq + index,
634
+ eventType: "message.new",
635
+ source: message.from,
636
+ subject: String(message.id),
637
+ data: message as unknown as Record<string, unknown>,
638
+ timestamp: event.timestamp,
639
+ }));
640
+ }
641
+
642
+ function batchMessages(data: Record<string, unknown>): Message[] {
643
+ const messages = (data as { messages?: unknown }).messages;
644
+ return Array.isArray(messages) ? messages as Message[] : [];
645
+ }
646
+
568
647
  function emitAgentStatusEvent(agentId: string): void {
569
648
  const agent = getAgent(agentId);
570
649
  // Same enriched projection as sse.ts's emitAgentStatus — without this the bus lifecycle
@@ -19,6 +19,10 @@ export function parseChannelRouteTarget(target: string): ChannelRouteTarget {
19
19
  if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
20
20
  if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
21
21
  if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
22
+ // `capability:` is an accepted alias of `cap:` at send time (planSend/FANOUT_PREFIXES + the SDK
23
+ // matcher both honor it). Parse it here too so channel routing, broadcast scoping and the
24
+ // claimable gate don't silently treat a `capability:` message as a bare agent id (#1022 F3).
25
+ if (target.startsWith("capability:")) return { type: "capability", id: target.slice("capability:".length) };
22
26
  if (target === "broadcast") return { type: "broadcast" };
23
27
  if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
24
28
  return { type: "agent", id: target };
@@ -1,6 +1,7 @@
1
1
  // Steward commands — auto-split from cli.ts (#294). Briefing rail for the steward
2
2
  // (#208): the attention queue, per-workspace diagnostics, and check suggestions.
3
3
  import { apiRequest } from "./_shared";
4
+ import { resolveStewardChecks } from "../gate-resolver";
4
5
 
5
6
  // Steward briefing commands (#208): queue of workspaces needing attention, a
6
7
  // per-workspace diagnostics inspection, and a check-command suggestion.
@@ -39,21 +40,21 @@ export async function handleStewardCommand(args: string[]): Promise<void> {
39
40
  return;
40
41
  }
41
42
 
42
- // checks: suggest validation commands from the workspace's changed files.
43
+ // checks: suggest validation commands from the workspace's changed files. Derive
44
+ // the gate from the repo (#1036) — hardcoding `bun test` produced bogus failures in
45
+ // non-bun repos. Resolve the worktree to read its package.json / land-gates pin:
46
+ // an explicit `--repo` wins, else the workspace's own worktreePath/repoRoot.
43
47
  const diff = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diff?patch=0`) as any;
44
48
  const files: string[] = Array.isArray(diff?.files) ? diff.files.map((f: any) => f.path) : [];
45
- const checks = suggestStewardChecks(files);
49
+ let repoRoot = repo;
50
+ if (!repoRoot) {
51
+ try {
52
+ const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`) as any;
53
+ repoRoot = ws?.worktreePath ?? ws?.repoRoot;
54
+ } catch {
55
+ // Best-effort: without a resolvable path the resolver falls back to its default.
56
+ }
57
+ }
58
+ const checks = resolveStewardChecks(files, repoRoot);
46
59
  console.log(JSON.stringify({ workspaceId: id, changedFiles: files.length, checks }, null, 2));
47
60
  }
48
-
49
- // Heuristic check suggestions from changed file paths. Repo-agnostic defaults a
50
- // steward can refine; cheaper than re-deriving from project docs every run.
51
- function suggestStewardChecks(files: string[]): Array<{ command: string; reason: string }> {
52
- const checks: Array<{ command: string; reason: string }> = [];
53
- const has = (re: RegExp) => files.some((f) => re.test(f));
54
- if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
55
- if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
56
- else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
57
- if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
58
- return checks;
59
- }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { STALE_TTL_MS } from "./config";
2
3
  import { getAgent, getDb, getTerminalEvent, recordTerminalEvent } from "./db";
3
4
  import { stringValue } from "agent-relay-sdk";
4
5
  import type { Command, CommandStatus, CreateCommandInput, UpdateCommandInput } from "./types";
@@ -63,6 +64,14 @@ function isTerminalStatus(status: CommandStatus): boolean {
63
64
  function isLegitimateTerminalCorrection(type: string, from: CommandStatus, to: CommandStatus): boolean {
64
65
  if (type === "agent.spawn" && from === "succeeded" && to === "failed") return true;
65
66
  if (type === "orchestrator.upgrade" && from === "failed" && to === "succeeded") return true;
67
+ // #1025 A7 — a lost merge RESULT: the orchestrator lands the branch (base pushed,
68
+ // worktree recycled onto `<branch>-N`) but dies before POSTing, so the command ages to
69
+ // `timed_out`. When it finally reports the real `succeeded` result, that late correction
70
+ // must be applied — otherwise setWorkspaceBranch never runs, the row keeps the old
71
+ // (deleted) branch, the worktree sits on `-N`, and every later preview reports `missing`
72
+ // → the lane degrades into a silent previewUnavailable strand (A5). The merge already
73
+ // happened, so there is no double-land hazard: applying the result only reconciles state.
74
+ if (type === "workspace.merge" && from === "timed_out" && to === "succeeded") return true;
66
75
  return false;
67
76
  }
68
77
 
@@ -162,16 +171,34 @@ export function findLatestCommandByCorrelation(type: string, correlationId: stri
162
171
  return row ? rowToCommand(row) : null;
163
172
  }
164
173
 
165
- export function countInFlightSpawnCommands(parentId: string): number {
174
+ // #1034 count a parent's still-in-flight spawn commands, EXCLUDING any whose child has
175
+ // already registered as a live agent. Spawn commands settle to a terminal status
176
+ // asynchronously (the orchestrator reports back AFTER the child registers), so between
177
+ // registration and settlement a child is otherwise double-counted: once by
178
+ // `countLiveSpawnedAgents` (ready=1) and once here (command still running). Combined into
179
+ // `occupied = live + inFlight`, that transient over-count could push a parent at its quota
180
+ // boundary over the limit and spuriously reject a concurrent spawn — which then "succeeded
181
+ // seconds later" once the command settled. The `NOT EXISTS` dedup mirrors the live-child
182
+ // liveness predicate in `countLiveSpawnedAgents` (and the display-side dedup already in
183
+ // relay_my_agents) so each child counts exactly once.
184
+ export function countInFlightSpawnCommands(parentId: string, now: number = Date.now()): number {
166
185
  const row = getDb()
167
186
  .query(
168
187
  `SELECT count(*) AS n
169
- FROM commands
170
- WHERE type = 'agent.spawn'
171
- AND status IN (${SPAWN_IN_FLIGHT_STATUSES.map(() => "?").join(",")})
172
- AND json_extract(params, '$.spawnedBy') = ?`,
188
+ FROM commands c
189
+ WHERE c.type = 'agent.spawn'
190
+ AND c.status IN (${SPAWN_IN_FLIGHT_STATUSES.map(() => "?").join(",")})
191
+ AND json_extract(c.params, '$.spawnedBy') = ?
192
+ AND NOT EXISTS (
193
+ SELECT 1 FROM agents a
194
+ WHERE a.spawned_by = ?
195
+ AND a.status NOT IN ('offline', 'stale')
196
+ AND a.ready = 1
197
+ AND a.last_seen > ?
198
+ AND json_extract(a.meta, '$.spawnRequestId') = coalesce(json_extract(c.params, '$.spawnRequestId'), c.correlation_id)
199
+ )`,
173
200
  )
174
- .get(...SPAWN_IN_FLIGHT_STATUSES, parentId) as { n: number };
201
+ .get(...SPAWN_IN_FLIGHT_STATUSES, parentId, parentId, now - STALE_TTL_MS) as { n: number };
175
202
  return row.n;
176
203
  }
177
204
 
@@ -244,11 +271,38 @@ export function deleteCommand(id: string): boolean {
244
271
  return true;
245
272
  }
246
273
 
247
- export function expireCommands(now: number = Date.now()): Command[] {
248
- const rows = getDb()
274
+ // #1025 A6 sweep both claimed/active leases past their deadline AND pending commands
275
+ // whose target orchestrator is permanently gone. Pending commands are normally left for a
276
+ // busy host to poll (#930/#953), but a `workspace.merge` (or `agent.spawn`) whose target
277
+ // host was decommissioned / re-registered under a new agent id will NEVER poll again — the
278
+ // command lingers forever, `recoverAgedInFlightWorkspaces` files the workspace under
279
+ // `stillActive` indefinitely, and the parked worker waits on a `landed-success` that can't
280
+ // fire. The `isTargetGone` predicate (supplied by the caller, which knows agent liveness)
281
+ // gates the pending sweep so a merely-busy host is untouched; a gone target's expired
282
+ // pending command is timed out, then settled → the workspace returns to review_requested
283
+ // and re-schedules, and the spawn parent is told. Opt-in: callers without the predicate
284
+ // keep the historical claimed/active-only behavior.
285
+ export function expireCommands(
286
+ now: number = Date.now(),
287
+ opts: { isTargetGone?: (command: Command) => boolean } = {},
288
+ ): Command[] {
289
+ const activeRows = getDb()
249
290
  .query(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status IN (${CLAIMED_ACTIVE_STATUSES.map(() => "?").join(",")})`)
250
291
  .all(now, ...CLAIMED_ACTIVE_STATUSES) as CommandRow[];
251
- for (const row of rows) updateCommand(row.id, { status: "timed_out", error: "command timed out" });
292
+ let pendingRows: CommandRow[] = [];
293
+ if (opts.isTargetGone) {
294
+ const candidates = getDb()
295
+ .query(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status = 'pending'`)
296
+ .all(now) as CommandRow[];
297
+ pendingRows = candidates.filter((row) => opts.isTargetGone!(rowToCommand(row)));
298
+ }
299
+ const rows = [...activeRows, ...pendingRows];
300
+ for (const row of rows) {
301
+ updateCommand(row.id, {
302
+ status: "timed_out",
303
+ error: row.status === "pending" ? "command target gone; expired before dispatch" : "command timed out",
304
+ });
305
+ }
252
306
  return rows.map((row) => getCommand(row.id)).filter((command): command is Command => Boolean(command));
253
307
  }
254
308
 
@@ -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;