此前 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。
-
数据库路径与 Node 相同,解析规则如下(后者覆盖前者):
- 默认
~/.zcode/cli/db/db.sqlite; - 用户配置
~/.zcode/cli/config.json的storage.sessionDbPath; - 环境变量
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覆盖。
- Rust 内嵌 Node 的 22 个迁移,SQL 原文逐字节一致,校验和为
sha256(sql.trim()),与用户现有库核对一致。 - 启动流程与 Node 的
migration-runner相同:busy_timeout5000 ms,foreign_keys = on;journal_mode不是 WAL 时切换为 WAL,失败则报sql_failed;- 预检:已应用的已知迁移逐个比对校验和,不一致报
checksum_mismatch; begin immediate,建schema_migration,按数组顺序补齐缺失的已知迁移,同一事务提交。
- 未知的已应用迁移(如官方构建的
0017_todo_state_revision、0023_session_soft_delete_outcome)、未知表和未知列一律忽略。 - Rust 不新增任何表、列或迁移。Node 不持久化的状态,Rust 也不持久化(见 §4 的"仅内存"项)。
- 所有写入沿用 Node 仓储层的 SQL 原文,包括 upsert、legacy 字段保留的
json_set子查询、sequence的coalesce(max(..),-1)+1分配、touchSession的max()语义,以及事务边界(Node 用begin immediate的地方 Rust 同样使用)。 - JSON 按 Node 的对象字面量顺序生成键,
undefined字段省略,数值按 JSJSON.stringify规则输出(整数值的浮点数不带.0)。 - id 格式:
- 会话
sess_<uuid>; - 消息
msg_<Date.now() 的 36 进制>_<uuid>,partpart_<同上>_<uuid>,轮次turn_<uuid>; - 子代理子会话
sess_subagent_agent_<uuid>(agentIdagent_<uuid>); - 输入账本
queue_<commandId>; - 其余 entry 的 id 模板见 §4。
- 会话
- 读取与 Node 相同:
JSON.parse后展开;user 消息丢弃 legacymodel,严格校验modelSelection;assistant 丢弃providerID/modelID/variant;model_change与subtaskpart 还原字段。
| 项目 | 原 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:// |
- 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 消息(NodeextractPersistedEnvInfo);Rust 不写nodeVersion。
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
- 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 进程。
| 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 缺失的字段补齐。
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
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]
- 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://按 NodeNodeToolArtifactStore的目录规则读取(文本产物按 UTF-8,其余 base64)。冷读取另算活动分支的上下文用量(NodecontextUsageFromPersistedMessages:最近压缩摘要的压缩后计数,否则最后一个 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。
| 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,与 NodepersistCancelledStreamSnapshot相同。 -
呈现: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 语义(errorpart),因此重启后该调用在模型上下文中记为失败;运行时文本与 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
忙时输入的账本时序(排队后提升):
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: 开始新轮
与 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 规则解析每个附件,结果(
filepart 主体与 Node 模型块)挂在StoredAttachment.node,提升时写入 user 消息(text part 之后,每个附件一个filepart),模型请求按同一结果组装:
| 附件 | 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)把
filepart 还原为 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按扩展名推断(NodeinferImageMimeFromPath,缺省 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
- 工具结果媒体(M11.4i,Node
persistToolResultMediaAttachments):成功的工具结果带图片、视频或 PDF 块时,引擎在记录工具 part 之前把每个媒体块写成 data URL 产物(<callId>-media-<序号>),completed状态带attachments(filepart,键序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 从产物解码
与 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同时持有这组持久字段与 V4GoalState投影(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 设置只更新状态)。随后是续跑轮:turnHeaderorigin = goalContinuation,首条消息是goal-continuation通知(model-only,metadata{targetId, visibility, source},正文为target_continuation系统提醒包裹的续跑提示,模型上下文中同样带包裹)。- 验证:续跑轮结束(无工具调用的步骤)后先结束运行并写本轮稳定边界,再验证。每个生命周期事件(
started与一个终态)写一条target_completion_verificationentry(id = eventId,data{eventId, payload, sequenceNumber, traceId},payload键序为anchorAssistantMessageId?, anchorTurnId?, goalIteration, status, targetId, verification?, verificationId),并写或更新 timeline 消息msg_goal_verify_<targetId>_<iteration>与 partpart_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
与 Node emitFileMutationCheckpoint、persistWorkspaceCheckpointEntry、persistWorkspaceFileRewindEntry、restoreWorkspaceCheckpointEntries 一致:
- 写入:成功的文件修改工具结果(结构化输出带
filePath、structuredPatch、originalFile,即 NodegetFileMutationCheckpointCandidate)在工具 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_checkpointentry(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 的文件在重启后撤销会被判为外部修改。
与 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})及其正文 partpart_<session>_shared_context_text(Markdown,metadata{sharedContext: true}),和v4/shared_context_importentry(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、最后一条消息时间与创建时间的较大者),先删除本会话已有的迁移消息(NodeisImportedHistoryMessage:旧固定 id、msg_claude-import-前缀或migrationSource相同),再按数组顺序写msg_<session>_import_<i>(user:metadata{migrationSource};assistant:parentID为前一条 user 或占位 id,finish: stop、零用量)与part_<session>_import_<i>_text;时间按顺序收敛为严格递增。模型只在导入请求带模型时写入。
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(写库)]
- 行投影按 Node
synthesizeEventsFromMessages的规则:turn 以真实用户输入(guide引导消息除外)、workflow 启动、模型专用触发消息为边界;可见性按getConversationMessageProjectionPolicy;pending/running工具与无completed的 assistant 投影为中断。 - 行投影只裁掉 rewind 分支(
selectActiveConversationBranch),不按压缩边界截断:压缩前的历史仍在时间线上,只有模型上下文从最后一个边界开始。 - 冷投影在
zcode_cli_domain::node_rows:合成事件(Node 的事件类型、hydrate-Nid、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 无正文、无推理、无工具且无有效用量时跳过。
冷加载一个会话(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) |
- 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(NodelistSessions的持久部分):给出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>)。
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}
- 候选:
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。
与 Node CommandInbox.lookupExact 相同的优先级:
- 进行中;
- live 输入;
- 本进程已结算 LRU(每会话 512 条);
- transcript:user 消息
anchor.sourceCommandId; - timeline:timeline part 的
sourceCommandId,以及v4_command_fact:timeline:<commandId>; - child:父会话上的
v4_command_fact:child:<parent>:<commandId>; - 已丢弃:
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,与 NodediscardAdmittedOnLoad: !live相同;常驻会话的排队输入不受影响。 - transcript 与 timeline 查询只读匹配的一行(
json_extract过滤,非法 JSON 视为不匹配),不加载整段对话。
--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_timeout25 ms 加退避重试,总上限 1 小时,与 Node 异步启动相同;完成后恢复 5000 ms。
- 每个进程一个连接,由存储 worker 串行使用;事务内不跨越异步等待。
- 多语句写入使用
begin immediate;sequence与账本序号在写语句内分配。 - 跨进程的读后写(
updateSession、输入历史去重)与 Node 保持相同的竞争窗口。 - 没有进程锁:一个会话同一时间只由一个 live runtime 持有,由 Host owner/lease 保证,Rust 与 Node 相同。会话级恢复(
admitted输入结算、中断收口、未完成的文件回退事务)只在会话被加载进本进程时做,启动时不扫描其他会话。已接受的风险:终端zcode -p --resume <id>对桌面正在运行的会话没有保护(冷加载会把对方admitted的输入结算为 discarded、把未收口的运行标为中断),与 TS CLI 相同。
各期单独提交;每期结束时 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恢复;legacysession/resume以最后一条 assistant 消息的模式覆盖执行状态(NodemodeOverride);没有存储模型选择的会话(分享导入、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 全部通过且比对一致。
- 用户现有库(含官方构建的
0017_todo_state_revision、0023_session_soft_delete_outcome):Rust 启动不报错、不改动未知表与列、不重跑迁移。 - Node 创建的会话(多轮、工具、推理、附件、压缩、回退、goal、子代理、fork),Rust 打开后行、模型上下文、会话状态与 Node 冷投影一致,并可继续对话。
- Rust 创建的会话,Node 打开后冷投影与模型上下文正确,可继续对话、编辑、重试、fork、回退。
- 同一会话 Node 与 Rust 交替继续多轮,
sequence、anchor、revert保持一致。 - 崩溃恢复:工具执行中与流式中杀进程,两个 runtime 重启后均投影为中断,排队输入被丢弃并能正确回答重复命令。
- 并发:Node 与 Rust 进程同时写不同会话,无
database is locked失败。