@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 +1 -1
- package/src/config/schemas/conversations.ts +8 -0
- package/src/daemon/lifecycle.ts +38 -49
- package/src/daemon/shutdown-handlers.ts +10 -12
- package/src/memory/__tests__/clear-stale-processing-flags.test.ts +52 -0
- package/src/memory/__tests__/db-async-query.test.ts +12 -5
- package/src/memory/__tests__/db-logs-attach.test.ts +1 -0
- package/src/memory/__tests__/db-memory-attach.test.ts +1 -0
- package/src/memory/conversation-crud.ts +18 -2
- package/src/memory/db-async-query.ts +30 -10
- package/src/memory/db-init.ts +4 -1
- package/src/memory/db-maintenance.ts +5 -1
- package/src/memory/fork-message-copy.ts +69 -14
- package/src/memory/job-handlers/cleanup.ts +2 -0
- package/src/memory/jobs-worker.ts +18 -1
- package/src/memory/memory-retrospective-job.ts +8 -1
- package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +13 -5
- package/src/memory/migrations/222-strip-placeholder-sentinels-from-messages.ts +13 -5
- package/src/memory/migrations/helpers/relocation.ts +3 -0
- package/src/memory/qdrant-manager.ts +28 -0
- package/src/memory/tool-usage-store.ts +1 -0
- package/src/runtime/http-server.ts +45 -1
- package/src/runtime/routes/migration-routes.ts +4 -1
- package/src/schedule/scheduler.ts +12 -1
package/package.json
CHANGED
|
@@ -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
|
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
331
|
-
|
|
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 =
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
126
|
+
await stopRuntimeHttpServer();
|
|
129
127
|
await browserManager.closeAllPages();
|
|
130
128
|
cleanupShellOutputTempFiles();
|
|
131
|
-
|
|
129
|
+
stopScheduler();
|
|
132
130
|
|
|
133
|
-
// Stop the in-process memory worker if
|
|
134
|
-
// event loop (memory.worker.enabled = false).
|
|
135
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
120
|
-
warnedAboutFallback = true;
|
|
140
|
+
if (result.ok && result.elapsedMs > SLOW_WRITE_WARN_MS) {
|
|
121
141
|
log.warn(
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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 {
|
package/src/memory/db-init.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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 {
|
|
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.
|
|
42
|
-
*
|
|
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
|
|
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.
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
164
|
-
|
|
165
|
-
|
|
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(
|
|
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(
|
|
157
|
-
|
|
158
|
-
|
|
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(
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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(
|