文档:接单化落地收尾,ADR 转已接受并同步下游口径

docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md:状态改成已接受并指向实施计划;"落地时要同步的文档与注释"改成已同步清单。
docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md:顶部取代注扩到"事件不带回合身份"与"宿主侧 Drop 守卫兜底"两条决策形状,影响一节的两条已知边界逐条写明新口径。
docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md:四步标记落地并补每步落地结果、验收证据(rust agent:: 949 / 前端 4473)与已知坑。
docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md:正常回合改成接单后先落盘再注入;异常回合收尾写明唯一终态出口是占用对象。
docs/project-memory/shared-memory/decision-log.md:host-dropped 两条口径加取代注(含 kind 追加 environment-not-ready),并追加 2026-09-23 接单化决策一条。
docs/README.md:索引行去掉"未实施",改成四步均已落地。
This commit is contained in:
2026-09-23 22:05:11 +08:00
parent 2772081791
commit ce668bbff3
6 changed files with 114 additions and 59 deletions
+2 -2
View File
@@ -43,8 +43,8 @@
- [DirectProject 对话历史单一事实源](./adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md):AGC 项目开发对话只以项目对话历史与运行态事件为真相源,聊天投影不落盘。
- [DirectProject 独立聊天容器与工作台钱包布局](./adr/【ADR】DirectProject独立聊天容器与工作台钱包布局-2026-09-18.md):DirectProject 与 Supervisor 等路径分容器,钱包入口由项目工作台布局独立承载。
- [引用候选由宿主注入](./adr/【ADR】引用候选由宿主注入-2026-09-22.md):引用输入区只接受宿主注入的引用 provider,素材选择面板独立成组件,附件芯片成为本轮附件唯一事实源。
- [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;尚为草案,未实施。
- [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;第 1 步(Thread Manager 逻辑回合)起为待实施。
- [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;拒单前置、失败后置。
- [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。
- [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
- [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。
@@ -1,9 +1,7 @@
# 【ADR】DirectProject命令接单化
状态:提议(草案,设计已定稿、未实施)
> 本文与 `docs/README.md` 里对应的一行索引先随文档提交进仓库;状态保持「提议(草案,未实施)」,
> 实施提交落地后改为「已接受」。
状态:已接受(2026-09-23 落地,实施顺序与验收见
[`【实施计划】DirectProject命令接单化-2026-09-23`](../technical/【实施计划】DirectProject命令接单化-2026-09-23.md))
## 背景
@@ -124,13 +122,16 @@
- 不恢复 invoke 拒绝通道,也不为"接单后的前置失败"新增事件类型——它们走同一对逻辑回合事件。
- 本轮不做"失败条目进历史但不喂模型"(TODO),不做 Rust 端发送队列(TODO)。
## 落地时要同步的文档与注释
## 落地时要同步的文档与注释(已同步 2026-09-23)
- `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`:事件带 `userItemId` 的事实、
失败原因的通道、"`turn.started` 之前的早退不产生终态事件"(作废)、失败说明是否落历史。
- `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`:影响里的两条已知边界被本 ADR 取代。
- `docs/project-memory/shared-memory/decision-log.md`:`host-dropped` 的两条口径。
- `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`:影响里的两条已知边界与"事件不带回合身份"
"宿主侧 Drop 守卫兜底"两条决策形状被本 ADR 取代。
- `docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`:四步标记落地,补验收证据与已知坑。
- `docs/project-memory/shared-memory/decision-log.md`:`host-dropped` 的两条口径加取代注,并追加一条
2026-09-23 的接单化决策。
- `docs/README.md`:索引行去掉"未实施"。
- 代码注释:`direct_runtime/user_input.rs` 的 `TODO(Direct 命令接单化)`、
`chat/controller/useDirectProjectChatController.ts` 的 catch TODO、
- 代码注释:`direct_runtime/user_input.rs` 的 `TODO`(分工改成 CLI 保持 await)、
`chat/controller/useDirectProjectChatController.ts` 的 catch TODO(队列挪 Rust)、
`direct_thread_wire.rs` 里 `userItemId`"由原生从已落盘条目上读取"的说明。
@@ -2,9 +2,11 @@
状态:已接受
> 注:本文件「影响」一节里的两条已知边界(① `kill -9` 后前端停在运行态;② `turn.started` 之前的早退
> 不产生终态事件)已由 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md)
> 重新决策(草案,未实施);实施后这两条按新 ADR 收口,本文件不再作为它们的依据。
> 注:本文件下列口径已被 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md)
> 重新决策并已落地,本文件不再作为它们的依据:「影响」一节里的两条已知边界(① `kill -9` 后前端停在运行态
> ——队列随进程消失,订阅 bootstrap 不会留下"有开始没结束";② `turn.started` 之前的早退不产生终态事件
> ——"早退"被拆成接单前的拒单,接单后由占用对象统一收口),以及「决策」里"事件不带回合身份"与
> "宿主侧 Drop 守卫兜底"两条的实现形状(见下)。
## 背景
@@ -24,12 +26,12 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- 两侧的过滤口径必须完全一致,包含「哪些条目根本不是本项目的聊天条目」:Codex app-server 回显的用户消息(`userMessage` / 非 AGC 的 `role=user`)在落盘侧被过滤,在运行态事件侧也必须被过滤(`direct_thread_visible_item`)。少一侧就会出现「实时比历史多出两条同文本用户条目、各自开出一个耗时 0 秒的假回合,重进页面又正常」这类只有其中一侧的事实源缺陷。
- 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。
- 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。
- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。
- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。(**按 2026-09-23 ADR §5 补充**:`turn.started` / `turn.completed` 现在带 `userItemId`,由 `clientTurnId` 推导、不读盘回填;事件仍不带 turn id,判据仍是"只有一对逻辑回合事件"。)
- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。
- 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。
- 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。
- 终态事件只有 `turn.completed` 一种,它同时承载三种语义:`status !== "failed"` 是正常结束 / 中断 / 终止,`status === "failed"` 是**失败**,且必须再带 `failure { kind, message }`(`message` 已脱敏截断)。失败原因只走这一条通道:前端不再从命令返回或另一条 IPC 里另造失败文案,聊天里那条失败说明仍落在同一个展示位上(本轮最后一条助手气泡、只在运行期显示),只是数据来源换成事件载荷;命令返回只用于运行错误横幅与诊断留痕。
- 宿主侧兜底:`turn.started` 发出之后才武装 Drop 守卫,正常写完终态即解除;panic、future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态,避免前端永远停在"还在跑"。已知边界见「影响」一节。
- 宿主侧兜底:`turn.started` 发出之后才武装 Drop 守卫,正常写完终态即解除;panic、future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态,避免前端永远停在"还在跑"。已知边界见「影响」一节。(**按 2026-09-23 ADR §2 改写**:守卫换成"接单即登记"的占用对象,终态写出后占用才释放。)
- 执行通道断开同样是失败终态,也必须带 `failure`:连接级故障(app-server 进程退出 / stdout 流断 / JSON 行越界)与回合事件通道关闭都算,`kind="transport-failed"`、`message` 用宿主当场写下的那份诊断(含 `exitStatus` 与 stderr 摘要,已脱敏截断)。宿主在检测到连接终止的第一时间把这条事实记到本回合的执行适配器上,终态判定再从适配器读:执行适配器的看门狗盯着同一个 `closed` 标志,用调用点局部变量会输给这场调度竞争,失败原因就只剩日志、界面只会看到"本轮已结束"。判据是"适配器是否已由宿主主动关闭"——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)走的是同一个 `TransportClosed` 事件,但这些不算失败。
- 终态由**事实**判定,不由收尾阶段反推:判定按优先级取「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段的账本读不出来时才用交付报告」,**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾会把 ledger 阶段推成 `Interrupted`,让阶段决定终态就会把已经失败的一轮讲成"已结束"。模型自报失败(原生 `turn/completed.status="failed"` 的 `error`,带 `codexErrorInfo` 分类)不为载荷新增输入字段:宿主把原生 `error` 的 `codexErrorInfo` 解析成 typed 分类后当作本回合的错误结果,走同一条通道进载荷;交付报告只说明"收束到哪一步",不得顶掉原因。
- 回合失败在宿主内部是 **typed** 的:`agent/direct_turn_error.rs` 的 `DirectTurnError` 每个变体自带字段(并发拒绝带两个 invocation id、模型失败带分类、超时带撞的是哪条上限、通道断开带宿主诊断),**调用级拒绝**(这一轮没有开始)与**回合级失败**(这一轮已开始并被判失败)不共用判据,分流只认 `is_turn_failure()`。判据不再对原因文本做子串匹配,`LlmError` 只在平台层入口出现一次(`DirectTurnError::from_model_call`)。线上载荷 `{kind, message}`、命令边界字符串与 CLI 返回值都由这一个出口投影出来,Rust 侧任何地方都不再解析它们。
@@ -60,7 +62,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。
- 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。
- 回合结束语义务必由 `turn.completed` 判定(失败时同一事件带 `failure` 载荷,不新增事件类型);缺少该事件的残留回合不得被渲染成运行中。
- 两条已知边界,都**不**在本次补路径:① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在运行态,那要靠前端自己的"命令已返回却没有任何终态事件"判据;② `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、历史注入参数构建失败等)不产生回合、也不补终态事件——它们不会留下永远开着的回合,出问题时只有运行错误横幅解释。
- 两条已知边界,都**不**在本次补路径,且已被 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) 取代(§3、§2):① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,但队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态;② `turn.started` 之前的失败按发生位置分流——接单**之前**的是拒单,根本不产生回合(不写用户条目、不写失败诊断),接单**之后**的由这一轮的占用对象统一收口成 `turn.completed`,不存在"有回合却没有事件解释"的路径。
- 失败原因里的 `message` 是宿主侧脱敏 + 截断后的可展示文本,前端仍按既有口径做一次可见文案映射(`projectRuntimeVisibleError`),映射规则不因这次改动改变。
- 执行通道断开时用户看到的仍是既有映射结果(诊断命中不了专门规则,落到通用兜底),真实诊断在事件载荷、宿主交付报告与运行日志里;把"连接断开"改成专门文案属于映射规则变更,不在本 ADR 范围内。
- 模型自报失败时用户看到的也仍是既有映射结果(`codex-app-server-error:<kind>` 那张中文表),区别只是原因现在从事件载荷来、同时命令返回带出运行错误横幅——这就是"事件出聊天文案、命令返回出横幅"的既有分工;前端可见文案的映射规则不因这次改动改变。
@@ -9274,10 +9274,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-09-22 失败回合的终态:`turn.completed` 带 `failure` 载荷 + 宿主 Drop 守卫兜底
- 背景:宿主崩在 `turn.started` 之后时没有任何终态事件,前端 `turnRunning` 永远为真,界面停在「陶泥儿正在处理」;同时失败在事件流里与正常结束同形(`turn.completed(status="failed")`,前端根本不读 `status`),失败文案只能从命令返回那条通道另造,同一次失败因此有两条通道、两份文案,而"这一轮结束了没有"只有事件说了算。
- 决策(协议形状:复用,不新增事件类型):终态事件仍只有 `turn.completed`。`status !== "failed"` 表示正常结束 / 中断 / 终止,不带载荷;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }` —— `kind` 为稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气),`message` 为宿主脱敏 + 截断后的可展示原因。"是不是失败"只看两件事:`collect_result` 是 Err 就用错误本身当原因;`collect_result` 是交付报告但状态已判成 `failed` 就用那份报告当原因。
- 决策(兜底覆盖全部收场路径):`turn.started` 进入队列之后武装 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `host-dropped` 失败终态。Thread Manager 不改一行:`turn.completed` 本来就是 `lifecycle_anchor` 成员,失败终态天然顶替更早的 `turn.started`,重放不会把已收口的回合看成"还在跑"。
- 决策(协议形状:复用,不新增事件类型):终态事件仍只有 `turn.completed`。`status !== "failed"` 表示正常结束 / 中断 / 终止,不带载荷;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }` —— `kind` 为稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气;**2026-09-23 追加 `environment-not-ready`**),`message` 为宿主脱敏 + 截断后的可展示原因。"是不是失败"只看两件事:`collect_result` 是 Err 就用错误本身当原因;`collect_result` 是交付报告但状态已判成 `failed` 就用那份报告当原因。
- 决策(兜底覆盖全部收场路径):`turn.started` 进入队列之后武装 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `host-dropped` 失败终态。Thread Manager 不改一行:`turn.completed` 本来就是 `lifecycle_anchor` 成员,失败终态天然顶替更早的 `turn.started`,重放不会把已收口的回合看成"还在跑"。(**已由 2026-09-23「DirectProject 命令接单化」取代**:守卫换成接单时登记的占用对象,接单前的早退改判为拒单、不再产生回合,`host-dropped` 只保留给"说不出原因"的一类。)
- 决策(失败文案只有一条通道):聊天里那条失败说明仍落在原来的展示位(本轮最后一条助手气泡、只在运行期显示、不写进 `project.jsonl`),数据来源换成事件载荷;命令返回只保留运行错误横幅(含 `read_agent_runtime_error_detail` 的长 detail)与诊断留痕,不再写聊天气泡。可见文案映射仍走既有 `projectRuntimeVisibleError` 规则,只是执行点从 controller 移到 reducer。
- 明确不做:不为 `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、缺稳定 `clientTurnId`、历史注入参数构建失败)补事件或兜底路径 —— 它们不产生回合、也不会留下永远开着的回合;不为进程被强杀(`kill -9`)补前端判据。
- 明确不做:不为 `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、缺稳定 `clientTurnId`、历史注入参数构建失败)补事件或兜底路径 —— 它们不产生回合、也不会留下永远开着的回合;不为进程被强杀(`kill -9`)补前端判据。(**已由 2026-09-23「DirectProject 命令接单化」取代**:接单前的失败改判为拒单、由命令边界返回 typed 错误;接单后的这类失败由占用对象收口成 `turn.completed`;`kill -9` 的界面表现在新 ADR §2。)
- 同日被取代的还有本条目里的另一条:命令返回只保留"运行错误横幅 + 长 detail"——`详情:` 引用与 `read_agent_runtime_error_detail` 已删除,失败说明也不再落命令边界(改由宿主投影写事件载荷与诊断池)。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_thread_wire.rs,direct_turn_failure.rs,direct_thread_manager.rs,codex_app_server/mod.rs}`、`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,conversation/directTurnFailure.ts,controller/useDirectProjectChatController.ts}`、生成绑定与两侧用例;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。
- 验证:见本条决策对应的提交记录(Rust 定向测试、reducer 与 appSurface 用例、`cargo test export_bindings` 后的生成绑定、`npm run check:encoding`、`git diff --check`)。
@@ -9299,3 +9300,20 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 明确不做:不新增事件类型、不给失败载荷加字段、不改前端可见文案映射;不给回合路径补轻型假 app-server 夹具(判据落在 `direct_turn_terminal` 与执行适配器两层单测)。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{mod.rs,execution.rs}`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs` 与其单测;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。
- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins direct_`(442 passed)、`codex_app_server`(100 passed)、`cargo fmt --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。真实客户端观感未复核。
## 2026-09-23 DirectProject 命令接单化:命令只接单,逻辑回合归 Thread Manager
- 背景:`chat_with_game_creator_direct_codex` 一个命令调用覆盖整轮(校验 → 跑 → 交付验证),于是命令边界同时兼职"接单被拒"与"回合失败"两种回执:失败文案有事件载荷与命令 Err 两条来源,`withDirectCodexSessionRefresh` 的"刷新会话 + 重跑整轮"会重复落盘一条用户消息,认证重试只能挂在 Err 上;而回合边界又镜像 Codex 原生回合(`turn/start` 成功应答后才发 `turn.started`),"接单到 `turn/start` 之间"的失败(连不上 app-server、配置未就绪、历史注入失败、`turn/start` 被拒)没有任何事件可解释,命令一旦不 await 就会静默。
- 决策(命令 = 接单 / 拒单):命令只做 `clientTurnId` 校验 → 占用调用身份(只挡并发,早于工程准备)→ 工作流恢复 → 用户条目校验 → 工程准备 → 接单成立 → 用户条目落盘 → 起 codex,成功后立刻返回、不等回合。命令返回类型改成结构化的 `DirectTurnError`(ts-rs 已导出到 `chat/generated/`,与 `DirectThreadEvent` 同一套 `cargo test export_bindings` 流程)。
- 决策(分流判据从"错误种类"改成"发生位置"):**接单之前**的失败(目录、权限、输入、并发、工程准备未就绪、宿主状态取不到)是拒单——不产生回合事件、不写用户条目、不写失败诊断;**接单之后**的失败(连接、配置、历史注入、`turn/start` 被拒以及回合过程中的一切)是回合失败,只走 `turn.completed` 带 `failure` 载荷一条通道。`DirectTurnError::EnvironmentNotReady` 接单前后都可能出现,因此新增自己的失败分类 `environment-not-ready`(否则投影会写成 `model-failed`、界面语气就错了)。这条位置判据取代原先"调用级拒绝直通"的分支。
- 决策(逻辑回合由 Thread Manager 拥有):新模块 `agent/direct_turn_accept.rs` 按 thread 维护占用登记,`accept(thread, user_item_id, client_turn_id)` 在同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`DirectTurnReservation::finish(terminal)` 幂等写出 `turn.completed` 并释放占用,`Drop` 兜底补 `host-dropped` 终态。发点在接单时、不再镜像 Codex 原生回合(原生事件留在适配器内部,不再进事件队列),线上仍只有一对 `turn.started` / `turn.completed`。因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性成立,不依赖实现者给每条早退路径补事件。并发锁与首页快照的口径:`DirectTaonierActiveInvocation` 退回纯单飞锁,首页"运行中的项目"改由 `list_direct_active_turns` 从 TM 的逻辑回合导出(`ActiveDirectTurn` 带快照字段,`DirectActiveTurnSnapshot` 移入 `direct_thread_manager.rs`),不再留两处事实。
- 决策(回合身份由 `clientTurnId` 推导):`turn.started` / `turn.completed` 的 `userItemId` 按 `direct-codex:{clientTurnId}:user` 算出(与前端 `directCodexConversationMessageId` 同规则),**不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败。
- 决策(界面:同级提示、删除 `详情:`):失败说明与接单被拒提示都与用户消息**同级**、按事件顺序排在它后面,不嵌在这条用户消息里;删掉 `详情:` 引用、它的正则解析与只服务详情展开的第二次 IPC `read_agent_runtime_error_detail`;顶部状态行只显示回合状态,不承载错误文本。前端按 typed 变体分流:认得的"前置条件不满足 / 用户参数无效"(`clientTurnIdMissing` / `clientTurnIdMalformed` / `turnAlreadyRunning` / `projectRootUnanchored` / `projectRootUnusable` / `permissionRejected` / `inputRejected` / `contentEmpty`)→ 出同级提示、不走 `captureAgentRuntimeError`;认不出的变体(`environmentNotReady` / `hostStateUnavailable`)以及非结构化错误 → 抛出,走既有捕获上报链路。
- 决策(队列与埋点听回合终态,不听命令返回):前端发送队列的放行改由"回合完成(终态事件)或接单被拒"驱动,reducer 新增 `completedTurnCount` 作为唯一判据——不能用 `turnRunning` 的下降沿,一轮可能同批开始 + 结束。埋点结算同样挂到回合终态:不能在接单返回时结算,成绩是回合末才入 `pending_runs`,提前结算会变成空操作;`runTurn` 返回"是否接单",接单失败的路径只清句柄、不结算。**加 TODO:这条队列以后挪到 Rust 端,落点就是 Thread Manager 的接单动作。** 首页"运行中的项目"快照改由 TM 的逻辑回合导出,任务侧不再单独维护一张表。
- 决策(认证失败不再重跑整轮):删掉 `withDirectCodexSessionRefresh` 的"刷新 + 重跑整轮"(重跑会重复落盘用户消息),登录态失效按普通回合失败呈现;用同一个包装的 `cancel_direct_codex_turn` 一并去掉。
- 决策(失败原因本轮不落历史):失败原因只走事件载荷与宿主诊断(`.agent/runtime/errors` + 应用日志 + 错误上报池由宿主投影写出,进池责任从前端 catch 移到宿主),不写进 `project.jsonl`——重进项目只会看到那条没有回复的用户消息。**加 TODO(暂定做法见 ADR 备选方案第 3 条 (b)):以后要做"进历史但不喂模型"的失败条目,本轮明确不持久化。**
- 决策(CLI 保持 await):CLI 入口(`cli.rs` 的 `direct-codex.chat`)继续 await 整轮,因为它要把回复文本打到终端、没有事件订阅可用;两个入口共用同一份接单前检查、同一个命令主体和同一份 `Display` 文案,不各写一套判据。
- 明确不做:不给失败载荷加字段(不加 `detailRef`);不恢复 invoke 拒绝通道,也不为"接单后的前置失败"新增事件类型;本轮不做"失败条目进历史但不喂模型"(TODO)、不做 Rust 端发送队列(TODO)。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_accept.rs,direct_thread_manager.rs,direct_thread_wire.rs,direct_turn_error.rs,direct_turn_failure.rs,direct_project_context.rs,direct_runtime/{mod.rs,user_input.rs},codex_app_server/mod.rs,runtime_driver/entrypoints.rs,cli.rs}`、前端 `chat/{controller/useDirectProjectChatController.ts,controller/useDirectProjectTurnStatus.ts,controller/useDirectThreadChatSubscription.ts,conversation/directCodexConversation.ts,conversation/directThreadChat.ts,conversation/directTurnPresentation.ts}`、`chat/generated/{DirectTurnError,DirectTurnRejection,DirectThreadEvent,...}.ts` 与 `tests/{directThreadChat.test.ts,appSurface/*.suite.ts}`;文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 与 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`。
- 已知坑:`cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` 掉不是本次新增的文件;本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。
- 验证:Rust 定向 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"`(949 passed);前端 `NODE_OPTIONS=--localstorage-file=/tmp/ls-gen.json npm test`(4473 passed);`npm run ai-game-creator-shell:typecheck`、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。真实客户端观感未复核。
@@ -2,65 +2,80 @@
更新时间:`2026-09-23`
状态:**四步全部落地**。
设计口径见 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md)。
本文件只排实施顺序、不变式与验收,不重复设计理由。
## 已落地
## 第 0 步:文档与既有缺陷清理(已落地)
- 设计定稿:ADR、`CONTEXT.md` 术语(逻辑回合 / 接单 / 拒单 / 在途回合)、两处旧文档的取代注。
- 前端删除由 invoke 拒绝驱动的认证重试(`directCodexSessionKeepalive.ts` 只留会话保活)。
- 用户可见文案不再带 `详情:` 引用、失败进错误上报池、失败说明不再写进项目历史、
- 用户可见文案不再带 `详情:` 引用、失败进错误上报池、失败说明不再写进项目历史,
只服务详情展开的 IPC `read_agent_runtime_error_detail` 已删除。
## 第 1 步:Thread Manager 拥有逻辑回合(Rust,一个原子提交)
## 第 1 步:Thread Manager 拥有逻辑回合(Rust,一个原子提交)——已落地
改动点:
- 新模块 `agent/direct_turn_accept.rs`:按 thread 维护占用登记。`accept(thread, user_item_id)` 在
同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`AcceptedTurn::finish(terminal)`
- 新模块 `agent/direct_turn_accept.rs`:按 thread 维护占用登记。`accept(thread, user_item_id, client_turn_id)`
在同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`AcceptedTurn::finish(terminal)`
幂等写出 `turn.completed` 并解除占用;`Drop` 兜底补 `host-dropped` 终态。终态写出后占用才释放。
- `direct_thread_manager.rs`:登记与事件追加必须共用同一把锁(不要再加第二张静态表)。
- 删除 `codex_app_server/mod.rs` 里镜像 Codex 原生回合的开始事件与终态追加,以及 app-server 侧
- `direct_thread_manager.rs`:登记与事件追加共用同一把锁(没有第二张静态表)。
- 删除了 `codex_app_server/mod.rs` 里镜像 Codex 原生回合的开始事件与终态追加,以及 app-server 侧
武装的 `DirectTurnFailureGuard`;终态统一交给 `AcceptedTurn::finish`。
- `direct_thread_wire.rs`:`userItemId` 的说明由"从已落盘条目读取"改成"由 `clientTurnId` 推导"。
不变式:线上仍只有一对生命周期事件;同一 thread 任意时刻至多一个占用;`turn.completed` 必带
`userItemId`。
不变式(已验证):线上仍只有一对生命周期事件;同一 thread 任意时刻至多一个占用;`turn.completed`
必带 `userItemId`。
验收:TM 单测(并发接单被拒 / finish 幂等 / Drop 兜底 / 收口后可再次接单)+
`cargo test agent::direct_runtime agent::direct_thread`。
## 第 2 步:命令改接单 + 后台跑整轮(Rust)
## 第 2 步:命令改接单 + 后台跑整轮(Rust)——已落地
- 顺序固定为:`clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 工程准备 →
`accept` → 落盘用户条目 → spawn 整轮。
- 接单前的检查从 `run_..._and_emitter` 上移到命令;分流判据改成位置(接单后一律回合失败),
`EnvironmentNotReady` 增加 `wire_kind() = "environment-not-ready"`,"调用级拒绝直通"的分支作废。
- spawn 出的任务在正常 / 失败 / 早退三条路径上都要走 `AcceptedTurn::finish`。
- spawn 出的任务在正常 / 失败 / 早退三条路径上都走 `AcceptedTurn::finish`;任务 panic 或被取消时
由占用对象的 `Drop` 兜底。
- 落盘即接单:接单成功后落盘用户条目,再起 codex;落盘失败仍是接单后的回合失败(有回合事件解释)。
验收:`cargo test agent::direct_runtime`;手工把 app-server 配错,界面应收到
`turn.completed{failed, environment-not-ready}`,而不是只有横幅。
## 第 3 步:拒单返回 typed 错误(Rust + TS)
## 第 3 步:拒单返回 typed 错误(Rust + TS)——已落地
- `DirectTurnError` 加 `Serialize + TS`(含嵌套枚举)并导出到 `chat/generated/`;命令返回
`Result<(), DirectTurnError>`,文案仍由 `Display` 生成一次随载荷带出。
- 前端 catch 按变体分流:认得的前置 / 参数类 → 与用户消息同级的提示、不走 `captureAgentRuntimeError`;
认不得的 → 抛出;状态行只显示回合状态。
- 前端 catch 按变体分流(`readDirectTurnRejection` / `directTurnRejectionNotice`):认得的
前置 / 参数类 → 与用户消息同级的提示、不走 `captureAgentRuntimeError`;认不出的 → 抛出;
状态行只显示回合状态。
- 认可名单:`clientTurnIdMissing` / `clientTurnIdMalformed` / `turnAlreadyRunning` /
`projectRootUnanchored` / `projectRootUnusable` / `permissionRejected` / `inputRejected` /
`contentEmpty`;`environmentNotReady` / `hostStateUnavailable` 返回 `null`(抛出上报)。
- 拒单**不结算埋点**(埋点句柄只清不发)。
验收:`npm run ai-game-creator-shell:typecheck`;手工触发一次拒单(并发 / 空内容)确认提示位置与无上报。
## 第 4 步:队列、埋点、快照、reducer(TS + Rust)——已落地
## 第 4 步:队列、埋点、快照、reducer(TS + Rust)
- 前端队列放行改听"回合完成或拒单":reducer 新增 `completedTurnCount`,作为放行与埋点结算的唯一
判据(不能用 `turnRunning` 的下降沿,一轮可能同批开始 + 结束)。**TODO(已写在代码里)**:这条
队列整体挪到 Rust 端,放行点就是 Thread Manager 的接单动作。
- 埋点结算挂到回合终态事件:句柄活过命令返回,接单成功才在终态结算,接单被拒不结算。
- 首页"运行中的项目"改由 TM 的逻辑回合导出(`list_direct_active_turns`);`DirectActiveTurnSnapshot`
移入 `direct_thread_manager.rs`,`DirectTaonierActiveInvocation` 退回纯单飞锁,不留两处事实。
- 删除取消占位的本地收口 `markTurnStopped()` 与 `turn.started` 的"重复起点保留第一次"兼容分支。
- 本地在途标签(`awaiting-start`)活到宿主认领,认领三判据:`turnUserItemId === pendingUserItemId`
(身份认领)、`currentTurnRunning`、`completedTurnCount > pendingTurnBaselineRef.current`。
- 前端队列放行改听"回合完成或拒单",加 TODO:以后挪到 Rust 端(落点就是接单动作)。
- 埋点结算挂到回合终态事件。
- 首页"运行中的项目"改由 TM 的逻辑回合导出。
- 删除取消占位的本地收口与 `turn.started` 的重复起点兼容分支。
## 验收证据
验收:typecheck;手工连发两条确认第二条不被丢;重进页面忙碌态正确。
- Rust 定向:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"`
(949 passed);TM 单测覆盖并发接单被拒 / finish 幂等 / Drop 兜底 / 收口后可再次接单。
- 前端:`NODE_OPTIONS=--localstorage-file=/tmp/ls-gen.json npm test`(4473 passed)、
`npm run ai-game-creator-shell:typecheck`。
- 仓库门禁:`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
- 手工:连发两条确认第二条不被丢;重进页面忙碌态正确;真实客户端观感未复核。
## 已知坑
- `project.jsonl` 与项目主对话共用信封类型,不要为了"可见但不喂模型"新增行结构。
- 埋点 `settle` 早于成绩入库会静默丢事件(未来"进历史但不喂模型"的条目同理要落在注入侧,不在读取侧)。
- `list_game_creator_direct_active_turns` 今天读的内存表与单飞锁是同一张,搬迁时别留两处事实。
- `cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --`
掉不是本次新增的文件。
- 本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。
@@ -1,10 +1,12 @@
# DirectProject Codex 原始历史与异常恢复
更新时间:`2026-09-16`
更新时间:`2026-09-23`
> 注:本文件里"事件不带回合身份"、"`turn.started` 之前的早退不产生终态事件"、"失败说明不写进
> `project.jsonl`"等结论,已由 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md)
> (草案,未实施)重新决策;实施时需按该 ADR 的"落地时要同步的文档"一节逐句修订本文件。
> 注:本文件里"事件不带回合身份"、"`turn.started` 之前的早退不产生终态事件"这两条结论已被
> [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md)
> 取代并落地:生命周期事件带可选的 `userItemId`,逻辑回合在**接单**时成对发出,接单之前的失败一律
> 是拒单(不产生回合事件)。下文相关段落已按该 ADR 修订;"失败说明不写进 `project.jsonl`"仍是
> 当前口径。
## 目标
@@ -31,13 +33,13 @@ DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会
## 正常回合
1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。
2. 新线程先把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入;注入成功后执行新的 `turn/start`。本轮 canonical user item 在发送前完成同样的投影校验,再写入项目历史。
2. 命令**接单**后先写本轮 canonical user item(发送前完成同样的投影校验),再在后台起 codex;新线程把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入,注入成功后执行新的 `turn/start`。这一轮的逻辑回合在接单那一刻就已开始,落盘与注入、`turn/start` 都在回合内,失败由这一轮的终态事件解释(见「异常回合收尾」)。
3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。
4. 正常 `turn/completed: completed` 不生成额外记录。
## 异常回合收尾
AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。
AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。终态出口只有接单时登记的占用对象一个:正常 / 失败 / 中断 / 取消谁先算出来谁写 `turn.completed`,都写不出时由它的 `Drop` 补 `host-dropped`。
`item/agentMessage/delta` 正常带有 `itemId`;若协议异常缺失,AGC 记录 warning 并按当前 turn 生成稳定回退 id。AGC 在内存中按该 id 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item:
@@ -116,8 +118,8 @@ Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thre
```ts
type DirectThreadEvent =
| { type: 'turn.started' }
| { type: 'turn.completed'; status: string; failure?: { kind: string; message: string } }
| { type: 'turn.started'; at?: number; userItemId?: string }
| { type: 'turn.completed'; status: string; at?: number; userItemId?: string; failure?: { kind: string; message: string } }
| { type: 'item.started'; item: DirectThreadItem }
| { type: 'item.completed'; item: DirectThreadItem }
| { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string }
@@ -126,13 +128,30 @@ type DirectThreadEvent =
进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。
**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`(失败时另带可选的 `failure`);条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。
**回合身份只挂在生命周期事件上,且由 `clientTurnId` 现算。** DirectProject 同一时刻只有一个回合在跑;
`turn.started` / `turn.completed` 各带一个可选的 `userItemId`(本轮开口用户条目的 canonical id,
`direct-codex:{clientTurnId}:user`),`turn.completed` 另外带 `status` 与失败时必有的 `failure`。
条目、增量、请求与生命周期锚点仍不带 turn id:这个字段只把"这一轮的边界属于哪条用户消息"讲清楚,
不新增一套回合身份,**不读盘回填**(开始事件发生在用户条目落盘之前,落盘本身也可能失败)。前端 state
里的 `turnRunning` 仍是唯一的活动判定,历史条目不记录回合身份;身份缺失时不猜历史归属。
**终态只有 `turn.completed` 一种,失败靠 `failure` 载荷区分。** `status !== "failed"` 表示正常结束 / 中断 / 终止,事件不带 `failure`;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }`:`kind` 是稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气),`message` 是脱敏截断后的失败原因。失败原因只走这一条通道——前端不再从命令返回或另一条 IPC 里另造失败文案;`status="failed"` 却没有载荷视为协议违规。
**终态只有 `turn.completed` 一种,失败靠 `failure` 载荷区分。** `status !== "failed"` 表示正常结束 / 中断 / 终止,事件不带 `failure`;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }`:`kind` 是稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `environment-not-ready` / `host-dropped`,只给界面选语气,界面不拿它做流程分支),`message` 是脱敏截断后的失败原因。失败原因只走这一条通道——前端不从命令返回或另一条 IPC 里另造失败文案;`status="failed"` 却没有载荷视为协议违规。
**接单之前发生的不是回合失败,是拒单。** 判据是**发生位置**而不是错误种类:目录、权限、输入、
并发、工程准备未就绪这类"接单前就能判定"的失败由命令以结构化的 `DirectTurnError`(ts-rs 导出,
载荷 = 变体 + `Display` 生成的一句文案)返回,不产生任何回合事件、不写用户条目、不写失败诊断;
接单之后的连接、配置、历史注入、`turn/start` 被拒以及回合过程中的一切,都只走 `turn.completed`
带失败载荷这一条通道。`EnvironmentNotReady` 接单前后都可能出现,因此它有自己的失败分类
(`environment-not-ready`),不会被投影成 `model-failed`。
执行通道断开(app-server 进程退出、stdout 流断、回合事件通道关闭)也走同一条终态:`kind="transport-failed"`,`message` 是宿主当场记下的诊断(`exitStatus` + stderr 摘要,脱敏截断)。宿主在检测到连接终止时**第一时间**把这条事实记到本回合的执行适配器上,终态判定再从适配器读——执行适配器的看门狗盯着同一个 `closed` 标志,若只在调用点用局部变量记录,会与看门狗的收束竞争,输掉时就只剩 `status="interrupted"` 加一句收尾说明,界面只显示"本轮已结束"、看不到原因。判据是"适配器是否已由宿主主动关闭":宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)同样会发 `TransportClosed`,但那些不算失败。
宿主的异常收场同样靠这条事件:`turn.started` 进入队列之后武装一个 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态。两条已知边界——宿主进程被强杀(`kill -9`)时没有任何 `Drop` 执行;`turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`)本身不产生回合——都不产出终态事件,也不假装有回合可收,前端在这两种情况下仍按"命令已返回"的既有语义收尾。
宿主的异常收场同样靠这条事件:**接单**时登记占用对象并发出 `turn.started`,占用对象持有这一轮唯一的
终态出口——正常 / 失败 / 中断 / 取消谁先算出来谁写终态,都写不出时由它的 `Drop` 补一条
`status="failed"` + `failure.kind="host-dropped"`,因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性
成立的,不依赖实现者记得给每条早退路径补事件。唯一的已知边界是宿主进程被强杀(`kill -9`):没有任何
`Drop` 执行,队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态。
接单**之前**的失败根本不产生回合(见上一条:那是拒单),所以不存在"没有事件可解释的回合"。
**终态由事实判定,不由收尾阶段反推。** `turn.completed.status` 不是收尾阶段的口径(`lifecycle_status` 只描述 ledger 阶段,没有终态否决权):判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有账本读不出来时才用交付报告」取原因,有载荷一定写 `status="failed"`。模型自报失败(原生 `turn/completed` 的 `error`,含 `codexErrorInfo`)复用同一条通道:宿主把它投影成 `LlmError` 后当作本回合的错误结果返回,原因文本里带着 `codex-app-server-error:<kind>` 前缀(前端 `projectRuntimeVisibleError` 已有对应中文映射),既不为载荷新增输入字段,也不让交付报告顶掉原因;`RepairRequired`(返修请求)保持自己的原语义。