agent-relay-server 0.91.4 → 0.93.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 +29 -1
- package/package.json +2 -2
- package/public/assets/MessageBubble-DX16Hbb5.js +3 -0
- package/public/assets/MessageBubble-DX16Hbb5.js.map +1 -0
- package/public/assets/{PlanGraphPanel-BwEOOqdj.js → PlanGraphPanel-BuWerKhs.js} +2 -2
- package/public/assets/{PlanGraphPanel-BwEOOqdj.js.map → PlanGraphPanel-BuWerKhs.js.map} +1 -1
- package/public/assets/activity-tuTEn3vn.js +2 -0
- package/public/assets/{activity-DD9bl5uf.js.map → activity-tuTEn3vn.js.map} +1 -1
- package/public/assets/{agent-profiles-CLJNeVxc.js → agent-profiles-CAwmjdDx.js} +2 -2
- package/public/assets/{agent-profiles-CLJNeVxc.js.map → agent-profiles-CAwmjdDx.js.map} +1 -1
- package/public/assets/agents-B-BSBrM5.js +2 -0
- package/public/assets/{agents-DJgqCIqi.js.map → agents-B-BSBrM5.js.map} +1 -1
- package/public/assets/{analytics-C1F28oqr.js → analytics-aI_oGHtR.js} +3 -3
- package/public/assets/{analytics-C1F28oqr.js.map → analytics-aI_oGHtR.js.map} +1 -1
- package/public/assets/{automation-Vbei8P9W.js → automation-BAQM1dBh.js} +2 -2
- package/public/assets/{automation-Vbei8P9W.js.map → automation-BAQM1dBh.js.map} +1 -1
- package/public/assets/{badge-CK6ejiOh.js → badge-fjSgjnDc.js} +2 -2
- package/public/assets/{badge-CK6ejiOh.js.map → badge-fjSgjnDc.js.map} +1 -1
- package/public/assets/branch-state-badge-DaafW7sN.js +2 -0
- package/public/assets/{branch-state-badge-Q4W68jxj.js.map → branch-state-badge-DaafW7sN.js.map} +1 -1
- package/public/assets/{button-BAwKuRIq.js → button-DidMOp8n.js} +2 -2
- package/public/assets/{button-BAwKuRIq.js.map → button-DidMOp8n.js.map} +1 -1
- package/public/assets/card-CpLnx1JJ.js +2 -0
- package/public/assets/{card-DDxxo-Fo.js.map → card-CpLnx1JJ.js.map} +1 -1
- package/public/assets/{channels-BQbNtrtF.js → channels-DHjTCcE8.js} +2 -2
- package/public/assets/{channels-BQbNtrtF.js.map → channels-DHjTCcE8.js.map} +1 -1
- package/public/assets/chat-uWKl3MpS.js +2 -0
- package/public/assets/{chat-Dw5xpc5W.js.map → chat-uWKl3MpS.js.map} +1 -1
- package/public/assets/{connectors-CmARSKoV.js → connectors-CI97PYOl.js} +2 -2
- package/public/assets/{connectors-CmARSKoV.js.map → connectors-CI97PYOl.js.map} +1 -1
- package/public/assets/copy-button-CKAwTG9g.js +2 -0
- package/public/assets/{copy-button-D3B_s7J7.js.map → copy-button-CKAwTG9g.js.map} +1 -1
- package/public/assets/display-CWMHll1J.js.map +1 -1
- package/public/assets/{dist-Dmiv1tSO.js → dist-BEpGArlE.js} +2 -2
- package/public/assets/{dist-Dmiv1tSO.js.map → dist-BEpGArlE.js.map} +1 -1
- package/public/assets/dist-BZ-CpDXy.js +2 -0
- package/public/assets/{dist-CIbvPDQV.js.map → dist-BZ-CpDXy.js.map} +1 -1
- package/public/assets/{dist-DLsSqk6h.js → dist-BaDWyAAG.js} +2 -2
- package/public/assets/{dist-DLsSqk6h.js.map → dist-BaDWyAAG.js.map} +1 -1
- package/public/assets/{dist-CAFUEpK2.js → dist-BivRSJg7.js} +2 -2
- package/public/assets/{dist-CAFUEpK2.js.map → dist-BivRSJg7.js.map} +1 -1
- package/public/assets/{dist-CL2npVDe.js → dist-C9ntDbUp.js} +2 -2
- package/public/assets/{dist-CL2npVDe.js.map → dist-C9ntDbUp.js.map} +1 -1
- package/public/assets/dist-CEb4q1gZ.js +2 -0
- package/public/assets/{dist-DyqEeyBI.js.map → dist-CEb4q1gZ.js.map} +1 -1
- package/public/assets/dist-DBDYIgS5.js +2 -0
- package/public/assets/{dist-CoRABSO3.js.map → dist-DBDYIgS5.js.map} +1 -1
- package/public/assets/{dist-ERYvDMdt.js → dist-DNxK5Ibj.js} +2 -2
- package/public/assets/{dist-ERYvDMdt.js.map → dist-DNxK5Ibj.js.map} +1 -1
- package/public/assets/{dist-YjP8Gd1C.js → dist-DayffsX1.js} +2 -2
- package/public/assets/{dist-YjP8Gd1C.js.map → dist-DayffsX1.js.map} +1 -1
- package/public/assets/{dist-YQVQrtnc.js → dist-L3vQoV3T.js} +2 -2
- package/public/assets/{dist-YQVQrtnc.js.map → dist-L3vQoV3T.js.map} +1 -1
- package/public/assets/{dist-CnRv0y2x.js → dist-uFOP-AfS.js} +2 -2
- package/public/assets/{dist-CnRv0y2x.js.map → dist-uFOP-AfS.js.map} +1 -1
- package/public/assets/{es2015-Yto-4EBw.js → es2015-CJr6bLcU.js} +2 -2
- package/public/assets/{es2015-Yto-4EBw.js.map → es2015-CJr6bLcU.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BisZ0FtO.js → formatted-body-impl-DuqZunxL.js} +2 -2
- package/public/assets/{formatted-body-impl-BisZ0FtO.js.map → formatted-body-impl-DuqZunxL.js.map} +1 -1
- package/public/assets/index-DLjkMB-b.css +2 -0
- package/public/assets/index-dMB8klzy.js +23 -0
- package/public/assets/index-dMB8klzy.js.map +1 -0
- package/public/assets/{input-CLW2gDUM.js → input-BbH2Jq-f.js} +2 -2
- package/public/assets/{input-CLW2gDUM.js.map → input-BbH2Jq-f.js.map} +1 -1
- package/public/assets/{insights-D9ytYZyE.js → insights-C0lZ7n8D.js} +2 -2
- package/public/assets/{insights-D9ytYZyE.js.map → insights-C0lZ7n8D.js.map} +1 -1
- package/public/assets/integrations-DXh00Ss-.js +2 -0
- package/public/assets/{integrations-D9Cx-7ma.js.map → integrations-DXh00Ss-.js.map} +1 -1
- package/public/assets/{lib-DdCM7GfN.js → lib-B0r9alsu.js} +2 -2
- package/public/assets/{lib-DdCM7GfN.js.map → lib-B0r9alsu.js.map} +1 -1
- package/public/assets/{lucide-react-CsPXvXMC.js → lucide-react-BCarrGVa.js} +2 -2
- package/public/assets/{lucide-react-CsPXvXMC.js.map → lucide-react-BCarrGVa.js.map} +1 -1
- package/public/assets/{maintenance-BA93FvLR.js → maintenance-Dh9nzjYV.js} +2 -2
- package/public/assets/{maintenance-BA93FvLR.js.map → maintenance-Dh9nzjYV.js.map} +1 -1
- package/public/assets/{managed-agents-DGwdEIzA.js → managed-agents-Bsveljn4.js} +4 -4
- package/public/assets/{managed-agents-DGwdEIzA.js.map → managed-agents-Bsveljn4.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-C7FDXM5c.js → markdown-preview-impl-CYpMFis9.js} +2 -2
- package/public/assets/{markdown-preview-impl-C7FDXM5c.js.map → markdown-preview-impl-CYpMFis9.js.map} +1 -1
- package/public/assets/{memory-CHzkRJqf.js → memory-DMFQMXrp.js} +2 -2
- package/public/assets/{memory-CHzkRJqf.js.map → memory-DMFQMXrp.js.map} +1 -1
- package/public/assets/messages-Cnzc7j50.js +2 -0
- package/public/assets/{messages-B7CJ5tgT.js.map → messages-Cnzc7j50.js.map} +1 -1
- package/public/assets/{orchestrators-DOy9xIx2.js → orchestrators-DSoDPNVS.js} +2 -2
- package/public/assets/{orchestrators-DOy9xIx2.js.map → orchestrators-DSoDPNVS.js.map} +1 -1
- package/public/assets/{overview-DLbv1BWa.js → overview-OsRxo5XQ.js} +2 -2
- package/public/assets/{overview-DLbv1BWa.js.map → overview-OsRxo5XQ.js.map} +1 -1
- package/public/assets/{pairs-BDQo-k3F.js → pairs-D4UiUhyW.js} +2 -2
- package/public/assets/{pairs-BDQo-k3F.js.map → pairs-D4UiUhyW.js.map} +1 -1
- package/public/assets/{projects-hU5KGW_T.js → projects-CYzU-Cf3.js} +2 -2
- package/public/assets/{projects-hU5KGW_T.js.map → projects-CYzU-Cf3.js.map} +1 -1
- package/public/assets/{react-dom-CEl58oxP.js → react-dom-CYpnckci.js} +2 -2
- package/public/assets/{react-dom-CEl58oxP.js.map → react-dom-CYpnckci.js.map} +1 -1
- package/public/assets/{security-9WrCcrWs.js → security-CC2F6xdd.js} +2 -2
- package/public/assets/{security-9WrCcrWs.js.map → security-CC2F6xdd.js.map} +1 -1
- package/public/assets/{select-CRjeGZ9a.js → select-CD38f1WQ.js} +2 -2
- package/public/assets/{select-CRjeGZ9a.js.map → select-CD38f1WQ.js.map} +1 -1
- package/public/assets/settings-T3B40Amb.js +6 -0
- package/public/assets/settings-T3B40Amb.js.map +1 -0
- package/public/assets/{store-DsqUEZOf.js → store-CJeafbP1.js} +3 -3
- package/public/assets/{store-DsqUEZOf.js.map → store-CJeafbP1.js.map} +1 -1
- package/public/assets/{tasks-86St2vpn.js → tasks-BkrhWMw7.js} +2 -2
- package/public/assets/{tasks-86St2vpn.js.map → tasks-BkrhWMw7.js.map} +1 -1
- package/public/assets/{teams-C9WUxzud.js → teams-DyzaREAg.js} +2 -2
- package/public/assets/{teams-C9WUxzud.js.map → teams-DyzaREAg.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-yXWyt2fm.js → terminal-viewer-impl-B5JeHof3.js} +3 -3
- package/public/assets/{terminal-viewer-impl-yXWyt2fm.js.map → terminal-viewer-impl-B5JeHof3.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-DABeIMEJ.js → use-keyboard-viewport-BYCJkZCN.js} +2 -2
- package/public/assets/{use-keyboard-viewport-DABeIMEJ.js.map → use-keyboard-viewport-BYCJkZCN.js.map} +1 -1
- package/public/assets/user-avatar-C7i11_ju.js +2 -0
- package/public/assets/{user-avatar-tomKla2H.js.map → user-avatar-C7i11_ju.js.map} +1 -1
- package/public/assets/{work-queue-BtLMzf0g.js → work-queue-DHp4p1Zk.js} +2 -2
- package/public/assets/{work-queue-BtLMzf0g.js.map → work-queue-DHp4p1Zk.js.map} +1 -1
- package/public/assets/workspaces-BzwZvbsq.js +3 -0
- package/public/assets/{workspaces-C740m1ys.js.map → workspaces-BzwZvbsq.js.map} +1 -1
- package/public/index.html +21 -21
- package/src/agent-lifecycle-events.ts +0 -10
- package/src/branch-landed.ts +17 -29
- package/src/bus.ts +9 -2
- package/src/config-store.ts +13 -26
- package/src/config.ts +1 -0
- package/src/context-advisory.ts +20 -10
- package/src/db/agent-predicates.ts +21 -0
- package/src/db/agents.ts +3 -3
- package/src/db/delivery.ts +38 -0
- package/src/db/messages.ts +18 -1
- package/src/db/migrations.ts +17 -0
- package/src/db/scheduled-timers.ts +33 -12
- package/src/db/schema.ts +4 -0
- package/src/db/workspaces.ts +44 -2
- package/src/maintenance/constants.ts +2 -0
- package/src/maintenance/jobs.ts +21 -8
- package/src/maintenance/workspaces.ts +76 -0
- package/src/mcp/index.ts +1 -1
- package/src/mcp/tools-messaging.ts +13 -3
- package/src/mcp/tools-spawn.ts +30 -2
- package/src/mcp-notification-subscription-tools.ts +55 -2
- package/src/notification-router.ts +163 -9
- package/src/notification-rules.ts +7 -1
- package/src/notification-settings-store.ts +47 -0
- package/src/notification-settings.ts +155 -0
- package/src/notification-types.ts +2 -0
- package/src/quota-advisory.ts +0 -3
- package/src/routes/agents-spawn.ts +15 -2
- package/src/routes/agents.ts +17 -2
- package/src/routes/commands.ts +5 -0
- package/src/routes/index.ts +2 -0
- package/src/routes/notifications.ts +36 -0
- package/src/routes/settings.ts +6 -0
- package/src/security.ts +1 -0
- package/src/services/managed-running.ts +18 -1
- package/src/services/rate-limit-resume.ts +3 -0
- package/src/services/register-agent.ts +7 -1
- package/src/services/scheduler.ts +29 -1
- package/src/services/spawn-agent.ts +62 -2
- package/src/settings/registry.ts +30 -44
- package/src/spawn-command.ts +10 -0
- package/src/sse.ts +1 -0
- package/src/validation.ts +7 -2
- package/src/workspace-auto-merge.ts +53 -9
- package/public/assets/MessageBubble-BORB9AKu.js +0 -3
- package/public/assets/MessageBubble-BORB9AKu.js.map +0 -1
- package/public/assets/activity-DD9bl5uf.js +0 -2
- package/public/assets/agents-DJgqCIqi.js +0 -2
- package/public/assets/branch-state-badge-Q4W68jxj.js +0 -2
- package/public/assets/card-DDxxo-Fo.js +0 -2
- package/public/assets/chat-Dw5xpc5W.js +0 -2
- package/public/assets/copy-button-D3B_s7J7.js +0 -2
- package/public/assets/dist-CIbvPDQV.js +0 -2
- package/public/assets/dist-CoRABSO3.js +0 -2
- package/public/assets/dist-DyqEeyBI.js +0 -2
- package/public/assets/index-Bsi2NH0v.js +0 -23
- package/public/assets/index-Bsi2NH0v.js.map +0 -1
- package/public/assets/index-umexz3cs.css +0 -2
- package/public/assets/integrations-D9Cx-7ma.js +0 -2
- package/public/assets/messages-B7CJ5tgT.js +0 -2
- package/public/assets/settings-Durch6xW.js +0 -4
- package/public/assets/settings-Durch6xW.js.map +0 -1
- package/public/assets/user-avatar-tomKla2H.js +0 -2
- package/public/assets/workspaces-C740m1ys.js +0 -3
package/src/branch-landed.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { errMessage } from "agent-relay-sdk";
|
|
2
2
|
import { emitRelayEvent } from "./events";
|
|
3
|
-
import { getNotificationsConfig } from "./config-store";
|
|
4
3
|
import { routeNotification, INFORMATIONAL_TTL_MS } from "./notification-router";
|
|
5
|
-
import { registerDefaultAudienceRules,
|
|
4
|
+
import { registerDefaultAudienceRules, audiencePredicates } from "./notification-rules";
|
|
6
5
|
import { createActivityEvent, getAgent, updateWorkspaceStatus } from "./db";
|
|
7
6
|
import { handleLandedIssueLifecycle } from "./services/issue-lifecycle";
|
|
8
7
|
import type { WorkspaceMergePreview, WorkspaceRecord } from "./types";
|
|
@@ -135,9 +134,6 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
|
|
|
135
134
|
console.warn(`issue lifecycle land hook failed for ${workspace.id}: ${errMessage(error)}`);
|
|
136
135
|
});
|
|
137
136
|
|
|
138
|
-
const config = getNotificationsConfig();
|
|
139
|
-
if (!config.enabled || !config.branchLanded) return;
|
|
140
|
-
|
|
141
137
|
const author = workspace.ownerAgentId;
|
|
142
138
|
const shaLabel = shortSha ? ` as \`${shortSha}\`` : "";
|
|
143
139
|
const subjectLabel = input.subject ? ` — "${input.subject}"` : "";
|
|
@@ -186,25 +182,22 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
|
|
|
186
182
|
// online agents whose cwd is the main checkout, author-excluded, team-scoped. Empty for a solo
|
|
187
183
|
// land with no other main agents → no-op.
|
|
188
184
|
const mainScope = { author, repoRoot: workspace.repoRoot, workspaceId: workspace.id, teamId: author ? getAgent(author)?.teamId : undefined };
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
},
|
|
206
|
-
});
|
|
207
|
-
}
|
|
185
|
+
const branchLabel = landedBranch ? `\`${landedBranch}\`` : "A branch";
|
|
186
|
+
const authorLabel = author ? ` by \`${author}\`` : "";
|
|
187
|
+
// Bystander fan-out (ttl informational): a "merged" notice is only useful while fresh — a
|
|
188
|
+
// late-joining main agent shouldn't be woken by an hours-old land. Omit explicit recipients so
|
|
189
|
+
// the notification router records the full #442 rule decision for #446's routed-because trace.
|
|
190
|
+
routeNotification({
|
|
191
|
+
type: "branch.landed",
|
|
192
|
+
scope: mainScope,
|
|
193
|
+
ttlMs: INFORMATIONAL_TTL_MS,
|
|
194
|
+
message: {
|
|
195
|
+
subject: `Merged to ${base}`,
|
|
196
|
+
body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
|
|
197
|
+
payload: { ...payload, notification: "landed-success" },
|
|
198
|
+
replyExpected: false,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
208
201
|
}
|
|
209
202
|
|
|
210
203
|
/**
|
|
@@ -240,8 +233,6 @@ export function notifyBranchLandFailed(input: {
|
|
|
240
233
|
});
|
|
241
234
|
|
|
242
235
|
if (!author) return;
|
|
243
|
-
const config = getNotificationsConfig();
|
|
244
|
-
if (!config.enabled || !config.branchLanded) return;
|
|
245
236
|
const branchLabel = workspace.branch ? `\`${workspace.branch}\`` : "Your branch";
|
|
246
237
|
routeNotification({
|
|
247
238
|
type: "landed-failure",
|
|
@@ -298,9 +289,6 @@ export function notifyStewardHandoff(input: {
|
|
|
298
289
|
|
|
299
290
|
emitRelayEvent({ type: "workspace.steward_handoff", source: "server", subject: workspace.id, data: payload });
|
|
300
291
|
|
|
301
|
-
const config = getNotificationsConfig();
|
|
302
|
-
if (!config.enabled) return;
|
|
303
|
-
|
|
304
292
|
// #641 — tell the owner branch agent which steward now holds its merge, so it stops
|
|
305
293
|
// thinking the branch silently failed. Durable (ttl null): the owner must learn this
|
|
306
294
|
// even if it went idle. Mirror the "don't touch main" guardrail of the landed-failure wake.
|
package/src/bus.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { projectAgentStatusData } from "./agent-card-projection";
|
|
|
5
5
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
6
6
|
import { getCommand, updateCommand } from "./commands-db";
|
|
7
7
|
import { emitCommandEvent } from "./command-events";
|
|
8
|
-
import { getLifecycleManager } from "./lifecycle-manager";
|
|
9
8
|
import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
|
|
10
9
|
import { reconcileTerminalProviderExit } from "./services/terminal-provider-exit";
|
|
11
10
|
import { registerAgent } from "./services/register-agent";
|
|
@@ -14,6 +13,8 @@ import { commandAuthorizationResource, dispatchCommand } from "./services/dispat
|
|
|
14
13
|
import { auditProviderStateTransition } from "./services/provider-state-audit";
|
|
15
14
|
import { ServiceAuthError } from "./services/errors";
|
|
16
15
|
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
|
|
16
|
+
import { flushQueuedDirectMessages } from "./services/managed-running";
|
|
17
|
+
import { isAgentUnavailable } from "./db/agent-predicates";
|
|
17
18
|
import { applyCommandToRecipe } from "./recipe-runner";
|
|
18
19
|
import { enqueueContinuationInjectionAfterSelfResume } from "./self-resume";
|
|
19
20
|
import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
|
|
@@ -121,7 +122,6 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs()): { agentIds: s
|
|
|
121
122
|
revokeRuntimeTokensForAgent(row.id, Date.now(), "stale");
|
|
122
123
|
setStatus(row.id, "offline");
|
|
123
124
|
emitAgentStatusEvent(row.id);
|
|
124
|
-
getLifecycleManager().onAgentDisappeared(row.id);
|
|
125
125
|
const tasks = orphanTasksForAgent(row.id);
|
|
126
126
|
for (const task of tasks) {
|
|
127
127
|
orphanedTasks.push(task);
|
|
@@ -152,10 +152,16 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
152
152
|
const current = getAgent(conn.agentId);
|
|
153
153
|
const heldOffline = current?.status === "offline" && isRecord(current.meta?.terminalProviderExit);
|
|
154
154
|
if (!heldOffline) {
|
|
155
|
+
const wasUnavailable = current ? isAgentUnavailable(current) : false;
|
|
155
156
|
const runtime = runtimeFromMeta(frame.payload.meta);
|
|
156
157
|
heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
|
|
157
158
|
if (frame.payload.status) setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
158
159
|
emitAgentStatusEvent(conn.agentId);
|
|
160
|
+
// #416 — flush queued messages if the agent transitioned from unavailable to available.
|
|
161
|
+
if (wasUnavailable) {
|
|
162
|
+
const after = getAgent(conn.agentId);
|
|
163
|
+
if (after && !isAgentUnavailable(after)) flushQueuedDirectMessages(conn.agentId);
|
|
164
|
+
}
|
|
159
165
|
}
|
|
160
166
|
}
|
|
161
167
|
if (frame.id) send(ws, { type: "ack", payload: { frameId: frame.id } });
|
|
@@ -652,6 +658,7 @@ function isContextLifecycleState(value: unknown): value is ContextState["lifecyc
|
|
|
652
658
|
value === "working" ||
|
|
653
659
|
value === "cooling" ||
|
|
654
660
|
value === "compacting" ||
|
|
661
|
+
value === "clearing-context" ||
|
|
655
662
|
value === "hibernating";
|
|
656
663
|
}
|
|
657
664
|
|
package/src/config-store.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { getDb, ValidationError } from "./db";
|
|
2
2
|
import { cleanEnum, cleanString, cleanStringArray } from "./validation";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
NOTIFICATIONS_CONFIG_DEFAULTS,
|
|
5
|
+
NOTIFICATIONS_DEFAULT_KEY,
|
|
6
|
+
NOTIFICATIONS_NAMESPACE,
|
|
7
|
+
parseNotificationSettingsKey,
|
|
8
|
+
validateNotificationSettingsLayer,
|
|
9
|
+
validateNotificationsConfig,
|
|
10
|
+
} from "./notification-settings";
|
|
5
11
|
import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
|
|
6
12
|
import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
|
|
7
13
|
import { PROJECT_INSTRUCTION_POLICY_NAMES } from "./project-instructions";
|
|
@@ -36,8 +42,6 @@ const STEWARD_NAMESPACE = "steward";
|
|
|
36
42
|
const STEWARD_KEY = "default";
|
|
37
43
|
const INSIGHTS_NAMESPACE = "insights";
|
|
38
44
|
const INSIGHTS_KEY = "default";
|
|
39
|
-
const NOTIFICATIONS_NAMESPACE = "notifications";
|
|
40
|
-
const NOTIFICATIONS_KEY = "default";
|
|
41
45
|
const SELF_RESUME_NAMESPACE = "self-resume";
|
|
42
46
|
const SELF_RESUME_KEY = "default";
|
|
43
47
|
const WORKSPACE_NAMESPACE = "workspace";
|
|
@@ -549,26 +553,6 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
|
|
|
549
553
|
};
|
|
550
554
|
}
|
|
551
555
|
|
|
552
|
-
// Relay-driven lifecycle push notifications (#239 event bus). Default-on; the
|
|
553
|
-
// operator can flip the master switch or individual events off via the generic
|
|
554
|
-
// config route. Push messages wake recipients, so they must be suppressible.
|
|
555
|
-
const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = { enabled: true, branchLanded: true, agentReady: true, agentExited: true, agentSpawnFailed: true, contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG, quotaThreshold: DEFAULT_QUOTA_THRESHOLD_CONFIG };
|
|
556
|
-
|
|
557
|
-
function validateNotificationsConfig(value: unknown): NotificationsConfig {
|
|
558
|
-
if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
|
|
559
|
-
const bool = (key: "enabled" | "branchLanded" | "agentReady" | "agentExited" | "agentSpawnFailed"): boolean =>
|
|
560
|
-
value[key] === undefined ? NOTIFICATIONS_CONFIG_DEFAULTS[key] : cleanBoolean(value[key], key);
|
|
561
|
-
return {
|
|
562
|
-
enabled: bool("enabled"),
|
|
563
|
-
branchLanded: bool("branchLanded"),
|
|
564
|
-
agentReady: bool("agentReady"),
|
|
565
|
-
agentExited: bool("agentExited"),
|
|
566
|
-
agentSpawnFailed: bool("agentSpawnFailed"),
|
|
567
|
-
contextThreshold: validateContextThresholdConfig(value.contextThreshold),
|
|
568
|
-
quotaThreshold: validateQuotaThresholdConfig(value.quotaThreshold),
|
|
569
|
-
};
|
|
570
|
-
}
|
|
571
|
-
|
|
572
556
|
// Global workspace provisioning config for isolated worktrees (#159 follow-up).
|
|
573
557
|
// Defaults seed the two untracked paths an isolated agent almost always needs:
|
|
574
558
|
// the agent guide and the rig config, both gitignored so a fresh worktree lacks them.
|
|
@@ -609,7 +593,10 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
|
|
|
609
593
|
if (namespace === AGENT_PROFILE_NAMESPACE) return validateAgentProfile(key, value);
|
|
610
594
|
if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
|
|
611
595
|
if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
|
|
612
|
-
if (namespace === NOTIFICATIONS_NAMESPACE)
|
|
596
|
+
if (namespace === NOTIFICATIONS_NAMESPACE) {
|
|
597
|
+
if (!parseNotificationSettingsKey(key)) throw new ValidationError("notifications config key must be default, team:<teamId>, or agent:<agentId>");
|
|
598
|
+
return key === NOTIFICATIONS_DEFAULT_KEY ? validateNotificationsConfig(value) : validateNotificationSettingsLayer(value);
|
|
599
|
+
}
|
|
613
600
|
if (namespace === SELF_RESUME_NAMESPACE) return validateSelfResumeConfig(value);
|
|
614
601
|
if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
|
|
615
602
|
if (namespace === LANDING_NAMESPACE) return validateLandingConfig(value);
|
|
@@ -748,7 +735,7 @@ export function getInsightsConfigEntry(): ConfigEntry<InsightsConfig> {
|
|
|
748
735
|
|
|
749
736
|
/** Lifecycle-notification config (#239), merged over defaults (always usable). */
|
|
750
737
|
export function getNotificationsConfig(): NotificationsConfig {
|
|
751
|
-
const entry = getConfig<Partial<NotificationsConfig>>(NOTIFICATIONS_NAMESPACE,
|
|
738
|
+
const entry = getConfig<Partial<NotificationsConfig>>(NOTIFICATIONS_NAMESPACE, NOTIFICATIONS_DEFAULT_KEY);
|
|
752
739
|
if (!entry) return { ...NOTIFICATIONS_CONFIG_DEFAULTS };
|
|
753
740
|
return validateNotificationsConfig({ ...NOTIFICATIONS_CONFIG_DEFAULTS, ...entry.value });
|
|
754
741
|
}
|
package/src/config.ts
CHANGED
|
@@ -154,6 +154,7 @@ export const WORKSPACE_REVIEW_TTL_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE
|
|
|
154
154
|
export const WORKSPACE_GC_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_GC_INTERVAL_MS", 60 * 60 * 1000);
|
|
155
155
|
export const WORKSPACE_ORPHAN_REAPER_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_ORPHAN_REAPER_INTERVAL_MS", 30 * 60 * 1000);
|
|
156
156
|
export const WORKSPACE_AUTO_MERGE_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_AUTO_MERGE_INTERVAL_MS", CONFLICT_SCAN_INTERVAL_MS);
|
|
157
|
+
export const WORKSPACE_IDLE_REFRESH_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_IDLE_REFRESH_INTERVAL_MS", CONFLICT_SCAN_INTERVAL_MS);
|
|
157
158
|
export const ORPHAN_REAPER_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_ORPHAN_REAPER_INTERVAL_MS", 5 * 60 * 1000);
|
|
158
159
|
export const STEWARD_WAKE_COOLDOWN_MS = envNumberOrDefault("AGENT_RELAY_STEWARD_WAKE_COOLDOWN_MS", 10 * 60 * 1000);
|
|
159
160
|
export const UNLANDED_FLAG_COOLDOWN_MS = envNumberOrDefault("AGENT_RELAY_ORPHAN_FLAG_COOLDOWN_MS", 6 * 60 * 60 * 1000);
|
package/src/context-advisory.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { deriveContextThresholdFromWindowTokens, findProviderCatalogModel, resolveProviderContextThreshold, type ProviderModelCatalogEntry, type ResolvedProviderContextThreshold } from "agent-relay-sdk/provider-catalog";
|
|
2
2
|
import { isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
|
|
3
|
-
import { getConfig
|
|
4
|
-
import {
|
|
3
|
+
import { getConfig } from "./config-store";
|
|
4
|
+
import { validateContextThresholdConfig } from "./context-threshold-config";
|
|
5
|
+
import { getResolvedNotificationsConfig } from "./notification-settings-store";
|
|
6
|
+
import { NOTIFICATIONS_DEFAULT_KEY, NOTIFICATIONS_NAMESPACE, notificationAgentKey, notificationTeamKey } from "./notification-settings";
|
|
5
7
|
import { getDb } from "./db/connection.ts";
|
|
6
8
|
import { emitRelayEvent } from "./events";
|
|
7
9
|
import { routeNotification } from "./notification-router";
|
|
@@ -53,9 +55,6 @@ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
|
|
|
53
55
|
data: payload,
|
|
54
56
|
});
|
|
55
57
|
|
|
56
|
-
const config = getNotificationsConfig();
|
|
57
|
-
if (!config.enabled || !config.contextThreshold.enabled) return;
|
|
58
|
-
|
|
59
58
|
// Short default TTL (per-type): a stale context advisory is misleading — the band may have moved
|
|
60
59
|
// by the time the agent next polls, so old advice shouldn't wake it.
|
|
61
60
|
routeNotification({
|
|
@@ -140,15 +139,14 @@ function effectiveContextThreshold(agent: AgentCard): EffectiveContextThreshold
|
|
|
140
139
|
if (model?.contextThreshold) {
|
|
141
140
|
return { ...resolveProviderContextThreshold(model), source: "model" };
|
|
142
141
|
}
|
|
143
|
-
const global = explicitContextThresholdConfig();
|
|
142
|
+
const global = explicitContextThresholdConfig(agent);
|
|
144
143
|
if (global) return global;
|
|
145
144
|
return { ...deriveContextThresholdFromWindowTokens(windowTokens), source: "window" };
|
|
146
145
|
}
|
|
147
146
|
|
|
148
|
-
function explicitContextThresholdConfig(): EffectiveContextThreshold | undefined {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const global = validateContextThresholdConfig({ ...DEFAULT_CONTEXT_THRESHOLD_CONFIG, ...entry.value.contextThreshold });
|
|
147
|
+
function explicitContextThresholdConfig(agent: AgentCard): EffectiveContextThreshold | undefined {
|
|
148
|
+
if (!hasExplicitContextThreshold(agent)) return undefined;
|
|
149
|
+
const global = validateContextThresholdConfig(getResolvedNotificationsConfig({ agentId: agent.id, teamId: agent.teamId }).contextThreshold);
|
|
152
150
|
const selfCompactUtilization = global.selfCompactUtilization ?? global.advisoryUtilization;
|
|
153
151
|
return {
|
|
154
152
|
selfCompactUtilization,
|
|
@@ -158,6 +156,18 @@ function explicitContextThresholdConfig(): EffectiveContextThreshold | undefined
|
|
|
158
156
|
};
|
|
159
157
|
}
|
|
160
158
|
|
|
159
|
+
function hasExplicitContextThreshold(agent: AgentCard): boolean {
|
|
160
|
+
const keys = [
|
|
161
|
+
NOTIFICATIONS_DEFAULT_KEY,
|
|
162
|
+
...(agent.teamId ? [notificationTeamKey(agent.teamId)] : []),
|
|
163
|
+
notificationAgentKey(agent.id),
|
|
164
|
+
];
|
|
165
|
+
return keys.some((key) => {
|
|
166
|
+
const entry = getConfig(NOTIFICATIONS_NAMESPACE, key);
|
|
167
|
+
return isRecord(entry?.value) && isRecord(entry.value.contextThreshold);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
161
171
|
function findAgentCatalogModel(agent: AgentCard, observedWindowTokens: number | undefined): ProviderModelCatalogEntry | undefined {
|
|
162
172
|
const runtime = agent.providerCapabilities?.model;
|
|
163
173
|
const provider = runtime?.provider;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isProviderBlocked } from "../provider-state.ts";
|
|
1
2
|
import type { AgentCard } from "../types";
|
|
2
3
|
|
|
3
4
|
export function isDeliveryAgent(agent: AgentCard): boolean {
|
|
@@ -7,3 +8,23 @@ export function isDeliveryAgent(agent: AgentCard): boolean {
|
|
|
7
8
|
agent.kind !== "channel" &&
|
|
8
9
|
agent.meta?.kind !== "channel";
|
|
9
10
|
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* True when the agent is registered and known to the relay but is currently in a state
|
|
14
|
+
* where it cannot immediately process new messages: stale (missed heartbeats), provider-
|
|
15
|
+
* blocked (rate-limited, API error hold), compacting/clearing context, or registered but
|
|
16
|
+
* not yet ready (starting up).
|
|
17
|
+
*
|
|
18
|
+
* Messages sent to an unavailable agent are stored as `delivery_status = 'queued'` and
|
|
19
|
+
* flushed when the agent transitions back to available (heartbeat, re-registration, or
|
|
20
|
+
* rate-limit-resume). This is distinct from `status === "offline"` which uses the existing
|
|
21
|
+
* store-ahead path for unknown/deleted targets.
|
|
22
|
+
*/
|
|
23
|
+
export function isAgentUnavailable(agent: AgentCard): boolean {
|
|
24
|
+
if (agent.status === "stale") return true;
|
|
25
|
+
if (isProviderBlocked(agent)) return true;
|
|
26
|
+
const lcs = agent.context?.lifecycleState;
|
|
27
|
+
if (lcs === "compacting" || lcs === "clearing-context") return true;
|
|
28
|
+
if (!agent.ready && agent.status !== "offline") return true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
package/src/db/agents.ts
CHANGED
|
@@ -395,7 +395,7 @@ export function heartbeat(
|
|
|
395
395
|
.query(`
|
|
396
396
|
UPDATE agents SET
|
|
397
397
|
last_seen = ?,
|
|
398
|
-
status = CASE WHEN status
|
|
398
|
+
status = CASE WHEN status IN ('offline', 'stale') THEN 'idle' ELSE status END,
|
|
399
399
|
provider_capabilities = coalesce(?, provider_capabilities),
|
|
400
400
|
context_state = coalesce(?, context_state),
|
|
401
401
|
quota_state = coalesce(?, quota_state)
|
|
@@ -412,7 +412,7 @@ export function heartbeat(
|
|
|
412
412
|
return result.changes > 0;
|
|
413
413
|
}
|
|
414
414
|
const result = getDb()
|
|
415
|
-
.query("UPDATE agents SET last_seen = ?, status = CASE WHEN status
|
|
415
|
+
.query("UPDATE agents SET last_seen = ?, status = CASE WHEN status IN ('offline', 'stale') THEN 'idle' ELSE status END WHERE id = ?")
|
|
416
416
|
.run(now, id);
|
|
417
417
|
return result.changes > 0;
|
|
418
418
|
}
|
|
@@ -495,7 +495,7 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
|
|
|
495
495
|
getDb().query("UPDATE agents SET last_seen = ? WHERE id IN ('user', 'system')").run(now);
|
|
496
496
|
const rows = getDb()
|
|
497
497
|
.query(
|
|
498
|
-
"UPDATE agents SET status = '
|
|
498
|
+
"UPDATE agents SET status = 'stale', ready = 0 WHERE status NOT IN ('offline', 'stale') AND last_seen < ? AND id NOT IN ('user', 'system') RETURNING id"
|
|
499
499
|
)
|
|
500
500
|
.all(cutoff) as any[];
|
|
501
501
|
for (const row of rows) {
|
package/src/db/delivery.ts
CHANGED
|
@@ -413,6 +413,44 @@ export function resolveQueuedPolicyMessages(policyName: string, agentId: string)
|
|
|
413
413
|
return ids.map((id) => getMessage(id)).filter((message): message is Message => Boolean(message));
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Flush queued messages addressed directly to `agentId` (not via a policy target): transition
|
|
418
|
+
* their `delivery_status` from `queued` to `pending` and stamp `resolved_to_agent` so the agent
|
|
419
|
+
* picks them up on its next poll. Ordered by `queued_at ASC, id ASC` (FIFO). Parallel to
|
|
420
|
+
* `resolveQueuedPolicyMessages`, which handles `policy:xxx` targets.
|
|
421
|
+
*
|
|
422
|
+
* Returns the flushed messages so callers can emit `message.available` / `message.new` /
|
|
423
|
+
* `message.delivery_updated` events.
|
|
424
|
+
*/
|
|
425
|
+
export function resolveQueuedDirectMessages(agentId: string): Message[] {
|
|
426
|
+
const rows = getDb().query(`
|
|
427
|
+
SELECT m.id
|
|
428
|
+
FROM messages m
|
|
429
|
+
WHERE m.to_target = ?
|
|
430
|
+
AND m.delivery_status = 'queued'
|
|
431
|
+
ORDER BY COALESCE(m.queued_at, m.created_at) ASC, m.id ASC
|
|
432
|
+
`).all(agentId) as Array<{ id: number }>;
|
|
433
|
+
if (rows.length === 0) return [];
|
|
434
|
+
const ids = rows.map((row) => row.id);
|
|
435
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
436
|
+
const now = Date.now();
|
|
437
|
+
getDb().query(`
|
|
438
|
+
UPDATE messages
|
|
439
|
+
SET delivery_status = 'pending',
|
|
440
|
+
resolved_to_agent = ?,
|
|
441
|
+
delivery_attempts = delivery_attempts + 1,
|
|
442
|
+
delivery_last_error = NULL,
|
|
443
|
+
delivery_next_retry_at = NULL,
|
|
444
|
+
delivery_poison_reason = NULL,
|
|
445
|
+
delivery_updated_at = ?
|
|
446
|
+
WHERE id IN (${placeholders})
|
|
447
|
+
`).run(agentId, now, ...ids);
|
|
448
|
+
for (const id of ids) {
|
|
449
|
+
insertMessageDeliveryAttempt(id, { agentId, status: "pending" }, now);
|
|
450
|
+
}
|
|
451
|
+
return ids.map((id) => getMessage(id)).filter((message): message is Message => Boolean(message));
|
|
452
|
+
}
|
|
453
|
+
|
|
416
454
|
export function expireQueuedMessages(now: number = Date.now()): Message[] {
|
|
417
455
|
const rows = getDb().query(`
|
|
418
456
|
SELECT id FROM messages
|
package/src/db/messages.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { getAgent, validateAgentSession } from "./agents.ts";
|
|
|
19
19
|
import { cleanAttachmentRefs, linkAttachmentRefs, validateAttachmentRefs } from "./artifacts.ts";
|
|
20
20
|
import { ClaimError, ValidationError, getDb } from "./connection.ts";
|
|
21
21
|
import { enforceQueueLimit, isChannelAgentId, matchingDeliveryAgents } from "./delivery.ts";
|
|
22
|
+
import { isAgentUnavailable } from "./agent-predicates.ts";
|
|
22
23
|
import { MSG_SELECT, normalizeReactionEmoji, rowToMessage } from "./mappers.ts";
|
|
23
24
|
import { claimMessageRow } from "./message-claims.ts";
|
|
24
25
|
import { getMessage } from "./message-queries.ts";
|
|
@@ -469,6 +470,19 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
469
470
|
queuedAt = now;
|
|
470
471
|
maxAgeSeconds = maxAgeSeconds ?? 86_400;
|
|
471
472
|
}
|
|
473
|
+
} else if (!isFanoutTarget(input.to) && !isMechanicalMessageKind(input.kind)) {
|
|
474
|
+
// #416 — queue messages to a registered-but-temporarily-unavailable direct target so they
|
|
475
|
+
// are not silently held as "pending" while the agent cannot process them. The flush fires
|
|
476
|
+
// when the agent returns to available (heartbeat stale→idle, re-registration, rate-limit
|
|
477
|
+
// resume). Offline agents use the existing store-ahead path (policyName is null, status
|
|
478
|
+
// "offline" → resolveQueuedDirectMessages is never called for them — the queue is cleared
|
|
479
|
+
// only when the agent actually comes back as non-offline).
|
|
480
|
+
const directAgent = getAgent(input.to);
|
|
481
|
+
if (directAgent && isAgentUnavailable(directAgent)) {
|
|
482
|
+
deliveryStatus = "queued";
|
|
483
|
+
queuedAt = now;
|
|
484
|
+
maxAgeSeconds = maxAgeSeconds ?? 86_400;
|
|
485
|
+
}
|
|
472
486
|
}
|
|
473
487
|
|
|
474
488
|
// #451/#461 — moderate default TTL for non-claimable FAN-OUT messages the sender didn't bound,
|
|
@@ -509,7 +523,10 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
509
523
|
const newId = Number(result.lastInsertRowid);
|
|
510
524
|
if (threadId === null) setSelfThread.run(newId, newId);
|
|
511
525
|
linkAttachmentRefs("message", newId, attachmentRefs, input.from);
|
|
512
|
-
if (
|
|
526
|
+
if (deliveryStatus === "queued") {
|
|
527
|
+
if (policyName) enforceQueueLimit(`policy:${policyName}`);
|
|
528
|
+
else enforceQueueLimit(input.to);
|
|
529
|
+
}
|
|
513
530
|
return newId;
|
|
514
531
|
})();
|
|
515
532
|
|
package/src/db/migrations.ts
CHANGED
|
@@ -896,9 +896,13 @@ export function applyMigrations(): void {
|
|
|
896
896
|
next_run_at INTEGER NOT NULL,
|
|
897
897
|
schedule_kind TEXT NOT NULL,
|
|
898
898
|
schedule_def TEXT NOT NULL,
|
|
899
|
+
message_kind TEXT,
|
|
900
|
+
channel TEXT,
|
|
899
901
|
subject TEXT,
|
|
900
902
|
body TEXT NOT NULL,
|
|
903
|
+
claimable INTEGER NOT NULL DEFAULT 0,
|
|
901
904
|
payload TEXT NOT NULL DEFAULT '{}',
|
|
905
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
902
906
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
903
907
|
run_count INTEGER NOT NULL DEFAULT 0,
|
|
904
908
|
max_runs INTEGER,
|
|
@@ -913,6 +917,19 @@ export function applyMigrations(): void {
|
|
|
913
917
|
claim_expires_at INTEGER
|
|
914
918
|
)
|
|
915
919
|
`);
|
|
920
|
+
const scheduledTimerCols = (getDb().query("PRAGMA table_info(scheduled_timers)").all() as any[]).map((c: any) => c.name);
|
|
921
|
+
if (!scheduledTimerCols.includes("message_kind")) {
|
|
922
|
+
getDb().run("ALTER TABLE scheduled_timers ADD COLUMN message_kind TEXT");
|
|
923
|
+
}
|
|
924
|
+
if (!scheduledTimerCols.includes("channel")) {
|
|
925
|
+
getDb().run("ALTER TABLE scheduled_timers ADD COLUMN channel TEXT");
|
|
926
|
+
}
|
|
927
|
+
if (!scheduledTimerCols.includes("claimable")) {
|
|
928
|
+
getDb().run("ALTER TABLE scheduled_timers ADD COLUMN claimable INTEGER NOT NULL DEFAULT 0");
|
|
929
|
+
}
|
|
930
|
+
if (!scheduledTimerCols.includes("meta")) {
|
|
931
|
+
getDb().run("ALTER TABLE scheduled_timers ADD COLUMN meta TEXT NOT NULL DEFAULT '{}'");
|
|
932
|
+
}
|
|
916
933
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_due ON scheduled_timers(enabled, next_run_at, claim_expires_at)");
|
|
917
934
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_creator ON scheduled_timers(created_by, enabled, next_run_at)");
|
|
918
935
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_correlation ON scheduled_timers(correlation_id)");
|
|
@@ -22,9 +22,13 @@ export interface ScheduledTimer {
|
|
|
22
22
|
nextRunAt: number;
|
|
23
23
|
scheduleKind: TimerScheduleKind;
|
|
24
24
|
scheduleDef: TimerScheduleDef;
|
|
25
|
+
messageKind?: string;
|
|
26
|
+
channel?: string;
|
|
25
27
|
subject?: string;
|
|
26
28
|
body: string;
|
|
29
|
+
claimable?: boolean;
|
|
27
30
|
payload: Record<string, unknown>;
|
|
31
|
+
meta: Record<string, unknown>;
|
|
28
32
|
enabled: boolean;
|
|
29
33
|
runCount: number;
|
|
30
34
|
maxRuns?: number;
|
|
@@ -51,9 +55,13 @@ interface ScheduledTimerRow {
|
|
|
51
55
|
next_run_at: number;
|
|
52
56
|
schedule_kind: string;
|
|
53
57
|
schedule_def: string;
|
|
58
|
+
message_kind: string | null;
|
|
59
|
+
channel: string | null;
|
|
54
60
|
subject: string | null;
|
|
55
61
|
body: string;
|
|
62
|
+
claimable: number | null;
|
|
56
63
|
payload: string;
|
|
64
|
+
meta: string | null;
|
|
57
65
|
enabled: number;
|
|
58
66
|
run_count: number;
|
|
59
67
|
max_runs: number | null;
|
|
@@ -78,9 +86,13 @@ export interface CreateScheduledTimerRowInput {
|
|
|
78
86
|
nextRunAt: number;
|
|
79
87
|
scheduleKind: TimerScheduleKind;
|
|
80
88
|
scheduleDef: TimerScheduleDef;
|
|
89
|
+
messageKind?: string;
|
|
90
|
+
channel?: string;
|
|
81
91
|
subject?: string;
|
|
82
92
|
body: string;
|
|
93
|
+
claimable?: boolean;
|
|
83
94
|
payload?: Record<string, unknown>;
|
|
95
|
+
meta?: Record<string, unknown>;
|
|
84
96
|
maxRuns?: number;
|
|
85
97
|
expiresAt?: number;
|
|
86
98
|
idempotencyKey?: string;
|
|
@@ -92,10 +104,10 @@ export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now =
|
|
|
92
104
|
getDb().query(`
|
|
93
105
|
INSERT INTO scheduled_timers (
|
|
94
106
|
id, target, created_by, created_by_kind, created_by_scopes, created_by_constraints, delivery_from,
|
|
95
|
-
created_at, next_run_at, schedule_kind, schedule_def, subject, body, payload, enabled,
|
|
107
|
+
created_at, next_run_at, schedule_kind, schedule_def, message_kind, channel, subject, body, claimable, payload, meta, enabled,
|
|
96
108
|
run_count, max_runs, expires_at, idempotency_key, correlation_id
|
|
97
109
|
)
|
|
98
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?)
|
|
110
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?)
|
|
99
111
|
`).run(
|
|
100
112
|
id,
|
|
101
113
|
input.target,
|
|
@@ -108,9 +120,13 @@ export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now =
|
|
|
108
120
|
input.nextRunAt,
|
|
109
121
|
input.scheduleKind,
|
|
110
122
|
JSON.stringify(input.scheduleDef),
|
|
123
|
+
input.messageKind ?? null,
|
|
124
|
+
input.channel ?? null,
|
|
111
125
|
input.subject ?? null,
|
|
112
126
|
input.body,
|
|
127
|
+
input.claimable ? 1 : 0,
|
|
113
128
|
JSON.stringify(input.payload ?? {}),
|
|
129
|
+
JSON.stringify(input.meta ?? {}),
|
|
114
130
|
input.maxRuns ?? null,
|
|
115
131
|
input.expiresAt ?? null,
|
|
116
132
|
input.idempotencyKey ?? null,
|
|
@@ -199,20 +215,20 @@ export function completeScheduledTimerRun(input: {
|
|
|
199
215
|
claimed_by = NULL,
|
|
200
216
|
claim_expires_at = NULL
|
|
201
217
|
WHERE id = ?
|
|
202
|
-
|
|
218
|
+
AND claimed_by = ?
|
|
219
|
+
AND enabled = 1
|
|
220
|
+
`).run(runCount, input.now, input.messageId, candidateNextRunAt, enabled ? 1 : 0, input.timer.id, input.timer.claimedBy ?? "");
|
|
203
221
|
return getScheduledTimer(input.timer.id)!;
|
|
204
222
|
}
|
|
205
223
|
|
|
206
224
|
export function recordScheduledTimerFailure(input: {
|
|
207
|
-
|
|
225
|
+
timer: ScheduledTimer;
|
|
208
226
|
error: string;
|
|
209
227
|
now: number;
|
|
210
228
|
retryMs: number;
|
|
211
229
|
maxFailures: number;
|
|
212
230
|
}): ScheduledTimer | null {
|
|
213
|
-
const
|
|
214
|
-
if (!current) return null;
|
|
215
|
-
const failures = current.consecutiveFailures + 1;
|
|
231
|
+
const failures = input.timer.consecutiveFailures + 1;
|
|
216
232
|
const enabled = failures < input.maxFailures;
|
|
217
233
|
getDb().query(`
|
|
218
234
|
UPDATE scheduled_timers
|
|
@@ -223,17 +239,18 @@ export function recordScheduledTimerFailure(input: {
|
|
|
223
239
|
claimed_by = NULL,
|
|
224
240
|
claim_expires_at = NULL
|
|
225
241
|
WHERE id = ?
|
|
226
|
-
|
|
227
|
-
|
|
242
|
+
AND claimed_by = ?
|
|
243
|
+
AND enabled = 1
|
|
244
|
+
`).run(input.error, failures, input.now + input.retryMs, enabled ? 1 : 0, input.timer.id, input.timer.claimedBy ?? "");
|
|
245
|
+
return getScheduledTimer(input.timer.id);
|
|
228
246
|
}
|
|
229
247
|
|
|
230
|
-
export function cancelScheduledTimer(id: string
|
|
231
|
-
|
|
248
|
+
export function cancelScheduledTimer(id: string): ScheduledTimer | null {
|
|
249
|
+
getDb().query(`
|
|
232
250
|
UPDATE scheduled_timers
|
|
233
251
|
SET enabled = 0, claimed_by = NULL, claim_expires_at = NULL, last_error = COALESCE(last_error, 'cancelled')
|
|
234
252
|
WHERE id = ? AND enabled = 1
|
|
235
253
|
`).run(id);
|
|
236
|
-
if (result.changes === 0) return getScheduledTimer(id);
|
|
237
254
|
return getScheduledTimer(id);
|
|
238
255
|
}
|
|
239
256
|
|
|
@@ -259,9 +276,13 @@ function rowToTimer(row: ScheduledTimerRow): ScheduledTimer {
|
|
|
259
276
|
nextRunAt: row.next_run_at,
|
|
260
277
|
scheduleKind: row.schedule_kind === "interval" ? "interval" : "once",
|
|
261
278
|
scheduleDef: parseRecord(row.schedule_def) as TimerScheduleDef,
|
|
279
|
+
messageKind: row.message_kind ?? undefined,
|
|
280
|
+
channel: row.channel ?? undefined,
|
|
262
281
|
subject: row.subject ?? undefined,
|
|
263
282
|
body: row.body,
|
|
283
|
+
claimable: row.claimable === 1 ? true : undefined,
|
|
264
284
|
payload: parseRecord(row.payload) ?? {},
|
|
285
|
+
meta: parseRecord(row.meta) ?? {},
|
|
265
286
|
enabled: row.enabled === 1,
|
|
266
287
|
runCount: row.run_count,
|
|
267
288
|
maxRuns: row.max_runs ?? undefined,
|
package/src/db/schema.ts
CHANGED
|
@@ -176,9 +176,13 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
176
176
|
next_run_at INTEGER NOT NULL,
|
|
177
177
|
schedule_kind TEXT NOT NULL,
|
|
178
178
|
schedule_def TEXT NOT NULL,
|
|
179
|
+
message_kind TEXT,
|
|
180
|
+
channel TEXT,
|
|
179
181
|
subject TEXT,
|
|
180
182
|
body TEXT NOT NULL,
|
|
183
|
+
claimable INTEGER NOT NULL DEFAULT 0,
|
|
181
184
|
payload TEXT NOT NULL DEFAULT '{}',
|
|
185
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
182
186
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
183
187
|
run_count INTEGER NOT NULL DEFAULT 0,
|
|
184
188
|
max_runs INTEGER,
|