agent-relay-server 0.59.0 → 0.60.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 (107) hide show
  1. package/docs/openapi.json +156 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-vzupFQD8.js +2 -0
  4. package/public/assets/MessageBubble-vzupFQD8.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-CXix1C-H.js → PlanGraphPanel-DQvycBMW.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CXix1C-H.js.map → PlanGraphPanel-DQvycBMW.js.map} +1 -1
  7. package/public/assets/{activity-CRafQ1Je.js → activity-gmfkOBhQ.js} +2 -2
  8. package/public/assets/{activity-CRafQ1Je.js.map → activity-gmfkOBhQ.js.map} +1 -1
  9. package/public/assets/{agent-profiles-DW4o5-ND.js → agent-profiles-7X8gFuwj.js} +2 -2
  10. package/public/assets/{agent-profiles-DW4o5-ND.js.map → agent-profiles-7X8gFuwj.js.map} +1 -1
  11. package/public/assets/{agents-Cbegv0Hm.js → agents-D201CuTR.js} +2 -2
  12. package/public/assets/{agents-Cbegv0Hm.js.map → agents-D201CuTR.js.map} +1 -1
  13. package/public/assets/{analytics-DX6T7JX3.js → analytics-CkOTrr5t.js} +2 -2
  14. package/public/assets/{analytics-DX6T7JX3.js.map → analytics-CkOTrr5t.js.map} +1 -1
  15. package/public/assets/{automation-CzSBv4gg.js → automation-_5u-nVs8.js} +2 -2
  16. package/public/assets/{automation-CzSBv4gg.js.map → automation-_5u-nVs8.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CRA50r81.js → branch-state-badge-BPXXLpQG.js} +2 -2
  18. package/public/assets/{branch-state-badge-CRA50r81.js.map → branch-state-badge-BPXXLpQG.js.map} +1 -1
  19. package/public/assets/{channels-B1MQCPCT.js → channels-BsJu5TXR.js} +2 -2
  20. package/public/assets/{channels-B1MQCPCT.js.map → channels-BsJu5TXR.js.map} +1 -1
  21. package/public/assets/{chat-BqIaFYGJ.js → chat-CHgPGHQD.js} +2 -2
  22. package/public/assets/{chat-BqIaFYGJ.js.map → chat-CHgPGHQD.js.map} +1 -1
  23. package/public/assets/{connectors-C9aFJIrh.js → connectors-arjfBQvc.js} +2 -2
  24. package/public/assets/{connectors-C9aFJIrh.js.map → connectors-arjfBQvc.js.map} +1 -1
  25. package/public/assets/display-CketawEF.js.map +1 -1
  26. package/public/assets/{formatted-body-impl-DOXH7oDF.js → formatted-body-impl-DBt5tnS0.js} +2 -2
  27. package/public/assets/{formatted-body-impl-DOXH7oDF.js.map → formatted-body-impl-DBt5tnS0.js.map} +1 -1
  28. package/public/assets/{index-br_Y6Cvt.js → index-D7js3XlH.js} +6 -6
  29. package/public/assets/{index-br_Y6Cvt.js.map → index-D7js3XlH.js.map} +1 -1
  30. package/public/assets/{insights-B1edq5lk.js → insights-BvjkOXk9.js} +2 -2
  31. package/public/assets/{insights-B1edq5lk.js.map → insights-BvjkOXk9.js.map} +1 -1
  32. package/public/assets/{integrations-Cm4fjp7_.js → integrations-7L1f3eIE.js} +2 -2
  33. package/public/assets/{integrations-Cm4fjp7_.js.map → integrations-7L1f3eIE.js.map} +1 -1
  34. package/public/assets/{maintenance-D6zvw0fU.js → maintenance-BBoL--vs.js} +2 -2
  35. package/public/assets/{maintenance-D6zvw0fU.js.map → maintenance-BBoL--vs.js.map} +1 -1
  36. package/public/assets/{managed-agents-CzfEznM1.js → managed-agents-BjYBzVZt.js} +2 -2
  37. package/public/assets/{managed-agents-CzfEznM1.js.map → managed-agents-BjYBzVZt.js.map} +1 -1
  38. package/public/assets/{markdown-preview-impl-DZlCaTHQ.js → markdown-preview-impl-BnwjZsmO.js} +2 -2
  39. package/public/assets/{markdown-preview-impl-DZlCaTHQ.js.map → markdown-preview-impl-BnwjZsmO.js.map} +1 -1
  40. package/public/assets/{memory-CUUAzJDc.js → memory-SLycz2gC.js} +2 -2
  41. package/public/assets/{memory-CUUAzJDc.js.map → memory-SLycz2gC.js.map} +1 -1
  42. package/public/assets/{messages-6QZH46LG.js → messages-DfSCTe6N.js} +2 -2
  43. package/public/assets/{messages-6QZH46LG.js.map → messages-DfSCTe6N.js.map} +1 -1
  44. package/public/assets/{orchestrators-CrcqkPuc.js → orchestrators-BwxdTQm_.js} +2 -2
  45. package/public/assets/{orchestrators-CrcqkPuc.js.map → orchestrators-BwxdTQm_.js.map} +1 -1
  46. package/public/assets/{overview-418xXZO_.js → overview-Djy3qkY5.js} +2 -2
  47. package/public/assets/{overview-418xXZO_.js.map → overview-Djy3qkY5.js.map} +1 -1
  48. package/public/assets/{pairs-CnedATgT.js → pairs-9KNduLnC.js} +2 -2
  49. package/public/assets/{pairs-CnedATgT.js.map → pairs-9KNduLnC.js.map} +1 -1
  50. package/public/assets/{security-DS13pbag.js → security-tG3q7fFW.js} +2 -2
  51. package/public/assets/{security-DS13pbag.js.map → security-tG3q7fFW.js.map} +1 -1
  52. package/public/assets/{select-BOprGZDS.js → select-CGUdMNOG.js} +2 -2
  53. package/public/assets/{select-BOprGZDS.js.map → select-CGUdMNOG.js.map} +1 -1
  54. package/public/assets/{settings-BgInn_f3.js → settings-CGcqyJ5L.js} +2 -2
  55. package/public/assets/{settings-BgInn_f3.js.map → settings-CGcqyJ5L.js.map} +1 -1
  56. package/public/assets/{store-DHO_a8BD.js → store-DXdfhiVf.js} +2 -2
  57. package/public/assets/store-DXdfhiVf.js.map +1 -0
  58. package/public/assets/{tasks-B-4oCPuv.js → tasks-DWlKg218.js} +2 -2
  59. package/public/assets/{tasks-B-4oCPuv.js.map → tasks-DWlKg218.js.map} +1 -1
  60. package/public/assets/{teams-sCQAs_QA.js → teams-DRchUP_p.js} +2 -2
  61. package/public/assets/{teams-sCQAs_QA.js.map → teams-DRchUP_p.js.map} +1 -1
  62. package/public/assets/{terminal-viewer-impl-BhqS2XpS.js → terminal-viewer-impl-D_Sqe-vo.js} +2 -2
  63. package/public/assets/{terminal-viewer-impl-BhqS2XpS.js.map → terminal-viewer-impl-D_Sqe-vo.js.map} +1 -1
  64. package/public/assets/{work-queue-BDhjlwUH.js → work-queue-HNcyjON9.js} +2 -2
  65. package/public/assets/{work-queue-BDhjlwUH.js.map → work-queue-HNcyjON9.js.map} +1 -1
  66. package/public/assets/{workspaces-6I-C69nf.js → workspaces-CicYE2Ax.js} +2 -2
  67. package/public/assets/{workspaces-6I-C69nf.js.map → workspaces-CicYE2Ax.js.map} +1 -1
  68. package/public/index.html +2 -2
  69. package/src/agent-lifecycle-events.ts +41 -21
  70. package/src/branch-landed.ts +46 -35
  71. package/src/channel-target.ts +10 -0
  72. package/src/cli/index.ts +11 -0
  73. package/src/cli/merges.ts +89 -0
  74. package/src/config.ts +9 -0
  75. package/src/context-advisory.ts +13 -6
  76. package/src/db/merge-lease.ts +95 -1
  77. package/src/db/message-reads.ts +6 -0
  78. package/src/db/messages.ts +16 -1
  79. package/src/db/migrations.ts +30 -0
  80. package/src/db/notification-subscriptions.ts +198 -0
  81. package/src/db/schema.ts +11 -0
  82. package/src/index.ts +2 -0
  83. package/src/maintenance.ts +40 -21
  84. package/src/mcp-merge-tools.ts +66 -0
  85. package/src/mcp-notification-subscription-tools.ts +111 -0
  86. package/src/mcp.ts +5 -3
  87. package/src/merge-pause-maintenance.ts +13 -0
  88. package/src/notification-router.ts +168 -0
  89. package/src/notification-rules.ts +186 -0
  90. package/src/notify.ts +9 -0
  91. package/src/reviewer-pipeline.ts +54 -40
  92. package/src/routes/agent-sessions.ts +4 -2
  93. package/src/routes/commands.ts +20 -0
  94. package/src/routes/index.ts +7 -0
  95. package/src/routes/merges.ts +95 -0
  96. package/src/security.ts +2 -0
  97. package/src/self-resume.ts +11 -6
  98. package/src/services/merge-pause.ts +37 -0
  99. package/src/services/plan-live-bindings.ts +11 -6
  100. package/src/services/rate-limit-resume.ts +17 -10
  101. package/src/services/spawn-agent.ts +10 -8
  102. package/src/services/transient-agent-reaper.ts +11 -6
  103. package/src/token-db.ts +3 -3
  104. package/src/workspace-pr-completion.ts +85 -8
  105. package/public/assets/MessageBubble-BrEKymoV.js +0 -2
  106. package/public/assets/MessageBubble-BrEKymoV.js.map +0 -1
  107. package/public/assets/store-DHO_a8BD.js.map +0 -1
@@ -1,9 +1,16 @@
1
1
  import { emitRelayEvent } from "./events";
2
2
  import { getNotificationsConfig } from "./config-store";
3
- import { notifySystemMessage } from "./notify";
4
- import { createActivityEvent, listAgents, updateWorkspaceStatus } from "./db";
5
- import { isAgentOnline } from "./agent-ref";
6
- import type { AgentCard, WorkspaceMergePreview, WorkspaceRecord } from "./types";
3
+ import { routeNotification, INFORMATIONAL_TTL_MS } from "./notification-router";
4
+ import { registerDefaultAudienceRules, resolveAudience, audiencePredicates } from "./notification-rules";
5
+ import { createActivityEvent, getAgent, updateWorkspaceStatus } from "./db";
6
+ import type { WorkspaceMergePreview, WorkspaceRecord } from "./types";
7
+
8
+ // #444 — branch.landed is the first rule-driven type: the on-main "merged" fan-out audience comes
9
+ // from the #442 engine, not a hand-rolled agentsOnMain scan. onRepoRoot delivers online agents in
10
+ // the main checkout (author excluded — they get the direct store-ahead copy below); differentTeam
11
+ // then scopes the fan-out to the land's team, so a Team-A land no longer wakes Team-B agents sharing
12
+ // the main checkout (epic #440's dogfooded symptom). A teamless author/agent keeps prior reach.
13
+ registerDefaultAudienceRules("branch.landed", [audiencePredicates.onRepoRoot(), audiencePredicates.differentTeam()]);
7
14
 
8
15
  export interface BranchLandedInput {
9
16
  /**
@@ -88,27 +95,44 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
88
95
  const continueLabel = input.newBranch
89
96
  ? ` You're now on \`${input.newBranch}\` — keep working there.`
90
97
  : " Worktree reclaimed.";
91
- notifySystemMessage(author, {
92
- subject: "Your branch landed",
93
- body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}${continueLabel}`,
94
- payload,
95
- replyExpected: false,
98
+ // Durable (ttl null): the author's own branch landing must reach them even after they go
99
+ // offline (store-ahead #234) — not a bystander notice that goes stale.
100
+ routeNotification({
101
+ type: "branch.landed",
102
+ scope: { author, repoRoot: workspace.repoRoot, workspaceId: workspace.id },
103
+ recipients: [author],
104
+ ttlMs: null,
105
+ message: {
106
+ subject: "Your branch landed",
107
+ body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}${continueLabel}`,
108
+ payload,
109
+ replyExpected: false,
110
+ },
96
111
  });
97
112
  }
98
113
 
99
- // Agents on `main`those whose cwd IS the main checkout (not an isolated worktree) —
100
- // get a live "merged" notice so a long-lived main agent's context stays current as work
101
- // lands under it (#239). Online-only: a stale/exited main session needs no wake, and
102
- // store-ahead to it would just pile up noise. The author is in a worktree (cwd ≠ repoRoot)
103
- // so it's naturally excluded; guard anyway for shared-mode owners.
104
- const branchLabel = landedBranch ? `\`${landedBranch}\`` : "A branch";
105
- const authorLabel = author ? ` by \`${author}\`` : "";
106
- for (const agent of agentsOnMain(workspace.repoRoot, author)) {
107
- notifySystemMessage(agent.id, {
108
- subject: `Merged to ${base}`,
109
- body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
110
- payload,
111
- replyExpected: false,
114
+ // On-main "merged" fan-outa live notice so a long-lived main agent's context stays current as
115
+ // work lands under it (#239). Recipients now come from the #442 rule engine (registered above):
116
+ // online agents whose cwd is the main checkout, author-excluded, team-scoped. Empty for a solo
117
+ // land with no other main agents no-op.
118
+ const mainScope = { author, repoRoot: workspace.repoRoot, workspaceId: workspace.id, teamId: author ? getAgent(author)?.teamId : undefined };
119
+ const mainRecipients = resolveAudience("branch.landed", mainScope).map((member) => member.agentId);
120
+ if (mainRecipients.length > 0) {
121
+ const branchLabel = landedBranch ? `\`${landedBranch}\`` : "A branch";
122
+ const authorLabel = author ? ` by \`${author}\`` : "";
123
+ // Bystander fan-out (ttl informational): a "merged" notice is only useful while fresh — a
124
+ // late-joining main agent shouldn't be woken by an hours-old land.
125
+ routeNotification({
126
+ type: "branch.landed",
127
+ scope: mainScope,
128
+ recipients: mainRecipients,
129
+ ttlMs: INFORMATIONAL_TTL_MS,
130
+ message: {
131
+ subject: `Merged to ${base}`,
132
+ body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
133
+ payload,
134
+ replyExpected: false,
135
+ },
112
136
  });
113
137
  }
114
138
  }
@@ -148,16 +172,3 @@ export function reconcileLandedWorkspace(ws: WorkspaceRecord, preview: Workspace
148
172
  // Notification is best-effort; the merged status + activity event still stand.
149
173
  }
150
174
  }
151
-
152
- // An agent is "on `main`" when its registered cwd equals the repo's main checkout — i.e. it
153
- // works in the base, not an isolated worktree. Excludes the author, pseudo agents (system/
154
- // user), channels, and offline sessions.
155
- function agentsOnMain(repoRoot: string, author: string | undefined): AgentCard[] {
156
- return listAgents().filter((a) => {
157
- if (a.id === author || a.id === "system" || a.id === "user") return false;
158
- if (a.kind === "channel" || a.meta?.kind === "channel") return false;
159
- const cwd = a.meta?.cwd;
160
- if (typeof cwd !== "string" || cwd !== repoRoot) return false;
161
- return isAgentOnline(a);
162
- });
163
- }
@@ -23,3 +23,13 @@ export function parseChannelRouteTarget(target: string): ChannelRouteTarget {
23
23
  if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
24
24
  return { type: "agent", id: target };
25
25
  }
26
+
27
+ // A fan-out target addresses MANY agents (broadcast / tag / cap / label / team / pool) rather than
28
+ // one. #451 uses this to scope the default message TTL: a stale fan-out message must not wake a
29
+ // late-joining agent, whereas a direct DM (and `policy:`, which resolves to one running agent or
30
+ // queues with its own expiry) is store-ahead and never auto-expires. Built on the one parser above
31
+ // so the prefix set can't drift from route resolution.
32
+ export function isFanoutTarget(target: string): boolean {
33
+ const { type } = parseChannelRouteTarget(target);
34
+ return type === "broadcast" || type === "tag" || type === "capability" || type === "label" || type === "team" || type === "pool";
35
+ }
package/src/cli/index.ts CHANGED
@@ -14,6 +14,7 @@ import { handleDevCommand } from "./dev";
14
14
  import { handleMemoryCommand } from "./memory";
15
15
  import { handleSlashOrPairCommand } from "./pair";
16
16
  import { handleWorkspaceCommand } from "./workspace";
17
+ import { handleMergesCommand } from "./merges";
17
18
  import { handleStewardCommand } from "./steward";
18
19
  import { handleMessageCommand, handleGetMessageCommand, handleReactCommand } from "./message";
19
20
  import { handleReplyCommand } from "./reply";
@@ -44,6 +45,7 @@ Usage:
44
45
  agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
45
46
  agent-relay token <create|list|revoke|verify> [options]
46
47
  agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
48
+ agent-relay merges <pause|resume|renew|list> [--repo PATH] [--base BRANCH] [--ttl 3m|--ttl-ms N] [--reason TEXT] [--holder TEXT] [--id ID] [--json]
47
49
  agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--auto-merge on-green|on-approval|manual] [--reviewer HANDLE] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]
48
50
  agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
49
51
  agent-relay message <target> <body> [options]
@@ -141,6 +143,11 @@ Token examples:
141
143
  agent-relay token list
142
144
  agent-relay token revoke JTI
143
145
 
146
+ Merge pause examples:
147
+ agent-relay merges pause --repo /repo --reason release --ttl 5m
148
+ agent-relay merges renew --id PAUSE_ID --ttl 5m
149
+ agent-relay merges resume --id PAUSE_ID
150
+
144
151
  Context probe examples:
145
152
  agent-relay context-probe print-status-line --wrap 'ccstatusline'
146
153
  agent-relay context-probe --wrap 'ccstatusline'
@@ -345,6 +352,10 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
345
352
  await handleTagsCommand(args.slice(1));
346
353
  return "handled";
347
354
  }
355
+ if (command === "merges" || command === "merge") {
356
+ await handleMergesCommand(args.slice(1));
357
+ return "handled";
358
+ }
348
359
  if (command === "workspace" || command === "workspaces") {
349
360
  await handleWorkspaceCommand(args.slice(1));
350
361
  return "handled";
@@ -0,0 +1,89 @@
1
+ // Merge coordination CLI. Generic pause/resume lease for relay-owned auto-merge
2
+ // sweeps; useful for release/build critical sections (#448).
3
+ import { spawnSync } from "node:child_process";
4
+ import { apiRequest } from "./_shared";
5
+
6
+ const MERGES_USAGE = "Usage: agent-relay merges <pause|resume|renew|list> [--repo PATH] [--base BRANCH] [--ttl 3m|--ttl-ms N] [--reason TEXT] [--holder TEXT] [--id ID] [--json]";
7
+
8
+ function gitRoot(): string | undefined {
9
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: "pipe" });
10
+ return r.status === 0 ? r.stdout.trim() : undefined;
11
+ }
12
+
13
+ function parseDurationMs(raw: string): number {
14
+ const match = raw.trim().match(/^(\d+)(ms|s|m|h)?$/);
15
+ if (!match) throw new Error("--ttl must be a positive duration like 180s, 3m, 1h, or use --ttl-ms");
16
+ const value = Number(match[1]);
17
+ const unit = match[2] ?? "ms";
18
+ const multiplier = unit === "h" ? 60 * 60_000 : unit === "m" ? 60_000 : unit === "s" ? 1000 : 1;
19
+ const ms = value * multiplier;
20
+ if (!Number.isSafeInteger(ms) || ms <= 0) throw new Error("--ttl is out of range");
21
+ return ms;
22
+ }
23
+
24
+ function formatPause(pause: any): string {
25
+ const until = new Date(pause.expiresAt).toISOString();
26
+ return `Merge pause ${pause.id}: ${pause.repoRoot}#${pause.baseRef} held by ${pause.holder} until ${until}${pause.reason ? ` (${pause.reason})` : ""}`;
27
+ }
28
+
29
+ export async function handleMergesCommand(args: string[]): Promise<void> {
30
+ const action = args[0];
31
+ if (action === "--help" || action === "-h" || action === "help") {
32
+ console.log(MERGES_USAGE);
33
+ return;
34
+ }
35
+ if (!action || !["pause", "resume", "renew", "list"].includes(action)) throw new Error(MERGES_USAGE);
36
+
37
+ let repoRoot: string | undefined;
38
+ let baseRef = "main";
39
+ let ttlMs: number | undefined;
40
+ let reason: string | undefined;
41
+ let holder: string | undefined;
42
+ let id: string | undefined;
43
+ let json = false;
44
+
45
+ for (let i = 1; i < args.length; i++) {
46
+ const arg = args[i];
47
+ if (arg === "--repo" && i + 1 < args.length) repoRoot = args[++i];
48
+ else if (arg === "--base" && i + 1 < args.length) baseRef = args[++i]!;
49
+ else if (arg === "--ttl" && i + 1 < args.length) ttlMs = parseDurationMs(args[++i]!);
50
+ else if (arg === "--ttl-ms" && i + 1 < args.length) {
51
+ ttlMs = Number.parseInt(args[++i]!, 10);
52
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) throw new Error("--ttl-ms must be a positive integer");
53
+ }
54
+ else if (arg === "--reason" && i + 1 < args.length) reason = args[++i];
55
+ else if (arg === "--holder" && i + 1 < args.length) holder = args[++i];
56
+ else if (arg === "--id" && i + 1 < args.length) id = args[++i];
57
+ else if (arg === "--json") json = true;
58
+ else throw new Error(`Unknown merges option "${arg}".`);
59
+ }
60
+
61
+ if (action === "list") {
62
+ const result = await apiRequest("GET", "/api/merges/pauses");
63
+ console.log(JSON.stringify(result, null, 2));
64
+ return;
65
+ }
66
+
67
+ if (action === "renew") {
68
+ if (!id) throw new Error("merges renew requires --id");
69
+ const result = await apiRequest("POST", "/api/merges/renew", { id, ttlMs });
70
+ if (json) console.log(JSON.stringify(result, null, 2));
71
+ else console.log(formatPause((result as any).pause));
72
+ return;
73
+ }
74
+
75
+ repoRoot = repoRoot ?? gitRoot();
76
+ if (action === "pause" && !repoRoot) throw new Error("No git repo detected. Pass --repo PATH.");
77
+ if (action === "resume" && !id && !repoRoot) throw new Error("No git repo detected. Pass --id ID or --repo PATH.");
78
+
79
+ if (action === "pause") {
80
+ const result = await apiRequest("POST", "/api/merges/pause", { repoRoot, baseRef, ttlMs, reason, holder });
81
+ if (json) console.log(JSON.stringify(result, null, 2));
82
+ else console.log(formatPause((result as any).pause));
83
+ return;
84
+ }
85
+
86
+ const result = await apiRequest("POST", "/api/merges/resume", id ? { id } : { repoRoot, baseRef });
87
+ if (json) console.log(JSON.stringify(result, null, 2));
88
+ else console.log((result as any).released ? "Merge pause released." : "No matching merge pause was active.");
89
+ }
package/src/config.ts CHANGED
@@ -20,6 +20,11 @@ function envPositiveInt(name: string, fallback: number): number {
20
20
  }
21
21
 
22
22
  export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 120_000); // 2min without heartbeat → offline
23
+ // #451 — default TTL for fire-and-forget FAN-OUT messages (broadcast/tag/cap/label/team) that the
24
+ // sender didn't bound. A stale fan-out push must not wake an agent that matches the target hours
25
+ // later (the cold-session-woken-by-old-coordination-thread bug). Direct DMs, reply-obligations, and
26
+ // claimable work are exempt (never auto-expire) so store-ahead (#234) still delivers them.
27
+ export const DEFAULT_FANOUT_TTL_SECONDS = envPositiveInt("AGENT_RELAY_FANOUT_TTL_SECONDS", 2 * 60 * 60); // 2h
23
28
  export const OFFLINE_PRUNE_MS = envPositiveInt("OFFLINE_PRUNE_MS", DAY_MS); // 24h offline → delete
24
29
  export const REAP_INTERVAL_MS = envPositiveInt("REAP_INTERVAL_MS", 60_000); // reaper cadence
25
30
  export const CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_CLAIM_LEASE_MS", 1_800_000); // 30min claim lease
@@ -28,6 +33,10 @@ export const POOL_CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_POOL_CLAIM_LEASE_
28
33
  // repo. Held from when a workspace.merge command is dispatched until it settles
29
34
  // (or this TTL expires, in case the orchestrator never reports back).
30
35
  export const WORKSPACE_MERGE_LEASE_MS = envPositiveInt("AGENT_RELAY_WORKSPACE_MERGE_LEASE_MS", 900_000); // 15min
36
+ // Opt-in release/build critical-section pause for relay-owned auto-merge sweeps.
37
+ // This narrows the race window while the release script creates and pushes a tag;
38
+ // expiry is the safety net so a killed holder cannot wedge the repo.
39
+ export const MAIN_MERGE_PAUSE_TTL_MS = envPositiveInt("AGENT_RELAY_MAIN_MERGE_PAUSE_TTL_MS", 5 * 60_000); // 5min
31
40
 
32
41
  // Max body size for any POST/PATCH request (64 KiB).
33
42
  export const MAX_BODY_BYTES = 64 * 1024;
@@ -1,7 +1,7 @@
1
1
  import { getNotificationsConfig } from "./config-store";
2
2
  import { getDb } from "./db/connection.ts";
3
3
  import { emitRelayEvent } from "./events";
4
- import { notifySystemMessage } from "./notify";
4
+ import { routeNotification } from "./notification-router";
5
5
  import type { AgentCard, Command, ContextState } from "./types";
6
6
 
7
7
  interface ContextAdvisoryInput {
@@ -38,11 +38,18 @@ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
38
38
  const config = getNotificationsConfig();
39
39
  if (!config.enabled || !config.contextThreshold.enabled) return;
40
40
 
41
- notifySystemMessage(input.agentId, {
42
- subject: "Context threshold reached",
43
- body: `Context advisory: ${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band). Compact at a clean seam before the ${input.hardBackstopPercent}% hard backstop may request compaction.`,
44
- payload,
45
- replyExpected: false,
41
+ // Short default TTL (per-type): a stale context advisory is misleading — the band may have moved
42
+ // by the time the agent next polls, so old advice shouldn't wake it.
43
+ routeNotification({
44
+ type: "agent.context_threshold",
45
+ scope: { agentId: input.agentId },
46
+ recipients: [input.agentId],
47
+ message: {
48
+ subject: "Context threshold reached",
49
+ body: `Context advisory: ${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band). Compact at a clean seam before the ${input.hardBackstopPercent}% hard backstop may request compaction.`,
50
+ payload,
51
+ replyExpected: false,
52
+ },
46
53
  });
47
54
  }
48
55
 
@@ -12,7 +12,7 @@ import {
12
12
  parseRuntimePackage,
13
13
  type RuntimeContracts,
14
14
  } from "../contracts";
15
- import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
15
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS, MAIN_MERGE_PAUSE_TTL_MS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { getDb } from "./connection.ts";
18
18
  import type {
@@ -158,3 +158,97 @@ export function releaseMergeLease(opts: { repoRoot?: string; commandId?: string;
158
158
  return getDb().query(`DELETE FROM workspace_merge_leases WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
159
159
  }
160
160
 
161
+ // --- Per-target merge pause lease (issue #448) -----------------------------
162
+
163
+ export interface MergePauseRecord {
164
+ id: string;
165
+ repoRoot: string;
166
+ baseRef: string;
167
+ holder: string;
168
+ reason?: string;
169
+ createdAt: number;
170
+ updatedAt: number;
171
+ expiresAt: number;
172
+ }
173
+
174
+ export function rowToMergePause(row: any): MergePauseRecord {
175
+ return {
176
+ id: row.id,
177
+ repoRoot: row.repo_root,
178
+ baseRef: row.base_ref,
179
+ holder: row.holder,
180
+ reason: row.reason ?? undefined,
181
+ createdAt: row.created_at,
182
+ updatedAt: row.updated_at,
183
+ expiresAt: row.expires_at,
184
+ };
185
+ }
186
+
187
+ export function getMergePause(id: string): MergePauseRecord | null {
188
+ const row = getDb().query("SELECT * FROM workspace_merge_pauses WHERE id = ?").get(id) as any;
189
+ return row ? rowToMergePause(row) : null;
190
+ }
191
+
192
+ export function getActiveMergePause(repoRoot: string, baseRef = "main", now: number = Date.now()): MergePauseRecord | null {
193
+ const row = getDb().query("SELECT * FROM workspace_merge_pauses WHERE repo_root = ? AND base_ref = ? AND expires_at > ?")
194
+ .get(repoRoot, baseRef, now) as any;
195
+ return row ? rowToMergePause(row) : null;
196
+ }
197
+
198
+ export function listMergePauses(): MergePauseRecord[] {
199
+ return (getDb().query("SELECT * FROM workspace_merge_pauses ORDER BY updated_at DESC").all() as any[]).map(rowToMergePause);
200
+ }
201
+
202
+ export function acquireMergePause(input: {
203
+ repoRoot: string;
204
+ baseRef?: string;
205
+ holder: string;
206
+ reason?: string;
207
+ ttlMs?: number;
208
+ now?: number;
209
+ }): { ok: true; pause: MergePauseRecord } | { ok: false; pause: MergePauseRecord } {
210
+ return getDb().transaction(() => {
211
+ const now = input.now ?? Date.now();
212
+ const baseRef = input.baseRef || "main";
213
+ const existing = getActiveMergePause(input.repoRoot, baseRef, now);
214
+ if (existing) return { ok: false as const, pause: existing };
215
+ const id = randomUUID();
216
+ const expiresAt = now + (input.ttlMs ?? MAIN_MERGE_PAUSE_TTL_MS);
217
+ getDb().query(`
218
+ INSERT INTO workspace_merge_pauses (id, repo_root, base_ref, holder, reason, created_at, updated_at, expires_at)
219
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
220
+ ON CONFLICT(repo_root, base_ref) DO UPDATE SET
221
+ id = excluded.id,
222
+ holder = excluded.holder,
223
+ reason = excluded.reason,
224
+ created_at = excluded.created_at,
225
+ updated_at = excluded.updated_at,
226
+ expires_at = excluded.expires_at
227
+ `).run(id, input.repoRoot, baseRef, input.holder, input.reason ?? null, now, now, expiresAt);
228
+ return { ok: true as const, pause: getMergePause(id)! };
229
+ })();
230
+ }
231
+
232
+ export function renewMergePause(id: string, ttlMs: number = MAIN_MERGE_PAUSE_TTL_MS, now: number = Date.now()): MergePauseRecord | null {
233
+ const current = getMergePause(id);
234
+ if (!current || current.expiresAt <= now) return null;
235
+ getDb().query("UPDATE workspace_merge_pauses SET updated_at = ?, expires_at = ? WHERE id = ?").run(now, now + ttlMs, id);
236
+ return getMergePause(id);
237
+ }
238
+
239
+ export function releaseMergePause(opts: { id?: string; repoRoot?: string; baseRef?: string }): boolean {
240
+ const where: string[] = [];
241
+ const params: string[] = [];
242
+ if (opts.id) { where.push("id = ?"); params.push(opts.id); }
243
+ if (opts.repoRoot) { where.push("repo_root = ?"); params.push(opts.repoRoot); }
244
+ if (opts.baseRef) { where.push("base_ref = ?"); params.push(opts.baseRef); }
245
+ if (!where.length) return false;
246
+ return getDb().query(`DELETE FROM workspace_merge_pauses WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
247
+ }
248
+
249
+ export function releaseExpiredMergePauses(now: number = Date.now()): string[] {
250
+ const expired = getDb().query("SELECT id FROM workspace_merge_pauses WHERE expires_at <= ?").all(now) as Array<{ id: string }>;
251
+ if (!expired.length) return [];
252
+ getDb().query("DELETE FROM workspace_merge_pauses WHERE expires_at <= ?").run(now);
253
+ return expired.map((r) => r.id);
254
+ }
@@ -204,6 +204,12 @@ export function pollMessages(query: PollQuery): Message[] {
204
204
  }
205
205
  conditions.push("delivery_status != 'queued' AND delivery_status NOT IN ('failed', 'dead')");
206
206
 
207
+ // #451 — never surface a message past its TTL: a stale fan-out notification must not wake an
208
+ // agent that matches its target long after it was sent. NULL max_age_seconds never expires
209
+ // (direct DMs, reply-obligations, claimable work — store-ahead #234 still delivers those).
210
+ conditions.push("(max_age_seconds IS NULL OR created_at + max_age_seconds * 1000 > ?)");
211
+ params.push(Date.now());
212
+
207
213
  // A claimable message is available to this agent when it is non-claimable, currently held by
208
214
  // THIS agent, or unclaimed/expired AND its task is not already closed. The closed-task guard
209
215
  // must cover BOTH the null-claim and the expired-claim branch: when a managed agent is
@@ -12,8 +12,9 @@ import {
12
12
  parseRuntimePackage,
13
13
  type RuntimeContracts,
14
14
  } from "../contracts";
15
- import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
15
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS, DEFAULT_FANOUT_TTL_SECONDS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
+ import { isFanoutTarget } from "../channel-target";
17
18
  import { getAgent, validateAgentSession } from "./agents.ts";
18
19
  import { cleanAttachmentRefs, linkAttachmentRefs, validateAttachmentRefs } from "./artifacts.ts";
19
20
  import { ClaimError, ValidationError, getDb } from "./connection.ts";
@@ -247,6 +248,10 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
247
248
  const policyName = policyNameFromTarget(input.to);
248
249
  let deliveryStatus: Message["deliveryStatus"] = "pending";
249
250
  let queuedAt: number | null = null;
251
+ // Distinguish "unspecified" (apply the #451 fan-out default below) from an explicit `null`
252
+ // (authoritative never-expire — routeNotification sets this for store-ahead notification types,
253
+ // and the baseline must not override it). Both store as NULL, but only unspecified gets a default.
254
+ const ttlUnspecified = input.maxAgeSeconds === undefined;
250
255
  let maxAgeSeconds = input.maxAgeSeconds ?? null;
251
256
  let resolvedToAgent: string | null = null;
252
257
 
@@ -260,6 +265,16 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
260
265
  }
261
266
  }
262
267
 
268
+ // #451 — moderate default TTL for fire-and-forget FAN-OUT messages the sender didn't bound, so a
269
+ // stale broadcast/tag/cap/label/team push can't wake a late-joining agent hours later. Exempt
270
+ // (never auto-expire, preserving store-ahead #234): direct DMs and policy targets (single
271
+ // recipient), reply-obligations (replyExpected !== false), claimable work, and any message that
272
+ // already carried an explicit TTL — including an explicit `null` from routeNotification, whose
273
+ // per-type decision (e.g. a store-ahead `plan.bound_agent_exited` to `team:`) must win here.
274
+ if (ttlUnspecified && input.replyExpected === false && !claimable && isFanoutTarget(input.to)) {
275
+ maxAgeSeconds = DEFAULT_FANOUT_TTL_SECONDS;
276
+ }
277
+
263
278
  const id = getDb().transaction(() => {
264
279
  const result = insert.run({
265
280
  $from: input.from,
@@ -117,6 +117,21 @@ export function applyMigrations(): void {
117
117
  normalizeExistingMessageReactions();
118
118
 
119
119
  // Migrations
120
+ getDb().run(`
121
+ CREATE TABLE IF NOT EXISTS workspace_merge_pauses (
122
+ id TEXT PRIMARY KEY,
123
+ repo_root TEXT NOT NULL,
124
+ base_ref TEXT NOT NULL,
125
+ holder TEXT NOT NULL,
126
+ reason TEXT,
127
+ created_at INTEGER NOT NULL,
128
+ updated_at INTEGER NOT NULL,
129
+ expires_at INTEGER NOT NULL,
130
+ UNIQUE(repo_root, base_ref)
131
+ )
132
+ `);
133
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_workspace_merge_pauses_expires ON workspace_merge_pauses(expires_at)");
134
+
120
135
  const cols = getDb().query("PRAGMA table_info(messages)").all() as any[];
121
136
  const colNames = cols.map((c: any) => c.name);
122
137
  if (!colNames.includes("thread_id")) {
@@ -449,6 +464,21 @@ export function applyMigrations(): void {
449
464
  getDb().run("ALTER TABLE agents ADD COLUMN team_id TEXT");
450
465
  }
451
466
  getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_team ON agents(team_id)");
467
+ getDb().run(`
468
+ CREATE TABLE IF NOT EXISTS notification_subscriptions (
469
+ id TEXT PRIMARY KEY,
470
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
471
+ type TEXT NOT NULL,
472
+ mode TEXT NOT NULL CHECK (mode IN ('opt_in', 'opt_out', 'mute')),
473
+ scope_match TEXT,
474
+ created_at INTEGER NOT NULL
475
+ )
476
+ `);
477
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_agent ON notification_subscriptions(agent_id, type)");
478
+ getDb().run(`
479
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_subscriptions_unique
480
+ ON notification_subscriptions(agent_id, type, mode, coalesce(scope_match, ''))
481
+ `);
452
482
  getDb().run(`
453
483
  CREATE TABLE IF NOT EXISTS context_snapshots (
454
484
  id INTEGER PRIMARY KEY AUTOINCREMENT,