agent-relay-server 0.86.0 → 0.88.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 +39 -1
- package/package.json +1 -1
- package/public/assets/{MessageBubble-CevL-2jc.js → MessageBubble-bI9fFi55.js} +3 -3
- package/public/assets/{MessageBubble-CevL-2jc.js.map → MessageBubble-bI9fFi55.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-DGpJhuVw.js → PlanGraphPanel-a3Z57nIZ.js} +2 -2
- package/public/assets/{PlanGraphPanel-DGpJhuVw.js.map → PlanGraphPanel-a3Z57nIZ.js.map} +1 -1
- package/public/assets/{activity-CY2Cbpet.js → activity-VTlBVIox.js} +2 -2
- package/public/assets/{activity-CY2Cbpet.js.map → activity-VTlBVIox.js.map} +1 -1
- package/public/assets/{agents-Bj5KzrYJ.js → agents-C2TNRCn1.js} +2 -2
- package/public/assets/{agents-Bj5KzrYJ.js.map → agents-C2TNRCn1.js.map} +1 -1
- package/public/assets/{automation-BOPk1oh_.js → automation-CUSyRZla.js} +2 -2
- package/public/assets/{automation-BOPk1oh_.js.map → automation-CUSyRZla.js.map} +1 -1
- package/public/assets/{chat-D5pI4ebq.js → chat-BEssCn_H.js} +2 -2
- package/public/assets/{chat-D5pI4ebq.js.map → chat-BEssCn_H.js.map} +1 -1
- package/public/assets/{formatted-body-impl-Dr_z1UvS.js → formatted-body-impl-DBbmythT.js} +2 -2
- package/public/assets/{formatted-body-impl-Dr_z1UvS.js.map → formatted-body-impl-DBbmythT.js.map} +1 -1
- package/public/assets/index-CiBdEE9x.js +23 -0
- package/public/assets/{index-CEXhh5nz.js.map → index-CiBdEE9x.js.map} +1 -1
- package/public/assets/index-F-FSPv4X.css +2 -0
- package/public/assets/{insights-C4dEK49e.js → insights-9P_MGbMS.js} +2 -2
- package/public/assets/{insights-C4dEK49e.js.map → insights-9P_MGbMS.js.map} +1 -1
- package/public/assets/{maintenance-DJZUSsja.js → maintenance-CA1XujU3.js} +2 -2
- package/public/assets/{maintenance-DJZUSsja.js.map → maintenance-CA1XujU3.js.map} +1 -1
- package/public/assets/managed-agents-DCfSlqW7.js +4 -0
- package/public/assets/managed-agents-DCfSlqW7.js.map +1 -0
- package/public/assets/{markdown-preview-impl-DOJQR3cO.js → markdown-preview-impl-MgZ3Nr7K.js} +2 -2
- package/public/assets/{markdown-preview-impl-DOJQR3cO.js.map → markdown-preview-impl-MgZ3Nr7K.js.map} +1 -1
- package/public/assets/{memory-BnhAD09c.js → memory-DatJLjrc.js} +2 -2
- package/public/assets/{memory-BnhAD09c.js.map → memory-DatJLjrc.js.map} +1 -1
- package/public/assets/{messages-Cc9YF8Wl.js → messages-CQsmbtzx.js} +2 -2
- package/public/assets/{messages-Cc9YF8Wl.js.map → messages-CQsmbtzx.js.map} +1 -1
- package/public/assets/{orchestrators-B01iyiBA.js → orchestrators-CAWYWXmh.js} +2 -2
- package/public/assets/{orchestrators-B01iyiBA.js.map → orchestrators-CAWYWXmh.js.map} +1 -1
- package/public/assets/overview-EqnqXVUb.js +2 -0
- package/public/assets/{overview-DN8CUTVx.js.map → overview-EqnqXVUb.js.map} +1 -1
- package/public/assets/{pairs-BxuDMLhs.js → pairs-BfKenirO.js} +2 -2
- package/public/assets/{pairs-BxuDMLhs.js.map → pairs-BfKenirO.js.map} +1 -1
- package/public/assets/{security-CO0PBIau.js → security-CJVJ9MgS.js} +2 -2
- package/public/assets/{security-CO0PBIau.js.map → security-CJVJ9MgS.js.map} +1 -1
- package/public/assets/{select-DHaFsLvt.js → select-BcmIzk-E.js} +2 -2
- package/public/assets/{select-DHaFsLvt.js.map → select-BcmIzk-E.js.map} +1 -1
- package/public/assets/{settings-De1DKwd6.js → settings-BWO-UyHv.js} +2 -2
- package/public/assets/{settings-De1DKwd6.js.map → settings-BWO-UyHv.js.map} +1 -1
- package/public/assets/{tasks-Bqd188ql.js → tasks-DTdI_MJi.js} +2 -2
- package/public/assets/{tasks-Bqd188ql.js.map → tasks-DTdI_MJi.js.map} +1 -1
- package/public/assets/{teams-Mh6ypDPr.js → teams-C9C3KVaV.js} +2 -2
- package/public/assets/{teams-Mh6ypDPr.js.map → teams-C9C3KVaV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-B7x_JNZe.js → terminal-viewer-impl-BDBa94-B.js} +2 -2
- package/public/assets/{terminal-viewer-impl-B7x_JNZe.js.map → terminal-viewer-impl-BDBa94-B.js.map} +1 -1
- package/public/assets/{user-avatar-Dy5J3E_b.js → user-avatar-BL-CJwnn.js} +2 -2
- package/public/assets/{user-avatar-Dy5J3E_b.js.map → user-avatar-BL-CJwnn.js.map} +1 -1
- package/public/assets/{work-queue-ClGgOUSu.js → work-queue-Vc_2-4DG.js} +2 -2
- package/public/assets/{work-queue-ClGgOUSu.js.map → work-queue-Vc_2-4DG.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/agent-busy-health.ts +93 -0
- package/src/agent-card-projection.ts +4 -1
- package/src/commands-db.ts +19 -2
- package/src/config.ts +5 -0
- package/src/db/activity.ts +92 -0
- package/src/maintenance/jobs.ts +75 -0
- package/src/maintenance/teams.ts +108 -2
- package/src/mcp/index.ts +3 -0
- package/src/mcp-ntfy-tools.ts +35 -0
- package/src/notification-types.ts +4 -0
- package/src/ntfy.ts +222 -0
- package/src/quota-advisory.ts +3 -2
- package/src/quota-threshold-config.ts +1 -1
- package/src/routes/index.ts +2 -1
- package/src/routes/spawn-policy.ts +19 -2
- package/src/team-idle-tracker.ts +19 -0
- package/public/assets/index-CEXhh5nz.js +0 -23
- package/public/assets/index-LUIvWcx6.css +0 -2
- package/public/assets/managed-agents-5wUMDKVi.js +0 -2
- package/public/assets/managed-agents-5wUMDKVi.js.map +0 -1
- package/public/assets/overview-DN8CUTVx.js +0 -2
package/src/maintenance/teams.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
clearTeamZeroMembersSince,
|
|
3
3
|
countLiveTeamMembers,
|
|
4
4
|
createActivityEvent,
|
|
5
|
+
getAgent,
|
|
5
6
|
getCrashReport,
|
|
6
7
|
hasTerminalEvent,
|
|
7
8
|
getTeamBusyState,
|
|
@@ -9,18 +10,25 @@ import {
|
|
|
9
10
|
listActiveTeams,
|
|
10
11
|
setTeamZeroMembersSince,
|
|
11
12
|
} from "../db";
|
|
12
|
-
import { STALE_TTL_MS } from "../config";
|
|
13
|
+
import { STALE_TTL_MS, stewardFallbackTarget } from "../config";
|
|
13
14
|
import { agentProviderState } from "../provider-state";
|
|
14
15
|
import { dissolveTeamById } from "../services/team";
|
|
16
|
+
import { resolveCoordinatorAgentId } from "../steward-identity";
|
|
15
17
|
import { getTeamsConfig } from "../teams-config-store";
|
|
16
18
|
import { teamReapGraceMs } from "./constants";
|
|
17
19
|
import { notifyTarget } from "./events";
|
|
18
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
teamCoordinatorWatchTracker,
|
|
22
|
+
teamIdleTracker,
|
|
23
|
+
teamMemberWatchTracker,
|
|
24
|
+
type TeamMemberWatch,
|
|
25
|
+
} from "../team-idle-tracker";
|
|
19
26
|
import type { AgentCard } from "../types";
|
|
20
27
|
|
|
21
28
|
export function resetTeamIdleWatchdogForTests(): void {
|
|
22
29
|
teamIdleTracker.clear();
|
|
23
30
|
teamMemberWatchTracker.clear();
|
|
31
|
+
teamCoordinatorWatchTracker.clear();
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
/** A member counts as "alive" (worth watching for a future silence) when it's past onboarding and
|
|
@@ -84,6 +92,95 @@ function detectSilentMembers(
|
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
94
|
|
|
95
|
+
/**
|
|
96
|
+
* #412/#468 — detect a team's COORDINATOR going silent and escalate to a human, instead of the team
|
|
97
|
+
* stalling forever. The member watchdog above SKIPS the coordinator (it's the ping recipient), and a
|
|
98
|
+
* missing/dead coordinator otherwise only gets recorded — never surfaced — so a silently-dead
|
|
99
|
+
* coordinator means nobody is driving and no member is even being listened to (#412's single point of
|
|
100
|
+
* failure). Reuses the SAME aliveness predicate (`memberIsAlive`) + terminal-event suppression
|
|
101
|
+
* (`getCrashReport || hasTerminalEvent`) as the member watch so a CLEANLY-exited coordinator does not
|
|
102
|
+
* false-positive. Escalates ONCE per gone-spell (re-arms on recovery) up the lineage — the gone
|
|
103
|
+
* coordinator's own coordinator (#639 resolver), falling back to the configured human/steward target
|
|
104
|
+
* — never dead-ending at `system`. A coordinator-gone team is always surfaced, never silently
|
|
105
|
+
* abandoned (no-silent-dissolve).
|
|
106
|
+
*/
|
|
107
|
+
function detectGoneCoordinator(
|
|
108
|
+
teamId: string,
|
|
109
|
+
coordinatorId: string | undefined,
|
|
110
|
+
now: number,
|
|
111
|
+
thresholdMs: number,
|
|
112
|
+
out: { escalatedTeamIds: string[]; suppressedTeamIds: string[]; goneCoordinatorIds: string[] },
|
|
113
|
+
): void {
|
|
114
|
+
if (!coordinatorId) return; // no coordinator on the row — nothing to watch (idle path records this)
|
|
115
|
+
const freshCutoff = now - thresholdMs;
|
|
116
|
+
const existing = teamCoordinatorWatchTracker.get(teamId);
|
|
117
|
+
// A re-elected/claimed coordinator is a fresh identity — drop a watch left over from the old one.
|
|
118
|
+
if (existing && existing.coordinatorId !== coordinatorId) teamCoordinatorWatchTracker.delete(teamId);
|
|
119
|
+
|
|
120
|
+
const coord = getAgent(coordinatorId);
|
|
121
|
+
if (coord && memberIsAlive(coord, freshCutoff)) {
|
|
122
|
+
const prior = existing?.coordinatorId === coordinatorId ? existing : undefined;
|
|
123
|
+
teamCoordinatorWatchTracker.set(teamId, {
|
|
124
|
+
coordinatorId,
|
|
125
|
+
lastActiveAt: Math.max(coord.lastSeen, prior?.lastActiveAt ?? 0),
|
|
126
|
+
escalated: false, // alive again ⇒ re-arm the escalation
|
|
127
|
+
...(coord.label ? { label: coord.label } : {}),
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Coordinator is NOT alive (offline, heartbeat-stale, or vanished). Only escalate a coordinator we
|
|
133
|
+
// actually observed alive — never fire on cold/unknown startup state (mirrors the member watch).
|
|
134
|
+
const watch = teamCoordinatorWatchTracker.get(teamId);
|
|
135
|
+
if (!watch || watch.coordinatorId !== coordinatorId || watch.escalated) return;
|
|
136
|
+
watch.escalated = true; // one escalation per gone-spell, regardless of outcome below
|
|
137
|
+
out.goneCoordinatorIds.push(coordinatorId);
|
|
138
|
+
|
|
139
|
+
// A terminal marker means a #645 clean terminus (graceful shutdown) or a #633 crash the operator was
|
|
140
|
+
// already surfaced on — not a SILENT death. Suppress.
|
|
141
|
+
if (getCrashReport(coordinatorId) || hasTerminalEvent(coordinatorId)) {
|
|
142
|
+
out.suppressedTeamIds.push(teamId);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Escalate up the lineage, falling back to the configured human/steward target (#639/#642 pattern).
|
|
147
|
+
const escalationTarget = resolveCoordinatorAgentId(coordinatorId) ?? stewardFallbackTarget();
|
|
148
|
+
const label = watch.label ?? coordinatorId;
|
|
149
|
+
const sent = escalationTarget
|
|
150
|
+
? notifyTarget(
|
|
151
|
+
"team.coordinator_gone",
|
|
152
|
+
{ teamId, agentId: coordinatorId, parent: resolveCoordinatorAgentId(coordinatorId) ?? undefined },
|
|
153
|
+
escalationTarget,
|
|
154
|
+
"Team coordinator went silent",
|
|
155
|
+
`Coordinator \`${label}\` of team \`${teamId}\` has gone silent with no terminal event — nobody is driving the team and its members are no longer being listened to. Take over coordination, restart the team, or stand it down.`,
|
|
156
|
+
{ kind: "team.coordinator_gone", teamId, coordinatorId, escalationTarget, label: watch.label },
|
|
157
|
+
)
|
|
158
|
+
: null;
|
|
159
|
+
if (sent) out.escalatedTeamIds.push(teamId);
|
|
160
|
+
|
|
161
|
+
// No-silent-dissolve: surface the coordinator-gone team on the dashboard even when there is no
|
|
162
|
+
// escalation target configured — it must never be silently abandoned.
|
|
163
|
+
createActivityEvent({
|
|
164
|
+
clientId: `team-coordinator-gone-${teamId}-${coordinatorId}-${now}`,
|
|
165
|
+
kind: "state",
|
|
166
|
+
title: "Team coordinator went silent",
|
|
167
|
+
body: sent
|
|
168
|
+
? `Coordinator ${label} of team ${teamId} went silent — escalated to ${sent}.`
|
|
169
|
+
: `Coordinator ${label} of team ${teamId} went silent — no escalation target configured (set AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK).`,
|
|
170
|
+
meta: teamId,
|
|
171
|
+
icon: "ti-user-x",
|
|
172
|
+
view: "teams",
|
|
173
|
+
agentId: coordinatorId,
|
|
174
|
+
metadata: {
|
|
175
|
+
source: "server",
|
|
176
|
+
maintenanceJobId: "team-idle-watchdog",
|
|
177
|
+
teamId,
|
|
178
|
+
coordinatorId,
|
|
179
|
+
escalationTarget: sent ?? null,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
87
184
|
function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
|
|
88
185
|
const roster = getTeamRoster(teamId).map((member) => {
|
|
89
186
|
const providerState = agentProviderState(member);
|
|
@@ -145,12 +242,17 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
|
|
|
145
242
|
const alreadyPingedTeamIds: string[] = [];
|
|
146
243
|
const missingCoordinatorTeamIds: string[] = [];
|
|
147
244
|
const memberSilence = { pingedMembers: [] as string[], watchedMembers: 0 };
|
|
245
|
+
const coordinatorGone = { escalatedTeamIds: [] as string[], suppressedTeamIds: [] as string[], goneCoordinatorIds: [] as string[] };
|
|
148
246
|
|
|
149
247
|
for (const team of listActiveTeams()) {
|
|
150
248
|
seen.add(team.id);
|
|
151
249
|
// #634 — runs every cycle, independent of the team-level idle gate below: a single silent
|
|
152
250
|
// member must surface even while OTHER members keep the team "busy".
|
|
153
251
|
detectSilentMembers(team.id, team.coordinatorId, now, thresholdMs, memberSilence);
|
|
252
|
+
// #412/#468 — coordinator-gone escalation, also every cycle and independent of the idle gate: a
|
|
253
|
+
// dead coordinator must surface even if the team isn't (yet) idle, and the idle-ping path can't
|
|
254
|
+
// reach it (it would just ping the void).
|
|
255
|
+
detectGoneCoordinator(team.id, team.coordinatorId, now, thresholdMs, coordinatorGone);
|
|
154
256
|
const busy = getTeamBusyState(team.id, cutoff);
|
|
155
257
|
if (busy.busyMemberIds.length > 0) {
|
|
156
258
|
teamIdleTracker.delete(team.id);
|
|
@@ -182,6 +284,7 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
|
|
|
182
284
|
|
|
183
285
|
for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
|
|
184
286
|
for (const key of teamMemberWatchTracker.keys()) if (!seen.has(key)) teamMemberWatchTracker.delete(key);
|
|
287
|
+
for (const key of teamCoordinatorWatchTracker.keys()) if (!seen.has(key)) teamCoordinatorWatchTracker.delete(key);
|
|
185
288
|
|
|
186
289
|
return {
|
|
187
290
|
enabled: true,
|
|
@@ -194,6 +297,9 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
|
|
|
194
297
|
missingCoordinatorTeamIds,
|
|
195
298
|
silentMemberIds: memberSilence.pingedMembers,
|
|
196
299
|
watchedMembers: memberSilence.watchedMembers,
|
|
300
|
+
coordinatorGoneTeamIds: coordinatorGone.escalatedTeamIds,
|
|
301
|
+
coordinatorGoneSuppressedTeamIds: coordinatorGone.suppressedTeamIds,
|
|
302
|
+
goneCoordinatorIds: coordinatorGone.goneCoordinatorIds,
|
|
197
303
|
};
|
|
198
304
|
}
|
|
199
305
|
|
package/src/mcp/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { TEAM_TOOLS, relayTeamTool } from "../mcp-team-tools";
|
|
|
9
9
|
import { BLACKBOARD_TOOLS, relayBlackboardTool } from "../mcp-blackboard-tools";
|
|
10
10
|
import { PROJECT_TOOLS, relayProjectTool } from "../mcp-project-tools"; import { ISSUE_REF_TOOLS, relayIssueRefTool } from "../mcp-issue-ref-tools"; import { ISSUE_ASSOCIATION_TOOLS, relayIssueAssociationTool } from "../mcp-issue-association-tools"; import { ISSUE_ACTIVITY_TOOLS, relayIssueActivityTool } from "../mcp-issue-activity-tools"; import { ISSUE_LIFECYCLE_TOOLS, relayIssueLifecycleTool } from "../mcp-issue-lifecycle-tools";
|
|
11
11
|
import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "../mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "../mcp-merge-tools";
|
|
12
|
+
import { NTFY_TOOLS, relayNotifyNtfy } from "../mcp-ntfy-tools";
|
|
12
13
|
import { AGENT_ACTION_TOOL, relayAgentAction } from "../mcp-agent-action"; import { providerCatalogToolInputSchema } from "../mcp-provider-catalog";
|
|
13
14
|
import type { ActivityKind } from "../types";
|
|
14
15
|
import { worktreeMcpInstructions } from "../workspace-phase";
|
|
@@ -33,6 +34,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
33
34
|
...BLACKBOARD_TOOLS,
|
|
34
35
|
...PROJECT_TOOLS,
|
|
35
36
|
...NOTIFICATION_SUBSCRIPTION_TOOLS,
|
|
37
|
+
...NTFY_TOOLS,
|
|
36
38
|
];
|
|
37
39
|
|
|
38
40
|
export async function postMcp(req: Request): Promise<Response> {
|
|
@@ -144,6 +146,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
144
146
|
else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
|
|
145
147
|
else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args); else if (name.startsWith("relay_issue_ref_")) result = relayIssueRefTool(auth, name, args); else if (name.startsWith("relay_issue_association_")) result = relayIssueAssociationTool(auth, name, args); else if (name.startsWith("relay_issue_activity_")) result = relayIssueActivityTool(auth, name, args); else if (name.startsWith("relay_issue_lifecycle_")) result = await relayIssueLifecycleTool(auth, name, args);
|
|
146
148
|
else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions") result = relayNotificationSubscriptionTool(auth, name, args);
|
|
149
|
+
else if (name === "relay_notify_ntfy") result = await relayNotifyNtfy(args);
|
|
147
150
|
else throw new ValidationError(`unknown tool: ${name}`);
|
|
148
151
|
|
|
149
152
|
auditToolCall(auth, name, "ok", result);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { MAX_BODY_BYTES } from "./config";
|
|
2
|
+
import { sendNtfyNotification, NTFY_PRIORITIES, type NtfyNotificationResult, type NtfyPriority } from "./ntfy";
|
|
3
|
+
import { optionalEnum } from "./validation";
|
|
4
|
+
import type { ToolDefinition } from "./mcp/types";
|
|
5
|
+
import { optionalString, optionalStringArray, stringField } from "./mcp/validation";
|
|
6
|
+
|
|
7
|
+
export const NTFY_TOOLS: ToolDefinition[] = [
|
|
8
|
+
{
|
|
9
|
+
name: "relay_notify_ntfy",
|
|
10
|
+
description: "Send an egress-only ntfy notification to the configured operator topic. Supports title, body, priority min→urgent, optional tags, and click-through link.",
|
|
11
|
+
requiredScopes: ["notifications:write"],
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
title: { type: "string" },
|
|
16
|
+
body: { type: "string" },
|
|
17
|
+
priority: { type: "string", enum: NTFY_PRIORITIES },
|
|
18
|
+
tags: { type: "array", items: { type: "string" } },
|
|
19
|
+
click: { type: "string" },
|
|
20
|
+
},
|
|
21
|
+
required: ["title", "body"],
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export async function relayNotifyNtfy(args: Record<string, unknown>): Promise<NtfyNotificationResult> {
|
|
28
|
+
return sendNtfyNotification({
|
|
29
|
+
title: stringField(args.title, "title", { required: true, max: 200 }),
|
|
30
|
+
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
31
|
+
priority: optionalEnum(args.priority, "priority", NTFY_PRIORITIES) as NtfyPriority | undefined,
|
|
32
|
+
tags: optionalStringArray(args.tags, "tags"),
|
|
33
|
+
click: optionalString(args.click, "click", 1000),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -5,6 +5,7 @@ export type NotificationType =
|
|
|
5
5
|
| "agent.exited"
|
|
6
6
|
| "agent.spawn_failed"
|
|
7
7
|
| "agent.context_threshold"
|
|
8
|
+
| "agent.stuck_busy"
|
|
8
9
|
| "agent.quota_threshold"
|
|
9
10
|
| "agent.resume_budget_warning"
|
|
10
11
|
| "agent.resume_budget_exhausted"
|
|
@@ -12,6 +13,7 @@ export type NotificationType =
|
|
|
12
13
|
| "agent.rate_limit_resume"
|
|
13
14
|
| "team.idle"
|
|
14
15
|
| "team.member_silent"
|
|
16
|
+
| "team.coordinator_gone"
|
|
15
17
|
| "plan.bound_agent_exited"
|
|
16
18
|
| "workspace.steward_task"
|
|
17
19
|
| "workspace.steward_handoff"
|
|
@@ -58,6 +60,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
58
60
|
"agent.exited": null,
|
|
59
61
|
"agent.spawn_failed": null,
|
|
60
62
|
"agent.context_threshold": 15 * MINUTES,
|
|
63
|
+
"agent.stuck_busy": null,
|
|
61
64
|
"agent.quota_threshold": 15 * MINUTES,
|
|
62
65
|
"agent.resume_budget_warning": null,
|
|
63
66
|
"agent.resume_budget_exhausted": null,
|
|
@@ -65,6 +68,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
65
68
|
"agent.rate_limit_resume": 15 * MINUTES,
|
|
66
69
|
"team.idle": null,
|
|
67
70
|
"team.member_silent": null,
|
|
71
|
+
"team.coordinator_gone": null,
|
|
68
72
|
"plan.bound_agent_exited": null,
|
|
69
73
|
"workspace.steward_task": null,
|
|
70
74
|
"workspace.steward_handoff": null,
|
package/src/ntfy.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { MAX_BODY_BYTES } from "./config";
|
|
3
|
+
import { getConfig } from "./config-store";
|
|
4
|
+
import { upsertAgent, ValidationError } from "./db";
|
|
5
|
+
import { registerChannelTypeSetting } from "./settings/modules";
|
|
6
|
+
import { cleanString, cleanStringArray } from "./validation";
|
|
7
|
+
import { errMessage, isRecord, type JSONSchema } from "agent-relay-sdk";
|
|
8
|
+
|
|
9
|
+
export const NTFY_CONFIG_NAMESPACE = "channel-type";
|
|
10
|
+
export const NTFY_CONFIG_KEY = "ntfy";
|
|
11
|
+
export const NTFY_CHANNEL_ID = "ntfy:default";
|
|
12
|
+
|
|
13
|
+
export const NTFY_PRIORITIES = ["min", "low", "default", "high", "urgent"] as const;
|
|
14
|
+
export type NtfyPriority = (typeof NTFY_PRIORITIES)[number];
|
|
15
|
+
|
|
16
|
+
export interface NtfyChannelConfig {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
serverUrl: string;
|
|
19
|
+
topic: string;
|
|
20
|
+
tokenEnv?: string;
|
|
21
|
+
username?: string;
|
|
22
|
+
passwordEnv?: string;
|
|
23
|
+
defaultTags: string[];
|
|
24
|
+
defaultClick?: string;
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NtfyNotificationInput {
|
|
29
|
+
title: string;
|
|
30
|
+
body: string;
|
|
31
|
+
priority?: NtfyPriority;
|
|
32
|
+
tags?: string[];
|
|
33
|
+
click?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NtfyNotificationResult {
|
|
37
|
+
ok: true;
|
|
38
|
+
status: number;
|
|
39
|
+
channelId: string;
|
|
40
|
+
topic: string;
|
|
41
|
+
priority: NtfyPriority;
|
|
42
|
+
tags: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_NTFY_CONFIG: NtfyChannelConfig = {
|
|
46
|
+
enabled: false,
|
|
47
|
+
serverUrl: "",
|
|
48
|
+
topic: "",
|
|
49
|
+
defaultTags: [],
|
|
50
|
+
timeoutMs: 15_000,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const NTFY_CONFIG_SCHEMA: JSONSchema = {
|
|
54
|
+
type: "object",
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
required: ["enabled", "serverUrl", "topic"],
|
|
57
|
+
properties: {
|
|
58
|
+
enabled: { type: "boolean" },
|
|
59
|
+
serverUrl: { type: "string", maxLength: 500 },
|
|
60
|
+
topic: { type: "string", maxLength: 200 },
|
|
61
|
+
tokenEnv: { type: "string", maxLength: 120, pattern: "^[A-Z0-9_]+$" },
|
|
62
|
+
username: { type: "string", maxLength: 120 },
|
|
63
|
+
passwordEnv: { type: "string", maxLength: 120, pattern: "^[A-Z0-9_]+$" },
|
|
64
|
+
defaultTags: { type: "array", maxItems: 20, items: { type: "string", maxLength: 40 } },
|
|
65
|
+
defaultClick: { type: "string", maxLength: 1000 },
|
|
66
|
+
timeoutMs: { type: "integer", minimum: 1_000, maximum: 60_000 },
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
registerChannelTypeSetting({
|
|
71
|
+
type: NTFY_CONFIG_KEY,
|
|
72
|
+
schema: NTFY_CONFIG_SCHEMA,
|
|
73
|
+
defaults: DEFAULT_NTFY_CONFIG,
|
|
74
|
+
display: {
|
|
75
|
+
label: "ntfy outgoing channel",
|
|
76
|
+
description: "Configures the built-in egress-only ntfy notification channel.",
|
|
77
|
+
order: 121,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export function getNtfyConfig(): NtfyChannelConfig {
|
|
82
|
+
const stored = getConfig<Partial<NtfyChannelConfig>>(NTFY_CONFIG_NAMESPACE, NTFY_CONFIG_KEY)?.value;
|
|
83
|
+
return normalizeNtfyConfig({ ...DEFAULT_NTFY_CONFIG, ...(isRecord(stored) ? stored : {}) });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function sendNtfyNotification(input: NtfyNotificationInput): Promise<NtfyNotificationResult> {
|
|
87
|
+
const config = getNtfyConfig();
|
|
88
|
+
validateSendConfig(config);
|
|
89
|
+
const title = cleanString(input.title, "title", { required: true, max: 200 })!;
|
|
90
|
+
const body = cleanString(input.body, "body", { required: true, max: MAX_BODY_BYTES })!;
|
|
91
|
+
const priority = input.priority ?? "default";
|
|
92
|
+
const tags = [...new Set([...(config.defaultTags ?? []), ...(input.tags ?? [])].map((tag) => tag.trim()).filter(Boolean))];
|
|
93
|
+
const click = cleanString(input.click ?? config.defaultClick, "click", { max: 1000 });
|
|
94
|
+
const url = ntfyTopicUrl(config);
|
|
95
|
+
const headers = ntfyHeaders({ config, title, priority, tags, click });
|
|
96
|
+
|
|
97
|
+
ensureNtfyChannelRegistered(config);
|
|
98
|
+
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
101
|
+
let response: Response;
|
|
102
|
+
try {
|
|
103
|
+
response = await fetch(url, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers,
|
|
106
|
+
body,
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (controller.signal.aborted) throw new Error(`ntfy notify timed out after ${config.timeoutMs}ms`);
|
|
111
|
+
throw new Error(`ntfy notify failed: ${errMessage(error)}`);
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
const text = await response.text().catch(() => "");
|
|
118
|
+
throw new Error(`ntfy notify failed: HTTP ${response.status}${text ? `: ${text.slice(0, 300)}` : ""}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { ok: true, status: response.status, channelId: NTFY_CHANNEL_ID, topic: config.topic, priority, tags };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeNtfyConfig(value: unknown): NtfyChannelConfig {
|
|
125
|
+
if (!isRecord(value)) throw new ValidationError("ntfy channel config must be an object");
|
|
126
|
+
const timeoutMs = value.timeoutMs === undefined ? DEFAULT_NTFY_CONFIG.timeoutMs : value.timeoutMs;
|
|
127
|
+
if (typeof value.enabled !== "boolean") throw new ValidationError("ntfy.enabled must be a boolean");
|
|
128
|
+
if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 60_000) {
|
|
129
|
+
throw new ValidationError("ntfy.timeoutMs must be an integer between 1000 and 60000");
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
enabled: value.enabled,
|
|
133
|
+
serverUrl: cleanString(value.serverUrl, "ntfy.serverUrl", { max: 500 }) ?? "",
|
|
134
|
+
topic: cleanString(value.topic, "ntfy.topic", { max: 200 }) ?? "",
|
|
135
|
+
tokenEnv: cleanString(value.tokenEnv, "ntfy.tokenEnv", { max: 120 }),
|
|
136
|
+
username: cleanString(value.username, "ntfy.username", { max: 120 }),
|
|
137
|
+
passwordEnv: cleanString(value.passwordEnv, "ntfy.passwordEnv", { max: 120 }),
|
|
138
|
+
defaultTags: cleanStringArray(value.defaultTags, "ntfy.defaultTags", { itemMax: 40, maxItems: 20 }) ?? [],
|
|
139
|
+
defaultClick: cleanString(value.defaultClick, "ntfy.defaultClick", { max: 1000 }),
|
|
140
|
+
timeoutMs,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateSendConfig(config: NtfyChannelConfig): void {
|
|
145
|
+
if (!config.enabled) throw new ValidationError("ntfy channel is disabled");
|
|
146
|
+
if (!config.serverUrl || !config.topic) {
|
|
147
|
+
throw new ValidationError("ntfy channel requires channel-type/ntfy serverUrl and topic");
|
|
148
|
+
}
|
|
149
|
+
if (config.topic.includes("/") || config.topic.includes("?") || config.topic.includes("#")) {
|
|
150
|
+
throw new ValidationError("ntfy.topic must be a single topic name, not a path or URL");
|
|
151
|
+
}
|
|
152
|
+
if (config.tokenEnv && (config.username || config.passwordEnv)) {
|
|
153
|
+
throw new ValidationError("ntfy auth must use tokenEnv or username/passwordEnv, not both");
|
|
154
|
+
}
|
|
155
|
+
if (config.passwordEnv && !config.username) throw new ValidationError("ntfy.username required when passwordEnv is set");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ntfyTopicUrl(config: NtfyChannelConfig): string {
|
|
159
|
+
let base: URL;
|
|
160
|
+
try {
|
|
161
|
+
base = new URL(config.serverUrl.endsWith("/") ? config.serverUrl : `${config.serverUrl}/`);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new ValidationError("ntfy.serverUrl must be a valid URL");
|
|
164
|
+
}
|
|
165
|
+
return new URL(encodeURIComponent(config.topic), base).toString();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ntfyHeaders(input: {
|
|
169
|
+
config: NtfyChannelConfig;
|
|
170
|
+
title: string;
|
|
171
|
+
priority: NtfyPriority;
|
|
172
|
+
tags: string[];
|
|
173
|
+
click?: string;
|
|
174
|
+
}): Record<string, string> {
|
|
175
|
+
const headers: Record<string, string> = {
|
|
176
|
+
Title: input.title,
|
|
177
|
+
Priority: String(priorityValue(input.priority)),
|
|
178
|
+
};
|
|
179
|
+
if (input.tags.length) headers.Tags = input.tags.join(",");
|
|
180
|
+
if (input.click) headers.Click = input.click;
|
|
181
|
+
const authorization = authHeader(input.config);
|
|
182
|
+
if (authorization) headers.Authorization = authorization;
|
|
183
|
+
return headers;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function authHeader(config: NtfyChannelConfig): string | undefined {
|
|
187
|
+
if (config.tokenEnv) {
|
|
188
|
+
const token = cleanString(process.env[config.tokenEnv], config.tokenEnv, { required: true, max: 4_000 })!;
|
|
189
|
+
return `Bearer ${token}`;
|
|
190
|
+
}
|
|
191
|
+
if (config.username && config.passwordEnv) {
|
|
192
|
+
const password = cleanString(process.env[config.passwordEnv], config.passwordEnv, { required: true, max: 4_000 })!;
|
|
193
|
+
return `Basic ${Buffer.from(`${config.username}:${password}`).toString("base64")}`;
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function priorityValue(priority: NtfyPriority): number {
|
|
199
|
+
return NTFY_PRIORITIES.indexOf(priority) + 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ensureNtfyChannelRegistered(config: NtfyChannelConfig): void {
|
|
203
|
+
upsertAgent({
|
|
204
|
+
id: NTFY_CHANNEL_ID,
|
|
205
|
+
name: "ntfy",
|
|
206
|
+
kind: "channel",
|
|
207
|
+
tags: ["channel", "ntfy", "channel:ntfy"],
|
|
208
|
+
capabilities: ["channel", "ntfy", "notify"],
|
|
209
|
+
status: "online",
|
|
210
|
+
ready: true,
|
|
211
|
+
meta: {
|
|
212
|
+
kind: "channel",
|
|
213
|
+
provider: "ntfy",
|
|
214
|
+
accountId: "default",
|
|
215
|
+
channelType: "ntfy",
|
|
216
|
+
transport: "ntfy",
|
|
217
|
+
direction: "outbound",
|
|
218
|
+
displayName: "ntfy",
|
|
219
|
+
topicChannels: [config.topic],
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
package/src/quota-advisory.ts
CHANGED
|
@@ -29,6 +29,7 @@ type AgentRecipientRow = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
const RESET_ROUNDING_PERCENT = 99;
|
|
32
|
+
const QUOTA_THRESHOLD_EPSILON = 1e-9;
|
|
32
33
|
|
|
33
34
|
export function installQuotaAdvisoryHandler(): void {
|
|
34
35
|
setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
|
|
@@ -62,14 +63,14 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
62
63
|
continue;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
if (remaining
|
|
66
|
+
if (remaining - config.warnRemaining > QUOTA_THRESHOLD_EPSILON) {
|
|
66
67
|
if (previous) delete nextState[window.name];
|
|
67
68
|
continue;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
const band = quotaRemainingBand(remaining, config.bandSize);
|
|
71
72
|
if (previous?.lastQuotaAdvisoryBand === band) continue;
|
|
72
|
-
const stage: QuotaAdvisoryStage = remaining
|
|
73
|
+
const stage: QuotaAdvisoryStage = remaining - config.criticalRemaining <= QUOTA_THRESHOLD_EPSILON ? "critical" : "warn";
|
|
73
74
|
notifyQuotaAdvisory({
|
|
74
75
|
provider: input.provider,
|
|
75
76
|
accountKey: input.accountKey,
|
|
@@ -4,7 +4,7 @@ import type { NotificationsConfig } from "./types";
|
|
|
4
4
|
|
|
5
5
|
export const DEFAULT_QUOTA_THRESHOLD_CONFIG: NotificationsConfig["quotaThreshold"] = {
|
|
6
6
|
enabled: true,
|
|
7
|
-
warnRemaining: 0.
|
|
7
|
+
warnRemaining: 0.15,
|
|
8
8
|
criticalRemaining: 0.1,
|
|
9
9
|
resetRemaining: 0.99,
|
|
10
10
|
bandSize: 0.05,
|
package/src/routes/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute,
|
|
|
14
14
|
import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
|
|
15
15
|
import { deleteMessageById, getCursorRoute, getMessageById, getMessageDeliveryRoute, getMessageStatusRoute, getMessageThread, getMessages, getQueuedMessagesRoute, patchMessage, postClaimMessage, postMessage, postMessageDeliveryAction, postMessageDeliveryAttempt, postMessageReaction, postRenewMessageClaim, postSystemBroadcast } from "./messages";
|
|
16
16
|
import { deleteOrchestratorById, getOrchestratorById, getOrchestrators, patchOrchestratorAgents, postOrchestrator, postOrchestratorAction, postOrchestratorHeartbeat } from "./orchestrator";
|
|
17
|
-
import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
|
|
17
|
+
import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyDiagnostics, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
|
|
18
18
|
import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
|
|
19
19
|
import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceRecoveryBranches, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim, postWorkspaceRecoveryBranchDiscard } from "./workspaces";
|
|
20
20
|
import { error, type Handler } from "./_shared";
|
|
@@ -259,6 +259,7 @@ const routes: Route[] = [
|
|
|
259
259
|
route("GET", "/api/spawn-policies/health", getSpawnPoliciesHealth),
|
|
260
260
|
route("GET", "/api/spawn-policy/:name/status", getSpawnPolicyStatus),
|
|
261
261
|
route("GET", "/api/spawn-policy/:name/health", getSpawnPolicyHealth),
|
|
262
|
+
route("GET", "/api/spawn-policy/:name/diagnostics", getSpawnPolicyDiagnostics),
|
|
262
263
|
route("GET", "/api/providers", getProvidersRoute),
|
|
263
264
|
route("GET", "/api/providers/config", getProviderConfigsRoute),
|
|
264
265
|
route("GET", "/api/providers/quota-config", getProviderQuotaConfigsRoute),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: spawn-policy.
|
|
2
|
-
import { ValidationError, getOrchestrator } from "../db";
|
|
3
|
-
import { auditEvent, authAuditMetadata, dashboardAttribution, emitCommand, error, json, parseBody, spawnRequestId, type Handler } from "./_shared";
|
|
2
|
+
import { ValidationError, getCrashReport, getManagedPolicyTimeline, getOrchestrator } from "../db";
|
|
3
|
+
import { auditEvent, authAuditMetadata, dashboardAttribution, emitCommand, error, json, parseBody, parseQueryInt, spawnRequestId, type Handler } from "./_shared";
|
|
4
4
|
import { buildManagedSpawnParams } from "../managed-policy";
|
|
5
5
|
import { cleanString } from "../validation";
|
|
6
6
|
import { createCommand } from "../commands-db";
|
|
@@ -229,3 +229,20 @@ export const getSpawnPolicyHealth: Handler = (_req, params) => {
|
|
|
229
229
|
const entry = getSpawnPolicy(params.name!);
|
|
230
230
|
return entry ? json(policyStatusPayload(entry.value)) : error("spawn policy not found", 404);
|
|
231
231
|
};
|
|
232
|
+
|
|
233
|
+
export const getSpawnPolicyDiagnostics: Handler = (req, params) => {
|
|
234
|
+
const entry = getSpawnPolicy(params.name!);
|
|
235
|
+
if (!entry) return error("spawn policy not found", 404);
|
|
236
|
+
const url = new URL(req.url);
|
|
237
|
+
const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 200, clamp: true });
|
|
238
|
+
if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 200");
|
|
239
|
+
const sinceRaw = parseQueryInt(url.searchParams.get("since"), { min: 0, max: Number.MAX_SAFE_INTEGER });
|
|
240
|
+
if (Number.isNaN(sinceRaw)) return error("since must be a non-negative integer");
|
|
241
|
+
const status = policyStatusPayload(entry.value);
|
|
242
|
+
const agentId = typeof status.state.agentId === "string" ? status.state.agentId : undefined;
|
|
243
|
+
return json({
|
|
244
|
+
...status,
|
|
245
|
+
timeline: getManagedPolicyTimeline(entry.value.name, { limit: limitRaw ?? 100, since: sinceRaw ?? undefined }),
|
|
246
|
+
crashReport: agentId ? getCrashReport(agentId) : null,
|
|
247
|
+
});
|
|
248
|
+
};
|
package/src/team-idle-tracker.ts
CHANGED
|
@@ -26,3 +26,22 @@ export interface TeamMemberWatch {
|
|
|
26
26
|
|
|
27
27
|
// teamId → (memberId → watch)
|
|
28
28
|
export const teamMemberWatchTracker = new Map<string, Map<string, TeamMemberWatch>>();
|
|
29
|
+
|
|
30
|
+
// #412/#468 — per-team COORDINATOR liveness watch. The member watch above deliberately skips the
|
|
31
|
+
// coordinator (it's the ping recipient, not a watched worker), so a coordinator that dies silently
|
|
32
|
+
// is invisible to it. This tracker remembers the coordinator's last-alive observation so a later
|
|
33
|
+
// silence escalates ONCE to the human/steward fallback instead of dead-ending — a coordinator-gone
|
|
34
|
+
// team must never be silently abandoned.
|
|
35
|
+
export interface TeamCoordinatorWatch {
|
|
36
|
+
/** Identity of the coordinator this watch is for; a re-elected/replaced coordinator resets it. */
|
|
37
|
+
coordinatorId: string;
|
|
38
|
+
/** Most recent time the coordinator was seen alive (non-offline, fresh heartbeat). */
|
|
39
|
+
lastActiveAt: number;
|
|
40
|
+
/** Whether the coordinator's CURRENT gone-spell already escalated (re-armed when it recovers). */
|
|
41
|
+
escalated: boolean;
|
|
42
|
+
/** Coordinator label for a readable escalation. */
|
|
43
|
+
label?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// teamId → coordinator watch
|
|
47
|
+
export const teamCoordinatorWatchTracker = new Map<string, TeamCoordinatorWatch>();
|