agent-relay-server 0.58.0 → 0.60.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 (156) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-CHKzD4RY.js → MessageBubble-BrEKymoV.js} +2 -2
  4. package/public/assets/{MessageBubble-CHKzD4RY.js.map → MessageBubble-BrEKymoV.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BFVTJa0q.js → PlanGraphPanel-CXix1C-H.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BFVTJa0q.js.map → PlanGraphPanel-CXix1C-H.js.map} +1 -1
  7. package/public/assets/{activity-M-dFLU_O.js → activity-CRafQ1Je.js} +2 -2
  8. package/public/assets/{activity-M-dFLU_O.js.map → activity-CRafQ1Je.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CvHSoyCX.js → agent-profiles-DW4o5-ND.js} +2 -2
  10. package/public/assets/{agent-profiles-CvHSoyCX.js.map → agent-profiles-DW4o5-ND.js.map} +1 -1
  11. package/public/assets/agents-Cbegv0Hm.js +2 -0
  12. package/public/assets/{agents-DZBM5dp_.js.map → agents-Cbegv0Hm.js.map} +1 -1
  13. package/public/assets/analytics-DX6T7JX3.js +910 -0
  14. package/public/assets/{analytics-bvjb3Urs.js.map → analytics-DX6T7JX3.js.map} +1 -1
  15. package/public/assets/{automation-CDSn9Uq-.js → automation-CzSBv4gg.js} +2 -2
  16. package/public/assets/{automation-CDSn9Uq-.js.map → automation-CzSBv4gg.js.map} +1 -1
  17. package/public/assets/{badge-Dj6PREuI.js → badge-Cy6qO_Jz.js} +2 -2
  18. package/public/assets/{badge-Dj6PREuI.js.map → badge-Cy6qO_Jz.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-CdYg2HYc.js → branch-state-badge-CRA50r81.js} +2 -2
  20. package/public/assets/{branch-state-badge-CdYg2HYc.js.map → branch-state-badge-CRA50r81.js.map} +1 -1
  21. package/public/assets/{button-Cqpufqam.js → button-73wpRw65.js} +2 -2
  22. package/public/assets/{button-Cqpufqam.js.map → button-73wpRw65.js.map} +1 -1
  23. package/public/assets/{card-OmDF73CE.js → card-BU6PvVHv.js} +2 -2
  24. package/public/assets/{card-OmDF73CE.js.map → card-BU6PvVHv.js.map} +1 -1
  25. package/public/assets/{channels-BkxrdOFZ.js → channels-B1MQCPCT.js} +2 -2
  26. package/public/assets/{channels-BkxrdOFZ.js.map → channels-B1MQCPCT.js.map} +1 -1
  27. package/public/assets/chat-BqIaFYGJ.js +2 -0
  28. package/public/assets/{chat-YzX1z76l.js.map → chat-BqIaFYGJ.js.map} +1 -1
  29. package/public/assets/{connectors-Bg3rHgl_.js → connectors-C9aFJIrh.js} +2 -2
  30. package/public/assets/{connectors-Bg3rHgl_.js.map → connectors-C9aFJIrh.js.map} +1 -1
  31. package/public/assets/{copy-button-DI7b8A7_.js → copy-button-HRopUwui.js} +2 -2
  32. package/public/assets/{copy-button-DI7b8A7_.js.map → copy-button-HRopUwui.js.map} +1 -1
  33. package/public/assets/display-CketawEF.js.map +1 -1
  34. package/public/assets/{dist-CNjnTZEI.js → dist-CC5f7oYw.js} +2 -2
  35. package/public/assets/{dist-CNjnTZEI.js.map → dist-CC5f7oYw.js.map} +1 -1
  36. package/public/assets/{dist-DkeTrbTA.js → dist-CLHMeXYi.js} +2 -2
  37. package/public/assets/{dist-DkeTrbTA.js.map → dist-CLHMeXYi.js.map} +1 -1
  38. package/public/assets/{dist-C5t_OxI0.js → dist-Ci1MM51P.js} +2 -2
  39. package/public/assets/{dist-C5t_OxI0.js.map → dist-Ci1MM51P.js.map} +1 -1
  40. package/public/assets/{dist-CV3sgFiv.js → dist-Cko2KPW8.js} +2 -2
  41. package/public/assets/{dist-CV3sgFiv.js.map → dist-Cko2KPW8.js.map} +1 -1
  42. package/public/assets/dist-D41tzJYg.js +2 -0
  43. package/public/assets/{dist-yYauqYKR.js.map → dist-D41tzJYg.js.map} +1 -1
  44. package/public/assets/{dist-CoUvwLTy.js → dist-DFLciw0h.js} +2 -2
  45. package/public/assets/{dist-CoUvwLTy.js.map → dist-DFLciw0h.js.map} +1 -1
  46. package/public/assets/dist-DUoZnlB2.js +2 -0
  47. package/public/assets/{dist-DmhyU0Na.js.map → dist-DUoZnlB2.js.map} +1 -1
  48. package/public/assets/dist-De2oguBm.js +2 -0
  49. package/public/assets/{dist-DxkMwkrr.js.map → dist-De2oguBm.js.map} +1 -1
  50. package/public/assets/{dist-1x-LE9Uk.js → dist-DmN94rc8.js} +2 -2
  51. package/public/assets/{dist-1x-LE9Uk.js.map → dist-DmN94rc8.js.map} +1 -1
  52. package/public/assets/{dist-DV2tZ2fq.js → dist-Kkpnjx_M.js} +2 -2
  53. package/public/assets/{dist-DV2tZ2fq.js.map → dist-Kkpnjx_M.js.map} +1 -1
  54. package/public/assets/{dist-Ca3ujqG_.js → dist-T7u7IZGJ.js} +2 -2
  55. package/public/assets/{dist-Ca3ujqG_.js.map → dist-T7u7IZGJ.js.map} +1 -1
  56. package/public/assets/{es2015-DcavLcQ0.js → es2015-Df08Opjx.js} +2 -2
  57. package/public/assets/{es2015-DcavLcQ0.js.map → es2015-Df08Opjx.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-D8i9qqL1.js → formatted-body-impl-DOXH7oDF.js} +2 -2
  59. package/public/assets/{formatted-body-impl-D8i9qqL1.js.map → formatted-body-impl-DOXH7oDF.js.map} +1 -1
  60. package/public/assets/index-DaNOQJci.css +2 -0
  61. package/public/assets/index-br_Y6Cvt.js +21 -0
  62. package/public/assets/{index-Ba2RDJaA.js.map → index-br_Y6Cvt.js.map} +1 -1
  63. package/public/assets/{input-C_dl_9fG.js → input-B2EDz-BE.js} +2 -2
  64. package/public/assets/{input-C_dl_9fG.js.map → input-B2EDz-BE.js.map} +1 -1
  65. package/public/assets/{insights-D1aANwZ2.js → insights-B1edq5lk.js} +2 -2
  66. package/public/assets/{insights-D1aANwZ2.js.map → insights-B1edq5lk.js.map} +1 -1
  67. package/public/assets/{integrations-BrAr9bap.js → integrations-Cm4fjp7_.js} +2 -2
  68. package/public/assets/{integrations-BrAr9bap.js.map → integrations-Cm4fjp7_.js.map} +1 -1
  69. package/public/assets/{lib-B3iMVsf-.js → lib-BZrjIp-o.js} +3 -3
  70. package/public/assets/{lib-B3iMVsf-.js.map → lib-BZrjIp-o.js.map} +1 -1
  71. package/public/assets/lucide-react-H_-8G79A.js +9 -0
  72. package/public/assets/{lucide-react-CMhBract.js.map → lucide-react-H_-8G79A.js.map} +1 -1
  73. package/public/assets/{maintenance-C-xspsMG.js → maintenance-D6zvw0fU.js} +2 -2
  74. package/public/assets/{maintenance-C-xspsMG.js.map → maintenance-D6zvw0fU.js.map} +1 -1
  75. package/public/assets/{managed-agents-Bf3ni0vo.js → managed-agents-CzfEznM1.js} +2 -2
  76. package/public/assets/{managed-agents-Bf3ni0vo.js.map → managed-agents-CzfEznM1.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-DKbUBTf_.js → markdown-preview-impl-DZlCaTHQ.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-DKbUBTf_.js.map → markdown-preview-impl-DZlCaTHQ.js.map} +1 -1
  79. package/public/assets/memory-CUUAzJDc.js +2 -0
  80. package/public/assets/{memory-DeLwJFwH.js.map → memory-CUUAzJDc.js.map} +1 -1
  81. package/public/assets/messages-6QZH46LG.js +2 -0
  82. package/public/assets/{messages-Cww6dZnM.js.map → messages-6QZH46LG.js.map} +1 -1
  83. package/public/assets/{orchestrators-DRjwROXw.js → orchestrators-CrcqkPuc.js} +2 -2
  84. package/public/assets/{orchestrators-DRjwROXw.js.map → orchestrators-CrcqkPuc.js.map} +1 -1
  85. package/public/assets/overview-418xXZO_.js +2 -0
  86. package/public/assets/{overview-DjcRomU_.js.map → overview-418xXZO_.js.map} +1 -1
  87. package/public/assets/pairs-CnedATgT.js +2 -0
  88. package/public/assets/{pairs-DT0u2fnW.js.map → pairs-CnedATgT.js.map} +1 -1
  89. package/public/assets/{react-dom-Dtl9-x_1.js → react-dom-WI6TjVe7.js} +2 -2
  90. package/public/assets/{react-dom-Dtl9-x_1.js.map → react-dom-WI6TjVe7.js.map} +1 -1
  91. package/public/assets/{security-Bwg3oFI8.js → security-DS13pbag.js} +2 -2
  92. package/public/assets/{security-Bwg3oFI8.js.map → security-DS13pbag.js.map} +1 -1
  93. package/public/assets/{select-Cariwv69.js → select-BOprGZDS.js} +2 -2
  94. package/public/assets/{select-Cariwv69.js.map → select-BOprGZDS.js.map} +1 -1
  95. package/public/assets/{settings-Bd6Ug3DO.js → settings-BgInn_f3.js} +4 -4
  96. package/public/assets/{settings-Bd6Ug3DO.js.map → settings-BgInn_f3.js.map} +1 -1
  97. package/public/assets/{store-BgJq_lN9.js → store-DHO_a8BD.js} +5 -5
  98. package/public/assets/{store-BgJq_lN9.js.map → store-DHO_a8BD.js.map} +1 -1
  99. package/public/assets/{tasks-DVz1zhOU.js → tasks-B-4oCPuv.js} +2 -2
  100. package/public/assets/{tasks-DVz1zhOU.js.map → tasks-B-4oCPuv.js.map} +1 -1
  101. package/public/assets/teams-sCQAs_QA.js +2 -0
  102. package/public/assets/{teams-TqFntFB_.js.map → teams-sCQAs_QA.js.map} +1 -1
  103. package/public/assets/{terminal-viewer-impl-OsJ5crlx.js → terminal-viewer-impl-BhqS2XpS.js} +3 -3
  104. package/public/assets/{terminal-viewer-impl-OsJ5crlx.js.map → terminal-viewer-impl-BhqS2XpS.js.map} +1 -1
  105. package/public/assets/{use-keyboard-viewport-BYHcL7sP.js → use-keyboard-viewport-fOL_3RHB.js} +2 -2
  106. package/public/assets/{use-keyboard-viewport-BYHcL7sP.js.map → use-keyboard-viewport-fOL_3RHB.js.map} +1 -1
  107. package/public/assets/{work-queue-B4b2jPLR.js → work-queue-BDhjlwUH.js} +2 -2
  108. package/public/assets/{work-queue-B4b2jPLR.js.map → work-queue-BDhjlwUH.js.map} +1 -1
  109. package/public/assets/workspaces-6I-C69nf.js +3 -0
  110. package/public/assets/{workspaces-m97LP4Eu.js.map → workspaces-6I-C69nf.js.map} +1 -1
  111. package/public/index.html +21 -21
  112. package/src/agent-lifecycle-events.ts +41 -21
  113. package/src/branch-landed.ts +46 -35
  114. package/src/channel-target.ts +10 -0
  115. package/src/config.ts +5 -0
  116. package/src/context-advisory.ts +13 -6
  117. package/src/db/message-reads.ts +6 -0
  118. package/src/db/messages.ts +16 -1
  119. package/src/db/migrations.ts +15 -0
  120. package/src/db/notification-subscriptions.ts +198 -0
  121. package/src/db/schema.ts +11 -0
  122. package/src/db/stats.ts +209 -38
  123. package/src/index.ts +2 -0
  124. package/src/maintenance.ts +41 -15
  125. package/src/mcp-notification-subscription-tools.ts +111 -0
  126. package/src/mcp.ts +6 -0
  127. package/src/notification-router.ts +168 -0
  128. package/src/notification-rules.ts +186 -0
  129. package/src/notify.ts +9 -0
  130. package/src/project-instructions.ts +176 -0
  131. package/src/reviewer-pipeline.ts +54 -40
  132. package/src/routes/agents-spawn.ts +2 -0
  133. package/src/routes/commands.ts +11 -5
  134. package/src/self-resume.ts +11 -6
  135. package/src/services/plan-live-bindings.ts +11 -6
  136. package/src/services/rate-limit-resume.ts +17 -10
  137. package/src/services/spawn-agent.ts +26 -1
  138. package/src/services/transient-agent-reaper.ts +11 -6
  139. package/src/token-db.ts +3 -3
  140. package/src/workspace-merge.ts +11 -0
  141. package/src/workspace-pr-completion.ts +72 -9
  142. package/public/assets/agents-DZBM5dp_.js +0 -2
  143. package/public/assets/analytics-bvjb3Urs.js +0 -910
  144. package/public/assets/chat-YzX1z76l.js +0 -2
  145. package/public/assets/dist-DmhyU0Na.js +0 -2
  146. package/public/assets/dist-DxkMwkrr.js +0 -2
  147. package/public/assets/dist-yYauqYKR.js +0 -2
  148. package/public/assets/index-Ba2RDJaA.js +0 -21
  149. package/public/assets/index-CtFXWxBJ.css +0 -2
  150. package/public/assets/lucide-react-CMhBract.js +0 -9
  151. package/public/assets/memory-DeLwJFwH.js +0 -2
  152. package/public/assets/messages-Cww6dZnM.js +0 -2
  153. package/public/assets/overview-DjcRomU_.js +0 -2
  154. package/public/assets/pairs-DT0u2fnW.js +0 -2
  155. package/public/assets/teams-TqFntFB_.js +0 -2
  156. package/public/assets/workspaces-m97LP4Eu.js +0 -3
@@ -0,0 +1,168 @@
1
+ import { notifySystemMessage } from "./notify";
2
+ import type { Message, MessageKind } from "./types";
3
+
4
+ /**
5
+ * #440 — the single seam every agent-facing "wake" notification flows through. Before this,
6
+ * ~10 emitter sites each hand-rolled their own recipient policy and called `notifySystemMessage`
7
+ * directly, with no central rule, no staleness guard, and no record of *why* an agent was chosen.
8
+ *
9
+ * This is the #441 slice: the seam + taxonomy + the #451 TTL guard. It is **behavior-preserving** —
10
+ * emitters still compute and pass their recipients (`recipients`); the router does not yet derive
11
+ * them from `scope`. The follow-ups move the *deciding* in:
12
+ * - #442 introduces the rule engine that computes recipients FROM `scope`.
13
+ * - #444 flips `branch.landed` to be the first rule-driven type (retiring its hand-rolled fan-out).
14
+ * - #446 turns the routing decision recorded here into an explainable trace.
15
+ *
16
+ * `scope` (the entities a notification is ABOUT) is captured now even though #441 doesn't route on
17
+ * it, so emitters are converted exactly once and the rule engine has its inputs the day it lands.
18
+ */
19
+ export type NotificationType =
20
+ | "branch.landed"
21
+ | "agent.ready"
22
+ | "agent.exited"
23
+ | "agent.spawn_failed"
24
+ | "agent.context_threshold"
25
+ | "agent.resume_budget_exhausted"
26
+ | "agent.transient_land_hold"
27
+ | "agent.rate_limit_resume"
28
+ | "plan.bound_agent_exited"
29
+ | "workspace.steward_task"
30
+ | "workspace.conflict"
31
+ | "workspace.steward_reassigned"
32
+ | "workspace.stranded_escalation"
33
+ | "workspace.auto_abandoned"
34
+ | "workspace.review_request"
35
+ | "workspace.review_changes_requested";
36
+
37
+ /**
38
+ * The entities a notification is ABOUT — the future routing inputs (#442) and the explainability
39
+ * subject (#446). All optional: each emitter fills the fields it has in scope at the call site.
40
+ */
41
+ export interface NotificationScope {
42
+ /** The agent whose work the notification concerns (branch author, workspace owner). */
43
+ author?: string;
44
+ /** The agent the notification is primarily about (lifecycle subject, advisory target). */
45
+ agentId?: string;
46
+ /** The spawn-parent that should hear about a child's lifecycle. */
47
+ parent?: string;
48
+ /** Team the notification belongs to (plan/coordination scope). */
49
+ teamId?: string;
50
+ /** Plan the notification concerns. */
51
+ planId?: string;
52
+ /** Repo checkout the notification concerns. */
53
+ repoRoot?: string;
54
+ /** Workspace the notification concerns. */
55
+ workspaceId?: string;
56
+ /** Files the notification concerns (path-scoped routing, #442). */
57
+ paths?: string[];
58
+ }
59
+
60
+ export interface NotificationMessage {
61
+ subject?: string;
62
+ body: string;
63
+ payload?: Record<string, unknown>;
64
+ kind?: MessageKind;
65
+ from?: string;
66
+ /** Default true. Set false for fire-and-forget pushes (no reply scaffold, no obligation). */
67
+ replyExpected?: boolean;
68
+ }
69
+
70
+ export interface RouteNotificationInput {
71
+ type: NotificationType;
72
+ scope: NotificationScope;
73
+ /**
74
+ * Recipients chosen by the emitter (#441, behavior-preserving). May be concrete agent ids OR
75
+ * durable targets (`policy:`, `team:`, `label:`, `broadcast`, …) — the delivery layer resolves
76
+ * durable forms at poll time. #442 will derive this set from `scope` via the rule engine.
77
+ */
78
+ recipients: Array<string | undefined | null>;
79
+ message: NotificationMessage;
80
+ /**
81
+ * #451 — time-to-live in ms from `emittedAt`, after which the notification is stale and must not
82
+ * wake a recipient. `undefined` → the per-type default ({@link DEFAULT_TTL_MS}); `null` → never
83
+ * expires (store-ahead). Emitters override per-call when the type default isn't right for the
84
+ * audience (e.g. `branch.landed`'s bystander fan-out is ephemeral while the author copy is durable).
85
+ */
86
+ ttlMs?: number | null;
87
+ /** When the underlying event occurred; defaults to now. Drives the staleness guard. */
88
+ emittedAt?: number;
89
+ }
90
+
91
+ const MINUTES = 60 * 1000;
92
+
93
+ /**
94
+ * Per-type default TTL (#451). Default-SAFE: anything about the recipient's own work or any
95
+ * obligation is `null` (never auto-expires — store-ahead #234 must still deliver it). Only the
96
+ * genuinely now-or-never pings get a short default; a stale one of those is actively misleading:
97
+ * - `agent.context_threshold` — the advisory band may have changed; old advice is wrong.
98
+ * - `agent.rate_limit_resume` — "your hold cleared, resume" is pointless once the window moved.
99
+ * Emitters opt INTO a shorter TTL per-call for the bystander/informational cases.
100
+ */
101
+ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
102
+ "branch.landed": null,
103
+ "agent.ready": null,
104
+ "agent.exited": null,
105
+ "agent.spawn_failed": null,
106
+ "agent.context_threshold": 15 * MINUTES,
107
+ "agent.resume_budget_exhausted": null,
108
+ "agent.transient_land_hold": null,
109
+ "agent.rate_limit_resume": 15 * MINUTES,
110
+ "plan.bound_agent_exited": null,
111
+ "workspace.steward_task": null,
112
+ "workspace.conflict": null,
113
+ "workspace.steward_reassigned": null,
114
+ "workspace.stranded_escalation": null,
115
+ "workspace.auto_abandoned": null,
116
+ "workspace.review_request": null,
117
+ "workspace.review_changes_requested": null,
118
+ };
119
+
120
+ /** Convenience TTL for emitter-supplied overrides on bystander/informational notifications. */
121
+ export const INFORMATIONAL_TTL_MS = 30 * MINUTES;
122
+
123
+ /**
124
+ * Resolve the effective TTL: an explicit per-call `ttlMs` wins (including an explicit `null` =
125
+ * never); `undefined` falls back to the per-type default.
126
+ */
127
+ function effectiveTtlMs(type: NotificationType, ttlMs: number | null | undefined): number | null {
128
+ return ttlMs === undefined ? DEFAULT_TTL_MS[type] : ttlMs;
129
+ }
130
+
131
+ /**
132
+ * Route a wake-notification to its recipients. Applies the #451 staleness guard, stamps the TTL as
133
+ * the delivered message's `maxAgeSeconds` (so the poll path drops it once stale), and sends one
134
+ * system message per unique recipient. Returns the delivered messages (most callers ignore this).
135
+ *
136
+ * Recipients are de-duplicated and emptied of falsy entries, so emitters can pass optional ids
137
+ * without guarding. A notification with zero resolved recipients is a no-op.
138
+ */
139
+ export function routeNotification(input: RouteNotificationInput): Message[] {
140
+ const ttl = effectiveTtlMs(input.type, input.ttlMs);
141
+ const emittedAt = input.emittedAt ?? Date.now();
142
+
143
+ // Staleness guard (#451): if the triggering event is already older than its TTL, drop before
144
+ // sending. On the normal synchronous path emittedAt ≈ now so this rarely fires, but it makes the
145
+ // router correct for any caller that routes a delayed/replayed event.
146
+ if (ttl !== null && Date.now() - emittedAt > ttl) return [];
147
+
148
+ const recipients = [...new Set(input.recipients.filter((r): r is string => Boolean(r && r.trim())))];
149
+ if (recipients.length === 0) return [];
150
+
151
+ // Stamp the TTL on the message so the delivery/poll path (pollMessages) enforces it for offline
152
+ // or late-joining recipients too. An explicit `null` (never-expire) is authoritative: it suppresses
153
+ // sendMessage's fan-out default, so a store-ahead type sent to `team:`/`broadcast` keeps its
154
+ // never-expire intent instead of inheriting the moderate baseline.
155
+ const maxAgeSeconds = ttl === null ? null : Math.max(1, Math.ceil(ttl / 1000));
156
+
157
+ return recipients.map((to) =>
158
+ notifySystemMessage(to, {
159
+ subject: input.message.subject,
160
+ body: input.message.body,
161
+ payload: input.message.payload,
162
+ kind: input.message.kind,
163
+ from: input.message.from,
164
+ replyExpected: input.message.replyExpected,
165
+ maxAgeSeconds,
166
+ }),
167
+ );
168
+ }
@@ -0,0 +1,186 @@
1
+ import type { AgentCard } from "./types";
2
+ import { listAgents } from "./db";
3
+ import { isAgentOnline } from "./agent-ref";
4
+ import type { NotificationType, NotificationScope } from "./notification-router";
5
+
6
+ /**
7
+ * #442 — the declarative rule engine that computes a notification's recipients FROM its `scope`,
8
+ * replacing the hand-rolled recipient policy each emitter used to carry (#441 kept those inline as
9
+ * the behavior-preserving step). This is the **model + evaluator**: a library of typed predicates,
10
+ * an ordered evaluator that yields a per-agent decision + the matched rule, and the seam where
11
+ * #443 subscriptions (opt-in / opt-out / mute) plug in as override rules.
12
+ *
13
+ * Core design (from epic #440): **rules-as-CODE, subscriptions/overrides-as-DATA.** The default
14
+ * audience for each type is a composition of these predicates in versioned, tested code — extending
15
+ * the engine means adding a typed predicate, never authoring a stored rule DSL. Per-type compositions
16
+ * are REGISTERED by each type's migration (#444 registers `branch.landed` first and flips its call
17
+ * site); until a type is registered, {@link resolveAudience} returns `[]` and that type stays on the
18
+ * #441 explicit-recipients path. Nothing here is wired into production delivery yet — #444 does that.
19
+ */
20
+
21
+ export type RuleDecision = "deliver" | "suppress" | "abstain";
22
+
23
+ export interface NotificationRuleContext {
24
+ type: NotificationType;
25
+ scope: NotificationScope;
26
+ /** Evaluation clock — passed in (never read via Date.now() inside a rule) so eval is testable. */
27
+ now: number;
28
+ }
29
+
30
+ export interface NotificationRule {
31
+ /** Stable identifier recorded as the matched rule — the #446 "why did I get this?" answer. */
32
+ name: string;
33
+ evaluate(agent: AgentCard, ctx: NotificationRuleContext): RuleDecision;
34
+ }
35
+
36
+ export interface AudienceMember {
37
+ agentId: string;
38
+ /** The rule whose decision was decisive (last non-abstain wins) — carried for explainability (#446). */
39
+ matchedRule: string;
40
+ }
41
+
42
+ /**
43
+ * Predicate library — the reusable building blocks each type composes its default audience from.
44
+ * Each factory returns a single-concern rule. `deliver` includes the agent; `abstain` leaves the
45
+ * running decision unchanged; `suppress` removes an agent a prior rule delivered (the opt-out/mute
46
+ * shape #443 builds on). Compose them ordered: later rules override earlier (see {@link evaluateAudience}).
47
+ */
48
+ export const audiencePredicates = {
49
+ /** The agent whose work the notification is about — delivered even when offline (store-ahead #234). */
50
+ author: (): NotificationRule => ({
51
+ name: "author",
52
+ evaluate: (a, { scope }) => (scope.author && a.id === scope.author ? "deliver" : "abstain"),
53
+ }),
54
+ /** The spawn-parent that should hear about a child's lifecycle — online-agnostic (store-ahead). */
55
+ spawnParent: (): NotificationRule => ({
56
+ name: "spawn-parent",
57
+ evaluate: (a, { scope }) => (scope.parent && a.id === scope.parent ? "deliver" : "abstain"),
58
+ }),
59
+ /** The notification's primary subject agent (advisory target, lifecycle subject). */
60
+ subjectAgent: (): NotificationRule => ({
61
+ name: "subject-agent",
62
+ evaluate: (a, { scope }) => (scope.agentId && a.id === scope.agentId ? "deliver" : "abstain"),
63
+ }),
64
+ /** Every member of the notification's team (coordination/plan scope). Online-agnostic by default. */
65
+ sameTeam: (): NotificationRule => ({
66
+ name: "same-team",
67
+ evaluate: (a, { scope }) => (scope.teamId && a.teamId === scope.teamId ? "deliver" : "abstain"),
68
+ }),
69
+ /**
70
+ * Team-scoping SUPPRESSOR (#444): removes an agent that's on a DIFFERENT team than the
71
+ * notification's. Compose it AFTER a deliver rule (last-wins) to scope a fan-out to the relevant
72
+ * team — e.g. a Team-A branch land no longer wakes Team-B agents sharing the main checkout (the
73
+ * epic #440 dogfooded symptom). Deliberately permissive at the edges: a teamless agent
74
+ * (`a.teamId` unset) and a teamless notification (`scope.teamId` unset) both ABSTAIN, so solo/
75
+ * unteamed work and cross-team-by-default notifications keep their prior broadcast reach.
76
+ */
77
+ differentTeam: (): NotificationRule => ({
78
+ name: "different-team",
79
+ evaluate: (a, { scope }) => (scope.teamId && a.teamId && a.teamId !== scope.teamId ? "suppress" : "abstain"),
80
+ }),
81
+ /**
82
+ * Agents working IN the repo's main checkout (cwd === repoRoot) — the `agentsOnMain` fan-out.
83
+ * Defaults match the legacy behavior exactly: online-only (a stale main session needs no wake)
84
+ * and author-excluded (the author gets its own store-ahead copy via the `author` rule).
85
+ */
86
+ onRepoRoot: (opts: { onlineOnly?: boolean; excludeAuthor?: boolean } = { onlineOnly: true, excludeAuthor: true }): NotificationRule => ({
87
+ name: "on-repo-root",
88
+ evaluate: (a, { scope, now }) => {
89
+ if (!scope.repoRoot || a.meta?.cwd !== scope.repoRoot) return "abstain";
90
+ if (opts.excludeAuthor && scope.author && a.id === scope.author) return "abstain";
91
+ if (opts.onlineOnly && !isAgentOnline(a, now)) return "abstain";
92
+ return "deliver";
93
+ },
94
+ }),
95
+ } as const;
96
+
97
+ /**
98
+ * The candidate pool for rule evaluation: every registered agent EXCEPT the pseudo-identities
99
+ * (`system`/`user`) and channel agents, which are never wake-notification recipients. Offline agents
100
+ * stay IN the pool deliberately — a store-ahead rule (author/spawn-parent) must be able to select an
101
+ * offline target; per-rule `onlineOnly` gates the cases that need liveness (e.g. on-repo-root fan-out).
102
+ */
103
+ export function audienceCandidates(): AgentCard[] {
104
+ return listAgents().filter(
105
+ (a) => a.id !== "system" && a.id !== "user" && a.kind !== "channel" && a.meta?.kind !== "channel",
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Evaluate an ordered rule list against each candidate. Decision fold: a later non-abstain decision
111
+ * overrides an earlier one (last-wins), so subscription overrides — appended after the defaults —
112
+ * can suppress a default deliver (opt-out/mute) or deliver a default-suppress (opt-in). The matched
113
+ * rule recorded is whichever rule set the final decision. Only `deliver` agents make the audience.
114
+ */
115
+ export function evaluateAudience(
116
+ rules: NotificationRule[],
117
+ ctx: NotificationRuleContext,
118
+ candidates: AgentCard[],
119
+ ): AudienceMember[] {
120
+ const audience: AudienceMember[] = [];
121
+ for (const agent of candidates) {
122
+ let decision: RuleDecision = "abstain";
123
+ let matchedRule = "";
124
+ for (const rule of rules) {
125
+ const d = rule.evaluate(agent, ctx);
126
+ if (d !== "abstain") {
127
+ decision = d;
128
+ matchedRule = rule.name;
129
+ }
130
+ }
131
+ if (decision === "deliver") audience.push({ agentId: agent.id, matchedRule });
132
+ }
133
+ return audience;
134
+ }
135
+
136
+ // Per-type active rule compositions. Production defaults are mirrored in `defaultAudienceRegistry`
137
+ // so the test reset seam can clear transient overrides without losing import-time registrations.
138
+ const audienceRegistry = new Map<NotificationType, NotificationRule[]>();
139
+ const defaultAudienceRegistry = new Map<NotificationType, NotificationRule[]>();
140
+
141
+ /** Register (or replace) the active audience rules for a notification type. Tests use this for
142
+ * transient compositions; production type modules should use registerDefaultAudienceRules. */
143
+ export function registerAudienceRules(type: NotificationType, rules: NotificationRule[]): void {
144
+ audienceRegistry.set(type, rules);
145
+ }
146
+
147
+ /** Register (or replace) the production default audience rules for a notification type. Called at
148
+ * module init by each type's migration. Idempotent — re-registering replaces, so hot reload is safe. */
149
+ export function registerDefaultAudienceRules(type: NotificationType, rules: NotificationRule[]): void {
150
+ defaultAudienceRegistry.set(type, rules);
151
+ audienceRegistry.set(type, rules);
152
+ }
153
+
154
+ // #443 subscription seam: opt-in / opt-out / mute become override rules injected here. They run AFTER
155
+ // a type's default rules so they win the last-wins fold. Empty until #443 wires a subscription store.
156
+ let subscriptionRules: NotificationRule[] = [];
157
+
158
+ /** #443 hook: install the subscription override rules (opt-in/opt-out/mute). Pass [] to clear. */
159
+ export function setSubscriptionRules(rules: NotificationRule[]): void {
160
+ subscriptionRules = rules;
161
+ }
162
+
163
+ /** The effective ordered rule list for a type: its registered defaults, then subscription overrides. */
164
+ export function audienceRulesFor(type: NotificationType): NotificationRule[] {
165
+ const defaults = audienceRegistry.get(type);
166
+ if (!defaults) return [];
167
+ return subscriptionRules.length === 0 ? defaults : [...defaults, ...subscriptionRules];
168
+ }
169
+
170
+ /**
171
+ * Resolve the concrete recipients for a notification from its scope, via the registered rules. Returns
172
+ * `[]` for an unregistered (not-yet-rule-driven) type — callers fall back to explicit recipients.
173
+ * This is what #444 will call in place of the hand-rolled `agentsOnMain` + author selection.
174
+ */
175
+ export function resolveAudience(type: NotificationType, scope: NotificationScope, now: number = Date.now()): AudienceMember[] {
176
+ const rules = audienceRulesFor(type);
177
+ if (rules.length === 0) return [];
178
+ return evaluateAudience(rules, { type, scope, now }, audienceCandidates());
179
+ }
180
+
181
+ /** Test seam: clear the per-type registry and subscription rules between tests. */
182
+ export function __resetAudienceRules(): void {
183
+ audienceRegistry.clear();
184
+ for (const [type, rules] of defaultAudienceRegistry) audienceRegistry.set(type, rules);
185
+ subscriptionRules = [];
186
+ }
package/src/notify.ts CHANGED
@@ -17,6 +17,14 @@ interface SystemNotifyOptions {
17
17
  * (steward task assignments, conflict handoffs).
18
18
  */
19
19
  replyExpected?: boolean;
20
+ /**
21
+ * #451 — TTL in seconds after which the message is stale and must not wake a recipient (the poll
22
+ * path drops it). Omit to use sendMessage's defaults (a moderate TTL for fire-and-forget fan-out,
23
+ * none for direct DMs / obligations / claimable). routeNotification sets this per notification type:
24
+ * a number for ephemeral types, explicit `null` for store-ahead types (authoritative never-expire,
25
+ * suppressing the fan-out default so the router's per-type decision wins).
26
+ */
27
+ maxAgeSeconds?: number | null;
20
28
  }
21
29
 
22
30
  /**
@@ -33,6 +41,7 @@ export function notifySystemMessage(to: string, opts: SystemNotifyOptions): Mess
33
41
  body: opts.body,
34
42
  payload: opts.payload,
35
43
  replyExpected: opts.replyExpected,
44
+ maxAgeSeconds: opts.maxAgeSeconds,
36
45
  });
37
46
  emitNewMessage(msg);
38
47
  return msg;
@@ -0,0 +1,176 @@
1
+ // Project instruction projection (#409) — the relay-owned replacement for host
2
+ // CLAUDE.md bleed-in. Given a resolved project, render its `rule`/`fact` entries
3
+ // (own + inherited `contains`-ancestor entries from #406's cascade) into a compact,
4
+ // DETERMINISTIC, size-bounded instruction block that can be prepended to a spawned
5
+ // agent's systemPromptAppend.
6
+ //
7
+ // ADDITIVE-FROM-EMPTY (#403 decision 4): this composer builds the instruction world
8
+ // from NOTHING and ADDS project knowledge. It NEVER reads host CLAUDE.md or subtracts
9
+ // from it. Going fully vanilla later = simply not including host-derived (`ingested`)
10
+ // content — a policy switch here, zero schema/composer change. `project_entries.source`
11
+ // is that lever: the included-source set is a policy PARAMETER, and the `vanilla`
12
+ // policy includes `manual`+`promoted` while excluding `ingested`.
13
+ import { resolveProjectEntries } from "./db";
14
+ import { PROJECT_ENTRY_SOURCES, type Project, type ProjectEntryKind, type ProjectEntrySource, type ResolvedProjectEntry } from "./types";
15
+
16
+ /** Named source policies — THE additive-from-empty lever (#403 decision 4).
17
+ * `all` — every source (manual + promoted + ingested host-CLAUDE.md content).
18
+ * `vanilla` — manual + promoted only; EXCLUDES `ingested`, so a vanilla agent gets
19
+ * zero host-CLAUDE.md bleed-in while still receiving relay-native knowledge. */
20
+ export const PROJECT_INSTRUCTION_SOURCE_SETS = {
21
+ all: PROJECT_ENTRY_SOURCES,
22
+ vanilla: ["manual", "promoted"],
23
+ } as const satisfies Record<string, readonly ProjectEntrySource[]>;
24
+
25
+ export const PROJECT_INSTRUCTION_POLICY_NAMES = ["all", "vanilla"] as const;
26
+ export type ProjectInstructionPolicyName = (typeof PROJECT_INSTRUCTION_POLICY_NAMES)[number];
27
+
28
+ /** Per-spawn projection request. Presence (a non-undefined value on the spawn input)
29
+ * means "project the preamble"; absence means OFF. `policy` selects the source set. */
30
+ export interface ProjectInstructionPolicy {
31
+ policy?: ProjectInstructionPolicyName;
32
+ }
33
+
34
+ export interface ComposeProjectInstructionsOptions {
35
+ /** Named policy selecting the included source set. Default `all`. */
36
+ policy?: ProjectInstructionPolicyName;
37
+ /** Explicit source set; overrides `policy` when given (the low-level lever). */
38
+ includeSources?: readonly ProjectEntrySource[];
39
+ /** Entry kinds rendered, in this order. Default `["rule", "fact"]` (the directive +
40
+ * truth kinds; `decision`/`memory` are log/scratch, not preamble material). */
41
+ includeKinds?: readonly ProjectEntryKind[];
42
+ /** Merge transitive `contains`-ancestor entries (#406 cascade). Default true. */
43
+ includeInherited?: boolean;
44
+ /** Hard byte cap on the rendered block (UTF-8). Default 8192. The block is truncated
45
+ * deterministically (lowest-priority entries dropped first) to stay within it. */
46
+ maxBytes?: number;
47
+ }
48
+
49
+ const DEFAULT_KINDS: readonly ProjectEntryKind[] = ["rule", "fact"];
50
+ const DEFAULT_MAX_BYTES = 8192;
51
+ const TRUNCATION_NOTICE = "_(… additional project entries omitted to fit the instruction budget.)_";
52
+
53
+ const KIND_HEADINGS: Record<ProjectEntryKind, string> = {
54
+ rule: "Rules",
55
+ fact: "Facts",
56
+ decision: "Decisions",
57
+ memory: "Memory",
58
+ };
59
+
60
+ function byteLen(value: string): number {
61
+ return Buffer.byteLength(value, "utf8");
62
+ }
63
+
64
+ /** Stable dedup key — same kind + same (trimmed) title + body collapses to one entry.
65
+ * Uses NUL separators so distinct fields can't collide by concatenation. */
66
+ function dedupKey(entry: ResolvedProjectEntry): string {
67
+ return `${entry.kind}\u0000${entry.title.trim()}\u0000${(entry.body ?? "").trim()}`;
68
+ }
69
+
70
+ function entryLine(entry: ResolvedProjectEntry): string {
71
+ const body = entry.body?.trim();
72
+ const tag = entry.inherited ? " _(inherited)_" : "";
73
+ return body ? `- ${entry.title.trim()}: ${body}${tag}` : `- ${entry.title.trim()}${tag}`;
74
+ }
75
+
76
+ /**
77
+ * Render a project's instruction block. Deterministic: ordering is by kind (in
78
+ * `includeKinds` order), then own-before-inherited, then title (code-point), then id —
79
+ * fully independent of DB row timestamps. Output is deduped and hard-bounded to
80
+ * `maxBytes`. Returns "" when no entry survives the source/kind filter (the caller then
81
+ * leaves systemPromptAppend untouched, so the spawn stays byte-identical).
82
+ */
83
+ export function composeProjectInstructions(
84
+ project: Pick<Project, "id" | "slug" | "title">,
85
+ opts: ComposeProjectInstructionsOptions = {},
86
+ ): string {
87
+ const kinds = opts.includeKinds ?? DEFAULT_KINDS;
88
+ const kindRank = new Map<ProjectEntryKind, number>(kinds.map((kind, index) => [kind, index]));
89
+ const sources = new Set<ProjectEntrySource>(
90
+ opts.includeSources ?? PROJECT_INSTRUCTION_SOURCE_SETS[opts.policy ?? "all"],
91
+ );
92
+ const includeInherited = opts.includeInherited !== false;
93
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
94
+
95
+ const resolved = resolveProjectEntries(project.id, { includeInherited });
96
+ const filtered = resolved.filter((entry) => kindRank.has(entry.kind) && sources.has(entry.source));
97
+
98
+ // Deterministic ordering, independent of DB row order/timestamps.
99
+ filtered.sort((a, b) => {
100
+ const byKind = (kindRank.get(a.kind) ?? 0) - (kindRank.get(b.kind) ?? 0);
101
+ if (byKind !== 0) return byKind;
102
+ const byInherited = Number(a.inherited) - Number(b.inherited); // own (false) first
103
+ if (byInherited !== 0) return byInherited;
104
+ const ta = a.title.trim();
105
+ const tb = b.title.trim();
106
+ if (ta !== tb) return ta < tb ? -1 : 1;
107
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
108
+ });
109
+
110
+ // Dedup, own-wins (own sorts before inherited above).
111
+ const seen = new Set<string>();
112
+ const deduped: ResolvedProjectEntry[] = [];
113
+ for (const entry of filtered) {
114
+ const key = dedupKey(entry);
115
+ if (seen.has(key)) continue;
116
+ seen.add(key);
117
+ deduped.push(entry);
118
+ }
119
+ if (deduped.length === 0) return "";
120
+
121
+ // Greedy size-bounded render. Entries are contiguous by kind (primary sort key), so a
122
+ // per-kind `## heading` is emitted once when the first entry of that kind is included.
123
+ const header = `# Project knowledge: ${project.title?.trim() || project.slug}`;
124
+ const budget = maxBytes - byteLen(`\n\n${TRUNCATION_NOTICE}`);
125
+ const lines: string[] = [header];
126
+ let bytes = byteLen(header);
127
+ let lastKind: ProjectEntryKind | null = null;
128
+ let truncated = false;
129
+
130
+ for (const entry of deduped) {
131
+ const segments: string[] = [];
132
+ if (entry.kind !== lastKind) segments.push("", `## ${KIND_HEADINGS[entry.kind]}`);
133
+ segments.push(entryLine(entry));
134
+ const addition = `\n${segments.join("\n")}`;
135
+ if (bytes + byteLen(addition) > budget) {
136
+ truncated = true;
137
+ break;
138
+ }
139
+ lines.push(...segments);
140
+ bytes += byteLen(addition);
141
+ lastKind = entry.kind;
142
+ }
143
+
144
+ if (truncated) lines.push("", TRUNCATION_NOTICE);
145
+ let out = lines.join("\n");
146
+ // Safety net: guarantee the hard byte cap even if the header + first entry overshoot.
147
+ if (byteLen(out) > maxBytes) {
148
+ out = Buffer.from(out, "utf8").subarray(0, maxBytes).toString("utf8");
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /**
154
+ * Parse a spawn-transport's `projectInstructions` wire value into the normalized policy
155
+ * (or undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift.
156
+ * - undefined / null / false → undefined (OFF)
157
+ * - true → {} (ON, default policy)
158
+ * - { policy } → validated policy
159
+ * Throws `onInvalid(message)` for a malformed shape or unknown policy name.
160
+ */
161
+ export function parseProjectInstructions(
162
+ value: unknown,
163
+ onInvalid: (message: string) => Error,
164
+ ): ProjectInstructionPolicy | undefined {
165
+ if (value === undefined || value === null || value === false) return undefined;
166
+ if (value === true) return {};
167
+ if (typeof value !== "object") {
168
+ throw onInvalid("projectInstructions must be a boolean or { policy } object");
169
+ }
170
+ const policy = (value as Record<string, unknown>).policy;
171
+ if (policy === undefined) return {};
172
+ if (typeof policy !== "string" || !PROJECT_INSTRUCTION_POLICY_NAMES.includes(policy as ProjectInstructionPolicyName)) {
173
+ throw onInvalid(`projectInstructions.policy must be one of: ${PROJECT_INSTRUCTION_POLICY_NAMES.join(", ")}`);
174
+ }
175
+ return { policy: policy as ProjectInstructionPolicyName };
176
+ }
@@ -3,7 +3,7 @@ import { planSend } from "./agent-ref";
3
3
  import { createCommand } from "./commands-db";
4
4
  import { getSpawnPolicy } from "./config-store";
5
5
  import { getLifecycleManager } from "./lifecycle-manager";
6
- import { notifySystemMessage } from "./notify";
6
+ import { routeNotification } from "./notification-router";
7
7
  import { getWorkspace, listAgents, patchWorkspaceMetadata, updateWorkspaceStatus } from "./db";
8
8
  import type { Command, WorkspaceMergePreview, WorkspaceRecord } from "./types";
9
9
 
@@ -60,26 +60,33 @@ export function handleReviewerMergeResult(command: Command, result: Record<strin
60
60
  });
61
61
  if (!updated) return false;
62
62
 
63
- notifySystemMessage(target, {
64
- subject: `Review requested: ${workspace.branch ?? workspace.id}`,
65
- body: [
66
- `Review PR ${prUrl ?? `#${prNumber ?? "unknown"}`} for workspace \`${workspace.id}\`.`,
67
- `Repo: ${workspace.repoRoot}`,
68
- workspace.branch ? `Branch: ${workspace.branch}` : undefined,
69
- workspace.baseRef ? `Base: ${workspace.baseRef}` : undefined,
70
- compareRef ? `Compare: ${compareRef}` : undefined,
71
- "Approve the PR to let Relay arm GitHub auto-merge, or request changes to send it back to the workspace owner.",
72
- ].filter(Boolean).join("\n"),
73
- payload: {
74
- kind: "workspace.review-request",
75
- workspaceId: workspace.id,
76
- repoRoot: workspace.repoRoot,
77
- worktreePath: workspace.worktreePath,
78
- branch: workspace.branch,
79
- baseRef: workspace.baseRef,
80
- prUrl,
81
- prNumber,
82
- compareRef,
63
+ // Obligation (ttl null by type default): a review request must reach the reviewer even if they're
64
+ // offline a while it's not a bystander notice.
65
+ routeNotification({
66
+ type: "workspace.review_request",
67
+ scope: { workspaceId: workspace.id, repoRoot: workspace.repoRoot },
68
+ recipients: [target],
69
+ message: {
70
+ subject: `Review requested: ${workspace.branch ?? workspace.id}`,
71
+ body: [
72
+ `Review PR ${prUrl ?? `#${prNumber ?? "unknown"}`} for workspace \`${workspace.id}\`.`,
73
+ `Repo: ${workspace.repoRoot}`,
74
+ workspace.branch ? `Branch: ${workspace.branch}` : undefined,
75
+ workspace.baseRef ? `Base: ${workspace.baseRef}` : undefined,
76
+ compareRef ? `Compare: ${compareRef}` : undefined,
77
+ "Approve the PR to let Relay arm GitHub auto-merge, or request changes to send it back to the workspace owner.",
78
+ ].filter(Boolean).join("\n"),
79
+ payload: {
80
+ kind: "workspace.review-request",
81
+ workspaceId: workspace.id,
82
+ repoRoot: workspace.repoRoot,
83
+ worktreePath: workspace.worktreePath,
84
+ branch: workspace.branch,
85
+ baseRef: workspace.baseRef,
86
+ prUrl,
87
+ prNumber,
88
+ compareRef,
89
+ },
83
90
  },
84
91
  });
85
92
  if (policyName) getLifecycleManager().onMessageForPolicy(policyName);
@@ -172,26 +179,33 @@ function notifyReviewChangesRequested(
172
179
  ): string | undefined {
173
180
  const target = workspace.ownerAgentId;
174
181
  if (!target) return undefined;
175
- notifySystemMessage(target, {
176
- subject: `Changes requested: ${workspace.branch ?? workspace.id}`,
177
- body: [
178
- `The reviewer requested changes on ${prUrl ?? `PR #${prNumber ?? "unknown"}`}.`,
179
- `Workspace \`${workspace.id}\` remains in review_requested.`,
180
- preview.baseRef ? `Base: ${preview.baseRef}` : undefined,
181
- ].filter(Boolean).join("\n"),
182
- payload: {
183
- kind: "workspace.review-changes-requested",
184
- workspaceId: workspace.id,
185
- repoRoot: workspace.repoRoot,
186
- worktreePath: workspace.worktreePath,
187
- branch: workspace.branch,
188
- baseRef: workspace.baseRef,
189
- prUrl,
190
- prNumber,
191
- reviewDecision: preview.reviewDecision,
192
- statusCheckRollup: preview.statusCheckRollup,
182
+ // Durable (ttl null): the owner must learn changes were requested on THEIR workspace even after
183
+ // being offline store-ahead, not a freshness-gated bystander notice.
184
+ routeNotification({
185
+ type: "workspace.review_changes_requested",
186
+ scope: { workspaceId: workspace.id, repoRoot: workspace.repoRoot, author: target },
187
+ recipients: [target],
188
+ message: {
189
+ subject: `Changes requested: ${workspace.branch ?? workspace.id}`,
190
+ body: [
191
+ `The reviewer requested changes on ${prUrl ?? `PR #${prNumber ?? "unknown"}`}.`,
192
+ `Workspace \`${workspace.id}\` remains in review_requested.`,
193
+ preview.baseRef ? `Base: ${preview.baseRef}` : undefined,
194
+ ].filter(Boolean).join("\n"),
195
+ payload: {
196
+ kind: "workspace.review-changes-requested",
197
+ workspaceId: workspace.id,
198
+ repoRoot: workspace.repoRoot,
199
+ worktreePath: workspace.worktreePath,
200
+ branch: workspace.branch,
201
+ baseRef: workspace.baseRef,
202
+ prUrl,
203
+ prNumber,
204
+ reviewDecision: preview.reviewDecision,
205
+ statusCheckRollup: preview.statusCheckRollup,
206
+ },
207
+ replyExpected: false,
193
208
  },
194
- replyExpected: false,
195
209
  });
196
210
  return target;
197
211
  }