Compare commits

...

20 Commits

Author SHA1 Message Date
k88936 07bac0377e 修复:调用级拒绝里属于宿主与环境事实的错补回运行错误诊断
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
- `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` 的变体集合
2026-09-23 15:59:32 +08:00
k88936 46b86527f9 注释:记录 Direct 命令接单化的 TODO 与实现要点
- `direct_runtime/user_input.rs` 命令入口加 TODO:目标形状(接单 + spawn、`turn.started` 与终态守卫下沉、接单后早退必须补终态、环境类失败升为回合级、宿主侧承担留痕与上报)与两条已决策的作废项
- `useDirectProjectChatController.ts` 的 catch 加同一份 TODO,说明它现在兼职"接单被拒"与"回合失败"、将来只剩前者
- 两处都指向草案 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,本次不实施、不提交该草案
2026-09-23 15:36:03 +08:00
k88936 34b95b5826 文档:Direct 回合错误改 typed 的决策与影响口径
- ADR【DirectProject对话历史单一事实源】新增一条决策:回合失败在宿主内部是 typed 的、调用级拒绝与回合级失败不共用判据,线上载荷与命令边界仍由同一出口投影
- 同 ADR 修正原措辞:原生 error 现在按 `codexErrorInfo` 解析成 typed 分类,不再描述成"投影成 LlmError";影响一节补一条调用级拒绝只回命令边界、不写诊断不发失败事件
- decision-log 记本次决策、根因、明确不做项、影响范围与验证结果
2026-09-23 14:36:42 +08:00
k88936 a553967ab9 Direct 回合失败全链路改 typed 错误:不再靠字符串匹配分类
- 新增 `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` 取值与可见文案与改造前逐一相同
2026-09-23 14:34:43 +08:00
k88936 69f6c2dc69 Direct 回合链路引入 typed 错误数据模型
- 新增 direct_turn_error 深模块:DirectTurnError 按变体各带字段,调用级拒绝与回合级失败分开
- 原生失败分类 DirectCodexNativeKind 由结构化前缀读入,事件载荷 kind 与旧口径逐条对齐
- 模型调用失败按平台 LlmError 分支投影成 DirectModelCallKind,反馈/重试/摘要/建议改由类型判定
- 跨进程边界仍由 Display 序列化成字符串,Rust 侧不再解析该字符串
2026-09-23 13:39:37 +08:00
k88936 217f5e8d81 Merge remote-tracking branch 'origin/master' into feat/fail-as-event 2026-09-23 11:50:42 +08:00
k88936 29d4b24226 宿主终态由事实判定:模型自报失败投影进既有错误通道,失败载荷不再被收尾阶段吞掉
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
- 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):看门狗与终态判定共用同一条事实,不用调用点局部变量
- 单测:投影后的原生失败压过被收尾改写的会话状态、账本读不出来仍带载荷、宿主自己关的连接不算失败(断言改用真实原因)
2026-09-23 11:20:48 +08:00
k88936 258c2f6cae 文档:终态由事实判定,模型自报失败的原生错误投影进既有错误通道
- ADR【DirectProject对话历史单一事实源】补一条决策:终态按事实取原因、有载荷必 failed;模型自报失败的 turn.error 投影成 LlmError 走同一条错误通道,不为载荷新增字段
- ADR「影响」补一条:可见文案仍走既有映射,区别只是原因改由事件载荷给出、命令返回恢复运行错误横幅
- 技术方案【DirectProject Codex原始历史与异常恢复】写明 lifecycle_status 没有终态否决权,以及原生错误的投影口径
- decision-log 记本次决策、根因、不做项、影响范围与验证方式
2026-09-23 11:20:39 +08:00
k88936 8b8e95908c Merge remote-tracking branch 'origin/feat/fail-as-event' into feat/fail-as-event 2026-09-23 10:07:21 +08:00
k88936 3467042000 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
2026-09-22 20:19:23 +08:00
k88936 8f2e5b4381 文档:执行通道断开定为失败终态,诊断记在执行适配器上
- ADR【DirectProject对话历史单一事实源】补一条决策:连接级故障与回合事件通道关闭同样带 failure{kind:"transport-failed"},原因用宿主当场写下的诊断,判据是"适配器是否已由宿主主动关闭"
- ADR「影响」补一条:断开时用户看到的仍是既有映射结果,真实诊断在事件载荷、宿主交付报告与运行日志里,改可见文案属于映射规则变更
- 技术方案【DirectProject Codex原始历史与异常恢复】同步线上形状,并写明失败事实为什么必须记在执行适配器上(看门狗会抢时序)
- decision-log 记本次决策、判据、不做项、影响范围与验证方式
2026-09-22 18:25:48 +08:00
k88936 7dca17d517 执行通道断开也算失败终态:诊断记在执行适配器上,事件带 transport-failed 载荷
- ExecutionAdapter 新增 transport_failed / transport_failure:调用方只给宿主诊断,文案、报告与"这算不算失败"都归适配器管;先同步记事实,再把同一份原因补进宿主交付报告
- 判据收在适配器里(is_closed):宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)会关掉同一条连接、发同一个 TransportClosed,那些不算失败,调用点两条分支的控制流保持不变;连接自己断掉才算,且只认第一份原因(第一份最接近现场,含 exitStatus 与 stderr 摘要)
- lifecycle_status 见到这条事实一律返回 failed:连接不是被本轮主动收束,也没有"用户主动停止"这层授权,报成 interrupted 只会让界面停在"本轮已结束"却不给原因
- 连接级故障(app-server 进程退出 / 流断 / JSON 行越界 / stderr 读取失败)在收束连接之前先把事实记到本回合的执行适配器上,避免与盯着同一个 closed 标志的看门狗抢时序
- direct_turn_failure 增加第三来源且优先级最高:通道断开时原因取宿主诊断,不取只会说"收束到哪一步"的交付报告
- 单测三条:适配器把诊断记成失败终态且只认第一份原因;宿主自己关的连接不算失败;失败载荷优先取宿主诊断(含与 LlmError 并存时的优先级)
2026-09-22 18:25:37 +08:00
k88936 80b15b24ae 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
- 新增「closes the turn from the host failure payload instead of leaving it running」:宿主发过 turn.started 之后以 failure 载荷收场并让命令失败,断言失败文案来自事件载荷(经同一份可见文案映射)、"陶泥儿正在处理"消失、终止钮消失、输入盒回到「发送」
- 同一条用例反向断言命令返回的错误原文不进聊天:那条通道只负责运行错误横幅
- 变异校验:让 reducer 不落失败说明条目时该用例变红(1 failed),恢复后绿
2026-09-22 17:40:17 +08:00
k88936 b432556ee3 reducer 用例:失败终态落说明条目、文案与横幅同源、重放不重复
- 新增「失败终态(turn.completed 带 failure 载荷)」六条用例:失败照样收口并冻结终点、说明条目按本轮开口身份派生、本轮开口条目拿到边界
- 可见文案与运行错误横幅共用同一份映射(点名 codex-app-server-error:context-window-exceeded)
- 重复 / 迟到的失败终态不追加第二条说明、不抬高冻结终点、不复活运行态
- 身份不匹配的失败终态不动正在跑的这一轮;空原因不落说明条目但终态照样收口
- 没有身份时用事件时间派生说明身份,两轮失败不会合并成一条
2026-09-22 17:40:17 +08:00
k88936 258645f182 前端失败说明改由事件驱动:turn.completed.failure 落成本轮说明条目,命令返回只留横幅
- 新增 conversation/directTurnFailure.ts:失败说明条目的展示身份(本轮开口身份 + :failure)与可见文案(复用 projectRuntimeVisibleError)两条口径集中一处
- directThreadChat 的 turn.completed 分支读 failure 载荷:非空原因先落成本轮最后一条说明条目,再走同一个收口函数;失败不再是第二套生命周期
- useDirectProjectChatController 的失败分支不再写聊天气泡:聊天文案唯一来源是事件,命令返回只保留运行错误横幅(含详情 long detail)与诊断留痕
- 数据流、时序与投影注释同步:标注失败说明来自事件、本地通道只剩终止说明与壳层 announce
2026-09-22 17:40:17 +08:00
k88936 5cf4a018b3 宿主终态接线:失败写进 turn.completed 的 failure 载荷,并在 turn.started 之后武装兜底守卫
- 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 / 失败策略模块)
2026-09-22 17:40:17 +08:00
k88936 5d9223c32e 失败终态策略独立成模块:分类、原因脱敏与 Drop 兜底守卫
- 新增 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 后不再产出事件
2026-09-22 17:40:17 +08:00
k88936 d32c99c927 事件协议:turn.completed 增加可选 failure 载荷,失败终态与正常终态同权入锚点
- 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 格式化
2026-09-22 17:40:17 +08:00
k88936 d36f5842b6 文档:失败回合终态定为 turn.completed 带 failure 载荷,宿主 Drop 守卫兜底
- 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 记本次决策、明确不做项、影响范围与验证方式
2026-09-22 17:40:17 +08:00
k88936 981a6b0021 Revert:撤掉前端「命令返回就收口」的兜底,改由宿主 turn.failed 事件收口
- 撤销 a35956f3e 的前端实现:directThreadChat 的 commandClosedTurnUserItemId / stopDirectThreadTurn、subscription 的 stopCommandTurn、controller 失败分支的调用,以及随附的两处用例
- 原因:失败语义改由宿主事件(turn.failed)表达,前端不再自造第二条「结束」判定路径,也不再在失败路径上补本地消息
2026-09-22 17:40:16 +08:00
23 changed files with 2665 additions and 604 deletions
@@ -34,6 +34,8 @@ mod direct_thread_wire;
mod direct_tool_bridge; mod direct_tool_bridge;
mod direct_tool_calls; mod direct_tool_calls;
mod direct_tools_mcp; mod direct_tools_mcp;
mod direct_turn_error;
mod direct_turn_failure;
mod direct_turn_metrics; mod direct_turn_metrics;
mod direct_turn_stream; mod direct_turn_stream;
mod direct_validation; mod direct_validation;
@@ -72,6 +74,8 @@ pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*; pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*; pub(crate) use direct_tools_mcp::*;
pub(crate) use direct_turn_error::*;
pub(crate) use direct_turn_failure::*;
pub(crate) use direct_turn_metrics::*; pub(crate) use direct_turn_metrics::*;
pub(crate) use direct_turn_stream::*; pub(crate) use direct_turn_stream::*;
pub(crate) use direct_validation::DirectValidationConfig; pub(crate) use direct_validation::DirectValidationConfig;
@@ -1,7 +1,7 @@
//! Native / third-party approval adapter. The host execution session owns policy //! Native / third-party approval adapter. The host execution session owns policy
//! and persistence; this module only binds the app-server protocol to its leases. //! and persistence; this module only binds the app-server protocol to its leases.
use super::super::{direct_delivery, direct_execution, direct_validation}; use super::super::{direct_delivery, direct_execution, direct_validation, DirectTurnError};
use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner}; use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner};
use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession}; use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -147,6 +147,11 @@ pub(super) struct ExecutionAdapter {
changed: Notify, changed: Notify,
shutdown_gate: tokio::sync::Mutex<()>, shutdown_gate: tokio::sync::Mutex<()>,
outcome: watch::Sender<Option<HostOutcome>>, outcome: watch::Sender<Option<HostOutcome>>,
/// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败,
/// 原因与交付报告同一份文本。
turn_failure: Mutex<Option<DirectTurnError>>,
/// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。
host_stop_requested: AtomicBool,
} }
fn identity(value: Option<&Value>) -> Option<&str> { fn identity(value: Option<&Value>) -> Option<&str> {
@@ -263,6 +268,8 @@ impl ExecutionAdapter {
changed: Notify::new(), changed: Notify::new(),
shutdown_gate: tokio::sync::Mutex::new(()), shutdown_gate: tokio::sync::Mutex::new(()),
outcome, outcome,
turn_failure: Mutex::new(None),
host_stop_requested: AtomicBool::new(false),
}) })
} }
@@ -654,6 +661,7 @@ impl ExecutionAdapter {
} }
pub(super) fn cancel_from_host(self: &Arc<Self>) { pub(super) fn cancel_from_host(self: &Arc<Self>) {
self.request_host_stop();
if self.background_done.load(Ordering::Acquire) || self.closed.load(Ordering::Acquire) { if self.background_done.load(Ordering::Acquire) || self.closed.load(Ordering::Acquire) {
return; return;
} }
@@ -672,6 +680,49 @@ impl ExecutionAdapter {
let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await; let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await;
} }
/// 宿主判定"这一轮以失败收口":记下 `(分类, 原因)`,再把同一条原因写进宿主交付报告。
///
/// 谁调用:宿主亲眼看到或亲手判定的异常收场——执行通道断开(app-server 进程退出 / 流断 / 回合
/// 事件通道关闭)、等待模型回执超时、app-server 单方面把这一轮判成中断。终态判定会读这份事实,
/// 于是这些收场不会再被收尾阶段(`ExecutionPhase::Interrupted`)抹成一次没有原因的"已结束"。
///
/// **宿主自己关的连接不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,回合
/// 事件通道上看到的是同一个 `TransportClosed`;判据是 [`Self::is_closed`]——适配器先于连接置位
/// 就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。
///
/// **事实要落在适配器上,不能落在调用点的局部变量里。** 回合还开着的时候,看门狗会在同一个
/// `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后取决于调度,而终态
/// 判定发生在收束之后;记不下原因,界面就只能看到"本轮已结束"、看不到为什么。
///
/// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由
/// 不得覆盖它。
pub(super) async fn fail_turn(&self, failure: DirectTurnError) {
let reason = failure.to_string();
if !self.is_closed() {
if let Ok(mut slot) = self.turn_failure.lock() {
if slot.is_none() {
*slot = Some(failure);
}
}
}
self.interrupt(&reason).await;
}
/// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。
pub(super) fn turn_failure(&self) -> Option<DirectTurnError> {
self.turn_failure.lock().ok().and_then(|slot| slot.clone())
}
/// 记下"用户主动要求终止这一轮"。用来把用户主动终止与 app-server 自己中断分开:
/// 前者不是失败,后者是(判据不能被事件到达的先后顺序左右,所以用标志而不是看阶段)。
pub(super) fn request_host_stop(&self) {
self.host_stop_requested.store(true, Ordering::Release);
}
pub(super) fn host_stop_requested(&self) -> bool {
self.host_stop_requested.load(Ordering::Acquire)
}
pub(super) fn start_watchdog(self: &Arc<Self>, inner: Weak<CodexAppServerInner>) { pub(super) fn start_watchdog(self: &Arc<Self>, inner: Weak<CodexAppServerInner>) {
let adapter = Arc::clone(self); let adapter = Arc::clone(self);
tokio::spawn(async move { tokio::spawn(async move {
@@ -744,7 +795,19 @@ impl ExecutionAdapter {
.unwrap_or(true) .unwrap_or(true)
} }
/// 本轮是不是**由宿主自己**在收束(正常终态 / 用户主动停止 / 预算收尾 / 交付封口)。
///
/// 用来把"连接被我们关掉"和"连接自己断了"分开:两种情况下回合事件通道都会收到
/// `TransportClosed`,但只有后者才算执行通道失败(见 [`Self::transport_failed`])。
/// `finish_model_attempt` 与 `shutdown_and_report` 都会在收束连接之前把它置位。
fn is_closed(&self) -> bool {
self.closed.load(Ordering::Acquire)
}
pub(super) fn lifecycle_status(&self, fallback: &str) -> String { pub(super) fn lifecycle_status(&self, fallback: &str) -> String {
// 只按收尾阶段归类。失败事实(`fail_turn` 记下的)不在这里翻案:终态由
// `direct_turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的
// `Interrupted` 抹成一次没有原因的"已结束"。
match self.session.snapshot().map(|state| state.phase) { match self.session.snapshot().map(|state| state.phase) {
Ok(ExecutionPhase::Completed) => "completed", Ok(ExecutionPhase::Completed) => "completed",
Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted", Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted",
@@ -1037,6 +1100,10 @@ pub(super) async fn wait_outcome(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::DirectTurnDeadline;
/// 通道断开在事件载荷里的稳定分类(`DirectTurnError::wire_kind` 的取值之一)。
const EXPECTED_TRANSPORT_KIND: &str = "transport-failed";
use super::*; use super::*;
fn fixture() -> (tempfile::TempDir, Arc<ExecutionAdapter>) { fn fixture() -> (tempfile::TempDir, Arc<ExecutionAdapter>) {
@@ -1084,6 +1151,57 @@ mod tests {
.unwrap(); .unwrap();
} }
#[tokio::test]
async fn host_observed_failure_is_recorded_with_its_kind_and_reason() {
let (_temp, adapter) = fixture();
assert!(adapter.turn_failure().is_none());
assert!(!adapter.host_stop_requested());
adapter
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
})
.await;
// 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。
let failure = adapter.turn_failure().expect("host fact must be recorded");
assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND));
assert!(failure.to_string().contains("SIGKILL"));
// 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。
assert!(adapter.report().contains("SIGKILL"));
// 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。
adapter
.fail_turn(DirectTurnError::TimedOut {
deadline: DirectTurnDeadline::ResponseIdle,
})
.await;
let failure = adapter.turn_failure().expect("first reason is kept");
assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND));
assert!(failure.to_string().contains("SIGKILL"));
assert!(!failure.to_string().contains("超时"));
}
/// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道
/// 上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。
#[tokio::test]
async fn host_ended_turn_is_not_a_failure() {
let (_temp, adapter) = fixture();
adapter.request_host_stop();
adapter.closed.store(true, Ordering::Release);
adapter
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: "模型本次执行结束,回收原生后台子树".into(),
})
.await;
assert!(adapter.turn_failure().is_none());
assert!(adapter.host_stop_requested());
// 原因照样进报告:不算失败不等于不用记。
assert!(adapter.report().contains("模型本次执行结束"));
}
#[tokio::test] #[tokio::test]
async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() { async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() {
let (_temp, adapter) = fixture(); let (_temp, adapter) = fixture();
@@ -3548,12 +3548,20 @@ impl CodexAppServerConnection {
let direct_turn_user_item_id = direct_persisted_user_item let direct_turn_user_item_id = direct_persisted_user_item
.as_ref() .as_ref()
.and_then(direct_thread_item_identity); .and_then(direct_thread_item_identity);
// 回合终态兜底:`turn.started` 进队列之后就武装,写完终态即解除。宿主在这两者之间任何
// 提前收场(panic、future 被丢弃、以后新增的早退)都由它补一条失败终态,否则前端只能
// 永远停在"还在跑"。
let mut direct_turn_failure_guard: Option<DirectTurnFailureGuard> = None;
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event( append_direct_thread_event(
&direct_thread_id, &direct_thread_id,
DirectThreadEvent::turn_started(direct_turn_started_at_ms) DirectThreadEvent::turn_started(direct_turn_started_at_ms)
.with_user_item_id(direct_turn_user_item_id.as_deref()), .with_user_item_id(direct_turn_user_item_id.as_deref()),
); );
direct_turn_failure_guard = Some(DirectTurnFailureGuard::arm(
direct_thread_id.clone(),
direct_turn_user_item_id.clone(),
));
if let Some(user_item) = direct_persisted_user_item.as_ref() { if let Some(user_item) = direct_persisted_user_item.as_ref() {
if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
// 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
@@ -3594,8 +3602,11 @@ impl CodexAppServerConnection {
hard_deadline.saturating_duration_since(tokio::time::Instant::now()); hard_deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() { if remaining.is_zero() {
if let Some(adapter) = approval_adapter.as_ref() { if let Some(adapter) = approval_adapter.as_ref() {
// 等不到终态就是这一轮失败:只收口不留原因等于界面静默结束。
adapter adapter
.interrupt("等待模型回合结束达到硬上限,已停止本轮并核对后台操作。") .fail_turn(DirectTurnError::TimedOut {
deadline: DirectTurnDeadline::TurnHardLimit,
})
.await; .await;
return execution::outcome_text(adapter.wait_outcome().await); return execution::outcome_text(adapter.wait_outcome().await);
} }
@@ -3623,7 +3634,9 @@ impl CodexAppServerConnection {
Err(_) => { Err(_) => {
if let Some(adapter) = approval_adapter.as_ref() { if let Some(adapter) = approval_adapter.as_ref() {
adapter adapter
.interrupt("等待模型执行回执超时,不能自动重放未确认操作。") .fail_turn(DirectTurnError::TimedOut {
deadline: DirectTurnDeadline::ResponseIdle,
})
.await; .await;
return execution::outcome_text(adapter.wait_outcome().await); return execution::outcome_text(adapter.wait_outcome().await);
} }
@@ -3953,9 +3966,16 @@ impl CodexAppServerConnection {
} }
"interrupted" => { "interrupted" => {
if let Some(adapter) = approval_adapter.as_ref() { if let Some(adapter) = approval_adapter.as_ref() {
if !adapter.is_host_ending() { // app-server 自己把这一轮判成中断,而宿主没有请求过终止(用户点
// 「终止」会先置 `host_stop_requested`、并把阶段推成终态):这是异常
// 收场,必须让界面看到原因,不能只是把回合静默收口。
if !adapter.is_host_ending() && !adapter.host_stop_requested() {
adapter adapter
.interrupt("本轮模型执行已中断,正在核对自有后台进程。") .fail_turn(DirectTurnError::TurnInterrupted {
detail:
"本轮模型执行被中断,正在核对自有后台进程。"
.into(),
})
.await; .await;
} }
return execution::outcome_text(adapter.wait_outcome().await); return execution::outcome_text(adapter.wait_outcome().await);
@@ -3968,14 +3988,23 @@ impl CodexAppServerConnection {
)); ));
} }
"failed" => { "failed" => {
// 原生 `turn.error` 是这一轮最准的原因:先把它投影成 `LlmError`
// 再作为 `collect` 的错误结果走既有的 collect_result 通道。投影之后
// 载荷形状(`{kind, message}`)和终态判定都不用为此多一个入参,
// 原因文本里带着 `codex-app-server-error:<kind>` 前缀交给界面归类。
// 交付报告只说明"收束到哪一步",不能顶掉原因;返修请求
// `RepairRequired`)是宿主复核要求,保持它自己的原语义。
let native = game_creator_codex_app_server_failed_turn_error(turn);
if let Some(adapter) = approval_adapter.as_ref() { if let Some(adapter) = approval_adapter.as_ref() {
if let Some(outcome) = if let Some(outcome) =
adapter.finish_model_attempt(&self.inner, false).await adapter.finish_model_attempt(&self.inner, false).await
{ {
return execution::outcome_text(outcome); if let Err(repair) = execution::outcome_text(outcome) {
return Err(repair);
}
} }
} }
return Err(game_creator_codex_app_server_failed_turn_error(turn)); return Err(native);
} }
status => { status => {
return Err(platform_llm::LlmError::Deserialize(format!( return Err(platform_llm::LlmError::Deserialize(format!(
@@ -3987,8 +4016,13 @@ impl CodexAppServerConnection {
Some(CodexTurnEvent::TransportClosed(error)) => { Some(CodexTurnEvent::TransportClosed(error)) => {
if let Some(adapter) = approval_adapter.as_ref() { if let Some(adapter) = approval_adapter.as_ref() {
if !adapter.is_host_ending() { if !adapter.is_host_ending() {
// 事件带的 `error` 就是连接终止时那份诊断。通道断开是不是'失败'由
// 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态
// 判定之后才读得到:见 `ExecutionAdapter::fail_turn`。
adapter adapter
.interrupt("执行通道已断开,不能自动重放未确认操作。") .fail_turn(DirectTurnError::TransportClosed {
diagnostic: error.clone(),
})
.await; .await;
} }
return execution::outcome_text(adapter.wait_outcome().await); return execution::outcome_text(adapter.wait_outcome().await);
@@ -4002,8 +4036,12 @@ impl CodexAppServerConnection {
None => { None => {
if let Some(adapter) = approval_adapter.as_ref() { if let Some(adapter) = approval_adapter.as_ref() {
if !adapter.is_host_ending() { if !adapter.is_host_ending() {
// 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能
// 以失败收口,不能报成"被中断"。
adapter adapter
.interrupt("执行事件通道已结束,正在核对后台操作。") .fail_turn(DirectTurnError::TransportClosed {
diagnostic: "Codex app-server turn 事件通道已关闭".into(),
})
.await; .await;
} }
return execution::outcome_text(adapter.wait_outcome().await); return execution::outcome_text(adapter.wait_outcome().await);
@@ -4061,11 +4099,33 @@ impl CodexAppServerConnection {
.map(|(_, at)| *at) .map(|(_, at)| *at)
.unwrap_or_else(direct_tool_call_now_ms) .unwrap_or_else(direct_tool_call_now_ms)
}; };
// 终态只有 `turn.completed` 一种事件:失败时同一个事件带 `failure` 载荷(原因由宿主
// 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。
// 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能
// 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。
// 判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败),不是收尾阶段推出来的
// `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把已经失败的回合讲成"已结束"。
let turn_failure = approval_adapter
.as_ref()
.and_then(|adapter| adapter.turn_failure());
// 收尾结果在这里投影成 typed 错误:载荷的 `kind` / `message` 都从这一份值出来。
let collect_outcome = match collect_result.as_ref() {
Ok(report) => Ok(report.as_str()),
Err(error) => Err(DirectTurnError::from_model_call(error)),
};
let terminal = direct_turn_terminal(
&status,
collect_outcome,
turn_failure.as_ref(),
history_root,
);
append_direct_thread_event( append_direct_thread_event(
&direct_thread_id, &direct_thread_id,
DirectThreadEvent::turn_completed(status, completed_at) terminal.event(completed_at, direct_turn_user_item_id.as_deref()),
.with_user_item_id(direct_turn_user_item_id.as_deref()),
); );
if let Some(guard) = direct_turn_failure_guard.as_mut() {
guard.disarm();
}
} }
let text = collect_result?; let text = collect_result?;
guard.armed = false; guard.armed = false;
@@ -4964,6 +5024,16 @@ async fn fail_game_creator_codex_app_server_connection(
let stderr = inner.stderr_summary.lock().await.diagnostic(); let stderr = inner.stderr_summary.lock().await.diagnostic();
let diagnostic = format!("{error}exitStatus={exit_status}{stderr}"); let diagnostic = format!("{error}exitStatus={exit_status}{stderr}");
app_log!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}"); app_log!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}");
// 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再去收束
// 连接。顺序不能反——执行适配器的看门狗盯着同一个 `closed` 标志,它可能先一步把本轮收束成
// "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。
record_execution_turn_failure(
&inner,
DirectTurnError::TransportClosed {
diagnostic: diagnostic.clone(),
},
)
.await;
match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await { match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await {
Ok(proof) if proof.confirmed() => {} Ok(proof) if proof.confirmed() => {}
Ok(_) => app_log!("Codex app-server 连接终止:process-group-only,完整子树退出未确认"), Ok(_) => app_log!("Codex app-server 连接终止:process-group-only,完整子树退出未确认"),
@@ -4971,6 +5041,24 @@ async fn fail_game_creator_codex_app_server_connection(
} }
} }
/// 把"这一轮以失败收口"的事实记到当前回合的执行适配器上:连接级故障、等待超时、app-server
/// 单方面中断都走这一条路径,别在多处各写一份。没有进行中的 DirectProject 回合(适配器已释放)
/// 就是空操作。
async fn record_execution_turn_failure(inner: &Arc<CodexAppServerInner>, failure: DirectTurnError) {
let adapter = {
let slot = match inner.execution.lock() {
Ok(slot) => slot,
Err(_) => return,
};
// 只借一下指针:后面要 await(写交付报告),不能带着执行槽位的锁等。
slot.as_ref().map(Arc::clone)
};
let Some(adapter) = adapter else {
return;
};
adapter.fail_turn(failure).await;
}
async fn shutdown_game_creator_codex_app_server_inner( async fn shutdown_game_creator_codex_app_server_inner(
inner: &Arc<CodexAppServerInner>, inner: &Arc<CodexAppServerInner>,
reason: &str, reason: &str,
@@ -5037,7 +5125,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
root: &std::path::Path, root: &std::path::Path,
system_prompt: String, system_prompt: String,
user_prompt: String, user_prompt: String,
) -> Result<String, String> { ) -> Result<String, DirectTurnError> {
direct_game_creator_codex_chat_at_with_optional_observer( direct_game_creator_codex_chat_at_with_optional_observer(
root, root,
system_prompt, system_prompt,
@@ -5055,7 +5143,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
system_prompt: String, system_prompt: String,
user_prompt: String, user_prompt: String,
observer: &mut (dyn FnMut(DirectCodexTurnObservation) + Send), observer: &mut (dyn FnMut(DirectCodexTurnObservation) + Send),
) -> Result<String, String> { ) -> Result<String, DirectTurnError> {
direct_game_creator_codex_chat_at_with_optional_observer( direct_game_creator_codex_chat_at_with_optional_observer(
root, root,
system_prompt, system_prompt,
@@ -5076,12 +5164,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
audit: Option<&mut DirectCodexTurnAudit>, audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>, direct_user_item: Option<serde_json::Value>,
) -> Result<String, String> { ) -> Result<String, DirectTurnError> {
// Resolve project authority before deriving the pool/thread identity. A // Resolve project authority before deriving the pool/thread identity. A
// caller may hold a stable symlink path whose target changes between // caller may hold a stable symlink path whose target changes between
// projects, or replace the project manifest in-place; raw path text alone // projects, or replace the project manifest in-place; raw path text alone
// must never select a connection created for the previous project. // must never select a connection created for the previous project.
let (canonical_root, project_id) = direct_codex_canonical_project_identity(root)?; let (canonical_root, project_id) = direct_codex_canonical_project_identity(root)
.map_err(|cause| DirectTurnError::ProjectRootUnanchored { cause })?;
let codex_root = if let Some(path) = canonical_root let codex_root = if let Some(path) = canonical_root
.to_str() .to_str()
.and_then(|value| value.strip_prefix("\\\\?\\")) .and_then(|value| value.strip_prefix("\\\\?\\"))
@@ -5090,9 +5179,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
} else { } else {
canonical_root.clone() canonical_root.clone()
}; };
let config = load_game_creator_app_config()?; let config = load_game_creator_app_config()
game_creator_codex_app_server_validate_llm_config(&config.llm) .map_err(|detail| DirectTurnError::EnvironmentNotReady { detail })?;
.map_err(|error| error.to_string())?; game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| {
DirectTurnError::EnvironmentNotReady {
detail: error.to_string(),
}
})?;
// 用户回合身份必须在模型目录/连接准备前冻结,不能先复用上一回合进程。 // 用户回合身份必须在模型目录/连接准备前冻结,不能先复用上一回合进程。
let generated_client_turn_id; let generated_client_turn_id;
let effective_client_turn_id = match client_turn_id { let effective_client_turn_id = match client_turn_id {
@@ -5105,7 +5198,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
.map(|state| state.client_turn_id) .map(|state| state.client_turn_id)
}) })
.await .await
.map_err(|_| "宿主 CLI 回合身份读取中断".to_string())??; .map_err(|_| DirectTurnError::HostStateUnavailable {
detail: "宿主 CLI 回合身份读取中断".to_string(),
})?
.map_err(|detail| DirectTurnError::HostStateUnavailable { detail })?;
Some(generated_client_turn_id.as_str()) Some(generated_client_turn_id.as_str())
} }
}; };
@@ -5125,8 +5221,11 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
web_search_enabled: config.llm.web_search_enabled, web_search_enabled: config.llm.web_search_enabled,
allow_idle_context_compaction: false, allow_idle_context_compaction: false,
}; };
let api_kind = let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| {
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?; DirectTurnError::EnvironmentNotReady {
detail: error.to_string(),
}
})?;
let metrics_attempt = audit.as_ref().map(|audit| { let metrics_attempt = audit.as_ref().map(|audit| {
audit.metrics().attempt( audit.metrics().attempt(
&config.llm.model, &config.llm.model,
@@ -5156,7 +5255,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
if let Some(attempt) = metrics_attempt.as_ref() { if let Some(attempt) = metrics_attempt.as_ref() {
attempt.finish("failed"); attempt.finish("failed");
} }
error.to_string() // 连接建立失败是环境/凭据层面的前置于失败:这一轮还没有开始。
DirectTurnError::EnvironmentNotReady {
detail: error.to_string(),
}
})?; })?;
let request = LlmRunRequest::single_turn(system_prompt, user_prompt) let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
.with_api_kind(api_kind) .with_api_kind(api_kind)
@@ -5178,7 +5280,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
) )
.await .await
.map(|value| value.text) .map(|value| value.text)
.map_err(|error| error.to_string()); .map_err(|error| DirectTurnError::from_model_call(&error));
if let Some(attempt) = metrics_attempt.as_ref() { if let Some(attempt) = metrics_attempt.as_ref() {
attempt.finish(if result.is_ok() { attempt.finish(if result.is_ok() {
"completed" "completed"
@@ -697,10 +697,14 @@ pub(super) async fn finish_sealing(
}).await.map_err(|_| "delivery-finalize-worker-exited")? }).await.map_err(|_| "delivery-finalize-worker-exited")?
} }
/// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。
///
/// 返修要求是**控制流**[`DirectTurnError::ReviewRequired`]),不是失败:调用方据此把要求写回
/// prompt 再跑一轮,界面不该看到失败文案。其余错误都是真的回合失败,按 typed 错误交给上层。
pub(super) async fn review_reply( pub(super) async fn review_reply(
root: &Path, root: &Path,
session: &Arc<ExecutionSession>, session: &Arc<ExecutionSession>,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, DirectTurnError> {
if let Some(report) = terminal_report(session) { if let Some(report) = terminal_report(session) {
return Ok(Some(report)); return Ok(Some(report));
} }
@@ -751,7 +755,9 @@ pub(super) async fn review_reply(
.map_err(|_| "delivery-review-worker-exited")??; .map_err(|_| "delivery-review-worker-exited")??;
return Ok(Some(report)); return Ok(Some(report));
} }
Err(format!("delivery-review-required: {detail}")) Err(DirectTurnError::ReviewRequired {
detail: format!("delivery-review-required: {detail}"),
})
} }
#[cfg(test)] #[cfg(test)]
@@ -914,10 +920,10 @@ mod tests {
assert_eq!(chat.snapshot().unwrap().delivery_reviews, 0); assert_eq!(chat.snapshot().unwrap().delivery_reviews, 0);
let (new_game, _new_host, required) = project_session(true); let (new_game, _new_host, required) = project_session(true);
for _ in 0..2 { for _ in 0..2 {
assert!(review_reply(new_game.path(), &required) assert!(matches!(
.await review_reply(new_game.path(), &required).await.unwrap_err(),
.unwrap_err() DirectTurnError::ReviewRequired { .. }
.starts_with("delivery-review-required:")); ));
} }
assert!(review_reply(new_game.path(), &required) assert!(review_reply(new_game.path(), &required)
.await .await
File diff suppressed because it is too large Load Diff
@@ -7,9 +7,9 @@ use super::*;
pub(crate) fn normalize_direct_client_turn_id( pub(crate) fn normalize_direct_client_turn_id(
client_turn_id: Option<&str>, client_turn_id: Option<&str>,
) -> Result<String, String> { ) -> Result<String, DirectTurnError> {
let Some(client_turn_id) = client_turn_id else { let Some(client_turn_id) = client_turn_id else {
return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string()); return Err(DirectTurnError::ClientTurnIdMissing);
}; };
let client_turn_id = client_turn_id.trim(); let client_turn_id = client_turn_id.trim();
let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS)
@@ -20,13 +20,33 @@ pub(crate) fn normalize_direct_client_turn_id(
.is_some_and(|byte| byte.is_ascii_alphanumeric()); .is_some_and(|byte| byte.is_ascii_alphanumeric());
let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
if !valid_length || !valid_first || !valid_rest { if !valid_length || !valid_first || !valid_rest {
return Err(format!( return Err(DirectTurnError::ClientTurnIdMalformed {
"clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS}{MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" min_chars: MIN_DIRECT_CLIENT_TURN_ID_CHARS,
)); max_chars: MAX_DIRECT_CLIENT_TURN_ID_CHARS,
});
} }
Ok(client_turn_id.to_string()) Ok(client_turn_id.to_string())
} }
/// DirectProject 聊天命令:对外仍然是 `Result<String, String>`。
///
/// 字符串只在这里生成一次;前端拿到的仍是"一句给用户看的话",而 Rust 侧从命令入口到宿主出口全程
/// 只传 typed 错误。可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,返回串因此带上
/// `详情:` 引用——界面横幅的展开逻辑按这个引用工作。
///
// TODO(Direct 命令接单化,未实施):现在这个命令 await 整轮,于是"命令边界"承担了不属于它的角色——
// 回合失败的文案要靠这条 Err 回到界面,认证失败重试也只能挂在它上面。目标形状(草案见
// `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,落地前不要照抄这里的一半):
// 1. 命令只负责**接单**:校验 + 权限门 + 用户条目落盘 + 占用调用身份,然后 spawn 整轮任务并立刻
// 返回;"这一轮跑成什么"只由订阅事件回答。
// 2. `turn.started` 与终态兜底守卫的所有权下沉到接单任务:接单之后**任何**早退(连不上
// app-server、配置 / 凭据未就绪、历史注入构建失败、`turn/start` 被拒)都必须有终态事件收口,
// 否则前端的乐观气泡会永远停在"宿主还没确认"。
// 3. 于是连接 / 环境类失败要从调用级升成回合级:`EnvironmentNotReady` 只留"接单前就能判定"的
// 语义(目录 / 权限 / 输入 / 并发)。
// 4. 诊断留痕与错误上报必须在宿主侧完成:接单之后命令不再返回 Err,前端 catch 看不到这些失败。
// 5. 顺带作废两条现役行为(已决策):删掉由 invoke 拒绝驱动的认证重试;失败横幅改由 reducer
// 供数据;"接单被拒"的文案不再占用状态行,改成这条消息下面的本地助手气泡。
#[tauri::command] #[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex( pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String, project_path: String,
@@ -35,26 +55,61 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
client_turn_id: Option<String>, client_turn_id: Option<String>,
analytics_attempt_id: Option<String>, analytics_attempt_id: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let capture = crate::analytics::gui::capture_writer_context();
let root = Path::new(project_path.trim()); let root = Path::new(project_path.trim());
let boundary_turn_id = client_turn_id.clone();
chat_with_game_creator_direct_codex_typed(
root,
user_item,
creation_type,
client_turn_id,
analytics_attempt_id,
)
.await
.map_err(|failure| direct_turn_error_boundary_text(root, boundary_turn_id.as_deref(), failure))
}
/// 命令主体:全程 typed,边界只在上面的 `map_err` 里做一次文本与留痕投影。
async fn chat_with_game_creator_direct_codex_typed(
root: &Path,
user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
analytics_attempt_id: Option<String>,
) -> Result<String, DirectTurnError> {
let capture = crate::analytics::gui::capture_writer_context();
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) DirectTurnError::HostStateUnavailable {
detail: redact_agent_runtime_error(
root,
&format!("恢复上一轮陶泥儿整包事务失败:{error}"),
500,
),
}
})?; })?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
validate_direct_codex_user_item(root, &user_item)?; validate_direct_codex_user_item(root, &user_item)
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?; .map_err(|detail| DirectTurnError::InputRejected { detail })?;
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
if user_prompt.trim().is_empty() { if user_prompt.trim().is_empty() {
return Err("聊天内容不能为空".to_string()); return Err(DirectTurnError::ContentEmpty);
} }
let canonical_user_item = let canonical_user_item =
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
.await .await
{ {
Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?), Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| {
Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)), DirectTurnError::InputRejected {
detail: error.to_string(),
}
})?),
Err(error) => {
let detail = redact_agent_runtime_error(root, &error, 1800);
return Err(DirectTurnError::EnvironmentNotReady { detail });
}
}; };
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root, root,
@@ -603,6 +603,30 @@ mod tests {
)); ));
} }
/// 失败终态与正常终态同权:`turn.completed(status="failed")` 必须顶替更早的 `turn.started`
/// 成为锚点,否则队列被回收后新订阅只会看到 `turn.started`,把这轮已收口的回合重放成"还在跑"。
#[test]
fn failed_turn_completed_replaces_started_anchor() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append("thread-1", DirectThreadEvent::turn_started(1_000));
manager.append(
"thread-1",
DirectThreadEvent::turn_completed_failed(
crate::agent::DirectTurnFailure::new("host-dropped", "回合宿主任务提前结束"),
FIXED_AT_MS,
),
);
let bootstrap = manager.subscribe("thread-1");
assert!(matches!(
bootstrap.events.as_slice(),
[DirectThreadEvent::TurnCompleted { status, failure, at, .. }]
if status == "failed"
&& failure.as_ref().is_some_and(|failure| failure.kind == "host-dropped")
&& *at == Some(FIXED_AT_MS)
));
}
/// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**,
/// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。
#[test] #[test]
@@ -211,6 +211,30 @@ impl DirectThreadRequestKind {
} }
} }
/// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。
///
/// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的
/// 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectTurnFailure {
/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` /
/// `host-dropped`。
pub(crate) kind: String,
/// 脱敏 + 截断后的失败原因。
pub(crate) message: String,
}
impl DirectTurnFailure {
pub(crate) fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
Self {
kind: kind.into(),
message: message.into(),
}
}
}
/// Thread Manager 下发的运行态事件。 /// Thread Manager 下发的运行态事件。
/// ///
/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: /// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`:
@@ -250,7 +274,13 @@ pub(crate) enum DirectThreadEvent {
}, },
#[serde(rename = "turn.completed")] #[serde(rename = "turn.completed")]
TurnCompleted { TurnCompleted {
/// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**
/// 此时必须带 `failure` 载荷。
status: String, status: String,
/// 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
failure: Option<DirectTurnFailure>,
/// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 /// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, as = "Option<f64>")] #[ts(optional, as = "Option<f64>")]
@@ -301,11 +331,30 @@ impl DirectThreadEvent {
pub(crate) fn turn_completed(status: String, at: u64) -> Self { pub(crate) fn turn_completed(status: String, at: u64) -> Self {
Self::TurnCompleted { Self::TurnCompleted {
status, status,
failure: None,
at: Some(at), at: Some(at),
user_item_id: None, user_item_id: None,
} }
} }
/// 失败终态:`status` 固定 `"failed"`,原因必须随事件一起带出去。
pub(crate) fn turn_completed_failed(failure: DirectTurnFailure, at: u64) -> Self {
Self::TurnCompleted {
status: "failed".to_string(),
failure: Some(failure),
at: Some(at),
user_item_id: None,
}
}
/// 失败载荷:只有失败终态有。
pub(crate) fn failure(&self) -> Option<&DirectTurnFailure> {
match self {
Self::TurnCompleted { failure, .. } => failure.as_ref(),
_ => None,
}
}
/// 附上本轮开口用户条目的 canonical itemId。 /// 附上本轮开口用户条目的 canonical itemId。
/// ///
/// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底 /// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底
@@ -317,8 +366,14 @@ impl DirectThreadEvent {
.map(str::to_string); .map(str::to_string);
match self { match self {
Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id }, Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id },
Self::TurnCompleted { status, at, .. } => Self::TurnCompleted { Self::TurnCompleted {
status, status,
failure,
at,
..
} => Self::TurnCompleted {
status,
failure,
at, at,
user_item_id, user_item_id,
}, },
@@ -1351,4 +1406,56 @@ mod tests {
); );
assert_eq!(item_event.user_item_id(), None); assert_eq!(item_event.user_item_id(), None);
} }
/// 失败终态:`status="failed"` 必须带 `failure{kind,message}`,正常终态不带;载荷跟着身份
/// 一起流转,缺载荷的 `failed` 事件仍能反序列化(前端按"没有原因"处理,不猜)。
#[test]
fn turn_completed_carries_failure_payload_only_when_failed() {
let failed = DirectThreadEvent::turn_completed_failed(
DirectTurnFailure::new("model-failed", "上游返回 500:模型服务暂不可用"),
4_000,
)
.with_user_item_id(Some("direct-codex:turn-1:user"));
assert_eq!(
failed.failure(),
Some(&DirectTurnFailure::new(
"model-failed",
"上游返回 500:模型服务暂不可用"
))
);
assert_eq!(failed.user_item_id(), Some("direct-codex:turn-1:user"));
assert_eq!(
serde_json::to_value(&failed).expect("serialize failed turn"),
json!({
"type": "turn.completed",
"status": "failed",
"failure": {"kind": "model-failed", "message": "上游返回 500:模型服务暂不可用"},
"at": 4_000u64,
"userItemId": "direct-codex:turn-1:user",
})
);
assert_eq!(
serde_json::from_value::<DirectThreadEvent>(
serde_json::to_value(&failed).expect("serialize")
)
.expect("round trip"),
failed
);
// 正常终态不带载荷,也不回写 `failure: null`。
let completed = DirectThreadEvent::turn_completed("completed".to_string(), 5_000);
assert_eq!(completed.failure(), None);
assert_eq!(
serde_json::to_value(&completed).expect("serialize completed turn"),
json!({"type": "turn.completed", "status": "completed", "at": 5_000u64})
);
// 精简 / 旧形状:`failed` 但没有载荷也要能反序列化。
let sparse: DirectThreadEvent = serde_json::from_value(json!({
"type": "turn.completed",
"status": "failed",
}))
.expect("failed turn without failure payload");
assert_eq!(sparse.failure(), None);
}
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,319 @@
//! 失败终态的宿主侧策略:把"这一轮为什么失败"翻译成可下发的 `failure` 载荷,并在宿主自己
//! 提前收场时补一条失败终态。
//!
//! 这个模块只有三件事,别再往里加第四件:
//! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么;
//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件;
//! 3. [`DirectTurnFailureGuard`]`turn.started` 之后武装、写完终态解除的 Drop 兜底。
//!
//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs``DirectTurnFailure`);
//! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见
//! [`DirectTurnError::wire_kind`]);这里只负责"什么算失败、原因怎么写、什么时候兜底",
//! 不碰事件队列的搬运规则,也不自己认 `LlmError`。
use std::path::Path;
use super::{
append_direct_thread_event, direct_tool_call_now_ms, redact_agent_runtime_error,
DirectThreadEvent, DirectTurnError, DirectTurnFailure,
};
/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于
/// 把整段上游报文塞进事件队列。
const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600;
/// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。
const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped";
const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str =
"陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。";
/// 一轮的终态:写进事件的 `status` 与(失败时的)载荷。**状态由载荷反推**,不由收尾阶段推。
pub(crate) struct DirectTurnTerminal {
pub(crate) status: String,
pub(crate) failure: Option<DirectTurnFailure>,
}
impl DirectTurnTerminal {
/// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。
pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> DirectThreadEvent {
let event = match self.failure {
Some(failure) => DirectThreadEvent::turn_completed_failed(failure, completed_at),
None => DirectThreadEvent::turn_completed(self.status, completed_at),
};
event.with_user_item_id(user_item_id)
}
}
/// 拿这一轮的**事实**判定终态。判据按优先级:
/// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…),
/// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步";
/// 2. `collect_outcome` 是错误:真失败(模型 / 传输 / 历史落盘)。模型自报失败也走这一档:
/// 原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 [`DirectTurnError`] 再进来;
/// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾
/// 阶段的账本读不出来时只有它可用。
///
/// **有载荷就一定是 `failed`,没载荷就用收尾阶段的 `session_status`。** 这条反推关系是这个模块存在
/// 的理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`
/// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束",
/// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。
///
/// 载荷的 `kind` 与 `message` 在这一个出口从 typed 错误投影:`kind` 决定界面语气,`message` 是脱敏
/// 截断后的原因文本;Rust 侧没有第二个地方再解析它。
pub(crate) fn direct_turn_terminal(
session_status: &str,
collect_outcome: Result<&str, DirectTurnError>,
host_failure: Option<&DirectTurnError>,
history_root: &Path,
) -> DirectTurnTerminal {
let failure = match (host_failure, collect_outcome) {
(Some(failure), _) => Some(failure.clone()),
(None, Err(error)) => Some(error.clone()),
// 账本读不出来时没有 typed 原因可用:报告文本就是这一轮唯一的收口依据,按未分类失败发出去,
// 不能让界面停在"已结束、没原因"。
(None, Ok(report)) if session_status == "failed" => {
Some(DirectTurnError::TurnFailedUnclassified {
detail: report.to_string(),
})
}
(None, Ok(_)) => None,
};
match failure {
Some(failure) => DirectTurnTerminal {
status: "failed".to_string(),
failure: Some(DirectTurnFailure::new(
failure.wire_kind().unwrap_or("model-failed").to_string(),
redact_agent_runtime_error(
history_root,
&failure.to_string(),
DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS,
),
)),
},
None => DirectTurnTerminal {
status: session_status.to_string(),
failure: None,
},
}
}
/// 回合终态兜底守卫:`turn.started` 发出去之后,这一轮在宿主侧只剩两条收场路径——正常路径
/// 写完终态事件(然后 [`Self::disarm`]),或者这个守卫的 `Drop`。
///
/// 兜底覆盖三种"走不到终态"的情况:panic 展开、future 被丢弃(任务 / 进程取消),以及今后在
/// 终态事件之前新增的 `?` 早退。它们都再也没有机会补终态事件,前端只能永远停在"还在跑";
/// 这里在 Drop 里补一条 `status="failed"` + `host-dropped` 的终态,让前端拿到收口依据。
///
/// 与 `CodexTurnGuard` / `CodexTurnStartGuard` 是**三件事**,不要合并:那两个守卫管的是
/// app-server 连接与 `turn/start` 请求的回收,Drop 里不产出任何事件。
///
/// 已知边界(不为它加路径):宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在
/// 运行态;`turn.started` 之前的早退根本不武装这个守卫——没有开始就没有"未收口的回合"。
pub(crate) struct DirectTurnFailureGuard {
thread_id: String,
user_item_id: Option<String>,
armed: bool,
}
impl DirectTurnFailureGuard {
/// 武装:调用点必须是 `turn.started` **已经**进入队列之后。
pub(crate) fn arm(thread_id: String, user_item_id: Option<String>) -> Self {
Self {
thread_id,
user_item_id,
armed: true,
}
}
/// 解除:终态事件(正常或失败)已经写完,兜底不再需要。
pub(crate) fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for DirectTurnFailureGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
append_direct_thread_event(
&self.thread_id,
DirectThreadEvent::turn_completed_failed(
DirectTurnFailure::new(
DIRECT_TURN_FAILURE_HOST_DROPPED_KIND,
DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE,
),
direct_tool_call_now_ms(),
)
.with_user_item_id(self.user_item_id.as_deref()),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{consume_direct_thread, subscribe_direct_thread};
use platform_llm::LlmError;
fn history_root() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/direct-turn-failure-test")
}
/// 正常收场:不带载荷,`status` 就用收尾阶段推出来的那个。
#[test]
fn non_failure_terminals_keep_the_session_status() {
for status in ["completed", "interrupted", "aborted"] {
let terminal = direct_turn_terminal(status, Ok("报告不重要"), None, &history_root());
assert!(terminal.failure.is_none(), "{status} 不该带失败载荷");
assert_eq!(terminal.status, status);
}
}
/// 拿得到错误:分类与原因都取自错误。
#[test]
fn collect_error_becomes_a_failure_terminal() {
let error = DirectTurnError::from_model_call(&LlmError::Transport(
"DirectProject 收尾历史失败:写入 project.jsonl 失败".into(),
));
let terminal = direct_turn_terminal("completed", Err(error), None, &history_root());
let failure = terminal
.failure
.expect("transport error must fail the turn");
assert_eq!(terminal.status, "failed");
assert_eq!(failure.kind, "transport-failed");
assert!(failure.message.contains("收尾历史失败"));
}
/// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 typed
/// 错误(原因带 `codex-app-server-error:<kind>` 前缀),宿主收尾自己又把 ledger 阶段推成
/// `Interrupted``session_status` 因此是 `interrupted`):事实就是失败、原因就是那份投影,
/// 必须原样发出去——否则界面只剩"本轮已结束",用户看不到任何东西。
#[test]
fn projected_native_failure_outranks_the_interrupted_session_status() {
let error = DirectTurnError::from_model_call(&LlmError::InvalidRequest(
"codex-app-server-error:context-window-exceeded".into(),
));
let terminal = direct_turn_terminal("interrupted", Err(error), None, &history_root());
let failure = terminal.failure.expect("native failure must fail the turn");
assert_eq!(terminal.status, "failed");
assert_eq!(failure.kind, "request-rejected");
assert_eq!(
failure.message,
"codex-app-server-error:context-window-exceeded"
);
}
/// 收尾阶段的账本读不出来(`session_status` 只能是 `failed`)时没有错误可用:用交付报告兜底,
/// 但照样要带载荷发出去,不能让界面停在"已结束、没原因"。
#[test]
fn unreadable_session_ledger_still_reports_a_payload() {
let terminal = direct_turn_terminal("failed", Ok("报告"), None, &history_root());
assert_eq!(terminal.status, "failed");
let failure = terminal
.failure
.expect("unreadable ledger must fail the turn");
assert_eq!(failure.kind, "model-failed");
assert_eq!(failure.message, "报告");
}
/// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。
#[test]
fn host_recorded_failure_outranks_every_other_source() {
let diagnostic = "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)\
stderrClass=nonemptystderrBytes=1000";
let host_failure = DirectTurnError::TransportClosed {
diagnostic: diagnostic.to_string(),
};
let terminal = direct_turn_terminal(
"interrupted",
Ok("执行连接已结束,正在核对自有子进程与在途操作。"),
Some(&host_failure),
&history_root(),
);
let failure = terminal.failure.expect("host fact must fail the turn");
assert_eq!(terminal.status, "failed");
assert_eq!(failure.kind, "transport-failed");
assert!(failure.message.contains("SIGKILL"));
assert!(!failure.message.contains("正在核对自有子进程"));
// 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。
let error = DirectTurnError::from_model_call(&LlmError::Transport(
"DirectProject 收尾历史失败".into(),
));
let host_failure = DirectTurnError::TurnInterrupted {
detail: "本轮模型执行被中断".into(),
};
let terminal = direct_turn_terminal(
"interrupted",
Err(error),
Some(&host_failure),
&history_root(),
);
let failure = terminal.failure.expect("host fact must fail the turn");
assert_eq!(failure.kind, "turn-interrupted");
assert!(failure.message.contains("本轮模型执行被中断"));
}
/// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。
#[test]
fn terminal_event_carries_the_payload_and_the_opening_identity() {
let error = DirectTurnError::from_model_call(&LlmError::Upstream {
status_code: 502,
message: "上游 502".into(),
});
let failing = direct_turn_terminal("interrupted", Err(error), None, &history_root());
let event = failing.event(2_000, Some("direct-codex:turn-1:user"));
assert_eq!(
event.failure().map(|failure| failure.kind.as_str()),
Some("model-failed")
);
assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user"));
assert_eq!(event.at(), Some(2_000));
let quiet = direct_turn_terminal("completed", Ok("本轮交付已完成"), None, &history_root());
let event = quiet.event(3_000, None);
assert!(event.failure().is_none());
assert!(matches!(
event,
DirectThreadEvent::TurnCompleted { ref status, .. } if status == "completed"
));
}
/// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)。
#[test]
fn armed_guard_appends_host_dropped_terminal_on_drop() {
let thread_id = "test-thread-failure-guard-armed";
let subscription = subscribe_direct_thread(thread_id);
let guard = DirectTurnFailureGuard::arm(
thread_id.to_string(),
Some("direct-codex:turn-1:user".to_string()),
);
drop(guard);
let events = consume_direct_thread(&subscription.subscription_id)
.expect("consume guard terminal")
.events;
assert!(matches!(
events.as_slice(),
[DirectThreadEvent::TurnCompleted { status, failure, user_item_id, .. }]
if status == "failed"
&& failure.as_ref().is_some_and(|failure| failure.kind == "host-dropped")
&& user_item_id.as_deref() == Some("direct-codex:turn-1:user")
));
}
/// 解除之后就闭嘴:正常写完终态的回合不得再多出一条兜底终态。
#[test]
fn disarmed_guard_appends_nothing() {
let thread_id = "test-thread-failure-guard-disarmed";
let subscription = subscribe_direct_thread(thread_id);
let mut guard = DirectTurnFailureGuard::arm(thread_id.to_string(), None);
guard.disarm();
drop(guard);
assert!(consume_direct_thread(&subscription.subscription_id)
.expect("consume disarmed guard")
.events
.is_empty());
}
}
@@ -972,8 +972,15 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
.enable_all() .enable_all()
.build() .build()
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let reply_result = runtime let reply_result = runtime.block_on(async {
.block_on(async { run_direct_game_creator_turn_at(&project_path, &prompt).await }); run_direct_game_creator_turn_at(&project_path, &prompt)
.await
// CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本;可留痕的
// 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影,带上诊断与 `详情:` 引用。
.map_err(|failure| {
direct_turn_error_boundary_text(&project_path, None, failure)
})
});
let shutdown_result = shutdown_game_creator_codex_app_servers(); let shutdown_result = shutdown_game_creator_codex_app_servers();
let reply = reply_result?; let reply = reply_result?;
shutdown_result?; shutdown_result?;
@@ -108,7 +108,7 @@ export type DirectProjectChatControllerProps = {
* 传输层(三条通道,前端各拉各的) * 传输层(三条通道,前端各拉各的)
* A 运行态:notify → invoke consume_direct_project_thread → events[](实时) * A 运行态:notify → invoke consume_direct_project_thread → events[](实时)
* B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描) * B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描)
* C 本地: 前端自己造(乐观用户气泡、忙态、失败 / 终止说明) * C 本地: 前端自己造(乐观用户气泡、忙态、终止说明)
* ▼ * ▼
* 前端 * 前端
* useDirectThreadChatSubscription reducerA + B 进同一份 stateturnRunning / history / live * useDirectThreadChatSubscription reducerA + B 进同一份 stateturnRunning / history / live
@@ -125,6 +125,8 @@ export type DirectProjectChatControllerProps = {
* 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。
* - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合 * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合
* 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。 * 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。
* 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目;
* 命令返回那条通道只提供横幅与诊断,不再写聊天文案。
* - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份 * - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份
* `direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。 * `direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。
* *
@@ -135,7 +137,8 @@ export type DirectProjectChatControllerProps = {
* 3. notify → consume → `turn.started`reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 * 3. notify → consume → `turn.started`reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。
* 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。 * 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。
* 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。
* 6. `turn.completed``live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上 * 6. `turn.completed``live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上
* 带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。
* 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。 * 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。
* **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队; * **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队;
* 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。 * 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。
@@ -579,6 +582,10 @@ export function useDirectProjectChatController({
runAnalytics.settle(); runAnalytics.settle();
} }
// 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。
// TODO(Direct 命令接单化,未实施):下面这个 catch 现在兼职"接单被拒"与"回合失败"两种回执。
// 计划(`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,草案)是让命令只负责接单、
// 整轮结果只由订阅事件的载荷回答:届时这里只剩接单被拒,文案改成这条消息下面的本地助手
// 气泡,状态行改由 reducer 供数据,而 invoke 拒绝驱动的认证重试作废。
} catch (error) { } catch (error) {
if ( if (
isDirectCodexTurnAlreadyRunningError(error) || isDirectCodexTurnAlreadyRunningError(error) ||
@@ -605,13 +612,6 @@ export function useDirectProjectChatController({
}); });
return; return;
} }
// 真失败:这条命令返回就说明这一轮在宿主那边已经收场,但终态事件可能永远不来
// app-server 崩了、任务被中止、panic 都只留下一条开着的 `turn.started`)。
// 按本轮身份放掉原生忙态,否则界面会一直显示「正在处理」、输入盒一直排队。
// 主动终止与「正在跑的是另一轮」不走这里:前者宿主必然补终态,后者不是这一轮。
directThread.stopCommandTurn(
directCodexConversationMessageId(input.clientTurnId, 'user'),
);
void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID);
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
let persistedDetail = ''; let persistedDetail = '';
@@ -634,14 +634,10 @@ export function useDirectProjectChatController({
true, true,
); );
if (projectPathRef.current !== nextProjectPath) return; if (projectPathRef.current !== nextProjectPath) return;
// 聊天里的失败说明不再由这里写:宿主已经把脱敏后的原因放进了
// `turn.completed.failure`reducer 会把它落成本轮最后一条条目(唯一来源)。这里只保留
// 运行错误横幅(含 `详情:` 那份长 detail)与诊断留痕,两条通道不再各写一份文案。
onRuntimeError(visibleMessage); onRuntimeError(visibleMessage);
appendLocalMessage({
role: 'assistant',
text: visibleMessage,
runtimeOwned: true,
messageId: `direct-codex:${input.clientTurnId}:failure`,
updatedAt: Date.now(),
});
} }
} }
@@ -19,7 +19,6 @@ import {
mergeDirectHistoryItems, mergeDirectHistoryItems,
resolveDirectThreadBootstrap, resolveDirectThreadBootstrap,
selectDirectChatEntries, selectDirectChatEntries,
stopDirectThreadTurn,
} from '../conversation/directThreadChat'; } from '../conversation/directThreadChat';
import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult'; import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult';
import type { DirectThreadItem } from '../generated/DirectThreadItem'; import type { DirectThreadItem } from '../generated/DirectThreadItem';
@@ -41,11 +40,6 @@ export type DirectThreadChatSubscription = {
mergeHistoryItems: (items: readonly DirectThreadItem[]) => void; mergeHistoryItems: (items: readonly DirectThreadItem[]) => void;
/** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */ /** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */
markTurnStopped: () => void; markTurnStopped: () => void;
/**
* 本地命令失败收场时按身份放掉这一轮:宿主的终态事件可能永远不会来(进程崩了 /
* 任务被中止),不能一直挂在 `turn.started` 上显示「正在处理」。
*/
stopCommandTurn: (userItemId: string) => void;
}; };
/** /**
@@ -191,13 +185,6 @@ export function useDirectThreadChatSubscription({
[], [],
); );
const stopCommandTurn = useMemo(
() => (userItemId: string) => {
setState((current) => stopDirectThreadTurn(current, userItemId));
},
[],
);
const entries = useMemo(() => selectDirectChatEntries(state), [state]); const entries = useMemo(() => selectDirectChatEntries(state), [state]);
return { return {
@@ -207,6 +194,5 @@ export function useDirectThreadChatSubscription({
anchorGateRef, anchorGateRef,
mergeHistoryItems, mergeHistoryItems,
markTurnStopped, markTurnStopped,
stopCommandTurn,
}; };
} }
@@ -5,6 +5,10 @@
* 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑, * 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑,
* `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期 * `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期
* 事件自带的 canonical user identity`userItemId`)做展示边界关联,不新建回合注册表。 * 事件自带的 canonical user identity`userItemId`)做展示边界关联,不新建回合注册表。
*
* 失败(`turn.completed.status === "failed"`)不是第二套生命周期:终态还是同一个事件,只是带了
* `failure` 载荷。这里把载荷落成本轮最后一条说明条目再走同一个收口函数——失败文案的唯一来源
* 就是事件流,命令返回只服务运行错误横幅与诊断。
*/ */
import type { GameCreatorDirectToolCall } from '../../../../app/types'; import type { GameCreatorDirectToolCall } from '../../../../app/types';
@@ -14,6 +18,10 @@ import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistoryS
import type { DirectThreadItem } from '../generated/DirectThreadItem'; import type { DirectThreadItem } from '../generated/DirectThreadItem';
import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap'; import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap';
import { projectDirectThreadItem } from './directThreadItemProjection'; import { projectDirectThreadItem } from './directThreadItemProjection';
import {
directTurnFailureItemId,
directTurnFailureNoticeText,
} from './directTurnFailure';
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';
@@ -65,16 +73,6 @@ export type DirectThreadChatState = {
* 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。 * 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。
*/ */
turnUserItemId: string; turnUserItemId: string;
/**
* 「本地命令已经返回、宿主却一直没给终态」的那一轮身份(见 `stopDirectThreadTurn`)。
*
* `turn.started` 与 `turn.completed` 是原生回合唯一的开闭配对,但**进程崩了、任务被
* 中止、panic** 这类收场不会补终态事件,只留一条永远开着的 `turn.started`:界面上就
* 一直显示「正在处理」,输入盒也一直忙。本地那一条命令(`chat_with_game_creator_direct_codex`
* 返回时说到底就是"这一轮在宿主那边已经收场",这条身份就是它的记录:同身份的
* `turn.started` 迟到 / 重放回来不再复活这一轮,避免收口之后又被拉回运行态。
*/
commandClosedTurnUserItemId: string;
/** 历史切片条目,保持文件顺序。 */ /** 历史切片条目,保持文件顺序。 */
history: DirectChatEntry[]; history: DirectChatEntry[];
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
@@ -87,40 +85,11 @@ export function emptyDirectThreadChatState(): DirectThreadChatState {
turnStartedAt: 0, turnStartedAt: 0,
turnEndedAt: 0, turnEndedAt: 0,
turnUserItemId: '', turnUserItemId: '',
commandClosedTurnUserItemId: '',
history: [], history: [],
live: [], live: [],
}; };
} }
/**
* 本地命令失败收场:这一轮命令已经返回,宿主却还在事件流里挂着 `turn.started`。
*
* 只放掉"是否在跑",**不写终态时间**——命令返回不等于我们知道这一轮真正的结束时刻,
* 编一个只会让耗时变成假数。收口后同身份的 `turn.started` 迟到 / 重放回来不再复活,
* 免得刚修好的"还在处理"又被拉起来。宿主随后真发来 `turn.completed` 时照旧正常收口。
*
* 身份不同的轮次不动:宿主同时只允许一条回合,但"正在跑的是另一轮"`another-turn-running`
* 这种拒绝也要能原样报给用户,不能顺手把别人那轮抹掉。空身份(宿主没能落上身份)时按
* 本轮处理,否则这条兜底永远盖不住协议早期失败。
*/
export function stopDirectThreadTurn(
state: DirectThreadChatState,
userItemId: string,
): DirectThreadChatState {
if (state.turnUserItemId !== '' && state.turnUserItemId !== userItemId) {
return state;
}
if (!state.turnRunning && state.commandClosedTurnUserItemId === userItemId) {
return state;
}
return {
...state,
turnRunning: false,
commandClosedTurnUserItemId: userItemId,
};
}
/** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */ /** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */
function validBoundaryAt(value: number | null | undefined): number { function validBoundaryAt(value: number | null | undefined): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 return typeof value === 'number' && Number.isFinite(value) && value > 0
@@ -341,14 +310,6 @@ export function reduceDirectThreadEvent(
// 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时 // 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时
// 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。 // 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。
const turnUserItemId = readDirectThreadEventUserItemId(event); const turnUserItemId = readDirectThreadEventUserItemId(event);
// 本地命令已经收过场的那一轮:迟到的 `turn.started` 不得把它拉回运行态(见
// `commandClosedTurnUserItemId`)。身份按 clientTurnId 唯一,只挡它自己那一轮。
if (
turnUserItemId !== '' &&
turnUserItemId === state.commandClosedTurnUserItemId
) {
return state;
}
return { return {
...state, ...state,
turnRunning: true, turnRunning: true,
@@ -370,20 +331,23 @@ export function reduceDirectThreadEvent(
return state; return state;
} }
// 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。 // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。
// 例外是"本地命令兜底收口"的那一轮(`commandClosedTurnUserItemId` 命中且还没有终态 if (!state.turnRunning && state.live.length === 0) {
// 时间):那次收口本来就没写时间,宿主这份迟到的终态要拿来补上真正的结束时刻。
const lateTerminalForCommandClosedTurn =
eventUserItemId !== '' &&
eventUserItemId === state.commandClosedTurnUserItemId &&
state.turnEndedAt <= 0;
if (
!state.turnRunning &&
state.live.length === 0 &&
!lateTerminalForCommandClosedTurn
) {
return state; return state;
} }
return finishDirectThreadTurn(state, eventAt); // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口
// 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。
const failure = event.failure;
const withNotice =
failure && failure.message.trim()
? upsertLiveEntry(state, {
itemId: directTurnFailureItemId(eventUserItemId, eventAt),
kind: 'message',
role: 'assistant',
text: directTurnFailureNoticeText(failure.message),
at: eventAt,
})
: state;
return finishDirectThreadTurn(withNotice, eventAt);
} }
case 'item.delta': case 'item.delta':
return appendLiveText(state, event); return appendLiveText(state, event);
@@ -426,7 +390,8 @@ export function reduceDirectThreadEvent(
/** /**
* 回合收口:把运行态条目并入历史、清空运行态,并固定本轮终态时间。 * 回合收口:把运行态条目并入历史、清空运行态,并固定本轮终态时间。
* *
* `endedAt` 只接受明确的终态时间`turn.completed.at`,或宿主终止收口时观测到的时刻): * `endedAt` 只接受明确的终态时间`turn.completed.at`(正常与失败同源),或宿主终止收口时
* 观测到的时刻。
* 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。 * 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。
* 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级 * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级
* 由投影层决定(条目上的 `at` 才是气泡时间)。 * 由投影层决定(条目上的 `at` 才是气泡时间)。
@@ -0,0 +1,46 @@
/**
* 失败终态的展示口径:`turn.completed.failure` 载荷怎么变成聊天里那条说明。
*
* 只放两条规则,别在这里做事件归并(那是 `directThreadChat.ts` 的事):
* 1. 说明条目的展示身份怎么派生;
* 2. 事件里的原始原因怎么变成用户可见文案。
*
* 为什么值得单独一个文件:这两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce)
* 区分开又保持可预期,文案映射要和运行错误横幅用同一份规则。把它们散在 reducer 里,读代码的人
* 只能靠猜"这条失败说明是从哪冒出来的"。
*/
import { projectRuntimeVisibleError } from '../../../../features/agent-runtime';
/** 没有本轮开口条目身份时的兜底展示身份前缀(正常路径不会用到)。 */
const DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID = 'direct-thread-turn-failure';
/**
* 失败说明条目的**展示身份**:本轮开口用户条目的 canonical identity + `:failure` 后缀。
*
* 它是派生身份,不是原生条目身份——宿主只在 `turn.completed.failure` 里给原因,不额外造条目。
* 用本轮身份派生可以保证"同一轮只有一条说明、跨轮不会合并",也不会和原生 itemId 撞车。
* 身份不可证明(本轮没有落盘用户条目)时退化成与事件时间绑定的固定形状:它随事件固定、
* 重放不变,又不会让两轮失败互相覆盖。
*/
export function directTurnFailureItemId(
userItemId: string | null | undefined,
at: number,
): string {
const identity = typeof userItemId === 'string' ? userItemId.trim() : '';
if (identity) return `${identity}:failure`;
return at > 0
? `${DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID}:${at}`
: DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID;
}
/**
* 失败原因的可见文案:与运行错误横幅共用同一份映射(`projectRuntimeVisibleError`)。
*
* 事件里的 `message` 是宿主已脱敏 + 截断的原始原因,这里只做"给人看"的那一步,不再另开文案
* 规则,也不在这里判断"这算不算失败"(那由事件载荷的有没有决定)。
*/
export function directTurnFailureNoticeText(message: string): string {
const raw = typeof message === 'string' ? message.trim() : '';
return projectRuntimeVisibleError(raw, '陶泥儿智能创作', true);
}
@@ -239,7 +239,10 @@ function newTurn(key: string): DirectChatTurnEntries {
* 条目 + 运行期本地消息 → 回合列表。 * 条目 + 运行期本地消息 → 回合列表。
* *
* 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息 * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息
* 失败 / 终止说明)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 * (终止说明、壳层 `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。
*
* 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与
* 顺序都归 reducer。
*/ */
export function buildDirectChatTurns({ export function buildDirectChatTurns({
entries, entries,
@@ -2,6 +2,7 @@
import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind';
import type { DirectThreadItem } from './DirectThreadItem'; import type { DirectThreadItem } from './DirectThreadItem';
import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; import type { DirectThreadRequestKind } from './DirectThreadRequestKind';
import type { DirectTurnFailure } from './DirectTurnFailure';
/** /**
* Thread Manager 下发的运行态事件。 * Thread Manager 下发的运行态事件。
@@ -41,7 +42,15 @@ export type DirectThreadEvent =
} }
| { | {
type: 'turn.completed'; type: 'turn.completed';
/**
* 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**
* 此时必须带 `failure` 载荷。
*/
status: string; status: string;
/**
* 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。
*/
failure?: DirectTurnFailure;
/** /**
* 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 * 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
*/ */
@@ -0,0 +1,19 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。
*
* `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的
* 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。
*/
export type DirectTurnFailure = {
/**
* 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` /
* `host-dropped`。
*/
kind: string;
/**
* 脱敏 + 截断后的失败原因。
*/
message: string;
};
@@ -555,7 +555,7 @@ export function registerChatComposerControlTests() {
}); });
}); });
it('stops claiming the turn is running when a failed send left turn.started open', async () => { it('closes the turn from the host failure payload instead of leaving it running', async () => {
let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null = let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null =
null; null;
const { surface } = await openDirectCodexSurface( const { surface } = await openDirectCodexSurface(
@@ -563,13 +563,23 @@ export function registerChatComposerControlTests() {
chat_with_game_creator_direct_codex: ( chat_with_game_creator_direct_codex: (
args: Record<string, unknown> | undefined, args: Record<string, unknown> | undefined,
) => { ) => {
// 宿主先认领了这一轮(turn.started),随后崩掉:没有终态事件,命令以失败返回。 // 宿主先认领了这一轮(turn.started),随后失败收场:失败原因由终态事件自己带出来,
harness?.emitDirectThreadEvents({ // 命令也以失败返回(真实宿主是先 append 终态、再把错误抛回前端)。
type: 'turn.started', const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`;
at: 5_000, harness?.emitDirectThreadEvents(
userItemId: `direct-codex:${String(args?.clientTurnId ?? '')}:user`, { type: 'turn.started', at: 5_000, userItemId },
}); {
throw new Error('模拟宿主崩溃:turn.started 之后没有终态事件'); type: 'turn.completed',
status: 'failed',
failure: {
kind: 'request-rejected',
message: 'codex-app-server-error:context-window-exceeded',
},
at: 6_000,
userItemId,
},
);
throw new Error('模拟宿主失败:错误只走终态事件,命令只回报失败');
}, },
}, },
(directHarness) => { (directHarness) => {
@@ -577,20 +587,28 @@ export function registerChatComposerControlTests() {
}, },
); );
const composer = within(surface).getByLabelText('陶泥儿对话内容'); const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '崩掉的那条'); await submitDirectTurn(surface, composer, '超限的那条');
// 聊天里的失败说明来自事件载荷(经同一份可见文案映射),不是命令返回的错误文本。
await waitFor(() => { await waitFor(() => {
expect( expect(
within(surface).getAllByText('陶泥儿智能创作 执行失败,请稍后重试') within(surface).getAllByText(
.length, '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试',
).length,
).toBeGreaterThan(0); ).toBeGreaterThan(0);
}); });
// 命令已经收场:卡片和输入区都不能再声称"还在处理"。 // 终态已经到了:卡片和输入区都不能再声称"还在处理"。
expect(within(surface).queryAllByText('陶泥儿正在处理')).toHaveLength(0); expect(within(surface).queryAllByText('陶泥儿正在处理')).toHaveLength(0);
expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull(); expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull();
expect( expect(
within(surface).getByRole('button', { name: '发送' }), within(surface).getByRole('button', { name: '发送' }),
).not.toBeNull(); ).not.toBeNull();
// 命令返回那条通道只负责横幅:它不写第二条聊天文案。
expect(
within(surface).queryAllByText(
'模拟宿主失败:错误只走终态事件,命令只回报失败',
),
).toHaveLength(0);
}); });
it('keeps the next queued turn busy when the write gate refuses the running one', async () => { it('keeps the next queued turn busy when the write gate refuses the running one', async () => {
@@ -11,7 +11,6 @@ import {
reduceDirectThreadEvents, reduceDirectThreadEvents,
resolveDirectThreadBootstrap, resolveDirectThreadBootstrap,
selectDirectChatEntries, selectDirectChatEntries,
stopDirectThreadTurn,
} from '../src/view/project-development/chat/conversation/directThreadChat'; } from '../src/view/project-development/chat/conversation/directThreadChat';
import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection'; import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection';
import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent'; import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent';
@@ -177,54 +176,6 @@ describe('DirectProject 聊天 reducer', () => {
expect(selectDirectChatEntries(done)).toHaveLength(2); expect(selectDirectChatEntries(done)).toHaveLength(2);
}); });
it('本地命令兜底收口后,迟到的同名 turn.started 不复活这一轮', () => {
const identity = 'direct-codex:client-turn-1:user';
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event(withUserItemId({ type: 'turn.started', at: 1_000 }, identity)),
event({ type: 'item.completed', item: messageItem() }),
]);
expect(running.turnRunning).toBe(true);
const stopped = stopDirectThreadTurn(running, identity);
expect(stopped.turnRunning).toBe(false);
// 兜底收口不写终态时间:命令返回不等于知道这一轮真正的结束时刻。
expect(stopped.turnEndedAt).toBe(0);
// 同一轮迟到的 turn.started 不再把它拉回运行态,运行态条目也没丢。
const revived = reduceDirectThreadEvents(stopped, [
event(withUserItemId({ type: 'turn.started', at: 2_000 }, identity)),
]);
expect(revived.turnRunning).toBe(false);
expect(selectDirectChatEntries(revived)).toHaveLength(1);
// 宿主随后补上的真终态照旧收口,并把真正的结束时间补上。
const late = reduceDirectThreadEvents(revived, [
event(
withUserItemId(
{ type: 'turn.completed', status: 'failed', at: 3_000 },
identity,
),
),
]);
expect(late.turnRunning).toBe(false);
expect(late.turnEndedAt).toBe(3_000);
});
it('本地命令兜底收口不碰身份不同的那轮', () => {
const other = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event(
withUserItemId(
{ type: 'turn.started', at: 1_000 },
'direct-codex:client-turn-9:user',
),
),
]);
expect(
stopDirectThreadTurn(other, 'direct-codex:client-turn-1:user')
.turnRunning,
).toBe(true);
});
it('历史切片搬运层不合并,合并发生在前端投影', () => { it('历史切片搬运层不合并,合并发生在前端投影', () => {
const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
toolStarted(), toolStarted(),
@@ -333,6 +284,165 @@ describe('DirectProject 聊天 reducer', () => {
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
}); });
describe('失败终态(turn.completed 带 failure 载荷)', () => {
it('失败也是终态:收口本轮、冻结终点,并把原因落成本轮最后一条说明', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000_000 }),
'direct-codex:turn-1:user',
),
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: {
kind: 'transport-failed',
message: 'DirectProject 收尾历史失败:未确认历史完整落盘',
},
at: 1_000_900,
}),
'direct-codex:turn-1:user',
),
]);
expect(failed.turnRunning).toBe(false);
expect(failed.turnEndedAt).toBe(1_000_900);
expect(failed.live).toHaveLength(0);
const notice = failed.history.at(-1);
expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure');
expect(notice?.role).toBe('assistant');
expect(notice?.text).toBe('陶泥儿智能创作 执行失败,请稍后重试');
// 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。
expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900);
expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000);
});
it('失败说明的可见文案与运行错误横幅共用同一份映射', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000 }),
'direct-codex:turn-1:user',
),
event({
type: 'turn.completed',
status: 'failed',
failure: {
kind: 'request-rejected',
message: 'codex-app-server-error:context-window-exceeded',
},
at: 2_000,
}),
]);
expect(failed.history.at(-1)?.text).toBe(
'陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试',
);
});
it('重复 / 迟到的失败终态不追加第二条说明,也不抬高冻结终点或复活运行态', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000_000 }),
'direct-codex:turn-1:user',
),
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '模型服务暂不可用' },
at: 1_000_900,
}),
'direct-codex:turn-1:user',
),
]);
const replayed = reduceDirectThreadEvents(failed, [
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '模型服务暂不可用' },
at: 9_900_000,
}),
'direct-codex:turn-1:user',
),
]);
expect(replayed.turnRunning).toBe(false);
expect(replayed.turnEndedAt).toBe(1_000_900);
expect(
replayed.history.filter((entry) => entry.itemId.endsWith(':failure')),
).toHaveLength(1);
});
it('身份不匹配的失败终态不动正在跑的这一轮', () => {
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 2_000_000 }),
'direct-codex:turn-2:user',
),
]);
const untouched = reduceDirectThreadEvents(running, [
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '上一轮的失败' },
at: 2_000_900,
userItemId: 'direct-codex:turn-1:user',
}),
]);
expect(untouched.turnRunning).toBe(true);
expect(untouched.turnEndedAt).toBe(0);
expect(untouched.history).toHaveLength(0);
expect(untouched.live).toHaveLength(0);
});
it('空原因不落说明条目,但终态照样收口', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000 }),
'direct-codex:turn-1:user',
),
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'host-dropped', message: ' ' },
at: 2_000,
}),
'direct-codex:turn-1:user',
),
]);
expect(failed.turnRunning).toBe(false);
expect(failed.turnEndedAt).toBe(2_000);
expect(failed.history).toHaveLength(0);
});
it('没有身份时用事件时间派生说明身份,两轮失败不会合并成一条', () => {
const first = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000 }),
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '第一轮失败' },
at: 2_000,
}),
]);
const second = reduceDirectThreadEvents(first, [
event({ type: 'turn.started', at: 3_000 }),
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '第二轮失败' },
at: 4_000,
}),
]);
expect(second.history.map((entry) => entry.itemId)).toEqual([
'direct-thread-turn-failure:2000',
'direct-thread-turn-failure:4000',
]);
});
});
describe('事件级计时边界', () => { describe('事件级计时边界', () => {
/** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */ /** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */
it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => { it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => {
@@ -24,6 +24,11 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 - 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。
- 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。 - 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。
- 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `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"` 的终态,避免前端永远停在"还在跑"。已知边界见「影响」一节。
- 执行通道断开同样是失败终态,也必须带 `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 侧任何地方都不再解析它们。
- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。
- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 - `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。
- 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。
@@ -50,7 +55,12 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 - 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。
- 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 - 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。
- 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 - 回合结束语义务必由 `turn.completed` 判定(失败时同一事件带 `failure` 载荷,不新增事件类型);缺少该事件的残留回合不得被渲染成运行中。
- 两条已知边界,都**不**在本次补路径:① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在运行态,那要靠前端自己的"命令已返回却没有任何终态事件"判据;② `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、历史注入参数构建失败等)不产生回合、也不补终态事件——它们不会留下永远开着的回合,出问题时只有运行错误横幅解释。
- 失败原因里的 `message` 是宿主侧脱敏 + 截断后的可展示文本,前端仍按既有口径做一次可见文案映射(`projectRuntimeVisibleError`),映射规则不因这次改动改变。
- 执行通道断开时用户看到的仍是既有映射结果(诊断命中不了专门规则,落到通用兜底),真实诊断在事件载荷、宿主交付报告与运行日志里;把"连接断开"改成专门文案属于映射规则变更,不在本 ADR 范围内。
- 模型自报失败时用户看到的也仍是既有映射结果(`codex-app-server-error:<kind>` 那张中文表),区别只是原因现在从事件载荷来、同时命令返回带出运行错误横幅——这就是"事件出聊天文案、命令返回出横幅"的既有分工;前端可见文案的映射规则不因这次改动改变。
- **调用级拒绝**(同一 `clientTurnId` 并发复用 / 项目已有另一条回合在跑 / 权限策略拒绝 / 目录锚不定 / 输入校验 / 环境与凭据未就绪)不属于回合失败:这一轮没有开始,只把原因回给命令边界(界面出运行错误横幅),不写失败诊断、不发 `failed` 事件、不进交付报告。此前它们与回合失败混在同一层、共用同一份错误文本,现在分流只认 typed 判据。
- 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。 - 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。
- 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts``DirectChatTurnState`。改判据时同步这两处与对应测试。 - 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts``DirectChatTurnState`。改判据时同步这两处与对应测试。
- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl` - 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`
@@ -1,5 +1,14 @@
# 决策记录 # 决策记录
## 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()`
- 根因:改造前两层错误混在同一份字符串里,靠对原因文本做子串匹配决定"算不算失败""要不要反馈给模型""怎么给建议",任何文案改动都可能静默改变分流;并发拒绝还只靠一个前缀字面量给前端识别。
- 分类不再做文本匹配:原生失败分类只解析 app-server 写下的 `codex-app-server-error:<kind>` 结构化前缀,转成 `DirectCodexNativeKind` 后再 `match`
- 明确不做:不改线上载荷(仍是 `{kind, message}`)、不改命令边界签名(仍是 `Result<String, String>`)、不改前端可见文案映射与 `wire_kind` 取值;Rust 侧不再解析那份字符串,字符串只在 `Display` 一处生成。不给深层尚未 typed 的事实补 typed 出口,只留一个显式的桥变体并在注释里写明新分类必须先加 typed 变体。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_error.rs,direct_turn_failure.rs,direct_delivery.rs,direct_runtime/mod.rs,direct_runtime/user_input.rs,codex_app_server/mod.rs,codex_app_server/execution.rs}``apps/ai-game-creator-shell/src-tauri/src/cli.rs`;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`
- 验证:`cargo test --bins -- direct_`460 passed)、`cargo test --bins -- codex_app_server`100 passed)、`cargo fmt --check``npm run check:encoding`、定向 `git diff --check`。整包 `cargo test --bins` 在本机被既有 `tests::provider` / `tests::project` 重型用例挂住(并发跑测试时另有一条锁竞争用例会假失败),非本次改动引入。真实客户端观感未复核。
## 2026-09-22 Direct 埋点与业务持久化锁隔离 ## 2026-09-22 Direct 埋点与业务持久化锁隔离
- Direct 采集身份和最新成果编号改由独立纯内存状态保存,初始化时从最终执行账本冻结项目与原 run 身份;成果采集、预览采集上下文和终态成果读取不再争用业务落盘锁。 - Direct 采集身份和最新成果编号改由独立纯内存状态保存,初始化时从最终执行账本冻结项目与原 run 身份;成果采集、预览采集上下文和终态成果读取不再争用业务落盘锁。
@@ -9262,11 +9271,31 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 边界(A 仍未修):`DirectProjectTurnUsage``Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx``DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts``DirectChatTurnState` 注释),改完删掉那段注释。 - 边界(A 仍未修):`DirectProjectTurnUsage``Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx``DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts``DirectChatTurnState` 注释),改完删掉那段注释。
- 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start``running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding``check:doc-index``git diff --check` 通过。真实客户端观感未复核。 - 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start``running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding``check:doc-index``git diff --check` 通过。真实客户端观感未复核。
## 2026-09-22 宿主崩掉不再留下永远开着的回合:本地命令失败时按身份兜底收口 ## 2026-09-22 失败回合的终态:`turn.completed``failure` 载荷 + 宿主 Drop 守卫兜底
- 背景:`turn.started` / `turn.completed` 是原生回合唯一的开闭配对,界面上的「正在处理」卡片与输入盒忙态都读 reducer 的 `turnRunning`。但 app-server 崩了、回合任务被中止或 panic 时没人补终态事件,事件流里就留一条永远开着的 `turn.started`:界面一直显示「陶泥儿正在处理」、输入盒一直排队(用户现场反馈) - 背景:宿主崩在 `turn.started` 之后时没有任何终态事件,前端 `turnRunning` 永远为真,界面停在「陶泥儿正在处理」;同时失败在事件流里与正常结束同形(`turn.completed(status="failed")`,前端根本不读 `status`),失败文案只能从命令返回那条通道另造,同一次失败因此有两条通道、两份文案,而"这一轮结束了没有"只有事件说了算
- 决策(本地命令返回即这一轮在宿主那边收场):`chat_with_game_creator_direct_codex` 以真失败返回时,controller 按本轮身份调用 `stopDirectThreadTurn`,只放掉「是否在跑」,**不写终态时间**——命令返回不等于知道这一轮真正的结束时刻,编一个只会让耗时变成假数。用户主动终止与「正在跑的是另一轮」两条不适用:前者宿主必然补终态,后者不是这一轮(不能顺手抹掉别人的回合) - 决策(协议形状:复用,不新增事件类型):终态事件仍只有 `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` 就用那份报告当原因
- 决策(身份作用域 + 不复活):`stopDirectThreadTurn` 只在 reducer 里的运行身份相同或为空时生效;收口记进 `commandClosedTurnUserItemId`,同身份迟到的 `turn.started` 不再把这一轮拉回运行态(迟到的 `turn.completed` 例外放行,仍要拿它补上真正的结束时间)。身份按 clientTurnId 唯一,所以这条记忆只挡它自己那一轮 - 决策(兜底覆盖全部收场路径):`turn.started` 进入队列之后武装 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `host-dropped` 失败终态。Thread Manager 不改一行:`turn.completed` 本来就是 `lifecycle_anchor` 成员,失败终态天然顶替更早的 `turn.started`,重放不会把已收口的回合看成"还在跑"
- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,controller/useDirectThreadChatSubscription.ts,controller/useDirectProjectChatController.ts}``apps/ai-game-creator-shell/tests/{directThreadChat.test.ts,appSurface/chat-composer.suite.ts}` - 决策(失败文案只有一条通道):聊天里那条失败说明仍落在原来的展示位(本轮最后一条助手气泡、只在运行期显示、不写进 `project.jsonl`),数据来源换成事件载荷;命令返回只保留运行错误横幅(含 `read_agent_runtime_error_detail` 的长 detail)与诊断留痕,不再写聊天气泡。可见文案映射仍走既有 `projectRuntimeVisibleError` 规则,只是执行点从 controller 移到 reducer
- 验证:reducer 新增 2 条用例(兜底收口后同名 `turn.started` 不复活且真终态仍能补上结束时间;身份不同的回合不动),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证:拿掉 controller 里的兜底收口调用后该用例变红(界面仍显示「陶泥儿正在处理」),恢复即绿 - 明确不做:不为 `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、缺稳定 `clientTurnId`、历史注入参数构建失败)补事件或兜底路径 —— 它们不产生回合、也不会留下永远开着的回合;不为进程被强杀(`kill -9`)补前端判据
- 边界(未做):根因仍在宿主侧——要在进程内保证开闭配对,应由 Rust 在回合函数退出(含 panic / 任务中止)时补一条终态事件(drop 守卫);本次只做到前端不再跟着说谎。另:兜底收口的回合没有终态时间,仍会落进「`finished` 但拿不到终态时间」那个已知缺口(终态文案要不要藏,见 `DirectProjectTurn.tsx``DirectChatTurnState` 注释里的 A 项) - 影响范围:`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`)。
## 2026-09-22 执行通道断开也是失败终态:诊断记在执行适配器上,不与看门狗抢时序
- 背景:手工杀掉 codex app-server`kill -9`)验证上一条修复时,回合确实收口了(界面不再停在"还在处理"),但**没有任何失败说明**:连接级故障走的是 `TransportClosed` 分支,那里用一句策略文案 `adapter.interrupt(...)` 收束成 `ExecutionPhase::Interrupted``lifecycle_status``Interrupted` 映射成 `status="interrupted"``direct_turn_failure` 因此返回 `None`,事件不带载荷、reducer 也就不落说明条目;真实诊断(`Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)stderrClass=...`)只进了 `app_log!`
- 决策(失败事实记在执行适配器上):新增 `ExecutionAdapter::transport_failed(diagnostic)` 与只读的 `transport_failure()`。连接级故障(`fail_game_creator_codex_app_server_connection`)与回合事件通道关闭(`TransportClosed` / 事件通道 `None`)都调它:先同步记下"本轮以传输失败收口"与原因,再把同一份原因补进宿主交付报告(`interrupt` 对已有终态不覆盖,报告只作旁证)。`lifecycle_status` 见到这条事实一律返回 `failed``direct_turn_failure` 因此产出 `kind="transport-failed"``message=诊断` 的载荷。原因不能存在调用点局部变量里:执行适配器的看门狗盯着同一个 `inner.closed` 标志,它可能先把回合收束成 `Interrupted`,而终态判定发生在收束之后。
- 决策(区分"连接自己断了"与"宿主关的连接",判据收在适配器里):`transport_failed` 先看 `is_closed`——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)时适配器先于连接置位 `closed`,那种情况下只按既有口径中断收口(原因照样写进报告),不记失败事实;调用点两条分支的判据保持原样(`!is_host_ending()`),不动它们的控制流。
- 决策(载荷取诊断而不是交付报告):`direct_turn_failure` 增加第三来源且优先级最高——通道断开时原因用宿主诊断(含 `exitStatus` / stderr 摘要),不用 `collect_result` 里那份只说"收束到哪一步"的交付报告;报告与载荷同源的说法只对"原因写进报告"这一步成立,事件载荷才是失败原因的唯一权威。
- 明确不做:不改前端可见文案映射(诊断命中不了专门规则,仍落到通用兜底文案);不给连接级故障补端到端集成用例(判据落在策略函数与适配器两层单测,DirectProject 回合路径缺轻型假 app-server 夹具);`kill -9` 掉宿主进程本身仍没有 `Drop`,不在本次范围。
- 影响范围:`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 direct_`439 passed)、定向 `transport_failure` / `direct_turn_failure::`(10 passed,含新增三条:适配器把诊断记成失败终态且只认第一份原因、宿主自己关的连接不算失败、失败载荷优先取宿主诊断)、`cargo fmt --check``npm run check:encoding``git diff --check`。真实客户端观感未复核。
## 2026-09-23 终态由事实判定:模型自报失败的原生错误投影进既有错误通道
- 背景:app-server 老实报了 `turn/completed{status:"failed", error:{message, additionalDetails, codexErrorInfo}}`,界面却没有任何原因。两条通道同时哑:① 事件侧 `lifecycle_status` 拿收尾阶段当终态口径(执行适配器 `drain()``interrupt()` 把 ledger 推成 `Interrupted`),把模型报的 `failed` 改写成 `interrupted`,失败载荷因此永远产不出来;② 命令侧有执行许可时 `finish_model_attempt` 先返回交付报告,命令变成 Ok,运行错误横幅的前提也消失。原生 `turn.error`(带 `codexErrorInfo` 分类,前端 `projectRuntimeVisibleError` 有现成中文映射表)只在"没有执行许可"的 `Err` 分支里被读一次。
- 决策(终态由事实判定,有载荷必 `failed`):`direct_turn_terminal` 去掉 `model_status` 入参,判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段账本读不出来时才用交付报告」取原因;**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾阶段的中断不再有终态否决权——这是本次修复的根因。
- 决策(原生失败用投影,不扩载荷、不加入参):`turn.error` 由既有的 `game_creator_codex_app_server_failed_turn_error` 投影成 `LlmError`,在 `"failed"` 分支里作为本回合的错误结果返回,于是走已有的错误槽位产出 `{kind, message}` 载荷;`RepairRequired`(返修请求)保持原语义优先。前端零改动——`projectRuntimeVisibleError` 现有映射直接命中 `codex-app-server-error:<kind>`;命令返回同时回到 Err,运行错误横幅恢复。
- 明确不做:不新增事件类型、不给失败载荷加字段、不改前端可见文案映射;不给回合路径补轻型假 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`。真实客户端观感未复核。
@@ -113,7 +113,7 @@ Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thre
```ts ```ts
type DirectThreadEvent = type DirectThreadEvent =
| { type: 'turn.started' } | { type: 'turn.started' }
| { type: 'turn.completed'; status: string } | { type: 'turn.completed'; status: string; failure?: { kind: string; message: string } }
| { type: 'item.started'; item: DirectThreadItem } | { type: 'item.started'; item: DirectThreadItem }
| { type: 'item.completed'; item: DirectThreadItem } | { type: 'item.completed'; item: DirectThreadItem }
| { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string } | { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string }
@@ -122,12 +122,22 @@ 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。 进入 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`;条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。 **事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`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"` 却没有载荷视为协议违规。
执行通道断开(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.completed.status` 不是收尾阶段的口径(`lifecycle_status` 只描述 ledger 阶段,没有终态否决权):判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有账本读不出来时才用交付报告」取原因,有载荷一定写 `status="failed"`。模型自报失败(原生 `turn/completed``error`,含 `codexErrorInfo`)复用同一条通道:宿主把它投影成 `LlmError` 后当作本回合的错误结果返回,原因文本里带着 `codex-app-server-error:<kind>` 前缀(前端 `projectRuntimeVisibleError` 已有对应中文映射),既不为载荷新增输入字段,也不让交付报告顶掉原因;`RepairRequired`(返修请求)保持自己的原语义。
一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。
前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started` 且其后没有 `turn.completed` 时才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 里没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started` 且其后没有 `turn.completed` 时才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 里没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。
失败终态与正常终态同权:`turn.completed`(无论 `status`)都顶替更早的 `turn.started` 成为队列锚点,重放时新订阅既不会把已收口的回合看成"还在跑",也不会看到已经过期的失败原因。
生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。
`item.started``item.completed` 必须携带与历史切片同形的**脱敏原始条目**(经同一套挑字段、脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。 `item.started``item.completed` 必须携带与历史切片同形的**脱敏原始条目**(经同一套挑字段、脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。