# SQLite 会话库

> Agent CLI 的持久状态都在一个 SQLite 文件里：为什么用 Node 内置的 node:sqlite，库在哪、有哪些表，22 条只追加的迁移怎样按校验和记账，多进程并发与事务，故障注入，-c 与 --resume 的查询，以及恢复时怎样把消息和 part 重建成模型历史与读文件状态。

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

ZCode 的 Agent 运行时把会话事件流放在内存里（`apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:730` 默认创建的是 `createInMemorySessionEventStore()`），进程退出后还要留着的东西——会话、消息与 part、输入账本、Todo、目标、检查点记录、用量——都写进同一个 SQLite 文件。读写它的是 `SqliteSessionStore`：一个类同时实现 `SessionStorePort`、`InputHistoryStorePort`、`LocalSettingStorePort`、`ScriptWorkflowStorePort`、`UsageStorePort` 五个端口（`apps/zcode-cli/packages/adapters/src/storage/session-store/sqlite-session-store.ts:225`），动态工作流的 journal 端口由 `workflowJournalStore()` 另外交出（`sqlite-session-store.ts:996`）。主端口定义在 `apps/zcode-cli/packages/contracts/src/interfaces/session-store.port.ts:1089`，45 个方法里有 22 个带问号，旧宿主可以不实现。

代码在 `apps/zcode-cli/packages/adapters/src/storage`，34 个非测试文件、8907 行，其中 `session-store/` 占 7115 行。事件怎样投影成消息与 part 落库，见[会话事件流与持久化投影](https://daiw.org/manual/zcode/session-events)；本篇讲库本身、迁移与恢复。

| 位置 | 职责 |
| --- | --- |
| `session-store/sqlite-session-store.ts` | 打开连接、触发迁移，把端口方法分派给仓储；分叉、共享上下文导入等多表事务也在这里 |
| `session-store/migration-runner.ts`、`migrations.ts`、`migrations/` | 迁移账本与 22 条迁移 |
| `session-store/repositories/` | 按表读写：`sessions`、`messages`、`session-entries`、`session-inputs`、`todos`、`usage`、`dwf-journal*` 等 |
| `session-store/codecs.ts`、`rows.ts`、`json.ts` | 行结构与 JSON 编解码，读取时剥掉旧版字段 |
| `session-target.ts` | 目标（Goal）表 |
| `fs-fault-injection.ts` | 测试用的文件系统故障注入 |
| `index.ts` | 另外导出工具结果文件存储 `NodeToolArtifactStore` |

## 怎么用

库文件默认在 `~/.zcode/cli/db/db.sqlite`（`apps/zcode-cli/packages/adapters/src/storage/session-store/paths.ts:7`，配置默认值见 `apps/zcode-cli/packages/contracts/src/config/index.ts:303`），NOTICE 第三节写的也是这个位置（`NOTICE.md:58`）。要换位置，改配置键 `storage.sessionDbPath`，或设环境变量 `ZCODE_SESSION_DB_PATH`（`ZCODE_SESSION_DB` 同义，`apps/zcode-cli/packages/adapters/src/config/env-config.adapter.ts:29`）；相对路径按进程工作目录解析（`apps/zcode-cli/packages/bootstrap/src/app/session-store.ts:104`）。库开着 WAL，旁边会多出 `-wal`、`-shm` 两个文件，桌面端资源管理器把三者都归为“会话存储”并标成不可清理（`packages/services/src/storage/domain/storageCatalog.ts:26`、`storageCatalog.ts:56`）。

接着之前的会话往下做，有这几个入口：

| 入口 | 行为 |
| --- | --- |
| `zcode -c`（`--continue`） | 取当前目录最近更新的一个根会话；没有就报 `No resumable session found for` 加目录（`apps/zcode-cli/packages/cli/src/resume.ts:16`） |
| `zcode --resume <sessionId>` | 直接用给定 ID，存不存在要到真正恢复时才查（`src/resume.ts:12`） |
| 两者同时给 | 报 `--resume and --continue cannot be used together.` 后退出（`apps/zcode-cli/packages/cli/src/run.ts:368`） |
| TUI 里的 `/resume` | 弹出当前目录最近 50 个根会话的选择列表（`apps/zcode-cli/packages/cli/src/tui-command-data.ts:45`、`apps/zcode-cli/packages/cli/src/command-center/create.ts:333`） |
| TUI 里的 `/resume <id>`、`/continue` | 按 ID 恢复，或恢复最近一个（`create.ts:345`） |
| `-p` 无头模式 | 走同一套解析，在解析出的会话里接着跑这次提示词（`apps/zcode-cli/packages/cli/src/prompt-command.ts:165`） |

“根会话”指 `parent_id` 为空的会话。两个列表查询都带 `roots: true`（`apps/zcode-cli/packages/bootstrap/src/sessions.ts:21`、`apps/zcode-cli/packages/bootstrap/src/sessions.ts:45`），SQL 是按 `time_updated desc, id desc` 排序、默认排除已归档（`apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/sessions.ts:217`、`repositories/sessions.ts:228`）。分叉出的子会话都记了 `parent_id`（见下一篇），所以它们既不在 `/resume` 列表里，也不会被 `-c` 选中，只能用 `--resume` 加 ID 打开。

## 为什么是 node:sqlite

整个仓库没有一处文字交代为什么选 Node 内置的 `node:sqlite`，但代码里能读出几条理由：

- **没有原生依赖**。仓库根与 `apps/zcode-cli` 的两份 `pnpm-lock.yaml` 里都找不到 sqlite 字样，不需要 better-sqlite3 这类按 Node ABI 与平台编译的原生模块。从打包脚本推断，这能省掉单文件可执行（SEA）的一块麻烦：TUI 用到的 koffi 就得按目标平台单独挑出 `koffi.node` 塞进包里（`apps/zcode-cli/packages/cli/scripts/sea-tui-assets.mjs:294`）。
- **同步 API**。`DatabaseSync` 的每个调用都是同步的，仓储函数虽然标着 `async`，函数体里并不等待 I/O。动态工作流的 journal 端口干脆设计成同步接口，注释说它“正好贴合 node:sqlite 的 DatabaseSync”（`apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/dwf-journal.ts:4`）。
- **运行时已经锁定**。CLI 固定在 Node 24.14.0（`apps/zcode-cli/package.json:47`、`mise.toml:2`），桌面端服务层的任务索引库也用 `node:sqlite`，还特意用 `createRequire` 加载，防止打包器把它改写成 npm 上的 sqlite 包（`packages/services/src/session/tasksDatabase/startup.ts:5`）。

代价是 Node 24 里这个模块仍会打印 `ExperimentalWarning: SQLite is an experimental feature`，CLI 在 stderr 边界把这一行吞掉（`apps/zcode-cli/packages/cli/src/runtime-warnings.ts:8`）。

打开连接时先把忙等超时交给驱动，再跑迁移（`sqlite-session-store.ts:243`）：

```ts
    try {
      ensureParentDir(this.dbPath);
      maybeThrowStorageFsFault({ operation: "sqliteOpen", path: this.dbPath });
      // 多个本地或远程 Agent 会共享同一个 session DB；timeout 必须在执行首条
      // PRAGMA 前生效，否则并发启动会在 migration prelude 直接抛 database is locked。
      this.db = new DatabaseSync(this.dbPath, { timeout: startupLockTimeoutMs });
    } catch (error) {
      // ...
    }
    try {
      if (startupToken !== deferredStartup)
        runSqliteSessionMigrations(this.db, this.dbPath, startupLockTimeoutMs);
```

`startupLockTimeoutMs` 默认 5000 毫秒（`apps/zcode-cli/packages/adapters/src/storage/session-store/migration-runner.ts:13`）。构造函数走同步迁移；应用启动走 `SqliteSessionStore.openStartup`，用一个模块私有的 symbol 跳过构造期迁移，再跑异步版本，保证“所有 Repo/业务只可能拿到 COMMIT 后的连接”（`sqlite-session-store.ts:276`）。

## 有哪些表

业务表都由迁移创建，一共 22 张，另有迁移运行器自己建的账本 `schema_migration`。核心几张的关系：

```mermaid
erDiagram
  session ||--o{ message : "session_id 级联删除"
  message ||--o{ part : "message_id 级联删除"
  session ||--o{ session_entry : "类型化条目"
  session ||--o{ session_input : "输入账本"
  session_input }o--o| message : "promoted_message_id"
  session ||--o{ todo : "整表替换"
  session ||--o| session_target : "目标"
  session ||--o{ turn_usage : "用量"
  session ||--o{ model_usage : "用量"
  session ||--o{ tool_usage : "用量"
  session |o--o{ session : "parent_id 子会话"
```

逐张列出来（“建表行”指 `apps/zcode-cli/packages/adapters/src/storage/session-store/migrations.ts` 里对应 `create table` 语句的行号）：

| 表 | 建表行 | 用途 | 关键列 |
| --- | --- | --- | --- |
| `session` | 14 | 会话主记录 | `project_id`、`workspace_id`、`parent_id`、`directory`、`title` 与 `title_source`、`revert`（对话回退游标）、`permission`、`task_type`、`trace_id`、`time_archived` |
| `message` | 41 | 消息；除 ID、时间和序号外，整条消息以 JSON 存在 `data` | `session_id`、`sequence` |
| `part` | 52 | 消息的组成部分：文本、推理、工具调用、文件、时间线等，同样整条存 JSON | `message_id`、`session_id`、`sequence` |
| `session_entry` | 77 | 会话级的类型化条目：模型选择、执行状态、检查点、命令幂等事实等 | `type`、`data` |
| `session_input` | 691 | 输入账本：立即开始、引导、排队三种输入的持久生命周期，0017、0018 各重建一次 | `delivery`、`status`、`admitted_sequence`、`promoted_message_id` |
| `todo` | 64 | Todo 列表 | 主键 `(session_id, position)` |
| `session_target` | 166 | 目标、Token 预算与活跃运行记账，0005 重建 | `objective`、`status`、`token_budget`、`tokens_used` |
| `input_history` | 97 | 输入框历史，全库最多保留 100 条 | `project_id`、`text`、`kind`、`attachments` |
| `local_setting` | 116 | 按作用域存的本地设置，如项目权限规则与模式 | `(scope, scope_id, namespace, key)` |
| `permission` | 90 | 旧的项目权限表，只作读取回退 | `project_id`、`data` |
| `model_usage`、`turn_usage`、`tool_usage` | 396、448、481 | 模型请求、回合、工具调用的用量与耗时事实，保留 30 天 | `trace_id`、`turn_id`、各类 Token 数 |
| `workflow_*` 四张与 `session_task_link` | 234 起 | 旧的脚本工作流 | 运行、活动、事件与父子会话链接 |
| `dwf_run`、`dwf_actor`、`dwf_node`、`dwf_event` | 831 起 | 动态工作流 journal，不设外键 | 见[动态工作流（三）](https://daiw.org/manual/zcode/dwf-tools) |

`session_entry` 的 `type` 是开放字符串，端口里登记了七种常量，比如 `runtime/model_selection`、`runtime/execution_state`、`runtime/workspace_checkpoint`（`session-store.port.ts:802`），分叉与共享上下文导入另外用到 `v4/command_fact`、`v4/shared_context_import` 等类型。两个数字的出处：输入历史的 100 条在 `apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/input-history.ts:12`，删除时不分项目；用量的 30 天在 `apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/usage.ts:17`，每次写用量都会顺手清一遍过期行（`usage.ts:163`、`usage.ts:335`）。

## 迁移：22 条，只追加

迁移注册表 `SQLITE_MIGRATIONS`（`apps/zcode-cli/packages/adapters/src/storage/session-store/migrations.ts:9`）是一个 `{ appVersion, id, sql }` 数组，编号从 `0001_base_session_store` 到 `0022_backfilled_session_reasoning`，`appVersion` 从 0.2.0 一路到 0.16.5。执行在一个 `begin immediate` 事务里完成（`migration-runner.ts:159`）：

```ts
    yield* acquire(() => db.exec("begin immediate"));
    transactionStarted = true;
    db.exec(`create table if not exists schema_migration (
      id text primary key, checksum text not null, app_version text, time_applied integer not null
    )`);
    // ...
    for (const migration of SQLITE_MIGRATIONS) {
      try {
        const checksum = migrationChecksum(migration.sql);
        const applied = readAppliedMigration(db, migration.id);
        if (applied) {
          ensureMigrationChecksum(migration.id, applied.checksum, checksum, dbPath);
          completed++;
          continue;
        }
        // ...
        db.exec(migration.sql);
        if (migrationFacts) migrationFacts.executedCount++;
        db.prepare(
          "insert into schema_migration (id, checksum, app_version, time_applied) values (?, ?, ?, ?)",
        ).run(migration.id, checksum, migration.appVersion, Date.now());
        completed++;
      } catch (error) {
        throw normalizeMigrationError(error, dbPath, migration.id);
      }
    }
```

几个要点：

- **账本与校验和**。校验和是 SQL 去掉首尾空白后的 sha256（`migration-runner.ts:296`）。已执行的迁移每次启动都重新核对，对不上就以 `checksum_mismatch` 拒绝启动，报错原文是“Historical migrations are immutable; add a new migration instead.”（`migration-runner.ts:308`）。
- **拿锁之后再读账本**。进事务前先把库切到 WAL、打开外键（`migration-runner.ts:137`、`migration-runner.ts:141`），再做一次只读预检，判断这次是 `none`、`initialize` 还是 `upgrade`，只用于展示（`migration-runner.ts:318`）；真正要跑哪些，以拿到写锁后的账本为准。两个进程同时启动，后到的那个等锁、读账本、发现都已提交，就什么也不做。失败则整体回滚，一条都不留（`migration-runner.ts:206`）。
- **只重试 SQLITE_BUSY**。等锁时退避间隔从 10 毫秒翻倍到最多 200 毫秒（`migration-runner.ts:16`），`SQLITE_LOCKED` 不重试（`migration-runner.ts:122`）。同步版本总共只等 5 秒；异步版本把驱动自身的忙等压到 25 毫秒、让出事件循环，总预算一小时，结束后恢复 5 秒（`migration-runner.ts:45`、`migration-runner.ts:66`、`migration-runner.ts:92`）。每个阶段（`checking`、`waiting_for_lock`、`migrating`、`committing`、`ready`、`failed`）都会回调进度：普通启动写进启动日志（`apps/zcode-cli/packages/bootstrap/src/app/session-store.ts:84`），协议与存储准备模式则以控制帧报给桌面端，见文末。

`migrations/` 目录里只有 0020 到 0022 三个文件：0001 到 0019 都以内联 SQL 写在 `migrations.ts` 里，后三条是改写 JSON 字段的数据迁移，SQL 很长，0020 与 0022 还要用 TypeScript 拼出来（比如 0020 把旧的模型身份字段改写成 `modelSelection`，并把 `builtin:` 前缀的 provider 映射成新 ID，`apps/zcode-cli/packages/adapters/src/storage/session-store/migrations/0020-provider-model-selection.ts:16`），于是各自成文件；文件头强调“冻结的数据迁移只生成 SQL，checksum 覆盖最终 SQL”（`0020-provider-model-selection.ts:1`）。编号为什么恰好从 0020 接着排，0019 的注释给了答案（`migrations.ts:803`）：

```ts
    // 本条是 beta 前把开发期 0019–0030 十二条迁移**压成的单一基线**：四张表一次建齐、形状即
    // 0030 之后的终态。中间态（三次为放宽 CHECK 的整表重建、0028 的删列改名）只存在于
    // 内部预览库里，收敛办法是删掉四张 dwf_* 表并清掉 schema_migration 里的 dwf 记账行，
    // 下次启动由本条重建。beta 之后本条
    // 不可再改：runner 按 checksum 记账，历史迁移只能追加。
    //
    // 只为占住 0019 这个槽位，让
    // staging 后续迁移从 0020 起编号，功能分支合回时 ledger 不会撞号。四张表在功能落地前闲置无害。
```

也就是说，动态工作流在开发期用掉了 0019 到 0030 十二个号，发布前压成一条 0019，占住这个槽位，staging 分支上的后续迁移因此从 0020 起编。迁移里还能看到新旧二进制并存的痕迹：0015 的注释记录了一次实测，本机有 1690 行 message、5933 行 part 的 `sequence` 为空，“主要嫌疑是旧版本二进制并存写同一 DB”，于是补了两个 `AFTER INSERT` 触发器兜底（`migrations.ts:598`、`migrations.ts:656`）。

## 并发与事务

同一个库会被多个进程同时打开，注释原话是“多个本地或远程 Agent 会共享同一个 session DB”（`sqlite-session-store.ts:246`）。协调全靠 SQLite 自己：WAL 允许并发读，写锁由 `busy_timeout` 等待。在这个前提下，写路径有几条规矩：

- **单条语句靠自动提交，多行写入用 `begin immediate`**。Todo 整表替换（`apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/todos.ts:30`）、输入历史插入并截断、输入账本的批量改写与“提升”、分叉提交、完全访问授权提交、用量清理都各开一个立即写事务。完全访问的提交还特意说明事务内不 `await`，“取消检查和提交之间没有异步重入窗口”（`apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/permission-full-access.ts:5`）。
- **序号在插入语句里算**。消息与 part 的 `sequence` 用子查询取同一会话（或同一消息）的最大值加一，冲突更新时保留原序号，只有跨会话改绑才换新序号；注释解释了为什么不能用 `coalesce`：会把历史里为空的行在任何一次重存时挪到队尾（`apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/messages.ts:35`）。读取时按 `sequence is null, sequence, time_created, rowid` 排序（`messages.ts:230`）。
- **时间只增不减**。每存一条消息或 part 都会“触碰”会话的 `time_updated`，写法是 `max(time_updated, ?)`（`repositories/sessions.ts:341`），这就是 `-c` 和 `/resume` 列表的排序依据；路径自愈这类维护写入也用旧值做比较交换，避免把并发写进来的新标题、权限覆盖掉（`repositories/sessions.ts:285`）。
- **给回滚留快照**。旧版读取器会无条件读 `user.model`、`toModel.providerID` 之类的字段，新代码写入时补上最小的旧对象，冲突更新时保留旧快照（`messages.ts:16`、`messages.ts:46`）。换句话说，库要能被上一个版本的二进制继续打开。
- **幂等靠事实行**。分叉等命令在父会话里写一条 `v4/command_fact` 条目，ID 形如 `v4_command_fact:child:` 加父会话 ID 和命令 ID；重复提交时先查这一行，存在就直接返回已创建的子会话（`sqlite-session-store.ts:336`、`sqlite-session-store.ts:395`）。输入账本也是同一个思路：`admitted` 之后要么在同一事务里和用户消息一起“提升”为 `promoted`，要么收口为 `cancelled`、`discarded`、`failed`，迟到的丢弃不能把已提升的记录改回去（`apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/session-inputs.ts:1`、`session-inputs.ts:323`）。

## 故障注入

存储层的关键操作之前都有一道 `maybeThrowStorageFsFault`，规则来自环境变量 `ZCODE_E2E_FS_FAULTS`，是一个 JSON 数组（`apps/zcode-cli/packages/adapters/src/storage/fs-fault-injection.ts:1`）。每条规则指定错误码、要拦的操作（`mkdir`、`writeFile`、`appendFile`、`rename`、`rm`、`sqliteOpen`、`sqliteRun` 或 `any`）、路径匹配条件，以及最多命中几次，缺省 1 次（`fs-fault-injection.ts:4`、`fs-fault-injection.ts:98`）。它只在 `ZCODE_ENV=test` 或同时设了 `ZCODE_E2E_FS_FAULTS_ALLOW=1` 时生效，否则规则被忽略（`fs-fault-injection.ts:219`）。

在会话库里，打开数据库对应 `sqliteOpen`，大部分写方法开头的 `throwBeforeWrite()` 对应 `sqliteRun`（`sqlite-session-store.ts:300`）；日志写盘和文件系统适配器也接了同一套钩子（`apps/zcode-cli/packages/adapters/src/logging/index.ts:132`、`apps/zcode-cli/packages/adapters/src/fs/index.ts:123`）。不过并不是每个写方法都过这道闸，目标（Goal）与用量的写入就没有调用它（`sqlite-session-store.ts:742`、`sqlite-session-store.ts:838`）。

另有两个只给测试用的构造选项：`forkCommitFaultAt` 让分叉事务在子会话、消息、目标、条目、输入、命令事实写完后或提交前的任一阶段抛错，用来验证全有或全无；`startupLockTimeoutMs` 调短启动锁等待（`apps/zcode-cli/packages/adapters/src/storage/session-store/options.ts:12`）。

## 恢复：从行到内存历史

`--resume` 或 `-c` 只是定出一个会话 ID，再以 `resume: true` 创建应用。这样启动时恢复是惰性的：等到第一次真实执行（提交提示词、启动工作流等）的边界才调用 `resumeFromStore`（`create-app.ts:502`、`create-app.ts:514`）；TUI 里的 `/resume` 换上新 App 后则立即显式调用（`create.ts:346`）。进 core 之前，bootstrap 先读 `runtime/model_selection` 条目恢复模型选择（`create-app.ts:484`）。core 里的流程（`apps/zcode-cli/packages/core/src/runtime/methods/resume.ts:59`）：

```mermaid
flowchart TD
  A["getSession"] -->|"不存在或已归档"| X["SessionNotFound"]
  A --> B["修复远端路径，读出全部 message 与 part"]
  B --> C["按 revert 游标与压缩边界选出活跃分支"]
  C --> D["hydrateReadFileStateFromSession"]
  D --> E["初始化上下文，收尾中断的压缩"]
  E --> F["hydrateMessageHistoryFromSession"]
  F --> G["检查点、文件回退条目回放进内存事件流"]
  G --> H["恢复模式、执行状态与授权标记"]
  H --> I["未消费的排队输入标为 discarded"]
  I --> J["注入 Todo 与目标，发 SessionResumed，跑 SessionStart 钩子"]
```

几处细节：会话已归档也算找不到（`methods/resume.ts:73`）；环境信息取第一条带 `contextSnapshot` 的用户消息里存下的那份，而不是当前机器的（`methods/resume.ts:114`）；账本里还处于 `admitted` 的输入一律以 `session_resumed` 为由丢弃，“重启不保留队列”（`apps/zcode-cli/packages/core/src/runtime/methods/steering.ts:1346`）；恢复出的目标会以附件形式提醒模型，并叮嘱计划或清单做完不等于目标完成（`methods/resume.ts:443`）。

**消息历史**。`hydrateMessageHistoryFromSession`（`apps/zcode-cli/packages/core/src/agent/session-history-hydrator.ts:54`）先用 `activeSessionMessages` 裁出活跃分支：先按回退游标取分支，再从分支里最后一个压缩边界往后取（`session-history-hydrator.ts:240`）；没有游标的旧数据保持先压缩、后分支的老顺序（`session-history-hydrator.ts:211`）。然后逐条回放。用户消息里的合成提醒还原成附件，文本附件还原成 `prompt_attachment` 提醒，图片、视频放到文字之后，与实时路径的顺序一致（`session-history-hydrator.ts:271`）；尚未附着的共享上下文不进历史（`session-history-hydrator.ts:84`）。助手消息还原文本、推理和工具调用，工具结果按状态分三种（`session-history-hydrator.ts:149`）：

```ts
    for (const part of toolParts) {
      const providerToolName = providerToolNameFromPart(part);
      if (part.state.status === "completed") {
        // ...
        const content = projectedMediaContent ?? part.state.output;
        input.history.addToolResult(part.callID, providerToolName, content, true);
        continue;
      }

      if (part.state.status === "error") {
        const persistedModelContent = part.state.metadata?.modelContent;
        // 实时链路使用 ToolExecutionResult.modelContent，但旧恢复逻辑只重放
        // 面向 UI / 日志的 state.error；优先使用持久化字符串并兼容旧 session。
        input.history.addToolResult(
          part.callID,
          providerToolName,
          typeof persistedModelContent === "string" ? persistedModelContent : part.state.error,
          false,
        );
        continue;
      }

      interruptedToolCount++;
      input.history.addToolResult(part.callID, providerToolName, INTERRUPTED_TOOL_RESULT, false);
    }
```

进程死在工具执行中途的调用，会被补上一条 `[Tool execution was interrupted before resume]` 作为结果（`session-history-hydrator.ts:45`），保证历史里每个工具调用都有一条配对的结果。

**文件 part**。`filePartToContentBlock`（`apps/zcode-cli/packages/core/src/agent/file-part-hydration.ts:8`）先看 part 自己的 URL 是不是有效的 data URL，不是就按 `zcode-artifact://` 地址从 artifact 存储读回（`file-part-hydration.ts:113`）。读到了，图片、视频、PDF 分别还原成对应的内容块；只有这三类会还原成二进制输入，注释提到曾因把白名单放宽到所有非文本类型，导致音频在冷恢复后意外变成 provider 文件输入（`file-part-hydration.ts:32`）。文本类型用存下的预览文字，其余一律退化成 `[Attached <mime>: <名字>]` 的占位文字。完成的工具结果若带附件，还要按持久化的内容布局重排，布局缺失或损坏就退回旧的文字输出（`session-history-hydrator.ts:161`）。

**读文件状态**。“先读后写”约束依赖运行时记住读过哪些文件（见[读、写、改、搜](https://daiw.org/manual/zcode/file-tools)），重启后这张表也要重建。`hydrateReadFileStateFromSession`（`apps/zcode-cli/packages/core/src/agent/read-file-state-hydrator.ts:28`）先清空，再扫活跃分支里已完成的 `Read`、`Write`、`Edit` 工具 part，只恢复带结构化元数据（修改时间、修订号、大小、内容）的完整读取；带偏移或行数限制的局部读取跨恢复一律不认（`read-file-state-hydrator.ts:90`、`read-file-state-hydrator.ts:170`）。它也从不去读当前磁盘来“补全”，理由写在注释里：外部手动保存会被误认成 Agent 已读（`read-file-state-hydrator.ts:125`）。结果里的 `skippedUnreadableEditCount` 在当前代码里初始化为 0 之后再没有递增过（`read-file-state-hydrator.ts:50`）。

给人看的“转录”是另一条投影：TUI 恢复后显示的历史来自 `projectSessionTranscript`，它跳过摘要消息和只给模型看的用户消息（`apps/zcode-cli/packages/bootstrap/src/session-transcript.ts:59`），与上面喂给模型的历史不是同一份。

## 存储准备模式

`--prepare-storage`（`apps/zcode-cli/packages/cli/src/arguments.ts:77`）目前只有桌面端调用。桌面 Host 启动时有一道数据库启动门：先在 Worker 线程里迁移自己的任务索引库 `tasks-index.sqlite`，再对每个候选工作目录，在 Worker 里跑同一个 CLI bundle 的 `app-server --stdio --prepare-storage --cwd <目录>`，最后才启动服务（`packages/desktop/src/host/hostDatabaseStartup.ts:46`、`packages/desktop/src/host/storagePreparationProcesses.ts:131`）。按目录逐个准备，是因为相对路径的 `sessionDbPath` 要按各自的工作目录解析；同一次准备里真实路径相同的库只迁移一次（`storagePreparationProcesses.ts:171`）。这个模式下 CLI 不改进程名、不装协议生命周期、不准备 SEA 运行时工具和 provider 环境（`apps/zcode-cli/packages/cli/src/main.ts:19`），bootstrap 也只做存储（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-entrypoint.ts:85`）。

stdout 上是一问一答的控制帧（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/storage-startup.ts:105`）：

```ts
  try {
    await write({ method: "startup/storagePath", params: { path: options.dbPath } });
    const reuse = await acknowledgement;
    clearTimeout(timer!);
    lines.close();
    // reuse 仅由同一次 Host 准备的成功路径集合授予；不打开连接，也不写永久跳过标记。
    if (!reuse) {
      store = await openProtocolStartupStorage(options);
      store.close();
      store = undefined;
    }
    await write({ method: "startup/storagePrepared", params: {} });
```

先报库路径，等 Host 回一行 `startup/storagePathReady`（Host 要先给这个路径的磁盘占用采个基线，`hostDatabaseStartup.ts:30`），30 秒收不到就以 `startup_status_timeout` 失败（`storage-startup.ts:78`）；随后迁移，每个阶段都以 `startup/storageState` 帧报告，最后发 `startup/storagePrepared`。失败原因被归成 12 种错误码，比如 `storage_full`、`corrupt`、`lock_timeout`、`checksum_mismatch`，跨进程只传错误码和迁移 ID，不带 SQL 或文件内容（`packages/shared/src/database-startup.ts:3`、`database-startup.ts:43`）；其中损坏、校验和不符、传输中断等几种不给手动重试，只能退出重开（`database-startup.ts:164`）。桌面端这道门的界面与服务启动见[桌面应用](https://daiw.org/manual/zcode/desktop)。

最后一个细节印证了“打开即迁移”：`apps/zcode-cli/scripts/shadow-replay.mjs` 要拿真实库做冷恢复对账，默认先把库连同 `-wal`、`-shm` 复制到临时目录再打开，因为“store 打开时会跑迁移”（`apps/zcode-cli/scripts/shadow-replay.mjs:6`）；调试界面读库则用只读连接（`apps/zcode-cli/packages/debug/server/sources.ts:75`）。

下一篇：[检查点、回退与分叉](https://daiw.org/manual/zcode/rewind-fork)——Write 与 Edit 留下的文件快照怎样撤销，“回退对话”为什么只移游标不删消息，分叉又复制了哪些行。
