@vellumai/assistant 0.10.3-dev.202606281138.e89180c → 0.10.3-dev.202606281337.481c7ef

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606281138.e89180c",
3
+ "version": "0.10.3-dev.202606281337.481c7ef",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,6 +20,14 @@ export const ConversationsConfigSchema = z
20
20
  .describe(
21
21
  "Inner text injected into the tail user message of non-interactive turns in background/scheduled conversations. The injector wraps this in <background_turn>...</background_turn> tags. Empty string disables the injection.",
22
22
  ),
23
+ resumeProcessingOnStartup: z
24
+ .boolean({
25
+ error: "conversations.resumeProcessingOnStartup must be a boolean",
26
+ })
27
+ .default(false)
28
+ .describe(
29
+ "Controls how conversations left mid-turn by a previous shutdown are handled on startup. When false (default), their stale processing flag is cleared so they come up idle. When true, the daemon will instead automatically resume the interrupted turn for each such conversation.",
30
+ ),
23
31
  })
24
32
  .describe("Conversation behavior configuration");
25
33
 
@@ -12,8 +12,6 @@ import { setVoiceBridgeDeps } from "../calls/voice-session-bridge.js";
12
12
  import { initFeatureFlagOverrides } from "../config/assistant-feature-flags.js";
13
13
  import {
14
14
  getPlatformAssistantId,
15
- getRuntimeHttpHost,
16
- getRuntimeHttpPort,
17
15
  setIngressPublicBaseUrl,
18
16
  validateEnv,
19
17
  } from "../config/env.js";
@@ -44,14 +42,18 @@ import {
44
42
  getSourcePathsForAttachments,
45
43
  } from "../memory/attachments-store.js";
46
44
  import { expireAllPendingCanonicalRequests } from "../memory/canonical-guardian-store.js";
47
- import { deleteMessageById, getMessages } from "../memory/conversation-crud.js";
45
+ import {
46
+ clearStaleProcessingFlags,
47
+ deleteMessageById,
48
+ getMessages,
49
+ } from "../memory/conversation-crud.js";
48
50
  import { getDb } from "../memory/db-connection.js";
49
51
  import { initializeDb } from "../memory/db-init.js";
50
52
  import { selectEmbeddingBackend } from "../memory/embedding-backend.js";
51
53
  import { enqueueMemoryJob, isMemoryEnabled } from "../memory/jobs-store.js";
52
54
  import { startMemoryJobsWorker } from "../memory/jobs-worker.js";
53
55
  import { initQdrantClient, resolveQdrantUrl } from "../memory/qdrant-client.js";
54
- import { QdrantManager } from "../memory/qdrant-manager.js";
56
+ import { createQdrantManager } from "../memory/qdrant-manager.js";
55
57
  import { rotateToolInvocations } from "../memory/tool-usage-store.js";
56
58
  import { sweepConceptPageFrontmatter } from "../memory/v2/frontmatter-sweep.js";
57
59
  import { emitNotificationSignal } from "../notifications/emit-signal.js";
@@ -69,7 +71,7 @@ import {
69
71
  initAuthSigningKey,
70
72
  resolveSigningKey,
71
73
  } from "../runtime/auth/token-service.js";
72
- import { RuntimeHttpServer } from "../runtime/http-server.js";
74
+ import { startRuntimeHttpServer } from "../runtime/http-server.js";
73
75
  import { warmLocalGuardianPrincipalCache } from "../runtime/local-actor-identity.js";
74
76
  import { recoverInterruptedImport } from "../runtime/migrations/vbundle-streaming-importer.js";
75
77
  import { registerSecretsDeps } from "../runtime/routes/secrets-deps.js";
@@ -326,34 +328,9 @@ export async function runDaemon(): Promise<void> {
326
328
  const signingKey = resolveSigningKey();
327
329
  initAuthSigningKey(signingKey);
328
330
 
329
- // Start the runtime HTTP server early so /healthz answers ASAP.
330
- let runtimeHttp: RuntimeHttpServer | null = null;
331
- const httpPort = getRuntimeHttpPort();
332
- const httpHostname = getRuntimeHttpHost();
333
- log.info({ httpPort }, "Daemon startup: starting runtime HTTP server");
334
-
335
- runtimeHttp = new RuntimeHttpServer({
336
- port: httpPort,
337
- hostname: httpHostname,
338
- });
339
-
340
- // Isolated try/catch around start() — a bind failure (port in use,
341
- // permission denied, fd exhaustion) must not tear down the rest of
342
- // daemon startup. The daemon falls back to IPC-only operation when
343
- // runtimeHttp is null.
344
- try {
345
- await runtimeHttp.start();
346
- log.info(
347
- { port: httpPort, hostname: httpHostname },
348
- "Daemon startup: runtime HTTP server listening",
349
- );
350
- } catch (err) {
351
- log.warn(
352
- { err, port: httpPort },
353
- "Failed to start runtime HTTP server, continuing without it",
354
- );
355
- runtimeHttp = null;
356
- }
331
+ // Start the runtime HTTP server early so /healthz answers ASAP. A bind
332
+ // failure is non-fatal the daemon falls back to IPC-only operation.
333
+ await startRuntimeHttpServer();
357
334
 
358
335
  // Pre-populate feature flag overrides so subsequent sync
359
336
  // isAssistantFeatureFlagEnabled() calls have data. Fired non-blocking
@@ -628,6 +605,31 @@ export async function runDaemon(): Promise<void> {
628
605
  log.info("Daemon startup: loading config");
629
606
  const config = loadConfig();
630
607
 
608
+ // Reconcile conversations left mid-turn by the previous shutdown. Their
609
+ // `processing_started_at` is still set even though the in-memory agent loop
610
+ // that owned the turn is gone.
611
+ if (dbReady) {
612
+ if (config.conversations.resumeProcessingOnStartup) {
613
+ // TODO: automatically resume the interrupted turn for each conversation
614
+ // whose processing flag is still set instead of clearing it.
615
+ } else {
616
+ try {
617
+ const cleared = clearStaleProcessingFlags();
618
+ if (cleared > 0) {
619
+ log.info(
620
+ { count: cleared },
621
+ "Cleared stale conversation processing flags from previous process",
622
+ );
623
+ }
624
+ } catch (err) {
625
+ log.warn(
626
+ { err },
627
+ "Failed to clear stale conversation processing flags — continuing startup",
628
+ );
629
+ }
630
+ }
631
+ }
632
+
631
633
  // Seed module-level ingress state from the workspace config so that
632
634
  // getIngressPublicBaseUrl() returns the correct value immediately after
633
635
  // startup (before any handleIngressConfig("set") call). Without this,
@@ -793,20 +795,12 @@ export async function runDaemon(): Promise<void> {
793
795
  startOrphanReaper();
794
796
  startEventLoopWatchdog();
795
797
 
796
- // Mutable refs for Qdrant and memory worker so background
797
- // init can assign them and the shutdown handler always sees the latest value.
798
- const bgRefs: {
799
- qdrantManager: QdrantManager | null;
800
- memoryWorker: { stop(): void } | null;
801
- } = { qdrantManager: null, memoryWorker: null };
802
-
803
798
  // Initialize Qdrant vector store and memory worker in the background so the
804
799
  // RuntimeHttpServer can start accepting requests without waiting for Qdrant.
805
800
  async function initializeQdrantAndMemory(): Promise<void> {
806
801
  const qdrantUrl = resolveQdrantUrl(config);
807
802
  log.info({ qdrantUrl }, "Daemon startup: initializing Qdrant");
808
- const manager = new QdrantManager({ url: qdrantUrl });
809
- bgRefs.qdrantManager = manager;
803
+ const manager = createQdrantManager({ url: qdrantUrl });
810
804
  const QDRANT_START_MAX_ATTEMPTS = 3;
811
805
  let qdrantStarted = false;
812
806
  for (let attempt = 1; attempt <= QDRANT_START_MAX_ATTEMPTS; attempt++) {
@@ -937,7 +931,7 @@ export async function runDaemon(): Promise<void> {
937
931
  // `memory.worker.enabled` is set. Shutdown stops whichever worker is
938
932
  // actually running — see shutdown-handlers.ts.
939
933
  log.info("Daemon startup: starting memory worker");
940
- bgRefs.memoryWorker = startMemoryJobsWorker();
934
+ startMemoryJobsWorker();
941
935
 
942
936
  // Seed capability graph nodes (new memory graph system)
943
937
  try {
@@ -1087,7 +1081,7 @@ export async function runDaemon(): Promise<void> {
1087
1081
  });
1088
1082
 
1089
1083
  // Fire-and-forget: Qdrant init and memory worker startup run concurrently
1090
- // with the rest of daemon boot. Must run AFTER `new RuntimeHttpServer(...)`
1084
+ // with the rest of daemon boot. Must run AFTER `startRuntimeHttpServer()`
1091
1085
  // so the analyze-deps singleton (populated inside `buildRouteTable()`) is
1092
1086
  // available before the memory worker can claim leftover
1093
1087
  // `conversation_analyze` jobs from a prior run. See the daemon-startup
@@ -1257,7 +1251,6 @@ export async function runDaemon(): Promise<void> {
1257
1251
  { err },
1258
1252
  "Failed to wire runtime HTTP server deps, continuing without them",
1259
1253
  );
1260
- runtimeHttp = null;
1261
1254
  }
1262
1255
 
1263
1256
  // Register built-in TTS providers so the provider abstraction can resolve
@@ -1379,10 +1372,6 @@ export async function runDaemon(): Promise<void> {
1379
1372
  workspaceHeartbeat,
1380
1373
  heartbeat,
1381
1374
  filing,
1382
- runtimeHttp,
1383
- scheduler,
1384
- getMemoryWorker: () => bgRefs.memoryWorker,
1385
- getQdrantManager: () => bgRefs.qdrantManager,
1386
1375
  });
1387
1376
 
1388
1377
  log.info(
@@ -5,9 +5,11 @@ import type { HeartbeatService } from "../heartbeat/heartbeat-service.js";
5
5
  import { stopGatewayFlagListener } from "../ipc/gateway-flag-listener.js";
6
6
  import { stopMcpServerManager } from "../mcp/manager.js";
7
7
  import { getSqlite, resetDb } from "../memory/db-connection.js";
8
- import type { QdrantManager } from "../memory/qdrant-manager.js";
8
+ import { stopMemoryJobsWorker } from "../memory/jobs-worker.js";
9
+ import { stopQdrantManager } from "../memory/qdrant-manager.js";
9
10
  import { stopMemoryWorkerProcess } from "../memory/worker-control.js";
10
- import type { RuntimeHttpServer } from "../runtime/http-server.js";
11
+ import { stopRuntimeHttpServer } from "../runtime/http-server.js";
12
+ import { stopScheduler } from "../schedule/scheduler.js";
11
13
  import { stopUsageTelemetryReporter } from "../telemetry/usage-telemetry-reporter.js";
12
14
  import { browserManager } from "../tools/browser/browser-manager.js";
13
15
  import { cleanupShellOutputTempFiles } from "../tools/shared/shell-output.js";
@@ -41,10 +43,6 @@ export interface ShutdownDeps {
41
43
  workspaceHeartbeat: WorkspaceHeartbeatService;
42
44
  heartbeat: HeartbeatService;
43
45
  filing: FilingService | null;
44
- runtimeHttp: RuntimeHttpServer | null;
45
- scheduler: { stop(): void };
46
- getMemoryWorker: () => { stop(): void } | null;
47
- getQdrantManager: () => QdrantManager | null;
48
46
  }
49
47
 
50
48
  export function installShutdownHandlers(deps: ShutdownDeps): void {
@@ -125,14 +123,14 @@ export function installShutdownHandlers(deps: ShutdownDeps): void {
125
123
  log.warn({ err }, "Telemetry reporter shutdown failed (non-fatal)");
126
124
  }
127
125
 
128
- if (deps.runtimeHttp) await deps.runtimeHttp.stop();
126
+ await stopRuntimeHttpServer();
129
127
  await browserManager.closeAllPages();
130
128
  cleanupShellOutputTempFiles();
131
- deps.scheduler.stop();
129
+ stopScheduler();
132
130
 
133
- // Stop the in-process memory worker if one was started on the daemon's
134
- // event loop (memory.worker.enabled = false).
135
- deps.getMemoryWorker()?.stop();
131
+ // Stop the in-process memory worker supervisor if it was started on the
132
+ // daemon's event loop (memory.worker.enabled = false).
133
+ stopMemoryJobsWorker();
136
134
 
137
135
  // Stop the out-of-process memory worker if it's actually running. This is
138
136
  // keyed off live state rather than config: the worker may have been
@@ -156,7 +154,7 @@ export function installShutdownHandlers(deps: ShutdownDeps): void {
156
154
  log.warn({ err }, "MCP server manager shutdown failed (non-fatal)");
157
155
  }
158
156
 
159
- await deps.getQdrantManager()?.stop();
157
+ await stopQdrantManager();
160
158
 
161
159
  // Optimize query planner statistics before closing so they persist for
162
160
  // the next session. Checkpoint WAL and close SQLite so no writes are
@@ -0,0 +1,52 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ mock.module("../../util/logger.js", () => ({
4
+ getLogger: () =>
5
+ new Proxy({} as Record<string, unknown>, {
6
+ get: () => () => {},
7
+ }),
8
+ }));
9
+
10
+ import {
11
+ clearStaleProcessingFlags,
12
+ createConversation,
13
+ isConversationProcessing,
14
+ setConversationProcessingStartedAt,
15
+ } from "../conversation-crud.js";
16
+ import { getDb } from "../db-connection.js";
17
+ import { initializeDb } from "../db-init.js";
18
+
19
+ await initializeDb();
20
+
21
+ function resetTables(): void {
22
+ const db = getDb();
23
+ db.run(`DELETE FROM messages`);
24
+ db.run(`DELETE FROM conversations`);
25
+ }
26
+
27
+ describe("clearStaleProcessingFlags", () => {
28
+ beforeEach(() => {
29
+ resetTables();
30
+ });
31
+
32
+ test("clears the processing flag on conversations left mid-turn", () => {
33
+ const processing = createConversation("mid-turn");
34
+ const idle = createConversation("idle");
35
+ setConversationProcessingStartedAt(processing.id, Date.now());
36
+
37
+ expect(isConversationProcessing(processing.id)).toBe(true);
38
+ expect(isConversationProcessing(idle.id)).toBe(false);
39
+
40
+ const cleared = clearStaleProcessingFlags();
41
+
42
+ expect(cleared).toBe(1);
43
+ expect(isConversationProcessing(processing.id)).toBe(false);
44
+ expect(isConversationProcessing(idle.id)).toBe(false);
45
+ });
46
+
47
+ test("returns 0 when no conversation is processing", () => {
48
+ createConversation("idle");
49
+
50
+ expect(clearStaleProcessingFlags()).toBe(0);
51
+ });
52
+ });
@@ -55,13 +55,13 @@ function inflateAndDelete(byteTarget: number): void {
55
55
 
56
56
  describe("runAsyncSqlite", () => {
57
57
  test("returns ok=true for a trivial statement", async () => {
58
- const result = await runAsyncSqlite("SELECT 1");
58
+ const result = await runAsyncSqlite("SELECT 1", "test:trivial-select");
59
59
  expect(result.ok).toBe(true);
60
60
  expect(result.error).toBeNull();
61
61
  });
62
62
 
63
63
  test("in-process fallback reports the right backend", async () => {
64
- const result = await runAsyncSqlite("SELECT 1", {
64
+ const result = await runAsyncSqlite("SELECT 1", "test:in-process-backend", {
65
65
  forceBackend: "in-process-blocking",
66
66
  });
67
67
  expect(result.ok).toBe(true);
@@ -87,6 +87,7 @@ describe("runAsyncSqlite", () => {
87
87
 
88
88
  const result = await runAsyncSqlite(
89
89
  "DELETE FROM async_changes_probe WHERE id <= 3; SELECT changes();",
90
+ "test:in-process-changes-count",
90
91
  { forceBackend: "in-process-blocking" },
91
92
  );
92
93
 
@@ -103,7 +104,10 @@ describe("runAsyncSqlite", () => {
103
104
  test.if(sqlite3Available)(
104
105
  "sqlite3 CLI backend reports the right backend on success",
105
106
  async () => {
106
- const result = await runAsyncSqlite("SELECT 1");
107
+ const result = await runAsyncSqlite(
108
+ "SELECT 1",
109
+ "test:cli-backend-success",
110
+ );
107
111
  expect(result.ok).toBe(true);
108
112
  expect(result.backend).toBe("sqlite3-cli");
109
113
  },
@@ -113,7 +117,10 @@ describe("runAsyncSqlite", () => {
113
117
  "surfaces sqlite3 errors as ok=false with the message preserved",
114
118
  async () => {
115
119
  // Intentional SQL syntax error.
116
- const result = await runAsyncSqlite("THIS IS NOT VALID SQL");
120
+ const result = await runAsyncSqlite(
121
+ "THIS IS NOT VALID SQL",
122
+ "test:cli-error",
123
+ );
117
124
  expect(result.ok).toBe(false);
118
125
  expect(result.backend).toBe("sqlite3-cli");
119
126
  expect(result.error).toBeTruthy();
@@ -148,7 +155,7 @@ describe("runAsyncSqlite", () => {
148
155
 
149
156
  let result;
150
157
  try {
151
- result = await runAsyncSqlite("VACUUM");
158
+ result = await runAsyncSqlite("VACUUM", "test:vacuum-anti-block");
152
159
  } finally {
153
160
  probing = false;
154
161
  }
@@ -74,6 +74,7 @@ describe("logs database connection", () => {
74
74
  try {
75
75
  const result = await runAsyncSqlite(
76
76
  "CREATE TABLE dbpath_probe (x INTEGER); INSERT INTO dbpath_probe VALUES (42); SELECT changes();",
77
+ "test:logs-attach-dbpath",
77
78
  { dbPath: targetPath, forceBackend: "sqlite3-cli" },
78
79
  );
79
80
  expect(result.ok).toBe(true);
@@ -77,6 +77,7 @@ describe("memory database connection", () => {
77
77
  try {
78
78
  const result = await runAsyncSqlite(
79
79
  "CREATE TABLE dbpath_probe (x INTEGER); INSERT INTO dbpath_probe VALUES (42); SELECT changes();",
80
+ "test:memory-attach-dbpath",
80
81
  { dbPath: targetPath, forceBackend: "sqlite3-cli" },
81
82
  );
82
83
  expect(result.ok).toBe(true);
@@ -2236,6 +2236,19 @@ export function setConversationProcessingStartedAt(
2236
2236
  );
2237
2237
  }
2238
2238
 
2239
+ /**
2240
+ * Clear the persisted processing flag on every conversation that still has one
2241
+ * set, returning the number of rows cleared. Called at daemon startup to reset
2242
+ * conversations whose `processing_started_at` was left non-NULL because the
2243
+ * previous process shut down mid-turn — the in-memory agent loop driving that
2244
+ * turn is gone, so the flag is stale.
2245
+ */
2246
+ export function clearStaleProcessingFlags(): number {
2247
+ return rawRun(
2248
+ "UPDATE conversations SET processing_started_at = NULL WHERE processing_started_at IS NOT NULL",
2249
+ );
2250
+ }
2251
+
2239
2252
  /**
2240
2253
  * Read whether a conversation is currently processing. Checks the in-memory
2241
2254
  * `Conversation._processing` flag first (hot path for resident conversations),
@@ -2552,7 +2565,7 @@ export async function clearAll(): Promise<{
2552
2565
  sql: string,
2553
2566
  options?: { dbPath?: string },
2554
2567
  ): Promise<void> => {
2555
- const result = await runAsyncSqlite(sql, options);
2568
+ const result = await runAsyncSqlite(sql, `clearAll: ${sql}`, options);
2556
2569
  if (!result.ok) {
2557
2570
  throw new Error(
2558
2571
  `clearAll: \`${sql}\` failed (${result.backend}): ${result.error ?? "unknown"}`,
@@ -2584,7 +2597,10 @@ export async function clearAll(): Promise<{
2584
2597
  await runOrThrow("DELETE FROM tool_invocations");
2585
2598
  await runOrThrow("DELETE FROM skill_loaded_events");
2586
2599
  let messagesFtsCorrupted = false;
2587
- const ftsResult = await runAsyncSqlite("DELETE FROM messages_fts");
2600
+ const ftsResult = await runAsyncSqlite(
2601
+ "DELETE FROM messages_fts",
2602
+ "clearAll:messages-fts",
2603
+ );
2588
2604
  if (!ftsResult.ok) {
2589
2605
  log.warn(
2590
2606
  { error: ftsResult.error, backend: ftsResult.backend },
@@ -43,6 +43,14 @@ const log = getLogger("db-async-query");
43
43
  */
44
44
  const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
45
45
 
46
+ /**
47
+ * A successful async statement that runs longer than this is logged at WARN.
48
+ * These dispatches are meant to be heavy-but-bounded; crossing this threshold
49
+ * is a signal worth surfacing (lock contention, an unexpectedly large sweep,
50
+ * a degraded disk) even when the statement ultimately succeeds.
51
+ */
52
+ const SLOW_WRITE_WARN_MS = 2000;
53
+
46
54
  export type AsyncSqliteBackend = "sqlite3-cli" | "in-process-blocking";
47
55
 
48
56
  export interface AsyncSqliteResult {
@@ -95,12 +103,14 @@ let warnedAboutFallback = false;
95
103
 
96
104
  export async function runAsyncSqlite(
97
105
  sql: string,
106
+ label: string,
98
107
  options: RunAsyncSqliteOptions = {},
99
108
  ): Promise<AsyncSqliteResult> {
100
109
  const forced = options.forceBackend;
101
110
  const sqlite3Path =
102
111
  forced === "in-process-blocking" ? undefined : findSqlite3();
103
112
 
113
+ let result: AsyncSqliteResult;
104
114
  if (sqlite3Path && forced !== "in-process-blocking") {
105
115
  const attachPrefix = (options.attach ?? [])
106
116
  .map(
@@ -108,23 +118,32 @@ export async function runAsyncSqlite(
108
118
  `ATTACH DATABASE '${a.path.replace(/'/g, "''")}' AS ${a.alias};\n`,
109
119
  )
110
120
  .join("");
111
- return runViaCli(
121
+ result = await runViaCli(
112
122
  sqlite3Path,
113
123
  attachPrefix + sql,
114
124
  options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
115
125
  options.dbPath ?? getDbPath(),
126
+ label,
116
127
  );
128
+ } else {
129
+ if (!warnedAboutFallback) {
130
+ warnedAboutFallback = true;
131
+ log.warn(
132
+ "No sqlite3 CLI found on host — falling back to in-process blocking " +
133
+ "execution for slow SQLite statements. Install sqlite3 to keep the " +
134
+ "event loop responsive during VACUUM and other long operations.",
135
+ );
136
+ }
137
+ result = await runInProcessBlocking(sql, options);
117
138
  }
118
139
 
119
- if (!warnedAboutFallback) {
120
- warnedAboutFallback = true;
140
+ if (result.ok && result.elapsedMs > SLOW_WRITE_WARN_MS) {
121
141
  log.warn(
122
- "No sqlite3 CLI found on host — falling back to in-process blocking " +
123
- "execution for slow SQLite statements. Install sqlite3 to keep the " +
124
- "event loop responsive during VACUUM and other long operations.",
142
+ { label, elapsedMs: result.elapsedMs, backend: result.backend },
143
+ "Async SQL completed but exceeded slow-write threshold",
125
144
  );
126
145
  }
127
- return runInProcessBlocking(sql, options);
146
+ return result;
128
147
  }
129
148
 
130
149
  /** For tests: reset the once-only fallback warning. */
@@ -155,11 +174,12 @@ async function runViaCli(
155
174
  sql: string,
156
175
  timeoutMs: number,
157
176
  dbPath: string,
177
+ label: string,
158
178
  ): Promise<AsyncSqliteResult> {
159
179
  const startMs = Date.now();
160
180
 
161
181
  log.info(
162
- { sqlite3Path, dbPath, timeoutMs, sqlPreview: sql.slice(0, 80) },
182
+ { label, sqlite3Path, dbPath, timeoutMs, sqlPreview: sql.slice(0, 80) },
163
183
  "Running async SQL via sqlite3 CLI subprocess",
164
184
  );
165
185
 
@@ -203,7 +223,7 @@ async function runViaCli(
203
223
 
204
224
  if (timedOut) {
205
225
  log.error(
206
- { timeoutMs, elapsedMs, stderr: stderr.slice(0, 2000) },
226
+ { label, timeoutMs, elapsedMs, stderr: stderr.slice(0, 2000) },
207
227
  "Async SQL subprocess timed out — killed",
208
228
  );
209
229
  return {
@@ -218,7 +238,7 @@ async function runViaCli(
218
238
  }
219
239
  if (exitCode !== 0) {
220
240
  log.error(
221
- { exitCode, elapsedMs, stderr: stderr.slice(0, 2000) },
241
+ { label, exitCode, elapsedMs, stderr: stderr.slice(0, 2000) },
222
242
  "Async SQL subprocess failed",
223
243
  );
224
244
  return {
@@ -181,7 +181,10 @@ function saveTemplate(): void {
181
181
  export async function checkpointWalBeforeOpen(): Promise<void> {
182
182
  const log = getLogger("db-init");
183
183
  try {
184
- const result = await runAsyncSqlite("PRAGMA wal_checkpoint(TRUNCATE)");
184
+ const result = await runAsyncSqlite(
185
+ "PRAGMA wal_checkpoint(TRUNCATE)",
186
+ "db-init:pre-open-wal-checkpoint",
187
+ );
185
188
  if (result.ok) {
186
189
  log.info(
187
190
  { backend: result.backend, elapsedMs: result.elapsedMs },
@@ -74,7 +74,10 @@ async function runDbMaintenance(): Promise<void> {
74
74
  // Refresh the query planner's statistics. PRAGMA optimize is cheap; it is
75
75
  // routed through the async path for consistency and to keep it off the main
76
76
  // thread when the sqlite3 CLI backend is available.
77
- const optimizeResult = await runAsyncSqlite("PRAGMA optimize");
77
+ const optimizeResult = await runAsyncSqlite(
78
+ "PRAGMA optimize",
79
+ "db-maintenance:optimize",
80
+ );
78
81
  if (!optimizeResult.ok) {
79
82
  log.warn(
80
83
  { error: optimizeResult.error, backend: optimizeResult.backend },
@@ -97,6 +100,7 @@ async function runDbMaintenance(): Promise<void> {
97
100
  // best-effort no-op and the next maintenance pass retries.
98
101
  const checkpointResult = await runAsyncSqlite(
99
102
  "PRAGMA wal_checkpoint(TRUNCATE)",
103
+ "db-maintenance:wal-checkpoint-truncate",
100
104
  );
101
105
  if (!checkpointResult.ok) {
102
106
  log.warn(
@@ -33,16 +33,34 @@
33
33
  // `client_message_id` is intentionally NOT copied, matching the
34
34
  // in-process fork (a forked row is not a client-submitted message).
35
35
 
36
- import { type AsyncSqliteResult, runAsyncSqlite } from "./db-async-query.js";
36
+ import { setTimeout as sleep } from "node:timers/promises";
37
+
38
+ import {
39
+ type AsyncSqliteBackend,
40
+ type AsyncSqliteResult,
41
+ runAsyncSqlite,
42
+ } from "./db-async-query.js";
37
43
 
38
44
  /**
39
45
  * Default batch size for the chunked copy. Each batch is one `INSERT … SELECT`
40
46
  * that auto-commits, so this bounds how long the subprocess holds the write
41
- * lock before releasing it to in-process writers. Small enough to keep the
42
- * lock-hold window short on a bloated database; large enough that per-batch
43
- * statement overhead stays negligible against the row copy.
47
+ * lock before releasing it to in-process writers. Kept small so the worst-case
48
+ * wait for a contending foreground write is one short batch, even on a bloated
49
+ * database; per-batch statement overhead stays negligible against the row copy
50
+ * and fork latency doesn't matter (nobody waits on a background fork).
51
+ */
52
+ export const DEFAULT_FORK_COPY_BATCH_SIZE = 50;
53
+
54
+ /**
55
+ * Pause inserted between batch subprocess calls. Without it the copy releases
56
+ * the write lock on each batch's auto-commit but greedily re-acquires it
57
+ * microseconds later, so a concurrent in-process writer (a live user turn
58
+ * persisting a message) can lose every race. A brief yield lets foreground
59
+ * writes reliably slip in between batches. The extra fork latency is free —
60
+ * nothing waits on a background fork — so we trade copy speed for foreground
61
+ * fairness.
44
62
  */
45
- export const DEFAULT_FORK_COPY_BATCH_SIZE = 500;
63
+ export const DEFAULT_FORK_COPY_INTER_BATCH_DELAY_MS = 25;
46
64
 
47
65
  /**
48
66
  * A single source→fork message-id mapping. `oldId` is an existing source
@@ -143,9 +161,13 @@ FROM messages m JOIN _fork_id_map map ON map.old_id = m.id;`,
143
161
 
144
162
  /**
145
163
  * Copy the message rows for a fork off the event loop, in lock-friendly
146
- * batches. Resolves once every batch has committed (or rejects via the
147
- * returned result's `ok: false` on subprocess failure — the caller is
148
- * responsible for cleaning up the partially-built fork).
164
+ * batches. Each batch runs as its own subprocess call with a brief yield in
165
+ * between (see {@link DEFAULT_FORK_COPY_INTER_BATCH_DELAY_MS}) so foreground
166
+ * writers reliably acquire the write lock between batches instead of losing
167
+ * every race to the copy's greedy lock re-acquisition. Resolves once every
168
+ * batch has committed (or returns the failing batch's `ok: false` result on
169
+ * subprocess failure — the caller is responsible for cleaning up the
170
+ * partially-built fork).
149
171
  *
150
172
  * No-op (returns a synthetic ok result) when there is nothing to copy.
151
173
  */
@@ -161,10 +183,43 @@ export async function copyForkMessagesViaSubprocess(
161
183
  };
162
184
  }
163
185
 
164
- const sql = buildForkCopyScript(options);
165
- return runAsyncSqlite(sql, {
166
- ...(options.forceInProcess
167
- ? { forceBackend: "in-process-blocking" as const }
168
- : {}),
169
- });
186
+ const batchSize = Math.max(
187
+ 1,
188
+ options.batchSize ?? DEFAULT_FORK_COPY_BATCH_SIZE,
189
+ );
190
+ const runOptions = options.forceInProcess
191
+ ? { forceBackend: "in-process-blocking" as const }
192
+ : {};
193
+
194
+ let totalElapsedMs = 0;
195
+ let backend: AsyncSqliteBackend = "in-process-blocking";
196
+
197
+ for (let i = 0; i < options.idPairs.length; i += batchSize) {
198
+ const batch = options.idPairs.slice(i, i + batchSize);
199
+ // One subprocess call per batch. Each generated script is self-contained
200
+ // (it creates and drops its own staging table), so splitting execution
201
+ // across calls is safe — and it's what lets us yield between batches below.
202
+ const sql = buildForkCopyScript({
203
+ forkConversationId: options.forkConversationId,
204
+ idPairs: batch,
205
+ batchSize,
206
+ });
207
+ const result = await runAsyncSqlite(
208
+ sql,
209
+ `fork-message-copy:copy-batch:${options.forkConversationId}`,
210
+ runOptions,
211
+ );
212
+ totalElapsedMs += result.elapsedMs;
213
+ backend = result.backend;
214
+ if (!result.ok) {
215
+ return { ...result, elapsedMs: totalElapsedMs };
216
+ }
217
+
218
+ const isLastBatch = i + batchSize >= options.idPairs.length;
219
+ if (!isLastBatch) {
220
+ await sleep(DEFAULT_FORK_COPY_INTER_BATCH_DELAY_MS);
221
+ }
222
+ }
223
+
224
+ return { ok: true, backend, error: null, elapsedMs: totalElapsedMs };
170
225
  }
@@ -57,6 +57,7 @@ export async function pruneOldLlmRequestLogsJob(
57
57
  const result = await runAsyncSqlite(
58
58
  `DELETE FROM llm_request_logs WHERE rowid IN (SELECT rowid FROM llm_request_logs WHERE created_at < ${cutoffMs} LIMIT ${PRUNE_LOG_BATCH_LIMIT});
59
59
  SELECT changes();`,
60
+ "cleanup:prune-llm-request-logs",
60
61
  { dbPath: getLogsDbPath() },
61
62
  );
62
63
  if (!result.ok) {
@@ -112,6 +113,7 @@ export async function pruneOldTraceEventsJob(
112
113
  const result = await runAsyncSqlite(
113
114
  `DELETE FROM trace_events WHERE rowid IN (SELECT rowid FROM trace_events WHERE created_at < ${cutoffMs} LIMIT ${PRUNE_LOG_BATCH_LIMIT});
114
115
  SELECT changes();`,
116
+ "cleanup:prune-trace-events",
115
117
  );
116
118
  if (!result.ok) {
117
119
  log.warn(
@@ -162,6 +162,9 @@ export interface MemoryJobsWorker {
162
162
  stop(): void;
163
163
  }
164
164
 
165
+ /** The daemon's in-process supervisor, retained so shutdown can stop it. */
166
+ let instance: MemoryJobsWorker | null = null;
167
+
165
168
  /**
166
169
  * Start the daemon's memory jobs worker supervisor.
167
170
  *
@@ -217,7 +220,21 @@ export function startMemoryJobsWorker(): MemoryJobsWorker {
217
220
  );
218
221
  }
219
222
 
220
- return startInProcessMemoryJobsWorker({ standDownForWorkerProcess: true });
223
+ instance = startInProcessMemoryJobsWorker({
224
+ standDownForWorkerProcess: true,
225
+ });
226
+ return instance;
227
+ }
228
+
229
+ /**
230
+ * Stop the daemon's in-process memory jobs supervisor if it was started; no-op
231
+ * otherwise. Does not touch the out-of-process worker — see
232
+ * stopMemoryWorkerProcess() in worker-control.ts.
233
+ */
234
+ export function stopMemoryJobsWorker(): void {
235
+ if (!instance) return;
236
+ instance.stop();
237
+ instance = null;
221
238
  }
222
239
 
223
240
  /**
@@ -135,6 +135,9 @@ export async function runForkBasedRetrospective(
135
135
  sourceConversationId: string,
136
136
  config: AssistantConfig,
137
137
  ): Promise<MemoryRetrospectiveOutcome> {
138
+ // Start stamp for the retrospective's end-to-end wall time, surfaced as
139
+ // `durationMs` on the "invoked" log (start → invoked).
140
+ const startedAtMs = Date.now();
138
141
  const sourceConversation = getConversation(sourceConversationId);
139
142
  if (!sourceConversation) {
140
143
  log.warn(
@@ -365,7 +368,11 @@ export async function runForkBasedRetrospective(
365
368
  newMessageCount: newMessages.length,
366
369
  prior,
367
370
  priorRemembers,
368
- logFields: { kind: "fork", windowStartTimestamp },
371
+ logFields: {
372
+ kind: "fork",
373
+ windowStartTimestamp,
374
+ durationMs: Date.now() - startedAtMs,
375
+ },
369
376
  });
370
377
  }
371
378
 
@@ -160,10 +160,14 @@ export async function migrateStripThinkingFromConsolidated(
160
160
  while (lo < maxRow) {
161
161
  const hi = Math.min(lo + ROWID_WINDOW, maxRow);
162
162
 
163
- const res = await runAsyncSqlite(windowSql(lo, hi), {
164
- dbPath,
165
- timeoutMs: WINDOW_TIMEOUT_MS,
166
- });
163
+ const res = await runAsyncSqlite(
164
+ windowSql(lo, hi),
165
+ `migration-209:strip-thinking-window:(${lo},${hi}]`,
166
+ {
167
+ dbPath,
168
+ timeoutMs: WINDOW_TIMEOUT_MS,
169
+ },
170
+ );
167
171
  if (!res.ok) {
168
172
  // Leave the watermark at the last completed window; throwing reports the
169
173
  // step failed so the runner retries it (from the watermark) next boot
@@ -179,7 +183,11 @@ export async function migrateStripThinkingFromConsolidated(
179
183
 
180
184
  // Bound WAL growth left by the windowed rewrites, then drop the watermark so
181
185
  // a future re-run (e.g. after a rollback) starts clean.
182
- await runAsyncSqlite(`PRAGMA wal_checkpoint(TRUNCATE);`, { dbPath });
186
+ await runAsyncSqlite(
187
+ `PRAGMA wal_checkpoint(TRUNCATE);`,
188
+ "migration-209:wal-checkpoint-truncate",
189
+ { dbPath },
190
+ );
183
191
  raw.query(`DELETE FROM memory_checkpoints WHERE key = ?`).run(WATERMARK_KEY);
184
192
 
185
193
  log.info(
@@ -153,10 +153,14 @@ export async function migrateStripPlaceholderSentinelsFromMessages(
153
153
  for (let lo = 0; lo < maxRow; lo += ROWID_WINDOW) {
154
154
  const hi = Math.min(lo + ROWID_WINDOW, maxRow);
155
155
 
156
- const res = await runAsyncSqlite(windowSql(lo, hi), {
157
- dbPath,
158
- timeoutMs: WINDOW_TIMEOUT_MS,
159
- });
156
+ const res = await runAsyncSqlite(
157
+ windowSql(lo, hi),
158
+ `migration-222:strip-placeholder-window:(${lo},${hi}]`,
159
+ {
160
+ dbPath,
161
+ timeoutMs: WINDOW_TIMEOUT_MS,
162
+ },
163
+ );
160
164
  if (!res.ok) {
161
165
  // Surface the failure so the runner leaves the step un-checkpointed and
162
166
  // retries the whole sweep on the next boot.
@@ -167,5 +171,9 @@ export async function migrateStripPlaceholderSentinelsFromMessages(
167
171
  }
168
172
 
169
173
  // Bound WAL growth left by the windowed rewrites.
170
- await runAsyncSqlite(`PRAGMA wal_checkpoint(TRUNCATE);`, { dbPath });
174
+ await runAsyncSqlite(
175
+ `PRAGMA wal_checkpoint(TRUNCATE);`,
176
+ "migration-222:wal-checkpoint-truncate",
177
+ { dbPath },
178
+ );
171
179
  }
@@ -174,6 +174,7 @@ export async function drainStagedTable(
174
174
  `DELETE FROM "${staging}" WHERE rowid IN (` +
175
175
  `SELECT rowid FROM "${staging}" WHERE NOT (${spec.copyWhere}) LIMIT ${DRAIN_BATCH});\n` +
176
176
  `SELECT changes();`,
177
+ `relocation:purge-batch:${table}`,
177
178
  { dbPath },
178
179
  );
179
180
  if (!res.ok) {
@@ -194,6 +195,7 @@ export async function drainStagedTable(
194
195
  `DELETE FROM "${staging}" WHERE rowid IN (` +
195
196
  `SELECT rowid FROM "${staging}" ${whereCopy} ORDER BY rowid LIMIT ${DRAIN_BATCH});\n` +
196
197
  `SELECT changes();`,
198
+ `relocation:copy-batch:${table}`,
197
199
  {
198
200
  dbPath,
199
201
  attach: [{ path: spec.targetDbPath(), alias: "_reloc_target" }],
@@ -216,6 +218,7 @@ export async function drainStagedTable(
216
218
  // Drained — drop the (now empty) staging table and truncate the main WAL.
217
219
  const finalizeRes = await runAsyncSqlite(
218
220
  `DROP TABLE IF EXISTS "${staging}";\nPRAGMA wal_checkpoint(TRUNCATE);`,
221
+ `relocation:finalize:${table}`,
219
222
  { dbPath },
220
223
  );
221
224
  if (!finalizeRes.ok) {
@@ -420,3 +420,31 @@ export class QdrantManager {
420
420
  }
421
421
  }
422
422
  }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // Singleton
426
+ // ---------------------------------------------------------------------------
427
+
428
+ let instance: QdrantManager | null = null;
429
+
430
+ /**
431
+ * Construct the singleton Qdrant manager and store it so shutdown can reach it.
432
+ * The caller (daemon startup) owns starting it — start() carries
433
+ * lifecycle-specific retry orchestration — but the module holds the reference
434
+ * so it need not be threaded through call arguments.
435
+ */
436
+ export function createQdrantManager(
437
+ config: QdrantManagerConfig,
438
+ ): QdrantManager {
439
+ instance = new QdrantManager(config);
440
+ return instance;
441
+ }
442
+
443
+ /**
444
+ * Stop the singleton Qdrant manager if one was created. No-op when Qdrant init
445
+ * never ran (e.g. shutdown before background init started).
446
+ */
447
+ export async function stopQdrantManager(): Promise<void> {
448
+ if (!instance) return;
449
+ await instance.stop();
450
+ }
@@ -97,6 +97,7 @@ export async function rotateToolInvocations(
97
97
  // integer (see Math.floor above), so inlining it here is safe.
98
98
  const result = await runAsyncSqlite(
99
99
  `DELETE FROM tool_invocations WHERE created_at < ${cutoffMs}`,
100
+ "tool-usage-store:prune-tool-invocations",
100
101
  );
101
102
  if (!result.ok) {
102
103
  log.error(
@@ -21,7 +21,11 @@ import {
21
21
  handleStatusCallback,
22
22
  handleVoiceWebhook,
23
23
  } from "../calls/twilio-routes.js";
24
- import { isHttpAuthDisabled } from "../config/env.js";
24
+ import {
25
+ getRuntimeHttpHost,
26
+ getRuntimeHttpPort,
27
+ isHttpAuthDisabled,
28
+ } from "../config/env.js";
25
29
  import { getIsPlatform } from "../config/env-registry.js";
26
30
  import { getConfig } from "../config/loader.js";
27
31
  import { processMessage } from "../daemon/process-message.js";
@@ -1073,3 +1077,43 @@ export class RuntimeHttpServer {
1073
1077
  return null;
1074
1078
  }
1075
1079
  }
1080
+
1081
+ // ---------------------------------------------------------------------------
1082
+ // Singleton
1083
+ // ---------------------------------------------------------------------------
1084
+
1085
+ let instance: RuntimeHttpServer | null = null;
1086
+
1087
+ /**
1088
+ * Start the runtime HTTP server singleton early in daemon startup so /healthz
1089
+ * answers ASAP. A bind failure (port in use, permission denied, fd exhaustion)
1090
+ * is non-fatal: it is logged and the daemon falls back to IPC-only operation,
1091
+ * leaving the singleton unset.
1092
+ */
1093
+ export async function startRuntimeHttpServer(): Promise<void> {
1094
+ const port = getRuntimeHttpPort();
1095
+ const hostname = getRuntimeHttpHost();
1096
+ log.info({ port }, "Daemon startup: starting runtime HTTP server");
1097
+ const server = new RuntimeHttpServer({ port, hostname });
1098
+ try {
1099
+ await server.start();
1100
+ instance = server;
1101
+ log.info(
1102
+ { port, hostname },
1103
+ "Daemon startup: runtime HTTP server listening",
1104
+ );
1105
+ } catch (err) {
1106
+ log.warn(
1107
+ { err, port },
1108
+ "Failed to start runtime HTTP server, continuing without it",
1109
+ );
1110
+ instance = null;
1111
+ }
1112
+ }
1113
+
1114
+ /** Stop the runtime HTTP server singleton if one is running; no-op otherwise. */
1115
+ export async function stopRuntimeHttpServer(): Promise<void> {
1116
+ if (!instance) return;
1117
+ await instance.stop();
1118
+ instance = null;
1119
+ }
@@ -171,7 +171,10 @@ const log = getLogger("migration-routes");
171
171
  * all the copy needs.
172
172
  */
173
173
  async function checkpointDbsForExport(): Promise<void> {
174
- const mainResult = await runAsyncSqlite("PRAGMA wal_checkpoint(FULL)");
174
+ const mainResult = await runAsyncSqlite(
175
+ "PRAGMA wal_checkpoint(FULL)",
176
+ "export-checkpoint:wal-checkpoint-full",
177
+ );
175
178
  if (!mainResult.ok) {
176
179
  log.warn(
177
180
  { error: mainResult.error, backend: mainResult.backend, db: "main" },
@@ -117,6 +117,9 @@ function handleExecutionFailure(params: {
117
117
  });
118
118
  }
119
119
 
120
+ /** The running scheduler, retained so shutdown can stop it. */
121
+ let instance: SchedulerHandle | null = null;
122
+
120
123
  export function startScheduler(
121
124
  processMessage: ScheduleMessageProcessor,
122
125
  notifyScheduleOneShot: ScheduleNotifyModeNotifier,
@@ -149,7 +152,7 @@ export function startScheduler(
149
152
  timer.unref();
150
153
  void tick();
151
154
 
152
- return {
155
+ instance = {
153
156
  async runOnce(): Promise<number> {
154
157
  return runScheduleOnce(
155
158
  processMessage,
@@ -174,6 +177,14 @@ export function startScheduler(
174
177
  clearInterval(timer);
175
178
  },
176
179
  };
180
+ return instance;
181
+ }
182
+
183
+ /** Stop the running scheduler if one was started; no-op otherwise. */
184
+ export function stopScheduler(): void {
185
+ if (!instance) return;
186
+ instance.stop();
187
+ instance = null;
177
188
  }
178
189
 
179
190
  export async function runScheduleOnce(