# 存储：SQLite、迁移与运行时归属

> 数据目录的布局，better-sqlite3 加 Drizzle 的单库与手写迁移，多个进程共用一个数据目录时怎样用租约判定归属，以及启动后的保留与清理。

- 作者：David（道雾轩）
- 专栏：MiniMax Code 源码解读（https://daiw.org/manual/minimax-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/minimax-code/storage
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

MiniMax Code 没有常驻服务进程，每个 `mcode` 都自己打开数据目录、自己拼一套运行时（见[进程内入口](https://daiw.org/manual/minimax-code/runtime-entry)）。存储层因此要回答三件事：东西放在哪；库结构怎样随版本升级；同一个目录被两个进程同时打开时，谁的工作算数。`packages/local-runtime-v2/src/infra` 下 100 个文件、20599 行就是答案，其中 `db` 目录独占 12686 行。

## 怎么用：数据目录

命令行的数据目录默认是 `~/.minimax-code`（`packages/tui/src/runtime/data-dir.ts:21`），环境变量 `MINIMAX_DATA_DIR` 优先、`MAVIS_DATA_DIR` 其次，可以改到别处（`runtime/data-dir.ts:28`）。选定后 CLI 会把两个变量都写回进程环境（`packages/tui/src/cli/environment.ts:58`），第一代宿主启动时也再写一遍 `MINIMAX_DATA_DIR`（`packages/local-runtime/src/runtime/host-factory.ts:67`），这样共享配置代码里的 `getDataDir`（`packages/config/src/config.ts:1476`）、技能模板里的 `{{DATA_DIR}}` 都落在同一处。

<Callout type="info">
配置包里另有一个同名文件，管的是另一套目录：新目录 `~/.minimax`、旧目录 `~/.mavis`（`packages/config/src/data-dir.ts:5`），能把旧目录整个挪过来并留一个兼容链接，Windows 上用 junction（`config/src/data-dir.ts:86`）。它只在没有显式指定数据目录时才被用到，而命令行总会设置 `MINIMAX_DATA_DIR`，Git 仓库自动探测那一支也因此被跳过（`config.ts:1327`）。
</Callout>

数据目录里与本篇相关的内容：

| 路径（相对数据目录） | 内容 | 出处 |
| --- | --- | --- |
| `config.yaml` | 配置文件 | `config.ts:1510` |
| `AGENTS.md` | 全局指导 | `packages/local-runtime-v2/src/service/turn-system/persistence/global-instructions.ts:31` |
| `agents/<name>/agent.md`、`agents/.builtin/` | Agent 定义，见[Agent 服务](https://daiw.org/manual/minimax-code/agent-service) | `packages/local-runtime-v2/src/service/agent/storage/agent-files.ts:110` |
| `v2/sqlite/runtime-state.sqlite` | 运行时读写的主库，WAL 模式 | `packages/local-runtime-v2/src/infra/db/client.ts:48` |
| `v2/sqlite/backups/` | 迁移前的整库备份 | `packages/local-runtime-v2/src/infra/db/backup.ts:8` |
| `v2/sessions/…/messages.jsonl` | 会话历史，一个会话一个目录 | `packages/local-runtime-v2/src/service/session-system/messages/history/session-history-paths.ts:77` |
| `v2/runtime-owner-leases/<id>.owner` | 每个运行中进程的租约 | `packages/local-runtime-v2/src/infra/runtime-owner/identity.ts:12` |
| `logs/`、`v2/observability/logs/` | 日志，保留 7 天 | `packages/local-runtime-v2/src/infra/storage-retention/index.ts:55` |
| `sqlite.db`、`opencode/` | 旧版 daemon 与 OpenCode 的数据，平时只读 | `packages/local-runtime-v2/src/infra/legacy-db/readonly-source.ts:57` |

会话历史的 JSONL 布局归[会话系统](https://daiw.org/manual/minimax-code/session-system)讲，最后一行的旧数据归[第一代运行时与 OpenCode 遗产](https://daiw.org/manual/minimax-code/runtime-v1)讲。

## 一个库，两本迁移账

驱动是 `better-sqlite3` 12.11.1，上面套一层 `drizzle-orm`（`packages/local-runtime-v2/package.json:105`、同文件第 107 行）。`DatabaseClient` 第一次被访问时才打开库文件，并设好三条 PRAGMA（`client.ts:116`）：

```ts
// v1 and v2 temporarily access the same database through separate connections; keep PRAGMAs aligned.
sqlite.exec(`
  PRAGMA journal_mode = WAL;
  PRAGMA busy_timeout = 5000;
  PRAGMA foreign_keys = ON;
`);
```

注释点明了一件事：第一代运行时也打开同一个文件。`packages/local-runtime/src/persistence/db.ts:545` 的 `resolveLocalRuntimeDbPath` 解析出的正是 `v2/sqlite/runtime-state.sqlite`，PRAGMA 一字不差（`db.ts:549`）。两代代码各记各的迁移：第一代写 `local_runtime_schema_migrations`（`db.ts:559`），第二代写 `local_runtime_v2_schema_migrations`。目录约定里还声明了 `session-index.sqlite`、`usage-state.sqlite`、`legacy-import.sqlite` 三个库（`packages/shared/src/local-runtime-paths.ts:58`），但代码里没有任何地方打开它们。Electron 需要自带重编译的原生模块时，可以用 `MAVIS_SQLITE3_MODULE_PATH` 指定 `better-sqlite3` 的位置（`client.ts:139`）。

表按领域分文件放在 `infra/db/schema/`，主要几组：

| 领域 | 代表表 |
| --- | --- |
| 会话 | `local_runtime_sessions`：整条记录存进 `record_json`，另把 Agent 名、状态、工作区等投影成列供索引 |
| 消息 | `local_runtime_message_rows`，一条展示消息一行 |
| 队列与回合 | `local_runtime_queue_items`、`local_runtime_turn_ingress`、`local_runtime_session_locks` |
| Agent | `agents`，逐列照搬旧 daemon 库的表 |
| 旧数据迁移 | `local_runtime_legacy_migrations` 等账本 |
| 其他 | 定时任务与调度、Token 用量、插件状态、偏好；画布、LiveBoard 属于桌面端 |

## 手写迁移

迁移没有用 drizzle-kit 的运行时迁移器，`packages/local-runtime-v2/src/infra/db/migrate.ts:23` 的注释列了三条理由：

> Why not drizzle-kit migrate():
> 1. It reads the migration directory from fs at runtime, which is unfriendly to Electron asar.
> 2. We must coexist with v1 data; some migrations backfill data rather than only applying DDL.
> 3. Keep runtime dependencies minimal: only drizzle-orm + better-sqlite3.

每条迁移是一个 `{ version, name, up }`，`up` 可以是 SQL 字符串，也可以是函数（用于回填数据）。`runMigrations` 逐条执行，关键在事务里的二次确认（`migrate.ts:48`）：

```ts
for (const migration of migrations) {
  if (applied.has(migration.version)) continue;
  rawDb.exec('BEGIN IMMEDIATE');
  try {
    if (isMigrationApplied(rawDb, migration.version)) {
      rawDb.exec('COMMIT');
      applied.add(migration.version);
      continue;
    }
```

`BEGIN IMMEDIATE` 一开始就拿写锁。两个进程同时启动时，后到的那个等到锁以后先在锁内重新查一次版本表，发现已经跑过就直接提交跳过。迁移失败则 `ROLLBACK` 并把异常抛出去，启动随之失败。

注册表 `packages/local-runtime-v2/src/infra/db/migrations.ts:38` 共 35 条，版本号从 1 到 36，唯独没有 19。编号里能看到多分支并行开发的痕迹：几个已发布的分支占用过同一个版本号，而运行器只认版本号，于是有了一批“撞号修复”迁移，用新版本号把表以 `CREATE TABLE IF NOT EXISTS` 再建一遍。m0027 的注释（`packages/local-runtime-v2/src/infra/db/migrations/session/migration-0027-repair-task-session-bindings-version-collision.ts:4`）：

> Some published profiles already recorded version 20 for an unrelated repair.
> Re-assert the Task binding table under a new version so those profiles converge
> without rewriting migration history or deleting existing data.

m0030、m0031 等也是同一类修复。启动时的完整顺序在 `initializeDatabase`（`packages/local-runtime-v2/src/infra/db/initialize.ts:60`）：

1. 有待执行的迁移就先备份整库（下一节）。
2. 停用没有下次运行时间的旧定时任务。
3. 先跑版本 7 之前的迁移，修好旧的项目偏好和格式损坏的会话记录，再跑版本 15 之前的迁移，修会话的项目元数据；这些修复都要赶在对应的回填迁移之前。
4. 处理一个已发布分支占用版本 16 却没建表的情况（`initialize.ts:150`），然后跑完全部迁移。
5. `assertDatabaseSchemaConsistent`（`packages/local-runtime-v2/src/infra/db/schema-consistency.ts:282`）逐项核对表、列、索引、触发器、虚表，以及已下线的表确实不存在，不一致就拒绝启动。

在打开这个库之前，还有一步由第一代代码完成的目录布局迁移 `ensureCurrentLocalRuntimeDataMigratedToV2OrThrow`（`packages/local-runtime/src/persistence/migration/v2-migration.ts:152`），失败时抛出带 `current_local_runtime_v2_migration_failed:` 前缀的错误（`v2-migration.ts:149`）。各阶段耗时会写进启动计时日志，见[进程内入口](https://daiw.org/manual/minimax-code/runtime-entry)。

## 迁移前备份

`createDatabaseBackupIfPending`（`backup.ts:67`）只在有待执行迁移时工作：用 SQLite 的在线备份接口（`client.ts:83`）把库复制到 `v2/sqlite/backups/runtime-state-before-v2-migration-<毫秒时间戳>-<UUID>.sqlite`，再开一个独立的只读连接跑 `PRAGMA integrity_check`，结果必须恰好是一行 `ok`（`client.ts:88`）。备份目录链上任何一级是符号链接都算不安全，直接放弃（`backup.ts:80`）。备份失败时删掉半成品，抛出 `LOCAL_RUNTIME_V2_DATABASE_BACKUP_FAILED`（`backup.ts:15`），迁移不会在没有备份的情况下进行。

旧备份的清理放到就绪之后：运行时登记一个 `migration-backup-retention` 维护任务（`packages/local-runtime-v2/src/runtime.ts:283`），调用 `pruneSupersededDatabaseBackups`（`backup.ts:121`）。它只删比本次备份更早、且文件名符合格式的备份，每删一个之前都重新核对本次备份的文件身份（设备号、inode、大小、时间戳）没变（`backup.ts:159`）。没有迁移的正常启动不创建备份，也不清理。

## 运行时归属：谁的工作还活着

同一个数据目录可以同时跑好几个 `mcode`，存储层不选出唯一的“主进程”，而是给每个进程发一份租约，再把租约身份写进它领走的每一份工作里。`createRuntimeOwnerIdentity`（`identity.ts:70`）做三件事：

1. 以 `wx`（文件已存在即失败）和 `0600` 权限写出 `v2/runtime-owner-leases/<instanceId>.owner`（`identity.ts:89`）。内容是一行裸 PID 再加一行 JSON；保留裸 PID 行是为了让回滚后的旧版本也能读懂（`packages/local-runtime-v2/src/infra/runtime-owner/runtime-owner-lease.ts:22`）。
2. 用 `proper-lockfile` 锁住这个文件，过期时间 10 秒（`identity.ts:11`），心跳间隔取过期时间的三分之一、至少 1 秒（`identity.ts:98`）。锁被判定失守时通知所有监听者，运行时记一条错误日志后自行关闭（`src/runtime.ts:492`）。
3. 发放所有者 ID，格式是 `kind:pid:instanceId.operationId`（`identity.ts:41`），`kind` 只有四种：`turn-lease`、`session-delete`、`queue-claim`、`background-task`（`identity.ts:13`）。

这些 ID 写在会话锁、队列认领、后台任务等记录上（`packages/local-runtime-v2/src/services.ts:913`、`services.ts:921`、`packages/local-runtime-v2/src/service/session-system/owner.ts:563`）。别的进程遇到一条带 ID 的记录，靠下面这段判断主人是否还活着（`identity.ts:53`）：

```ts
return (ownerId) => {
  const parsed = parseOwnerId(ownerId);
  if (!parsed) return ownerId.startsWith('session-delete:') ? false : undefined;
  if (!parsed.instanceId) return options.isProcessAlive(parsed.pid);
  const leaseState = options.getInstanceLeaseState(parsed.instanceId);
  const currentInstance =
    parsed.pid === options.currentPid && parsed.instanceId === options.currentInstanceId;
  // Compromise revokes authority, but does not prove that this process finished draining.
  if (currentInstance) return leaseState !== 'missing';
  if (leaseState === 'missing') return false;
  if (!options.isProcessAlive(parsed.pid)) return false;
  // A delayed heartbeat is not proof that this exact foreign owner died.
  // Its process must close the lease or exit before another Runtime may recover its work.
  return true;
};
```

判定偏保守：租约文件不在，或者进程已经不存在（`process.kill(pid, 0)`），才算死；心跳迟到不算。租约文件在正常关闭时删除（`identity.ts:163`），进程崩溃会留下文件，但进程不在了同样判死。以回合为例：会话锁表 `local_runtime_session_locks` 每个会话一行，记录所有者 ID 和过期时间，默认租期 30 分钟，运行期间续约（`packages/local-runtime-v2/src/service/turn-system/persistence/turn.repository.ts:37`）；进程启动时扫描在自己启动之前拿到的锁，主人已死的就回收（`packages/local-runtime-v2/src/service/turn-system/persistence/turn-lease-recovery.ts:87`）；删除会话时若锁属于另一个还活着的进程，就直接报错拒绝（`turn.repository.ts:808`）。锁的获取与续约细节见[回合系统](https://daiw.org/manual/minimax-code/turn-system)。

租约里还有一个可选的进程启动令牌：Linux 取启动 ID 加进程启动时刻，macOS 用 `ps -o lstart=` 并强制 UTC，Windows 没有探针，只能退回裸 PID（`packages/local-runtime-v2/src/infra/runtime-owner/process-start-time.ts:39`）。它给“数据目录代际切换”用：`switchRootlessV2Generation` 在切换前调用 `assertRuntimeOwnersStopped`，只要有一个活着的租约就以 `ROOTLESS_V2_OWNERS_ACTIVE` 拒绝（`packages/local-runtime-v2/src/infra/runtime-owner/rootless-v2-generation-fence.ts:120`），令牌用来识破被复用的 PID。启动路径上的整目录检查点已经拿掉，注释说这个模块留给一个自带操作围栏的命令行工具（`src/runtime.ts:169`）；包入口导出了 `switchRootlessV2Generation`，但公开源码里找不到调用方。

还有一把更粗的锁。启动时的 Agent 定义切换在 `withAgentStorageLock` 里进行（`packages/local-runtime-v2/src/compat/v1/runtime.ts:125`），底下是第一代的 `withAgentNameConflictMigrationLock`：直接用 `proper-lockfile` 锁数据目录本身（`packages/local-runtime/src/persistence/migration/agent-name-conflict-migration.ts:197`），过期时间 30 分钟，最多重试 120 次（`agent-name-conflict-migration.ts:40`）。两个进程同时冷启动时，后到者在这里排队。

## 其他基础设施

- **JSONL**：`infra/file` 提供三种写法。追加 `appendJsonl` 明说不做跨进程加锁，调用方必须按文件串行（`packages/local-runtime-v2/src/infra/file/jsonl.ts:73`）；整体替换 `replaceFileAtomically` 先写同目录的临时文件并落盘，再改名覆盖（`jsonl.ts:149`）；首次发布 `publishFileIfAbsent` 用硬链接原子地“目标不存在才创建”（`jsonl.ts:193`）。
- **历史来源选择**：同一会话的历史可能同时存在于新 JSONL、旧账本快照、SQLite 行、SQLite 大字段四处，`selectCanonicalHistorySource`（`packages/local-runtime-v2/src/infra/file/canonical-history-source.ts:16`）按这个顺序取第一个有权威数据的来源，选中之后读失败也不再往下回退。
- **Git**：`packages/local-runtime-v2/src/infra/git/fork-worktree-adapter.ts:38` 只服务会话分叉，用 `git worktree add -b` 在仓库根的 `.worktrees/` 下建新工作树（`fork-worktree-adapter.ts:136`、`fork-worktree-adapter.ts:194`），参数以数组传给 `git`，从不经过 Shell 字符串。
- **SSE 缓冲**：`BoundedRing` 默认保留 512 条、2 MiB（`packages/local-runtime-v2/src/infra/sse/bounded-ring.ts:52`），会话事件流把上限放宽到 2000 帧、8 MiB（`packages/local-runtime-v2/src/service/session-system/stream/session-frame-retention.ts:4`）；每个订阅者自己的队列默认 128 条、512 KiB，溢出时以 `SubscriberOverflowError` 断开（`packages/local-runtime-v2/src/infra/sse/subscriber-registry.ts:42`）。
- **调度器**：`infra/scheduler` 用 croner 计算触发时间，作业存在 `local_runtime_v2_scheduler_jobs` 表，只在 Electron 宿主上创建（`packages/local-runtime-v2/src/background-runtime.ts:15`）。

## 保留与清理

清理全部放在运行时就绪之后：`startMaintenance` 先让出一轮事件循环（`background-runtime.ts:109`），再并行跑已登记的维护任务，任何一个失败只记警告。一共三项：

| 任务 | 做什么 | 出处 |
| --- | --- | --- |
| `transient-storage-retention` | 删除 `logs/` 与 `v2/observability/logs/` 下超过 7 天的文件，每轮最多检查 2000 个条目；顺带整个删掉已退役的 `debug/` 目录 | `storage-retention/index.ts:18` |
| `turn-diff-retention` | 删除 7 天前的回合差异（每轮的文件改动记录），每批 250 条 | `packages/local-runtime-v2/src/service/session-system/storage-retention.ts:3` |
| `migration-backup-retention` | 删除比本次迁移备份更早的备份 | `src/runtime.ts:283` |

没有列进来的就不会被自动删除：会话记录、JSONL 历史、旧版 `sqlite.db` 都一直留着。回合差异清理的注释（`storage-retention.ts:22`）说得很直白：

> One bounded post-ready pass over current Turn Diff state. Migrated Session history is intentionally outside automatic retention and is never read here.

需要腾空间时只能手动删会话，或者直接清理数据目录。

下一篇：[第一代运行时与 OpenCode 遗产](https://daiw.org/manual/minimax-code/runtime-v1)——同一个库文件的另一位使用者，以及旧会话怎样被迁成 pi 会话。
