agent-relay-server 0.59.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.
@@ -59,7 +59,7 @@ import {
59
59
  emitPoolBindingChanged,
60
60
  emitTaskChanged,
61
61
  } from "./sse";
62
- import { notifySystemMessage } from "./notify";
62
+ import { routeNotification, type NotificationType, type NotificationScope } from "./notification-router";
63
63
  import { pruneExpiredTokenRecords } from "./token-db";
64
64
  import { dissolveTeamById } from "./services/team";
65
65
  import { reapTransientAgents, resetTransientTrackerForTests } from "./services/transient-agent-reaper";
@@ -633,10 +633,16 @@ function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | null {
633
633
  const policyName = ensureRepoSteward(ws.repoRoot);
634
634
  if (!policyName) return null;
635
635
  try {
636
- notifySystemMessage(`policy:${policyName}`, {
637
- subject: `Steward: ${ws.status} workspace needs attention`,
638
- body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
639
- payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
636
+ // Obligation (ttl null by type default): a steward task must survive until claimed/handled.
637
+ routeNotification({
638
+ type: "workspace.steward_task",
639
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
640
+ recipients: [`policy:${policyName}`],
641
+ message: {
642
+ subject: `Steward: ${ws.status} workspace needs attention`,
643
+ body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
644
+ payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
645
+ },
640
646
  });
641
647
  getLifecycleManager().onMessageForPolicy(policyName);
642
648
  patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName });
@@ -740,10 +746,16 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
740
746
  if (woke) notifiedStewards.push(woke);
741
747
  } else if (ws.stewardAgentId) {
742
748
  try {
743
- notifySystemMessage(ws.stewardAgentId, {
744
- subject: "Workspace merge conflict",
745
- body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
746
- payload: { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
749
+ // Obligation (ttl null by type default): a conflict handoff must reach the steward.
750
+ routeNotification({
751
+ type: "workspace.conflict",
752
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
753
+ recipients: [ws.stewardAgentId],
754
+ message: {
755
+ subject: "Workspace merge conflict",
756
+ body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
757
+ payload: { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
758
+ },
747
759
  });
748
760
  notifiedStewards.push(ws.stewardAgentId);
749
761
  } catch {
@@ -848,12 +860,13 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
848
860
  return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldAwaitingApproval, leftForSteward, wokeStewards };
849
861
  }
850
862
 
851
- // Send a system DM, swallowing failures (a stale/missing/misconfigured target
852
- // must never break the GC sweep). Returns the target on success, null otherwise.
853
- function notifyTarget(target: string, subject: string, body: string, payload: Record<string, unknown>): string | null {
863
+ // Route a GC notification, swallowing failures (a stale/missing/misconfigured target must never
864
+ // break the GC sweep). Returns the target on success, null otherwise. These are all obligation-style
865
+ // workspace handoffs (null TTL by type default) they must survive until the recipient acts.
866
+ function notifyTarget(type: NotificationType, scope: NotificationScope, target: string, subject: string, body: string, payload: Record<string, unknown>): string | null {
854
867
  if (!target) return null;
855
868
  try {
856
- notifySystemMessage(target, { subject, body, payload });
869
+ routeNotification({ type, scope, recipients: [target], message: { subject, body, payload } });
857
870
  return target;
858
871
  } catch {
859
872
  return null;
@@ -905,6 +918,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
905
918
  // steward hasn't been told, hand it off explicitly, then clear markers.
906
919
  if (strandedAt !== undefined && meta.strandedNotifiedSteward !== steward) {
907
920
  const sent = notifyTarget(
921
+ "workspace.steward_reassigned",
922
+ { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
908
923
  steward!,
909
924
  "Workspace stewardship reassigned",
910
925
  `You are now steward for ${fresh.repoRoot}. Workspace \`${fresh.branch ?? fresh.id}\` is ${fresh.status} and was stranded without an online steward — please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"}.`,
@@ -920,6 +935,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
920
935
  if (strandedAt === undefined) { patchWorkspaceMetadata(fresh.id, { strandedAt: now }); continue; }
921
936
  if (now - strandedAt < escalationMs || meta.escalatedAt) continue;
922
937
  const sent = notifyTarget(
938
+ "workspace.stranded_escalation",
939
+ { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
923
940
  fallbackTarget,
924
941
  "Stranded workspace needs an owner",
925
942
  `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
@@ -951,6 +968,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
951
968
  // stranded abandon isn't silent (issue #157).
952
969
  const target = ws.stewardAgentId ?? fallbackTarget;
953
970
  const sent = notifyTarget(
971
+ "workspace.auto_abandoned",
972
+ { workspaceId: ws.id, repoRoot: ws.repoRoot },
954
973
  target,
955
974
  "Workspace auto-abandoned",
956
975
  `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
@@ -0,0 +1,111 @@
1
+ import {
2
+ addSubscription,
3
+ cleanSubscriptionMode,
4
+ cleanSubscriptionType,
5
+ listSubscriptions,
6
+ NOTIFICATION_SUBSCRIPTION_MODES,
7
+ NOTIFICATION_SUBSCRIPTION_TYPES,
8
+ removeSubscription,
9
+ type NotificationSubscriptionMode,
10
+ } from "./db/notification-subscriptions";
11
+ import { ValidationError } from "./db";
12
+ import { McpAuthError } from "./mcp-errors";
13
+ import { authContextFromMcp } from "./services/auth-context";
14
+ import { cleanString } from "./validation";
15
+ import type { NotificationScope } from "./notification-router";
16
+
17
+ export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
18
+ {
19
+ name: "relay_subscribe",
20
+ description: "Subscribe, opt out, or mute this caller agent for a notification type. The caller's token identity is the subscription owner.",
21
+ requiredScopes: ["notifications:write"],
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: {
25
+ type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
26
+ mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES, description: "Default opt_in. mute and opt_out suppress matching default deliveries." },
27
+ scope: { type: "object", description: "Optional partial notification scope match, such as { teamId } or { repoRoot }." },
28
+ },
29
+ required: ["type"],
30
+ additionalProperties: false,
31
+ },
32
+ },
33
+ {
34
+ name: "relay_unsubscribe",
35
+ description: "Remove this caller agent's notification subscription by id, or by type/mode/scope. Omitting mode removes all modes for the matched type/scope.",
36
+ requiredScopes: ["notifications:write"],
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ id: { type: "string" },
41
+ type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
42
+ mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES },
43
+ scope: { type: "object" },
44
+ },
45
+ additionalProperties: false,
46
+ },
47
+ },
48
+ {
49
+ name: "relay_list_subscriptions",
50
+ description: "List this caller agent's durable notification subscriptions.",
51
+ requiredScopes: ["notifications:read"],
52
+ inputSchema: {
53
+ type: "object",
54
+ properties: {},
55
+ additionalProperties: false,
56
+ },
57
+ },
58
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
59
+
60
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
61
+
62
+ export function relayNotificationSubscriptionTool(
63
+ auth: McpAuthLike,
64
+ name: string,
65
+ args: Record<string, unknown>,
66
+ ): Record<string, unknown> {
67
+ const agentId = callerAgentId(auth);
68
+ if (name === "relay_subscribe") {
69
+ const subscription = addSubscription({
70
+ agentId,
71
+ type: cleanSubscriptionType(args.type),
72
+ mode: cleanModeOrDefault(args.mode),
73
+ scopeMatch: cleanScope(args.scope),
74
+ });
75
+ return { subscription };
76
+ }
77
+ if (name === "relay_unsubscribe") {
78
+ const id = cleanString(args.id, "id", { max: 120 });
79
+ const type = args.type === undefined || args.type === null ? undefined : cleanSubscriptionType(args.type);
80
+ if (!id && !type) throw new ValidationError("id or type required");
81
+ const removed = removeSubscription({
82
+ agentId,
83
+ id,
84
+ type,
85
+ mode: args.mode === undefined || args.mode === null ? undefined : cleanSubscriptionMode(args.mode),
86
+ ...(Object.prototype.hasOwnProperty.call(args, "scope") ? { scopeMatch: cleanScope(args.scope) } : {}),
87
+ });
88
+ return { removed };
89
+ }
90
+ if (name === "relay_list_subscriptions") {
91
+ return { subscriptions: listSubscriptions(agentId) };
92
+ }
93
+ throw new ValidationError(`unknown tool: ${name}`);
94
+ }
95
+
96
+ function callerAgentId(auth: McpAuthLike): string {
97
+ const ctx = authContextFromMcp(auth);
98
+ if (!ctx.callerAgentId) throw new McpAuthError("notification subscriptions require a single caller agent identity");
99
+ return ctx.callerAgentId;
100
+ }
101
+
102
+ function cleanModeOrDefault(value: unknown): NotificationSubscriptionMode {
103
+ if (value === undefined || value === null) return "opt_in";
104
+ return cleanSubscriptionMode(value);
105
+ }
106
+
107
+ function cleanScope(value: unknown): Partial<NotificationScope> | null {
108
+ if (value === undefined || value === null) return null;
109
+ if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError("scope must be an object");
110
+ return value as Partial<NotificationScope>;
111
+ }
package/src/mcp.ts CHANGED
@@ -50,6 +50,7 @@ import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
50
50
  import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
51
51
  import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
52
52
  import { PROJECT_TOOLS, relayProjectTool } from "./mcp-project-tools";
53
+ import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "./mcp-notification-subscription-tools";
53
54
  import { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
54
55
  import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
55
56
  import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
@@ -463,6 +464,7 @@ const TOOLS: ToolDefinition[] = [
463
464
  ...PLAN_TOOLS,
464
465
  ...BLACKBOARD_TOOLS,
465
466
  ...PROJECT_TOOLS,
467
+ ...NOTIFICATION_SUBSCRIPTION_TOOLS,
466
468
  ];
467
469
 
468
470
  export async function postMcp(req: Request): Promise<Response> {
@@ -579,6 +581,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
579
581
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
580
582
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
581
583
  else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args);
584
+ else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions") result = relayNotificationSubscriptionTool(auth, name, args);
582
585
  else throw new ValidationError(`unknown tool: ${name}`);
583
586
 
584
587
  auditToolCall(auth, name, "ok", result);
@@ -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;