@vellumai/assistant 0.10.0-dev.202606200539.c052d10 → 0.10.0-dev.202606201539.0cebd50

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 (61) 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__/db-migration-rollback.test.ts +69 -1
  13. package/src/__tests__/heartbeat-disk-pressure.test.ts +3 -0
  14. package/src/__tests__/heartbeat-service.test.ts +6 -0
  15. package/src/__tests__/list-messages-attachments.test.ts +41 -0
  16. package/src/__tests__/plugin-source-watcher.test.ts +33 -1
  17. package/src/__tests__/usage-cache-backfill-migration.test.ts +17 -2
  18. package/src/acp/__tests__/session-manager.test.ts +72 -1
  19. package/src/acp/index.ts +10 -0
  20. package/src/acp/session-manager.ts +35 -0
  21. package/src/agent/loop.ts +28 -22
  22. package/src/cli/commands/db/status.ts +35 -1
  23. package/src/config/schemas/memory-lifecycle.ts +5 -3
  24. package/src/config/schemas/timeouts.ts +24 -0
  25. package/src/daemon/app-source-watcher.ts +31 -18
  26. package/src/daemon/conversation-agent-loop.ts +8 -5
  27. package/src/daemon/conversation.ts +30 -41
  28. package/src/daemon/handlers/conversations.ts +7 -0
  29. package/src/daemon/plugin-source-watcher.ts +5 -0
  30. package/src/daemon/workspace-tools-watcher.ts +4 -0
  31. package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
  32. package/src/heartbeat/heartbeat-service.ts +3 -4
  33. package/src/memory/__tests__/db-maintenance.test.ts +27 -35
  34. package/src/memory/__tests__/db-memory-attach.test.ts +126 -0
  35. package/src/memory/conversation-crud.ts +9 -3
  36. package/src/memory/db-connection.ts +49 -0
  37. package/src/memory/db-init.ts +50 -14
  38. package/src/memory/db-maintenance.ts +43 -38
  39. package/src/memory/job-handlers/cleanup.ts +6 -0
  40. package/src/memory/migrations/{100-core-tables.ts → 000-core-tables.ts} +6 -10
  41. package/src/memory/migrations/103-complex-migrations.ts +13 -12
  42. package/src/memory/migrations/104-core-indexes.ts +1 -1
  43. package/src/memory/migrations/113-late-migrations.ts +16 -14
  44. package/src/memory/migrations/297-move-llm-request-logs-to-logs-db.ts +130 -0
  45. package/src/memory/migrations/__tests__/297-move-llm-request-logs.test.ts +159 -0
  46. package/src/memory/migrations/__tests__/run-migrations.test.ts +147 -5
  47. package/src/memory/migrations/index.ts +5 -4
  48. package/src/memory/migrations/run-migrations.ts +162 -12
  49. package/src/memory/migrations/validate-migration-state.ts +14 -56
  50. package/src/plugin-api/index.ts +7 -0
  51. package/src/plugin-api/vision-support.ts +75 -0
  52. package/src/prompts/system-prompt.ts +1 -1
  53. package/src/runtime/__tests__/agent-wake.test.ts +6 -4
  54. package/src/runtime/agent-wake.ts +15 -7
  55. package/src/runtime/routes/conversation-routes.ts +24 -3
  56. package/src/runtime/routes/migration-routes.ts +35 -39
  57. package/src/schedule/scheduler.ts +5 -9
  58. package/src/tools/ask-question/ask-question-tool.test.ts +60 -52
  59. package/src/tools/ask-question/ask-question-tool.ts +14 -73
  60. package/src/util/fs-watcher-error.ts +36 -0
  61. package/src/util/memory-db-path.ts +23 -0
@@ -1,41 +1,191 @@
1
1
  import { getLogger } from "../../util/logger.js";
2
- import type { DrizzleDb } from "../db-connection.js";
2
+ import { getDbPath } from "../../util/platform.js";
3
+ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
3
4
 
4
5
  const log = getLogger("db-init");
5
6
 
6
- /** A single forward migration step, identified for logging by its `.name`. */
7
+ /**
8
+ * A single forward migration step, identified for checkpointing and logging by
9
+ * its `.name`. Anonymous steps (empty `.name`) cannot be tracked and always run.
10
+ */
7
11
  export type MigrationStep = (database: DrizzleDb) => void;
8
12
 
9
13
  export interface MigrationRunResult {
10
14
  /** Steps whose body threw. */
11
15
  failed: string[];
16
+ /** Steps skipped because a prior run already applied them. */
17
+ skipped: string[];
18
+ }
19
+
20
+ /**
21
+ * Prefix under which forward-step completions are recorded in the shared
22
+ * `memory_checkpoints` ledger. A distinct namespace keeps step bookkeeping from
23
+ * colliding with registry checkpoint keys (`migration_*`, `backfill_*`,
24
+ * `drop_*`) and is deliberately chosen so `validateMigrationState` does not
25
+ * mistake a step record for an unknown registry migration.
26
+ */
27
+ const STEP_CHECKPOINT_PREFIX = "step:";
28
+
29
+ /**
30
+ * Create the migration bookkeeping table if it is missing.
31
+ *
32
+ * The runner records its own checkpoints, so it must not depend on an earlier
33
+ * step having created the ledger. This is the single place `memory_checkpoints`
34
+ * is created; `IF NOT EXISTS` makes it a no-op on an already-migrated database.
35
+ */
36
+ function ensureCheckpointsTable(raw: ReturnType<typeof getSqliteFrom>): void {
37
+ raw.run(/*sql*/ `
38
+ CREATE TABLE IF NOT EXISTS memory_checkpoints (
39
+ key TEXT PRIMARY KEY,
40
+ value TEXT NOT NULL,
41
+ updated_at INTEGER NOT NULL
42
+ )
43
+ `);
12
44
  }
13
45
 
14
46
  /**
15
- * Run the ordered list of forward migration steps against the database.
47
+ * Recover from crashed migrations before the migration runner executes its steps.
48
+ *
49
+ * Scans memory_checkpoints for entries with value 'started' or 'rolling_back' —
50
+ * these represent migrations that began but never completed (e.g., due to a
51
+ * process crash). Deletes the stalled checkpoint so the migration can re-run
52
+ * from scratch on this startup. Each migration's own idempotency guards (DDL
53
+ * IF NOT EXISTS, transactional rollback) ensure re-running is safe.
54
+ *
55
+ * Runs on every boot — it must observe the state left by *this* boot's prior
56
+ * crash — so it is invoked directly by {@link runMigrationSteps} before the
57
+ * checkpointed step loop rather than being a checkpointed step itself.
58
+ */
59
+ export function recoverCrashedMigrations(database: DrizzleDb): string[] {
60
+ const raw = getSqliteFrom(database);
61
+
62
+ let rows: Array<{ key: string; value: string }>;
63
+ try {
64
+ rows = raw
65
+ .query(`SELECT key, value FROM memory_checkpoints`)
66
+ .all() as Array<{ key: string; value: string }>;
67
+ } catch {
68
+ return [];
69
+ }
70
+
71
+ const crashed = rows
72
+ .filter((r) => r.value === "started" || r.value === "rolling_back")
73
+ .map((r) => r.key);
74
+ if (crashed.length === 0) return [];
75
+
76
+ log.error(
77
+ { crashed },
78
+ [
79
+ "╔══════════════════════════════════════════════════════════════╗",
80
+ "║ CRASHED MIGRATIONS DETECTED — AUTO-RECOVERING ║",
81
+ "╚══════════════════════════════════════════════════════════════╝",
82
+ "",
83
+ `The following migrations started but never completed: ${crashed.join(", ")}`,
84
+ "",
85
+ "Clearing stalled checkpoints so they can be retried on this startup.",
86
+ "If retries continue to fail, manually inspect the database:",
87
+ ` sqlite3 ${getDbPath()} "SELECT * FROM memory_checkpoints"`,
88
+ ].join("\n"),
89
+ );
90
+
91
+ for (const key of crashed) {
92
+ raw.query(`DELETE FROM memory_checkpoints WHERE key = ?`).run(key);
93
+ log.info(
94
+ { key },
95
+ `Cleared stalled checkpoint "${key}" — migration will re-run`,
96
+ );
97
+ }
98
+
99
+ return crashed;
100
+ }
101
+
102
+ /**
103
+ * Run the ordered list of forward migration steps against the database, each at
104
+ * most once across boots.
105
+ *
106
+ * A step's name is recorded in `memory_checkpoints` after its body completes; on
107
+ * later boots an applied step is skipped instead of re-executed. This turns the
108
+ * unconditional ~200-step re-probe — which floors daemon startup at tens of
109
+ * seconds on a fully-migrated database — into a single bookkeeping read.
110
+ *
111
+ * Step bookkeeping shares the same `memory_checkpoints` ledger that the
112
+ * registry's `withCrashRecovery` uses, under the {@link STEP_CHECKPOINT_PREFIX}
113
+ * namespace, so applied-state for every migration — DDL guard or registry-backed
114
+ * — lives in one place.
115
+ *
116
+ * Before the step loop runs, the ledger is created if missing and
117
+ * {@link recoverCrashedMigrations} clears any stalled checkpoints left by a
118
+ * prior crash, so a migration interrupted mid-flight re-runs this boot.
16
119
  *
17
120
  * Individual step failures are caught and logged so one broken migration does
18
- * not prevent independent later ones from succeeding.
121
+ * not prevent independent later ones from succeeding; a failed step is not
122
+ * checkpointed and is retried on the next boot.
19
123
  */
20
124
  export function runMigrationSteps(
21
125
  database: DrizzleDb,
22
126
  steps: MigrationStep[],
23
127
  ): MigrationRunResult {
128
+ const raw = getSqliteFrom(database);
129
+
130
+ ensureCheckpointsTable(raw);
131
+ recoverCrashedMigrations(database);
132
+
133
+ const applied = new Set(
134
+ (
135
+ raw
136
+ .query(
137
+ `SELECT key FROM memory_checkpoints WHERE key LIKE '${STEP_CHECKPOINT_PREFIX}%'`,
138
+ )
139
+ .all() as Array<{ key: string }>
140
+ ).map((row) => row.key.slice(STEP_CHECKPOINT_PREFIX.length)),
141
+ );
142
+ const markApplied = raw.query(
143
+ `INSERT OR REPLACE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
144
+ );
145
+
24
146
  const failed: string[] = [];
147
+ const skipped: string[] = [];
25
148
 
26
149
  for (const step of steps) {
150
+ const name = step.name;
151
+ const checkpointable = name !== "";
152
+
153
+ if (checkpointable && applied.has(name)) {
154
+ skipped.push(name);
155
+ log.debug({ migration: name }, `Skipping applied migration: ${name}`);
156
+ continue;
157
+ }
158
+
27
159
  try {
28
- log.debug({ migration: step.name }, `Starting migration: ${step.name}`);
160
+ log.debug({ migration: name }, `Starting migration: ${name}`);
29
161
  step(database);
30
- log.debug({ migration: step.name }, `Migration succeeded: ${step.name}`);
162
+ log.debug({ migration: name }, `Migration succeeded: ${name}`);
163
+ if (checkpointable) {
164
+ markApplied.run(`${STEP_CHECKPOINT_PREFIX}${name}`, Date.now());
165
+ }
31
166
  } catch (err) {
32
- failed.push(step.name);
33
- log.error(
34
- { err, migration: step.name },
35
- `Migration failed: ${step.name}`,
36
- );
167
+ failed.push(name);
168
+ log.error({ err, migration: name }, `Migration failed: ${name}`);
37
169
  }
38
170
  }
39
171
 
40
- return { failed };
172
+ return { failed, skipped };
173
+ }
174
+
175
+ /**
176
+ * Discard every forward-step checkpoint so the next {@link runMigrationSteps}
177
+ * call re-runs and re-records all steps.
178
+ *
179
+ * Migration rollback calls this. A rolled-back step whose body is registry-backed
180
+ * (guarded by `withCrashRecovery`) clears its own `memory_checkpoints` registry
181
+ * entry when its `down()` runs; the `step:` checkpoint recorded here must be
182
+ * discarded in the same operation, otherwise the runner skips the step on the
183
+ * next upgrade and the rolled-back schema is never restored. Only the `step:`
184
+ * namespace is cleared, leaving registry checkpoints and other ledger entries
185
+ * untouched.
186
+ */
187
+ export function clearMigrationStepCheckpoints(database: DrizzleDb): void {
188
+ getSqliteFrom(database).run(
189
+ `DELETE FROM memory_checkpoints WHERE key LIKE '${STEP_CHECKPOINT_PREFIX}%'`,
190
+ );
41
191
  }
@@ -6,64 +6,10 @@ import {
6
6
  MIGRATION_REGISTRY,
7
7
  type MigrationValidationResult,
8
8
  } from "./registry.js";
9
+ import { clearMigrationStepCheckpoints } from "./run-migrations.js";
9
10
 
10
11
  const log = getLogger("memory-db");
11
12
 
12
- /**
13
- * Recover from crashed migrations before the migration runner executes.
14
- *
15
- * Scans memory_checkpoints for entries with value 'started' — these represent
16
- * migrations that began but never completed (e.g., due to a process crash).
17
- * Deletes the stalled checkpoint so the migration can re-run from scratch on
18
- * this startup. Each migration's own idempotency guards (DDL IF NOT EXISTS,
19
- * transactional rollback) ensure re-running is safe.
20
- *
21
- * Call this BEFORE running migrations so that stalled checkpoints don't block
22
- * re-execution.
23
- */
24
- export function recoverCrashedMigrations(database: DrizzleDb): string[] {
25
- const raw = getSqliteFrom(database);
26
-
27
- let rows: Array<{ key: string; value: string }>;
28
- try {
29
- rows = raw
30
- .query(`SELECT key, value FROM memory_checkpoints`)
31
- .all() as Array<{ key: string; value: string }>;
32
- } catch {
33
- return [];
34
- }
35
-
36
- const crashed = rows
37
- .filter((r) => r.value === "started" || r.value === "rolling_back")
38
- .map((r) => r.key);
39
- if (crashed.length === 0) return [];
40
-
41
- log.error(
42
- { crashed },
43
- [
44
- "╔══════════════════════════════════════════════════════════════╗",
45
- "║ CRASHED MIGRATIONS DETECTED — AUTO-RECOVERING ║",
46
- "╚══════════════════════════════════════════════════════════════╝",
47
- "",
48
- `The following migrations started but never completed: ${crashed.join(", ")}`,
49
- "",
50
- "Clearing stalled checkpoints so they can be retried on this startup.",
51
- "If retries continue to fail, manually inspect the database:",
52
- ` sqlite3 ${getDbPath()} "SELECT * FROM memory_checkpoints"`,
53
- ].join("\n"),
54
- );
55
-
56
- for (const key of crashed) {
57
- raw.query(`DELETE FROM memory_checkpoints WHERE key = ?`).run(key);
58
- log.info(
59
- { key },
60
- `Cleared stalled checkpoint "${key}" — migration will re-run`,
61
- );
62
- }
63
-
64
- return crashed;
65
- }
66
-
67
13
  /**
68
14
  * Wrap a migration function with crash-recovery bookkeeping.
69
15
  *
@@ -260,7 +206,9 @@ export function validateMigrationState(
260
206
  * **Checkpoint state**: Each rolled-back migration's checkpoint is deleted
261
207
  * from `memory_checkpoints`. If the process crashes mid-rollback, the
262
208
  * `"rolling_back"` marker is detected and cleared by
263
- * `recoverCrashedMigrations` on the next startup.
209
+ * `recoverCrashedMigrations` on the next startup. The forward-step checkpoints
210
+ * recorded by the migration runner (the `step:` namespace) are also discarded
211
+ * so a later upgrade re-applies (and re-validates) every step.
264
212
  *
265
213
  * **Warning — data loss**: Some down() migrations may not fully restore the
266
214
  * original state (e.g., DROP TABLE migrations recreate the table but cannot
@@ -303,6 +251,16 @@ export function rollbackMemoryMigration(
303
251
  (entry) => entry.version > targetVersion && completedKeys.has(entry.key),
304
252
  ).sort((a, b) => b.version - a.version); // reverse version order
305
253
 
254
+ // Forward-step checkpoints recorded by the migration runner gate whether each
255
+ // step re-runs on the next upgrade. A rolled-back registry-backed step clears
256
+ // its own memory_checkpoints entry via down(), so its step checkpoint must be
257
+ // discarded too — otherwise the runner skips the step on re-upgrade and the
258
+ // rolled-back schema is never restored. Clearing before the loop keeps this
259
+ // correct even if the process crashes partway through the rollback.
260
+ if (toRollback.length > 0) {
261
+ clearMigrationStepCheckpoints(database);
262
+ }
263
+
306
264
  const rolledBack: string[] = [];
307
265
 
308
266
  for (const entry of toRollback) {
@@ -132,6 +132,13 @@ export type {
132
132
  export { assistantEventHub } from "../runtime/assistant-event-hub.js";
133
133
  export { getSecureKeyAsync } from "../security/secure-keys.js";
134
134
  export { getModelProfiles } from "./model-profiles.js";
135
+ // Check whether a profile's resolved model can process image input. Resolves
136
+ // the effective (provider, model) by merging over the workspace default and
137
+ // inferring the provider for model-only profiles, then looks up the model
138
+ // catalog's `supportsVision` flag. Handles mix profiles (true if any arm
139
+ // supports vision). Fail-open for unknown models. Pair with
140
+ // `getModelProfiles()` to inspect the active or candidate profiles.
141
+ export { doesSupportVision } from "./vision-support.js";
135
142
  // Resolve a provider for a call site (optionally overriding the profile) so a
136
143
  // plugin can run inference through the workspace's configured profiles and
137
144
  // credentials — managed-proxy or BYOK — without supplying its own API key.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Vision-support resolution for plugin consumption.
3
+ *
4
+ * A plugin that gates image processing on vision capability (e.g. an
5
+ * image-to-text fallback for text-only models) calls {@link doesSupportVision}
6
+ * instead of hardcoding model names. The function resolves the effective
7
+ * (provider, model) for a profile — merging with `llm.default` to fill gaps,
8
+ * inferring the provider for model-only profiles via the catalog — and then
9
+ * looks up `supportsVision` in the model catalog.
10
+ */
11
+
12
+ import { getConfig } from "../config/loader.js";
13
+ import {
14
+ getCatalogProviderForModel,
15
+ PROVIDER_CATALOG,
16
+ } from "../providers/model-catalog.js";
17
+ import type { ModelProfileInfo } from "./types.js";
18
+
19
+ /**
20
+ * Whether a profile's resolved model can process image input.
21
+ *
22
+ * Resolution mirrors the host's call-site resolver:
23
+ * - The profile's `(provider, model)` fields are merged over `llm.default` so
24
+ * a profile that only sets `model` (or only `provider`) inherits the other
25
+ * from the workspace default.
26
+ * - When `provider` is still missing but `model` is a known catalog model,
27
+ * the provider is inferred via `getCatalogProviderForModel` (same logic as
28
+ * the resolver's `withImpliedProviderForKnownModel`).
29
+ * - For a mix profile, returns `true` if any constituent arm supports vision
30
+ * (the mix can route to it) and `false` only if every arm is text-only.
31
+ * - Unknown `(provider, model)` pairs default to `true` (fail-open), matching
32
+ * the config GET route's `enrichProfilesWithVisionFlag`.
33
+ */
34
+ export function doesSupportVision(profile: ModelProfileInfo): boolean {
35
+ const { llm } = getConfig();
36
+ const entry = llm.profiles[profile.key];
37
+ if (entry == null) return true;
38
+
39
+ // Mix: fail-open if any arm supports vision.
40
+ if (entry.mix != null) {
41
+ return entry.mix.some((arm) => {
42
+ const armEntry = llm.profiles[arm.profile];
43
+ if (armEntry == null) return true;
44
+ return resolveEntrySupportsVision(armEntry, llm);
45
+ });
46
+ }
47
+
48
+ return resolveEntrySupportsVision(entry, llm);
49
+ }
50
+
51
+ /**
52
+ * Resolve whether a concrete (non-mix) profile entry supports vision by
53
+ * merging its fields over `llm.default` and inferring the provider when
54
+ * only the model is set.
55
+ */
56
+ function resolveEntrySupportsVision(
57
+ entry: { provider?: string; model?: string },
58
+ llm: { default?: { provider?: string; model?: string } },
59
+ ): boolean {
60
+ const provider = entry.provider ?? llm.default?.provider;
61
+ const model = entry.model ?? llm.default?.model;
62
+
63
+ // Infer provider from model when missing (mirrors the resolver's
64
+ // withImpliedProviderForKnownModel).
65
+ const effectiveProvider =
66
+ provider ?? (typeof model === "string" ? getCatalogProviderForModel(model) : undefined);
67
+
68
+ if (typeof effectiveProvider !== "string" || typeof model !== "string") {
69
+ return true; // fail-open
70
+ }
71
+
72
+ const catalogProvider = PROVIDER_CATALOG.find((p) => p.id === effectiveProvider);
73
+ const catalogModel = catalogProvider?.models.find((m) => m.id === model);
74
+ return catalogModel?.supportsVision ?? true;
75
+ }
@@ -274,7 +274,7 @@ export function maybeReseedBootstrap(templateFileName: string): boolean {
274
274
  * Marking happens here — at the single point where the bootstrap selection is
275
275
  * known — so it lands BEFORE the agent loop resolves tools and BEFORE the model
276
276
  * can call the emit tool on the first activation-rail turn. (`resolveTools`
277
- * runs before `resolveSystemPrompt` in the loop, so the system-prompt build is
277
+ * runs before the system-prompt build in the loop, so the system-prompt build is
278
278
  * too late to be the *only* marking site.) The marker write is best-effort and
279
279
  * idempotent (`markActivationSession` swallows errors and dedups on the PK), so
280
280
  * calling this from both `setOnboardingContext` and `buildSystemPrompt` is safe.
@@ -52,8 +52,8 @@ interface WakeConversationProbe {
52
52
  allowedTools?: string[];
53
53
  /**
54
54
  * `conversation.wakePersonaOverride` as observed at run start — the
55
- * field the conversation's resolveSystemPrompt callback reads when
56
- * building the wake's system prompt.
55
+ * field `buildCurrentSystemPrompt` reads when building the wake's
56
+ * system prompt before `agentLoop.run()`.
57
57
  */
58
58
  personaOverride?: unknown;
59
59
  order: number;
@@ -503,6 +503,8 @@ function makeWakeConversation(options: {
503
503
  getTurnChannelContext: () => null,
504
504
  getTurnInterfaceContext: () => null,
505
505
  trustContext: undefined,
506
+ buildCurrentSystemPrompt: () => "mock-system-prompt",
507
+ modelOverride: undefined,
506
508
  ...(drainQueue ? { drainQueue } : {}),
507
509
  };
508
510
 
@@ -693,8 +695,8 @@ describe("wakeAgentForOpportunity", () => {
693
695
  );
694
696
 
695
697
  expect(result.invoked).toBe(true);
696
- // The override was live on the conversation when the loop ran — the
697
- // resolveSystemPrompt callback reads this field at prompt-build time.
698
+ // The override was live on the conversation when the loop ran —
699
+ // `buildCurrentSystemPrompt` reads this field before `agentLoop.run()`.
698
700
  expect(conversation.runCalls[0]!.personaOverride).toEqual(override);
699
701
  // Applied exactly once and cleared before the wake released the
700
702
  // conversation, so a queued user turn can't build under it.
@@ -597,13 +597,14 @@ export async function wakeAgentForOpportunity(
597
597
  });
598
598
 
599
599
  // Apply the caller's persona override for the duration of the run. The
600
- // wake's agent loop builds the system prompt through the conversation's
601
- // resolveSystemPrompt callback, which reads this field; cleared (below,
602
- // before drainQueue) so a queued user turn never builds its prompt under
603
- // the wake's override. Assigned only AFTER the profile/config reads above
604
- // — those can throw, and they run before the try/finally that clears the
605
- // override, so an earlier assignment would strand the override on the
606
- // cached Conversation and corrupt every later prompt build on it.
600
+ // prompt is built once before `agentLoop.run()` (via
601
+ // `conversation.buildCurrentSystemPrompt()`), which reads this field;
602
+ // cleared (below, before drainQueue) so a queued user turn never builds
603
+ // its prompt under the wake's override. Assigned only AFTER the
604
+ // profile/config reads above — those can throw, and they run before the
605
+ // try/finally that clears the override, so an earlier assignment would
606
+ // strand the override on the cached Conversation and corrupt every later
607
+ // prompt build on it.
607
608
  if (opts.personaOverride) {
608
609
  conversation.wakePersonaOverride = opts.personaOverride;
609
610
  }
@@ -1146,6 +1147,13 @@ export async function wakeAgentForOpportunity(
1146
1147
  maxInputTokens: effectiveContextWindow.maxInputTokens,
1147
1148
  overflowRecovery: { enabled: false, safetyMarginRatio: 0 },
1148
1149
  }),
1150
+ // Resolve the system prompt once before the run — the loop does
1151
+ // not re-resolve mid-loop. `wakePersonaOverride` (if set above)
1152
+ // is baked into the prompt at this point.
1153
+ systemPrompt: conversation.buildCurrentSystemPrompt(),
1154
+ ...(conversation.modelOverride
1155
+ ? { model: conversation.modelOverride }
1156
+ : {}),
1149
1157
  }));
1150
1158
  } catch (err) {
1151
1159
  // An over-window throw on a compaction-suppressed wake is the
@@ -10,7 +10,10 @@ import {
10
10
  createAssistantMessage,
11
11
  createUserMessage,
12
12
  } from "../../agent/message-types.js";
13
- import { ConversationMessageSchema } from "../../api/responses/conversation-message.js";
13
+ import {
14
+ type ConversationContentBlock,
15
+ ConversationMessageSchema,
16
+ } from "../../api/responses/conversation-message.js";
14
17
  import {
15
18
  CHANNEL_IDS,
16
19
  INTERFACE_IDS,
@@ -943,7 +946,25 @@ export function handleListMessages({
943
946
  .filter((block) => block.type !== "text" || block.text.length > 0);
944
947
  }
945
948
 
946
- const alignedContentOrder = aligned.rewriteContentOrder(contentOrder);
949
+ // Ensure every hydrated attachment has a corresponding content block.
950
+ // renderHistoryContent inlines attachment blocks only when it has
951
+ // file-block refs with matching DB rows; directives (assistant-authored
952
+ // <vellum-attachment/> tags) don't leave a file block after stripping,
953
+ // so their attachments end up in the flat `attachments` array but not in
954
+ // `contentBlocks`. Append any that are missing so the canonical
955
+ // projection is complete.
956
+ const existingAttachmentIds = new Set(
957
+ contentBlocks
958
+ .filter((b): b is Extract<ConversationContentBlock, { type: "attachment" }> => b.type === "attachment")
959
+ .map((b) => b.attachment.id),
960
+ );
961
+ for (const att of msgAttachments) {
962
+ if (!existingAttachmentIds.has(att.id)) {
963
+ contentBlocks.push({ type: "attachment", attachment: att });
964
+ }
965
+ }
966
+
967
+ const alignedContentOrder = aligned.rewriteContentOrder(contentOrder);
947
968
 
948
969
  // Use sentAt (actual event time) for the display timestamp when available,
949
970
  // falling back to createdAt (persistence time). Clients use this display
@@ -971,7 +992,7 @@ export function handleListMessages({
971
992
  ...(alignedContentOrder.length > 0
972
993
  ? { contentOrder: alignedContentOrder }
973
994
  : {}),
974
- ...(contentBlocks.length > 0 ? { contentBlocks } : {}),
995
+ contentBlocks,
975
996
  ...(m.subagentNotification
976
997
  ? { subagentNotification: m.subagentNotification }
977
998
  : {}),
@@ -38,6 +38,7 @@ import {
38
38
  upsertCredentialMetadata,
39
39
  } from "../../tools/credentials/metadata-store.js";
40
40
  import { getLogger } from "../../util/logger.js";
41
+ import { getLogsDbPath } from "../../util/logs-db-path.js";
41
42
  import { getWorkspaceDir, getWorkspaceHooksDir } from "../../util/platform.js";
42
43
  import { APP_VERSION } from "../../version.js";
43
44
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js";
@@ -151,6 +152,38 @@ export async function reconcileVellumMetadataFromCes(warningSink: {
151
152
 
152
153
  const log = getLogger("migration-routes");
153
154
 
155
+ /**
156
+ * Flush both database files' WALs before an export copies them. The bundle
157
+ * walker includes `*.db` but skips `*.db-wal`, so committed frames still sitting
158
+ * in a WAL would be missing from the bundle.
159
+ *
160
+ * Dispatched through `runAsyncSqlite` so the flush runs in a sqlite3 subprocess
161
+ * where available — a multi-GB WAL flush would otherwise stall the event loop.
162
+ * The in-process fallback runs on the daemon connection, where one unqualified
163
+ * checkpoint already covers every attached database; the subprocess opens a
164
+ * single file, so we issue a second checkpoint targeting the logs file. FULL
165
+ * (not TRUNCATE) writes committed frames back to the main file, which is all the
166
+ * copy needs.
167
+ */
168
+ async function checkpointDbsForExport(): Promise<void> {
169
+ const targets: Array<{ label: string; dbPath?: string }> = [
170
+ { label: "main" },
171
+ { label: "logs", dbPath: getLogsDbPath() },
172
+ ];
173
+ for (const { label, dbPath } of targets) {
174
+ const result = await runAsyncSqlite(
175
+ "PRAGMA wal_checkpoint(FULL)",
176
+ dbPath ? { dbPath } : undefined,
177
+ );
178
+ if (!result.ok) {
179
+ log.warn(
180
+ { error: result.error, backend: result.backend, db: label },
181
+ "WAL checkpoint failed — exporting without checkpoint",
182
+ );
183
+ }
184
+ }
185
+ }
186
+
154
187
  /**
155
188
  * Fields the export pipeline must populate on the v1 manifest.
156
189
  *
@@ -353,28 +386,7 @@ export async function handleMigrationExport(
353
386
  ...manifestInputs,
354
387
  secretsRedacted,
355
388
  credentials,
356
- checkpoint: async () => {
357
- // Dispatch through `runAsyncSqlite` so the WAL checkpoint runs
358
- // in a sqlite3 subprocess on hosts where it's available. A WAL
359
- // flush on a multi-GB WAL file can otherwise stall the daemon's
360
- // event loop for the full duration of the flush.
361
- //
362
- // FULL (not TRUNCATE): FULL blocks until every committed frame is
363
- // written back to the main `.db` file, which is all the export
364
- // needs since it copies that file. TRUNCATE additionally restarts
365
- // the WAL and truncates it to zero bytes — extra work and blocking
366
- // this path does not need.
367
- const result = await runAsyncSqlite("PRAGMA wal_checkpoint(FULL)");
368
- if (!result.ok) {
369
- // Best-effort: if the DB can't be checkpointed (e.g. not a valid
370
- // SQLite file, missing WAL, etc.) we still proceed with the export
371
- // using whatever is on disk.
372
- log.warn(
373
- { error: result.error, backend: result.backend },
374
- "WAL checkpoint failed — exporting without checkpoint",
375
- );
376
- }
377
- },
389
+ checkpoint: checkpointDbsForExport,
378
390
  });
379
391
 
380
392
  cleanup = result.cleanup;
@@ -582,23 +594,7 @@ export async function handleMigrationExportToGcs({ body }: RouteHandlerArgs) {
582
594
  ...manifestInputs,
583
595
  secretsRedacted,
584
596
  credentials: collected.credentials,
585
- checkpoint: async () => {
586
- // Dispatch through `runAsyncSqlite` so the WAL checkpoint runs
587
- // in a sqlite3 subprocess on hosts where it's available. A
588
- // WAL flush on a multi-GB WAL file can otherwise stall the
589
- // daemon's event loop for the full duration of the flush.
590
- //
591
- // FULL (not TRUNCATE): see the disk-export checkpoint above
592
- // for rationale. assistant/AGENTS.md "SQLite WAL
593
- // checkpointing".
594
- const result = await runAsyncSqlite("PRAGMA wal_checkpoint(FULL)");
595
- if (!result.ok) {
596
- log.warn(
597
- { error: result.error, backend: result.backend },
598
- "WAL checkpoint failed — exporting without checkpoint",
599
- );
600
- }
601
- },
597
+ checkpoint: checkpointDbsForExport,
602
598
  });
603
599
 
604
600
  cleanup = result.cleanup;
@@ -1,3 +1,4 @@
1
+ import { getConfig } from "../config/loader.js";
1
2
  import {
2
3
  checkDiskPressureBackgroundGate,
3
4
  diskPressureBackgroundSkipLogFields,
@@ -85,14 +86,6 @@ const TICK_INTERVAL_MS = 15_000;
85
86
  */
86
87
  const WAKE_MAX_RETRIES = 20;
87
88
 
88
- /**
89
- * Hard timeout for `talk`-mode scheduled jobs. Schedules can do
90
- * non-trivial work (research, summarize the day, etc.), so the cap is
91
- * generous; it exists primarily so a wedged turn cannot block the next
92
- * scheduler tick indefinitely. Mirrors the heartbeat/filing budgets.
93
- */
94
- const SCHEDULE_TALK_TIMEOUT_MS = 30 * 60 * 1000;
95
-
96
89
  /**
97
90
  * Apply retry policy on schedule-execution failure. Retries are scheduled by
98
91
  * `applyRetryDecision`; once retries are exhausted, the `emitAlert` callback
@@ -718,7 +711,10 @@ export async function runScheduleDueWorkOnce(
718
711
  ...(job.inferenceProfile
719
712
  ? { overrideProfile: job.inferenceProfile }
720
713
  : {}),
721
- timeoutMs: SCHEDULE_TALK_TIMEOUT_MS,
714
+ // Hard timeout for talk-mode scheduled turns: bounds a wedged turn so
715
+ // it cannot block the next scheduler tick. Configurable via
716
+ // timeouts.scheduleTurnTimeoutSec (default 1800s).
717
+ timeoutMs: getConfig().timeouts.scheduleTurnTimeoutSec * 1000,
722
718
  origin: "schedule",
723
719
  groupId: "system:scheduled",
724
720
  conversationType: "scheduled",