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 2833e9890..4bf1fa2e2 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 @@ -686,9 +686,15 @@ impl ExecutionAdapter { /// 事件通道关闭)、等待模型回执超时、app-server 单方面把这一轮判成中断。终态判定会读这份事实, /// 于是这些收场不会再被收尾阶段(`ExecutionPhase::Interrupted`)抹成一次没有原因的"已结束"。 /// - /// **宿主自己关的连接不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,回合 - /// 事件通道上看到的是同一个 `TransportClosed`;判据是 [`Self::is_closed`]——适配器先于连接置位 - /// 就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。 + /// **宿主自己收束的这一轮不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉, + /// 回合事件通道上看到的是同一个 `TransportClosed`;判据有两条,都收在这里,调用点不必各写一遍: + /// + /// - [`Self::is_closed`]:适配器先于连接置位,说明这一轮是宿主在收束; + /// - [`Self::host_stop_requested`]:用户按过「终止」。`cancel_from_host` 先**同步**置位再异步 + /// 中断会话,`closed` 与阶段都要等那个任务跑到才变,所以"标志已置、阶段未变"的窗口里到达的 + /// 通道断开 / 中断都是宿主自己收尾的结果,不能记成 `transport-failed`。 + /// + /// 不记失败事实不等于不收束:原因照样写进报告(`interrupt` 会把它追加进去),便于核对。 /// /// **事实要落在适配器上,不能落在调用点的局部变量里。** 回合还开着的时候,看门狗会在同一个 /// `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后取决于调度,而终态 @@ -698,7 +704,7 @@ impl ExecutionAdapter { /// 不得覆盖它。 pub(super) async fn fail_turn(&self, failure: DirectTurnError) { let reason = failure.to_string(); - if !self.is_closed() { + if !self.is_closed() && !self.host_stop_requested() { if let Ok(mut slot) = self.turn_failure.lock() { if slot.is_none() { *slot = Some(failure); @@ -1202,6 +1208,25 @@ mod tests { assert!(adapter.report().contains("模型本次执行结束")); } + /// 用户按下的「终止」不记失败事实:`cancel_from_host` 先同步置位 `host_stop_requested`、再异步 + /// 中断会话,这中间到达的通道断开 / 中断都是宿主自己收尾的结果,不能讲成 `transport-failed`。 + #[tokio::test] + async fn user_requested_stop_is_not_recorded_as_a_failure() { + let (_temp, adapter) = fixture(); + adapter.request_host_stop(); + + adapter + .fail_turn(DirectTurnError::TransportClosed { + diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), + }) + .await; + + assert!(adapter.turn_failure().is_none()); + assert!(adapter.host_stop_requested()); + // 不算失败不等于不用记:原因照样进报告,排障能看到现场。 + assert!(adapter.report().contains("SIGKILL")); + } + #[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 f12e148df..b029ee4c7 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 @@ -3958,10 +3958,11 @@ impl CodexAppServerConnection { } "interrupted" => { if let Some(adapter) = approval_adapter.as_ref() { - // app-server 自己把这一轮判成中断,而宿主没有请求过终止(用户点 - // 「终止」会先置 `host_stop_requested`、并把阶段推成终态):这是异常 - // 收场,必须让界面看到原因,不能只是把回合静默收口。 - if !adapter.is_host_ending() && !adapter.host_stop_requested() { + // app-server 自己把这一轮判成中断,而宿主没有在收束:这是异常 + // 收场,必须让界面看到原因,不能只是把回合静默收口。用户按过 + // 「终止」的情况由 `fail_turn` 自己判(`host_stop_requested`), + // 不在这里再写一遍。 + if !adapter.is_host_ending() { adapter .fail_turn(DirectTurnError::TurnInterrupted { detail: