校准 DirectProject 历史兼容口径的技术方案与共享决策记录

- 技术方案:把「不迁移旧 {role,content} 行 / 不会对旧格式做迁移或兼容」改成当前状态——读侧白名单兼容 legacy 行(含 tool 行只识别不注入)、写侧 DirectProject 自己的旧行写点已收口
- 技术方案:修正「Rust 是该历史文件的唯一写入方」,写清 project.jsonl 同时被通用对话链(agent_id=None 的项目主对话与 Runtime 公开状态消息)读写,并记录两条链互读兼容的规则
- 技术方案:说明为什么故意不给通用对话写入器加「Direct-owned 就拒写」的硬报错(会把旧行噪声换成任务起不来),以及这类失败为何按不可重试处理
- 技术方案:更新时间改 2026-09-11
- decision-log:新增 2026-09-11 条目,记录格式切换当年「唯一写入方 + 测试手动清理」的前提在存量用户项目上不成立、现改为「读侧白名单兼容 + 写侧统一」,以及为什么不做数据迁移
This commit is contained in:
2026-09-11 18:36:29 +08:00
parent 2b44b62599
commit c527c19bf8
2 changed files with 23 additions and 5 deletions
@@ -15,7 +15,17 @@
- 关联文档:相关 PRD、技术文档、提交或 Issue
```
## 2026-09-11 资源卡「编辑标签」面板只编辑标签,分类不再有手动设置入口
## 2026-09-11 DirectProject 历史格式切换改为「读侧白名单兼容 + 写侧统一」,不做数据迁移
- 背景:格式切换到 `response_item`#282 / `d3f5d0a35`)当年的决策前提是「Rust 是该历史文件的唯一写入方」+「存量测试数据由测试手动清理」,据此在技术方案里写了「不迁移旧 `{role,content}` 行」「不会对旧格式做迁移或兼容」。这两个前提在真实用户项目上都不成立:切换前 DirectProject 主对话由通用对话写入器写到同一份 `.agent/conversations/project.jsonl`,存量项目整份都是旧行(实测 27 个项目里 25 个是纯旧格式),而读侧只认 `{"type":"response_item","payload":…}`,于是这些项目的 DirectProject 回合全部失败在「DirectProject 历史记录类型无效」;写侧也没真正唯一——`agent/direct_tools_mcp.rs``conversation.record_codex_response` 还在往同一份文件插旧行(它本来就有自己的 journal `.agent/conversations/codex-responses.jsonl`),历史就算修好,Codex 一调该工具就会再次毒化。同时这条失败没有专门 hint,落进默认的「Codex 未完成本轮代码修改,请检查运行时配置后重试」,且 `retryable=true`,用户看到「可直接重试」但重试永远不会过(同一份历史)。
- 决策:兼容口径从「不兼容不迁移」改为「**读侧白名单兼容 + 写侧统一**」,仍然**不做数据迁移**。读侧只接受一种明确枚举的旧行形状(`schemaVersion=game-creator-conversation.v1`、无 `type`、role 在 legacy 写入器自己的角色集合 `user`/`assistant`/`tool` 内、content 为非空字符串):`user`/`assistant` 投影成与 `direct_project_local_message_item` 同形状的 Responses `message` item`role``content` 逐字节保留、不 trim、不改写,未知字段忽略;`tool` 行已识别但不注入 Codex 上下文(它不是 Responses item,无法还原成真正的工具 item,聊天投影本来也只展示 user/assistant),与 developer/system item 同样过滤。角色集合取的是 `project/conversation.rs` 里那条 `matches!(role, "user" | "assistant" | "tool")` 校验,所以「legacy 写入器能写出的行」被完整覆盖;白名单之外的角色、带别的 `type`、换了 `schemaVersion`、content 非字符串或为空、缺 `payload`、坏 JSON 继续失败关闭。写侧删掉 `conversation.record_codex_response` 那条投影(显式 Codex 返回只留在自己的 journal)。同一份文件也被通用对话链(`project/conversation.rs``agent_id=None`=项目主对话,含 `agent/runtime_state.rs` 的项目级公开状态消息)读写,两条链此前的 reader 互不兼容(一个要 `type:response_item`、一个要 `schemaVersion`),现改为**各自白名单兼容对方的行**:通用对话侧跳过 `type=response_item` 且带 `payload` 的行(不把它二次投影成自己的记录,DirectProject 侧已经拥有那份投影),其余坏行两侧都失败关闭。这条失败新增专门 hint 并改为 `retryable=false`,不再显示「可直接重试」;历史文件的打开/读取类 IO 失败仍按可重试处理。
- 一个**被明确否决**的更强做法:给通用对话写入器加「文件已属于 DirectProject 就拒绝追加旧行」的硬报错。它看似能「关上再污染入口」,实测会把「尾行噪声」换成「任务起不来」——这些写入点不是尽力而为的旁路:`agent/runtime_driver/task_start.rs``ensure_game_creator_agent_runtime_accepted_public_status_at` 返回 `Err` 时会中止本次后台任务(「后台任务启动确认落盘失败,任务未执行」),`agent/runtime_protocol/steering.rs:572/604/1300` 三处调用也用 `?` 上抛。改用「消毒」而不是「关门」:legacy 写入器的行形状与角色集合都被 `conversation.rs` 自己的校验穷举,全部落在 DirectProject 的读侧白名单内,因此任何现役写入点都不可能再产出 DirectProject 读不了的行(真机 27 个项目实测 legacy 行 role 只有 user/assistant0 条 tool)。
- 为什么不做数据迁移:读侧白名单兼容就能让 25 个存量项目**零改动**继续跑——历史文件字节不动、不改 mtime、不需要写权限、没有迁移中途失败要回滚的窗口,用户也不需要先打开一次客户端等迁移。反过来,迁移要改用户项目目录里的私有文件,得处理全量扫描、权限(Windows 私有 DACL)、断点续跑与失败回滚,还在迁移期要求新旧两个版本客户端并存时继续保留读侧兼容——等于同时维护迁移器和兼容层;而它换来的只是「文件里不再有旧行」这一项整洁性,旧行本来就只读兼容。另一个决定性理由是存量形态无法枚举:用户手改、或把项目目录从别处拷回来都会再出现旧行,读侧兼容是唯一能覆盖这些形态的做法。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs`(两处行信封判定收敛到同一个函数+legacy 投影+`tool` 行过滤)、`agent/direct_tools_mcp.rs`(删除旧行投影与只为它存在的 `message_id` 计算)、`agent/direct_runtime.rs`(专门 hint`retryable=false` 白名单)、`project/conversation.rs`(跳过 DirectProject 行)、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。跨端契约与 sidecar schema 不变,`.agent/conversations/project.jsonl` 的行形状与字段零改动。
- 验证方式:Rust 定向测试覆盖旧行投影(role/content 逐字节、无 `messageId` 时不带 `id`)、旧行+新行交替的混合文件按行顺序读取、`tool` 行被识别但不进上下文、非白名单异常行仍失败关闭、通用对话写入器产出的旧行形状落在白名单内、通用对话链跳过 DirectProject 行且坏行仍失败关闭、混合文件从两条链都能读(互不毒化)、显式 Codex 返回不再写 `project.jsonl`、专门 hint 与 `retryable=false` 落到诊断 sidecar。变异验证:去掉 legacy 兼容分支→投影与混合文件用例变红;把白名单放宽成「任意行都接受」→失败关闭用例变红。
- 关联文档:`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md``docs/project-memory/shared-memory/pitfalls.md`
- 背景:用户给出的目标样式截图里,这个面板标题是「编辑素材标签」、副标题是素材名、已有标签是自带删除按钮的胶囊 pill、输入框提示「新增标签,多个用逗号分隔」、底部只有「取消」与「保存标签」,**没有分类那一排**。而面板原实现同时承担 6 类 `category` 手动设置与 `assets[].tags` 编辑,PRD §5.3 也写着「用户可在「分类与标签」面板手动设置 `category`」。资源卡浮出工具条上只有这一个相关入口(`label="分类与标签"``setResourceClassificationAssetId`),不存在第二个「编辑素材标签」入口,所以两种读法只能二选一。截图里的标题/按钮文案在全仓(含 `docs/**``.codex/**`、各类型源码)检索均无命中,属仓库之外的来源,因此本次改动以用户截图为准、不宣称是 PRD 明文。
- 决策:**该面板只编辑 manifest `assets[].tags`,移除分类 chip 那一排。** 随之的事实是:**「用户手动设置 `category`」这项能力就此移除**`category` 只由落盘值与 `assets[].kind` 派生加读时自愈决定。写入命令 `update_local_project_resource_classification``category` 是必填,前端读一次当前权威值并在保存时**原样回传**,因此「只改标签」不会顺带改动分类,Rust 侧与 manifest 字段构成都不改。面板标题改「编辑素材标签」、入口按钮 label 改「编辑标签」(否则工具条写着「分类与标签」却打开纯标签面板,属误导)。「删除资源」按钮截图未画但保留:`openDeleteResourceDialog` 只在这个面板里被调用,删掉会让用户失去唯一的资源删除入口。
@@ -1,6 +1,6 @@
# DirectProject Codex 原始历史与异常恢复
更新时间:`2026-09-07`
更新时间:`2026-09-11`
## 目标
@@ -18,7 +18,9 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史
`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。
DirectProject 不迁移旧 `{role,content}` 行;实现按新格式工作
DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会写旧行的路径已收口——显式 Codex 返回只落在自己的 journal `.agent/conversations/codex-responses.jsonl`,不再投影进 `project.jsonl`
读侧白名单兼容旧 `{role,content}` 行:格式切换前,DirectProject 主对话由通用对话写入器落到同一份 `project.jsonl`,存量用户项目的历史文件整份都是这种行。读取时把**明确枚举的那一种**旧行形状(`schemaVersion=game-creator-conversation.v1`、无 `type`、role 在 legacy 写入器自己的角色集合 `user`/`assistant`/`tool` 内、content 为非空字符串)认下来:`user`/`assistant` 投影成与 `direct_project_local_message_item` 同形状的 message item`role``content` 逐字节保留;`tool` 行已识别但不进 Codex 上下文(它不是 Responses item,无法还原成真正的工具 item,聊天投影本来也只展示 user/assistant),与 developer/system item 同样过滤。角色集合取的是 `project/conversation.rs` 里那条 `matches!(role, "user" | "assistant" | "tool")` 校验,所以「legacy 写入器能写出的行」被完整覆盖;白名单之外的角色、换了 `schemaVersion`、带 `type`、content 非字符串或为空、缺 `payload`、坏 JSON 仍按损坏失败关闭。这条兼容是只读的,不迁移、不改写历史文件。
## 正常回合
@@ -51,8 +53,14 @@ Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions`
聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。
DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器Rust 是该历史文件的唯一写入方。历史读写与回合累计分别位于 `agent/direct_project_history.rs``agent/direct_project_turn_history.rs`
DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器。历史读写与回合累计分别位于 `agent/direct_project_history.rs``agent/direct_project_turn_history.rs`
`project.jsonl` 不是 DirectProject 独享的写入方:通用对话链(`project/conversation.rs`)把「项目主对话」(`agent_id=None`)映射到同一份文件,非 DirectProject 模式(`codex_cli`/`provider`)的项目对话、以及 Agent Runtime 的项目级公开状态消息(`agent/runtime_state.rs` 的 public status 写入点)都由它追加 `game-creator-conversation.v1` 行。两条链的行形状不同但**互读兼容**:DirectProject 侧投影旧行(见上),通用对话侧跳过 `type=response_item` 且带 `payload` 的行(不把它二次投影成自己的记录,DirectProject 侧已经拥有那份投影),其余坏行两侧都失败关闭。因此同一份文件里出现两种行不会让任何一侧失败。
这里**故意不给通用对话写入器加「文件已属于 DirectProject 就拒绝追加」的硬报错**:这些写入点不是尽力而为的旁路——`agent/runtime_driver/task_start.rs``ensure_game_creator_agent_runtime_accepted_public_status_at` 返回 `Err` 时会直接中止本次后台任务(「后台任务启动确认落盘失败,任务未执行」),`agent/runtime_protocol/steering.rs` 的三处调用也用 `?` 上抛。加硬报错会把「旧行噪声」换成「任务起不来」,比它要解决的问题更糟;而毒化本身已经不可能发生——legacy 写入器的行形状与角色集合都被 `conversation.rs` 的校验穷举,全部落在 DirectProject 的读侧白名单内。
## 写入与损坏边界
写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;中间坏行直接失败。不会对旧格式做迁移或兼容
写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;白名单化的旧行投影成 message item;其余中间坏行直接失败。兼容只发生在读取侧,不对旧格式做数据迁移或改写
该失败有专门恢复提示,并按不可重试处理:同一份历史文件每次读都会得到同一结论,重试不会改变结果,因此不会向用户显示「可直接重试」。