| 1 | # 会话所有权、回溯与 worktree 回退 |
| 2 | |
| 3 | <a href="./SESSION_OWNERSHIP.md">English</a> |
| 4 | |
| 5 | Reasonix 如何决定谁可以写会话、冲突如何落盘,以及回溯和工作区隔离如何配合。 |
| 6 | |
| 7 | ## 会话写者 |
| 8 | |
| 9 | 一个会话保存在只追加的事件日志 `<id>.events.jsonl` 中(会话格式 2)。每条 |
| 10 | 消息条目都带有自己的 id 和父消息 id,因此日志是一张 DAG:从任一叶子回溯到 |
| 11 | 根的路径就是该会话的一个版本,称为 *head*。日志初始只有一个 head `main`; |
| 12 | 分叉、回溯和并发写入会新增 kind 为 `fork`、`rewind`、`concurrent` 的 head。 |
| 13 | `<id>.jsonl` 只是选中 head 的派生缓存,永远不是权威来源。 |
| 14 | |
| 15 | 选中 head 由最后一条仍指向存活 head 的 `select` 标记决定,否则取活动时间最新 |
| 16 | 的 head。按路径打开会话即打开该 head。`.event-index.json` 镜像全部 head, |
| 17 | 会话目录据此列出版本,无需重放日志。 |
| 18 | |
| 19 | 写者只追加,从不改写或截断日志。每次保存获取有界等待的 `.jsonl.lock` flock, |
| 20 | 读入其他写者自上次保存以来追加的内容,然后继续写自己的 head。session lease |
| 21 | (`.lease.lock`,绑定带 generation 的 `SessionWriter`)不再限制追加:它只决定 |
| 22 | 谁写派生的 `.jsonl`、索引和回合账本,因此第二个窗口无需等待即可加入同一会话。 |
| 23 | 回合以 `turn_begin` 标记开始、以 `turn_end` 结束;两者之间崩溃会在下次打开时 |
| 24 | 被识别,未完成的尾部用 `rewind` 标记搁置,绝不截断字节。关机保存在有界等待内 |
| 25 | 拿不到保存锁时,会不加锁地把未保存的尾部追加到一个新的 `concurrent` head 上, |
| 26 | 派生文件留给下一次加锁保存;关机永远不会复制出一份会话。 |
| 27 | |
| 28 | 路径切换(`new`、`clear`)仍采用“先准备、后发布”的交接:前端先取得目标 |
| 29 | lease 并给尚未发布的 Session 绑定写权限,Controller 才替换路径。`fork`、 |
| 30 | `branch`、`switch` 和对话回溯则停留在同一路径上,只在 head 之间移动。 |
| 31 | |
| 32 | Reasonix 1.39.0 之前保存的会话使用格式 1:整文件 transcript 加基于位置的事件 |
| 33 | 日志。1.39.0 及更新版本在首次保存时、且能证明自己是唯一写者的前提下,就地把 |
| 34 | 它升级为格式 2;在此之前该会话沿用下文的格式 1 规则。早于 1.39.0 的版本会拒绝 |
| 35 | 打开格式 2 日志,且一字节不改;需要回滚时, |
| 36 | `reasonix doctor session <id> --export-v1 PATH.jsonl` 会把当前 head 导出为 |
| 37 | 格式 1 会话。 |
| 38 | |
| 39 | ## 冲突 |
| 40 | |
| 41 | 两个进程向同一格式 2 日志追加不会冲突,只会交错。若保存时发现另一写者延长了 |
| 42 | 本会话所在的链而本地没有新增,就直接跟随磁盘;若双方都有新增,保存会从最后 |
| 43 | 一条共同消息分叉出 `concurrent` head 继续写入,双方各收到一次提示 |
| 44 | (`session_concurrent_writer`)。之后重新加载会打开最新的 head,并在“查看 |
| 45 | 版本”里列出另一个(`session_head_switched`)。不会再复制出 `-recovery-` |
| 46 | 文件,保存也绝不删除任何 head。 |
| 47 | |
| 48 | 格式 1 会话在升级前沿用原规则: |
| 49 | |
| 50 | 1. 事件日志尾部仍匹配当前写者 → 正常保存。 |
| 51 | 2. 磁盘已经覆盖本地前缀 → 采用磁盘版本,不建分支。 |
| 52 | 3. 真正分歧、日志被替换或原会话被删除 → 写入一条由根 branch ID + 当前 |
| 53 | Session 首次 writer generation 决定的稳定 recovery 文件。lease 重绑不会 |
| 54 | 改变该 lane;后续冲突更新同一路径,不再嵌套。 |
| 55 | |
| 56 | ## head 即版本 |
| 57 | |
| 58 | 从消息分叉、`/branch` 和对话回溯都会追加一条 `fork` 标记和一条 `select` |
| 59 | 标记:新 head 从所选消息开始并成为当前版本,原有链作为同一会话的另一个版本 |
| 60 | 保留。桌面端就地把当前标签页切到新 head;终端重放 transcript。“查看版本” |
| 61 | 列出存活 head 及其类型,可以把另一个 head 设为当前(`select`)、给 head |
| 62 | 改名,以及清理*已覆盖*的 head——即整条链已经包含在当前链中的 head。清理只 |
| 63 | 追加一条 `retire` 标记:退役 head 从版本列表消失,其字节只在单写者轮转日志 |
| 64 | 时回收。含独有内容的 head 永远不会被自动清理;最近一分钟内仍有活动的 head |
| 65 | 会被报告为“正在使用”而不是退役。 |
| 66 | |
| 67 | ## 回溯 |
| 68 | |
| 69 | - **代码**:恢复 before-image。当前已等于 before 的文件跳过;外部修改拒绝覆盖。 |
| 70 | - **对话**:在回合边界分叉出 `rewind` head 并设为当前。原有链永不截断。格式 1 |
| 71 | 会话则仍创建新的会话文件。 |
| 72 | - **两者**:先分叉,再恢复文件。文件冲突时保留新 head 并返回 `partial=true`。 |
| 73 | - **撤销**:恢复文件 after-image。若回溯 head 之后没有新增内容,Controller |
| 74 | 回到父 head 并退役这个空的回溯 head;已经继续对话的回溯 head 作为版本保留。 |
| 75 | |
| 76 | 新 checkpoint 写入 `turns/<turn>/meta.json` 和原始字节 |
| 77 | `files/NNNN.before`(schema v3)。默认保留最近 100 个回合目录;新 checkpoint |
| 78 | 不再把载荷重复写入 blob。旧的 v1/v2 `turn-N.json` 及其 blob 仍可读。 |
| 79 | |
| 80 | v2 兼容 marker 同时也是 v3 turn 的存活标记。旧版本截断 `turn-N.json` 后, |
| 81 | 对应的 v3 目录会被视为 tombstone;再次升级不会让已经删除的未来 checkpoint 复活。 |
| 82 | |
| 83 | 结构化写工具在发布前重新校验存在性、SHA-256 和 mode,不匹配则返回 |
| 84 | `ErrFileChanged`。 |
| 85 | |
| 86 | ## Worktree 回退 |
| 87 | |
| 88 | 从消息分叉时可以选择两种工作区策略。**仅分叉对话(共享工作区)**继续使用源工作区, |
| 89 | 因此会保留并继续看到当前未提交文件。**隔离 worktree** 则从仓库已提交的 `HEAD` |
| 90 | 创建持久的 `reasonix/delivery-*` 分支,把新分叉注册为独立项目,并保持源 checkout |
| 91 | 不变。Git worktree 不会复制本地改动,所以组合分叉要求源 checkout 干净;检测到 |
| 92 | 未提交或未跟踪文件时,Reasonix 会拒绝创建,并提示先 commit/stash,或改用共享分叉。 |
| 93 | |
| 94 | 如果当前目录不是 Git 项目,或环境不满足 worktree 前提,Reasonix 会在共享工作区中 |
| 95 | 完成会话分叉并明确提示已回退。如果 worktree 创建后,会话创建或标签页挂载失败, |
| 96 | 自动清理只会删除分支、`HEAD` 和状态仍与创建结果完全一致的未使用 worktree;一旦 |
| 97 | 检测到任何变化,就会保留现场以便恢复。成功挂载的 worktree 会作为项目持久注册, |
| 98 | 关闭标签页或重启后仍可发现。新建 allocation 还会在 checkout 旁以 `0600` 权限写入 |
| 99 | v1 `metadata.json`,绑定原始 source checkout、目标分支、创建时 `HEAD`、受管 |
| 100 | worktree 根和临时分支。旧版本创建且没有该元数据的 worktree 无法使用 Merge-Back, |
| 101 | 因为 Reasonix 不会猜测目标分支;界面会保留现场并给出手动合并指引。未知元数据版本 |
| 102 | 同样按失败关闭处理。 |
| 103 | |
| 104 | Merge-Back 是“合并、清理分离”的失败原子流程。预检会验证受管路径和仓库身份、精确 |
| 105 | 分支与 `HEAD`、source 干净且没有进行中的 Git 操作、全部可见或 detached Desktop |
| 106 | 活动任务、工作区写租约、integrated terminal、ahead/behind、diff 和冲突。取得双 |
| 107 | workspace lease 后,Desktop 会 |
| 108 | 短暂封闭 turn start 和 controller publication,再为 canonical source/worktree 两个根登记 |
| 109 | 贯穿 Git 变更的 reservation。项目 runtime owner、新 turn 以及 terminal create/write 都 |
| 110 | 经过同一 admission;子目录和 symlink 别名受保护,prefix sibling 与无关项目不受影响。 |
| 111 | worktree 有未提交改动时默认禁止合并;只有用户显式开启自动提交才会继续,并在精确新 |
| 112 | 提交上重新做冲突预检。确认 token 使用 NUL-safe 状态,同时绑定真实 index entries、 |
| 113 | 每个脏路径的类型、mode、文件内容或 symlink 目标。自动提交从确认的 `HEAD` 创建 `0600` |
| 114 | 临时 index,`git add -A` 只作用于该副本。若真实 index 含有当前完整工作区未表示的 |
| 115 | staged/index-only 内容,Reasonix 会停止,真实 index 和两个版本都保持原样。否则通过无 |
| 116 | hook、单父提交的 `commit-tree` 创建精确提交,对确认的 worktree branch 做 compare-and-swap, |
| 117 | 并仅在真实 index 字节仍一致时通过独占 `index.lock` 安装准备好的 index。branch CAS 后的 |
| 118 | 任何失败都返回 recovery-required;目标分支、`HEAD`、index 或内容发生漂移时不会继续。 |
| 119 | source 合并使用带 Reasonix 命令级提交身份的 |
| 120 | `git merge --no-ff --no-commit --no-verify`,不依赖用户 Git identity,也不运行 commit hook, |
| 121 | 并把实际 index tree 与重新计算的 merge-tree 精确绑定;准备前及安装 ref 前都会重新验证 |
| 122 | worktree root、Git common-dir、symbolic branch、branch ref、`HEAD`、Git operation 和内容 |
| 123 | token。只有这些身份、目标分支、原始 `HEAD`、精确 `MERGE_HEAD` 和 prepared tree 都仍 |
| 124 | 一致时,才通过无 hook 的 `commit-tree` 创建固定 parents/tree 的提交。短生命周期 source |
| 125 | mutation fence 会持有真实 index、`HEAD` 和 `MERGE_HEAD` lockfile,并比较三者准确快照。 |
| 126 | 这些 checkout 局部锁保持期间,Git 通过指向同一 common ref store 的 detached 管理视图 |
| 127 | 只取得 branch ref 锁。单个 |
| 128 | `update-ref --stdin` transaction 会同时验证 worktree branch ref,并用原目标 `HEAD` 对 |
| 129 | target ref 做 compare-and-swap,避免任一 ref 检查部分生效。提交后还会复核两个 checkout、 |
| 130 | commit tree、真实 index tree、parents、refs、干净状态和 Git operations。安装后使用 |
| 131 | `git merge --quit` 只清理辅助 merge state,不直接更新 `MERGE_HEAD` pseudoref,也不 reset |
| 132 | prepared index。CAS 前只有仍能证明 prepared state 完整的失败才会 abort;target ref 漂移、 |
| 133 | CAS 后漂移或无法证明恢复成功的状态返回 recovery-required,同时保留所有 worktree 资源和 |
| 134 | 外部状态。 |
| 135 | |
| 136 | 合并成功后,Reasonix 先通过正常 Desktop 生命周期切换到记录的 source checkout。每次 |
| 137 | 前端导航都会向后端登记 opaque intent token;关闭请求在快照前和实际移除 Tab 的线性化 |
| 138 | 点都必须仍持有该 token。因此更新导航会停止关闭和清理并保留资源;稳定时后端也只会在 |
| 139 | 精确 source Tab 仍 active、精确 worktree Tab 仍 idle 时关闭页面和终端。 |
| 140 | |
| 141 | 独立、可重试的 finalization 会 reservation 包含原 canonical worktree 与固定 recovery 子树的整个 |
| 142 | allocation,并扫描可见及 detached runtime;项目 runtime 创建、恢复、删除/归档 fallback |
| 143 | 和重定向都经过同一 admission gate。symlink 与子目录受保护,allocation 外的 prefix |
| 144 | sibling 和其他 allocation 不受影响。只有临时提交已包含在目标分支、身份一致且包含 ignored 文件的 |
| 145 | 完整 status 为空时,Reasonix 才会先以 `0600` 原子写入 v2 `cleanup-state.json`,记录原路径、 |
| 146 | allocation 内随机 recovery 路径、branch、`HEAD` 和 `planned` 阶段。随后使用普通 |
| 147 | `git worktree move`,再次验证 common-dir、symbolic branch、branch ref、`HEAD`、Git operation、 |
| 148 | 完整 status 和注册路径,再把 journal 推进到 `retained`。任一阶段崩溃都按 journal 与 Git |
| 149 | worktree 注册表的精确身份重试;多候选或未知状态失败关闭。 |
| 150 | |
| 151 | recovery checkout 会继续保持 registered,并继续检出其 `reasonix/delivery-*` 分支。Reasonix |
| 152 | 不会注销 worktree、删除临时分支、逐文件 unlink 或递归删除任何路径。因此移动前已经打开的文件 |
| 153 | 描述符会跟随 checkout,晚到写入仍可恢复;原公开路径重新出现的内容也会原样保留并报告。恢复回执 |
| 154 | 持久化后,Desktop 只移除原 managed worktree 的陈旧项目注册,保持 source project active,且 |
| 155 | 不会把隐藏 recovery 路径加入侧栏;注册表写入失败可以借助 journal 重试。 |
| 156 | |
| 157 | 新版本只以保留方式读取 v1 journal:仍注册的 legacy checkout 只有在精确身份和 manifest 均可 |
| 158 | 证明时才转换为 v2;已经 detached 或身份不明确的 legacy 路径只报告人工恢复,不删除也不自动 |
| 159 | 重新注册。未知 journal 版本失败关闭。metadata 继续使用 v1;旧 cleanup reader 会拒绝未知的 |
| 160 | v2 journal,从而保留 recovery checkout。 |
| 161 | |
| 162 | Delivery worktree 仍是可选能力。非隔离目录使用 workspace lease(`filelock`)。 |
| 163 | 路径型写入对祖先兼容锁和目标路径层级分片加 shared 锁、对具体文件分片加 |
| 164 | exclusive 锁,且只在该次 tool 期间持有。整区写入会独占精确根锁和对应层级分片, |
| 165 | 因此父工作区与直接打开的嵌套仓库能够互斥,而两个会话仍可同时写不同文件(包括同一 |
| 166 | 仓库)。`bash`/MCP 的写操作只在该命令期间独占整区;若配置的 tool hook 可能写入 |
| 167 | 未声明路径,任何 tool 调用都会改用整区锁。文件和层级身份都映射到有界锁分片;哈希 |
| 168 | 碰撞最多让无关工作串行,不会削弱保护。只读 bash 不拿写锁。冲突卡片会说明正在写的 |
| 169 | 文件或整区。macOS 使用折叠身份协调大小写别名,同时保留原始大小写根锁兼容旧版; |
| 170 | 旧版进程仍只认识它打开时的路径拼写,跨拼写共存需要双方都使用新协议。Git 不是运行 |
| 171 | 前提;对话结束后不继续占锁。需要长期隔离工作树时再用 worktree。 |
| 172 |