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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.88.1",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.88.1",
3
+ "version": "0.88.3",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -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: spawnRequestIdOf(child) },
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 || !config.agentReady) return;
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: spawnRequestIdOf(child) },
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
  }
@@ -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 || getTerminalEvent(agentId)) return;
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
  }
@@ -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 quotaWindowSampleTimes(provider: string, accountKey: string, fallback?: QuotaState): Map<string, number> {
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: QuotaState | undefined, incoming: QuotaState, provider: string, accountKey: string): {
216
- quota: QuotaState;
260
+ function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: QuotaState, provider: string, accountKey: string): {
261
+ quota: StoredQuotaState;
217
262
  acceptedIncomingWindow: boolean;
218
263
  } {
219
- if (!existing) return { quota: incoming, acceptedIncomingWindow: true };
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 (incoming.updatedAt < existingUpdatedAt) return window;
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: parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: row.captured_at }),
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 ? parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: lastUpdatedAt ?? row.last_attempt_at }) : undefined;
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 ? parseJson<QuotaState>(existing.quota_state, { windows: [], source: "probe", updatedAt: existing.last_updated_at ?? 0 }) : undefined;
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
- if (input.quota && latestSnapshotFingerprint(provider, accountKey) !== quotaFingerprint(input.quota)) {
360
- recordProviderQuotaSnapshot({ provider, accountKey, quota: input.quota, sourceAgentId: input.sourceAgentId }, now);
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 ever recorded. */
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 any terminal event (clean exit, spawn-fail, reap) was recorded for the agent. */
54
- export function hasTerminalEvent(agentId: string): boolean {
55
- return getTerminalEvent(agentId) !== null;
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
  }
@@ -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
- if (getCrashReport(memberId) || hasTerminalEvent(memberId)) continue;
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
- if (getCrashReport(coordinatorId) || hasTerminalEvent(coordinatorId)) {
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,
@@ -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?.lastQuotaAdvisoryBand !== undefined && isQuotaReset(remaining, previousWindow, window, config.resetRemaining);
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",
@@ -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
  }