Skip to content

Latest commit

 

History

History
542 lines (452 loc) · 98 KB

rust-m11-node-storage.md

File metadata and controls

542 lines (452 loc) · 98 KB

Rust M11:与 Node 共用会话数据库

1. 背景与目标

此前 Rust 使用独立的 ~/.zcode/rust/rust-sessions.sqlite,首次打开工作区时只读导入 TS 历史(rust-cli-core.md、rust-app-p0.md 的"TS 原库只读,Rust 使用独立 SQLite")。这一决定来自"可独立运行的核心切片"阶段,与"替换用户 App 中的 CLI"的目标冲突:

  • 切回 Node 后,Rust 期间新建或继续的会话不可见;
  • 每个工作区导入时复制一份完整 TS 库(实测每份约 1 GB);
  • 两份数据各自演进,无法双向切换。

本里程碑的目标:Rust 与 Node 读写同一个会话数据库,记录格式与 Node 完全一致,用户可在两个 runtime 之间任意切换,历史双向连续。

依据(均以当前检出源码为准):

  • 写入:apps/zcode-cli/packages/adapters/src/storage/session-store/**、adapters/src/storage/session-target.ts、core/src/runtime/methods/{message-persistence,turn*,tool-part-persistence,timeline-persistence,compact-persistence,stable-fork-boundary,events,steering}.ts;
  • 读取:core/src/runtime/methods/resume.ts、core/src/agent/session-history-hydrator.ts、bootstrap/src/zcode-protocol-v4/{transcript-hydration,cold-event-merge,persistent-command-facts,command-inbox}.ts、packages/shared/src/conversation-message-projection-policy.ts;
  • 启动:adapters/.../migration-runner.ts、bootstrap/src/zcode-protocol/storage-startup.ts、packages/desktop/src/host/storagePreparationProcesses.ts。

2. 产品规则

2.1 单一数据库

  • 数据库路径与 Node 相同,解析规则如下(后者覆盖前者):

    1. 默认 ~/.zcode/cli/db/db.sqlite;
    2. 用户配置 ~/.zcode/cli/config.json 的 storage.sessionDbPath;
    3. 环境变量 ZCODE_SESSION_DB_PATH 或 ZCODE_SESSION_DB。

    只展开 ~/ 前缀;相对路径在 --prepare-storage 时相对 --cwd,否则相对进程 cwd。ZCODE_DATA_BASE_DIR、ZCODE_STORAGE_DIR 与项目配置都不移动数据库,与 Node 协议入口一致。

  • Rust 不再创建或读取 rust-sessions.sqlite,不再导入 TS 历史,不再复制备份。--import-ts-db 参数删除。旧的 ~/.zcode/rust/rust-sessions.sqlite 不迁移(其中只有 Rust 试运行期间的会话),由用户自行删除。

  • 产物(工具结果落盘、附件、媒体、checkpoint)与 Node 同根:<storage.dir>/cli/artifacts/<会话目录>/,URI 为 zcode-artifact://<sessionId>/<artifactId>。storage.dir 默认 ~/.zcode,可由 ZCODE_STORAGE_DIR 覆盖。

2.2 表结构与迁移

  • Rust 内嵌 Node 的 22 个迁移,SQL 原文逐字节一致,校验和为 sha256(sql.trim()),与用户现有库核对一致。
  • 启动流程与 Node 的 migration-runner 相同:
    1. busy_timeout 5000 ms,foreign_keys = on;
    2. journal_mode 不是 WAL 时切换为 WAL,失败则报 sql_failed;
    3. 预检:已应用的已知迁移逐个比对校验和,不一致报 checksum_mismatch;
    4. begin immediate,建 schema_migration,按数组顺序补齐缺失的已知迁移,同一事务提交。
  • 未知的已应用迁移(如官方构建的 0017_todo_state_revision、0023_session_soft_delete_outcome)、未知表和未知列一律忽略。
  • Rust 不新增任何表、列或迁移。Node 不持久化的状态,Rust 也不持久化(见 §4 的"仅内存"项)。

2.3 记录格式

  • 所有写入沿用 Node 仓储层的 SQL 原文,包括 upsert、legacy 字段保留的 json_set 子查询、sequence 的 coalesce(max(..),-1)+1 分配、touchSession 的 max() 语义,以及事务边界(Node 用 begin immediate 的地方 Rust 同样使用)。
  • JSON 按 Node 的对象字面量顺序生成键,undefined 字段省略,数值按 JS JSON.stringify 规则输出(整数值的浮点数不带 .0)。
  • id 格式:
    • 会话 sess_<uuid>;
    • 消息 msg_<Date.now() 的 36 进制>_<uuid>,part part_<同上>_<uuid>,轮次 turn_<uuid>;
    • 子代理子会话 sess_subagent_agent_<uuid>(agentId agent_<uuid>);
    • 输入账本 queue_<commandId>;
    • 其余 entry 的 id 模板见 §4。
  • 读取与 Node 相同:JSON.parse 后展开;user 消息丢弃 legacy model,严格校验 modelSelection;assistant 丢弃 providerID/modelID/variant;model_change 与 subtask part 还原字段。

2.4 与现有 Rust 行为的差异(全部改为 Node 语义)

项目 原 Rust 改后(Node)
流式正文 每 250 ms checkpoint 行 不落库;模型步骤结束后写入,取消时写入已收到的部分
界面行 行与行号持久化 不落库;冷加载从消息与 part 重建,行号为回放计数,重启后重新编号(快照 epoch 本就变化)
回退与编辑 截断行与消息 只追加;写 session.revert 的分支切点,读取时选取活动分支
命令回执 每个命令的 ACK 持久化 只按 Node 的优先级从耐久事实反查:user 消息 anchor.sourceCommandId、timeline part sourceCommandId、v4/command_fact、session_input 终态行
未启动的排队输入 重启后按 ACK 判定丢弃 session_input 中 admitted 行在会话恢复或冷查询时改为 discarded/session_resumed
工作区设置 以 workspace identity 为键 local_setting 的 (project, project_id),project_id = projectIdFromDirectory(path)
工具结果与附件 ~/.zcode/rust/tool-results、attachments Node 产物目录与 zcode-artifact://

2.5 与 Node 的有意差异

  • Node 的 runtime_command_<n> 与 pending_deferred_<n> 在每个进程从 1 计数,而 session_input.id 是全库主键,两个 runtime 共用库时会互相覆盖行。Rust 对这两类 id 使用 <前缀>_<uuid>。Node 不解析这些 id,因此不影响兼容。
  • 其余已知 Node 缺陷(如不过滤 time_deleted、v4_command_fact:timeline:* 等不含会话 id 的全局 id)保持一致。
  • 一个会话的待写记录在同一事务提交,任何写入失败(含 workspace checkpoint entry)都会终止该会话 actor;Node 对 checkpoint 写入失败只记 warn 并继续。
  • session/read(旧协议)的消息由界面行投影,重启后 id 与时间随新 epoch 变化;Node 直接返回存储的消息。
  • 子会话与恢复会话的环境快照日期按本次运行重新取(Node 恢复时同样重建上下文),Git 等环境事实取第一条带 contextSnapshot.envInfo 的 user 消息(Node extractPersistedEnvInfo);Rust 不写 nodeVersion。

3. 状态所有者与依赖方向

flowchart LR
  subgraph Rust进程
    A[Session actor<br/>运行时状态唯一所有者] -- NodeJournal 写操作 --> W[存储 worker<br/>本进程唯一 SQLite 连接]
    A -- 冷加载请求 --> W
    W -- Node 记录 --> R[冷投影<br/>行/模型上下文/会话状态]
    R --> A
  end
  subgraph Node进程
    N[SqliteSessionStore]
  end
  W <--> DB[(db.sqlite<br/>WAL)]
  N <--> DB
  H[Host owner/lease] -. 保证同一会话只有一个 live runtime .-> A
  H -.-> N
Loading
  • domain(无 IO):Node 记录构造器(消息、part、entry、输入账本、会话行),按 Node 键序生成 JSON;冷投影与历史还原的纯函数。
  • core:Session actor 在与 Node 相同的时机调用 NodeJournal,把待写记录排入会话;persist 把排队的写操作连同其他耐久事实交给存储。行与模型上下文仍是运行时内存状态。
  • state:存储 worker 持有唯一连接,按顺序执行写操作。一次 persist 在一个 begin immediate 事务中提交。冷加载读取 Node 表并调用 domain 的冷投影。
  • 进程间:只依赖 SQLite 锁(WAL、busy_timeout、begin immediate),与 Node 相同。同一会话的 live 归属由 Host owner/lease 负责,不在库中加锁,Rust 进程也不再持有工作区文件锁;同一 cwd 可同时运行多个 Rust 进程。

4. Rust 状态到 Node 记录的映射

Rust 会话状态 Node 记录 说明
id、标题、标题来源、创建/更新时间、父会话、task_type、归档、trace、workspace 路径与 identity session 行 project_id = projectIdFromDirectory(path);slug = slugify(id);directory = path = workspacePath;本地 workspace_id 为 NULL,远程为 identity;version 为 Rust CLI 版本;permission = {"mode": 模式}
provider、model、推理档位 runtime/model_selection(id <sid>:runtime-model-selection,touchSession:false);user 消息 modelSelection;assistant providerId/modelId 恢复时按 registry 校验,失败回退到只含 provider 与 model
执行模式、plan 开关 runtime/execution_state(id <sid>:runtime-execution-state) 会话首次落库时写入;模式变化时写入
shell 选择 runtime/bash_shell_selection(id <sid>:runtime:bash_shell_selection) 首次落库写一次
界面行 不存 冷投影重建(§6)
模型上下文 message / part 冷加载按 Node 的 history hydrator 重建
输入/响应边界(编辑、重试、fork) user 消息与各轮最终 assistant 的 anchor 最终 assistant 带 historyRoundCount、orderedMessageIds、boundaryMessageId、goalBoundary
压缩状态 时间线宿主 assistant(timeline context_compaction + compaction part)与摘要 user 消息(summary、compaction part 带 compactBoundary) 与 Node 相同
todo todo 表(替换式写入)
goal session_target;target_completion_verification entry;goal_verification timeline part
子代理 子会话行(subagent_child,parent_id);父会话 Agent 工具 part 的输出含 agentId: 行
文件 checkpoint 与文件回退 checkpoint 产物;runtime/workspace_checkpoint、runtime/workspace_file_rewind entry
共享上下文导入 v4/shared_context_import entry 与上下文消息
全权限授权 runtime/permission_full_access entry 与 execution_state
提问自动结算 runtime/user_input_auto_resolution entry
命令回执 不单独存(§7)
排队输入 session_input 重启不保留队列
用量 model_usage、turn_usage、tool_usage 列与 Rust 现有实现一致,只去掉 rust_ 前缀
工作区设置 local_setting permission/ruleset、permission/mode
输入历史 input_history 只在 Rust 有对应入口时写
后台任务、mailbox、skills 目录、提示快照、hook 调用行、重试状态 仅内存 Node 同样不持久化

实现各期逐项核对上表;Node 未持久化的字段不新增落库,Node 持久化而 Rust 缺失的字段补齐。

5. 写入时序(一轮带工具的对话)

sequenceDiagram
  participant C as Client
  participant A as Session actor
  participant J as NodeJournal
  participant W as 存储 worker
  C->>A: sendText(commandId)
  A->>J: 会话未落库则 createSession + model_selection + bash_shell + execution_state
  A->>J: session_input 准入(queue_<commandId>, admitted)
  A->>W: persist(事务提交)
  A-->>C: ACK(持久化之后)
  A->>J: promoteSessionInput:user 消息 + text/file part + 输入行 promoted
  A->>W: persist
  loop 每个模型步骤
    A->>J: assistant 消息(未完成)+ step-start
    A->>W: persist
    Note over A: 流式正文只进内存行
    A->>J: reasoning part、text part、工具 part pending
    A->>J: 工具 part running
    A->>W: persist(工具执行前落库)
    A->>J: 工具 part completed/error
    A->>J: step-finish + assistant 完成(completed、finish、tokens)
    A->>W: persist(下一次模型请求前落库)
  end
  A->>J: 最终 assistant 重写 anchor(稳定 fork 边界)
  A->>W: persist
Loading

5.1 NodeJournal 与 NodeStore

flowchart LR
  E[Engine<br/>会话 actor] -- 事件钩子 --> J[Session.node<br/>NodeJournal]
  J -- 待写记录 --> E
  E -- persist/commit --> S[NodeStore<br/>SessionStore 实现]
  S -- begin immediate --> DB[(Node db.sqlite)]
  S -- load_session/index/list/ack --> R[冷读取<br/>§6]
Loading
  • NodeJournal(domain,纯函数、无 IO)挂在 Session.node:记住当前轮与当前模型步骤的 Node id(用户消息、assistant 消息、各 part、工具 part 的声明序号与开始时间),在与 Node 相同的时机按 Node 写入模板生成记录,排入待写队列。
  • NodeStore(state)实现 core 的 SessionStore:一次 commit 把会话的待写队列在一个 begin immediate 事务里交给 M11.1 的仓储函数;load_index、list_sessions、load_session、lookup_ack 走 §6、§7 的冷读取;用量写入 Node 的 model_usage/turn_usage/tool_usage;项目设置写 local_setting。
  • 唯一实现:M11.5 起 app-server 与 -p 都只使用 NodeStore,数据库与产物根按 §2.1 解析(zcode_cli_host::storage_paths);旧存储、导入与 --node-storage 开关已删除。data dir 只放 Rust 工具缓存,不放锁:原工作区 owner 锁(workspace-<sha256>.lock)已删除,会话 live 归属只由 Host owner/lease 保证(§3、§9)。
  • 开关:SessionStore::node_journal() 只有 NodeStore 为 true。测试替身为 false 时引擎不调用任何 NodeJournal 钩子。记录范围是根会话与已存在于库中的子会话(fork、子代理)。
  • id:新会话 sess_<uuid>(NodeStore 时);消息与 part 用 Node 格式(毫秒 36 进制 + uuid);anchor.turnId 为 turn_<轮 uuid>。
  • 提交:commit 取出会话的待写队列,在 worker 上以一个 begin immediate 事务执行;失败时整批写操作放回 Session.node,下一次提交重试,不丢失也不重复。ack 参数不落库(Node 不持久化回执,§7)。
  • 读取:load_index 为 §6.2 的冷种子;list_sessions 按 §6.2 过滤后以 path ?? directory 构造工作区引用;load_session(workspace, id) 只返回该工作区(workspaceID.trim() || path || directory 等于给出的 key)的会话;引擎用请求指明的 workspace,未指明时用默认 workspace(rust-cli-architecture.md「Workspace 作用域」规则 5),会话带库中的 key 与路径(path ?? directory);按 §6.1 恢复,并把 admitted 输入结算为 discarded/session_resumed;zcode-artifact:// 按 Node NodeToolArtifactStore 的目录规则读取(文本产物按 UTF-8,其余 base64)。冷读取另算活动分支的上下文用量(Node contextUsageFromPersistedMessages:最近压缩摘要的压缩后计数,否则最后一个 assistant 的 tokens.total 或 input + output),引擎载入后按会话模型的窗口写入 usage.contextWindow{usedTokens, maxTokens, autoCompactThresholdTokens: null}(Node 冷用量种子);冷行投影本身不带窗口(store 不持有模型 registry)。
  • 项目设置:permission/ruleset 以 core 的 projectIdFromDirectory(空 slug 为 session)为 scope id,permission/mode 以 bootstrap 的同名函数(空 slug 为 default)为 scope id,两者都带 proj_ 前缀,与 Node 两处写入方一致。
  • 用量写入 Node 的 model_usage、turn_usage、tool_usage。
  • 附件(M11.4h):提示附件只存 Node 产物,不再有 Rust 自己的内容寻址目录,见 §5.3。

5.2 事件到 Node 写入的对应

Rust 时机 Node 写入(模板)
会话首次落库(首个输入准入) createSession(title = titleFromInput、titleSource = first_input、permission = {mode})、runtime/model_selection、runtime/bash_shell_selection(Rust 工具实际使用的 shell:/bin/bash,Windows 为 cmd.exe;Node 按 $SHELL 可能选 zsh,Rust 执行不跟随 Node 的选择)、runtime/execution_state(events.ts)
输入准入(startNow) user 消息 + text part(persistUserPrompt:semantics real_user、anchor、modelSelection、contextSnapshot{envInfo}、metadata.conversationInputIntent 等)。Rust 在运行内才取首个环境快照:此前写入的 user 消息在快照提交时带上 contextSnapshot 重写一次(Node 先初始化上下文再写消息)
模型步骤开始 assistant 消息(无 completed)+ step-start part(turn-model-step.ts)
ModelDone reasoning part(按 provider 推理块拆分,metadata 为 providerOptions)、text part;无工具调用时 step-finish + assistant 完成(finish、tokens)
ModelDone 带工具调用 每个调用一个 pending 工具 part(declarationIndex、input、raw)
ToolStart 工具 part running(time.start)
ToolDone 工具 part completed(output、metadata.schemaVersion,带媒体时另有 attachments 与 metadata.modelContentLayout,§5.3)或 error(error、metadata.modelContent)
一个步骤的工具全部完成 step-finish + assistant 完成(finish = tool-calls)
轮次成功结束 最终 assistant 重写 anchor(historyRoundCount、orderedMessageIds、boundaryMessageId、goalBoundary)
轮次失败 当前步骤 assistant 完成并带 error{name, data{message, code?, attribution?}}
取消 已流式到达的 reasoning/text part,assistant 完成并带取消 error(data.turnResult = cancelled)
流恢复重试 旧步骤 assistant 带 StreamRecoveryDiscarded error、finish = stream_recovery_discarded,新步骤另起消息
标题、模型、模式、todo updateSession、runtime/model_selection、runtime/execution_state、todo
生成标题 协议会话(app-server;-p 与 legacy titleGenerationEnabled: false 不生成)的首条真实输入(规整后 ≥ 10 字符,/goal 取目标文本)启动后,异步发标题 sidecar(Node title-generation-sidecar.ts:系统提示 + 规整输入,无工具,辅助模型,60 s 超时;需要刷新运行时请求头的账号 provider 延到本轮成功结束后)。清洗后的标题写 updateSession{title, titleSource: generated, titleMessageID}(expectedTitleSources = default/first_input/generated,自定义标题或首条输入已被编辑时跳过),并发 legacy session.titleUpdated;/goal 同时写 session_target.summary_title,无法生成或失败时退回目标文本。用量记 model_usage(query_source = session_title/goal_summary_title)
权限询问选择完全访问 commitPermissionFullAccess:排队中(admitted)输入的 intent、conversationInputIntent 的 mode 改为 yolo,写 runtime/execution_state 与回执 runtime/permission_full_access(id <session>:permission-full-access:<interactionId>,touchSession: false,data{interactionId, event},event 为 session_mode_changed);回执已存在时不重复写。账本行缺失时跳过该行(Node 整体回滚,Rust 的提交失败会重试,不能卡住后续写入)
提问自动结束阶段变化 runtime/user_input_auto_resolution(id user-input-auto-resolution:<interactionId>,同一交互覆写;time.created = startedAt;data{interactionId, toolCallId, autoResolution, eventId, sequenceNumber, traceId, turnId?}):进入隐藏宽限、转为可见倒计时、暂停时各写一次,到期应答不单独记录。sequenceNumber 取会话 revision(Rust 没有 Node 的内存事件序号)
忙时输入(排队/引导) 账本行 queue_<commandId>(TurnSteerQueued 形态:intent 带 queuePosition,conversationInputIntent.dispatch = queued,delivery = queue/guide)
忙时立即发送(抢占) 账本行为 admission 形态(delivery = startNow);提升时 user 消息 metadata.inputPresentation = user_steer
排队输入提升为新轮 promoteSessionInput:user 消息 metadata.inputIntent(排队时的 intent)与 conversationInputIntent.dispatch = drained
引导输入并入当前轮 promoteSessionInput:user 消息 metadata.turnSteerDelivery = guide,无附件时 inputPresentation = user_steer;anchor.turnId 为当前轮,不开新轮
队列编辑、重排 updateSessionInputs:编辑写 text;重排按新顺序给每项写 queuePosition(运行时队列项的 order.queuePosition 同步更新)
删除排队项、清空队列 settleSessionInput:cancelled/user_removed
引导回退为排队 updateSessionInputs:delivery = queue,intent 为回退后的完整 intent(fallbackReasonCode)
编辑、重试(重跑前) setRevert:keptMessageIDs 为活动分支中目标 user 消息之前的消息,branchCutAfterMessageID 为已存最后一条消息,branchGeneration 加一,messageID 为保留前缀最后一条(为空时取锚点),kind = conversation_rewind,scope = conversation,targetMessageID 为编辑的 user 消息或被重试的 assistant 消息
编辑、重试(重跑输入) 账本 queue_<命令>(sourceCommandType = editUserQuery/retryTurn),intent 带 provenance(原输入自身的来源,否则指向原命令);随后按普通输入提升
分叉(forkAssistant) Node 原子 fork 包,与子会话一起在一个事务中提交:子会话行(taskType = fork、Fork of <标题>、slug 与父会话一致规则);活动分支前缀与目标稳定分段按子会话本地 id 复制(消息、part、轮次、工具调用、goal 与验证 id 全部重映射,带 copy source);fork 提示(model-only 提醒与分隔线);runtime/model_selection、runtime/execution_state;引用到的 goal 验证 entry 与 goal;父会话 v4_command_fact:child:<父>:<命令>;父会话边界锚点缺 productTurnId 或 goalBoundary 时补写。子会话随后按冷加载注册,运行时与重启后一致
选区侧聊(createSelectionSideSession) 同一个原子 fork 包(kind = selection_side_chat,Node createSelectionSideConversation):子会话 taskType = selection_side_chat、标题 Selection side chat、titleSource = generated;复制父会话已落库的活动对话(父会话运行中只到本轮真实输入),去掉 goalBoundary,每条复制消息 visibility = model-only、界面与 transcript 隐藏;追加 selection_side_chat 边界提醒;模型选择为首条输入推荐或父会话当前选择,执行状态取父会话当前值;不复制 goal 与验证 entry;父会话写 v4_command_fact:child(结果类型 createSelectionSideSession,无 forkTarget)。首条输入随后在子会话按普通 sendText 准入,父会话的队列与运行不受影响
压缩开始 时间线宿主 assistant(timeline_event)+ context_compaction 时间线 part + compaction part(timelineStatus = started,operationId = cmp_<uuid>,自动压缩带 attempt/maxAttempts;手动压缩带轮头的 sourceCommandId)
压缩完成 摘要 user 消息(summary、compact_summary 语义、文本 part 为摘要消息正文、compaction part 带 compactBoundary)与压缩后提醒消息(plan_file_reference、resume_referenced_session_context),随后时间线置 completed;保留段在已存活动消息中按助手轮取最后 groupsPreserved 轮(Node selectPersistedCompactTail),lastSummarizedMessageId 为最近一条对话消息
压缩无内容、失败、中断 时间线置 skipped、failed 或 interrupted(运行被取消或失败时收口未完成的压缩)
todo 提醒、@plugin 引用 合成 user 通知(todo_reminder、plugin_reference,model-only,Node persistSyntheticUserNoticeForSession);引用提醒在本轮首个模型请求前提交,冷读取按同一 source 还原
切换模型(switchModelConfig) 记为待写的模型切换(连续切换保留首次来源,切回原模型即清除),下一轮开始、写 user 消息之前落为 model_change 时间线(宿主 msg_<requestId>_message,part part_<requestId>_timeline,锚点为最近一条对话消息)
子代理启动 子会话 sess_subagent_agent_<uuid>(agentId = agent_<uuid>):会话行以任务提示为标题(first_input,subagent_child,parentID),选型与执行状态 entry,首轮前记子会话模型的模型切换(无来源);任务提示为 user 消息(metadata.inputPresentation = coordinator_input,无账本);子会话消息的 agent 为 zcode-<代理类型>。SendMessage 唤起空闲子会话时同样以 coordinator_input 写入
后台子代理完成 父会话账本 runtime_command_<uuid>(backgroundNotification,payload.originMeta = {backgroundSource: subagent, title, workId}),background_task 合成通知(inputPresentation = task_notification,锚点 backgroundResult)开启新轮,账本行置 promoted;运行时模型消息按 Node 呈现(系统通知前缀并包装为 incoming_message 提醒)
运行中子会话收到 SendMessage 账本 pending_<turnId>_<id>(guide),提升为 user 消息(turnSteerDelivery = guide,inputPresentation = coordinator_steer,锚点 sourceCommandId 为消息 id);运行时模型消息按 coordinator_steer 呈现
工具阶段结束(取消或失败) 未完成的工具写 error part(内容与 metadata.modelContent 为运行时历史中该调用的结果);取消时步骤按工具步骤收口(step-finish、finish = tool-calls),失败时带 error
  • Rust canonical assistant 到 Node part 的映射是 §6 冷读取映射的逆:Anthropic thinking 块 → 每块一个 reasoning part(metadata.anthropic.signature / redactedData),Responses 推理项 → metadata.openai.{itemId, reasoningEncryptedContent},其余 reasoning_content → 一个无 metadata 的 reasoning part;工具参数解析为对象作为 input;失败工具的模型可见内容写 metadata.modelContent。

  • 验收:用 NodeStore 跑脚本化会话,把 Node 库按 §6 冷读取,模型上下文与运行时一致,界面行的种类、文本、工具状态一致;Node 侧用真实 SqliteSessionStore 读取同一库无解码错误。

  • 步骤开始即落库:model_request_started(querySource 为 main_turn/subagent,且没有未完成的步骤)时写 assistant 与 step-start,并立即提交;同一步骤的重试(attempt ≥ 2)不另起消息。

  • 取消:写入已收到的 reasoning 与 text(time.start 为 assistant 创建时间),assistant 带 completed 与取消错误,不写 step-finish,与 Node persistCancelledStreamSnapshot 相同。

  • 呈现:Node 在请求时按 inputPresentation 格式化 user 消息(formatIncomingMessage),Rust 运行时把格式化后的正文存进消息;冷读取转回 Rust 形态时按同一规则格式化。Node 的引导输入不带 inputPresentation(以普通 user 消息出现),Rust 运行时以 user_steer 呈现引导输入,落库同样标注 user_steer,使重启后的模型上下文与运行时一致,Node 读取时按插话呈现。

  • 已知差异:运行时在工具阶段被取消时,Rust 内存历史给未完成调用补的结果文本与 _zcode_tool_failed = false 来自 Rust 实现,Node 为 Tool execution cancelled 且记为失败。落库沿用 Node 语义(error part),因此重启后该调用在模型上下文中记为失败;运行时文本与 Node 对齐另行处理。

  • 崩溃:已落库的 pending/running 工具与未完成的 assistant 保持原样,读取端按"中断"投影,不改库。

  • 工具执行前与下一次模型请求前的两个耐久屏障沿用 rust-cli-core.md 的规则。

编辑/重试的写入顺序(同一次 persist 内按序提交):

sequenceDiagram
  participant C as Client
  participant A as Session actor
  participant J as NodeJournal
  participant W as 存储 worker
  C->>A: editUserQuery / retryTurn
  A->>A: 停止当前运行(取消收口写入先入队)
  A->>J: Rewind(目标 user 消息, 锚点)
  A->>A: 截断内存行与模型上下文
  A->>J: session_input queue_<命令>(provenance)
  A->>J: promoteSessionInput:新 user 消息开启新轮
  A->>W: persist:setRevert 基于此前已存消息计算,再写新输入
  A-->>C: ACK disposition=rewind
Loading

忙时输入的账本时序(排队后提升):

sequenceDiagram
  participant C as Client
  participant A as Session actor
  participant J as NodeJournal
  participant W as 存储 worker
  C->>A: sendText(c2)(当前轮运行中)
  A->>J: session_input queue_c2(admitted, queue, dispatch=queued)
  A->>W: persist(事务提交)
  A-->>C: ACK delivery=queue
  Note over A: 当前轮 Finished
  A->>J: promoteSessionInput(queue_c2):user 消息 + text part,账本 promoted
  A->>W: persist
  A->>A: 开始新轮
Loading

5.3 提示附件

与 Node writePromptAttachment、mapAttachmentRefsToTurnAttachments、resolveTurnAttachments 与 persistUserPrompt 一致:

  • 上传提交(v4/attachment/commit):字节以 data URL(data:<mime>;base64,…)写入 Node 产物 <artifacts>/<会话目录>/prompt-attachment-upload-<毫秒 36 进制>-<8 位随机>-tool-result-<uuid>.txt,回执的 ref 就是该产物的 zcode-artifact:// URI。本地路径的图片、视频、PDF 在准入时读成快照并以 attachment-<序号> 写入同一目录(Node 在开轮时写,Rust 仍在准入时写,排队期间文件被改不影响已接受的输入),ref 改为产物 URI;其余本地文件不写产物,保留路径引用。
  • 字节所有者:StoredAttachment.data_url = true 表示 path 是 data URL 产物、total_bytes 为解码后的字节数。读取(预览分块、PDF 头校验、模型请求)都从产物解码;冷会话与 Node 上传的附件不在 Session.attachments 中时按 URI 从产物根定位(SessionStore::attachment_of)。
  • 准入时按 Node 规则解析每个附件,结果(file part 主体与 Node 模型块)挂在 StoredAttachment.node,提升时写入 user 消息(text part 之后,每个附件一个 file part),模型请求按同一结果组装:
附件 file part 模型输入(Node buildRuntimeUserEntriesFromTurn)
上传图片 url = 产物 URI;metadata{recoverability: provider_ready, sizeBytes: data URL 长度(Node 行为), storageKind: artifact, artifactUri} 图片放在正文与其他块之后(粘贴图片)
上传视频、PDF 同上,sizeBytes 为解码字节数;PDF 带 filename 紧随正文
上传其他文件 ≤ 64 KiB mime: text/plain、filename、url = 文件名、metadata{originalUrl, preview{text, truncated: false, originalBytes}, recoverability, sizeBytes, storageKind: inline}(UTF-8 有损解码) user 消息之后的 prompt_attachment 提醒(inline text)
上传其他文件 > 64 KiB、空文件 占位:mime: text/plain、url: ""、metadata{errorCode: attachment_read_failed, recoverability: metadata_only, storageKind: metadata_only} prompt_attachment 提醒 [Attached text/plain: attachment-N]
本地图片、视频、PDF filename、url = 产物 URI、source{type: file, path, text}、metadata{originalUrl, recoverability, sha256, sizeBytes, storageKind: artifact, artifactUri} 紧随正文
本地文本(Node 文本扩展名) filename、url = 原路径、source、metadata{originalUrl, preview{text, truncated, originalBytes, startLine, totalLines}, …, storageKind: inline};超过 256 KiB 取前 2000 行 prompt_attachment 提醒(Read 工具格式)
本地其他文件 路径引用:storageKind: local_ref、recoverability: metadata_only 文本块 Attached <mime>: <path> 与原因说明
本地文件不可读 占位(errorCode、storageKind: local_ref) 不进入模型
  • 冷读取:Node hydrator 规则(§6)把 file part 还原为 Node 形态的块(image/video/file 带 dataUrl,文本附件为提醒);模型请求把 Node 块与 _zcode_attachment 一样转换为 provider 格式,并套用同样的能力与大小检查。
  • 提示图片(M11.6,Node prepareImageDataUrl):准入时按 Node Jimp 规则准备图片(长边 ≤ 2000、base64 ≤ 5 MiB、原始字节 ≤ 3.75 MiB,无 token 上限;与 Read 图片共用 zcode_cli_host::image)。metadata.image 为 {height, maxDimension: 2000, originalHeight, originalWidth, resized, transformedSizeBytes, width}(WebP 原样通过时无尺寸),排在 metadata 首位;无法缩放时为占位 attachment_image_resize_failed,解码失败为 attachment_image_invalid。
    • 本地图片:mime 按扩展名推断(Node inferImageMimeFromPath,缺省 PNG),写入产物的是准备后的字节,part 与块的 mime 为准备后的类型;sizeBytes 与 sha256 为原文件的。冷读取得到的也是准备后的图片。
    • 上传图片:产物保持上传的原始 data URL(Node 复用已有产物),part 与块的 mime 为准备后的类型,块的 source.mimeType 与 sizeBytes 仍按原始内容;本次运行的请求使用准备后的字节(Rust 写在 data dir 的 tool-results/prompt-images,StoredAttachment.prepared)。与 Node 一样,冷读取按产物发送原图。
  • 本地视频上限 30 MiB(Node VIDEO_INPUT_MAX_BYTES),图片与 PDF 仍为 20 MiB。
  • 与 Node 的差异:本地文本不做 Node Read 的 token 上限截断(truncatedByTokenCap、partialViewNotice);空的非媒体上传按占位处理(Node 会按文件名去工作区找本地文件);音频附件仍在准入时拒绝。
  • 模型请求媒体预算(M11.6,Node projectMessagesForModelMediaPolicy):每个请求先做能力投影(不支持的媒体换成说明文本),再按编码后的 data URL 字节(data:<mime>;base64, 加 base64 长度)统计全部消息中的图片、视频与 PDF,总量上限 40 MiB:
    • 未超限时全部发送;超限时最新真实用户消息(role: user、无 _zcode_source、非工具结果、正文不以 <system-reminder>/<task-notification> 开头)中的媒体受保护,其余历史媒体按消息从新到旧、块从后到前在剩余额度内保留,放不下的跳过(不中断),被省略的块换成 <占位>\n[Media omitted from provider request to keep the request body under the configured media budget.](占位与能力投影相同:[Attached <mime>: <名称>])。
    • 受保护媒体本身超过 40 MiB 时本轮失败,错误码 MEDIA_BUDGET_CURRENT_ATTACHMENT_TOO_LARGE(UI 已本地化),不可重试。
    • 预算在读取附件字节之前决定,被省略的附件不读盘。原先单块 20 MiB、总量 64 MiB 即报 context_exceeded 的检查删除;编码后请求 96 MiB 的安全阀保留。
flowchart LR
  M[请求消息] --> C[能力投影:不支持的媒体换成说明文本]
  C --> B{媒体总量 ≤ 40 MiB?}
  B -- 是 --> E[读取字节并编码]
  B -- 否 --> P{最新真实用户消息的媒体 ≤ 40 MiB?}
  P -- 否 --> F[失败 MEDIA_BUDGET_CURRENT_ATTACHMENT_TOO_LARGE]
  P -- 是 --> R[历史媒体从新到旧装入剩余额度,其余换成省略文本] --> E
Loading
  • 工具结果媒体(M11.4i,Node persistToolResultMediaAttachments):成功的工具结果带图片、视频或 PDF 块时,引擎在记录工具 part 之前把每个媒体块写成 data URL 产物(<callId>-media-<序号>),completed 状态带 attachments(file part,键序 id, messageID, sessionID, type, mime, filename, url, metadata{artifactUri, recoverability, storageKind, sizeBytes},filename 为 PDF 的文件名或占位名)与 metadata.modelContentLayout(文本块与附件序号);output 为模型内容的文本形态(媒体为 [Attached <mime>: <名称>],块之间空一行)。含其他块的内容不写媒体,只留 output。写入失败时本轮失败(与 Node 一致)。冷读取按 layout 还原为 Node 媒体块。Rust 工具自身的媒体缓存(tool-results)仍保留,供当前进程的请求使用。
sequenceDiagram
  participant C as 客户端
  participant E as Engine
  participant S as NodeStore
  participant A as 产物目录
  participant DB as db.sqlite
  C->>E: v4/attachment/begin/chunk/commit
  E->>S: put_attachment(session, call, bytes, mime)
  S->>A: 写 data URL 产物(.txt)
  S-->>E: (zcode-artifact URI, StoredAttachment)
  E-->>C: {ref: URI}
  C->>E: sendText{attachments:[{ref,…}]}
  E->>S: attachment_of(session, ref)(不在内存时)
  E->>E: 按 Node 规则解析 → StoredAttachment.node
  E->>DB: 账本行(conversationInputIntent.attachments = refs)
  Note over E: 提升/开轮
  E->>DB: user 消息 + text part + file part…
  E->>E: 模型请求:_zcode_attachment 从产物解码
Loading

5.4 目标(goal)

与 Node session-facade.ts(applyGoalCommand、updateTargetStatus)、target.ts(续跑循环)、target-completion-verification.ts、turn.ts 的目标记账一致。

  • 状态所有者:session_target 行是目标的持久事实(status 为 active/paused/budget_limited/complete,tokens_used、time_used_seconds、active_input_id、active_run_started_at、active_run_last_seen_at)。引擎内存中的 Goal 同时持有这组持久字段与 V4 GoalState 投影(status 为 active/verifying/notSatisfied/verified/paused/failed、iteration、verifications、iterations)。Goal::node_target 是持久行的唯一映射;每次提交前(以及稳定边界写入前)比较上次写入的行,变化时整行写入(Node 各个 session_target 写函数的最终结果),目标被清除时删除该行。
  • 记账(Node 语义):一轮开始时开启运行(仅 active),active_input_id 为发起续跑的命令 id;模型用量累计到本轮;本轮成功结束时 tokens_used 加本轮 totalTokens,时间按 ceil((结束 - 开始)/1000) 秒累加并关闭运行;失败只记时间,取消记时间并置 paused;进程中断后首次操作目标时按最后心跳结算并置 paused(recoverInterruptedSessionTargetRun)。Rust 在每次提交时更新心跳时间(Node 每 15 秒)。
  • /goal X(sendGoalCommand):先是一轮 control-only 轮次——user 消息为展示文本(/goal X),进入模型上下文,metadata.executionKind = controlOnly,anchor 无 turnId;行是已完成的 turnHeader 与 userInput,没有 goalSet 标记行(Node 的 goal 设置只更新状态)。随后是续跑轮:turnHeader origin = goalContinuation,首条消息是 goal-continuation 通知(model-only,metadata{targetId, visibility, source},正文为 target_continuation 系统提醒包裹的续跑提示,模型上下文中同样带包裹)。
  • 验证:续跑轮结束(无工具调用的步骤)后先结束运行并写本轮稳定边界,再验证。每个生命周期事件(started 与一个终态)写一条 target_completion_verification entry(id = eventId,data{eventId, payload, sequenceNumber, traceId},payload 键序为 anchorAssistantMessageId?, anchorTurnId?, goalIteration, status, targetId, verification?, verificationId),并写或更新 timeline 消息 msg_goal_verify_<targetId>_<iteration> 与 part part_goal_verify_<targetId>_<iteration>_timeline(timelineType = goal_verification,创建时间取首次写入)。
  • 验证结论(Node fail-open):返回无效 JSON 或调用工具记为 completed、passed = true;请求失败记为 failed_closed、passed = true;取消记为 cancelled、passed = false 并暂停目标。通过时目标置 complete;未通过且有 nextAction 时开下一轮续跑(提示带验证理由),没有 nextAction 时停止、目标保持 active。
  • 暂停/恢复/清除:paused、active 与删除目标行,并写 goal_state_change 通知(原状态不是暂停时写 “paused”,原状态为暂停时写 “resumed”,确实删除时写 “cleared”,文本与 Node 相同)。
  • 与 Node 的差异:Rust 不生成目标摘要标题(summary_title 为空);goal_state_change 通知不延迟到当前轮结束;验证序号只在进程内递增(Node 相同)。
sequenceDiagram
  participant C as 客户端
  participant E as Engine
  participant G as Goal(内存)
  participant DB as db.sqlite
  C->>E: sendGoalCommand /goal X
  E->>G: set(active,无运行)
  E->>DB: 账本提升为 user 消息 "/goal X"(controlOnly)
  E->>DB: session_target 行
  E->>G: 开启运行(active_input_id = 命令 id)
  E->>DB: goal-continuation 通知(续跑轮)+ session_target
  Note over E: 模型步骤(assistant 记录同普通轮)
  E->>G: 结束运行(tokens、时间)
  E->>DB: session_target + 稳定边界(goalBoundary 快照)
  E->>DB: verification entry(started) + timeline
  E->>E: verifier 请求(target_completion_verification)
  E->>DB: verification entry(终态) + timeline
  alt 通过
    E->>DB: session_target status = complete
  else 未通过且有 nextAction
    E->>G: 开启运行
    E->>DB: goal-continuation 通知(下一轮)
  end
Loading

5.5 工作区 checkpoint 与文件撤销

与 Node emitFileMutationCheckpoint、persistWorkspaceCheckpointEntry、persistWorkspaceFileRewindEntry、restoreWorkspaceCheckpointEntries 一致:

  • 写入:成功的文件修改工具结果(结构化输出带 filePath、structuredPatch、originalFile,即 Node getFileMutationCheckpointCandidate)在工具 part 记录时写 checkpoint 产物(<callId>-tool-result-<uuid>.json,application/vnd.zcode.workspace-checkpoint+json,内容为 JSON.stringify(_, null, 2) 的 {createdAt, files:[{afterContent?, afterContentLength?, beforeContent, existedBefore, path, structuredPatch}], kind: workspace_file_before_change, toolCallId, toolName, version: 1})与 runtime/workspace_checkpoint entry(id = workspace-checkpoint:<eventId>,payload{checkpointId: checkpoint_<uuid>, messageId, targetMessageId(本轮 user 消息), toolMessageId(本步 assistant), scope: workspace, snapshotRef, diffRef, fileCount: 1})。产物写失败只记警告(Node 相同)。
  • 撤销:文件摘要撤销(applyFileRewind)写 runtime/workspace_file_rewind(payload{rewindId, scope: workspace, strategy: active_chain, targetMessageId(第一个被撤销 checkpoint 的消息), targetCheckpointId 与 restoredSnapshotRef(最后一个), reason: file_summary_rewind})。
  • 冷读取:按 entry 顺序读回 checkpoint 产物(缺 afterContent 时由 beforeContent 套用 structuredPatch 得到修改后内容),按撤销 entry 标记“目标消息的第一个 checkpoint 到目标 checkpoint”为已撤销;引擎按工具调用 id 挂到对应的工具行,内容交给工具层保存,文件变更摘要与撤销预览照常可用。
  • 差异:Rust 的撤销以内容哈希判断文件是否被外部修改,冷读取的内容来自产物的逻辑文本,CRLF 或带 BOM 的文件在重启后撤销会被判为外部修改。

5.6 分享上下文导入

与 Node persistImportedSessionHistory(sharedContext)、commitSharedContextImportBundle、transitionSharedContextImport 与 promoteSessionInput 的附加逻辑一致:

  • 导入(legacy session/create 的 importedHistory.source = sharedContext):一次提交会话行(title 为分享标题、titleSource: custom、slug 为 slugifyImportedSession、创建与更新时间为导入时间)、上下文消息 msg_<session>_shared_context(model-only,source: shared_context,semantics{origin: import, kind: shared_context, source: conversation_share},metadata{shareId, contextId, sharedContextStatus})及其正文 part part_<session>_shared_context_text(Markdown,metadata{sharedContext: true}),和 v4/shared_context_import entry(id = v4_shared_context_import:<session>:<shareId>,data 为导入的 provenance)。不写模型选择、shell 快照与执行状态(Node 相同)。Markdown 只存于上下文消息,不再另存附件。
  • 状态:排队准入 pending → reserved(sourceId 为队列项),取消 reserved → pending,丢弃 pending → discarded,entry 与上下文消息的 sharedContextStatus 一起更新;首个引用它的输入提升时由 promoteSessionInput 置为 attached(attachedMessageId)。
  • 冷读取:由 entry 与上下文消息还原引擎的分享上下文(provenance、Markdown、预留来源与附加消息),模型上下文中 attached 的上下文消息位于输入之前。
  • Claude Code 迁移(importedHistory.source = claudeCode):会话行 upsert(title 为标题或 Imported session,创建时间为 createdAt,更新时间取 updatedAt、最后一条消息时间与创建时间的较大者),先删除本会话已有的迁移消息(Node isImportedHistoryMessage:旧固定 id、msg_claude-import- 前缀或 migrationSource 相同),再按数组顺序写 msg_<session>_import_<i>(user:metadata{migrationSource};assistant:parentID 为前一条 user 或占位 id,finish: stop、零用量)与 part_<session>_import_<i>_text;时间按顺序收敛为严格递增。模型只在导入请求带模型时写入。

6. 冷加载

flowchart TD
  S[session 行] --> B{已归档?}
  B -- 是 --> NF[SessionNotFound]
  B -- 否 --> M[message/part<br/>按 sequence 排序]
  M --> BR[活动分支:revert 切点]
  BR --> AB[再取最后一个压缩边界]
  AB --> H[history hydrator:模型上下文]
  BR --> SY[合成事件:synthesizeEventsFromMessages]
  E[session_entry] --> GE[goal 验证事实]
  GE --> SY
  TG[session_target] --> MG[冷合并:session_created 之后插入 target_changed,重排 seq]
  SY --> MG
  MG --> P[ProductProjection 批量回放:rows 与快照状态]
  E --> ST[模式、模型选择、shell、checkpoint、全权限]
  T[todo / session_target] --> ST
  I[session_input admitted] --> D[改为 discarded/session_resumed(写库)]
  CP[started/retrying 的压缩 part] --> CR[改为 completed/interrupted(写库)]
Loading
  • 行投影按 Node synthesizeEventsFromMessages 的规则:turn 以真实用户输入(guide 引导消息除外)、workflow 启动、模型专用触发消息为边界;可见性按 getConversationMessageProjectionPolicy;pending/running 工具与无 completed 的 assistant 投影为中断。
  • 行投影只裁掉 rewind 分支(selectActiveConversationBranch),不按压缩边界截断:压缩前的历史仍在时间线上,只有模型上下文从最后一个边界开始。
  • 冷投影在 zcode_cli_domain::node_rows:合成事件(Node 的事件类型、hydrate-N id、seq、展示时间戳与 payload)→ 冷合并(重启后没有内存事件,只插入持久 goal)→ 投影回放。投影只实现合成事件会触达的处理器,delta 语义与 Node 相同:处理器读取事件前的快照,delta 按序应用,回放结束后物化命令行动作。
  • 元数据解析使用 Node 的严格 schema(crates/domain/schema/node-projection.json,由 TS 的 zod schema 生成):conversationInputIntent、errorAttribution、workflowLaunch/workflowNotification、已完成工具 part 的 display。解析输出同 zod:未知键按 strip/strict/passthrough 处理,缺省值补齐,声明键按 schema 顺序。
  • V4 行与状态是解析后使用的 JSON,成员顺序不属于兼容契约;夹具按值比对(数组有序、对象成员无序)。持久化记录的字节兼容由 M11.1 保证。
  • 模型上下文按 Node hydrateMessageHistoryFromSession:pending/running 工具结果为 [Tool execution was interrupted before resume];工具 part 每个 callID 取最后一条,全部带 declarationIndex 时按其排序;assistant 无正文、无推理、无工具且无有效用量时跳过。

6.1 会话恢复事实

冷加载一个会话(Node resumeFromStore 读取的持久事实,state::node::resume):

事实 来源与规则
会话行 不存在或已归档为 SessionNotFound
模型选择 最后一条 runtime/model_selection:完整结构(parseModelSelectionValue)优先,否则只取 providerId、modelId;执行前的 registry 校验属于运行时
执行状态 以会话行 permission.mode 经 resolveExecutionState 为底;最后一条 runtime/execution_state 满足 {mode, planEnabled} 时整体覆盖
全权限标记 最后一条 runtime/permission_full_access:receipt 严格校验通过(含 interactionId 与 payload.permissionGrant.interactionId 相同)且 event.sessionId 为本会话时取 interactionId
todo、goal todo 表、session_target
轮次号 活动分支(含压缩保留段)中不带 summary 的 user 消息数
压缩时间线 恢复时停在 started/retrying 的压缩时间线收口:已存边界为 completed,否则 interrupted(Node recoverInterruptedCompactTimelines,写库)
编辑与分叉边界 冷投影给出每个可操作行的存储消息(Node messageIdByRowId)与每个真实输入的编辑目标,模型上下文给出每条消息的来源;据此重建运行时的输入边界与稳定回答边界,边界记住对应的 Node 消息 id。边界状态取加载时状态(Node 不保存逐轮快照,编辑后 todo、goal、模型选择不回退)
最新消息 不含压缩保留段的活动分支:最后一条 user/assistant 的 id;最后一条 assistant 的 id、anchor.turnId、time.completed
行与快照状态 §6 冷投影
模型上下文 history hydrator(开头的压缩摘要成为 context summary)

6.2 会话列表

  • sessions-index 冷种子(Node loadStoredSessionSummaries,不含旧远程数据的 claim):listSessions 条件为 directory = 远程 identity 解析出的路径或 workspaceId 本身,workspace_id 远程时等于 identity、本地时 IS NULL,task_type 为 interactive、fork、workflow_parent,不含归档,按 time_updated desc, id desc,上限 200。摘要字段:sessionId、workspaceId、parentSessionId(有时)、title、titleSource(custom、default 原样,其余为 generated)、phase = completedSuccess、sessionEnded = true、hasBackgroundWork = false、lastActivityAt、createdAt。
  • session/list(Node listSessions 的持久部分):给出 sessionIds 时逐个按 id 读取,否则按 directory = workspace.workspacePath、task_type 同上、includeArchived、上限默认 50 查询;再过滤归档与工作区(workspaceID.trim() || path || directory 等于 workspaceIdentity.trim() || workspacePath)。每项为 mapSessionInfo 的持久形态:sessionId、workspace(请求给出的,或由 path ?? directory 构造)、sessionKind、title、titleSource、mode = build、status = idle、createdAt、updatedAt,以及存在时的 archivedAt、parentSessionId、traceId。
  • 远程 identity 按 Node parseRemoteWorkspaceIdentity 解析(remote:ssh:<host>:<port>:<user>:<path>、remote:wsl:<distro>[:<user>]:<path>、remote:docker:<container>:<path>)。

6.3 子代理列表

session/subagents 按 Node listSessionSubagents(subagent-session-query.ts,zcode_cli_domain::subagent_query)从存储推导,常驻运行时只提供叠加事实:

sequenceDiagram
  participant C as 客户端
  participant E as Engine
  participant S as NodeStore
  C->>E: session/subagents
  E->>E: 常驻父会话的 Live(子任务关系、后台任务、进行中的工具调用、常驻子会话状态)
  E->>S: subagent_facts(parent, Live)
  S->>S: 会话行、全部消息;活动分支的 Agent/Task 工具 part → 候选子会话 id
  S-->>E: 父会话事实 + subagent_child 子会话的消息
  E->>E: project(running/ended、状态、摘要、时间)→ 按 Node 游标分页
  E-->>C: {revision, childSessionIds, running, ended}
Loading
  • 候选:agentId 取输出 JSON、launch ACK 文本(agentId: <id>)、元数据或常驻关系,子会话 id 缺省为 sess_subagent_<agentId>;标题、类型、摘要与运行/结束状态规则同 Node。
  • revision:常驻父会话为当前 revision,否则为会话行 time_updated;游标为 Node 的 base64url {childSessionId, endedAt}。
  • Node 缺陷保持一致:Agent 工具结果不带 agentId/childSessionId,被取消的前台 Agent 只留 error part,冷加载后列不出其子会话;冷投影的 subagent 行不读 subagent_type,类型为 subagent。

7. 命令幂等

与 Node CommandInbox.lookupExact 相同的优先级:

  1. 进行中;
  2. live 输入;
  3. 本进程已结算 LRU(每会话 512 条);
  4. transcript:user 消息 anchor.sourceCommandId;
  5. timeline:timeline part 的 sourceCommandId,以及 v4_command_fact:timeline:<commandId>;
  6. child:父会话上的 v4_command_fact:child:<parent>:<commandId>;
  7. 已丢弃:session_input 的 discarded/cancelled 行,按 Node 规则生成 fault.command.inputDiscardedOnRestart 或 fault.command.inputCancelled。

createSession 的全局查找按 queue_<commandId> 读取输入行(Node lookupGlobalCreateSessionCommand)。Node 不持久化回执的命令,重启后 Rust 同样不能重放其 ACK。

  • 1–3 由引擎内存负责(进行中、队列中的回执常驻,已结算回执有上限);未命中时引擎调用 SessionStore::lookup_ack_live(workspace, key, live),live 表示 key 所属会话在本进程常驻。
  • 冷查询(live = false)先把该会话 admitted 的输入结算为 discarded/session_resumed,与 Node discardAdmittedOnLoad: !live 相同;常驻会话的排队输入不受影响。
  • transcript 与 timeline 查询只读匹配的一行(json_extract 过滤,非法 JSON 视为不匹配),不加载整段对话。

8. 启动与存储准备握手

  • --prepare-storage:输出 startup/storagePath(解析后的 Node 库路径),等待一行 startup/storagePathReady(30 s)。reuse 为 true 时跳过迁移;否则打开并迁移,输出 startup/storageState 进度,最后输出 startup/storagePrepared 并退出。
  • 正常启动时先输出 startup/storageState(checking → ready/failed),databaseId = sha256(dbPath),错误码沿用 Node 的 DatabaseStartupErrorCode:lock_timeout、checksum_mismatch、sql_failed、corrupt、storage_full、permission_denied、io_error、open_failed。
  • 迁移期间等待写锁:busy_timeout 25 ms 加退避重试,总上限 1 小时,与 Node 异步启动相同;完成后恢复 5000 ms。

9. 并发

  • 每个进程一个连接,由存储 worker 串行使用;事务内不跨越异步等待。
  • 多语句写入使用 begin immediate;sequence 与账本序号在写语句内分配。
  • 跨进程的读后写(updateSession、输入历史去重)与 Node 保持相同的竞争窗口。
  • 没有进程锁:一个会话同一时间只由一个 live runtime 持有,由 Host owner/lease 保证,Rust 与 Node 相同。会话级恢复(admitted 输入结算、中断收口、未完成的文件回退事务)只在会话被加载进本进程时做,启动时不扫描其他会话。已接受的风险:终端 zcode -p --resume <id> 对桌面正在运行的会话没有保护(冷加载会把对方 admitted 的输入结算为 discarded、把未收口的运行标为中断),与 TS CLI 相同。

10. 分期

各期单独提交;每期结束时 Rust 全量测试、集成测试、typecheck、lint 通过。

分期 内容 验收
M11.1 存储基础(不接入运行时):打开与迁移、JS 兼容 JSON、serde_json 保留插入顺序、Node id 格式、仓储层(会话、消息、part、entry、输入账本、todo、target、设置、输入历史)全部使用 Node SQL 原文 迁移 SQL 由生成脚本从 Node 源码同步并校验字节一致;scripts/zcode-cli-rust-node-db-fixtures.mjs 用 Node 仓储按固定时钟与 uuid 执行一组操作,Rust 回放同一组操作后各表逐字节一致、解码结果一致;迁移账本、未知迁移、校验和不一致、锁等待测试
M11.2a 冷读取模型上下文:活动分支(session.revert 分支切点、压缩边界与保留段)、Node history hydrator 的全部规则(提醒来源、附件提醒、工具顺序与中断结果、空 assistant、共享上下文)、Rust canonical 消息与压缩摘要 scripts/zcode-cli-rust-node-cold-fixtures.mjs 用 Node 仓储写入多组 transcript,并记录 Node hydrateMessageHistoryFromSession 的结果;Rust 读同一份库后条目逐一相等
M11.2b 冷读取界面行:Node synthesizeEventsFromMessages 与 ProductProjection 冷路径用到的事件处理 同一夹具记录 Node 回放流水线(replay.ts)产出的合成事件、行与快照状态(除 seq/revision 等发布计数),Rust 逐项比对
M11.2c 冷读取会话事实与列表(§6.1、§6.2):会话行、模型选择、执行状态、全权限标记、todo、goal、轮次号与最新消息锚点;sessions-index 冷种子与 session/list scripts/zcode-cli-rust-node-session-fixtures.mjs 用 Node SqliteSessionStore 建多工作区、多类型会话,记录 Node 的列表与恢复读取结果;Rust 逐项相等。运行时接入(按需冷加载替换整库加载、编辑边界)随 M11.5 切换完成
M11.3 写入核心对话:会话创建、输入账本、user 消息、assistant 步骤、工具、取消、标题、模型与执行状态、todo、用量、设置、命令幂等 Node 读取 Rust 写入的会话:Node 仓储解码、Node 冷投影与 history hydrator 无错误且内容一致
M11.4 写入扩展:压缩、回退/编辑/重试(session.revert)、fork 与侧聊、goal、子代理、共享上下文、全权限授权、提问自动结算、后台通知、附件与产物 各功能的 Node 读取验证
M11.5 切换与清理:Node 库成为唯一存储;删除 rust_* 表、导入流程、备份、--import-ts-db 与 data dir 中的库;集成测试改用 Node 库 交叉运行:Node 建会话 → Rust 恢复并继续 → Node 恢复并继续,反向同样;全量集成测试

M11.3 至 M11.4 期间两种存储二选一,不双写;M11.5 删除原存储。

M11.3 已完成的范围与验证:

  • 写入:会话创建(首个输入)、直接开始与忙时输入的账本、排队提升、user 消息、assistant 步骤、推理与正文、工具 pending/running/completed/error、取消与失败收口、流恢复丢弃、稳定 fork 边界、标题(重命名)、模型选择、执行状态、todo、用量、项目设置、命令回执反查。
  • 集成测试 apps/zcode-cli-rust/tests/suite/node_storage.rs:引擎跑带工具的轮次、忙时排队与提升、重启后由新 runtime 续聊;按 §6 冷读取,模型上下文与运行时请求一致,行与账本正确。
  • 交叉读取 node --import tsx scripts/zcode-cli-rust-node-storage-check.mjs:Node 的 SqliteSessionStore、history hydrator 与冷投影读取 Rust 写出的库,history、行与快照状态与 Rust 冷读取逐项相等。

M11.4 的写入:引导输入与队列编辑/删除/重排/回退(M11.4a,已完成:集成测试覆盖运行中引导与删除排队项);sendQueuedNow 与 deleteSession 与 Node 一样不改账本(提升时写入,关闭时保留 admitted,冷查询结算为 discarded/session_resumed);编辑与重试的分支切点、重跑输入与冷加载边界(M11.4b,已完成:集成测试覆盖编辑、重试与重启后编辑,Node 交叉读取一致);稳定分叉(M11.4c,已完成:分叉后子会话继续对话,Node 交叉读取一致);压缩与 todo 提醒(M11.4d,已完成:手动压缩后继续对话,Node 交叉读取一致;自动压缩保留段与中断恢复有单元测试。autoCompactThreshold/willRetriggerNextTurn 暂不写,Node 读取时视为缺省);模型切换时间线(M11.4e,已完成);子代理子会话(M11.4f,已完成:父子会话均被 Node 交叉读取一致);后台子代理完成通知与运行中子会话插话(M11.4g,已完成:Node 交叉读取一致。Rust 的后台 bash 状态以本地 <task-notification> 随下次输入进入上下文,Node 没有对应记录,不落库,重启后与 Node 一样不再出现);提示附件(M11.4h,已完成:上传图片与文本附件、重启后继续对话,Node 交叉读取一致,§5.3);工具结果媒体(M11.4i,已完成:Read 图片的结果重启后仍以图片发送,Node 交叉读取一致);完全访问授权(M11.4j,已完成:回执通过 Node schema 校验,重启后恢复授权标记);提问自动结束阶段(M11.4k,已完成);会话 shell 快照(M11.4l,已完成:Node 能恢复快照,分叉子会话与 Node 一样不写);目标(M11.4m,已完成:两轮续跑与验证、暂停后恢复,Node 交叉读取的行、模型上下文与目标状态一致,§5.4);工作区 checkpoint 与文件撤销(M11.4n,已完成:Node 按严格 schema 解析 entry 与产物,重启后可读回,§5.5);分享上下文导入与 Claude Code 迁移(M11.4o,已完成:导入后继续对话,Node 交叉读取一致,§5.6);冷读取的上下文用量种子(M11.4p,已完成);选区侧聊(M11.4q,已完成:Node 交叉读取一致,端到端覆盖首条输入、父会话运行中、重启与重复命令);自动标题更新(M11.4r,已完成:首条输入与 /goal 的标题、目标摘要标题与用量落库,端到端覆盖)。

M11.5(已完成):NodeStore 成为唯一存储,删除 rust_* 表、TS 导入与备份、--import-ts-db、--node-storage;启动与 --prepare-storage 握手使用 Node 库路径。随切换对齐的行为:

  • 冷加载:环境快照从 user 消息 contextSnapshot.envInfo 恢复;legacy session/resume 以最后一条 assistant 消息的模式覆盖执行状态(Node modeOverride);没有存储模型选择的会话(分享导入、Claude 导入)回退默认模型;followupMode 与会话 Skill 目录不落库,恢复后分别为 queue 与重新发现(与 Node 相同)。
  • 写入:@plugin 引用提醒以 plugin_reference 通知落库;user 消息带 contextSnapshot。
  • 模型请求:任何消息中模型不支持的媒体按 Node projectMessagesForInputFormat 换成说明文本(此前 user 消息会报 attachment_unsupported)。
  • 目标:后台结果唤醒的续跑轮(task-notification)结束后同样验证。
  • session/subagents 从存储推导(§6.3)。
  • 集成测试全部改用 Node 库(packages/services/tests/zcode-cli-rust-*.test.ts,291 项):界面行跨冷热按内容比对(§2.4),回执与重放按 Node 规则(§7);原导入测试改为“Node 写入、Rust 读取并继续、Node 再读取并续写、Rust 重启后再继续”的交叉用例(§11 场景 2–4)(zcode-cli-rust-node-sessions、-node-boundaries、-shared-node、-todo-node、-session-list-node)。

M11.6(剩余对齐与真实环境验收):

  • 冷读取性能:子代理行回放(Node findSubagentLifecycleRow、materializeSubagentProjection)按行序维护子代理行位置索引,并借用快照行计算预期行(只在摘要追加时复制),结果与原扫描相同;20,000 条消息、66,688 个 part 的压测会话回放从 12.2 s 降到 1.7 s(Node 1.5 s)。
  • 模型请求媒体预算、提示图片准备与 30 MiB 本地视频上限(§5.3)。
  • 验收脚本(均用 SQLite 在线备份或临时目录,只打印计数、id 与 JSON 路径,结束后删除副本):
    • node --import tsx scripts/zcode-cli-rust-real-db-check.mjs [db] [artifacts] [rust] [node_read]:场景 1。用户库副本上 Rust 启动前后迁移账本与 sqlite_master 不变;examples/node_read.rs 按 Rust 冷读取导出每个会话的模型上下文、行与快照状态,scripts/zcode-cli-rust-node-compare.mjs 用 Node 仓储、history hydrator 与冷投影逐会话比对;Rust 续写本工作区一个会话后 Node 读回。
    • node --import tsx scripts/zcode-cli-rust-node-interop.mjs [zcode.cjs] [rust] [node_read]:场景 4–6,真实 Node CLI(zcode.cjs)与 Rust 进程共用 HOME、会话库与 Provider Registry。交替续写(含工具轮)时每次请求带齐此前全部输入与对方的工具调用;流式中与工具执行中 SIGKILL 后由另一方恢复,快照没有运行中或流式中的行,重复排队命令回答 fault.command.inputDiscardedOnRestart;两个进程并发各写 12 轮无锁错误。每个场景后全库逐会话比对。
  • 性能(切换前后、与 Node 对照、大历史冷读取)见 2026-09-25 报告。
  • 验收结果(2026-09-25):用户库 5,397 个会话、76,625 条消息全部比对一致,迁移与表结构不变,续写后 Node 读回;场景 4–6 全部通过且比对一致。

11. 验收场景

  1. 用户现有库(含官方构建的 0017_todo_state_revision、0023_session_soft_delete_outcome):Rust 启动不报错、不改动未知表与列、不重跑迁移。
  2. Node 创建的会话(多轮、工具、推理、附件、压缩、回退、goal、子代理、fork),Rust 打开后行、模型上下文、会话状态与 Node 冷投影一致,并可继续对话。
  3. Rust 创建的会话,Node 打开后冷投影与模型上下文正确,可继续对话、编辑、重试、fork、回退。
  4. 同一会话 Node 与 Rust 交替继续多轮,sequence、anchor、revert 保持一致。
  5. 崩溃恢复:工具执行中与流式中杀进程,两个 runtime 重启后均投影为中断,排队输入被丢弃并能正确回答重复命令。
  6. 并发:Node 与 Rust 进程同时写不同会话,无 database is locked 失败。