2026 年 9 月 4 日,一次 Windows 桌面应用更新之后,我原来在 Codex 左侧栏里的 Projects 全部消失了。
项目里的会话并没有一起消失。旧 conversations 仍然能从 Recents 或 Search 找到,只是全部变成了并列的单独会话,不再归属于原来的 Project。新建 Project 又可以正常显示。
更有意思的是,当时 Mobile Remote 还能看到旧 Projects。
所以没有马上重建项目,也没有清空应用数据。这个现象首先指向一个更值得验证的可能性:
消失的可能只是前端项目状态,而不是底层数据本身。
这次恢复是在 ChatGPT 和 Codex 协作下完成的:我负责描述现象、反馈每轮结果、判断是否继续以及最终执行;ChatGPT 主要用于方案推演和风险边界控制;Codex 负责本地只读取证、恢复脚本实现,以及后续复现和调试。本文重点仍然放在恢复原理和证据链,而不是把“用了 AI”本身当作主题。
最终恢复提交时,一共找回了:
- 21 个 Projects
- 21 条 project-order
- 143 条 thread-project assignments
- 7 个 restored sidebar groups
- 27 条 workspace-root hints
- 10 条 projectless threads
恢复目标中的 sidebar threads 为 56/56 assignment 一致。
这篇记录主要整理这次恢复过程中真正值得留下来的部分:怎么判断数据到底有没有丢、为什么没有直接覆盖旧状态文件,以及如何在两个都不能完全信任的 state 之间做一次相对保守的恢复。
先确认:项目是真的没了,还是 UI 不知道怎么显示它们
第一步没有修改任何文件,只做只读取证。
检查之后确认:
.codex\state_5.sqlite中旧 Projects 仍然存在- sessions / thread 数据仍然存在
- 对应的本地项目目录仍在
- Git 仓库没有丢
- 能找到一份 2026-08-28 的
.codex-global-state.json.tmp-*历史快照 - 当前
.codex-global-state.json曾出现 NUL 字节,导致 JSON parse failure
这一步基本确定了问题的性质:
项目数据没有整体消失,真正出问题的是应用用于组织和展示这些数据的状态。
后续检查又显示,backend project migration 已经完成,但旧 thread assignment 和 sidebar state 没有被完整恢复。
这也解释了为什么会出现一个看起来很奇怪的状态:
Project 不见了,会话却还在。
这种情况下,最危险的操作不是“什么都不做”,而是还没搞清楚数据结构,就开始重新创建 Project、清空应用数据或者拿旧文件整份覆盖。
为什么没有直接恢复旧 JSON
历史 .tmp 快照看起来是最直接的恢复来源。
但最终没有把它直接覆盖到当前的 .codex-global-state.json。
原因很简单:它是一份旧状态。
在这段时间里,当前版本的应用已经产生了新的合法数据,其中可能包括:
- 新增字段
- migration 状态
- Remote 相关状态
- 当前版本新生成的其他 metadata
如果为了找回 Projects,把整个 global state 回滚到一个更早的时间点,本质上是在用一个已知较旧的完整状态替换一个部分受损、但包含新信息的当前状态。
这样可能让 sidebar 暂时“看起来恢复了”,同时破坏其他东西。
所以最后采用的不是 file rollback,而是:
current state + historical snapshot + SQLite evidence 的 semantic merge。
也就是只恢复那些已经通过其他证据确认过的语义数据。
最终允许恢复的范围包括:
- local projects
- project order
- thread-project assignments
- sidebar project/thread order
- workspace root hints
- projectless thread IDs
其他当前状态全部保留。
重点不是“旧文件里有什么就复制什么”,而是先回答:
这个字段现在应该是什么?有什么证据证明?
Dry-run 发现的 18 条特殊 thread
恢复脚本做到 dry-run 阶段时,出现了一个不能直接跳过的问题。
sidebar 里有 18 条 thread,但新版 assignment 中找不到对应关系。
最简单的办法是根据 cwd 自动推断这些 thread 属于哪个 Project。
最终没有直接这么做。
原因是 cwd 并不天然等于 Project assignment。实际使用过程中可能存在:
- 子目录
- nested repository
- 临时工作目录
- agent 内部任务目录
- projectless thread
如果把 cwd 当成唯一依据批量恢复,很容易制造出一个“结构完整、语义错误”的 sidebar。
于是这 18 条 thread 被单独拿出来审计,交叉使用:
- 历史 sidebar 信息
- session cwd
turn_contextcwd- SQLite / catalog 信息
- projectless 状态
最终 18/18 都能得到高置信度的历史 Project assignment。
这些确认结果没有继续泛化成自动规则,而是做成固定 allowlist,再进入正式恢复。
这是这次 dry-run 最有价值的地方之一。
它验证的不只是“脚本会不会报错”,而是暴露出了数据本身存在的语义边界。
完整备份也不能盲目递归
另一个问题出现在 .codex 的备份阶段。
目录中存在 internal / external Junction,也就是 Windows reparse points。
如果一个恢复脚本为了“安全”而直接递归备份整个目录,却没有处理 Junction,就有可能沿着链接进入其他目录,使备份范围远远超过原来的预期。
最终的处理方式是:
- 严格 allowlist
/XJ- 单独记录 reparse manifest
这次经历也重新确认了一件事:
备份本身同样需要定义边界。
“先全部复制一份”听起来最保险,但如果目录中存在 symlink、Junction 或其他 reparse point,无限制递归不一定是更安全的方案。
PowerShell 里的两个小坑
恢复过程中还碰到了两个比较具体、但以后很可能再次遇到的问题。
OrderedDictionary 没有 .ContainsKey()
ConvertFrom-Json -AsHashtable 和 [ordered]@{} 得到的对象并不是同一种具体类型。
其中 OrderedDictionary 不支持 .ContainsKey()。
如果脚本假设所有类似 map 的对象都有同一套方法,很容易在处理 JSON state 时突然失败。
最终脚本改成了兼容 IDictionary 的 key check,而不是依赖某个具体实现的方法。
File.Replace() 和 PowerShell $null
最后执行 atomic replace 时,第一次失败了:
The path is empty. (Parameter 'path')
开始看起来很像变量意外变成了空字符串。
后续排查中,Codex 在独立临时目录复现了这个问题,最后确认异常发生在 PowerShell 到 CLR 的参数绑定阶段。
调用四参数 File.Replace() 时,普通 PowerShell $null 被 binder 转成了空字符串,而不是传递真正的 null string。
最终改成:
[System.Management.Automation.Language.NullString]::Value
才真正向 CLR 传入需要的 null。
这不是整个恢复中最重要的问题,却是最容易让最后一步莫名其妙失败的问题之一。
恢复不是以“sidebar 出现了”为结束
正式提交恢复之前,脚本先完成完整 validation。
恢复提交时的结果是:
- 21 Projects
- 21 project-order entries
- 143 thread-project assignments
- 7 restored sidebar groups
- 27 workspace-root hints
- 10 projectless threads
- restored sidebar 中 56/56 threads assignment 一致
restore script 最终 exit code 为 0。
atomic replacement 成功之后,ChatGPT/Codex 自动重新启动。
启动以后,左侧 Projects 全部重新出现,旧 conversations 也重新归入正确项目。
但这一步仍然没有被直接当作恢复完成。
后续又选择了一个长期使用的旧项目“记账系统”,对其中的历史会话做只读验证:
- Project root 正确
- cwd 正确
- Git root 正确
- branch =
main
之后应用又经历了一次 Codex 更新和重启,恢复后的 Projects 仍然保持正常。
到这里,这次恢复才算真正完成。
目前能够确定什么
目前证据足以确认:
- 旧 Project 和 conversation 数据没有被整体删除。
- 当前 global state 曾存在 NUL 字节并导致 JSON parse failure。
- Desktop 随后出现了不完整的 frontend state。
- backend project migration 已经完成,但部分旧 assignment / sidebar state 没有完整恢复。
- 通过 historical snapshot、current state 和 SQLite 数据进行语义合并,可以恢复原有 Project 组织关系。
- 恢复结果能够跨应用重启和后续更新保持。
但有一件事不能从现有证据中推出:
不能证明那次 Codex 更新直接制造了 .codex-global-state.json 中的 NUL corruption。
Projects 是在更新后消失的,这是时间关系。
global state 存在 corruption,这是取证结果。
两者之间是否存在直接因果,目前没有足够证据。
后来 Codex 又发布了一次更新,但当时人工恢复已经完成,所以也无法判断那个新版本是否本来就包含自动修复逻辑。
因此,这篇记录不是“某个 Codex 更新损坏了我的数据”,而是:
一次更新之后旧 Projects 从 sidebar 消失;进一步检查发现 global state 损坏,并且应用没有完整恢复原来的 Project state。
这是目前证据真正支持的范围。
这次恢复最后留下来的几个原则
回头看,真正值得保存的并不是哪一个 JSON 字段应该怎么改。
这些内部格式以后很可能还会变化。
更有价值的是几个恢复状态类故障时可以复用的原则。
第一,UI 中的数据消失,不代表底层数据真的删除。
先证明数据是否还存在,再决定修 UI、索引、catalog、assignment,还是数据本身。
第二,不要因为找到一份旧 snapshot,就默认整份覆盖是最安全的恢复方式。
旧状态可能解决一个问题,同时回滚当前版本已经产生的新状态。
第三,恢复 state 最好以语义为单位,而不是以文件为单位。
知道“我要恢复 Project assignment”,比知道“我要复制这个 JSON 节点”更重要。
第四,dry-run 不只是检查脚本能不能执行。
一个好的 dry-run 应该能把 ambiguous state 暴露出来,并在无法证明时停止,而不是尽量自动猜一个答案。
第五,恢复成功不能只看界面。
sidebar 重新出现只能证明 UI 又有东西可显示。
Project root、cwd、Git root、thread assignment,以及重启后能否继续保持,才更接近真正的数据一致性验证。
第六,AI 辅助恢复仍然需要人为控制边界。
这次有效的方式不是把问题直接交给 agent “想办法修好”,而是把任务拆成只读取证、假设验证、风险排除、脚本实现、dry-run 和最终人工确认。
AI 可以完成大量本地分析和实现,但是否拥有足够证据进入下一步,仍需要明确判断。
这次没有留下故障前后的截图,也没有为了文章重新制造故障。
因为这不是一个依赖视觉对比才能成立的案例。
真正需要保存下来的证据,是状态还在、哪里损坏、依据什么恢复、恢复了哪些数据,以及恢复之后是否还能保持一致。
这些已经足够了。
说明
这是一次个人环境中的 AI 辅助故障恢复记录,不是 Codex 官方修复方案。应用内部状态文件和数据结构可能随版本变化。如果以后遇到类似问题,仍然应该先做只读取证和备份,而不是直接照搬这次的字段或恢复脚本。