agent-relay-server 0.88.2 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -64,7 +64,7 @@ function landedCommitSubjects(input: BranchLandedInput, log: BranchLandedLogFn):
64
64
  const tipSha = cleanRef(input.mergedSha);
65
65
  if (!repoRoot || !baseSha || !tipSha || baseSha === tipSha) return undefined;
66
66
  try {
67
- const proc = Bun.spawnSync(["git", "-C", repoRoot, "log", "--format=%s", `${baseSha}..${tipSha}`], {
67
+ const proc = Bun.spawnSync(["git", "-C", repoRoot, "log", "--format=%B%x00", `${baseSha}..${tipSha}`], {
68
68
  stdin: "ignore",
69
69
  stdout: "pipe",
70
70
  stderr: "pipe",
@@ -74,7 +74,7 @@ function landedCommitSubjects(input: BranchLandedInput, log: BranchLandedLogFn):
74
74
  log(`issue lifecycle commit range scan failed for ${input.workspace.id} (${baseSha.slice(0, 12)}..${tipSha.slice(0, 12)}): ${error}; falling back to tip subject`);
75
75
  return undefined;
76
76
  }
77
- const subjects = Buffer.from(proc.stdout).toString("utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
77
+ const subjects = Buffer.from(proc.stdout).toString("utf8").split("\x00").map((msg) => msg.trim()).filter(Boolean);
78
78
  return subjects.length > 0 ? subjects : undefined;
79
79
  } catch (error) {
80
80
  log(`issue lifecycle commit range scan failed for ${input.workspace.id} (${baseSha.slice(0, 12)}..${tipSha.slice(0, 12)}): ${errMessage(error)}; falling back to tip subject`);
@@ -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
  }
package/src/config.ts CHANGED
@@ -80,6 +80,13 @@ export const MAX_BODY_BYTES = 64 * 1024;
80
80
  export const INTEGRATION_RATE_LIMIT_PER_MINUTE = envPositiveInt("AGENT_RELAY_INTEGRATION_RATE_LIMIT_PER_MINUTE", 120);
81
81
 
82
82
  export const AUTOMATION_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_AUTOMATION_INTERVAL_MS", 30 * 1000);
83
+ export const SCHEDULER_TICK_MS = envNumberOrDefault("AGENT_RELAY_SCHEDULER_TICK_MS", 5 * 1000);
84
+ export const SCHEDULER_CLAIM_LEASE_MS = envNumberOrDefault("AGENT_RELAY_SCHEDULER_CLAIM_LEASE_MS", 60 * 1000);
85
+ export const SCHEDULER_RETRY_MS = envNumberOrDefault("AGENT_RELAY_SCHEDULER_RETRY_MS", 60 * 1000);
86
+ export const SCHEDULER_MAX_FAILURES = envNumberOrDefault("AGENT_RELAY_SCHEDULER_MAX_FAILURES", 3);
87
+ export const SCHEDULER_MIN_RECURRING_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_SCHEDULER_MIN_RECURRING_INTERVAL_MS", 60 * 1000);
88
+ export const SCHEDULER_MAX_ACTIVE_TIMERS = envNumberOrDefault("AGENT_RELAY_SCHEDULER_MAX_ACTIVE_TIMERS", 100);
89
+ export const SCHEDULER_MAX_PAYLOAD_BYTES = envNumberOrDefault("AGENT_RELAY_SCHEDULER_MAX_PAYLOAD_BYTES", 16 * 1024);
83
90
  export const IDLE_TIMEOUT_SECONDS = envNumberOrDefault("AGENT_RELAY_IDLE_TIMEOUT_SECONDS", 255);
84
91
  export const LOG_REQUESTS = process.env.AGENT_RELAY_LOG_REQUESTS === "1";
85
92
 
package/src/db/index.ts CHANGED
@@ -37,3 +37,4 @@ export * from "./project-entries.ts";
37
37
  export * from "./provisioning-registry.ts";
38
38
  export * from "./provider-quotas.ts";
39
39
  export * from "./provider-quota-leases.ts";
40
+ export * from "./scheduled-timers.ts";
@@ -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,47 @@ 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
+
884
+ // #361 — persistent Relay scheduler. Keep this append-only at the end: older DBs may
885
+ // have reached this point through many schema vintages, and the table references messages.
886
+ getDb().run(`
887
+ CREATE TABLE IF NOT EXISTS scheduled_timers (
888
+ id TEXT PRIMARY KEY,
889
+ target TEXT NOT NULL,
890
+ created_by TEXT NOT NULL,
891
+ created_by_kind TEXT NOT NULL,
892
+ created_by_scopes TEXT NOT NULL DEFAULT '[]',
893
+ created_by_constraints TEXT,
894
+ delivery_from TEXT NOT NULL,
895
+ created_at INTEGER NOT NULL,
896
+ next_run_at INTEGER NOT NULL,
897
+ schedule_kind TEXT NOT NULL,
898
+ schedule_def TEXT NOT NULL,
899
+ subject TEXT,
900
+ body TEXT NOT NULL,
901
+ payload TEXT NOT NULL DEFAULT '{}',
902
+ enabled INTEGER NOT NULL DEFAULT 1,
903
+ run_count INTEGER NOT NULL DEFAULT 0,
904
+ max_runs INTEGER,
905
+ expires_at INTEGER,
906
+ last_run_at INTEGER,
907
+ last_error TEXT,
908
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
909
+ idempotency_key TEXT,
910
+ correlation_id TEXT NOT NULL,
911
+ last_message_id INTEGER REFERENCES messages(id),
912
+ claimed_by TEXT,
913
+ claim_expires_at INTEGER
914
+ )
915
+ `);
916
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_due ON scheduled_timers(enabled, next_run_at, claim_expires_at)");
917
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_creator ON scheduled_timers(created_by, enabled, next_run_at)");
918
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_correlation ON scheduled_timers(correlation_id)");
919
+
868
920
  }
@@ -0,0 +1,297 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb } from "./connection.ts";
3
+ import type { TokenConstraints } from "../types";
4
+
5
+ export type TimerScheduleKind = "once" | "interval";
6
+
7
+ export interface TimerScheduleDef {
8
+ afterMs?: number;
9
+ at?: string;
10
+ everyMs?: number;
11
+ }
12
+
13
+ export interface ScheduledTimer {
14
+ id: string;
15
+ target: string;
16
+ createdBy: string;
17
+ createdByKind: string;
18
+ createdByScopes: string[];
19
+ createdByConstraints?: TokenConstraints;
20
+ deliveryFrom: string;
21
+ createdAt: number;
22
+ nextRunAt: number;
23
+ scheduleKind: TimerScheduleKind;
24
+ scheduleDef: TimerScheduleDef;
25
+ subject?: string;
26
+ body: string;
27
+ payload: Record<string, unknown>;
28
+ enabled: boolean;
29
+ runCount: number;
30
+ maxRuns?: number;
31
+ expiresAt?: number;
32
+ lastRunAt?: number;
33
+ lastError?: string;
34
+ consecutiveFailures: number;
35
+ idempotencyKey?: string;
36
+ correlationId: string;
37
+ lastMessageId?: number;
38
+ claimedBy?: string;
39
+ claimExpiresAt?: number;
40
+ }
41
+
42
+ interface ScheduledTimerRow {
43
+ id: string;
44
+ target: string;
45
+ created_by: string;
46
+ created_by_kind: string;
47
+ created_by_scopes: string;
48
+ created_by_constraints: string | null;
49
+ delivery_from: string;
50
+ created_at: number;
51
+ next_run_at: number;
52
+ schedule_kind: string;
53
+ schedule_def: string;
54
+ subject: string | null;
55
+ body: string;
56
+ payload: string;
57
+ enabled: number;
58
+ run_count: number;
59
+ max_runs: number | null;
60
+ expires_at: number | null;
61
+ last_run_at: number | null;
62
+ last_error: string | null;
63
+ consecutive_failures: number;
64
+ idempotency_key: string | null;
65
+ correlation_id: string;
66
+ last_message_id: number | null;
67
+ claimed_by: string | null;
68
+ claim_expires_at: number | null;
69
+ }
70
+
71
+ export interface CreateScheduledTimerRowInput {
72
+ target: string;
73
+ createdBy: string;
74
+ createdByKind: string;
75
+ createdByScopes: string[];
76
+ createdByConstraints?: TokenConstraints;
77
+ deliveryFrom: string;
78
+ nextRunAt: number;
79
+ scheduleKind: TimerScheduleKind;
80
+ scheduleDef: TimerScheduleDef;
81
+ subject?: string;
82
+ body: string;
83
+ payload?: Record<string, unknown>;
84
+ maxRuns?: number;
85
+ expiresAt?: number;
86
+ idempotencyKey?: string;
87
+ }
88
+
89
+ export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now = Date.now()): ScheduledTimer {
90
+ const id = randomUUID();
91
+ const correlationId = `timer:${id}`;
92
+ getDb().query(`
93
+ INSERT INTO scheduled_timers (
94
+ id, target, created_by, created_by_kind, created_by_scopes, created_by_constraints, delivery_from,
95
+ created_at, next_run_at, schedule_kind, schedule_def, subject, body, payload, enabled,
96
+ run_count, max_runs, expires_at, idempotency_key, correlation_id
97
+ )
98
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?)
99
+ `).run(
100
+ id,
101
+ input.target,
102
+ input.createdBy,
103
+ input.createdByKind,
104
+ JSON.stringify(input.createdByScopes),
105
+ input.createdByConstraints ? JSON.stringify(input.createdByConstraints) : null,
106
+ input.deliveryFrom,
107
+ now,
108
+ input.nextRunAt,
109
+ input.scheduleKind,
110
+ JSON.stringify(input.scheduleDef),
111
+ input.subject ?? null,
112
+ input.body,
113
+ JSON.stringify(input.payload ?? {}),
114
+ input.maxRuns ?? null,
115
+ input.expiresAt ?? null,
116
+ input.idempotencyKey ?? null,
117
+ correlationId,
118
+ );
119
+ return getScheduledTimer(id)!;
120
+ }
121
+
122
+ export function getScheduledTimer(id: string): ScheduledTimer | null {
123
+ const row = getDb().query("SELECT * FROM scheduled_timers WHERE id = ?").get(id) as ScheduledTimerRow | undefined;
124
+ return row ? rowToTimer(row) : null;
125
+ }
126
+
127
+ export function listScheduledTimers(input: {
128
+ createdBy?: string;
129
+ includeDisabled?: boolean;
130
+ limit?: number;
131
+ } = {}): ScheduledTimer[] {
132
+ const where: string[] = [];
133
+ const params: Array<string | number> = [];
134
+ if (input.createdBy) {
135
+ where.push("created_by = ?");
136
+ params.push(input.createdBy);
137
+ }
138
+ if (!input.includeDisabled) where.push("enabled = 1");
139
+ const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
140
+ const sql = `SELECT * FROM scheduled_timers${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY next_run_at ASC, created_at ASC LIMIT ?`;
141
+ const rows = getDb().query(sql).all(...params, limit) as ScheduledTimerRow[];
142
+ return rows.map(rowToTimer);
143
+ }
144
+
145
+ export function countActiveScheduledTimers(createdBy: string): number {
146
+ const row = getDb()
147
+ .query("SELECT count(*) AS count FROM scheduled_timers WHERE created_by = ? AND enabled = 1")
148
+ .get(createdBy) as { count: number };
149
+ return row.count;
150
+ }
151
+
152
+ export function listDueScheduledTimerIds(now: number, limit = 25): string[] {
153
+ const rows = getDb().query(`
154
+ SELECT id FROM scheduled_timers
155
+ WHERE enabled = 1
156
+ AND next_run_at <= ?
157
+ AND (claim_expires_at IS NULL OR claim_expires_at <= ?)
158
+ ORDER BY next_run_at ASC, created_at ASC
159
+ LIMIT ?
160
+ `).all(now, now, limit) as Array<{ id: string }>;
161
+ return rows.map((row) => row.id);
162
+ }
163
+
164
+ export function claimScheduledTimer(id: string, owner: string, now: number, leaseMs: number): ScheduledTimer | null {
165
+ const claimExpiresAt = now + leaseMs;
166
+ const result = getDb().query(`
167
+ UPDATE scheduled_timers
168
+ SET claimed_by = ?, claim_expires_at = ?
169
+ WHERE id = ?
170
+ AND enabled = 1
171
+ AND next_run_at <= ?
172
+ AND (claim_expires_at IS NULL OR claim_expires_at <= ?)
173
+ `).run(owner, claimExpiresAt, id, now, now);
174
+ return result.changes === 0 ? null : getScheduledTimer(id);
175
+ }
176
+
177
+ export function completeScheduledTimerRun(input: {
178
+ timer: ScheduledTimer;
179
+ messageId: number;
180
+ now: number;
181
+ }): ScheduledTimer {
182
+ const runCount = input.timer.runCount + 1;
183
+ const everyMs = input.timer.scheduleKind === "interval" ? input.timer.scheduleDef.everyMs : undefined;
184
+ const candidateNextRunAt = everyMs ? Math.max(input.now + everyMs, input.timer.nextRunAt + everyMs) : null;
185
+ const reachedMaxRuns = input.timer.maxRuns !== undefined && runCount >= input.timer.maxRuns;
186
+ const expiredBeforeNext = input.timer.expiresAt !== undefined &&
187
+ (candidateNextRunAt === null || candidateNextRunAt > input.timer.expiresAt);
188
+ const enabled = Boolean(candidateNextRunAt && !reachedMaxRuns && !expiredBeforeNext);
189
+
190
+ getDb().query(`
191
+ UPDATE scheduled_timers
192
+ SET run_count = ?,
193
+ last_run_at = ?,
194
+ last_error = NULL,
195
+ consecutive_failures = 0,
196
+ last_message_id = ?,
197
+ next_run_at = COALESCE(?, next_run_at),
198
+ enabled = ?,
199
+ claimed_by = NULL,
200
+ claim_expires_at = NULL
201
+ WHERE id = ?
202
+ `).run(runCount, input.now, input.messageId, candidateNextRunAt, enabled ? 1 : 0, input.timer.id);
203
+ return getScheduledTimer(input.timer.id)!;
204
+ }
205
+
206
+ export function recordScheduledTimerFailure(input: {
207
+ id: string;
208
+ error: string;
209
+ now: number;
210
+ retryMs: number;
211
+ maxFailures: number;
212
+ }): ScheduledTimer | null {
213
+ const current = getScheduledTimer(input.id);
214
+ if (!current) return null;
215
+ const failures = current.consecutiveFailures + 1;
216
+ const enabled = failures < input.maxFailures;
217
+ getDb().query(`
218
+ UPDATE scheduled_timers
219
+ SET last_error = ?,
220
+ consecutive_failures = ?,
221
+ next_run_at = ?,
222
+ enabled = ?,
223
+ claimed_by = NULL,
224
+ claim_expires_at = NULL
225
+ WHERE id = ?
226
+ `).run(input.error, failures, input.now + input.retryMs, enabled ? 1 : 0, input.id);
227
+ return getScheduledTimer(input.id);
228
+ }
229
+
230
+ export function cancelScheduledTimer(id: string, now = Date.now()): ScheduledTimer | null {
231
+ const result = getDb().query(`
232
+ UPDATE scheduled_timers
233
+ SET enabled = 0, claimed_by = NULL, claim_expires_at = NULL, last_error = COALESCE(last_error, 'cancelled')
234
+ WHERE id = ? AND enabled = 1
235
+ `).run(id);
236
+ if (result.changes === 0) return getScheduledTimer(id);
237
+ return getScheduledTimer(id);
238
+ }
239
+
240
+ export function disableScheduledTimer(id: string, reason: string): ScheduledTimer | null {
241
+ getDb().query(`
242
+ UPDATE scheduled_timers
243
+ SET enabled = 0, last_error = ?, claimed_by = NULL, claim_expires_at = NULL
244
+ WHERE id = ?
245
+ `).run(reason, id);
246
+ return getScheduledTimer(id);
247
+ }
248
+
249
+ function rowToTimer(row: ScheduledTimerRow): ScheduledTimer {
250
+ return {
251
+ id: row.id,
252
+ target: row.target,
253
+ createdBy: row.created_by,
254
+ createdByKind: row.created_by_kind,
255
+ createdByScopes: parseStringArray(row.created_by_scopes),
256
+ createdByConstraints: parseRecord(row.created_by_constraints) as TokenConstraints | undefined,
257
+ deliveryFrom: row.delivery_from,
258
+ createdAt: row.created_at,
259
+ nextRunAt: row.next_run_at,
260
+ scheduleKind: row.schedule_kind === "interval" ? "interval" : "once",
261
+ scheduleDef: parseRecord(row.schedule_def) as TimerScheduleDef,
262
+ subject: row.subject ?? undefined,
263
+ body: row.body,
264
+ payload: parseRecord(row.payload) ?? {},
265
+ enabled: row.enabled === 1,
266
+ runCount: row.run_count,
267
+ maxRuns: row.max_runs ?? undefined,
268
+ expiresAt: row.expires_at ?? undefined,
269
+ lastRunAt: row.last_run_at ?? undefined,
270
+ lastError: row.last_error ?? undefined,
271
+ consecutiveFailures: row.consecutive_failures,
272
+ idempotencyKey: row.idempotency_key ?? undefined,
273
+ correlationId: row.correlation_id,
274
+ lastMessageId: row.last_message_id ?? undefined,
275
+ claimedBy: row.claimed_by ?? undefined,
276
+ claimExpiresAt: row.claim_expires_at ?? undefined,
277
+ };
278
+ }
279
+
280
+ function parseRecord(raw: string | null): Record<string, unknown> | undefined {
281
+ if (!raw) return undefined;
282
+ try {
283
+ const parsed = JSON.parse(raw);
284
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
285
+ } catch {
286
+ return undefined;
287
+ }
288
+ }
289
+
290
+ function parseStringArray(raw: string): string[] {
291
+ try {
292
+ const parsed = JSON.parse(raw);
293
+ return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
294
+ } catch {
295
+ return [];
296
+ }
297
+ }
package/src/db/schema.ts CHANGED
@@ -164,6 +164,37 @@ export function initDb(path: string = "agent-relay.db"): Database {
164
164
  -- per candidate row (O(n^2)): ~8.6s at 4k rows, which blew the 5s Stop-hook
165
165
  -- timeout and wedged turns in "busy" (#199).
166
166
  CREATE INDEX IF NOT EXISTS idx_msg_reply_to ON messages(reply_to, from_agent);
167
+ CREATE TABLE IF NOT EXISTS scheduled_timers (
168
+ id TEXT PRIMARY KEY,
169
+ target TEXT NOT NULL,
170
+ created_by TEXT NOT NULL,
171
+ created_by_kind TEXT NOT NULL,
172
+ created_by_scopes TEXT NOT NULL DEFAULT '[]',
173
+ created_by_constraints TEXT,
174
+ delivery_from TEXT NOT NULL,
175
+ created_at INTEGER NOT NULL,
176
+ next_run_at INTEGER NOT NULL,
177
+ schedule_kind TEXT NOT NULL,
178
+ schedule_def TEXT NOT NULL,
179
+ subject TEXT,
180
+ body TEXT NOT NULL,
181
+ payload TEXT NOT NULL DEFAULT '{}',
182
+ enabled INTEGER NOT NULL DEFAULT 1,
183
+ run_count INTEGER NOT NULL DEFAULT 0,
184
+ max_runs INTEGER,
185
+ expires_at INTEGER,
186
+ last_run_at INTEGER,
187
+ last_error TEXT,
188
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
189
+ idempotency_key TEXT,
190
+ correlation_id TEXT NOT NULL,
191
+ last_message_id INTEGER REFERENCES messages(id),
192
+ claimed_by TEXT,
193
+ claim_expires_at INTEGER
194
+ );
195
+ CREATE INDEX IF NOT EXISTS idx_scheduled_timers_due ON scheduled_timers(enabled, next_run_at, claim_expires_at);
196
+ CREATE INDEX IF NOT EXISTS idx_scheduled_timers_creator ON scheduled_timers(created_by, enabled, next_run_at);
197
+ CREATE INDEX IF NOT EXISTS idx_scheduled_timers_correlation ON scheduled_timers(correlation_id);
167
198
  CREATE TABLE IF NOT EXISTS teams (
168
199
  id TEXT PRIMARY KEY,
169
200
  coordinator_id TEXT,
@@ -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
  }
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { startConnectorStatusPoller } from "./connectors";
13
13
  import { installNotificationSubscriptionRule } from "./notification-subscription-rules";
14
14
  import { installQuotaAdvisoryHandler } from "./quota-advisory";
15
15
  import { startPlanLiveBindings } from "./services/plan-live-bindings";
16
+ import { startRelayScheduler } from "./services/scheduler";
16
17
  import { resolve, sep } from "path";
17
18
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
18
19
  import {
@@ -85,6 +86,7 @@ function startServer(): void {
85
86
  reconcileRelayVersion();
86
87
  startConnectorStatusPoller();
87
88
  startMaintenanceScheduler();
89
+ startRelayScheduler();
88
90
 
89
91
  setInterval(() => {
90
92
  emitAutomationReconciliations(reconcileAutomationRuns());