agent-relay-server 0.94.4 → 0.94.6
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 +1 -1
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/agent-busy-health.ts +12 -1
- package/src/db/delivery.ts +32 -0
- package/src/db/stats.ts +32 -0
- package/src/maintenance/jobs.ts +29 -2
- package/src/notification-router.ts +4 -0
- package/src/notification-rules.ts +87 -6
- package/src/notification-types.ts +12 -0
- package/src/services/managed-running.ts +6 -4
package/docs/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Relay API",
|
|
5
|
-
"version": "0.94.
|
|
5
|
+
"version": "0.94.6",
|
|
6
6
|
"description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
|
|
7
7
|
"license": {
|
|
8
8
|
"name": "MIT",
|
package/package.json
CHANGED
package/src/agent-busy-health.ts
CHANGED
|
@@ -38,6 +38,13 @@ function activeWork(agent: AgentCard): ActiveWorkLike[] {
|
|
|
38
38
|
return Array.isArray(agent.meta?.activeWork) ? agent.meta.activeWork as ActiveWorkLike[] : [];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function hasLiveToolOrSubprocessWork(works: ActiveWorkLike[]): boolean {
|
|
42
|
+
return works.some((work) => {
|
|
43
|
+
const kind = stringValue(work.kind);
|
|
44
|
+
return Boolean(kind && kind !== "provider-turn");
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
function thresholdFor(kind: BusyHealthKind): number {
|
|
42
49
|
if (kind === "startup") return STUCK_BUSY_STARTUP_MS;
|
|
43
50
|
if (kind === "reconnect") return STUCK_BUSY_RECONNECT_MS;
|
|
@@ -70,13 +77,17 @@ function classifyBusy(agent: AgentCard, now: number): { kind: BusyHealthKind; wo
|
|
|
70
77
|
export function deriveAgentBusyHealth(agent: AgentCard, now: number = Date.now()): AgentBusyHealth | undefined {
|
|
71
78
|
const classified = classifyBusy(agent, now);
|
|
72
79
|
if (!classified) return undefined;
|
|
80
|
+
const works = activeWork(agent);
|
|
73
81
|
const thresholdMs = thresholdFor(classified.kind);
|
|
74
82
|
const activeWorkStartedAt = Math.max(0, classified.startedAt);
|
|
75
83
|
const activeWorkAgeMs = Math.max(0, now - activeWorkStartedAt);
|
|
76
84
|
const lastProviderEventAt = numberValue(agent.meta?.lastProviderEventAt);
|
|
77
85
|
const lastProgressAt = lastProviderEventAt ?? activeWorkStartedAt;
|
|
78
86
|
const staleForMs = Math.max(0, now - lastProgressAt);
|
|
79
|
-
const stuck =
|
|
87
|
+
const stuck =
|
|
88
|
+
!hasLiveToolOrSubprocessWork(works) &&
|
|
89
|
+
activeWorkAgeMs >= thresholdMs &&
|
|
90
|
+
staleForMs >= thresholdMs;
|
|
80
91
|
const work = classified.work;
|
|
81
92
|
return {
|
|
82
93
|
status: stuck ? "stuck-busy" : "ok",
|
package/src/db/delivery.ts
CHANGED
|
@@ -422,6 +422,23 @@ export function resolveQueuedPolicyMessages(policyName: string, agentId: string)
|
|
|
422
422
|
* Returns the flushed messages so callers can emit `message.available` / `message.new` /
|
|
423
423
|
* `message.delivery_updated` events.
|
|
424
424
|
*/
|
|
425
|
+
/**
|
|
426
|
+
* Distinct direct (agent) targets that have messages still parked at
|
|
427
|
+
* `queued` older than `minAgeMs`. Backstop for #706: the primary recovery is
|
|
428
|
+
* flush-on-availability, but if that event is ever missed a queued message is
|
|
429
|
+
* invisible (pollMessages excludes `queued`) until it expires. The periodic
|
|
430
|
+
* resweep uses this to re-resolve such messages once the target is available.
|
|
431
|
+
*/
|
|
432
|
+
export function listStaleQueuedDirectTargets(now: number = Date.now(), minAgeMs = 60_000): string[] {
|
|
433
|
+
const rows = getDb().query(`
|
|
434
|
+
SELECT DISTINCT to_target FROM messages
|
|
435
|
+
WHERE delivery_status = 'queued'
|
|
436
|
+
AND to_target IS NOT NULL
|
|
437
|
+
AND COALESCE(queued_at, created_at) <= ?
|
|
438
|
+
`).all(now - minAgeMs) as Array<{ to_target: string }>;
|
|
439
|
+
return rows.map((row) => row.to_target);
|
|
440
|
+
}
|
|
441
|
+
|
|
425
442
|
export function resolveQueuedDirectMessages(agentId: string): Message[] {
|
|
426
443
|
const rows = getDb().query(`
|
|
427
444
|
SELECT m.id
|
|
@@ -451,6 +468,21 @@ export function resolveQueuedDirectMessages(agentId: string): Message[] {
|
|
|
451
468
|
return ids.map((id) => getMessage(id)).filter((message): message is Message => Boolean(message));
|
|
452
469
|
}
|
|
453
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Count messages that expired undelivered at max_age within the trailing
|
|
473
|
+
* `windowMs` (default 24h). Surfaces silent channel data loss (#706) — a queued
|
|
474
|
+
* message that died without ever reaching its recipient — as a health signal.
|
|
475
|
+
*/
|
|
476
|
+
export function countExpiredUndeliveredMessages(now: number = Date.now(), windowMs = 86_400_000): number {
|
|
477
|
+
return (getDb().query(`
|
|
478
|
+
SELECT COUNT(*) as c FROM messages
|
|
479
|
+
WHERE delivery_status = 'dead'
|
|
480
|
+
AND delivery_poison_reason = 'queued message expired'
|
|
481
|
+
AND delivery_updated_at IS NOT NULL
|
|
482
|
+
AND delivery_updated_at >= ?
|
|
483
|
+
`).get(now - windowMs) as { c: number }).c;
|
|
484
|
+
}
|
|
485
|
+
|
|
454
486
|
export function expireQueuedMessages(now: number = Date.now()): Message[] {
|
|
455
487
|
const rows = getDb().query(`
|
|
456
488
|
SELECT id FROM messages
|
package/src/db/stats.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_ME
|
|
|
16
16
|
import { matchAgents } from "../agent-ref";
|
|
17
17
|
import { listAgents } from "./agents.ts";
|
|
18
18
|
import { listChannels } from "./channels.ts";
|
|
19
|
+
import { countExpiredUndeliveredMessages } from "./delivery.ts";
|
|
19
20
|
import { messageTaskClosed } from "./claim-sql.ts";
|
|
20
21
|
import { getDb } from "./connection.ts";
|
|
21
22
|
import type {
|
|
@@ -466,6 +467,37 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
466
467
|
: undefined,
|
|
467
468
|
});
|
|
468
469
|
|
|
470
|
+
// A channel bridge (e.g. Telegram) that is registered but stale or not-ready
|
|
471
|
+
// is silently NOT delivering — channel messages queue against it (#706). The
|
|
472
|
+
// binding-target health above can read "ok" while the bridge itself is wedged,
|
|
473
|
+
// so check the bridge agent's own liveness to avoid green-washing a down bridge.
|
|
474
|
+
const channelBridges = listAgents().filter((agent) =>
|
|
475
|
+
agent.id !== "user" && agent.id !== "system" &&
|
|
476
|
+
(agent.kind === "channel" || agent.meta?.kind === "channel"));
|
|
477
|
+
const staleChannelBridges = channelBridges.filter((agent) =>
|
|
478
|
+
agent.status !== "offline" && agent.lastSeen <= now - STALE_TTL_MS);
|
|
479
|
+
const notReadyChannelBridges = channelBridges.filter((agent) =>
|
|
480
|
+
agent.status !== "offline" && !agent.ready && agent.lastSeen > now - STALE_TTL_MS);
|
|
481
|
+
const unhealthyChannelBridges = [...staleChannelBridges, ...notReadyChannelBridges];
|
|
482
|
+
checks.push({
|
|
483
|
+
name: "channel-bridge-liveness",
|
|
484
|
+
status: staleChannelBridges.length > 0 ? "error" : notReadyChannelBridges.length > 0 ? "warn" : "ok",
|
|
485
|
+
count: unhealthyChannelBridges.length,
|
|
486
|
+
detail: unhealthyChannelBridges.length > 0
|
|
487
|
+
? `${unhealthyChannelBridges.length} channel bridge(s) not delivering: ${unhealthyChannelBridges.slice(0, 3).map((a) => `${a.id} (status=${a.status}, ready=${a.ready})`).join("; ")}`
|
|
488
|
+
: undefined,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const expiredUndelivered = countExpiredUndeliveredMessages(now);
|
|
492
|
+
checks.push({
|
|
493
|
+
name: "expired-undelivered-messages",
|
|
494
|
+
status: expiredUndelivered > 0 ? "warn" : "ok",
|
|
495
|
+
count: expiredUndelivered,
|
|
496
|
+
detail: expiredUndelivered > 0
|
|
497
|
+
? `${expiredUndelivered} queued message(s) expired undelivered at max_age in the last 24h (silent data loss)`
|
|
498
|
+
: undefined,
|
|
499
|
+
});
|
|
500
|
+
|
|
469
501
|
const rootTokenRuntimeAgents = listAgents().filter((agent) => {
|
|
470
502
|
if (agent.status === "offline") return false;
|
|
471
503
|
if (agent.id === "user" || agent.id === "system") return false;
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
expireQueuedMessages,
|
|
11
11
|
getAgent,
|
|
12
12
|
listAgents,
|
|
13
|
+
listStaleQueuedDirectTargets,
|
|
13
14
|
pruneOrphanedSharedWorkspaces,
|
|
14
15
|
pruneOfflineAgents,
|
|
15
16
|
pruneOldMessages,
|
|
@@ -22,6 +23,8 @@ import {
|
|
|
22
23
|
runDbMaintenance,
|
|
23
24
|
sweepArtifacts,
|
|
24
25
|
} from "../db";
|
|
26
|
+
import { isAgentUnavailable } from "../db/agent-predicates";
|
|
27
|
+
import { flushQueuedDirectMessages } from "../services/managed-running";
|
|
25
28
|
import { notifyAgentOffline } from "../agent-lifecycle-events";
|
|
26
29
|
import { diagnoseAgentDeath } from "../agent-death-diagnosis";
|
|
27
30
|
import { autoMergeCleanFastForwards } from "../workspace-auto-merge";
|
|
@@ -117,7 +120,6 @@ export function runStuckBusyWatchdog(input: { now?: number } = {}): Record<strin
|
|
|
117
120
|
const messages = routeNotification({
|
|
118
121
|
type: "agent.stuck_busy",
|
|
119
122
|
scope: { agentId: agent.id, parent: agent.spawnedBy, teamId: agent.teamId },
|
|
120
|
-
recipients: ["user"],
|
|
121
123
|
message: {
|
|
122
124
|
subject: "Agent stuck busy",
|
|
123
125
|
body: stuckBusyBody(agent.id, health),
|
|
@@ -193,10 +195,35 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
193
195
|
runOnStart: true,
|
|
194
196
|
handler() {
|
|
195
197
|
const messages = expireQueuedMessages();
|
|
196
|
-
for (const message of messages)
|
|
198
|
+
for (const message of messages) {
|
|
199
|
+
// Silent data loss is the worst failure mode here (#706): a queued
|
|
200
|
+
// message died without ever reaching its recipient. Surface it loudly.
|
|
201
|
+
console.warn(`[delivery] message ${message.id} to ${message.to} (kind=${message.kind}) expired undelivered after max_age — silent data loss`);
|
|
202
|
+
emitMessageExpired(message);
|
|
203
|
+
}
|
|
197
204
|
return { expiredQueuedMessageIds: messages.map((message) => message.id) };
|
|
198
205
|
},
|
|
199
206
|
},
|
|
207
|
+
{
|
|
208
|
+
id: "queued-message-resweep",
|
|
209
|
+
title: "Queued message re-sweep",
|
|
210
|
+
description: "Re-resolve long-queued direct messages whose target agent is available again — safety net for a missed flush-on-recovery event (#706).",
|
|
211
|
+
intervalMs: REAP_INTERVAL_MS,
|
|
212
|
+
runOnStart: true,
|
|
213
|
+
handler() {
|
|
214
|
+
const reswept: Record<string, number> = {};
|
|
215
|
+
for (const target of listStaleQueuedDirectTargets()) {
|
|
216
|
+
const agent = getAgent(target);
|
|
217
|
+
// Only flush to a target that is actually available now; otherwise the
|
|
218
|
+
// message belongs in the queue (this is hooked to agent availability,
|
|
219
|
+
// not pool-slot reclaim).
|
|
220
|
+
if (!agent || isAgentUnavailable(agent)) continue;
|
|
221
|
+
const flushed = flushQueuedDirectMessages(target);
|
|
222
|
+
if (flushed.length > 0) reswept[target] = flushed.length;
|
|
223
|
+
}
|
|
224
|
+
return { resweptTargets: reswept };
|
|
225
|
+
},
|
|
226
|
+
},
|
|
200
227
|
{
|
|
201
228
|
id: "command-expiration",
|
|
202
229
|
title: "Command expiration",
|
|
@@ -203,6 +203,10 @@ function routingReason(
|
|
|
203
203
|
): string {
|
|
204
204
|
if (!settingsEnabled) return "notifications are disabled for this recipient by settings";
|
|
205
205
|
if (matchedRule === "author") return "you are the author";
|
|
206
|
+
if (matchedRule === "targeted-at-user") return "you are directly targeted";
|
|
207
|
+
if (matchedRule === "spawned-by-user") return "you directly spawned this agent";
|
|
208
|
+
if (matchedRule === "spawn-parent-of") return "you are the subject agent's spawn parent";
|
|
209
|
+
if (matchedRule === "thread-participant") return "you are a participant in this thread";
|
|
206
210
|
if (matchedRule === "spawn-parent") return "you are the spawn parent";
|
|
207
211
|
if (matchedRule === "subject-agent") return "you are the notification subject";
|
|
208
212
|
if (matchedRule === "same-team") return scope.teamId ? `same team ${scope.teamId}` : "same team";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentCard } from "./types";
|
|
2
|
-
import {
|
|
2
|
+
import { canonicalHumanAgentId, isHumanAgentId } from "agent-relay-sdk";
|
|
3
|
+
import { getAgent, getThread, listAgents } from "./db";
|
|
3
4
|
import { isAgentOnline } from "./agent-ref";
|
|
4
5
|
import { notificationEnabledFor } from "./notification-settings-store";
|
|
5
6
|
import type { NotificationType, NotificationScope } from "./notification-types";
|
|
@@ -40,6 +41,31 @@ interface AudienceMember {
|
|
|
40
41
|
matchedRule: string;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
function sameAddress(a: string | undefined, b: string | undefined): boolean {
|
|
45
|
+
if (!a || !b) return false;
|
|
46
|
+
if (isHumanAgentId(a) || isHumanAgentId(b)) return canonicalHumanAgentId(a) === canonicalHumanAgentId(b);
|
|
47
|
+
return a === b;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function subjectParent(scope: NotificationScope): string | undefined {
|
|
51
|
+
if (scope.parent) return scope.parent;
|
|
52
|
+
return scope.agentId ? getAgent(scope.agentId)?.spawnedBy : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function threadParticipantIds(scope: NotificationScope): Set<string> {
|
|
56
|
+
const ids = new Set<string>();
|
|
57
|
+
for (const id of scope.participants ?? []) {
|
|
58
|
+
if (id.trim()) ids.add(id);
|
|
59
|
+
}
|
|
60
|
+
if (typeof scope.threadId === "number" && Number.isSafeInteger(scope.threadId) && scope.threadId > 0) {
|
|
61
|
+
for (const message of getThread(scope.threadId)) {
|
|
62
|
+
ids.add(message.from);
|
|
63
|
+
ids.add(message.to);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return ids;
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
/**
|
|
44
70
|
* Predicate library — the reusable building blocks each type composes its default audience from.
|
|
45
71
|
* Each factory returns a single-concern rule. `deliver` includes the agent; `abstain` leaves the
|
|
@@ -47,6 +73,38 @@ interface AudienceMember {
|
|
|
47
73
|
* shape #443 builds on). Compose them ordered: later rules override earlier (see {@link evaluateAudience}).
|
|
48
74
|
*/
|
|
49
75
|
export const audiencePredicates = {
|
|
76
|
+
/** A human directly targeted by the message/chat event. */
|
|
77
|
+
targetedAtUser: (): NotificationRule => ({
|
|
78
|
+
name: "targeted-at-user",
|
|
79
|
+
evaluate: (a, { scope }) => {
|
|
80
|
+
if (!isHumanAgentId(a.id)) return "abstain";
|
|
81
|
+
const target = scope.to ?? scope.target;
|
|
82
|
+
return sameAddress(a.id, target) ? "deliver" : "abstain";
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
/** A human directly spawned the subject agent. Depth-1 only; grandchildren abstain. */
|
|
86
|
+
spawnedByUser: (): NotificationRule => ({
|
|
87
|
+
name: "spawned-by-user",
|
|
88
|
+
evaluate: (a, { scope }) => {
|
|
89
|
+
if (!isHumanAgentId(a.id)) return "abstain";
|
|
90
|
+
return sameAddress(a.id, subjectParent(scope)) ? "deliver" : "abstain";
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
/** The subject agent's spawn-parent, deriving it from `scope.agentId` when needed. */
|
|
94
|
+
spawnParentOf: (): NotificationRule => ({
|
|
95
|
+
name: "spawn-parent-of",
|
|
96
|
+
evaluate: (a, { scope }) => (sameAddress(a.id, subjectParent(scope)) ? "deliver" : "abstain"),
|
|
97
|
+
}),
|
|
98
|
+
/** Any participant in the message/chat thread. */
|
|
99
|
+
threadParticipant: (): NotificationRule => ({
|
|
100
|
+
name: "thread-participant",
|
|
101
|
+
evaluate: (a, { scope }) => {
|
|
102
|
+
for (const participant of threadParticipantIds(scope)) {
|
|
103
|
+
if (sameAddress(a.id, participant)) return "deliver";
|
|
104
|
+
}
|
|
105
|
+
return "abstain";
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
50
108
|
/** The agent whose work the notification is about — delivered even when offline (store-ahead #234). */
|
|
51
109
|
author: (): NotificationRule => ({
|
|
52
110
|
name: "author",
|
|
@@ -96,14 +154,15 @@ export const audiencePredicates = {
|
|
|
96
154
|
} as const;
|
|
97
155
|
|
|
98
156
|
/**
|
|
99
|
-
* The candidate pool for rule evaluation: every registered agent EXCEPT
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* offline target; per-rule `onlineOnly` gates
|
|
157
|
+
* The candidate pool for rule evaluation: every registered agent EXCEPT `system` and channel agents,
|
|
158
|
+
* which are never wake-notification recipients. Human sinks stay in the pool so human-surface rules
|
|
159
|
+
* can decide when `user` belongs in an audience. Offline agents stay IN deliberately — a store-ahead
|
|
160
|
+
* rule (author/spawn-parent) must be able to select an offline target; per-rule `onlineOnly` gates
|
|
161
|
+
* the cases that need liveness (e.g. on-repo-root fan-out).
|
|
103
162
|
*/
|
|
104
163
|
export function audienceCandidates(): AgentCard[] {
|
|
105
164
|
return listAgents().filter(
|
|
106
|
-
(a) => a.id !== "system" && a.
|
|
165
|
+
(a) => a.id !== "system" && a.kind !== "system" && a.kind !== "channel" && a.meta?.kind !== "channel",
|
|
107
166
|
);
|
|
108
167
|
}
|
|
109
168
|
|
|
@@ -190,3 +249,25 @@ export function __resetAudienceRules(): void {
|
|
|
190
249
|
for (const [type, rules] of defaultAudienceRegistry) audienceRegistry.set(type, rules);
|
|
191
250
|
subscriptionRules = [];
|
|
192
251
|
}
|
|
252
|
+
|
|
253
|
+
const SPAWN_LIFECYCLE_AUDIENCE_TYPES = [
|
|
254
|
+
"agent.ready",
|
|
255
|
+
"agent.exited",
|
|
256
|
+
"agent.spawn_failed",
|
|
257
|
+
"agent.spawn_recovered",
|
|
258
|
+
] as const satisfies readonly NotificationType[];
|
|
259
|
+
|
|
260
|
+
for (const type of SPAWN_LIFECYCLE_AUDIENCE_TYPES) {
|
|
261
|
+
registerDefaultAudienceRules(type, [audiencePredicates.spawnParentOf(), audiencePredicates.spawnedByUser()]);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
registerDefaultAudienceRules("agent.stuck_busy", [
|
|
265
|
+
audiencePredicates.spawnParentOf(),
|
|
266
|
+
audiencePredicates.spawnedByUser(),
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
registerDefaultAudienceRules("message.new", [
|
|
270
|
+
audiencePredicates.targetedAtUser(),
|
|
271
|
+
audiencePredicates.spawnedByUser(),
|
|
272
|
+
audiencePredicates.threadParticipant(),
|
|
273
|
+
]);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type NotificationType =
|
|
2
|
+
| "message.new"
|
|
2
3
|
| "landed-failure"
|
|
3
4
|
| "branch.landed"
|
|
4
5
|
| "agent.ready"
|
|
@@ -31,6 +32,16 @@ export type NotificationType =
|
|
|
31
32
|
* All optional because each emitter fills the fields it has in scope at the call site.
|
|
32
33
|
*/
|
|
33
34
|
export interface NotificationScope {
|
|
35
|
+
/** Message sender for message/chat audience evaluation. */
|
|
36
|
+
from?: string;
|
|
37
|
+
/** Message target for message/chat audience evaluation. */
|
|
38
|
+
to?: string;
|
|
39
|
+
/** Alias for `to` used by audience preview clients that describe the primary target. */
|
|
40
|
+
target?: string;
|
|
41
|
+
/** Message thread id for thread participant audience evaluation. */
|
|
42
|
+
threadId?: number;
|
|
43
|
+
/** Pre-resolved thread participants when the caller already has the thread membership. */
|
|
44
|
+
participants?: string[];
|
|
34
45
|
/** The agent whose work the notification concerns (branch author, workspace owner). */
|
|
35
46
|
author?: string;
|
|
36
47
|
/** The agent the notification is primarily about (lifecycle subject, advisory target). */
|
|
@@ -56,6 +67,7 @@ const MINUTES = 60 * 1000;
|
|
|
56
67
|
* obligation is `null` (never auto-expires). Only genuinely now-or-never pings get a short default.
|
|
57
68
|
*/
|
|
58
69
|
export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
70
|
+
"message.new": null,
|
|
59
71
|
"landed-failure": null,
|
|
60
72
|
"branch.landed": null,
|
|
61
73
|
"agent.ready": null,
|
|
@@ -17,7 +17,7 @@ import { getManagedAgentState, updateManagedAgentState } from "../config-store";
|
|
|
17
17
|
import { emitRelayEvent } from "../events";
|
|
18
18
|
import { emitMessageAvailable, emitMessageDeliveryUpdated, emitNewMessage } from "../sse";
|
|
19
19
|
import type { AuthContext } from "./auth-context";
|
|
20
|
-
import type { ManagedAgentState } from "../types";
|
|
20
|
+
import type { ManagedAgentState, Message } from "../types";
|
|
21
21
|
|
|
22
22
|
interface ManagedRunningInput {
|
|
23
23
|
policyName?: string;
|
|
@@ -135,14 +135,16 @@ function recordManagedTransition(
|
|
|
135
135
|
* transitions back to available: heartbeat stale→idle, re-registration after restart/compact, or
|
|
136
136
|
* rate-limit resume. Emits the same `message.available` / `message.new` / `message.delivery_updated`
|
|
137
137
|
* events that the policy flush emits so SSE subscribers and the dashboard react identically.
|
|
138
|
-
* No-op when there are no queued direct messages.
|
|
138
|
+
* No-op when there are no queued direct messages. Returns the flushed messages so
|
|
139
|
+
* callers (e.g. the resweep safety net, #706) can report how many were recovered.
|
|
139
140
|
*/
|
|
140
|
-
export function flushQueuedDirectMessages(agentId: string):
|
|
141
|
+
export function flushQueuedDirectMessages(agentId: string): Message[] {
|
|
141
142
|
const available = resolveQueuedDirectMessages(agentId);
|
|
142
|
-
if (!available.length) return;
|
|
143
|
+
if (!available.length) return [];
|
|
143
144
|
emitMessageAvailable("direct", agentId, available);
|
|
144
145
|
for (const message of available) {
|
|
145
146
|
emitNewMessage(message);
|
|
146
147
|
emitMessageDeliveryUpdated(message);
|
|
147
148
|
}
|
|
149
|
+
return available;
|
|
148
150
|
}
|