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.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-CHKzD4RY.js → MessageBubble-BrEKymoV.js} +2 -2
- package/public/assets/{MessageBubble-CHKzD4RY.js.map → MessageBubble-BrEKymoV.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BFVTJa0q.js → PlanGraphPanel-CXix1C-H.js} +2 -2
- package/public/assets/{PlanGraphPanel-BFVTJa0q.js.map → PlanGraphPanel-CXix1C-H.js.map} +1 -1
- package/public/assets/{activity-M-dFLU_O.js → activity-CRafQ1Je.js} +2 -2
- package/public/assets/{activity-M-dFLU_O.js.map → activity-CRafQ1Je.js.map} +1 -1
- package/public/assets/{agent-profiles-CvHSoyCX.js → agent-profiles-DW4o5-ND.js} +2 -2
- package/public/assets/{agent-profiles-CvHSoyCX.js.map → agent-profiles-DW4o5-ND.js.map} +1 -1
- package/public/assets/agents-Cbegv0Hm.js +2 -0
- package/public/assets/{agents-DZBM5dp_.js.map → agents-Cbegv0Hm.js.map} +1 -1
- package/public/assets/analytics-DX6T7JX3.js +910 -0
- package/public/assets/{analytics-bvjb3Urs.js.map → analytics-DX6T7JX3.js.map} +1 -1
- package/public/assets/{automation-CDSn9Uq-.js → automation-CzSBv4gg.js} +2 -2
- package/public/assets/{automation-CDSn9Uq-.js.map → automation-CzSBv4gg.js.map} +1 -1
- package/public/assets/{badge-Dj6PREuI.js → badge-Cy6qO_Jz.js} +2 -2
- package/public/assets/{badge-Dj6PREuI.js.map → badge-Cy6qO_Jz.js.map} +1 -1
- package/public/assets/{branch-state-badge-CdYg2HYc.js → branch-state-badge-CRA50r81.js} +2 -2
- package/public/assets/{branch-state-badge-CdYg2HYc.js.map → branch-state-badge-CRA50r81.js.map} +1 -1
- package/public/assets/{button-Cqpufqam.js → button-73wpRw65.js} +2 -2
- package/public/assets/{button-Cqpufqam.js.map → button-73wpRw65.js.map} +1 -1
- package/public/assets/{card-OmDF73CE.js → card-BU6PvVHv.js} +2 -2
- package/public/assets/{card-OmDF73CE.js.map → card-BU6PvVHv.js.map} +1 -1
- package/public/assets/{channels-BkxrdOFZ.js → channels-B1MQCPCT.js} +2 -2
- package/public/assets/{channels-BkxrdOFZ.js.map → channels-B1MQCPCT.js.map} +1 -1
- package/public/assets/chat-BqIaFYGJ.js +2 -0
- package/public/assets/{chat-YzX1z76l.js.map → chat-BqIaFYGJ.js.map} +1 -1
- package/public/assets/{connectors-Bg3rHgl_.js → connectors-C9aFJIrh.js} +2 -2
- package/public/assets/{connectors-Bg3rHgl_.js.map → connectors-C9aFJIrh.js.map} +1 -1
- package/public/assets/{copy-button-DI7b8A7_.js → copy-button-HRopUwui.js} +2 -2
- package/public/assets/{copy-button-DI7b8A7_.js.map → copy-button-HRopUwui.js.map} +1 -1
- package/public/assets/display-CketawEF.js.map +1 -1
- package/public/assets/{dist-CNjnTZEI.js → dist-CC5f7oYw.js} +2 -2
- package/public/assets/{dist-CNjnTZEI.js.map → dist-CC5f7oYw.js.map} +1 -1
- package/public/assets/{dist-DkeTrbTA.js → dist-CLHMeXYi.js} +2 -2
- package/public/assets/{dist-DkeTrbTA.js.map → dist-CLHMeXYi.js.map} +1 -1
- package/public/assets/{dist-C5t_OxI0.js → dist-Ci1MM51P.js} +2 -2
- package/public/assets/{dist-C5t_OxI0.js.map → dist-Ci1MM51P.js.map} +1 -1
- package/public/assets/{dist-CV3sgFiv.js → dist-Cko2KPW8.js} +2 -2
- package/public/assets/{dist-CV3sgFiv.js.map → dist-Cko2KPW8.js.map} +1 -1
- package/public/assets/dist-D41tzJYg.js +2 -0
- package/public/assets/{dist-yYauqYKR.js.map → dist-D41tzJYg.js.map} +1 -1
- package/public/assets/{dist-CoUvwLTy.js → dist-DFLciw0h.js} +2 -2
- package/public/assets/{dist-CoUvwLTy.js.map → dist-DFLciw0h.js.map} +1 -1
- package/public/assets/dist-DUoZnlB2.js +2 -0
- package/public/assets/{dist-DmhyU0Na.js.map → dist-DUoZnlB2.js.map} +1 -1
- package/public/assets/dist-De2oguBm.js +2 -0
- package/public/assets/{dist-DxkMwkrr.js.map → dist-De2oguBm.js.map} +1 -1
- package/public/assets/{dist-1x-LE9Uk.js → dist-DmN94rc8.js} +2 -2
- package/public/assets/{dist-1x-LE9Uk.js.map → dist-DmN94rc8.js.map} +1 -1
- package/public/assets/{dist-DV2tZ2fq.js → dist-Kkpnjx_M.js} +2 -2
- package/public/assets/{dist-DV2tZ2fq.js.map → dist-Kkpnjx_M.js.map} +1 -1
- package/public/assets/{dist-Ca3ujqG_.js → dist-T7u7IZGJ.js} +2 -2
- package/public/assets/{dist-Ca3ujqG_.js.map → dist-T7u7IZGJ.js.map} +1 -1
- package/public/assets/{es2015-DcavLcQ0.js → es2015-Df08Opjx.js} +2 -2
- package/public/assets/{es2015-DcavLcQ0.js.map → es2015-Df08Opjx.js.map} +1 -1
- package/public/assets/{formatted-body-impl-D8i9qqL1.js → formatted-body-impl-DOXH7oDF.js} +2 -2
- package/public/assets/{formatted-body-impl-D8i9qqL1.js.map → formatted-body-impl-DOXH7oDF.js.map} +1 -1
- package/public/assets/index-DaNOQJci.css +2 -0
- package/public/assets/index-br_Y6Cvt.js +21 -0
- package/public/assets/{index-Ba2RDJaA.js.map → index-br_Y6Cvt.js.map} +1 -1
- package/public/assets/{input-C_dl_9fG.js → input-B2EDz-BE.js} +2 -2
- package/public/assets/{input-C_dl_9fG.js.map → input-B2EDz-BE.js.map} +1 -1
- package/public/assets/{insights-D1aANwZ2.js → insights-B1edq5lk.js} +2 -2
- package/public/assets/{insights-D1aANwZ2.js.map → insights-B1edq5lk.js.map} +1 -1
- package/public/assets/{integrations-BrAr9bap.js → integrations-Cm4fjp7_.js} +2 -2
- package/public/assets/{integrations-BrAr9bap.js.map → integrations-Cm4fjp7_.js.map} +1 -1
- package/public/assets/{lib-B3iMVsf-.js → lib-BZrjIp-o.js} +3 -3
- package/public/assets/{lib-B3iMVsf-.js.map → lib-BZrjIp-o.js.map} +1 -1
- package/public/assets/lucide-react-H_-8G79A.js +9 -0
- package/public/assets/{lucide-react-CMhBract.js.map → lucide-react-H_-8G79A.js.map} +1 -1
- package/public/assets/{maintenance-C-xspsMG.js → maintenance-D6zvw0fU.js} +2 -2
- package/public/assets/{maintenance-C-xspsMG.js.map → maintenance-D6zvw0fU.js.map} +1 -1
- package/public/assets/{managed-agents-Bf3ni0vo.js → managed-agents-CzfEznM1.js} +2 -2
- package/public/assets/{managed-agents-Bf3ni0vo.js.map → managed-agents-CzfEznM1.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DKbUBTf_.js → markdown-preview-impl-DZlCaTHQ.js} +2 -2
- package/public/assets/{markdown-preview-impl-DKbUBTf_.js.map → markdown-preview-impl-DZlCaTHQ.js.map} +1 -1
- package/public/assets/memory-CUUAzJDc.js +2 -0
- package/public/assets/{memory-DeLwJFwH.js.map → memory-CUUAzJDc.js.map} +1 -1
- package/public/assets/messages-6QZH46LG.js +2 -0
- package/public/assets/{messages-Cww6dZnM.js.map → messages-6QZH46LG.js.map} +1 -1
- package/public/assets/{orchestrators-DRjwROXw.js → orchestrators-CrcqkPuc.js} +2 -2
- package/public/assets/{orchestrators-DRjwROXw.js.map → orchestrators-CrcqkPuc.js.map} +1 -1
- package/public/assets/overview-418xXZO_.js +2 -0
- package/public/assets/{overview-DjcRomU_.js.map → overview-418xXZO_.js.map} +1 -1
- package/public/assets/pairs-CnedATgT.js +2 -0
- package/public/assets/{pairs-DT0u2fnW.js.map → pairs-CnedATgT.js.map} +1 -1
- package/public/assets/{react-dom-Dtl9-x_1.js → react-dom-WI6TjVe7.js} +2 -2
- package/public/assets/{react-dom-Dtl9-x_1.js.map → react-dom-WI6TjVe7.js.map} +1 -1
- package/public/assets/{security-Bwg3oFI8.js → security-DS13pbag.js} +2 -2
- package/public/assets/{security-Bwg3oFI8.js.map → security-DS13pbag.js.map} +1 -1
- package/public/assets/{select-Cariwv69.js → select-BOprGZDS.js} +2 -2
- package/public/assets/{select-Cariwv69.js.map → select-BOprGZDS.js.map} +1 -1
- package/public/assets/{settings-Bd6Ug3DO.js → settings-BgInn_f3.js} +4 -4
- package/public/assets/{settings-Bd6Ug3DO.js.map → settings-BgInn_f3.js.map} +1 -1
- package/public/assets/{store-BgJq_lN9.js → store-DHO_a8BD.js} +5 -5
- package/public/assets/{store-BgJq_lN9.js.map → store-DHO_a8BD.js.map} +1 -1
- package/public/assets/{tasks-DVz1zhOU.js → tasks-B-4oCPuv.js} +2 -2
- package/public/assets/{tasks-DVz1zhOU.js.map → tasks-B-4oCPuv.js.map} +1 -1
- package/public/assets/teams-sCQAs_QA.js +2 -0
- package/public/assets/{teams-TqFntFB_.js.map → teams-sCQAs_QA.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-OsJ5crlx.js → terminal-viewer-impl-BhqS2XpS.js} +3 -3
- package/public/assets/{terminal-viewer-impl-OsJ5crlx.js.map → terminal-viewer-impl-BhqS2XpS.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-BYHcL7sP.js → use-keyboard-viewport-fOL_3RHB.js} +2 -2
- package/public/assets/{use-keyboard-viewport-BYHcL7sP.js.map → use-keyboard-viewport-fOL_3RHB.js.map} +1 -1
- package/public/assets/{work-queue-B4b2jPLR.js → work-queue-BDhjlwUH.js} +2 -2
- package/public/assets/{work-queue-B4b2jPLR.js.map → work-queue-BDhjlwUH.js.map} +1 -1
- package/public/assets/workspaces-6I-C69nf.js +3 -0
- package/public/assets/{workspaces-m97LP4Eu.js.map → workspaces-6I-C69nf.js.map} +1 -1
- package/public/index.html +21 -21
- package/src/agent-lifecycle-events.ts +41 -21
- package/src/branch-landed.ts +46 -35
- package/src/channel-target.ts +10 -0
- package/src/config.ts +5 -0
- package/src/context-advisory.ts +13 -6
- package/src/db/message-reads.ts +6 -0
- package/src/db/messages.ts +16 -1
- package/src/db/migrations.ts +15 -0
- package/src/db/notification-subscriptions.ts +198 -0
- package/src/db/schema.ts +11 -0
- package/src/db/stats.ts +209 -38
- package/src/index.ts +2 -0
- package/src/maintenance.ts +41 -15
- package/src/mcp-notification-subscription-tools.ts +111 -0
- package/src/mcp.ts +6 -0
- package/src/notification-router.ts +168 -0
- package/src/notification-rules.ts +186 -0
- package/src/notify.ts +9 -0
- package/src/project-instructions.ts +176 -0
- package/src/reviewer-pipeline.ts +54 -40
- package/src/routes/agents-spawn.ts +2 -0
- package/src/routes/commands.ts +11 -5
- package/src/self-resume.ts +11 -6
- package/src/services/plan-live-bindings.ts +11 -6
- package/src/services/rate-limit-resume.ts +17 -10
- package/src/services/spawn-agent.ts +26 -1
- package/src/services/transient-agent-reaper.ts +11 -6
- package/src/token-db.ts +3 -3
- package/src/workspace-merge.ts +11 -0
- package/src/workspace-pr-completion.ts +72 -9
- package/public/assets/agents-DZBM5dp_.js +0 -2
- package/public/assets/analytics-bvjb3Urs.js +0 -910
- package/public/assets/chat-YzX1z76l.js +0 -2
- package/public/assets/dist-DmhyU0Na.js +0 -2
- package/public/assets/dist-DxkMwkrr.js +0 -2
- package/public/assets/dist-yYauqYKR.js +0 -2
- package/public/assets/index-Ba2RDJaA.js +0 -21
- package/public/assets/index-CtFXWxBJ.css +0 -2
- package/public/assets/lucide-react-CMhBract.js +0 -9
- package/public/assets/memory-DeLwJFwH.js +0 -2
- package/public/assets/messages-Cww6dZnM.js +0 -2
- package/public/assets/overview-DjcRomU_.js +0 -2
- package/public/assets/pairs-DT0u2fnW.js +0 -2
- package/public/assets/teams-TqFntFB_.js +0 -2
- package/public/assets/workspaces-m97LP4Eu.js +0 -3
package/src/branch-landed.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { emitRelayEvent } from "./events";
|
|
2
2
|
import { getNotificationsConfig } from "./config-store";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import type {
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
// On-main "merged" fan-out — a 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
|
-
}
|
package/src/channel-target.ts
CHANGED
|
@@ -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/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
|
package/src/context-advisory.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
package/src/db/message-reads.ts
CHANGED
|
@@ -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
|
package/src/db/messages.ts
CHANGED
|
@@ -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,
|
package/src/db/migrations.ts
CHANGED
|
@@ -449,6 +449,21 @@ export function applyMigrations(): void {
|
|
|
449
449
|
getDb().run("ALTER TABLE agents ADD COLUMN team_id TEXT");
|
|
450
450
|
}
|
|
451
451
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_team ON agents(team_id)");
|
|
452
|
+
getDb().run(`
|
|
453
|
+
CREATE TABLE IF NOT EXISTS notification_subscriptions (
|
|
454
|
+
id TEXT PRIMARY KEY,
|
|
455
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
456
|
+
type TEXT NOT NULL,
|
|
457
|
+
mode TEXT NOT NULL CHECK (mode IN ('opt_in', 'opt_out', 'mute')),
|
|
458
|
+
scope_match TEXT,
|
|
459
|
+
created_at INTEGER NOT NULL
|
|
460
|
+
)
|
|
461
|
+
`);
|
|
462
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_agent ON notification_subscriptions(agent_id, type)");
|
|
463
|
+
getDb().run(`
|
|
464
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_subscriptions_unique
|
|
465
|
+
ON notification_subscriptions(agent_id, type, mode, coalesce(scope_match, ''))
|
|
466
|
+
`);
|
|
452
467
|
getDb().run(`
|
|
453
468
|
CREATE TABLE IF NOT EXISTS context_snapshots (
|
|
454
469
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { isRecord } from "agent-relay-sdk";
|
|
3
|
+
import { DEFAULT_TTL_MS, type NotificationScope, type NotificationType } from "../notification-router";
|
|
4
|
+
import { setSubscriptionRules, type NotificationRule, type NotificationRuleContext, type RuleDecision } from "../notification-rules";
|
|
5
|
+
import type { AgentCard } from "../types";
|
|
6
|
+
import { parseJson } from "../utils";
|
|
7
|
+
import { getDb, ValidationError } from "./connection";
|
|
8
|
+
|
|
9
|
+
export const NOTIFICATION_SUBSCRIPTION_TYPES = ["*", ...Object.keys(DEFAULT_TTL_MS)] as Array<NotificationType | "*">;
|
|
10
|
+
export const NOTIFICATION_SUBSCRIPTION_MODES = ["opt_in", "opt_out", "mute"] as const;
|
|
11
|
+
|
|
12
|
+
export type NotificationSubscriptionType = NotificationType | "*";
|
|
13
|
+
export type NotificationSubscriptionMode = (typeof NOTIFICATION_SUBSCRIPTION_MODES)[number];
|
|
14
|
+
|
|
15
|
+
export interface NotificationSubscription {
|
|
16
|
+
id: string;
|
|
17
|
+
agentId: string;
|
|
18
|
+
type: NotificationSubscriptionType;
|
|
19
|
+
mode: NotificationSubscriptionMode;
|
|
20
|
+
scopeMatch: Partial<NotificationScope> | null;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface NotificationSubscriptionRow {
|
|
25
|
+
id: string;
|
|
26
|
+
agent_id: string;
|
|
27
|
+
type: string;
|
|
28
|
+
mode: string;
|
|
29
|
+
scope_match: string | null;
|
|
30
|
+
created_at: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SCOPE_KEYS = ["author", "agentId", "parent", "teamId", "planId", "repoRoot", "workspaceId", "paths"] as const;
|
|
34
|
+
|
|
35
|
+
export function addSubscription(input: {
|
|
36
|
+
agentId: string;
|
|
37
|
+
type: NotificationSubscriptionType;
|
|
38
|
+
mode: NotificationSubscriptionMode;
|
|
39
|
+
scopeMatch?: Partial<NotificationScope> | null;
|
|
40
|
+
now?: number;
|
|
41
|
+
}): NotificationSubscription {
|
|
42
|
+
const agentId = cleanAgentId(input.agentId);
|
|
43
|
+
const type = cleanSubscriptionType(input.type);
|
|
44
|
+
const mode = cleanSubscriptionMode(input.mode);
|
|
45
|
+
const scopeMatch = normalizeScopeMatch(input.scopeMatch);
|
|
46
|
+
const now = input.now ?? Date.now();
|
|
47
|
+
|
|
48
|
+
getDb().query(`
|
|
49
|
+
DELETE FROM notification_subscriptions
|
|
50
|
+
WHERE agent_id = ? AND type = ? AND mode = ? AND coalesce(scope_match, '') = coalesce(?, '')
|
|
51
|
+
`).run(agentId, type, mode, scopeMatch);
|
|
52
|
+
|
|
53
|
+
const id = `nsub_${randomUUID()}`;
|
|
54
|
+
getDb().query(`
|
|
55
|
+
INSERT INTO notification_subscriptions (id, agent_id, type, mode, scope_match, created_at)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
57
|
+
`).run(id, agentId, type, mode, scopeMatch, now);
|
|
58
|
+
|
|
59
|
+
return getSubscription(id)!;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function removeSubscription(input: {
|
|
63
|
+
agentId: string;
|
|
64
|
+
id?: string;
|
|
65
|
+
type?: NotificationSubscriptionType;
|
|
66
|
+
mode?: NotificationSubscriptionMode;
|
|
67
|
+
scopeMatch?: Partial<NotificationScope> | null;
|
|
68
|
+
}): number {
|
|
69
|
+
const agentId = cleanAgentId(input.agentId);
|
|
70
|
+
const conditions = ["agent_id = ?"];
|
|
71
|
+
const params: any[] = [agentId];
|
|
72
|
+
|
|
73
|
+
if (input.id) {
|
|
74
|
+
conditions.push("id = ?");
|
|
75
|
+
params.push(input.id);
|
|
76
|
+
}
|
|
77
|
+
if (input.type) {
|
|
78
|
+
conditions.push("type = ?");
|
|
79
|
+
params.push(cleanSubscriptionType(input.type));
|
|
80
|
+
}
|
|
81
|
+
if (input.mode) {
|
|
82
|
+
conditions.push("mode = ?");
|
|
83
|
+
params.push(cleanSubscriptionMode(input.mode));
|
|
84
|
+
}
|
|
85
|
+
if (Object.prototype.hasOwnProperty.call(input, "scopeMatch")) {
|
|
86
|
+
conditions.push("coalesce(scope_match, '') = coalesce(?, '')");
|
|
87
|
+
params.push(normalizeScopeMatch(input.scopeMatch));
|
|
88
|
+
}
|
|
89
|
+
if (conditions.length === 1) throw new ValidationError("id or type required");
|
|
90
|
+
|
|
91
|
+
return getDb().query(`DELETE FROM notification_subscriptions WHERE ${conditions.join(" AND ")}`).run(...params).changes;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function listSubscriptions(agentId: string): NotificationSubscription[] {
|
|
95
|
+
const rows = getDb().query(`
|
|
96
|
+
SELECT * FROM notification_subscriptions
|
|
97
|
+
WHERE agent_id = ?
|
|
98
|
+
ORDER BY created_at ASC, id ASC
|
|
99
|
+
`).all(cleanAgentId(agentId)) as NotificationSubscriptionRow[];
|
|
100
|
+
return rows.map(rowToSubscription);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function subscriptionDecisionFor(agent: AgentCard, ctx: NotificationRuleContext): RuleDecision {
|
|
104
|
+
const rows = getDb().query(`
|
|
105
|
+
SELECT * FROM notification_subscriptions
|
|
106
|
+
WHERE agent_id = ? AND (type = ? OR type = '*')
|
|
107
|
+
ORDER BY created_at ASC, id ASC
|
|
108
|
+
`).all(agent.id, ctx.type) as NotificationSubscriptionRow[];
|
|
109
|
+
|
|
110
|
+
let decision: RuleDecision = "abstain";
|
|
111
|
+
for (const row of rows) {
|
|
112
|
+
const subscription = rowToSubscription(row);
|
|
113
|
+
if (!scopeMatches(subscription.scopeMatch, ctx.scope)) continue;
|
|
114
|
+
decision = subscription.mode === "opt_in" ? "deliver" : "suppress";
|
|
115
|
+
}
|
|
116
|
+
return decision;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const subscriptionRule: NotificationRule = {
|
|
120
|
+
name: "subscription:store",
|
|
121
|
+
evaluate: subscriptionDecisionFor,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function installNotificationSubscriptionRule(): void {
|
|
125
|
+
setSubscriptionRules([subscriptionRule]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getSubscription(id: string): NotificationSubscription | null {
|
|
129
|
+
const row = getDb().query("SELECT * FROM notification_subscriptions WHERE id = ?").get(id) as NotificationSubscriptionRow | undefined;
|
|
130
|
+
return row ? rowToSubscription(row) : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function cleanSubscriptionType(value: unknown): NotificationSubscriptionType {
|
|
134
|
+
if (typeof value !== "string" || !NOTIFICATION_SUBSCRIPTION_TYPES.includes(value as NotificationSubscriptionType)) {
|
|
135
|
+
throw new ValidationError(`type must be one of: ${NOTIFICATION_SUBSCRIPTION_TYPES.join(", ")}`);
|
|
136
|
+
}
|
|
137
|
+
return value as NotificationSubscriptionType;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function cleanSubscriptionMode(value: unknown): NotificationSubscriptionMode {
|
|
141
|
+
if (typeof value !== "string" || !NOTIFICATION_SUBSCRIPTION_MODES.includes(value as NotificationSubscriptionMode)) {
|
|
142
|
+
throw new ValidationError(`mode must be one of: ${NOTIFICATION_SUBSCRIPTION_MODES.join(", ")}`);
|
|
143
|
+
}
|
|
144
|
+
return value as NotificationSubscriptionMode;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function cleanAgentId(value: unknown): string {
|
|
148
|
+
if (typeof value !== "string" || !value.trim()) throw new ValidationError("agentId required");
|
|
149
|
+
return value.trim();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeScopeMatch(scope: Partial<NotificationScope> | null | undefined): string | null {
|
|
153
|
+
if (scope === undefined || scope === null) return null;
|
|
154
|
+
if (!isRecord(scope)) throw new ValidationError("scope must be an object");
|
|
155
|
+
const normalized: Record<string, unknown> = {};
|
|
156
|
+
for (const key of SCOPE_KEYS) {
|
|
157
|
+
const value = scope[key];
|
|
158
|
+
if (value === undefined || value === null) continue;
|
|
159
|
+
if (key === "paths") {
|
|
160
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
161
|
+
throw new ValidationError("scope.paths must be an array of strings");
|
|
162
|
+
}
|
|
163
|
+
const paths = [...new Set(value.map((item) => item.trim()).filter(Boolean))].sort();
|
|
164
|
+
if (paths.length) normalized.paths = paths;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (typeof value !== "string" || !value.trim()) throw new ValidationError(`scope.${key} must be a string`);
|
|
168
|
+
normalized[key] = value.trim();
|
|
169
|
+
}
|
|
170
|
+
return Object.keys(normalized).length ? JSON.stringify(normalized) : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function rowToSubscription(row: NotificationSubscriptionRow): NotificationSubscription {
|
|
174
|
+
return {
|
|
175
|
+
id: row.id,
|
|
176
|
+
agentId: row.agent_id,
|
|
177
|
+
type: cleanSubscriptionType(row.type),
|
|
178
|
+
mode: cleanSubscriptionMode(row.mode),
|
|
179
|
+
scopeMatch: parseJson<Partial<NotificationScope> | null>(row.scope_match, null),
|
|
180
|
+
createdAt: row.created_at,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function scopeMatches(match: Partial<NotificationScope> | null, scope: NotificationScope): boolean {
|
|
185
|
+
if (!match) return true;
|
|
186
|
+
const actual = scope as Record<string, unknown>;
|
|
187
|
+
for (const [key, expected] of Object.entries(match)) {
|
|
188
|
+
const value = actual[key];
|
|
189
|
+
if (Array.isArray(expected)) {
|
|
190
|
+
if (!Array.isArray(value)) return false;
|
|
191
|
+
const actualSet = new Set(value.filter((item): item is string => typeof item === "string"));
|
|
192
|
+
if (!expected.every((item) => actualSet.has(item))) return false;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (value !== expected) return false;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
}
|
package/src/db/schema.ts
CHANGED
|
@@ -110,6 +110,17 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
110
110
|
last_seen INTEGER NOT NULL,
|
|
111
111
|
created_at INTEGER NOT NULL
|
|
112
112
|
);
|
|
113
|
+
CREATE TABLE IF NOT EXISTS notification_subscriptions (
|
|
114
|
+
id TEXT PRIMARY KEY,
|
|
115
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
116
|
+
type TEXT NOT NULL,
|
|
117
|
+
mode TEXT NOT NULL CHECK (mode IN ('opt_in', 'opt_out', 'mute')),
|
|
118
|
+
scope_match TEXT,
|
|
119
|
+
created_at INTEGER NOT NULL
|
|
120
|
+
);
|
|
121
|
+
CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_agent ON notification_subscriptions(agent_id, type);
|
|
122
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_subscriptions_unique
|
|
123
|
+
ON notification_subscriptions(agent_id, type, mode, coalesce(scope_match, ''));
|
|
113
124
|
CREATE TABLE IF NOT EXISTS messages (
|
|
114
125
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
115
126
|
from_agent TEXT NOT NULL,
|