重构/对话错误类型化, 避免string-typed #474

Merged
k88936 merged 63 commits from feat/fail-as-event into master 2026-09-24 19:42:50 +08:00
Member
No description provided.
k88936 added 8 commits 2026-09-22 19:32:30 +08:00
- 撤销 a35956f3e 的前端实现:directThreadChat 的 commandClosedTurnUserItemId / stopDirectThreadTurn、subscription 的 stopCommandTurn、controller 失败分支的调用,以及随附的两处用例
- 原因:失败语义改由宿主事件(turn.failed)表达,前端不再自造第二条「结束」判定路径,也不再在失败路径上补本地消息
- ADR【DirectProject对话历史单一事实源】补三条决策:终态事件只有 turn.completed,失败时 status="failed" 必须带 failure{kind,message};宿主 Drop 守卫在 turn.started 之后武装、写完终态即解除;失败原因只走事件这条通道,聊天说明的展示位保留、数据来源换成事件
- 同 ADR「影响」补两条已知边界(进程被强杀时没有 Drop、turn.started 之前的早退不产回合也不补终态)与「可见文案映射规则不变」的口径
- 技术方案【DirectProject Codex原始历史与异常恢复】同步线上形状:turn.completed 增加可选 failure,并写明失败终态与正常终态同权顶替 lifecycle_anchor
- decision-log 记本次决策、明确不做项、影响范围与验证方式
- direct_thread_wire 新增 DirectTurnFailure{kind,message} 类型,给 TurnCompleted 增可选 failure 字段,并补 turn_completed_failed 构造器与 failure 读取器
- with_user_item_id 显式带上 failure:原先把 TurnCompleted 写成 `..` 会静默吞掉失败载荷,身份与原因必须一起流转
- 新增 wire 用例:失败终态带载荷、正常终态不带且回写不补 null、缺载荷的 failed 事件仍可反序列化
- direct_thread_manager 增回归用例:turn.completed(status=failed) 必须顶替更早的 turn.started 成为 lifecycle_anchor,重放不会把已收口的回合看成"还在跑"
- 重新生成 ts-rs 绑定(新增 DirectTurnFailure.ts、DirectThreadEvent.ts 增 failure 字段)并按 prettier 格式化
- 新增 agent/direct_turn_failure.rs:LlmError → 稳定分类(timeout / model-failed / transport-failed / request-rejected)、判定"终态是不是失败"并给出脱敏截断后的原因、DirectTurnFailureGuard(turn.started 之后武装、写完终态 disarm,Drop 时补 host-dropped 失败终态)
- 守卫兜底覆盖 panic / future 被丢弃 / 终态之前的早退;kill -9 与 turn.started 之前的早退写进模块注释,明确不为它们补路径
- agent.rs 注册模块并再导出
- 5 条用例:错误分类映射、只有 failed 终态带载荷、原因脱敏 + 按字符截断、armed 后 Drop 补终态、disarm 后不再产出事件
- codex_app_server 的 DirectProject 终态改用 direct_turn_failure 判定:失败走 turn_completed_failed(原因脱敏 + 截断后写进同一个事件),其余仍走 turn_completed(status)
- 失败判定两个来源:collect_result 是 Err 时用错误本身当原因;collect_result 是交付报告但 status 已判成 failed 时用那份报告当原因
- turn.started 进入队列后立即武装 DirectTurnFailureGuard,写完终态 disarm:panic、回合 future 被丢弃、终态之前的早退都会补一条 host-dropped 失败终态,前端不会停在"还在跑"
- 定向 `cargo test direct_`(438 passed,含 wire / manager / 失败策略模块)
- 新增 conversation/directTurnFailure.ts:失败说明条目的展示身份(本轮开口身份 + :failure)与可见文案(复用 projectRuntimeVisibleError)两条口径集中一处
- directThreadChat 的 turn.completed 分支读 failure 载荷:非空原因先落成本轮最后一条说明条目,再走同一个收口函数;失败不再是第二套生命周期
- useDirectProjectChatController 的失败分支不再写聊天气泡:聊天文案唯一来源是事件,命令返回只保留运行错误横幅(含详情 long detail)与诊断留痕
- 数据流、时序与投影注释同步:标注失败说明来自事件、本地通道只剩终止说明与壳层 announce
- 新增「失败终态(turn.completed 带 failure 载荷)」六条用例:失败照样收口并冻结终点、说明条目按本轮开口身份派生、本轮开口条目拿到边界
- 可见文案与运行错误横幅共用同一份映射(点名 codex-app-server-error:context-window-exceeded)
- 重复 / 迟到的失败终态不追加第二条说明、不抬高冻结终点、不复活运行态
- 身份不匹配的失败终态不动正在跑的这一轮;空原因不落说明条目但终态照样收口
- 没有身份时用事件时间派生说明身份,两轮失败不会合并成一条
appSurface 用例:失败只经终态事件收口,界面不再停在"还在处理"
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
80b15b24ae
- 新增「closes the turn from the host failure payload instead of leaving it running」:宿主发过 turn.started 之后以 failure 载荷收场并让命令失败,断言失败文案来自事件载荷(经同一份可见文案映射)、"陶泥儿正在处理"消失、终止钮消失、输入盒回到「发送」
- 同一条用例反向断言命令返回的错误原文不进聊天:那条通道只负责运行错误横幅
- 变异校验:让 reducer 不落失败说明条目时该用例变红(1 failed),恢复后绿
k88936 added 1 commit 2026-09-22 20:19:26 +08:00
Merge branch 'master' into feat/fail-as-event
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
3467042000
k88936 added 5 commits 2026-09-23 11:22:49 +08:00
- ExecutionAdapter 新增 transport_failed / transport_failure:调用方只给宿主诊断,文案、报告与"这算不算失败"都归适配器管;先同步记事实,再把同一份原因补进宿主交付报告
- 判据收在适配器里(is_closed):宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)会关掉同一条连接、发同一个 TransportClosed,那些不算失败,调用点两条分支的控制流保持不变;连接自己断掉才算,且只认第一份原因(第一份最接近现场,含 exitStatus 与 stderr 摘要)
- lifecycle_status 见到这条事实一律返回 failed:连接不是被本轮主动收束,也没有"用户主动停止"这层授权,报成 interrupted 只会让界面停在"本轮已结束"却不给原因
- 连接级故障(app-server 进程退出 / 流断 / JSON 行越界 / stderr 读取失败)在收束连接之前先把事实记到本回合的执行适配器上,避免与盯着同一个 closed 标志的看门狗抢时序
- direct_turn_failure 增加第三来源且优先级最高:通道断开时原因取宿主诊断,不取只会说"收束到哪一步"的交付报告
- 单测三条:适配器把诊断记成失败终态且只认第一份原因;宿主自己关的连接不算失败;失败载荷优先取宿主诊断(含与 LlmError 并存时的优先级)
- ADR【DirectProject对话历史单一事实源】补一条决策:连接级故障与回合事件通道关闭同样带 failure{kind:"transport-failed"},原因用宿主当场写下的诊断,判据是"适配器是否已由宿主主动关闭"
- ADR「影响」补一条:断开时用户看到的仍是既有映射结果,真实诊断在事件载荷、宿主交付报告与运行日志里,改可见文案属于映射规则变更
- 技术方案【DirectProject Codex原始历史与异常恢复】同步线上形状,并写明失败事实为什么必须记在执行适配器上(看门狗会抢时序)
- decision-log 记本次决策、判据、不做项、影响范围与验证方式
- ADR【DirectProject对话历史单一事实源】补一条决策:终态按事实取原因、有载荷必 failed;模型自报失败的 turn.error 投影成 LlmError 走同一条错误通道,不为载荷新增字段
- ADR「影响」补一条:可见文案仍走既有映射,区别只是原因改由事件载荷给出、命令返回恢复运行错误横幅
- 技术方案【DirectProject Codex原始历史与异常恢复】写明 lifecycle_status 没有终态否决权,以及原生错误的投影口径
- decision-log 记本次决策、根因、不做项、影响范围与验证方式
宿主终态由事实判定:模型自报失败投影进既有错误通道,失败载荷不再被收尾阶段吞掉
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m6s
Project CI / Frontend tests (pull_request) Successful in 2m2s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m43s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m33s
Project CI / Native shell tests (pull_request) Successful in 6m12s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m3s
29d4b24226
- codex_app_server 的 failed 分支先投影原生 turn.error(复用 game_creator_codex_app_server_failed_turn_error),把它当作本回合的错误结果返回:载荷形状不变,RepairRequired 保持自己的原语义,交付报告不再顶掉原因
- direct_turn_terminal 去掉 model_status 入参:判定改为「宿主当场记下的失败 -> 本回合错误结果是 Err -> 只有账本读不出来时才用交付报告」,有载荷一定写 status="failed",没载荷才用收尾阶段推出来的 status
- 执行适配器把宿主观察到的失败记在适配器上(fail_turn / turn_failure / host_stop_requested):看门狗与终态判定共用同一条事实,不用调用点局部变量
- 单测:投影后的原生失败压过被收尾改写的会话状态、账本读不出来仍带载荷、宿主自己关的连接不算失败(断言改用真实原因)
k88936 changed title from WIP: Feat/fail as event to WIP: 重构/对话错误类型化, 避免string-typed 2026-09-23 13:31:09 +08:00
k88936 added 6 commits 2026-09-23 16:33:48 +08:00
- 新增 direct_turn_error 深模块:DirectTurnError 按变体各带字段,调用级拒绝与回合级失败分开
- 原生失败分类 DirectCodexNativeKind 由结构化前缀读入,事件载荷 kind 与旧口径逐条对齐
- 模型调用失败按平台 LlmError 分支投影成 DirectModelCallKind,反馈/重试/摘要/建议改由类型判定
- 跨进程边界仍由 Display 序列化成字符串,Rust 侧不再解析该字符串
- 新增 `agent/direct_turn_error.rs`:`DirectTurnError` 每个变体自带字段(调用级拒绝与回合级失败不共用结构和判据),分流只认 `is_turn_failure()`,不再有 `kind` 字段 + 共用字段的伪结构化
- 分类判据从"对原因文本做子串匹配"改成 `match` typed 值:`DirectCodexNativeKind` 只解析 `codex-app-server-error:<kind>` 结构化前缀,原 `direct_turn_failure_kind` / 各 `contains` 词表判据删除
- `direct_runtime`:`run_direct_game_creator_turn_*` 返回 typed 错误;本地 `DirectCodexFailureStage` / `DirectCodexTurnFailure` 与并发前缀常量改由 typed 模型提供;调用级拒绝不进失败诊断、不发 `failed` 事件
- `codex_app_server`:执行适配器把宿主亲见的收场事实(通道断开 / 超时 / 中断)存成 typed 值;模型自报失败经 `DirectTurnError::from_model_call` 投影
- `direct_turn_failure`:终态判定收 typed 错误并投影出载荷 `kind` / `message`;删除 `DIRECT_TURN_FAILURE_{TRANSPORT,INTERRUPTED,TIMEOUT}_KIND` 与 `direct_turn_failure_kind`
- `direct_delivery` 返修控制流改用 `ReviewRequired`(不是失败);命令边界与 CLI 仍是 `Result<String, String>`,字符串只在 `Display` 一处生成,`wire_kind` 取值与可见文案与改造前逐一相同
- ADR【DirectProject对话历史单一事实源】新增一条决策:回合失败在宿主内部是 typed 的、调用级拒绝与回合级失败不共用判据,线上载荷与命令边界仍由同一出口投影
- 同 ADR 修正原措辞:原生 error 现在按 `codexErrorInfo` 解析成 typed 分类,不再描述成"投影成 LlmError";影响一节补一条调用级拒绝只回命令边界、不写诊断不发失败事件
- decision-log 记本次决策、根因、明确不做项、影响范围与验证结果
- `direct_runtime/user_input.rs` 命令入口加 TODO:目标形状(接单 + spawn、`turn.started` 与终态守卫下沉、接单后早退必须补终态、环境类失败升为回合级、宿主侧承担留痕与上报)与两条已决策的作废项
- `useDirectProjectChatController.ts` 的 catch 加同一份 TODO,说明它现在兼职"接单被拒"与"回合失败"、将来只剩前者
- 两处都指向草案 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,本次不实施、不提交该草案
修复:调用级拒绝里属于宿主与环境事实的错补回运行错误诊断
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m23s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m51s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m22s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m45s
Project CI / Native shell tests (pull_request) Successful in 6m38s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m29s
07bac0377e
- `DirectTurnError::is_reportable`:按变体判定哪几条调用级拒绝值得进 `.agent/runtime/errors` 与应用日志(环境未就绪、宿主状态取不到、项目目录锚不定),回合级失败恒 false(上游已写过诊断)
- `record_direct_codex_turn_failure` 改名 `record_direct_codex_failure` 并开放到 crate 内:它同时服务回合失败与可留痕的调用级拒绝,摘要 / 可重试 / 建议仍全部由 typed 分类判定
- 新增 `direct_turn_error_boundary_text`:命令边界唯一的文本投影——可留痕的拒绝补一份诊断并在返回串里带 `详情:` 引用,其余只输出 `Display`
- GUI 命令与 CLI 边界共用这一份投影:字符串只在边界生成一次,前端横幅的 `详情:` 展开与诊断留痕恢复分层改 typed 之前的行为
- 新增 4 项测试:可留痕拒绝写出诊断、用户侧拒绝不留痕、回合失败不在边界二次留痕、`is_reportable` 的变体集合
k88936 added 36 commits 2026-09-24 16:57:22 +08:00
- 重写 ADR:逻辑回合归 Thread Manager、接单/拒单判据改成发生位置、拒单载荷复用 typed 错误、userItemId 由 clientTurnId 推导、提示与用户消息同级并删除详情、队列与埋点改挂回合完成、失败原因本轮不落历史
- CONTEXT.md 新增「逻辑回合」「接单」「拒单」「在途回合」四个术语
- 对话历史单一事实源 ADR 加注:两条已知边界已由新 ADR 重新决策
- Codex 原始历史技术方案加注:三句结论待实施时按新 ADR 修订
- docs/README.md 索引补上该 ADR
- `directCodexSession.ts` 改名 `directCodexSessionKeepalive.ts`,只保留会话保活常量,删除 `withDirectCodexSessionRefresh` 的"刷新 + 重跑整轮"及其登录失效识别
- 发送回合与终止回合两处调用点直接 invoke,不再包一层重试
- controller 的 catch TODO 更新为新形状:命令只接单、拒单返回 typed 错误、整轮结果只由事件载荷回答
- `record_direct_codex_failure` 的收口文案去掉 `;详情:<path>`,诊断 sidecar 与应用日志照写
- 同一出口把最终文案送进错误上报池:命令接单化后前端 catch 只剩"接单被拒",池不能只靠前端填
- 删除 `persist_direct_codex_failure_context`:失败说明本轮不写进项目历史,留 TODO 记录以后"进历史但不喂模型"的通道
- 删除只服务详情展开的 IPC `read_agent_runtime_error_detail` 及其注册
- 前端去掉 `详情:` 正则与第二次读取,横幅只显示一句话
- 同步命令边界与相关测试注释
- 新增实施计划:四步落地顺序(Thread Manager 逻辑回合 → 命令接单 + 后台整轮 → typed 拒单 → 队列/埋点/快照/reducer),每步给改动点、不变式与验收
- 记录已落地三项(重试删除、详情引用删除、失败进池与不落历史)与三条已知坑
- docs/README.md 索引补上该计划
- direct_thread_manager 新增逻辑回合占用:接单在同一个临界区里拒并发 + 登记占用 + 追加 turn.started,返回已占用的 token
- direct_thread_manager 拆出深层终态出口与占用兜底出口,notify 从 append 里抽出来复用
- 新增 direct_turn_accept:接单对象持有这一轮的终态出口,Drop 兜底补 host-dropped
- direct_turn_failure 删除 DirectTurnFailureGuard,终态改成显式构造的 DirectTurnTerminal
- codex_app_server 不再镜像 Codex 原生回合:删掉 run_turn 内的 turn.started 与守卫武装,终态改走 complete_direct_thread_turn
- 用户条目事件仍由 run_turn 下发,顺序固定为逻辑回合开始 → 用户消息 → 起 codex
- 命令顺序固定为 clientTurnId 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备 → 接单 → 落盘用户条目 → spawn
- 命令返回值收窄成"拒单":接单成立后不再有 Err,整轮结果只由事件流回答
- 新增 check_direct_turn_preconditions,前置检查从 run_..._and_emitter 上移,GUI 与 CLI 共用
- 作废"调用级拒绝直通"分支:判据改成位置,接单后一律按回合失败处理
- 删除 DirectTurnError::is_turn_failure,EnvironmentNotReady 补 wire_kind = environment-not-ready
- run_turn 不再重复落盘用户条目,只把它的身份作为第一条运行态条目下发
- 新增用例:接单之后才发现的失败也必须补出 turn.completed
宿主侧把拒单收成结构化载荷,界面不再解析任何文案前缀。
- `DirectTurnError` 及其嵌套枚举补 `Serialize + TS`,导出到 `chat/generated/`
- 新增 `DirectTurnRejection`(结构化变体 + `Display` 生成的唯一一份文案),命令返回类型改为它
- `EnvironmentNotReady` 补 `environment-not-ready` 失败分类,避免回合失败被写成 `model-failed`
- `TurnAlreadyRunning` 去掉机器前缀,两条文案按身份是否相同分岔
- 删掉「按文案前缀判定」的协议约定与 `is_turn_failure`,通道改由**发生位置**决定
- 兜底终止路径改走 `complete_direct_thread_turn`:写终态的同时解除占用,不再只裸追加事件

前端按 `error.type` 分流,删掉三个按文案判断的旧函数。
- 新增 `readDirectTurnRejection` / `directTurnRejectionNotice` / `directTurnRejectionNoticeMessageId`
- 认得的参数 / 前置条件类(空内容、并发、参数非法、工程根等)写成与用户消息同级的提示,
  不占状态行、不写运行错误、不上报
- 认不得的宿主 / 环境事实与其它非结构化错误原样抛出,走既有捕获链路(上报 + 横幅)
- `chat_with_game_creator_direct_codex` 的 catch 从此只剩「拒单」一种输入

测试与绑定同步更新:appSurface 两条用例按新语义重写,`userItemId` / 终态时刻的注释跟着改。
活动回合表的唯一事实源从"调用身份守卫"搬进逻辑回合占用,任务侧不再另建一张表。
- `ActiveDirectTurn` 带上快照字段(回合身份 / 项目名 / 起点 / 状态 / 活动 / 序号),
  接单时初始化,收口时随占用一起消失
- 新增 `update_direct_thread_active_turn`(进度回填,只认身份一致且序号不倒退)与
  `list_direct_active_turns`(只导出仍有未收口回合的 thread)
- `DirectActiveTurnSnapshot` 移进 `direct_thread_manager`,`projectPath` 用线程身份,
  与事件流里的项目身份是同一个字符串
- `DirectTaonierActiveInvocation` 退回纯单飞锁:只留调用身份与登记时刻
- 回合更新发射器不再按项目路径 canonicalize 找表,改为持线程身份回填
- `DirectTurnReservation::accept` 多带一个 `clientTurnId`(快照与进度匹配用),
  与占用 token 是两个身份
- 上下文身份的两个测试补上"逻辑回合也接单"这一步:身份来自接单,不是调用守卫
- reducer 新增 `completedTurnCount`(单调计数):一轮可能在同一次 consume 里开始并结束,
  下降沿不可靠,收口是**状态**不是转移
- 队列放行只在"回合终态或拒绝接单"发生;命令返回不再驱动出队
  (接单被拒仍当场出队,权限被拒等从未发出的路径保持原样)
- 埋点结算挂到回合终态:接单返回时成绩还没入账,句柄因此活过命令返回;
  拒单只丢句柄、不发一次注定被丢弃的结算
- 本地在途标签活到宿主认领这一轮(身份命中 / 出现开始事件 / 收口计数变化),
  "命令返回"不再等于"这一轮结束",命令与开始事件之间不再有可发送的空窗
- 删掉 `markTurnStopped()`:终止成功的回合边界由宿主写的兜底终态收口
- 删掉 `turn.started` 的"重复起点保留第一次"兼容分支(接单只发一次开始事件)
- 同步注释:发送时序、待认领窗口、`commandInFlight` 的真实含义
- 测试:队列用例改用终态事件驱动,认证失败用例断言拒单不结算,reducer 补收口计数用例
direct_runtime/user_input.rs:补 CLI 与 GUI 的分工——CLI 入口保持 await(它要回复文本,没有事件订阅),两个入口共用同一份接单前检查、同一个命令主体与同一份 Display 文案;DirectTaonierActiveInvocationGuard 的注释改成只挡并发、不再是首页快照来源。
direct_runtime/mod.rs:TODO(Direct 命令接单化) 改成 TODO(失败条目进历史),指向备选方案第 3 条;失败说明本轮不落历史的口径不变。
cli.rs:删掉"带上诊断与 详情: 引用"的过期注释,改成与 GUI 同一份 Display 文案、不另加引用,差别只在 CLI 自己 await 整轮。
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:索引行去掉"未实施",改成四步均已落地。
docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md:§2 写明"早退"= 回合内任何没走到正常终态的收口点(turn/start 被拒、注入失败、panic)。
docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md:第 2 步同一处补定义。
docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md:宿主异常收场段补同一句定义。
断言原来查的是命令 Err 的原始文本,而这条文本永远不会被渲染(前端先用 projectRuntimeVisibleError 映射成通用可见文案,再走头部状态行的横幅),因此断言空过、盖不住"命令通道又写一条聊天文案"这个回归。
改成在 `陶泥儿消息` 列表里查映射后的文案(横幅不在这个列表里),并用一次变异验证:在 catch 里补一条 appendLocalMessage 后该用例变红,撤掉即绿。
directThreadChat.ts:`turn.completed.failure.message` 在生成类型里是必填 string,但跨 IPC 的载荷没有运行时校验,缺字段 / null 时 `.trim()` 会在 reducer 里抛错,把这条订阅之后的所有事件一起打断;改成与兄弟函数 directTurnFailureNoticeText 一致的 typeof 判据,取不到非空字符串就按"没有原因"收口。
directThreadChat.test.ts:补一条回归用例(message 为 undefined / null 时不抛错、不补空气泡、终态照样收口);变异验证:撤掉 typeof 判据后该用例变红。
directTurnFailure.ts:原来那句"身份不可证明时退化成与事件时间绑定的固定形状……不会让两轮失败互相覆盖"漏了 `at` 也拿不到的那一档——常量 direct-thread-turn-failure 会让两条这样的失败按同一个 itemId 合并。补上这一档的真实行为与取舍(唯一性与重放不变不可兼得,这里选重放不变),代码不动。
direct_turn_error.rs:DirectDomainFact::invites_repair 每个分支都返回 false,is_model_repairable 里的 is_none_or(invites_repair) 实际等价于 is_none(),第一个分支还误导性地暗示"有些事实值得反馈"。删掉该方法,调用点直接写 is_none(),并把"认出是哪一类就拦"的理由写进注释;行为逐条不变(direct_ 过滤 474 passed)。
direct_runtime/mod.rs:两处 `Err(_) if terminal_report(...).is_some()` 的 guard 与取值各调了一次 terminal_report,两次之间状态变化就会拿到不一致的结果——流式分支第二次拿到 None 时会把空串当回复返回(界面显示"未返回可展示的回复"),非流式分支则绕过"未返回结果"的兜底错误。改成一次读取后落变量,判据与取值同源;两个分支的优先级(有报告 > 可反馈修复 > 原样抛出)不变。direct_ 过滤 474 passed。
direct_turn_error.rs:DirectCodexNativeKind::is_retryable 把 Unauthorized 归进 false 组,而同一份事实走 DirectDomainFact::AuthenticationRejected 时是 true,于是 retryable 取决于哪一层先认出它;旧口径对 401 / authentication-required 一律返回 true,这里对齐成可重试,并写明与 recovery_hint 同口径的理由。
direct_runtime/mod.rs:补一条断言(原生 codex-app-server-error:unauthorized 与深层 authentication-required: HTTP 401 同为可重试、都不可反馈给模型);agent:: 过滤 950 passed。
execution.rs:`fail_turn` 的判据从"只看 is_closed"改成"`is_closed` 或 `host_stop_requested` 都不算失败"。用户点「终止」时 `cancel_from_host` 先同步置位 `host_stop_requested`、再异步中断会话,`closed` 与阶段要等那个任务跑到才变;这段窗口里到达的 `TransportClosed` / `interrupted` 都是宿主自己收尾的结果,以前会被记成 `transport-failed`。判据收在 `fail_turn` 里,调用点不必各写一遍,将来新增收口路径也不会漏。不记失败事实照旧收束,原因仍写进报告。
mod.rs:`interrupted` 分支去掉现在重复的 `!host_stop_requested()` 检查(同一个事实只留一处判据)。
execution.rs 单测:新增"用户请求过终止 + 未 closed 时 fail_turn 不写失败事实、原因仍进报告";变异验证:撤掉新判据该用例变红。codex_app_server 过滤 101 passed。
- 新增 `HostOutcomeText`:封口复核的"继续返修批次"用独立变体表达,不再伪装成 `LlmError::InvalidRequest`
- 新增 `DirectTurnRunFailure`:app-server 回合结果区分"真失败"与"返修控制流",`RepairRequired` 不写终态
- `direct_turn_terminal_write` 收口终态写出:返修要求跳过,真失败从 typed 错误投影 `kind` / `message`
- 早退取得宿主收尾事实的分支只认真失败,返修控制流不再被中断成一次收束
- `DirectTurnError` 新增 `RepairRequired` 变体并重新生成前端绑定
- 返修循环同时消费 `ReviewRequired` / `RepairRequired`,次数上限仍留在产生侧
- 补单测:返修要求不写终态、真失败投影成 transport-failed、正常收尾不带载荷
- Direct 回合的终态判定事实改成先固定上下文,写点留到解析与线程释放之后
- structured output 解析折进同一个收尾结果:解析失败不再"终态写完才失败",改走失败载荷
- 收尾结果拆成 `DirectTurnReport`(报告正文 + 解析结果),终态兜底文案仍取被解析的那份文本
- 新增 `DirectTurnTerminalContext::write` 作为唯一终态出口,占用解除与 `turn.completed` 一起走
- `direct_turn_terminal_write` 改成"报告 / 失败"两个入参,便于单测覆盖三种投影
- 补单测:解析失败投影成 `model-failed` 载荷,正常收尾不带失败载荷
- ADR §2 补两条不变式:终态的写点在整轮结束之后、封口返修要求不是回合失败
- 技术方案同步改写"终态由事实判定"一段,并补"终态写点在整轮结束之后"的判据
- ADR 末尾补 2026-09-24 后续更新索引,指向技术方案与决策记录
- 决策记录追加 2026-09-24 条目:终态写点、返修控制流、终止判据、登录态重试与失败载荷健壮性
- 冲突 codex_app_server/mod.rs:保留类型化回合失败 DirectTurnRunFailure 与 EnvironmentNotReady 分类,接受删除 audit/metrics 参数
- 冲突 direct_runtime/mod.rs:保留类型化错误反馈循环与"交付报告只读一次",保留 check_direct_turn_preconditions,接受 master 的 turn_kind 贯穿与首页无项目对话退役
- 冲突 direct_runtime/user_input.rs:保留接单后发射器与 canonical 用户条目的新调用签名
- 删除:随 master 移除 direct_codex_audit、direct_turn_metrics 两个账本模块及其全部引用
- 删除:direct_codex_error_should_feedback 字符串判据与首页对话函数及其测试
- 调整:direct_codex_error_feedback_prompt 收成单参数,与已落地的提示词模板一致
- 修复:master 新插入的用户条目冻结块改用 DirectTurnError::turn_failed
- `directTurnRejectionNotice` 认得的拒单在宿主文案为空白时返回 `null`,不再返回空串:`''` 显示不出任何提示,却会被按 `!== null` 判据的调用方当成"有提示"
- 补注释把"有提示"的判据说清:要么给一条能显示的话,要么给 `null`
- `runTurn` 的 catch 先把非结构化错误折成文本,再让结构化拒单文案覆盖,替掉原先"拒单 / Error / 其它"三层嵌套的三元表达式
- `runTurn` 的 catch 先读结构化拒单,只有"这一轮没接单"的拒单才清 `pendingRunAnalyticsRef`;非结构化错误(IPC 失败、命令 panic)可能发生在接单之后,句柄留着等 `turn.completed` 结算,不再让宿主侧这一轮的候选永远没人结算
- chat-composer 用例的注释同步:这一轮没接单,就没有回合终态事件来驱动结算
- `chat-composer` 的"登录态失效不重跑整轮"用例给 `requestPlatformSessionRefresh` 的 spy 补 `mockResolvedValue`,真回归时以 mock 结果干净失败,不再在测试里发起真实刷新
- 桩值用现役的 `stale`(`PlatformSessionRefreshResult` 只有 `refreshed / stale / failed` 三种)
- 新增 `DirectTurnFailureKind`,成为 `DirectTurnFailure.kind` 的唯一取值表:`timeout / model-failed / transport-failed / request-rejected / environment-not-ready / turn-interrupted / host-dropped`(补齐原先两份注释都漏掉的 `turn-interrupted`)
- `DirectModelCallKind::wire_kind` 与 `DirectTurnError::wire_kind` 改成返回该枚举,`DirectTurnFailure::new` / `DirectTurnTerminal::host_dropped` 同步改签名;线上取值仍是原来的 kebab-case 字符串
- 补 `failure_kind_wire_values_are_stable` 用例:7 个变体的序列化 / 反序列化取值逐条钉住,改名即改协议会先在这里失败
- 前端生成绑定重新导出:`chat/generated/DirectTurnFailureKind.ts` 新增,`DirectTurnFailure.ts` 的 `kind` 由 `string` 收窄成 union
- 受影响断言(`direct_thread_manager` / `direct_turn_accept` / `direct_thread_wire` / `codex_app_server`)改成比较枚举变体
- Thread Manager 的 `accept_turn` 在并发冲突时回**已有的 `turn_id`**(调用方的 `clientTurnId`),不再回进程内的占用 token
- `DirectTurnReservation::accept` 的拒单载荷改成 `existing`=已在跑那一轮的 `clientTurnId`、`incoming`=本次请求的 `clientTurnId`;同一轮重发时两者相等,"同一轮消息仍在处理中"那条文案才走得到
- 补 `accept_conflict_reports_client_turn_ids_not_reservation_tokens`(同一轮 / 另一轮两条分支都钉住)与 `accept_conflict_returns_the_existing_turn_id`(manager 侧只回回合身份)
- 占用对象自己的 token 保持 UUID 不变(`complete_direct_thread_turn_if_reserved` 靠它配对),改的只有错误载荷
- `ProjectRootUnanchored` 从 `is_reportable()` 拿掉,与 `ProjectRootUnusable` 同类:符号链接 / 权限 / 目录被删都是用户自己就能修的文件系统事实,留痕只会变成噪声
- 它不再被 `direct_turn_rejection` 覆写成 `direct-codex-failure:v2 …` 诊断文案,界面按 `Display` 显示「无法锚定 Direct 调用项目目录:{cause}」,两侧对同一变体的分类不再自相矛盾
- 同步 `only_host_and_environment_rejections_are_reportable` 用例与 `is_reportable` 的文档注释
- `projectRuntimeVisibleError` 增加宿主 `Display` 事实句模式:`执行通道已断开`(TransportClosed)、`等待模型回合结束达到硬上限`(TimedOut 的硬上限那档)、`宿主任务提前结束`(host-dropped),并给落盘那档补上 `收尾历史失败` / `未确认历史完整落盘` / `写入本项目对话历史失败`——改动前这几种都掉进「执行失败,请稍后重试」
- 只认宿主写死的短语、不回落原文:原文带 `exitStatus=` / `stderrClass=` 这类内部字段,`TransportClosed` 就是这种
- 修掉宿主收口文案的版本口径:解析只认 `v1`,而宿主发的是多一段 `code=` 的 `v2`,于是脱敏摘要永远命中不了;现在两版都认,并把解析结果拆成 parts,供拒单文案复用(`projectRuntimeVisibleRejectionError`,不带阶段标签——拒单这一轮没有开始)
- `directTurnFailureNoticeText` 的文档注释写明「不加模式就只会看到通用文案」是有意取舍,加模式时补 `agentRuntimeModel.test.ts` 用例
- 用例:`agentRuntimeModel.test.ts` 补 v2 收口文案、拒单文案与三句宿主事实句;`directThreadChat.test.ts` 的「收尾历史失败」期望改成映射后的句子
- 新增 `directTurnUnrecognizedRejectionNoticeText`:宿主 / 环境事实的拒单在聊天里的文案取宿主收口文案的脱敏摘要与建议(`projectRuntimeVisibleRejectionError`),不是收口形状时只给一句通用兜底,机器字段不进聊天
- 控制器在「认不出的拒单」分支补写一条与用户消息同级的提示(沿用 `directTurnRejectionNoticeMessageId` 身份):拒单不产生 `turn.completed`,这条乐观用户气泡后面不会再有事件来解释它;上报与横幅照旧保留
- 修正该处注释「聊天里的失败说明不由这里写」——那条只对回合失败成立,拒单没有终态出口;同时把非结构化错误继续只走横幅的理由写清楚
- 用例:`chat-composer.suite.ts` 补一条结构化拒单的界面用例(同级提示可见、`direct-codex-failure` / `stage=` 不进聊天、忙碌态放掉);`project-conversation.suite.ts` 补该文案函数的单元断言
- ADR 的后续更新补第二轮:§4 的拒单载荷 `kind` 收成 typed 枚举与并发拒单身份改成回合身份、§5 的回合身份口径覆盖拒单载荷、§6 的可留痕判据收掉 `ProjectRootUnanchored`、§7 的"同级提示"补上认不出的拒单
- 实施计划加「review 收口第二轮(2026-09-24)」一节,记下拒单表与界面提示口径的现状
- 决策记录追加同日第二条:五条决策、明确不做、两条待决策(连接收束时序、接单后落盘失败的双通道)与影响范围 / 验证证据
- `CodexAppServerInner` 新增私有去重标志 `connection_end_claimed`,与 `closed` 分开:认领只保证死亡收口只跑一次,"看门狗可以开始收束"必须等失败事实写进执行适配器
- `fail_game_creator_codex_app_server_connection` 改用新标志去重,不再顺带置 `closed`;`closed` 交给 `shutdown_game_creator_codex_app_server_inner` 在 `record_execution_turn_failure` 之后置位,看门狗在事实落地前没有可观测信号
- 补一条把看门狗真正跑起来的回归用例 `connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn`:卡住 stderr 摘要锁把窗口拉成确定性,断言终态仍带 `transport-failed` 载荷(顺序反了就红)
- 失败事实是在模型终态那一刻被快照进终态上下文的,晚补记无用,所以只修"事实先于可见性"这一条落点
- `chat_with_game_creator_direct_codex_typed` 在接单后的历史追加写失败时仍写失败终态,但返回 `Ok(())`:命令的 `Err` 只表示拒单,同一个失败不该从事件与横幅两条通道下发,前端也不该把已经开始的回合读成没开始
- 不继续起整轮:`project.jsonl` 是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户消息的助手回复
- 补 Rust 用例 `a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting`:借历史追加写的测试注入钉住恰好一条失败终态、不带拒单收口文案、占用已释放
- 补前端用例:落盘失败的说明只来自事件且恰好一条,忙态放掉,下一条能直接发出去
文档:接单化 review 收口第二轮的剩余两条写进 ADR、实施计划与共享记忆
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m4s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m25s
Project CI / Backend tests (pull_request) Successful in 4m0s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m1s
Project CI / Frontend tests (pull_request) Successful in 2m7s
Project CI / Native shell tests (pull_request) Successful in 6m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m47s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m45s
Project CI / Repository checks (pull_request) Successful in 2m12s
8ff155e14f
- ADR §1 补"接单之后的一切失败都回 `Ok(())`"、§2 补"失败事实先于看门狗可见",并说明落盘失败不继续起整轮
- 实施计划第二轮小节补命令返回值口径与连接死亡的记录顺序,点名看门狗回归用例
- 共享记忆同日条目的"两条待决策"转成决策,验证计数更新为 902 passed / 5 ignored
Author
Member
  • 9ecb084b6 宿主:失败载荷的 kind 改成 typed 枚举(第 1 条)
  • bd78a91e5 宿主:并发拒单的两个身份改成回合身份(第 8 条)
  • 5a0f3b803 宿主:目录锚不定的拒单不再写诊断(第 9 条)
  • b42966eb9 前端:Direct 失败说明补上宿主事实句的文案模式(第 7 条)
  • 85d69a29a 前端:认不出的拒单也在聊天里补一条同级提示(第 11 条)
  • ab970b9fd 宿主:连接死亡的失败事实先于看门狗落地(第 6 条,按你选的 ①)
  • cd5feac5f 宿主:接单之后的落盘失败不再从命令返回 Err(第 10 条,按你选的「接单成立」)
  • 2f5e0b0ed 文档:接单化 review 收口第二轮写进 ADR、实施计划与共享记忆
  • 8ff155e14 文档:接单化 review 收口第二轮的剩余两条写进 ADR、实施计划与共享记忆
  • 第一轮:2ce96a73f(第 3 条)、6cee61b97(第 4 条)、7941aaa66(第 5 条)、2331f62f1(第 2 条)

验证:Rust cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"(902 passed / 5 ignored,含本轮新增的看门狗回归用例)、定向 --bins "agent::direct_turn_error"(15 passed)、cargo fmt;前端
npx vitest run apps/ai-game-creator-shell/tests/{appSurface.test.ts,directRunAnalytics.test.ts,directProjectTurn.test.tsx,agentRuntimeModel.test.ts,directThreadChat.test.ts}appSurface.test.ts 222 tests / 9 skipped,另四份 65 passed,合计 278 passed / 9 skipped)、
npm --prefix apps/ai-game-creator-shell run typechecknpm run check:encoding(5069 files)、npm run check:doc-indexgit diff --check
坑:cargo test 会跑 export_bindings 并重写全部 chat/generated/(引号风格漂移),跑完必须 git checkout -- 掉不是本次新增 / 改动过的那些文件,否则 git diff --check 会报 trailing whitespace。

对齐的 ADR / 决策(读过的权威口径):

  • docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md:命令 = 接单 / 拒单(接单前的失败不产生回合事件、不写用户条目、不写诊断);逻辑回合由 Thread Manager 拥有且 turn.started / turn.completed 结构性成对;占用对象是唯一终态出口且幂等(正常 / 失败 / 中断 / 取消 / 连接断开谁先到谁写);终态的写点在整轮真正结束之后;回合身份由 clientTurnId 推导(§5);失败原因只走 turn.completed.failure(§6/§7)。

  • docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.mdproject.jsonl 就是这条对话的历史,回合失败原因本轮不落历史。

  • docs/project-memory/shared-memory/decision-log.md 2026-09-24 条目:不改线上载荷形状(仍是 {kind, message})、失败说明条目的已知边界只补注释;本轮追加了同日第二条(kind 收成 typed 枚举、拒单身份、可留痕判据、提示口径与两条待决策)。

  • 1. DirectTurnFailure.kind 仍是裸 string,且两份取值名单都漏了 turn-interrupted → 已修 9ecb084b6

    • 当前实现(修前):direct_thread_wire.rs 的载荷是 DirectTurnFailure { kind: String, message: String },由 DirectTurnTerminal::failedfailure.wire_kind().unwrap_or("model-failed") 投影;ts-rs 导出成 kind: stringwire_kind() 实际有 7 个取值(timeout / model-failed / transport-failed / request-rejected / environment-not-ready / turn-interrupted / host-dropped),而生成文件与 Rust doc 注释里的名单只有 6 个。
    • 问题:名单不完整 + TS 侧无法穷尽收窄。影响面有限:全仓没有一处按 failure.kind 分支(rg "failure\.kind" 只命中生成文件与测试),它只是「给界面选语气」的标签。
    • 已修:新增 DirectTurnFailureKindSerialize + Deserialize + TSkebab-case、7 个变体),wire_kind() 返回 Option<DirectTurnFailureKind>host_dropped()DirectTurnFailure::new 同步改签名,重新导出 generated/DirectTurnFailureKind.ts 并把两处 doc 名单补全。线上形状与取值一个都没变(仍是 {kind, message}、仍是那 7 个字符串),所以决策记录里「不改线上载荷形状」没有被破例;新增用例 failure_kind_wire_values_are_stable 钉住取值。
  • 2. 测试 spy 没有 mock 实现(appSurface/chat-composer.suite.ts,登录态那条)→ 已修 2331f62f1

    • 当前实现(修前):vi.spyOn(platformSession, 'requestPlatformSessionRefresh') 只包了一层、没有桩实现;真回归时测试会先跑真实刷新(网络 / 会话副作用)再断言 not.toHaveBeenCalled()
    • 已修:补 mockResolvedValue({ status: 'stale' })。注意 review 给的建议值 { status: 'unavailable' } 不是现役取值——PlatformSessionRefreshResult 只有 refreshed / stale / failed
    • 额外核对:这个 spy 打在模块命名空间上,能不能拦到控制器里直接 import 的绑定是本条成立的前提;我用一个临时探针用例实测(能拦到),探针用完已删。
  • 3. directTurnRejectionNotice 的空文案返回 '' 而不是 null → 已修 2ce96a73f

    • 当前实现(修前):认得的那批拒单统一 return rejection.message.trim();,宿主文案为空白时返回空串,与函数标注的 string | null 契约不符。
    • 已修:return rejection.message.trim() || null;。补充实测结论:review 说「会静默掉到通用横幅」不准——调用方现在是 if (notice),空串本来就落到同一条横幅分支,所以这次没有用户可见行为变化,只是把契约改对。
  • 4. 失败分支里的嵌套三元(控制器 catch)→ 已修 6cee61b97

    • 当前实现(修前):rejection ? … : error instanceof Error ? … : String(error) 三层套。
    • 已修:先折非结构化错误,再让结构化拒单文案覆盖,语义不变(这一处在第 11 条里被改成了 rejection ? 拒单映射 : 运行错误映射,仍没有嵌套三元)。
  • 5. 埋点句柄对所有错误都先清掉 → 已修 7941aaa66

    • 当前实现(修前):catch 一进来就把 pendingRunAnalyticsRef 匹配 clientTurnId 的那一项清空,之后才读结构化拒单。
    • 问题:review 的场景成立——非结构化错误只可能来自 IPC 层(命令 panic / 序列化失败),那时宿主可能已经接单;句柄被提前清掉,等 turn.completed 到达时 settlePendingRunAnalytics() 只能空转,宿主侧这一轮的候选永远没人结算。
    • 已修:先 readDirectTurnRejection(error),只有结构化拒单(「这一轮没接单」)才清句柄;非结构化错误留着等终态结算。同提交里同步了 chat-composer.suite.ts 里那句已经过时的注释。
  • 6. 「失败事实先于连接收束」的时序保证不成立(codex_app_server/mod.rs vs execution.rs)→ 已修 ab970b9fd(你问"哪个修法让代码库更好":① 是唯一能给出"事实先于可见性"的修法,已按 ① 落地)

    • 当前实现(按现在的代码逐点核过):
      1. fail_game_creator_codex_app_server_connection(stdout EOF / JSON-RPC 无效 / stderr 读失败 / stderr 单条超限都调它)第一步就是 inner.closed.swap(true, Ordering::AcqRel)——它同时干两件事:去重(第二个调用者直接 return)和把"连接已死"暴露给看门狗
      2. 之后才 child.lock().await + try_wait()stderr_summary.lock().awaitapp_log!,最后 record_execution_turn_failureadapter.fail_turn(TransportClosed{…})。这三步里有两个 .await 加锁和一个日志写,都可能让出线程。
      3. 看门狗(ExecutionAdapter::start_watchdog)每一轮先判 !adapter.closed && inner.closedinterrupt("执行连接已结束,正在核对自有子进程与在途操作。"),紧接着(同一轮迭代session.tick() 看到阶段已经是终态就 shutdown_and_report:置 adapter.closed + background_done、回收连接、发 outcome。
      4. fail_turn 的记入判据是 !self.is_closed() && !self.host_stop_requested()is_closed() 读的正是适配器自己那个被看门狗置上的 closed
      5. 所以看门狗只要在第 1 步和第 2 步末尾之间抢到一次 tick,typed TransportClosed 就记不进去,那一轮退化成「本轮已结束、没有原因」。
    • 三个 review 没提、但决定修法的关键事实:
      a. 还有第二个记入点:collect 循环收到 CodexTurnEvent::TransportClosed(或事件通道无终态关闭)时也会 fail_turn,外面还套了一层 !adapter.is_host_ending()。看门狗先动手时这两条判据同样都已经为真,所以这个记入点也救不回来。
      b. 失败事实是"快照"读的mod.rs 里在模型终态那一刻就把 adapter.turn_failure() 拷进 DirectTurnTerminalContext(不是终态写出时才读),所以"晚一点补记"没有用——必须保证事实在看门狗可见之前就已经落地。
      c. 窗口量级:inner.closed 置位到 record_execution_turn_failure 之间是"两次无争用加锁 + 一次日志写"(微秒级),而看门狗最长要等 200ms,但 changed / notified 也会唤醒它,且 stderr 读任务持 stderr_summary 锁(observe 写摘要时)、app_log! 的 sink 阻塞时窗口会被拉长——所以窗口窄但真实存在。
      d. 现有 Rust 用例(host_observed_failure_is_recorded_with_its_kind_and_reasonhost_ended_turn_is_not_a_failure)都是直接调 fail_turn,没有把看门狗跑起来,所以这条不变量目前只活在注释里。
    • 修法①(保证"先记事实、再让看门狗看得见";我推荐这条):给 CodexAppServerInner 加一个只服务去重的私有 AtomicBool(例如 connection_failure_claimed),fail_game_creator_codex_app_server_connection 用它去重,取消这里对 inner.closed 的置位,让 shutdown_game_creator_codex_app_server_inner 里那一次(它已经存在)在 record_execution_turn_failure 之后把标志置上。于是看门狗在事实落地前没有任何可观测信号,竞态从根上消失。代价与注意:① 其它读 inner.closed 的地方(mod.rs:2966mod.rs:4559、连接池淘汰 mod.rs:2417)会晚几十微秒看到"连接已死";② 两个并发的死亡观察者会各自走到 shutdown_game_creator_codex_app_server_inner(它本身是幂等的:child 已被取走时读已记录的退出证明),但"只记一次事实"的语义要改由新的私有标志承担;③ 必须补一条把看门狗真正跑起来的用例(断言 turn_failure() 非空、终态带 transport-failed 载荷),否则这条不变式还是只有注释。
    • 修法②(让这条路径绕开适配器的 is_closed()):新增一个只给"连接死亡"用的入口(如 fail_turn_from_connection_end),判据收到 !host_stop_requested(),调用方再叠加"这次死亡不是宿主挑起的"。要点与两个风险:① stdout EOF 与 stderr 读失败走的是同一个函数,而宿主自己收尾时顺序是"先置 adapter.closed → 再关连接 → stdout 才 EOF",所以拿掉 is_closed() 后必须换一个判据,否则正常终态 / 预算封口会被回写成 transport-failedhost_ended_turn_is_not_a_failure 会红);② 剩下唯一能用的判据是阶段(is_host_ending()),可看门狗在竞态里同一轮已经把阶段推成 Interrupted,所以 ② 只能收窄窗口、堵不住它。要真做 ②,得把"谁挑起的收尾"变成显式 provenance(谁置的标志写在自己的字段上),而不是复用 is_closed() / 阶段这两个都会被看门狗翻的判据。
    • 我的结论:做 ①(唯一能给出"事实先于可见性"的落点),② 只作为"如果你想少动标志语义"的备选,且必须先把 provenance 拆出来。两条都要补看门狗用例;这条也建议在 ADR §2 的"谁先到谁写"不变式旁边补一句"失败事实必须先于看门狗可见"。
    • 已修(ab970b9fd):CodexAppServerInner 新增私有的 connection_end_claimed 做去重;fail_game_creator_codex_app_server_connection 不再置 inner.closedclosed 交给 shutdown_game_creator_codex_app_server_innerrecord_execution_turn_failure 之后置位。于是看门狗在事实落地前拿不到任何可观测信号,窗口从根上消失;closed 的字段注释改成"这一段已经收束 / 失败事实已经记下",明说它不能再兼作去重标志,收口路径里也补了顺序不变式的注释。
    • 用例(connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn#[cfg(unix)]):假 app-server 起真连接 + 真 DirectProject 工作区,turn/start 应答后锁住 stderr_summary(收口路径停在"记事实"之前),再放子进程退出、睡 500ms(≥ 看门狗 200ms 周期)后放锁,断言恰好一条终态是 failed + transport-failed 载荷、message 含「已退出」。两个方向都验过:现在的代码绿,把去重改回 inner.closed.swap(...) 立刻红(left: "interrupted", right: "failed")。
    • 没做 ②:is_closed() 与阶段都会被看门狗翻,要真做必须先拆出"谁挑起的收尾"的 provenance,改动面比 ① 大且只能收窄窗口,所以只作为备选留档。
  • 7. 失败说明条目的文案映射会吃掉宿主准备好的原因 → 已修 b42966eb9(含一处 review 没提的口径 bug)

    • 当前实现(修前):turn.completed.failure.message 由宿主脱敏 + 截断(600 字上限),前端 directTurnFailureNoticeText 整条丢进 projectRuntimeVisibleError(raw, '陶泥儿智能创作', true);那个函数只认它自己的模式 / 白名单表,未命中就兜底成「陶泥儿智能创作 执行失败,请稍后重试」。
    • 问题:确实吃掉原因。例如「DirectProject 收尾历史失败:未确认历史完整落盘」既不含 落盘失败 / 写入失败,也不含路径或 kind=,用户只剩一句通用文案;TransportClosed 的「执行通道已断开,不能自动重放未确认操作:…」同理(它连 connect / transport 这类关键字都没有)。review 建议的「没命中就回落宿主原文」不能照做:原文里带 exitStatus= / stderrClass= 这类内部字段,正是靠不回落才没进聊天。
    • 附带发现(review 只提了前半句,我这边核实了两处):前端 directCodexDiagnosticFailureDetail 只匹配 direct-codex-failure:v1,而宿主发的是多一段 code=v2git log -S 'v2 stage=' → 自 #376 起就是 v2),所以那条"可读诊断摘要"路径从来没命中过,横幅对可留痕的拒单一直是通用文案。
    • 已修:① 按宿主 Display 逐个加模式——执行通道已断开 → 「服务连接已断开,请稍后重试」、等待模型回合结束达到硬上限 → 「响应超时,请稍后重试」、宿主任务提前结束 → 「本轮执行已中断,请重试」、落盘那一档补 收尾历史失败 / 未确认历史完整落盘 / 写入本项目对话历史失败(这一档 review 的举例正好命中);② v1/v2 都认(把解析拆成 DirectDiagnosticPartsprojectRuntimeVisibleRejectionError 复用同一份解析);③ 把「不加模式就只会看到通用文案」写成 directTurnFailure.ts 的注释,并注明加模式要补 agentRuntimeModel.test.ts 用例;④ 用例:新增 v2 收口文案、拒单文案、三句宿主事实句,directThreadChat.test.ts 里那条断言旧通用文案的期望改成映射后的句子。
    • 一处没动的边界(避免扩大改动面,先记在这里):TimedOut 的空闲上限那句(「等待模型执行回执超时…」)本来就含"超时"→ 命中已有分支,所以只给硬上限那句加了模式;另外「无法锚定 Direct 调用项目目录:拒绝访问」会被映射里的「拒绝」子串分支认领成「被项目权限或安全策略阻止」,但目录锚不定的拒单在聊天里走 Display 原样显示、且不在上报名单里,摸不到这句(我在用例里把它写成了已知边界)。
  • 8. TurnAlreadyRunning 的身份字段在占用登记这条路径上是进程内 UUID → 已修 bd78a91e5

    • 当前实现(修前):direct_thread_manager.rs:205-236accept_turn 冲突时返回 active.token.clone()(进程内 UUID);direct_turn_accept.rs:44-51existing_invocation_id = existingincoming_invocation_id = token.clone()(另一个新 UUID)塞进 TurnAlreadyRunning。另一个产出点用的是真实 clientTurnId
    • 问题:① Displayexisting == incoming 的「同一轮消息仍在处理中」分支在这条路径上永远不可能命中;② 前端拿到的两个身份不是 clientTurnId,与「回合身份由 clientTurnId 推导」和字段注释冲突。
    • 影响(比 review 说的要小):GUI 流程里身份 guard(按 canonical root 登记、身份就是 clientTurnId)先于占用登记执行,且两个 guard 同生共死,所以并发重发实际撞的是前者、身份是对的;这条 UUID 路径基本到不了,但它是一处真实的不一致。
    • 已修:accept_turn 冲突时返回 active.turn_idDirectTurnReservation::acceptclient_turn_id 传成 incoming_invocation_id。占用对象自己的 token 仍是 UUID(complete_direct_thread_turn_if_reserved 靠它配对),只换了错误载荷里的两项;新增两条用例(占用登记侧、Thread Manager 侧各一条)。
  • 9. ProjectRootUnanchored 归「可上报」,但前端把它当「用户能自己改」,于是用户看到原始诊断串 → 已修 5a0f3b803(按你选的 ①)

    • 当前实现(修前):is_reportable()ProjectRootUnanchored 标成 true;边界 direct_turn_rejection 对可上报变体用 record_direct_codex_failure(...) 的返回值覆盖 message,那份文本是 direct-codex-failure:v2 stage=… code=… retryable=… summary=…;建议:…;已保存脱敏项目诊断;前端 directTurnRejectionNoticeprojectRootUnanchored 列进「认得」名单并原样显示 message
    • 问题:符号链接 / 权限 / 目录被删这类事实被归成「可上报」后,用户在聊天里看到的是 v2 诊断串,而不是 Display 的「无法锚定 Direct 调用项目目录:{cause}」;两侧对同一变体的分类自相矛盾。
    • 已修(①):把它从 is_reportable() 拿掉,与 ProjectRootUnusable 同类——用户自己就能修的文件系统事实;现在它按 Display 显示,也不再进 .agent/runtime/errors。可留痕的拒单只剩 environmentNotReady / hostStateUnavailable。代价是这类事实的留痕变少(你已确认接受)。
    • 注:review 说的「附带发现(v1/v2 口径)」确实是真 bug,已在第 7 条里修掉。
  • 10. 接单之后的落盘失败仍从命令返回 Err,同一个失败下发两次 → 已修 cd5feac5f(你说「接单成立」:落盘失败仍然写失败终态,但命令不再回 Err

    • 当前实现(逐行核过):direct_runtime/user_input.rsDirectTurnReservation::accept 已经发出 turn.started 之后,append_direct_project_user_message_at 失败会先 reservation.finish_if_unfinished(DirectTurnTerminal::failed(...))(写出 turn.completed status=failed + EnvironmentNotReady 载荷),然后 return Err(EnvironmentNotReady{ detail: "写入本项目对话历史失败:…" })
    • 问题:① 与命令契约冲突(「接单成立之后的一切失败由占用对象收口成 turn.completed,不再回到返回值上」);② 同一个失败经两条通道下发:事件在聊天里写一条失败说明,命令 Err 又给横幅,且 EnvironmentNotReady 可上报 → 边界再写一份诊断 + 上报池(前端 catch 也 captureAgentRuntimeError,只靠 fingerprint 合并);③ 前端模型是「Err = 拒单、这一轮没开始」,但这里 turn.started 已经发过,于是忙态与出队会同时被"事件驱动"和"命令返回驱动"两条路推。
    • 你问的两件事,我的回答:
      1. 落盘失败要不要"继续起回合"?不要。 project.jsonl 是这条对话的单一事实源(见「对话历史单一事实源」ADR):这一轮的用户消息没进历史,下一轮的注入就缺这条消息,而继续跑出来的回复会正常落盘 → 历史里出现「没有开口用户消息的助手回复」;而且失败被静默,用户以为在跑。所以接单后的落盘失败仍然是这一轮的失败,必须留下解释,只是解释不该走命令返回值。
      2. 要不要用 Err 下发这条"落盘失败"?不要,建议改成 Ok(()) finish_if_unfinished 已经写完终态,占用对象的 Drop 兜底自然变成空操作;前端 Ok 只表示"接单成立",忙态 / 出队 / 埋点结算都由那条已经入队的事件驱动(订阅会补发),于是同一失败只出现一次、也不会再把"已经开始的回合"读成"没开始"。
    • 已修(cd5feac5f):chat_with_game_creator_direct_codex_typed 在接单后的历史追加写失败时仍然 reservation.finish_if_unfinished(DirectTurnTerminal::failed(...))(事件流里那条失败说明就是界面唯一一份解释),但改成 return Ok(())——命令的 Err 只表示拒单;注释里写明"接单后的一切失败由占用对象收口"以及"不继续起整轮"的理由(历史是这条对话的单一事实源)。
    • 用例:Rust a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting.agent/runtime/test-fail-next-direct-project-history-append 注入把两次追加写都判成争用失败,断言恰好一条失败终态、message 含「写入本项目对话历史失败」且不含 direct-codex-failure(拒单收口文案不许出现在回合失败里)、占用已释放(下一轮还能接单);前端 appSurface/chat-composer.suite.ts 的落盘失败用例断言说明恰好一条、忙态放掉、下一条能直接发出去。
    • 若你想保留 Err:那要一起改三处——ADR 的分工描述改成两条通道并存、前端对 environmentNotReady 拒单不再写横幅、以及"命令 Errturn.started 已发"下由谁驱动队列与忙态的说明。成本比 Ok(()) 高,未采用。
  • 11. 认不出的拒单只留横幅,乐观用户气泡永远没有解释 → 已修 85d69a29a(按你选的 ①)

    • 当前实现(修前):控制器 catch 里认不出的拒单(environmentNotReady / hostStateUnavailable)与其它非结构化错误共用「上报 + 横幅」通道,聊天里什么都不写;注释还写着「宿主已经把它放进了 turn.completed.failure,reducer 会把它落成本轮最后一条条目」。
    • 问题:拒单没有接单,宿主不会为它发 turn.completeddirect_turn_error.rs 的拒单 / 失败分层就是这条判据),所以那条乐观用户气泡后面永远没有说明,只剩一条会消失的横幅;注释对拒单不成立。这是删掉本地说明条目时留下的洞(当时的"失败说明唯一来源是事件"只对回合失败成立)。
    • 已修(①):新增 directTurnUnrecognizedRejectionNoticeText,控制器在这条分支补写一条与用户消息同级的提示(沿用 directTurnRejectionNoticeMessageId 身份);文案走第 7 条那份映射的拒单档——取宿主收口文案里已脱敏的 summary;建议:…不套阶段标签(拒单这一轮没有开始,阶段只会是默认值 code-generation,套上去会把没发生的事讲成发生了),机器字段(direct-codex-failure / stage= / code=)不进聊天。上报与横幅照旧保留(一个是给用户看的话,一个是把现场送进上报池 / .agent/runtime/errors)。非结构化错误仍只走横幅——它可能发生在接单之后,说明由事件流负责。
    • 用例:chat-composer.suite.ts 补一条结构化拒单的界面用例(同级提示可见、direct-codex-failure / stage= 不进聊天、忙碌态放掉、能直接重发);project-conversation.suite.ts 补文案函数的单元断言(含"不是收口形状时只给通用兜底")。
- `9ecb084b6` 宿主:失败载荷的 kind 改成 typed 枚举(第 1 条) - `bd78a91e5` 宿主:并发拒单的两个身份改成回合身份(第 8 条) - `5a0f3b803` 宿主:目录锚不定的拒单不再写诊断(第 9 条) - `b42966eb9` 前端:Direct 失败说明补上宿主事实句的文案模式(第 7 条) - `85d69a29a` 前端:认不出的拒单也在聊天里补一条同级提示(第 11 条) - `ab970b9fd` 宿主:连接死亡的失败事实先于看门狗落地(第 6 条,按你选的 ①) - `cd5feac5f` 宿主:接单之后的落盘失败不再从命令返回 Err(第 10 条,按你选的「接单成立」) - `2f5e0b0ed` 文档:接单化 review 收口第二轮写进 ADR、实施计划与共享记忆 - `8ff155e14` 文档:接单化 review 收口第二轮的剩余两条写进 ADR、实施计划与共享记忆 - 第一轮:`2ce96a73f`(第 3 条)、`6cee61b97`(第 4 条)、`7941aaa66`(第 5 条)、`2331f62f1`(第 2 条) 验证:Rust `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"`(902 passed / 5 ignored,含本轮新增的看门狗回归用例)、定向 `--bins "agent::direct_turn_error"`(15 passed)、`cargo fmt`;前端 `npx vitest run apps/ai-game-creator-shell/tests/{appSurface.test.ts,directRunAnalytics.test.ts,directProjectTurn.test.tsx,agentRuntimeModel.test.ts,directThreadChat.test.ts}`(`appSurface.test.ts` 222 tests / 9 skipped,另四份 65 passed,合计 278 passed / 9 skipped)、 `npm --prefix apps/ai-game-creator-shell run typecheck`、`npm run check:encoding`(5069 files)、`npm run check:doc-index`、`git diff --check`。 坑:`cargo test` 会跑 `export_bindings` 并重写全部 `chat/generated/`(引号风格漂移),跑完必须 `git checkout --` 掉不是本次新增 / 改动过的那些文件,否则 `git diff --check` 会报 trailing whitespace。 对齐的 ADR / 决策(读过的权威口径): - `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`:命令 = 接单 / 拒单(接单前的失败不产生回合事件、不写用户条目、不写诊断);逻辑回合由 Thread Manager 拥有且 `turn.started` / `turn.completed` 结构性成对;占用对象是唯一终态出口且幂等(正常 / 失败 / 中断 / 取消 / 连接断开**谁先到谁写**);终态的写点在整轮真正结束之后;回合身份由 `clientTurnId` 推导(§5);失败原因只走 `turn.completed.failure`(§6/§7)。 - `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`:`project.jsonl` 就是这条对话的历史,回合失败原因本轮不落历史。 - `docs/project-memory/shared-memory/decision-log.md` 2026-09-24 条目:不改线上载荷形状(仍是 `{kind, message}`)、失败说明条目的已知边界只补注释;本轮追加了同日第二条(`kind` 收成 typed 枚举、拒单身份、可留痕判据、提示口径与两条待决策)。 - [x] 1. `DirectTurnFailure.kind` 仍是裸 `string`,且两份取值名单都漏了 `turn-interrupted` → 已修 `9ecb084b6` - 当前实现(修前):`direct_thread_wire.rs` 的载荷是 `DirectTurnFailure { kind: String, message: String }`,由 `DirectTurnTerminal::failed` 用 `failure.wire_kind().unwrap_or("model-failed")` 投影;ts-rs 导出成 `kind: string`。`wire_kind()` 实际有 7 个取值(`timeout / model-failed / transport-failed / request-rejected / environment-not-ready / turn-interrupted / host-dropped`),而生成文件与 Rust doc 注释里的名单只有 6 个。 - 问题:名单不完整 + TS 侧无法穷尽收窄。影响面有限:全仓没有一处按 `failure.kind` 分支(`rg "failure\.kind"` 只命中生成文件与测试),它只是「给界面选语气」的标签。 - 已修:新增 `DirectTurnFailureKind`(`Serialize + Deserialize + TS`、`kebab-case`、7 个变体),`wire_kind()` 返回 `Option<DirectTurnFailureKind>`,`host_dropped()` 与 `DirectTurnFailure::new` 同步改签名,重新导出 `generated/DirectTurnFailureKind.ts` 并把两处 doc 名单补全。**线上形状与取值一个都没变**(仍是 `{kind, message}`、仍是那 7 个字符串),所以决策记录里「不改线上载荷形状」没有被破例;新增用例 `failure_kind_wire_values_are_stable` 钉住取值。 - [x] 2. 测试 spy 没有 mock 实现(`appSurface/chat-composer.suite.ts`,登录态那条)→ 已修 `2331f62f1` - 当前实现(修前):`vi.spyOn(platformSession, 'requestPlatformSessionRefresh')` 只包了一层、没有桩实现;真回归时测试会先跑真实刷新(网络 / 会话副作用)再断言 `not.toHaveBeenCalled()`。 - 已修:补 `mockResolvedValue({ status: 'stale' })`。注意 review 给的建议值 `{ status: 'unavailable' }` **不是现役取值**——`PlatformSessionRefreshResult` 只有 `refreshed / stale / failed`。 - 额外核对:这个 spy 打在模块命名空间上,能不能拦到控制器里直接 import 的绑定是本条成立的前提;我用一个临时探针用例实测(能拦到),探针用完已删。 - [x] 3. `directTurnRejectionNotice` 的空文案返回 `''` 而不是 `null` → 已修 `2ce96a73f` - 当前实现(修前):认得的那批拒单统一 `return rejection.message.trim();`,宿主文案为空白时返回空串,与函数标注的 `string | null` 契约不符。 - 已修:`return rejection.message.trim() || null;`。补充实测结论:review 说「会静默掉到通用横幅」不准——调用方现在是 `if (notice)`,空串本来就落到同一条横幅分支,所以这次**没有用户可见行为变化**,只是把契约改对。 - [x] 4. 失败分支里的嵌套三元(控制器 catch)→ 已修 `6cee61b97` - 当前实现(修前):`rejection ? … : error instanceof Error ? … : String(error)` 三层套。 - 已修:先折非结构化错误,再让结构化拒单文案覆盖,语义不变(这一处在第 11 条里被改成了 `rejection ? 拒单映射 : 运行错误映射`,仍没有嵌套三元)。 - [x] 5. 埋点句柄对所有错误都先清掉 → 已修 `7941aaa66` - 当前实现(修前):`catch` 一进来就把 `pendingRunAnalyticsRef` 匹配 `clientTurnId` 的那一项清空,之后才读结构化拒单。 - 问题:review 的场景成立——非结构化错误只可能来自 IPC 层(命令 panic / 序列化失败),那时宿主**可能已经接单**;句柄被提前清掉,等 `turn.completed` 到达时 `settlePendingRunAnalytics()` 只能空转,宿主侧这一轮的候选永远没人结算。 - 已修:先 `readDirectTurnRejection(error)`,只有结构化拒单(「这一轮没接单」)才清句柄;非结构化错误留着等终态结算。同提交里同步了 `chat-composer.suite.ts` 里那句已经过时的注释。 - [x] 6. 「失败事实先于连接收束」的时序保证不成立(`codex_app_server/mod.rs` vs `execution.rs`)→ 已修 `ab970b9fd`(你问"哪个修法让代码库更好":① 是唯一能给出"事实先于可见性"的修法,已按 ① 落地) - 当前实现(按现在的代码逐点核过): 1. `fail_game_creator_codex_app_server_connection`(stdout EOF / JSON-RPC 无效 / stderr 读失败 / stderr 单条超限都调它)第一步就是 `inner.closed.swap(true, Ordering::AcqRel)`——它同时干两件事:**去重**(第二个调用者直接 return)和**把"连接已死"暴露给看门狗**。 2. 之后才 `child.lock().await` + `try_wait()`、`stderr_summary.lock().await`、`app_log!`,最后 `record_execution_turn_failure` → `adapter.fail_turn(TransportClosed{…})`。这三步里有两个 `.await` 加锁和一个日志写,都可能让出线程。 3. 看门狗(`ExecutionAdapter::start_watchdog`)每一轮先判 `!adapter.closed && inner.closed` 就 `interrupt("执行连接已结束,正在核对自有子进程与在途操作。")`,紧接着(**同一轮迭代**)`session.tick()` 看到阶段已经是终态就 `shutdown_and_report`:置 `adapter.closed` + `background_done`、回收连接、发 outcome。 4. `fail_turn` 的记入判据是 `!self.is_closed() && !self.host_stop_requested()`,`is_closed()` 读的正是适配器自己那个被看门狗置上的 `closed`。 5. 所以看门狗只要在第 1 步和第 2 步末尾之间抢到一次 tick,typed `TransportClosed` 就记不进去,那一轮退化成「本轮已结束、没有原因」。 - 三个 review 没提、但决定修法的关键事实: a. **还有第二个记入点**:collect 循环收到 `CodexTurnEvent::TransportClosed`(或事件通道无终态关闭)时也会 `fail_turn`,外面还套了一层 `!adapter.is_host_ending()`。看门狗先动手时这两条判据同样都已经为真,所以这个记入点也救不回来。 b. **失败事实是"快照"读的**:`mod.rs` 里在模型终态那一刻就把 `adapter.turn_failure()` 拷进 `DirectTurnTerminalContext`(不是终态写出时才读),所以"晚一点补记"没有用——必须保证事实在看门狗可见之前就已经落地。 c. 窗口量级:`inner.closed` 置位到 `record_execution_turn_failure` 之间是"两次无争用加锁 + 一次日志写"(微秒级),而看门狗最长要等 200ms,但 `changed` / `notified` 也会唤醒它,且 stderr 读任务持 `stderr_summary` 锁(`observe` 写摘要时)、`app_log!` 的 sink 阻塞时窗口会被拉长——所以窗口窄但真实存在。 d. 现有 Rust 用例(`host_observed_failure_is_recorded_with_its_kind_and_reason`、`host_ended_turn_is_not_a_failure`)都是**直接调 `fail_turn`**,没有把看门狗跑起来,所以这条不变量目前只活在注释里。 - 修法①(**保证"先记事实、再让看门狗看得见"**;我推荐这条):给 `CodexAppServerInner` 加一个只服务去重的私有 `AtomicBool`(例如 `connection_failure_claimed`),`fail_game_creator_codex_app_server_connection` 用它去重,**取消**这里对 `inner.closed` 的置位,让 `shutdown_game_creator_codex_app_server_inner` 里那一次(它已经存在)在 `record_execution_turn_failure` **之后**把标志置上。于是看门狗在事实落地前没有任何可观测信号,竞态从根上消失。代价与注意:① 其它读 `inner.closed` 的地方(`mod.rs:2966`、`mod.rs:4559`、连接池淘汰 `mod.rs:2417`)会晚几十微秒看到"连接已死";② 两个并发的死亡观察者会各自走到 `shutdown_game_creator_codex_app_server_inner`(它本身是幂等的:child 已被取走时读已记录的退出证明),但"只记一次事实"的语义要改由新的私有标志承担;③ 必须补一条**把看门狗真正跑起来**的用例(断言 `turn_failure()` 非空、终态带 `transport-failed` 载荷),否则这条不变式还是只有注释。 - 修法②(**让这条路径绕开适配器的 `is_closed()`**):新增一个只给"连接死亡"用的入口(如 `fail_turn_from_connection_end`),判据收到 `!host_stop_requested()`,调用方再叠加"这次死亡不是宿主挑起的"。要点与两个风险:① stdout EOF 与 stderr 读失败走的是同一个函数,而宿主自己收尾时顺序是"先置 `adapter.closed` → 再关连接 → stdout 才 EOF",所以拿掉 `is_closed()` 后必须换一个判据,否则正常终态 / 预算封口会被回写成 `transport-failed`(`host_ended_turn_is_not_a_failure` 会红);② 剩下唯一能用的判据是阶段(`is_host_ending()`),可看门狗在竞态里**同一轮**已经把阶段推成 `Interrupted`,所以 ② 只能收窄窗口、堵不住它。要真做 ②,得把"谁挑起的收尾"变成显式 provenance(谁置的标志写在自己的字段上),而不是复用 `is_closed()` / 阶段这两个都会被看门狗翻的判据。 - 我的结论:做 ①(唯一能给出"事实先于可见性"的落点),② 只作为"如果你想少动标志语义"的备选,且必须先把 provenance 拆出来。两条都要补看门狗用例;这条也建议在 ADR §2 的"谁先到谁写"不变式旁边补一句"失败事实必须先于看门狗可见"。 - 已修(`ab970b9fd`):`CodexAppServerInner` 新增私有的 `connection_end_claimed` **只**做去重;`fail_game_creator_codex_app_server_connection` 不再置 `inner.closed`,`closed` 交给 `shutdown_game_creator_codex_app_server_inner` 在 `record_execution_turn_failure` **之后**置位。于是看门狗在事实落地前拿不到任何可观测信号,窗口从根上消失;`closed` 的字段注释改成"这一段已经收束 / 失败事实已经记下",明说它不能再兼作去重标志,收口路径里也补了顺序不变式的注释。 - 用例(`connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn`,`#[cfg(unix)]`):假 app-server 起真连接 + 真 `DirectProject` 工作区,`turn/start` 应答后**锁住 `stderr_summary`**(收口路径停在"记事实"之前),再放子进程退出、睡 500ms(≥ 看门狗 200ms 周期)后放锁,断言恰好一条终态是 `failed` + `transport-failed` 载荷、message 含「已退出」。**两个方向都验过**:现在的代码绿,把去重改回 `inner.closed.swap(...)` 立刻红(`left: "interrupted", right: "failed"`)。 - 没做 ②:`is_closed()` 与阶段都会被看门狗翻,要真做必须先拆出"谁挑起的收尾"的 provenance,改动面比 ① 大且只能收窄窗口,所以只作为备选留档。 - [x] 7. 失败说明条目的文案映射会吃掉宿主准备好的原因 → 已修 `b42966eb9`(含一处 review 没提的口径 bug) - 当前实现(修前):`turn.completed.failure.message` 由宿主脱敏 + 截断(600 字上限),前端 `directTurnFailureNoticeText` 整条丢进 `projectRuntimeVisibleError(raw, '陶泥儿智能创作', true)`;那个函数只认它自己的模式 / 白名单表,未命中就兜底成「陶泥儿智能创作 执行失败,请稍后重试」。 - 问题:确实吃掉原因。例如「DirectProject 收尾历史失败:未确认历史完整落盘」既不含 `落盘失败 / 写入失败`,也不含路径或 `kind=`,用户只剩一句通用文案;`TransportClosed` 的「执行通道已断开,不能自动重放未确认操作:…」同理(它连 `connect` / `transport` 这类关键字都没有)。review 建议的「没命中就回落宿主原文」**不能照做**:原文里带 `exitStatus=` / `stderrClass=` 这类内部字段,正是靠不回落才没进聊天。 - 附带发现(review 只提了前半句,我这边核实了两处):前端 `directCodexDiagnosticFailureDetail` 只匹配 `direct-codex-failure:v1`,而宿主发的是多一段 `code=` 的 `v2`(`git log -S 'v2 stage='` → 自 #376 起就是 v2),所以那条"可读诊断摘要"路径**从来没命中过**,横幅对可留痕的拒单一直是通用文案。 - 已修:① 按宿主 `Display` 逐个加模式——`执行通道已断开` → 「服务连接已断开,请稍后重试」、`等待模型回合结束达到硬上限` → 「响应超时,请稍后重试」、`宿主任务提前结束` → 「本轮执行已中断,请重试」、落盘那一档补 `收尾历史失败 / 未确认历史完整落盘 / 写入本项目对话历史失败`(这一档 review 的举例正好命中);② v1/v2 都认(把解析拆成 `DirectDiagnosticParts`,`projectRuntimeVisibleRejectionError` 复用同一份解析);③ 把「不加模式就只会看到通用文案」写成 `directTurnFailure.ts` 的注释,并注明加模式要补 `agentRuntimeModel.test.ts` 用例;④ 用例:新增 v2 收口文案、拒单文案、三句宿主事实句,`directThreadChat.test.ts` 里那条断言旧通用文案的期望改成映射后的句子。 - 一处**没动**的边界(避免扩大改动面,先记在这里):`TimedOut` 的空闲上限那句(「等待模型执行回执超时…」)本来就含"超时"→ 命中已有分支,所以只给硬上限那句加了模式;另外「无法锚定 Direct 调用项目目录:拒绝访问」会被映射里的「拒绝」子串分支认领成「被项目权限或安全策略阻止」,但目录锚不定的拒单在聊天里走 `Display` 原样显示、且不在上报名单里,摸不到这句(我在用例里把它写成了已知边界)。 - [x] 8. `TurnAlreadyRunning` 的身份字段在占用登记这条路径上是进程内 UUID → 已修 `bd78a91e5` - 当前实现(修前):`direct_thread_manager.rs:205-236` 的 `accept_turn` 冲突时返回 `active.token.clone()`(进程内 UUID);`direct_turn_accept.rs:44-51` 把 `existing_invocation_id = existing`、`incoming_invocation_id = token.clone()`(另一个新 UUID)塞进 `TurnAlreadyRunning`。另一个产出点用的是真实 `clientTurnId`。 - 问题:① `Display` 里 `existing == incoming` 的「同一轮消息仍在处理中」分支在这条路径上永远不可能命中;② 前端拿到的两个身份不是 `clientTurnId`,与「回合身份由 `clientTurnId` 推导」和字段注释冲突。 - 影响(比 review 说的要小):GUI 流程里身份 guard(按 canonical root 登记、身份就是 `clientTurnId`)先于占用登记执行,且两个 guard 同生共死,所以并发重发实际撞的是前者、身份是对的;这条 UUID 路径基本到不了,但它是一处真实的不一致。 - 已修:`accept_turn` 冲突时返回 `active.turn_id`,`DirectTurnReservation::accept` 把 `client_turn_id` 传成 `incoming_invocation_id`。占用对象自己的 `token` 仍是 UUID(`complete_direct_thread_turn_if_reserved` 靠它配对),只换了错误载荷里的两项;新增两条用例(占用登记侧、Thread Manager 侧各一条)。 - [x] 9. `ProjectRootUnanchored` 归「可上报」,但前端把它当「用户能自己改」,于是用户看到原始诊断串 → 已修 `5a0f3b803`(按你选的 ①) - 当前实现(修前):`is_reportable()` 把 `ProjectRootUnanchored` 标成 `true`;边界 `direct_turn_rejection` 对可上报变体用 `record_direct_codex_failure(...)` 的返回值覆盖 `message`,那份文本是 `direct-codex-failure:v2 stage=… code=… retryable=… summary=…;建议:…;已保存脱敏项目诊断`;前端 `directTurnRejectionNotice` 把 `projectRootUnanchored` 列进「认得」名单并原样显示 `message`。 - 问题:符号链接 / 权限 / 目录被删这类事实被归成「可上报」后,用户在聊天里看到的是 v2 诊断串,而不是 `Display` 的「无法锚定 Direct 调用项目目录:{cause}」;两侧对同一变体的分类自相矛盾。 - 已修(①):把它从 `is_reportable()` 拿掉,与 `ProjectRootUnusable` 同类——用户自己就能修的文件系统事实;现在它按 `Display` 显示,也不再进 `.agent/runtime/errors`。可留痕的拒单只剩 `environmentNotReady` / `hostStateUnavailable`。代价是这类事实的留痕变少(你已确认接受)。 - 注:review 说的「附带发现(v1/v2 口径)」确实是真 bug,已在第 7 条里修掉。 - [x] 10. 接单之后的落盘失败仍从命令返回 `Err`,同一个失败下发两次 → 已修 `cd5feac5f`(你说「接单成立」:落盘失败仍然写失败终态,但命令不再回 `Err`) - 当前实现(逐行核过):`direct_runtime/user_input.rs` 里 `DirectTurnReservation::accept` 已经发出 `turn.started` 之后,`append_direct_project_user_message_at` 失败会先 `reservation.finish_if_unfinished(DirectTurnTerminal::failed(...))`(写出 `turn.completed status=failed` + `EnvironmentNotReady` 载荷),**然后** `return Err(EnvironmentNotReady{ detail: "写入本项目对话历史失败:…" })`。 - 问题:① 与命令契约冲突(「接单成立之后的一切失败由占用对象收口成 `turn.completed`,不再回到返回值上」);② 同一个失败经两条通道下发:事件在聊天里写一条失败说明,命令 `Err` 又给横幅,且 `EnvironmentNotReady` 可上报 → 边界再写一份诊断 + 上报池(前端 catch 也 `captureAgentRuntimeError`,只靠 fingerprint 合并);③ 前端模型是「`Err` = 拒单、这一轮没开始」,但这里 `turn.started` 已经发过,于是忙态与出队会同时被"事件驱动"和"命令返回驱动"两条路推。 - 你问的两件事,我的回答: 1. **落盘失败要不要"继续起回合"?不要。** `project.jsonl` 是这条对话的单一事实源(见「对话历史单一事实源」ADR):这一轮的用户消息没进历史,下一轮的注入就缺这条消息,而继续跑出来的回复会正常落盘 → 历史里出现「没有开口用户消息的助手回复」;而且失败被静默,用户以为在跑。所以接单后的落盘失败**仍然是这一轮的失败**,必须留下解释,只是解释不该走命令返回值。 2. **要不要用 `Err` 下发这条"落盘失败"?不要,建议改成 `Ok(())`。** `finish_if_unfinished` 已经写完终态,占用对象的 `Drop` 兜底自然变成空操作;前端 `Ok` 只表示"接单成立",忙态 / 出队 / 埋点结算都由那条已经入队的事件驱动(订阅会补发),于是同一失败只出现一次、也不会再把"已经开始的回合"读成"没开始"。 - 已修(`cd5feac5f`):`chat_with_game_creator_direct_codex_typed` 在接单后的历史追加写失败时仍然 `reservation.finish_if_unfinished(DirectTurnTerminal::failed(...))`(事件流里那条失败说明就是界面唯一一份解释),但改成 `return Ok(())`——命令的 `Err` 只表示**拒单**;注释里写明"接单后的一切失败由占用对象收口"以及"不继续起整轮"的理由(历史是这条对话的单一事实源)。 - 用例:Rust `a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting` 借 `.agent/runtime/test-fail-next-direct-project-history-append` 注入把两次追加写都判成争用失败,断言恰好一条失败终态、message 含「写入本项目对话历史失败」且**不含** `direct-codex-failure`(拒单收口文案不许出现在回合失败里)、占用已释放(下一轮还能接单);前端 `appSurface/chat-composer.suite.ts` 的落盘失败用例断言说明恰好一条、忙态放掉、下一条能直接发出去。 - 若你想**保留 `Err`**:那要一起改三处——ADR 的分工描述改成两条通道并存、前端对 `environmentNotReady` 拒单不再写横幅、以及"命令 `Err` 但 `turn.started` 已发"下由谁驱动队列与忙态的说明。成本比 `Ok(())` 高,未采用。 - [x] 11. 认不出的拒单只留横幅,乐观用户气泡永远没有解释 → 已修 `85d69a29a`(按你选的 ①) - 当前实现(修前):控制器 catch 里认不出的拒单(`environmentNotReady` / `hostStateUnavailable`)与其它非结构化错误共用「上报 + 横幅」通道,聊天里什么都不写;注释还写着「宿主已经把它放进了 `turn.completed.failure`,reducer 会把它落成本轮最后一条条目」。 - 问题:拒单没有接单,宿主不会为它发 `turn.completed`(`direct_turn_error.rs` 的拒单 / 失败分层就是这条判据),所以那条乐观用户气泡后面永远没有说明,只剩一条会消失的横幅;注释对拒单不成立。这是删掉本地说明条目时留下的洞(当时的"失败说明唯一来源是事件"只对回合失败成立)。 - 已修(①):新增 `directTurnUnrecognizedRejectionNoticeText`,控制器在这条分支补写一条与用户消息同级的提示(沿用 `directTurnRejectionNoticeMessageId` 身份);文案走第 7 条那份映射的**拒单档**——取宿主收口文案里已脱敏的 `summary;建议:…`,**不套阶段标签**(拒单这一轮没有开始,阶段只会是默认值 `code-generation`,套上去会把没发生的事讲成发生了),机器字段(`direct-codex-failure` / `stage=` / `code=`)不进聊天。上报与横幅照旧保留(一个是给用户看的话,一个是把现场送进上报池 / `.agent/runtime/errors`)。非结构化错误仍只走横幅——它可能发生在接单之后,说明由事件流负责。 - 用例:`chat-composer.suite.ts` 补一条结构化拒单的界面用例(同级提示可见、`direct-codex-failure` / `stage=` 不进聊天、忙碌态放掉、能直接重发);`project-conversation.suite.ts` 补文案函数的单元断言(含"不是收口形状时只给通用兜底")。
k88936 marked the pull request as ready for review 2026-09-24 16:59:29 +08:00
k88936 added 6 commits 2026-09-24 19:16:56 +08:00
- codex_app_server 逐次审批门禁与 direct_execution 补丁执行器门禁改为按 profile 分流:发行构建仍要求严格等于捆绑侧车固定版本,开发构建(debug_assertions)直接通过
- 修正开发态必然被拒的问题:开发构建从宿主 PATH 解析到的 Codex(本机 codex-cli 0.156.0)与固定版本 codex-cli 0.155.1 不等,且 Linux 与未 stage 侧车时没有可选固定版本,导致 Direct 回合在建连前就被拒
- 发行构建的拒单文案补上期望版本与实际版本,便于排障
- 同步调整受影响的单测:开发构建断言跳过门禁,发行构建断言仍拒绝版本漂移
- 本轮开口用户条目(item_completed,direct-codex:{clientTurnId}:user)原来在 app-server turn/start 应答之后才下发;接单到 turn/start 之间的失败(连不上 app-server、执行器未通过验收、历史注入失败)走不到那一步,事件流里只有逻辑回合的一对事件,没有开口条目
- 把那段内联下发抽成 emit_direct_thread_user_item,发点提前到「接单成立、用户条目落盘成功、起 codex 之前」(direct_runtime/user_input.rs 的命令主体),并删掉 turn/start 之后那一处:线上仍只有一处下发,不变式变成「接单 → 开口用户条目 → 整轮里其余一切」
- 新增回归用例 the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn:断言行首两条事件是带身份的 turn.started 与开口用户条目,终态只能在它们之后
- 回显过滤用例补上同一发点的模拟步骤(生产入口的两个动作:落盘 + 下发)
- 回合归属改成按身份(开口用户条目的 canonical itemId):失败说明条目带 turnUserItemId(reducer 写,缺身份时保持原顺序语义),buildDirectChatTurns 按身份分组,同一身份的条目永远同一轮
- 本轮开口条目还没到(回合在宿主下发条目之前就失败、或历史切片还没读回)时,本地乐观气泡按身份挂回自己那一轮,不再另开一轮:界面不再出现「错误显示在用户消息上面」+「气泡底下 0.0 秒」+「上一轮借走本轮终点(15.6 秒)」这一组现象
- 收口早退不再吞掉还没写进界面的失败说明(订阅重建后的 bootstrap 只回放生命周期锚点):只补说明、终点时间与「回合完成」计数,不重开回合、不动本轮起点 / 终点 / 身份
- 用例:directTurnPresentation 复现现场(两个回合、说明与气泡同段、耗时不再借上一轮的终点);directThreadChat 补身份字段与早退不吞说明两条
- ADR:补「开口用户条目先于整轮里的一切失败」这条顺序不变式(发点在接单 + 落盘之后、起 codex 之前),以及界面「回合归属只认身份」的口径
- 实施计划:新增「回合顺序修复(2026-09-24)」一节,写清现场、根因、两条改动与回归用例
- decision-log:新增同日决策(宿主发点提前 + 前端按身份归位、收口早退不吞说明)
- pitfalls:新增同日条目,并记下排查提示——先分清逻辑回合的 turn.started / turn.completed 与 app-server 协议的 turn/start 请求
- controller 删 `pendingUserItemId` / `beginTurnCommand` / `endTurnCommand`:忙态改由 `beginTurnBusy` / `endTurnBusy` 持有,宿主认领判据 = `turnRunning` 或收口计数变过(一轮在同一次 consume 里开始并结束)
- controller 删 `startTurn` 的乐观追加与 `messageAppended` 重跑参数、`DirectProjectTurnInput.messageText` 与首轮的 `directInitialTurnText`;controller 不再需要 `assets`
- 投影删 `awaiting-start` 展示态(只剩 `running` / `finished`)、`localSentTimes` / `sameIdentitySentAt`、本地用户气泡与它开回合的路径;带身份的拒单提示在会话末尾自成一组,不挂进上一轮,也不开运行态标记
- 时间口径:起点只认 `turn.started.at`、终点只认 `turn.completed.at`,用户气泡时钟取宿主落盘 / 观测时间;两边都空的回合整条「本轮结束于 … 」隐藏,不再出现 0.0 秒
- 用例:改造 `directTurnPresentation`(本地用户消息不进回合、拒单提示自成一组、两态判据、失败说明按身份归位)、`directProjectTurn` / `directProjectTurnStatus` / appSurface 窗口期用例,`directProjectTurn` 补 `afterEach(cleanup)`
- 注释与文档:ADR「命令接单化」后续更新、实施计划新增「删掉本地乐观用户气泡」、decision-log 与 pitfalls 同日条目、Codex 原始历史方案的口径句、`codex_app_server` 用户条目时间注释
合并:把 origin/master 的对外 MCP 语义工具与状态条、Markdown 修复并进接单化分支
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
7cb7bd9581
- 冲突(`chat-composer.suite.ts` 接单窗口用例)按双方意图合并:保留上游「窗口期就要显示处理中卡片」,同时保留本分支「卡片要等宿主 `turn.started.at` 才开始读秒」;用例改为窗口期断言卡片在、无「已耗时」,再补发 `turn.started` 与开口用户条目后才断言气泡与读秒出现。
- 补上运行中回合的起点链路(合入前只有收口条目带起点,运行中卡片读不到秒):`useDirectThreadChatSubscription` 暴露 `turnStartedAt`,控制器透传 `directTurnStartedAt`,`DirectProjectChatView` 交给 `buildDirectChatTurns`。
- 同步 ADR、实施计划、`decision-log`、`pitfalls` 的口径与注释(两态投影;接单窗口只有卡片且不读秒)。
- 其余上游变更直接并入:对外 OpenAPI / MCP 语义工具方案与实现、状态条读秒粒度与几何、对话 Markdown 容错。
k88936 added 1 commit 2026-09-24 19:18:15 +08:00
Merge remote-tracking branch 'origin/master' into feat/fail-as-event
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m39s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m53s
Project CI / Backend tests (pull_request) Successful in 5m10s
Project CI / Native shell tests (pull_request) Successful in 6m36s
Project CI / Frontend tests (pull_request) Successful in 2m6s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m8s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m2s
Project CI / Repository checks (pull_request) Successful in 1m53s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
2fa89006c4
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
k88936 merged commit bdab414d32 into master 2026-09-24 19:42:50 +08:00
k88936 deleted branch feat/fail-as-event 2026-09-24 19:42:50 +08:00
Sign in to join this conversation.