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.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/display-CWMHll1J.js.map +1 -1
- package/runner/src/adapter.ts +4 -0
- package/src/agent-lifecycle-events.ts +74 -4
- package/src/branch-landed.ts +2 -2
- package/src/commands-db.ts +4 -1
- package/src/config.ts +7 -0
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +52 -0
- package/src/db/scheduled-timers.ts +297 -0
- package/src/db/schema.ts +31 -0
- package/src/db/terminal-events.ts +17 -11
- package/src/index.ts +2 -0
- package/src/maintenance/teams.ts +7 -2
- package/src/mcp/index.ts +5 -0
- package/src/mcp/tools-messaging.ts +22 -2
- package/src/mcp/tools-scheduler.ts +135 -0
- package/src/notification-types.ts +2 -0
- package/src/quota-advisory.ts +27 -3
- package/src/services/scheduler.ts +303 -0
- package/src/services/send-message.ts +19 -18
- package/src/services/transient-agent-reaper.ts +1 -1
- package/src/team-idle-tracker.ts +6 -0
- package/src/token-db.ts +5 -5
|
@@ -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/branch-landed.ts
CHANGED
|
@@ -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=%
|
|
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(
|
|
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`);
|
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/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
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,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
|
|
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/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());
|