← 返回 DacAI Lab

实践记录

Codex Projects 消失后:一次不覆盖当前状态的 21 项目恢复记录

一次 Windows 桌面应用更新后旧 Projects 从侧栏消失的恢复记录:先证明底层数据仍在,再通过历史快照、当前状态与 SQLite 证据进行语义合并。

CodexWindowsPowerShell故障恢复AI 协作

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_context cwd
  • 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 仍然保持正常。

到这里,这次恢复才算真正完成。

目前能够确定什么

目前证据足以确认:

  1. 旧 Project 和 conversation 数据没有被整体删除。
  2. 当前 global state 曾存在 NUL 字节并导致 JSON parse failure。
  3. Desktop 随后出现了不完整的 frontend state。
  4. backend project migration 已经完成,但部分旧 assignment / sidebar state 没有完整恢复。
  5. 通过 historical snapshot、current state 和 SQLite 数据进行语义合并,可以恢复原有 Project 组织关系。
  6. 恢复结果能够跨应用重启和后续更新保持。

但有一件事不能从现有证据中推出:

不能证明那次 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 官方修复方案。应用内部状态文件和数据结构可能随版本变化。如果以后遇到类似问题,仍然应该先做只读取证和备份,而不是直接照搬这次的字段或恢复脚本。