diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs index f2977c116..2cedfbbb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs @@ -7,8 +7,8 @@ //! | [`TurnFailure`] | `turn.completed.failure` 事件载荷 | 9 | //! //! 走哪条通道由**发生位置**决定:入队前发生的进第一张表,放行后发生的进第二张,第二张里 -//! "这一轮已经判失败"的那部分由 [`TurnError::terminal_failure`] 投影成第三张。 -//! 控制流(返修要求)不是失败,投影返回 `None`,于是它既不会进失败载荷,也不需要前端接。 +//! "这一轮已经判失败"的那部分由 [`TurnError::classify`] 投影成第三张。控制流(返修要求)不是 +//! 失败,分类成 [`TurnErrorClassified::ShouldContinue`],于是它既不会进失败载荷,也不需要前端接。 //! //! 为什么不是 `struct { kind, message }`:两张表根本不共享字段——模型自报失败要带原生分类、 //! 等待超时要带是哪条上限、通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`, @@ -465,10 +465,11 @@ pub(crate) struct HostStateUnavailable { // ══════════════════════════════════════════════════════════════════════════════════════════ /// 回合侧 typed 错误。**不跨进程、不序列化**:它只作宿主内部的 `Err`,前端拿到的是 -/// [`TurnError::terminal_failure`] 投影出来的 [`TurnFailure`] 事件载荷。 +/// [`TurnError::classify`] 投影出来的 [`TurnFailure`] 事件载荷。 /// -/// 10 个变体里 8 个是失败、2 个是控制流(返修要求)。控制流**不是失败**:投影返回 `None`, -/// 既不进失败载荷也不上报,所以前端不需要、也不会写这两个分支。 +/// 10 个变体里 8 个是失败、2 个是控制流(返修要求)。控制流**不是失败**:分类成 +/// [`TurnErrorClassified::ShouldContinue`],既不进失败载荷也不上报,所以前端不需要、也不会写 +/// 这两个分支。 #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum TurnError { /// 项目目录锚不定(符号链接 / 权限 / 目录被删):app-server 侧解析项目身份时判定。 @@ -564,53 +565,81 @@ pub(crate) struct Unclassified { pub(crate) detail: String, } +/// [`TurnError::classify`] 的处置结果:调用方据此决定要不要写终态。 +/// +/// 这层替换掉原来的 `Option`:`None` 以前同时表示"没有失败"和"不是失败、要继续跑", +/// 调用方看到 `None` 只会理解成前者——控制流要么被当成"这轮没失败"糊过去,要么被当成畸形输入 +/// 硬报一条失败。两个取值各有名字,控制流带自己的说明,不再借道 `None`。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TurnErrorClassified { + /// 真失败:这一轮到此为止。载荷已投影并脱敏,可直接下发。 + ShouldStop(TurnFailure), + /// 控制流(返修 / 复核要求继续):这一轮还没结束,调用方**不得**写终态。 + ShouldContinue { detail: String }, +} + impl TurnError { - /// 这一轮**已经判失败**时的可下发载荷;控制流(返修要求)返回 `None`。 + /// 把这条错误投影成"该收场 / 该继续"两层:真失败带上可下发的 [`TurnFailure`] 载荷,控制流 + /// 带上自己的说明。 /// /// 这是 `turn.completed.failure` 的**唯一**投影点:控制流不是失败,所以它既不写终态、也不进 /// 载荷,前端因此不需要为它写分支。载荷里的宿主原文(`detail` / `cause` / `diagnostic`)在这 /// 一处统一脱敏 + 截断,与改造前同一条边界、同一份判据。 - pub(crate) fn terminal_failure(&self, history_root: &Path) -> Option { + pub(crate) fn classify(&self, history_root: &Path) -> TurnErrorClassified { let redact = |value: &str| redact_agent_runtime_error(history_root, value, FAILURE_DETAIL_MAX_CHARS); match self { - Self::ProjectRootUnanchored(payload) => { - Some(TurnFailure::ProjectRootUnanchored(ProjectRootUnanchored { + Self::ProjectRootUnanchored(payload) => TurnErrorClassified::ShouldStop( + TurnFailure::ProjectRootUnanchored(ProjectRootUnanchored { cause: redact(&payload.cause), - })) - } - Self::EnvironmentNotReady(payload) => { - Some(TurnFailure::EnvironmentNotReady(EnvironmentNotReady { + }), + ), + Self::EnvironmentNotReady(payload) => TurnErrorClassified::ShouldStop( + TurnFailure::EnvironmentNotReady(EnvironmentNotReady { + detail: redact(&payload.detail), + }), + ), + Self::HostStateUnavailable(payload) => TurnErrorClassified::ShouldStop( + TurnFailure::HostStateUnavailable(HostStateUnavailable { + detail: redact(&payload.detail), + }), + ), + Self::ModelCallFailed(payload) => { + TurnErrorClassified::ShouldStop(TurnFailure::ModelCallFailed(ModelCallFailed { + kind: payload.kind.clone(), detail: redact(&payload.detail), })) } - Self::HostStateUnavailable(payload) => { - Some(TurnFailure::HostStateUnavailable(HostStateUnavailable { + Self::TransportClosed(payload) => { + TurnErrorClassified::ShouldStop(TurnFailure::TransportClosed(TransportClosed { + diagnostic: redact(&payload.diagnostic), + })) + } + Self::TimedOut(payload) => { + TurnErrorClassified::ShouldStop(TurnFailure::TimedOut(payload.clone())) + } + Self::TurnInterrupted(payload) => { + TurnErrorClassified::ShouldStop(TurnFailure::TurnInterrupted(TurnInterrupted { detail: redact(&payload.detail), })) } - Self::ModelCallFailed(payload) => Some(TurnFailure::ModelCallFailed(ModelCallFailed { - kind: payload.kind.clone(), - detail: redact(&payload.detail), - })), - Self::TransportClosed(payload) => Some(TurnFailure::TransportClosed(TransportClosed { - diagnostic: redact(&payload.diagnostic), - })), - Self::TimedOut(payload) => Some(TurnFailure::TimedOut(payload.clone())), - Self::TurnInterrupted(payload) => Some(TurnFailure::TurnInterrupted(TurnInterrupted { - detail: redact(&payload.detail), - })), - Self::SuperErrorFromStringPlusStage(payload) => Some( + Self::SuperErrorFromStringPlusStage(payload) => TurnErrorClassified::ShouldStop( TurnFailure::SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage { stage: payload.stage, detail: redact(&payload.detail), }), ), - Self::Unclassified(payload) => Some(TurnFailure::Unclassified(Unclassified { - detail: redact(&payload.detail), - })), + Self::Unclassified(payload) => { + TurnErrorClassified::ShouldStop(TurnFailure::Unclassified(Unclassified { + detail: redact(&payload.detail), + })) + } // 控制流:这一轮还没结束,不是失败。 - Self::ReviewRequired { .. } | Self::RepairRequired { .. } => None, + Self::ReviewRequired { detail } | Self::RepairRequired { detail } => { + TurnErrorClassified::ShouldContinue { + detail: detail.clone(), + } + } } } @@ -1098,16 +1127,16 @@ mod tests { ); } - /// `terminal_failure` 是失败载荷的唯一投影点:控制流不是失败,返回 `None`。 + /// `classify` 是失败载荷的唯一投影点:控制流不是失败,落进 `ShouldContinue`。 #[test] - fn terminal_failure_projects_failures_and_skips_control_flow() { + fn classify_projects_failures_and_separates_control_flow() { let root = Path::new("/tmp/direct-turn-error-test"); let timed_out = TurnError::TimedOut(TimedOut { deadline: Deadline::TurnHardLimit, }); assert_eq!( - timed_out.terminal_failure(root), - Some(TurnFailure::TimedOut(TimedOut { + timed_out.classify(root), + TurnErrorClassified::ShouldStop(TurnFailure::TimedOut(TimedOut { deadline: Deadline::TurnHardLimit, })) ); @@ -1115,15 +1144,19 @@ mod tests { TurnError::ReviewRequired { detail: "还缺证据".into() } - .terminal_failure(root), - None + .classify(root), + TurnErrorClassified::ShouldContinue { + detail: "还缺证据".into() + } ); assert_eq!( TurnError::RepairRequired { detail: "继续返修".into() } - .terminal_failure(root), - None + .classify(root), + TurnErrorClassified::ShouldContinue { + detail: "继续返修".into() + } ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index 77e9ed922..10f69bf6f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -22,7 +22,7 @@ use crate::agent::{ direct_codex_user_item_to_prompt, now_ms, record_direct_codex_failure, redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project, DirectGameCreatorTurnUpdateEmitter, DispatchedTurn, FailureStage, - TurnCompletion, TurnError, + TurnCompletion, TurnError, TurnErrorClassified, }; /// 一次放行的占用。持有它就代表这一轮还没收口。 @@ -189,6 +189,20 @@ pub(crate) fn kick_queue_dispatch(root: &Path) { })); } +/// 放行之后的回合失败写点:typed 错误按 [`TurnError::classify`] 投影,真失败才写终态。 +/// +/// 这些路径上的错误都是**回合失败**(写历史失败 / 回合体返回的 `Err`);控制流(返修 / 复核要求 +/// 继续)在 `direct_runtime` 的返修循环里就被消化,不会到这里。真漏到这里也不写终态——这一轮还 +/// 没结束,不能伪造一条失败。 +fn finish_turn_failure(reservation: &TurnReservation, error: &TurnError, history_root: &Path) { + match error.classify(history_root) { + TurnErrorClassified::ShouldStop(payload) => { + reservation.finish_if_unfinished(TurnCompletion::failed(payload)); + } + TurnErrorClassified::ShouldContinue { .. } => {} + } +} + /// 把 panic 负载转成可读文本:`panic!("…")` 的负载是 `&str`,`panic!("{x}")` 是 `String`。 fn direct_turn_panic_detail(payload: &(dyn std::any::Any + Send)) -> String { if let Some(text) = payload.downcast_ref::<&str>() { @@ -224,11 +238,7 @@ async fn run_dispatched_direct_turn( &format!("写入本项目对话历史失败:{error}"), 600, )); - reservation.finish_if_unfinished(TurnCompletion::failed( - failure - .terminal_failure(&root) - .expect("回合失败必可投影成失败载荷"), - )); + finish_turn_failure(&reservation, &failure, &root); return; } // 用户条目落盘成功即下发:这一轮从"放行"到"起 codex"之间的一切失败(连不上 app-server、执行器 @@ -277,11 +287,7 @@ async fn run_dispatched_direct_turn( Err(error) => { // 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 - reservation.finish_if_unfinished(TurnCompletion::failed( - error - .terminal_failure(&root) - .expect("回合失败必可投影成失败载荷"), - )); + finish_turn_failure(&reservation, &error, &root); } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs index b660d2f55..5384e3006 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs @@ -11,12 +11,12 @@ //! 终态的**出口**(谁写、什么时候兜底)不在这里,在 [`super::dispatch`] 的放行占用对象里: //! 这里只负责"什么算失败、原因怎么写"。 //! -//! 载荷与脱敏都由 [`TurnError::terminal_failure`] 一处投影:Rust 侧没有第二个地方再拼它, -//! 也没有任何地方再解析它。 +//! 载荷与脱敏都由 [`TurnError::classify`] 一处投影:Rust 侧没有第二个地方再拼它,也没有任何 +//! 地方再解析它。 use std::path::Path; -use crate::agent::{ThreadEvent, TurnError, TurnOutcome, Unclassified}; +use crate::agent::{ThreadEvent, TurnError, TurnErrorClassified, TurnOutcome, Unclassified}; use super::wire::TurnFailure; @@ -49,7 +49,7 @@ impl TurnCompletion { event.with_user_item_id(user_item_id) } - /// 一次失败终态:载荷已由 [`TurnError::terminal_failure`] 投影并脱敏。 + /// 一次失败终态:载荷已由 [`TurnError::classify`] 投影并脱敏。 pub(crate) fn failed(failure: TurnFailure) -> Self { Self::Failed(failure) } @@ -70,16 +70,19 @@ impl TurnCompletion { /// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾 /// 阶段的账本读不出来时只有它可用。 /// -/// 载荷在这一个出口从 typed 错误投影([`TurnError::terminal_failure`]):脱敏与截断也在那 -/// 一处完成,Rust 侧没有第二个地方再拼它、也没有任何地方再解析它。 +/// 错误由 [`TurnError::classify`] 分成两层:`ShouldStop` 的载荷直接成失败终态;`ShouldContinue` +/// (返修 / 复核要求继续)说明这一轮还没结束,正常不该走到这里(调用方在写终态之前就拦下了), +/// 真漏进来也不伪造失败——落到账本给出的正常终态。 +/// +/// 载荷在这一个出口从 typed 错误投影([`TurnError::classify`]):脱敏与截断也在那一处完成, +/// Rust 侧没有第二个地方再拼它、也没有任何地方再解析它。 pub(crate) fn turn_terminal( session_status: &str, collect_outcome: Result<&str, TurnError>, host_failure: Option<&TurnError>, history_root: &Path, ) -> TurnCompletion { - let outcome = SessionOutcome::parse(session_status); - let failure = match (host_failure, collect_outcome) { + let error = match (host_failure, collect_outcome) { (Some(failure), _) => Some(failure.clone()), (None, Err(error)) => Some(error.clone()), // 账本读不出来时(`session_status == "failed"`)没有 typed 原因可用:报告文本就是这一轮 @@ -91,50 +94,42 @@ pub(crate) fn turn_terminal( } (None, Ok(_)) => None, }; - match ( - failure.and_then(|failure| failure.terminal_failure(history_root)), - outcome, - ) { - (Some(payload), _) => TurnCompletion::Failed(payload), - (None, Some(outcome)) => outcome.into_completion(), + if let Some(error) = error { + match error.classify(history_root) { + TurnErrorClassified::ShouldStop(payload) => return TurnCompletion::Failed(payload), + // 控制流:这一轮还没结束。写终态是调用方的事,正常在调用方那一层就被拦下;这里显式 + // 认账,不把"继续跑"重新压回静默失败。 + TurnErrorClassified::ShouldContinue { detail } => { + debug_assert!(false, "控制流错误不应进入终态判定:{detail}"); + } + } + } + session_completion(session_status).unwrap_or_else(|| { // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条说不出原因的失败,也不冒充 // 正常收场;载荷照样从 typed 错误投影,保持"只在一处拼载荷"。 - (None, None) => TurnCompletion::Failed( - TurnError::Unclassified(Unclassified { - detail: format!("收尾阶段给出的回合终态无法识别:{session_status}"), - }) - .terminal_failure(history_root) - .unwrap_or(TurnFailure::HostDropped), - ), - } + let error = TurnError::Unclassified(Unclassified { + detail: format!("收尾阶段给出的回合终态无法识别:{session_status}"), + }); + match error.classify(history_root) { + TurnErrorClassified::ShouldStop(payload) => TurnCompletion::Failed(payload), + // `Unclassified` 恒为真失败;这一臂写全只是把"分类层不允许静默"补齐。 + TurnErrorClassified::ShouldContinue { .. } => TurnCompletion::host_dropped(), + } + }) } -/// 收尾阶段按 ledger 阶段推出来的**非失败**终态(只可能是这三档)。 -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SessionOutcome { - Completed, - Interrupted, - Aborted, -} - -impl SessionOutcome { - /// `status` 只在协议边界是字符串,这里是它进入宿主内部的唯一收口。认不出的值返回 `None`: - /// 由 [`turn_terminal`] 按失败兜底,绝不冒充正常收场。 - fn parse(status: &str) -> Option { - match status { - "completed" => Some(Self::Completed), - "interrupted" => Some(Self::Interrupted), - "aborted" => Some(Self::Aborted), - _ => None, - } - } - - fn into_completion(self) -> TurnCompletion { - match self { - Self::Completed => TurnCompletion::Completed, - Self::Interrupted => TurnCompletion::Interrupted, - Self::Aborted => TurnCompletion::Aborted, - } +/// 收尾阶段账本给出的**非失败** `status` → 终态;认不出的值返回 `None` 由 [`turn_terminal`] 按 +/// 失败兜底。 +/// +/// 这就是原 `SessionOutcome` 的全部内容——它只是"没有 `Failed` 的 [`TurnCompletion`]",并进来 +/// 少一个同义类型。 +fn session_completion(status: &str) -> Option { + match status { + "completed" => Some(TurnCompletion::Completed), + "interrupted" => Some(TurnCompletion::Interrupted), + "aborted" => Some(TurnCompletion::Aborted), + // `failed` 不走这里:它要么带 typed 错误、要么按报告文本兜底。 + _ => None, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs index 53e643efa..deb44460a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs @@ -6,8 +6,8 @@ //! "什么算失败、原因怎么写"是宿主内部策略,不在 wire 里:那是 //! [`crate::agent::TurnCompletion`](`thread_manager::turn_completion`)。 //! -//! 载荷与脱敏都由 [`TurnError::terminal_failure`] 一处投影:Rust 侧没有第二个地方再拼它, -//! 也没有任何地方再解析它。这里不碰事件队列的搬运规则,也不自己认 `LlmError`。 +//! 载荷与脱敏都由 [`TurnError::classify`] 一处投影:Rust 侧没有第二个地方再拼它,也没有任何 +//! 地方再解析它。这里不碰事件队列的搬运规则,也不自己认 `LlmError`。 use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -23,8 +23,10 @@ use crate::agent::{ /// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 /// 不直接上屏。 /// -/// 唯一投影点是 [`crate::agent::TurnError::terminal_failure`]:控制流(返修要求)返回 -/// `None`,所以控制流既不会出现在这里,前端也不需要为它写分支。 +/// 唯一投影点是 [`crate::agent::TurnError::classify`]:控制流(返修要求)落进 +/// [`crate::agent::TurnErrorClassified::ShouldContinue`],所以控制流既不会出现在这里,前端也 +/// 不需要为它写分支。 +// TODO badnaming: 这个名字没有表达出它只是"回合终态的失败载荷",先留着待改名。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde( tag = "type",