存储:SQLite、迁移与运行时归属
数据目录的布局,better-sqlite3 加 Drizzle 的单库与手写迁移,多个进程共用一个数据目录时怎样用租约判定归属,以及启动后的保留与清理。
MiniMax Code 没有常驻服务进程,每个 mcode 都自己打开数据目录、自己拼一套运行时(见进程内入口)。存储层因此要回答三件事:东西放在哪;库结构怎样随版本升级;同一个目录被两个进程同时打开时,谁的工作算数。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}} 都落在同一处。
配置包里另有一个同名文件,管的是另一套目录:新目录 ~/.minimax、旧目录 ~/.mavis(packages/config/src/data-dir.ts:5),能把旧目录整个挪过来并留一个兼容链接,Windows 上用 junction(config/src/data-dir.ts:86)。它只在没有显式指定数据目录时才被用到,而命令行总会设置 MINIMAX_DATA_DIR,Git 仓库自动探测那一支也因此被跳过(config.ts:1327)。
数据目录里与本篇相关的内容:
| 路径(相对数据目录) | 内容 | 出处 |
|---|---|---|
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 服务 | 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 布局归会话系统讲,最后一行的旧数据归第一代运行时与 OpenCode 遗产讲。
一个库,两本迁移账
驱动是 better-sqlite3 12.11.1,上面套一层 drizzle-orm(packages/local-runtime-v2/package.json:105、同文件第 107 行)。DatabaseClient 第一次被访问时才打开库文件,并设好三条 PRAGMA(client.ts:116):
// 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():
- It reads the migration directory from fs at runtime, which is unfriendly to Electron asar.
- We must coexist with v1 data; some migrations backfill data rather than only applying DDL.
- Keep runtime dependencies minimal: only drizzle-orm + better-sqlite3.
每条迁移是一个 { version, name, up },up 可以是 SQL 字符串,也可以是函数(用于回填数据)。runMigrations 逐条执行,关键在事务里的二次确认(migrate.ts:48):
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):
- 有待执行的迁移就先备份整库(下一节)。
- 停用没有下次运行时间的旧定时任务。
- 先跑版本 7 之前的迁移,修好旧的项目偏好和格式损坏的会话记录,再跑版本 15 之前的迁移,修会话的项目元数据;这些修复都要赶在对应的回填迁移之前。
- 处理一个已发布分支占用版本 16 却没建表的情况(
initialize.ts:150),然后跑完全部迁移。 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)。各阶段耗时会写进启动计时日志,见进程内入口。
迁移前备份
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)做三件事:
- 以
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)。 - 用
proper-lockfile锁住这个文件,过期时间 10 秒(identity.ts:11),心跳间隔取过期时间的三分之一、至少 1 秒(identity.ts:98)。锁被判定失守时通知所有监听者,运行时记一条错误日志后自行关闭(src/runtime.ts:492)。 - 发放所有者 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):
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)。锁的获取与续约细节见回合系统。
租约里还有一个可选的进程启动令牌: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 遗产——同一个库文件的另一位使用者,以及旧会话怎样被迁成 pi 会话。