文档:接单化 review 收口的不变式写进 ADR 与共享记忆

- ADR §2 补两条不变式:终态的写点在整轮结束之后、封口返修要求不是回合失败
- 技术方案同步改写"终态由事实判定"一段,并补"终态写点在整轮结束之后"的判据
- ADR 末尾补 2026-09-24 后续更新索引,指向技术方案与决策记录
- 决策记录追加 2026-09-24 条目:终态写点、返修控制流、终止判据、登录态重试与失败载荷健壮性
This commit is contained in:
2026-09-24 13:51:10 +08:00
parent f19bd8de8c
commit c8478f07ef
3 changed files with 44 additions and 1 deletions
@@ -42,6 +42,12 @@
占用才释放。
- 因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性成立,不依赖实现者记得给每条"接单后提前收场"
(早退:回合内任何没走到正常终态的收口点,比如 `turn/start` 被拒、注入失败、panic)的路径补事件。
- **终态的写点在整轮真正结束之后**(执行结果收集、历史落盘、structured output 解析都定型):解析失败
也是这一轮的失败,落进同一份失败载荷。终态一旦先写成 `completed`,后面再失败的步骤就没有出口——
占用对象只兜"早退",解释不了"终态之后又失败"。
- **封口返修要求不是回合失败**:`HostOutcome::RepairRequired` 走独立的 typed 控制流变体
(`DirectTurnRunFailure::RepairRequired` → `DirectTurnError::RepairRequired`),不写终态、不进载荷、
不上报,由返修循环写回提示词继续跑。
### 3. 通道判据从"错误种类"改成"发生位置"
@@ -136,3 +142,7 @@
- 代码注释:`direct_runtime/user_input.rs` 的 `TODO`(分工改成 CLI 保持 await)、
`chat/controller/useDirectProjectChatController.ts` 的 catch TODO(队列挪 Rust)、
`direct_thread_wire.rs` 里 `userItemId`"由原生从已落盘条目上读取"的说明。
后续更新(2026-09-24,接单化 review 收口):§2 补"终态的写点在整轮结束之后"与"封口返修要求不是回合
失败"两条不变式;`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 的
"终态由事实判定"一段同步改写;`docs/project-memory/shared-memory/decision-log.md` 追加同日条目。
@@ -1,5 +1,36 @@
# 决策记录
## 2026-09-24 接单化 review 收口:终态写点、返修控制流、终止判据与失败投影
- 决策(终态的写点在整轮真正结束之后):Direct 回合先固定终态判定的上下文,`turn.completed` 的写出
挪到执行结果收集、历史落盘、structured output 解析都定型之后,成功与失败共用一个写点。解析失败也是
这一轮的失败,落进同一份失败载荷;改动前终态先写、再解析,解析失败时终态已是 `completed`,占用对象
的兜底变成空操作,用户看到"本轮结束、没有回复、没有任何解释"。收尾结果因此拆成
`DirectTurnReport`(报告正文 + 解析结果),占用解除与终态事件一起走 `DirectTurnTerminalContext::write`。
- 决策(封口返修要求是控制流,不是失败):`HostOutcome::RepairRequired` 不再伪装成
`LlmError::InvalidRequest("validation-source-changed: …")`,改为 typed 的
`DirectTurnRunFailure::RepairRequired` → `DirectTurnError::RepairRequired`:不写终态、不进载荷、不上报,
由 `direct_runtime` 的返修循环写回提示词继续跑(与 `ReviewRequired` 同一族,次数上限仍留在产生侧)。
改动前它被判成 `failed` 终态、界面收到一条假失败,还会让同一个逻辑回合写出第二条终态。
- 决策(用户按下的终止不算通道失败,判据收进 `fail_turn`):失败事实的判据是
`!is_closed() && !host_stop_requested()`,不再由各调用点各写一遍 `!is_host_ending()`。用户点「终止」时
标志先置位、阶段后变,原来的窗口里到达的 `TransportClosed` 会把用户自己的终止记成 `transport-failed`。
- 决策(登录态失效的两条分类路径统一可重试):认证失败不再按"重跑整轮"处理,刷新失败与重试失败都按
可重试的回合失败呈现(用户可见文案可能多一句"可直接重试",真实客户端观感未复核)。
- 决策(失败载荷的健壮性):前端 reducer 对 `failure.message` 做运行时判据(缺字段 / `null` 不再抛错,
与 `directTurnFailureNoticeText` 同口径);交付报告兜底只读一次 `terminal_report`(两次读取之间状态可能
变化,`None` 不再被 `unwrap_or_default()` 变成空回复);失败说明条目在无身份无时间时会撞成同一条
(已知边界,仅补注释)。
- 明确不做:不改线上载荷形状(仍是 `{kind, message}`);不给 DirectProject 回合补端到端集成用例(缺轻型
假 app-server 夹具),判据落在策略函数与适配器单测;不持久化"可见但不喂模型"的失败条目(TODO)。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server/{mod.rs,execution.rs},direct_runtime/{mod.rs,user_input.rs},direct_turn_error.rs}`、前端
`chat/{conversation/directThreadChat.ts,generated/DirectTurnError.ts}` 与 `tests/directThreadChat.test.ts`;
文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。
- 验证:`cargo test --bins "agent::"`(952 passed)、`cargo test --bins "direct_"`(475 passed)、定向
`codex_app_server`(102 passed)、前端 `directThreadChat.test.ts`(36 passed)与
`npm run ai-game-creator-shell:typecheck`、`npm run check:encoding`、`git diff --check` 通过。真实客户端观感未复核
(终态写点与终止竞态落在真实宿主收尾上,单测盖不住)。
## 2026-09-23 Direct 回合错误改 typed:调用级拒绝与回合级失败分开
- 回合失败在宿主内部改成 typed 的 `DirectTurnError`(`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs`):每个变体自带字段,调用级拒绝(并发复用同一 `clientTurnId`、另一条回合在跑、权限策略拒绝、目录锚不定、输入校验、环境/凭据未就绪)与回合级失败(模型调用失败、通道断开、等待超时、app-server 单方面中断、阶段失败)不共用判据,分流只认 `is_turn_failure()`。
@@ -154,7 +154,9 @@ type DirectThreadEvent =
`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`(返修请求)保持自己的原语义。
**终态由事实判定,不由收尾阶段反推。** `turn.completed.status` 不是收尾阶段的口径(`lifecycle_status` 只描述 ledger 阶段,没有终态否决权):判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有账本读不出来时才用交付报告」取原因,有载荷一定写 `status="failed"`。模型自报失败(原生 `turn/completed` 的 `error`,含 `codexErrorInfo`)复用同一条通道:宿主把它投影成 `LlmError` 后当作本回合的错误结果返回,原因文本里带着 `codex-app-server-error:<kind>` 前缀(前端 `projectRuntimeVisibleError` 已有对应中文映射),既不为载荷新增输入字段,也不让交付报告顶掉原因。`RepairRequired`(封口复核要求继续当前返修批次)**不是失败**:它是控制流,有独立的 typed 变体(宿主侧 `DirectTurnRunFailure::RepairRequired`,跨界后是 `DirectTurnError::RepairRequired`),不写终态、不进失败载荷、不上报,由返修循环把它写回提示词继续跑;伪装成 `LlmError` 会让"继续返修"被讲成一次用户可见的失败,还会让同一个逻辑回合写出第二条终态。
**终态的写点在整轮真正结束之后。** 执行结果收集(含执行器收尾)、历史落盘、structured output 解析都定型了才写 `turn.completed`,成功与失败共用这一个写点:解析失败也是这一轮的失败,必须落进同一份失败载荷。反过来(先写终态、再解析)会让"终态写完又失败"的回合在协议上无解——终态已经是 `completed`,占用对象的兜底变成空操作,用户看到的是"本轮结束、没有回复、没有任何解释"。
一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。