@vellumai/assistant 0.10.0-dev.202606200318.c052d10 → 0.10.0-dev.202606201453.1417592

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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/agent-loop-callsite-precedence.test.ts +1 -40
  3. package/src/__tests__/agent-wake-override-profile.test.ts +2 -0
  4. package/src/__tests__/app-source-watcher.test.ts +30 -10
  5. package/src/__tests__/config-schema.test.ts +34 -0
  6. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +3 -0
  7. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +3 -0
  8. package/src/__tests__/conversation-agent-loop-overflow.test.ts +3 -0
  9. package/src/__tests__/conversation-agent-loop.test.ts +3 -0
  10. package/src/__tests__/conversation-process-callsite.test.ts +0 -14
  11. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +6 -1
  12. package/src/__tests__/heartbeat-disk-pressure.test.ts +3 -0
  13. package/src/__tests__/heartbeat-service.test.ts +6 -0
  14. package/src/__tests__/list-messages-attachments.test.ts +41 -0
  15. package/src/__tests__/plugin-source-watcher.test.ts +33 -1
  16. package/src/__tests__/usage-cache-backfill-migration.test.ts +17 -2
  17. package/src/acp/__tests__/session-manager.test.ts +72 -1
  18. package/src/acp/index.ts +10 -0
  19. package/src/acp/session-manager.ts +35 -0
  20. package/src/agent/loop.ts +28 -22
  21. package/src/config/schemas/memory-lifecycle.ts +5 -3
  22. package/src/config/schemas/timeouts.ts +24 -0
  23. package/src/daemon/app-source-watcher.ts +31 -18
  24. package/src/daemon/conversation-agent-loop.ts +8 -5
  25. package/src/daemon/conversation.ts +30 -41
  26. package/src/daemon/handlers/conversations.ts +7 -0
  27. package/src/daemon/plugin-source-watcher.ts +5 -0
  28. package/src/daemon/workspace-tools-watcher.ts +4 -0
  29. package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
  30. package/src/heartbeat/heartbeat-service.ts +3 -4
  31. package/src/memory/__tests__/db-maintenance.test.ts +27 -35
  32. package/src/memory/conversation-crud.ts +9 -3
  33. package/src/memory/db-init.ts +33 -5
  34. package/src/memory/db-maintenance.ts +43 -38
  35. package/src/memory/job-handlers/cleanup.ts +6 -0
  36. package/src/memory/migrations/297-move-llm-request-logs-to-logs-db.ts +130 -0
  37. package/src/memory/migrations/__tests__/297-move-llm-request-logs.test.ts +159 -0
  38. package/src/memory/migrations/index.ts +1 -0
  39. package/src/plugin-api/index.ts +7 -0
  40. package/src/plugin-api/vision-support.ts +75 -0
  41. package/src/prompts/system-prompt.ts +1 -1
  42. package/src/runtime/__tests__/agent-wake.test.ts +6 -4
  43. package/src/runtime/agent-wake.ts +15 -7
  44. package/src/runtime/routes/conversation-routes.ts +24 -3
  45. package/src/runtime/routes/migration-routes.ts +35 -39
  46. package/src/schedule/scheduler.ts +5 -9
  47. package/src/tools/ask-question/ask-question-tool.test.ts +60 -52
  48. package/src/tools/ask-question/ask-question-tool.ts +14 -73
  49. package/src/util/fs-watcher-error.ts +36 -0
package/src/agent/loop.ts CHANGED
@@ -493,12 +493,6 @@ export function shouldCaptureAgentLoopError(err: Error): boolean {
493
493
  return true;
494
494
  }
495
495
 
496
- export interface ResolvedSystemPrompt {
497
- systemPrompt: string;
498
- maxTokens?: number;
499
- model?: string;
500
- }
501
-
502
496
  export interface AgentLoopRunOptions {
503
497
  /** Input history the run starts from; the loop appends its output onto a copy. */
504
498
  messages: Message[];
@@ -506,6 +500,20 @@ export interface AgentLoopRunOptions {
506
500
  onEvent: (event: AgentEvent) => void | Promise<void>;
507
501
  signal?: AbortSignal;
508
502
  requestId: string;
503
+ /**
504
+ * System prompt for this run, resolved by the caller (Conversation) before
505
+ * invoking the loop. One turn == one agent loop == one system prompt; the
506
+ * prompt is NOT re-resolved mid-loop (that would bust the provider's
507
+ * prefix cache). When omitted, falls back to the constructor's
508
+ * {@link AgentLoopConstructorOptions.systemPrompt}.
509
+ */
510
+ systemPrompt?: string;
511
+ /**
512
+ * Explicit model override (provider/model string) for every LLM call in
513
+ * this run. When omitted, the model is resolved through the normal
514
+ * call-site / profile resolution path.
515
+ */
516
+ model?: string;
509
517
  onCheckpoint?: (
510
518
  checkpoint: CheckpointInfo,
511
519
  ) => CheckpointDecision | Promise<CheckpointDecision>;
@@ -634,7 +642,6 @@ export interface AgentLoopConstructorOptions {
634
642
  tools?: ToolDefinition[];
635
643
  toolExecutor?: LoopToolExecutor;
636
644
  resolveTools?: (history: Message[]) => ToolDefinition[];
637
- resolveSystemPrompt?: (history: Message[]) => ResolvedSystemPrompt;
638
645
  /**
639
646
  * Conversation this loop drives. Scopes the loop-held compaction circuit
640
647
  * breaker and is the source of truth the loop's pipeline contexts and
@@ -659,9 +666,6 @@ export class AgentLoop {
659
666
  private config: AgentLoopConfig;
660
667
  private tools: ToolDefinition[];
661
668
  private resolveTools: ((history: Message[]) => ToolDefinition[]) | null;
662
- private resolveSystemPrompt:
663
- | ((history: Message[]) => ResolvedSystemPrompt)
664
- | null;
665
669
  private toolExecutor: LoopToolExecutor | null;
666
670
 
667
671
  /**
@@ -692,7 +696,6 @@ export class AgentLoop {
692
696
  tools,
693
697
  toolExecutor,
694
698
  resolveTools,
695
- resolveSystemPrompt,
696
699
  conversationId,
697
700
  resolveConversationDir,
698
701
  } = options;
@@ -701,7 +704,6 @@ export class AgentLoop {
701
704
  this.config = { ...DEFAULT_CONFIG, ...config };
702
705
  this.tools = tools ?? [];
703
706
  this.resolveTools = resolveTools ?? null;
704
- this.resolveSystemPrompt = resolveSystemPrompt ?? null;
705
707
  this.toolExecutor = toolExecutor ?? null;
706
708
  this.conversationId = conversationId;
707
709
  this.resolveConversationDir = resolveConversationDir ?? null;
@@ -910,7 +912,14 @@ export class AgentLoop {
910
912
  compactInPlace = false,
911
913
  isNonInteractive = false,
912
914
  modelProfileKey = null,
915
+ systemPrompt: runSystemPrompt,
916
+ model: runModel,
913
917
  } = options;
918
+ // Resolve the system prompt once per run — the caller (Conversation)
919
+ // builds it before invoking the loop. Re-resolving mid-loop would bust
920
+ // the provider's prefix cache. Falls back to the constructor seed when
921
+ // the caller doesn't supply one.
922
+ const resolvedSystemPrompt = runSystemPrompt ?? this.systemPrompt;
914
923
  let history = [...messages];
915
924
  // Index into `history` where this run's appended output begins. It starts
916
925
  // after the input and resets to the new base whenever the loop rewrites the
@@ -1218,15 +1227,14 @@ export class AgentLoop {
1218
1227
  ? this.resolveTools(history)
1219
1228
  : this.tools;
1220
1229
 
1221
- // Resolve system prompt, per-turn maxTokens, and model
1222
- const resolved = this.resolveSystemPrompt
1223
- ? this.resolveSystemPrompt(history)
1224
- : null;
1225
- const turnSystemPrompt = resolved?.systemPrompt ?? this.systemPrompt;
1226
- const turnModel = resolved?.model;
1230
+ // System prompt and model are resolved once per run (by the caller),
1231
+ // not per-LLM-call — re-resolving mid-loop would bust the provider's
1232
+ // prefix cache.
1233
+ const turnSystemPrompt = resolvedSystemPrompt;
1234
+ const turnModel = runModel;
1227
1235
 
1228
1236
  // Field precedence (highest wins):
1229
- // 1. Per-turn explicit (`resolved.maxTokens` / `resolved.model`)
1237
+ // 1. Per-run explicit (`runModel`)
1230
1238
  // 2. Call-site resolved values (filled by
1231
1239
  // `RetryProvider.normalizeSendMessageOptions` from
1232
1240
  // `resolveCallSiteConfig(callSite, llm)`)
@@ -1244,9 +1252,7 @@ export class AgentLoop {
1244
1252
  // they always come from `this.config` regardless of `callSite`.
1245
1253
  const providerConfig: Record<string, unknown> = {};
1246
1254
 
1247
- if (resolved?.maxTokens !== undefined) {
1248
- providerConfig.max_tokens = resolved.maxTokens;
1249
- } else if (!callSite) {
1255
+ if (!callSite) {
1250
1256
  providerConfig.max_tokens = this.config.maxTokens;
1251
1257
  }
1252
1258
 
@@ -169,7 +169,7 @@ export const MemoryMaintenanceConfigSchema = z
169
169
  .positive("memory.maintenance.intervalMs must be a positive integer")
170
170
  .default(24 * 60 * 60 * 1000)
171
171
  .describe(
172
- "Minimum interval between database maintenance (VACUUM / PRAGMA optimize) runs, in milliseconds",
172
+ "Minimum interval between database maintenance (PRAGMA optimize / WAL checkpoint) runs, in milliseconds",
173
173
  ),
174
174
  quietPeriodMs: z
175
175
  .number({ error: "memory.maintenance.quietPeriodMs must be a number" })
@@ -177,10 +177,12 @@ export const MemoryMaintenanceConfigSchema = z
177
177
  .nonnegative("memory.maintenance.quietPeriodMs must be non-negative")
178
178
  .default(3 * 60 * 60 * 1000)
179
179
  .describe(
180
- "Database maintenance is deferred unless at least this many milliseconds have elapsed since the last user message, so the VACUUM's exclusive lock never collides with an active user (0 disables the quiet-period gate)",
180
+ "Database maintenance is deferred unless at least this many milliseconds have elapsed since the last user message, so maintenance's write locks never collide with an active user (0 disables the quiet-period gate)",
181
181
  ),
182
182
  })
183
- .describe("Database maintenance (VACUUM / PRAGMA optimize) scheduling");
183
+ .describe(
184
+ "Database maintenance (PRAGMA optimize / WAL checkpoint) scheduling",
185
+ );
184
186
 
185
187
  export type MemoryJobsConfig = z.infer<typeof MemoryJobsConfigSchema>;
186
188
  export type MemoryRetentionConfig = z.infer<typeof MemoryRetentionConfigSchema>;
@@ -42,6 +42,30 @@ export const TimeoutConfigSchema = z
42
42
  .describe(
43
43
  "Timeout for waiting on the LLM provider's streaming response (seconds)",
44
44
  ),
45
+ backgroundTurnTimeoutSec: z
46
+ .number({ error: "timeouts.backgroundTurnTimeoutSec must be a number" })
47
+ .int("timeouts.backgroundTurnTimeoutSec must be an integer")
48
+ .positive("timeouts.backgroundTurnTimeoutSec must be a positive integer")
49
+ .max(
50
+ 2147483,
51
+ "timeouts.backgroundTurnTimeoutSec must be at most 2147483 (setTimeout-safe limit)",
52
+ )
53
+ .default(1800)
54
+ .describe(
55
+ "Hard timeout for heartbeat and generic background agent turns (seconds)",
56
+ ),
57
+ scheduleTurnTimeoutSec: z
58
+ .number({ error: "timeouts.scheduleTurnTimeoutSec must be a number" })
59
+ .int("timeouts.scheduleTurnTimeoutSec must be an integer")
60
+ .positive("timeouts.scheduleTurnTimeoutSec must be a positive integer")
61
+ .max(
62
+ 2147483,
63
+ "timeouts.scheduleTurnTimeoutSec must be at most 2147483 (setTimeout-safe limit)",
64
+ )
65
+ .default(1800)
66
+ .describe(
67
+ "Hard timeout for deliberately-launched scheduled (talk-mode) agent turns (seconds)",
68
+ ),
45
69
  })
46
70
  .describe("Timeout configuration for various operations");
47
71
 
@@ -11,11 +11,9 @@
11
11
 
12
12
  import { existsSync, type FSWatcher, watch } from "node:fs";
13
13
 
14
- import {
15
- getAppsDir,
16
- resolveAppIdByDirName,
17
- } from "../memory/app-store.js";
14
+ import { getAppsDir, resolveAppIdByDirName } from "../memory/app-store.js";
18
15
  import { DebouncerMap } from "../util/debounce.js";
16
+ import { attachFsWatcherErrorHandler } from "../util/fs-watcher-error.js";
19
17
  import { getLogger } from "../util/logger.js";
20
18
 
21
19
  const log = getLogger("app-source-watcher");
@@ -51,8 +49,10 @@ function resolveAppIdFromRelPath(relPath: string): string | null {
51
49
 
52
50
  // Skip non-source directories (include bare directory names for fs.watch events)
53
51
  if (
54
- innerPath === "records" || innerPath.startsWith("records/") ||
55
- innerPath === "dist" || innerPath.startsWith("dist/")
52
+ innerPath === "records" ||
53
+ innerPath.startsWith("records/") ||
54
+ innerPath === "dist" ||
55
+ innerPath.startsWith("dist/")
56
56
  ) {
57
57
  return null;
58
58
  }
@@ -89,7 +89,9 @@ export class AppSourceWatcher {
89
89
  try {
90
90
  appsDir = getAppsDir();
91
91
  } catch {
92
- log.warn("Could not resolve apps directory; app source watching disabled");
92
+ log.warn(
93
+ "Could not resolve apps directory; app source watching disabled",
94
+ );
93
95
  return;
94
96
  }
95
97
 
@@ -102,19 +104,30 @@ export class AppSourceWatcher {
102
104
  if (!onChange) return;
103
105
 
104
106
  try {
105
- this.watcher = watch(appsDir, { recursive: true }, (_eventType, filename) => {
106
- if (!filename) return;
107
-
108
- const appId = resolveAppIdFromRelPath(filename);
109
- if (!appId) return;
110
-
111
- this.debouncer.schedule(`app:${appId}`, () => {
112
- onChange(appId);
113
- });
114
- });
107
+ this.watcher = watch(
108
+ appsDir,
109
+ { recursive: true },
110
+ (_eventType, filename) => {
111
+ if (!filename) return;
112
+
113
+ const appId = resolveAppIdFromRelPath(filename);
114
+ if (!appId) return;
115
+
116
+ this.debouncer.schedule(`app:${appId}`, () => {
117
+ onChange(appId);
118
+ });
119
+ },
120
+ );
121
+ // Recursive watches over app trees (incl. node_modules) can exhaust the
122
+ // inotify watch limit and emit ENOSPC asynchronously. Without an 'error'
123
+ // listener that unhandled emitter error crashes the daemon.
124
+ attachFsWatcherErrorHandler(this.watcher, log, appsDir);
115
125
  log.info("App source watcher started");
116
126
  } catch (err) {
117
- log.warn({ err }, "Failed to watch apps directory; source watching disabled");
127
+ log.warn(
128
+ { err },
129
+ "Failed to watch apps directory; source watching disabled",
130
+ );
118
131
  }
119
132
  }
120
133
 
@@ -190,8 +190,8 @@ export interface AssistantSurface {
190
190
  // ── abort watchdog ───────────────────────────────────────────────────
191
191
 
192
192
  /**
193
- * Generous backstop that drives an aborted turn to its `finally` even if some
194
- * awaited operation fails to observe the abort signal.
193
+ * Backstop that drives an aborted turn to its `finally` even if some awaited
194
+ * operation fails to observe the abort signal.
195
195
  *
196
196
  * Abort is otherwise cooperative and already wired into the slow paths: the
197
197
  * provider call forwards the signal to its HTTP/streaming fetch, and tool
@@ -199,10 +199,11 @@ export interface AssistantSurface {
199
199
  * watchdog only fires when a future code path silently ignores abort — without
200
200
  * it, such a path would hang the loop forever and latch the conversation's
201
201
  * `processing` flag true (the wedged "Thinking…" indicator). It is
202
- * defense-in-depth, not the primary mechanism, so the timeout is deliberately
203
- * generous; in the common case abort settles in-flight work well before it.
202
+ * defense-in-depth, not the primary mechanism: in the common case abort settles
203
+ * in-flight work in well under a second, so a few seconds is ample headroom for
204
+ * a cooperative unwind while still releasing a genuinely wedged turn promptly.
204
205
  */
205
- const ABORT_WATCHDOG_MS = 45_000;
206
+ const ABORT_WATCHDOG_MS = 5_000;
206
207
 
207
208
  /**
208
209
  * Race `work` against an abort watchdog. The watchdog stays disarmed until the
@@ -980,6 +981,8 @@ export async function runAgentLoopImpl(
980
981
  compactInPlace,
981
982
  isNonInteractive,
982
983
  modelProfileKey,
984
+ systemPrompt: ctx.buildCurrentSystemPrompt(),
985
+ ...(ctx.modelOverride ? { model: ctx.modelOverride } : {}),
983
986
  }),
984
987
  abortController.signal,
985
988
  watchdogMs,
@@ -15,7 +15,7 @@
15
15
  * - conversation-usage.ts — recordUsage
16
16
  */
17
17
 
18
- import type { AgentLoopConfig, ResolvedSystemPrompt } from "../agent/loop.js";
18
+ import type { AgentLoopConfig } from "../agent/loop.js";
19
19
  import { AgentLoop } from "../agent/loop.js";
20
20
  import type { AssistantActivityStateEvent } from "../api/events/assistant-activity-state.js";
21
21
  import type {
@@ -537,6 +537,7 @@ export class Conversation {
537
537
  };
538
538
  public readonly traceEmitter: TraceEmitter;
539
539
  /** @internal */ hasSystemPromptOverride: boolean;
540
+ /** @internal */ modelOverride: string | undefined;
540
541
  /** @internal */ readonly graphMemory: ConversationGraphMemory;
541
542
  /** @internal */ activeContextNodeIds?: string[];
542
543
  /** @internal */ streamThinking: boolean;
@@ -666,34 +667,9 @@ export class Conversation {
666
667
  const hasSystemPromptOverride = systemPrompt !== buildSystemPrompt();
667
668
  this.hasSystemPromptOverride = hasSystemPromptOverride;
668
669
 
669
- // If an explicit modelOverride is supplied, use it verbatim. Otherwise
670
- // leave the model unset and let `RetryProvider`'s call-site resolver pick
671
- // it up from `llm.default` / `llm.callSites.<id>` on every turn.
672
- const resolvedModel: string | undefined = modelOverride;
673
-
674
- const resolveSystemPromptCallback = (
675
- _history: Message[],
676
- ): ResolvedSystemPrompt => {
677
- const resolved: ResolvedSystemPrompt = {
678
- systemPrompt: this.hasSystemPromptOverride
679
- ? systemPrompt
680
- : buildSystemPrompt({
681
- hasNoClient: this.hasNoClient,
682
- trustContext: this.currentTurnTrustContext,
683
- channelCapabilities: this.currentTurnChannelCapabilities,
684
- personaOverride: this.wakePersonaOverride,
685
- onboardingContext: this.getOnboardingContext(),
686
- conversationId: this.conversationId,
687
- }),
688
- };
689
- if (configuredMaxTokens !== undefined) {
690
- resolved.maxTokens = configuredMaxTokens;
691
- }
692
- if (resolvedModel !== undefined) {
693
- resolved.model = resolvedModel;
694
- }
695
- return resolved;
696
- };
670
+ // Store the model override for per-run resolution. The loop receives it
671
+ // as a top-level `model` param on `run()`.
672
+ this.modelOverride = modelOverride;
697
673
 
698
674
  const fastModeEnabled = isAssistantFeatureFlagEnabled("fast-mode", config);
699
675
  const resolvedSpeed = speedOverride ?? resolvedMainAgent.speed;
@@ -726,7 +702,6 @@ export class Conversation {
726
702
  tools: toolDefs.length > 0 ? toolDefs : undefined,
727
703
  toolExecutor: toolDefs.length > 0 ? toolExecutor : undefined,
728
704
  resolveTools,
729
- resolveSystemPrompt: resolveSystemPromptCallback,
730
705
  resolveConversationDir: () => {
731
706
  const conv = getConversation(this.conversationId);
732
707
  if (!conv) return null;
@@ -738,7 +713,7 @@ export class Conversation {
738
713
  });
739
714
  createContextWindowManager({
740
715
  provider,
741
- systemPrompt: () => resolveSystemPromptCallback([]).systemPrompt,
716
+ systemPrompt: () => this.buildCurrentSystemPrompt(),
742
717
  config: initialContextWindowConfig,
743
718
  toolTokenBudget: this.agentLoop.getToolTokenBudget(),
744
719
  conversationId: this.conversationId,
@@ -799,6 +774,29 @@ export class Conversation {
799
774
  this.inferenceProfileExpiresAt = state.expiresAt;
800
775
  }
801
776
 
777
+ /**
778
+ * Build the system prompt for the current conversation state. When a
779
+ * system-prompt override was supplied at construction, use it as-is;
780
+ * otherwise rebuild the full prompt (picks up workspace file changes,
781
+ * live trust/channel context, persona overrides, onboarding context).
782
+ *
783
+ * Called by the caller before invoking `agentLoop.run()` — the loop
784
+ * itself never re-resolves the prompt mid-loop (re-resolving would bust
785
+ * the provider's prefix cache).
786
+ */
787
+ buildCurrentSystemPrompt(): string {
788
+ return this.hasSystemPromptOverride
789
+ ? this.systemPrompt
790
+ : buildSystemPrompt({
791
+ hasNoClient: this.hasNoClient,
792
+ trustContext: this.currentTurnTrustContext,
793
+ channelCapabilities: this.currentTurnChannelCapabilities,
794
+ personaOverride: this.wakePersonaOverride,
795
+ onboardingContext: this.getOnboardingContext(),
796
+ conversationId: this.conversationId,
797
+ });
798
+ }
799
+
802
800
  // ── Prompt Cache Warming ─────────────────────────────────────────
803
801
 
804
802
  /**
@@ -811,16 +809,7 @@ export class Conversation {
811
809
  const abort = new AbortController();
812
810
  this.cacheWarmAbort = abort;
813
811
 
814
- const systemPrompt = this.hasSystemPromptOverride
815
- ? this.systemPrompt
816
- : buildSystemPrompt({
817
- hasNoClient: this.hasNoClient,
818
- trustContext: this.currentTurnTrustContext,
819
- channelCapabilities: this.currentTurnChannelCapabilities,
820
- personaOverride: this.wakePersonaOverride,
821
- onboardingContext: this.getOnboardingContext(),
822
- conversationId: this.conversationId,
823
- });
812
+ const systemPrompt = this.buildCurrentSystemPrompt();
824
813
  const tools = getAllToolDefinitions();
825
814
  const provider = this.provider;
826
815
 
@@ -1,5 +1,6 @@
1
1
  import { v4 as uuid } from "uuid";
2
2
 
3
+ import { peekAcpSessionManager } from "../../acp/index.js";
3
4
  import { clearAll, getConversation } from "../../memory/conversation-crud.js";
4
5
  import { resolveConversationId } from "../../memory/conversation-key-store.js";
5
6
  import { broadcastMessage } from "../../runtime/assistant-event-hub.js";
@@ -106,6 +107,12 @@ export function cancelGeneration(conversationId: string): boolean {
106
107
  // being cancelled, so enqueuing synthetic messages would trigger
107
108
  // unwanted model activity after the user pressed stop.
108
109
  getSubagentManager().abortAllForParent(conversationId);
110
+ // Cancel any in-flight ACP agent sessions this conversation spawned, for the
111
+ // same reason: a backgrounded ACP prompt would otherwise keep running (and
112
+ // holding a child process) past the stop and, on completion, enqueue a
113
+ // follow-up message back into the conversation the user just cancelled. Peek
114
+ // the singleton so a conversation that never used ACP doesn't spin one up.
115
+ peekAcpSessionManager()?.cancelForParent(conversationId);
109
116
  // The processing flag is cleared by the in-flight turn's `finally`, not here.
110
117
  // Abort propagates into the provider call and tool execution (and is backed
111
118
  // by the agent loop's abort watchdog), so the turn reaches its `finally`
@@ -39,6 +39,7 @@ import { type FSWatcher, mkdirSync, readdirSync, watch } from "node:fs";
39
39
 
40
40
  import { getRegisteredPlugin } from "../plugins/registry.js";
41
41
  import { DebouncerMap } from "../util/debounce.js";
42
+ import { attachFsWatcherErrorHandler } from "../util/fs-watcher-error.js";
42
43
  import { getLogger } from "../util/logger.js";
43
44
  import { getWorkspacePluginsDir } from "../util/platform.js";
44
45
  import { reregisterExternalPlugin } from "./external-plugins-bootstrap.js";
@@ -267,6 +268,10 @@ export class PluginSourceWatcher {
267
268
  });
268
269
  },
269
270
  );
271
+ // Recursive watches over plugin trees (incl. node_modules) can exhaust
272
+ // the inotify watch limit and emit ENOSPC asynchronously. Without an
273
+ // 'error' listener that unhandled emitter error crashes the daemon.
274
+ attachFsWatcherErrorHandler(this.watcher, log, pluginsDir);
270
275
  log.info({ pluginsDir }, "Plugin source watcher started");
271
276
  } catch (err) {
272
277
  log.warn(
@@ -57,6 +57,7 @@ import {
57
57
  loadSingleWorkspaceTool,
58
58
  } from "../tools/workspace-tools/loader.js";
59
59
  import { DebouncerMap } from "../util/debounce.js";
60
+ import { attachFsWatcherErrorHandler } from "../util/fs-watcher-error.js";
60
61
  import { getLogger } from "../util/logger.js";
61
62
  import { getWorkspaceToolsDir } from "../util/platform.js";
62
63
 
@@ -154,6 +155,9 @@ export class WorkspaceToolsWatcher {
154
155
  });
155
156
  },
156
157
  );
158
+ // Async FSWatcher errors (e.g. ENOSPC, ENXIO) arrive as an 'error' event;
159
+ // without a listener they crash the daemon. Degrade to a dead watcher.
160
+ attachFsWatcherErrorHandler(this.watcher, log, toolsDir);
157
161
  log.info({ toolsDir }, "Workspace tools watcher started");
158
162
  } catch (err) {
159
163
  log.warn(
@@ -88,6 +88,9 @@ const stubConfig: {
88
88
  maxDailyRuns: number | null;
89
89
  disposition: string;
90
90
  };
91
+ timeouts: {
92
+ backgroundTurnTimeoutSec: number;
93
+ };
91
94
  } = {
92
95
  heartbeat: {
93
96
  enabled: true,
@@ -98,6 +101,9 @@ const stubConfig: {
98
101
  maxDailyRuns: null,
99
102
  disposition: "Default disposition text.",
100
103
  },
104
+ timeouts: {
105
+ backgroundTurnTimeoutSec: 1800,
106
+ },
101
107
  };
102
108
  mock.module("../../config/loader.js", () => ({
103
109
  getConfig: () => stubConfig,
@@ -46,7 +46,6 @@ const DEFAULT_CHECKLIST = `- Check in with yourself. Read NOW.md. Is it still ac
46
46
 
47
47
  const EARLY_HEARTBEAT_THRESHOLD = 3;
48
48
  const REENGAGEMENT_COOLDOWN_MS = 18 * 60 * 60 * 1000; // 18 hours
49
- const HEARTBEAT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
50
49
 
51
50
  // Stripped-comment form of the guardian persona scaffold. Computed
52
51
  // once at module load because stripping comment lines is deterministic
@@ -774,8 +773,8 @@ export class HeartbeatService {
774
773
  //
775
774
  // The runner fires `onConversationCreated` synchronously after
776
775
  // bootstrap so the macOS sidebar gets the new conversation
777
- // immediately rather than waiting up to HEARTBEAT_TIMEOUT_MS for
778
- // the LLM turn to finish. If the model judges the run worth
776
+ // immediately rather than waiting up to the full background-turn timeout
777
+ // for the LLM turn to finish. If the model judges the run worth
779
778
  // surfacing to the guardian, it calls the `notifications` skill
780
779
  // directly — no in-band marker.
781
780
  let conversationId: string | undefined;
@@ -789,7 +788,7 @@ export class HeartbeatService {
789
788
  trustClass: "guardian",
790
789
  },
791
790
  callSite: "heartbeatAgent",
792
- timeoutMs: HEARTBEAT_TIMEOUT_MS,
791
+ timeoutMs: getConfig().timeouts.backgroundTurnTimeoutSec * 1000,
793
792
  origin: "heartbeat",
794
793
  deferNotifications: true,
795
794
  onConversationCreated: (newConversationId) => {
@@ -2,14 +2,12 @@
2
2
  * Tests for `db-maintenance.ts` (orchestration) and the underlying
3
3
  * `db-async-query.ts` abstraction.
4
4
  *
5
- * The contract this PR locks in:
6
- * 1. `runDbMaintenance` runs `VACUUM` through the async abstraction
7
- * — when the `sqlite3` CLI is available, that means a subprocess
8
- * and the daemon's main event loop keeps ticking. (The structural
9
- * anti-block assertion lives in
10
- * `db-async-query.test.ts`; here we focus on orchestration.)
11
- * 2. The subprocess actually shrinks the on-disk page count when
12
- * there's reclaimable space.
5
+ * The contract this locks in:
6
+ * 1. `runDbMaintenance` runs `PRAGMA optimize` through the async
7
+ * abstraction and then truncates the WAL on the daemon connection.
8
+ * It intentionally does NOT run a full VACUUM (which in WAL mode
9
+ * inflates the WAL to ~the DB size and needs ~2x the DB size free).
10
+ * 2. The truncating checkpoint shrinks the WAL file once it has grown.
13
11
  * 3. `maybeRunDbMaintenance` is genuinely async — callers can `await`
14
12
  * it and observe completion.
15
13
  * 4. The 24 h interval guard short-circuits a recent re-run.
@@ -17,7 +15,7 @@
17
15
  * The per-file temp workspace is set up by `test-preload.ts`; tests just
18
16
  * dynamic-import the DB modules so they resolve paths under that temp dir.
19
17
  */
20
- import { Database } from "bun:sqlite";
18
+ import { existsSync, statSync } from "node:fs";
21
19
  import { beforeEach, describe, expect, test } from "bun:test";
22
20
 
23
21
  const { getSqlite } = await import("../db-connection.js");
@@ -56,8 +54,9 @@ function insertMessage(role: "user" | "assistant", createdAt: number): void {
56
54
  .run(`msg-${createdAt}-${role}`, convId, role, "[]", createdAt);
57
55
  }
58
56
 
59
- /** Inflate the test DB with bloat that VACUUM can reclaim. */
60
- function inflateAndDelete(byteTarget: number): void {
57
+ /** Pile writes into the WAL (without checkpointing) so a truncating
58
+ * checkpoint has measurable work to do. */
59
+ function inflateWal(byteTarget: number): void {
61
60
  const sqlite = getSqlite();
62
61
  sqlite.exec(
63
62
  "CREATE TABLE IF NOT EXISTS bloat (id INTEGER PRIMARY KEY, payload BLOB)",
@@ -72,11 +71,10 @@ function inflateAndDelete(byteTarget: number): void {
72
71
  for (let i = 0; i < rowsTarget; i++) {
73
72
  insert.run(payload);
74
73
  }
74
+ // Commit, but do not checkpoint — the frames stay in the WAL, leaving it at
75
+ // its high-water mark (a PASSIVE auto-checkpoint resets the WAL for reuse but
76
+ // never shrinks the file). Maintenance is what should truncate it.
75
77
  sqlite.exec("COMMIT");
76
- sqlite.exec("DELETE FROM bloat");
77
- sqlite.exec("DROP TABLE bloat");
78
- // Force the WAL onto the main DB file so the bloat is visible on disk.
79
- sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)");
80
78
  }
81
79
 
82
80
  describe("maybeRunDbMaintenance", () => {
@@ -106,36 +104,30 @@ describe("maybeRunDbMaintenance", () => {
106
104
  expect(getMemoryCheckpoint(MAINTENANCE_CHECKPOINT_KEY)).toBe(String(now));
107
105
  });
108
106
 
109
- test("VACUUM reclaims pages on a bloated DB", async () => {
107
+ test("truncates a bloated WAL", async () => {
110
108
  const sqlite = getSqlite();
111
109
  sqlite.exec("DROP TABLE IF EXISTS bloat");
112
110
  sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)");
113
111
 
114
- inflateAndDelete(8 * 1024 * 1024);
115
-
116
- const dbPath = getDbPath();
117
- // Read page_count from a fresh connection so we observe post-write
118
- // ground truth without snapshot caching on the main test connection.
119
- const readPageCount = (): number => {
120
- const probe = new Database(dbPath, { readonly: true });
121
- try {
122
- return (
123
- probe.query("PRAGMA page_count").get() as { page_count: number }
124
- ).page_count;
125
- } finally {
126
- probe.close();
127
- }
128
- };
129
- const pagesBefore = readPageCount();
112
+ const walPath = `${getDbPath()}-wal`;
113
+ const walSize = (): number =>
114
+ existsSync(walPath) ? statSync(walPath).size : 0;
115
+
116
+ inflateWal(4 * 1024 * 1024);
117
+ // Sanity: the writes really did grow the WAL on disk.
118
+ expect(walSize()).toBeGreaterThan(1024 * 1024);
130
119
 
131
120
  await maybeRunDbMaintenance();
132
121
 
133
- const pagesAfter = readPageCount();
134
- expect(pagesAfter).toBeLessThan(pagesBefore);
122
+ // Maintenance truncates the WAL back down (no VACUUM, so the data is folded
123
+ // into the main file rather than rebuilt).
124
+ expect(walSize()).toBeLessThan(64 * 1024);
125
+
126
+ sqlite.exec("DROP TABLE IF EXISTS bloat");
135
127
  }, 60_000);
136
128
 
137
129
  test("defers maintenance while the last user message is within the quiet period", async () => {
138
- /** VACUUM must not fire while the user is active, so a recent user
130
+ /** Maintenance must not fire while the user is active, so a recent user
139
131
  * message keeps maintenance deferred. */
140
132
  // GIVEN the user sent a message one minute ago (well within the quiet period)
141
133
  const now = Date.now();
@@ -37,6 +37,7 @@ import { publishSyncInvalidation } from "../runtime/sync/sync-publisher.js";
37
37
  import { UserError } from "../util/errors.js";
38
38
  import { safeParseRecord } from "../util/json.js";
39
39
  import { getLogger } from "../util/logger.js";
40
+ import { getLogsDbPath } from "../util/logs-db-path.js";
40
41
  import { getConversationsDir } from "../util/platform.js";
41
42
  import { createRowMapper } from "../util/row-mapper.js";
42
43
  import {
@@ -2140,8 +2141,11 @@ export async function clearAll(): Promise<{
2140
2141
  // Each DELETE goes through `runAsyncSqlite`. The original code threw
2141
2142
  // on rawExec failure; mirror that here by throwing when the async
2142
2143
  // result reports `ok: false`, so the route handler still returns 500.
2143
- const runOrThrow = async (sql: string): Promise<void> => {
2144
- const result = await runAsyncSqlite(sql);
2144
+ const runOrThrow = async (
2145
+ sql: string,
2146
+ options?: { dbPath?: string },
2147
+ ): Promise<void> => {
2148
+ const result = await runAsyncSqlite(sql, options);
2145
2149
  if (!result.ok) {
2146
2150
  throw new Error(
2147
2151
  `clearAll: \`${sql}\` failed (${result.backend}): ${result.error ?? "unknown"}`,
@@ -2163,7 +2167,9 @@ export async function clearAll(): Promise<{
2163
2167
  await runOrThrow("DELETE FROM memory_embeddings");
2164
2168
  await runOrThrow("DELETE FROM memory_jobs");
2165
2169
  await runOrThrow("DELETE FROM memory_checkpoints");
2166
- await runOrThrow("DELETE FROM llm_request_logs");
2170
+ // llm_request_logs lives in the attached logs database; point the sqlite3
2171
+ // subprocess at that file (the in-process fallback resolves it via ATTACH).
2172
+ await runOrThrow("DELETE FROM llm_request_logs", { dbPath: getLogsDbPath() });
2167
2173
  await runOrThrow("DELETE FROM llm_usage_events");
2168
2174
  await runOrThrow("DELETE FROM message_attachments");
2169
2175
  await runOrThrow("DELETE FROM attachments");