diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index cec26a002..2833e9890 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -1,7 +1,7 @@ //! Native / third-party approval adapter. The host execution session owns policy //! 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 direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession}; use serde_json::{json, Value}; @@ -149,7 +149,7 @@ pub(super) struct ExecutionAdapter { outcome: watch::Sender>, /// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败, /// 原因与交付报告同一份文本。 - turn_failure: Mutex>, + turn_failure: Mutex>, /// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。 host_stop_requested: AtomicBool, } @@ -696,19 +696,20 @@ impl ExecutionAdapter { /// /// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由 /// 不得覆盖它。 - pub(super) async fn fail_turn(&self, kind: &str, reason: &str) { + 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((kind.to_string(), reason.to_string())); + *slot = Some(failure); } } } - self.interrupt(reason).await; + self.interrupt(&reason).await; } - /// 本轮以什么理由失败;有值就是宿主记下的 `(分类, 原因)`。终态判定只读这一次。 - pub(super) fn turn_failure(&self) -> Option<(String, String)> { + /// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。 + pub(super) fn turn_failure(&self) -> Option { self.turn_failure.lock().ok().and_then(|slot| slot.clone()) } @@ -1099,7 +1100,10 @@ pub(super) async fn wait_outcome( #[cfg(test)] mod tests { - use super::super::{DIRECT_TURN_FAILURE_TIMEOUT_KIND, DIRECT_TURN_FAILURE_TRANSPORT_KIND}; + use super::super::DirectTurnDeadline; + + /// 通道断开在事件载荷里的稳定分类(`DirectTurnError::wire_kind` 的取值之一)。 + const EXPECTED_TRANSPORT_KIND: &str = "transport-failed"; use super::*; fn fixture() -> (tempfile::TempDir, Arc) { @@ -1154,27 +1158,28 @@ mod tests { assert!(!adapter.host_stop_requested()); adapter - .fail_turn( - DIRECT_TURN_FAILURE_TRANSPORT_KIND, - "执行通道已断开:Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)", - ) + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), + }) .await; // 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。 - let (kind, reason) = adapter.turn_failure().expect("host fact must be recorded"); - assert_eq!(kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); - assert!(reason.contains("SIGKILL")); + 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(DIRECT_TURN_FAILURE_TIMEOUT_KIND, "等待模型执行回执超时") + .fail_turn(DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::ResponseIdle, + }) .await; - let (kind, reason) = adapter.turn_failure().expect("first reason is kept"); - assert_eq!(kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); - assert!(reason.contains("SIGKILL")); - assert!(!reason.contains("超时")); + 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("超时")); } /// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道 @@ -1186,10 +1191,9 @@ mod tests { adapter.closed.store(true, Ordering::Release); adapter - .fail_turn( - DIRECT_TURN_FAILURE_TRANSPORT_KIND, - "执行通道已断开:模型本次执行结束,回收原生后台子树", - ) + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: "模型本次执行结束,回收原生后台子树".into(), + }) .await; assert!(adapter.turn_failure().is_none()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 55aba8d09..f67237146 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -3604,10 +3604,9 @@ impl CodexAppServerConnection { if let Some(adapter) = approval_adapter.as_ref() { // 等不到终态就是这一轮失败:只收口不留原因等于界面静默结束。 adapter - .fail_turn( - DIRECT_TURN_FAILURE_TIMEOUT_KIND, - "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。", - ) + .fail_turn(DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::TurnHardLimit, + }) .await; return execution::outcome_text(adapter.wait_outcome().await); } @@ -3635,10 +3634,9 @@ impl CodexAppServerConnection { Err(_) => { if let Some(adapter) = approval_adapter.as_ref() { adapter - .fail_turn( - DIRECT_TURN_FAILURE_TIMEOUT_KIND, - "等待模型执行回执超时,不能自动重放未确认操作。", - ) + .fail_turn(DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::ResponseIdle, + }) .await; return execution::outcome_text(adapter.wait_outcome().await); } @@ -3973,10 +3971,11 @@ impl CodexAppServerConnection { // 收场,必须让界面看到原因,不能只是把回合静默收口。 if !adapter.is_host_ending() && !adapter.host_stop_requested() { adapter - .fail_turn( - DIRECT_TURN_FAILURE_INTERRUPTED_KIND, - "本轮模型执行被中断,正在核对自有后台进程。", - ) + .fail_turn(DirectTurnError::TurnInterrupted { + detail: + "本轮模型执行被中断,正在核对自有后台进程。" + .into(), + }) .await; } return execution::outcome_text(adapter.wait_outcome().await); @@ -4021,10 +4020,9 @@ impl CodexAppServerConnection { // 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态 // 判定之后才读得到:见 `ExecutionAdapter::fail_turn`。 adapter - .fail_turn( - DIRECT_TURN_FAILURE_TRANSPORT_KIND, - &execution_channel_failure_reason(&error), - ) + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: error.clone(), + }) .await; } return execution::outcome_text(adapter.wait_outcome().await); @@ -4041,12 +4039,9 @@ impl CodexAppServerConnection { // 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能 // 以失败收口,不能报成"被中断"。 adapter - .fail_turn( - DIRECT_TURN_FAILURE_TRANSPORT_KIND, - &execution_channel_failure_reason( - "Codex app-server turn 事件通道已关闭", - ), - ) + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: "Codex app-server turn 事件通道已关闭".into(), + }) .await; } return execution::outcome_text(adapter.wait_outcome().await); @@ -4113,12 +4108,15 @@ impl CodexAppServerConnection { 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_result.as_ref().map(String::as_str), - turn_failure - .as_ref() - .map(|(kind, reason)| (kind.as_str(), reason.as_str())), + collect_outcome, + turn_failure.as_ref(), history_root, ); append_direct_thread_event( @@ -5031,8 +5029,9 @@ async fn fail_game_creator_codex_app_server_connection( // "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。 record_execution_turn_failure( &inner, - DIRECT_TURN_FAILURE_TRANSPORT_KIND, - &execution_channel_failure_reason(&diagnostic), + DirectTurnError::TransportClosed { + diagnostic: diagnostic.clone(), + }, ) .await; match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await { @@ -5045,7 +5044,7 @@ async fn fail_game_creator_codex_app_server_connection( /// 把"这一轮以失败收口"的事实记到当前回合的执行适配器上:连接级故障、等待超时、app-server /// 单方面中断都走这一条路径,别在多处各写一份。没有进行中的 DirectProject 回合(适配器已释放) /// 就是空操作。 -async fn record_execution_turn_failure(inner: &Arc, kind: &str, reason: &str) { +async fn record_execution_turn_failure(inner: &Arc, failure: DirectTurnError) { let adapter = { let slot = match inner.execution.lock() { Ok(slot) => slot, @@ -5057,13 +5056,7 @@ async fn record_execution_turn_failure(inner: &Arc, kind: & let Some(adapter) = adapter else { return; }; - adapter.fail_turn(kind, reason).await; -} - -/// 执行通道断开的统一说明:策略句(未确认操作禁止自动重放)+ 宿主诊断。回合失败载荷与宿主交付 -/// 报告共用这一份文本:用户看到的现象和交付状态必须对得上。 -fn execution_channel_failure_reason(diagnostic: &str) -> String { - format!("执行通道已断开,不能自动重放未确认操作:{diagnostic}") + adapter.fail_turn(failure).await; } async fn shutdown_game_creator_codex_app_server_inner( @@ -5132,7 +5125,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( root: &std::path::Path, system_prompt: String, user_prompt: String, -) -> Result { +) -> Result { direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt, @@ -5150,7 +5143,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( system_prompt: String, user_prompt: String, observer: &mut (dyn FnMut(DirectCodexTurnObservation) + Send), -) -> Result { +) -> Result { direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt, @@ -5171,12 +5164,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, direct_user_item: Option, -) -> Result { +) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between // projects, or replace the project manifest in-place; raw path text alone // 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 .to_str() .and_then(|value| value.strip_prefix("\\\\?\\")) @@ -5185,9 +5179,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( } else { canonical_root.clone() }; - let config = load_game_creator_app_config()?; - game_creator_codex_app_server_validate_llm_config(&config.llm) - .map_err(|error| error.to_string())?; + let config = load_game_creator_app_config() + .map_err(|detail| DirectTurnError::EnvironmentNotReady { detail })?; + 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 effective_client_turn_id = match client_turn_id { @@ -5200,7 +5198,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map(|state| state.client_turn_id) }) .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()) } }; @@ -5220,8 +5221,11 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( web_search_enabled: config.llm.web_search_enabled, allow_idle_context_compaction: false, }; - let api_kind = - parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?; + let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| { + DirectTurnError::EnvironmentNotReady { + detail: error.to_string(), + } + })?; let metrics_attempt = audit.as_ref().map(|audit| { audit.metrics().attempt( &config.llm.model, @@ -5251,7 +5255,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( if let Some(attempt) = metrics_attempt.as_ref() { attempt.finish("failed"); } - error.to_string() + // 连接建立失败是环境/凭据层面的前置于失败:这一轮还没有开始。 + DirectTurnError::EnvironmentNotReady { + detail: error.to_string(), + } })?; let request = LlmRunRequest::single_turn(system_prompt, user_prompt) .with_api_kind(api_kind) @@ -5273,7 +5280,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( ) .await .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() { attempt.finish(if result.is_ok() { "completed" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs index e888816bc..df71818da 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs @@ -697,10 +697,14 @@ pub(super) async fn finish_sealing( }).await.map_err(|_| "delivery-finalize-worker-exited")? } +/// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。 +/// +/// 返修要求是**控制流**([`DirectTurnError::ReviewRequired`]),不是失败:调用方据此把要求写回 +/// prompt 再跑一轮,界面不该看到失败文案。其余错误都是真的回合失败,按 typed 错误交给上层。 pub(super) async fn review_reply( root: &Path, session: &Arc, -) -> Result, String> { +) -> Result, DirectTurnError> { if let Some(report) = terminal_report(session) { return Ok(Some(report)); } @@ -751,7 +755,9 @@ pub(super) async fn review_reply( .map_err(|_| "delivery-review-worker-exited")??; return Ok(Some(report)); } - Err(format!("delivery-review-required: {detail}")) + Err(DirectTurnError::ReviewRequired { + detail: format!("delivery-review-required: {detail}"), + }) } #[cfg(test)] @@ -914,10 +920,10 @@ mod tests { assert_eq!(chat.snapshot().unwrap().delivery_reviews, 0); let (new_game, _new_host, required) = project_session(true); for _ in 0..2 { - assert!(review_reply(new_game.path(), &required) - .await - .unwrap_err() - .starts_with("delivery-review-required:")); + assert!(matches!( + review_reply(new_game.path(), &required).await.unwrap_err(), + DirectTurnError::ReviewRequired { .. } + )); } assert!(review_reply(new_game.path(), &required) .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index b7d19cda3..3b9786de8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -30,7 +30,6 @@ const PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX: &str = const DIRECT_TAONIER_LOCAL_RECONCILIATION_PREFIX: &str = "platform-generation-local-reconciliation:"; const DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX: &str = "platform-generation-result-unknown:"; -const DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX: &str = "direct-codex-turn-already-running:"; const DIRECT_TAONIER_REGENERATION_WORKFLOW_SCHEMA_VERSION: &str = "direct-taonier-package-regeneration.v4"; const DIRECT_TAONIER_REGENERATION_WORKFLOW_PATH: &str = @@ -515,24 +514,23 @@ pub(crate) struct DirectTaonierActiveInvocationGuard { } impl DirectTaonierActiveInvocationGuard { - pub(crate) fn enter(root: &Path, invocation_id: &str) -> Result { + pub(crate) fn enter(root: &Path, invocation_id: &str) -> Result { let root = root .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; + .map_err(|error| DirectTurnError::ProjectRootUnanchored { + cause: error.to_string(), + })?; let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get_or_init(|| Mutex::new(HashMap::new())); let mut active = active .lock() - .map_err(|_| "Direct 调用身份锁已损坏".to_string())?; + .map_err(|_| DirectTurnError::HostStateUnavailable { + detail: "Direct 调用身份锁已损坏".to_string(), + })?; match active.get(&root) { Some(existing) => { - return Err(if existing.invocation_id == invocation_id { - format!( - "{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" - ) - } else { - format!( - "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" - ) + return Err(DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: existing.invocation_id.clone(), + incoming_invocation_id: invocation_id.to_string(), }); } None => { @@ -2145,215 +2143,10 @@ fn direct_taonier_art_generation_outcome( } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DirectCodexFailureStage { - ArtPreparation, - CodeGeneration, - BrowserValidation, - VersionRegistration, -} - -impl DirectCodexFailureStage { - fn id(self) -> &'static str { - match self { - Self::ArtPreparation => "art-preparation", - Self::CodeGeneration => "code-generation", - Self::BrowserValidation => "browser-validation", - Self::VersionRegistration => "version-registration", - } - } -} - -#[derive(Debug)] -struct DirectCodexTurnFailure { - stage: DirectCodexFailureStage, - error: String, -} - -impl DirectCodexTurnFailure { - fn new(stage: DirectCodexFailureStage, error: impl Into) -> Self { - Self { - stage, - error: error.into(), - } - } -} - -fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &str) -> &'static str { - let normalized = error.to_ascii_lowercase(); - if direct_codex_error_is_mud_points_insufficient(error) { - return "泥点余额不足,请充值后发送“继续”"; - } - if private_external_editor_credentials_storage_preparation_failed(error) { - return "请检查当前 Windows 用户对本机私有凭据目录的权限后重试"; - } - if private_external_editor_credentials_persistence_failed(error) { - return "请先在账户开发者凭据页面撤销刚创建但未保存的凭据,再重试"; - } - if error.contains("本机陶泥儿开发者 Key") { - return "请先在已登录的陶泥儿客户端发起一次直连创作,以创建仅保存在本机的开发者 Key"; - } - if normalized.contains("authentication-required") - || normalized.contains("unauthorized") - || normalized.contains("http 401") - { - return "登录态可能已失效,请重新登录陶泥儿后重试"; - } - if normalized.contains("permission-denied") || normalized.contains("http 403") { - return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试"; - } - if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) { - return "当前项目仍有写入正在结束,请稍后再次发送该需求"; - } - if error.contains("身份不唯一") - || error.contains("身份不匹配") - || error.contains("未找到身份完整的历史图集") - || error.contains("没有可见像素") - { - return "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集"; - } - if direct_project_history_injection_oversize(error) { - return "项目对话历史有单条记录或整份载荷超过注入上限,无法整体注入 Codex;请按项目诊断里的 itemId 处理该条记录后再发送需求"; - } - if direct_project_history_shape_failure(error) { - return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求"; - } - if direct_project_history_contention_failure(error) { - return "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求"; - } - match stage { - DirectCodexFailureStage::ArtPreparation => { - "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" - } - DirectCodexFailureStage::CodeGeneration => { - "Codex 未完成本轮代码修改,请检查运行时配置后重试" - } - DirectCodexFailureStage::BrowserValidation => { - "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求" - } - DirectCodexFailureStage::VersionRegistration => { - "产物尚未安全登记为版本,请检查项目目录后重试" - } - } -} - -fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> { - if direct_codex_error_is_mud_points_insufficient(error) { - return Some("泥点余额不足"); - } - if direct_project_history_injection_oversize(error) { - return Some("项目对话历史有单条记录超过注入上限"); - } - if private_external_editor_credentials_storage_preparation_failed(error) { - return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据"); - } - if private_external_editor_credentials_persistence_failed(error) { - return Some("本机开发者凭据已创建但未能安全保存"); - } - None -} - -fn direct_codex_failure_is_retryable(error: &str) -> bool { - if direct_codex_error_is_mud_points_insufficient(error) { - return false; - } - if direct_project_history_shape_failure(error) { - return false; - } - // 注入超限与「行形状」同类:同一份历史文件每次读都会得到同一结论,重试只会 - // 再次注入同一份(且本轮用户消息已先追加进同一文件,载荷只会更大),因此不标可重试。 - if direct_project_history_injection_oversize(error) { - return false; - } - ![ - "validation-budget-exhausted", - "validation-already-running", - "private-external-editor-credential-storage-preparation-failed", - "private-external-editor-credential-persistence-failed", - "身份不唯一", - "身份不匹配", - "未找到身份完整的历史图集", - "没有可见像素", - "合同发生变化", - ] - .iter() - .any(|marker| error.contains(marker)) -} - -/// DirectProject 的工具 / 构建 / 试玩失败应作为下一轮 LLM 的调试上下文继续处理, -/// 而不是在 app-server 把本轮标成 failed 后立即把错误交给用户。基础设施、身份和 -/// 历史一致性错误没有安全的自动修复路径,必须保持终止语义。 +/// 回合级失败最多反馈给模型几次:工具 / 构建 / 试玩类失败继续修,基础设施类失败在 +/// [`DirectTurnError::is_model_repairable`] 里就已经被拦下,不会走到这里。 const DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS: usize = 3; -fn direct_codex_error_should_feedback(error: &str) -> bool { - let normalized = error.to_ascii_lowercase(); - let terminal_markers = [ - "validation-budget-exhausted", - "validation-already-running", - "authentication-required", - "401", - "403", - "泥点余额不足", - "insufficient_mud_points", - "身份不唯一", - "身份不匹配", - "合同发生变化", - "历史记录类型无效", - "历史记录缺少 payload", - "历史注入载荷超过单行上限", - "工具参数", - "transport closed", - "连接已关闭", - "连接上游失败", - "硬上限", - "超时", - "取消", - "凭据", - "credential", - "context-window-exceeded", - "request-too-large", - "session-budget-exceeded", - "usage-limit-exceeded", - "stream-required", - "cyber-policy", - "sandbox-error", - "thread-rollback-failed", - "bad-request", - ]; - if terminal_markers.iter().any(|marker| { - if marker.chars().any(|character| character.is_uppercase()) { - error.contains(marker) - } else { - normalized.contains(marker) - } - }) { - return false; - } - let repairable_markers = [ - "工具", - "tool", - "构建", - "build", - "编译", - "验证", - "verify", - "试玩", - "playtest", - "console", - "exception", - "未通过", - "失败", - "error", - ]; - repairable_markers.iter().any(|marker| { - if marker.chars().any(|character| character.is_uppercase()) { - error.contains(marker) - } else { - normalized.contains(marker) - } - }) -} - fn direct_codex_error_feedback_prompt(error: &str, attempt: usize) -> String { format!( prompt_text!("direct.errorFeedback"), @@ -2363,76 +2156,34 @@ fn direct_codex_error_feedback_prompt(error: &str, attempt: usize) -> String { ) } -/// DirectProject 历史文件里与“行形状”有关的失败:同一份文件每次读都会得到同一结果, -/// 重试不会改变结论。IO 类失败(打开/读取目录)不在其中,那些仍按可重试处理。 -const DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS: &[&str] = &[ - "DirectProject 历史记录类型无效", - "DirectProject 历史记录缺少 payload", - "解析 DirectProject 历史失败", -]; - -/// DirectProject 历史**注入超限**的失败标记。 +/// 把 typed 回合失败写成诊断:摘要 / 可重试 / 建议全部由 typed 分类判定,文本只用于详情与兜底。 /// -/// 判据是「同一份历史 ⇒ 同一份载荷 ⇒ 同一结论」:本次注入因为单条记录(或整份载荷)超过 -/// 单行上限而被前置校验拦下(前缀与字节数定义在 `agent/codex_app_server.rs`),重试只会再注入 -/// 同一份、且更大的历史。所以按不可重试处理,并给专属恢复提示;这里沿用本文件既有的 -/// 「字面量子串」口径,只取前缀的特征子串。 -const DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_MARKERS: &[&str] = &["历史注入载荷超过单行上限"]; - -fn direct_project_history_injection_oversize(error: &str) -> bool { - DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_MARKERS - .iter() - .any(|marker| error.contains(marker)) -} - -fn direct_project_history_shape_failure(error: &str) -> bool { - DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS - .iter() - .any(|marker| error.contains(marker)) -} - -/// 追加写的**跨进程追加锁**超时。 -/// -/// 与"行形状"类相反:它不是同一份历史的同一个结论,而是别的进程此刻正拿着锁——锁本身 -/// 没有残留(所有权是句柄,进程退出即释放),所以"稍后重试"是真能生效的动作。提示因此 -/// 指向现象与动作,而不是原来 CodeGeneration 阶段那句"请检查运行时配置后重试"。 -/// -/// 判据刻意只认追加锁那一个常量:项目写锁争用(`PROJECT_WRITE_LOCK_CONTENTION_PREFIX`) -/// 在 [`direct_codex_failure_recovery_hint`] 里更早、更具体地判掉了("当前项目仍有写入正在 -/// 结束"),把项目写锁也写进这里只会得到一段永远走不到的判据,并让"历史未能落盘"这句 -/// 与真实原因不符的描述有机会出现。 -fn direct_project_history_contention_failure(error: &str) -> bool { - error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER) -} - -fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool { - let normalized = error.to_ascii_lowercase(); - error.contains("泥点余额不足") - || error.contains("可消费泥点不足") - || normalized.contains("kind=mud-points-insufficient") - || normalized.contains("insufficient_mud_points") - || normalized.contains("insufficient-mud-points") -} - +/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮的收口说明,事件载荷、横幅与 +/// 项目历史共用同一份,命令边界也只序列化它一次。 fn record_direct_codex_turn_failure( root: &Path, - failure: DirectCodexTurnFailure, + failure: &DirectTurnError, client_turn_id: Option<&str>, ) -> String { - let summary = direct_codex_failure_public_summary(&failure.error) + let detail = failure.to_string(); + let stage = failure.turn_failure_stage(); + let summary = failure + .public_summary() .map(str::to_string) - .unwrap_or_else(|| redact_agent_runtime_error(root, &failure.error, 320)); + .unwrap_or_else(|| redact_agent_runtime_error(root, &detail, 320)); let summary = summary.split_whitespace().collect::>().join(" "); let summary = if summary.trim().is_empty() { "未提供可安全展示的详细原因".to_string() } else { summary }; - let retryable = direct_codex_failure_is_retryable(&failure.error); - let recovery_hint = direct_codex_failure_recovery_hint(failure.stage, &failure.error); + let retryable = failure.is_retryable(); + let recovery_hint = failure + .recovery_hint() + .unwrap_or("请重试;如持续失败请检查项目诊断"); let diagnostic = serde_json::json!({ "schemaVersion": "direct-codex-diagnostic.v1", - "stage": failure.stage.id(), + "stage": stage.id(), "summary": summary, "retryable": retryable, "recoveryHint": recovery_hint, @@ -2455,17 +2206,18 @@ fn record_direct_codex_turn_failure( } else { "未能保存项目诊断" }; - let error_code = classify_direct_codex_error(&failure.error); + // 诊断 code 仍走共享的 runtime_error 分类(它是跨链路的持久化标签,不是流程判据)。 + let error_code = classify_direct_codex_error(&detail); let unified_detail_ref = persist_agent_runtime_error( root, client_turn_id, "direct-codex", - failure.stage.id(), + stage.id(), error_code, retryable, &summary, recovery_hint, - &failure.error, + &detail, None, serde_json::json!({ "legacyDiagnosticWritten": diagnostic_written, @@ -2475,7 +2227,7 @@ fn record_direct_codex_turn_failure( .map(|event| event.detail_ref); format!( "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}{}", - failure.stage.id(), + stage.id(), error_code, retryable, diagnostic["summary"] @@ -4797,7 +4549,7 @@ pub(crate) async fn run_direct_game_creator_home_turn( pub(crate) async fn run_direct_game_creator_turn_at( root: &Path, prompt: &str, -) -> Result { +) -> Result { // The CLI entry point does not receive the GUI's clientTurnId. Still arm // one invocation identity so an otherwise optional AGC generation tool // cannot fail merely because the request came through the CLI. This is @@ -4811,7 +4563,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( root: &Path, prompt: &str, creation_type: Option<&str>, -) -> Result { +) -> Result { run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, prompt, @@ -4837,17 +4589,20 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( crate::analytics::store::AnalyticsWriter, )>, analytics_attempt_id: Option<&str>, -) -> Result { +) -> Result { if !root.is_absolute() || !root.is_dir() { - return Err("当前项目目录不存在或不是绝对路径".to_string()); + return Err(DirectTurnError::ProjectRootUnusable); } - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "conversation.read") + .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; + enforce_project_permission_policy(root, "conversation.write") + .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; let prompt = prompt.trim(); if prompt.is_empty() { - return Err("聊天内容不能为空".to_string()); + return Err(DirectTurnError::ContentEmpty); } - direct_creation_type_system_context(creation_type)?; + direct_creation_type_system_context(creation_type) + .map_err(|detail| DirectTurnError::InputRejected { detail })?; emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { emitter.emit("accepted", Some("request-accepted"), None, None); @@ -4865,10 +4620,13 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( .await { Ok(reply) => Ok(reply), + // 调用级拒绝不属于回合失败:它们不写失败诊断、不发 `failed` 事件,只把原因交回调用方。 + Err(failure) if !failure.is_turn_failure() => Err(failure), Err(failure) => { + let stage = failure.turn_failure_stage(); let error = record_direct_codex_turn_failure( root, - failure, + &failure, turn_emitter.map(|emitter| emitter.turn_id()), ); if let Some(emitter) = turn_emitter { @@ -4892,7 +4650,10 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( .collect::>(); emitter.emit_with_stream_items("failed", Some("none"), None, None, failure_item); } - Err(error) + Err(DirectTurnError::TurnFailed { + stage, + detail: error, + }) } } } @@ -5066,25 +4827,27 @@ async fn run_direct_game_creator_turn_inner( crate::analytics::store::AnalyticsWriter, )>, analytics_attempt_id: Option<&str>, -) -> Result { +) -> Result { let requires_contract = super::direct_delivery::requires_new_web_contract( root, creation_type, turn_emitter.is_none(), ) .await - .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; + .map_err(|error| { + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) + })?; // CLI 没有首页适配器;可信、尚未交付的脚手架沿用同一宿主准备入口。 if requires_contract && turn_emitter.is_none() { crate::environment_check::prepare_new_web_project_at(root, Some("game")) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })?; } let execution_config = load_game_creator_app_config() .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })? .validation; let analytics_run = capture.as_ref().map(|(context, _)| { @@ -5102,7 +4865,9 @@ async fn run_direct_game_creator_turn_inner( analytics_run, ) .await - .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; + .map_err(|error| { + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) + })?; let execution_session = execution_guard.session(); execution_session.set_analytics_capture(capture.clone()); let started = execution_session @@ -5116,7 +4881,7 @@ async fn run_direct_game_creator_turn_inner( } } // 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。 - let result: Result = async { + let result: Result = async { if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { return Ok(report); } @@ -5140,12 +4905,16 @@ async fn run_direct_game_creator_turn_inner( let stream_enabled = load_game_creator_app_config() .map(|config| config.llm.stream) .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + error) })?; let previous_output_fingerprint = direct_codex_output_fingerprint(root); let base_system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( - |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error), + |error| DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + error), )?; // 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前, // 避免被长度上限截断,也不阻断任何工具。 @@ -5287,21 +5056,22 @@ async fn run_direct_game_creator_turn_inner( ) .await { - Ok(value) => match super::direct_delivery::review_reply(root,&execution_session).await { + Ok(value) => match super::direct_delivery::review_reply(root, &execution_session).await { Ok(Some(report)) => break Ok(report), Ok(None) => break Ok(value), - Err(detail) if detail.starts_with("delivery-review-required:") => { + // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 + Err(DirectTurnError::ReviewRequired { detail }) => { emitter.emit("running",Some("host-review"),Some("宿主复核发现必需证据尚未齐备,正在按冻结范围继续处理".into()),None); feedback_prompt = format!(prompt_text!("direct.deliveryFeedback"),detail=detail); } Err(error) => break Err(error), }, - Err(_) if super::direct_delivery::terminal_report(&execution_session).is_some() => break Ok(super::direct_delivery::terminal_report(&execution_session).unwrap()), + Err(_) if super::direct_delivery::terminal_report(&execution_session).is_some() => break Ok(super::direct_delivery::terminal_report(&execution_session).unwrap_or_default()), Err(error) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && direct_codex_error_should_feedback(&error) => + && error.is_model_repairable() => { - let detail = redact_agent_runtime_error(root, &error, 1800); + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); emitter.emit( "running", Some("error-feedback"), @@ -5345,40 +5115,41 @@ async fn run_direct_game_creator_turn_inner( .await { Ok(value) => { - match super::direct_delivery::review_reply(root,&execution_session).await { + match super::direct_delivery::review_reply(root, &execution_session).await { Ok(Some(report)) => { response = Some(report); break; } Ok(None) => { response = Some(value); break; } - Err(detail) if detail.starts_with("delivery-review-required:") => { + // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 + Err(DirectTurnError::ReviewRequired { detail }) => { feedback_prompt = format!(prompt_text!("direct.deliveryFeedback"),detail=detail); } - Err(error) => return Err(DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration,error)), + // 回合级失败原样带出:typed 分类决定诊断摘要与建议,不再降级成文本。 + Err(error) => return Err(error), } } Err(_) if super::direct_delivery::terminal_report(&execution_session).is_some() => { response = super::direct_delivery::terminal_report(&execution_session); break; } Err(error) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && direct_codex_error_should_feedback(&error) => + && error.is_model_repairable() => { - let detail = redact_agent_runtime_error(root, &error, 1800); + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); attempt += 1; feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt); } - Err(error) => { - return Err(DirectCodexTurnFailure::new( - DirectCodexFailureStage::CodeGeneration, - error, - )); - } + Err(error) => return Err(error), } } - response.ok_or_else(|| "陶泥儿错误反馈回合未返回结果".to_string()) - } - .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; + response.ok_or_else(|| { + DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "陶泥儿错误反馈回合未返回结果", + ) + }) + }?; // 回合结束:把本回合累积的工具调用整批落盘(一次锁、一次重写,幂等 upsert)。 // 落盘失败只记日志,不能把已经成功的回合判成失败——工具调用卡片是展示数据。 persist_collected_direct_tool_calls(root, &tool_calls); let visible_reply = project_direct_codex_visible_text(&reply).ok_or_else(|| { - DirectCodexTurnFailure::new( + DirectTurnError::turn_failed( DirectCodexFailureStage::CodeGeneration, "陶泥儿未返回可展示的回复".to_string(), ) @@ -5415,7 +5186,9 @@ async fn run_direct_game_creator_turn_inner( } sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error) + DirectTurnError::turn_failed( + DirectCodexFailureStage::VersionRegistration, + error) })?; } Ok(visible_reply) @@ -5570,7 +5343,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( root: &Path, prompt: &str, prepare_art: bool, -) -> Result { +) -> Result { if prepare_art { let mode = if direct_prompt_requests_fresh_art_generation(prompt) { DirectTaonierArtPreparationMode::Regenerate @@ -5580,12 +5353,12 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( ensure_direct_taonier_art_package_at(root, prompt, mode) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::ArtPreparation, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::ArtPreparation, error) })?; } let previous_output_fingerprint = direct_codex_output_fingerprint(root); let mut system_prompt = build_direct_codex_system_prompt(root).map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })?; if prepare_art { system_prompt.push_str(prompt_text!("direct.production.preparedArt")); @@ -5598,7 +5371,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })?; let initial_output_fingerprint = direct_codex_output_fingerprint(root); let mut completion_error = direct_game_output_completion_error(root); @@ -5616,7 +5389,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( run_direct_browser_evidence_at(root, evidence_attempts) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) })?, ); browser_checked_output_fingerprint = Some(initial_output_fingerprint.clone()); @@ -5634,7 +5407,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( let mut reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })?; let repaired_fingerprint = direct_codex_output_fingerprint(root); completion_error = direct_game_output_completion_error(root); @@ -5648,7 +5421,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( run_direct_browser_evidence_at(root, evidence_attempts) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) })?, ); browser_checked_output_fingerprint = Some(repaired_fingerprint.clone()); @@ -5671,7 +5444,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt) .await .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) })?; let final_fingerprint = direct_codex_output_fingerprint(root); completion_error = direct_game_output_completion_error(root); @@ -5688,7 +5461,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( run_direct_browser_evidence_at(root, evidence_attempts) .await .map_err(|error| { - DirectCodexTurnFailure::new( + DirectTurnError::turn_failed( DirectCodexFailureStage::BrowserValidation, error, ) @@ -5702,16 +5475,19 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( if let Some(evidence) = evidence.as_ref() { write_direct_browser_acceptance_summary(root, evidence, false, evidence_attempts) .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error) + DirectTurnError::turn_failed( + DirectCodexFailureStage::VersionRegistration, + error, + ) })?; } - return Err(DirectCodexTurnFailure::new( + return Err(DirectTurnError::turn_failed( DirectCodexFailureStage::VersionRegistration, format!("Codex 自主验收未通过,未登记完成版本:{completion_error}"), )); } let Some(evidence) = evidence else { - return Err(DirectCodexTurnFailure::new( + return Err(DirectTurnError::turn_failed( DirectCodexFailureStage::BrowserValidation, "Codex 自主试玩未产生真实浏览器证据,未登记完成版本", )); @@ -5719,9 +5495,9 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( if !evidence.passed { write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) })?; - return Err(DirectCodexTurnFailure::new( + return Err(DirectTurnError::turn_failed( DirectCodexFailureStage::BrowserValidation, format!( "Codex 自主试玩未通过,未登记完成版本:{}", @@ -5738,9 +5514,9 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( if direct_browser_evidence_needs_art_repair(root, Some(&evidence)) { write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) })?; - return Err(DirectCodexTurnFailure::new( + return Err(DirectTurnError::turn_failed( DirectCodexFailureStage::BrowserValidation, "Codex 自主试玩未证明陶泥儿平台素材进入核心 Canvas/WebGL 渲染,未登记完成版本" .to_string(), @@ -5752,10 +5528,10 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( "游戏已通过真实浏览器试玩,正在登记项目版本", ); sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)).map_err( - |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error), + |error| DirectTurnError::turn_failed(DirectCodexFailureStage::VersionRegistration, error), )?; write_direct_browser_acceptance_summary(root, &evidence, true, evidence_attempts).map_err( - |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error), + |error| DirectTurnError::turn_failed(DirectCodexFailureStage::VersionRegistration, error), )?; emit_direct_game_creator_progress(root, "project.ready", "项目版本已登记,正在刷新运行预览"); Ok(format!( @@ -5804,6 +5580,7 @@ fn persist_direct_codex_assistant_reply_at( #[cfg(test)] mod tests { use super::*; + use platform_llm::LlmError; #[test] fn godot_prompt_uses_bundled_extension_and_never_requires_manual_bootstrap() { @@ -5821,22 +5598,54 @@ mod tests { } } + /// 反馈判据现在看类型:模型自报的普通失败继续反馈,一分到底的失败直接收口。 #[test] - fn direct_tool_and_playtest_errors_are_feedbackable_but_transport_and_identity_errors_stop() { - assert!(direct_codex_error_should_feedback( + fn model_failures_are_fed_back_only_when_another_attempt_can_repair_them() { + // 工具 / 构建 / 试玩这类"代码里的问题"没有 typed 事实,按可修处理。 + assert!(DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, "agc_browser_playtest 失败:页面抛出异常" - )); - assert!(direct_codex_error_should_feedback("npm run build 编译失败")); - assert!(!direct_codex_error_should_feedback( - "authentication-required: HTTP 401" - )); - assert!(!direct_codex_error_should_feedback( - "Codex app-server 连接已关闭" - )); - assert!(!direct_codex_error_should_feedback("项目身份不匹配")); - assert!(!direct_codex_error_should_feedback( + ) + .is_model_repairable()); + assert!(DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "npm run build 编译失败" + ) + .is_model_repairable()); + // 模型自报 `codexErrorInfo=other`:分类认得出,仍值得再跑一轮。 + assert!(DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:other detail=fields=codexErrorInfo".into() + )) + .is_model_repairable()); + // 鉴权、通道断开、上下文超限:再跑一轮不会变好。 + assert!(!DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:unauthorized".into() + )) + .is_model_repairable()); + assert!(!DirectTurnError::from_model_call(&LlmError::Transport( + "Codex app-server 连接已关闭".into() + )) + .is_model_repairable()); + assert!(!DirectTurnError::from_model_call(&LlmError::Upstream { + status_code: 503, + message: "Codex app-server 上游服务暂时不可用".into(), + }) + .is_model_repairable()); + assert!(!DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "项目身份不匹配" + ) + .is_model_repairable()); + assert!(!DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, "工具参数 attempt 必须是 1 到 3 的整数" - )); + ) + .is_model_repairable()); + assert!(!DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "authentication-required: HTTP 401" + ) + .is_model_repairable()); } #[test] @@ -5870,34 +5679,51 @@ mod tests { #[test] fn direct_codex_insufficient_mud_points_has_explicit_non_retryable_guidance() { - let error = "direct-codex-failure:v1 summary=泥点余额不足"; - assert!(direct_codex_error_is_mud_points_insufficient(error)); + let error = DirectTurnError::from_model_call(&LlmError::Upstream { + status_code: 409, + message: "泥点余额不足".into(), + }); assert_eq!( - direct_codex_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, error), - "泥点余额不足,请充值后发送“继续”" + error.recovery_hint(), + Some("泥点余额不足,请充值后发送“继续”") + ); + assert_eq!(error.public_summary(), Some("泥点余额不足")); + assert!(!error.is_retryable()); + + // 深层(还没 typed 出口)的同一个事实也必须落到同一条建议上。 + let deep = DirectTurnError::turn_failed( + DirectCodexFailureStage::ArtPreparation, + "平台图片生成任务失败:泥点余额不足", ); assert_eq!( - direct_codex_failure_public_summary(error), - Some("泥点余额不足") + deep.recovery_hint(), + Some("泥点余额不足,请充值后发送“继续”") ); - assert!(!direct_codex_failure_is_retryable(error)); + assert!(!deep.is_retryable()); } #[test] fn direct_project_history_shape_failure_has_explicit_non_retryable_guidance() { - let error = - "DirectProject 历史记录类型无效:$PROJECT_ROOT/.agent/conversations/project.jsonl"; - assert!(direct_project_history_shape_failure(error)); - assert_eq!( - direct_codex_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, error), - "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求" + let error = DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "DirectProject 历史记录类型无效:$PROJECT_ROOT/.agent/conversations/project.jsonl", ); - assert!(!direct_codex_failure_is_retryable(error)); + assert_eq!( + error.recovery_hint(), + Some("项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求") + ); + assert!(!error.is_retryable()); // 历史文件的 IO 失败仍按可重试处理:它与行形状无关,重试可能成功。 - let io_error = "打开 DirectProject 历史失败:拒绝访问"; - assert!(!direct_project_history_shape_failure(io_error)); - assert!(direct_codex_failure_is_retryable(io_error)); + let io_error = DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "打开 DirectProject 历史失败:拒绝访问", + ); + assert_eq!( + io_error.recovery_hint(), + Some("Codex 未完成本轮代码修改,请检查运行时配置后重试") + ); + assert!(io_error.is_retryable()); } /// 锁争用提示按"更具体的那条赢":项目写锁争用走上面的专用提示, @@ -5908,31 +5734,27 @@ mod tests { "获取DirectProject 历史追加写{}", crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER ); - assert!(direct_project_history_contention_failure( - &append_lock_timeout - )); + let append_lock = DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + append_lock_timeout, + ); assert_eq!( - direct_codex_failure_recovery_hint( - DirectCodexFailureStage::CodeGeneration, - &append_lock_timeout - ), - "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求" + append_lock.recovery_hint(), + Some("另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求") ); let write_lock_contention = format!( "{}C:/project", crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX ); - assert!( - !direct_project_history_contention_failure(&write_lock_contention), - "项目写锁争用不得再落进历史争用判据" + let write_lock = DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + write_lock_contention, ); assert_eq!( - direct_codex_failure_recovery_hint( - DirectCodexFailureStage::CodeGeneration, - &write_lock_contention - ), - "当前项目仍有写入正在结束,请稍后再次发送该需求" + write_lock.recovery_hint(), + Some("当前项目仍有写入正在结束,请稍后再次发送该需求"), + "项目写锁争用不得落进历史争用判据" ); } @@ -5956,13 +5778,28 @@ mod tests { let duplicate = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") .expect_err("same stable turn is already running"); assert!( - duplicate.starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), + matches!( + &duplicate, + DirectTurnError::TurnAlreadyRunning { + existing_invocation_id, + incoming_invocation_id, + } if existing_invocation_id == "client-turn-0001" + && incoming_invocation_id == "client-turn-0001" + ), + "{duplicate:?}" + ); + assert!( + duplicate + .to_string() + .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), "{duplicate}" ); let different = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0002") .expect_err("different turn cannot take over the project"); assert!( - !different.starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), + !different + .to_string() + .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), "{different}" ); drop(first); @@ -5997,7 +5834,9 @@ mod tests { let duplicate = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2") .expect_err("read-only probe must not take over the project"); - assert!(!duplicate.starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX)); + assert!(!duplicate + .to_string() + .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX)); drop(first); assert_eq!( @@ -8084,9 +7923,9 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( &root, - DirectCodexTurnFailure::new( - DirectCodexFailureStage::ArtPreparation, - "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret", + &DirectTurnError::turn_failed( + DirectCodexFailureStage::ArtPreparation, + "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret", ), None, ); @@ -8127,7 +7966,7 @@ mod tests { .to_string(); let error = record_direct_codex_turn_failure( &root, - DirectCodexTurnFailure::new( + &DirectTurnError::turn_failed( DirectCodexFailureStage::CodeGeneration, format!("DirectProject 历史记录类型无效:{history_path}"), ), @@ -8167,7 +8006,7 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( &root, - DirectCodexTurnFailure::new( + &DirectTurnError::turn_failed( DirectCodexFailureStage::ArtPreparation, "陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复", ), @@ -8188,9 +8027,9 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( &root, - DirectCodexTurnFailure::new( - DirectCodexFailureStage::ArtPreparation, - "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据", + &DirectTurnError::turn_failed( + DirectCodexFailureStage::ArtPreparation, + "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据", ), None, ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 6b3a85455..9689eff11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -7,9 +7,9 @@ use super::*; pub(crate) fn normalize_direct_client_turn_id( client_turn_id: Option<&str>, -) -> Result { +) -> Result { 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 valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) @@ -20,13 +20,18 @@ pub(crate) fn normalize_direct_client_turn_id( .is_some_and(|byte| byte.is_ascii_alphanumeric()); let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); if !valid_length || !valid_first || !valid_rest { - return Err(format!( - "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" - )); + return Err(DirectTurnError::ClientTurnIdMalformed { + min_chars: MIN_DIRECT_CLIENT_TURN_ID_CHARS, + max_chars: MAX_DIRECT_CLIENT_TURN_ID_CHARS, + }); } Ok(client_turn_id.to_string()) } +/// DirectProject 聊天命令:对外仍然是 `Result`。 +/// +/// 字符串只在这里、由 [`DirectTurnError`] 的 `Display` 生成一次;前端拿到的仍是"一句给用户看的话", +/// 而 Rust 侧从命令入口到宿主出口全程只传 typed 错误。 #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -35,26 +40,60 @@ pub(crate) async fn chat_with_game_creator_direct_codex( client_turn_id: Option, analytics_attempt_id: Option, ) -> Result { + chat_with_game_creator_direct_codex_typed( + project_path, + user_item, + creation_type, + client_turn_id, + analytics_attempt_id, + ) + .await + .map_err(String::from) +} + +/// 命令主体:全程 typed,边界只在上面那层 `map_err(String::from)`。 +async fn chat_with_game_creator_direct_codex_typed( + project_path: String, + user_item: DirectCodexUserItem, + creation_type: Option, + client_turn_id: Option, + analytics_attempt_id: Option, +) -> Result { let capture = crate::analytics::gui::capture_writer_context(); let root = Path::new(project_path.trim()); let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; 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()); - validate_direct_codex_user_item(root, &user_item)?; - let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?; + validate_direct_codex_user_item(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() { - return Err("聊天内容不能为空".to_string()); + return Err(DirectTurnError::ContentEmpty); } let canonical_user_item = // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) .await { - Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?), - Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)), + Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| { + 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( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index 23af1cd1e..bf7e0f5a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -30,6 +30,13 @@ use platform_llm::LlmError; /// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`direct_codex_native_kind`] 这一个地方读它。 const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; +/// 并发复用同一 `clientTurnId` 时的稳定前缀。 +/// +/// 前端按前缀识别这一条(`directCodexConversation.ts` 里有一份同样的字面量):它让界面把"同一轮 +/// 重复发送"与"另一轮正在跑"分开处理,所以它同时是文案约定和协议约定,改这里要一起改前端。 +pub(crate) const DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX: &str = + "direct-codex-turn-already-running:"; + /// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum DirectCodexFailureStage { @@ -250,12 +257,11 @@ impl DirectModelCallKind { Some(native) => !native.is_terminal(), None => true, }, - Self::UpstreamFailed { - status_code, - native, - } => match native { + // 认得出原生分类就按分类判;认不出(宿主自己构造的上游失败)就不猜:没有得到证据的 + // 上游故障不值得让模型再跑一轮。 + Self::UpstreamFailed { native, .. } => match native { Some(native) => !native.is_terminal(), - None => *status_code >= 500, + None => false, }, Self::PaidCreditsInsufficient => false, Self::EmptyResponse => false, @@ -423,8 +429,9 @@ impl DirectTurnError { pub(crate) fn is_model_repairable(&self) -> bool { match self { Self::ModelCallFailed { kind, .. } => kind.is_model_repairable(), + // 阶段失败 / 桥变体:只认得出的事实才拦(产生层还没 typed 出口的深层事实)。 Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { - direct_code_failure_invites_repair(detail) + DirectDomainFact::classify(detail).is_none_or(DirectDomainFact::invites_repair) } _ => false, } @@ -438,7 +445,7 @@ impl DirectTurnError { // 超时/中断后重试是常规动作:宿主已经把这一轮收干净了。 Self::TimedOut { .. } | Self::TurnInterrupted { .. } => true, Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { - !direct_code_failure_is_content_frozen(detail) + DirectDomainFact::classify(detail).is_none_or(DirectDomainFact::is_retryable) } _ => false, } @@ -448,6 +455,9 @@ impl DirectTurnError { pub(crate) fn public_summary(&self) -> Option<&'static str> { match self { Self::ModelCallFailed { kind, .. } => kind.public_summary(), + Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { + DirectDomainFact::classify(detail).and_then(DirectDomainFact::public_summary) + } _ => None, } } @@ -539,7 +549,7 @@ impl fmt::Display for DirectTurnError { if existing_invocation_id == incoming_invocation_id { write!( formatter, - "direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" + "{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" ) } else { write!( @@ -612,51 +622,263 @@ fn direct_codex_native_kind(detail: &str) -> Option { Some(DirectCodexNativeKind::from_id(id)) } -/// 深层(还没 typed 的)代码生成失败:值不值得反馈给模型继续修。 +/// 深层域事实:**产生层还没有 typed 出口**的事实,在这里读成 typed 值,之后所有决策只 `match`。 /// -/// 这一层只剩**产生层还没给 typed 事实**的判据:交付模块的预算/验证状态、项目历史形状、凭据 -/// 存储。它们都还没有 typed 出口,所以这里仍在读文本;**新增分类必须先在产生层加 typed 变体**, -/// 别往这个列表里加词。 -fn direct_code_failure_invites_repair(detail: &str) -> bool { - const NOT_REPAIRABLE: &[&str] = &[ - "validation-budget-exhausted", - "validation-already-running", - "playtest-attempt-limit-exceeded", - "private-external-editor-credential-storage-preparation-failed", - "private-external-editor-credential-persistence-failed", - "身份不唯一", - "身份不匹配", - "未找到身份完整的历史图集", - "没有可见像素", - "合同发生变化", - "历史记录类型无效", - "历史记录缺少 payload", - "历史注入载荷超过单行上限", - "工具参数", - ]; - !NOT_REPAIRABLE.iter().any(|marker| detail.contains(marker)) +/// 这里的判据仍然是文本,因为产生层给出来的就只有文本(平台美术/凭据、项目历史、执行预算)。 +/// 规则:**新分类必须先在产生层加 typed 变体**,别往这份表里加词;每条都注明了应由谁给出 typed 事实。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DirectDomainFact { + /// 泥点余额不足:平台付费接口(`direct_paid_submission` / 美术生成 / 上游 409)。 + PaidCreditsInsufficient, + /// 本机私有凭据目录没准备好(`assets` 的私有凭据存储)。 + CredentialStorageUnprepared, + /// 本机私有凭据已创建但没保存住(`assets` 的私有凭据存储)。 + CredentialNotPersisted, + /// 本机还没有直连用的开发者 Key(`assets`)。 + LocalDeveloperKeyMissing, + /// 登录态失效 / 上游 401(`assets` / 项目权限读取)。 + AuthenticationRejected, + /// 账号没有访问该资源的权限 / 上游 403。 + PermissionDenied, + /// 本机私有凭据不可用(笼统的一条)。 + CredentialsUnavailable, + /// 项目对话历史注入载荷超限(`codex_app_server` 的历史注入前置校验)。 + HistoryInjectionOversize, + /// 项目对话历史存在本版本无法识别的记录(`direct_project_history`)。 + HistoryShapeUnsupported, + /// 另一个进程正持有历史追加锁(`project` 的追加锁)。 + HistoryContention, + /// 项目写锁争用(`project` 的写锁)。 + ProjectWriteLockContention, + /// 历史图集身份不唯一 / 不匹配 / 无可见像素(`direct_runtime` 的图集恢复前置校验)。 + ArtIdentityRejected, + /// 交付合同在恢复期间发生变化(`direct_runtime` 的图集恢复)。 + ContractChanged, + /// 本轮执行/返修预算已耗尽(`direct_execution`)。 + ValidationBudgetExhausted, + /// 同一输入的验证已受理(`direct_execution`)。 + ValidationAlreadyRunning, + /// 试玩次数上限(`direct_validation`)。 + PlaytestAttemptLimitExceeded, + /// 工具参数不合法(`direct_tool_bridge`)。 + ToolArgumentsInvalid, + /// 本轮被取消(用户终止 / 上游取消)。 + Cancelled, } -/// 深层失败的"重试没有意义"判据:同一份输入每次都会得到同一结论的事实。 -/// -/// 与 [`direct_code_failure_invites_repair`] 同一类,都是等产生层给 typed 事实的临时判据。 -fn direct_code_failure_is_content_frozen(detail: &str) -> bool { - const FROZEN: &[&str] = &[ - "validation-budget-exhausted", - "validation-already-running", - "playtest-attempt-limit-exceeded", - "private-external-editor-credential-storage-preparation-failed", - "private-external-editor-credential-persistence-failed", - "身份不唯一", - "身份不匹配", - "未找到身份完整的历史图集", - "没有可见像素", - "合同发生变化", - "历史记录类型无效", - "历史记录缺少 payload", - "历史注入载荷超过单行上限", - ]; - FROZEN.iter().any(|marker| detail.contains(marker)) +impl DirectDomainFact { + fn classify(detail: &str) -> Option { + let normalized = detail.to_ascii_lowercase(); + let contains = |marker: &str| { + if marker.chars().any(char::is_uppercase) { + detail.contains(marker) + } else { + normalized.contains(marker) + } + }; + // 顺序即优先级:更具体的事实先判,笼统的放后面。 + const CANDIDATES: &[(DirectDomainFact, &[&str])] = &[ + ( + DirectDomainFact::PaidCreditsInsufficient, + &[ + "泥点余额不足", + "可消费泥点不足", + "kind=mud-points-insufficient", + "insufficient_mud_points", + "insufficient-mud-points", + ], + ), + ( + DirectDomainFact::CredentialStorageUnprepared, + &["private-external-editor-credential-storage-preparation-failed"], + ), + ( + DirectDomainFact::CredentialNotPersisted, + &["private-external-editor-credential-persistence-failed"], + ), + ( + DirectDomainFact::LocalDeveloperKeyMissing, + &["本机陶泥儿开发者 Key"], + ), + ( + DirectDomainFact::AuthenticationRejected, + &["authentication-required", "unauthorized", "http 401"], + ), + ( + DirectDomainFact::PermissionDenied, + &["permission-denied", "http 403"], + ), + ( + DirectDomainFact::HistoryInjectionOversize, + &["历史注入载荷超过单行上限"], + ), + ( + DirectDomainFact::HistoryShapeUnsupported, + &[ + "DirectProject 历史记录类型无效", + "DirectProject 历史记录缺少 payload", + "解析 DirectProject 历史失败", + ], + ), + ( + DirectDomainFact::ProjectWriteLockContention, + &[crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX], + ), + ( + DirectDomainFact::HistoryContention, + &[crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER], + ), + ( + DirectDomainFact::ArtIdentityRejected, + &[ + "身份不唯一", + "身份不匹配", + "未找到身份完整的历史图集", + "没有可见像素", + ], + ), + (DirectDomainFact::ContractChanged, &["合同发生变化"]), + ( + DirectDomainFact::ValidationBudgetExhausted, + &["validation-budget-exhausted"], + ), + ( + DirectDomainFact::ValidationAlreadyRunning, + &["validation-already-running"], + ), + ( + DirectDomainFact::PlaytestAttemptLimitExceeded, + &["playtest-attempt-limit-exceeded"], + ), + (DirectDomainFact::ToolArgumentsInvalid, &["工具参数"]), + (DirectDomainFact::Cancelled, &["取消"]), + ( + DirectDomainFact::CredentialsUnavailable, + &["credential", "凭据"], + ), + ]; + CANDIDATES + .iter() + .find(|(_, markers)| markers.iter().any(|marker| contains(marker))) + .map(|(fact, _)| *fact) + } + + /// 值不值得把这条失败作为下一轮调试上下文反馈给模型。 + fn invites_repair(self) -> bool { + match self { + Self::ArtIdentityRejected | Self::ContractChanged => false, + Self::PaidCreditsInsufficient + | Self::CredentialStorageUnprepared + | Self::CredentialNotPersisted + | Self::LocalDeveloperKeyMissing + | Self::AuthenticationRejected + | Self::PermissionDenied + | Self::CredentialsUnavailable + | Self::HistoryInjectionOversize + | Self::HistoryShapeUnsupported + | Self::HistoryContention + | Self::ProjectWriteLockContention + | Self::ValidationBudgetExhausted + | Self::ValidationAlreadyRunning + | Self::PlaytestAttemptLimitExceeded + | Self::ToolArgumentsInvalid + | Self::Cancelled => false, + } + } + + /// 用户重试这一轮有没有意义:同一份输入每次都会得到同一结论的事实不标可重试。 + fn is_retryable(self) -> bool { + match self { + Self::PaidCreditsInsufficient + | Self::CredentialStorageUnprepared + | Self::CredentialNotPersisted + | Self::HistoryInjectionOversize + | Self::HistoryShapeUnsupported + | Self::ArtIdentityRejected + | Self::ContractChanged + | Self::ValidationBudgetExhausted + | Self::ValidationAlreadyRunning + | Self::PlaytestAttemptLimitExceeded => false, + Self::LocalDeveloperKeyMissing + | Self::AuthenticationRejected + | Self::PermissionDenied + | Self::CredentialsUnavailable + | Self::HistoryContention + | Self::ProjectWriteLockContention + | Self::ToolArgumentsInvalid + | Self::Cancelled => true, + } + } + + /// 给用户看的稳定摘要:能一句话说清"哪里不对"的才有。 + fn public_summary(self) -> Option<&'static str> { + match self { + Self::PaidCreditsInsufficient => Some("泥点余额不足"), + Self::CredentialStorageUnprepared => { + Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据") + } + Self::CredentialNotPersisted => Some("本机开发者凭据已创建但未能安全保存"), + Self::HistoryInjectionOversize => Some("项目对话历史有单条记录超过注入上限"), + Self::LocalDeveloperKeyMissing + | Self::AuthenticationRejected + | Self::PermissionDenied + | Self::CredentialsUnavailable + | Self::HistoryShapeUnsupported + | Self::HistoryContention + | Self::ProjectWriteLockContention + | Self::ArtIdentityRejected + | Self::ContractChanged + | Self::ValidationBudgetExhausted + | Self::ValidationAlreadyRunning + | Self::PlaytestAttemptLimitExceeded + | Self::ToolArgumentsInvalid + | Self::Cancelled => None, + } + } + + /// 恢复建议:每条事实对应一个用户能做的动作。 + fn recovery_hint(self) -> &'static str { + match self { + Self::PaidCreditsInsufficient => "泥点余额不足,请充值后发送“继续”", + Self::CredentialStorageUnprepared => { + "请检查当前 Windows 用户对本机私有凭据目录的权限后重试" + } + Self::CredentialNotPersisted => { + "请先在账户开发者凭据页面撤销刚创建但未保存的凭据,再重试" + } + Self::LocalDeveloperKeyMissing => { + "请先在已登录的陶泥儿客户端发起一次直连创作,以创建仅保存在本机的开发者 Key" + } + Self::AuthenticationRejected => "登录态可能已失效,请重新登录陶泥儿后重试", + Self::PermissionDenied => { + "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试" + } + Self::CredentialsUnavailable => "请检查本机开发者凭据后重试", + Self::HistoryInjectionOversize => { + "项目对话历史有单条记录或整份载荷超过注入上限,无法整体注入 Codex;请按项目诊断里的 itemId 处理该条记录后再发送需求" + } + Self::HistoryShapeUnsupported => { + "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求" + } + Self::HistoryContention => { + "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求" + } + Self::ProjectWriteLockContention => "当前项目仍有写入正在结束,请稍后再次发送该需求", + Self::ArtIdentityRejected => { + "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集" + } + Self::ContractChanged => "本轮产物合同在恢复期间发生变化,请重新发送该需求", + Self::ValidationBudgetExhausted => { + "本轮验证预算已耗尽,请发送“继续”开始下一批返修" + } + Self::ValidationAlreadyRunning => "同一输入的验证已受理,请等待原执行结束后再试", + Self::PlaytestAttemptLimitExceeded => { + "试玩次数已达上限,请按项目诊断修复后再次发送需求" + } + Self::ToolArgumentsInvalid => "工具参数不合法,请重试;如持续失败请检查项目诊断", + Self::Cancelled => "本轮已取消,请重新发送该需求", + } + } } /// 阶段兜底的恢复建议:typed 分类给不出动作时,由阶段给一句与交付状态对得上的话。 @@ -664,8 +886,8 @@ fn direct_code_failure_recovery_hint( stage: DirectCodexFailureStage, detail: &str, ) -> Option<&'static str> { - if detail.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) { - return Some("当前项目仍有写入正在结束,请稍后再次发送该需求"); + if let Some(fact) = DirectDomainFact::classify(detail) { + return Some(fact.recovery_hint()); } Some(match stage { DirectCodexFailureStage::ArtPreparation => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs index e2732ca3c..ee3259ab6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs @@ -2,20 +2,20 @@ //! 提前收场时补一条失败终态。 //! //! 这个模块只有三件事,别再往里加第四件: -//! 1. [`direct_turn_failure_kind`]:把 `LlmError` 归到稳定分类(只给界面选语气); -//! 2. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; +//! 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 platform_llm::LlmError; - use super::{ append_direct_thread_event, direct_tool_call_now_ms, redact_agent_runtime_error, - DirectThreadEvent, DirectTurnFailure, + DirectThreadEvent, DirectTurnError, DirectTurnFailure, }; /// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 @@ -27,34 +27,6 @@ const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped"; const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = "陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。"; -/// 执行通道断开的分类:宿主自己看到的事实(app-server 进程退出 / 流断 / 回合事件通道关闭), -/// 不由 `LlmError` 反推——那种情况下宿主手里只有一份交付报告,报告里没有"连接没了"这句真话。 -pub(crate) const DIRECT_TURN_FAILURE_TRANSPORT_KIND: &str = "transport-failed"; - -/// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)时的分类:这是异常收场, -/// 不是"被主动终止",界面必须给原因。 -pub(crate) const DIRECT_TURN_FAILURE_INTERRUPTED_KIND: &str = "turn-interrupted"; - -/// 宿主等待模型回执超时(空闲上限 / 回合硬上限)时的分类。 -pub(crate) const DIRECT_TURN_FAILURE_TIMEOUT_KIND: &str = "timeout"; - -/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / -/// `turn-interrupted` / `host-dropped`。 -/// -/// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。 -fn direct_turn_failure_kind(error: &LlmError) -> &'static str { - match error { - LlmError::Timeout { .. } => "timeout", - LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => "request-rejected", - LlmError::Connectivity { .. } | LlmError::Transport(_) | LlmError::StreamUnavailable => { - DIRECT_TURN_FAILURE_TRANSPORT_KIND - } - LlmError::Upstream { .. } | LlmError::EmptyResponse | LlmError::Deserialize(_) => { - "model-failed" - } - } -} - /// 一轮的终态:写进事件的 `status` 与(失败时的)载荷。**状态由载荷反推**,不由收尾阶段推。 pub(crate) struct DirectTurnTerminal { pub(crate) status: String, @@ -75,9 +47,8 @@ impl DirectTurnTerminal { /// 拿这一轮的**事实**判定终态。判据按优先级: /// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…), /// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步"; -/// 2. `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取。模型自报失败 -/// 也走这一档:原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 `LlmError`, -/// 于是原因带着 `codex-app-server-error:` 前缀进来,不用在这里多认一种输入; +/// 2. `collect_outcome` 是错误:真失败(模型 / 传输 / 历史落盘)。模型自报失败也走这一档: +/// 原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 [`DirectTurnError`] 再进来; /// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾 /// 阶段的账本读不出来时只有它可用。 /// @@ -85,31 +56,35 @@ impl DirectTurnTerminal { /// 的理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`, /// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束", /// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。 +/// +/// 载荷的 `kind` 与 `message` 在这一个出口从 typed 错误投影:`kind` 决定界面语气,`message` 是脱敏 +/// 截断后的原因文本;Rust 侧没有第二个地方再解析它。 pub(crate) fn direct_turn_terminal( session_status: &str, - collect_result: Result<&str, &LlmError>, - host_failure: Option<(&str, &str)>, + collect_outcome: Result<&str, DirectTurnError>, + host_failure: Option<&DirectTurnError>, history_root: &Path, ) -> DirectTurnTerminal { - let failure = match (host_failure, collect_result) { - (Some((kind, reason)), _) => Some((kind.to_string(), reason.to_string())), - (None, Err(error)) => Some(( - direct_turn_failure_kind(error).to_string(), - error.to_string(), - )), + 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(("model-failed".to_string(), report.to_string())) + Some(DirectTurnError::TurnFailedUnclassified { + detail: report.to_string(), + }) } (None, Ok(_)) => None, }; match failure { - Some((kind, message)) => DirectTurnTerminal { + Some(failure) => DirectTurnTerminal { status: "failed".to_string(), failure: Some(DirectTurnFailure::new( - kind, + failure.wire_kind().unwrap_or("model-failed").to_string(), redact_agent_runtime_error( history_root, - &message, + &failure.to_string(), DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS, ), )), @@ -178,57 +153,12 @@ impl Drop for DirectTurnFailureGuard { 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") } - #[test] - fn llm_error_variants_map_to_stable_kinds() { - assert_eq!( - direct_turn_failure_kind(&LlmError::Timeout { attempts: 3 }), - "timeout" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::InvalidConfig("missing key".into())), - "request-rejected" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::InvalidRequest("bad payload".into())), - "request-rejected" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::Connectivity { - attempts: 2, - message: "reset".into(), - }), - "transport-failed" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::Transport("stream closed".into())), - "transport-failed" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::StreamUnavailable), - "transport-failed" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::Upstream { - status_code: 500, - message: "boom".into(), - }), - "model-failed" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::EmptyResponse), - "model-failed" - ); - assert_eq!( - direct_turn_failure_kind(&LlmError::Deserialize("bad json".into())), - "model-failed" - ); - } - /// 正常收场:不带载荷,`status` 就用收尾阶段推出来的那个。 #[test] fn non_failure_terminals_keep_the_session_status() { @@ -242,9 +172,10 @@ mod tests { /// 拿得到错误:分类与原因都取自错误。 #[test] fn collect_error_becomes_a_failure_terminal() { - let error = - LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into()); - let terminal = direct_turn_terminal("completed", Err(&error), None, &history_root()); + 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"); @@ -253,15 +184,16 @@ mod tests { assert!(failure.message.contains("收尾历史失败")); } - /// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 - /// `LlmError`(原因带 `codex-app-server-error:` 前缀),宿主收尾自己又把 ledger 阶段推成 + /// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 typed + /// 错误(原因带 `codex-app-server-error:` 前缀),宿主收尾自己又把 ledger 阶段推成 /// `Interrupted`(`session_status` 因此是 `interrupted`):事实就是失败、原因就是那份投影, /// 必须原样发出去——否则界面只剩"本轮已结束",用户看不到任何东西。 #[test] fn projected_native_failure_outranks_the_interrupted_session_status() { - let error = - LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()); - let terminal = direct_turn_terminal("interrupted", Err(&error), None, &history_root()); + 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"); @@ -287,12 +219,15 @@ mod tests { /// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。 #[test] fn host_recorded_failure_outranks_every_other_source() { - let diagnostic = "执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;\ -exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000"; + let diagnostic = "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);\ +stderrClass=nonempty;stderrBytes=1000"; + let host_failure = DirectTurnError::TransportClosed { + diagnostic: diagnostic.to_string(), + }; let terminal = direct_turn_terminal( "interrupted", Ok("执行连接已结束,正在核对自有子进程与在途操作。"), - Some(("transport-failed", diagnostic)), + Some(&host_failure), &history_root(), ); let failure = terminal.failure.expect("host fact must fail the turn"); @@ -302,11 +237,16 @@ exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000"; assert!(!failure.message.contains("正在核对自有子进程")); // 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。 - let error = LlmError::Transport("DirectProject 收尾历史失败".into()); + 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(("turn-interrupted", "本轮模型执行被中断")), + Err(error), + Some(&host_failure), &history_root(), ); let failure = terminal.failure.expect("host fact must fail the turn"); @@ -317,11 +257,11 @@ exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000"; /// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 #[test] fn terminal_event_carries_the_payload_and_the_opening_identity() { - let error = LlmError::Upstream { + 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 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()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index a38a27d76..4497b61c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -972,8 +972,12 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { .enable_all() .build() .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; - let reply_result = runtime - .block_on(async { run_direct_game_creator_turn_at(&project_path, &prompt).await }); + let reply_result = runtime.block_on(async { + run_direct_game_creator_turn_at(&project_path, &prompt) + .await + // CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本。 + .map_err(String::from) + }); let shutdown_result = shutdown_game_creator_codex_app_servers(); let reply = reply_result?; shutdown_result?;