@vellumai/assistant 0.10.3-dev.202606271934.9783ef3 → 0.10.3-dev.202606272129.e87b552

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.202606271934.9783ef3",
3
+ "version": "0.10.3-dev.202606272129.e87b552",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Regression: `Conversation.setProcessing(value)` flips the in-memory flag and
3
+ * then persists it to the `processing_started_at` column. If that DB write
4
+ * throws (e.g. SQLITE_BUSY under contention), the in-memory flag must not be
5
+ * left stranded out of sync with the column — it reverts to its prior value
6
+ * and the error re-throws so callers' existing failure handling still runs.
7
+ */
8
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
9
+
10
+ import { CompactionCircuit } from "../agent/compaction-circuit.js";
11
+ import type { AgentEvent } from "../agent/loop.js";
12
+ import type { Message, ProviderResponse } from "../providers/types.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Persistence mock — `setConversationProcessingStartedAt` throws on demand so
16
+ // the test can simulate a locked SQLite write.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ let persistShouldThrow = false;
20
+
21
+ mock.module("../util/logger.js", () => ({
22
+ getLogger: () =>
23
+ new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
24
+ }));
25
+
26
+ mock.module("../providers/registry.js", () => ({
27
+ getProvider: () => ({ name: "mock-provider" }),
28
+ initializeProviders: async () => {},
29
+ }));
30
+
31
+ mock.module("../config/loader.js", () => ({
32
+ getConfig: () => ({
33
+ ui: {},
34
+ llm: {
35
+ default: {
36
+ provider: "mock-provider",
37
+ model: "mock-model",
38
+ maxTokens: 4096,
39
+ effort: "max" as const,
40
+ speed: "standard" as const,
41
+ temperature: null,
42
+ thinking: { enabled: false, streamThinking: true },
43
+ contextWindow: {
44
+ enabled: true,
45
+ maxInputTokens: 100000,
46
+ targetBudgetRatio: 0.3,
47
+ compactThreshold: 0.8,
48
+ summaryBudgetRatio: 0.05,
49
+ overflowRecovery: {
50
+ enabled: true,
51
+ safetyMarginRatio: 0.05,
52
+ maxAttempts: 3,
53
+ interactiveLatestTurnCompression: "summarize",
54
+ nonInteractiveLatestTurnCompression: "truncate",
55
+ },
56
+ },
57
+ },
58
+ profiles: {},
59
+ callSites: {},
60
+ pricingOverrides: [],
61
+ },
62
+ rateLimit: { maxRequestsPerMinute: 0 },
63
+ memory: { enabled: false },
64
+ }),
65
+ loadRawConfig: () => ({}),
66
+ saveRawConfig: () => {},
67
+ invalidateConfigCache: () => {},
68
+ }));
69
+
70
+ mock.module("../prompts/system-prompt.js", () => ({
71
+ buildSystemPrompt: () => "system prompt",
72
+ }));
73
+
74
+ mock.module("../config/skills.js", () => ({
75
+ loadSkillCatalog: () => [],
76
+ loadSkillBySelector: () => ({ skill: null }),
77
+ ensureSkillIcon: async () => null,
78
+ }));
79
+
80
+ mock.module("../config/skill-state.js", () => ({
81
+ resolveSkillStates: () => [],
82
+ }));
83
+
84
+ mock.module("../permissions/trust-store.js", () => ({
85
+ addRule: () => {},
86
+ findHighestPriorityRule: () => null,
87
+ clearCache: () => {},
88
+ }));
89
+
90
+ mock.module("../security/secret-allowlist.js", () => ({
91
+ resetAllowlist: () => {},
92
+ }));
93
+
94
+ mock.module("../memory/conversation-crud.js", () => ({
95
+ setConversationProcessingStartedAt: () => {
96
+ if (persistShouldThrow) {
97
+ throw new Error("database is locked (SQLITE_BUSY)");
98
+ }
99
+ },
100
+ isConversationProcessing: () => false,
101
+ setConversationOriginChannelIfUnset: () => {},
102
+ setConversationHistoryStrippedAt: () => {},
103
+ provenanceFromTrustContext: () => ({
104
+ source: "user",
105
+ trustContext: undefined,
106
+ }),
107
+ getConversationOriginInterface: () => null,
108
+ getConversationOriginChannel: () => null,
109
+ getMessages: () => [],
110
+ getConversation: () => ({
111
+ id: "conv-1",
112
+ createdAt: Date.parse("2026-03-19T12:00:00.000Z"),
113
+ contextSummary: null,
114
+ contextCompactedMessageCount: 0,
115
+ contextCompactedAt: null,
116
+ totalInputTokens: 0,
117
+ totalOutputTokens: 0,
118
+ totalEstimatedCost: 0,
119
+ }),
120
+ addMessage: () => ({ id: "msg-1" }),
121
+ updateConversationUsage: () => {},
122
+ updateConversationTitle: () => {},
123
+ updateConversationContextWindow: () => {},
124
+ deleteMessageById: () => ({ segmentIds: [], deletedSummaryIds: [] }),
125
+ deleteLastExchange: () => 0,
126
+ reserveMessage: mock(async () => ({ id: "msg-reserve" })),
127
+ }));
128
+
129
+ mock.module("../memory/conversation-queries.js", () => ({
130
+ isLastUserMessageToolResult: () => false,
131
+ }));
132
+
133
+ mock.module("../memory/attachments-store.js", () => ({
134
+ uploadAttachment: () => ({ id: "att-1" }),
135
+ linkAttachmentToMessage: () => {},
136
+ }));
137
+
138
+ mock.module("../memory/retriever.js", () => ({
139
+ buildMemoryRecall: async () => null,
140
+ injectMemoryRecallAsUserBlock: (msgs: Message[]) => msgs,
141
+ }));
142
+
143
+ mock.module("../memory/query-builder.js", () => ({
144
+ buildMemoryQuery: () => "",
145
+ }));
146
+
147
+ mock.module("../memory/retrieval-budget.js", () => ({
148
+ computeRecallBudget: () => 0,
149
+ }));
150
+
151
+ mock.module("../runtime/sync/sync-publisher.js", () => ({
152
+ publishSyncInvalidation: () => {},
153
+ }));
154
+
155
+ mock.module("../daemon/message-types/sync.js", () => ({
156
+ conversationMetadataSyncTag: (id: string) => `conversation-metadata:${id}`,
157
+ }));
158
+
159
+ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
160
+ ContextWindowManager: class {
161
+ estimateInputTokens() {
162
+ return 0;
163
+ }
164
+ get tokenCountInputs() {
165
+ return { systemPrompt: "", tools: undefined };
166
+ }
167
+ constructor() {}
168
+ updateConfig() {}
169
+ shouldCompact() {
170
+ return { needed: false, estimatedTokens: 0 };
171
+ }
172
+ async maybeCompact() {
173
+ return { compacted: false };
174
+ }
175
+ resetOverflowRecovery() {}
176
+ },
177
+ createContextSummaryMessage: () => ({
178
+ role: "user",
179
+ content: [{ type: "text", text: "summary" }],
180
+ }),
181
+ getSummaryFromContextMessage: () => null,
182
+ }));
183
+
184
+ mock.module("../memory/llm-usage-store.js", () => ({
185
+ recordUsageEvent: () => ({ id: "usage-1", createdAt: Date.now() }),
186
+ }));
187
+
188
+ mock.module("../memory/app-store.js", () => ({
189
+ getApp: () => null,
190
+ updateApp: () => {},
191
+ }));
192
+
193
+ mock.module("../agent/loop.js", () => ({
194
+ AgentLoop: class {
195
+ compactionCircuit = new CompactionCircuit("test-conv");
196
+ constructor() {}
197
+ getToolTokenBudget() {
198
+ return 0;
199
+ }
200
+ getResolvedTools() {
201
+ return [];
202
+ }
203
+ getActiveModel() {
204
+ return undefined;
205
+ }
206
+ async run(options: {
207
+ messages: Message[];
208
+ onEvent: (event: AgentEvent) => void;
209
+ }): Promise<Message[]> {
210
+ return options.messages;
211
+ }
212
+ },
213
+ }));
214
+
215
+ import { Conversation } from "../daemon/conversation.js";
216
+
217
+ function makeConversation(): Conversation {
218
+ const provider = {
219
+ name: "mock",
220
+ async sendMessage(): Promise<ProviderResponse> {
221
+ return {
222
+ content: [],
223
+ model: "mock",
224
+ usage: { inputTokens: 0, outputTokens: 0 },
225
+ stopReason: "end_turn",
226
+ };
227
+ },
228
+ };
229
+ return new Conversation(
230
+ "conv-1",
231
+ provider,
232
+ "system prompt",
233
+ () => {},
234
+ "/tmp",
235
+ { maxTokens: 4096 },
236
+ );
237
+ }
238
+
239
+ describe("Conversation.setProcessing persistence failure", () => {
240
+ let conversation: Conversation;
241
+
242
+ beforeEach(() => {
243
+ persistShouldThrow = false;
244
+ conversation = makeConversation();
245
+ });
246
+
247
+ test("reverts the in-memory flag and re-throws when the DB write fails", () => {
248
+ expect(conversation.isProcessing()).toBe(false);
249
+
250
+ persistShouldThrow = true;
251
+ expect(() => conversation.setProcessing(true)).toThrow(
252
+ "database is locked",
253
+ );
254
+
255
+ // The persisted column never landed, so the in-memory flag must not be
256
+ // stranded at `true`.
257
+ expect(conversation.isProcessing()).toBe(false);
258
+ });
259
+
260
+ test("restores the prior value when clearing fails mid-turn", () => {
261
+ conversation.setProcessing(true);
262
+ expect(conversation.isProcessing()).toBe(true);
263
+
264
+ persistShouldThrow = true;
265
+ expect(() => conversation.setProcessing(false)).toThrow(
266
+ "database is locked",
267
+ );
268
+
269
+ // Clear didn't persist, so the flag stays consistent with the column.
270
+ expect(conversation.isProcessing()).toBe(true);
271
+ });
272
+
273
+ test("commits the flag when the DB write succeeds", () => {
274
+ conversation.setProcessing(true);
275
+ expect(conversation.isProcessing()).toBe(true);
276
+
277
+ conversation.setProcessing(false);
278
+ expect(conversation.isProcessing()).toBe(false);
279
+ });
280
+ });
@@ -1352,11 +1352,20 @@ export class Conversation {
1352
1352
  this._processing = value;
1353
1353
  // Persist the cross-process source of truth so out-of-process callers
1354
1354
  // (retrospective CLI, future detached workers) can detect mid-turn state
1355
- // by reading the conversations row directly.
1356
- setConversationProcessingStartedAt(
1357
- this.conversationId,
1358
- value ? Date.now() : null,
1359
- );
1355
+ // by reading the conversations row directly. If the write fails (e.g.
1356
+ // SQLITE_BUSY), the persisted column keeps its prior value, so revert the
1357
+ // in-memory flag to match rather than stranding `processing = true` in
1358
+ // memory against a NULL column. Re-throw so callers' existing failure
1359
+ // handling still runs.
1360
+ try {
1361
+ setConversationProcessingStartedAt(
1362
+ this.conversationId,
1363
+ value ? Date.now() : null,
1364
+ );
1365
+ } catch (err) {
1366
+ this._processing = wasProcessing;
1367
+ throw err;
1368
+ }
1360
1369
  if (wasProcessing && !value) {
1361
1370
  void publishSyncInvalidation([
1362
1371
  conversationMetadataSyncTag(this.conversationId),
@@ -38,10 +38,7 @@ import { FilingService } from "../filing/filing-service.js";
38
38
  import { HeartbeatService } from "../heartbeat/heartbeat-service.js";
39
39
  import { backfillRelationshipStateIfMissing } from "../home/relationship-state-writer.js";
40
40
  import { closeSentry, initSentry, setSentryDeviceId } from "../instrument.js";
41
- import {
42
- startGatewayFlagListener,
43
- stopGatewayFlagListener,
44
- } from "../ipc/gateway-flag-listener.js";
41
+ import { startGatewayFlagListener } from "../ipc/gateway-flag-listener.js";
45
42
  import { getMcpServerManager } from "../mcp/manager.js";
46
43
  import {
47
44
  getAttachmentsByIds,
@@ -112,11 +109,7 @@ import { repairAdaptiveThinkingOnManagedProfiles } from "../workspace/adaptive-t
112
109
  import { WorkspaceHeartbeatService } from "../workspace/heartbeat-service.js";
113
110
  import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
114
111
  import { runWorkspaceMigrations } from "../workspace/migrations/runner.js";
115
- import {
116
- cleanupPidFile,
117
- cleanupPidFileIfOwner,
118
- writePid,
119
- } from "./daemon-control.js";
112
+ import { cleanupPidFileIfOwner, writePid } from "./daemon-control.js";
120
113
  import {
121
114
  evaluateDiskPressureNow,
122
115
  startDiskPressureGuard,
@@ -1440,13 +1433,6 @@ export async function runDaemon(): Promise<void> {
1440
1433
  getQdrantManager: () => bgRefs.qdrantManager,
1441
1434
  mcpManager,
1442
1435
  telemetryReporter,
1443
- cleanupPidFile: () => {
1444
- stopGatewayFlagListener();
1445
- stopDiskPressureGuardForLifecycle();
1446
- stopOrphanReaper();
1447
- stopEventLoopWatchdog();
1448
- cleanupPidFile();
1449
- },
1450
1436
  });
1451
1437
 
1452
1438
  log.info(
@@ -2,6 +2,7 @@ import * as Sentry from "@sentry/node";
2
2
 
3
3
  import type { FilingService } from "../filing/filing-service.js";
4
4
  import type { HeartbeatService } from "../heartbeat/heartbeat-service.js";
5
+ import { stopGatewayFlagListener } from "../ipc/gateway-flag-listener.js";
5
6
  import type { McpServerManager } from "../mcp/manager.js";
6
7
  import { getSqlite, resetDb } from "../memory/db-connection.js";
7
8
  import type { QdrantManager } from "../memory/qdrant-manager.js";
@@ -12,11 +13,28 @@ import { cleanupShellOutputTempFiles } from "../tools/shared/shell-output.js";
12
13
  import { getLogger } from "../util/logger.js";
13
14
  import { getEnrichmentService } from "../workspace/commit-message-enrichment-service.js";
14
15
  import type { WorkspaceHeartbeatService } from "../workspace/heartbeat-service.js";
16
+ import { cleanupPidFile } from "./daemon-control.js";
17
+ import { stopEventLoopWatchdog } from "./event-loop-watchdog.js";
18
+ import { stopDiskPressureGuardForLifecycle } from "./lifecycle.js";
19
+ import { stopOrphanReaper } from "./orphan-reaper.js";
15
20
  import type { DaemonServer } from "./server.js";
16
21
  import { runShutdownHooks } from "./shutdown-registry.js";
17
22
 
18
23
  const log = getLogger("lifecycle");
19
24
 
25
+ /**
26
+ * Stop the daemon's background services and remove the PID file. Invoked on
27
+ * both the graceful-shutdown and force-exit-timeout paths so the process never
28
+ * leaves a stale PID file or orphaned timers behind.
29
+ */
30
+ function stopBackgroundServicesAndCleanupPidFile(): void {
31
+ stopGatewayFlagListener();
32
+ stopDiskPressureGuardForLifecycle();
33
+ stopOrphanReaper();
34
+ stopEventLoopWatchdog();
35
+ cleanupPidFile();
36
+ }
37
+
20
38
  export interface ShutdownDeps {
21
39
  server: DaemonServer;
22
40
  workspaceHeartbeat: WorkspaceHeartbeatService;
@@ -28,7 +46,6 @@ export interface ShutdownDeps {
28
46
  getQdrantManager: () => QdrantManager | null;
29
47
  mcpManager: McpServerManager | null;
30
48
  telemetryReporter: { stop(): Promise<void> } | null;
31
- cleanupPidFile: () => void;
32
49
  }
33
50
 
34
51
  export function installShutdownHandlers(deps: ShutdownDeps): void {
@@ -51,7 +68,7 @@ export function installShutdownHandlers(deps: ShutdownDeps): void {
51
68
  // bump only changes behavior for the stuck-shutdown path.
52
69
  const forceTimer = setTimeout(() => {
53
70
  log.warn("Graceful shutdown timed out, forcing exit");
54
- deps.cleanupPidFile();
71
+ stopBackgroundServicesAndCleanupPidFile();
55
72
  process.exit(1);
56
73
  }, 20_000);
57
74
  forceTimer.unref();
@@ -171,7 +188,7 @@ export function installShutdownHandlers(deps: ShutdownDeps): void {
171
188
 
172
189
  await Sentry.flush(2000);
173
190
  clearTimeout(forceTimer);
174
- deps.cleanupPidFile();
191
+ stopBackgroundServicesAndCleanupPidFile();
175
192
  process.exit(exitCode);
176
193
  };
177
194
 
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Locks in that every assistant SQLite connection shares one busy_timeout.
3
+ *
4
+ * `runAsyncSqlite` can open an out-of-process (sqlite3 CLI) or transient
5
+ * in-process connection that writes the same database file as the live
6
+ * daemon connection. If those connections don't wait the same amount of
7
+ * time for a lock, a concurrent writer fails immediately with SQLITE_BUSY
8
+ * instead of waiting its turn. The shared `SQLITE_BUSY_TIMEOUT_MS` constant
9
+ * is the single source of truth; this test guards against the live daemon
10
+ * connection drifting away from it.
11
+ */
12
+ import { expect, test } from "bun:test";
13
+
14
+ const { getSqlite, SQLITE_BUSY_TIMEOUT_MS } =
15
+ await import("../db-connection.js");
16
+ const { initializeDb } = await import("../db-init.js");
17
+
18
+ await initializeDb();
19
+
20
+ test("the daemon connection runs with the shared busy_timeout", () => {
21
+ const sqlite = getSqlite();
22
+ const value = (
23
+ sqlite.query("PRAGMA busy_timeout").get() as { timeout: number }
24
+ ).timeout;
25
+ expect(value).toBe(SQLITE_BUSY_TIMEOUT_MS);
26
+ });
@@ -31,7 +31,7 @@ import { Database } from "bun:sqlite";
31
31
  import { getLogger } from "../util/logger.js";
32
32
  import { getDbPath } from "../util/platform.js";
33
33
  import { findSqlite3 } from "../util/sqlite3-runtime.js";
34
- import { getSqlite } from "./db-connection.js";
34
+ import { getSqlite, SQLITE_BUSY_TIMEOUT_MS } from "./db-connection.js";
35
35
 
36
36
  const log = getLogger("db-async-query");
37
37
 
@@ -172,8 +172,14 @@ async function runViaCli(
172
172
  stderr: "pipe",
173
173
  });
174
174
 
175
+ // Match the busy_timeout the daemon connection uses so this subprocess
176
+ // waits for — rather than instantly failing against — a lock held by the
177
+ // still-running in-process connection (and vice versa). Prepended to the
178
+ // piped SQL so it takes effect before the statement runs.
179
+ const sqlWithPragma = `PRAGMA busy_timeout=${SQLITE_BUSY_TIMEOUT_MS};\n${sql}`;
180
+
175
181
  // Write the SQL and close stdin so sqlite3 sees EOF and exits.
176
- proc.stdin.write(sql + "\n");
182
+ proc.stdin.write(sqlWithPragma + "\n");
177
183
  await proc.stdin.end();
178
184
 
179
185
  // Begin draining the streams immediately so the subprocess never
@@ -253,6 +259,11 @@ async function runInProcessBlocking(
253
259
  let sqlite: Database;
254
260
  if (usesDedicatedFile) {
255
261
  transient = new Database(options.dbPath ?? getDbPath());
262
+ // Match the daemon connection's busy_timeout so this transient
263
+ // connection waits for a lock held by another writer rather than
264
+ // failing immediately with SQLITE_BUSY. (The daemon connection reused
265
+ // in the else branch already has it set via applyConnectionPragmas.)
266
+ transient.exec(`PRAGMA busy_timeout=${SQLITE_BUSY_TIMEOUT_MS}`);
256
267
  for (const a of options.attach ?? []) {
257
268
  transient.exec(
258
269
  `ATTACH DATABASE '${a.path.replace(/'/g, "''")}' AS ${a.alias}`,
@@ -77,6 +77,16 @@ export function assertTestDbIsIsolated(): void {
77
77
  }
78
78
  }
79
79
 
80
+ /**
81
+ * How long a SQLite connection waits to acquire a lock held by another
82
+ * writer before giving up with `SQLITE_BUSY`. Every connection that touches
83
+ * an assistant database — the main/dedicated daemon connections here and the
84
+ * out-of-process / transient connections in `db-async-query.ts` — must set
85
+ * the same value, so a writer that grabs the lock can finish without a
86
+ * concurrent writer failing immediately instead of waiting its turn.
87
+ */
88
+ export const SQLITE_BUSY_TIMEOUT_MS = 5000;
89
+
80
90
  /**
81
91
  * Apply the connection-wide PRAGMAs every assistant SQLite connection runs
82
92
  * with. These are per-connection settings, so the dedicated logs/memory
@@ -85,7 +95,7 @@ export function assertTestDbIsIsolated(): void {
85
95
  function applyConnectionPragmas(sqlite: Database): void {
86
96
  sqlite.exec("PRAGMA journal_mode=WAL");
87
97
  sqlite.exec("PRAGMA synchronous=FULL");
88
- sqlite.exec("PRAGMA busy_timeout=5000");
98
+ sqlite.exec(`PRAGMA busy_timeout=${SQLITE_BUSY_TIMEOUT_MS}`);
89
99
  sqlite.exec("PRAGMA foreign_keys = ON");
90
100
  sqlite.exec("PRAGMA cache_size=-256000");
91
101
  sqlite.exec("PRAGMA temp_store=MEMORY");