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 0c7ea6c6d..3fa3ba729 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 @@ -147,6 +147,9 @@ pub(super) struct ExecutionAdapter { changed: Notify, shutdown_gate: tokio::sync::Mutex<()>, outcome: watch::Sender>, + /// 宿主自己观察到的"执行通道断开":有值就代表本轮不是被主动收束的,终态必须是失败。 + /// 内容是给用户看的完整原因(策略句 + 宿主诊断),与交付报告同一份文本。 + transport_failure: Mutex>, } fn identity(value: Option<&Value>) -> Option<&str> { @@ -263,6 +266,7 @@ impl ExecutionAdapter { changed: Notify::new(), shutdown_gate: tokio::sync::Mutex::new(()), outcome, + transport_failure: Mutex::new(None), }) } @@ -672,6 +676,40 @@ impl ExecutionAdapter { let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await; } + /// 执行通道断开(app-server 进程退出 / 流断 / 回合事件通道关闭)时的收口入口:调用方只给 + /// 宿主诊断,文案、"这算不算失败"、报告都归这里管。 + /// + /// **宿主自己关的连接不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,回合 + /// 事件通道上看到的是同一个 `TransportClosed`;区分判据是 [`Self::is_closed`]——适配器先于连接 + /// 置位就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。 + /// + /// **连接自己断的才算失败,且事实要落在适配器上。** 调用点局部变量不行:回合还开着的时候, + /// 看门狗会在同一个 `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后 + /// 取决于调度,而终态判定发生在收束之后。记不下原因,界面就只能看到"本轮已结束"、看不到为什么。 + /// 所以顺序是:先**同步**记事实(终态随之判失败),再写报告。 + /// + /// 只记第一份原因:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束 + /// 理由(事件通道关闭、看门狗收尾)不得覆盖它。 + pub(super) async fn transport_failed(&self, diagnostic: &str) { + let reason = format!("执行通道已断开,不能自动重放未确认操作:{diagnostic}"); + if !self.is_closed() { + if let Ok(mut slot) = self.transport_failure.lock() { + if slot.is_none() { + *slot = Some(reason.clone()); + } + } + } + self.interrupt(&reason).await; + } + + /// 本轮是否以"执行通道断开"收场;有值就是宿主记下的那份原因。终态判定只读这一次。 + pub(super) fn transport_failure(&self) -> Option { + self.transport_failure + .lock() + .ok() + .and_then(|slot| slot.clone()) + } + pub(super) fn start_watchdog(self: &Arc, inner: Weak) { let adapter = Arc::clone(self); tokio::spawn(async move { @@ -744,7 +782,21 @@ impl ExecutionAdapter { .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 { + // 执行通道断开过的回合一律是失败:那不是本轮主动收束,也没有"用户主动停止"这层授权, + // 报成 `interrupted` 只会让界面停在"本轮已结束"却不给原因(这就是连接被强杀时的老现象)。 + if self.transport_failure().is_some() { + return "failed".to_string(); + } match self.session.snapshot().map(|state| state.phase) { Ok(ExecutionPhase::Completed) => "completed", Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted", @@ -1084,6 +1136,50 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn transport_failure_is_a_failed_terminal_with_the_host_diagnostic() { + let (_temp, adapter) = fixture(); + assert_eq!(adapter.lifecycle_status("completed"), "completed"); + + adapter + .transport_failed("Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)") + .await; + + // 终态判成失败:界面才有理由把它当失败讲,而不是"本轮已结束"。 + assert_eq!(adapter.lifecycle_status("completed"), "failed"); + let reason = adapter + .transport_failure() + .expect("host diagnostic must be recorded"); + assert!(reason.contains("SIGKILL")); + // 报告与事件载荷同一份原因:用户看到的现象和交付状态要对得上。 + assert!(adapter.report().contains("SIGKILL")); + + // 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。 + adapter + .transport_failed("Codex app-server turn 事件通道已关闭") + .await; + let reason = adapter.transport_failure().expect("first reason is kept"); + assert!(reason.contains("SIGKILL")); + assert!(!reason.contains("事件通道已关闭")); + } + + /// 宿主自己关的连接不算传输失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件 + /// 通道上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。 + #[tokio::test] + async fn host_ended_turn_is_not_a_transport_failure() { + let (_temp, adapter) = fixture(); + adapter.closed.store(true, Ordering::Release); + + adapter + .transport_failed("模型本次执行结束,回收原生后台子树") + .await; + + assert!(adapter.transport_failure().is_none()); + assert_ne!(adapter.lifecycle_status("completed"), "failed"); + // 原因照样进报告:不算失败不等于不用记。 + assert!(adapter.report().contains("不能自动重放未确认操作")); + } + #[tokio::test] async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() { let (_temp, adapter) = fixture(); 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 138ae6871..47f646be8 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 @@ -3995,9 +3995,10 @@ impl CodexAppServerConnection { Some(CodexTurnEvent::TransportClosed(error)) => { if let Some(adapter) = approval_adapter.as_ref() { if !adapter.is_host_ending() { - adapter - .interrupt("执行通道已断开,不能自动重放未确认操作。") - .await; + // 事件带的 `error` 就是连接终止时那份诊断。通道断开是不是'失败'由 + // 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态 + // 判定之后才读得到:见 `ExecutionAdapter::transport_failed`。 + adapter.transport_failed(&error).await; } return execution::outcome_text(adapter.wait_outcome().await); } @@ -4010,8 +4011,10 @@ impl CodexAppServerConnection { None => { if let Some(adapter) = approval_adapter.as_ref() { if !adapter.is_host_ending() { + // 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能 + // 以失败收口,不能报成"被中断"。 adapter - .interrupt("执行事件通道已结束,正在核对后台操作。") + .transport_failed("Codex app-server turn 事件通道已关闭") .await; } return execution::outcome_text(adapter.wait_outcome().await); @@ -4073,9 +4076,15 @@ impl CodexAppServerConnection { // 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。 // 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能 // 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。 + // 通道断开的失败原因取自执行适配器(宿主亲眼看到的断连事实),不从交付报告里猜: + // 报告只说明收束状态,说不清连接为什么没了。 + let transport_failure = approval_adapter + .as_ref() + .and_then(|adapter| adapter.transport_failure()); let failure = direct_turn_failure( &status, collect_result.as_ref().map(String::as_str), + transport_failure.as_deref(), history_root, ); match failure { @@ -4991,6 +5000,10 @@ async fn fail_game_creator_codex_app_server_connection( let stderr = inner.stderr_summary.lock().await.diagnostic(); let diagnostic = format!("{error};exitStatus={exit_status};{stderr}"); app_log!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}"); + // 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再去收束 + // 连接。顺序不能反——执行适配器的看门狗盯着同一个 `closed` 标志,它可能先一步把本轮收束成 + // "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。 + record_execution_transport_failure(&inner, &diagnostic).await; match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await { Ok(proof) if proof.confirmed() => {} Ok(_) => app_log!("Codex app-server 连接终止:process-group-only,完整子树退出未确认"), @@ -4998,6 +5011,23 @@ async fn fail_game_creator_codex_app_server_connection( } } +/// 把"执行通道断开"这个失败事实记到当前回合的执行适配器上:连接级故障与回合事件通道关闭共用 +/// 这一条路径,别在两处各写一份。没有进行中的 DirectProject 回合(适配器已释放)就是空操作。 +async fn record_execution_transport_failure(inner: &Arc, diagnostic: &str) { + 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.transport_failed(diagnostic).await; +} + async fn shutdown_game_creator_codex_app_server_inner( inner: &Arc, reason: &str, 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 0c5b18af7..4ef8e0738 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 @@ -27,6 +27,10 @@ const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped"; const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = "陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。"; +/// 执行通道断开的分类:宿主自己看到的事实(app-server 进程退出 / 流断 / 回合事件通道关闭), +/// 不由 `LlmError` 反推——那种情况下宿主手里只有一份交付报告,报告里没有"连接没了"这句真话。 +const DIRECT_TURN_FAILURE_TRANSPORT_KIND: &str = "transport-failed"; + /// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected`。 /// /// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。 @@ -35,7 +39,7 @@ fn direct_turn_failure_kind(error: &LlmError) -> &'static str { LlmError::Timeout { .. } => "timeout", LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => "request-rejected", LlmError::Connectivity { .. } | LlmError::Transport(_) | LlmError::StreamUnavailable => { - "transport-failed" + DIRECT_TURN_FAILURE_TRANSPORT_KIND } LlmError::Upstream { .. } | LlmError::EmptyResponse | LlmError::Deserialize(_) => { "model-failed" @@ -45,24 +49,37 @@ fn direct_turn_failure_kind(error: &LlmError) -> &'static str { /// 这一轮的终态是不是「失败」?是的话给出失败载荷(原因已脱敏并截断)。 /// -/// 失败有两个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷: -/// - `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取; -/// - `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合, -/// 原因用那份报告本身(它本来就是给用户看的失败说明)。 +/// 失败有三个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷,按优先级: +/// 1. `transport_failure` 有值:宿主亲眼看到执行通道断开(app-server 进程退出、流断、回合事件 +/// 通道关闭)。原因就是宿主记下的那份诊断(含 exitStatus / stderr 摘要),它比交付报告更接近 +/// 现场;报告只说明"收束到哪一步",说不清连接为什么没了; +/// 2. `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取; +/// 3. `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合, +/// 原因用那份报告本身(它本来就是给用户看的失败说明)。 /// /// 其余终态(`completed` / `interrupted` / `aborted`)都不是失败,返回 `None`,事件不带载荷。 +/// 注意这里只负责"原因写什么":把 `status` 判成 `failed` 是调用方的事,通道断开必须让宿主把本轮 +/// 判失败(`ExecutionAdapter::lifecycle_status` 就是这么做的)——只补一条载荷而状态还是 +/// `interrupted`,界面照样不会把它当成失败来讲。 pub(crate) fn direct_turn_failure( status: &str, collect_result: Result<&str, &LlmError>, + transport_failure: Option<&str>, history_root: &Path, ) -> Option { - let (kind, message) = match collect_result { - Err(error) => ( + let (kind, message) = match (transport_failure, collect_result) { + (Some(diagnostic), _) => ( + DIRECT_TURN_FAILURE_TRANSPORT_KIND.to_string(), + diagnostic.to_string(), + ), + (None, Err(error)) => ( direct_turn_failure_kind(error).to_string(), error.to_string(), ), - Ok(report) if status == "failed" => ("model-failed".to_string(), report.to_string()), - Ok(_) => return None, + (None, Ok(report)) if status == "failed" => { + ("model-failed".to_string(), report.to_string()) + } + (None, Ok(_)) => return None, }; Some(DirectTurnFailure::new( kind, @@ -186,22 +203,22 @@ mod tests { fn only_failed_terminals_carry_a_failure_payload() { // 正常终态:无论交付报告写了什么都不是失败。 assert_eq!( - direct_turn_failure("completed", Ok("本轮交付已完成"), &history_root()), + direct_turn_failure("completed", Ok("本轮交付已完成"), None, &history_root()), None ); assert_eq!( - direct_turn_failure("interrupted", Ok("本轮已被终止"), &history_root()), + direct_turn_failure("interrupted", Ok("本轮已被终止"), None, &history_root()), None ); assert_eq!( - direct_turn_failure("aborted", Ok("已结束这一轮占用"), &history_root()), + direct_turn_failure("aborted", Ok("已结束这一轮占用"), None, &history_root()), None ); // 失败且拿得到错误:分类取自错误,原因取自错误文本。 let error = LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into()); - let failure = direct_turn_failure("failed", Err(&error), &history_root()) + let failure = direct_turn_failure("failed", Err(&error), None, &history_root()) .expect("transport error must produce a failure payload"); assert_eq!(failure.kind, "transport-failed"); assert!(failure.message.contains("收尾历史失败")); @@ -210,6 +227,7 @@ mod tests { let failure = direct_turn_failure( "failed", Ok("宿主尚未确认交付完成;请核对未完成项。"), + None, &history_root(), ) .expect("failed status must produce a failure payload"); @@ -217,21 +235,61 @@ mod tests { assert_eq!(failure.message, "宿主尚未确认交付完成;请核对未完成项。"); } + /// 执行通道断开:连接被强杀 / 流断时宿主手里只有交付报告,但真相是连接没了。原因必须用宿主 + /// 记下的诊断,而不是那份只说"收束到哪一步"的报告——否则界面只能看到一句泛泛的收尾说明。 + #[test] + fn host_observed_transport_failure_outranks_the_delivery_report() { + let diagnostic = "执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;\ +exitStatus=signal: 9 (SIGKILL);stderrClass=nonempty;stderrBytes=1000"; + let failure = direct_turn_failure( + "failed", + Ok("执行连接已结束,正在核对自有子进程与在途操作。"), + Some(diagnostic), + &history_root(), + ) + .expect("transport failure must produce a failure payload"); + assert_eq!(failure.kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); + assert!(failure.message.contains("SIGKILL")); + assert!(!failure.message.contains("正在核对自有子进程")); + + // 即使同时拿到了错误,通道断开仍是本轮的第一事实。 + let error = LlmError::Transport("DirectProject 收尾历史失败".into()); + let failure = direct_turn_failure( + "failed", + Err(&error), + Some("执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出"), + &history_root(), + ) + .expect("transport failure must produce a failure payload"); + assert_eq!(failure.kind, DIRECT_TURN_FAILURE_TRANSPORT_KIND); + assert!(failure.message.contains("Codex app-server 已退出")); + } + #[test] fn failure_message_is_redacted_and_truncated() { let root = history_root(); let with_path = format!("落盘失败:{} 不可写", root.display()); - let failure = direct_turn_failure("failed", Ok(&with_path), &history_root()) + let failure = direct_turn_failure("failed", Ok(&with_path), None, &history_root()) .expect("failed status must produce a failure payload"); assert!(!failure.message.contains("/tmp/direct-turn-failure-test")); assert!(failure.message.contains("$PROJECT_ROOT")); let long = "x".repeat(4_000); - let failure = direct_turn_failure("failed", Ok(&long), &history_root()) + let failure = direct_turn_failure("failed", Ok(&long), None, &history_root()) .expect("failed status must produce a failure payload"); // 按字符截断,最多再多一个省略号标记。 assert!(failure.message.chars().count() <= DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS + 1); assert!(failure.message.ends_with('…')); + + // 通道断开的诊断同样要脱敏 + 截断:它比报告长得多,且可能带本机路径。 + let diagnostic = format!( + "执行通道已断开:Codex app-server 已退出;路径 {}", + root.display() + ); + let failure = direct_turn_failure("failed", Ok("报告"), Some(&diagnostic), &history_root()) + .expect("transport failure must produce a failure payload"); + assert!(!failure.message.contains("/tmp/direct-turn-failure-test")); + assert!(failure.message.contains("$PROJECT_ROOT")); } /// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)。