agent-relay-server 0.15.1 → 0.17.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/src/db.ts CHANGED
@@ -76,6 +76,59 @@ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_ME
76
76
  let db: Database;
77
77
  const CONTEXT_SNAPSHOT_DEBOUNCE_MS = 60_000;
78
78
 
79
+ // Connection-scoped pragmas. journal_mode and auto_vacuum persist in the file;
80
+ // the rest reset per connection, so they must be re-applied on every open.
81
+ function applyConnectionPragmas(conn: Database): void {
82
+ conn.run("PRAGMA journal_mode = WAL");
83
+ // Wait up to 5s for a held lock instead of failing instantly with SQLITE_BUSY.
84
+ // Matters with many agents polling/writing concurrently.
85
+ conn.run("PRAGMA busy_timeout = 5000");
86
+ // NORMAL is the recommended durability level under WAL: as safe as FULL except
87
+ // for a power loss mid-checkpoint, and meaningfully faster on writes.
88
+ conn.run("PRAGMA synchronous = NORMAL");
89
+ conn.run("PRAGMA foreign_keys = ON");
90
+ // ~8MB page cache (negative = KiB) and 256MB mmap window for the read-heavy
91
+ // poll workload. Both are best-effort; SQLite ignores them if unsupported.
92
+ conn.run("PRAGMA cache_size = -8000");
93
+ conn.run("PRAGMA mmap_size = 268435456");
94
+ }
95
+
96
+ // Slow-query log. Off unless AGENT_RELAY_DB_SLOW_MS is set (>0). Wraps the
97
+ // execution of the heavy dynamic queries so the next slow query surfaces before
98
+ // it wedges something (see issue #197 — the 8.6s reply-obligation query).
99
+ const SLOW_QUERY_MS = Number(process.env.AGENT_RELAY_DB_SLOW_MS) || 0;
100
+ function timedQuery<T>(label: string, run: () => T): T {
101
+ if (SLOW_QUERY_MS <= 0) return run();
102
+ const start = performance.now();
103
+ try {
104
+ return run();
105
+ } finally {
106
+ const ms = performance.now() - start;
107
+ if (ms >= SLOW_QUERY_MS) {
108
+ console.warn(`[db.slow] ${label} took ${ms.toFixed(1)}ms (threshold ${SLOW_QUERY_MS}ms)`);
109
+ }
110
+ }
111
+ }
112
+
113
+ // Periodic DB upkeep, invoked by the maintenance scheduler. ANALYZE refreshes
114
+ // planner statistics, PRAGMA optimize applies them, wal_checkpoint(TRUNCATE)
115
+ // bounds WAL growth, and VACUUM (opt-in) reclaims space since auto_vacuum is off.
116
+ export function runDbMaintenance(options: { vacuum?: boolean } = {}): {
117
+ analyzed: boolean;
118
+ checkpointed: boolean;
119
+ vacuumed: boolean;
120
+ } {
121
+ db.run("ANALYZE");
122
+ db.run("PRAGMA optimize");
123
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
124
+ let vacuumed = false;
125
+ if (options.vacuum) {
126
+ db.run("VACUUM");
127
+ vacuumed = true;
128
+ }
129
+ return { analyzed: true, checkpointed: true, vacuumed };
130
+ }
131
+
79
132
  const REACTION_EMOJI_ALIASES: Record<string, string> = {
80
133
  "+1": "👍",
81
134
  ":+1:": "👍",
@@ -110,7 +163,7 @@ export function normalizeReactionEmoji(value: string): string {
110
163
  }
111
164
 
112
165
  function normalizeExistingMessageReactions(): void {
113
- const rows = db.prepare("SELECT message_id, actor_id, emoji, created_at, updated_at FROM message_reactions").all() as Array<{
166
+ const rows = db.query("SELECT message_id, actor_id, emoji, created_at, updated_at FROM message_reactions").all() as Array<{
114
167
  message_id: number;
115
168
  actor_id: string;
116
169
  emoji: string;
@@ -118,14 +171,14 @@ function normalizeExistingMessageReactions(): void {
118
171
  updated_at: number;
119
172
  }>;
120
173
  const migrate = db.transaction((items: typeof rows) => {
121
- const upsert = db.prepare(`
174
+ const upsert = db.query(`
122
175
  INSERT INTO message_reactions (message_id, actor_id, emoji, created_at, updated_at)
123
176
  VALUES (?, ?, ?, ?, ?)
124
177
  ON CONFLICT(message_id, actor_id, emoji) DO UPDATE SET
125
178
  created_at = min(message_reactions.created_at, excluded.created_at),
126
179
  updated_at = max(message_reactions.updated_at, excluded.updated_at)
127
180
  `);
128
- const remove = db.prepare("DELETE FROM message_reactions WHERE message_id = ? AND actor_id = ? AND emoji = ?");
181
+ const remove = db.query("DELETE FROM message_reactions WHERE message_id = ? AND actor_id = ? AND emoji = ?");
129
182
  for (const row of items) {
130
183
  const normalized = normalizeReactionEmoji(row.emoji);
131
184
  if (normalized === row.emoji) continue;
@@ -138,8 +191,7 @@ function normalizeExistingMessageReactions(): void {
138
191
 
139
192
  export function initDb(path: string = "agent-relay.db"): Database {
140
193
  db = new Database(path, { create: true });
141
- db.run("PRAGMA journal_mode = WAL");
142
- db.run("PRAGMA foreign_keys = ON");
194
+ applyConnectionPragmas(db);
143
195
 
144
196
  db.run(`
145
197
  CREATE TABLE IF NOT EXISTS agents (
@@ -187,7 +239,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
187
239
  payload TEXT NOT NULL DEFAULT '{}',
188
240
  meta TEXT NOT NULL DEFAULT '{}',
189
241
  read_by TEXT NOT NULL DEFAULT '[]',
190
- created_at INTEGER NOT NULL
242
+ created_at INTEGER NOT NULL,
243
+ occurred_at INTEGER
191
244
  );
192
245
 
193
246
  CREATE INDEX IF NOT EXISTS idx_msg_to ON messages(to_target);
@@ -794,7 +847,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
794
847
  normalizeExistingMessageReactions();
795
848
 
796
849
  // Migrations
797
- const cols = db.prepare("PRAGMA table_info(messages)").all() as any[];
850
+ const cols = db.query("PRAGMA table_info(messages)").all() as any[];
798
851
  const colNames = cols.map((c: any) => c.name);
799
852
  if (!colNames.includes("thread_id")) {
800
853
  db.run("ALTER TABLE messages ADD COLUMN thread_id INTEGER");
@@ -838,7 +891,12 @@ export function initDb(path: string = "agent-relay.db"): Database {
838
891
  if (!colNames.includes("resolved_to_agent")) {
839
892
  db.run("ALTER TABLE messages ADD COLUMN resolved_to_agent TEXT");
840
893
  }
841
- db.prepare(
894
+ // Event time (#196): when a Runner queues a message in its durable outbox during an
895
+ // outage, occurred_at preserves when it really happened vs. the later receive time.
896
+ if (!colNames.includes("occurred_at")) {
897
+ db.run("ALTER TABLE messages ADD COLUMN occurred_at INTEGER");
898
+ }
899
+ db.query(
842
900
  "UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
843
901
  ).run(Date.now(), CLAIM_LEASE_MS);
844
902
  db.run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
@@ -846,7 +904,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
846
904
  db.run("CREATE INDEX IF NOT EXISTS idx_msg_delivery_status ON messages(delivery_status)");
847
905
  db.run("CREATE INDEX IF NOT EXISTS idx_msg_resolved_to_agent ON messages(resolved_to_agent)");
848
906
 
849
- const tokenCols = db.prepare("PRAGMA table_info(tokens)").all() as any[];
907
+ const tokenCols = db.query("PRAGMA table_info(tokens)").all() as any[];
850
908
  const tokenColNames = tokenCols.map((c: any) => c.name);
851
909
  if (!tokenColNames.includes("constraints")) {
852
910
  db.run("ALTER TABLE tokens ADD COLUMN constraints TEXT");
@@ -870,7 +928,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
870
928
  `);
871
929
  db.run("CREATE INDEX IF NOT EXISTS idx_mda_message ON message_delivery_attempts(message_id, created_at DESC)");
872
930
 
873
- const channelBindingsSql = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
931
+ const channelBindingsSql = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
874
932
  if (channelBindingsSql?.sql?.includes("UNIQUE(channel_id, conversation_key)")) {
875
933
  db.transaction(() => {
876
934
  db.run("ALTER TABLE channel_bindings RENAME TO channel_bindings_old");
@@ -899,7 +957,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
899
957
  db.run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
900
958
  db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
901
959
 
902
- const bindingColNames = (db.prepare("PRAGMA table_info(channel_bindings)").all() as any[]).map((c: any) => c.name);
960
+ const bindingColNames = (db.query("PRAGMA table_info(channel_bindings)").all() as any[]).map((c: any) => c.name);
903
961
  if (!bindingColNames.includes("pool_selector")) {
904
962
  db.run("ALTER TABLE channel_bindings ADD COLUMN pool_selector TEXT");
905
963
  db.run("ALTER TABLE channel_bindings ADD COLUMN pool_agent_id TEXT");
@@ -918,23 +976,23 @@ export function initDb(path: string = "agent-relay.db"): Database {
918
976
  // Backfill thread_id for pre-migration rows (self-threaded).
919
977
  db.run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
920
978
 
921
- const taskCols = db.prepare("PRAGMA table_info(tasks)").all() as any[];
979
+ const taskCols = db.query("PRAGMA table_info(tasks)").all() as any[];
922
980
  const taskColNames = taskCols.map((c: any) => c.name);
923
981
  if (!taskColNames.includes("claim_expires_at")) {
924
982
  db.run("ALTER TABLE tasks ADD COLUMN claim_expires_at INTEGER");
925
983
  }
926
- db.prepare(
984
+ db.query(
927
985
  "UPDATE tasks SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
928
986
  ).run(Date.now(), CLAIM_LEASE_MS);
929
987
 
930
988
  // Migration: orchestrators.api_url
931
- const orchCols = db.prepare("PRAGMA table_info(orchestrators)").all() as any[];
989
+ const orchCols = db.query("PRAGMA table_info(orchestrators)").all() as any[];
932
990
  const orchColNames = orchCols.map((c: any) => c.name);
933
991
  if (!orchColNames.includes("api_url")) {
934
992
  db.run("ALTER TABLE orchestrators ADD COLUMN api_url TEXT");
935
993
  }
936
994
 
937
- const managedStateCols = db.prepare("PRAGMA table_info(managed_agent_state)").all() as any[];
995
+ const managedStateCols = db.query("PRAGMA table_info(managed_agent_state)").all() as any[];
938
996
  const managedStateColNames = managedStateCols.map((c: any) => c.name);
939
997
  if (!managedStateColNames.includes("workspace_id")) {
940
998
  db.run("ALTER TABLE managed_agent_state ADD COLUMN workspace_id TEXT");
@@ -954,7 +1012,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
954
1012
  `);
955
1013
 
956
1014
  // Migration: agents.label
957
- const agentCols = db.prepare("PRAGMA table_info(agents)").all() as any[];
1015
+ const agentCols = db.query("PRAGMA table_info(agents)").all() as any[];
958
1016
  const agentColNames = agentCols.map((c: any) => c.name);
959
1017
  if (!agentColNames.includes("label")) {
960
1018
  db.run("ALTER TABLE agents ADD COLUMN label TEXT");
@@ -1076,7 +1134,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
1076
1134
  // pass the sendMessage validation. The reaper exempts these by checking
1077
1135
  // meta.builtin (or by id for "user").
1078
1136
  const now = Date.now();
1079
- const builtinStmt = db.prepare(`
1137
+ const builtinStmt = db.query(`
1080
1138
  INSERT INTO agents (id, name, kind, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
1081
1139
  VALUES (?, ?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
1082
1140
  ON CONFLICT(id) DO UPDATE SET status = 'online', ready = 1, last_seen = excluded.last_seen
@@ -1095,6 +1153,15 @@ export function initDb(path: string = "agent-relay.db"): Database {
1095
1153
  `);
1096
1154
  }
1097
1155
 
1156
+ // Bootstrap planner statistics on first run (sqlite_stat1 absent means ANALYZE
1157
+ // has never run — the planner would otherwise rely on heuristics only), then
1158
+ // let PRAGMA optimize apply/refresh them cheaply on every startup.
1159
+ const hasStats = db
1160
+ .query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_stat1'")
1161
+ .get();
1162
+ if (!hasStats) db.run("ANALYZE");
1163
+ db.run("PRAGMA optimize");
1164
+
1098
1165
  return db;
1099
1166
  }
1100
1167
 
@@ -1247,6 +1314,7 @@ function rowToMessage(row: any): Message {
1247
1314
  reactions: parseJson(row.reactions_json ?? "[]", []),
1248
1315
  readBy: parseJson(row.read_by_agents ?? "[]", []),
1249
1316
  createdAt: row.created_at,
1317
+ occurredAt: row.occurred_at ?? undefined,
1250
1318
  };
1251
1319
  }
1252
1320
 
@@ -1687,7 +1755,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
1687
1755
  const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
1688
1756
  const readyProvided = Object.prototype.hasOwnProperty.call(input, "ready");
1689
1757
  const instanceProvided = Boolean(input.instanceId);
1690
- const stmt = db.prepare(`
1758
+ const stmt = db.query(`
1691
1759
  INSERT INTO agents (id, name, kind, label, tags, machine, rig, capabilities, ready, status, instance_id, epoch, provider_capabilities, context_state, meta, last_seen, created_at)
1692
1760
  VALUES ($id, $name, $kind, $label, $tags, $machine, $rig, $capabilities, $ready, $status, $instanceId, $initialEpoch, $providerCapabilities, $contextState, $meta, $now, $now)
1693
1761
  ON CONFLICT(id) DO UPDATE SET
@@ -1759,18 +1827,18 @@ export function validateAgentSession(id: string, guard?: AgentSessionGuard): { o
1759
1827
  export function setLabel(id: string, label: string | null): boolean {
1760
1828
  const normalized = label && label.trim() ? label.trim() : null;
1761
1829
  return (
1762
- db.prepare("UPDATE agents SET label = ? WHERE id = ?").run(normalized, id).changes > 0
1830
+ db.query("UPDATE agents SET label = ? WHERE id = ?").run(normalized, id).changes > 0
1763
1831
  );
1764
1832
  }
1765
1833
 
1766
1834
  export function setTags(id: string, tags: string[]): AgentCard | null {
1767
1835
  const normalized = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
1768
- const result = db.prepare("UPDATE agents SET tags = ?, last_seen = ? WHERE id = ?").run(JSON.stringify(normalized), Date.now(), id);
1836
+ const result = db.query("UPDATE agents SET tags = ?, last_seen = ? WHERE id = ?").run(JSON.stringify(normalized), Date.now(), id);
1769
1837
  return result.changes > 0 ? getAgent(id) : null;
1770
1838
  }
1771
1839
 
1772
1840
  export function getAgent(id: string): AgentCard | null {
1773
- const row = db.prepare("SELECT * FROM agents WHERE id = ?").get(id) as any;
1841
+ const row = db.query("SELECT * FROM agents WHERE id = ?").get(id) as any;
1774
1842
  return row ? rowToAgent(row) : null;
1775
1843
  }
1776
1844
 
@@ -1796,7 +1864,7 @@ export function listAgents(filter?: {
1796
1864
  }
1797
1865
 
1798
1866
  sql += " ORDER BY last_seen DESC";
1799
- return (db.prepare(sql).all(...params) as any[]).map(rowToAgent);
1867
+ return (db.query(sql).all(...params) as any[]).map(rowToAgent);
1800
1868
  }
1801
1869
 
1802
1870
  export function setStatus(id: string, status: AgentCard["status"], guard?: AgentSessionGuard): boolean {
@@ -1806,7 +1874,7 @@ export function setStatus(id: string, status: AgentCard["status"], guard?: Agent
1806
1874
  const sql = ready === 0
1807
1875
  ? "UPDATE agents SET status = ?, ready = 0, last_seen = ? WHERE id = ?"
1808
1876
  : "UPDATE agents SET status = ?, last_seen = ? WHERE id = ?";
1809
- const changed = db.prepare(sql).run(status, now, id).changes > 0;
1877
+ const changed = db.query(sql).run(status, now, id).changes > 0;
1810
1878
  if (changed && status === "offline") closeOpenPairsForAgent(id, now);
1811
1879
  if (changed && status === "offline") electWorkspaceStewards();
1812
1880
  return changed;
@@ -1817,7 +1885,7 @@ export function markReady(id: string, ready: boolean, guard?: AgentSessionGuard)
1817
1885
  const now = Date.now();
1818
1886
  return (
1819
1887
  db
1820
- .prepare("UPDATE agents SET ready = ?, last_seen = ? WHERE id = ?")
1888
+ .query("UPDATE agents SET ready = ?, last_seen = ? WHERE id = ?")
1821
1889
  .run(ready ? 1 : 0, now, id).changes > 0
1822
1890
  );
1823
1891
  }
@@ -1828,7 +1896,7 @@ export function mergeAgentMeta(id: string, meta: Record<string, unknown>, guard?
1828
1896
  if (!agent) return false;
1829
1897
  const merged = { ...(agent.meta ?? {}), ...meta };
1830
1898
  const result = db
1831
- .prepare("UPDATE agents SET meta = ?, last_seen = ? WHERE id = ?")
1899
+ .query("UPDATE agents SET meta = ?, last_seen = ? WHERE id = ?")
1832
1900
  .run(JSON.stringify(merged), Date.now(), id);
1833
1901
  return result.changes > 0;
1834
1902
  }
@@ -1844,7 +1912,7 @@ export function recordAgentExitDiagnostics(id: string, diagnostics: ManagedSessi
1844
1912
  lastExitAt: diagnostics.detectedAt || now,
1845
1913
  };
1846
1914
  const result = db
1847
- .prepare("UPDATE agents SET status = 'offline', ready = 0, meta = ?, last_seen = ? WHERE id = ?")
1915
+ .query("UPDATE agents SET status = 'offline', ready = 0, meta = ?, last_seen = ? WHERE id = ?")
1848
1916
  .run(JSON.stringify(merged), now, id);
1849
1917
  if (result.changes <= 0) return null;
1850
1918
  closeOpenPairsForAgent(id, now);
@@ -1860,7 +1928,7 @@ export function heartbeat(
1860
1928
  const now = Date.now();
1861
1929
  if (runtime?.providerCapabilities || runtime?.context) {
1862
1930
  const result = db
1863
- .prepare(`
1931
+ .query(`
1864
1932
  UPDATE agents SET
1865
1933
  last_seen = ?,
1866
1934
  status = CASE WHEN status = 'offline' THEN 'idle' ELSE status END,
@@ -1878,7 +1946,7 @@ export function heartbeat(
1878
1946
  return result.changes > 0;
1879
1947
  }
1880
1948
  const result = db
1881
- .prepare("UPDATE agents SET last_seen = ?, status = CASE WHEN status = 'offline' THEN 'idle' ELSE status END WHERE id = ?")
1949
+ .query("UPDATE agents SET last_seen = ?, status = CASE WHEN status = 'offline' THEN 'idle' ELSE status END WHERE id = ?")
1882
1950
  .run(now, id);
1883
1951
  return result.changes > 0;
1884
1952
  }
@@ -1893,23 +1961,23 @@ export function listContextSnapshots(agentId: string, options: { since?: number;
1893
1961
  }
1894
1962
  sql += " ORDER BY captured_at ASC LIMIT ?";
1895
1963
  params.push(limit);
1896
- return (db.prepare(sql).all(...params) as any[]).map(rowToContextSnapshot);
1964
+ return (db.query(sql).all(...params) as any[]).map(rowToContextSnapshot);
1897
1965
  }
1898
1966
 
1899
1967
  function pruneContextSnapshots(agentId?: string, olderThan = Date.now() - DAY_MS): number {
1900
1968
  const result = agentId
1901
- ? db.prepare("DELETE FROM context_snapshots WHERE agent_id = ? AND captured_at < ?").run(agentId, olderThan)
1902
- : db.prepare("DELETE FROM context_snapshots WHERE captured_at < ?").run(olderThan);
1969
+ ? db.query("DELETE FROM context_snapshots WHERE agent_id = ? AND captured_at < ?").run(agentId, olderThan)
1970
+ : db.query("DELETE FROM context_snapshots WHERE captured_at < ?").run(olderThan);
1903
1971
  return result.changes;
1904
1972
  }
1905
1973
 
1906
1974
  function recordContextSnapshot(agentId: string, context: ContextState, now: number): void {
1907
1975
  const latest = db
1908
- .prepare("SELECT captured_at FROM context_snapshots WHERE agent_id = ? ORDER BY captured_at DESC LIMIT 1")
1976
+ .query("SELECT captured_at FROM context_snapshots WHERE agent_id = ? ORDER BY captured_at DESC LIMIT 1")
1909
1977
  .get(agentId) as { captured_at: number } | undefined;
1910
1978
  if (latest && latest.captured_at > now - CONTEXT_SNAPSHOT_DEBOUNCE_MS) return;
1911
1979
 
1912
- db.prepare(`
1980
+ db.query(`
1913
1981
  INSERT INTO context_snapshots (
1914
1982
  agent_id,
1915
1983
  utilization,
@@ -1999,7 +2067,7 @@ function upsertChannelForAgent(agent: AgentCard): void {
1999
2067
  ? (agent.meta.topicChannels as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)
2000
2068
  : [];
2001
2069
 
2002
- db.prepare(`
2070
+ db.query(`
2003
2071
  INSERT INTO channels (id, provider, account_id, display_name, agent_id, transport, direction, topic_channels, capabilities, meta, created_at, updated_at)
2004
2072
  VALUES ($id, $provider, $accountId, $displayName, $agentId, $transport, $direction, $topicChannels, $capabilities, $meta, $now, $now)
2005
2073
  ON CONFLICT(provider, account_id) DO UPDATE SET
@@ -2039,7 +2107,7 @@ function upsertChannelForAgent(agent: AgentCard): void {
2039
2107
  }
2040
2108
 
2041
2109
  export function listChannels(): ChannelSummary[] {
2042
- const rows = db.prepare(`
2110
+ const rows = db.query(`
2043
2111
  SELECT
2044
2112
  c.*,
2045
2113
  c.capabilities AS channel_capabilities,
@@ -2074,8 +2142,8 @@ export function getChannel(channelId: string): ChannelSummary | null {
2074
2142
 
2075
2143
  export function listChannelBindings(channelId?: string): ChannelBinding[] {
2076
2144
  const rows = channelId
2077
- ? db.prepare("SELECT * FROM channel_bindings WHERE channel_id = ? ORDER BY priority DESC, updated_at DESC").all(channelId) as any[]
2078
- : db.prepare("SELECT * FROM channel_bindings ORDER BY channel_id, priority DESC, updated_at DESC").all() as any[];
2145
+ ? db.query("SELECT * FROM channel_bindings WHERE channel_id = ? ORDER BY priority DESC, updated_at DESC").all(channelId) as any[]
2146
+ : db.query("SELECT * FROM channel_bindings ORDER BY channel_id, priority DESC, updated_at DESC").all() as any[];
2079
2147
  return rows.map(rowToChannelBinding);
2080
2148
  }
2081
2149
 
@@ -2097,9 +2165,9 @@ export function upsertChannelBinding(input: {
2097
2165
  const now = Date.now();
2098
2166
  db.transaction(() => {
2099
2167
  if (mode === "exclusive") {
2100
- db.prepare("DELETE FROM channel_bindings WHERE channel_id = ? AND conversation_key = ?").run(input.channelId, conversationKey);
2168
+ db.query("DELETE FROM channel_bindings WHERE channel_id = ? AND conversation_key = ?").run(input.channelId, conversationKey);
2101
2169
  }
2102
- db.prepare(`
2170
+ db.query(`
2103
2171
  INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, pool_selector, created_at, updated_at)
2104
2172
  VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $poolSelector, $now, $now)
2105
2173
  ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
@@ -2122,7 +2190,7 @@ export function upsertChannelBinding(input: {
2122
2190
  })();
2123
2191
 
2124
2192
  evaluatePoolBindings(now);
2125
- const row = db.prepare("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
2193
+ const row = db.query("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
2126
2194
  return rowToChannelBinding(row);
2127
2195
  }
2128
2196
 
@@ -2134,7 +2202,7 @@ interface PoolBindingChange {
2134
2202
  }
2135
2203
 
2136
2204
  export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChange[] {
2137
- const rows = db.prepare("SELECT * FROM channel_bindings WHERE target_type = 'pool'").all() as any[];
2205
+ const rows = db.query("SELECT * FROM channel_bindings WHERE target_type = 'pool'").all() as any[];
2138
2206
  const changes: PoolBindingChange[] = [];
2139
2207
 
2140
2208
  for (const row of rows) {
@@ -2149,7 +2217,7 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
2149
2217
  const holder = getAgent(currentAgentId);
2150
2218
  if (holder && holder.status !== "offline" && holder.ready && holder.lastSeen > now - STALE_TTL_MS && agentCanServeChannel(holder, channelId)) {
2151
2219
  if (currentEpoch === null || holder.epoch === currentEpoch) {
2152
- db.prepare("UPDATE channel_bindings SET pool_claim_expires_at = ? WHERE id = ?")
2220
+ db.query("UPDATE channel_bindings SET pool_claim_expires_at = ? WHERE id = ?")
2153
2221
  .run(now + POOL_CLAIM_LEASE_MS, bindingId);
2154
2222
  holderValid = true;
2155
2223
  }
@@ -2157,7 +2225,7 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
2157
2225
  }
2158
2226
 
2159
2227
  if (!holderValid && currentAgentId) {
2160
- db.prepare("UPDATE channel_bindings SET pool_agent_id = NULL, pool_agent_epoch = NULL, pool_claim_expires_at = NULL WHERE id = ?")
2228
+ db.query("UPDATE channel_bindings SET pool_agent_id = NULL, pool_agent_epoch = NULL, pool_claim_expires_at = NULL WHERE id = ?")
2161
2229
  .run(bindingId);
2162
2230
  changes.push({ bindingId, channelId, previousAgentId: currentAgentId, newAgentId: null });
2163
2231
  }
@@ -2173,7 +2241,7 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
2173
2241
 
2174
2242
  if (eligible.length > 0) {
2175
2243
  const picked = eligible[0]!;
2176
- db.prepare("UPDATE channel_bindings SET pool_agent_id = ?, pool_agent_epoch = ?, pool_claim_expires_at = ? WHERE id = ?")
2244
+ db.query("UPDATE channel_bindings SET pool_agent_id = ?, pool_agent_epoch = ?, pool_claim_expires_at = ? WHERE id = ?")
2177
2245
  .run(picked.id, picked.epoch, now + POOL_CLAIM_LEASE_MS, bindingId);
2178
2246
  const lastChange = changes[changes.length - 1];
2179
2247
  if (lastChange && lastChange.bindingId === bindingId && lastChange.newAgentId === null) {
@@ -2190,12 +2258,12 @@ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChang
2190
2258
 
2191
2259
  export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
2192
2260
  const rows = conversationId
2193
- ? db.prepare(`
2261
+ ? db.query(`
2194
2262
  SELECT * FROM channel_bindings
2195
2263
  WHERE channel_id = ? AND conversation_key IN (?, '')
2196
2264
  ORDER BY CASE WHEN conversation_key = ? THEN 1 ELSE 0 END DESC, priority DESC, updated_at DESC
2197
2265
  `).all(channelId, conversationId, conversationId) as any[]
2198
- : db.prepare(`
2266
+ : db.query(`
2199
2267
  SELECT * FROM channel_bindings
2200
2268
  WHERE channel_id = ? AND conversation_key = ''
2201
2269
  ORDER BY priority DESC, updated_at DESC
@@ -2208,9 +2276,9 @@ export function resolveChannelRoutes(channelId: string, conversationId?: string)
2208
2276
  export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
2209
2277
  const now = Date.now();
2210
2278
  const cutoff = now - ttlMs;
2211
- db.prepare("UPDATE agents SET last_seen = ? WHERE id IN ('user', 'system')").run(now);
2279
+ db.query("UPDATE agents SET last_seen = ? WHERE id IN ('user', 'system')").run(now);
2212
2280
  const rows = db
2213
- .prepare(
2281
+ .query(
2214
2282
  "UPDATE agents SET status = 'offline', ready = 0 WHERE status NOT IN ('offline', 'stale') AND last_seen < ? AND id NOT IN ('user', 'system') RETURNING id"
2215
2283
  )
2216
2284
  .all(cutoff) as any[];
@@ -2232,9 +2300,9 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
2232
2300
  // re-open (gated on status IN claimed/in_progress/blocked/orphaned) then skips.
2233
2301
  function settleSingleTargetOnDemandTasks(condition: string, params: any[], now: number, reason: string): void {
2234
2302
  const selectSql = `${TASK_SELECT} WHERE (${condition}) AND json_extract(metadata, '$.targetMode') = 'on_demand_agent' AND status IN ('claimed', 'in_progress', 'blocked', 'orphaned')`;
2235
- const rows = db.prepare(selectSql).all(...params) as any[];
2303
+ const rows = db.query(selectSql).all(...params) as any[];
2236
2304
  if (rows.length === 0) return;
2237
- db.prepare(
2305
+ db.query(
2238
2306
  `UPDATE tasks SET status = 'done', claim_expires_at = NULL, updated_at = ?, last_seen_at = ? WHERE (${condition}) AND json_extract(metadata, '$.targetMode') = 'on_demand_agent' AND status IN ('claimed', 'in_progress', 'blocked', 'orphaned')`
2239
2307
  ).run(now, now, ...params);
2240
2308
  for (const row of rows) {
@@ -2253,7 +2321,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
2253
2321
  const cutoff = Date.now() - maxOfflineMs;
2254
2322
  return db.transaction(() => {
2255
2323
  const rows = db
2256
- .prepare(
2324
+ .query(
2257
2325
  "SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')"
2258
2326
  )
2259
2327
  .all(cutoff) as any[];
@@ -2262,7 +2330,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
2262
2330
 
2263
2331
  // Release claims held by pruned agents so work becomes claimable again.
2264
2332
  db
2265
- .prepare(
2333
+ .query(
2266
2334
  "UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by IN (SELECT id FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system'))"
2267
2335
  )
2268
2336
  .run(cutoff);
@@ -2271,7 +2339,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
2271
2339
  settleSingleTargetOnDemandTasks(offlineClaimCondition, [cutoff], now, "agent-pruned");
2272
2340
 
2273
2341
  db
2274
- .prepare(
2342
+ .query(
2275
2343
  `UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE ${offlineClaimCondition} AND status IN ('claimed', 'in_progress', 'blocked')`
2276
2344
  )
2277
2345
  .run(now, cutoff);
@@ -2282,7 +2350,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
2282
2350
  }
2283
2351
 
2284
2352
  db
2285
- .prepare(
2353
+ .query(
2286
2354
  "DELETE FROM agents WHERE status = 'offline' AND last_seen < ? AND id NOT IN ('user', 'system')"
2287
2355
  )
2288
2356
  .run(cutoff);
@@ -2299,7 +2367,7 @@ export function findAgentsByCapability(capability: string, onlineOnly = true): A
2299
2367
  params.push(Date.now() - STALE_TTL_MS);
2300
2368
  }
2301
2369
  sql += " ORDER BY last_seen DESC";
2302
- return (db.prepare(sql).all(...params) as any[]).map(rowToAgent);
2370
+ return (db.query(sql).all(...params) as any[]).map(rowToAgent);
2303
2371
  }
2304
2372
 
2305
2373
  export function deleteAgent(id: string): { ok: boolean; error?: string } {
@@ -2310,23 +2378,23 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
2310
2378
  // Release any claims held by this agent so the tasks become claimable again.
2311
2379
  // from_agent is left intact as historical record.
2312
2380
  const now = Date.now();
2313
- db.prepare("UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by = ?").run(id);
2381
+ db.query("UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE claimed_by = ?").run(id);
2314
2382
  settleSingleTargetOnDemandTasks("claimed_by = ?", [id], now, "agent-removed");
2315
- db.prepare("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by = ? AND status IN ('claimed', 'in_progress', 'blocked')").run(now, id);
2383
+ db.query("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by = ? AND status IN ('claimed', 'in_progress', 'blocked')").run(now, id);
2316
2384
  revokeRuntimeTokensForAgent(id, now);
2317
2385
  closeOpenPairsForAgent(id, now);
2318
- return db.prepare("DELETE FROM agents WHERE id = ?").run(id).changes > 0;
2386
+ return db.query("DELETE FROM agents WHERE id = ?").run(id).changes > 0;
2319
2387
  })();
2320
2388
  return deleted ? { ok: true } : { ok: false, error: "agent not found" };
2321
2389
  }
2322
2390
 
2323
2391
  export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now()): string[] {
2324
- const row = db.prepare("SELECT meta FROM agents WHERE id = ?").get(agentId) as { meta?: string } | undefined;
2392
+ const row = db.query("SELECT meta FROM agents WHERE id = ?").get(agentId) as { meta?: string } | undefined;
2325
2393
  const jtis = runtimeTokenJtisFromMeta(parseJson(row?.meta ?? "{}", {}));
2326
2394
  if (jtis.length === 0) return [];
2327
2395
  const revokedAt = Math.floor(now / 1000);
2328
2396
  const placeholders = jtis.map(() => "?").join(",");
2329
- const rows = db.prepare(`
2397
+ const rows = db.query(`
2330
2398
  UPDATE tokens
2331
2399
  SET revoked_at = ?
2332
2400
  WHERE revoked_at IS NULL
@@ -2360,13 +2428,13 @@ const TASK_SELECT = "SELECT * FROM tasks";
2360
2428
 
2361
2429
  function findOpenTaskByDedupe(source: string, dedupeKey: string): Task | null {
2362
2430
  const row = db
2363
- .prepare(`${TASK_SELECT} WHERE source = ? AND dedupe_key = ? AND status NOT IN ('done', 'failed', 'canceled') ORDER BY id DESC LIMIT 1`)
2431
+ .query(`${TASK_SELECT} WHERE source = ? AND dedupe_key = ? AND status NOT IN ('done', 'failed', 'canceled') ORDER BY id DESC LIMIT 1`)
2364
2432
  .get(source, dedupeKey) as any;
2365
2433
  return row ? rowToTask(row) : null;
2366
2434
  }
2367
2435
 
2368
2436
  function insertTaskEvent(taskId: number, event: Required<Omit<TaskEvent, "id" | "taskId" | "createdAt">>, now: number): TaskEvent {
2369
- const result = db.prepare(`
2437
+ const result = db.query(`
2370
2438
  INSERT INTO task_events (task_id, source, type, severity, title, body, metadata, created_at)
2371
2439
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2372
2440
  `).run(taskId, event.source, event.type, event.severity, event.title, event.body, JSON.stringify(event.metadata), now);
@@ -2374,7 +2442,7 @@ function insertTaskEvent(taskId: number, event: Required<Omit<TaskEvent, "id" |
2374
2442
  }
2375
2443
 
2376
2444
  function getTaskEvent(id: number): TaskEvent | null {
2377
- const row = db.prepare("SELECT * FROM task_events WHERE id = ?").get(id) as any;
2445
+ const row = db.query("SELECT * FROM task_events WHERE id = ?").get(id) as any;
2378
2446
  return row ? rowToTaskEvent(row) : null;
2379
2447
  }
2380
2448
 
@@ -2406,7 +2474,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
2406
2474
  let created = false;
2407
2475
 
2408
2476
  if (existing) {
2409
- db.prepare(`
2477
+ db.query(`
2410
2478
  UPDATE tasks
2411
2479
  SET title = ?, body = ?, severity = ?, target = ?, channel = ?, external_url = ?,
2412
2480
  occurrence_count = occurrence_count + 1, metadata = ?, updated_at = ?, last_seen_at = ?,
@@ -2431,7 +2499,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
2431
2499
  );
2432
2500
  taskId = existing.id;
2433
2501
  } else {
2434
- const result = db.prepare(`
2502
+ const result = db.query(`
2435
2503
  INSERT INTO tasks (source, title, body, severity, status, target, channel, dedupe_key, external_url, metadata, created_at, updated_at, last_seen_at)
2436
2504
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2437
2505
  `).run(
@@ -2485,7 +2553,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
2485
2553
  ...(attachmentRefs.length ? { attachments: attachmentRefs } : {}),
2486
2554
  },
2487
2555
  });
2488
- db.prepare("UPDATE tasks SET message_id = ?, updated_at = ? WHERE id = ?").run(message.id, now, task.id);
2556
+ db.query("UPDATE tasks SET message_id = ?, updated_at = ? WHERE id = ?").run(message.id, now, task.id);
2489
2557
  }
2490
2558
 
2491
2559
  return { task: getTask(taskId)!, event, created, message };
@@ -2493,7 +2561,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
2493
2561
  }
2494
2562
 
2495
2563
  export function getTask(id: number): Task | null {
2496
- const row = db.prepare(`${TASK_SELECT} WHERE id = ?`).get(id) as any;
2564
+ const row = db.query(`${TASK_SELECT} WHERE id = ?`).get(id) as any;
2497
2565
  return row ? rowToTask(row) : null;
2498
2566
  }
2499
2567
 
@@ -2514,11 +2582,11 @@ export function listTasks(filter?: { status?: string; source?: string; target?:
2514
2582
  }
2515
2583
  const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
2516
2584
  params.push(filter?.limit ?? 100);
2517
- return (db.prepare(`${TASK_SELECT} ${where} ORDER BY updated_at DESC LIMIT ?`).all(...params) as any[]).map(rowToTask);
2585
+ return (db.query(`${TASK_SELECT} ${where} ORDER BY updated_at DESC LIMIT ?`).all(...params) as any[]).map(rowToTask);
2518
2586
  }
2519
2587
 
2520
2588
  export function listIntegrationTaskStats(): IntegrationTaskStats[] {
2521
- const rows = db.prepare(`
2589
+ const rows = db.query(`
2522
2590
  SELECT
2523
2591
  source,
2524
2592
  COUNT(*) AS tasks,
@@ -2597,7 +2665,7 @@ export function upsertIntegrationRegistry(input: IntegrationRegistryInput): Retu
2597
2665
  const name = stringValue(input.name);
2598
2666
  if (!name) throw new ValidationError("integration name required");
2599
2667
  const now = Date.now();
2600
- db.prepare(`
2668
+ db.query(`
2601
2669
  INSERT INTO integration_registry (
2602
2670
  name, display_name, description, enabled, scopes, targets, channels, type, icon, accent_color,
2603
2671
  tags, homepage_url, repository_url, docs_url, manifest, source, created_at, updated_at
@@ -2644,17 +2712,17 @@ export function upsertIntegrationRegistry(input: IntegrationRegistryInput): Retu
2644
2712
  }
2645
2713
 
2646
2714
  function getIntegrationRegistry(name: string): ReturnType<typeof rowToIntegrationRegistry> | null {
2647
- const row = db.prepare("SELECT * FROM integration_registry WHERE name = ?").get(name) as any;
2715
+ const row = db.query("SELECT * FROM integration_registry WHERE name = ?").get(name) as any;
2648
2716
  return row ? rowToIntegrationRegistry(row) : null;
2649
2717
  }
2650
2718
 
2651
2719
  export function listIntegrationRegistry(): ReturnType<typeof rowToIntegrationRegistry>[] {
2652
- return (db.prepare("SELECT * FROM integration_registry ORDER BY display_name COLLATE NOCASE, name COLLATE NOCASE").all() as any[]).map(rowToIntegrationRegistry);
2720
+ return (db.query("SELECT * FROM integration_registry ORDER BY display_name COLLATE NOCASE, name COLLATE NOCASE").all() as any[]).map(rowToIntegrationRegistry);
2653
2721
  }
2654
2722
 
2655
2723
  function updateIntegrationObserved(name: string, eventAt: number): void {
2656
2724
  const now = Date.now();
2657
- db.prepare(`
2725
+ db.query(`
2658
2726
  INSERT INTO integration_registry (name, enabled, scopes, targets, channels, tags, manifest, source, created_at, updated_at, last_event_at, last_task_at)
2659
2727
  VALUES (?, 1, '[]', '[]', '[]', '[]', '{}', 'observed', ?, ?, ?, ?)
2660
2728
  ON CONFLICT(name) DO UPDATE SET
@@ -2670,7 +2738,7 @@ export function isIntegrationRegistryEnabled(name: string): boolean {
2670
2738
  }
2671
2739
 
2672
2740
  export function listTaskEvents(taskId: number): TaskEvent[] {
2673
- return (db.prepare("SELECT * FROM task_events WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[]).map(rowToTaskEvent);
2741
+ return (db.query("SELECT * FROM task_events WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[]).map(rowToTaskEvent);
2674
2742
  }
2675
2743
 
2676
2744
  export function recordTaskEvent(taskId: number, input: {
@@ -2698,21 +2766,21 @@ export function releaseExpiredClaims(now: number = Date.now()): { messageIds: nu
2698
2766
  settleSingleTargetOnDemandTasks("claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?)", [now], now, "claim-lease-expired");
2699
2767
  const releasableMessageClaim = "claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = messages.id AND t.status IN ('done', 'failed', 'canceled'))";
2700
2768
  const messageRows = db
2701
- .prepare(`SELECT id FROM messages WHERE ${releasableMessageClaim}`)
2769
+ .query(`SELECT id FROM messages WHERE ${releasableMessageClaim}`)
2702
2770
  .all(now) as any[];
2703
2771
  const taskRows = db
2704
- .prepare(`${TASK_SELECT} WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')`)
2772
+ .query(`${TASK_SELECT} WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')`)
2705
2773
  .all(now) as any[];
2706
2774
 
2707
2775
  if (messageRows.length > 0) {
2708
2776
  db
2709
- .prepare(`UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE ${releasableMessageClaim}`)
2777
+ .query(`UPDATE messages SET claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL WHERE ${releasableMessageClaim}`)
2710
2778
  .run(now);
2711
2779
  }
2712
2780
 
2713
2781
  if (taskRows.length > 0) {
2714
2782
  db
2715
- .prepare("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')")
2783
+ .query("UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')")
2716
2784
  .run(now, now);
2717
2785
 
2718
2786
  for (const row of taskRows) {
@@ -2737,11 +2805,11 @@ export function releaseExpiredClaims(now: number = Date.now()): { messageIds: nu
2737
2805
  export function orphanTasksForAgent(agentId: string, now: number = Date.now()): Task[] {
2738
2806
  return db.transaction(() => {
2739
2807
  const rows = db
2740
- .prepare(`${TASK_SELECT} WHERE claimed_by = ? AND status IN ('claimed', 'in_progress')`)
2808
+ .query(`${TASK_SELECT} WHERE claimed_by = ? AND status IN ('claimed', 'in_progress')`)
2741
2809
  .all(agentId) as any[];
2742
2810
  if (rows.length === 0) return [];
2743
2811
 
2744
- db.prepare(`
2812
+ db.query(`
2745
2813
  UPDATE tasks
2746
2814
  SET status = 'orphaned', updated_at = ?, last_seen_at = ?
2747
2815
  WHERE claimed_by = ? AND status IN ('claimed', 'in_progress')
@@ -2767,11 +2835,11 @@ export function releaseOrphanedTasks(graceMs = 120_000, now: number = Date.now()
2767
2835
  const cutoff = now - graceMs;
2768
2836
  settleSingleTargetOnDemandTasks("status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?", [cutoff], now, "orphan-grace-elapsed");
2769
2837
  const rows = db
2770
- .prepare(`${TASK_SELECT} WHERE status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?`)
2838
+ .query(`${TASK_SELECT} WHERE status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?`)
2771
2839
  .all(cutoff) as any[];
2772
2840
  if (rows.length === 0) return [];
2773
2841
 
2774
- db.prepare(`
2842
+ db.query(`
2775
2843
  UPDATE tasks
2776
2844
  SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ?
2777
2845
  WHERE status = 'orphaned' AND claimed_by IS NOT NULL AND updated_at <= ?
@@ -2807,7 +2875,7 @@ export function claimTask(taskId: number, agentId: string, guard?: AgentSessionG
2807
2875
  return db.transaction(() => {
2808
2876
  const now = Date.now();
2809
2877
  const expiresAt = now + CLAIM_LEASE_MS;
2810
- const result = db.prepare(`
2878
+ const result = db.query(`
2811
2879
  UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, claim_expires_at = ?, updated_at = ?
2812
2880
  WHERE id = ? AND status IN ('open', 'blocked')
2813
2881
  `).run(agentId, now, expiresAt, now, taskId);
@@ -2846,9 +2914,9 @@ export function renewTaskClaim(taskId: number, agentId: string, guard?: AgentSes
2846
2914
 
2847
2915
  const now = Date.now();
2848
2916
  const expiresAt = now + CLAIM_LEASE_MS;
2849
- db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
2917
+ db.query("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
2850
2918
  if (task.messageId) {
2851
- db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
2919
+ db.query("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
2852
2920
  }
2853
2921
  return { ok: true, task: getTask(taskId)! };
2854
2922
  }
@@ -2862,7 +2930,7 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
2862
2930
  const session = validateAgentSession(agentId, input);
2863
2931
  if (!session.ok) return { ok: false, error: session.error };
2864
2932
  }
2865
- const result = db.prepare(`
2933
+ const result = db.query(`
2866
2934
  UPDATE tasks
2867
2935
  SET status = ?, result = COALESCE(?, result), claimed_by = COALESCE(?, claimed_by),
2868
2936
  claimed_at = CASE WHEN claimed_by IS NULL AND ? IS NOT NULL THEN ? ELSE claimed_at END,
@@ -2872,13 +2940,13 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
2872
2940
  `).run(input.status, input.result ?? null, agentId, agentId, now, input.status, now, now, taskId);
2873
2941
  if (result.changes === 0) return { ok: false, error: "task not found" };
2874
2942
  if (["done", "failed", "canceled"].includes(input.status) && task.messageId) {
2875
- db.prepare("UPDATE messages SET claim_expires_at = NULL WHERE id = ?").run(task.messageId);
2943
+ db.query("UPDATE messages SET claim_expires_at = NULL WHERE id = ?").run(task.messageId);
2876
2944
  }
2877
2945
  if (agentId && ["claimed", "in_progress", "blocked"].includes(input.status)) {
2878
2946
  const expiresAt = now + CLAIM_LEASE_MS;
2879
- db.prepare("UPDATE tasks SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, taskId, agentId);
2947
+ db.query("UPDATE tasks SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, taskId, agentId);
2880
2948
  if (task.messageId) {
2881
- db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
2949
+ db.query("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, task.messageId, agentId);
2882
2950
  }
2883
2951
  }
2884
2952
  const event = insertTaskEvent(taskId, {
@@ -2894,7 +2962,7 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
2894
2962
 
2895
2963
  export function createCallbackDelivery(taskId: number, url: string, eventType: string, payload: unknown): number {
2896
2964
  const now = Date.now();
2897
- const result = db.prepare(`
2965
+ const result = db.query(`
2898
2966
  INSERT INTO task_callback_deliveries (task_id, url, event_type, payload, status, attempts, created_at, updated_at)
2899
2967
  VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)
2900
2968
  `).run(taskId, url, eventType, JSON.stringify(payload), now, now);
@@ -2902,7 +2970,7 @@ export function createCallbackDelivery(taskId: number, url: string, eventType: s
2902
2970
  }
2903
2971
 
2904
2972
  export function finishCallbackDelivery(id: number, ok: boolean, error?: string): void {
2905
- db.prepare(`
2973
+ db.query(`
2906
2974
  UPDATE task_callback_deliveries
2907
2975
  SET status = ?, attempts = attempts + 1, last_error = ?, updated_at = ?
2908
2976
  WHERE id = ?
@@ -2918,7 +2986,7 @@ export function listCallbackDeliveries(taskId: number): Array<{
2918
2986
  attempts: number;
2919
2987
  lastError?: string;
2920
2988
  }> {
2921
- return (db.prepare("SELECT * FROM task_callback_deliveries WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[])
2989
+ return (db.query("SELECT * FROM task_callback_deliveries WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[])
2922
2990
  .map((row) => ({
2923
2991
  id: row.id,
2924
2992
  taskId: row.task_id,
@@ -2937,12 +3005,12 @@ const DEFAULT_PAIR_TTL_MS = 5 * 60_000;
2937
3005
  const MAX_PAIR_TTL_MS = DAY_MS;
2938
3006
 
2939
3007
  function expirePendingPairs(now: number = Date.now()): void {
2940
- db.prepare("UPDATE pairs SET status = 'expired', updated_at = ?, ended_at = ? WHERE status = 'pending' AND expires_at <= ?")
3008
+ db.query("UPDATE pairs SET status = 'expired', updated_at = ?, ended_at = ? WHERE status = 'pending' AND expires_at <= ?")
2941
3009
  .run(now, now, now);
2942
3010
  }
2943
3011
 
2944
3012
  function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void {
2945
- db.prepare(`
3013
+ db.query(`
2946
3014
  UPDATE pairs
2947
3015
  SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ?
2948
3016
  WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
@@ -2951,7 +3019,7 @@ function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void
2951
3019
 
2952
3020
  function getOpenPairForAgent(agentId: string): PairSession | null {
2953
3021
  expirePendingPairs();
2954
- const row = db.prepare(`
3022
+ const row = db.query(`
2955
3023
  SELECT * FROM pairs
2956
3024
  WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
2957
3025
  ORDER BY updated_at DESC
@@ -3038,7 +3106,7 @@ function pairSystemMessage(pair: PairSession, to: string, event: string, subject
3038
3106
 
3039
3107
  export function getPair(id: string): PairSession | null {
3040
3108
  expirePendingPairs();
3041
- const row = db.prepare("SELECT * FROM pairs WHERE id = ?").get(id) as any;
3109
+ const row = db.query("SELECT * FROM pairs WHERE id = ?").get(id) as any;
3042
3110
  return row ? rowToPair(row) : null;
3043
3111
  }
3044
3112
 
@@ -3055,7 +3123,7 @@ export function listPairs(filter?: { agentId?: string; status?: PairStatus }): P
3055
3123
  params.push(filter.status);
3056
3124
  }
3057
3125
  const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
3058
- return (db.prepare(`SELECT * FROM pairs ${where} ORDER BY updated_at DESC LIMIT 100`).all(...params) as any[]).map(rowToPair);
3126
+ return (db.query(`SELECT * FROM pairs ${where} ORDER BY updated_at DESC LIMIT 100`).all(...params) as any[]).map(rowToPair);
3059
3127
  }
3060
3128
 
3061
3129
  export function createPair(input: CreatePairInput): {
@@ -3082,7 +3150,7 @@ export function createPair(input: CreatePairInput): {
3082
3150
  const now = Date.now();
3083
3151
  const ttlMs = Math.min(Math.max(input.ttlMs ?? DEFAULT_PAIR_TTL_MS, 10_000), MAX_PAIR_TTL_MS);
3084
3152
  const id = randomUUID();
3085
- db.prepare(`
3153
+ db.query(`
3086
3154
  INSERT INTO pairs (id, requester_id, target_id, status, objective, meta, created_at, updated_at, expires_at)
3087
3155
  VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?)
3088
3156
  `).run(id, input.from, resolved.agent.id, input.objective ?? null, JSON.stringify(input.meta ?? {}), now, now, now + ttlMs);
@@ -3122,7 +3190,7 @@ export function acceptPair(id: string, input: PairActionInput): { ok: true; pair
3122
3190
  }
3123
3191
 
3124
3192
  const now = Date.now();
3125
- db.prepare("UPDATE pairs SET status = 'active', accepted_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
3193
+ db.query("UPDATE pairs SET status = 'active', accepted_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'")
3126
3194
  .run(now, now, id);
3127
3195
  const active = getPair(id)!;
3128
3196
  const notices = [
@@ -3139,7 +3207,7 @@ export function rejectPair(id: string, input: PairActionInput): { ok: true; pair
3139
3207
  if (pair.status !== "pending") return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
3140
3208
  if (pair.targetId !== input.agentId) return { ok: false, code: "forbidden", error: "only the target agent can reject this pair" };
3141
3209
  const now = Date.now();
3142
- db.prepare("UPDATE pairs SET status = 'rejected', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
3210
+ db.query("UPDATE pairs SET status = 'rejected', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
3143
3211
  .run(now, input.agentId, now, id);
3144
3212
  const rejected = getPair(id)!;
3145
3213
  const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
@@ -3154,7 +3222,7 @@ export function endPair(id: string, input: PairActionInput): { ok: true; pair: P
3154
3222
  if (!pairParticipant(pair, input.agentId)) return { ok: false, code: "forbidden", error: "only pair participants can hang up" };
3155
3223
  if (!OPEN_PAIR_STATUSES.includes(pair.status as any)) return { ok: false, code: "invalid", error: `pair is ${pair.status}` };
3156
3224
  const now = Date.now();
3157
- db.prepare("UPDATE pairs SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
3225
+ db.query("UPDATE pairs SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ? WHERE id = ?")
3158
3226
  .run(now, input.agentId, now, id);
3159
3227
  const ended = getPair(id)!;
3160
3228
  const reason = input.reason ? `\n\nReason:\n${input.reason}` : "";
@@ -3184,7 +3252,7 @@ export function sendPairMessage(id: string, input: PairMessageInput): { ok: true
3184
3252
  targetId: pair.targetId,
3185
3253
  },
3186
3254
  });
3187
- db.prepare("UPDATE pairs SET last_message_at = ?, updated_at = ? WHERE id = ?").run(now, now, id);
3255
+ db.query("UPDATE pairs SET last_message_at = ?, updated_at = ? WHERE id = ?").run(now, now, id);
3188
3256
  return { ok: true, pair: getPair(id)!, message };
3189
3257
  }
3190
3258
 
@@ -3224,7 +3292,7 @@ export function upsertArtifactBlob(input: {
3224
3292
  createdAt?: number;
3225
3293
  }): ArtifactBlob {
3226
3294
  const now = input.createdAt ?? Date.now();
3227
- db.prepare(`
3295
+ db.query(`
3228
3296
  INSERT INTO artifact_blobs (digest, storage_uri, media_type, size, created_at)
3229
3297
  VALUES (?, ?, ?, ?, ?)
3230
3298
  ON CONFLICT(digest) DO UPDATE SET
@@ -3236,12 +3304,12 @@ export function upsertArtifactBlob(input: {
3236
3304
  }
3237
3305
 
3238
3306
  export function getArtifactBlob(digest: string): ArtifactBlob | null {
3239
- const row = db.prepare("SELECT * FROM artifact_blobs WHERE digest = ?").get(digest) as any;
3307
+ const row = db.query("SELECT * FROM artifact_blobs WHERE digest = ?").get(digest) as any;
3240
3308
  return row ? rowToArtifactBlob(row) : null;
3241
3309
  }
3242
3310
 
3243
3311
  export function deleteArtifactBlob(digest: string): boolean {
3244
- return db.prepare("DELETE FROM artifact_blobs WHERE digest = ?").run(digest).changes > 0;
3312
+ return db.query("DELETE FROM artifact_blobs WHERE digest = ?").run(digest).changes > 0;
3245
3313
  }
3246
3314
 
3247
3315
  export function createArtifact(input: {
@@ -3260,7 +3328,7 @@ export function createArtifact(input: {
3260
3328
  if (!getArtifactBlob(input.blobDigest)) throw new ValidationError("artifact blob not found");
3261
3329
  const id = input.id ?? artifactId();
3262
3330
  const now = Date.now();
3263
- db.prepare(`
3331
+ db.query(`
3264
3332
  INSERT INTO artifacts (
3265
3333
  id, blob_digest, media_type, kind, filename, size, visibility, sensitivity,
3266
3334
  created_by, created_at, expires_at, metadata
@@ -3284,7 +3352,7 @@ export function createArtifact(input: {
3284
3352
  }
3285
3353
 
3286
3354
  export function getArtifact(id: string): Artifact | null {
3287
- const row = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id) as any;
3355
+ const row = db.query("SELECT * FROM artifacts WHERE id = ?").get(id) as any;
3288
3356
  if (!row) return null;
3289
3357
  return rowToArtifact(row, listArtifactLinks(id));
3290
3358
  }
@@ -3311,22 +3379,22 @@ export function listArtifacts(query: {
3311
3379
  }
3312
3380
  const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
3313
3381
  const limit = Math.min(Math.max(query.limit ?? 100, 1), 500);
3314
- const rows = db.prepare(`SELECT a.* FROM artifacts a ${where} ORDER BY a.created_at DESC LIMIT ?`).all(...params, limit) as any[];
3382
+ const rows = db.query(`SELECT a.* FROM artifacts a ${where} ORDER BY a.created_at DESC LIMIT ?`).all(...params, limit) as any[];
3315
3383
  return rows.map((row) => rowToArtifact(row, listArtifactLinks(row.id)));
3316
3384
  }
3317
3385
 
3318
3386
  export function deleteArtifact(id: string): boolean {
3319
3387
  return db.transaction(() => {
3320
- return db.prepare("DELETE FROM artifacts WHERE id = ?").run(id).changes > 0;
3388
+ return db.query("DELETE FROM artifacts WHERE id = ?").run(id).changes > 0;
3321
3389
  })();
3322
3390
  }
3323
3391
 
3324
3392
  function listArtifactLinks(artifactId: string): ArtifactLink[] {
3325
- return (db.prepare("SELECT * FROM artifact_links WHERE artifact_id = ? ORDER BY created_at ASC").all(artifactId) as any[]).map(rowToArtifactLink);
3393
+ return (db.query("SELECT * FROM artifact_links WHERE artifact_id = ? ORDER BY created_at ASC").all(artifactId) as any[]).map(rowToArtifactLink);
3326
3394
  }
3327
3395
 
3328
3396
  export function listArtifactsForEntity(entityType: ArtifactLink["entityType"], entityId: string | number): Artifact[] {
3329
- const rows = db.prepare(`
3397
+ const rows = db.query(`
3330
3398
  SELECT a.*
3331
3399
  FROM artifacts a
3332
3400
  JOIN artifact_links l ON l.artifact_id = a.id
@@ -3349,7 +3417,7 @@ export function linkArtifact(input: {
3349
3417
  if (!getArtifact(input.artifactId)) throw new ValidationError(`artifact ${input.artifactId} not found`);
3350
3418
  const now = Date.now();
3351
3419
  const id = artifactLinkId();
3352
- db.prepare(`
3420
+ db.query(`
3353
3421
  INSERT OR IGNORE INTO artifact_links (id, artifact_id, entity_type, entity_id, role, title, created_by, created_at)
3354
3422
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3355
3423
  `).run(
@@ -3362,7 +3430,7 @@ export function linkArtifact(input: {
3362
3430
  input.createdBy,
3363
3431
  now,
3364
3432
  );
3365
- const row = db.prepare(`
3433
+ const row = db.query(`
3366
3434
  SELECT * FROM artifact_links
3367
3435
  WHERE artifact_id = ? AND entity_type = ? AND entity_id = ? AND ((role IS NULL AND ? IS NULL) OR role = ?)
3368
3436
  ORDER BY created_at DESC
@@ -3372,16 +3440,16 @@ export function linkArtifact(input: {
3372
3440
  }
3373
3441
 
3374
3442
  function deleteArtifactLinksForEntity(entityType: ArtifactLink["entityType"], entityId: string | number): number {
3375
- return db.prepare("DELETE FROM artifact_links WHERE entity_type = ? AND entity_id = ?").run(entityType, String(entityId)).changes;
3443
+ return db.query("DELETE FROM artifact_links WHERE entity_type = ? AND entity_id = ?").run(entityType, String(entityId)).changes;
3376
3444
  }
3377
3445
 
3378
3446
  export function artifactBlobReferenceCount(digest: string): number {
3379
- const row = db.prepare("SELECT COUNT(*) AS count FROM artifacts WHERE blob_digest = ?").get(digest) as { count?: number };
3447
+ const row = db.query("SELECT COUNT(*) AS count FROM artifacts WHERE blob_digest = ?").get(digest) as { count?: number };
3380
3448
  return row.count ?? 0;
3381
3449
  }
3382
3450
 
3383
3451
  function unreferencedArtifactBlobs(): ArtifactBlob[] {
3384
- return (db.prepare(`
3452
+ return (db.query(`
3385
3453
  SELECT b.*
3386
3454
  FROM artifact_blobs b
3387
3455
  WHERE NOT EXISTS (SELECT 1 FROM artifacts a WHERE a.blob_digest = b.digest)
@@ -3392,12 +3460,12 @@ export function sweepArtifacts(input: { now?: number; unlinkedGraceMs?: number }
3392
3460
  const now = input.now ?? Date.now();
3393
3461
  const unlinkedCutoff = now - (input.unlinkedGraceMs ?? 60 * 60 * 1000);
3394
3462
  return db.transaction(() => {
3395
- const rows = db.prepare(`
3463
+ const rows = db.query(`
3396
3464
  SELECT id FROM artifacts a
3397
3465
  WHERE (expires_at IS NOT NULL AND expires_at <= ?)
3398
3466
  OR (created_at <= ? AND NOT EXISTS (SELECT 1 FROM artifact_links l WHERE l.artifact_id = a.id))
3399
3467
  `).all(now, unlinkedCutoff) as Array<{ id: string }>;
3400
- for (const row of rows) db.prepare("DELETE FROM artifacts WHERE id = ?").run(row.id);
3468
+ for (const row of rows) db.query("DELETE FROM artifacts WHERE id = ?").run(row.id);
3401
3469
  const blobs = unreferencedArtifactBlobs();
3402
3470
  for (const blob of blobs) deleteArtifactBlob(blob.digest);
3403
3471
  return { artifactIds: rows.map((row) => row.id), blobs };
@@ -3451,7 +3519,7 @@ function linkAttachmentRefs(entityType: ArtifactLink["entityType"], entityId: st
3451
3519
 
3452
3520
  function findMessageByIdempotencyKey(from: string, key: string): Message | null {
3453
3521
  const row = db
3454
- .prepare(`${MSG_SELECT} WHERE m.from_agent = ? AND m.idempotency_key = ? LIMIT 1`)
3522
+ .query(`${MSG_SELECT} WHERE m.from_agent = ? AND m.idempotency_key = ? LIMIT 1`)
3455
3523
  .get(from, key) as any;
3456
3524
  return row ? rowToMessage(row) : null;
3457
3525
  }
@@ -3463,12 +3531,12 @@ function policyNameFromTarget(target: string): string | null {
3463
3531
  }
3464
3532
 
3465
3533
  function spawnPolicyExists(policyName: string): boolean {
3466
- const row = db.prepare("SELECT 1 FROM config WHERE namespace = 'spawn-policy' AND key = ?").get(policyName);
3534
+ const row = db.query("SELECT 1 FROM config WHERE namespace = 'spawn-policy' AND key = ?").get(policyName);
3467
3535
  return Boolean(row);
3468
3536
  }
3469
3537
 
3470
3538
  function runningAgentForPolicy(policyName: string): string | null {
3471
- const row = db.prepare(`
3539
+ const row = db.query(`
3472
3540
  SELECT agent_id
3473
3541
  FROM managed_agent_state
3474
3542
  WHERE policy_name = ? AND status = 'running' AND agent_id IS NOT NULL
@@ -3480,7 +3548,7 @@ function runningAgentForPolicy(policyName: string): string | null {
3480
3548
  }
3481
3549
 
3482
3550
  function queueDepthLimit(target: string): number {
3483
- const row = db.prepare("SELECT value FROM config WHERE namespace = 'system' AND key = 'message-queue'").get() as { value?: string } | undefined;
3551
+ const row = db.query("SELECT value FROM config WHERE namespace = 'system' AND key = 'message-queue'").get() as { value?: string } | undefined;
3484
3552
  const parsed = row?.value ? parseJson<Record<string, unknown>>(row.value, {}) : {};
3485
3553
  const perTarget = parsed?.maxDepthPerTarget;
3486
3554
  if (typeof perTarget === "number" && Number.isSafeInteger(perTarget) && perTarget > 0) return perTarget;
@@ -3494,7 +3562,7 @@ function queueDepthLimit(target: string): number {
3494
3562
 
3495
3563
  function enforceQueueLimit(target: string): void {
3496
3564
  const limit = queueDepthLimit(target);
3497
- const rows = db.prepare(`
3565
+ const rows = db.query(`
3498
3566
  SELECT id FROM messages
3499
3567
  WHERE to_target = ? AND delivery_status = 'queued'
3500
3568
  ORDER BY queued_at DESC, id DESC
@@ -3596,7 +3664,7 @@ const REPLY_DUPLICATE_WINDOW_MS = 2 * 60 * 1000;
3596
3664
 
3597
3665
  function findRecentDuplicateReply(input: SendMessageInput, threadId: number | null, now: number, hasAttachments: boolean): Message | null {
3598
3666
  if (!input.replyTo || threadId === null || hasAttachments) return null;
3599
- const row = db.prepare(`
3667
+ const row = db.query(`
3600
3668
  ${MSG_SELECT}
3601
3669
  WHERE m.from_agent = ?
3602
3670
  AND m.to_target = ?
@@ -3619,6 +3687,16 @@ function findRecentDuplicateReply(input: SendMessageInput, threadId: number | nu
3619
3687
  return row ? rowToMessage(row) : null;
3620
3688
  }
3621
3689
 
3690
+ // Event time may be queued-then-backfilled, so it can legitimately be older than the
3691
+ // receive time — but it must be a sane epoch-ms value. Returns null (column stays NULL, so
3692
+ // readers fall back to created_at) for absent/invalid values or anything more than a minute
3693
+ // in the future (clock-skew guard). Only a real backfilled time is stored.
3694
+ function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: number): number | null {
3695
+ if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return null;
3696
+ if (occurredAt <= 0 || occurredAt > receivedAt + 60_000) return null;
3697
+ return Math.floor(occurredAt);
3698
+ }
3699
+
3622
3700
  export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
3623
3701
  const now = Date.now();
3624
3702
  const payload = input.payload ?? {};
@@ -3645,19 +3723,19 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
3645
3723
  const duplicateReply = findRecentDuplicateReply(input, threadId, now, attachmentRefs.length > 0);
3646
3724
  if (duplicateReply) return { message: duplicateReply, created: false };
3647
3725
 
3648
- const insert = db.prepare(`
3726
+ const insert = db.query(`
3649
3727
  INSERT INTO messages (
3650
3728
  from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable,
3651
3729
  idempotency_key, delivery_status, queued_at, max_age_seconds, resolved_to_agent,
3652
- payload, meta, created_at
3730
+ payload, meta, created_at, occurred_at
3653
3731
  )
3654
3732
  VALUES (
3655
3733
  $from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable,
3656
3734
  $idempotencyKey, $deliveryStatus, $queuedAt, $maxAgeSeconds, $resolvedToAgent,
3657
- $payload, $meta, $now
3735
+ $payload, $meta, $now, $occurredAt
3658
3736
  )
3659
3737
  `);
3660
- const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
3738
+ const setSelfThread = db.query("UPDATE messages SET thread_id = ? WHERE id = ?");
3661
3739
  const claimable = shouldStoreClaimable(input);
3662
3740
  const kind = inferMessageKind(input);
3663
3741
  const policyName = policyNameFromTarget(input.to);
@@ -3695,6 +3773,8 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
3695
3773
  $payload: JSON.stringify(payload),
3696
3774
  $meta: JSON.stringify(input.meta ?? {}),
3697
3775
  $now: now,
3776
+ // Sanitize: only accept a plausible epoch-ms event time, else fall back to receive time.
3777
+ $occurredAt: sanitizeOccurredAt(input.occurredAt, now),
3698
3778
  });
3699
3779
  const newId = Number(result.lastInsertRowid);
3700
3780
  if (threadId === null) setSelfThread.run(newId, newId);
@@ -3716,7 +3796,7 @@ export function getThread(messageId: number): Message[] {
3716
3796
  const threadId = msg.threadId ?? msg.id;
3717
3797
  return (
3718
3798
  db
3719
- .prepare(`${MSG_SELECT} WHERE m.thread_id = ? ORDER BY m.created_at ASC`)
3799
+ .query(`${MSG_SELECT} WHERE m.thread_id = ? ORDER BY m.created_at ASC`)
3720
3800
  .all(threadId) as any[]
3721
3801
  ).map(rowToMessage);
3722
3802
  }
@@ -3735,10 +3815,10 @@ export function setMessageReaction(input: {
3735
3815
 
3736
3816
  const now = Date.now();
3737
3817
  if (input.action === "remove") {
3738
- db.prepare("DELETE FROM message_reactions WHERE message_id = ? AND actor_id = ? AND emoji = ?")
3818
+ db.query("DELETE FROM message_reactions WHERE message_id = ? AND actor_id = ? AND emoji = ?")
3739
3819
  .run(input.messageId, actorId, emoji);
3740
3820
  } else {
3741
- db.prepare(`
3821
+ db.query(`
3742
3822
  INSERT INTO message_reactions (message_id, actor_id, emoji, created_at, updated_at)
3743
3823
  VALUES (?, ?, ?, ?, ?)
3744
3824
  ON CONFLICT(message_id, actor_id, emoji) DO UPDATE SET updated_at = excluded.updated_at
@@ -3753,7 +3833,7 @@ export function findMessageByTelegramSource(input: {
3753
3833
  chatId: string;
3754
3834
  messageId: string;
3755
3835
  }): Message | null {
3756
- const rows = db.prepare(`
3836
+ const rows = db.query(`
3757
3837
  ${MSG_SELECT}
3758
3838
  WHERE json_extract(m.payload, '$.source.telegram.chatId') = ?
3759
3839
  AND json_extract(m.payload, '$.source.telegram.messageId') = ?
@@ -3767,7 +3847,7 @@ export function findMessageByTelegramSource(input: {
3767
3847
 
3768
3848
  function claimMessageRow(messageId: number, agentId: string, now: number): { ok: false; error: string } | { ok: true } {
3769
3849
  const expiresAt = now + CLAIM_LEASE_MS;
3770
- const result = db.prepare(
3850
+ const result = db.query(
3771
3851
  "UPDATE messages SET claimed_by = ?, claimed_at = ?, claim_expires_at = ? WHERE id = ? AND claimed_by IS NULL"
3772
3852
  ).run(agentId, now, expiresAt, messageId);
3773
3853
 
@@ -3807,7 +3887,7 @@ export function claimMessage(messageId: number, agentId: string, guard?: AgentSe
3807
3887
  throw new ClaimError(`linked task is ${task.status}`);
3808
3888
  }
3809
3889
 
3810
- const taskClaim = db.prepare(`
3890
+ const taskClaim = db.query(`
3811
3891
  UPDATE tasks SET status = 'claimed', claimed_by = ?, claimed_at = ?, claim_expires_at = ?, updated_at = ?
3812
3892
  WHERE id = ? AND message_id = ? AND status IN ('open', 'blocked')
3813
3893
  `).run(agentId, now, expiresAt, now, taskId, messageId);
@@ -3846,12 +3926,12 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
3846
3926
  const expiresAt = now + CLAIM_LEASE_MS;
3847
3927
  let task: Task | undefined;
3848
3928
  db.transaction(() => {
3849
- db.prepare("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
3929
+ db.query("UPDATE messages SET claim_expires_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, messageId, agentId);
3850
3930
  const taskId = typeof msg.payload?.taskId === "number" && Number.isSafeInteger(msg.payload.taskId)
3851
3931
  ? msg.payload.taskId
3852
3932
  : null;
3853
3933
  if (taskId) {
3854
- db.prepare("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
3934
+ db.query("UPDATE tasks SET claim_expires_at = ?, updated_at = ? WHERE id = ? AND claimed_by = ?").run(expiresAt, now, taskId, agentId);
3855
3935
  task = getTask(taskId) ?? undefined;
3856
3936
  }
3857
3937
  })();
@@ -3859,13 +3939,13 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
3859
3939
  }
3860
3940
 
3861
3941
  export function getMessage(id: number): Message | null {
3862
- const row = db.prepare(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
3942
+ const row = db.query(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
3863
3943
  return row ? rowToMessage(row) : null;
3864
3944
  }
3865
3945
 
3866
3946
  export function getMessageDeliveryAttempts(messageId: number, limit = 50): MessageDeliveryAttempt[] {
3867
3947
  const safeLimit = Math.min(Math.max(limit, 1), 200);
3868
- return (db.prepare(`
3948
+ return (db.query(`
3869
3949
  SELECT * FROM message_delivery_attempts
3870
3950
  WHERE message_id = ?
3871
3951
  ORDER BY created_at DESC, id DESC
@@ -3885,7 +3965,7 @@ function insertMessageDeliveryAttempt(
3885
3965
  },
3886
3966
  now: number,
3887
3967
  ): void {
3888
- db.prepare(`
3968
+ db.query(`
3889
3969
  INSERT INTO message_delivery_attempts (message_id, agent_id, action, status, error, next_retry_at, poison_reason, created_at)
3890
3970
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3891
3971
  `).run(
@@ -3911,7 +3991,7 @@ function setMessageDeliveryState(
3911
3991
  },
3912
3992
  now: number,
3913
3993
  ): boolean {
3914
- return db.prepare(`
3994
+ return db.query(`
3915
3995
  UPDATE messages
3916
3996
  SET delivery_status = ?,
3917
3997
  delivery_attempts = delivery_attempts + ?,
@@ -4005,7 +4085,7 @@ export function getMessageDeliveryStatus(id: number): Pick<Message, "id" | "to"
4005
4085
 
4006
4086
  export function listQueuedMessages(target: string, limit = 100): Message[] {
4007
4087
  const safeLimit = Math.min(Math.max(limit, 1), 500);
4008
- return (db.prepare(`
4088
+ return (db.query(`
4009
4089
  ${MSG_SELECT}
4010
4090
  WHERE m.to_target = ? AND m.delivery_status = 'queued'
4011
4091
  ORDER BY m.queued_at ASC, m.id ASC
@@ -4015,7 +4095,7 @@ export function listQueuedMessages(target: string, limit = 100): Message[] {
4015
4095
 
4016
4096
  export function resolveQueuedPolicyMessages(policyName: string, agentId: string): Message[] {
4017
4097
  const target = `policy:${policyName}`;
4018
- const rows = db.prepare(`
4098
+ const rows = db.query(`
4019
4099
  SELECT m.id
4020
4100
  FROM messages m
4021
4101
  WHERE m.to_target = ?
@@ -4031,7 +4111,7 @@ export function resolveQueuedPolicyMessages(policyName: string, agentId: string)
4031
4111
  if (rows.length === 0) return [];
4032
4112
  const ids = rows.map((row) => row.id);
4033
4113
  const placeholders = ids.map(() => "?").join(",");
4034
- db.prepare(`
4114
+ db.query(`
4035
4115
  UPDATE messages
4036
4116
  SET delivery_status = 'pending',
4037
4117
  resolved_to_agent = ?,
@@ -4050,7 +4130,7 @@ export function resolveQueuedPolicyMessages(policyName: string, agentId: string)
4050
4130
  }
4051
4131
 
4052
4132
  export function expireQueuedMessages(now: number = Date.now()): Message[] {
4053
- const rows = db.prepare(`
4133
+ const rows = db.query(`
4054
4134
  SELECT id FROM messages
4055
4135
  WHERE delivery_status = 'queued'
4056
4136
  AND queued_at IS NOT NULL
@@ -4095,7 +4175,8 @@ export function listRecentMessages(limit: number = 100, since?: number, channel?
4095
4175
  const sql = `${MSG_SELECT} ${where} ORDER BY m.created_at DESC LIMIT ?`;
4096
4176
  params.push(limit);
4097
4177
 
4098
- return (db.prepare(sql).all(...params) as any[]).map(rowToMessage).reverse();
4178
+ const rows = timedQuery("listMessages", () => db.query(sql).all(...params) as any[]);
4179
+ return rows.map(rowToMessage).reverse();
4099
4180
  }
4100
4181
 
4101
4182
  export function pollMessages(query: PollQuery): Message[] {
@@ -4163,7 +4244,8 @@ export function pollMessages(query: PollQuery): Message[] {
4163
4244
  const sql = `${MSG_SELECT} WHERE ${conditions.join(" AND ")} ORDER BY m.created_at ASC LIMIT ?`;
4164
4245
  params.push(limit);
4165
4246
 
4166
- return (db.prepare(sql).all(...params) as any[]).map(rowToMessage);
4247
+ const rows = timedQuery("pollMessages", () => db.query(sql).all(...params) as any[]);
4248
+ return rows.map(rowToMessage);
4167
4249
  }
4168
4250
 
4169
4251
  function messageRequiresReply(message: Message): boolean {
@@ -4191,7 +4273,7 @@ function isCoveredByLaterAgentResponse(message: Message, agentId: string): boole
4191
4273
  // Order by id, not created_at: ids are monotonic insertion order, so this is
4192
4274
  // robust when a reply lands in the same millisecond as the message it covers
4193
4275
  // (created_at > … strictly would miss it, leaving the message wrongly pending).
4194
- const replies = (db.prepare(`
4276
+ const replies = (db.query(`
4195
4277
  ${MSG_SELECT}
4196
4278
  WHERE m.from_agent = ?
4197
4279
  AND m.id > ?
@@ -4224,7 +4306,7 @@ function replyObligationFromMessage(message: Message, agentId: string): ReplyObl
4224
4306
 
4225
4307
  export function listPendingReplyObligations(agentId: string, limit = 20): ReplyObligation[] {
4226
4308
  const scanLimit = Math.max(limit * 5, 50);
4227
- const rows = db.prepare(`
4309
+ const rows = timedQuery("listPendingReplyObligations", () => db.query(`
4228
4310
  ${MSG_SELECT}
4229
4311
  WHERE EXISTS (
4230
4312
  SELECT 1 FROM message_reads mr
@@ -4238,7 +4320,7 @@ export function listPendingReplyObligations(agentId: string, limit = 20): ReplyO
4238
4320
  )
4239
4321
  ORDER BY m.created_at ASC
4240
4322
  LIMIT ?
4241
- `).all(agentId, agentId, agentId, scanLimit) as any[];
4323
+ `).all(agentId, agentId, agentId, scanLimit) as any[]);
4242
4324
  return rows
4243
4325
  .map(rowToMessage)
4244
4326
  .filter(messageRequiresReply)
@@ -4248,12 +4330,12 @@ export function listPendingReplyObligations(agentId: string, limit = 20): ReplyO
4248
4330
  }
4249
4331
 
4250
4332
  export function markRead(messageId: number, agentId: string): boolean {
4251
- const exists = db.prepare("SELECT 1 FROM messages WHERE id = ?").get(messageId);
4333
+ const exists = db.query("SELECT 1 FROM messages WHERE id = ?").get(messageId);
4252
4334
  if (!exists) return false;
4253
- db.prepare(
4335
+ db.query(
4254
4336
  "INSERT OR IGNORE INTO message_reads (message_id, agent_id, read_at) VALUES (?, ?, ?)"
4255
4337
  ).run(messageId, agentId, Date.now());
4256
- db.prepare(`
4338
+ db.query(`
4257
4339
  UPDATE messages
4258
4340
  SET delivery_status = 'delivered',
4259
4341
  delivery_last_error = NULL,
@@ -4266,10 +4348,10 @@ export function markRead(messageId: number, agentId: string): boolean {
4266
4348
  }
4267
4349
 
4268
4350
  export function getInboxState(operatorId: string): InboxState {
4269
- const threads = (db.prepare(
4351
+ const threads = (db.query(
4270
4352
  "SELECT * FROM inbox_thread_state WHERE operator_id = ? ORDER BY updated_at DESC",
4271
4353
  ).all(operatorId) as any[]).map(rowToInboxThreadState);
4272
- const drafts = (db.prepare(
4354
+ const drafts = (db.query(
4273
4355
  "SELECT * FROM inbox_drafts WHERE operator_id = ? ORDER BY updated_at DESC",
4274
4356
  ).all(operatorId) as any[]).map(rowToInboxDraft);
4275
4357
  return { operatorId, threads, drafts };
@@ -4282,7 +4364,7 @@ export function setInboxThreadState(input: {
4282
4364
  archivedAtMessageId?: number | null;
4283
4365
  }): InboxThreadState {
4284
4366
  const now = Date.now();
4285
- const current = db.prepare(
4367
+ const current = db.query(
4286
4368
  "SELECT * FROM inbox_thread_state WHERE operator_id = ? AND peer_id = ?",
4287
4369
  ).get(input.operatorId, input.peerId) as any | undefined;
4288
4370
 
@@ -4293,7 +4375,7 @@ export function setInboxThreadState(input: {
4293
4375
  ? input.archivedAtMessageId ?? null
4294
4376
  : current?.archived_at_message_id ?? null;
4295
4377
 
4296
- db.prepare(`
4378
+ db.query(`
4297
4379
  INSERT INTO inbox_thread_state (operator_id, peer_id, read_cursor_message_id, archived_at_message_id, updated_at)
4298
4380
  VALUES (?, ?, ?, ?, ?)
4299
4381
  ON CONFLICT(operator_id, peer_id) DO UPDATE SET
@@ -4302,7 +4384,7 @@ export function setInboxThreadState(input: {
4302
4384
  updated_at = excluded.updated_at
4303
4385
  `).run(input.operatorId, input.peerId, readCursorMessageId, archivedAtMessageId, now);
4304
4386
 
4305
- return rowToInboxThreadState(db.prepare(
4387
+ return rowToInboxThreadState(db.query(
4306
4388
  "SELECT * FROM inbox_thread_state WHERE operator_id = ? AND peer_id = ?",
4307
4389
  ).get(input.operatorId, input.peerId));
4308
4390
  }
@@ -4315,7 +4397,7 @@ export function setInboxDraft(input: {
4315
4397
  channel?: string | null;
4316
4398
  }): InboxDraft {
4317
4399
  const now = Date.now();
4318
- db.prepare(`
4400
+ db.query(`
4319
4401
  INSERT INTO inbox_drafts (operator_id, peer_id, body, subject, channel, updated_at)
4320
4402
  VALUES (?, ?, ?, ?, ?, ?)
4321
4403
  ON CONFLICT(operator_id, peer_id) DO UPDATE SET
@@ -4325,13 +4407,13 @@ export function setInboxDraft(input: {
4325
4407
  updated_at = excluded.updated_at
4326
4408
  `).run(input.operatorId, input.peerId, input.body, input.subject ?? null, input.channel ?? null, now);
4327
4409
 
4328
- return rowToInboxDraft(db.prepare(
4410
+ return rowToInboxDraft(db.query(
4329
4411
  "SELECT * FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?",
4330
4412
  ).get(input.operatorId, input.peerId));
4331
4413
  }
4332
4414
 
4333
4415
  export function deleteInboxDraft(operatorId: string, peerId: string): boolean {
4334
- return db.prepare("DELETE FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?").run(operatorId, peerId).changes > 0;
4416
+ return db.query("DELETE FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?").run(operatorId, peerId).changes > 0;
4335
4417
  }
4336
4418
 
4337
4419
  export function createChatHistoryImport(input: {
@@ -4359,7 +4441,7 @@ export function createChatHistoryImport(input: {
4359
4441
  const id = randomUUID();
4360
4442
  const now = Date.now();
4361
4443
  db.transaction(() => {
4362
- db.prepare(`
4444
+ db.query(`
4363
4445
  INSERT INTO chat_history_imports (
4364
4446
  id, target_agent_id, target_spawn_request_id, source_peer_id, source_agent_id,
4365
4447
  source_thread_id, source_agent_label, imported_by, imported_at
@@ -4376,7 +4458,7 @@ export function createChatHistoryImport(input: {
4376
4458
  now,
4377
4459
  );
4378
4460
 
4379
- const insertEntry = db.prepare(`
4461
+ const insertEntry = db.query(`
4380
4462
  INSERT INTO chat_history_import_entries (
4381
4463
  import_id, position, original_message_id, original_from, original_to, original_created_at, message_snapshot
4382
4464
  ) VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -4398,9 +4480,9 @@ export function createChatHistoryImport(input: {
4398
4480
  }
4399
4481
 
4400
4482
  function getChatHistoryImport(id: string): ChatHistoryImport | null {
4401
- const row = db.prepare("SELECT * FROM chat_history_imports WHERE id = ?").get(id) as any | undefined;
4483
+ const row = db.query("SELECT * FROM chat_history_imports WHERE id = ?").get(id) as any | undefined;
4402
4484
  if (!row) return null;
4403
- const entries = (db.prepare(
4485
+ const entries = (db.query(
4404
4486
  "SELECT * FROM chat_history_import_entries WHERE import_id = ? ORDER BY position ASC",
4405
4487
  ).all(id) as any[]).map(rowToChatHistoryImportEntry);
4406
4488
  return rowToChatHistoryImport(row, entries);
@@ -4423,9 +4505,9 @@ export function listChatHistoryImports(input: {
4423
4505
  }
4424
4506
  const limit = Math.max(1, Math.min(input.limit ?? 100, 500));
4425
4507
  const where = conditions.length ? `WHERE ${conditions.join(" OR ")}` : "";
4426
- const rows = db.prepare(`SELECT * FROM chat_history_imports ${where} ORDER BY imported_at ASC LIMIT ?`).all(...params, limit) as any[];
4508
+ const rows = db.query(`SELECT * FROM chat_history_imports ${where} ORDER BY imported_at ASC LIMIT ?`).all(...params, limit) as any[];
4427
4509
  return rows.map((row) => {
4428
- const entries = (db.prepare(
4510
+ const entries = (db.query(
4429
4511
  "SELECT * FROM chat_history_import_entries WHERE import_id = ? ORDER BY position ASC",
4430
4512
  ).all(row.id) as any[]).map(rowToChatHistoryImportEntry);
4431
4513
  return rowToChatHistoryImport(row, entries);
@@ -4454,7 +4536,7 @@ export function listActivityEvents(input: {
4454
4536
  }
4455
4537
  const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
4456
4538
  params.push(input.limit ?? 200);
4457
- return (db.prepare(
4539
+ return (db.query(
4458
4540
  `SELECT * FROM activity_events ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
4459
4541
  ).all(...params) as any[]).map(rowToActivityEvent);
4460
4542
  }
@@ -4483,11 +4565,11 @@ export function getAgentTimeline(agentId: string, opts: { limit?: number; since?
4483
4565
 
4484
4566
  // Managed agents: include policy-scoped lifecycle transitions recorded before
4485
4567
  // the agent registered (agent_id null), correlated via metadata.policyName.
4486
- const managed = db.prepare("SELECT policy_name FROM managed_agent_state WHERE agent_id = ? LIMIT 1").get(agentId) as { policy_name?: string } | undefined;
4568
+ const managed = db.query("SELECT policy_name FROM managed_agent_state WHERE agent_id = ? LIMIT 1").get(agentId) as { policy_name?: string } | undefined;
4487
4569
  if (managed?.policy_name) {
4488
4570
  const rows = (since !== undefined
4489
- ? db.prepare("SELECT * FROM activity_events WHERE agent_id IS NULL AND json_extract(metadata, '$.policyName') = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ?").all(managed.policy_name, since, limit)
4490
- : db.prepare("SELECT * FROM activity_events WHERE agent_id IS NULL AND json_extract(metadata, '$.policyName') = ? ORDER BY created_at DESC LIMIT ?").all(managed.policy_name, limit)) as any[];
4571
+ ? db.query("SELECT * FROM activity_events WHERE agent_id IS NULL AND json_extract(metadata, '$.policyName') = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ?").all(managed.policy_name, since, limit)
4572
+ : db.query("SELECT * FROM activity_events WHERE agent_id IS NULL AND json_extract(metadata, '$.policyName') = ? ORDER BY created_at DESC LIMIT ?").all(managed.policy_name, limit)) as any[];
4491
4573
  for (const ev of rows.map(rowToActivityEvent)) {
4492
4574
  entries.push({ ts: ev.createdAt, source: "activity", kind: ev.kind, title: ev.title, detail: ev.body, metadata: ev.metadata });
4493
4575
  }
@@ -4495,16 +4577,16 @@ export function getAgentTimeline(agentId: string, opts: { limit?: number; since?
4495
4577
 
4496
4578
  // Commands targeting this agent (spawn/shutdown/restart/compact) with outcome.
4497
4579
  const cmds = (since !== undefined
4498
- ? db.prepare("SELECT id, type, status, result, correlation_id, created_at, updated_at FROM commands WHERE target = ? AND updated_at >= ? ORDER BY updated_at DESC LIMIT ?").all(agentId, since, limit)
4499
- : db.prepare("SELECT id, type, status, result, correlation_id, created_at, updated_at FROM commands WHERE target = ? ORDER BY updated_at DESC LIMIT ?").all(agentId, limit)) as any[];
4580
+ ? db.query("SELECT id, type, status, result, correlation_id, created_at, updated_at FROM commands WHERE target = ? AND updated_at >= ? ORDER BY updated_at DESC LIMIT ?").all(agentId, since, limit)
4581
+ : db.query("SELECT id, type, status, result, correlation_id, created_at, updated_at FROM commands WHERE target = ? ORDER BY updated_at DESC LIMIT ?").all(agentId, limit)) as any[];
4500
4582
  for (const c of cmds) {
4501
4583
  entries.push({ ts: c.updated_at ?? c.created_at, source: "command", kind: c.type, title: `${c.type} · ${c.status}`, detail: c.result ?? undefined, metadata: { id: c.id, status: c.status, correlationId: c.correlation_id ?? undefined } });
4502
4584
  }
4503
4585
 
4504
4586
  // Delivery attempts involving this agent (failures, retries, poison).
4505
4587
  const attempts = (since !== undefined
4506
- ? db.prepare("SELECT * FROM message_delivery_attempts WHERE agent_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ?").all(agentId, since, limit)
4507
- : db.prepare("SELECT * FROM message_delivery_attempts WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?").all(agentId, limit)) as any[];
4588
+ ? db.query("SELECT * FROM message_delivery_attempts WHERE agent_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ?").all(agentId, since, limit)
4589
+ : db.query("SELECT * FROM message_delivery_attempts WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?").all(agentId, limit)) as any[];
4508
4590
  for (const row of attempts) {
4509
4591
  entries.push({ ts: row.created_at, source: "delivery", kind: `delivery.${row.action}`, title: `delivery ${row.status}`, detail: row.error ?? undefined, metadata: { messageId: row.message_id, status: row.status, poisonReason: row.poison_reason ?? undefined } });
4510
4592
  }
@@ -4515,11 +4597,11 @@ export function getAgentTimeline(agentId: string, opts: { limit?: number; since?
4515
4597
 
4516
4598
  export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
4517
4599
  if (input.clientId) {
4518
- const existing = db.prepare("SELECT * FROM activity_events WHERE client_id = ?").get(input.clientId) as any | undefined;
4600
+ const existing = db.query("SELECT * FROM activity_events WHERE client_id = ?").get(input.clientId) as any | undefined;
4519
4601
  if (existing) return rowToActivityEvent(existing);
4520
4602
  }
4521
4603
  const now = Date.now();
4522
- const result = db.prepare(`
4604
+ const result = db.query(`
4523
4605
  INSERT INTO activity_events (
4524
4606
  operator_id, client_id, kind, title, body, meta_text, icon, view, peer_id,
4525
4607
  message_id, pair_id, task_id, agent_id, metadata, created_at
@@ -4542,21 +4624,21 @@ export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
4542
4624
  JSON.stringify(input.metadata ?? {}),
4543
4625
  now,
4544
4626
  );
4545
- return rowToActivityEvent(db.prepare("SELECT * FROM activity_events WHERE id = ?").get(Number(result.lastInsertRowid)));
4627
+ return rowToActivityEvent(db.query("SELECT * FROM activity_events WHERE id = ?").get(Number(result.lastInsertRowid)));
4546
4628
  }
4547
4629
 
4548
4630
  export function deleteMessage(id: number): boolean {
4549
4631
  return db.transaction(() => {
4550
4632
  // Break reply_to references from children so the FK doesn't block delete.
4551
4633
  // Children keep their thread_id — the thread shows up minus this message.
4552
- db.prepare("UPDATE messages SET reply_to = NULL WHERE reply_to = ?").run(id);
4634
+ db.query("UPDATE messages SET reply_to = NULL WHERE reply_to = ?").run(id);
4553
4635
  deleteArtifactLinksForEntity("message", id);
4554
- return db.prepare("DELETE FROM messages WHERE id = ?").run(id).changes > 0;
4636
+ return db.query("DELETE FROM messages WHERE id = ?").run(id).changes > 0;
4555
4637
  })();
4556
4638
  }
4557
4639
 
4558
4640
  export function getLatestMessageId(): number {
4559
- const row = db.prepare("SELECT MAX(id) as id FROM messages").get() as any;
4641
+ const row = db.query("SELECT MAX(id) as id FROM messages").get() as any;
4560
4642
  return row?.id ?? 0;
4561
4643
  }
4562
4644
 
@@ -4564,10 +4646,10 @@ export function pruneOldMessages(maxAgeMs: number): number {
4564
4646
  const cutoff = Date.now() - maxAgeMs;
4565
4647
  return db.transaction(() => {
4566
4648
  db
4567
- .prepare("UPDATE messages SET reply_to = NULL WHERE reply_to IN (SELECT id FROM messages WHERE created_at < ?)")
4649
+ .query("UPDATE messages SET reply_to = NULL WHERE reply_to IN (SELECT id FROM messages WHERE created_at < ?)")
4568
4650
  .run(cutoff);
4569
4651
  return db
4570
- .prepare("DELETE FROM messages WHERE created_at < ?")
4652
+ .query("DELETE FROM messages WHERE created_at < ?")
4571
4653
  .run(cutoff).changes;
4572
4654
  })();
4573
4655
  }
@@ -4582,28 +4664,28 @@ export function getStats(): {
4582
4664
  openTasks: number;
4583
4665
  } {
4584
4666
  const agents = (
4585
- db.prepare("SELECT COUNT(*) as c FROM agents").get() as any
4667
+ db.query("SELECT COUNT(*) as c FROM agents").get() as any
4586
4668
  ).c;
4587
4669
  const online = (
4588
4670
  db
4589
- .prepare(
4671
+ .query(
4590
4672
  "SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen > ?"
4591
4673
  )
4592
4674
  .get(Date.now() - STALE_TTL_MS) as any
4593
4675
  ).c;
4594
4676
  const messages = (
4595
- db.prepare("SELECT COUNT(*) as c FROM messages").get() as any
4677
+ db.query("SELECT COUNT(*) as c FROM messages").get() as any
4596
4678
  ).c;
4597
4679
  const messagesLast24h = (
4598
4680
  db
4599
- .prepare("SELECT COUNT(*) as c FROM messages WHERE created_at > ?")
4681
+ .query("SELECT COUNT(*) as c FROM messages WHERE created_at > ?")
4600
4682
  .get(Date.now() - DAY_MS) as any
4601
4683
  ).c;
4602
4684
  const tasks = (
4603
- db.prepare("SELECT COUNT(*) as c FROM tasks").get() as any
4685
+ db.query("SELECT COUNT(*) as c FROM tasks").get() as any
4604
4686
  ).c;
4605
4687
  const openTasks = (
4606
- db.prepare("SELECT COUNT(*) as c FROM tasks WHERE status NOT IN ('done', 'failed', 'canceled')").get() as any
4688
+ db.query("SELECT COUNT(*) as c FROM tasks WHERE status NOT IN ('done', 'failed', 'canceled')").get() as any
4607
4689
  ).c;
4608
4690
 
4609
4691
  return { version: VERSION, agents, online, messages, messagesLast24h, tasks, openTasks };
@@ -4613,14 +4695,14 @@ export function getHealth(now: number = Date.now()): HealthReport {
4613
4695
  const checks: HealthCheck[] = [];
4614
4696
 
4615
4697
  try {
4616
- db.prepare("SELECT 1").get();
4698
+ db.query("SELECT 1").get();
4617
4699
  checks.push({ name: "database", status: "ok" });
4618
4700
  } catch (e) {
4619
4701
  checks.push({ name: "database", status: "error", detail: e instanceof Error ? e.message : "database check failed" });
4620
4702
  }
4621
4703
 
4622
4704
  const staleLiveAgents = (db
4623
- .prepare("SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen <= ? AND id NOT IN ('user', 'system')")
4705
+ .query("SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen <= ? AND id NOT IN ('user', 'system')")
4624
4706
  .get(now - STALE_TTL_MS) as any).c as number;
4625
4707
  checks.push({
4626
4708
  name: "stale-live-agents",
@@ -4630,7 +4712,7 @@ export function getHealth(now: number = Date.now()): HealthReport {
4630
4712
  });
4631
4713
 
4632
4714
  const expiredMessageClaims = (db
4633
- .prepare("SELECT COUNT(*) as c FROM messages WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = messages.id AND t.status IN ('done', 'failed', 'canceled'))")
4715
+ .query("SELECT COUNT(*) as c FROM messages WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = messages.id AND t.status IN ('done', 'failed', 'canceled'))")
4634
4716
  .get(now) as any).c as number;
4635
4717
  checks.push({
4636
4718
  name: "expired-message-claims",
@@ -4640,7 +4722,7 @@ export function getHealth(now: number = Date.now()): HealthReport {
4640
4722
  });
4641
4723
 
4642
4724
  const expiredTaskClaims = (db
4643
- .prepare("SELECT COUNT(*) as c FROM tasks WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')")
4725
+ .query("SELECT COUNT(*) as c FROM tasks WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')")
4644
4726
  .get(now) as any).c as number;
4645
4727
  checks.push({
4646
4728
  name: "expired-task-claims",
@@ -4650,7 +4732,7 @@ export function getHealth(now: number = Date.now()): HealthReport {
4650
4732
  });
4651
4733
 
4652
4734
  const offlineClaimedTasks = (db
4653
- .prepare(`
4735
+ .query(`
4654
4736
  SELECT COUNT(*) as c
4655
4737
  FROM tasks t
4656
4738
  JOIN agents a ON a.id = t.claimed_by
@@ -4797,12 +4879,12 @@ function parseOrchestratorUpgrade(value: unknown): OrchestratorUpgradeState | un
4797
4879
  * meta blob (no schema change). Pass null to clear.
4798
4880
  */
4799
4881
  export function setOrchestratorUpgradeState(id: string, state: OrchestratorUpgradeState | null): Orchestrator | null {
4800
- const row = db.prepare("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
4882
+ const row = db.query("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
4801
4883
  if (!row) return null;
4802
4884
  const meta = parseJson<Record<string, unknown>>(row.meta ?? "{}", {});
4803
4885
  if (state) meta.upgrade = state;
4804
4886
  else delete meta.upgrade;
4805
- db.prepare("UPDATE orchestrators SET meta = ? WHERE id = ?").run(JSON.stringify(meta), id);
4887
+ db.query("UPDATE orchestrators SET meta = ? WHERE id = ?").run(JSON.stringify(meta), id);
4806
4888
  return getOrchestrator(id);
4807
4889
  }
4808
4890
 
@@ -4890,11 +4972,11 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
4890
4972
  // Carry forward server-managed meta the orchestrator never reports (upgrade
4891
4973
  // state) — registration meta would otherwise drop it on the very re-register
4892
4974
  // that follows a self-upgrade restart, breaking version reconciliation.
4893
- const existingRow = db.prepare("SELECT meta FROM orchestrators WHERE id = ?").get(input.id) as { meta?: string } | undefined;
4975
+ const existingRow = db.query("SELECT meta FROM orchestrators WHERE id = ?").get(input.id) as { meta?: string } | undefined;
4894
4976
  const existingMeta = existingRow ? parseJson<Record<string, unknown>>(existingRow.meta ?? "{}", {}) : {};
4895
4977
  const mergedMeta = mergeOrchestratorRuntimeMeta(input.meta ?? {}, input);
4896
4978
  if (existingMeta.upgrade !== undefined && mergedMeta.upgrade === undefined) mergedMeta.upgrade = existingMeta.upgrade;
4897
- const stmt = db.prepare(`
4979
+ const stmt = db.query(`
4898
4980
  INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, api_url, env_keys, meta, last_seen, created_at)
4899
4981
  VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $apiUrl, $envKeys, $meta, $now, $now)
4900
4982
  ON CONFLICT(id) DO UPDATE SET
@@ -4944,24 +5026,24 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
4944
5026
  }
4945
5027
 
4946
5028
  export function getOrchestrator(id: string): Orchestrator | null {
4947
- const row = db.prepare("SELECT * FROM orchestrators WHERE id = ?").get(id) as any;
5029
+ const row = db.query("SELECT * FROM orchestrators WHERE id = ?").get(id) as any;
4948
5030
  return row ? rowToOrchestrator(row) : null;
4949
5031
  }
4950
5032
 
4951
5033
  export function listOrchestrators(): Orchestrator[] {
4952
- return (db.prepare("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
5034
+ return (db.query("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
4953
5035
  }
4954
5036
 
4955
5037
  export function orchestratorHeartbeat(id: string, runtime: OrchestratorRuntimeInput = {}): Orchestrator | null {
4956
5038
  const now = Date.now();
4957
- const row = db.prepare("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
5039
+ const row = db.query("SELECT meta FROM orchestrators WHERE id = ?").get(id) as { meta?: string } | undefined;
4958
5040
  if (!row) return null;
4959
5041
  const meta = mergeOrchestratorRuntimeMeta(parseJson<Record<string, unknown>>(row.meta ?? "{}", {}), runtime);
4960
5042
  if (runtime.providers) {
4961
- db.prepare("UPDATE orchestrators SET last_seen = ?, status = 'online', providers = ?, meta = ? WHERE id = ?")
5043
+ db.query("UPDATE orchestrators SET last_seen = ?, status = 'online', providers = ?, meta = ? WHERE id = ?")
4962
5044
  .run(now, JSON.stringify(runtime.providers), JSON.stringify(meta), id);
4963
5045
  } else {
4964
- db.prepare("UPDATE orchestrators SET last_seen = ?, status = 'online', meta = ? WHERE id = ?").run(now, JSON.stringify(meta), id);
5046
+ db.query("UPDATE orchestrators SET last_seen = ?, status = 'online', meta = ? WHERE id = ?").run(now, JSON.stringify(meta), id);
4965
5047
  }
4966
5048
  // Also heartbeat the agent
4967
5049
  const orch = getOrchestrator(id);
@@ -4993,7 +5075,7 @@ export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchest
4993
5075
  },
4994
5076
  } : agent;
4995
5077
  });
4996
- db.prepare("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
5078
+ db.query("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
4997
5079
  .run(JSON.stringify(enriched), Date.now(), id);
4998
5080
  return getOrchestrator(id);
4999
5081
  }
@@ -5051,7 +5133,7 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
5051
5133
 
5052
5134
  export function upsertWorkspace(input: Omit<WorkspaceRecord, "createdAt" | "updatedAt"> & Partial<Pick<WorkspaceRecord, "createdAt" | "updatedAt">>): WorkspaceRecord {
5053
5135
  const now = Date.now();
5054
- db.prepare(`
5136
+ db.query(`
5055
5137
  INSERT INTO workspaces (id, repo_root, source_cwd, worktree_path, branch, base_ref, base_sha, mode, requested_mode, status, owner_agent_id, owner_policy_name, owner_automation_run_id, steward_agent_id, metadata, created_at, updated_at, ready_at, cleaned_at)
5056
5138
  VALUES ($id, $repoRoot, $sourceCwd, $worktreePath, $branch, $baseRef, $baseSha, $mode, $requestedMode, $status, $ownerAgentId, $ownerPolicyName, $ownerAutomationRunId, $stewardAgentId, $metadata, $createdAt, $updatedAt, $readyAt, $cleanedAt)
5057
5139
  ON CONFLICT(id) DO UPDATE SET
@@ -5098,7 +5180,7 @@ export function upsertWorkspace(input: Omit<WorkspaceRecord, "createdAt" | "upda
5098
5180
  }
5099
5181
 
5100
5182
  export function getWorkspace(id: string): WorkspaceRecord | null {
5101
- const row = db.prepare("SELECT * FROM workspaces WHERE id = ?").get(id) as any;
5183
+ const row = db.query("SELECT * FROM workspaces WHERE id = ?").get(id) as any;
5102
5184
  return row ? rowToWorkspace(row) : null;
5103
5185
  }
5104
5186
 
@@ -5109,11 +5191,11 @@ export function listWorkspaces(filter: { repoRoot?: string; ownerAgentId?: strin
5109
5191
  if (filter.ownerAgentId) { where.push("owner_agent_id = ?"); params.push(filter.ownerAgentId); }
5110
5192
  if (filter.status) { where.push("status = ?"); params.push(filter.status); }
5111
5193
  const sql = `SELECT * FROM workspaces${where.length ? " WHERE " + where.join(" AND ") : ""} ORDER BY updated_at DESC`;
5112
- return (db.prepare(sql).all(...params) as any[]).map(rowToWorkspace);
5194
+ return (db.query(sql).all(...params) as any[]).map(rowToWorkspace);
5113
5195
  }
5114
5196
 
5115
5197
  export function deleteWorkspace(id: string): boolean {
5116
- return db.prepare("DELETE FROM workspaces WHERE id = ?").run(id).changes > 0;
5198
+ return db.query("DELETE FROM workspaces WHERE id = ?").run(id).changes > 0;
5117
5199
  }
5118
5200
 
5119
5201
  // Shared-mode rows are pure occupancy markers (no worktree on disk) that only
@@ -5131,9 +5213,9 @@ export function pruneOrphanedSharedWorkspaces(): string[] {
5131
5213
  OR owner_agent_id NOT IN (SELECT id FROM agents)
5132
5214
  OR owner_agent_id IN (SELECT id FROM agents WHERE status = 'offline')
5133
5215
  )`;
5134
- const rows = db.prepare(`SELECT id FROM workspaces WHERE ${orphanCondition}`).all() as Array<{ id: string }>;
5216
+ const rows = db.query(`SELECT id FROM workspaces WHERE ${orphanCondition}`).all() as Array<{ id: string }>;
5135
5217
  if (!rows.length) return [];
5136
- db.prepare(`DELETE FROM workspaces WHERE ${orphanCondition}`).run();
5218
+ db.query(`DELETE FROM workspaces WHERE ${orphanCondition}`).run();
5137
5219
  return rows.map((r) => r.id);
5138
5220
  })();
5139
5221
  }
@@ -5143,7 +5225,7 @@ export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metad
5143
5225
  if (!existing) return null;
5144
5226
  const nextMeta = { ...existing.metadata, ...metadata };
5145
5227
  const now = Date.now();
5146
- db.prepare(`
5228
+ db.query(`
5147
5229
  UPDATE workspaces
5148
5230
  SET status = ?, metadata = ?, updated_at = ?, ready_at = coalesce(ready_at, ?), cleaned_at = coalesce(cleaned_at, ?)
5149
5231
  WHERE id = ?
@@ -5182,19 +5264,19 @@ function rowToRepoSteward(row: any): RepoStewardRecord {
5182
5264
  }
5183
5265
 
5184
5266
  export function getRepoSteward(repoRoot: string): RepoStewardRecord | null {
5185
- const row = db.prepare("SELECT * FROM repo_stewards WHERE repo_root = ?").get(repoRoot) as any;
5267
+ const row = db.query("SELECT * FROM repo_stewards WHERE repo_root = ?").get(repoRoot) as any;
5186
5268
  return row ? rowToRepoSteward(row) : null;
5187
5269
  }
5188
5270
 
5189
5271
  export function listRepoStewards(): RepoStewardRecord[] {
5190
- return (db.prepare("SELECT * FROM repo_stewards ORDER BY updated_at DESC").all() as any[]).map(rowToRepoSteward);
5272
+ return (db.query("SELECT * FROM repo_stewards ORDER BY updated_at DESC").all() as any[]).map(rowToRepoSteward);
5191
5273
  }
5192
5274
 
5193
5275
  // Persist the elected steward for a repo. The row is never deleted, so a repo's
5194
5276
  // stewardship survives a full all-agents-offline gap (steward goes NULL/dormant,
5195
5277
  // last_steward_agent_id keeps continuity) and resumes on the next agent join.
5196
5278
  function upsertRepoSteward(repoRoot: string, steward: string | null, now: number): void {
5197
- db.prepare(`
5279
+ db.query(`
5198
5280
  INSERT INTO repo_stewards (repo_root, steward_agent_id, last_steward_agent_id, elected_at, updated_at)
5199
5281
  VALUES ($repoRoot, $steward, $steward, $electedAt, $now)
5200
5282
  ON CONFLICT(repo_root) DO UPDATE SET
@@ -5210,7 +5292,7 @@ function upsertRepoSteward(repoRoot: string, steward: string | null, now: number
5210
5292
 
5211
5293
  function electWorkspaceStewards(repoRoot?: string): void {
5212
5294
  const params: string[] = repoRoot ? [repoRoot] : [];
5213
- const repoRows = db.prepare(`
5295
+ const repoRows = db.query(`
5214
5296
  SELECT DISTINCT repo_root FROM workspaces
5215
5297
  WHERE status IN (${STEWARD_LIVE_STATUSES})
5216
5298
  ${repoRoot ? "AND repo_root = ?" : ""}
@@ -5220,7 +5302,7 @@ function electWorkspaceStewards(repoRoot?: string): void {
5220
5302
  // Candidate pool: owners of live workspaces in this repo who are online,
5221
5303
  // oldest first. A steward must be an online agent actively in the repo — an
5222
5304
  // offline agent can't coordinate, so it is never elected (the old bug).
5223
- const pool = (db.prepare(`
5305
+ const pool = (db.query(`
5224
5306
  SELECT w.owner_agent_id AS id, MIN(w.created_at) AS created_at
5225
5307
  FROM workspaces w JOIN agents a ON a.id = w.owner_agent_id
5226
5308
  WHERE w.repo_root = ? AND w.owner_agent_id IS NOT NULL
@@ -5239,7 +5321,7 @@ function electWorkspaceStewards(repoRoot?: string): void {
5239
5321
  // re-elections don't churn updated_at and reset the auto-abandon clock for a
5240
5322
  // dormant repo (a stranded review_requested must still age out).
5241
5323
  if (steward !== current) {
5242
- db.prepare(`UPDATE workspaces SET steward_agent_id = ?, updated_at = ? WHERE repo_root = ? AND status IN (${STEWARD_LIVE_STATUSES})`)
5324
+ db.query(`UPDATE workspaces SET steward_agent_id = ?, updated_at = ? WHERE repo_root = ? AND status IN (${STEWARD_LIVE_STATUSES})`)
5243
5325
  .run(steward, now, row.repo_root);
5244
5326
  }
5245
5327
  }
@@ -5263,7 +5345,7 @@ export function patchWorkspaceMetadata(id: string, patch: Record<string, unknown
5263
5345
  if (v === undefined) delete next[k];
5264
5346
  else next[k] = v;
5265
5347
  }
5266
- db.prepare("UPDATE workspaces SET metadata = ? WHERE id = ?").run(JSON.stringify(next), id);
5348
+ db.query("UPDATE workspaces SET metadata = ? WHERE id = ?").run(JSON.stringify(next), id);
5267
5349
  return getWorkspace(id);
5268
5350
  }
5269
5351
 
@@ -5271,7 +5353,7 @@ export function patchWorkspaceMetadata(id: string, patch: Record<string, unknown
5271
5353
  // when an agent (re)registers so a dormant repo regains a steward on rejoin
5272
5354
  // without a full unscoped sweep.
5273
5355
  function electWorkspaceStewardsForAgent(agentId: string): void {
5274
- const repos = db.prepare(`
5356
+ const repos = db.query(`
5275
5357
  SELECT DISTINCT repo_root FROM workspaces
5276
5358
  WHERE owner_agent_id = ? AND status IN (${STEWARD_LIVE_STATUSES})
5277
5359
  `).all(agentId) as Array<{ repo_root: string }>;
@@ -5301,18 +5383,18 @@ function rowToMergeLease(row: any): MergeLeaseRecord {
5301
5383
  }
5302
5384
 
5303
5385
  export function getMergeLease(repoRoot: string): MergeLeaseRecord | null {
5304
- const row = db.prepare("SELECT * FROM workspace_merge_leases WHERE repo_root = ?").get(repoRoot) as any;
5386
+ const row = db.query("SELECT * FROM workspace_merge_leases WHERE repo_root = ?").get(repoRoot) as any;
5305
5387
  return row ? rowToMergeLease(row) : null;
5306
5388
  }
5307
5389
 
5308
5390
  export function listMergeLeases(): MergeLeaseRecord[] {
5309
- return (db.prepare("SELECT * FROM workspace_merge_leases ORDER BY acquired_at DESC").all() as any[]).map(rowToMergeLease);
5391
+ return (db.query("SELECT * FROM workspace_merge_leases ORDER BY acquired_at DESC").all() as any[]).map(rowToMergeLease);
5310
5392
  }
5311
5393
 
5312
5394
  export function releaseExpiredMergeLeases(now: number = Date.now()): string[] {
5313
- const expired = db.prepare("SELECT repo_root FROM workspace_merge_leases WHERE expires_at <= ?").all(now) as Array<{ repo_root: string }>;
5395
+ const expired = db.query("SELECT repo_root FROM workspace_merge_leases WHERE expires_at <= ?").all(now) as Array<{ repo_root: string }>;
5314
5396
  if (!expired.length) return [];
5315
- db.prepare("DELETE FROM workspace_merge_leases WHERE expires_at <= ?").run(now);
5397
+ db.query("DELETE FROM workspace_merge_leases WHERE expires_at <= ?").run(now);
5316
5398
  return expired.map((r) => r.repo_root);
5317
5399
  }
5318
5400
 
@@ -5329,7 +5411,7 @@ export function acquireMergeLease(
5329
5411
  const existing = getMergeLease(repoRoot);
5330
5412
  if (existing && existing.expiresAt > now) return { ok: false as const, lease: existing };
5331
5413
  const expiresAt = now + WORKSPACE_MERGE_LEASE_MS;
5332
- db.prepare(`
5414
+ db.query(`
5333
5415
  INSERT INTO workspace_merge_leases (repo_root, workspace_id, command_id, holder, acquired_at, expires_at)
5334
5416
  VALUES (?, ?, NULL, ?, ?, ?)
5335
5417
  ON CONFLICT(repo_root) DO UPDATE SET
@@ -5343,7 +5425,7 @@ export function acquireMergeLease(
5343
5425
  // Attach the dispatched command id to a held lease so it can be released by
5344
5426
  // command id when the merge settles.
5345
5427
  export function setMergeLeaseCommand(repoRoot: string, commandId: string): void {
5346
- db.prepare("UPDATE workspace_merge_leases SET command_id = ? WHERE repo_root = ?").run(commandId, repoRoot);
5428
+ db.query("UPDATE workspace_merge_leases SET command_id = ? WHERE repo_root = ?").run(commandId, repoRoot);
5347
5429
  }
5348
5430
 
5349
5431
  // Release a merge lease. Guard by commandId/workspaceId when known so a stale
@@ -5355,26 +5437,26 @@ export function releaseMergeLease(opts: { repoRoot?: string; commandId?: string;
5355
5437
  if (opts.commandId) { where.push("command_id = ?"); params.push(opts.commandId); }
5356
5438
  if (opts.workspaceId) { where.push("workspace_id = ?"); params.push(opts.workspaceId); }
5357
5439
  if (!where.length) return false;
5358
- return db.prepare(`DELETE FROM workspace_merge_leases WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
5440
+ return db.query(`DELETE FROM workspace_merge_leases WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
5359
5441
  }
5360
5442
 
5361
5443
  export function deleteOrchestrator(id: string): boolean {
5362
5444
  const orch = getOrchestrator(id);
5363
5445
  if (!orch) return false;
5364
- db.prepare("DELETE FROM orchestrators WHERE id = ?").run(id);
5446
+ db.query("DELETE FROM orchestrators WHERE id = ?").run(id);
5365
5447
  deleteAgent(orch.agentId);
5366
5448
  return true;
5367
5449
  }
5368
5450
 
5369
5451
  export function reapStaleOrchestrators(): string[] {
5370
5452
  const cutoff = Date.now() - STALE_TTL_MS;
5371
- const stale = db.prepare("SELECT id, agent_id FROM orchestrators WHERE last_seen < ? AND status = 'online'").all(cutoff) as any[];
5453
+ const stale = db.query("SELECT id, agent_id FROM orchestrators WHERE last_seen < ? AND status = 'online'").all(cutoff) as any[];
5372
5454
  for (const row of stale) {
5373
- db.prepare("UPDATE orchestrators SET status = 'offline' WHERE id = ?").run(row.id);
5455
+ db.query("UPDATE orchestrators SET status = 'offline' WHERE id = ?").run(row.id);
5374
5456
  // An orchestrator agent holds no workspaces, so use a direct status update
5375
5457
  // instead of setStatus() (which triggers an unscoped electWorkspaceStewards
5376
5458
  // sweep across all repos on every offline transition).
5377
- db.prepare("UPDATE agents SET status = 'offline', ready = 0 WHERE id = ?").run(row.agent_id);
5459
+ db.query("UPDATE agents SET status = 'offline', ready = 0 WHERE id = ?").run(row.agent_id);
5378
5460
  }
5379
5461
  return stale.map((row: any) => row.id);
5380
5462
  }