agent-relay-server 0.88.2 → 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.2",
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.2",
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
  }
@@ -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
  }