agent-relay-server 0.88.1 → 0.88.3
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/src/adapter.ts +4 -0
- package/src/agent-lifecycle-events.ts +74 -4
- package/src/commands-db.ts +4 -1
- package/src/db/migrations.ts +16 -0
- package/src/db/provider-quotas.ts +71 -13
- package/src/db/terminal-events.ts +17 -11
- package/src/maintenance/teams.ts +7 -2
- package/src/notification-types.ts +2 -0
- package/src/quota-advisory.ts +27 -3
- package/src/services/transient-agent-reaper.ts +1 -1
- package/src/team-idle-tracker.ts +6 -0
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.88.
|
|
5
|
+
"version": "0.88.3",
|
|
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/runner/src/adapter.ts
CHANGED
|
@@ -25,6 +25,10 @@ export interface ProviderStatusEvent {
|
|
|
25
25
|
label?: string;
|
|
26
26
|
role?: string;
|
|
27
27
|
parentId?: string;
|
|
28
|
+
// OS pids of the process(es) this busy work tracks (#650). When supplied, the
|
|
29
|
+
// runner reconciles them against actual liveness and clears the busy marker if
|
|
30
|
+
// every pid is dead — recovering from a tracked process killed out-of-band.
|
|
31
|
+
pids?: number[];
|
|
28
32
|
providerState?: Record<string, unknown>;
|
|
29
33
|
metadata?: Record<string, unknown>;
|
|
30
34
|
}
|
|
@@ -28,6 +28,34 @@ function diagnosisSuffix(diagnosis?: AgentDeathDiagnosis): string {
|
|
|
28
28
|
// falls back to the persisted stamp, so a post-restart clean exit still reads as `agent.exited`.
|
|
29
29
|
const readyFired = new Set<string>();
|
|
30
30
|
|
|
31
|
+
// #648 — terminal-outcome guard per spawnRequestId, enforcing the invariant "exactly one terminal
|
|
32
|
+
// event per spawn". `agent.ready` and `agent.spawn_failed` are mutually exclusive for one request,
|
|
33
|
+
// yet two paths can race: the orchestrator can report the `agent.spawn` COMMAND failed (a transient
|
|
34
|
+
// launch/registration timeout) at the same moment the child actually registers and becomes ready.
|
|
35
|
+
// Observed live (issue #648): spawn_failed delivered, then ready for the SAME spawnRequestId — a
|
|
36
|
+
// false negative a coordinator acts on (double-spawn or false-abandon).
|
|
37
|
+
//
|
|
38
|
+
// We record the first terminal seen, keyed on spawnRequestId (the idempotency key shared by the
|
|
39
|
+
// command's `params.spawnRequestId` and the child's `meta.spawnRequestId`):
|
|
40
|
+
// - a late spawn_failed after ready → SUPPRESSED (the agent is alive, the failure is wrong)
|
|
41
|
+
// - a ready after a delivered spawn_failed → CORRECTED with `agent.spawn_recovered` so a
|
|
42
|
+
// coordinator that already acted on the false failure can reconcile (do not retry / un-abandon).
|
|
43
|
+
// `ready` is sticky — once a request is ready it never downgrades to failed. In-memory like
|
|
44
|
+
// `readyFired`: the race it guards is same-moment, so a relay restart between the two is not a concern.
|
|
45
|
+
type SpawnTerminal = "agent.ready" | "agent.spawn_failed";
|
|
46
|
+
const spawnTerminalBySpawnRequest = new Map<string, SpawnTerminal>();
|
|
47
|
+
|
|
48
|
+
function spawnTerminalOf(spawnRequestId: string | undefined): SpawnTerminal | undefined {
|
|
49
|
+
return spawnRequestId === undefined ? undefined : spawnTerminalBySpawnRequest.get(spawnRequestId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function recordSpawnTerminal(spawnRequestId: string | undefined, outcome: SpawnTerminal): void {
|
|
53
|
+
if (spawnRequestId === undefined) return;
|
|
54
|
+
// ready is terminal-sticky: never let a (stale/contradicting) spawn_failed overwrite a live ready.
|
|
55
|
+
if (spawnTerminalBySpawnRequest.get(spawnRequestId) === "agent.ready") return;
|
|
56
|
+
spawnTerminalBySpawnRequest.set(spawnRequestId, outcome);
|
|
57
|
+
}
|
|
58
|
+
|
|
31
59
|
/** A child counts as "had become ready" if the live set saw it OR the durable stamp survives a restart. */
|
|
32
60
|
function everReady(child: AgentCard): boolean {
|
|
33
61
|
return typeof child.meta?.readyNotifiedAt === "number";
|
|
@@ -63,15 +91,48 @@ export function notifyAgentReady(childId: string): void {
|
|
|
63
91
|
// (the in-memory set is wiped on restart). Idempotent — only stamped on first ready.
|
|
64
92
|
markAgentReadyNotified(childId);
|
|
65
93
|
|
|
94
|
+
const spawnRequestId = spawnRequestIdOf(child);
|
|
95
|
+
// #648 — if a spawn_failed was already emitted for this request, this ready RETRACTS it: the agent
|
|
96
|
+
// actually came up, so the earlier failure was a false negative. Emit a corrective recovery instead
|
|
97
|
+
// of a plain ready notice so a coordinator that acted on the failure can reconcile.
|
|
98
|
+
const recovered = spawnTerminalOf(spawnRequestId) === "agent.spawn_failed";
|
|
99
|
+
recordSpawnTerminal(spawnRequestId, "agent.ready");
|
|
100
|
+
|
|
66
101
|
emitRelayEvent({
|
|
67
102
|
type: "agent.ready",
|
|
68
103
|
source: "server",
|
|
69
104
|
subject: childId,
|
|
70
|
-
data: { agentId: childId, parent, provider: providerOf(child), label: child.label, spawnRequestId
|
|
105
|
+
data: { agentId: childId, parent, provider: providerOf(child), label: child.label, spawnRequestId },
|
|
71
106
|
});
|
|
72
107
|
|
|
108
|
+
if (recovered) {
|
|
109
|
+
emitRelayEvent({
|
|
110
|
+
type: "agent.spawn_recovered",
|
|
111
|
+
source: "server",
|
|
112
|
+
subject: childId,
|
|
113
|
+
data: { agentId: childId, parent, provider: providerOf(child), label: child.label, spawnRequestId, supersedes: "agent.spawn_failed" },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
73
117
|
const config = getNotificationsConfig();
|
|
74
|
-
if (!config.enabled
|
|
118
|
+
if (!config.enabled) return;
|
|
119
|
+
if (recovered) {
|
|
120
|
+
// A correction must always reach a parent that got the (false) failure — gate only on the global
|
|
121
|
+
// switch, NOT the agentReady sub-toggle, so the retraction lands even if ready pushes are off.
|
|
122
|
+
routeNotification({
|
|
123
|
+
type: "agent.spawn_recovered",
|
|
124
|
+
scope: { agentId: childId, parent },
|
|
125
|
+
recipients: [parent],
|
|
126
|
+
message: {
|
|
127
|
+
subject: "Spawn recovered",
|
|
128
|
+
body: `♻️ Correction: your earlier spawn failure${spawnRequestId ? ` for \`${spawnRequestId}\`` : ""} was a false alarm — ${describe(child)} actually registered and is ready and idle. Disregard the failure and do NOT retry; send it work with relay_send_message to \`${childId}\`.`,
|
|
129
|
+
payload: { kind: "agent.spawn_recovered", agentId: childId, parent, provider: providerOf(child), spawnRequestId, supersedes: "agent.spawn_failed" },
|
|
130
|
+
replyExpected: false,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!config.agentReady) return;
|
|
75
136
|
routeNotification({
|
|
76
137
|
type: "agent.ready",
|
|
77
138
|
scope: { agentId: childId, parent },
|
|
@@ -79,7 +140,7 @@ export function notifyAgentReady(childId: string): void {
|
|
|
79
140
|
message: {
|
|
80
141
|
subject: "Spawned agent ready",
|
|
81
142
|
body: `✅ Your spawned agent ${describe(child)} is ready and idle — send it work with relay_send_message to \`${childId}\`.`,
|
|
82
|
-
payload: { kind: "agent.ready", agentId: childId, parent, provider: providerOf(child), spawnRequestId
|
|
143
|
+
payload: { kind: "agent.ready", agentId: childId, parent, provider: providerOf(child), spawnRequestId },
|
|
83
144
|
replyExpected: false,
|
|
84
145
|
},
|
|
85
146
|
});
|
|
@@ -108,7 +169,7 @@ export function notifyAgentOffline(childId: string, reason: string, diagnosis?:
|
|
|
108
169
|
// #645 — persist a terminal marker for ANY offline terminus (clean exit or spawn-fail) keyed by
|
|
109
170
|
// agentId so it outlives the reaper. The #634 watchdog reads this to skip a benign shutdown; it
|
|
110
171
|
// must fire ONLY on a genuine silent death that emitted no terminal event at all.
|
|
111
|
-
recordTerminalEvent({ agentId: childId, reason, kind: wasReady ? "agent.exited" : "agent.spawn_failed", createdAt: Date.now() });
|
|
172
|
+
recordTerminalEvent({ agentId: childId, reason, kind: wasReady ? "agent.exited" : "agent.spawn_failed", epoch: child.epoch, createdAt: Date.now() });
|
|
112
173
|
|
|
113
174
|
if (wasReady) {
|
|
114
175
|
emitRelayEvent({
|
|
@@ -132,6 +193,9 @@ export function notifyAgentOffline(childId: string, reason: string, diagnosis?:
|
|
|
132
193
|
return;
|
|
133
194
|
}
|
|
134
195
|
|
|
196
|
+
// #648 — record the terminal outcome so a (defensive) later ready for the same request is detected
|
|
197
|
+
// as a recovery and corrected, keeping the one-terminal-per-spawn invariant across both fail paths.
|
|
198
|
+
recordSpawnTerminal(spawnRequestIdOf(child), "agent.spawn_failed");
|
|
135
199
|
emitRelayEvent({
|
|
136
200
|
type: "agent.spawn_failed",
|
|
137
201
|
source: "server",
|
|
@@ -158,6 +222,11 @@ export function notifyAgentOffline(childId: string, reason: string, diagnosis?:
|
|
|
158
222
|
* to the spawning parent threaded on the command, since there is no child agent record to resolve.
|
|
159
223
|
*/
|
|
160
224
|
export function notifyAgentSpawnFailed(input: { parent: string; spawnRequestId?: string; provider?: string; reason: string }): void {
|
|
225
|
+
// #648 — if the spawn already became ready, the orchestrator's command-failure report is a false
|
|
226
|
+
// negative (the child registered despite the spawn command reporting failure). Suppress it: the
|
|
227
|
+
// ready already delivered the authoritative terminal outcome for this request.
|
|
228
|
+
if (spawnTerminalOf(input.spawnRequestId) === "agent.ready") return;
|
|
229
|
+
recordSpawnTerminal(input.spawnRequestId, "agent.spawn_failed");
|
|
161
230
|
emitRelayEvent({
|
|
162
231
|
type: "agent.spawn_failed",
|
|
163
232
|
source: "server",
|
|
@@ -182,4 +251,5 @@ export function notifyAgentSpawnFailed(input: { parent: string; spawnRequestId?:
|
|
|
182
251
|
/** Test seam: clear the in-memory ready-tracking set between tests. */
|
|
183
252
|
export function __resetReadyTracking(): void {
|
|
184
253
|
readyFired.clear();
|
|
254
|
+
spawnTerminalBySpawnRequest.clear();
|
|
185
255
|
}
|
package/src/commands-db.ts
CHANGED
|
@@ -139,13 +139,16 @@ function defaultExpiresAt(type: string, now: number): number | undefined {
|
|
|
139
139
|
function recordShutdownTerminalEventOnSuccess(previousStatus: CommandStatus, command: Command): void {
|
|
140
140
|
if (command.type !== "agent.shutdown" || command.status !== "succeeded" || previousStatus === "succeeded") return;
|
|
141
141
|
const agentId = stringValue(command.result?.agentId) ?? stringValue(command.params?.agentId);
|
|
142
|
-
if (!agentId
|
|
142
|
+
if (!agentId) return;
|
|
143
143
|
const agent = getAgent(agentId);
|
|
144
|
+
const epoch = agent?.epoch ?? 0;
|
|
145
|
+
if (getTerminalEvent(agentId, epoch)) return;
|
|
144
146
|
const hadReadySignal = !agent || agent.ready || typeof agent.meta?.readyNotifiedAt === "number";
|
|
145
147
|
recordTerminalEvent({
|
|
146
148
|
agentId,
|
|
147
149
|
reason: stringValue(command.result?.reason) ?? stringValue(command.params?.reason) ?? "shutdown",
|
|
148
150
|
kind: hadReadySignal ? "agent.exited" : "agent.spawn_failed",
|
|
151
|
+
epoch,
|
|
149
152
|
createdAt: Date.now(),
|
|
150
153
|
});
|
|
151
154
|
}
|
package/src/db/migrations.ts
CHANGED
|
@@ -838,6 +838,15 @@ export function applyMigrations(): void {
|
|
|
838
838
|
// writes it), so it adds nothing to the legacy flow.
|
|
839
839
|
ensureDriveLeaseSchema();
|
|
840
840
|
|
|
841
|
+
// #652 — epoch column on terminal_events: a stale event from a prior epoch must NOT suppress the
|
|
842
|
+
// heartbeat-gap alert for a genuine silent death in a later epoch (agent_id reuse across epochs).
|
|
843
|
+
// Default 0 preserves the suppression behaviour for all existing rows written before this migration.
|
|
844
|
+
const terminalEventCols = (getDb().query("PRAGMA table_info(terminal_events)").all() as any[]).map((c: any) => c.name);
|
|
845
|
+
if (!terminalEventCols.includes("epoch")) {
|
|
846
|
+
getDb().run("ALTER TABLE terminal_events ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0");
|
|
847
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_terminal_events_agent ON terminal_events(agent_id, epoch, created_at DESC)");
|
|
848
|
+
}
|
|
849
|
+
|
|
841
850
|
// Built-in agents — registered unconditionally so sends from these ids
|
|
842
851
|
// pass the sendMessage validation. The reaper exempts these by checking
|
|
843
852
|
// meta.builtin (or by id for "user").
|
|
@@ -865,4 +874,11 @@ export function applyMigrations(): void {
|
|
|
865
874
|
`);
|
|
866
875
|
}
|
|
867
876
|
|
|
877
|
+
// #652 follow-up — the epoch migration above used CREATE INDEX IF NOT EXISTS, but existing prod
|
|
878
|
+
// DBs already had idx_terminal_events_agent under the old (agent_id, created_at DESC) shape from
|
|
879
|
+
// #645; that made the new three-column definition a silent no-op. Drop-and-recreate forces the
|
|
880
|
+
// correct shape on all DB vintages; fresh DBs already carry the right shape and are unaffected.
|
|
881
|
+
getDb().run("DROP INDEX IF EXISTS idx_terminal_events_agent");
|
|
882
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_terminal_events_agent ON terminal_events(agent_id, epoch, created_at DESC)");
|
|
883
|
+
|
|
868
884
|
}
|
|
@@ -45,6 +45,10 @@ type ProviderQuotaSnapshotRow = {
|
|
|
45
45
|
last_error?: string | null;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
type StoredQuotaState = QuotaState & {
|
|
49
|
+
_windowUpdatedAt?: Record<string, number>;
|
|
50
|
+
};
|
|
51
|
+
|
|
48
52
|
function cleanKey(value: string): string {
|
|
49
53
|
return value.trim();
|
|
50
54
|
}
|
|
@@ -189,8 +193,48 @@ function quotaWindowKey(window: QuotaState["windows"][number]): string {
|
|
|
189
193
|
return `${window.name}:${window.unit}`;
|
|
190
194
|
}
|
|
191
195
|
|
|
192
|
-
function
|
|
196
|
+
function publicQuotaState(quota: StoredQuotaState): QuotaState {
|
|
197
|
+
return {
|
|
198
|
+
windows: quota.windows.map((window) => ({ ...window })),
|
|
199
|
+
source: quota.source,
|
|
200
|
+
updatedAt: quota.updatedAt,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseStoredQuotaState(value: string, fallback: QuotaState): StoredQuotaState {
|
|
205
|
+
const quota = parseJson<StoredQuotaState>(value, fallback);
|
|
206
|
+
return {
|
|
207
|
+
...publicQuotaState(quota),
|
|
208
|
+
...(quota._windowUpdatedAt && typeof quota._windowUpdatedAt === "object" ? { _windowUpdatedAt: quota._windowUpdatedAt } : {}),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function storedQuotaWindowSampleTimes(quota: StoredQuotaState): Map<string, number> {
|
|
193
213
|
const times = new Map<string, number>();
|
|
214
|
+
const rawTimes = quota._windowUpdatedAt && typeof quota._windowUpdatedAt === "object" ? quota._windowUpdatedAt : {};
|
|
215
|
+
for (const window of quota.windows) {
|
|
216
|
+
const key = quotaWindowKey(window);
|
|
217
|
+
const value = rawTimes[key];
|
|
218
|
+
if (typeof value === "number" && Number.isFinite(value)) times.set(key, value);
|
|
219
|
+
}
|
|
220
|
+
return times;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function quotaWithWindowSampleTimes(quota: QuotaState, times: Map<string, number>): StoredQuotaState {
|
|
224
|
+
const windowUpdatedAt: Record<string, number> = {};
|
|
225
|
+
for (const window of quota.windows) {
|
|
226
|
+
const timestamp = times.get(quotaWindowKey(window));
|
|
227
|
+
if (timestamp !== undefined) windowUpdatedAt[quotaWindowKey(window)] = timestamp;
|
|
228
|
+
}
|
|
229
|
+
return Object.keys(windowUpdatedAt).length > 0
|
|
230
|
+
? { ...quota, _windowUpdatedAt: windowUpdatedAt }
|
|
231
|
+
: quota;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function quotaWindowSampleTimes(provider: string, accountKey: string, fallback?: StoredQuotaState): Map<string, number> {
|
|
235
|
+
const times = new Map<string, number>();
|
|
236
|
+
const storedTimes = fallback ? storedQuotaWindowSampleTimes(fallback) : new Map<string, number>();
|
|
237
|
+
for (const [key, timestamp] of storedTimes) times.set(key, timestamp);
|
|
194
238
|
const rows = getDb().query(`
|
|
195
239
|
SELECT quota_state FROM quota_snapshots
|
|
196
240
|
WHERE provider = ? AND account_key = ? AND unavailable = 0
|
|
@@ -200,6 +244,7 @@ function quotaWindowSampleTimes(provider: string, accountKey: string, fallback?:
|
|
|
200
244
|
const quota = parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: 0 });
|
|
201
245
|
for (const window of quota.windows) {
|
|
202
246
|
const key = quotaWindowKey(window);
|
|
247
|
+
if (storedTimes.has(key)) continue;
|
|
203
248
|
times.set(key, Math.max(times.get(key) ?? 0, quota.updatedAt));
|
|
204
249
|
}
|
|
205
250
|
}
|
|
@@ -212,25 +257,36 @@ function quotaWindowSampleTimes(provider: string, accountKey: string, fallback?:
|
|
|
212
257
|
return times;
|
|
213
258
|
}
|
|
214
259
|
|
|
215
|
-
function mergeQuotaWindows(existing:
|
|
216
|
-
quota:
|
|
260
|
+
function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: QuotaState, provider: string, accountKey: string): {
|
|
261
|
+
quota: StoredQuotaState;
|
|
217
262
|
acceptedIncomingWindow: boolean;
|
|
218
263
|
} {
|
|
219
|
-
if (!existing)
|
|
264
|
+
if (!existing) {
|
|
265
|
+
const times = new Map(incoming.windows.map((window) => [quotaWindowKey(window), incoming.updatedAt]));
|
|
266
|
+
return { quota: quotaWithWindowSampleTimes(incoming, times), acceptedIncomingWindow: true };
|
|
267
|
+
}
|
|
220
268
|
const existingTimes = quotaWindowSampleTimes(provider, accountKey, existing);
|
|
221
269
|
const incomingByKey = new Map(incoming.windows.map((window) => [quotaWindowKey(window), window]));
|
|
270
|
+
const mergedTimes = new Map<string, number>();
|
|
222
271
|
let acceptedIncomingWindow = false;
|
|
223
272
|
let updatedAt = existing.updatedAt;
|
|
224
273
|
let source = existing.source;
|
|
225
274
|
const windows = existing.windows.map((window) => {
|
|
226
275
|
const key = quotaWindowKey(window);
|
|
227
276
|
const incomingWindow = incomingByKey.get(key);
|
|
228
|
-
if (!incomingWindow) return window;
|
|
229
277
|
const existingUpdatedAt = existingTimes.get(key) ?? existing.updatedAt;
|
|
230
|
-
if (
|
|
278
|
+
if (!incomingWindow) {
|
|
279
|
+
mergedTimes.set(key, existingUpdatedAt);
|
|
280
|
+
return window;
|
|
281
|
+
}
|
|
282
|
+
if (incoming.updatedAt < existingUpdatedAt) {
|
|
283
|
+
mergedTimes.set(key, existingUpdatedAt);
|
|
284
|
+
return window;
|
|
285
|
+
}
|
|
231
286
|
acceptedIncomingWindow = true;
|
|
232
287
|
updatedAt = Math.max(updatedAt, incoming.updatedAt);
|
|
233
288
|
source = incoming.source;
|
|
289
|
+
mergedTimes.set(key, incoming.updatedAt);
|
|
234
290
|
return incomingWindow;
|
|
235
291
|
});
|
|
236
292
|
const existingKeys = new Set(existing.windows.map(quotaWindowKey));
|
|
@@ -240,8 +296,9 @@ function mergeQuotaWindows(existing: QuotaState | undefined, incoming: QuotaStat
|
|
|
240
296
|
windows.push(window);
|
|
241
297
|
updatedAt = Math.max(updatedAt, incoming.updatedAt);
|
|
242
298
|
source = incoming.source;
|
|
299
|
+
mergedTimes.set(quotaWindowKey(window), incoming.updatedAt);
|
|
243
300
|
}
|
|
244
|
-
return { quota: { windows, source, updatedAt }, acceptedIncomingWindow };
|
|
301
|
+
return { quota: quotaWithWindowSampleTimes({ windows, source, updatedAt }, mergedTimes), acceptedIncomingWindow };
|
|
245
302
|
}
|
|
246
303
|
|
|
247
304
|
function rowToSnapshot(row: ProviderQuotaSnapshotRow): ProviderQuotaSnapshot {
|
|
@@ -258,14 +315,14 @@ function rowToSnapshot(row: ProviderQuotaSnapshotRow): ProviderQuotaSnapshot {
|
|
|
258
315
|
}
|
|
259
316
|
return {
|
|
260
317
|
...base,
|
|
261
|
-
quota:
|
|
318
|
+
quota: publicQuotaState(parseStoredQuotaState(row.quota_state, { windows: [], source: "probe", updatedAt: row.captured_at })),
|
|
262
319
|
};
|
|
263
320
|
}
|
|
264
321
|
|
|
265
322
|
function rowToRecord(row: ProviderQuotaRow, history: ProviderQuotaSnapshot[], now: number): ProviderQuotaRecord {
|
|
266
323
|
const lastError = parseJson<ProviderQuotaError | undefined>(row.last_error, undefined);
|
|
267
324
|
const lastUpdatedAt = row.last_updated_at ?? undefined;
|
|
268
|
-
const quota = row.quota_state ?
|
|
325
|
+
const quota = row.quota_state ? publicQuotaState(parseStoredQuotaState(row.quota_state, { windows: [], source: "probe", updatedAt: lastUpdatedAt ?? row.last_attempt_at })) : undefined;
|
|
269
326
|
return {
|
|
270
327
|
provider: row.provider,
|
|
271
328
|
accountKey: row.account_key,
|
|
@@ -322,7 +379,7 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
|
|
|
322
379
|
const existing = getDb().query("SELECT * FROM provider_quotas WHERE provider = ? AND account_key = ?").get(provider, accountKey) as ProviderQuotaRow | undefined;
|
|
323
380
|
const sourceAgentIds = normalizeSourceAgentIds(existing?.source_agent_ids);
|
|
324
381
|
if (input.sourceAgentId && !sourceAgentIds.includes(input.sourceAgentId)) sourceAgentIds.push(input.sourceAgentId);
|
|
325
|
-
const existingQuota = existing?.quota_state ?
|
|
382
|
+
const existingQuota = existing?.quota_state ? parseStoredQuotaState(existing.quota_state, { windows: [], source: "probe", updatedAt: existing.last_updated_at ?? 0 }) : undefined;
|
|
326
383
|
const mergedQuota = input.quota ? mergeQuotaWindows(existingQuota, input.quota, provider, accountKey) : undefined;
|
|
327
384
|
const lastUpdatedAt = mergedQuota ? mergedQuota.quota.updatedAt : existing?.last_updated_at ?? undefined;
|
|
328
385
|
const lastAttemptAt = input.lastAttemptAt ?? input.quota?.updatedAt ?? now;
|
|
@@ -356,8 +413,9 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
|
|
|
356
413
|
now,
|
|
357
414
|
);
|
|
358
415
|
|
|
359
|
-
|
|
360
|
-
|
|
416
|
+
const snapshotQuota = mergedQuota?.acceptedIncomingWindow ? publicQuotaState(mergedQuota.quota) : undefined;
|
|
417
|
+
if (snapshotQuota && latestSnapshotFingerprint(provider, accountKey) !== quotaFingerprint(snapshotQuota)) {
|
|
418
|
+
recordProviderQuotaSnapshot({ provider, accountKey, quota: snapshotQuota, sourceAgentId: input.sourceAgentId }, now);
|
|
361
419
|
} else if (!input.quota && input.lastError?.type === "unavailable" && !latestSnapshotIsUnavailable(provider, accountKey)) {
|
|
362
420
|
// #609 — the provider couldn't be collected (orchestrator skip, #601). Record one explicit gap
|
|
363
421
|
// marker on the transition into unavailable (deduped while it stays unavailable) so history shows
|
|
@@ -368,7 +426,7 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
|
|
|
368
426
|
const quotaAdvisoryState = providerQuotaAdvisoryHandler?.({
|
|
369
427
|
provider,
|
|
370
428
|
accountKey,
|
|
371
|
-
previousQuota: existingQuota,
|
|
429
|
+
previousQuota: existingQuota ? publicQuotaState(existingQuota) : undefined,
|
|
372
430
|
quota: input.quota,
|
|
373
431
|
sourceAgentIds,
|
|
374
432
|
previousAdvisoryState: existing?.quota_advisory_state,
|
|
@@ -10,6 +10,7 @@ interface TerminalEventRow {
|
|
|
10
10
|
agent_id: string;
|
|
11
11
|
reason: string;
|
|
12
12
|
kind: string;
|
|
13
|
+
epoch: number;
|
|
13
14
|
created_at: number;
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -20,10 +21,11 @@ export function ensureTerminalEventsSchema(): void {
|
|
|
20
21
|
agent_id TEXT NOT NULL,
|
|
21
22
|
reason TEXT NOT NULL,
|
|
22
23
|
kind TEXT NOT NULL,
|
|
24
|
+
epoch INTEGER NOT NULL DEFAULT 0,
|
|
23
25
|
created_at INTEGER NOT NULL
|
|
24
26
|
)
|
|
25
27
|
`);
|
|
26
|
-
getDb().run("CREATE INDEX IF NOT EXISTS idx_terminal_events_agent ON terminal_events(agent_id, created_at DESC)");
|
|
28
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_terminal_events_agent ON terminal_events(agent_id, epoch, created_at DESC)");
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export interface TerminalEvent {
|
|
@@ -32,25 +34,29 @@ export interface TerminalEvent {
|
|
|
32
34
|
reason: string;
|
|
33
35
|
/** Relay event kind that terminated the agent, e.g. "agent.exited", "agent.spawn_failed". */
|
|
34
36
|
kind: string;
|
|
37
|
+
/** Agent epoch at the time of the terminal event. A stale event from an older epoch must not
|
|
38
|
+
* suppress a death alert in a later epoch (#652). */
|
|
39
|
+
epoch: number;
|
|
35
40
|
createdAt: number;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export function recordTerminalEvent(event: TerminalEvent): void {
|
|
39
44
|
getDb()
|
|
40
|
-
.query("INSERT INTO terminal_events (agent_id, reason, kind, created_at) VALUES (?, ?, ?, ?)")
|
|
41
|
-
.run(event.agentId, event.reason, event.kind, event.createdAt);
|
|
45
|
+
.query("INSERT INTO terminal_events (agent_id, reason, kind, epoch, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
46
|
+
.run(event.agentId, event.reason, event.kind, event.epoch, event.createdAt);
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
/** The most recent terminal event for an agent, or null if none was
|
|
45
|
-
export function getTerminalEvent(agentId: string): TerminalEvent | null {
|
|
49
|
+
/** The most recent terminal event for an agent at the given epoch, or null if none was recorded. */
|
|
50
|
+
export function getTerminalEvent(agentId: string, epoch: number): TerminalEvent | null {
|
|
46
51
|
const row = getDb()
|
|
47
|
-
.query("SELECT * FROM terminal_events WHERE agent_id = ? ORDER BY created_at DESC, id DESC LIMIT 1")
|
|
48
|
-
.get(agentId) as TerminalEventRow | undefined;
|
|
52
|
+
.query("SELECT * FROM terminal_events WHERE agent_id = ? AND epoch = ? ORDER BY created_at DESC, id DESC LIMIT 1")
|
|
53
|
+
.get(agentId, epoch) as TerminalEventRow | undefined;
|
|
49
54
|
if (!row) return null;
|
|
50
|
-
return { agentId: row.agent_id, reason: row.reason, kind: row.kind, createdAt: row.created_at };
|
|
55
|
+
return { agentId: row.agent_id, reason: row.reason, kind: row.kind, epoch: row.epoch, createdAt: row.created_at };
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
/** True when
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
/** True when a terminal event (clean exit, spawn-fail, reap) was recorded for the agent at the given epoch.
|
|
59
|
+
* A terminal event from an older epoch does NOT suppress a death alert for the current epoch (#652). */
|
|
60
|
+
export function hasTerminalEvent(agentId: string, epoch: number): boolean {
|
|
61
|
+
return getTerminalEvent(agentId, epoch) !== null;
|
|
56
62
|
}
|
package/src/maintenance/teams.ts
CHANGED
|
@@ -66,6 +66,7 @@ function detectSilentMembers(
|
|
|
66
66
|
watch.set(member.id, {
|
|
67
67
|
lastActiveAt: Math.max(member.lastSeen, existing?.lastActiveAt ?? 0),
|
|
68
68
|
pinged: recovered ? false : existing?.pinged ?? false,
|
|
69
|
+
epoch: member.epoch,
|
|
69
70
|
...(member.label ? { label: member.label } : {}),
|
|
70
71
|
});
|
|
71
72
|
}
|
|
@@ -77,7 +78,9 @@ function detectSilentMembers(
|
|
|
77
78
|
entry.pinged = true;
|
|
78
79
|
// A terminal event already fired — a #633 crash report (death) or a #645 clean terminus
|
|
79
80
|
// (transient reap after land / graceful shutdown). Either way this is not a silent death.
|
|
80
|
-
|
|
81
|
+
// #652: only suppress when the terminal event's epoch matches the member's observed epoch —
|
|
82
|
+
// a stale event from a prior epoch must not mask a real death in the current epoch.
|
|
83
|
+
if (getCrashReport(memberId) || hasTerminalEvent(memberId, entry.epoch)) continue;
|
|
81
84
|
if (!coordinatorId) continue;
|
|
82
85
|
const minutes = Math.floor(gapMs / 60_000);
|
|
83
86
|
notifyTarget(
|
|
@@ -124,6 +127,7 @@ function detectGoneCoordinator(
|
|
|
124
127
|
coordinatorId,
|
|
125
128
|
lastActiveAt: Math.max(coord.lastSeen, prior?.lastActiveAt ?? 0),
|
|
126
129
|
escalated: false, // alive again ⇒ re-arm the escalation
|
|
130
|
+
epoch: coord.epoch,
|
|
127
131
|
...(coord.label ? { label: coord.label } : {}),
|
|
128
132
|
});
|
|
129
133
|
return;
|
|
@@ -138,7 +142,8 @@ function detectGoneCoordinator(
|
|
|
138
142
|
|
|
139
143
|
// A terminal marker means a #645 clean terminus (graceful shutdown) or a #633 crash the operator was
|
|
140
144
|
// already surfaced on — not a SILENT death. Suppress.
|
|
141
|
-
|
|
145
|
+
// #652: only suppress when the terminal event's epoch matches the coordinator's observed epoch.
|
|
146
|
+
if (getCrashReport(coordinatorId) || hasTerminalEvent(coordinatorId, watch.epoch)) {
|
|
142
147
|
out.suppressedTeamIds.push(teamId);
|
|
143
148
|
return;
|
|
144
149
|
}
|
|
@@ -4,6 +4,7 @@ export type NotificationType =
|
|
|
4
4
|
| "agent.ready"
|
|
5
5
|
| "agent.exited"
|
|
6
6
|
| "agent.spawn_failed"
|
|
7
|
+
| "agent.spawn_recovered"
|
|
7
8
|
| "agent.context_threshold"
|
|
8
9
|
| "agent.stuck_busy"
|
|
9
10
|
| "agent.quota_threshold"
|
|
@@ -59,6 +60,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
59
60
|
"agent.ready": null,
|
|
60
61
|
"agent.exited": null,
|
|
61
62
|
"agent.spawn_failed": null,
|
|
63
|
+
"agent.spawn_recovered": null,
|
|
62
64
|
"agent.context_threshold": 15 * MINUTES,
|
|
63
65
|
"agent.stuck_busy": null,
|
|
64
66
|
"agent.quota_threshold": 15 * MINUTES,
|
package/src/quota-advisory.ts
CHANGED
|
@@ -7,15 +7,17 @@ import { parseJson } from "./utils";
|
|
|
7
7
|
import { setProviderQuotaAdvisoryHandler, type ProviderQuotaAdvisoryInput } from "./db/provider-quotas.ts";
|
|
8
8
|
import type { QuotaState } from "./types";
|
|
9
9
|
|
|
10
|
+
type QuotaAdvisoryStage = "warn" | "critical" | "reset";
|
|
11
|
+
|
|
10
12
|
interface QuotaAdvisoryWindowState {
|
|
11
13
|
lastQuotaAdvisoryBand?: number;
|
|
14
|
+
lastQuotaAdvisoryStage?: QuotaAdvisoryStage;
|
|
12
15
|
lastQuotaAdvisoryAt?: number;
|
|
13
16
|
lastRemainingPercent?: number;
|
|
14
17
|
lastResetsAt?: number;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
type QuotaAdvisoryState = Record<string, QuotaAdvisoryWindowState>;
|
|
18
|
-
type QuotaAdvisoryStage = "warn" | "critical" | "reset";
|
|
19
21
|
|
|
20
22
|
type AgentRecipientRow = {
|
|
21
23
|
id: string;
|
|
@@ -45,7 +47,7 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
45
47
|
const remaining = quotaRemaining(window.utilization);
|
|
46
48
|
const remainingPercent = percent(remaining);
|
|
47
49
|
const previous = nextState[window.name];
|
|
48
|
-
const reset = previous
|
|
50
|
+
const reset = hasLastQuotaAdvisory(previous) && isQuotaReset(remaining, previousWindow, window, config.resetRemaining);
|
|
49
51
|
if (reset) {
|
|
50
52
|
notifyQuotaAdvisory({
|
|
51
53
|
provider: input.provider,
|
|
@@ -69,8 +71,8 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
const band = quotaRemainingBand(remaining, config.bandSize);
|
|
72
|
-
if (previous?.lastQuotaAdvisoryBand === band) continue;
|
|
73
74
|
const stage: QuotaAdvisoryStage = remaining - config.criticalRemaining <= QUOTA_THRESHOLD_EPSILON ? "critical" : "warn";
|
|
75
|
+
if (lastQuotaAdvisoryStage(previous, config.criticalRemaining) === stage) continue;
|
|
74
76
|
notifyQuotaAdvisory({
|
|
75
77
|
provider: input.provider,
|
|
76
78
|
accountKey: input.accountKey,
|
|
@@ -85,6 +87,7 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
85
87
|
});
|
|
86
88
|
nextState[window.name] = {
|
|
87
89
|
lastQuotaAdvisoryBand: band,
|
|
90
|
+
lastQuotaAdvisoryStage: stage,
|
|
88
91
|
lastQuotaAdvisoryAt: input.now,
|
|
89
92
|
lastRemainingPercent: remainingPercent,
|
|
90
93
|
...(window.resetsAt !== undefined ? { lastResetsAt: window.resetsAt } : {}),
|
|
@@ -94,6 +97,27 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
94
97
|
return JSON.stringify(nextState);
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
function hasLastQuotaAdvisory(previous: QuotaAdvisoryWindowState | undefined): boolean {
|
|
101
|
+
return previous?.lastQuotaAdvisoryStage !== undefined || previous?.lastQuotaAdvisoryBand !== undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function lastQuotaAdvisoryStage(
|
|
105
|
+
previous: QuotaAdvisoryWindowState | undefined,
|
|
106
|
+
criticalRemaining: number,
|
|
107
|
+
): QuotaAdvisoryStage | undefined {
|
|
108
|
+
if (!previous) return undefined;
|
|
109
|
+
if (previous.lastQuotaAdvisoryStage === "warn" || previous.lastQuotaAdvisoryStage === "critical") {
|
|
110
|
+
return previous.lastQuotaAdvisoryStage;
|
|
111
|
+
}
|
|
112
|
+
if (typeof previous.lastRemainingPercent === "number") {
|
|
113
|
+
return previous.lastRemainingPercent <= percent(criticalRemaining) ? "critical" : "warn";
|
|
114
|
+
}
|
|
115
|
+
if (typeof previous.lastQuotaAdvisoryBand === "number") {
|
|
116
|
+
return previous.lastQuotaAdvisoryBand <= percent(criticalRemaining) ? "critical" : "warn";
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
97
121
|
function notifyQuotaAdvisory(input: {
|
|
98
122
|
provider: string;
|
|
99
123
|
accountKey: string;
|
|
@@ -137,7 +137,7 @@ export function reapTransientAgents(): Record<string, unknown> {
|
|
|
137
137
|
emitRelayEvent({ type: "agent.exited", source: "server", subject: agent.id, data: { agentId: agent.id, parent: agent.spawnedBy, reason: "transient-complete", workspaceId: land.workspaceId, workspaceStatus: land.status } });
|
|
138
138
|
// #645 — persist a clean terminal marker so the #634 heartbeat-gap watchdog reads this benign
|
|
139
139
|
// completion as a terminus (not a silent death) once the reaped worker stops heartbeating.
|
|
140
|
-
recordTerminalEvent({ agentId: agent.id, reason: "transient-complete", kind: "agent.exited", createdAt: now });
|
|
140
|
+
recordTerminalEvent({ agentId: agent.id, reason: "transient-complete", kind: "agent.exited", epoch: agent.epoch, createdAt: now });
|
|
141
141
|
createActivityEvent({
|
|
142
142
|
clientId: `transient-agent-reaped-${agent.id}-${now}`,
|
|
143
143
|
kind: "state",
|
package/src/team-idle-tracker.ts
CHANGED
|
@@ -20,6 +20,9 @@ export interface TeamMemberWatch {
|
|
|
20
20
|
lastActiveAt: number;
|
|
21
21
|
/** Whether the member's CURRENT silence has already pinged the coordinator (re-armed on recovery). */
|
|
22
22
|
pinged: boolean;
|
|
23
|
+
/** Agent epoch at the time the member was last observed alive. Used to epoch-scope the terminal-event
|
|
24
|
+
* suppression check (#652): a stale event from an older epoch must not suppress a current-epoch alert. */
|
|
25
|
+
epoch: number;
|
|
23
26
|
/** Member label for a readable ping. */
|
|
24
27
|
label?: string;
|
|
25
28
|
}
|
|
@@ -39,6 +42,9 @@ export interface TeamCoordinatorWatch {
|
|
|
39
42
|
lastActiveAt: number;
|
|
40
43
|
/** Whether the coordinator's CURRENT gone-spell already escalated (re-armed when it recovers). */
|
|
41
44
|
escalated: boolean;
|
|
45
|
+
/** Agent epoch at the time the coordinator was last observed alive. Used to epoch-scope the terminal-event
|
|
46
|
+
* suppression check (#652): a stale event from an older epoch must not suppress a current-epoch alert. */
|
|
47
|
+
epoch: number;
|
|
42
48
|
/** Coordinator label for a readable escalation. */
|
|
43
49
|
label?: string;
|
|
44
50
|
}
|